Как пишут свою ОС: от bootloader до планировщика и что читать дальше
Восемнадцать статей трека мы разбирали ОС снаружи: читали /proc, трассировали
системные вызовы, сравнивали планировщики, спорили о микроядрах. Это правильный
способ учиться — но у него есть потолок. Пока вы не написали строку, после которой
процессор впервые не знает, куда идти дальше, потому что вы забыли настроить IDT,
многие вещи остаются словами. «Прерывание», «таблица страниц», «переключение
контекста» — это не абстракции, это конкретные структуры в памяти, которые кто-то
однажды руками заполнил.
Эта статья — маршрут. Не полное руководство (полное занимает месяцы и лежит по ссылкам в конце), а карта: какие этапы бывают, в каком порядке их проходят, где именно все спотыкаются, какие решения на развилках чего стоят, и — главное — что из всего этого приносит пользу человеку, который завтра вернётся писать веб-сервис на Go.
Зачем это вообще делать
Честный ответ: коммерческую ОС вы не напишете. Linux — это ~40 миллионов строк, из которых больше половины драйверы; конкурировать бессмысленно. Но есть четыре причины, по которым люди всё равно пишут ядра:
- Понимание. Это самый эффективный способ перестать бояться низкого уровня.
После написания своего аллокатора страниц вы иначе читаете
/proc/meminfo. После собственногоswitch_contextвам не нужно объяснять, почему горутина дешевле потока — вы видели, из чего состоит «дорого». - Embedded и RTOS. В прошивке микроконтроллера, в автомобильном ECU, в спутнике — там нет Linux и нет libc. Там ровно то, что вы напишете. Zephyr, FreeRTOS, NuttX — это ядра масштаба учебного проекта, только зрелые.
- Исследования и специализированные системы. Unikernels, гипервизоры, доверенные загрузчики, верифицированные микроядра (seL4), экспериментальные модели изоляции. Здесь «своя ОС» — рабочий инструмент, см. https://courses.digitable.life/post/operating-systems/02-kernel-architectures/.
- Собеседования и инженерная репутация. Кандидат, который приносит на собеседование работающее ядро с вытесняющим планировщиком, разговаривает с интервьюером на другом языке.
Есть и пятая, менее очевидная: это лучший в мире тренажёр отладки. Нет
отладчика по умолчанию, нет printf, нет стектрейса, нет сообщений об ошибках.
Есть тройная ошибка и перезагрузка. Люди, прошедшие через это, потом иначе
подходят к любому «непонятно почему падает».
Развилка первая: какую машину вы моделируете
Первое решение — целевая архитектура. Оно определяет 80% боли на первых неделях.
| Мишень | Порог входа | Плюсы | Минусы |
|---|---|---|---|
RISC-V (QEMU virt) |
Низкий | Чистая спецификация, 3 режима (M/S/U), OpenSBI берёт на себя раннюю инициализацию, вся периферия описана в device tree | Меньше готовых туториалов «для новичка», реального железа под рукой обычно нет |
x86-64 (QEMU q35) |
Высокий | Гора документации, запускается на любом ноутбуке, знание применимо к отладке настоящих систем | 40 лет обратной совместимости: реальный режим, A20, GDT, PIC, три перехода режимов |
AArch64 (QEMU virt) |
Средний | Современный дизайн, EL0–EL3, device tree, реальное железо = Raspberry Pi | Загрузка сильно зависит от платформы, документация ARM тяжеловесна |
Практический совет, который дают почти все преподаватели: если цель — понять ОС, берите RISC-V; если цель — понять x86, берите x86. MIT перевёл свой курс 6.1810 (бывший 6.828) с x86 на RISC-V именно потому, что студенты тратили половину семестра на исторические особенности Intel, а не на операционные системы. Учебное ядро xv6-riscv — это ~9000 строк C, которые целиком читаются за выходные, и в них есть всё: процессы, страницы, файловая система, драйверы, системные вызовы.
Ниже примеры на x86-64 — просто потому, что читателю проще проверить их на своей машине, и потому что большая часть трека была про x86-64-системы. Идеи переносятся без изменений; меняются имена регистров.
Инструментарий: freestanding-мир
Ядро — это программа без операционной системы под собой. Ни libc, ни main,
ни динамического линкера, ни malloc. На языке компилятора это называется
freestanding: доступны только заголовки без зависимостей от ОС
(stdint.h, stddef.h, stdbool.h, stdarg.h, limits.h) — всё остальное
пишете сами.
# Минимальный набор. На Debian/Ubuntu:
sudo apt install build-essential nasm qemu-system-x86 gdb xorriso mtools
# Проверяем, что системный gcc умеет целиться в freestanding x86-64:
gcc -ffreestanding -mno-red-zone -mcmodel=kernel -c kernel.c -o kernel.o && echo ok
Каноничный путь — собрать кросс-компилятор x86_64-elf-gcc
(инструкция OSDev), чтобы системный
toolchain не подсовывал заголовки и библиотеки хоста. На практике на Linux-хосте
системный gcc с правильными флагами работает; на macOS кросс-компилятор
обязателен, потому что Apple clang по умолчанию целится в Mach-O, а вам нужен ELF.
Флаги, которые придётся выучить наизусть:
CFLAGS = -ffreestanding # нет libc, нет предположений о рантайме
-fno-builtin # не подменять код вызовами memcpy из libc
-fno-stack-protector # канарейки требуют __stack_chk_fail и TLS
-fno-pic -fno-pie # ядро линкуется по фиксированному адресу
-mno-red-zone # КРИТИЧНО: см. ниже
-mgeneral-regs-only # запретить SSE/AVX, пока не включили их в CR0/CR4
-mcmodel=kernel # адреса в старших 2 ГиБ, rip-relative помещается в 32 бита
-Wall -Wextra -O2 -g
LDFLAGS = -nostdlib -static -T linker.ld -z max-page-size=0x1000
Про -mno-red-zone стоит сказать отдельно, потому что это ошибка, на которой
теряют по несколько дней. System V ABI разрешает функции-листу использовать
128 байт ниже RSP без сдвига указателя стека — это и есть red zone. В
пользовательском коде безопасно: никто не пишет ниже RSP. В ядре прерывание
приходит асинхронно, процессор кладёт кадр прерывания прямо на текущий RSP
и затирает red zone. Результат — редкие, невоспроизводимые порчи локальных
переменных. Компилируйте ядро без red zone; в Linux этот флаг стоит в
arch/x86/Makefile с 2005 года.
Аналогично -mgeneral-regs-only: пока вы не включили SSE (сбросить CR0.EM,
поставить CR0.MP, CR4.OSFXSR, CR4.OSXMMEXCPT) и не научились сохранять
XMM-регистры при переключении контекста, компилятор не должен их использовать —
иначе memcpy, который gcc самостоятельно векторизует, уронит вас в #UD.
Этап 1: как код вообще получает управление
Процессор после сброса не знает про вашу ОС ничего. Между питанием и вашим
kernel_main лежит цепочка передач управления — про неё подробно в
https://courses.digitable.life/post/operating-systems/10-boot-process/, здесь — с точки зрения того, кто пишет ядро.
x86-64: 0xFFFFFFF0, real mode
RISC-V: 0x1000 в ROM"] --> B{Прошивка} B -->|Legacy BIOS| C["Читает LBA 0 в 0x7C00
проверяет 0xAA55
512 байт, 16-битный режим"] B -->|UEFI| D["Читает FAT32 ESP
запускает PE32+ приложение
уже 64-битный режим, есть Boot Services"] B -->|OpenSBI / U-Boot| E["M-mode инициализация
передача в S-mode по адресу 0x80200000"] C --> F["Ваш stage 1: включить A20,
загрузить stage 2 через INT 13h,
protected mode, long mode"] D --> G["Готовый загрузчик:
GRUB2 / Limine / systemd-boot"] E --> K F --> H["Разбор ELF ядра, построение
стартовых таблиц страниц"] G --> H H --> I["Прыжок на точку входа ядра
по высокому виртуальному адресу"] I --> J["_start на ассемблере:
обнулить .bss, поставить стек,
выровнять RSP по 16"] J --> K["kernel_main на C"] style C fill:#b5555f,fill-opacity:0.2 style G fill:#6b9e7a,fill-opacity:0.2 style K fill:#5b8fc9,fill-opacity:0.25
Здесь первая серьёзная развилка проекта: писать свой загрузчик или взять готовый.
Свой boot sector — это романтика: 512 байт ассемблера, org 0x7C00,
jmp $ в конце. Вы своими руками включаете линию A20, переводите процессор
через unreal/protected/long mode, читаете диск через INT 13h. Это красиво
и очень познавательно ровно один раз. Проблемы: BIOS-загрузка мертва на новом
железе (Intel убрала CSM в 2020-м), 512 байт заканчиваются мгновенно, отладка
16-битного кода мучительна.
; boot.asm — минимальный boot sector: печатает символ и виснет.
; Собирается: nasm -f bin boot.asm -o boot.bin
; Запускается: qemu-system-x86_64 -drive format=raw,file=boot.bin
bits 16
org 0x7C00
start:
cli
xor ax, ax
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0x7C00 ; стек растёт вниз от нас же
sti
mov ah, 0x0E ; телетайп BIOS
mov al, 'K'
int 0x10
cli
.hang:
hlt
jmp .hang
times 510-($-$$) db 0 ; добить до 510 байт
dw 0xAA55 ; сигнатура загрузочного сектора
Готовый загрузчик — это то, что делают все серьёзные хобби-проекты. Варианты:
- Limine — современный выбор по умолчанию. Поддерживает UEFI и BIOS, отдаёт ядру уже включённый long mode, готовый higher-half direct map, карту памяти, framebuffer, модули, SMP. Стартовать «с нуля» на нём можно за вечер.
- GRUB2 + Multiboot2 —
классика. Ядро объявляет заголовок в первых 32 КиБ файла, GRUB грузит его
в protected mode и передаёт указатель на структуру информации в
EBX. Long mode придётся включать самому — это полезное упражнение. qemu -kernel— QEMU сам умеет загружать ELF/Multiboot-ядра. Быстрейший способ начать, но вы пропускаете весь загрузочный этап.
Разумная стратегия: начните с готового загрузчика, напишите ядро, а свой
загрузчик сделайте отдельным упражнением потом — иначе рискуете застрять
на неделю в 16-битном ассемблере и потерять мотивацию до первого printf.
Раскладка памяти и линкер-скрипт
Как только в игре появляется C, появляется и вопрос: по какому адресу лежит ваш код и по какому адресу он думает, что лежит. Это разные вещи, и линкер различает их как LMA (load memory address) и VMA (virtual memory address).
Практически все ядра — higher-half: ядро живёт в верхней половине
виртуального адресного пространства, пользовательские процессы — в нижней.
Причина проста: при таком раскладе верхние записи PML4 одинаковы во всех
адресных пространствах, поэтому при переключении процесса ядро остаётся
отображённым и системный вызов не требует смены таблиц. Именно поэтому
0xFFFFFFFF80000000 — знакомый адрес всякому, кто читал Linux-овый
System.map.
/* linker.ld — higher-half ядро, загружаемое по физическому 1 МиБ */
ENTRY(_start)
KERNEL_VMA = 0xFFFFFFFF80000000;
SECTIONS
{
. = KERNEL_VMA + 1M;
.text : AT(ADDR(.text) - KERNEL_VMA) {
__text_start = .;
*(.multiboot) /* заголовок должен быть в начале файла */
*(.text .text.*)
__text_end = .;
}
. = ALIGN(4K);
.rodata : AT(ADDR(.rodata) - KERNEL_VMA) { *(.rodata .rodata.*) }
. = ALIGN(4K);
.data : AT(ADDR(.data) - KERNEL_VMA) { *(.data .data.*) }
. = ALIGN(4K);
.bss : AT(ADDR(.bss) - KERNEL_VMA) {
__bss_start = .;
*(COMMON)
*(.bss .bss.*)
__bss_end = .;
}
__kernel_end = .;
/DISCARD/ : { *(.comment) *(.eh_frame) *(.note.*) }
}
Три вещи, которые ломаются чаще всего:
.bssне обнулён. В ELF секция.bssзанимает ноль байт файла — только адрес и размер. Загрузчик не обязан её обнулять (Multiboot2 обязан, Limine — да, а свой загрузчик — нет). Глобальныйstatic int counter;окажется мусором. Обнуляйте.bssв_startруками.- Стек не выровнен по 16 байт. System V ABI требует, чтобы на входе в функцию
RSP % 16 == 8(послеcall). Нарушение проявится не сразу, а на первой инструкцииmovaps— то есть в неожиданном месте. - Символы линкера читают неправильно.
extern char __bss_start;— значение символа это адрес, поэтому нужен&__bss_start, а не__bss_start.
Этап 2: первый вывод — последовательный порт, а не VGA
Инстинкт подсказывает писать в текстовый буфер VGA по 0xB8000. Это работает
и даёт приятный момент «оно живое». Но для отладки в 10 раз полезнее
последовательный порт: QEMU перенаправит его в ваш терминал, вывод можно
грепать, сохранять в файл и сравнивать между запусками. UEFI-системы вообще
могут не иметь текстового VGA.
/* serial.c — драйвер 16550 UART на COM1. Никакой магии: 8 портов ввода-вывода. */
#include <stdint.h>
#define COM1 0x3F8
static inline void outb(uint16_t port, uint8_t val) {
__asm__ volatile ("outb %0, %1" : : "a"(val), "Nd"(port));
}
static inline uint8_t inb(uint16_t port) {
uint8_t r;
__asm__ volatile ("inb %1, %0" : "=a"(r) : "Nd"(port));
return r;
}
void serial_init(void) {
outb(COM1 + 1, 0x00); /* выключить прерывания */
outb(COM1 + 3, 0x80); /* DLAB = 1: следующие два порта — делитель */
outb(COM1 + 0, 0x03); /* делитель 3 => 115200/3 = 38400 бод */
outb(COM1 + 1, 0x00);
outb(COM1 + 3, 0x03); /* DLAB = 0, 8 бит, без чётности, 1 стоп-бит */
outb(COM1 + 2, 0xC7); /* включить FIFO, порог 14 байт */
outb(COM1 + 4, 0x0B); /* DTR, RTS, OUT2 */
}
void serial_putc(char c) {
while ((inb(COM1 + 5) & 0x20) == 0) { } /* ждём, пока THR пуст */
if (c == '\n') serial_putc('\r');
outb(COM1, (uint8_t)c);
}
# Вывод ядра приезжает прямо в терминал:
qemu-system-x86_64 -cdrom kernel.iso -serial stdio -display none -no-reboot
# И сразу же — привычка, которая экономит недели:
qemu-system-x86_64 -cdrom kernel.iso -serial stdio -display none \
-no-reboot -no-shutdown -d int,guest_errors -D qemu.log
Флаг -d int печатает каждое прерывание и исключение с номером вектора,
кодом ошибки и значениями регистров. Когда ядро молча перезагружается,
это первое, куда надо смотреть: в логе видно последовательность
#PF → #GP → #DF — тройную ошибку, то есть исключение при обработке
исключения при обработке исключения. Процессор в этот момент делает reset.
Этап 3: прерывания — момент, когда ядро становится ядром
Пока ядро просто выполняет линейный код, оно ничем не отличается от прошивки. Ядро начинается там, где появляется асинхронность: устройство или таймер может прервать текущее исполнение.
На x86-64 нужны две таблицы:
- GDT (Global Descriptor Table). В long mode сегментация почти отключена,
но дескрипторы всё ещё нужны: код ring 0, данные ring 0, код ring 3, данные
ring 3 и TSS — структура, из которой процессор берёт
RSP0, стек ядра при переходе из ring 3, и IST-стеки для критичных исключений. - IDT (Interrupt Descriptor Table). 256 записей по 16 байт: адрес обработчика, селектор кода, DPL (кому разрешено вызывать программно), тип (interrupt gate запрещает прерывания на входе, trap gate — нет), индекс IST.
/* idt.c — сокращённо, но по-настоящему */
struct idt_entry {
uint16_t offset_low;
uint16_t selector; /* селектор кода ядра из GDT */
uint8_t ist; /* 0 = обычный стек, 1..7 = стек из TSS.IST[n] */
uint8_t type_attr; /* 0x8E = present, DPL=0, 64-bit interrupt gate */
uint16_t offset_mid;
uint32_t offset_high;
uint32_t zero;
} __attribute__((packed));
static struct idt_entry idt[256];
void idt_set(int vec, void *handler, uint8_t ist, uint8_t dpl) {
uint64_t a = (uint64_t)handler;
idt[vec].offset_low = a & 0xFFFF;
idt[vec].selector = 0x08;
idt[vec].ist = ist;
idt[vec].type_attr = 0x8E | (uint8_t)(dpl << 5);
idt[vec].offset_mid = (a >> 16) & 0xFFFF;
idt[vec].offset_high = (a >> 32) & 0xFFFFFFFF;
idt[vec].zero = 0;
}
Обработчик не может быть обычной C-функцией: процессор кладёт на стек кадр
прерывания, а у части исключений ещё и код ошибки, и выходить надо через
iretq, а не ret. Стандартное решение — ассемблерные «заглушки»,
выравнивающие стек и вызывающие C-функцию:
%macro ISR_NOERR 1
global isr%1
isr%1:
push qword 0 ; фиктивный код ошибки — чтобы кадр был одинаковый
push qword %1 ; номер вектора
jmp isr_common
%endmacro
%macro ISR_ERR 1
global isr%1
isr%1:
push qword %1
jmp isr_common
%endmacro
isr_common:
push rax
push rcx
push rdx
push rsi
push rdi
push r8
push r9
push r10
push r11
; callee-saved регистры трогать не обязаны — их сохранит C-компилятор
cld ; ABI требует DF=0 на входе в C
mov rdi, rsp ; первый аргумент — указатель на кадр
call isr_handler
pop r11
pop r10
pop r9
pop r8
pop rdi
pop rsi
pop rdx
pop rcx
pop rax
add rsp, 16 ; снять вектор и код ошибки
iretq
Затем — источник тиков. Простейший вариант, PIT 8253: перепрограммировать делитель базовой частоты 1 193 182 Гц, перенаправить прерывания PIC с векторов 8–15 (которые конфликтуют с исключениями) на 0x20–0x2F, размаскировать IRQ0. Правильный современный вариант — Local APIC timer плюс HPET для калибровки. Начинайте с PIT: он проще, а переписать на APIC можно потом.
Момент, когда в консоли идёт tick 1, tick 2, tick 3... и ядро при этом
продолжает делать что-то ещё, — психологически важный. С него начинается
вытеснение.
Этап 4: физическая память
Загрузчик отдаёт карту памяти: массив регионов с типами (usable, reserved, ACPI reclaimable, bad). Ваша задача — научиться выдавать и возвращать физические страницы по 4 КиБ.
Три классических подхода:
| Аллокатор | Как устроен | Сложность | Когда брать |
|---|---|---|---|
| Bitmap | 1 бит на страницу, поиск первого нуля | O(n) на выделение, ~32 КиБ на 1 ГиБ RAM | Первая версия. Просто и отлаживаемо |
| Free-list (stack) | Свободные страницы связаны сами через себя | O(1) | Когда bitmap стал узким местом |
| Buddy | Степени двойки, слияние соседей | O(log n), борется с внешней фрагментацией | Когда нужны непрерывные блоки под DMA |
/* Bitmap-аллокатор: примитивно, но работает и легко проверяется. */
static uint64_t *bitmap; /* 1 = занята */
static size_t total_pages;
static size_t hint; /* подсказка: откуда начинать поиск */
void *pmm_alloc_page(void) {
for (size_t i = 0; i < total_pages; i++) {
size_t p = (hint + i) % total_pages;
if (!(bitmap[p / 64] & (1ULL << (p % 64)))) {
bitmap[p / 64] |= (1ULL << (p % 64));
hint = p + 1;
return (void *)(p * 4096); /* физический адрес */
}
}
return NULL; /* OOM: у вас нет swap */
}
void pmm_free_page(void *phys) {
size_t p = (uintptr_t)phys / 4096;
bitmap[p / 64] &= ~(1ULL << (p % 64));
}
Тонкость, которая обязательно вас укусит: сам bitmap где-то должен лежать. Классический приём — bootstrap: пройти карту памяти, найти достаточно большой usable-регион, разместить bitmap в его начале и сразу пометить занятыми страницы самого bitmap, образа ядра и первого мегабайта.
Ядро Linux использует buddy-аллокатор для страниц и slab поверх него для объектов — идея slab описана в классической работе Джеффа Бонвика «The Slab Allocator: An Object-Caching Kernel Memory Allocator» (USENIX Summer 1994); теория — в https://courses.digitable.life/post/operating-systems/04-memory-management/.
Этап 5: виртуальная память
Теперь самое интересное. На x86-64 с 4-уровневой пагинацией виртуальный адрес разбирается так: биты 47:39 — индекс в PML4, 38:30 — в PDPT, 29:21 — в PD, 20:12 — в PT, 11:0 — смещение внутри страницы. Каждая таблица — ровно одна страница из 512 восьмибайтных записей.
#define PTE_PRESENT (1ULL << 0)
#define PTE_WRITE (1ULL << 1)
#define PTE_USER (1ULL << 2)
#define PTE_HUGE (1ULL << 7) /* 2 МиБ на уровне PD, 1 ГиБ на PDPT */
#define PTE_NX (1ULL << 63) /* требует EFER.NXE = 1 */
#define PTE_ADDR_MASK 0x000FFFFFFFFFF000ULL
static uint64_t *walk(uint64_t *pml4, uint64_t va, bool create) {
uint64_t *table = pml4;
for (int level = 4; level > 1; level--) {
size_t idx = (va >> (12 + 9 * (level - 1))) & 0x1FF;
if (!(table[idx] & PTE_PRESENT)) {
if (!create) return NULL;
void *p = pmm_alloc_page();
memset(phys_to_virt(p), 0, 4096); /* новая таблица должна быть чистой */
table[idx] = (uint64_t)p | PTE_PRESENT | PTE_WRITE | PTE_USER;
}
table = phys_to_virt(table[idx] & PTE_ADDR_MASK);
}
return &table[(va >> 12) & 0x1FF];
}
void vmm_map(uint64_t *pml4, uint64_t va, uint64_t pa, uint64_t flags) {
uint64_t *pte = walk(pml4, va, true);
*pte = (pa & PTE_ADDR_MASK) | flags | PTE_PRESENT;
__asm__ volatile ("invlpg (%0)" : : "r"(va) : "memory"); /* сбросить TLB */
}
Четыре ловушки этого этапа:
- Права на промежуточных уровнях перемножаются логическим И. Если на уровне
PML4 не стоит
PTE_USER, тоPTE_USERна листе не поможет. Ставьте разрешительные флаги на промежуточных уровнях, ограничивайте на листе. invlpgобязателен при изменении отображения. TLB — кэш, процессор не знает, что вы поменяли таблицу в памяти. Забыли — получите «код работает через раз».- Записи в таблицах — физические адреса, а ходите вы по виртуальным.
Отсюда
phys_to_virt(): пока действует прямое отображение всей RAM, это просто+ HHDM_OFFSET. До того, как оно построено, — рекурсивное отображение или временное окно. - Переключение на свои таблицы — самый опасный
mov cr3в проекте. Если новая таблица не отображает код, который выполняетmov cr3, следующая инструкция даст#PF, обработчик тоже не отобразится, — тройная ошибка и мгновенная перезагрузка. Приём: до переключения отобразить ядро и по низкому, и по высокому адресу (identity + higher-half), прыгнуть на высокий адрес и только потом убрать identity-отображение.
Этап 6: куча ядра
С физическим и виртуальным менеджерами появляется возможность написать
kmalloc. Простейший вариант — bump-аллокатор (только выделяет, не
освобождает): 30 строк, и его хватает надолго. Дальше — free-list с
объединением соседей, дальше — slab.
Полезное правило: сделайте kmalloc заметно шумным на этапе разработки.
Заполняйте выданную память байтом 0xAB, а освобождённую — 0xDE.
Обращение к освобождённой памяти тогда падает сразу и на понятном адресе,
а не через два часа в другой подсистеме.
Этап 7: потоки и переключение контекста
Это концептуальная вершина проекта. Всё, что нужно, — уметь сохранить состояние исполнения так, чтобы его можно было восстановить.
Ключевое наблюдение: регистры, которые сохраняет вызывающая сторона по ABI,
сохранять не нужно — если переключение происходит внутри обычного вызова
функции, компилятор уже позаботился о caller-saved регистрах. Сохранить надо
только шесть callee-saved (RBX, RBP, R12–R15) и указатель стека.
; void switch_context(uint64_t *prev_rsp, uint64_t next_rsp);
; rdi = &prev->rsp, rsi = next->rsp
global switch_context
switch_context:
push rbp
push rbx
push r12
push r13
push r14
push r15
mov [rdi], rsp ; запомнили, где остановился старый поток
mov rsp, rsi ; встали на стек нового
pop r15
pop r14
pop r13
pop r12
pop rbx
pop rbp
ret ; ret снимает адрес возврата НОВОГО потока
Вся магия — в последнем ret. Он забирает адрес возврата не того потока,
который вызвал функцию, а того, чей стек мы только что подставили. Функция
входит в одном потоке и выходит в другом.
Для нового потока стек подделывают: кладут шесть нулей (будущие
callee-saved), а под ними — адрес функции-точки входа. Первый ret в
switch_context тогда «вернётся» туда, где поток никогда не был.
struct thread *thread_create(void (*entry)(void *), void *arg) {
struct thread *t = kmalloc(sizeof *t);
uint8_t *stack = kmalloc(16 * 1024);
uint64_t *sp = (uint64_t *)(stack + 16 * 1024);
*--sp = (uint64_t)thread_trampoline; /* сюда прыгнет ret */
*--sp = 0; /* rbp */
*--sp = (uint64_t)arg; /* положим в rbx, трамплин заберёт */
*--sp = (uint64_t)entry; /* r12 */
*--sp = 0; /* r13 */
*--sp = 0; /* r14 */
*--sp = 0; /* r15 */
t->rsp = (uint64_t)sp;
t->state = THREAD_READY;
runqueue_push(t);
return t;
}
Состояния потока и переходы между ними — то же самое, что в https://courses.digitable.life/post/operating-systems/03-processes-and-scheduling/, только теперь вы их пишете сами:
вытеснение RUNNING --> BLOCKED: ждём мьютекс, ввод-вывод, sleep BLOCKED --> READY: событие произошло, wake_up переложил в runqueue RUNNING --> ZOMBIE: поток вернулся из entry или вызвал exit ZOMBIE --> [*]: родитель забрал код возврата,
стек и дескриптор освобождены note right of BLOCKED Заблокированный поток НЕ в runqueue. Забыть его туда вернуть = вечное зависание, самый частый баг во всех учебных ядрах. end note note right of RUNNING На одном CPU RUNNING ровно один. При SMP — по одному на ядро, и runqueue нужен блокировкой либо per-CPU. end note
Первый планировщик — round-robin: кольцевая очередь готовых потоков, переключение по тику таймера. Дальше можно приделать приоритеты, потом пропорционально-справедливый (CFS/EEVDF-подобный, по виртуальному времени), потом — realtime-классы. Каждое усложнение — отдельный обозримый шаг, и все они честно описаны в литературе.
Важная деталь про вытеснение. Переключаться прямо из обработчика
прерывания технически можно, но проще и безопаснее так: обработчик таймера
только ставит флаг need_resched, а фактическое switch_context() происходит
в общей точке выхода из прерывания, когда прерывания ещё запрещены, но кадр
уже разложен. Ровно так устроены и Linux, и FreeBSD.
Этап 8: ring 3 и системные вызовы
До сих пор всё ядро было одной программой в ring 0. Настоящая ОС начинается, когда появляется код, которому нельзя доверять.
Для этого нужно: страницы пользователя с флагом PTE_USER, отдельный стек,
дескрипторы ring 3 в GDT, TSS с корректным RSP0 — и способ вернуться в ядро.
Быстрый путь на x86-64 — инструкции syscall/sysret, минующие IDT.
Настраиваются через MSR:
#define MSR_EFER 0xC0000080 /* бит 0 SCE — разрешить syscall */
#define MSR_STAR 0xC0000081 /* селекторы сегментов для ring 0 и ring 3 */
#define MSR_LSTAR 0xC0000082 /* адрес входа в ядро */
#define MSR_SFMASK 0xC0000084 /* какие биты RFLAGS сбросить при входе */
#define MSR_KERNEL_GS 0xC0000102 /* «теневой» GS для swapgs */
void syscall_init(void) {
wrmsr(MSR_EFER, rdmsr(MSR_EFER) | 1);
wrmsr(MSR_STAR, ((uint64_t)0x08 << 32) | ((uint64_t)0x1B << 48));
wrmsr(MSR_LSTAR, (uint64_t)syscall_entry);
wrmsr(MSR_SFMASK, 0x200 | 0x100); /* сбросить IF и TF на входе */
}
Ключевая тонкость, которая ломает всех: syscall не переключает стек.
Процессор кладёт RIP в RCX, RFLAGS в R11, меняет CS/SS — и всё.
RSP остаётся пользовательским. Ядро обязано само найти свой стек, и делает
это через swapgs: инструкция меняет местами GS_BASE и KERNEL_GS_BASE,
после чего по %gs:offset доступна per-CPU структура, откуда берётся стек ядра.
Забыть swapgs при выходе — уязвимость (пользователь получает указатель
на per-CPU данные ядра); сделать его дважды — тоже.
CS/SS из MSR_STAR
RSP НЕ меняется C->>K: переход на MSR_LSTAR K->>K: swapgs — достать per-CPU данные K->>K: mov rsp, gs:[cpu_kernel_stack] K->>K: проверить номер вызова, скопировать аргументы,
ВАЛИДИРОВАТЬ пользовательские указатели alt Данные готовы, например getpid K-->>U: результат в RAX, swapgs, sysretq else Надо ждать — read с пустого канала K->>S: block_current(), состояние BLOCKED S->>S: выбрать другой поток S->>K: switch_context — CPU уходит другому потоку Note over U,S: ...проходит время, приходит прерывание устройства... S->>K: wake_up: поток снова READY, затем RUNNING K-->>U: результат в RAX, swapgs, sysretq end
Валидация пользовательских указателей — не формальность, а граница
безопасности. Процесс передаёт адрес буфера; если ядро просто разыменует его,
пользователь сможет заставить ядро писать по адресам ядра. Нужны:
проверка, что адрес в нижней половине; проверка, что весь диапазон отображён
и доступен; на x86-64 — включённый SMAP (CR4.SMAP), который вообще запрещает
ядру трогать пользовательские страницы вне явных stac/clac окон. Так
устроены copy_from_user/copy_to_user в Linux — см.
https://courses.digitable.life/post/operating-systems/07-syscalls-and-ipc/ и https://courses.digitable.life/post/operating-systems/09-security-and-isolation/.
Этап 9: драйверы, initrd, файловая система
Дальше проект расходится веером, и порядок — на ваш вкус:
- Клавиатура (PS/2, порт 0x60) — первый интерактивный ввод, IRQ1.
- Диск: ATA PIO — простейший вариант; virtio-blk — правильный для QEMU и очень поучительный (кольцевые дескрипторы, как в https://courses.digitable.life/post/operating-systems/06-io-and-drivers/).
- initrd в формате tar — гениальный хак учебных ядер:
tar— это 512-байтные заголовки с ASCII-полями, парсер пишется за час, а вы получаете файлы, не написав ни одной ФС. - VFS-слой — прежде чем писать ext2, сделайте абстракцию:
struct vnode, операцииread/write/lookup. Тогда tarfs, ramfs и ext2 подключаются одинаково. Подробности — https://courses.digitable.life/post/operating-systems/05-filesystems/. - ELF-загрузчик — читаете program headers, отображаете сегменты, ставите entry point. С этого момента ядро запускает настоящие программы.
- Порт libc:
newlibилиmlibc— они рассчитаны на то, что вы реализуете два десятка «системных заглушек». После этого на вашей ОС собирается bash.
Момент, когда на вашем ядре запускается чужая программа, которую вы не писали, —
второй по силе после первого tick.
Как отлаживать то, у чего нет отладчика
Инструментарий, без которого проект превращается в мучение:
# 1. GDB подключается к QEMU по протоколу gdbserver.
qemu-system-x86_64 -cdrom kernel.iso -s -S -serial stdio -display none
# -s = слушать :1234, -S = замереть до команды continue
# в другом терминале:
gdb kernel.elf
(gdb) target remote :1234
(gdb) hbreak kernel_main # аппаратная точка останова — работает до пагинации
(gdb) continue
(gdb) info registers cr3
(gdb) monitor info mem # QEMU покажет текущие отображения страниц
(gdb) monitor info tlb
# 2. Автотесты: QEMU умеет завершаться с нужным кодом.
qemu-system-x86_64 -cdrom kernel.iso \
-device isa-debug-exit,iobase=0xf4,iosize=0x04 \
-serial stdio -display none
# запись value в порт 0xf4 => процесс QEMU выходит с кодом (value << 1) | 1
# ядро пишет 0x10 при успехе => код 33; Makefile трактует 33 как «тесты прошли»
Это превращает ядро в обычный проект с CI: набор ядер-тестов, каждое проверяет одну подсистему и завершает QEMU нужным кодом. Так устроен тест-фреймворк в «Writing an OS in Rust», и это одна из лучших идей, которые оттуда стоит взять.
Отладочные привычки, стоящие своего веса золотом:
- Логируйте всё в serial с самого начала. Первое, что вы пишете после
serial_putc, — свойprintk. Не откладывайте. - Тройная ошибка = смотреть
-d int. Последнее исключение перед reset и егоCR2(адрес, вызвавший#PF) обычно называет виновника прямо. QEMU monitor(Ctrl-A C в-serial stdioрежиме, либо-monitor):info registers,info mem,info irq,xp /16x 0x1000— дампы физической памяти без всякого отладчика.- Assert-ы, которые печатают и виснут.
panic()с дампом регистров и адресом вызова — не роскошь, а базовая инфраструктура. - Bisect по коммитам. Git работает и здесь: «вчера загружалось, сегодня нет» решается за 5 итераций.
Развилка вторая: на чём писать
- C — путь по умолчанию. Вся литература, все туториалы, весь xv6, весь Linux. Freestanding-C работает без единой оговорки. Минус ровно один: язык не защищает вас ни от чего, а в ядре цена ошибки — тройная ошибка вместо segfault.
- Rust — сегодня полноценная альтернатива.
#![no_std], крейтx86_64,bootloader, отличная серия os.phil-opp.com. Заимствования и типы реально ловят классы ошибок ядра (use-after-free в аллокаторе, гонки на разделяемых структурах). Цена:unsafeвсё равно придётся писать много, и часть времени уйдёт на борьбу с компилятором, а не с процессором. Rust for Linux в апстриме — доказательство, что подход жизнеспособен. - Zig — приятный компромисс: freestanding из коробки (
-target x86_64-freestanding-none), встроенная кросс-компиляция,comptimeдля генерации таблиц дескрипторов, никакого скрытого рантайма. - Go / Java / C# требуют рантайма с GC и планировщиком — то есть половину того, что вы пишете. Существуют экзотические проекты (gVisor на Go — это не ядро на голом железе, а ядро в userspace), но как учебный путь это плохой выбор.
- Чистый ассемблер — только для boot sector, точек входа в прерывания
и
switch_context. Всё остальное на ассемблере — способ никогда не дойти до планировщика.
Типичные заблуждения
«Сначала напишу правильную архитектуру, потом код». Ядро — это область,
где преждевременное проектирование убивает проекты чаще, чем спагетти.
Сделайте tick, потом bump-аллокатор, потом round-robin. Переписать
подсистему на 300 строк — вечер; не дойти до неё — конец проекта.
«Мне нужен свой bootloader, иначе нечестно». Linux не содержит своего загрузчика — он полагается на GRUB/systemd-boot/UEFI stub. Использовать Limine так же честно, как использовать компилятор, который вы не писали.
«Ядро должно поддерживать SMP с самого начала». Многопроцессорность добавляет блокировки, per-CPU данные, IPI, TLB shootdown и целый класс гонок. Однопроцессорное ядро с работающим ring 3 полезнее, чем многопроцессорное, которое не грузится.
«Прерывания можно оставить включёнными в критической секции — там всего
пара строк». Нет. Обработчик таймера дёрнет планировщик, тот переключит
контекст, второй поток войдёт в ту же секцию. Классическая гонка. Учитесь
сразу писать cli/sti парами с сохранением предыдущего состояния RFLAGS —
иначе вложенные критические секции включат прерывания слишком рано.
«Работает в QEMU — значит корректно». QEMU снисходителен: он часто прощает неточные дескрипторы, невыровненные структуры, отсутствующие сбросы TLB. Классический опыт: ядро годами работает в эмуляторе и мгновенно падает на настоящем ноутбуке. Хотя бы иногда запускайте на реальном железе или под другим эмулятором (Bochs с его строгими проверками, VirtualBox).
«Ядро — это про производительность, поэтому сразу оптимизируем». Наоборот. Ядро — про корректность и инварианты. Оптимизировать распределение страниц, когда у вас ещё нет ring 3, — трата жизни.
Что из этого пригодится в обычной работе
Это главный вопрос трека, и ответ у него неожиданно конкретный.
- Вы перестаёте гадать про производительность. Написав
switch_context, вы точно знаете, что переключение потока — это десятки инструкций плюс сотни-тысячи циклов на промахи кэша и TLB. Отсюда сразу понятно, почему пул из 8 потоков быстрее пула из 800 и почему async-модель выигрывает на I/O-bound нагрузке — см. https://courses.digitable.life/post/operating-systems/03-processes-and-scheduling/. - Вы читаете чужой системный код. Исходники Go runtime, BEAM, JVM, nginx, PostgreSQL полны того же самого: свои аллокаторы, свои планировщики, свои очереди на кольцевых буферах. Написав это однажды, вы читаете такое как обычный код, а не как магию.
- Диагностика продакшена становится дедуктивной. OOM-killer, page cache,
%sysна 60%,iowait, stalls в cgroup — за каждым понятием стоит конкретный механизм, который вы теперь представляете структурно (https://courses.digitable.life/post/operating-systems/14-observability-and-performance/). - Отладка вслепую перестаёт пугать. Когда единственный инструмент — вывод в порт и лог прерываний, вы вырабатываете метод: сузить, воспроизвести, проверить инвариант. Этот метод работает и на распределённой системе из сорока сервисов.
- Растёт уважение к границам. Понимая, что
copy_from_user— это реальная граница доверия, вы иначе смотрите на валидацию входных данных в своём API.
Небольшое честное предупреждение: написание ОС не сделает вас лучше в бизнес-логике. Это глубина, а не ширина. Она окупается в системном программировании, в производительности, в отладке и в понимании — но не заменяет ни архитектуру приложений, ни знание домена.
Как это устроено в настоящих ОС
Ваши упражнения по этапам повторяют то, что реальные системы делают в проде — только с поправкой на масштаб и на разные решения на тех же развилках:
| Этап | Linux | FreeBSD | OpenBSD | macOS / XNU | Windows NT |
|---|---|---|---|---|---|
| Точка входа | arch/x86/boot/, EFI stub, start_kernel() |
sys/amd64/amd64/locore.S, loader(8) |
biosboot/boot |
boot.efi → Mach kernel_bootstrap |
winload.efi → KiSystemStartup |
| Физическая память | buddy + slab/SLUB | vm_page, UMA |
uvm, pool |
vm_page, zone allocator |
PFN database, pool |
| Виртуальная память | mm/, struct mm_struct |
vm_map/vm_object (из Mach) |
UVM (Cranor) | Mach VM | MMSUPPORT, VAD-дерево |
| Планировщик | EEVDF (с 6.6), раньше CFS | ULE | родная многоуровневая очередь | Mach threads + WorkQueue/QoS | приоритеты 0–31, quantum boost |
| Границы | namespaces, cgroups, seccomp, LSM | jails, Capsicum, MAC | pledge, unveil, W^X | sandbox, entitlements | Job objects, AppContainer, token |
| Модель драйверов | in-tree модули, GPL-барьер | KLD, newbus | in-tree, минимализм | IOKit на C++ | WDM/KMDF, подписанные драйверы |
Полезно после своего ядра прочитать хотя бы один этап в чужом. Самый доступный для чтения — FreeBSD: код структурен и обильно комментирован; см. https://courses.digitable.life/post/operating-systems/15-bsd-family/ и https://courses.digitable.life/post/operating-systems/16-other-operating-systems/.
Реалистичный маршрут на 12 недель
Оценки — для примерно 8–10 часов в неделю у человека, который уверенно пишет на C, но раньше не трогал bare metal.
| Недели | Веха | Признак успеха |
|---|---|---|
| 1 | Сборка, Limine, _start, serial |
В терминале печатается ваша строка |
| 2 | GDT, IDT, обработка исключений | Намеренный #PF даёт красивый дамп, а не reset |
| 3 | PIC/PIT, тики таймера | tick идёт, а ядро продолжает работать |
| 4 | Физический аллокатор по карте памяти | Выделяете и освобождаете тысячи страниц, счётчики сходятся |
| 5–6 | Свои таблицы страниц, переход на свой CR3, kmalloc |
Ядро выжило после mov cr3, куча не течёт |
| 7 | Потоки и switch_context |
Два потока по очереди печатают в консоль |
| 8 | Вытесняющий round-robin | Поток с while(1) не вешает систему |
| 9 | Примитивы синхронизации: спинлок, мьютекс, семафор | Producer-consumer не теряет элементы |
| 10 | Ring 3, TSS, syscall/sysret |
Программа из ring 3 печатает через ваш write |
| 11 | initrd (tar), VFS, ELF-загрузчик | Ядро запускает отдельно собранный бинарник |
| 12 | Своя оболочка на 100 строк | Вы вводите команду, ваша ОС её исполняет |
Дальше — по вкусу: SMP, virtio-драйверы, ext2, сеть (стек из
https://courses.digitable.life/post/operating-systems/08-networking-stack/ в миниатюре), графика, порт newlib
и сборка чужого софта.
Что читать: карта источников
что читать)) Учебные ядра xv6-riscv + xv6 book MIT 6.1810, лекции и лабы открыты MINIX 3 книга Танненбаума с полным кодом SerenityOS живой большой проект, видео-разработка ToaruOS один автор, полная ОС с GUI, читаемый код Пошаговые руководства OSDev Wiki справочник номер один, а не туториал Writing an OS in Rust os.phil-opp.com, отличная методика тестов The Little Book About OS Development littleosbook.github.io, x86 32 бита Blundell, OS from Scratch про загрузчик подробнее всех Теория ОС OSTEP бесплатная, лучшая первая книга Tanenbaum, Modern Operating Systems Silberschatz, Operating System Concepts McKusick, Design and Implementation of FreeBSD Спецификации Intel SDM том 3 глава про пагинацию и прерывания AMD64 APM том 2 часто понятнее интеловского RISC-V Privileged Spec UEFI и ACPI Specifications Multiboot2 и Limine Protocol Продвинутое seL4 формально верифицированное микроядро Unikernels, MirageOS Rust for Linux Writing a hypervisor Intel VT-x, KVM API
Конкретные ссылки, все настоящие и живые:
Основа.
- OSDev Wiki — справочник по каждому регистру и структуре. Читать точечно, не подряд; на форуме отвечают по делу.
- xv6-riscv book (MIT) и исходники — эталонное учебное ядро.
- OSTEP — Operating Systems: Three Easy Pieces, Arpaci-Dusseau. Бесплатная, написана человеческим языком, лучшая теория для старта.
- Writing an OS in Rust, Philipp Oppermann — лучший современный пошаговый курс, даже если писать вы будете на C.
- The Little Book About OS Development — короткая, x86 32-битный, зато проходится за неделю.
Спецификации, которые придётся открывать.
- Intel SDM, том 3A/3B: пагинация, прерывания, режимы, MSR.
- AMD64 Architecture Programmer’s Manual, том 2 — про long mode часто понятнее, чем у Intel.
- RISC-V Privileged Specification и OpenSBI.
- UEFI Specification и ACPI — там же.
- Limine Boot Protocol и Multiboot2.
Книги для чтения после.
- A. Tanenbaum, «Modern Operating Systems» и «Operating Systems: Design and Implementation» (MINIX) — классика, обе с кодом.
- M. McKusick et al., «The Design and Implementation of the FreeBSD Operating System», 2nd ed. — лучшее описание живой Unix-системы целиком.
- R. Love, «Linux Kernel Development» — для входа в Linux; дальше The Linux Kernel documentation.
- M. Russinovich et al., «Windows Internals» — если интересен NT.
- Klein et al., «seL4: Formal Verification of an OS Kernel», SOSP 2009 — что значит «доказано корректное ядро» (sel4.systems).
- A. Madhavapeddy et al., «Unikernels: Library Operating Systems for the Cloud», ASPLOS 2013 — другой конец спектра дизайна.
Живые проекты, у которых полезно читать код.
- SerenityOS — Unix-подобная ОС с GUI, написанная с нуля; автор годами вёл видеодневник разработки.
- Redox OS — микроядро на Rust.
- ToaruOS — полная ОС одного автора, очень аккуратный, читаемый C.
- Zephyr и FreeRTOS — если интересны RTOS и embedded, а не «полноценная ОС».
Мини-итог
Написание ОС раскладывается на конечный список независимых задач, каждая из которых обозрима: получить управление, научиться печатать, поймать прерывание, раздать страницу, построить таблицу, переключить стек, спуститься в ring 3, подняться обратно. Ни одна из них не является магией; сложность в том, что между ними нельзя схалтурить — ошибка в любой проявится в другой.
И именно поэтому упражнение работает. Оно устраняет последний слой «здесь
происходит что-то, что я принимаю на веру». После него strace, perf,
/proc/self/maps, cgroups и контейнеры перестают быть инструментами и
становятся окнами в механизм, который вы понимаете структурно.
Что дальше
На этом трек по операционным системам закончен. Мы прошли путь от истории Unix и архитектур ядер через процессы, память, файловые системы, ввод-вывод, сеть, безопасность, загрузку, инициализацию, наблюдаемость, семейство BSD, графический стек и виртуализацию — до написания собственного ядра. Если хочется освежить общую картину и связи между статьями, вернитесь к карте трека.
Куда идти дальше на портале:
- Алгоритмы и Структуры данных — ровно то, из чего собраны ядра: очереди планировщика, красно-чёрные деревья в VMA, хеш-таблицы dcache, битовые множества аллокаторов. После этого трека вы читаете эти темы совсем другими глазами.
- Парадигмы программирования и Принципы разработки — как удерживать сложность в системах, где нельзя «просто переписать».
- Go — язык, чей рантайм
(планировщик горутин,
netpollerповерх epoll/kqueue, свой аллокатор) устроен ровно как маленькая ОС в пользовательском пространстве. - Elixir — BEAM с вытесняющим планировщиком, изоляцией процессов и супервизорами: другая, но родственная реализация тех же идей.
- Математика — если заинтересовала формальная верификация ядер вроде seL4, начинать надо оттуда.
- TypeScript и C# — если хочется вернуться к прикладной разработке, зная, что под ней.
И общая карта портала со всеми маршрутами обучения: Roadmap.