---
tags: [kernel, tooling]
verified: 2026-09-06
---

# Ядро как программа: freestanding, компоновка, ABI, выбор языка

## Что здесь на самом деле решается

Ядро — обычная программа в том смысле, что его собирает обычный компилятор обычной командой. Всё остальное в нём необычно: под ним нет ни библиотеки языка, ни кучи, ни динамического загрузчика, который расставил бы всё по местам, ни системы, которая подберёт за ним, когда он упадёт. Список того, чего нет, читается как набор мелких неудобств, и в туториалах его так и подают — «добавьте эти флаги». На самом деле у каждого отсутствия своя причина, причины связаны между собой, и знание причины — единственный способ понять, что произойдёт, когда флаг забудут.

Статья разбирает три вещи. Первая — что означает freestanding-окружение и почему компилятор всё равно продолжает рассчитывать на код, которого у вас нет. Вторая, и главная, — чем компоновка ядра отличается от компоновки программы: скрипт компоновщика как часть исходников, различие адреса загрузки и адреса исполнения, и модели кода, где ограничение на кодирование непосредственных операндов напрямую задаёт, в какой части адресного пространства ядру разрешено жить. Третья — ABI, которых здесь не одно, а по меньшей мере три: соглашение о вызовах внутри ядра, отдельное соглашение системного вызова и отдельный способ не входить в ядро вовсе.

Ближе к концу — вопрос о языке. Он ставится не как «что лучше», а как «что конкретно даёт и чего стоит безопасный язык там, где небезопасные операции неустранимы по существу задачи»: ядро обязано писать в таблицы страниц, отображённые регистры устройств и буферы DMA, и ни одна система типов этого не проверит. Разбирать это до компоновки бессмысленно — половина цены языка состоит именно в том, как он ведёт себя в отсутствие рантайма.

Это восьмая статья цикла про устройство операционных систем; состав и порядок — в [плане](./os-knowledge-base-plan.md). Она не про сборку ядра Linux со стороны администратора: конфигурация, модули и параметры загрузки разобраны в [отдельной заметке](./linux-kernel.md), и пересечение здесь только в именах файлов. Все примеры ниже выверялись на дереве mainline 7.3-rc1 и на исходниках FreeBSD `main`.

## Freestanding: чего нет и почему

### Что обещает стандарт и почему этого мало

Формально всё просто. Стандарт C определяет две среды: hosted, где доступна вся стандартная библиотека и вход в программу — `main`, и freestanding, где «библиотечных средств может не быть сверх тех, что требуются от freestanding-реализаций, а обработка запуска и завершения программы определяется реализацией». Флаг `-ffreestanding` переводит компилятор во второй режим: `__STDC_HOSTED__` становится нулём, а компилятор, как формулирует документация GCC, «не делает предположений о значении имён функций из стандартной библиотеки». Тот же флаг подразумевает `-fno-builtin`.

Перечень того, что freestanding-реализация обязана предоставить, документация GCC приводит накопительно, по редакциям стандарта. В редакции C11 он невелик и — что важнее — состоит из одних деклараций: `<float.h>`, `<limits.h>`, `<stdarg.h>`, `<stddef.h>`, `<iso646.h>`, `<stdbool.h>`, `<stdint.h>`, `<stdalign.h>`, `<stdnoreturn.h>`. Ни одного байта кода. Практически это означает, что стандарт гарантирует вам `size_t`, `NULL`, `uint64_t` и работу с переменным числом аргументов — и ничего более. Всё, что делает что-нибудь, вы пишете сами. Редакцию здесь стоит называть вслух, потому что она перестала быть умолчанием: начиная с GCC 15 компилятор без явного `-std` собирает как `-std=gnu23`, а про C23 та же страница документации говорит, что freestanding-реализация обязана поддерживать «более широкий круг библиотечных средств, включая некоторые функции из других заголовков». То есть «ни одного байта кода» — свойство списка C11, а не freestanding-режима как такового.

Полезнее формального определения — то, как флагом распоряжаются реальные ядра, потому что здесь они расходятся. FreeBSD включает `-ffreestanding` для всего ядра, и комментарий в `sys/conf/kern.mk` называет причину прямо: «GCC 3.0 и выше любит делать некоторые оптимизации, исходя из предположения, что программа скомпонована с libc. Прекратить это». Linux `-ffreestanding` для основного ядра не передаёт вовсе — он раздаёт его точечно: распаковщику и коду реального режима на x86 (`REALMODE_CFLAGS` в `arch/x86/Makefile`), файлам с интринсиками NEON на arm64. Вместо этого Linux убирает системные заголовки (`-nostdinc`) и гасит отдельные встроенные функции по мере того, как они начинают мешать: в верхнем `Makefile` живёт `-fno-builtin-wcslen` с комментарием «убедиться, что компиляторы не превращают некоторые циклы в вызовы `wcslen()`».

Разница осмысленная, и обе позиции защитимы. `-ffreestanding` отключает знание компилятора о библиотечных функциях целиком, а вместе с ним и полезную его часть: способность встроить `memcpy` вместо вызова и знание, что `memcpy` не портит произвольную память. Linux эту способность бережёт и платит точечными флагами по мере обнаружения проблем — по мере обнаружения буквально: `-fno-builtin-wcslen` дописан после того, как компилятор начал сворачивать в вызов циклы, написанные руками. Для своего ядра правильный старт — всё-таки `-ffreestanding`, но полезно точно знать, какой класс проблем он снимает, а какой оставляет. Снимает он преобразования по семантике libc: компилятор перестаёт узнавать в вашем коде библиотечную функцию и подставлять вызов на её место — тот самый случай с `wcslen`, — а заодно перестаёт делать предположения о том, чего такая функция не делает, вроде названного в документации GCC «`strlen` не изменяет глобальную память». Оставляет он две вещи, и обе известны заранее: четыре функции работы с памятью, которые GCC зовёт по праву и с `-ffreestanding` тоже — про них следующий раздел, — и подпрограммы libgcc, до которых флаг не дотягивается вовсе. Тем остаток и хорош, что он перечислим и ловится компоновкой: при `-nostdlib -static` неразрешённый символ — это ошибка сборки, а не отказ на живой машине. До машины дело доходит, только если символ чем-то разрешился, — например, вашей же реализацией `memset`, свёрнутой оптимизацией в вызов самой себя.

### Функции, которые компилятор зовёт сам

Самое неприятное свойство freestanding-окружения в том, что оно не полностью freestanding. Документация GCC говорит об этом одной строкой: «GCC требует, чтобы freestanding-окружение предоставляло `memcpy`, `memmove`, `memset` и `memcmp`». То есть даже с `-ffreestanding` компилятор оставляет за собой право сгенерировать вызов этих четырёх функций из кода, где вы их не писали, — например, при присваивании структуры или при инициализации массива.

Дальше начинается ловушка, на которой спотыкаются все. Оптимизация `-ftree-loop-distribute-patterns`, включённая на `-O2` и выше, распознаёт цикл, заполняющий память константой, и заменяет его вызовом `memset`. Примените это к телу вашей собственной реализации `memset` — получите функцию, вызывающую саму себя. Лечится либо `-fno-tree-loop-distribute-patterns` на файл с реализациями строковых операций, либо сборкой этого файла отдельными флагами.

Строковыми операциями дело не исчерпывается, и вот здесь паритет архитектур особенно нагляден, потому что у каждой свой набор скрытых вызовов в libgcc. На ARM64 по умолчанию включён `-moutline-atomics`: атомарные операции превращаются в вызовы вспомогательных функций, которые в рантайме определяют, доступны ли инструкции LSE из ARMv8.1-A, и если нет — используют пару load/store-exclusive. Вспомогательные функции живут в libgcc, и FreeBSD отключает механизм явно (`-mno-outline-atomics`). На RISC-V есть `-msave-restore`, где прологи и эпилоги функций заменяются вызовами библиотечных подпрограмм ради размера кода; Linux передаёт `-mno-save-restore`, хотя умолчание и так — встроенные прологи. На 32-битных целях 64-битное деление превращается в вызов `__udivdi3`. Защита стека зовёт `__stack_chk_fail`.

Общее правило отсюда важнее любого из частных случаев: **вывод компилятора — это не замкнутый набор инструкций, а инструкции плюс неявная зависимость от библиотеки поддержки языка.** У обычной программы эта зависимость удовлетворяется автоматически и потому невидима. Ядро обязано либо предоставить недостающее, либо выключить порождающие его возможности — и выключается это разными флагами на разных архитектурах, потому что порождают их разные механизмы.

### Кучи нет, пока вы её не написали

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

Для сборки существенно то, как отсутствие кучи выглядит с точки зрения языка, и здесь три языка ведут себя по-разному настолько, что это заметно влияет на выбор. Для C вопроса нет: `malloc` — просто функция, вы её пишете, компилятор ничего о ней не знает. У Rust библиотека `alloc` устроена вокруг предположения, что выделение либо удаётся, либо программа завершается, — и ядро Linux от неё отказалось, построив собственные `KBox` и `KVec`, где каждая операция, способная выделить память, принимает флаги GFP и возвращает `Result`. У Zig аллокатор с самого начала является явным параметром: «любая функция, которой нужно выделить память, принимает параметр-аллокатор. Как следствие, стандартную библиотеку Zig можно использовать даже для цели freestanding». Это не деталь удобства — это ответ на вопрос, сколько чужого кода вы сможете взять, не переписывая.

