Создание своего ядра на C

от автора

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

Поговорим с вами о том как:

  • Создание ядра — кратко

  • Вывод на экран

  • Получение нажатий клавиатуры

  • Время

  • Системных вызовов

  • Аллокатора

  • Многозадачности

  • Создание базовой файловой системы

  • Запуск пользовательских приложений в ядре

Перед тем как начнём немного предисловия.

Скрытый текст

О развитии ядра — почему я перешёл на C

Возможно, вы читали мои предыдущие статьи про разработку ядра на Rust, и у вас появились вопросы:

  • А что с разработкой ядра на Rust?

  • Почему вдруг ядро стало писаться на C?

  • Будут ли ещё статьи про создание ядра на Rust?

Начнём по порядку.

  1. Разработка ядра на Rust в какой-то момент зашла в тупик. Основная проблема — зависимость от внешних библиотек и от самого bootloader. Эти зависимости ограничивали возможность реализовать новые функции ядра. Например, библиотека x86_64 (в моей версии — 0.14.12) не давала управлять регистрами процессора напрямую, а готовой реализации многозадачности в ней не было. Возможно, в новых версиях такое есть, но они несовместимы с моей версией bootloader; при попытке обновить загрузчик ломается что-то ещё. Из-за такой «жёсткой» конструкции не получилось реализовать многозадачность и другие требуемые возможности.

    Сам bootloader ещё более «чёрный ящик» — я не знаю, что он делает «под капотом». Вместо него гораздо проще и удобнее использовать самописный ASM-файл, который можно настроить под свои нужды. Я не утверждаю, что bootloader — плохая библиотека: она довольно удобна, если нужно быстро написать ядро для x86 и вы хорошо знакомы с Rust, но не умеете работать с C и ASM. К недостаткам загрузчика я также отношу неудобную сборку под ARM.

  2. Потому что C предоставляет большую гибкость. Да, можно было бы переписать проблемные части на Rust и реализовать собственные библиотеки, но в этом случае большая часть кода была бы завёрнута в unsafe, чтобы обойти ограничения borrow checker’а. Но тогда это ломает идею использования Rust как безопасного языка и сводит на нет преимущества, которые я искал при разработке на Rust.

  3. С учётом всего вышеперечисленного сейчас я не считаю продолжение разработки ядра на 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 — возвращает список файлов в корне.

Таким образом, у нас есть полноценная файловая система, которая позволяет сохранять файлы, записывать новые, запускать исполняемые программы и взаимодействовать с ними.


Запуск пользовательских приложений в ядре

Чтобы запускать приложения отдельно от ядра, нам нужно:

  1. Мультизадачность — позволяет запускать программы параллельно с ядром.

  2. Файловая система — позволяет хранить и загружать бинарные файлы приложений.

  3. Механизм загрузки и размещения приложения в пользовательской памяти.

Теперь осталось лишь:

  • Найти 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 не может напрямую исполнять код с файловой системы. Диск — это просто хранилище данных, а не память с инструкциями для выполнения.

  1. Исполняемая память – процессор может выполнять инструкции только из оперативной памяти. Файловая система хранит данные на диске, которые нужно сначала загрузить в RAM.

  2. Изоляция и безопасность – .user выделяется как отдельная область памяти под пользовательские задачи. Если бы мы запускали код прямо с буфера диска, не было бы контроля над доступом к памяти.

  3. Управление памятью через user_malloc – каждая программа получает свой блок в .user, который можно освободить после завершения. Это позволяет многократно запускать новые программы без засорения памяти.

  4. Корректная работа сегментов ELF – ELF-файл содержит сегменты .text, .data, .bss, которые могут быть разбросаны и содержать разные атрибуты (RX/RW). Копирование в .user позволяет правильно разместить сегменты с учётом прав доступа.

  5. Возможность модификации – некоторые сегменты (например, .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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *