# 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 `** — загружает модуль по прямому пути к файлу, без разрешения зависимостей от других модулей. - **`rmmod `** — выгружает модуль по имени (если счётчик использования равен нулю). - **`modprobe `** — загружает модуль по имени, сам находит файл в `/lib/modules/$(uname -r)/` и подгружает зависимости по карте `modules.dep` (строится утилитой `depmod`) — на практике используется чаще `insmod`, именно из-за автоматических зависимостей. - **`lsmod`** — список загруженных сейчас модулей (по сути читает `/proc/modules`), с размером и счётчиком использования. - **`modinfo `** — метаданные модуля (лицензия, описание, параметры, зависимости) без его загрузки в ядро. **Факты для карточек** - base | Чем `insmod` отличается от `modprobe`? — `insmod` грузит модуль по прямому пути без разрешения зависимостей, `modprobe` находит модуль по имени и сам подгружает зависимости - base | Какая команда показывает метаданные модуля, не загружая его? — `modinfo` - core | Откуда `modprobe` берёт карту зависимостей модулей? — из файла `modules.dep`, который строит утилита `depmod` Почему дальше: раз модуль — это отдельно загружаемый код, у него должна быть точка входа и точка выхода из ядра — разберём структуру простейшего модуля. ## 3. Структура простейшего модуля ```c #include #include #include 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(имя, тип, права)` (заголовок ``) делает переменную настраиваемой при загрузке модуля — значение можно передать прямо в `insmod modname.ko count=5`. Третий аргумент — режим доступа файла в sysfs: `0` означает, что параметр доступен только при загрузке и не публикуется отдельным файлом, ненулевое значение (например `S_IRUGO` — чтение всем) создаёт файл `/sys/module/<имя_модуля>/parameters/`, из которого параметр можно прочитать (а при подходящих правах — и переписать) уже после загрузки. **Факты для карточек** - base | Как передать параметр модулю при загрузке? — `insmod modname.ko имя_параметра=значение` - core | Что означает третий аргумент `module_param`, если он ненулевой? — параметр публикуется файлом в `/sys/module/<имя>/parameters/<имя>` с заданными правами доступа Почему дальше: файл параметра в sysfs — частный случай общего механизма, которым ядро вообще разговаривает с пользовательским пространством через файловую систему, — `/proc` и `/sys`. ## 5. `/proc` и `/sys` как интерфейс к ядру **`/proc`** (procfs) — виртуальная файловая система, изначально созданная для информации о процессах (`/proc//...`), позже расширенная общей информацией о ядре (`/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).
Проверь себя 1. Почему нельзя просто разыменовать указатель, пришедший из пользовательской программы, прямо внутри `.read` драйвера?
ОтветЭто указатель в чужом (пользовательском) адресном пространстве: соответствующая страница памяти может быть не загружена или указатель вообще некорректен. Прямое разыменование либо роняет ядро (oops), либо блокируется аппаратно на CPU с SMAP/SMEP. Нужно использовать `copy_from_user`/`copy_to_user`, которые безопасно обрабатывают этот переход.
2. На плате обновили дистрибутив ядра, но модуль устройства продолжает работать без пересборки под новую хардкод-конфигурацию регистров. За счёт какого механизма это возможно и что бы сломалось без него?
ОтветDevice tree: адреса регистров и конфигурация железа вынесены в `.dtb`, отдельный от кода ядра, и подгружаются загрузчиком при старте. Без device tree адреса были бы зашиты в код драйвера (board files), и под каждое изменение конфигурации платы пришлось бы переписывать и пересобирать сам код ядра.
3. Драйвер читает статус-регистр устройства в цикле, ожидая, пока значение изменится, но переменная объявлена без `volatile` — цикл не завершается, хотя железо статус давно сменило. В чём причина и как это чинится?
ОтветКомпилятор решил, что раз в теле цикла переменная кодом программы не изменяется, можно прочитать её из памяти один раз и дальше сверяться с закэшированным в регистре значением — оптимизация, законная для обычной переменной, но ломающая логику для регистра, который меняет само железо. Нужно объявить указатель на регистр как `volatile`, чтобы компилятор перечитывал память при каждой итерации.
4. Почему для системного вызова на x86-64 нужна специальная инструкция `syscall`, а не обычный вызов функции ядра через `call` по известному адресу?
ОтветОбычный `call` не меняет уровень привилегий процессора — пользовательская программа в кольце 3 так и осталась бы в кольце 3, не получив доступа к привилегированным операциям ядра. `syscall` — специальная инструкция, которая одновременно переключает CPU в кольцо 0 и передаёт управление фиксированному обработчику ядра, что обычный переход по адресу сделать не может.
## Материалы - 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