Перейти до вмісту

Ядро і межа user/kernel

printf("hi") виглядає як звичайний виклик функції. Насправді там відбувається перехід між двома світами з різними правами, зі збереженням контексту, перевіркою аргументів і поверненням назад, і коштує це приблизно у сто разів більше.

Якщо розуміти цю межу, стає ясно чимало речей, які інакше здаються довільними: чому буферизований ввід швидший за небуферизований, навіщо взагалі з’явився io_uring, чому мікроядра програли і чому strace показує зовсім не те, що написано у вихідному коді.

Передумови. Режими виконання і переривання (модуль 2).

Що відбувається при системному виклику

Section titled “Що відбувається при системному виклику”
Шлях системного виклику від printf у режимі користувача через межу в ядро і назад режим користувачарежим ядраprintf("hi")ваш кодwrite() у libcще режим користувачаінструкція syscallномер виклику в регістріточка входузбереження контекстуsys_writeперевірка аргументівповернення: контекст відновлено, результат у регістрімежу перетинають двічі — інакше в режим ядра не потрапити
libc — ще режим користувача. Справжня межа проходить на інструкції syscall, і перетнути її можна лише через наперед задану точку входу ядра.

Програма кладе номер виклику й аргументи у визначені регістри та виконує інструкцію syscall. Процесор перемикає режим і передає керування за адресою, яку ядро зареєструвало ще при завантаженні. Не за тією, яку вказала програма: інакше вся ізоляція була б декоративною.

Далі ядро зберігає контекст, за номером знаходить обробник у таблиці й перевіряє аргументи. Це не формальність: вказівник, переданий процесом, цілком може вказувати в пам’ять ядра, і ядро зобов’язане таке помітити.

На поверненні контекст відновлюється, режим користувача повертається, а результат лягає в регістр.

Звичайний виклик функції займає одиниці наносекунд, системний — сотні. Після мітигацій Meltdown і Spectre (модуль 16) ще більше, бо перемикання таблиць сторінок стало обов’язковим.

Ця різниця в сто разів пояснює чимало:

  • printf буферизує вивід і робить один write замість сотні, тому вивід у файл і вивід на термінал мають різну швидкість;
  • epoll замість select економить не обчислення, а самі виклики;
  • io_uring узагалі з’явився заради того, щоб прибрати системний виклик із кожної операції (модуль 13);
  • vDSO — це невеликий шматок коду ядра, відображений у кожен процес, щоб дешеві запити на кшталт gettimeofday обходилися без перетину межі.

Хоч би яка була архітектура, ядро містить приблизно одні й ті самі підсистеми:

Підсистема Відповідає за Модуль
Керування процесами створення, стани, планування 6, 7
Керування пам’яттю таблиці сторінок, виділення, заміщення 1012
Віртуальна файлова система єдиний інтерфейс до різних ФС 15
Підсистема вводу-виводу драйвери, черги запитів 13
Мережевий стек сокети, протоколи, маршрутизація
Безпека права, ізоляція, аудит 16

Питання архітектури — не «що робить ядро», а що з цього виконується в режимі ядра, а що винесено назовні.

Монолітне ядро й мікроядро

Section titled “Монолітне ядро й мікроядро”

Монолітне ядро тримає всі підсистеми в одному адресному просторі режиму ядра, тож виклик між ними є звичайним викликом функції. Це швидко, але ізоляції всередині немає, і помилка в будь-якому драйвері здатна зруйнувати всю систему.

Мікроядро лишає в режимі ядра тільки мінімум: планування, базове керування пам’яттю і передавання повідомлень. Драйвери, файлові системи й мережевий стек виносяться в окремі процеси режиму користувача, тому збій драйвера вбиває один процес, який можна перезапустити.

Платити за це доводиться тим, що колишній виклик функції перетворюється на обмін повідомленнями через ядро. Одне читання файлу коштує вже кількох перемикань контексту. Історично ця вартість і вирішила суперечку на користь монолітних ядер.

Гібридні ядра, як-от Windows NT або XNU у macOS, декларують мікроядерну структуру, але з міркувань швидкості тримають критичні підсистеми в режимі ядра.

Протилежна крайність — уніядро, де застосунок і ядро збираються в один образ і працюють в одному адресному просторі без поділу на режими. Ізоляції там немає взагалі, бо єдиний застосунок нема від кого ізолювати, зате немає й вартості переходів. Ніша в них вузька: віртуальні машини, де ізоляцію забезпечує гіпервізор.

Terminal window
strace -c -- cat /etc/hostname

Зведення системних викликів одного запуску. Видно, що більшість часу припадає на кілька викликів, а решта — накладні витрати на запуск.

Terminal window
strace -e trace=write -- sh -c 'printf "a"; printf "b"; printf "c"' > /dev/null

Три printf дають три виклики write на термінал і один — у файл, бо в файл libc буферизує. Це і є вартість межі, помітна неозброєним оком.

Terminal window
ltrace -e 'printf' -- /bin/echo hi 2>&1 | head -3

Виклики бібліотеки — інший рівень. Порівняння виводу ltrace і strace показує, де саме проходить межа.

Terminal window
grep -c . /usr/include/asm/unistd_64.h 2>/dev/null || \
ausyscall --dump 2>/dev/null | wc -l

Скільки всього системних викликів існує. Число росте з кожним ядром і майже ніколи не зменшується — через зобов’язання не ламати ABI.

Terminal window
cat /proc/self/maps | grep -E 'vdso|vsyscall'

vDSO, відображений у кожен процес. Саме звідти беруться gettimeofday і clock_gettime без переходу в режим ядра.

Terminal window
lsmod | head; modinfo $(lsmod | awk 'NR==2{print $1}') 2>/dev/null | head -5

Завантажені модулі ядра й опис одного з них.

Terminal window
dmesg --level=err,warn | tail -5

Повідомлення ядра. Помилка в модулі проявляється саме тут — і, на відміну від помилки в програмі, здатна вплинути на всю систему.

Типові помилки розуміння

Section titled “Типові помилки розуміння”

«Виклик функції з libc — це системний виклик». libc працює в режимі користувача, і printf може взагалі обійтися без системного виклику, якщо дані помістилися в буфер.

«Системний виклик — це просто виклик функції ядра». Це перемикання режиму зі збереженням контексту й перевіркою аргументів, і коштує воно приблизно у сто разів більше за звичайний виклик.

«Root виконує код у режимі ядра». Не виконує. Процес від root працює в режимі користувача так само, як будь-який інший, просто ядро дозволяє йому більше на своїх системних викликах.

«Модулі роблять Linux мікроядром». Модуль виконується в адресному просторі ядра з повними правами. Мікроядро виносить драйвери в окремі процеси режиму користувача, і це принципово інша модель.

«Мікроядра надійніші, тому їх треба використовувати всюди». Надійніші, і платять за це вартістю обміну повідомленнями. Там, де надійність важливіша за пропускну здатність, вони й використовуються: QNX в автомобілях, seL4 в авіації.

Перевір себе

1. Куди процесор передає керування при виконанні інструкції syscall?
2. Три виклики printf у програмі дали один системний виклик write. Чому?
3. Процес запущено від root. У якому режимі виконується його код?
4. Чому мікроядра програли монолітним у системах загального призначення?
5. Що таке vDSO і навіщо він потрібен?
6. Чому в Linux існують і open, і openat, і clone, і clone3?

A1 — власний shell спирається саме на цей матеріал: кожна дія оболонки є системним викликом, і strace на власній програмі показує це наочно.