STM32 з нуля без HAL: Місяць 5, Тиждень 3: Як pytest знайшов баг, який я сам ніколи не побачив би через термінал

Частина 17 циклу “STM32 з нуля без HAL” · Місяць 5, Тиждень 3

Колись великі географічні відкриття робили одинаки. Вони вирушали у невідоме, довіряючи компасу, картам і власній інтуїції. Так подорожував і Миклухо-Маклай, досліджуючи землі, де до нього майже не ступала нога європейця. Але навіть найуважніший дослідник міг пройти повз щось важливе. Наш мозок не здатний однаково уважно сприймати все навколо. Він постійно відбирає, що варте уваги, а що можна проігнорувати. Саме тому досвідчені фахівці часто бачать те, чого новачки просто не помічають, це і є те саме «набите око», або професійна деформація.

Але виникає цікаве запитання. Чи варто сьогодні роками «набивати око» на всі можливі помилки, якщо дедалі більше рутинної роботи можуть виконувати автоматизовані інструменти та штучний інтелект? Мені здається, що розуміти природу помилок потрібно обов’язково. Проте шукати кожну з них власними очима в наш час зовсім не обов’язково.

Саме так відбувається і з ручним тестуванням прошивки. Відкриваєш serial-консоль, надсилаєш команду, дивишся на відповідь і здається, що все працює. Але це лише тому, що той маршрут, проклав ти сам, а помилки ж часто ховаються на стежках, якими ніхто не ходив, принаймні сьогодні.

Після Частини 16 мені довелося пройти саме такий шлях. Ground loop, дивна поведінка UART, priority starvation проблеми,які виникали лише за дуже специфічних умов. Доводилося буквально прокладати маршрут подорожі через дроти, аналізатор сигналів і нескінченні перезапуски плати. Саме тоді стало зрозуміло: ручне тестування через screen або minicom дозволяє перевірити лише те, що я сам здогадався перевірити.

Автоматизовані тести працюють інакше. Це вже не один мандрівник із блокнотом, а ціла експедиція. Вона розбиває територію на квадрати і методично обходить кожну стежку, перевіряє кожен поворот, не забуває жодної точки й повертається саме туди, де минулого разу знайшла проблему. Вона досліджує маршрути, про існування яких ми могли навіть і не здогадуватися. Взагалі тести дуже корисна штука і я завжди свій код покривав автоматизованими тестами принаймні на 60%, так витрачаєш час на написання, але ж створення чогось корисного не завжди спринт, в більшості випадків це довга подорож. Оскільки я орієнтуюсь більше на новачків, то хочу показати, як це проявляється буквально в перший день роботи: автоматичні тести знайшли більше, ніж можна було б перевірити вручну. І саме з цього моменту починається зовсім інша культура розробки прошивки.

◆ Чужий досвід перед стартом

Є одна цікава думка з книги «48 законів влади». Якщо дуже спростити, то вона звучить приблизно так: не обов’язково бути тим, хто придумав ідею першим. Історія часто запам’ятовує не винахідника, а того, хто зміг зробити ідею популярною або довести її до досконалості. Для бізнесу це, мабуть, чудова порада. Але для навчання я роблю навпаки. Коли тільки вчишся дуже легко потрапити в копіпаст. Знайшов проєкт на GitHub, повторив рядок у рядок, запустив працює. Клас. Але що ти зрозумів? Мабуть майже нічого.

Давно помітив одну річ. Розвиваєшся не на десятому клоні чужого проєкту, а  коли тобі свербить: «А що, якщо зробити трохи по-іншому?» І починається найцікавіше. Не працює. Ламається. Знову не працює. Але саме в цей момент мозок починає будувати нові нейронні зв’язки. Так дитина вчиться ходити. Так ми вчимося їздити на велосипеді чи автомобілі. Ніхто не стає майстром, просто дивлячись, як їздять інші, хоча може і є випадки.

Мені здається, майже всі великі інженери, фізики й винахідники були трохи дивними «чомучками». Вони не могли спокійно пройти повз якусь штуку, не поставивши собі запитання: «А чому саме так? А якщо зробити інакше? А як це працює?» Саме ця цікавість і є справжнім двигуном навчання, а бажання створити щось своє слугує двигуном прогресу.

Тому чужий досвід я використовую не як готовий рецепт, а як карту. Карта показує, куди вже ходили інші. Але сам маршрут я все одно хочу пройти власними ногами.

Першим дороговказом для мене став Golioth Developer Blog. Вони запропонували дуже чисту архітектуру тестів: conftest.py із fixtures, які підключають реальну плату до pytest, функцію program() для автоматичної прошивки перед тестом і сам тест, який читає UART.

Особливо сподобалася їхня фраза:

“There is no substitute for testing firmware on real target hardware, but manual testing is both error prone and time consuming.”

З попередньої статті і вам це відомо.

Другим джерелом натхнення став uart-protocol-tester на GitHub. Ні я не хотів його повторити, просто мені сподобалася сама ідея. Автор не лише перевіряє чи це працює. Він перевіряє й завідомо неправильні команди, timeout, невалідні дані та інші ситуації, про які розробники часто згадують уже після того, як користувачі знайшли баг.

Дещо мені взагалі не підійшло. Наприклад, багато прикладів використовують stm32loader (PyPI), який прошиває STM32 через UART Bootloader. У мене ж плата прошивається через ST-Link і SWD, тому program() використовую не через вбудований bootloader, тож мій program()-еквівалент обгортає subprocess.run(["st-flash", ...]), не stm32loader.

До речі, Bootloader це взагалі окрема цікава тема, про яку варто поговорити хоча б кілька хвилин.

Коли ми натискаємо кнопку Upload в Arduino IDE, здається, ніби магія відбувається сама собою. Підключив USB, натиснув кнопку — і нова прошивка вже працює. Але за цією «магією» стоїть саме Bootloader. Це невелика програма, яка живе у Flash-пам’яті мікроконтролера й уміє приймати нову прошивку без програматора. По суті, це маленький інсталятор, який запускається першим і вирішує: запускати основну програму чи перейти в режим оновлення.

І тут починається найцікавіше.

Археологічна знахідка з моєї майстерні. 😄 Старий програматор, AT89C52 і купа дротів — саме з цього колись почалася моя Embedded-пригода. Якщо впізнали ці артефакти, значить ви теж із тих часів. Напишіть у коментарях — цікаво, скільки олдів читає цю серію.

Bootloader теж потрібно колись записати в мікроконтролер. На Arduino це вже зробили за нас, тому все виглядає як справжній Plug & Play. Підключив USB, натиснув Upload — і ніби ніякої магії більше не існує.

Але так було не завжди.

У мене й досі лежить коробка з тих часів, коли я тільки знайомився з мікроконтролерами. Старенький програматор, шлейфи, перехідники, макетні плати і мій перший AT89C52 від Atmel. Тоді кожен крок здавався маленькою перемогою. Спочатку потрібно було зрозуміти, як взагалі записати програму в чип, потім розібратися, чому він не запускається, а потім ще й знайти, який провід я переплутав.

Bootloader — це теж програма. Просто вона запускається першою, ще до вашого застосунку. Її теж хтось написав, протестував і колись записав у мікроконтролер.

До речі, якщо ви користуєтесь Arduino, то можете легко це перевірити самі. В Arduino IDE є команда Burn Bootloader. Вона не записує ваш скетч, а саме перепрошиває бутлоадер мікроконтролера. Для цього знадобиться або зовнішній програматор, або… інша плата Arduino, яку можна тимчасово перетворити на програматор. Саме так багато хто й починав знайомство з низькорівневим світом мікроконтролерів.

А тепер подивіться, наскільки еволюціонувала ця ідея. Візьмемо Raspberry Pi Pico. На платі є кнопка BOOTSEL. Якщо затиснути її під час підключення USB, мікроконтролер навіть не запускає вашу програму. Замість цього він переходить у режим Bootloader, а комп’ютер бачить Pico як звичайну USB-флешку. Далі все виглядає майже як копіювання фотографій: достатньо просто перетягнути файл .uf2 на цей «диск», і через кілька секунд плата вже запускає нову програму.

І тут є один цікавий нюанс, який часто плутають новачки. Може здатися, що кожного разу ви перепрошиваєте Bootloader. Насправді — ні.

У мікроконтролері RP2040 Bootloader записаний виробником у спеціальній ROM-пам’яті. Це означає, що він є частиною самого чипа й практично не може бути випадково стертий або пошкоджений. Кнопка BOOTSEL лише наказує процесору запустити саме цей ROM Bootloader, який уже записує файл .uf2 у зовнішню Flash-пам’ять.

Інакше кажучи, коли ви встановлюєте MicroPython, запускаєте програму, написану через Pico SDK, використовуєте Arduino або CircuitPython, ви не змінюєте Bootloader. Ви змінюєте лише свою програму (application). Bootloader залишається тим самим.

