В этой статье мы пройдём путь создания простого, но функционального ядра операционной системы на языке 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 в объектные файлы, а затем линкуем их, явно указывая, какие секции куда попадают и какие адреса занимают (с помощью скрипта линковщика).
Примеры кода:
; kernel.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");
}
}/* link.ld */
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-код, второй — байт атрибутов (цвета фона/текста).
Пример реализации:
// vga\vga.c
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; // (или можно сделать перенос)
}
}Немного о курсоре. В ранней версии ядра курсор у меня был статичным в верхнем углу экрана. Сейчас я реализовал его так, что он следует за выводимым текстом (и мигает) — это гораздо удобнее при отладке и выводе логов: курсор показывает текущую позицию вставки и не мешает читать текст.
// vga\vga.c
#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 — но теперь клавиши не выводятся напрямую в терминал. Вместо этого обработчик прерывания записывает поступившие коды в буфер ввода. Терминал теперь — отдельное приложение (не часть ядра), и при необходимости оно получает символы через системный вызов, просто читая этот общий буфер.
Преимущества такого подхода:
ядро не «вталкивает» символы напрямую в интерфейс — оно лишь собирает ввод;
разные приложения (терминал, игра, утилита) могут читать один и тот же буфер;
терминал может управлять вводом (редактировать строку, обрабатывать клавиши управления и т.д.), не завися от того, как именно генерируется ввод.
Реализация:
// keyboard\keyboard.c
#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);
}
; interrupt\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 (таймер). Обработчик выполняет не только учёт времени — он также служит механизмом переключения для вытесняемой многозадачности (подробно об этом — позже).
; interrupt\isr32.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
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. Этот счётчик затем используется остальной системой (таймеры, планировщик и т.д.).
// time\timer.c
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, закрывающих основные потребности программ: ввод/вывод, чт��ние из буфера клавиатуры, управление процессами и т.п. Программы обращаются к этим вызовам для обмена данными с ядром.
// syscall\syscall.h
#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-функцию, обрабатывающую запросы.
; interrupt\isr80.asm — trap‑gate для int 0x80, с ручным сохранением регистров и 6 аргументами
[bits 32]
extern syscall_handler
global isr80
isr80:
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
iret ; возврат из прерывания
section .note.GNU-stack
; emptyПоскольку это программное прерывание, мы вручную сохраняем и восстанавливаем контекст — регистры и служебные данные (EFLAGS, CS, EIP и прочее) — чтобы корректно вернуться в приложение п��сле обработки. Внутри обработчика также проверяются номера syscall и аргументы, выполняется нужное действие и результат возвращается вызывающему процессу.
// syscall\syscall.c
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-ядро, поэтому его нужно реализовать самостоятельно. Первый шаг — зарезервировать область памяти под кучу при линковке, чтобы гарантировать, что туда не попадут другие секции и никто не «перетрёт» её адреса.
/* link.ld */
.heap : {
_heap_start = .;
. = . + 32 * 1024 * 1024; /* 32 MiB */
_heap_end = .;
} :dataВ моей реализации я резервирую 32 мегабайта сразу после секции .bss. Этот диапазон помечается как область кучи — теперь можно быть уверенным, что при линковке на этих адресах не окажется никакого кода или данных, и можно безопасно работать с этим пространством.
// kernel.c
size_t heap_size = (size_t)((uintptr_t)&_heap_end - (uintptr_t)&_heap_start);
malloc_init(&_heap_start, heap_size);Дальше на этой области создаётся сама куча: мы получаем один большой свободный блок, который далее «разрезаем» на более мелкие куски под запросы malloc. В начале каждого блока храним его метаданные (заголовок) — информацию, необходимую для управления памятью и проверок целостности.
// malloc\malloc.c
#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).
Восстанавливает её контекст.
Переключение происходит прозрачно для программ.
Инициализация планировщика
// multitask\multitask.c
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), который будет содержать все задачи.
Стек и регистры зада��и
Каждая задача имеет:
Собственный стек (обязательно, чтобы данные не перезаписывались).
Блок регистров, который нужно восстановить при возобновлении.
// multitask\multitask.c
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-задачу.Восстанавливаем её регистры → переключаемся.
Создание и завершение задач
Функции ядра для управления задачами:
// multitask\multitask.h
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.
// ramdisk\ramdisk.c
#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
Каждый файл представлен структурой:
// fat16\fs.h
typedef struct
{
char name[FS_NAME_MAX]; // имя файла или папки (без точки)
char ext[FS_EXT_MAX]; // расширение для файлов, пусто для директорий
int16_t parent; // индекс родительского каталога (FS_ROOT_IDX для корня), -1 для корня
uint16_t first_cluster; // для файлов: первый кластер, для папок — 0
uint32_t size; // размер файла в байтах (0 для директорий)
uint8_t used; // 1 — запись занята
uint8_t is_dir; // 1 — это директория
} fs_entry_t;first_cluster— указывает на первый кластер в цепочке FAT, где хранятся данные файла.size— реальный размер файла.used— индикатор занятости записи в корневой директории.
FAT16 хранит цепочку кластеров для каждого файла:
+-----------+ +-----------+ +-----------+
| Cluster 2 | --> | Cluster 5 | --> | Cluster 7 |
+-----------+ +-----------+ +-----------+0x0000— свободный кластер0xFFFF— EOF
Код реализации FAT16 на RAM-диске
Инициализация
// fat16\fs.c
void fs_init(void)
{
memset(entries, 0, sizeof(entries));
memset(&fat, 0, sizeof(fat));
/* Создадим запись корня */
entries[FS_ROOT_IDX].used = 1;
entries[FS_ROOT_IDX].is_dir = 1;
entries[FS_ROOT_IDX].parent = -1;
strncpy(entries[FS_ROOT_IDX].name, "/", FS_NAME_MAX - 1);
entries[FS_ROOT_IDX].name[FS_NAME_MAX - 1] = '\0';
entries[FS_ROOT_IDX].ext[0] = '\0';
entries[FS_ROOT_IDX].first_cluster = 0;
entries[FS_ROOT_IDX].size = 0;
}Очистка FAT-таблицы и корневой директории.
Выделение кластера
// fat16\fs.c
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— записывает данные, выделяя новые кластеры при необходимости.
// fat16\fs.c
size_t fs_write(uint16_t first_cluster, const void *buf, size_t size)
{
uint8_t *data = (uint8_t *)buf;
size_t cluster_size = BYTES_PER_SECTOR * SECTORS_PER_CLUSTER;
if (first_cluster < 2 || first_cluster >= FAT_ENTRIES)
return 0;
uint16_t cur = first_cluster;
size_t written = 0;
while (written < size)
{
uint8_t *clptr = get_cluster(cur);
size_t to_write = size - written;
if (to_write > cluster_size)
to_write = cluster_size;
memcpy(clptr, data + written, to_write);
written += to_write;
if (written < size)
{
if (fat.entries[cur] == 0xFFFF)
{
uint16_t nc = alloc_cluster();
if (nc == 0)
{
fat.entries[cur] = 0xFFFF;
return written;
}
fat.entries[cur] = nc;
cur = nc;
}
else
{
cur = fat.entries[cur];
}
}
else
{
fat.entries[cur] = 0xFFFF;
break;
}
}
return written;
}Автоматическое выделение новых кластеров для больших файлов.
Поддержка перезаписи существующих файлов.
Высокоуровневая работа с файлами
// fat16\fs.h
int fs_write_file_in_dir(const char *name, const char *ext, int parent, const void *data, size_t size);
int fs_read_file_in_dir(const char *name, const char *ext, int parent, void *buf, size_t bufsize, size_t *out_size);
int fs_get_all_in_dir(fs_entry_t *out_files, int max_files, int parent);fs_write_file_in_dir— создаёт или перезаписывает файл.fs_read_file_in_dir— читает файл в буфер.fs_get_all_in_dir— возвращает список файлов в директории.
Таким образом, у нас есть полноценная файловая система, которая позволяет сохранять файлы, записывать новые, запускать исполняемые программы и взаимодействовать с ними.
Запуск пользовательских приложений в ядре
Чтобы запускать приложения отдельно от ядра, нам нужно:
Мультизадачность — позволяет запускать программы параллельно с ядром.
Файловая система — позволяет хранить и загружать бинарные файлы приложений.
Механизм загрузки и размещения приложения в пользовательской памяти.
Теперь осталось лишь:
Найти 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, открыв путь для взаимодействия пользовательских программ с ядром.Аллокатор — разработали простой менеджер памяти для динамического распределения ресурсов в ядре.
Многозадачность — реализовали переключение контекста и поддержку нескольких процессов, сделав систему по-настоящему многозадачной.
Создание базовой файловой системы — подготовили основу для хранения данных, что является ключом к запуску программ.
Запуск пользовательских приложений в ядре — сделали завершающий шаг: теперь наше ядро умеет загружать и выполнять внешние программы.
Теперь у вас есть понимание того, как из «ничего» шаг за шагом создаётся ядро, которое способно выполнять задачи, обрабатывать ввод, управлять памятью и даже запускать приложения. Это не просто код — это фундамент, на котором можно строить полноценную операционную систему.
Дальше вы можете расширять возможности: добавлять драйверы, улучшать файловую систему, внедрять графический интерфейс, сетевой стек и многое другое. Всё зависит только от вашего желания и целей.
Помните: путь создания ядра — это не только про технологии, но и про понимание принципов работы компьютера на самом низком уровне. Если вы дошли до этого этапа — вы уже сделали огромный шаг вперёд.
Спасибо за прочтение статьи!
Надеюсь, она была интересна для вас, и вы узнали что-то новое.