### Плавающей точки нет, и это следствие входа в ядро

Запрет на плавающую точку в ядре выглядит произвольным, пока не назвать причину, и причина не в том, что вычисления с плавающей точкой ядру не нужны. Документация Linux формулирует её в двух предложениях: «Коду ядра обычно запрещено использовать регистры или инструкции плавающей точки, включая типы C `float` и `double`. Это правило снижает накладные расходы системного вызова, потому что ядру не нужно сохранять и восстанавливать состояние регистров плавающей точки пользовательского пространства». То есть запрет — обратная сторона решения не сохранять контекст FPU при входе в ядро; механизм самого входа разбирается отдельной статьёй цикла, здесь достаточно следствия: регистры FPU при входе продолжают принадлежать прерванному потоку, и любое обращение к ним из ядра эти данные уничтожит.

Компилятору об этом надо сказать явно, и снова не потому, что вы собираетесь считать в `double`. Та же документация называет настоящую причину: компиляторы иногда используют регистры плавающей точки для оптимизации встроенного `memcpy` или присваивания переменной, поскольку эти регистры шире целочисленных. Один флаг лечит это, но выражается он на трёх архитектурах по-разному, и разница поучительна:

```bash
# общая часть: ни библиотек, ни стартовых файлов, ни системных заголовков
CFLAGS='-ffreestanding -nostdinc -fno-stack-protector -fno-asynchronous-unwind-tables'
LDFLAGS='-nostdlib -static -T kernel.ld'

# x86-64: запрет перечисляется по расширениям, и забыть одно легко
CFLAGS_X86='-mno-sse -mno-mmx -mno-sse2 -mno-3dnow -mno-avx -mno-sse4a -mno-80387 -mno-fp-ret-in-387 -mno-red-zone -mcmodel=kernel'
# ARM64: тот же запрет одним флагом, модель кода менять не нужно
CFLAGS_ARM64='-mgeneral-regs-only'
# RISC-V: запрет выражен не флагом, а выбором ABI — lp64 против lp64d
CFLAGS_RISCV='-march=rv64imac -mabi=lp64 -mcmodel=medany'
```

RISC-V здесь интереснее остальных: у него отказ от плавающей точки — это не запрет на инструкции, а выбор целочисленного ABI (`lp64` вместо `lp64d`), то есть соглашения, по которому аргументы не передаются в FP-регистрах. FreeBSD пользуется разделением буквально: оставляет расширения F и D в `-march`, чтобы код переключения контекста мог сохранять регистры плавающей точки, и при этом собирает ядро с `-mabi=lp64`, чтобы компилятор не трогал их сам.

Когда плавающая точка всё-таки нужна — обычно в криптографии или контрольных суммах, ускоренных векторными инструкциями, — её включают явными окнами: `kernel_fpu_available()`, затем `kernel_fpu_begin()` и `kernel_fpu_end()` вокруг критической секции; у FreeBSD это `fpu_kern_enter()`. Существенны ограничения окна, а не его наличие. Код с плавающей точкой обязан лежать в отдельной единице трансляции, собираемой своими флагами, — «чтобы не дать компилятору сгенерировать код, трогающий FP-регистры вне этих критических секций»: попросить «регион без FPU» внутри файла нечем. Сама секция гарантированно вызываема только из процессного контекста, внутри неё может быть запрещено вытеснение, и реентерантной она не обязана быть — вложенность вы считаете сами.

### Раскрутки стека нет

Ядра просят компилятор таблиц не делать, и комментарий в `arch/arm64/Makefile` предельно краток: «избежать генерации секций `.eh_frame`». Строгость при этом у архитектур разная: arm64 передаёт пару `-fno-asynchronous-unwind-tables -fno-unwind-tables`, x86 обходится первым флагом из двух. Таблицы раскрутки описывают, как восстановить состояние вызывающего при проходе по стеку вверх; порождаются они прежде всего ради исключений, которых в ядре нет, и потому оказываются мёртвым весом в образе. Обратные трассировки ядру при этом нужны, но берутся не отсюда: указателем кадра либо специально придуманным форматом метаданных, компактнее DWARF. Ассемблерные точки входа в любом случае описываются вручную — автоматически там описывать нечего, кадра в привычном смысле нет. Показательно, что решение не абсолютно: arm64 умеет собираться и с таблицами (`CONFIG_UNWIND_TABLES`), и тогда флаги переворачиваются на `-fasynchronous-unwind-tables`.

Из отсутствия раскрутки следует отсутствие исключений и всего, что на них построено. Для C это ничего не меняет. Для Rust это означает `-Cpanic=abort`: паника не разматывает стек, а останавливает, и обработчик паники вы пишете сами (`#[panic_handler]`). Для C++ это означало бы `-fno-exceptions -fno-rtti`, что вычёркивает из языка изрядную часть его самого.

### Локальной памяти потока нет — есть данные на процессор

`__thread` в ядре не используется, и дело не только в том, что под ним нет рантайма, который завёл бы область TLS: он означал бы не то. Поток ядра мигрирует между процессорами, а данные, которые обычно хотят получить по быстрому пути, — счётчики, текущая задача, локальный список — принадлежат процессору, а не потоку, и обязаны следовать за процессором. Механизм, которым это делается, разобран в [первой статье цикла](./os-address-space-models.md): база GS на x86-64 и `swapgs` при входе, `TPIDR_EL1` на ARM64, регистр `tp` на RISC-V.

Здесь важна другая сторона того же механизма — как об этом узнаёт компилятор, ведь распределитель регистров по умолчанию считает своими все регистры общего назначения. Способов три, и все три встречаются в живых деревьях. Linux на RISC-V объявляет глобальную регистровую переменную — `register struct task_struct *riscv_current_is_tp __asm__("tp")`, — после чего `current` компилируется в чтение `tp` без единой инструкции. FreeBSD на ARM64 резервирует платформенный регистр целиком: `-ffixed-x18` с комментарием «зарезервировать x18 под данные pcpu»; Linux на той же архитектуре тратит `-ffixed-x18` на теневой стек вызовов, и то условно — только при включённом `CONFIG_SHADOW_CALL_STACK`, — а данные процессора держит в `TPIDR_EL1`. Третий способ — попросить компилятор о конкретной услуге: у GCC есть `-mstack-protector-guard=sysreg` с указанием регистра и смещения, и документация объясняет происхождение опции честнее некуда — «нет умолчания для регистра и смещения, поскольку это целиком для использования внутри ядра Linux». Канарейка защиты стека обычно лежит в TLS; в ядре TLS нет, и компилятору пришлось научиться брать её из данных процессора.

## Компоновка

### Скрипт компоновщика как часть исходников

Обычная программа собирается без скрипта компоновки, потому что он есть — встроенный, и он делает разумные вещи: складывает секции в привычном порядке, назначает адреса, оставляет место под заголовки. Ядру этого мало по четырём независимым причинам, и полезно держать их раздельно, потому что каждая может укусить отдельно.

Во-первых, порядок. Точка входа обязана оказаться там, куда передаст управление загрузчик, а это не всегда адрес из заголовка ELF: протокол загрузки может прыгать в начало плоского образа, ничего не читая. `ENTRY(_start)` записывает точку входа в заголовок, но не гарантирует её расположения — это делает порядок секций в скрипте.

Во-вторых, границы. Ядру нужны символы, которых нет в исходниках: где кончается текст, где начинается и кончается `.bss`, где начинается и кончается раздел инициализации. Обнулить `.bss` некому, кроме самого ядра, а чтобы обнулить, нужны его границы.

В-третьих, выравнивание. Права назначаются страницами, значит границы между областями с разными правами обязаны быть выровнены на страницу; об этом ниже отдельно.

В-четвёртых — то, о чём вспоминают последним. Новая секция, появившаяся из-за флага компилятора или чужого объектного файла, будет размещена компоновщиком по эвристике, и обнаружится это на живой машине. Linux эту дыру закрывает явно: `--orphan-handling` с комментарием «мы никогда не хотим, чтобы ожидаемые секции размещались компоновщиком эвристически. Все секции должны быть явно названы в скрипте компоновки». Для своего ядра это стоит включить с первого дня — цена нулевая, а найденная ошибка всегда неочевидная.

### Адрес загрузки против адреса исполнения

Различие, снимающее половину вопросов о ранней загрузке, документация GNU ld формулирует в двух определениях. VMA — «адрес, который секция будет иметь при исполнении файла». LMA — «адрес, по которому секция будет загружена». У обычной программы они совпадают, и потому о существовании двух адресов легко не узнать за всю карьеру. У ядра они различаются всегда, и по причине более фундаментальной, чем классический пример ld про данные в ПЗУ, копируемые в ОЗУ при старте: **ядро собрано под виртуальные адреса, которых в момент загрузки образа ещё не существует.** Их создаст сам образ, когда построит таблицы страниц и включит трансляцию, — а до тех пор он лежит по физическому адресу, выбранному загрузчиком или прошивкой.