Саме тут добре видно різницю з класичними Arduino. У багатьох AVR-мікроконтролерах бутлоадер знаходиться у Flash-пам’яті разом із вашою програмою. Його можна оновити, переписати або навіть випадково стерти. На RP2040 це майже неможливо, тому його Bootloader можна вважати практично «невмирущим».

І ось що мені найбільше подобається. Один і той самий Raspberry Pi Pico сьогодні може працювати з MicroPython, завтра — з Arduino, післязавтра — з C/C++ через Pico SDK, а ще через тиждень — з будь-якою іншою платформою, яка вміє генерувати файл UF2. Для користувача це виглядає як магія. Але насправді вся ця магія починається з маленької програми, яку ми називаємо Bootloader.

.uf2 — це спеціальний формат прошивки для мікроконтролерів, розроблений компанією Microsoft. Його назва розшифровується як USB Flashing Format.

Саме тому Arduino свого часу став маленькою революцією. Він приховав усю цю складність від новачків. Замість думати про програматори, fuse-біти чи бутлоадери, можна було одразу почати писати код. І це, мабуть, одна з причин його шаленої популярності.

У багатьох плат STM32 виробник теж записав свій системний Bootloader у спеціальну область пам’яті (ROM), яку не можна випадково стерти. Саме ним користуються такі інструменти, як stm32loader.

Але в реальних комерційних проєктах усе часто набагато цікавіше. Якщо уважно почитати вакансії Embedded-розробників, особливо в automotive, aerospace чи miltech, дуже часто можна побачити вимогу «розробка Bootloader». Спочатку мене це дивувало. Навіщо писати свій, якщо виробник уже все зробив?

Потім починаєш розбиратися і розумієш, що заводський Bootloader універсальний. А бізнесу потрібен свій.

Компанія може захотіти оновлювати прошивку через Ethernet, Wi-Fi, Bluetooth, CAN, USB Flash, SD-карту або навіть через супутниковий канал зв’язку. Може знадобитися перевірка цифрового підпису, шифрування, захист від відкату до старої версії, резервна прошивка, яка врятує пристрій, якщо оновлення обірветься посеред процесу. Усе це — уже відповідальність власного Bootloader.

І ось тут стає зрозуміло, чому написання Bootloader вважається досить престижною задачею. Насправді це окремий невеликий проєкт усередині великого проєкту. Він стартує ще до запуску основної програми, працює буквально з першої інструкції процесора й повинен бути настільки надійним, щоб навіть невдале оновлення не перетворило пристрій на «цеглину».

Мій випадок набагато простіший. Під час розробки я використовую ST-Link через SWD, тому можу записувати прошивку безпосередньо у Flash-пам’ять і не залежати від Bootloader взагалі.

Ось це, мабуть, і є баланс, і не тре винаходити велосипед щоразу, але й копіювати чужий велосипед до останнього гвинтика теж не варто. Подивитися, як його зробили інші, зрозуміти принцип, а потім зібрати свій. Можливо, він не буде ідеальним. Зате це вже буде твій велосипед. А значить, наступний ти побудуєш ще кращим.

💡 До речі, якщо не хочеться винаходити велосипед…

Як це часто буває в Open Source, велосипед уже хтось винайшов. І не один.

Якщо потрібен серйозний промисловий Bootloader із підтримкою цифрових підписів, безпечного оновлення та відкату прошивки, варто подивитися на MCUboot. Це, мабуть, найвідоміший Open Source Bootloader для 32-бітних мікроконтролерів. Його використовують у Zephyr, Apache Mynewt та багатьох комерційних продуктах. Проєкт доступний на GitHub, а також має гарну документацію. MCUboot Documentation

Якщо ж цікаво подивитися, як усе починалося в екосистемі Arduino, зверніть увагу на Optiboot. Це маленький і дуже елегантний Bootloader, який багато років використовується в Arduino Uno та інших AVR-платах. Його код значно простіший за MCUboot, тому це чудова відправна точка для тих, хто хоче зрозуміти принцип роботи Bootloader. Optiboot GitHub

Для Raspberry Pi Pico та інших плат Adafruit існує TinyUF2 — Open Source Bootloader, який перетворює плату на звичайну USB-флешку. Саме завдяки йому достатньо просто перетягнути файл .uf2, щоб оновити прошивку.

А якщо колись доберетеся до Embedded Linux, то майже напевно зустрінете U-Boot. Попри схожу назву, це вже зовсім інший клас Bootloader. Він запускає не окрему прошивку мікроконтролера, а цілі операційні системи, наприклад Linux, на Raspberry Pi, BeagleBone, Rockchip, NXP та багатьох інших ARM-платформах.

Чесно кажучи, після знайомства з усіма цими проєктами в мене виникла зовсім інша думка. А чи обов’язково одразу використовувати готовий Bootloader? Можливо, для навчання набагато цікавіше написати свій. Нехай спочатку він уміє лише перевірити кнопку, записати прошивку через UART і передати керування основній програмі. Зате кожен рядок коду буде зрозумілим.

Зрештою, власний HAL у мене вже є. То, може, настав час написати й власний Bootloader? 🙂

◆ Архітектура: три шари

requirements.txt:

conftest.py — fixtures, що прошивають плату й підключають serial:

◆ Перші дві гулі — ще до першого реального тесту

Ґуля 1: os.path.join() з ведучим слешем скидає весь попередній шлях. Написав спочатку так:

Помилка ховається в третьому сегменті — ведучий / перед STM32F103HAL. Python-документація каже прямо: якщо будь-який компонент — абсолютний шлях, усі попередні компоненти відкидаються, і побудова шляху починається заново з нього. Результат — os.path.join мовчки повернув /STM32F103HAL/stm32_freertos/... від кореня диска замість реального шляху. st-flash чесно повідомив open(...) == -1, і мені знадобилась хвилина, щоб зрозуміти, чому шлях, який я сам написав, виявився не тим, що я думав.

Ґуля 2: st-flash пише підтвердження успіху в stderr, не в stdout. Перша версія assert перевіряла тільки result.stdout — і падала з дуже дивним повідомленням: текст "Flash written and verified" був присутній у виводі помилки, але assert все одно не проходив. Причина — вся діагностика й фінальне підтвердження st-flash йдуть у stderr, stdout містить тільки прогрес N/M pages written. Контрінтуїтивно для CLI-інструменту (успіх зазвичай очікують у stdout), але легко виправляється, тре просто перевіряти обидва потоки разом.

◆ Перший тестовий набір

Перший запуск дав ще одну, третю ґулю.

Ґуля 3: race між відкриттям порту тестом і реальною готовністю плати. Перший прогін: test_on_command FAILED, решта трьох — PASSED. Дивно — чому саме перший? test_on_command — це перший тест, який реально відкриває serial-порт після прошивки й reset. Між моментом, коли dut-fixture успішно відкрив порт, і моментом, коли FreeRTOS реально запустив vUartTask і ввімкнув UART-переривання, минає якийсь час. Команда on, надіслана занадто рано, просто губиться в порожнечу до того, як плата взагалі готова слухати.

Виправлення — flash_once тепер явно чекає перший рядок з UART ("FreeRTOS UART task ready...") як сигнал готовності, перш ніж повертати керування тестам. Другий прогін — усі 4 тести PASSED.

◆ Що можна перевірити через UART — і що ні

Перш ніж розширювати набір тестів, варто чесно окреслити межу.

Реально тестується:

  • Функціональна поведінка (команда → очікувана відповідь)
  • Регресія на конкретні знайдені баги (priority starvation)
  • Межові умови буфера команди
  • Стійкість до сирого, непередбаченого вхідного сміття
  • Відсутність STACK OVERFLOW (наш hook з Частини 16) під навантаженням
  • Латентність відповіді

Принципово не тестується тільки через UART:

  • Фізичний стан GPIO — "LED on" у терміналі доводить лише, що плата написала цей текст, не що PC13 реально пішов у LOW. Для фізичної перевірки потрібен мультиметр, логічний аналізатор, чи GPIO loopback — окрема тема поза межами цієї статті.
  • Внутрішній стан, не виведений явно в UART (наприклад реальний залишок heap_4) — тест бачить лише те, що прошивка сама вирішила показати.
  • Мікросекундна точність тайминга — UART на 9600 baud сам додає ~1мс на символ затримки.

◆ Розширення: межові умови й стрес

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

◆ Тест, який знайшов справжній, досі не відомий баг

Останній тест з першого набору навмисно перевіряв екстремальний, але реалістичний сценарій: сирі байти без \r\n зовсім, довші за розмір буфера команди. Логіка vUartTask з Частини 16:

Гіпотеза: якщо idx досягає межі CMD_LEN-1 без термінатора, умова idx < CMD_LEN - 1 стає false назавжди — символи мовчки відкидаються, idx ніколи не скидається, буфер “заклинює”.

Перша версія тесту виявилась нечесною — рятівний рядок b"\r\non\r\n" починався з \r, який сам скидав idx ще до перевірки. Виправив:

