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:

Чекай, я ж думав це 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:

Перед тим як встромляти картку — перевіряю які диски в системі зараз:

⚠ Запам’ятай вивід. Тепер встромляєш картку — і знову 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, але MAC однаковий — це одна і та сама Pi на двох інтерфейсах. Підключаюся:

На питання fingerprint — yes, пароль той що задав у Imager.

Вже на Pi перевіряю інтерфейси:

OK, eth0 = .109, wlan0 = .114. Зафіксую ethernet IP як основний — для kernel розробки стабільність важлива (якщо щось зламаю в мережевому стеку через невдалий модуль, wifi першим помре).

На Ubuntu додаю alias у ~/.ssh/config щоб не запам’ятовувати IP:

Тепер просто ssh pi. На роутері додатково прив’язую MAC B8:27:EB:D4:26:95 до цього IP в DHCP reservation, щоб ніколи не плавав.

◆ Перші команди на Pi

Системна інформація:

Ядро 6.12.75 від березня 2026, aarch64. Свіже.

29 GB rootfs (Pi сама розширила до повного розміру картки при першому boot), 3 GB зайнято — місця море. 51°C у спокої — нормально для Pi 3 без радіатора.

Запускаю оновлення:

⚠ Якщо у тебе тут оновиться ядро — обов’язково sudo reboot після завершення і перепідключитись по SSH. Інакше uname -r показуватиме одне ядро, а /lib/modules з headers матиме інше — і потім всі kernel модулі будуть з помилкою «Invalid module format».

◆ Ґуля № 2: де поділись raspberrypi-kernel-headers

Класичний рецепт для kernel модулів на Pi казав так:

Запускаю — і отримую:

Хм. На свіжих 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

Перевіряємо чи це справді так:

Бачимо Makefileincludescripts — все необхідне для kbuild. Чудово, ставити нічого не треба, рухаємось далі.

⚠ Урок: туторіали 2-3-річної давності можуть пропонувати команди яких уже не існує. Завжди перевіряємо ls /lib/modules/$(uname -r)/build/ — якщо там є вміст, headers вже встановлені. Якщо порожньо або битий symlink — це окрема пригода з rpi-source, але у нашому випадку обійшлось. Істина проста. Як було раніше вже не буде))

Доставляю інші утиліти для розробки (без kernel-headers, який вже не потрібен):

Перевіряю що компілятор і make на місці:

◆ Ґуля № 3: Pi пропала з мережі

Між сесіями робив паузу, повернувся за ноут — ssh pi:

«No route to host» — це не «пароль не той» і не «ssh не запущений». Це нижчий рівень: ноут просто не знає куди слати пакети. Сама Pi або вимкнена, або відключена від мережі, або змінила IP. Перед тим як панікувати — короткий чек-ліст з 4 кроків:

Крок 1: чи Pi взагалі жива

Якщо «Destination Host Unreachable» — Pi нема в мережі. Якщо «100% packet loss» без unreachable — Pi є але не відповідає.

Крок 2: перевір WiFi адресу як резерв

У мене дві адреси — eth0 і wlan0. Якщо одна не пінгується, інша може.

Крок 3: чи ноут в тій самій мережі

Маршрут має йти через 192.168.1.x. Якщо там інша підмережа (наприклад 192.168.0.1 або 10.x.x.x) — ноут перепідключився до іншої мережі.

Крок 4: сканування — раптом IP змінився

Якщо Pi знайшлась на іншому IP — отже DHCP видав їй нову адресу після перезавантаження. Тре робити DHCP reservation у роутері, але не сьогодні.

У моєму випадку: подивився на плату — червоний LED живлення не горить. Просто був вимкнений блок живлення (хтось випадково смикнув подовжувач, не питайте). Підключив назад — Pi за 30 секунд знову у мережі, ssh pi працює як треба. У мене завжди так)), починаю з самого важкого, а тре з самого простого і очевидного, але ж тоді це буде інша пригода.

⚠ Урок сісадміна: перш ніж лізти у Wireshark або переустановлювати OS — глянь на саму плату. Чи горять світлодіоди? Чи в розетці кабель живлення? У 80% випадків відповідь там.

Користуючись нагодою. Ось класичний жарт про сисадміна та пошук несправностей: Сидить сисадмін, глибоко задумавшись, крутить крісло туди-сюди, нахиляється, заглядає під системний блок, знову крутиться. До нього підходить колега:— Щось серйозне сталося? Сервер «ліг»? База даних не відповідає?Сисадмін, не перестаючи крутити крісло:— Та ні, розумієш… У кріслі щось поскрипує, коли кручуся. Вже пів години не можу зрозуміти, чи це крісло змастити треба, чи в мене десь кулер на сервері так шумить.

◆ Частина 2: пишемо hello.ko

Все готове, рухаємось до самого цікавого. Створюю робочу структуру і одразу git репо щоб коміти не загубились:

hello.c — перший модуль

Файл hello.c — найпростіший kernel модуль з параметрами. Init друкує привіт, exit прощається, параметри name і times передаються при insmod:

