40 KiB
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. Структура простейшего модуля
#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
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
Типичная последовательность:
- Boot ROM процессора (зашит в кристалл, неизменяем) загружает первый этап загрузчика.
- U-Boot — распространённый загрузчик embedded-плат — инициализирует минимально
необходимое железо (память, консоль) и находит образ ядра и
.dtb(раздел 8) на носителе. - U-Boot загружает ядро и
.dtbв память и передаёт управление точке входа ядра. - Ядро инициализируется и монтирует initramfs — временную корневую файловую систему,
целиком находящуюся в оперативной памяти, — и запускает из неё
/init. /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).
Проверь себя
-
Почему нельзя просто разыменовать указатель, пришедший из пользовательской программы, прямо внутри
.readдрайвера?Ответ
Это указатель в чужом (пользовательском) адресном пространстве: соответствующая страница памяти может быть не загружена или указатель вообще некорректен. Прямое разыменование либо роняет ядро (oops), либо блокируется аппаратно на CPU с SMAP/SMEP. Нужно использовать `copy_from_user`/`copy_to_user`, которые безопасно обрабатывают этот переход. -
На плате обновили дистрибутив ядра, но модуль устройства продолжает работать без пересборки под новую хардкод-конфигурацию регистров. За счёт какого механизма это возможно и что бы сломалось без него?
Ответ
Device tree: адреса регистров и конфигурация железа вынесены в `.dtb`, отдельный от кода ядра, и подгружаются загрузчиком при старте. Без device tree адреса были бы зашиты в код драйвера (board files), и под каждое изменение конфигурации платы пришлось бы переписывать и пересобирать сам код ядра. -
Драйвер читает статус-регистр устройства в цикле, ожидая, пока значение изменится, но переменная объявлена без
volatile— цикл не завершается, хотя железо статус давно сменило. В чём причина и как это чинится?Ответ
Компилятор решил, что раз в теле цикла переменная кодом программы не изменяется, можно прочитать её из памяти один раз и дальше сверяться с закэшированным в регистре значением — оптимизация, законная для обычной переменной, но ломающая логику для регистра, который меняет само железо. Нужно объявить указатель на регистр как `volatile`, чтобы компилятор перечитывал память при каждой итерации. -
Почему для системного вызова на 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