В этой статье мы пройдём путь создания простого, но функционального ядра операционной системы на языке C.

Поговорим с вами о том как:
-
Создание ядра — кратко
-
Вывод на экран
-
Получение нажатий клавиатуры
-
Время
-
Системных вызовов
-
Аллокатора
-
Многозадачности
-
Создание базовой файловой системы
-
Запуск пользовательских приложений в ядре
Перед тем как начнём немного предисловия.
Скрытый текст
О развитии ядра — почему я перешёл на C
Возможно, вы читали мои предыдущие статьи про разработку ядра на Rust, и у вас появились вопросы:
-
А что с разработкой ядра на Rust?
-
Почему вдруг ядро стало писаться на C?
-
Будут ли ещё статьи про создание ядра на Rust?
Начнём по порядку.
-
Разработка ядра на Rust в какой-то момент зашла в тупик. Основная проблема — зависимость от внешних библиотек и от самого
bootloader. Эти зависимости ограничивали возможность реализовать новые функции ядра. Например, библиотекаx86_64(в моей версии — 0.14.12) не давала управлять регистрами процессора напрямую, а готовой реализации многозадачности в ней не было. Возможно, в новых версиях такое есть, но они несовместимы с моей версиейbootloader; при попытке обновить загрузчик ломается что-то ещё. Из-за такой «жёсткой» конструкции не получилось реализовать многозадачность и другие требуемые возможности.Сам
bootloaderещё более «чёрный ящик» — я не знаю, что он делает «под капотом». Вместо него гораздо проще и удобнее использовать самописный ASM-файл, который можно настроить под свои нужды. Я не утверждаю, чтоbootloader— плохая библиотека: она довольно удобна, если нужно быстро написать ядро для x86 и вы хорошо знакомы с Rust, но не умеете работать с C и ASM. К недостаткам загрузчика я также отношу неудобную сборку под ARM. -
Потому что C предоставляет большую гибкость. Да, можно было бы переписать проблемные части на Rust и реализовать собственные библиотеки, но в этом случае большая часть кода была бы завёрнута в
unsafe, чтобы обойти ограничения borrow checker’а. Но тогда это ломает идею использования Rust как безопасного языка и сводит на нет преимущества, которые я искал при разработке на Rust. -
С учётом всего вышеперечисленного сейчас я не считаю продолжение разработки ядра на Rust целесообразным, поэтому дальнейшее развитие проекта на Rust ставится под вопрос. Рекомендую не ждать продолжения: мне сейчас интереснее развивать ядро на C — процесс идёт быстрее и приносит больше прикладных результатов.
Бонус
Сборка проекта на Rust занимает заметно больше времени; C-сборки выполняются за секунды, что существенно ускоряет цикл разработки и отладки.
Как-то так.
Надеюсь, я ответил на ваши вопросы. Если появятся ещё — пишите, отвечу.
Вернёмся к самой статье.
Создание ядра — кратко
Не буду глубоко останавливаться на вводной части — в сети полно хороших статей для новичков (например, та, с которой я начинал на xakep.ru). Пробежимся по основным шагам.
Сначала пишем входной файл на ассемблере — он выполняет минимальную инициализацию и передаёт управление в наше ядро на C. По сути это — небольшой «базовый загрузчик» (аналог bootloader на Rust), только вручную подстроенный под наши нужды. В C-файле определяется функция — точка входа, на которую прыгает ассемблерный код, и дальше уже выполняется остальной код ядра.
На этапе сборки мы компилируем asm и C в объектные файлы, а затем линкуем их, явно указывая, какие секции куда попадают и какие адреса занимают (с помощью скрипта линковщика).
Примеры кода:
; boot.asm — точка входа, Multiboot‑заголовок bits 32 section .text ;multiboot spec align 4 dd 0x1BADB002 ; magic Multiboot dd 0x00 ; flags dd -(0x1BADB002 + 0x00) ; checksum global start extern kmain start: cli ; отключаем прерывания mov esp, stack_top ; настраиваем стек call kmain ; переходим в C‑ядро ;hlt ; останавливаем процессор section .bss resb 8192 ; резервируем 8 KiB под стек stack_top: section .note.GNU-stack ; empty
/* kernel.c */ /*------------------------------------------------------------- Основная функция ядра -------------------------------------------------------------*/ void kmain(void) { /* Основной бесконечный цикл ядра */ for (;;) { asm volatile("hlt"); } }
OUTPUT_FORMAT(elf32-i386) ENTRY(start) PHDRS { text PT_LOAD FLAGS(5); /* PF_R | PF_X */ data PT_LOAD FLAGS(6); /* PF_R | PF_W */ } SECTIONS { . = 0x00100000; /* Multiboot header (оставляем в текстовом сегменте) */ .multiboot ALIGN(4) : { KEEP(*(.multiboot)) } :text /* код и константы -> сегмент text (RX) */ .text : { *(.text) *(.text*) *(.rodata) *(.rodata*) } :text /* данные и RW-константы -> сегмент data (RW) */ .data : { *(.data) *(.data*) } :data .bss : { *(.bss) *(.bss*) *(COMMON) } :data /* Простая область под кучу: 32 MiB сразу после .bss */ .heap : { _heap_start = .; . = . + 32 * 1024 * 1024; /* 32 MiB */ _heap_end = .; } :data /* * Пространство для пользовательских (user) программ. * Здесь резервируем N MiB (в примере — 128 MiB) начиная сразу после кучи. * Каждая пользовательская задача будет получать свой кусок из этого пространства. */ .user : { _user_start = .; /* 128 MiB под user-программы */ . = . + 128 * 1024 * 1024; /* 128 MiB */ _user_end = .; } :data }
В простейшем примере минимальное ядро ничего не делает: оно запускается и остаётся «висеть» — это хороший старт для пошаговой отладки и постепенного добавления функционала.
Вывод на экран
Я уже описывал это в своей первой статье по созданию ядра на Rust — рекомендую с ней ознакомиться. Кратко: для вывода в текстовом режиме мы записываем два байта (сам символ и его атрибут/цвет) в видеопамять по адресу 0xB8000. Каждый символ занимает два байта: первый — ASCII-код, второй — байт атрибутов (цвета фона/текста).
Пример реализации:
void clean_screen(void) { uint8_t *vid = VGA_BUF; for (unsigned int i = 0; i < 80 * 25 * 2; i += 2) { vid[i] = ' '; // сам символ vid[i + 1] = 0x07; // атрибут цвета } } uint8_t make_color(const uint8_t fore, const uint8_t back) { return (back << 4) | (fore & 0x0F); } void print_char(const char c, const unsigned int x, const unsigned int y, const uint8_t fore, const uint8_t back) { // проверка границ экрана if (x >= VGA_WIDTH || y >= VGA_HEIGHT) return; uint8_t *vid = VGA_BUF; uint8_t color = make_color(fore, back); // вычисляем смещение в байтах unsigned int offset = (y * VGA_WIDTH + x) * 2; vid[offset] = (uint8_t)c; // ASCII‑код символа vid[offset + 1] = color; // атрибут цвета } void print_string(const char *str, const unsigned int x, const unsigned int y, const uint8_t fore, const uint8_t back) { uint8_t *vid = VGA_BUF; unsigned int offset = (y * VGA_WIDTH + x) * 2; uint8_t color = make_color(fore, back); unsigned int col = x; // текущая колонка for (uint32_t i = 0; str[i]; ++i) { char c = str[i]; if (c == '\t') { // считаем сколько пробелов до следующего кратного TAB_SIZE unsigned int spaces = TAB_SIZE - (col % TAB_SIZE); for (unsigned int s = 0; s < spaces; s++) { vid[offset] = ' '; vid[offset + 1] = color; offset += 2; col++; } } else { vid[offset] = (uint8_t)c; vid[offset + 1] = color; offset += 2; col++; } // если дошли до конца строки VGA if (col >= VGA_WIDTH) break; // (или можно сделать перенос) } }
Немного о курсоре. В ранней версии ядра курсор у меня был статичным в верхнем углу экрана. Сейчас я реализовал его так, что он следует за выводимым текстом (и мигает) — это гораздо удобнее при отладке и выводе логов: курсор показывает текущую позицию вставки и не мешает читать текст.
#define VGA_CTRL 0x3D4 #define VGA_DATA 0x3D5 #define CURSOR_HIGH 0x0E #define CURSOR_LOW 0x0F #define VGA_WIDTH 80 #define VGA_HEIGHT 25 void update_hardware_cursor(uint8_t x, uint8_t y) { uint16_t pos = y * VGA_WIDTH + x; // старший байт outb(VGA_CTRL, CURSOR_HIGH); outb(VGA_DATA, (pos >> 8) & 0xFF); // младший байт outb(VGA_CTRL, CURSOR_LOW); outb(VGA_DATA, pos & 0xFF); }
Получение нажатий клавиатуры
Подробно в первой статье.
Та же логика применяется и к прерыванию 33 — но теперь клавиши не выводятся напрямую в терминал. Вместо этого обработчик прерывания записывает поступившие коды в буфер ввода. Терминал теперь — отдельное приложение (не часть ядра), и при необходимости оно получает символы через системный вызов, просто читая этот общий буфер.
Преимущества такого подхода:
-
ядро не «вталкивает» символы напрямую в интерфейс — оно лишь собирает ввод;
-
разные приложения (терминал, игра, утилита) могут читать один и тот же буфер;
-
терминал может управлять вводом (редактировать строку, обрабатывать клавиши управления и т.д.), не завися от того, как именно генерируется ввод.
Реализация:
#define KBD_BUF_SIZE 256 #define INTERNAL_SPACE 0x01 static bool shift_down = false; static bool caps_lock = false; /* Кольцевой буфер */ static char kbd_buf[KBD_BUF_SIZE]; static volatile int kbd_head = 0; /* место для следующего push */ static volatile int kbd_tail = 0; /* место для чтения */ // Преобразование сканкода в ASCII (или 0, если нет соответствия) char get_ascii_char(uint8_t scancode) { if (is_alpha(scancode)) { bool upper = shift_down ^ caps_lock; char base = scancode_to_ascii[(uint8_t)scancode]; // 'a'–'z' return upper ? my_toupper(base) : base; } if (shift_down) { return scancode_to_ascii_shifted[(uint8_t)scancode]; } else { return scancode_to_ascii[(uint8_t)scancode]; } } /* Простые helpers для атомарности: сохраняем/восстанавливаем flags */ static inline unsigned long irq_save_flags(void) { unsigned long flags; asm volatile("pushf; pop %0; cli" : "=g"(flags)::"memory"); return flags; } static inline void irq_restore_flags(unsigned long flags) { asm volatile("push %0; popf" ::"g"(flags) : "memory", "cc"); } /* Вызывается из ISR (keyboard_handler). Добавляет ASCII в буфер (если не переполнен). */ void kbd_buffer_push(char c) { unsigned long flags = irq_save_flags(); /* отключаем прерывания на короткое время */ int next = (kbd_head + 1) % KBD_BUF_SIZE; if (next != kbd_tail) /* если не полный */ { kbd_buf[kbd_head] = c; kbd_head = next; } else { /* буфер полный — символ теряем (альтернатива: overwrite oldest) */ } irq_restore_flags(flags); } /* Берёт символ из буфера без блокировки. Возвращает -1 если пусто. */ char kbd_getchar(void) { unsigned long flags = irq_save_flags(); if (kbd_head == kbd_tail) { irq_restore_flags(flags); return -1; /* пусто */ } char c = (char)kbd_buf[kbd_tail]; kbd_tail = (kbd_tail + 1) % KBD_BUF_SIZE; irq_restore_flags(flags); return c; } /* Модифицированный обработчик клавиатуры — вместо печати пушим символ в буфер. */ void keyboard_handler(void) { uint8_t code = inb(KEYBOARD_PORT); // Проверяем Break‑код (высокий бит = 1) bool released = code & 0x80; uint8_t key = code & 0x7F; if (key == KEY_LSHIFT || key == KEY_RSHIFT) { shift_down = !released; pic_send_eoi(1); return; } if (key == KEY_CAPSLOCK && !released) { // при нажатии (make-код) — переключаем caps_lock = !caps_lock; pic_send_eoi(1); return; } if (!released) { char ch = get_ascii_char(key); if (ch) { kbd_buffer_push(ch); } } pic_send_eoi(1); }
; isr33.asm [bits 32] extern keyboard_handler global isr33 isr33: pusha call keyboard_handler popa ; End Of Interrupt для IRQ1 mov al, 0x20 out 0x20, al ; EOI на мастере ; (Slave PIC – не нужен, т.к. keyboard на мастере) iretd section .note.GNU-stack ; empty
Таким образом мы не просто выводим напечатанный текст — мы сохраняем ввод в универсальный буфер, с которым могут удобно работать любые пользовательские приложения.
Время
Подробно в первой статье.
Мы обрабатываем аппаратное прерывание IRQ 32 (таймер). Обработчик выполняет не только учёт времени — он также служит механизмом переключения для вытесняемой многозадачности (подробно об этом — позже).
; isr.asm [bits 32] global isr32 extern isr_timer_dispatch ; C-функция, возвращающая указатель на стек фрейм для восстановления isr32: cli ; save segment registers (will be restored after iret) push ds push es push fs push gs ; save general-purpose registers pusha ; push fake err_code and int_no for uniform frame push dword 0 push dword 32 ; pass pointer to frame (esp) -> call dispatch mov eax, esp push eax call isr_timer_dispatch add esp, 4 ; isr_timer_dispatch returns pointer to frame to restore in EAX mov esp, eax ; pop int_no, err_code (balanced with pushes earlier) pop eax pop eax popa pop gs pop fs pop es pop ds sti iretd section .note.GNU-stack ; empty
Как это работает внутренне:
-
При аппаратном прерывании процессор автоматически сохраняет регистры
EFLAGS,CS,EIPна стек. Если же прерывание не аппаратное (не IRQ), то эту информацию нужно сохранять вручную. -
Далее мы вызываем
pusha— эта инструкция сохраняет все общие регистры (EAX,EBX,ECX,EDX,ESI,EDI,EBPи т.д.). Таким образом сохраняется состояние CPU перед обработкой прерывания. -
Дополнительно сохраняются другие полезные данные (например, номера прерываний, код ошибки и т.п.). Перед входом в C-функцию мы помещаем значение указателя стека (
ESP) в регистр — так C-код получает доступ к контексту/стеку прерванного процесса. (В моей реализации для передачи этого значения используетсяEAX.) -
Затем вызывается C-функция обработчика таймера. В простейшем варианте она просто инкрементирует счётчик тиков:
ticks += 1. Этот счётчик затем используется остальной системой (таймеры, планировщик и т.д.).
void init_timer(uint32_t frequency) { uint32_t divisor = 1193180 / frequency; outb(0x43, 0x36); // Command port outb(0x40, divisor & 0xFF); // Low byte outb(0x40, (divisor >> 8) & 0xFF); // High byte } init_timer(1000);
Про частоту тиков и параметр init_time:
-
Параметр
init_timeуправляет частотой тиков таймера — чем больше значение, тем чаще будут срабатывать тики, и тем более «чётко» можно управлять частотой переключений при вытесняемой многозадачности. -
На практике задавайте
init_timeдостаточно большим (от порядка сотен), если вам нужна высокая частота тиков. (Реализация не даёт микросекундной точности, но для большинства задач планирования и учёта времени этого достаточно.)
Таким способом мы реализуем базовый таймер в системе — он считает тики и предоставляет точку входа для планировщика вытесняемой многозадачности. Это простой, но рабочий механизм, который легко расширять и улучшать.
Системных вызовов
Подробно во второй статье.
Системные вызовы (syscall) в моей реализации критичны, потому что они отделяют пользовательские программы (терминал, утилиты и т. п.) от ядра. Приложения не имеют прямого доступа к внутренним переменным и функциям ядра, поэтому для взаимодействия с оборудованием и служебными функциями им нужны именно системные вызовы.
Я реализовал несколько базовых syscall, закрывающих основные потребности программ: ввод/вывод, чтение из буфера клавиатуры, управление процессами и т.п. Программы обращаются к этим вызовам для обмена данными с ядром.
#define SYSCALL_PRINT_CHAR 0 #define SYSCALL_PRINT_STRING 1 #define SYSCALL_GET_TIME 2 #define SYSCALL_MALLOC 10 #define SYSCALL_REALLOC 11 #define SYSCALL_FREE 12 #define SYSCALL_KMALLOC_STATS 13 #define SYSCALL_GETCHAR 30 #define SYSCALL_SETPOSCURSOR 31 #define SYSCALL_POWER_OFF 100 #define SYSCALL_REBOOT 101 #define SYSCALL_TASK_CREATE 200 #define SYSCALL_TASK_LIST 201 #define SYSCALL_TASK_STOP 202 #define SYSCALL_REAP_ZOMBIES 203 #define SYSCALL_TASK_EXIT 204
Для передачи управления из пользовательского приложения в обработчик syscall выделено программное прерывание int 0x80 (номер 80). На уровне сборки это реализовано как короткая ASM-рутинa, которая переключается на режим ядра и вызывает C-функцию, обрабатывающую запросы.
; isr80.asm — trap‑gate для int 0x80, с ручным сохранением регистров и 6 аргументами [bits 32] extern syscall_handler global isr80 isr80: pushfd ; сохранить EFLAGS cli ; запретить прерывания ; ——— Сохранить контекст (все регистры, кроме ESP) ——— push edi push esi push ebp push ebx push edx push ecx ; ——— Передать 6 аргументов в стек по cdecl ——— push ebp ; a6 push edi ; a5 push esi ; a4 push edx ; a3 push ecx ; a2 push ebx ; a1 push eax ; num call syscall_handler add esp, 28 ; убрать 7 × 4 байт аргументов ; ——— Восстановить сохранённые регистры ——— pop ecx pop edx pop ebx pop ebp pop esi pop edi sti ; разрешить прерывания popfd ; восстановить EFLAGS iret ; возврат из прерывания section .note.GNU-stack ; empty
Поскольку это программное прерывание, мы вручную сохраняем и восстанавливаем контекст — регистры и служебные данные (EFLAGS, CS, EIP и прочее) — чтобы корректно вернуться в приложение после обработки. Внутри обработчика также проверяются номера syscall и аргументы, выполняется нужное действие и результат возвращается вызывающему процессу.
uint32_t syscall_handler( uint32_t num, // EAX uint32_t a1, // EBX uint32_t a2, // ECX uint32_t a3, // EDX uint32_t a4, // ESI uint32_t a5, // EDI uint32_t a6 // EBP ) { switch (num) { case SYSCALL_PRINT_CHAR: print_char((char)a1, a2, a3, (uint8_t)a4, (uint8_t)a5); return 0; case SYSCALL_PRINT_STRING: print_string((const char *)a1, a2, a3, (uint8_t)a4, (uint8_t)a5); return 0; case SYSCALL_GET_TIME: uint_to_str(seconds, str); return (uint32_t)str; case SYSCALL_MALLOC: return (uint32_t)malloc((size_t)a1); // a1 = размер case SYSCALL_FREE: free((void *)a1); // a1 = указатель return 0; case SYSCALL_REALLOC: return (uint32_t)realloc((void *)a1, (size_t)a2); // a1 = ptr, a2 = new_size case SYSCALL_KMALLOC_STATS: if (a1) { get_kmalloc_stats((kmalloc_stats_t *)a1); // a1 = указатель на структуру } return 0; case SYSCALL_GETCHAR: { char c = kbd_getchar(); /* возвращает -1 если пусто */ if (c == -1) return '\0'; /* пустой символ */ return c; /* возвращаем сразу char */ } case SYSCALL_SETPOSCURSOR: { update_hardware_cursor((uint8_t)a1, (uint8_t)a2); return 0; } case SYSCALL_POWER_OFF: power_off(); return 0; // на самом деле ядро выключится и сюда не вернётся case SYSCALL_REBOOT: reboot_system(); return 0; // ядро перезагрузится case SYSCALL_TASK_CREATE: task_create((void (*)(void))a1, (size_t)a2); return 0; case SYSCALL_TASK_LIST: return task_list((task_info_t *)a1, a2); case SYSCALL_TASK_STOP: return task_stop((int)a1); case SYSCALL_REAP_ZOMBIES: reap_zombies(); return 0; case SYSCALL_TASK_EXIT: { task_exit((int)a1); return 0; } default: return (uint32_t)-1; } }
В ядре имеются программные прерывания, предназначенные для вызова системных сервисов — именно через них пользовательские приложения «стучатся» в систему и получают доступ к общим ресурсам.
Аллокатора
Про принцип работы рассказано в первой статье, но здесь мы затронем работу аллокатора немного глубже.
В C нет стандартного аллокатора под freestanding-ядро, поэтому его нужно реализовать самостоятельно. Первый шаг — зарезервировать область памяти под кучу при линковке, чтобы гарантировать, что туда не попадут другие секции и никто не «перетрёт» её адреса.
.heap : { _heap_start = .; . = . + 32 * 1024 * 1024; /* 32 MiB */ _heap_end = .; } :data
В моей реализации я резервирую 32 мегабайта сразу после секции .bss. Этот диапазон помечается как область кучи — теперь можно быть уверенным, что при линковке на этих адресах не окажется никакого кода или данных, и можно безопасно работать с этим пространством.
size_t heap_size = (size_t)((uintptr_t)&_heap_end - (uintptr_t)&_heap_start); malloc_init(&_heap_start, heap_size);
Дальше на этой области создаётся сама куча: мы получаем один большой свободный блок, который далее «разрезаем» на более мелкие куски под запросы malloc. В начале каждого блока храним его метаданные (заголовок) — информацию, необходимую для управления памятью и проверок целостности.
#define ALIGN 8 #define MAGIC 0xB16B00B5U typedef struct block_header { uint32_t magic; size_t size; /* payload size в байтах */ int free; /* 1 если свободен, 0 если занят */ struct block_header *prev; struct block_header *next; } block_header_t;
Типичные поля заголовка блока:
-
magic— фиксированное «магическое» число для проверки целостности блока. При каждом обращении проверяется его совпадение с ожидаемым значением; при несоответствии считается, что блок повреждён, и возвращается ошибка. -
size— размер блока (полезная часть). -
align— выравнивание. Если, например, нужно выделить 5 байт, фактически будет выделено 8 (или другое кратное значение) для соблюдения выравнивания, устойчивости и предсказуемости поведения. -
free(флаг) — состояние блока: свободен ли он; используется для защиты от двойного освобождения и для поиска подходящего свободного блока при выделении. -
указатели на предыдущий/следующий блок (для списков фрагментов или слияния при освобождении).
Благодаря этой структуре мы можем:
-
отдавать корректно выровненные куски памяти под запросы программ;
-
объединять соседние свободные блоки при free (coalescing);
-
обнаруживать повреждения через magic;
-
защититься от двойного free через флаг free.
Многозадачности
Новое
Ну и новвоведения по сравнению со старой реализацией на Rust и самое важное.
Это многозадачность, а точнее вытесняемая многозадачность.
Какие бывают модели многозадачности?
Кооперативная
-
Текущая задача сама должна уступить управление другой.
-
Если разработчик не вызовет функцию переключения, все ресурсы будет занимать один процесс.
-
Минусы: отсутствие бесшовного переключения, зависимость от добросовестности кода.
[ Задача A ] -> (yield) -> [ Задача B ] -> (yield) -> [ Задача C ]
Вытесняемая
-
Переключение задач происходит по прерыванию (у нас — по прерыванию таймера).
-
CPU сам прерывает текущий поток, чтобы дать время другим задачам.
-
Плюсы: честное распределение времени между задачами.
-
Минус: реализация сложнее, нужно сохранять и восстанавливать весь контекст (регистры, стеки).
IRQ0 (таймер): ┌───────────────┐ │ Задача A │ │ (работает) │ └───────────────┘ ↓ tick ┌───────────────┐ │ Задача B │ │ (работает) │ └───────────────┘ ↓ tick ┌───────────────┐ │ Задача C │ │ (работает) │ └───────────────┘
Как реализована вытесняемая многозадачность?
-
Используем IRQ 32 (таймер) → он срабатывает каждые
nмиллисекунд. -
Обработчик прерывания вызывает
schedule_from_isr(), которая:-
Сохраняет регистры текущей задачи.
-
Выбирает следующую задачу (алгоритм Round-Robin).
-
Восстанавливает её контекст.
-
-
Переключение происходит прозрачно для программ.
Инициализация планировщика
void scheduler_init(void) { memset(&init_task, 0, sizeof(init_task)); init_task.pid = 0; init_task.state = TASK_RUNNING; init_task.regs = NULL; init_task.kstack = NULL; init_task.kstack_size = 0; init_task.next = &init_task; task_ring = &init_task; current = NULL; next_pid = 1; }
-
Создаём init-задачу (PID=0) — ядро, которое нельзя завершить.
-
Формируем кольцевой список (
task_ring), который будет содержать все задачи.
Стек и регистры задачи
Каждая задача имеет:
-
Собственный стек (обязательно, чтобы данные не перезаписывались).
-
Блок регистров, который нужно восстановить при возобновлении.
sp[0] = 32; /* int_no (dummy) */ sp[1] = 0; /* err_code */ sp[2] = 0; /* EDI */ sp[3] = 0; /* ESI */ sp[4] = 0; /* EBP */ sp[5] = (uint32_t)sp; /* ESP_saved */ sp[6] = 0; /* EBX */ sp[7] = 0; /* EDX */ sp[8] = 0; /* ECX */ sp[9] = 0; /* EAX */ sp[10] = 0x10; /* DS */ sp[11] = 0x10; /* ES */ sp[12] = 0x10; /* FS */ sp[13] = 0x10; /* GS */ sp[14] = (uint32_t)entry; /* EIP */ sp[15] = 0x08; /* CS */ sp[16] = 0x202; /* EFLAGS: IF = 1 */
Это модель фрейма стека, которую ожидает ISR для корректного возврата из прерывания.
Таким образом, новая задача стартует как будто она «возвратилась» в entry().
Работа планировщика
-
Задачи хранятся в кольцевом списке:
┌─────────┐ ┌─────────┐ ┌─────────┐ │ Task0 │→→→│ Task1 │→→→│ Task2 │ └─────────┘ └─────────┘ └─────────┘ ↑___________________________|
-
Алгоритм выбора: Round Robin.
-
При каждом тике таймера:
-
Сохраняем current->regs.
-
pick_next()выбирает следующую READY-задачу. -
Восстанавливаем её регистры → переключаемся.
-
Создание и завершение задач
Функции ядра для управления задачами:
void scheduler_init(void); void task_create(void (*entry)(void), size_t stack_size); void schedule_from_isr(uint32_t *regs, uint32_t **out_regs_ptr); int task_list(task_info_t *buf, size_t max); int task_stop(int pid); void reap_zombies(void); task_t *get_current_task(void); void task_exit(int exit_code);
-
task_create()— создаёт новую задачу, выделяет стек, готовит регистры. -
task_exit()— помечает задачу как ZOMBIE. -
reap_zombies()— окончательно удаляет задачи и освобождает память.
Почему не удаляем сразу?
Потому что мы находимся внутри прерывания — если сразу убрать задачу, нарушится кольцо и ядро упадёт.
Завершение задачи
[ RUNNING ] → (exit) → [ ZOMBIE ] → (reap_zombies) → [ FREED ]
Мы получаем полноценную многозадачность, позволяющую создавать, управлять и завершать задачи прямо в процессе работы ядра.
Создание базовой файловой системы
Для хранения файлов, работы с ними и добавления новых нам нужна простая файловая система.
Для учебного ядра оптимально использовать FAT16, так как она:
-
Простая в реализации.
-
Подходит для маленького объема данных (например, RAM-диск).
-
Поддерживает понятную структуру кластеров и FAT-таблицу.
Так как у нас нет драйверов для
SATA,NVMeи других дисков, используем RAM-диск — диск в оперативной памяти.
Используем RAM-диск
RAM-диск — это просто массив фиксированного размера в ОЗУ, на который накатывается структура файловой системы FAT16.
#define RAMDISK_SIZE (64 * 1024 * 1024) // 64 MiB static uint8_t ramdisk[RAMDISK_SIZE] = {0}; uint8_t *ramdisk_base(void) { return ramdisk; }
Минусы RAM-диска:
-
Все файлы исчезают при выключении/перезагрузке, так как ОЗУ — это энергозависимая память.
-
Программы (например, терминал) нужно добавлять при каждой загрузке.
Решение: бинарный код ELF-программы терминала хранится в .h файле и при старте ядра копируется в RAM-диск.
Позже RAM-диск можно заменить на реальный диск, не меняя структуру
FAT16.
FAT16
Плюсы FAT16
-
Простая структура — легко реализовать и отлаживать.
-
Подходит для маленьких учебных проектов и RAM-дисков.
-
Поддерживает цепочку кластеров — можно хранить файлы любого размера.
-
Совместима с реальными дисками (легко расширить ядро).
Минусы FAT16
-
Ограничение размера кластера и количества файлов.
-
Нет прав доступа и журналирования.
-
Низкая эффективность для очень больших файлов.
-
RAM-диск не сохраняет данные после выключения.
Структура FAT16
Каждый файл представлен структурой:
typedef struct { char name[FS_NAME_MAX]; // имя файла char ext[FS_EXT_MAX]; // расширение uint16_t first_cluster; // первый кластер файла uint32_t size; // размер файла в байтах uint8_t used; // 1 — файл используется } fs_file_t;
-
first_cluster— указывает на первый кластер в цепочке FAT, где хранятся данные файла. -
size— реальный размер файла. -
used— индикатор занятости записи в корневой директории.
FAT16 хранит цепочку кластеров для каждого файла:
+-----------+ +-----------+ +-----------+ | Cluster 2 | --> | Cluster 5 | --> | Cluster 7 | +-----------+ +-----------+ +-----------+
-
0x0000— свободный кластер -
0xFFFF— EOF
Код реализации FAT16 на RAM-диске
Инициализация
void fs_init(void) { memset(root_dir, 0, sizeof(root_dir)); memset(&fat, 0, sizeof(fat)); }
Очистка FAT-таблицы и корневой директории.
Выделение кластера
static uint16_t alloc_cluster(void) { for (uint16_t i = 2; i < FAT_ENTRIES; i++) { if (fat.entries[i] == 0) { fat.entries[i] = 0xFFFF; // помечаем как EOF return i; } } return 0; // нет места }
-
Начало поиска с кластера 2, так как 0 и 1 зарезервированы.
-
Возвращает номер свободного кластера.
Чтение и запись
-
fs_read— следует цепочке FAT и копирует данные в буфер. -
fs_write— записывает данные, выделяя новые кластеры при необходимости.
size_t fs_write(uint16_t first_cluster, const void *buf, size_t size) { size_t written = 0; uint16_t cur = first_cluster; while (written < size) { uint8_t *clptr = get_cluster(cur); size_t to_write = size - written; if (to_write > BYTES_PER_SECTOR) to_write = BYTES_PER_SECTOR; memcpy(clptr, (uint8_t *)buf + written, to_write); written += to_write; if (written < size) { if (fat.entries[cur] == 0xFFFF) { uint16_t nc = alloc_cluster(); if (!nc) { fat.entries[cur] = 0xFFFF; break; } fat.entries[cur] = nc; cur = nc; } else cur = fat.entries[cur]; } else { fat.entries[cur] = 0xFFFF; } } return written; }
-
Автоматическое выделение новых кластеров для больших файлов.
-
Поддержка перезаписи существующих файлов.
Высокоуровневая работа с файлами
int fs_write_file(const char *name, const char *ext, const void *data, size_t size); int fs_read_file(const char *name, const char *ext, void *buf, size_t bufsize, size_t *out_size); int fs_get_all_files(fs_file_t *out_files, int max_files);
-
fs_write_file— создаёт или перезаписывает файл. -
fs_read_file— читает файл в буфер. -
fs_get_all_files— возвращает список файлов в корне.
Таким образом, у нас есть полноценная файловая система, которая позволяет сохранять файлы, записывать новые, запускать исполняемые программы и взаимодействовать с ними.
Запуск пользовательских приложений в ядре
Чтобы запускать приложения отдельно от ядра, нам нужно:
-
Мультизадачность — позволяет запускать программы параллельно с ядром.
-
Файловая система — позволяет хранить и загружать бинарные файлы приложений.
-
Механизм загрузки и размещения приложения в пользовательской памяти.
Теперь осталось лишь:
-
Найти ELF-файл на диске.
-
Получить адрес начала данных и размер файла.
-
Выделить память через
user_mallocдля загрузки приложения. -
Скопировать туда код.
-
Передать адрес функции в
utask_createдля добавления в планировщик.
┌───────────────────────────────────────────────┐ │ Файловая система │ │ (RAMDISK / диск с ELF файлами программ) │ └───────────────────────────────────────────────┘ │ ▼ ┌───────────────────┐ │ Найти ELF-файл │ │ fs_find() /fs_read│ └───────────────────┘ │ ▼ ┌───────────────────┐ │ Временный буфер │ │ malloc(file_size) │ └───────────────────┘ │ ▼ ┌────────────────────────────┐ │ Разбор ELF (exec_inplace) │ │ - Проверка ELF-заголовка │ │ - Копирование сегментов │ └────────────────────────────┘ │ ▼ ┌────────────────────────────┐ │ Выделение памяти через │ │ user_malloc в .user области│ └────────────────────────────┘ │ ▼ ┌────────────────────────────┐ │ Копирование сегментов ELF │ │ в user_malloc область │ └────────────────────────────┘ │ ▼ ┌────────────────────────────┐ │ Передача entry-point в │ │ utask_create() │ │ + указание user_mem │ │ + stack_size │ └────────────────────────────┘ │ ▼ ┌────────────────────────────┐ │ Планировщик задач ядра │ │ (scheduler) │ │ Запуск программы как │ │ пользовательской задачи │ └────────────────────────────┘ │ ▼ ┌────────────────────────────┐ │ Программа работает в ядре │ │ и использует память .user │ └────────────────────────────┘
Почему мы копируем программу в user_malloc (.user) вместо запуска напрямую с диска?
Мы копируем программу с диска (RAMDISK/файловой системы) в область .user в памяти ядра, потому что CPU не может напрямую исполнять код с файловой системы. Диск — это просто хранилище данных, а не память с инструкциями для выполнения.
-
Исполняемая память – процессор может выполнять инструкции только из оперативной памяти. Файловая система хранит данные на диске, которые нужно сначала загрузить в RAM.
-
Изоляция и безопасность –
.userвыделяется как отдельная область памяти под пользовательские задачи. Если бы мы запускали код прямо с буфера диска, не было бы контроля над доступом к памяти. -
Управление памятью через
user_malloc– каждая программа получает свой блок в.user, который можно освободить после завершения. Это позволяет многократно запускать новые программы без засорения памяти. -
Корректная работа сегментов ELF – ELF-файл содержит сегменты
.text,.data,.bss, которые могут быть разбросаны и содержать разные атрибуты (RX/RW). Копирование в.userпозволяет правильно разместить сегменты с учётом прав доступа. -
Возможность модификации – некоторые сегменты (например,
.dataили.bss) нужно инициализировать нулями или подготавливать в памяти перед запуском.
Иными словами: диск — хранит данные, .user — это память, из которой CPU реально исполняет код, поэтому без копирования программа просто не сможет работать.
Мы не просто последовательно размещаем код программ в области .user, а используем подход, аналогичный работе malloc. Это позволяет динамически выделять память для каждой пользовательской задачи и освобождать её после завершения программы. Такой подход предотвращает исчерпание пространства .user при запуске множества задач и обеспечивает возможность многократного использования памяти для новых процессов.
В результате мы получаем полноценный механизм запуска пользовательских программ в ядре, при котором они изолированы и не являются частью самого ядра.
Итог:
Таким образом, мы разобрали работу базовых компонентов ядра на языке C и реализовали их на практике.
Теперь у нас есть представление о том, как происходит взаимодействие загрузчика, менеджера памяти, файловой системы и других модулей.
Ниже представлена общая картина работы системы:
[BIOS/Bootloader (ASM) : Multiboot header, установление стека] | V [Переход в точку входа ядра (start) -> вызов kmain] | V ┌─────────────────────────────────────────────────────────────────────────┐ │ kmain: инициализация ядра │ │ │ │ 1) Отключаем прерывания | │ 2) Инициализируем таблицу прерываний (IDT) и переназначаем PIC | │ 3) Инициализируем системные часы и PIT (таймер) | │ 4) Устанавливаем маску IRQ (блокируем ненужные аппаратные IRQ) | │ 5) Вычисляем размер кучи по линкер-символам и инициализируем malloc │ │ 6) Инициализируем пользовательский аллокатор в области .user | │ 7) Монтируем/инициализируем файловую систему (FAT16) | │ 8) Копируем/загружаем программу терминала в FS | │ 9) Инициализируем планировщик задач и создаём/запускаем задачи (tasks) │ │10) Разрешаем прерывания (sti) │ │11) Входим в основной цикл: hlt / ожидание прерываний │ └─────────────────────────────────────────────────────────────────────────┘ | V (ядро простаивает, CPU в HLT — ждёт IRQ/INT) | +----------------+----------------+----------------+ | | | | V V V V [IRQ0/TIMER] [IRQ1/KEYBOARD] [INT 0x80/syscall] [CPU exceptions, other IRQs] | | | | V V V V ┌──────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐ │ Аппаратный IRQ0 │ │ Аппаратный IRQ1 │ │ Программный int │ │ Исключение │ │ (PIT/tick) │ │ (клавиатура) │ │ (syscall trap) │ │ (fault/err) │ └──────────────────┘ └─────────────────┘ └─────────────────┘ └───────────────┘ | | | V V V [Вход в соответствующий ISR — общий сценарий:] - Ассемблерная «обвязка» ISR сохраняет состояние процессора (регистры, сегменты) - Формируется единообразный фрейм/стек для передачи в C-диспетчер - Вызывается C-обработчик (dispatch) для конкретного IRQ/INT - После обработки: при необходимости — переключение задач (scheduler) - Отправляется EOI в PIC (для аппаратных IRQ) - Восстановление регистров и возврат (iret/iretd) Подробно — ветки обработки: ────────────────────────────────────────────────────────────────────────────── TIMER (IRQ0) - Срабатывает PIT по частоте (инициализирована в kmain) - ISR сохраняет контекст, вызывает C-функцию таймера: • Увеличивает глобальный тик/счётчик времени • По достижении порога вызывает clock_tick() (таймер часов) • Вызывает планировщик: schedule_from_isr получает pointer на текущий стек, выбирает следующую задачу и возвращает фрейм для восстановления - Обязательный EOI в PIC отправляется сразу (чтобы позволить другие IRQ) - Если планировщик выбрал другую задачу — происходит контекст-свитч: • Текущие регистры сохраняются (в стеке/TCB), затем ESP устанавливается на указанный планировщиком фрейм — CPU продолжает выполнение новой задачи - Завершение ISR и возврат из прерывания KEYBOARD (IRQ1) - ISR клавиатуры сохраняет регистры и вызывает keyboard_handler - keyboard_handler: • Читает scancode с порта клавиатуры (аппаратный порт 0x60) • Обрабатывает коды Shift / CapsLock (держит состояние модификаторов) • Преобразует scancode в ASCII (или 0 при отсутствии соответствия) • Записывает символ в кольцевой буфер (kbd buffer) атомарно: - кратковременно блокирует прерывания / сохраняет флаги, чтобы запись была безопасна (irq_save_flags / irq_restore_flags) • Не блокирует планировщик — только буферизация ввода - Отправляет EOI на PIC - Возврат из ISR SYSCALL (INT 0x80) - Пользовательский или системный код вызывает int 0x80 с номером и аргументами - ASM-входная точка для int 0x80: • Сохраняет EFLAGS, запрещает прерывания • Сохраняет набор регистров (callee state) • Формирует стек с аргументами и номером системного вызова • Вызывает syscall_handler (C) - syscall_handler: • Читает номер и аргументы • Выполняет соответствующую службу: I/O, аллокация, процессы, FS и т.д. • Возвращает результат (в регистр/на стек) - ASM-обвязка восстанавливает регистры, EFLAGS и выполняет iret - Syscall выполняется синхронно в контексте вызывающей задачи ПАМЯТЬ: Секции и области управления ────────────────────────────────────────────────────────────────────────────── [.text] — код ядра и мультибут-заголовок [.data] — данные и инициализированные переменные [.bss] — неинициализированные данные [.heap] — область кучи для ядра (резерв ~32 MiB сразу после .bss) • В начале heap ставятся линкер-символы _heap_start и _heap_end • Ядровый malloc управляет этой областью: список блоков, split/coalesce • Для расширения используется простая bump-алокация сверху вниз (brk_ptr) [.user] — отдельное пространство для пользовательских программ (резерв ~128 MiB) • Отдельный простой аллокатор user_malloc оперирует только в этой области • Каждая user-задача получает кусок из .user и работает изолированно (логически) Взаимодействие планировщика и аллокаторов ────────────────────────────────────────────────────────────────────────────── - Планировщик создаёт/регулирует задачи: каждая задача имеет свой стек/контекст. - При создании пользовательских задач память для их HEAP/стеков выделяется из .user. - Ядровые вызовы malloc/realloc/free управляют .heap; статистика доступна через get_kmalloc_stats. - User-allocator управляет .user, поддерживает split/coalesce и simple first-fit поиск. - При переключении задач контексты (регистры, ESP) сохраняются в структуре задачи, и при возобновлении — ESP/регистры восстанавливаются из этой структуры. Дополнительные замечания (поведение, гарантии, атомарность) ────────────────────────────────────────────────────────────────────────────── - Все аппаратные IRQ посылают EOI в PIC после обработки, иначе IRQ блокируются. - Критические секции (например, запись в KBD-буфер) кратковременно блокируют прерывания, чтобы избежать гонок при одновременном доступе из ISR и из кода. - Таймер — источник вытесняющей многозадачности: он принудительно вызывает scheduler из ISR и даёт возможность переключать задачи без их явного «yield». - Syscall выполняется в контексте вызывающей задачи и не должен нарушать целостность ядра. - Файловая система должна быть доступна до запуска пользовательских программ, т.к. образ/программа терминала загружаются в FS до создания задач. Краткая карта «от старта до реакции на ввод» ────────────────────────────────────────────────────────────────────────────── BIOS/Bootloader → старт ядра (kmain) → init IDT/PIC, timer, маски IRQ → init heap и user-heap → init FS и загрузка программ (терминал) → init scheduler и create tasks → sti (разрешаем IRQ) → основной цикл HLT → IRQ0 (timer) → tick → scheduler → возможный context switch → resume → IRQ1 (keyboard) → scancode → to ASCII → push в kbd buffer → resume → INT0x80 (syscall) → syscall_handler → результат → resume
Полный исходный код проекта, а также инструкция по сборке и запуску доступны здесь:
GitHub
Мы прошли долгий путь от первых шагов до полноценного прототипа операционной системы, написанной на C. На этом пути мы реализовали ключевые механизмы, без которых невозможно представить работу ядра:
-
Создание ядра — мы настроили загрузчик, инициализировали
GDTиIDT, подготовили окружение для работы в защищённом режиме. -
Вывод на экран — реализовали базовый драйвер
VGA, который позволил отображать текстовую информацию напрямую в видеопамяти. -
Получение нажатий клавиатуры — настроили обработчики прерываний и добавили поддержку клавиатуры для интерактивного взаимодействия.
-
Время — подключили таймер
PIT, научились отслеживать системное время и использовать его для планирования задач. -
Системные вызовы — внедрили механизм
syscall, открыв путь для взаимодействия пользовательских программ с ядром. -
Аллокатор — разработали простой менеджер памяти для динамического распределения ресурсов в ядре.
-
Многозадачность — реализовали переключение контекста и поддержку нескольких процессов, сделав систему по-настоящему многозадачной.
-
Создание базовой файловой системы — подготовили основу для хранения данных, что является ключом к запуску программ.
-
Запуск пользовательских приложений в ядре — сделали завершающий шаг: теперь наше ядро умеет загружать и выполнять внешние программы.
Теперь у вас есть понимание того, как из «ничего» шаг за шагом создаётся ядро, которое способно выполнять задачи, обрабатывать ввод, управлять памятью и даже запускать приложения. Это не просто код — это фундамент, на котором можно строить полноценную операционную систему.
Дальше вы можете расширять возможности: добавлять драйверы, улучшать файловую систему, внедрять графический интерфейс, сетевой стек и многое другое. Всё зависит только от вашего желания и целей.
Помните: путь создания ядра — это не только про технологии, но и про понимание принципов работы компьютера на самом низком уровне. Если вы дошли до этого этапа — вы уже сделали огромный шаг вперёд.
Спасибо за прочтение статьи!
Надеюсь, она была интересна для вас, и вы узнали что-то новое.
ссылка на оригинал статьи https://habr.com/ru/articles/939698/
Добавить комментарий