Files

40 KiB
Raw Permalink Blame History

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

Типичная последовательность:

  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 и передаёт управление фиксированному обработчику ядра, что обычный переход по адресу сделать не может.

Материалы