---
tags: [kernel, security]
verified: 2026-09-07
---

# Вход в ядро и переключение контекста

## Одна граница, три двери

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

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

Готовым здесь берётся многое. Механизм трансляции, теги и цена инвалидации — из [второй статьи](./os-paging-and-tlb.md); изоляция таблиц страниц, входной тамбур и EntryBleed — из [третьей](./os-kernel-memory-map.md); стеки ядра, прерываний и исключений как ресурс — из [четвёртой](./os-stacks.md); ABI системного вызова, vDSO и довод в пользу ассемблера в точке входа — из [восьмой](./os-kernel-as-program.md). Четвёртая статья отложила сюда ровно один вопрос — переключение на стек ядра при входе, — и раздел про механику начинается с него.

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

## Чем двери различаются

### Прерывание, исключение, системный вызов

Различие между тремя дверями не в том, что за ними, а в том, кто выбрал момент и кто участвует в соглашении.

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

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

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

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

### Отказ страницы: исключение, которое ядро заказало само

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

Оговорка, которую стоит сделать сразу, потому что формулировка «единственное исключение, используемое как механизм» встречается часто и неверна. Отказ страницы — самое частое из таких исключений, но не единственное. Ловушка на первое обращение к векторным регистрам устроена ровно так же и служит ровно тому же: в диспетчере синхронных исключений ARM64 у кодов `ESR_ELx_EC_SVE` и `ESR_ELx_EC_SME` стоят обработчики, которые не убивают процесс, а выделяют потоку место под векторное состояние и возвращают управление той же инструкции. На x86-64 в той же роли исторически выступало исключение «устройство недоступно» — на нём была построена ленивая работа с FPU, и о том, чем это кончилось, дальше отдельный разговор.

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

## Механика на трёх архитектурах

![Пересечение границы при системном вызове, разложенное по шагам сверху вниз: смена уровня привилегий, запись кадра события, переключение на стек ядра, переключение корня трансляции при включённой изоляции, сохранение регистров в кадр входа и их обнуление, контрмеры спекулятивных атак и только после этого вызов кода на C. Рядом с шагами — четыре дорожки входа в трёх колонках: x86-64 отдельно для исключения и отдельно для системного вызова, затем ARM64 и RISC-V; каждая показывает, где проходит граница между тем, что делает железо, и тем, что обязана сделать операционная система: x86-64 при исключении сам переключает стек и пишет кадр события в память, но при системном вызове не делает ни того, ни другого; ARM64 не пишет в память ничего и переключает стек аппаратно по целевому уровню привилегий; RISC-V не пишет в память ничего и стек оставляет полностью на ядро. Горизонтальная линия через все три колонки отмечает момент, начиная с которого состояние описывается обычным соглашением о вызовах и вызывать код на C безопасно](./assets/os-kernel-entry-boundary.svg)

### Сколько работы берёт на себя железо

Одно расхождение приходит сюда готовым из [статьи про стеки](./os-stacks.md), и без него остальное не читается: пишет ли процессор что-нибудь в память при входе. x86-64 при исключении пишет — сам укладывает на стек ядра `SS`, `RSP`, `RFLAGS`, `CS` и `RIP`. ARM64 и RISC-V не пишут в память ничего: адрес возврата и состояние уходят в системные регистры (`ELR_ELx` и `SPSR_ELx`, `sepc` и `sstatus`), а стек обработчик выбирает сам.

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

x86-64 разбирает максимально. Таблица дескрипторов прерываний содержит до 256 входов, и номер вектора выбирает сразу три вещи: обработчик, стек (через трёхбитное поле IST в дескрипторе шлюза) и то, положит ли процессор на стек код ошибки. Плата — таблица дескрипторов, которую надо построить, держать и защищать; выигрыш — обработчик получает управление, когда причина уже разобрана.

ARM64 разбирает грубее. Таблица векторов по адресу из `VBAR_EL1` состоит из шестнадцати слотов по 128 байт: четыре группы — текущий уровень исключения с `SP_EL0`, текущий с `SP_ELx`, младший уровень в AArch64, младший в AArch32 — по четыре записи в каждой: синхронное исключение, IRQ, FIQ, SError. Железо, таким образом, различает откуда пришли и какого рода событие, а что именно случилось, сообщает полем EC регистра `ESR_EL1`, и разбирать его приходится программно. В Linux весь синхронный вход из EL0 — это один `switch` по этому полю, от системного вызова до отказа выравнивания стека.

RISC-V разбирает минимально. Адрес обработчика один, в `stvec`; причина лежит в `scause`, где старший бит отделяет прерывание от исключения, а остальное — код причины. Векторный режим `stvec` разносит по разным адресам только прерывания, и то по формуле «база плюс четыре на номер причины»; синхронные исключения все идут на базовый адрес. Всё остальное — таблица в самом ядре, и в Linux она так и называется, `excp_vect_table`.

Расхождение это не про удобство, а про то, где живёт диспетчер. Аппаратная таблица снимает с ядра один уровень разбора и взамен делает добавление обработчика правкой структуры, которую читает процессор; программная оставляет разбор ядру и полностью в его власти. Для своей ОС разница почти не ощущается, потому что диспетчер второго уровня вам всё равно писать — даже на x86-64 обработчики прерываний устройств сводятся к одной функции `common_interrupt()`, которой номер вектора передаётся параметром. Ощущается другое: на x86-64 придётся с самого начала уметь строить дескрипторы, а на RISC-V достаточно записать один регистр, и это разница в том, когда у вас впервые заработает вход, а не в том, каким он получится.

### Переключение на стек ядра: одна задача, три решения

Вторая развилка та самая, что четвёртая статья отложила сюда. Задача одна: к моменту, когда обработчик начнёт что-нибудь писать в память, указатель стека обязан указывать на стек ядра, а не на то, что оставил пользователь. Решения три, и они различаются тем, сколько из этого сделает железо.

| | Откуда берётся указатель | Кто переключает | Что обязана делать ОС |
| --- | --- | --- | --- |
| x86-64, прерывание и исключение | поле `RSP0` в TSS, для отмеченных векторов — один из семи входов IST | железо, до первой инструкции обработчика | держать поле актуальным и защищать TSS |
| x86-64, инструкция `syscall` | ниоткуда | никто | сохранить пользовательский указатель и загрузить свой первыми же инструкциями, не имея ни одного свободного регистра |
| ARM64 | банкированный `SP_EL1`, выбираемый автоматически при входе в EL1 | железо | заранее записать `SP_EL1` при переключении контекста |
| RISC-V | `sscratch` | операционная система | обменять `sscratch` с рабочим регистром одной инструкцией и достать указатель по нему |

Строка про `syscall` — самая поучительная, потому что она ломает правило внутри одной архитектуры. Комментарий в точке входа Linux говорит прямо: «SYSCALL ничего не сохраняет на стеке и не меняет `rsp`». Значит, первые инструкции обработчика исполняются с пользовательским указателем стека, в режиме ядра, и записать что-либо в память нельзя — некуда. Выход из положения — в самой архитектуре: `swapgs` меняет базу сегмента GS на ядерную, после чего появляется доступ к данным текущего процессора, и уже туда, в поле `sp2` того же TSS, кладётся пользовательский указатель. Дальше при включённой изоляции переключается корень таблиц, и только потом загружается настоящий стек — его вершину Linux держит в переменной на процессор `cpu_current_top_of_stack`, а не в `RSP0`, потому что `RSP0` занят под входной трамплин.

RISC-V решает ту же задачу одной инструкцией, и решение стоит того, чтобы его скопировать. Обмен `csrrw tp, sscratch, tp` одновременно даёт рабочий регистр и отвечает на вопрос, откуда пришли: в `sscratch` лежит указатель на данные потока, пока исполняется пользовательский код, и ноль, пока исполняется ядро. Ненулевой результат обмена — значит пришли из пользовательского режима; нулевой — значит это вложенное событие в ядре, и стек менять не надо.

ARM64 не платит ничего: `SP_EL1` банкирован, обработчик получает управление уже на нём. Освободившийся `SP_EL0` Linux использует не по назначению — там лежит указатель на структуру текущего потока.

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

```asm
# x86-64, вход по syscall: стек ещё пользовательский, свободных регистров нет
swapgs                                              # база GS становится ядерной
movq %rsp, PER_CPU_VAR(cpu_tss_rw + TSS_sp2)        # спрятать указатель пользователя
SWITCH_TO_KERNEL_CR3 scratch_reg=%rsp               # единственный годный временный регистр — rsp
movq PER_CPU_VAR(cpu_current_top_of_stack), %rsp

# RISC-V: обмен даёт и рабочий регистр, и ответ на вопрос «откуда пришли»
csrrw tp, CSR_SCRATCH, tp    # tp меняется местами с sscratch
bnez  tp, .Lsave_context     # ненулевой — пришли из пользовательского режима
csrr  tp, CSR_SCRATCH        # нулевой — вложенное событие в ядре, tp вернуть на место
```

Вердикт для своей ОС здесь уже сформулирован в [статье про стеки](./os-stacks.md) и не меняется: одно поле в структуре потока под вершину его стека ядра и один способ до него добраться; там же — и практическое следствие про x86-64, где это поле обязано переписываться при каждом переключении контекста. Со стороны входа к вердикту добавляется ровно одно наблюдение: цена механизма разная — от нуля инструкций там, где указатель банкирован, до четырёх шагов там, где железо не дало ничего, — а форма кода входа от неё не зависит, если ответ на вопрос «где стек текущего потока» в ядре один.

### Быстрый вход и почему выход опаснее входа

Для системного вызова все три архитектуры дают отдельную инструкцию: `syscall`, `SVC`, `ecall`. На ARM64 и RISC-V это обычные синхронные исключения, отличающиеся только кодом причины, — никакой особой механики. На x86-64 `syscall` устроена принципиально иначе: она не идёт через таблицу дескрипторов вообще. Адрес обработчика лежит в регистре модели `LSTAR`, селекторы сегментов — в `STAR`, маска флагов — в `FMASK`; выборки дескриптора, проверки прав шлюза и записи в память не происходит — на этом и экономится.

Экономия имеет обратную сторону, и она вся на выходе. Инструкция `sysret` восстанавливает меньше состояния, чем `iret`, и всё, чего она не восстанавливает, превращается в предусловие, которое обязана проверить операционная система. Условий в Linux пять. Четыре перечисляются одной фразой: регистры `rcx` и `r11` должны совпадать с сохранённым адресом возврата и флагами, селекторы `cs` и `ss` — с теми, что записаны в `STAR`, флаги `RF` и `TF` должны быть сброшены, а адрес возврата — лежать ниже границы пользовательского пространства. Пятое отсекает паравиртуализованного гостя Xen, который возвращается только через `iret`. Не сошлось хоть что-то — путь возврата уходит на медленный `iret`.

Последняя проверка — самая важная, и комментарий рядом с ней объясняет почему: «на процессорах Intel `SYSRET` с неканоническим `RCX`/`RIP` вызовет `#GP` в пространстве ядра. Это, по сути, позволяет пользователю захватить ядро, поскольку `RSP` контролируется пользователем». Механика в том, что `#GP` доставляется, когда процессор ещё в режиме ядра, а указатель стека уже загружен пользовательским значением: обработчик исключения запускается с полными правами на стеке, который выбрал атакующий. Это CVE-2012-0217, и список затронутых систем — Xen, Solaris, illumos, SmartOS, FreeBSD до 9.0-RELEASE-p3, NetBSD, Windows 7 и Server 2008 R2 — показывает природу дефекта лучше любого разбора: описание в базе называет причину «неверным использованием спецификации Intel», то есть в яму независимо друг от друга упали почти все, кто читал одну и ту же документацию. Общий комментарий в той же точке входа формулирует вывод шире: если пользователь мог изменить сохранённый кадр, возврат делается через `iret` безусловно, потому что «`iret` лучше справляется с неканоническими адресами; у `SYSRET` с ними проблемы из-за ошибок в процессорах и AMD, и Intel».

Урок для своей ОС прямой и переносимый: быстрая инструкция возврата — это возврат с предусловиями, а список предусловий ваш. Если вы её используете, проверка должна стоять в одном месте с ней и по умолчанию отвечать «нет»; сомнительный случай уходит на медленный путь. ARM64 и RISC-V этой проблемы не имеют вовсе, и не потому, что лучше спроектированы, а потому, что у них нет второго, более дешёвого пути возврата: `ERET` и `sret` восстанавливают ровно то, что железо записало при входе. Меньше вариантов — здесь свойство безопасности.

### Что обязана сохранить сама ОС

Дальше начинается работа, одинаковая на всех трёх архитектурах. Регистры общего назначения складываются в структуру заранее известной раскладки — кадр входа, `pt_regs` в терминах Linux, — и складываются полностью, потому что отладчику, доставке сигнала и перезапуску прерванного системного вызова нужен весь набор.

Интереснее то, что происходит сразу после сохранения: регистры обнуляются. На x86-64 это макрос `PUSH_AND_CLEAR_REGS`, где рядом с каждой инструкцией `xor` стоит комментарий `nospec`; на ARM64 — макрос `clear_gp_regs` в начале входа из EL0. Причина не в гигиене. Значение, оставленное в регистре пользователем, — это готовый материал для гаджета: если дальше в ядре найдётся косвенный переход или обращение по такому регистру, управлять им будет тот, кто пришёл снаружи. Обнуление стоит десятка инструкций и снимает целый класс приёмов.

Почему всё это пишется на ассемблере, разобрано в [статье про ядро как программу](./os-kernel-as-program.md), и повторять довод здесь незачем. Существенно другое — где именно проходит граница между ассемблером и C, потому что она и есть ответ на вопрос «когда стало безопасно». Кадр входа сформирован, стек переключён, корень трансляции переключён, данные текущего процессора доступны — с этого момента состояние описывается обычным соглашением о вызовах, и дальше идёт обычная функция.

Ровно эту границу Linux с версии 5.9 оформил отдельным слоем: каталог `kernel/entry/` содержит общий для архитектур код входа и выхода, а `arch/*/entry` сводится к ассемблеру плюс вызову этих функций. x86-64, RISC-V, s390 и powerpc его используют, ARM64 держит свою копию в `arch/arm64/kernel/entry-common.c`. Смысл выделения не в экономии строк, а в том, что порядок действий на входе одинаков везде и ошибка в нём не проявляется явно: сначала lockdep, потом отслеживание контекста для RCU, потом трассировка, и лишь затем работа, которую заказал пользователь этого потока, — `ptrace`, seccomp, аудит, трассировка системных вызовов. На выходе тот же список отрабатывается в обратном порядке. Рандомизация смещения стека ядра в этот список не входит вовсе и стоит после него, вплотную к диспетчеризации: на x86-64 `add_random_kstack_offset()` вызывается в `do_syscall_64()` сразу за `syscall_enter_from_user_mode()`, на ARM64 — внутри `invoke_syscall()`, то есть уже после `syscall_trace_enter()`. Условие у неё своё, и записано оно прямо в описании макроса — вызывать «на пути входа в системный вызов после того, как пользовательские регистры сохранены на стек»: смещается то, что ляжет на стек дальше, а не уже построенный кадр входа. Участок до того, как RCU начал следить, и после того, как он перестал, помечен `noinstr` — вставлять туда инструментацию нельзя, потому что сама инструментация пользуется тем, что ещё не включено или уже выключено.

