Files
eduplan-cpp-eltex/lessons/D6_kernel.md
T

381 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# D6, часть 1. Ядро и embedded обзорно (урок)
Уровень этого урока — «уверенно отвечать словами на собеседовании», а не «написать драйвер
с нуля». Идём от границы user space/kernel space к модулю, от модуля к драйверу символьного
устройства, от драйвера к железу (device tree, cross-compile, загрузка платы). Опорные
источники для самостоятельного углубления: docs.kernel.org, The Linux Kernel Module
Programming Guide (LKMPG), `man 2`/`man 3`/`man 7` (системные вызовы, библиотечные функции,
конвенции ядра).
## 1. User space vs kernel space и системный вызов как переход границы
Процессор x86 поддерживает уровни привилегий (кольца защиты): **кольцо 0** — режим ядра,
полный доступ к железу и памяти; **кольцо 3** — режим пользователя, обычные программы, без
прямого доступа к физической памяти чужих процессов или портам ввода-вывода. Ядро работает в
кольце 0, все обычные процессы — в кольце 3. Разделение существует ради защиты и стабильности:
если бы любая программа могла напрямую писать в память другого процесса или в регистры
диска, ошибка или злой умысел в одной программе обрушивали бы всю систему.
Чтобы попросить ядро что-то сделать (открыть файл, выделить память, создать процесс),
пользовательская программа не может просто вызвать функцию ядра — она делает **системный
вызов**: специальную инструкцию процессора, которая переключает CPU в привилегированный режим
и передаёт управление фиксированному обработчику в ядре, а после выполнения запроса управление
возвращается программе обратно в непривилегированном режиме. На x86-64 эту инструкцию зовут
**`syscall`**, на ARM — **`svc`** (supervisor call). Оба случая — не обычный вызов функции
(`call`), а специальная инструкция именно потому, что она обязана сменить уровень привилегий
процессора, а не просто передать управление по адресу.
**Факты для карточек**
- base | В каком кольце защиты x86 работает ядро Linux? — в кольце 0 (пользовательские процессы — в кольце 3)
- base | Какая инструкция делает системный вызов на x86-64? — `syscall` (на ARM — `svc`)
- core | Почему системный вызов — отдельная инструкция процессора, а не обычный `call`? — обычный `call` не меняет уровень привилегий CPU, а переход в кольцо 0 требует именно смены режима процессора
Почему дальше: код, работающий в кольце 0, можно добавлять в ядро двумя разными способами —
разберём, чем модуль ядра отличается от кода, встроенного в ядро на этапе сборки.
## 2. Модуль ядра vs встроенный в ядро
Функциональность можно либо **встроить в ядро** на этапе сборки (код компилируется прямо в
образ ядра, доступен сразу при загрузке, но требует пересборки и перезагрузки при любом
изменении), либо оформить как **загружаемый модуль ядра** (`.ko`-файл, подключается и
отключается в работающей системе без перезагрузки). Встроенным делают то, что нужно с самого
первого момента загрузки (например, драйвер корневой файловой системы); модулем — то, что
может понадобиться позже или не понадобиться вовсе (драйвер конкретного периферийного
устройства), чтобы не раздувать образ ядра и не грузить лишний код.
Инструменты управления модулями:
- **`insmod <path.ko>`** — загружает модуль по прямому пути к файлу, без разрешения
зависимостей от других модулей.
- **`rmmod <name>`** — выгружает модуль по имени (если счётчик использования равен нулю).
- **`modprobe <name>`** — загружает модуль по имени, сам находит файл в
`/lib/modules/$(uname -r)/` и подгружает зависимости по карте `modules.dep` (строится
утилитой `depmod`) — на практике используется чаще `insmod`, именно из-за автоматических
зависимостей.
- **`lsmod`** — список загруженных сейчас модулей (по сути читает `/proc/modules`), с
размером и счётчиком использования.
- **`modinfo <name|path>`** — метаданные модуля (лицензия, описание, параметры, зависимости)
без его загрузки в ядро.
**Факты для карточек**
- base | Чем `insmod` отличается от `modprobe`? — `insmod` грузит модуль по прямому пути без разрешения зависимостей, `modprobe` находит модуль по имени и сам подгружает зависимости
- base | Какая команда показывает метаданные модуля, не загружая его? — `modinfo`
- core | Откуда `modprobe` берёт карту зависимостей модулей? — из файла `modules.dep`, который строит утилита `depmod`
Почему дальше: раз модуль — это отдельно загружаемый код, у него должна быть точка входа и
точка выхода из ядра — разберём структуру простейшего модуля.
## 3. Структура простейшего модуля
```c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init hello_init(void)
{
printk(KERN_INFO "hello: module loaded\n");
return 0; // 0 — успех; ненулевой код отменяет загрузку модуля
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "hello: module unloaded\n");
}
module_init(hello_init); // вызывается ядром при insmod/modprobe
module_exit(hello_exit); // вызывается ядром при rmmod
MODULE_LICENSE("GPL");
```
`module_init`/`module_exit` — не обычные вызовы функций, а макросы, которые регистрируют
функции как точки входа/выхода: сам код внутри них ядро вызывает автоматически в момент
`insmod`/`rmmod`, программист их напрямую не зовёт. `MODULE_LICENSE("GPL")` обязателен: без
него или с иной лицензией ядро помечает себя как **tainted** (загрязнённое) и закрывает
модулю доступ к символам, экспортированным только для GPL-кода (`EXPORT_SYMBOL_GPL`).
`printk` — это `printf` уровня ядра, пишет не в консоль процесса, а в кольцевой буфер ядра.
Первым аргументом обычно идёт уровень важности — восемь уровней от `KERN_EMERG` (0,
критично) до `KERN_DEBUG` (7, отладочная информация); сообщения с уровнем выше порога
консоли попадают только в буфер, но не печатаются на экран сразу. Прочитать буфер целиком —
команда **`dmesg`**.
**Факты для карточек**
- base | Какой макрос ядра — аналог `printf`? — `printk`
- base | Команда для чтения буфера сообщений ядра? — `dmesg`
- core | Сколько уровней важности у `printk` и какие крайние? — 8 уровней, от `KERN_EMERG` (0) до `KERN_DEBUG` (7)
- core | Что произойдёт с модулем без `MODULE_LICENSE("GPL")`? — ядро станет tainted и закроет модулю доступ к символам `EXPORT_SYMBOL_GPL`
- core | Кто вызывает функции, зарегистрированные `module_init`/`module_exit`? — ядро автоматически, при `insmod`/`modprobe` и `rmmod` соответственно, а не сам программист
Почему дальше: модулю часто нужно принимать настройки снаружи ещё до его собственной логики —
разберём, как модуль объявляет параметры запуска.
## 4. Параметры модуля: `module_param`
```c
static int count = 1;
module_param(count, int, S_IRUGO); // третий аргумент — права доступа в sysfs
```
`module_param(имя, тип, права)` (заголовок `<linux/moduleparam.h>`) делает переменную
настраиваемой при загрузке модуля — значение можно передать прямо в `insmod modname.ko
count=5`. Третий аргумент — режим доступа файла в sysfs: `0` означает, что параметр доступен
только при загрузке и не публикуется отдельным файлом, ненулевое значение (например
`S_IRUGO` — чтение всем) создаёт файл `/sys/module/<имя_модуля>/parameters/<count>`, из
которого параметр можно прочитать (а при подходящих правах — и переписать) уже после загрузки.
**Факты для карточек**
- base | Как передать параметр модулю при загрузке? — `insmod modname.ko имя_параметра=значение`
- core | Что означает третий аргумент `module_param`, если он ненулевой? — параметр публикуется файлом в `/sys/module/<имя>/parameters/<имя>` с заданными правами доступа
Почему дальше: файл параметра в sysfs — частный случай общего механизма, которым ядро вообще
разговаривает с пользовательским пространством через файловую систему, — `/proc` и `/sys`.
## 5. `/proc` и `/sys` как интерфейс к ядру
**`/proc`** (procfs) — виртуальная файловая система, изначально созданная для информации о
процессах (`/proc/<pid>/...`), позже расширенная общей информацией о ядре (`/proc/cpuinfo`,
`/proc/meminfo`, `/proc/modules`); файлы внутри часто содержат несколько значений свободным
текстом. **`/sys`** (sysfs, с ядра 2.6) — более новый и структурированный интерфейс,
отражающий модель устройств и драйверов ядра (шины, устройства, классы), с соглашением «один
файл — одно значение», что упрощает и чтение скриптами, и программную запись настроек. Оба
это не диски, а генерируются ядром на лету при каждом обращении — `cat /proc/meminfo`
формирует ответ в момент чтения, а не читает файл с диска.
**Факты для карточек**
- base | Чем отличается соглашение о содержимом файлов `/sys` от `/proc`? — в `/sys` одно значение на файл, в `/proc` файл может содержать несколько значений свободным текстом
- core | Откуда `cat /proc/meminfo` берёт данные? — ядро формирует ответ на лету в момент чтения, это не файл на диске
Почему дальше: `/proc` и `/sys` — интерфейсы общего назначения; когда нужно управлять
конкретным устройством операциями `open`/`read`/`write`, пишут символьный драйвер.
## 6. Символьный драйвер: `file_operations`, major/minor, барьер user/kernel
Драйвер символьного устройства регистрирует набор функций-обработчиков в структуре
`struct file_operations` — как минимум `.open`, `.read`, `.write`, `.release` (плюс,
например, `.unlocked_ioctl`); ядро вызывает нужный обработчик, когда пользовательский процесс
делает соответствующий системный вызов над файлом устройства в `/dev`. Регистрация —
`register_chrdev(major, name, &fops)`: если `major` передан как `0`, ядро само подбирает
свободный старший номер.
Устройство идентифицируется парой **major:minor**: **major** (старший номер) определяет
драйвер, обслуживающий устройство, **minor** (младший) — конкретный экземпляр внутри этого
драйвера (например, второй последовательный порт того же типа). `dev_t` в современном ядре —
32-битное число: 12 бит под major (до 4095) и 20 бит под minor (до 1 048 575).
Внутри `.read`/`.write` драйвер **не может напрямую разыменовать указатель, пришедший из
пользовательского пространства** — это чужое адресное пространство, страница может быть не
загружена в память или указатель вообще некорректен, а прямое разыменование либо уронит
ядро (kernel oops), либо (на CPU с SMAP/SMEP) вызовет аппаратный запрет. Вместо этого
обязательны **`copy_to_user`**/**`copy_from_user`** — они безопасно копируют данные через
границу, сами обрабатывают отсутствующую страницу и возвращают число байт, которые
**не** удалось скопировать (0 — полный успех).
**Ловушки**
- Разыменовать пользовательский указатель напрямую в `.read`/`.write` → крах ядра (oops) или
аппаратный запрет на SMAP/SMEP-системах → видно как немедленный крэш при обращении к
устройству, а не как «иногда неверные данные».
- Забыть проверить возвращаемое значение `copy_to_user`/`copy_from_user` → часть данных не
скопирована, а код считает операцию успешной → приложение получает частично мусорный буфер
без явной ошибки.
**Факты для карточек**
- base | Какие 4 обработчика минимально нужны в `file_operations` символьного драйвера? — `.open`, `.read`, `.write`, `.release`
- core | Что означают major и minor номера устройства? — major определяет драйвер, minor — конкретный экземпляр устройства внутри этого драйвера
- core | Сколько бит под major и minor в `dev_t`? — 12 бит major, 20 бит minor (32-битное число целиком)
- core | Почему в драйвере нельзя напрямую разыменовать указатель из user space? — это чужое адресное пространство, страница может быть не загружена или указатель некорректен; прямое разыменование роняет ядро или блокируется SMAP/SMEP
Почему дальше: `read`/`write` подходят для потока байт, но не для команд, которые не
укладываются в чтение/запись (настроить режим устройства, запросить статус) — для этого есть
`ioctl`.
## 7. `ioctl`: команды, которые не являются чтением или записью
`ioctl` — обработчик `.unlocked_ioctl` в `file_operations`, принимающий числовой код команды
и один аргумент (`unsigned long`, часто указатель на структуру в user space). Используется,
когда операция над устройством не укладывается в модель «поток байт» — например, «сообщи
текущую скорость порта» или «переведи устройство в другой режим». Коды команд собирают
макросами **`_IO`**, **`_IOR`**, **`_IOW`**, **`_IOWR`** (кодируют направление передачи данных,
«магическое число» драйвера и номер команды) — это соглашение снижает риск, что два разных
драйвера случайно используют одинаковый числовой код команды.
**Факты для карточек**
- base | В какой функции `file_operations` реализуется `ioctl`? — `.unlocked_ioctl`
- core | Зачем коды ioctl-команд собирают через `_IO`/`_IOR`/`_IOW`/`_IOWR`, а не пишут произвольным числом? — макросы кодируют направление передачи, магическое число драйвера и номер команды, снижая риск коллизии кодов между разными драйверами
Почему дальше: и `file_operations`, и `ioctl` работают с уже существующим, известным
устройством — а откуда ядро вообще узнаёт, какое железо есть на конкретной плате, особенно в
embedded, где плат много и они разные?
## 8. Device tree: описание железа без хардкода
**Device tree** — текстовое описание аппаратной конфигурации платы (`.dts`, компилируется
утилитой `dtc` в бинарный `.dtb`): адреса регистров периферии, линии прерываний, доступные
шины, строки `compatible`, по которым ядро сопоставляет узел дерева с подходящим драйвером.
Загрузчик передаёт `.dtb` ядру вместе с образом ядра при старте.
Смысл — один и тот же бинарник ядра должен уметь работать на разных платах с разной
периферией и разными адресами регистров без перекомпиляции под каждую плату: без device tree
адреса и конфигурация железа были бы зашиты прямо в код ядра (так называемые board files),
и под каждую новую плату требовалась бы правка и пересборка самого ядра. Device tree выносит
это описание из кода в данные, которые загрузчик просто подкладывает рядом с ядром.
**Факты для карточек**
- base | Во что компилируется `.dts` и какой утилитой? — в бинарный `.dtb`, утилитой `dtc`
- core | Зачем device tree вообще нужен, если можно было бы прописать адреса регистров прямо в коде драйвера? — один и тот же бинарник ядра работает на разных платах без пересборки под каждую; хардкод адресов требовал бы правки и компиляции ядра под каждую конкретную плату
Почему дальше: чтобы вообще собрать ядро (и модуль) под плату, у которой процессор отличается
от машины разработчика, обычную сборку компилятором хоста использовать нельзя — нужен
кросс-компилятор.
## 9. Cross-compile: тулчейн под целевую архитектуру
Сборка ведётся на машине разработчика (обычно x86-64), а результат должен исполняться на
целевом процессоре платы (например, ARM) — обычный компилятор хоста генерирует машинный код
под архитектуру хоста, целевая плата такой код исполнить не сможет. Нужен **кросс-компилятор**
— тулчейн, генерирующий код именно под целевую архитектуру: например `arm-linux-gnueabihf-gcc`.
- **`CROSS_COMPILE`** — переменная окружения/аргумент сборки (используется, например, в
Makefile ядра и Buildroot), задающая префикс имени инструментов тулчейна
(`CROSS_COMPILE=arm-linux-gnueabihf-`), чтобы вызывался `arm-linux-gnueabihf-gcc`, а не
системный `gcc`.
- **`-march`** — флаг компилятора, задающий конкретный набор инструкций целевого процессора
(например `-march=armv7-a`); несовпадение с реальным железом даёт крах «illegal
instruction» на плате или отказ собраться, если код использует расширения, которых у
целевого процессора нет.
- **Статическая сборка** — линковка всех библиотек прямо в бинарник вместо динамических
`.so`; в embedded это снимает риск несовпадения версии библиотек (например, glibc) в
минимальном корневом ФС платы с версией, под которую собирался бинарник, ценой большего
размера самого файла.
**Факты для карточек**
- base | Что задаёт переменная `CROSS_COMPILE`? — префикс имени инструментов тулчейна (например `arm-linux-gnueabihf-`)
- core | Что произойдёт при запуске бинарника, собранного с неверным `-march`, на реальной плате? — крах «illegal instruction» (процессор не поддерживает часть использованных инструкций) или отказ сборки
- core | Какую проблему в embedded снимает статическая линковка? — несовпадение версии динамических библиотек (например glibc) на целевой плате с версией сборки
Почему дальше: тулчейн даёт бинарники ядра и модулей под плату, но сама плата должна ещё
дойти от включения питания до работающей системы — разберём цепочку загрузки.
## 10. Загрузка платы: u-boot → ядро+dtb → initramfs → init
Типичная последовательность:
1. Boot ROM процессора (зашит в кристалл, неизменяем) загружает первый этап загрузчика.
2. **U-Boot** — распространённый загрузчик embedded-плат — инициализирует минимально
необходимое железо (память, консоль) и находит образ ядра и `.dtb` (раздел 8) на носителе.
3. U-Boot загружает **ядро** и **`.dtb`** в память и передаёт управление точке входа ядра.
4. Ядро инициализируется и монтирует **initramfs** — временную корневую файловую систему,
целиком находящуюся в оперативной памяти, — и запускает из неё `/init`.
5. `/init` в initramfs делает раннюю настройку (загружает нужные модули, находит настоящий
диск), затем переключается на настоящую корневую файловую систему (`pivot_root`/
`switch_root`) и запускает уже настоящий **init** (PID 1 — например `systemd` или
BusyBox init) на постоянном разделе.
initramfs нужен потому, что к моменту, когда ядро только загрузилось, оно ещё может не знать,
как смонтировать настоящий диск (нужный драйвер файловой системы или контроллера диска может
сам быть модулем, который ещё не загружен) — временная ФС в памяти даёт минимальную среду,
достаточную, чтобы загрузить эти модули и уже потом перейти на постоянное хранилище.
**Факты для карточек**
- base | В каком порядке идёт цепочка загрузки платы? — Boot ROM → U-Boot → ядро+dtb → initramfs → переключение на настоящий rootfs → init (PID 1)
- core | Зачем нужен initramfs, если ядро уже загружено и работает? — ядру для монтирования настоящего диска может понадобиться драйвер, который сам является модулем и ещё не загружен; initramfs даёт минимальную среду в памяти, чтобы его подгрузить перед переходом на постоянный rootfs
Почему дальше: после того как система загрузилась, embedded-разработчик работает с реальным
железом напрямую через память — а компилятор по умолчанию считает, что содержимое памяти
меняется только его собственным кодом. Разберём, что с этим не так.
## 11. `volatile`, `mmap` регистров и барьеры памяти
Обычная оптимизация компилятора — закэшировать значение переменной в регистре процессора и не
перечитывать его из памяти повторно, если по коду программы оно «не могло измениться».
Регистр памяти-отображённого устройства (MMIO) может измениться сам, независимо от кода
программы (например, статус-регистр меняется самим железом) — без `volatile` компилятор
законно уберёт «повторное» чтение как избыточное, и драйвер будет вечно видеть устаревшее
значение. `volatile` заставляет компилятор реально выполнять каждое обращение к памяти по
этому адресу, не кэшируя и не убирая «дублирующиеся» чтения/записи.
Важная ловушка: `volatile` **не даёт атомарности и не расставляет барьеры памяти** — он
касается только компилятора и только одной переменной, а не порядка операций между
несколькими адресами и не защищает от гонки между несколькими ядрами процессора (для этого в
пользовательском C++ есть `std::atomic`, раздел про многопоточность).
Доступ к регистрам устройства из драйвера обычно идёт не через прямой физический адрес, а
через **`ioremap()`** — функция ядра, отображающая физический адрес MMIO-региона в виртуальное
адресное пространство ядра, после чего к регистру обращаются как к обычному указателю.
**Барьеры памяти** (`mb()`, `rmb()`, `wmb()`) принудительно фиксируют порядок операций чтения
и записи в памяти, как его видит железо — нужны потому, что и компилятор, и сам процессор
вправе переставлять инструкции местами, если это не меняет результат с точки зрения
однопоточной программы, а DMA-контроллер или другое устройство читает память независимо от
этого порядка. Типичный случай: драйвер записывает данные в дескриптор DMA-кольца
(`tasks/03_ring`, задача этого набора про кольцевой буфер — та же структура head/tail лежит
в основе DMA-колец), а затем «звонит в дверной звонок» — пишет в регистр, запускающий
устройство; без `wmb()` между этими двумя записями устройство может увидеть сигнал запуска
раньше, чем реально записанные данные дескриптора, и прочитать старое содержимое.
**Ловушки**
- Забыть `volatile` на указателе на регистр устройства → компилятор кэширует значение в
регистре CPU и не видит изменений железа → драйвер «зависает», читая одно и то же старое
значение, хотя устройство давно сменило статус.
- Считать, что `volatile` защищает от гонки между несколькими ядрами процессора → в
многоядерном коде это не так, нужны барьеры памяти или атомики → гонка, не ловится
однопоточным тестированием.
**Факты для карточек**
- base | Что делает `volatile` с точки зрения компилятора? — заставляет реально выполнять каждое обращение к памяти по адресу, не кэшируя значение в регистре и не убирая повторные чтения/записи
- core | Даёт ли `volatile` атомарность или барьер памяти между несколькими ядрами CPU? — нет, только запрещает компилятору кэшировать/убирать обращения к конкретной переменной
- core | Какая функция ядра отображает физический адрес MMIO-региона в виртуальный адрес для доступа как к указателю? — `ioremap()`
- deep | Зачем нужен `wmb()` между записью DMA-дескриптора и записью в регистр запуска устройства? — без барьера порядок этих двух записей, как его видит железо, не гарантирован, и устройство может прочитать старое содержимое дескриптора раньше, чем увидит новые данные
Ссылки на задачи этого набора: `tasks/07_epoll` — то, как ядро уведомляет процесс о готовых
событиях на файловых дескрипторах (тот же принцип «пользовательский процесс не опрашивает
железо/сеть напрямую, а получает уведомление от ядра», что и в syscall-границе раздела 1);
`tasks/03_ring` — кольцевой буфер head/tail, структурно совпадающий с DMA-кольцами в
драйверах (раздел 11).
<details>
<summary>Проверь себя</summary>
1. Почему нельзя просто разыменовать указатель, пришедший из пользовательской программы,
прямо внутри `.read` драйвера?
<details><summary>Ответ</summary>Это указатель в чужом (пользовательском) адресном
пространстве: соответствующая страница памяти может быть не загружена или указатель
вообще некорректен. Прямое разыменование либо роняет ядро (oops), либо блокируется
аппаратно на CPU с SMAP/SMEP. Нужно использовать `copy_from_user`/`copy_to_user`, которые
безопасно обрабатывают этот переход.</details>
2. На плате обновили дистрибутив ядра, но модуль устройства продолжает работать без
пересборки под новую хардкод-конфигурацию регистров. За счёт какого механизма это
возможно и что бы сломалось без него?
<details><summary>Ответ</summary>Device tree: адреса регистров и конфигурация железа
вынесены в `.dtb`, отдельный от кода ядра, и подгружаются загрузчиком при старте. Без
device tree адреса были бы зашиты в код драйвера (board files), и под каждое изменение
конфигурации платы пришлось бы переписывать и пересобирать сам код ядра.</details>
3. Драйвер читает статус-регистр устройства в цикле, ожидая, пока значение изменится, но
переменная объявлена без `volatile` — цикл не завершается, хотя железо статус давно
сменило. В чём причина и как это чинится?
<details><summary>Ответ</summary>Компилятор решил, что раз в теле цикла переменная кодом
программы не изменяется, можно прочитать её из памяти один раз и дальше сверяться с
закэшированным в регистре значением — оптимизация, законная для обычной переменной, но
ломающая логику для регистра, который меняет само железо. Нужно объявить указатель на
регистр как `volatile`, чтобы компилятор перечитывал память при каждой
итерации.</details>
4. Почему для системного вызова на x86-64 нужна специальная инструкция `syscall`, а не
обычный вызов функции ядра через `call` по известному адресу?
<details><summary>Ответ</summary>Обычный `call` не меняет уровень привилегий процессора —
пользовательская программа в кольце 3 так и осталась бы в кольце 3, не получив доступа к
привилегированным операциям ядра. `syscall` — специальная инструкция, которая одновременно
переключает CPU в кольцо 0 и передаёт управление фиксированному обработчику ядра, что
обычный переход по адресу сделать не может.</details>
</details>
## Материалы
- sysprog21.github.io, Linux Kernel Module Programming Guide (LKMPG) — https://sysprog21.github.io/lkmpg/
- docs.kernel.org, sysfs — https://docs.kernel.org/filesystems/sysfs.html
- docs.kernel.org, "volatile" Considered Harmful — https://docs.kernel.org/process/volatile-considered-harmful.html
- man7.org, `syscall(2)` — https://man7.org/linux/man-pages/man2/syscall.2.html
- man7.org, `ioctl(2)` — https://man7.org/linux/man-pages/man2/ioctl.2.html
- man7.org, `proc(5)` — https://man7.org/linux/man-pages/man5/proc.5.html