![Один и тот же образ ядра в двух системах координат. Слева — как он лежит в файле и по адресу загрузки: секции идут подряд, bss места не занимает вовсе, раздел инициализации занимает столько же, сколько остальные. Справа — как он выглядит при исполнении: те же секции сдвинуты на постоянную величину в верхнюю часть адресного пространства, bss появилась и обнулена самим ядром, раздел инициализации через некоторое время после старта исчезает и его память возвращается аллокатору. Между двумя видами показана постоянная разница адресов и точка, в которой ранний код перестаёт пользоваться адресами загрузки и переходит на адреса исполнения](./assets/os-kernel-as-program-lma-vma.svg)

В скрипте это выражается одной конструкцией: секции назначается VMA обычным способом, а LMA задаётся через `AT()`. Реальный скрипт ядра Linux на x86 делает ровно это — `.text : AT(ADDR(.text) - LOAD_OFFSET)`, где `LOAD_OFFSET` есть база отображения ядра; то есть физический адрес получается вычитанием константы из виртуального. Минимальный самостоятельный вариант выглядит так:

```text
KERNEL_VMA = 0xffffffff80000000;   /* верхние 2 ГиБ: этого требует -mcmodel=kernel */
KERNEL_LMA = 0x200000;             /* куда образ кладёт загрузчик */

ENTRY(_start)

SECTIONS
{
    . = KERNEL_VMA + KERNEL_LMA;

    .text : AT(ADDR(.text) - KERNEL_VMA) {
        *(.text.boot)              /* точка входа: загрузчик придёт по адресу загрузки */
        *(.text .text.*)
    }

    . = ALIGN(4096);               /* граница прав — всегда граница страницы */
    .rodata : AT(ADDR(.rodata) - KERNEL_VMA) { *(.rodata .rodata.*) }

    . = ALIGN(4096);
    .data   : AT(ADDR(.data) - KERNEL_VMA)   { *(.data .data.*) }

    . = ALIGN(4096);
    __bss_start = .;
    .bss    : AT(ADDR(.bss) - KERNEL_VMA)    { *(.bss .bss.*) *(COMMON) }
    __bss_end = .;
}
```

Три следствия этого различия стоит проговорить, потому что они и есть источник ранних падений. Первое: код, исполняющийся до включения трансляции, обязан обращаться к данным по адресам загрузки, а не по адресам, которые ему подставит компилятор из символов, — отсюда весь ранний ассемблер и вычитания константы вручную. Второе: `.bss` не имеет содержательного LMA, потому что в образе её нет — она объявлена, но не загружена, и обнулять её будет само ядро по границам из скрипта. Третье: символы границ существуют в двух видах, виртуальном и физическом, и путаница между ними — классическая ошибка, потому что оба вида являются числами одного типа и компилятор их не различает. Сам момент перехода — где именно ранний код перестаёт пользоваться адресами загрузки и начинает пользоваться адресами исполнения — принадлежит не этой статье, а той, что в цикле разбирает загрузку: инструкция, включающая трансляцию, исполняется по адресу, который обязан быть осмысленным сразу в обеих картах, и три архитектуры решают это по-разному.

### Модели кода: система команд задаёт раскладку

Это тот редкий случай, где ограничение на кодирование инструкций напрямую диктует, в какой части адресного пространства живёт ядро. Механизм на всех трёх архитектурах один: адрес символа надо как-то поместить в инструкцию, а места под полный 64-битный адрес там нет. Различаются архитектуры не этим, а тем, к чему привязано окно достижимости.

Спецификация SysV AMD64 говорит прямо: «архитектура x86-64 обычно не позволяет инструкции закодировать произвольную 64-битную константу как непосредственный операнд. Большинство инструкций принимают 32-битные непосредственные, расширяемые знаком до 64-битных», и то же ограничение действует для переходов и для адресации относительно счётчика команд. Отсюда 2 ГиБ достижимости — и отсюда же две модели: малая привязывает окно к нулю, а «ядерная» — к верхнему краю пространства, «поскольку ядро операционной системы обычно довольно мало, но исполняется в отрицательной половине адресного пространства». Именно это, а не соображения безопасности или традиция, и есть причина, по которой ядро x86-64 живёт в верхних двух гигабайтах.

![Одна и та же линия 64-битного адресного пространства в трёх случаях. В первом окно достижимости прибито к нулю: образ обязан лежать в его нижнем конце. Во втором окно того же размера прибито к верхнему краю пространства, и образ обязан лежать там. В третьем окно привязано не к концу линии, а к самой инструкции и потому едет вместе с кодом: образ может лежать где угодно, лишь бы целиком помещался в собственное окно. Показано также, что цель вызова за пределом досягаемости перехода требует переходника внутри окна](./assets/os-kernel-as-program-code-models.svg)

| Архитектура и модель | К чему привязано окно | Досягаемость | Как выражено в инструкциях |
| --- | --- | --- | --- |
| x86-64, `small` | к нулю | от `0` до `2³¹−2²⁴−1` | 32-битные знаковые непосредственные, RIP-относительная адресация |
| x86-64, `kernel` | к верхнему краю | от `2⁶⁴−2³¹` до `2⁶⁴−2²⁴` | те же кодировки, окно перенесено вверх |
| ARM64, `small` (умолчание) | к счётчику команд | окно 4 ГиБ где угодно | `adrp` + `add` |
| RISC-V, `medlow` | к нулю | от −2 ГиБ до +2 ГиБ | `lui` + смещение |
| RISC-V, `medany` | к счётчику команд | окно 2 ГиБ где угодно | `auipc` + смещение |

Читать таблицу стоит по второму столбцу. Там, где окно привязано к счётчику команд, отдельной «ядерной» модели не нужно вовсе — код сам таскает своё окно за собой, и вопрос «куда положить ядро» перестаёт быть вопросом компилятора. Поэтому ARM64 собирается моделью по умолчанию, а 64-битный RISC-V — моделью `medany`; `medlow` остаётся 32-битным конфигурациям, где всё пространство и так помещается в окно. Тут же поджидает ловушка именования, которую FreeBSD документирует в своём `kern.mk`: одну и ту же модель clang называет `medium`, а GCC — `medany`, и сборка ядра одним компилятором с флагом от другого просто не начнётся.

Практическое следствие модели кода живёт не в самом ядре, а в том, что грузится к нему потом. Модуль, попавший за пределы окна, адресовать напрямую нельзя, и загрузчику модулей приходится синтезировать переходники: код arm64 в `arch/arm64/kernel/module.c` содержит и «вне досягаемости `ADR` — сгенерировать переходник», и генерацию элементов PLT для вызовов дальше досягаемости `bl`. То же соображение объясняет, почему модули на RISC-V собираются моделью `medany`, даже когда само ядро собрано `medlow`. Для своего ядра вывод простой: модель кода выбирается один раз, до первой строки скрипта компоновки, потому что она задаёт диапазон адресов, а из диапазона адресов вытекает всё остальное, включая то, где вы разместите динамически загружаемый код. Карта памяти ядра целиком — предмет [отдельной статьи цикла](./os-kernel-memory-map.md).

### Перемещаемое ядро против фиксированного адреса

Ядро можно собрать под конкретный виртуальный адрес и грузить только туда, а можно сделать перемещаемым — и это разные объёмы работы, а не настройка. Реализаций две, и обе живут в mainline.

Первая — собрать образ как позиционно-независимый и применить перемещения при старте. ARM64 и RISC-V идут этим путём: `CONFIG_RELOCATABLE` добавляет к сборке `-fPIE`, а к компоновке `-shared -Bsymbolic -z notext`, после чего ранний код проходит по таблице RELA и правит адреса. Цена названа в самом Kconfig: «поскольку AArch64 использует формат перемещений RELA, это требует прохода перемещений во время исполнения, даже если ядро загружено по тому же адресу, по которому скомпоновано».

Вторая — оставить фиксированный адрес компоновки, но заставить компоновщик сохранить сведения о перемещениях и вынести их отдельной таблицей. Так делает x86: `--emit-relocs --discard-none` при сборке `vmlinux`, затем утилита `arch/x86/tools/relocs` вытаскивает перемещения в файл `vmlinux.relocs`, который кладётся рядом со сжатым образом, а распаковщик применяет их по фактическому адресу.

Платят за перемещаемость трижды. Проход перемещений выполняется на каждой загрузке независимо от того, сдвинулся ли образ. Таблица перемещений увеличивает образ — настолько заметно, что её сжимают, для чего в сборке появился `--pack-dyn-relocs=relr` (он же `-z pack-relative-relocs`), кодирующий подряд идущие относительные перемещения битовой картой. И ранний код усложняется: до прохода перемещений абсолютными адресами пользоваться нельзя, а значит участок кода, который этот проход выполняет, обязан обойтись без них сам.

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

### Права на секции и момент их включения

Как получается, что текст ядра доступен только для чтения, — вопрос из двух половин, и вторая интереснее первой.

Первая половина — механическая. Права назначаются страницами, поэтому каждая граница между областями с разными правами обязана быть выровнена на страницу: `. = ALIGN(PAGE_SIZE)` в скрипте компоновки, дальше символ границы, дальше проход по диапазону, выставляющий права в записях таблиц. Форма записи и то, как права набираются вдоль пути обхода, разобраны в [статье про страничную трансляцию](./os-paging-and-tlb.md). Выравнивание не бесплатно: оно оставляет неиспользованные хвосты, а на x86-64 Linux выравнивает конец `.rodata` не на страницу, а на большую страницу (`HPAGE_SIZE`), чтобы отображение ядра можно было собрать блоками по 2 МиБ, — прямой обмен памяти на охват TLB. Там же в сборку добавляется `-z max-page-size=0x200000` — комментарий в `arch/x86/Makefile` объясняет, что 64-битное ядро обязано быть выровнено на 2 МиБ, а флаг заставляет компоновщика считать так независимо от его собственного умолчания.

Вторая половина — когда именно права включаются, и ответ «не сразу» имеет причину. Раннее ядро правит собственный текст: подставляет варианты инструкций под обнаруженный процессор, включает и выключает статические ветвления, размечает точки трассировки. Пока это происходит, текст обязан быть доступен на запись. Последовательность в `init/main.c` показывает точный порядок: `free_initmem()` освобождает раздел инициализации, затем `mark_readonly()` дожидается завершения отложенных работ, переводит в постоянный режим статические ветвления (`jump_label_init_ro()`) и вызывает `mark_rodata_ro()`, затем `pti_finalize()` доводит до конца изоляцию таблиц страниц — и только после этого система объявляется работающей. То есть окно записи в собственный текст закрывается последним действием загрузки, а не первым.

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

### Разделы, выбрасываемые после загрузки

Код, нужный ровно один раз, занимает память всё время работы машины, если ничего не предпринять. Механизм борьбы — атрибут размещения: функция помечается как принадлежащая разделу инициализации, компоновщик собирает такие функции вместе между символами границ, а ядро после загрузки возвращает диапазон аллокатору. В Linux это `__init` и `.init.*`, границы `__init_begin`/`__init_end`, освобождение — `free_initmem_default(POISON_FREE_INITMEM)`, то есть с заполнением ядовитым значением, чтобы обращение к освобождённому обнаружилось шумно. Место этого диапазона в скрипте компоновки тоже не произвольно: в `arch/x86/kernel/vmlinux.lds.S` он лежит между `.data` и `.bss`, и причина в том, чем `.bss` отличается от всего остального, — у раздела инициализации содержимое в образе есть, у `.bss` его нет, поэтому всё, что несёт байты, обязано оказаться раньше секции, не занимающей в файле места.

Механизм — именно размещение, а не освобождение: вернуть память россыпью нельзя, диапазон обязан быть непрерывным, и непрерывным его делает компоновщик. Отсюда же его главная опасность. Вызов функции инициализации после загрузки — это обращение к освобождённой памяти, и обычная сборка его не поймает, потому что на уровне C это законный вызов законной функции. Ловится он проверкой соответствия секций — в Linux это делает `modpost`, ругаясь на ссылку из обычного кода в `.init.*` на этапе сборки. Если вы заводите такой раздел у себя, заводите вместе с ним и проверку, иначе получите механизм, который экономит сотню килобайт и раз в полгода роняет систему.

## ABI: три соглашения вместо одного

### Соглашение о вызовах

Внутри ядра действует обычный C-ABI целевой платформы — ничего специфического для ядра в нём нет, кроме того, что нарушать его теперь некому за вас. Сводка по трём архитектурам:

| | x86-64 (SysV) | ARM64 (AAPCS64) | RISC-V (psABI) |
| --- | --- | --- | --- |
| Целочисленные аргументы | `rdi`, `rsi`, `rdx`, `rcx`, `r8`, `r9` | `x0`–`x7` | `a0`–`a7` |
| Возврат | `rax`, второй в `rdx` | `x0`, второй в `x1` | `a0`, второй в `a1` |
| Сохраняет вызываемый | `rbx`, `rbp`, `r12`–`r15`, `rsp` | `x19`–`x28`, `x29`, `sp` | `s0`–`s11`, `sp` |
| Выравнивание стека | `rsp+8` кратно 16 в точке входа | `sp` кратно 16 | `sp` кратно 16 |
| Ниже указателя стека | 128 байт неприкосновенны | обращаться запрещено | полагаться запрещено |

Последняя строка — та, где различие важнее прочих для ядра, и она же объясняет один из самых загадочных флагов сборки.

### Красная зона

SysV AMD64 объявляет 128 байт ниже `rsp` зарезервированными: эта область «не должна изменяться обработчиками сигналов и прерываний», а потому листовая функция может разместить там весь свой кадр, не трогая указатель стека вовсе. Сноска в спецификации называет и мотив: 128 байт адресуются однобайтовым смещением.

Для пользовательского кода это чистый выигрыш. Для ядра — источник тихого разрушения памяти, и документация GCC формулирует почему, описывая атрибут `interrupt`: «любой код, прерываемый без переключения стека, должен собираться с `-mno-red-zone`, поскольку обработчики прерываний могут и будут, из-за устройства аппаратуры, затрагивать красную зону». Прерывание, пришедшее, когда процессор уже в ядре, стека не меняет и складывает своё состояние прямо туда, где прерванная функция держала свои данные. Отсюда `-mno-red-zone` в сборке ядра на x86-64 и у Linux, и у FreeBSD.

Наглядно здесь то, что проблема целиком x86-64-специфична, причём по решению ABI, а не архитектуры. AAPCS64 требует обратного: «ни один поток не имеет права обращаться (для чтения или для записи) к неактивной области» стека. RISC-V psABI столь же прям: «процедуры не должны полагаться на сохранность размещённых на стеке данных, адреса которых лежат ниже указателя стека». То есть на двух из трёх архитектур красной зоны нет и флага не требуется — и если вы переносите ядро с x86-64 и не найдёте в чужих сборочных файлах аналога `-mno-red-zone`, это не упущение. Стеки ядра, стеки прерываний и то, почему их вообще несколько, разобраны в [отдельной статье цикла](./os-stacks.md).

### ABI системного вызова

Соглашение системного вызова — отдельное от C-ABI, и это не историческая случайность, а следствие того, что инструкцию перехода в ядро проектировали в железе. Комментарий в точке входа Linux на x86-64 показывает механизм полностью: «64-битный SYSCALL сохраняет `rip` в `rcx`, сбрасывает `rflags.RF`, затем сохраняет `rflags` в `r11` <…> SYSCALL ничего не сохраняет на стеке и не меняет `rsp`». Инструкция забирает себе `rcx` и `r11`, а `rcx` в C-ABI — регистр четвёртого аргумента; поэтому четвёртый аргумент системного вызова едет в `r10`, и в том же комментарии рядом с ним стоит пометка «нужно переложить в `rcx`, чтобы соответствовать C ABI». Перекладывают обёртки в libc — и у glibc, и у FreeBSD в стабе стоит одна и та же инструкция `movq %rcx, %r10` перед `syscall`; у FreeBSD она безусловна, у glibc спрятана в макрос `DOARGS_4` и появляется у вызовов от четырёх аргументов, то есть ровно там, где четвёртый аргумент вообще возникает. Это и есть самый наглядный признак того, что перед вами два разных соглашения, а не одно: между вызовом функции и системным вызовом всегда стоит переходник, пусть даже в одну инструкцию.

Способ вернуть ошибку различается уже не архитектурой, а операционной системой, и это самое поучительное различие во всём разделе.

| | Linux | FreeBSD |
| --- | --- | --- |
| x86-64 | номер в `rax`, аргументы `rdi`, `rsi`, `rdx`, `r10`, `r8`, `r9`; ошибка — отрицательный код в `rax` | номер и аргументы те же; ошибка — флаг переноса, положительный код в `rax` |
| ARM64 | номер в `x8`, аргументы `x0`–`x5`; ошибка — отрицательный код в `x0` | номер в `x8`; ошибка — флаг `C` в `PSTATE`, проверяется `b.cs` |
| RISC-V | номер в `a7`, аргументы `a0`–`a5`; ошибка — отрицательный код в `a0` | номер в `t0`; ошибка — `t0` отличен от нуля после возврата, положительный код в `a0` |

Правая колонка последней строки — это то, ради чего таблицу стоит рассматривать. FreeBSD исторически сигнализирует об ошибке флагом переноса; у RISC-V регистра флагов нет вовсе, и соглашение пришлось перенести в обычный регистр: ядро выставляет `t0` в единицу при ошибке и в ноль при успехе, а обёртка libc проверяет его условным переходом. Механизм сохранён, носитель заменён.

```asm
# FreeBSD, RISC-V: номер в t0, он же признак ошибки при возврате
li      t0, SYS_read
ecall
bnez    t0, 1f
ret
1:      la t1, cerror                # положительный код ошибки лежит в a0
        jr t1

# Linux, RISC-V: номер в a7, ошибка закодирована в самом результате
li      a7, __NR_read
ecall
                                     # значение a0 в [-4095, -1] — это -errno
```

У обоих способов есть цена, и обе видны. Отрицательный код навсегда отнимает у диапазона возвращаемых значений верхние 4096: ядро закрепляет число константой `MAX_ERRNO`, равной 4095, а libc проверяет ровно эту границу — комментарий в glibc для x86-64 объясняет происхождение соглашения буквально: «Линус сказал, что позаботится о том, чтобы ни один системный вызов не возвращал значение из `-1 .. -4095` как правильный результат, так что мы можем спокойно сравнивать с `-4095`», после чего идёт `cmpq $-4095, %rax; jae SYSCALL_ERROR_LABEL`. Флаг переноса не отнимает ничего, но требует от архитектуры флага, которого может не оказаться. Для своего ядра вердикт прямой: отрицательный код ошибки, потому что он не требует от системы команд ничего и переживает перенос на следующую архитектуру без изобретений. Потеря четырёх килобайт из диапазона возвращаемых значений — цена, которую вы не заметите; поиск замены флагу на третьей по счёту архитектуре — заметите.