Для своей ОС отсюда следует ровно одно, зато рано: у входа есть порядок, и порядок этот определяется зависимостями подсистем, а не удобством. Выпишите его явно, одним списком, до того как подсистем станет больше двух.

### FRED: x86-64 признаёт накопленный долг

Всё, что перечислено выше как сложность x86-64 — хрупкий `swapgs`, требующий идеальной вложенности; невложимые стеки IST; `iret`, безусловно снимающий блокировку NMI; полусформированное состояние при входе; предусловия `sysret`, — Intel собрала в один список и предложила механизм, который их закрывает. FRED (Flexible Return and Event Delivery) заменяет доставку событий через таблицу дескрипторов и возврат инструкцией `iret` на новые переходы: событие доставляется с полным супервизорным контекстом, возврат — инструкциями `ERETS` и `ERETU`, из которых вторая возвращает в кольцо 3. Диспетчеризация становится двухуровневой и программной: сначала по типу события, потом по вектору. Невложимую таблицу IST заменяют четыре уровня стека, где текущий уровень при доставке события может только повыситься, а понизить его вправе только возврат. `swapgs` при включённом FRED запрещён и вызывает `#UD`: смену базы GS берут на себя сама доставка события и `ERETU`, а для кода, которому она нужна вне переходов, есть отдельная инструкция `LKGS`.

Поддержка FRED появилась в Linux в версии 6.9 — раньше, чем процессоры, которые его умеют. Первым поколением с FRED называют Panther Lake, но это сведения отраслевой прессы: подтверждения в документах Intel я не нашёл и подаю это как непроверенное. Измерений здесь тоже нет, и по той же причине: публикация с бенчмарками на реальном железе существует, но открыть её для сверки не удалось, а повторять чужие числа без проверки не стоит.

Читать эту историю автору своей ОС стоит не как новость, а как оценку. Список того, что FRED чинит, — это список того, что накопилось за сорок лет обратной совместимости в механизме входа, и почти каждый пункт в нём когда-то был разумным решением. Практический вывод один и он про раскладку кода: граница «сформировали кадр входа и позвали C» должна проходить как можно раньше, потому что всё, что выше неё, — это то, что придётся переписать целиком, когда механизм под вами сменится. У Linux на x86-64 сегодня два комплекта точек входа, и общего у них — всё, что ниже этой границы.

## Граница доверия

### Почему пользовательский указатель нельзя разыменовать

Ядро получает от процесса адреса — буфер для `read`, структуру для `ioctl`, строку с именем файла. Написать `*ptr` по такому адресу нельзя, и классических причин три, независимых друг от друга; недавно к ним добавилась четвёртая.

Первая: указатель может указывать не туда, куда думает ядро. Классический вариант — адрес в половине ядра, и тогда ядро по просьбе процесса читает или пишет собственные структуры. Есть и обратный, менее очевидный: адрес пользовательский, но маленький, а в ядре есть ошибка с разыменованием нулевого указателя — и если процесс сумел отобразить нулевую страницу, ошибка из паники превращается в исполнение кода, который процесс туда положил. Отсюда `mmap_min_addr`: нижняя граница, ниже которой процессу не дают отображать. Умолчание в самом ядре — 4096 байт (`CONFIG_DEFAULT_MMAP_MIN_ADDR`), дистрибутивы обычно поднимают до 65536, и то же число стоит умолчанием у соответствующей проверки SELinux (`LSM_MMAP_MIN_ADDR`) — кроме 32-битного ARM и 32-битной совместимости на ARM64, где умолчание вдвое меньше, 32768: справка к параметру говорит, что выше на этих архитектурах поднимать нельзя.

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

Третья: содержимое по адресу может измениться между проверкой и использованием. Другой поток того же процесса пишет туда же и вправе делать это в любой момент.

Четвёртая причина появилась недавно и относится к самому адресу, а не к памяти: старшие биты указателя могут нести теги — LAM на x86-64, TBI на ARM64. «Тот же адрес» для проверки границ и для обращения перестаёт быть одним числом, и всякая проверка обязана сначала снять теги (`untagged_addr`). Занятно, что цена этого механизма выяснилась не сразу: в конфигурации ядра поддержка LAM и на 7.2 помечена зависимостью с комментарием «ждём LASS» — тегирование адресов ослабляет ту самую проверку границ, о которой ниже, и включать его без нового аппаратного барьера не стали.

### Как ядро делает заведомо опасное обращение

Первая мысль — проверять адрес по-настоящему: искать область памяти процесса, которой он принадлежит, и убеждаться, что права позволяют. Так и делали; документация ядра рассказывает, чем это кончилось: «в некоторых тестах профилирования эта обычно ненужная проверка съедала заметное время», и «Линус решил доверить проверку аппаратуре виртуальной памяти, которая есть в каждом процессоре, способном работать под Linux».

Механизм получился из двух частей, и обе того стоят. Первая — `access_ok()`: не обход таблиц, а одно сравнение, отвечающее только на вопрос «весь ли диапазон лежит в пользовательской половине», причём написанное так, чтобы переполнение при сложении адреса с размером не проскочило. Всё остальное — права, наличие отображения, выгруженность — не проверяется вообще.

Вторая часть — таблица исключений. Каждая инструкция, которая имеет право отказать, регистрируется в отдельной секции образа парой «адрес инструкции, адрес продолжения». Обработчик отказа страницы, обнаружив, что отказ случился в ядре и адрес отказа не объясняется картой процесса, ищет адрес инструкции в этой таблице; нашёл — правит сохранённый адрес возврата и возвращается, и исполнение продолжается в коде обработки ошибки, спрятанном внутри макроса доступа. Не нашёл — это настоящая ошибка ядра.

Для своей ОС здесь важен порядок работ, а не сам приём. Таблица исключений — это секция, которую формирует ассемблер, сортирует сборка и читает обработчик отказа, то есть она затрагивает скрипт компоновки (см. [статью 8](./os-kernel-as-program.md)) и путь обработки отказа одновременно. Сделать её сразу стоит нескольких строк; приделать потом — значит трогать оба места в системе, где ошибиться дороже всего.

### Аппаратная поддержка: одна задача, три уровня участия ОС

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

| | Исполнение кода из памяти процесса | Чтение и запись памяти процесса |
| --- | --- | --- |
| x86-64 | SMEP: бит в `CR4`, действует на все страницы с признаком пользовательской | SMAP: бит в `CR4`, снимается на время доступа флагом `AC` через `stac` и возвращается через `clac` |
| ARM64 | PXN: атрибут страницы, который ядро выставляет каждому пользовательскому отображению | PAN: бит в `PSTATE` (FEAT_PAN), снимается и возвращается вокруг доступа; при отсутствии — программная эмуляция |
| RISC-V | запрещено архитектурой безусловно: «S-режим никогда не может исполнять инструкции со страниц пользователя, независимо от состояния SUM» | бит `SUM` в `sstatus`, сбрасываемый при входе в ядро и выставляемый внутри процедур копирования |

Читается таблица по столбцам, и разница в них — это разница в том, кому доверено решение. На x86-64 запрет исполнения глобален и не имеет исключений: включили — и всё. На ARM64 того же результата добиваются атрибутом страницы, то есть ответственность на ядре: забыли выставить `PXN` в одном месте — получили дыру, зато можно сделать исключение там, где оно осмысленно. На RISC-V выбора нет вообще, и это самый дешёвый вариант из трёх: ничего не надо ни включать, ни выставлять.

Для доступа к данным картина иная: там окно нужно всем, и все три открывают его вокруг процедур копирования. Разница в цене открытия — от одной инструкции (`stac`, `csrs sstatus`) до записи системного регистра с барьером.

Отдельного внимания заслуживает программная эмуляция PAN на ARM64, потому что это готовый рецепт для архитектуры без аппаратной поддержки. Смысл в том, что у пользовательской половины свой корень трансляции: чтобы ядро не могло дотянуться до памяти процесса, `TTBR0_EL1` переключают на заранее подготовленную пустую таблицу с зарезервированным ASID, а на время копирования возвращают настоящую. Приём переносится на любую систему, где половины транслируются раздельно, и стоит двух записей системных регистров на окно доступа. Ту же идею, но в железе, реализует более новый механизм x86-64 — LASS, где запрет опирается не на биты таблиц, а на 63-й бит адреса: ядру просто не разрешено трогать нижнюю половину. Поддержка LASS появилась в Linux 7.0, и вместе с ней в том же заголовке появилась вторая пара функций для снятия запрета — потому что SMAP и LASS ловят разные вещи, и обращение ядра к своим же данным по нижнему адресу первый пропустит, а второй нет. Оговорку про LAM это, вопреки ожиданию, не снимает: на 7.2 `ADDRESS_MASKING` всё ещё зависит от `COMPILE_TEST || !CPU_MITIGATIONS` с тем же комментарием «ждём LASS», то есть влитый в ядро аппаратный барьер и снятое ограничение — ещё не одно и то же.

### Спекуляция: проверка, которую процессор пропускает мимо ушей

У проверки границ есть свойство, испортившее всю конструкцию: это условный переход. Процессор предсказывает его исход и исполняет тело спекулятивно — то есть выполняет обращение по непроверенному адресу, откатывает результат, но оставляет след в кэше. Именно так устроен первый вариант Spectre, и именно поэтому в ядре Linux рядом с открытием окна доступа стоит барьер: `__uaccess_begin_nospec()` — это `stac()` плюс `barrier_nospec()`. Тот же приём — в самом диспетчере системных вызовов, где номер, пришедший от пользователя, перед использованием в качестве индекса пропускается через `array_index_nospec()`.

Второй ответ лучше первого и заслуживает того, чтобы его знали. Вместо ветвления адрес зажимают: `mask_user_address()` сравнивает указатель с верхней границей пользовательского пространства и инструкцией условного пересыла заменяет его на эту границу, если он больше. Комментарий рядом объясняет мотив: «маскирование адреса — альтернатива условному `user_access_begin`, позволяющая обойтись без барьера». Смысл в том, что процессор не может спекулировать мимо арифметики: плохой указатель превращается в заведомо отказывающий, а не в предсказанную ветку. Обобщение годится далеко за пределы этого случая — проверка, которую удаётся выразить арифметикой, устойчивее проверки, выраженной переходом.

### TOCTOU и дисциплина проверки аргументов

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

Дисциплина видна там, где интерфейс разделяет память с процессом. Комментарий над функцией, достающей запрос из кольца io_uring, формулирует это дословно: указатель ведёт в память, отображённую пользователем, «мы не можем рассчитывать, что пользовательское пространство всегда ведёт себя прилично», и если поля запроса проверяются, а потом используются, читать их надо через `READ_ONCE()`, «чтобы предотвратить повторную загрузку дальше по коду». Там же рядом стоит `array_index_nospec()` — на индекс, взятый из того же кольца.

Тот же вопрос определил форму seccomp, и это лучший пример того, как ограничение механизма выбирают ради невозможности ошибки. Документация ядра говорит, что BPF «делает невозможным для пользователей seccomp попасться на атаки типа время-проверки-время-использования (TOCTOU), обычные для систем перехвата системных вызовов»: программа фильтра не вправе разыменовывать указатели вообще, и потому вынуждена принимать решение только по значениям аргументов. Там, где разыменование всё же нужно — у трассировщика через `ptrace`, — документация требует того же вручную: все аргументы «должны быть прочитаны в память трассировщика до принятия каких-либо решений».

Проверка аргументов вообще стоит того, чтобы считать её отдельной дисциплиной, а не набором проверок по месту. Три правила, каждое из которых дорого приделывать задним числом. Размер, пришедший снаружи, проверяется до умножения, а не после. Указатель на структуру сопровождается её размером, а не только типом, — тогда структуру можно расширять: механизм `copy_struct_from_user()` разбирает три случая, и в двух из них решение принимается автоматически (пользователь прислал старую структуру — хвост занулить; прислал новую, а ядро её не знает — потребовать, чтобы неизвестный хвост был нулевым, иначе отказать). И третье: у флагов должно быть проверяемое множество допустимых значений, потому что неиспользуемый бит, пропущенный сегодня, завтра нельзя будет занять.

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

## Переключение контекста

### Что такое контекст и где он лежит

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

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

Ядерный контекст — регистры, сохраняемые вызываемым по соглашению о вызовах, указатель стека и адрес возврата — записывается собственно переключением. Он крошечный, и это главное, что стоит про него знать: на x86-64 шесть регистров, на ARM64 — `x19`–`x28` плюс кадровый указатель, указатель стека и регистр связи, на RISC-V — `ra`, `sp` и `s0`–`s11`. Учебное ядро xv6-riscv сохраняет ровно тот же набор из четырнадцати регистров, что и Linux.

А вот куда он сохраняется, реализации решают по-разному, и знать это стоит заранее, потому что от ответа зависит, как подделывать контекст новому потоку. ARM64 и RISC-V пишут весь набор в структуру потока — в `thread.cpu_context` и `task->thread` соответственно, и так же устроен `struct context` в xv6. x86-64 кладёт свои шесть регистров обычными `push` на стек уходящего потока, а в структуру уходит единственное поле — вершина этого стека. Состояния в обоих случаях поровну, разница в том, где оно физически лежит: у одних это поле структуры, у других — кадр на стеке, который при создании потока придётся построить руками.

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

Третья часть — состояние FPU и векторных расширений — живёт отдельно и по отдельному расписанию, о чём ниже.

### Функция, которая возвращается в другом потоке

![Два стека ядра рядом, левый принадлежит уходящему потоку, правый — приходящему. На каждом показана одна и та же цепочка кадров: кадр входа, кадр планировщика и кадр функции переключения. Посередине — обмен указателями стека через структуры потоков: сохранённая вершина стека уходящего потока записывается в его структуру, вершина приходящего читается из его структуры и загружается в указатель стека. Стрелка входит в функцию переключения слева и выходит из неё справа: вызвали на одном стеке, вернулись на другом, и локальные переменные после возврата принадлежат уже другому потоку. Сноска под схемой отмечает, что место регистров, сберегаемых вызываемым, у реализаций разное: x86-64 кладёт их на стек уходящего потока и держит в структуре только вершину, ARM64, RISC-V и xv6 пишут в структуру потока весь набор. Отдельной веткой показан особый случай — только что созданный поток, который никогда не вызывал планировщик, и потому его сохранённый контекст подделан при создании так, чтобы возврат ушёл на отдельную точку входа, а не в середину планировщика](./assets/os-kernel-entry-context-switch.svg)