FAILED. Справжній баг, зловлений з першої чесної спроби.

Розібрав побайтово, що реально відбувається. Причина глибша за просту “забув else“: з idx < CMD_LEN - 1 і сусіднім окремим (не else if) блоком термінатора символи \r/\n могли одночасно потрапляти і в гілку відправки команди, і записуватись як звичайні дані — залежно від точного порядку if, який я спершу написав неправильно.

Виправив структуру на else if, додав явний скид idx=0 при переповненні:

Тест і далі падав. Причина виявилась не в структурі if, а концептуальна: мій сценарій “30 символів сміття + on\r\n одним неперерваним потоком, без жодного розділювача” — з точки зору лінійного посимвольного парсера це виглядає як один рядок "xxx...xxxon", що завершується \r\n лише в самому кінці. Немає способу коректно “витягти” on з середини такого потоку — правильна поведінка тут не магічне відновлення, а відкидання всього переповненого рядка цілком, аж до наступного термінатора:

І тест виправив сміттєвий рядок який завершується власним \r\n (шум зупинився), а вже потім, окремо, приходить справжня команда:

PASSED. Прогнав увесь набір повторно — усі 9 тестів пройшли, регресії немає.

◆ Фінальний результат

◆ Довідник: команди й поняття цієї статті

Коротка шпаргалка — все, що реально використовувалось у Частинах 15-17, в одному місці для швидкого пошуку.

Команди термінала

Команда Що робить
st-info --probe перевірка, чи ST-Link бачить плату, розмір Flash/SRAM
st-flash --connect-under-reset write file.bin 0x08000000 прошивка з обходом проблеми “Can not connect to target”
screen /dev/ttyUSB0 9600 UART-термінал (вихід: Ctrl+A, потім K)
lsusb | grep -i "CH340|CP210|FTDI" перевірка, чи система бачить USB-TTL адаптер
pytest -v запуск усіх тестів з докладним виводом
pytest -v -k назва_тесту запуск лише одного тесту за частковим збігом імені
pip install пакет --break-system-packages встановлення пакета в системний Python (потрібно на деяких дистрибутивах)

Ключові FreeRTOS-виклики з цих трьох статей

Виклик Призначення
xTaskCreate(func, "name", stack, params, priority, handle) створити таск; stack — у словах
vTaskStartScheduler() запустити планувальник
vTaskDelay(pdMS_TO_TICKS(ms)) блокуюча затримка, звільняє CPU іншим таскам
xQueueCreate(довжина, розмір_елемента) створити чергу в динамічній памʼяті (heap_4)
xQueueSend / xQueueReceive покласти/забрати елемент з черги; з portMAX_DELAY — блокує до появи місця/даних
xQueueSendFromISR те саме, але безпечно викликати з переривання
xSemaphoreCreateMutex() / xSemaphoreTake / xSemaphoreGive взаємне виключення доступу до спільного ресурсу (у нас — UART)
portYIELD_FROM_ISR(xHigherPriorityTaskWoken) у кінці ISR — дозволяє негайне перемикання на таск, якщо той прокинувся від FromISR-виклику

Поняття

  • Polling (полінг) — активне опитування апаратного прапорця в циклі (while (!(SR & RXNE));). Просто, але займає процесор весь час очікування — небезпечно поєднувати з високим пріоритетом таска в RTOS.
  • Priority starvation (голодування за пріоритетом) — коли високопріоритетний таск, що не блокується по-справжньому (наприклад, через busy-wait), не дає низькопріоритетним тускам жодного шансу виконатись.
  • Time-slicing (пороздільний розподіл часу) — примусове перемикання між тасками однакового пріоритету на кожному тіку SysTick. Рятує від starvation як побічний ефект, але не вирішує причину.
  • ORE (Overrun Error) — апаратний прапорець USART, що виставляється, коли новий байт прийшов, а попередній ще не прочитаний з однобайтового буфера прийому.
  • configMAX_SYSCALL_INTERRUPT_PRIORITY — поріг пріоритету переривань в NVIC, вище якого забороняється викликати будь-які FreeRTOS API, навіть FromISR-варіанти.
  • Ground loop (петля заземлення) — паразитний струм, що виникає, коли один пристрій має два незалежні шляхи до “землі” (наприклад, ST-Link і UART-адаптер одночасно) через різницю потенціалів між портами хоста.

Наступна стаття — OpenCV motion detection на Pi, готуємо зір для автобота.

Опубліковано в STM32

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

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