### Почему точки входа пишут на ассемблере

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

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

Частично компиляторы это умеют: у GCC на x86 есть атрибут `interrupt`, при котором «все регистры, кроме `EFLAGS`, восстанавливаемого инструкцией `IRET`, сохраняются компилятором», а возврат делается через `IRET`; требуется при этом `-mgeneral-regs-only`, поскольку состояния SSE, MMX и x87 компилятор не сохраняет. Но полного ответа это не даёт, и хорошо видно, чего именно не хватает: атрибут не сделает `swapgs`, не переключит стек, не переключит корень таблиц страниц при включённой изоляции и вовсе неприменим к пути системного вызова — там, как сказано выше, аппаратура не меняет указатель стека, так что первые инструкции обязаны работать на стеке пользователя или вовсе без него.

Отсюда устойчивая форма, одинаковая во всех ядрах: несколько десятков строк ассемблера, которые сохраняют регистры в структуру заранее известного вида, переключают то, что нужно переключить, и вызывают функцию на C, передавая ей указатель на сохранённый кадр. Граница между ассемблером и C проходит ровно там, где состояние стало описываться C-ABI.

### vDSO: способ не входить в ядро вовсе

Часть системных вызовов не нуждается в привилегиях — им нужны данные, которые ядро могло бы просто показать. Механизм, который из этого вырос, у Linux называется vDSO: «маленькая разделяемая библиотека, которую ядро автоматически отображает в адресное пространство всех программ». Адрес отображения программа узнаёт из вспомогательного вектора ELF по тегу `AT_SYSINFO_EHDR`; набор символов зависит от архитектуры и включает чтение времени на всех трёх (`__vdso_clock_gettime` и родственные, под своими именами на arm64).

С точки зрения этой статьи в vDSO интереснее всего то, как он смещает границу совместимости. Во-первых, вызывается он не как системный вызов, а как функция: «вы используете стандартные соглашения о вызовах C при вызове любой из этих функций. Не нужно беспокоиться о странном поведении регистров или стека». То есть ядро собирает и отдаёт кусок себя, который снаружи выглядит библиотекой, — со всеми вытекающими требованиями к сборке: позиционно-независимый код, никаких обращений к тому, чего нет у процесса, отдельный скрипт компоновки, отдельные флаги. Во-вторых, номер системного вызова — это соглашение навсегда, а символ в vDSO ищется по имени и версии, и потому реализацию можно менять. Взамен публичным становится другое: раскладка страницы данных, из которой vDSO читает.

FreeBSD решает ту же задачу иначе, и сравнение показывает, где именно проходит выбор. Отдельного ELF-объекта ядро не отдаёт — оно отображает разделяемую страницу, а функции вроде `__vdso_clock_gettime` живут в libc и читают из этой страницы структуру `vdso_timehands`, снабжённую номером версии и кодом алгоритма таймера. Linux публикует код и прячет данные, FreeBSD публикует данные и оставляет код в libc; и там и там появляется новый ABI, но он разного рода. Для своего ядра из этого следует практическое: как только вы отображаете процессу страницу со своими данными, её раскладка становится обязательством, и версионировать её надо с первого дня — задним числом это не приделывается.

Выбирать же между двумя формами стоит по тому, что обязано уметь меняться. Начинать дешевле с формы FreeBSD — страница данных с номером версии и функция в libc, которая её читает: страницу вы отображаете в любом случае, а отдельный ELF-объект внутри ядра стоит собственных флагов сборки, собственного скрипта компоновки, позиционно-независимого кода и версионирования символов. Форма Linux окупается тогда, когда меняться обязан читающий код, а не только данные: другой алгоритм часов, обход ошибки конкретного процессора, вариант на другом наборе инструкций — всё это ядро подменяет в отдаваемом объекте, ничего не пересобирая в пользовательском пространстве. Пока такой потребности нет, вы платите за неё вперёд.

## Выбор языка

### Что именно выбирается

Спор «C против безопасных языков» в применении к ядру сформулирован неудачно, потому что подразумевает выбор между «есть проверки» и «нет проверок». В ядре небезопасные операции — не досадное исключение, а содержание работы: запись в таблицу страниц, обращение к регистру устройства по физическому адресу, передача буфера контроллеру DMA, переключение стека. Ни одна система типов не проверит, что адрес, который вы записали в поле дескриптора, действительно указывает на выделенный вами кадр физической памяти, — она об этом соответствии не знает и знать не может.

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

### C

Главный довод за C — не свойства языка, а корпус. Всё, на что придётся смотреть, написано на нём: Linux, FreeBSD, xv6, seL4. Механизм, разобранный в любой статье этого цикла, в конечном счёте проверяется чтением чьего-то C. Второй довод — прозрачность: между исходником и потоком инструкций нет ничего, что пряталось бы от вас, а интеграция с ассемблером тривиальна, потому что C-ABI и есть та граница, к которой ассемблер пишут. Третий — доступность тулчейна: GCC собирает под архитектуры, которых LLVM не знает, а freestanding-режим у него описан стандартом.

Цена известна, и смягчается она лишь отчасти — но отчасти всё-таки смягчается, и умолчать об этом значило бы сравнивать нечестно. Динамические санитайзеры ядра ловят ровно тот класс ошибок, который пропускает компилятор: KASAN документация ядра называет «динамическим детектором ошибок безопасности памяти, предназначенным находить выходы за границу и обращения к освобождённому», рядом с ним живут UBSAN и KMSAN. Слово «динамический» и есть ограничение: ошибка обязана оказаться на исполненном пути, а у основного, отладочного режима KASAN та же документация отдельно оговаривает заметные накладные расходы по памяти и по скорости. Статический анализ работает до исполнения, но проверяет размеченное руками — `sparse` встроен в сборку ядра (`make C=1`) и ловит смешение типов, которые вы сами объявили несовместимыми. Окно оба сужают, ни один не закрывает: гарантии, что ошибка работы с памятью будет поймана до её проявления, в C нет. В прикладной программе проявление — падение; в ядре — испорченная таблица страниц, обнаруживающаяся через три секунды в другой подсистеме, и отладка начинается с вопроса, какая из подсистем солгала.

### Rust

Что Rust предполагает о рантайме, лучше всего видно на том, от чего пришлось отказаться. Ядру доступна только библиотека `core`: «поддержка Rust в ядре может компоноваться только с `core`, но не со `std`; крейты для использования в ядре должны выбрать это поведение атрибутом `#![no_std]`». Библиотека `alloc` тоже не подошла — она устроена вокруг предположения, что выделение памяти не отказывает, а при отказе программа завершается; ядро построило собственные `KBox`, `KVec` и родственные типы, где каждая выделяющая операция принимает флаги GFP и возвращает `Result`, так что отказ выделения обязан быть обработан на месте. Паника не разматывает стек (`-Cpanic=abort`), а обработчик паники пишется вручную.

Что даёт владение, точнее всего описывается через то, чего оно не даёт. `unsafe` в ядре не устраняется — он локализуется, и локализация проверяема механически. Правила Rust for Linux требуют комментария `// SAFETY:` перед каждым блоком `unsafe`, объясняющего, почему код внутри корректен, и раздела `# Safety` в документации небезопасной функции, описывающего контракт для вызывающего; сборка ядра включает `-Dunsafe_op_in_unsafe_fn`, из-за которого даже внутри небезопасной функции каждая небезопасная операция обязана быть обёрнута явным блоком. Разница с C — не в наличии проверок, а в том, где заканчивается зона ручного доказательства:

```c
/* C: контракт существует, но только в голове у автора */
static inline void uart_put(volatile uint32_t *base, uint8_t byte)
{
        base[UART_TX] = byte;
}
```

```rust
pub struct Uart {
    base: *mut u32,
}

impl Uart {
    /// # Safety
    /// `base` обязан указывать на отображение регистров UART,
    /// и никто другой не должен владеть этим отображением.
    pub unsafe fn new(base: *mut u32) -> Self {
        Self { base }
    }

    pub fn put(&mut self, byte: u8) {
        // SAFETY: инвариант типа гарантирует, что `base` — регистры UART,
        // а `&mut self` — что параллельного владельца нет.
        unsafe { self.base.add(UART_TX).write_volatile(byte as u32) }
    }
}
```

Небезопасная операция осталась ровно одна, и доказывается она один раз, в конструкторе. Дальше идёт обычный проверяемый код — а именно им и является большая часть ядра: списки, счётчики ссылок, разбор структур, пути обработки ошибок.