Механику переключения проще всего понять через одну фразу из комментария в планировщике Linux: «переключение контекста перевернуло стек под нами и восстановило локальные переменные, сохранённые тогда, когда эта задача вызывала `schedule()` в прошлом». Функция была вызвана в одном потоке и вернулась в другом; всё, что было в регистрах и на стеке до вызова, принадлежит теперь тому потоку, который вернулся.

Из этого следуют три вещи, каждая из которых в своей ОС проявится в виде ошибки, если про неё не знать заранее.

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

Вторая: замок очереди выполнения берётся одним потоком, а отпускается другим. Это законно, потому что замок защищает структуру, а не поток, но всякая проверка вида «отпускает тот, кто взял» на этом коде сломается, и в отладочных сборках её приходится отключать явно.

Третья и самая практичная: только что созданный поток никогда не вызывал планировщик, и возвращаться ему некуда. Значит, его сохранённый контекст надо подделать при создании — так, чтобы переключение на него привело в специально написанную точку входа, которая доведёт дело до возврата в пользовательское пространство. У Linux на x86-64 это отдельная структура кадра неактивной задачи и отдельная точка `ret_from_fork`. Первый возврат нового потока идёт не туда, куда все последующие, и это не частный случай, а обязательная часть механизма.

### Векторное состояние: где ленивость выжила, а где нет

Состояние FPU и векторных расширений выделено в отдельную часть контекста по одной причине: оно большое, а трогает его не всякий поток. Порядок величин стоит назвать. Длину вектора SVE на ARM64 задаёт четырёхбитное поле, считающее вектор в 128-битных словах, — то есть архитектурный максимум равен шестнадцати таким словам: 2048 бит, 256 байт на регистр; тридцать два таких регистра дают восемь килобайт, и это без предикатных регистров и без матричного хранилища SME, размер которого равен квадрату длины вектора в байтах — 64 КиБ при максимальной длине. Сравните с шестью-четырнадцатью регистрами обычного переключения. Шестнадцать здесь — то, что архитектура определяет сегодня: ABI Linux заложил под длину вектора все зарезервированные под расширение биты `ZCR`, то есть 512 слов (`__SVE_VQ_MAX` в `sve_context.h`), и комментарий в `arch/arm64/include/asm/fpsimd.h` объясняет ровно этот разрыв — размеры буферов «на худший случай» по нему выходят непомерными, поэтому для собственных нужд ядро считает максимум по ширине поля `LEN`.

Классический ответ на это — истинная ленивость: при переключении не трогать векторные регистры вообще, запретить к ним доступ, а состояние восстановить только тогда, когда новый поток на них наткнётся и получит ловушку. Схема мертва, и убила её не производительность. Уязвимость Lazy FP State Restore (CVE-2018-3665) показала, что остаточное состояние предыдущего потока, оставленное в регистрах, читается спекулятивно ещё до того, как ловушка сработает. Linux к тому моменту уже переходил на жадное сохранение: в 4.6 умолчание внутреннего переключателя `eagerfpu` сменилось с «по обстоятельствам» на «включено», а сам ленивый режим вместе с параметром загрузки исчез из исходников в 4.10.

Что выжило и работает сегодня — схема, одинаковая по форме на x86-64 и ARM64, хотя названия разные. Сохранение жадное: при переключении регистры уходят в память в контексте уходящего потока. Восстановление отложено до возврата в пользовательское пространство и — вот главное — вовсе не делается, если регистры уже содержат состояние этого потока на этом процессоре. Признак хранится парой «последний процессор у потока, последний поток у процессора»: на x86-64 это флаг `TIF_NEED_FPU_LOAD` вместе с номером процессора в структуре FPU, на ARM64 — `TIF_FOREIGN_FPSTATE` вместе с переменной на процессор. Поток, который поработал, ушёл в ядро на системный вызов и вернулся, не платит за векторное состояние ничего.

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

Есть и четвёртый ответ, самый радикальный, и он не про реализацию, а про ABI. Документация ARM64 объявляет, что при системном вызове сохраняются только младшие 128 бит регистров `Z0`–`Z31`, а всё остальное — старшие биты, предикатные регистры и `FFR` — обнуляется. То есть дорогая часть состояния объявлена непереживающей системный вызов, и сохранять её ядру просто не нужно. Приём стоит запомнить: когда состояние слишком велико, чтобы его сохранять, дешевле объявить в соглашении, что оно не сохраняется.

Связь с запретом на плавающую точку в коде ядра прямая и разобрана в [статье 8](./os-kernel-as-program.md): регистры FPU при входе в ядро продолжают принадлежать прерванному потоку, и любое обращение к ним из ядра эти данные уничтожит.

### Смена адресного пространства

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

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

Переключение между процессами меняет корень, и дальше всё зависит от тегов. Механизм разобран во [второй статье](./os-paging-and-tlb.md), здесь достаточно следствия: на x86-64 запись корня без включённых PCID выбрасывает из TLB всё, кроме глобальных страниц, — это медленно, но верно; на ARM64 и RISC-V запись корня сама по себе не выбрасывает ничего, и переключение без тегов и без явной инвалидации — не медленный код, а неверный. Теги здесь не оптимизация, а способ не платить за инвалидацию, которую иначе ядро обязано делать само.

Третий случай — переключение на поток ядра. У него нет своего адресного пространства, и Linux не переключает корень вообще: поток ядра одалживает карту предыдущего (`active_mm`), и переключение туда и обратно может не стоить ни одной записи регистра. Приём называется ленивым TLB и для своей ОС полезен рано: потоки ядра появляются раньше, чем второй процесс.

К этому добавляется изоляция таблиц, если она включена, — но добавляется она не к переключению, а к каждому пересечению границы, по записи корня на вход и на выход. Оценки стоимости и то, как её смягчают теги, разобраны в [третьей статье](./os-kernel-memory-map.md).

### Из чего складывается стоимость

Про абсолютные числа стоит сказать прямо: одна цифра здесь всегда врёт. Стоимость входа отличается в разы между поколениями процессоров, между наборами включённых контрмер и между «есть теги адресных пространств» и «нет». Правильный ответ — измерить на своей машине: `perf bench syscall basic` меряет пропускную способность самого дешёвого системного вызова, `perf bench sched pipe` — итерацию из двух переключений и четырёх системных вызовов на обмене через канал, потому что на каждом круге пишут и читают оба участника. Полезно то, из чего цена складывается, потому что состав от машины не зависит.

| Слагаемое | Чем определяется | Когда платится |
| --- | --- | --- |
| Смена уровня привилегий | микроархитектура; на x86-64 — ещё и выбор между `syscall` и вектором | каждый вход |
| Формирование и разбор кадра входа | число регистров общего назначения | каждый вход и выход |
| Контрмеры спекулятивных атак | набор уязвимостей конкретного процессора | каждый вход и выход, часть — и на переключении |
| Переключение корня трансляции при изоляции таблиц | наличие тегов адресных пространств | каждый вход и выход, если включена |
| Сохранение и загрузка ядерного контекста | 6–14 регистров, всегда одно и то же | каждое переключение |
| Смена адресного пространства | наличие тегов; без них — полный сброс TLB | только между процессами |
| Векторное состояние | ширина вектора и то, трогал ли его поток | только при смене владельца регистров |
| Холодные кэши, TLB и предсказатели у нового потока | размер рабочего множества | после переключения, растянуто по времени |

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