Кілька цікавих деталей:

  • __init і __exit — це не для красоти, це секції в ELF файлі. Код з __init після успішного завантаження модуля звільняється з пам’яті ядра. Тобто hello_init існує тільки до моменту коли він повернув 0 — потім його пам’ять вивільняється. Мертвий код у kernel space не тримаємо.
  • pr_info — обгортка над printk(KERN_INFO ...). Має багато братів: pr_errpr_warnpr_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. У свій час дуже з тим намучився, тому постійно на цьому наголошую.

Як це працює: ми не компілюємо самі. Ми просимо kbuild систему ядра ($(MAKE) -C $(KDIR)) зайти в директорію headers і скомпілювати наш файл з усіма правильними прапорами, які використовуються для збірки ядра. M=$(PWD) каже kbuild «зовнішній модуль лежить отут». Це ключовий момент: всі kernel модулі компілюються через kbuild.

Збираємо

⚠ Стривайте — ядро тут 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» який лякає всіх початківців, ну чи не всіх.

Перевіряємо що вийшло:

vermagic — це той самий «штамп сумісності» який ядро перевіряє при завантаженні модуля. Якщо твій vermagic не співпадає з ядром що працює зараз — модуль не вантажиться.

Завантажуємо у ядро

Працює! Розбираємо:

  • taints kernel — це нормально і очікувано. Tainted kernel означає що в ядро завантажено код не з основного дерева (out-of-tree). Кожен такий модуль робить це, включаючи Nvidia, VirtualBox, VMware. Це попередження для мейнтейнерів: «якщо потім буде kernel panic, не плачтесь офіційним розробникам».
  • hello 12288 0 — ім’я, розмір у байтах (12K — мінімум для будь-якого kernel модуля на ARM64), reference count (0 = ніхто не використовує).

Вивантажуємо:

Тепер з параметрами

П’ять привітів як замовляли. Тепер найцікавіший трюк — параметри видно через sysfs і можна змінювати на льоту:

⚠ Бачите ту дивну розривку «до побачення, Stm32 ! Модуль вивантажено»? Це не баг ядра — це echo додало символ переносу рядка в значення параметра. Параметр name тепер містить "Stm32\n", а коли модуль друкує його з форматом «до побачення, %s! Модуль вивантажено», новий рядок ріже повідомлення посередині. Якщо тре чистіше — printf "Stm32" | sudo tee без переносу. Дрібниця, але наглядно: userspace tooling працює непомітно, і це може ламати твій kernel код.

Поки це робив згадав один бородатий анекдот, але мене він веселить до цих пір.

Зі слів юзера:
— Комп не працював. Прийшов адмін, підніс руки до неба, пробурмотів якісь заклинання, прокрутив мій стілець 3 рази навколо своєї осі, штурхнув комп’ютер — і сталося диво — він запрацював!!!

Зі слів адміна:
— Дзвінок. Біжу до юзера. Цей бовдур так крутився на новому стільці, що кабель живлення намотався на ніжку і висмикнувся з блока. Побачивши це, підводжу руки до неба, виголошую кілька триповерхових матюків, розмотую кабель, заштовхую комп ногою подалі під стіл і вмикаю.

◆ Експерименти: коли все йде не так

Тепер давайте навмисно ламати. Бо знайти баг в чужому коді легко, а в своєму найскладніше — і навчитись передбачати проблеми можна тільки борячись з багами. Писати код легко, важко вмовити його працювати стабільно.

Експеримент 1: відмова від завантаження через -EINVAL

Що буде якщо init поверне не 0? Дописуємо в hello_init:

Що сталось:

  • 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:

Стоп. Просив 10000, отримав 2045. Куди поділись 8000 повідомлень? Збільшую буфер запиту:

Все одно ~2000. Значить це не обмеження dmesg, це обмеження самого kernel ring buffer. Дивимось перший рядок який залишився:

Перші 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:

Спочатку — без crash, переконатись що нічого не зламали:

Працює як раніше. Тепер з crash_test=1:

І ось воно, шикарне:

Це справжній kernel forensics артефакт — exactly те що embedded інженер бачить коли драйвер багнувся в продакшні. Уміння читати stack trace — окрема навичка. Швидкий розбір зверху вниз:

  • Unable to handle kernel NULL pointer dereference at virtual address 0x0 — ARM64 MMU спіймало запис у адресу 0. Це найважливіший рядок, з нього починаємо читати будь-який oops.
  • ESR = 0x96000045FSC = level 1 translation faultWnR = 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.

Перший запуск:

Cannot allocate memory? Подивимось dmesg:

⚠ Ого. 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:

init_task — це PID 1, він завжди root. Тобто ми просимо ядро не «дай root з повітря», а «дай мені копію credentials реального процесу init». Це legitimate use, який використовується багатьма kernel threads. Запускаємо знову:

Працює. Це і є ілюстрація: моя 50-рядкова функція з обчисленнями над 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! Підпишись і клацни зірочку, у мене ще багато чого є написано в шухляді, окрім цієї серії.

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *