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

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

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

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

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

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

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

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


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

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

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


  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. RranAmaru
    24.08.2025 19:04

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


  1. dyadyaSerezha
    24.08.2025 19:04

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

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