Вопрос, ради которого статья вообще завела разговор о цене, — дорого ли в этой системе иметь много потоков — читается по той же таблице с другого края: потоки одного процесса не платят по строке смены адресного пространства вовсе, потому что корень трансляции у них общий и инвалидировать нечего, и заметно экономят на последней строке, потому что отображения и рабочее множество у них тоже общие. В таблицу не попадает только постоянная цена потока, платящаяся не за переключение, а за существование, — стек ядра, 16 КиБ у Linux и FreeBSD, занятый всё время, пока поток жив ([статья про стеки](./os-stacks.md)).

## Вытеснение и возврат

### Вытесняемое ядро против невытесняемого

Развилка формулируется одним вопросом: вправе ли ядро отобрать процессор у потока, исполняющегося в ядре, в произвольной точке?

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

Вытесняемое ядро отвечает «да, если счётчик запрета вытеснения равен нулю». Задержка перестаёт зависеть от длины участков, зато всякое обращение к данным процессора требует явного запрета вытеснения, а всякое допущение «это не может измениться, пока я не отпустил процессор» становится подозрительным.

Linux эту развилку последовательно закрывает, и это тот случай, когда общеизвестное представление устарело сильнее всего. Модель ленивого вытеснения появилась в 6.13; в 7.0 набор моделей урезали, и сообщение коммита говорит прямо: `PREEMPT_NONE` оставлен только архитектурам, которые вытеснение не поддерживают вовсе, `PREEMPT_VOLUNTARY` — тем, у которых ещё нет ленивого режима, «с дальней целью сделать это множество пустым и полностью убрать добровольное вытеснение и `cond_resched()`». Итог назван там же: «у современных архитектур (arm64, loongarch, powerpc, riscv, s390, x86) остаётся только две модели вытеснения: полная и ленивая». Конфигурация реального времени `PREEMPT_RT` при этом перестала быть внешним набором заплат: в 6.12 в основную ветку влили последний недостающий кусок — переписанный `printk()`, — после чего ядро стало собираться с ней из штатных исходников.

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

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

### Возврат в пользовательское пространство: место, где делается отложенное

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

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

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

В-третьих, переход всё равно происходит, и работа обходится без дополнительного пересечения границы.

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

FreeBSD делает то же самое под другим именем: на пути возврата из системного вызова проверяется поле AST текущего потока, и при ненулевом значении вызывается общая функция обработки отложенного, после чего проверка повторяется. Одинаковая конструкция в двух независимо выросших ядрах — хороший признак того, что она не случайна.

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

### RCU как механизм, которому нужна эта граница

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

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

## Налог на переходы

### Что сегодня стоит в точке входа

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

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

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

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

### Как это поменяло компромиссы

До всего этого путь к быстрой работе на границе был один — сделать переход дешевле. Так появились специальные инструкции вместо программного прерывания, так появился vDSO, где часть вызовов не входит в ядро вообще ([статья 8](./os-kernel-as-program.md)). Этот путь во многом исчерпан, а контрмеры сдвинули стоимость в обратную сторону.

Заменил его другой: не делать перехода чаще, чем нужно. Отсюда интерфейсы, где одно пересечение границы обслуживает много операций, — кольца подачи и завершения, куда процесс складывает запросы, а ядро забирает их пачкой, вплоть до режима, где ядерный поток опрашивает кольцо и переходов не происходит вовсе. В Linux это `io_uring`; та же логика стоит за групповыми вариантами сетевых вызовов и, с другой стороны, за vDSO.

Для проектирования своей ОС отсюда следует вывод, который дороже всего игнорировать: число пересечений границы — параметр интерфейса, а не деталь реализации. Интерфейс, спроектированный как «один вызов — одна операция», нельзя пакетировать задним числом, не заводя второй интерфейс рядом. Решать это надо тогда же, когда решается форма системного вызова, а не после первого профилирования.

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

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

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

Где выбор есть:

| Решение | Рекомендация | Почему и что будет, если передумать |
| --- | --- | --- |
| Диспетчер событий | двухуровневый: тонкий ассемблер по вектору, разбор причины на C | Ни одна из трёх аппаратных таблиц не делит события так, как нужно ядру: одна дробит мельче полезного, другая сваливает все синхронные исключения в один адрес; второй уровень вы пишете в любом случае |
| Где живёт «стек текущего потока» | одно поле в структуре потока | `RSP0`, `SP_EL1` и `sscratch` — три способа ответить на один вопрос; при двух ответах код входа перестаёт быть симметричным |
| Быстрый возврат из системного вызова | не сразу; когда добавляете — с полным списком предусловий рядом | `sysret` восстанавливает меньше, чем `iret`, и каждая невосстановленная мелочь — ваша проверка; цена ошибки здесь — захват ядра |
| Обнуление регистров при входе | да, сразу | Десяток инструкций; убирает пользовательские значения из регистров, которыми потом может воспользоваться гаджет |
| Восстановление векторного состояния | при переключении, как во FreeBSD | Проще; отложенное восстановление до возврата в пользовательский режим окупается на коротких системных вызовах и добавляется локально |
| Ленивое восстановление через ловушку | нет | Схема мертва не из-за скорости: остаточное состояние читается спекулятивно (CVE-2018-3665) |
| Вытеснение в ядре | начать невытесняемым, разметку ввести сразу | Счётчик запрета вытеснения и обёртки вокруг данных процессора компилируются в ничто, пока вытеснения нет; найти постфактум места, полагавшиеся на невытесняемость, нечем |
| Где делается отложенная работа | одна точка на возврате в пользовательское пространство, циклом | Единственное место, где заведомо ничего не держится; цикл нужен потому, что работа порождает работу |
| Пакетность интерфейса | решать вместе с формой системного вызова | Интерфейс «один вызов — одна операция» задним числом не пакетируется |

## Как это сделано в трёх системах

| | Linux | FreeBSD (amd64) | xv6-riscv |
| --- | --- | --- | --- |
| Слой входа | общий `kernel/entry/` с 5.9; у ARM64 своя копия | свой на архитектуру | один файл на всё |
| Стек ядра при `syscall` | вершина из переменной на процессор | вершина из `RSP0` в данных процессора | вершина из кадра ловушки процесса |
| Изоляция таблиц | урезанный тамбур входа | трамплин со всем текстом ядра | раздельные таблицы всегда, вход через страницу-трамплин |
| Векторное состояние | сохранение при переключении, восстановление при возврате | сохранение и восстановление при переключении | не сохраняется: в `swtch.S` его просто нет |
| Отложенная работа | цикл по флагам потока при возврате | поле AST потока, проверка после вызова | проверка флагов в обработчике ловушки |
| Вытеснение в ядре | полное или ленивое, третьего на актуальных архитектурах нет | вытесняемое (`options PREEMPTION` в GENERIC), с критическими секциями | нет: переключение только при явной уступке |
| Доступ к памяти процесса | окно вокруг копирования, барьер спекуляции, маскирование указателя | окно вокруг копирования | обход таблиц процесса программно |