Состояние на сегодня стоит зафиксировать точно, потому что тема движется быстро. Решение снять с Rust статус эксперимента принято на ежегодной встрече сопровождающих и объявлено 10 декабря 2025 года; в дереве это видно так: раздел «The Rust experiment» из `Documentation/rust/index.rst` присутствует в v6.19 и отсутствует в v7.0, а в `init/Kconfig` пометки об экспериментальности нет. Драйвер Binder на Rust появился в v6.18: `CONFIG_ANDROID_BINDER_IPC_RUST` есть в `drivers/android/Kconfig` этого тега и отсутствует в v6.17. Архитектуры с поддержкой: arm, arm64, loongarch, riscv (только riscv64 и только LLVM/Clang), s390, um, x86 (только x86_64). Минимальная версия компилятора — rustc 1.85.0, для генерации привязок к C нужен bindgen 0.71.1; сборка идёт стабильным компилятором, но с `RUSTC_BOOTSTRAP=1`, что комментарий в `Makefile` объясняет как «разрешение использовать нестабильные возможности в стабильных компиляторах». Границы у этого статуса тоже стоит назвать, потому что впечатление «Rust уже везде» неверно. По отчёту LWN с той же встречи, сопровождающий графической подсистемы сказал, что DRM примерно через год перестанет принимать новые драйверы на C и потребует Rust, — но это заявление о намерении, а не действующее правило, и по дереву оно сегодня не проверяется. Там же сказано и обратное: добавлять зависимости основного кода ядра от кода на Rust не предполагается «ещё год или два». То есть Rust сегодня — язык драйверов и подсистем, а не язык ядра целиком.

Цена для собственного ядра складывается из трёх пунктов. Стабильного ABI у языка нет: справочник Rust прямо говорит, что «раскладка типа может меняться с каждой компиляцией», поэтому всякая граница с ассемблером и с аппаратными структурами описывается через `extern "C"` и `#[repr(C)]`. Кодогенерация — только LLVM, так что архитектура, которой LLVM не знает, отпадает. И цели без операционной системы — второго уровня поддержки: `x86_64-unknown-none`, `aarch64-unknown-none`, `aarch64-unknown-none-softfloat`, `riscv64gc-unknown-none-elf` относятся к «tier 2 without host tools», что означает гарантию сборки, а не гарантию работы. Насколько шаблонные цели не покрывают ядро, видно на самом Linux: arm64 и riscv64 обходятся встроенными целями rustc, а x86_64 приходится описывать сгенерированным файлом `scripts/target.json`, потому что нужного сочетания модели кода, отключённой красной зоны и программной плавающей точки среди готовых целей нет.

### Zig

Zig привлекателен в этой задаче двумя решениями, и оба идеологически совпадают с тем, как устроено ядро. Первое — `comptime` вместо препроцессора: вычисления и порождение типов происходят при компиляции средствами самого языка, а не текстовой подстановкой, то есть исчезает целый класс приёмов, которыми в ядрах на C выражают то, что языку выразить нечем. Второе — явные аллокаторы: «нет скрытого потока управления, нет скрытых выделений памяти, нет препроцессора и нет макросов», а любая функция, которой нужна память, принимает аллокатор параметром, — благодаря чему стандартная библиотека применима и на цели freestanding. Ядру, где аллокатор всё равно свой, это подходит буквально. Третье, менее идеологическое: `zig cc` работает готовым кросс-компилятором C, и отдельного тулчейна для сборки под другую архитектуру не требуется.

Цена — незавершённость языка, и её стоит оценивать по темпу изменений, а не по номеру версии. Последний выпуск на момент написания — 0.16.0 от 14 апреля 2026 года; в нём объявлен устаревшим `@cImport` с переносом трансляции заголовков C в систему сборки, целиком переделан ввод-вывод, удалены существовавшие типы потоков ввода-вывода, написан новый компоновщик ELF. Ветка `master` уже несёт версию 0.17.0-dev. Нетривиальный код, написанный под 0.15, на 0.16 без правок не переносится — примечания к выпуску сами описывают требуемые миграции, — и это нормальный режим языка до стабилизации, а не разовое неудобство. Второй пункт цены подпёрт слабее, и это стоит сказать прямо: ядер на Zig, кроме учебных и любительских, найти не удалось — жанр представлен минимальными примерами вроде разобранного в OSDev Wiki, а отсутствие остального ссылкой не доказывается. Считайте это наблюдением, а не установленным фактом; практический вывод от такой оговорки не меняется — корпус, по которому вы будете сверяться, всё равно останется C.

### Вердикт по языку

Вопрос распадается на два, и ответы у них разные — в этом и состоит выбор.

Что даёт больше понимания механизма — C, и без колебаний. Не потому, что язык хорош, а потому, что весь материал, из которого понимание собирается, написан на нём: чтобы сравнить, как одну задачу решают Linux, FreeBSD, seL4 и xv6, надо читать четыре реализации на C. Плюс к тому C ничего не скрывает между исходником и инструкциями, а в задаче, где половина ответов лежит в том, какие именно инструкции получились, это и есть главное свойство.

Что даёт более работоспособный результат — Rust, при одном условии: цель входит в число x86-64, ARM64 или RISC-V, то есть находится на территории LLVM. Довод не в том, что система владения проверит вашу работу с таблицами страниц, — не проверит. Довод в пропорции: таблицы страниц, MMIO и DMA составляют небольшую часть ядра, а всё остальное — структуры данных, счётчики ссылок, пути обработки ошибок, — то есть ровно тот код, где проверки работают и где в C рождается большинство ошибок. К этому добавляется вынужденная явность отказов выделения памяти, которая в C достигается только дисциплиной. Цена — те же три пункта, что названы выше: всякая граница с ассемблером и с аппаратными структурами описывается вручную через `extern "C"` и `#[repr(C)]`, кодогенерация только LLVM, а цели без операционной системы дают гарантию сборки, а не гарантию работы.

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

Zig имеет смысл выбрать, если `comptime` и явные аллокаторы для вас — не удобство, а способ выразить структуру задачи, и вы готовы платить пересборкой под движущийся язык. Это честный выбор, но его цену труднее оценить заранее, чем цену первых двух, — именно потому, что язык не закончен. Читать C придётся при любом ответе, и это цена не Zig и не Rust, а предмета: Linux, FreeBSD, seL4 и xv6 написаны на нём независимо от того, на чём пишете вы.

Наконец, стоит заметить, что решение связывает меньше, чем кажется. Граница между модулями во всех трёх случаях описывается C-ABI, ассемблерные точки входа выглядят одинаково, а скрипт компоновки вообще не знает, на чём написаны объектные файлы. Начать на C и перенести отдельную подсистему на Rust — рабочий путь; именно им идёт Linux.

## Сборка и отладка

### Свой toolchain обычно не нужен

Совет «соберите себе кросс-компилятор» происходит из времён, когда основной болью кросс-сборки была libc: чтобы собрать GCC, нужна библиотека C, чтобы собрать библиотеку C — компилятор. Для ядра эта петля не существует вовсе, потому что libc не нужна: сборка идёт с `-nostdlib`, и ни стартовых файлов, ни библиотек не требуется. Остаются ассемблер, компоновщик и компилятор под целевую архитектуру.

Дальше всё зависит от тулчейна. У LLVM вопрос снят по построению: «Clang/LLVM изначально является кросс-компилятором, то есть один набор программ может компилировать под все цели заданием опции `-target`»; отдельно нужен только компоновщик, и `lld` покрывает все три архитектуры. У Rust целевая платформа ставится одной командой, и для целей второго уровня библиотека `core` поставляется собранной. У Zig кросс-компиляция — заявленный основной режим работы. У GCC кросс-сборка по-прежнему означает отдельный набор программ, но его почти всегда можно поставить пакетом дистрибутива, а собирать самому приходится редко.

Практический вывод: не собирайте тулчейн, если для этого нет отдельной причины. Берите clang с lld и переключайте цель флагом; если нужен именно GCC — ставьте кросс-пакет. Сборка своего GCC остаётся оправданной ровно тогда, когда целевой архитектуры нет ни в LLVM, ни в пакетах.

### QEMU и GDB

Отладчик ядра — это QEMU с подключённым GDB, и два флага делают почти всё: `-s` заставляет QEMU слушать подключение gdb на TCP-порту 1234, `-S` — не запускать гостя, пока об этом не скажут из отладчика. Вместе они дают то, чего на живой машине нет: остановку до первой исполненной инструкции.

Дальше начинаются особенности, специфичные именно для ранней загрузки, и обе связаны с тем, что отладчик знает про программу больше, чем сейчас верно. Первая: символы скомпонованы под адреса исполнения, а процессор до включения трансляции работает по адресам загрузки — значит все адреса из таблицы символов смещены на постоянную величину, и до переключения полезно загрузить вторую копию символов со сдвигом, а после — вернуться к обычной. Это ровно то различие LMA и VMA, о котором шла речь выше, только увиденное со стороны инструмента. Вторая: на x86-64 процессор стартует не в 64-битном режиме, и gdb об этом не догадывается — руководство QEMU советует `set architecture i8086`, чтобы дизассемблировать 16-битный код, и считать адрес как `$cs*16+$eip`. То же руководство предупреждает и о более общем случае: перемещение кода средствами вроде рандомизации базы сбивает представление отладчика о памяти, и на время отладки её проще отключить.

### Ранний вывод и почему `printf` в ядре нетривиален

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

Ловушка в том, что адрес этого регистра не универсален. QEMU говорит об этом прямо: у машины `virt` на ARM гарантированы только адреса флеш-памяти и ОЗУ, а «вся прочая информация о расположении устройств может меняться между версиями QEMU, так что гостевой код обязан смотреть в DTB»; для RISC-V сказано то же — гостевое ПО должно обнаруживать устройства из сгенерированного дерева устройств. Ответ, к которому пришли настоящие ядра, — двухступенчатый. Linux заводит отдельную раннюю консоль: `earlycon` без параметров определяет устройство «свойством `stdout-path` в узле `chosen` дерева устройств либо таблицей ACPI SPCR», работает в режиме опроса и позже уступает место обычному драйверу консоли. Своему ядру стоит повторить эту же лестницу: зашитый адрес, чтобы вывести первую строку и увидеть, что вы вообще исполняетесь, — затем адрес из дерева устройств, как только его есть чем разобрать.

