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

Поговорим с вами о том как:
Создание ядра — кратко
Вывод на экран
Получение нажатий клавиатуры
Время
Системные вызовы
Создание аллокатора
Реализация многозадачности
Создание базовой файловой системы
Запуск пользовательских приложений в ядре
Перед тем как начнём немного предисловия.
Скрытый текст
О развитии ядра — почему я перешёл на C
Возможно, вы читали мои предыдущие статьи про разработку ядра на Rust, и у вас появились вопросы:
А что с разработкой ядра на Rust?
Почему вдруг ядро стало писаться на C?
Будут ли ещё статьи про создание ядра на Rust?
Начнём по порядку.
Разработка ядра на Rust в какой-то момент зашла в тупик. Основная проблема — зависимость от внешних библиотек и от самого
bootloader
. Эти зависимости ограничивали возможность реализовать новые функции ядра. Например, библиотекаx86_64
(в моей версии — 0.14.12) не давала управлять регистрами процессора напрямую, а готовой реализации многозадачности в ней не было. Возможно, в новых версиях такое есть, но они несовместимы с моей версиейbootloader
; при попытке обновить загрузчик ломается что-то ещё. Из-за такой «жёсткой» конструкции не получилось реализовать многозадачность и другие требуемые возможности.
Самbootloader
ещё более «чёрный ящик» — я не знаю, что он делает «под капотом». Вместо него гораздо проще и удобнее использовать самописный ASM-файл, который можно настроить под свои нужды. Я не утверждаю, чтоbootloader
— плохая библиотека: она довольно удобна, если нужно быстро написать ядро для x86 и вы хорошо знакомы с Rust, но не умеете работать с C и ASM. К недостаткам загрузчика я также отношу неудобную сборку под ARM.Потому что C предоставляет большую гибкость. Да, можно было бы переписать проблемные части на Rust и реализовать собственные библиотеки, но в этом случае большая часть кода была бы завёрнута в
unsafe
, чтобы обойти ограничения borrow checker’а. Но тогда это ломает идею использования Rust как безопасного языка и сводит на нет преимущества, которые я искал при разработке на Rust.С учётом всего вышеперечисленного сейчас я не считаю продолжение разработки ядра на Rust целесообразным, поэтому дальнейшее развитие проекта на Rust ставится под вопрос. Рекомендую не ждать продолжения: мне сейчас интереснее развивать ядро на C — процесс идёт быстрее и приносит больше прикладных результатов.
Бонус
Сборка проекта на Rust занимает заметно больше времени; C-сборки выполняются за секунды, что существенно ускоряет цикл разработки и отладки.
Как-то так.
Надеюсь, я ответил на ваши вопросы. Если появятся ещё — пишите, отвечу.
Вернёмся к самой статье.
Создание ядра — кратко
Не буду глубоко останавливаться на вводной части — в сети полно хороших статей для новичков (например, та, с которой я начинал на xakep.ru). Пробежимся по основным шагам.
Сначала пишем входной файл на ассемблере — он выполняет минимальную инициализацию и передаёт управление в наше ядро на C. По сути это — небольшой «базовый загрузчик» (аналог bootloader
на Rust), только вручную подстроенный под наши нужды. В C-файле определяется функция — точка входа, на которую прыгает ассемблерный код, и дальше уже выполняется остальной код ядра.
На этапе сборки мы компилируем asm и C в объектные файлы, а затем линкуем их, явно указывая, какие секции куда попадают и какие адреса занимают (с помощью скрипта линковщика).
Примеры кода:
; boot.asm — точка входа, Multiboot‑заголовок
bits 32
section .text
;multiboot spec
align 4
dd 0x1BADB002 ; magic Multiboot
dd 0x00 ; flags
dd -(0x1BADB002 + 0x00) ; checksum
global start
extern kmain
start:
cli ; отключаем прерывания
mov esp, stack_top ; настраиваем стек
call kmain ; переходим в C‑ядро
;hlt ; останавливаем процессор
section .bss
resb 8192 ; резервируем 8 KiB под стек
stack_top:
section .note.GNU-stack
; empty
/* kernel.c */
/*-------------------------------------------------------------
Основная функция ядра
-------------------------------------------------------------*/
void kmain(void)
{
/* Основной бесконечный цикл ядра */
for (;;)
{
asm volatile("hlt");
}
}
OUTPUT_FORMAT(elf32-i386)
ENTRY(start)
PHDRS
{
text PT_LOAD FLAGS(5); /* PF_R | PF_X */
data PT_LOAD FLAGS(6); /* PF_R | PF_W */
}
SECTIONS
{
. = 0x00100000;
/* Multiboot header (оставляем в текстовом сегменте) */
.multiboot ALIGN(4) : { KEEP(*(.multiboot)) } :text
/* код и константы -> сегмент text (RX) */
.text : {
*(.text)
*(.text*)
*(.rodata)
*(.rodata*)
} :text
/* данные и RW-константы -> сегмент data (RW) */
.data : {
*(.data)
*(.data*)
} :data
.bss : {
*(.bss)
*(.bss*)
*(COMMON)
} :data
/* Простая область под кучу: 32 MiB сразу после .bss */
.heap : {
_heap_start = .;
. = . + 32 * 1024 * 1024; /* 32 MiB */
_heap_end = .;
} :data
/*
* Пространство для пользовательских (user) программ.
* Здесь резервируем N MiB (в примере — 128 MiB) начиная сразу после кучи.
* Каждая пользовательская задача будет получать свой кусок из этого пространства.
*/
.user : {
_user_start = .;
/* 128 MiB под user-программы */
. = . + 128 * 1024 * 1024; /* 128 MiB */
_user_end = .;
} :data
}
В простейшем примере минимальное ядро ничего не делает: оно запускается и остаётся «висеть» — это хороший старт для пошаговой отладки и постепенного добавления функционала.
Вывод на экран
Я уже описывал это в своей первой статье по созданию ядра на Rust — рекомендую с ней ознакомиться. Кратко: для вывода в текстовом режиме мы записываем два байта (сам символ и его атрибут/цвет) в видеопамять по адресу 0xB8000. Каждый символ занимает два байта: первый — ASCII-код, второй — байт атрибутов (цвета фона/текста).
Пример реализации:
void clean_screen(void)
{
uint8_t *vid = VGA_BUF;
for (unsigned int i = 0; i < 80 * 25 * 2; i += 2)
{
vid[i] = ' '; // сам символ
vid[i + 1] = 0x07; // атрибут цвета
}
}
uint8_t make_color(const uint8_t fore, const uint8_t back)
{
return (back << 4) | (fore & 0x0F);
}
void print_char(const char c,
const unsigned int x,
const unsigned int y,
const uint8_t fore,
const uint8_t back)
{
// проверка границ экрана
if (x >= VGA_WIDTH || y >= VGA_HEIGHT)
return;
uint8_t *vid = VGA_BUF;
uint8_t color = make_color(fore, back);
// вычисляем смещение в байтах
unsigned int offset = (y * VGA_WIDTH + x) * 2;
vid[offset] = (uint8_t)c; // ASCII‑код символа
vid[offset + 1] = color; // атрибут цвета
}
void print_string(const char *str,
const unsigned int x,
const unsigned int y,
const uint8_t fore,
const uint8_t back)
{
uint8_t *vid = VGA_BUF;
unsigned int offset = (y * VGA_WIDTH + x) * 2;
uint8_t color = make_color(fore, back);
unsigned int col = x; // текущая колонка
for (uint32_t i = 0; str[i]; ++i)
{
char c = str[i];
if (c == '\t')
{
// считаем сколько пробелов до следующего кратного TAB_SIZE
unsigned int spaces = TAB_SIZE - (col % TAB_SIZE);
for (unsigned int s = 0; s < spaces; s++)
{
vid[offset] = ' ';
vid[offset + 1] = color;
offset += 2;
col++;
}
}
else
{
vid[offset] = (uint8_t)c;
vid[offset + 1] = color;
offset += 2;
col++;
}
// если дошли до конца строки VGA
if (col >= VGA_WIDTH)
break; // (или можно сделать перенос)
}
}
Немного о курсоре. В ранней версии ядра курсор у меня был статичным в верхнем углу экрана. Сейчас я реализовал его так, что он следует за выводимым текстом (и мигает) — это гораздо удобнее при отладке и выводе логов: курсор показывает текущую позицию вставки и не мешает читать текст.
#define VGA_CTRL 0x3D4
#define VGA_DATA 0x3D5
#define CURSOR_HIGH 0x0E
#define CURSOR_LOW 0x0F
#define VGA_WIDTH 80
#define VGA_HEIGHT 25
void update_hardware_cursor(uint8_t x, uint8_t y)
{
uint16_t pos = y * VGA_WIDTH + x;
// старший байт
outb(VGA_CTRL, CURSOR_HIGH);
outb(VGA_DATA, (pos >> 8) & 0xFF);
// младший байт
outb(VGA_CTRL, CURSOR_LOW);
outb(VGA_DATA, pos & 0xFF);
}
Получение нажатий клавиатуры
Подробно в первой статье.
Та же логика применяется и к прерыванию 33 — но теперь клавиши не выводятся напрямую в терминал. Вместо этого обработчик прерывания записывает поступившие коды в буфер ввода. Терминал теперь — отдельное приложение (не часть ядра), и при необходимости оно получает символы через системный вызов, просто читая этот общий буфер.
Преимущества такого подхода:
ядро не «вталкивает» символы напрямую в интерфейс — оно лишь собирает ввод;
разные приложения (терминал, игра, утилита) могут читать один и тот же буфер;
терминал может управлять вводом (редактировать строку, обрабатывать клавиши управления и т.д.), не завися от того, как именно генерируется ввод.
Реализация:
#define KBD_BUF_SIZE 256
#define INTERNAL_SPACE 0x01
static bool shift_down = false;
static bool caps_lock = false;
/* Кольцевой буфер */
static char kbd_buf[KBD_BUF_SIZE];
static volatile int kbd_head = 0; /* место для следующего push */
static volatile int kbd_tail = 0; /* место для чтения */
// Преобразование сканкода в ASCII (или 0, если нет соответствия)
char get_ascii_char(uint8_t scancode)
{
if (is_alpha(scancode))
{
bool upper = shift_down ^ caps_lock;
char base = scancode_to_ascii[(uint8_t)scancode]; // 'a'–'z'
return upper ? my_toupper(base) : base;
}
if (shift_down)
{
return scancode_to_ascii_shifted[(uint8_t)scancode];
}
else
{
return scancode_to_ascii[(uint8_t)scancode];
}
}
/* Простые helpers для атомарности: сохраняем/восстанавливаем flags */
static inline unsigned long irq_save_flags(void)
{
unsigned long flags;
asm volatile("pushf; pop %0; cli" : "=g"(flags)::"memory");
return flags;
}
static inline void irq_restore_flags(unsigned long flags)
{
asm volatile("push %0; popf" ::"g"(flags) : "memory", "cc");
}
/* Вызывается из ISR (keyboard_handler). Добавляет ASCII в буфер (если не переполнен). */
void kbd_buffer_push(char c)
{
unsigned long flags = irq_save_flags(); /* отключаем прерывания на короткое время */
int next = (kbd_head + 1) % KBD_BUF_SIZE;
if (next != kbd_tail) /* если не полный */
{
kbd_buf[kbd_head] = c;
kbd_head = next;
}
else
{
/* буфер полный — символ теряем (альтернатива: overwrite oldest) */
}
irq_restore_flags(flags);
}
/* Берёт символ из буфера без блокировки. Возвращает -1 если пусто. */
char kbd_getchar(void)
{
unsigned long flags = irq_save_flags();
if (kbd_head == kbd_tail)
{
irq_restore_flags(flags);
return -1; /* пусто */
}
char c = (char)kbd_buf[kbd_tail];
kbd_tail = (kbd_tail + 1) % KBD_BUF_SIZE;
irq_restore_flags(flags);
return c;
}
/* Модифицированный обработчик клавиатуры — вместо печати пушим символ в буфер. */
void keyboard_handler(void)
{
uint8_t code = inb(KEYBOARD_PORT);
// Проверяем Break‑код (высокий бит = 1)
bool released = code & 0x80;
uint8_t key = code & 0x7F;
if (key == KEY_LSHIFT || key == KEY_RSHIFT)
{
shift_down = !released;
pic_send_eoi(1);
return;
}
if (key == KEY_CAPSLOCK && !released)
{
// при нажатии (make-код) — переключаем
caps_lock = !caps_lock;
pic_send_eoi(1);
return;
}
if (!released)
{
char ch = get_ascii_char(key);
if (ch)
{
kbd_buffer_push(ch);
}
}
pic_send_eoi(1);
}
; isr33.asm
[bits 32]
extern keyboard_handler
global isr33
isr33:
pusha
call keyboard_handler
popa
; End Of Interrupt для IRQ1
mov al, 0x20
out 0x20, al ; EOI на мастере
; (Slave PIC – не нужен, т.к. keyboard на мастере)
iretd
section .note.GNU-stack
; empty
Таким образом мы не просто выводим напечатанный текст — мы сохраняем ввод в универсальный буфер, с которым могут удобно работать любые пользовательские приложения.
Время
Подробно в первой статье.
Мы обрабатываем аппаратное прерывание IRQ 32 (таймер). Обработчик выполняет не только учёт времени — он также служит механизмом переключения для вытесняемой многозадачности (подробно об этом — позже).
; isr.asm
[bits 32]
global isr32
extern isr_timer_dispatch ; C-функция, возвращающая указатель на стек фрейм для восстановления
isr32:
cli
; save segment registers (will be restored after iret)
push ds
push es
push fs
push gs
; save general-purpose registers
pusha
; push fake err_code and int_no for uniform frame
push dword 0
push dword 32
; pass pointer to frame (esp) -> call dispatch
mov eax, esp
push eax
call isr_timer_dispatch
add esp, 4
; isr_timer_dispatch returns pointer to frame to restore in EAX
mov esp, eax
; pop int_no, err_code (balanced with pushes earlier)
pop eax
pop eax
popa
pop gs
pop fs
pop es
pop ds
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
— возвращает список файлов в директории.
Таким образом, у нас есть полноценная файловая система, которая позволяет сохранять файлы, записывать новые, запускать исполняемые программы и взаимодействовать с ними.
Запуск пользовательских приложений в ядре
Чтобы запускать приложения отдельно от ядра, нам нужно:
Мультизадачность — позволяет запускать программы параллельно с ядром.
Файловая система — позволяет хранить и загружать бинарные файлы приложений.
Механизм загрузки и размещения приложения в пользовательской памяти.
Теперь осталось лишь:
Найти ELF-файл на диске.
Получить адрес начала данных и размер файла.
Выделить память через
user_malloc
для загрузки приложения.Скопировать туда код.
Передать адрес функции в
utask_create
для добавления в планировщик.
┌───────────────────────────────────────────────┐
│ Файловая система │
│ (RAMDISK / диск с ELF файлами программ) │
└───────────────────────────────────────────────┘
│
▼
┌───────────────────┐
│ Найти ELF-файл │
│ fs_find() /fs_read│
└───────────────────┘
│
▼
┌───────────────────┐
│ Временный буфер │
│ malloc(file_size) │
└───────────────────┘
│
▼
┌────────────────────────────┐
│ Разбор ELF (exec_inplace) │
│ - Проверка ELF-заголовка │
│ - Копирование сегментов │
└────────────────────────────┘
│
▼
┌────────────────────────────┐
│ Выделение памяти через │
│ user_malloc в .user области│
└────────────────────────────┘
│
▼
┌────────────────────────────┐
│ Копирование сегментов ELF │
│ в user_malloc область │
└────────────────────────────┘
│
▼
┌────────────────────────────┐
│ Передача entry-point в │
│ utask_create() │
│ + указание user_mem │
│ + stack_size │
└────────────────────────────┘
│
▼
┌────────────────────────────┐
│ Планировщик задач ядра │
│ (scheduler) │
│ Запуск программы как │
│ пользовательской задачи │
└────────────────────────────┘
│
▼
┌────────────────────────────┐
│ Программа работает в ядре │
│ и использует память .user │
└────────────────────────────┘
Почему мы копируем программу в user_malloc
(.user
) вместо запуска напрямую с диска?
Мы копируем программу с диска (RAMDISK/файловой системы) в область .user
в памяти ядра, потому что CPU не может напрямую исполнять код с файловой системы. Диск — это просто хранилище данных, а не память с инструкциями для выполнения.
Исполняемая память – процессор может выполнять инструкции только из оперативной памяти. Файловая система хранит данные на диске, которые нужно сначала загрузить в RAM.
Изоляция и безопасность –
.user
выделяется как отдельная область памяти под пользовательские задачи. Если бы мы запускали код прямо с буфера диска, не было бы контроля над доступом к памяти.Управление памятью через
user_malloc
– каждая программа получает свой блок в.user
, который можно освободить после завершения. Это позволяет многократно запускать новые программы без засорения памяти.Корректная работа сегментов ELF – ELF-файл содержит сегменты
.text
,.data
,.bss
, которые могут быть разбросаны и содержать разные атрибуты (RX/RW). Копирование в.user
позволяет правильно разместить сегменты с учётом прав доступа.Возможность модификации – некоторые сегменты (например,
.data
или.bss
) нужно инициализировать нулями или подготавливать в памяти перед запуском.
Иными словами: диск — хранит данные, .user
— это память, из которой CPU реально исполняет код, поэтому без копирования программа просто не сможет работать.
Мы не просто последовательно размещаем код программ в области .user
, а используем подход, аналогичный работе malloc
. Это позволяет динамически выделять память для каждой пользовательской задачи и освобождать её после завершения программы. Такой подход предотвращает исчерпание пространства .user
при запуске множества задач и обеспечивает возможность многократного использования памяти для новых процессов.
В результате мы получаем полноценный механизм запуска пользовательских программ в ядре, при котором они изолированы и не являются частью самого ядра.
Итог:
Таким образом, мы разобрали работу базовых компонентов ядра на языке C и реализовали их на практике.
Теперь у нас есть представление о том, как происходит взаимодействие загрузчика, менеджера памяти, файловой системы и других модулей.
Ниже представлена общая картина работы системы:
[BIOS/Bootloader (ASM) : Multiboot header, установление стека]
|
V
[Переход в точку входа ядра (start) -> вызов kmain]
|
V
┌─────────────────────────────────────────────────────────────────────────┐
│ kmain: инициализация ядра │
│ │
│ 1) Отключаем прерывания |
│ 2) Инициализируем таблицу прерываний (IDT) и переназначаем PIC |
│ 3) Инициализируем системные часы и PIT (таймер) |
│ 4) Устанавливаем маску IRQ (блокируем ненужные аппаратные IRQ) |
│ 5) Вычисляем размер кучи по линкер-символам и инициализируем malloc │
│ 6) Инициализируем пользовательский аллокатор в области .user |
│ 7) Монтируем/инициализируем файловую систему (FAT16) |
│ 8) Копируем/загружаем программу терминала в FS |
│ 9) Инициализируем планировщик задач и создаём/запускаем задачи (tasks) │
│10) Разрешаем прерывания (sti) │
│11) Входим в основной цикл: hlt / ожидание прерываний │
└─────────────────────────────────────────────────────────────────────────┘
|
V
(ядро простаивает, CPU в HLT — ждёт IRQ/INT)
|
+----------------+----------------+----------------+
| | | |
V V V V
[IRQ0/TIMER] [IRQ1/KEYBOARD] [INT 0x80/syscall] [CPU exceptions, other IRQs]
| | | |
V V V V
┌──────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌───────────────┐
│ Аппаратный IRQ0 │ │ Аппаратный IRQ1 │ │ Программный int │ │ Исключение │
│ (PIT/tick) │ │ (клавиатура) │ │ (syscall trap) │ │ (fault/err) │
└──────────────────┘ └─────────────────┘ └─────────────────┘ └───────────────┘
| | |
V V V
[Вход в соответствующий ISR — общий сценарий:]
- Ассемблерная «обвязка» ISR сохраняет состояние процессора (регистры, сегменты)
- Формируется единообразный фрейм/стек для передачи в C-диспетчер
- Вызывается C-обработчик (dispatch) для конкретного IRQ/INT
- После обработки: при необходимости — переключение задач (scheduler)
- Отправляется EOI в PIC (для аппаратных IRQ)
- Восстановление регистров и возврат (iret/iretd)
Подробно — ветки обработки:
──────────────────────────────────────────────────────────────────────────────
TIMER (IRQ0)
- Срабатывает PIT по частоте (инициализирована в kmain)
- ISR сохраняет контекст, вызывает C-функцию таймера:
• Увеличивает глобальный тик/счётчик времени
• По достижении порога вызывает clock_tick() (таймер часов)
• Вызывает планировщик: schedule_from_isr получает pointer на
текущий стек, выбирает следующую задачу и возвращает фрейм для восстановления
- Обязательный EOI в PIC отправляется сразу (чтобы позволить другие IRQ)
- Если планировщик выбрал другую задачу — происходит контекст-свитч:
• Текущие регистры сохраняются (в стеке/TCB), затем ESP устанавливается
на указанный планировщиком фрейм — CPU продолжает выполнение новой задачи
- Завершение ISR и возврат из прерывания
KEYBOARD (IRQ1)
- ISR клавиатуры сохраняет регистры и вызывает keyboard_handler
- keyboard_handler:
• Читает scancode с порта клавиатуры (аппаратный порт 0x60)
• Обрабатывает коды Shift / CapsLock (держит состояние модификаторов)
• Преобразует scancode в ASCII (или 0 при отсутствии соответствия)
• Записывает символ в кольцевой буфер (kbd buffer) атомарно:
- кратковременно блокирует прерывания / сохраняет флаги,
чтобы запись была безопасна (irq_save_flags / irq_restore_flags)
• Не блокирует планировщик — только буферизация ввода
- Отправляет EOI на PIC
- Возврат из ISR
SYSCALL (INT 0x80)
- Пользовательский или системный код вызывает int 0x80 с номером и аргументами
- ASM-входная точка для int 0x80:
• Сохраняет EFLAGS, запрещает прерывания
• Сохраняет набор регистров (callee state)
• Формирует стек с аргументами и номером системного вызова
• Вызывает syscall_handler (C)
- syscall_handler:
• Читает номер и аргументы
• Выполняет соответствующую службу: I/O, аллокация, процессы, FS и т.д.
• Возвращает результат (в регистр/на стек)
- ASM-обвязка восстанавливает регистры, EFLAGS и выполняет iret
- Syscall выполняется синхронно в контексте вызывающей задачи
ПАМЯТЬ: Секции и области управления
──────────────────────────────────────────────────────────────────────────────
[.text] — код ядра и мультибут-заголовок
[.data] — данные и инициализированные переменные
[.bss] — неинициализированные данные
[.heap] — область кучи для ядра (резерв ~32 MiB сразу после .bss)
• В начале heap ставятся линкер-символы _heap_start и _heap_end
• Ядровый malloc управляет этой областью: список блоков, split/coalesce
• Для расширения используется простая bump-алокация сверху вниз (brk_ptr)
[.user] — отдельное пространство для пользовательских программ (резерв ~128 MiB)
• Отдельный простой аллокатор user_malloc оперирует только в этой области
• Каждая user-задача получает кусок из .user и работает изолированно (логически)
Взаимодействие планировщика и аллокаторов
──────────────────────────────────────────────────────────────────────────────
- Планировщик создаёт/регулирует задачи: каждая задача имеет свой стек/контекст.
- При создании пользовательских задач память для их HEAP/стеков выделяется из .user.
- Ядровые вызовы malloc/realloc/free управляют .heap; статистика доступна через get_kmalloc_stats.
- User-allocator управляет .user, поддерживает split/coalesce и simple first-fit поиск.
- При переключении задач контексты (регистры, ESP) сохраняются в структуре задачи,
и при возобновлении — ESP/регистры восстанавливаются из этой структуры.
Дополнительные замечания (поведение, гарантии, атомарность)
──────────────────────────────────────────────────────────────────────────────
- Все аппаратные IRQ посылают EOI в PIC после обработки, иначе IRQ блокируются.
- Критические секции (например, запись в KBD-буфер) кратковременно блокируют прерывания,
чтобы избежать гонок при одновременном доступе из ISR и из кода.
- Таймер — источник вытесняющей многозадачности: он принудительно вызывает scheduler
из ISR и даёт возможность переключать задачи без их явного «yield».
- Syscall выполняется в контексте вызывающей задачи и не должен нарушать целостность ядра.
- Файловая система должна быть доступна до запуска пользовательских программ,
т.к. образ/программа терминала загружаются в FS до создания задач.
Краткая карта «от старта до реакции на ввод»
──────────────────────────────────────────────────────────────────────────────
BIOS/Bootloader
→ старт ядра (kmain)
→ init IDT/PIC, timer, маски IRQ
→ init heap и user-heap
→ init FS и загрузка программ (терминал)
→ init scheduler и create tasks
→ sti (разрешаем IRQ) → основной цикл HLT
→ IRQ0 (timer) → tick → scheduler → возможный context switch → resume
→ IRQ1 (keyboard) → scancode → to ASCII → push в kbd buffer → resume
→ INT0x80 (syscall) → syscall_handler → результат → resume
Полный исходный код проекта, а также инструкция по сборке и запуску доступны здесь:
GitHub
Мы прошли долгий путь от первых шагов до полноценного прототипа операционной системы, написанной на C. На этом пути мы реализовали ключевые механизмы, без которых невозможно представить работу ядра:
Создание ядра — мы настроили загрузчик, инициализировали
GDT
иIDT
, подготовили окружение для работы в защищённом режиме.Вывод на экран — реализовали базовый драйвер
VGA
, который позволил отображать текстовую информацию напрямую в видеопамяти.Получение нажатий клавиатуры — настроили обработчики прерываний и добавили поддержку клавиатуры для интерактивного взаимодействия.
Время — подключили таймер
PIT
, научились отслеживать системное время и использовать его для планирования задач.Системные вызовы — внедрили механизм
syscall
, открыв путь для взаимодействия пользовательских программ с ядром.Аллокатор — разработали простой менеджер памяти для динамического распределения ресурсов в ядре.
Многозадачность — реализовали переключение контекста и поддержку нескольких процессов, сделав систему по-настоящему многозадачной.
Создание базовой файловой системы — подготовили основу для хранения данных, что является ключом к запуску программ.
Запуск пользовательских приложений в ядре — сделали завершающий шаг: теперь наше ядро умеет загружать и выполнять внешние программы.
Теперь у вас есть понимание того, как из «ничего» шаг за шагом создаётся ядро, которое способно выполнять задачи, обрабатывать ввод, управлять памятью и даже запускать приложения. Это не просто код — это фундамент, на котором можно строить полноценную операционную систему.
Дальше вы можете расширять возможности: добавлять драйверы, улучшать файловую систему, внедрять графический интерфейс, сетевой стек и многое другое. Всё зависит только от вашего желания и целей.
Помните: путь создания ядра — это не только про технологии, но и про понимание принципов работы компьютера на самом низком уровне. Если вы дошли до этого этапа — вы уже сделали огромный шаг вперёд.
Спасибо за прочтение статьи!
Надеюсь, она была интересна для вас, и вы узнали что-то новое.
Комментарии (30)
checkpoint
24.08.2025 19:04Как тут принято говорить - "немного подушним".
Во-первых, из текста статьи не ясно работает Ваша ОС в Real Mode или Protected Mode ?Хотя вот, нашел.
Создание ядра — мы настроили загрузчик, инициализировали
GDT
иIDT
, подготовили окружение для работы в защищённом режиме.
Во-вторых, есть неточность:
Минусы FAT16
Нет поддержки папок и поддиректорий — все файлы находятся в корне.
File Allocation Table с версии 12 (FAT12, FAT16 и FAT32) поддерживают иерархическую структуру подкаталогов. Список файлов подкаталога хранится в файле со специальным атрибутом "directory".
На мой взгляд основными недостатками FAT12 и FAT16 являютя:
Отсутствие поддержки прав доступа и принадлежности к пользователю. В Novel Netware, OS/2 LAN Manager, Linux VFAT и прочих сетевых ОС добавили свои несовместимые расширения (extended attributes) для решения этой проблемы.
Ограничение на формат имени файла: 6.3 или 8.3. Проблема решалась костылем - файлы с закодированными названиями и отдельный файл с таблицей перевода закодированного названия в нормальное.
Теперь вопрос. Как Вы реализовали преобразование сканкода нажатой клавиши в ASCII код ? Задача эта весьма нетривиальная. Я столкнулся с ней когда писал программу Монитор для своей синтезируемой СнК KarnixSoC, мне требовалось сделать свой видео-терминал чтобы пользователь мог управлять устройством с помощью клавиатуры и VGA. В итоге, самое минималистичное решение обнаружилось в ядре ОС Linux. Код видео-терминала там организован в виде небольшой виартуальной машины с семью таблицами (программами). Мне удалось вырезать этот код и адаптировать под свои нужды. Интересно как это сделано у Вас.
Elieren Автор
24.08.2025 19:04Добрый день.
Действительно, в FAT16 есть директории. Похоже, в источнике, откуда я брал первоначальную информацию, были неточности — спасибо за замечание, исправлю этот пункт.
Что касается сканкодов: я создал массив символов, в котором каждому коду соответствует свой символ, и просто перебирал соответствия «код → символ». Минус такого подхода — сложность добавления новых языков. Тем не менее для защищённого режима этого вполне достаточно, так как базовый VGA-ASCII выводит только латинский алфавит (русского там нет); остальные языки я не проверял.
Elieren Автор
24.08.2025 19:04P.S. В статье обновлён пункт «Создание базовой файловой системы».
В ядре теперь реализованы директории и папки.
a-tk
24.08.2025 19:04Кроме алфавита есть ещё раскладка, состояние shift и caps lock и ещё немножко всякое разное.
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]; } }
checkpoint
24.08.2025 19:04А как быть с управляющими символами типа Ctrl-C/Ctrl-X/Ctrl-Z ? С кнопками управления курсором ? Чтобы сделать полноценную строку ввода команд придется попотеть.
Elieren Автор
24.08.2025 19:04Добрый день.
Терминал пока версии 0.1. Весь функционал будет постепенно добавляться и дополняться. На данный момент это предрелизная версия.
dyadyaSerezha
24.08.2025 19:04Создание своего ядра на C
Читаю и сразу в голове: "это про ядерный электорат?". Вот дожили! Ужас.
evgeniy_kudinov
24.08.2025 19:04Спасибо за статью. Интересно было бы подобное на Zig под RISC-V почитать.
a-tk
24.08.2025 19:04sti ; разрешить прерывания popfd ; восстановить EFLAGS iret ; возврат из прерывания
Эпилог обработчика прерывания немного избыточен и даже некорректен: во-первых, при входе в прерывание флаги уже на стеке, и ещё раз пушить их нет смысла. Во-вторых, команда iret обеспечивает атомарную операцию, в которой восстанавливаются флаги и осуществляется длинный переход на сохранённый адрес возврата.
У Вас же получается разрешение прерывания до выхода из обработчика, что приводит к тому, что другой обработчик может вытеснить выполнение сразу после инструкции sti. Далее, popf всё равно восстановит флаг прерываний, ранее сохранённый, поэтому операция sti перед popf тут избыточна.
a-tk
24.08.2025 19:04/* Берёт символ из буфера без блокировки. Возвращает -1 если пусто. */ char kbd_getchar(void)
Строго говоря, char не обязательно знаковый и -1 может не быть в диапазоне допустимых значений.
mapnik
24.08.2025 19:04Почитал подводку — интересно!
Раскрыл статью — и тут откуда ни возьмись™ появился x86 :(
cdriper
24.08.2025 19:04Мы копируем программу с диска (RAMDISK/файловой системы) в область
.user
в памяти ядра, потому что CPU не может напрямую исполнять код с файловой системы.мне кажется, что те, кто лезут в тему создания ОС должны уже как-то понимать такого рода штуки
VBDUnit
24.08.2025 19:04На фоне развития ИИ есть вероятность появления аппаратных архитектур, где память будет общая, может даже она же будет являться CPU :)
buratino
тут многа букв, но среди них нет "PC" или "Intel", или хотя бы упоминания архитектуры , зато есть номера прерываний, что явно указывает на использование Ибием Писи архитектуры.
Это на каком языке написано?