Крайний правый столбец полезен именно тем, что он бедный. xv6 держит для ядра отдельные таблицы страниц, а значит, доступ к памяти процесса не может быть окном: `copyin()` сам обходит таблицы процесса, получает физический адрес страницы и копирует уже по нему. Это не упрощение ради учебности, а другая точка в том же пространстве решений, разобранном в [третьей статье](./os-kernel-memory-map.md): раздельные адресные пространства снимают вопрос об окне доступа и взамен делают каждое копирование обходом дерева. И там же видно, откуда взялась страница-трамплин, отображённая по одному и тому же адресу в обеих картах: инструкция, переключающая корень трансляции, обязана быть отображена и до, и после. Ровно та же конструкция стоит в изоляции таблиц у Linux — но там она появилась не от простоты устройства, а от необходимости после Meltdown, и это хорошая иллюстрация того, что одна и та же фигура в разных системах может означать разное.

## Источники

- [Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 3A](https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html) — таблица дескрипторов прерываний и механизм IST, переключение стека при смене уровня привилегий, `SYSCALL`/`SYSRET` и регистры модели `STAR`, `LSTAR`, `FMASK`, SMEP и SMAP с флагом `AC`
- [Intel: Flexible Return and Event Delivery (FRED) Specification](https://www.intel.com/content/www/us/en/content-details/779982/flexible-return-and-event-delivery-fred-specification.html) — архитектура FRED, уровни стека, `ERETS`/`ERETU`, `LKGS`
- [Arm Architecture Reference Manual for A-profile (DDI 0487)](https://developer.arm.com/documentation/ddi0487/latest/) — нормативное описание модели исключений AArch64: таблица векторов и `VBAR_ELx`, `ELR_ELx`, `SPSR_ELx`, `ESR_ELx`, выбор указателя стека при входе, PAN и PXN. По самому документу здесь не сверялось ничего: `developer.arm.com` отдаёт вместо текста JS-оболочку. Все утверждения про ARM64 в статье выведены из дерева ядра и `arch/arm64/tools/sysreg` — см. строку с исходниками ниже
- [RISC-V Privileged Architecture, глава «Supervisor-Level ISA» (редакция v20260120)](https://docs.riscv.org/reference/isa/v20260120/priv/supervisor.html) — `stvec` с прямым и векторным режимами, `scause` со старшим битом прерывания, `sepc`, `stval`, `sscratch` как указатель на локальный контекст супервизора, биты `SPP`, `SPIE`, `SUM` и `MXR`, поведение `sret`, а также прямой запрет исполнять инструкции со страниц пользователя независимо от `SUM`
- Документация ядра Linux: [`arch/x86/entry_64.rst`](https://docs.kernel.org/arch/x86/entry_64.html) — перечень точек входа, хрупкость `swapgs`, дешёвая и дорогая проверка режима, три следствия режима `paranoid=1`; [`core-api/entry.rst`](https://docs.kernel.org/core-api/entry.html) — порядок действий на входе и выходе, отслеживание контекста для RCU, правила `noinstr`; [`arch/x86/exception-tables.rst`](https://docs.kernel.org/arch/x86/exception-tables.html) — история отказа от `verify_area()` и механика таблицы исключений; [`arch/x86/x86_64/fred.rst`](https://docs.kernel.org/arch/x86/x86_64/fred.html) — цели FRED, программная диспетчеризация, уровни стека вместо IST, запрет `swapgs`; [`admin-guide/hw-vuln/`](https://docs.kernel.org/admin-guide/hw-vuln/index.html) — восемнадцать разделов про аппаратные уязвимости (столько записей в `toctree` файла `index.rst` на `v7.2`), [`spectre.rst`](https://docs.kernel.org/admin-guide/hw-vuln/spectre.html) и [`attack_vector_controls.rst`](https://docs.kernel.org/admin-guide/hw-vuln/attack_vector_controls.html) с пятью векторами атаки и форматом `mitigations=`; [`userspace-api/seccomp_filter.rst`](https://docs.kernel.org/userspace-api/seccomp_filter.html) — запрет разыменования указателей в фильтре как защита от TOCTOU и требование к трассировщику читать аргументы до принятия решений; [`arch/arm64/sve.rst`](https://docs.kernel.org/arch/arm64/sve.html) — обнуление старших бит `Z0`–`Z31`, `P0`–`P15` и `FFR` при системном вызове; [`RCU/Design/Requirements`](https://docs.kernel.org/RCU/Design/Requirements/Requirements.html) — квиесцентные состояния и то, что пользовательский режим одно из них
- Исходники ядра Linux: [`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` целиком, комментарий «SYSCALL ничего не сохраняет на стеке и не меняет `rsp`», правило «если пользователь мог изменить кадр — всегда `iret`», `IBRS_ENTER`, `UNTRAIN_RET`, `CLEAR_BRANCH_HISTORY`, `CLEAR_CPU_BUFFERS`, трамплинный стек из `tss.sp0`, `FILL_RETURN_BUFFER` в `__switch_to_asm`; [`arch/x86/entry/syscall_64.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/entry/syscall_64.c) — пять условий выбора `sysret`, комментарий про `#GP` в пространстве ядра и `add_random_kstack_offset()` сразу за `syscall_enter_from_user_mode()`; [`arch/x86/entry/calling.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/entry/calling.h) — `PUSH_AND_CLEAR_REGS` с пометками `nospec`; [`arch/x86/include/asm/uaccess.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/include/asm/uaccess.h) и [`uaccess_64.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/include/asm/uaccess_64.h) — `__uaccess_begin_nospec()`, `mask_user_address()` и комментарий про альтернативу барьеру; [`arch/x86/include/asm/smap.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/include/asm/smap.h) — `stac`/`clac` и отдельная пара для LASS; [`arch/x86/kernel/cpu/common.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/cpu/common.c) — `syscall_init()` и оговорка про FRED; [`arch/x86/include/asm/fpu/sched.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/include/asm/fpu/sched.h) — жадное сохранение и `TIF_NEED_FPU_LOAD`; [`arch/arm64/kernel/entry.S`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/kernel/entry.S) — шестнадцать слотов по 128 байт, `clear_gp_regs`, `cpu_switch_to`; [`arch/arm64/kernel/entry-common.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/kernel/entry-common.c) — разбор поля EC регистра `ESR_EL1` одним `switch`, обработчики первого обращения к SVE и SME; [`arch/arm64/kernel/syscall.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/kernel/syscall.c) — `add_random_kstack_offset()` внутри `invoke_syscall()`, то есть после `syscall_trace_enter()`; [`arch/arm64/include/asm/uaccess.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/include/asm/uaccess.h) — аппаратный PAN и программная эмуляция через `TTBR0_EL1`; [`arch/arm64/include/asm/pgtable-prot.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/include/asm/pgtable-prot.h) — `PTE_PXN` у всех пользовательских отображений; [`arch/arm64/kernel/fpsimd.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/kernel/fpsimd.c) — `TIF_FOREIGN_FPSTATE` и подробный комментарий о последовательности; [`arch/arm64/include/uapi/asm/sve_context.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/include/uapi/asm/sve_context.h) — размеры векторного состояния и `ZA_SIG_REGS_SIZE` как квадрат длины вектора; [`arch/arm64/include/asm/fpsimd.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/include/asm/fpsimd.h) и [`arch/arm64/tools/sysreg`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/tools/sysreg) — `ARCH_SVE_VQ_MAX`, выведенный из четырёхбитного поля `LEN` регистра `ZCR_ELx`; [`arch/riscv/kernel/entry.S`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/kernel/entry.S) — обмен `sscratch`, сброс `SR_SUM` при входе, `excp_vect_table`, `__switch_to` с сохранением флага `SUM`; [`arch/riscv/include/asm/uaccess.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/riscv/include/asm/uaccess.h) — установка и снятие `SR_SUM` вокруг копирования; [`kernel/entry/common.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/entry/common.c) — цикл отложенной работы при возврате и условие вытеснения при возврате в ядро; [`kernel/sched/core.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/sched/core.c) — `context_switch()`, ленивый TLB для потоков ядра и комментарий «переключение контекста перевернуло стек под нами»; [`kernel/Kconfig.preempt`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/Kconfig.preempt) — состав моделей вытеснения; [`include/linux/uaccess.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/linux/uaccess.h) — три случая `copy_struct_from_user()`; [`include/linux/randomize_kstack.h`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/include/linux/randomize_kstack.h) — требование вызывать `add_random_kstack_offset()` после сохранения пользовательских регистров на стек; [`io_uring/io_uring.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/io_uring/io_uring.c) — комментарий о нестабильности чтений из разделяемого кольца; [`mm/Kconfig`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/mm/Kconfig) и [`security/Kconfig`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/security/Kconfig) — умолчания `mmap_min_addr`
- [Коммит 7dadeaa6e851 «sched: Further restrict the preemption modes»](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=7dadeaa6e851e7d67733f3e24fc53ee107781d0f) — мотивация ленивого вытеснения и формулировка «у современных архитектур остаётся только две модели вытеснения». Дата коммита — 18 декабря 2025, и обе новые зависимости (`ARCH_NO_PREEMPT` у `PREEMPT_NONE`, `!ARCH_HAS_PREEMPT_LAZY` у `PREEMPT_VOLUNTARY`) уже стоят в [`kernel/Kconfig.preempt` на теге `v7.0`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/Kconfig.preempt?h=v7.0) и отсутствуют [на `v6.18`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/Kconfig.preempt?h=v6.18)
- Остальные привязки к версиям сверены сравнением тегов того же дерева: `X86_FRED` в `arch/x86/Kconfig` есть на [`v6.9`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/Kconfig?h=v6.9) и нет на `v6.8`; файл `kernel/entry/common.c` есть на [`v5.9`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/entry/common.c?h=v5.9) и нет на `v5.8`; `PREEMPT_LAZY` есть в `kernel/Kconfig.preempt` на [`v6.13`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/Kconfig.preempt?h=v6.13) и нет на `v6.12`; `ARCH_SUPPORTS_RT` впервые выбирается x86, arm64 и riscv на [`v6.12`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/Kconfig?h=v6.12) и не выбирается ни одной из них на `v6.11`; LASS в `arch/x86/include/asm/smap.h` есть на [`v7.0`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/include/asm/smap.h?h=v7.0) и нет на `v6.18`
- [CVE-2012-0217](https://nvd.nist.gov/vuln/detail/CVE-2012-0217) — перечень затронутых систем и формулировка про «неверное использование спецификации Intel»
- [Lazy FP state restore (CVE-2018-3665)](https://nvd.nist.gov/vuln/detail/CVE-2018-3665) — уязвимость, закрывшая ленивое восстановление векторного состояния через ловушку; переход Linux на жадное сохранение состоялся в 4.6, ленивый режим удалён в 4.10: `eagerfpu` четырнадцать раз встречается в [`arch/x86/kernel/fpu/init.c` на теге `v4.9`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/fpu/init.c?h=v4.9) и ни разу — [на `v4.10`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/arch/x86/kernel/fpu/init.c?h=v4.10)
- Исходники FreeBSD: [`sys/amd64/amd64/exception.S`](https://cgit.freebsd.org/src/tree/sys/amd64/amd64/exception.S) — вход по `syscall` через `PCPU(RSP0)`, флаг полного возврата, проверка поля AST, `handle_ibrs_entry`/`handle_ibrs_exit` и обнуление регистров перед `sysretq`; [`sys/amd64/amd64/cpu_switch.S`](https://cgit.freebsd.org/src/tree/sys/amd64/amd64/cpu_switch.S) — сохранение состояния FPU при переключении и заполнение буфера возвратов; [`sys/amd64/amd64/fpu.c`](https://cgit.freebsd.org/src/tree/sys/amd64/amd64/fpu.c) — восстановление в `fpu_activate_sw()` и остаточная роль `fpudna()`; [`copy(9)`](https://man.freebsd.org/cgi/man.cgi?query=copy&sektion=9) — семейство `copyin`/`copyout` и варианты, не допускающие отказа страницы
- [xv6-riscv: `kernel/trampoline.S`](https://github.com/mit-pdos/xv6-riscv/blob/riscv/kernel/trampoline.S) — страница входа, отображённая по одному адресу в обеих картах, и переключение `satp` внутри неё; [`kernel/swtch.S`](https://github.com/mit-pdos/xv6-riscv/blob/riscv/kernel/swtch.S) — те же четырнадцать регистров ядерного контекста и отсутствие в нём векторного состояния; [`kernel/vm.c`](https://github.com/mit-pdos/xv6-riscv/blob/riscv/kernel/vm.c) — `copyin()`, обходящий таблицы процесса и копирующий по физическому адресу
- [`perf-bench(1)`](https://man7.org/linux/man-pages/man1/perf-bench.1.html) — `syscall basic` для пропускной способности системного вызова и `sched pipe` для итерации из двух переключений и четырёх системных вызовов (цикл `write` плюс `read` у каждого из двух участников — [`tools/perf/bench/sched-pipe.c`](https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/tools/perf/bench/sched-pipe.c))

---

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

- [Страничная трансляция: таблицы, TLB, разрядность](./os-paging-and-tlb.md) — вторая статья цикла: теги адресных пространств и инвалидация, из которых складывается самая дорогая часть переключения контекста
- [Карта памяти ядра: половины, прямое отображение, KASLR](./os-kernel-memory-map.md) — третья статья цикла: изоляция таблиц страниц, входной тамбур и его цена на каждом пересечении границы
- [Стеки: ядра, прерываний, пользовательские](./os-stacks.md) — четвёртая статья цикла: стеки как ресурс; сюда она отложила переключение на стек ядра при входе
- [Ядро как программа: freestanding, компоновка, ABI, выбор языка](./os-kernel-as-program.md) — восьмая статья цикла: ABI системного вызова, vDSO, почему точки входа пишут на ассемблере и почему в ядре нет плавающей точки
- [Устройство ОС — план цикла статей](./os-knowledge-base-plan.md) — состав и порядок цикла
- [Linux — справочник команд: приложения и системные вызовы](./linux-tldr-syscall.md) — та же граница со стороны наблюдателя: strace, ltrace и цена трассировки
- [TLPI — база знаний](./tlpi-start.md) — та же граница со стороны прикладного программиста: системные вызовы, сигналы, обработка ошибок
- [Linux — безопасность: hardening, SELinux/AppArmor](./linux-security-hardening.md) — как контрмеры и seccomp выглядят со стороны эксплуатации, а не реализации
- [Загрузка: от питания до первой инструкции ядра](./os-boot-sequence.md) — шестая статья цикла: первое пересечение границы, собранное вручную, — запуск начального процесса через этот же путь возврата
- [Структура ядра: монолит, микроядро и что между](./os-kernel-structure.md) — девятая статья цикла: тот же налог на переход, но платящийся на каждый вызов драйвера