Вывод строки в ядре нетривиален по причине, не имеющей отношения к форматированию. Функция обязана быть вызываемой из любого контекста: из обработчика прерывания, из планировщика, из кода, который уже держит какие-то замки, из обработчика отказа страницы. Значит она не имеет права ни спать, ни выделять память, ни брать замок, который может оказаться уже взятым ею же на другом уровне вложенности, — а последнее случается сплошь и рядом, потому что предупреждение, выданное внутри драйвера консоли, приведёт к повторному входу. Linux разрешает это разделением на две части: запись форматируется в кольцевой буфер, устроенный так, что «внутреннее состояние дескриптора — ключевой элемент, позволяющий читателям и писателям синхронизировать доступ к данным без блокировок», а собственно вывод на консоль отвязан от записи. Своему ядру достаточно повторить идею: путь записи не берёт замков и не выделяет память, а вывод в порт делается отдельно и с оглядкой на то, что он может быть прерван.

### Ассерты и паника

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

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

## Вердикт для своей ОС

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

Выбора нет в следующем. Своими будут `memcpy`, `memset`, `memmove` и `memcmp` — компилятор их зовёт сам. Плавающей точки в ядре не будет, и запретить её компилятору придётся явно, разными флагами на трёх архитектурах. Скрипт компоновки станет частью исходников, и в нём появятся символы границ, выравнивание на страницу и различие адреса загрузки с адресом исполнения. Точки входа будут на ассемблере. Аллокатор будет ваш.

Выбор есть, и вот он вместе с рекомендациями. Модель кода: `-mcmodel=kernel` на x86-64, умолчание на ARM64, `medany` на 64-битном RISC-V — и решается это раньше всего остального, потому что задаёт диапазон адресов. Перемещаемость: сначала фиксированный адрес, перемещаемость потом, когда понадобится рандомизация. Freestanding: `-ffreestanding` с первого дня — не потому, что так делают все (Linux как раз не делает), а потому, что он оптом закрывает вызовы, которые компилятор придумывает по семантике libc; остальное компилятор зовёт по праву, и это ловит компоновщик. Возврат ошибки из системного вызова: отрицательный код, потому что он не требует от архитектуры ничего. Права на секции: закладывайте выравнивание сразу, включайте права в конце загрузки. Раздел инициализации: заводите вместе с проверкой ссылок в него, иначе он опаснее, чем полезен. Отображаемые процессу данные: страница с номером версии — с первого дня, отдельный ELF-объект с кодом — когда меняться обязан код, а не только данные. Тулчейн: clang с lld, если нет причины на GCC.

И один выбор, у которого два правильных ответа на два разных вопроса. Учиться механизму — на C, потому что весь корпус, с которым вы будете сверяться, написан на нём. Получить работающее ядро с меньшим числом ошибок в неинтересной части — на Rust, если цель попадает в LLVM. Пока ядро учебное, ваш вопрос первый; второй становится вашим, когда у ядра появляются пользователи. Обе рекомендации совместимы больше, чем кажется: граница модулей описывается C-ABI в любом случае, и переход подсистемы с одного языка на другой не требует переделки ни компоновки, ни точек входа.

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

## Источники

