Уроки D2-D7 (14 файлов): деревья/куча, epoll, потоки, gdb, графы, L2-L3, ДП, TCP, bash, ядро, docker, мок-интервью + cards_src 613

This commit is contained in:
Kodlo-chan
2026-09-25 19:15:23 +07:00
parent 52d401f5e3
commit d313c28919
17 changed files with 5768 additions and 63 deletions
+380
View File
@@ -0,0 +1,380 @@
# 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