STM32 з нуля без HAL: Місяць 4. Linux Kernel Modules: пишемо hello world з ядра. Частина 11
Десять статей серії позаду. Bare-metal STM32, Buildroot на Luckfox, GStreamer стрім, NPU розпізнавання обличчя — все це userspace код, який звертався до /dev/ttyS1, /dev/video0, до драйверів які хтось колись написав. Я туди ще не заліз, руки чухаються. Час лізти.
Місяць 4 це зоряний час kernel space. Тут немає printf, є printk. Немає malloc, є kmalloc. Помилився з вказівником — не segfault, а kernel panic і reboot. Десь чув, що embedded middle від junior відрізняється саме здатністю писати свій драйвер, а не тільки використовувати чужі. Та байдуже, що говорять, як на мене це круто забацати свій драйвер, а ще краще зрозуміти, що воно таке і з чим його їдять
◆ Замість дисклеймера
Трохи предісторії. Linux я відкрив не минулого тижня, але й не дуже так і давно починав з 16 Ubuntu і остаточно пересів з 2019 року. На Orange pi — Arch Linux ще з доковідних часів. На полицях артефакти з тих самих часів — Raspbian Buster 2019, CentOS 7 ISO, ще одна стара малинка з підписом CentOS 6 і маркером на коробці. Колись я ці системи ставив, ламав, відновлював, переставляв. Не так щоб сильно навчився, але точно вже звик і на вінду вже не хочу. Весело пригадувати, як вивчав лінукс через Cygwin UNIX-подібну незалежну оболонку та набір інструментів, що емулює роботу Linux-оточення у Windows. Навіщо? Та для мене стало сюрпризом, що нода і пайтон трохи обрізані, а С++ там меж інший і різноманітні приклади рішень усі були на linux. Але зламало мене те, що я ніяк не міг налаштувати на вінді Docker.
Користуючись нагодою, хочу нагадати, що паралельно з цією серією веду інші, зокрема про Zephyr RTOS на Pi Pico (Частина 0 вже на DOU), поки на паузі, бо думаю вона стане логічним продовженням цієї де ми теж будемо кулупати RTOS. Ну, а тим хто, як і я в захваті від малінки, апельсинів і прочих фруктів до душі може припасти П’ять малинок, стійка і DietPi: як я будую домашній IoT-сервер
◆ Ґуля (граблі набили) № 0: де я хотів додати голос
Останнє що залишилось з Місяця 3 — відкрите архітектурне питання. Хотів додати голосове керування на Luckfox: моя wake-word модель «Катя» слухає, NPU розпізнає обличчя, STM32 крутить реле. Класична embedded магія.
Сів читати даташит RV1106. Потер очі. Знову прочитав. Подивився на свій Pico Pro і ще раз на pinout. Audio I²S піни на header не виведені. Внутрішній codec є — це я бачу у специфікації чіпа. А назовні не прокинули. Можливо Pico Pro позиціонується саме як IP-камера SoC: аудіо в дизайні плати не передбачено.
⚠ Тут варто зробити уточнення яке часто плутають.
I²C виведений нормально, він є на пінах, ним я вже користувався — BMP180, MPU6050 в попередніх статтях через нього спілкувалися з STM32. Але тут потрібен I²S — це зовсім інший інтерфейс, не плутати:
| I²C | I²S | |
|---|---|---|
| Розшифровка | Inter-Integrated Circuit | Inter-IC Sound |
| Для чого | Сенсори, EEPROM, RTC, OLED | Тільки аудіо |
| Лінії | SDA, SCL (2 проводи) | BCLK, LRCK, DATA (3+ проводи) |
| Швидкість | 100 kHz — 3.4 MHz | Десятки MHz, безперервний потік |
| Призначення | Команди + дрібні дані | Стрім PCM семплів |
I²C — для датчиків, типу BMP180, MPU6050. I²S — спеціально для цифрового аудіо. Передає неперервний потік PCM семплів (наприклад 16 000 чисел за секунду = 16 kHz audio). На таких швидкостях I²C просто не вистачить bandwidth, плюс у I²S інша архітектура — continuous streaming замість transaction-based.
Може виникнути логічна думка: «а є I²C мікрофон?» Технічно — є. Але для wake-word він не підходить. I²C-мікрофон передає дані запитами (request-response). Кожен раз треба слати команду «дай мені семпл», потім приймати відповідь. Для 16 000 семплів на секунду це 16 000 транзакцій I²C — bus буде задушено, ніякого іншого I²C девайса вже не приєднати. Окрім того jitter — між семплами непостійний, що псує якість для wake-word.
Що з чим на Luckfox Pico Pro
| Інтерфейс | Виведений на header? | Для чого |
|---|---|---|
| I²C | ✓ Так | Сенсори, OLED, RTC |
| SPI | ✓ Так | Дисплеї, флеш, ADC |
| UART (1, 3, 4) | ✓ Так | STM32, GPS, debug |
| GPIO | ✓ Так | Кнопки, LED, реле |
| PWM | ✓ Так | Сервоприводи, реле |
| I²S (audio) | ✗ Ні | На цій моделі плати не виведений |
| USB audio через USB-C | Технічно так | Єдиний шлях, але конфлікт з живленням |
Залишався один варіант — USB audio dongle. Але:
- USB-C порт на Luckfox один, і він мені потрібен для живлення і RNDIS network.
- USB камера з мікрофоном тягне
200-500 mA, на межі того що Luckfox може дати з власного host port. - Тре пересборку ядра з
SND_USB_AUDIO+ alsa-utils у Buildroot.
Ось тут тре подумати. А чи це взагалі правильна архітектура? Якщо Luckfox — це наш «мозок з очима» (NPU + камера), то навіщо туди ще й «вуха» прикручувати? Echo Dot же не на одному SoC робить — там завжди увімкнений low-power MCU слухає wake-word, а основний процесор спить.
Архітектурне рішення: wake-word йде на STM32
Чим більше думав, тим більше мені це подобалось, ну прям модульно, ух красота:
- STM32 має I²S виведений на пінах — INMP441 мікрофон і MAX98357A DAC підключаються прямо.
- DMA на STM32 пише I²S семпли в RAM без участі CPU — стабільний 16 kHz без jitter, який буде на Linux scheduler.
- TFLite Micro компілюється під STM32 F4/F7/H7. Моя 50 KB модель Катя влізе з запасом (Стаття Як я навчив нейромережу чути своє ім’я: wake word з нуля на TensorFlow вже майже в редакції ).
- Bare-metal = real-time. Від звуку до спрацювання ~50 ms predictable. На Linux
100-300 ms з jitter. - Енергія: STM32F4 у активному режимі ~50 mA, Luckfox ~300-500 mA. Для always-on wake-word різниця в 10 разів.
- STM32 слухає Катю → детектить → шле UART trigger на Luckfox, той прокидається з камерою та NPU.
Це і є справжня heterogeneous embedded архітектура — та що використовують Amazon Echo Dot, Apple HomePod, Google Nest. Завжди увімкнений low-power MCU з wake-word моделлю. SoC прокидається тільки коли wake-word спрацював.
Як само собою виходить, що в Місяць 3 Luckfox призначено стати мозком, а STM32 буде керувати м’язом. А тепер додаємо ще й вухо на STM32. Це щось неймовірне, м’язи, які чують і реагують.
⚠ Це окрема стаття, до якої я ще повернусь у Місяці 5 (FreeRTOS на STM32 + I²S + TFLite Micro). А зараз в нас інші важливі справи, дізнатись, що всередені самого Linux. Власне колупаємо, kernel modules.
◆ Залізо: шість малинок, п’ять простих + одна з плюсиком
У тумбочці 6 штук Pi 3: 5 звичайних 3B + одна 3B+. Дістав ту що вже використовую в серії де я будую домашній IoT-сервер. Перший boot:
$ ssh pi $ cat /proc/device-tree/model Raspberry Pi 3 Model B Rev 1.2
Чекай, я ж думав це 3B+. А це звичайний 3B без плюсика. Малінка в корпусі, а /proc/device-tree/model не робив, от і маєш. Корисна команда, щоб не розбирати корпус і дізнатись модель, запам’ятовуємо.
Чим відрізняються:
| Pi 3B | Pi 3B+ | |
|---|---|---|
| CPU | Cortex-A53 @ 1.2 GHz | Cortex-A53 @ 1.4 GHz |
| Ethernet | 100 Mbps | Gigabit (через USB 2.0) |
| WiFi | 2.4 GHz | 2.4 + 5 GHz |
Для kernel модулів — той самий BCM2837, той самий GPIO chip, той самий код. Поточну 3B пускаю в експерименти (їх багато, не шкода якщо щось зламаю в ядрі), 3B+ резервую для Місяця 5 — там буде FreeRTOS + OpenCV, додаткові 200 MHz і двосмуговий WiFi не зайві.
◆ Перший boot: чиста картка з нуля
SD картка в ній вже з образом і він мені тре. Тому розпакую свіжу SanDisk 32 GB.
Записувати буду з Ubuntu. Найпростіший шлях — офіційний інструмент Raspberry Pi Imager:
sudo snap install rpi-imager # або sudo apt install rpi-imager
Перед тим як встромляти картку — перевіряю які диски в системі зараз:
lsblk
⚠ Запам’ятай вивід. Тепер встромляєш картку — і знову
lsblk. Нова строка з розміром ~29-30 GB (32 GB номінально завжди показуються трохи меншими) — це наша SD. Назву обов’язково запам’ятай, бо якщо переплутаєш з системним диском,ddабо Imager радо затре твою Ubuntu без зайвих запитань.
Запуск Imager
Запускаю rpi-imager, тиснемо три кнопки:
- CHOOSE DEVICE → Raspberry Pi 3
- CHOOSE OS → Raspberry Pi OS (other) → Raspberry Pi OS Lite
(64-bit). Чому Lite — нам не треба GUI, kernel розробка вся в терміналі. Чому64-bit — Pi 3B це Cortex-A53,64-bit native, майбутнє за aarch64. - CHOOSE STORAGE → наша SanDisk. Звіряємо розмір з тим що бачив у
lsblk.
Далі — Next → EDIT SETTINGS. Це ключовий момент, тут одразу налаштовуємо все що знадобиться при першому boot, щоб потім не возитись.
General вкладка:
- Hostname:
alex-pi - Username:
alex, пароль будь-який - Configure wireless LAN: вмикаю, вписую WiFi мережу. Country code: UA. Це резервний канал на випадок якщо з ethernet щось піде не так.
- Locale: timezone Europe/Kyiv, keyboard us
Services вкладка:
- Enable SSH → ✓
- Password authentication (ключі додам потім)
SAVE → Yes → Yes. Запис триває ~5 хвилин, Imager сам перевіряє контрольну суму.
◆ Перший boot і SSH у мережі
Картку в Pi, ethernet кабель у роутер, HDMI у монітор (на всяк випадок щоб бачити що відбувається), USB клавіатура, і в останню чергу — micro USB живлення. Pi стартує одразу як з’явилось живлення.
Через ~30 секунд на моніторі: alex-pi login:. Перший boot пройшов чисто.
Тепер як знайти Pi в мережі з ноута? Підказка — nmap по підмережі шукаючи MAC префікс Raspberry Pi Foundation (B8:27:EB):
ip route | grep default # default via 192.168.1.1 dev enp0s25 proto dhcp metric 100 # отже наша мережа 192.168.1.0/24 sudo apt install nmap sudo nmap -sn 192.168.1.0/24 | grep -B 2 -i raspberry
Вивід:
Nmap scan report for 192.168.1.109 Host is up (0.0015s latency). MAC Address: B8:27:EB:D4:26:95 (Raspberry Pi Foundation) Nmap scan report for 192.168.1.114 Host is up (0.0016s latency). MAC Address: B8:27:EB:D4:26:95 (Raspberry Pi Foundation)
Два IP, але MAC однаковий — це одна і та сама Pi на двох інтерфейсах. Підключаюся:
ssh [email protected]
На питання fingerprint — yes, пароль той що задав у Imager.
Вже на Pi перевіряю інтерфейси:
$ ip -br addr lo UNKNOWN 127.0.0.1/8 eth0 UP 192.168.1.109/24 wlan0 UP 192.168.1.114/24
OK, eth0 = .109, wlan0 = .114. Зафіксую ethernet IP як основний — для kernel розробки стабільність важлива (якщо щось зламаю в мережевому стеку через невдалий модуль, wifi першим помре).
На Ubuntu додаю alias у ~/.ssh/config щоб не запам’ятовувати IP:
Host pi HostName 192.168.1.109 User alex
Тепер просто ssh pi. На роутері додатково прив’язую MAC B8:27:EB:D4:26:95 до цього IP в DHCP reservation, щоб ніколи не плавав.
◆ Перші команди на Pi
Системна інформація:
$ uname -a Linux alex-pi 6.12.75+rpt-rpi-v8 #1 SMP PREEMPT Debian 1:6.12.75-1+rpt1 (2026-03-11) aarch64 GNU/Linux
Ядро 6.12.75 від березня 2026, aarch64. Свіже.
$ cat /proc/device-tree/model Raspberry Pi 3 Model B Rev 1.2 $ df -h / Filesystem Size Used Avail Use% Mounted on /dev/mmcblk0p2 29G 3.0G 24G 11% / $ vcgencmd measure_temp temp=51.0'C
29 GB rootfs (Pi сама розширила до повного розміру картки при першому boot), 3 GB зайнято — місця море. 51°C у спокої — нормально для Pi 3 без радіатора.
Запускаю оновлення:
sudo apt update sudo apt upgrade -y
⚠ Якщо у тебе тут оновиться ядро — обов’язково
sudo rebootпісля завершення і перепідключитись по SSH. Інакшеuname -rпоказуватиме одне ядро, а/lib/modulesз headers матиме інше — і потім всі kernel модулі будуть з помилкою «Invalid module format».
◆ Ґуля № 2: де поділись raspberrypi-kernel-headers
Класичний рецепт для kernel модулів на Pi казав так:
sudo apt install raspberrypi-kernel-headers build-essential
Запускаю — і отримую:
Error: Unable to locate package raspberrypi-kernel-headers
Хм. На свіжих Raspberry Pi OS Bookworm пакет Headers окремо більше не існує. Headers тепер пакетуються разом з ядром, в одному пакеті linux-image-rpi-v8. Ставлячи ОС з образу — ти вже маєш все що потрібно.
Raspberry Pi OS Bookworm — це офіційна операційна система для одноплатних комп’ютерів Raspberry Pi, яка базується на випуску Debian 12 «Bookworm». Вона прийшла на зміну версії Bullseye (на базі Debian 11) та принесла значні архітектурні зміни, підвищену продуктивність та повну підтримку новітніх моделей, таких як Raspberry Pi 5
Перевіряємо чи це справді так:
$ ls /lib/modules/$(uname -r)/build/ | head arch include Makefile Module.symvers scripts tools vmlinux
Бачимо Makefile, include, scripts — все необхідне для kbuild. Чудово, ставити нічого не треба, рухаємось далі.
⚠ Урок: туторіали 2-3-річної давності можуть пропонувати команди яких уже не існує. Завжди перевіряємо
ls /lib/modules/$(uname -r)/build/— якщо там є вміст, headers вже встановлені. Якщо порожньо або битий symlink — це окрема пригода зrpi-source, але у нашому випадку обійшлось. Істина проста. Як було раніше вже не буде))
Доставляю інші утиліти для розробки (без kernel-headers, який вже не потрібен):
sudo apt install build-essential git bc bison flex libssl-dev
Перевіряю що компілятор і make на місці:
gcc --version make --version git --version
◆ Ґуля № 3: Pi пропала з мережі
Між сесіями робив паузу, повернувся за ноут — ssh pi:
$ ssh pi ssh: connect to host 192.168.1.109 port 22: No route to host
«No route to host» — це не «пароль не той» і не «ssh не запущений». Це нижчий рівень: ноут просто не знає куди слати пакети. Сама Pi або вимкнена, або відключена від мережі, або змінила IP. Перед тим як панікувати — короткий чек-ліст з 4 кроків:
Крок 1: чи Pi взагалі жива
ping -c 3 192.168.1.109
Якщо «Destination Host Unreachable» — Pi нема в мережі. Якщо «100% packet loss» без unreachable — Pi є але не відповідає.
Крок 2: перевір WiFi адресу як резерв
ping -c 3 192.168.1.114
У мене дві адреси — eth0 і wlan0. Якщо одна не пінгується, інша може.
Крок 3: чи ноут в тій самій мережі
ip route | grep default # default via 192.168.1.1 dev enp0s25 proto dhcp metric 100
Маршрут має йти через 192.168.1.x. Якщо там інша підмережа (наприклад 192.168.0.1 або 10.x.x.x) — ноут перепідключився до іншої мережі.
Крок 4: сканування — раптом IP змінився
sudo nmap -sn 192.168.1.0/24 | grep -B 2 -i raspberry
Якщо Pi знайшлась на іншому IP — отже DHCP видав їй нову адресу після перезавантаження. Тре робити DHCP reservation у роутері, але не сьогодні.
У моєму випадку: подивився на плату — червоний LED живлення не горить. Просто був вимкнений блок живлення (хтось випадково смикнув подовжувач, не питайте). Підключив назад — Pi за 30 секунд знову у мережі, ssh pi працює як треба. У мене завжди так)), починаю з самого важкого, а тре з самого простого і очевидного, але ж тоді це буде інша пригода.
⚠ Урок сісадміна: перш ніж лізти у Wireshark або переустановлювати OS — глянь на саму плату. Чи горять світлодіоди? Чи в розетці кабель живлення? У 80% випадків відповідь там.
Користуючись нагодою. Ось класичний жарт про сисадміна та пошук несправностей: Сидить сисадмін, глибоко задумавшись, крутить крісло туди-сюди, нахиляється, заглядає під системний блок, знову крутиться. До нього підходить колега:— Щось серйозне сталося? Сервер «ліг»? База даних не відповідає?Сисадмін, не перестаючи крутити крісло:— Та ні, розумієш... У кріслі щось поскрипує, коли кручуся. Вже пів години не можу зрозуміти, чи це крісло змастити треба, чи в мене десь кулер на сервері так шумить.
◆ Частина 2: пишемо hello.ko
Все готове, рухаємось до самого цікавого. Створюю робочу структуру і одразу git репо щоб коміти не загубились:
mkdir -p ~/kernel-modules/01-hello cd ~/kernel-modules/01-hello git init
hello.c — перший модуль
Файл hello.c — найпростіший kernel модуль з параметрами. Init друкує привіт, exit прощається, параметри name і times передаються при insmod:
#include <linux/init.h> /* __init, __exit */
#include <linux/module.h> /* всі макроси для модуля */
#include <linux/kernel.h> /* pr_info, pr_err */
#include <linux/moduleparam.h> /* module_param */
/* ─── параметри модуля ─── */
static char *name = "World";
static int times = 1;
module_param(name, charp, 0644);
MODULE_PARM_DESC(name, "Кому передаємо привіт");
module_param(times, int, 0644);
MODULE_PARM_DESC(times, "Скільки разів привітатись");
/* ─── викликається при insmod ─── */
static int __init hello_init(void)
{
int i;
pr_info("hello: модуль завантажено\n");
for (i = 0; i < times; i++) {
pr_info("hello: Привіт, %s! (раз %d з %d)\n",
name, i + 1, times);
}
/* 0 = успіх. Будь-яке інше — помилка */
return 0;
}
/* ─── викликається при rmmod ─── */
static void __exit hello_exit(void)
{
pr_info("hello: до побачення, %s! Модуль вивантажено\n", name);
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Alex");
MODULE_DESCRIPTION("Перший kernel module: hello world з параметрами");
MODULE_VERSION("0.1");
Кілька цікавих деталей:
__initі__exit— це не для красоти, це секції в ELF файлі. Код з__initпісля успішного завантаження модуля звільняється з пам’яті ядра. Тобтоhello_initіснує тільки до моменту коли він повернув 0 — потім його пам’ять вивільняється. Мертвий код у kernel space не тримаємо.pr_info— обгортка надprintk(KERN_INFO ...). Має багато братів:pr_err,pr_warn,pr_debug. У серйозному коді додають#define pr_fmt(fmt) KBUILD_MODNAME ": " fmt— тоді кожне повідомлення автоматично починається з імені модуля.module_param— третій параметр0644це права доступу до файлу/sys/module/hello/parameters/.0644= читати можна всім, записує тільки root. Якщо0000— параметр не буде видно у sysfs.MODULE_LICENSE("GPL")— обов’язково. Без цього ядро видасть «taints kernel» з warning’ом про missing key.
Makefile
⚠ Дуже важливо: відступи перед командами це таб, а НЕ пробіли. Якщо копіюєш через буфер обміну, перевір
cat -A Makefile— табуляція показується як^I. Якщо там пробіли — отримаєш*** missing separator. Stop.У свій час дуже з тим намучився, тому постійно на цьому наголошую.
# Makefile для kernel модуля — використовує kbuild систему ядра obj-m += hello.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean reload: -sudo rmmod hello 2>/dev/null sudo insmod hello.ko dmesg | tail -20
Як це працює: ми не компілюємо самі. Ми просимо kbuild систему ядра ($(MAKE) -C $(KDIR)) зайти в директорію headers і скомпілювати наш файл з усіма правильними прапорами, які використовуються для збірки ядра. M=$(PWD) каже kbuild «зовнішній модуль лежить отут». Це ключовий момент: всі kernel модулі компілюються через kbuild.
Збираємо
$ make make -C /lib/modules/6.18.33+rpt-rpi-v8/build M=/home/alex/kernel-modules/01-hello modules make[1]: Entering directory '/usr/src/linux-headers-6.18.33+rpt-rpi-v8' CC [M] hello.o MODPOST Module.symvers CC [M] hello.mod.o LD [M] hello.ko
⚠ Стривайте — ядро тут 6.18.33, а коли я перевіряв уперше було 6.12.75. Виявляється
apt upgradeтихенько оновив ядро до 6.18 під час встановлення git і build-essential. Headers оновились разом з ним. Якби міжsudo apt upgradeіmakeя не зробив reboot —unameпоказував би старе 6.12, а build linkувався б до 6.18 headers, і модуль не завантажився б. Це той самий випадок «Invalid module format» який лякає всіх початківців, ну чи не всіх.
Перевіряємо що вийшло:
$ ls -la -rw-rw-r-- 1 alex alex 1994 Jun 10 08:02 hello.c -rw-rw-r-- 1 alex alex 8112 Jun 10 08:31 hello.ko -rw-rw-r-- 1 alex alex 761 Jun 10 08:02 Makefile ... $ modinfo hello.ko filename: /home/alex/kernel-modules/01-hello/hello.ko version: 0.1 description: Перший kernel module: hello world з параметрами author: Alex license: GPL name: hello vermagic: 6.18.33+rpt-rpi-v8 SMP preempt mod_unload modversions aarch64 parm: name:Кому передаємо привіт (charp) parm: times:Скільки разів привітатись (int)
vermagic — це той самий «штамп сумісності» який ядро перевіряє при завантаженні модуля. Якщо твій vermagic не співпадає з ядром що працює зараз — модуль не вантажиться.
Завантажуємо у ядро
$ sudo insmod hello.ko $ dmesg | tail -5 [2271.782480] hello: loading out-of-tree module taints kernel. [2271.784431] hello: модуль завантажено [2271.784448] hello: Привіт, World! (раз 1 з 1) $ lsmod | grep hello hello 12288 0
Працює! Розбираємо:
taints kernel— це нормально і очікувано. Tainted kernel означає що в ядро завантажено код не з основного дерева (out-of-tree). Кожен такий модуль робить це, включаючи Nvidia, VirtualBox, VMware. Це попередження для мейнтейнерів: «якщо потім буде kernel panic, не плачтесь офіційним розробникам».hello 12288 0— ім’я, розмір у байтах (12K — мінімум для будь-якого kernel модуля на ARM64), reference count (0 = ніхто не використовує).
Вивантажуємо:
$ sudo rmmod hello $ dmesg | tail -3 [2271.784431] hello: модуль завантажено [2271.784448] hello: Привіт, World! (раз 1 з 1) [2314.989473] hello: до побачення, World! Модуль вивантажено
Тепер з параметрами
$ sudo insmod hello.ko name=Alex times=5 $ dmesg | tail -10 [2324.628422] hello: модуль завантажено [2324.628446] hello: Привіт, Alex! (раз 1 з 5) [2324.628458] hello: Привіт, Alex! (раз 2 з 5) [2324.628466] hello: Привіт, Alex! (раз 3 з 5) [2324.628474] hello: Привіт, Alex! (раз 4 з 5) [2324.628482] hello: Привіт, Alex! (раз 5 з 5)
П’ять привітів як замовляли. Тепер найцікавіший трюк — параметри видно через sysfs і можна змінювати на льоту:
$ sudo insmod hello.ko name=Alex times=3 $ cat /sys/module/hello/parameters/name Alex $ cat /sys/module/hello/parameters/times 3 $ echo "Stm32" | sudo tee /sys/module/hello/parameters/name Stm32 $ sudo rmmod hello $ dmesg | tail -3 [2335.933427] hello: Привіт, Alex! (раз 3 з 3) [2336.082876] hello: до побачення, Stm32 ! Модуль вивантажено
⚠ Бачите ту дивну розривку «до побачення, Stm32 ! Модуль вивантажено»? Це не баг ядра — це
echoдодало символ переносу рядка в значення параметра. Параметрnameтепер містить"Stm32\n", а коли модуль друкує його з форматом «до побачення, %s! Модуль вивантажено», новий рядок ріже повідомлення посередині. Якщо тре чистіше —printf "Stm32" | sudo teeбез переносу. Дрібниця, але наглядно: userspace tooling працює непомітно, і це може ламати твій kernel код.
Поки це робив згадав один бородатий анекдот, але мене він веселить до цих пір.
Зі слів юзера:
— Комп не працював. Прийшов адмін, підніс руки до неба, пробурмотів якісь заклинання, прокрутив мій стілець 3 рази навколо своєї осі, штурхнув комп’ютер — і сталося диво — він запрацював!!!
Зі слів адміна:
— Дзвінок. Біжу до юзера. Цей бовдур так крутився на новому стільці, що кабель живлення намотався на ніжку і висмикнувся з блока. Побачивши це, підводжу руки до неба, виголошую кілька триповерхових матюків, розмотую кабель, заштовхую комп ногою подалі під стіл і вмикаю.
◆ Експерименти: коли все йде не так
Тепер давайте навмисно ламати. Бо знайти баг в чужому коді легко, а в своєму найскладніше — і навчитись передбачати проблеми можна тільки борячись з багами. Писати код легко, важко вмовити його працювати стабільно.
Експеримент 1: відмова від завантаження через -EINVAL
Що буде якщо init поверне не 0? Дописуємо в hello_init:
pr_err("hello: відмовляюсь завантажуватись, тестую помилку\n");
return -EINVAL;
$ make
$ sudo insmod hello.ko
insmod: ERROR: could not insert module hello.ko: Invalid parameters
$ echo "exit code: $?"
exit code: 1
$ dmesg | tail -10
[24360.911041] hello: модуль завантажено
[24360.911064] hello: Привіт, World! (раз 1 з 1)
[24360.911074] hello: відмовляюсь завантажуватись, тестую помилку
$ lsmod | grep hello
(нічого — модуль НЕ завантажений)
Що сталось:
- init функція повертає
int, де 0 = успіх, мінус errno = помилка. Це contract ядра. -EINVAL= −22.insmodпереклав це в стандартне повідомлення «Invalid parameters».- Хоча
pr_infoвивів привіти іpr_errвивело помилку — модуль не залишився в системі. Ядро відкотило ініціалізацію. - Реальний приклад: USB драйвер не знайшов потрібний chip → повертає
-ENODEV. Не сумісне залізо →-ENXIO. Параметр модуля недопустимий →-EINVAL.
Експеримент 2: rate limiting та ring buffer
А що якщо просто навалити купу повідомлень? Запускаю times=10000:
$ sudo insmod hello.ko name=Test times=10000 $ dmesg | grep "hello: Привіт" | wc -l 2045
Стоп. Просив 10000, отримав 2045. Куди поділись 8000 повідомлень? Збільшую буфер запиту:
$ dmesg --buffer-size=10485760 | grep "hello: Привіт" | wc -l 1987
Все одно ~2000. Значить це не обмеження dmesg, це обмеження самого kernel ring buffer. Дивимось перший рядок який залишився:
$ dmesg --buffer-size=10485760 | grep "hello: Привіт" | head -1 [24379.679135] hello: Привіт, Test! (раз 8016 з 10000)
Перші 8015 повідомлень буквально витиснуті новішими. Kernel ring buffer — це circular buffer фіксованого розміру (часто 256K-1M, задається при компіляції ядра через CONFIG_LOG_BUF_SHIFT). Коли він повний — старі повідомлення затираються.
- Цикл виконався весь — побачили «(раз 10000 з 10000)» в кінці.
- Але історія обрізана — лише ~2000 останніх повідомлень доступні.
⚠ Урок:
pr_infoв тісному циклі — це антипаттерн. У реальних драйверах для гарячого коду використовуютьpr_info_ratelimited()(явно обмежує частоту) абоdev_dbg()(виводиться тільки якщо ядро в debug режимі). Або просто не друкують у hot path взагалі.
Експеримент 3: справжній kernel oops
Тепер найкраще. Робимо навмисний NULL pointer dereference в init. Це безпечно: ядро спіймає помилку, вб’є процес insmod, але система продовжить працювати. Я це робив десятки разів.
Додаю параметр crash_test:
static int crash_test = 0;
module_param(crash_test, int, 0644);
MODULE_PARM_DESC(crash_test, "Якщо 1 — навмисно ламаємось у init");
/* в hello_init, перед return 0: */
if (crash_test) {
int *bad_pointer = NULL;
pr_warn("hello: зараз буде боляче — навмисний NULL deref\n");
*bad_pointer = 42; // BOOM
}
Спочатку — без crash, переконатись що нічого не зламали:
$ make $ sudo insmod hello.ko name=Safe $ sudo rmmod hello $ dmesg | tail -3 [24477.510478] hello: модуль завантажено [24477.510500] hello: Привіт, Safe! (раз 1 з 1) [24477.643819] hello: до побачення, Safe! Модуль вивантажено
Працює як раніше. Тепер з crash_test=1:
$ sudo insmod hello.ko name=Boom crash_test=1 $ dmesg | tail -50
І ось воно, шикарне:
[24497.385684] hello: модуль завантажено [24497.385714] hello: Привіт, Boom! (раз 1 з 1) [24497.385733] hello: зараз буде боляче — навмисний NULL deref [24497.385763] Unable to handle kernel NULL pointer dereference at virtual address 0000000000000000 [24497.394196] Mem abort info: [24497.396997] ESR = 0x0000000096000045 [24497.400755] EC = 0x25: DABT (current EL), IL = 32 bits [24497.412356] FSC = 0x05: level 1 translation fault [24497.417305] Data abort info: [24497.420195] ISV = 0, ISS = 0x00000045, ISS2 = 0x00000000 [24497.425759] CM = 0, WnR = 1, TnD = 0, TagAccess = 0 [24497.451571] Internal error: Oops: 0000000096000045 [#1] SMP [24497.457280] Modules linked in: hello(O+) rfcomm cmac ... [last unloaded: hello(O)] [24497.524318] CPU: 1 UID: 0 PID: 2409 Comm: insmod Tainted: G C O 6.18.33+rpt-rpi-v8 #1 PREEMPT [24497.540962] Hardware name: Raspberry Pi 3 Model B Rev 1.2 (DT) [24497.553910] pc : hello_init+0x78/0xff8 [hello] [24497.558403] lr : hello_init+0x70/0xff8 [hello] [24497.631250] x2 : 0000000000000000 x1 : 000000000000002a x0 : 0000000000000000 [24497.638475] Call trace: [24497.640942] hello_init+0x78/0xff8 [hello] (P) [24497.645434] do_one_initcall+0x60/0x2a0 [24497.649309] do_init_module+0x5c/0x268 [24497.653096] load_module+0x197c/0x1fb8 [24497.656885] init_module_from_file+0x90/0xe0 [24497.661201] __arm64_sys_finit_module+0x1c4/0x340 [24497.665957] invoke_syscall+0x4c/0x100 [24497.669746] el0_svc_common.constprop.0+0x48/0xf0 [24497.674502] do_el0_svc+0x24/0x38 [24497.677849] el0_svc+0x38/0x120 [24497.681020] el0t_64_sync_handler+0xa0/0xe8 [24497.685249] el0t_64_sync+0x198/0x1a0 [24497.695119] ---[ end trace 0000000000000000 ]---
Це справжній kernel forensics артефакт — exactly те що embedded інженер бачить коли драйвер багнувся в продакшні. Уміння читати stack trace — окрема навичка. Швидкий розбір зверху вниз:
Unable to handle kernel NULL pointer dereference at virtual address 0x0— ARM64 MMU спіймало запис у адресу 0. Це найважливіший рядок, з нього починаємо читати будь-який oops.ESR = 0x96000045,FSC = level 1 translation fault,WnR = 1— це деталі: переклад віртуальної адреси в фізичну впав на верхньому рівні page table, операція була запис (WnR=1, не читання). Тобто ми писали в неіснуючу пам’ять.Modules linked in: hello(O+)— наш модуль зі статусомO(Out-of-tree) і+(init in inconsistent state).Hardware name: Raspberry Pi 3 Model B Rev 1.2— куди корисніше за просто «Linux» :)pc : hello_init+0x78 [hello]— Program Counter в момент крашу.+0x78від початку нашої функції. Якщо мати символи з налагоджувальною інформацією, можна знайти точний рядок в коді.x1 : 000000000000002a— регістрx1містить значення0x2a= 42 (саме його ми хотіли записати в NULL!).x0 : 0— нашbad_pointer.- Call trace знизу вгору — як ми сюди дістались: userspace викликав syscall (
el0_svc), ядро викликало__arm64_sys_finit_module(це syscall завантаження модуля), потімload_module→do_init_module→do_one_initcall→ і нарешті нашhello_init+0x78— БУМ.
⚠ Зверни увагу на «last unloaded: hello(O)» — це наш попередній чистий
rmmodпісля Safe тесту. Ядро пам’ятає недавно вивантажені модулі, бо їх символи можуть фігурувати в oops’ах якщо одразу післяrmmod. Корисна функція для debug’у.
Невеличкий рікошет: одна навичка — два шляхи
Той oops тригернув таракана в голові. Якщо в kernel модулі можна писати в довільну пам’ять (наприклад через bug — buffer overflow у драйвері), теоретично можна переписати функцію в kernel symbol table, підмінити syscall у sys_call_table, викликати потрібний код з повними kernel-привілеями. Можливо це саме те, що називається kernel exploitation і privilege escalation через kernel bug.
Тобто та сама базова навичка — написати kernel module — це одночасно фундамент для embedded driver development та перший крок у kernel security. Один і той самий insmod, два різні шляхи з нього. Хочу показати один невинний експеримент який ілюструє це наочно.
Робимо модуль який знаходить current task struct (себе самого, процес insmod) і друкує його UID. Потім намагається змінити свій UID на 0 через kernel API. Якщо це працює — побачимо що kernel code справді має повний доступ до credentials будь-якого процесу. Якщо не працює — побачимо як сучасне ядро себе захищає.
⚠ Це НЕ exploit. Цей модуль завантажується лише через
sudo insmod— тобто ми вже root перед запуском. Демонстрація лише ілюструє що означає «kernel space має повний доступ». Не використовується жодна CVE, жодного memory corruption, жодного обходу захисту. Просто два публічні виклики kernel API які кожен embedded інженер мусить знати.
Створив директорію 02-creds-demo, написав модуль, який в init робить три речі: читає current_cred(), виводить UID/EUID, потім викликає prepare_kernel_cred(NULL) і commit_creds() щоб отримати root credentials.
Перший запуск:
$ sudo insmod creds_demo.ko insmod: ERROR: could not insert module creds_demo.ko: Cannot allocate memory
Cannot allocate memory? Подивимось dmesg:
[25836.342661] x2 : 0000000000000000 x1 : ffffff8002e48000 x0 : 0000000000000000 [25836.345127] Call trace: [25836.349797] prepare_kernel_cred+0x2a8/0x300 (P) [25836.354816] creds_demo_init+0x90/0xff8 [creds_demo] [25836.358692] do_one_initcall+0x60/0x2a0 [25836.362479] do_init_module+0x5c/0x268 [25836.366268] load_module+0x197c/0x1fb8 [25836.403085] creds_demo: не вдалось prepare_kernel_cred
⚠ Ого.
prepare_kernel_cred(NULL)повернув NULL — функція пройшла далеко (+0x2a8/0x300, тобто майже кінець реалізації) і явно вирішила відмовити. Це не баг — це захист. У Linux 5.18 (2022 рік)prepare_kernel_cred(NULL)було навмисно зламано, бо саме цей виклик був класичним інструментом kernel rootkit’ів — повертав чисті root credentials. Спільнота зважила на те що це більше шкодить ніж допомагає, і додала перевірку. Моє ядро 6.18.33 успадкувало цей захист.
Виправлення для legitimate use case — передавати task struct реального процесу замість NULL:
/* було: */ new_cred = prepare_kernel_cred(NULL); /* стало (додаємо #include <linux/sched/task.h>): */ new_cred = prepare_kernel_cred(&init_task);
init_task — це PID 1, він завжди root. Тобто ми просимо ядро не «дай root з повітря», а «дай мені копію credentials реального процесу init». Це legitimate use, який використовується багатьма kernel threads. Запускаємо знову:
$ sudo insmod creds_demo.ko $ dmesg | tail -10 creds_demo: ===== STAGE 1: хто запустив insmod ===== creds_demo: PID=2917, name=insmod creds_demo: UID=0, EUID=0 (це твій реальний sudo user) creds_demo: ===== STAGE 2: змінюємо UID процесу на 0 ===== creds_demo: тепер UID=0, EUID=0 creds_demo: ===== STAGE 3: що це означало ===== creds_demo: процес insmod (PID 2917) тепер має UID 0 creds_demo: але це не exploit — sudo вже дав нам root, creds_demo: ми просто продемонстрували що kernel code creds_demo: має повний доступ до task credentials
Працює. Це і є ілюстрація: моя struct cred та полями task_struct має ту саму вагу, що й код будь-якого реального драйвера USB чи мережі. Жодного «security» рівня всередині ядра немає — є тільки контракти між модулями і ядром. Якщо твій код запустився всередині ядра — він всемогутній.
З цього випливають defensive lessons для embedded інженера: Module signing (CONFIG_MODULE_SIG_FORCE) щоб тільки підписані модулі могли завантажуватись. Kernel lockdown mode щоб обмежити навіть root. Secure Boot щоб обмежити що взагалі може стартонути. Для серйозного embedded продукту відкритий root + можливість insmod = catastrophe — ніби ти комусь дав незаповнений підписаний бланк з печаткою на своє їм’я.
Тут зупиняюсь — це окрема глибока тема, яку я люблю і вивчаю, але це не для цієї статті та і взагалі не для DOU. Для тих хто хоче далі: MITRE ATT&CK T1547.006 (Kernel Modules and Extensions), документація Linux kernel hardening, репозиторій linux-hardened. На DOU я колись писав огляд книги Packt про malware development — теж дотичне.
Це фінальна ілюстрація для розділу: одна навичка insmod hello.ko — і два світи. Один пише /dev/hcsr04 для робота, інший вивчає як цей сам механізм еволюціонує проти зловживань. Обидва шляхи варто знати.
◆ Що зробили і що далі
У цій статті ми розглянули фундамент для Місяця 4:
- Виявили що audio на Luckfox Pico Pro архітектурно не варто (I²S не виведений на header, Linux scheduler нестабільний для real-time). Wake-word йде на STM32 — це окрема стаття можливо для Місяця 5.
- Записали свіжу Raspberry Pi OS Lite
64-bit, ядро 6.12.75 (потім apt upgrade тихенько оновив до 6.18.33). - Знайшли Pi у мережі через nmap, підключились по SSH, зафіксували IP, додали alias.
- Виявили що 3B+ виявилась 3B Rev 1.2 — без розбирання корпусу не побачиш.
- Headers вже стоять разом з ядром, окремого пакета
raspberrypi-kernel-headersбільше не існує. - Зібрали чек-ліст «Pi пропала з мережі» з 4 кроків.
- Написали і завантажили перший
hello.koз module params та sysfs runtime зміною. - Експериментували:
-EINVALвідмова, ring buffer переповнення, повний живий kernel oops з ARM64 stack trace.
В наступній статті — переходимо від «hello world» до реальної взаємодії з залізом. Пишемо kernel module який блимає LED через GPIO ядра. Перша різниця між sysfs способом (
echo > /sys/class/gpio/export, як ми звикли з userspace) і власним driver’ом. Зробимо те ж саме — але як справжні драйверщики.
До зустрічі — Hello, GPIO! Підпишись і клацни зірочку, у мене ще багато чого є написано в шухляді, окрім цієї серії.
4 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарівА ще бувай глючний USB дріт/повербанк/блок-живлення. І при цьому все горить як треба, але ознаків життя немає.5-10 хвилин працює, а потім все зникає. Після перезапуску знову працює, і знову зникає.
Є таке 🙂. Це класика, це бісить але як без цього 🤗
А ще бувас помилка в FPGA дизайнi плати, якщо ти отримав не стандартну. Attention: asic vs fpga vs jetson. Note: У автора мова постійно іде тільки про ASIC
www.google.com/...&sourceid=chrome&ie=UTF-8