- [GCC: Language Standards Supported by GCC](https://gcc.gnu.org/onlinedocs/gcc/Standards.html) — определения freestanding и hosted, список обязательных заголовков freestanding-реализации по редакциям стандарта (девять — накопленные к C11) и расширение набора в C23 «включая некоторые функции из других заголовков», требование предоставить `memcpy`, `memmove`, `memset` и `memcmp`
- [GCC: Options Controlling C Dialect](https://gcc.gnu.org/onlinedocs/gcc/C-Dialect-Options.html) — `-ffreestanding` подразумевает `-fno-builtin` и эквивалентен `-fno-hosted`; `-fno-builtin` описан как отказ распознавать библиотечные функции, то есть отключение преобразований по их семантике
- [GCC 15 Release Series — Changes, New Features, and Fixes](https://gcc.gnu.org/gcc-15/changes.html) — умолчание для C переведено с `-std=gnu17` на `-std=gnu23`
- [GCC: Options That Control Optimization](https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html) — `-ftree-loop-distribute-patterns` превращает цикл инициализации в вызов `memset` и включён на `-O2`
- [GCC: x86 Options](https://gcc.gnu.org/onlinedocs/gcc/x86-Options.html) — `-mcmodel=small/kernel/medium/large`, `-mno-red-zone` и определение красной зоны, `-mgeneral-regs-only`
- [GCC: x86 Function Attributes](https://gcc.gnu.org/onlinedocs/gcc/x86-Attributes.html) — атрибут `interrupt`, сохранение всех регистров и возврат через `IRET`, требование `-mgeneral-regs-only` и предупреждение про красную зону при прерывании без переключения стека
- [GCC: AArch64 Options](https://gcc.gnu.org/onlinedocs/gcc/AArch64-Options.html) — модели кода `tiny`/`small`/`large`, `-mgeneral-regs-only`, `-moutline-atomics` и вспомогательные функции LSE, `-mstack-protector-guard=sysreg` с оговоркой «целиком для использования внутри ядра Linux»
- [GCC: RISC-V Options](https://gcc.gnu.org/onlinedocs/gcc/RISC-V-Options.html) — `-mcmodel=medlow/medany/large`, `-mabi`, `-msave-restore`, `-mrelax`, `-mstrict-align`
- [Clang: Cross-compilation using Clang](https://clang.llvm.org/docs/CrossCompilation.html) — «Clang/LLVM изначально является кросс-компилятором», опция `-target` и что всё равно приходится искать снаружи
- [System V Application Binary Interface — AMD64 Architecture Processor Supplement](https://gitlab.com/x86-psABIs/x86-64-ABI) — таблица регистров и кто их сохраняет, 128-байтная красная зона, ограничение на 32-битные знаковые непосредственные и определения моделей кода, включая kernel («ядро <…> исполняется в отрицательной половине адресного пространства»)
- [Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64)](https://github.com/ARM-software/abi-aa/blob/main/aapcs64/aapcs64.rst) — роли регистров, выравнивание стека и запрет обращаться к неактивной области стека
- [RISC-V ELF psABI: Calling Conventions](https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-cc.adoc) — таблица регистров, запрет полагаться на данные ниже указателя стека
- [GNU ld: Basic Linker Script Concepts](https://sourceware.org/binutils/docs/ld/Basic-Script-Concepts.html) и [Output Section LMA](https://sourceware.org/binutils/docs/ld/Output-Section-LMA.html) — определения VMA и LMA, `AT()` и `AT>`, правила выбора LMA по умолчанию
- [The RISC-V Code Models (2026 Edition): medlow, medany, & large — SiFive](https://www.sifive.com/blog/all-aboard-part-4-risc-v-code-models) — какие последовательности инструкций порождает каждая модель и откуда берётся окно в 2 ГиБ
- [Floating-point API — документация ядра Linux](https://docs.kernel.org/core-api/floating-point.html) — почему плавающая точка запрещена, зачем изолировать FP-код в отдельную единицу трансляции, ограничения `kernel_fpu_begin()`
- [earlycon — Documentation/admin-guide/kernel-parameters.txt](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Documentation/admin-guide/kernel-parameters.txt) — ранняя консоль по `stdout-path` в дереве устройств либо по таблице ACPI SPCR
- [Kernel Address Sanitizer (KASAN)](https://docs.kernel.org/dev-tools/kasan.html) и [Sparse](https://docs.kernel.org/dev-tools/sparse.html) — документация ядра Linux: KASAN как динамический детектор выходов за границу и обращений к освобождённому, накладные расходы отладочного режима; `sparse` как семантический анализатор, встраиваемый в сборку через `make C=1`
- Исходники ядра Linux: [`Makefile`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Makefile) — `-nostdinc`, `-fno-builtin-wcslen`, `--emit-relocs --discard-none`, `--orphan-handling`, `--pack-dyn-relocs=relr`, `-Cpanic=abort` и `RUSTC_BOOTSTRAP := 1`; [`arch/x86/Makefile`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/Makefile) — `REALMODE_CFLAGS` с `-ffreestanding`, полный список запретов расширений (`-mno-sse -mno-mmx -mno-sse2 -mno-3dnow -mno-avx -mno-sse4a`, отдельно `-mno-80387` и `-mno-fp-ret-in-387`), `-mno-red-zone`, `-mcmodel=kernel`, одиночный `-fno-asynchronous-unwind-tables`, `-z max-page-size=0x200000`, `--target=scripts/target.json`; [`arch/arm64/Makefile`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/Makefile) — `-mgeneral-regs-only`, `-ffixed-x18` под теневой стек при `CONFIG_SHADOW_CALL_STACK`, пара `-fno-asynchronous-unwind-tables -fno-unwind-tables` с комментарием про `.eh_frame`, `-shared -Bsymbolic -z notext` при `CONFIG_RELOCATABLE`; [`arch/riscv/Makefile`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/Makefile) и [`arch/riscv/Kconfig`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/Kconfig) — `-mabi=lp64`, `-mno-save-restore`, выбор модели кода по умолчанию (`medlow` для 32 бит, `medany` для 64) и сборка модулей с `medany`; [`scripts/mod/modpost.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/scripts/mod/modpost.c) — проверка ссылок в `.init.*`; [`arch/x86/kernel/vmlinux.lds.S`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/vmlinux.lds.S) — `AT(ADDR(.text) - LOAD_OFFSET)`, символы границ, выравнивание `.rodata` на большую страницу, порядок `.data` → раздел инициализации между `__init_begin` и `__init_end` → `.bss`; [`init/main.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/init/main.c) — порядок `free_initmem()` → `mark_readonly()` → `pti_finalize()`; [`arch/Kconfig`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/Kconfig) — `STRICT_KERNEL_RWX`; [`arch/arm64/Kconfig`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/Kconfig) — `RELOCATABLE` и проход перемещений при каждой загрузке; [`arch/arm64/kernel/module.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/kernel/module.c) — переходники для `ADR` и элементы PLT; [`arch/x86/entry/entry_64.S`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/entry/entry_64.S) — что делает `SYSCALL` с `rcx` и `r11` и почему четвёртый аргумент в `r10`; [`include/linux/err.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/linux/err.h) — `MAX_ERRNO` равный 4095; [`kernel/printk/printk_ringbuffer.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/printk/printk_ringbuffer.c) — кольцевой буфер без блокировок; [`arch/riscv/include/asm/current.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/include/asm/current.h) — `current` как глобальная регистровая переменная в `tp`; [`arch/arm64/include/asm/percpu.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/include/asm/percpu.h) — смещение данных процессора в `TPIDR_EL1`; [`scripts/generate_rust_target.rs`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/scripts/generate_rust_target.rs) — arm64 и riscv64 берут встроенные цели rustc, x86_64 требует своей
- [Rust in the kernel — документация ядра Linux](https://docs.kernel.org/rust/index.html): [general-information](https://docs.kernel.org/rust/general-information.html) — компоновка только с `core` и `#![no_std]`; [arch-support](https://docs.kernel.org/rust/arch-support.html) — список архитектур, riscv только riscv64 и только LLVM; [coding-guidelines](https://docs.kernel.org/rust/coding-guidelines.html) — `// SAFETY:` перед каждым блоком `unsafe` и раздел `# Safety` как контракт
- [Documentation/process/changes.rst](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Documentation/process/changes.rst) — минимальные версии rustc 1.85.0 и bindgen 0.71.1
- [`Documentation/rust/index.rst` в v6.19](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/Documentation/rust/index.rst?h=v6.19) и [`drivers/android/Kconfig`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/drivers/android/Kconfig) — раздел «The Rust experiment», исчезнувший в v7.0, и `CONFIG_ANDROID_BINDER_IPC_RUST`, появившийся в v6.18
- [The (successful) end of the kernel Rust experiment — LWN.net](https://lwn.net/Articles/1049831/) — дата решения и формулировка «Rust в ядре больше не эксперимент»
- [The state of the kernel Rust experiment — LWN.net](https://lwn.net/Articles/1050174/) — отчёт со встречи сопровождающих: заявление про DRM «примерно через год» и оговорка про зависимости основного кода ядра «ещё год или два»
- [Rust: platform support](https://doc.rust-lang.org/nightly/rustc/platform-support.html) — уровни поддержки целей без ОС и что гарантирует tier 2
- [Rust Reference: Type layout](https://doc.rust-lang.org/reference/type-layout.html) — отсутствие гарантий раскладки у представления по умолчанию и «раскладка типа может меняться с каждой компиляцией»
- [The Rustonomicon: `#[panic_handler]`](https://doc.rust-lang.org/nomicon/panic-handler.html) — обработчик паники в `no_std`
- [Zig: In-depth Overview](https://ziglang.org/learn/overview/) — отсутствие скрытых выделений памяти, аллокатор как параметр, применимость стандартной библиотеки на freestanding, кросс-компиляция и `zig cc`
- [Zig 0.16.0 Release Notes](https://ziglang.org/download/0.16.0/release-notes.html) и [объявление о выпуске](https://ziglang.org/news/0.16.0-released/) — состав изменений и дата 14 апреля 2026 года; текущие версии — в [индексе загрузок](https://ziglang.org/download/index.json)
- [Zig Bare Bones — OSDev Wiki](https://wiki.osdev.org/Zig_Bare_Bones) — минимальное ядро на Zig как жанр учебного проекта
- Исходники FreeBSD: [`sys/conf/kern.mk`](https://cgit.freebsd.org/src/tree/sys/conf/kern.mk) — `-ffreestanding` с обоснованием, `-mcmodel=kernel -mno-red-zone` на amd64, `-mgeneral-regs-only` и `-ffixed-x18` на arm64, разъяснение про `medium` в clang против `medany` в GCC; [`lib/libsys/amd64/SYS.h`](https://cgit.freebsd.org/src/tree/lib/libsys/amd64/SYS.h), [`lib/libsys/aarch64/SYS.h`](https://cgit.freebsd.org/src/tree/lib/libsys/aarch64/SYS.h), [`lib/libsys/riscv/SYS.h`](https://cgit.freebsd.org/src/tree/lib/libsys/riscv/SYS.h) — обёртки системных вызовов и признак ошибки; [`sys/riscv/riscv/vm_machdep.c`](https://cgit.freebsd.org/src/tree/sys/riscv/riscv/vm_machdep.c) — `cpu_set_syscall_retval()`, выставляющий `t0` в 0 или 1; [`sys/sys/vdso.h`](https://cgit.freebsd.org/src/tree/sys/sys/vdso.h) — `struct vdso_timehands` с версией и кодом алгоритма
- [`syscall(2)` — Linux manual page](https://man7.org/linux/man-pages/man2/syscall.2.html) — таблицы соглашений системного вызова по архитектурам
- [glibc, `sysdeps/unix/sysv/linux/x86_64/sysdep.h`](https://sourceware.org/git/?p=glibc.git;a=blob;f=sysdeps/unix/sysv/linux/x86_64/sysdep.h) — граница `-4095` и комментарий о том, откуда взялось обещание не возвращать значения из этого диапазона, а также `movq %rcx, %r10` в макросе `DOARGS_4`
- [`vdso(7)` — Linux manual page](https://man7.org/linux/man-pages/man7/vdso.7.html) — назначение vDSO, `AT_SYSINFO_EHDR`, набор символов и «стандартные соглашения о вызовах C»
- [QEMU: GDB usage](https://www.qemu.org/docs/master/system/gdb.html) — `-s` и `-S`, отладка 16-битного кода, предупреждение про перемещение кода
- [QEMU: `virt` generic virtual platform (ARM)](https://www.qemu.org/docs/master/system/arm/virt.html) и [`virt` (RISC-V)](https://www.qemu.org/docs/master/system/riscv/virt.html) — PL011 и NS16550, нестабильность карты устройств и требование читать DTB

---

## Связанные заметки

- [Адресное пространство: зачем трансляция и какие есть альтернативы](./os-address-space-models.md) — откуда берутся FS/GS, `TPIDR_EL1` и `tp`, которыми в ядре заменяют локальную память потока
- [Страничная трансляция: таблицы, TLB, разрядность](./os-paging-and-tlb.md) — права в записи таблицы, из-за которых границы секций в скрипте компоновки выравниваются на страницу
- [Карта памяти ядра: половины, прямое отображение, KASLR](./os-kernel-memory-map.md) — куда именно попадает образ, чью раскладку задаёт выбранная здесь модель кода, и что требуется от перемещаемого ядра
- [Стеки: ядра, прерываний, пользовательские](./os-stacks.md) — продолжение сюжета про красную зону и про то, на каком стеке оказывается код после ассемблерной точки входа
- [Устройство ОС — план цикла статей](./os-knowledge-base-plan.md) — состав и порядок цикла; загрузка, физическая память и вход в ядро разбираются отдельными статьями
- [Linux — ядро: сборка, модули, параметры](./linux-kernel.md) — то же ядро со стороны администратора: конфигурация, сборка пакетом, загрузка модулей
- [TLPI — база знаний](./tlpi-start.md) — ABI системного вызова с другой стороны границы, глазами прикладного программиста
- [Методика изучения Go-библиотек](./go-library-study-guide.md) — приёмы разбора чужого кода; выбор языка ядра во многом и есть выбор корпуса, который придётся так читать
- [Вход в ядро и переключение контекста](./os-kernel-entry.md) — седьмая статья цикла: что происходит по ту сторону соглашения о системном вызове и почему в ядре нет плавающей точки
- [Физическая память: от карты firmware до аллокатора](./os-physical-memory.md) — пятая статья цикла: первый потребитель этого скрипта компоновки — ранние таблицы страниц, зарезервированные в образе, и первый жилец раздела инициализации
- [Загрузка: от питания до первой инструкции ядра](./os-boot-sequence.md) — шестая статья цикла: момент, в котором ранний код перестаёт пользоваться адресами загрузки и переходит на адреса исполнения
- [Структура ядра: монолит, микроядро и что между](./os-kernel-structure.md) — девятая статья цикла: безопасный язык как вторая ось изоляции, независимая от структуры ядра
