В этой статье мы пройдём путь создания простого, но функционального ядра операционной системы на языке 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

    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:
    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 и аргументы, выполняется нужное действие и результат возвращается вызывающему процессу.

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];   // расширение для файлов, пусто для директорий
    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-диске

Инициализация

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-таблицы и корневой директории.

Выделение кластера

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)
{
    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;
}
  • Автоматическое выделение новых кластеров для больших файлов.

  • Поддержка перезаписи существующих файлов.

Высокоуровневая работа с файлами

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 — возвращает список файлов в директории.

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


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

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

  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, открыв путь для взаимодействия пользовательских программ с ядром.

  • Аллокатор — разработали простой менеджер памяти для динамического распределения ресурсов в ядре.

  • Многозадачность — реализовали переключение контекста и поддержку нескольких процессов, сделав систему по-настоящему многозадачной.

  • Создание базовой файловой системы — подготовили основу для хранения данных, что является ключом к запуску программ.

  • Запуск пользовательских приложений в ядре — сделали завершающий шаг: теперь наше ядро умеет загружать и выполнять внешние программы.

Теперь у вас есть понимание того, как из «ничего» шаг за шагом создаётся ядро, которое способно выполнять задачи, обрабатывать ввод, управлять памятью и даже запускать приложения. Это не просто код — это фундамент, на котором можно строить полноценную операционную систему.

Дальше вы можете расширять возможности: добавлять драйверы, улучшать файловую систему, внедрять графический интерфейс, сетевой стек и многое другое. Всё зависит только от вашего желания и целей.

Помните: путь создания ядра — это не только про технологии, но и про понимание принципов работы компьютера на самом низком уровне. Если вы дошли до этого этапа — вы уже сделали огромный шаг вперёд.


Спасибо за прочтение статьи!

Надеюсь, она была интересна для вас, и вы узнали что-то новое.

Комментарии (30)


  1. buratino
    24.08.2025 19:04

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

    тут многа букв, но среди них нет "PC" или "Intel", или хотя бы упоминания архитектуры , зато есть номера прерываний, что явно указывает на использование Ибием Писи архитектуры.

    Аллокатора

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

    Это на каком языке написано?


  1. checkpoint
    24.08.2025 19:04

    Как тут принято говорить - "немного подушним".

    Во-первых, из текста статьи не ясно работает Ваша ОС в Real Mode или Protected Mode ?

    Хотя вот, нашел.

    • Создание ядра — мы настроили загрузчик, инициализировали GDT и IDT, подготовили окружение для работы в защищённом режиме.

    Во-вторых, есть неточность:

    Минусы FAT16

    • Нет поддержки папок и поддиректорий — все файлы находятся в корне.

    File Allocation Table с версии 12 (FAT12, FAT16 и FAT32) поддерживают иерархическую структуру подкаталогов. Список файлов подкаталога хранится в файле со специальным атрибутом "directory".

    На мой взгляд основными недостатками FAT12 и FAT16 являютя:

    1. Отсутствие поддержки прав доступа и принадлежности к пользователю. В Novel Netware, OS/2 LAN Manager, Linux VFAT и прочих сетевых ОС добавили свои несовместимые расширения (extended attributes) для решения этой проблемы.

    2. Ограничение на формат имени файла: 6.3 или 8.3. Проблема решалась костылем - файлы с закодированными названиями и отдельный файл с таблицей перевода закодированного названия в нормальное.

    Теперь вопрос. Как Вы реализовали преобразование сканкода нажатой клавиши в ASCII код ? Задача эта весьма нетривиальная. Я столкнулся с ней когда писал программу Монитор для своей синтезируемой СнК KarnixSoC, мне требовалось сделать свой видео-терминал чтобы пользователь мог управлять устройством с помощью клавиатуры и VGA. В итоге, самое минималистичное решение обнаружилось в ядре ОС Linux. Код видео-терминала там организован в виде небольшой виартуальной машины с семью таблицами (программами). Мне удалось вырезать этот код и адаптировать под свои нужды. Интересно как это сделано у Вас.


    1. Elieren Автор
      24.08.2025 19:04

      Добрый день.

      1. Действительно, в FAT16 есть директории. Похоже, в источнике, откуда я брал первоначальную информацию, были неточности — спасибо за замечание, исправлю этот пункт.

      2. Что касается сканкодов: я создал массив символов, в котором каждому коду соответствует свой символ, и просто перебирал соответствия «код → символ». Минус такого подхода — сложность добавления новых языков. Тем не менее для защищённого режима этого вполне достаточно, так как базовый VGA-ASCII выводит только латинский алфавит (русского там нет); остальные языки я не проверял.


      1. Elieren Автор
        24.08.2025 19:04

        P.S. В статье обновлён пункт «Создание базовой файловой системы».
        В ядре теперь реализованы директории и папки.


      1. a-tk
        24.08.2025 19:04

        Кроме алфавита есть ещё раскладка, состояние shift и caps lock и ещё немножко всякое разное.


        1. Elieren Автор
          24.08.2025 19:04

          Добрый день.

          Клавиши вроде Caps Lock, Shift и других тоже имеют свои уникальные коды при нажатии, и их можно легко обработать.
          Вместо поиска значения в массиве мы можем обрабатывать их напрямую, например, для изменения размера клавиш и других действий.

          Реализация в моём ядре:

          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;
              }
          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];
              }
          }


          1. a-tk
            24.08.2025 19:04

            Будем считать это корректным, но ad-hoc решением.


          1. checkpoint
            24.08.2025 19:04

            А как быть с управляющими символами типа Ctrl-C/Ctrl-X/Ctrl-Z ? С кнопками управления курсором ? Чтобы сделать полноценную строку ввода команд придется попотеть.


            1. Elieren Автор
              24.08.2025 19:04

              Добрый день.

              Терминал пока версии 0.1. Весь функционал будет постепенно добавляться и дополняться. На данный момент это предрелизная версия.


  1. RranAmaru
    24.08.2025 19:04

    Торвальдс, перелогинтесь!


  1. dyadyaSerezha
    24.08.2025 19:04

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

    Читаю и сразу в голове: "это про ядерный электорат?". Вот дожили! Ужас.


  1. evgeniy_kudinov
    24.08.2025 19:04

    Спасибо за статью. Интересно было бы подобное на Zig под RISC-V почитать.


  1. a-tk
    24.08.2025 19:04

        sti                  ; разрешить прерывания
        popfd                ; восстановить EFLAGS
        iret                 ; возврат из прерывания

    Эпилог обработчика прерывания немного избыточен и даже некорректен: во-первых, при входе в прерывание флаги уже на стеке, и ещё раз пушить их нет смысла. Во-вторых, команда iret обеспечивает атомарную операцию, в которой восстанавливаются флаги и осуществляется длинный переход на сохранённый адрес возврата.

    У Вас же получается разрешение прерывания до выхода из обработчика, что приводит к тому, что другой обработчик может вытеснить выполнение сразу после инструкции sti. Далее, popf всё равно восстановит флаг прерываний, ранее сохранённый, поэтому операция sti перед popf тут избыточна.


    1. Elieren Автор
      24.08.2025 19:04

      Добрый день.

      Спасибо за замечание — всё исправил.


      1. a-tk
        24.08.2025 19:04

        Не везде исправили.

        Перед iret не нужны ни popf (и pushf в прологе), ни sti.


        1. Elieren Автор
          24.08.2025 19:04

          Всё, исправил.


          1. a-tk
            24.08.2025 19:04

            Всё ещё нет


            1. a-tk
              24.08.2025 19:04


              1. Elieren Автор
                24.08.2025 19:04

                Обновите окно.


                1. a-tk
                  24.08.2025 19:04

                  Я про текст в статье.


                  1. Elieren Автор
                    24.08.2025 19:04

                    А.
                    Понял.

                    Исправил.


  1. a-tk
    24.08.2025 19:04

    /* Берёт символ из буфера без блокировки. Возвращает -1 если пусто. */
    char kbd_getchar(void)

    Строго говоря, char не обязательно знаковый и -1 может не быть в диапазоне допустимых значений.


  1. mapnik
    24.08.2025 19:04

    Почитал подводку — интересно!
    Раскрыл статью — и тут откуда ни возьмись™ появился x86 :(


  1. ciuafm
    24.08.2025 19:04

    Извините, все не осилил, многабукв. Эта ОС может быть загружена только с MBR диска и только если ПК поддерживает BIOS simulation? Или можно запустить на чистом UEFI?


    1. Elieren Автор
      24.08.2025 19:04

      Добрый день.

      Это ядро запускается как ELF-файл через загрузчик GRUB.


  1. cdriper
    24.08.2025 19:04

    Мы копируем программу с диска (RAMDISK/файловой системы) в область .user в памяти ядра, потому что CPU не может напрямую исполнять код с файловой системы. 

    мне кажется, что те, кто лезут в тему создания ОС должны уже как-то понимать такого рода штуки


    1. VBDUnit
      24.08.2025 19:04

      На фоне развития ИИ есть вероятность появления аппаратных архитектур, где память будет общая, может даже она же будет являться CPU :)


      1. a-tk
        24.08.2025 19:04

        Кажется, одна ось с персистентной памятью уже была в истории.


        1. Siemargl
          24.08.2025 19:04

          Завалишин что то пилил. Но до появления Ссд смысла было маловато.


          1. a-tk
            24.08.2025 19:04

            Я о ней и говорю.