STM32 з нуля без HAL: Місяць 5, Тиждень 2: Черги, семафори і магічна п’ятірка. Частина 16

Чому черга на чотири елементи змусила систему реагувати лише на п’яту команду

У Частині 15 виявилося, що хардкод у конфігурації протягом чотирьох місяців передавав системі неправильну частоту тактування. Після цієї PLL-історії до черг і семафорів я підійшов обережніше. Якщо навіть базовий vTaskDelay() був налаштований неправильно, то що чекало попереду — там, де дані вже реально передаються між задачами?

Щоб зрозуміти, де виникали труднощі в інших дослідників з тим самим залізом, адже 100% хтось таке вже робив, трохи погуглив.

У Частині 2 я вчився розраховувати baud rate без бібліотек. Тоді я навмисно проігнорував переривання й використав опитування — polling. Пулінг це коли процесор сам, у циклі, безперервно опитує апаратний прапорець і чекає, поки той підніметься:

Простота  жодних переривань, жодного NVIC, жодного окремого обробника: код читається зверху вниз, як звичайна лінійна програма.

NVIC (Nested Vectored Interrupt Controller) — це спеціальний апаратний блок у мікроконтролерах із ядрами ARM Cortex-M. Його головне завдання — керувати всіма перериваннями: він відстежує запити від різних пристроїв, визначає їхній пріоритет та дозволяє переривати виконання поточної програми для обробки більш важливих завдань.

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

На bare-metal без RTOS це не проблема бо процесору однаково нема чим зайнятись. Але щойно зʼявляється FreeRTOS з кількома тасками, (busy-wait) перестає бути нейтральним воно займає час, який міг би дістатись іншому таску. Наскільки це серйозно, зʼясується не одразу.

◆ Що знайшлось

Корисна знахідка — тема на офіційному форумі FreeRTOS 2019 року. Розробник на тому самому Blue Pill зловив тихе зависання рівно на xQueueSendToBack  LED вмикався, але ніколи не вимикався, xTickCount завмирав на місці. Після виснажливого дебагу через GDB причина виявилась не в його коді і не в FreeRTOS, а в конкретній версії arm-none-eabi-gcc (6.3.1, та, що йде в репозиторіях Ubuntu 18.04) — баг у memcpy() для Cortex-M3, який prvCopyDataToQueue використовує на кожен виклик черги. Усе вирішило оновлення тулчейну до 8-2018-q4 та скільки сил було витрачено.

Це мене зацікавило, бо в Частині 15 я щойно написав власний memcpy щоправда не через баг тулчейну, а через -nostdlib. Різні причини, але той самий висновок: memcpy важлива деталь, яку не варто ігнорувати. На наступному кроці черга буде викликати саме мою реалізацію на кожен xQueueSend, тож варто тримати в голові, що там усередині моя власна пара рядків, а не бібліотечна.

Офіційна документація ST підтвердила, що конфіг-макроси, які я вже виставив у Частині 15 (configMAX_SYSCALL_INTERRUPT_PRIORITY, configCHECK_FOR_STACK_OVERFLOW), не самодіяльність, а той самий набір, який ST рекомендує в офіційному FreeRTOS User Guide.

Практичний чекліст для першого RTOS-проєкту на STM32 прямим текстом попереджають: “Random crashes after minutes → stack overflow or calling non-FromISR API in ISR”. Це вже прицільно про те, куди я йду в цій статті, бо якщо дійду до UART через переривання, виклик звичайного xQueueSend замість xQueueSendFromISR з ISR-контексту це непередбачувана поведінка, яка проявиться не одразу. Як виявиться, я  дійду туди набагато швидше, ніж планувалось.

◆ Що виявилось контекстом

І Hackster.io -туторіал на Keil uVision, і CubeMX-серія на ControllersTech розв’язують SysTick-конфлікт тим самим способом вони переносять HAL timebase на TIM6/TIM7 через CubeMX, залишаючи SysTick виключно FreeRTOS. Логічне рішення для CubeMX-проєктів. А в мене іншого шляху й не було ця проблема вирішена в Частині 15 через #ifdef FREERTOS розділення відповідальності над SysTick_Handler в самому HAL, бо CubeMX і HAL_Delay() в мене звісно немає.

Цікавий побічний факт з CubeMX-туторіалу: типовий Total Heap Size там близько 3000 байт. Мій configTOTAL_HEAP_SIZE = 12*1024 учетверо більше. Просто мій HAL не генерує окремий “DefaultTask” з мінімальним навантаженням, а одразу враховує UART-таск з чергою і LED-таск, бо хотів мати запас, щоб не балансувати на межі пари байт.

◆ Що я реально взяв собі на озброєння

vApplicationStackOverflowHook() лишається головним персонажем цієї історії. У мене в FreeRTOSConfig.h виставлено configCHECK_FOR_STACK_OVERFLOW 0 тобто вимкнено. Це означає: якщо будь-який таск переповнить свій 128-словний стек (а в цій статті я додаю UART-таск з чергою, що цілком ризиковано), мікроконтролер піде в HardFault без жодної підказки чому.

Увімкнув:

чому 2 набагато краще для етапу розробки та налагодження:
  • Нульова перевірка (0): Вимикає будь-який контроль. Економить кілька тактів процесора і трохи пам’яті, але якщо задача вичерпає свій стек, почнеться хаотичний перезапис сусідніх даних (наприклад, стек іншої задачі), що призведе до непередбачуваної поведінки системи.
  • Перевірка способом 1: Здійснює перевірку лише під час перемикання контексту. Ядро перевіряє, чи не опустився вказівник стеку (Stack Pointer) нижче виділеної межі. Це працює швидко, але між перемиканнями задач може виникнути критичний стрибок пам’яті (наприклад, через виклик глибоких вкладених функцій), який пропустять.
  • Перевірка способом 2: Використовує апаратну особливість процесора та спеціальні значення (магічні байти), якими ініціалізується стек кожної задачі. Ядро аналізує стек кожного разу, а не лише під час перемикання контексту. Якщо задача затирає визначену межу, негайно спрацьовує перехоплення помилок.

І додав функцію-перехоплювач:

Надіюсь це допоможе мені, коли я почну ускладнювати код чергами. Другий момент, менш терміновий, але вартий занотувати на майбутнє: коли дійду до UART через переривання “ніяких звичайних викликів API всередині обробника переривань (ISR)” якщо цього не зробити, точно почнеться хаос.

Ось чому це відбувається:
1. Відсутність контексту очікування (Blocking)
  • Багато функцій FreeRTOS (наприклад, xQueueSend чи vTaskDelay) розроблені так, щоб призупиняти (блокувати) поточну задачу, якщо черга заповнена або ресурс зайнятий. Вони чекають, поки звільниться місце.
  • Переривання (ISR) повинно виконуватися максимально швидко і ніколи не переходити в режим очікування або блокування. У мікроконтролерів немає «задачі переривання», яку можна призупинити. Якщо викликати звичайний API, який спробує перевести потік в режим очікування, ядро системи розгубиться, що призведе до фатальної помилки.
2. Заблоковані ресурси (Deadlocks & Mutexes)
  • Якщо ваш обробник переривання UART почне викликати звичайні функції, що взаємодіють з мьютексами (Mutexes) або семафорами для захисту ресурсів, він може випадково спробувати захопити мьютекс, який вже тримає головна задача. Це спричинить взаємне блокування або зависання виконання.
3. Безпека потоків (Thread Safety та логіка планувальника)
  • Звичайні API-функції використовують захищені критичні секції та управляють внутрішніми списками, які діляться між різними задачами. Без спеціальної ізоляції з ISR-контексту, виклики почнуть конфліктувати із системним планувальником (Scheduler) і можуть пошкодити внутрішні структури даних FreeRTOS.
  • Функції з приставкою FromISR працюють оптимізовано та безпечно. Вони не ставлять задачу на паузу. Натомість вони повертають спеціальний прапор (наприклад, xHigherPriorityTaskWoken або еквівалент), який сигналізує ядру про те, що якась задача тепер готова до виконання.

Озброївшись таким міцним фундаментом знань, думаю вже можна переходити до черг.

◆ Етап 1 — UART-таск + черга

Задача проста: UART-таск читає команди (on/off) через уже готовий uart_gets(), кладе в чергу. LED-таск читає з черги, виконує:

Зібралось, прошилось, підключив термінал і одразу зрозумів, що щось не так.

◆ Детектив, частина перша: дивна поведінка черги

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

Написав on  а іноді видавало off. Написав on , а прийшло off. Перше, куди я глянув race condition у uart_gets(): тут, функція зупиняється на першому \r або \n, а термінал міг слати обидва символи парою. Якщо перший виклик зупиняється на \r, лишаючи \n непрочитаним у буфері USART, то наступний виклик одразу зʼїсть цей залишок як порожній рядок.

Гіпотеза не витримала перевірки: порожній рядок мав би йти в unknown: з пустим текстом, а не перетворюватись на off. Щось тут не так.

Додав діагностику вивід сирого вмісту команди одразу після xQueueReceive, перед будь-якою перевіркою:

І ось тут стало справді цікаво:

wn: — це підрядок з "unknown: ", який плата щойно сама вивела в попередньому циклі. ot: — фрагмент власного "got: [".  У RX потрапляли фрагменти даних, дуже схожі на власний TX-вивід плати. Після заміни джамперів проблема повністю зникла. Отже, причина була у фізичній лінії — найімовірніше, у наведенні між TX і RX або нестабільному контакті.

Перевірив пряму гіпотезу — фізично відключив RX-провід. Тиша, жодного got: без реального сигналу. Це виключило “floating pin” шум сам по собі: проблема виникала тільки коли обидва проводи, TX і RX, активно працювали одночасно.

Викинув перехідні дроти-джампери. Сміття зникло повністю навіть при максимально швидкому наборі команд поспіль, без жодних пауз:

Чисто, стабільно, жодного unknown.

Причина виявилась банальнішою за всі мої гіпотези: подовжувальні дроти-джампери на макетці. Не race condition, не переповнення черги, не порядок операцій у FreeRTOS. Просто погана лінія, яка або ловила наведення від сусіднього TX-проводу, або мала настільки нестабільний контакт, що породжувала випадкові фантомні біти на RX. 

Залишив діагностичний got: [...]-вивід у фінальному коді, щоб коли знову буде щось незрозуміло на межі UART і черги, проаналізувати echo сирих даних, перш ніж підозрювати FreeRTOS.

◆ Семафори і mutex

Другий крок — mutex навколо UART, щоб кілька тасків могли писати одночасно без змішаного виводу. Щоб було з ким змагатися за ресурс додав третій, “шумний” таск (vDebugTask), який кожні 200мс виводить значення лічильника:

Перша версія навмисно мала помилку: vDebugTask викликав uart_printf() напряму, без safe_uart_puts()-обгортки. Хотів побачити реальну проблему і переконатись, що вона існує.

Результат передбачуваний:

Для тих хто не зрозумів, це посимвольно перемішані "debug tick: 0" і "LED off", далі "debug tick: 1" і "LED off". Два таски писали в UART буквально одночасно, упереміш символ за символом. 

Обгорнув uart_printf у vDebugTask через mutex:

Скремблений вивід зник повністю, навіть під навантаженням.

◆ П’ять спроб, шоста дає вивід в консоль — полювання за примарою

Ми дійшли до найцікавішого місця, полювання за багом. Симптом: набираю on, LED не реагує. off теж тиша. І тільки на п’ятій введеній команді Led змінює стан, а на шостій дає вивід ехо в консоль причому це виявляється першою командою, яку я вводив, а не останньою. Наступні команди після цього виконуються одна за одною підряд, ніби вивільняючись із стеку, перший прийшов перший пішов, але із затримкою, наче тре повністю зарядити обойму з 5 набоїв, щоб здійснити перший постріл

У мене був час, тому для вирішення цієї цікавої аномалії я зайшов здалеку.

Гіпотеза 1 — залишок апаратного шуму. Щойно вирішена проблема з дротами могла повернутись частково. Перевірив уважно — дроти нові, чисті, проблема відтворювалась стабільно і без жодного видимого шуму в даних.

Гіпотеза 2 — переповнення буфера UART чи \r\n-розсинхрон. uart_gets() зупиняється на першому \r або \n — якщо термінал шле обидва символи парою, наступний виклик міг би зʼїсти залишок як порожній рядок. Не витримала перевірки: порожній рядок мав би йти в unknown: з пустим текстом одразу.

Справжня причина — priority starvation. Подивився уважно на пріоритети тасків:

І на те, як uart_getc() реально чекає символ:

Це не блокуюча RTOS-операція, це busy-wait. У preemptive планувальнику вищий пріоритет ніколи не поступається нижчому, поки сам явно не заблокується через справжній RTOS-примітив (vTaskDelay, xQueueReceive, xSemaphoreTake з очікуванням). vUartTask (пріоритет 2) весь час залишається у стані Ready, крутячись у циклі опитування — і планувальник просто ніколи не дає шансу vLedTask чи vDebugTask (пріоритет 1), навіть коли в черзі вже лежить готова команда.

Єдине місце, де vUartTask реально блокується –

і то тільки коли черга повна. Розмір черги — xQueueCreate(4, ...). Перші 4 команди vUartTask встигає прочитати й покласти в чергу без реального блокування — xQueueSend миттєво повертається, бо є вільне місце. vLedTask весь цей час нудиться, жодного разу не запускається. На 5-й команді черга нарешті заповнюється, xQueueSend блокується по-справжньому — і от тут вперше vLedTask отримує процесор, вичитує всі 4 накопичені команди підряд одну за одною.

Тобто число “5” не збіг, воно не випадкове, а точно дорівнює розміру черги плюс одиниця.

Швидкий тест підтвердив гіпотезу: зробив усі пріоритети рівними (1, 1, 1) і запрацювало одразу, з першого разу, стабільно. Round-robin time-slicing між рівними пріоритетами (configUSE_TIME_SLICING, дефолт увімкнено) примусово перемикав vUartTask на кожному тіку SysTick, навіть попри її busy-wait.

⚠️ Це працює, але це не вирішення, бо корінь лишався: vUartTask монополізувала б CPU знову, якщо знов повернути їй пріоритет, чи вимкнути time-slicing, чи додати ще один таск з вищим пріоритетом. Лайнове рішення, яке зламається тихо при найменшій зміні конфігурації в майбутньому, а це саме той тип бага, який важко відтворити й ще важче пояснити.

◆ Нарешті правильне рішення: UART через переривання

Замінив busy-wait uart_getc() у vUartTask на USART1_IRQHandler + xQueueSendFromISR. Символи більше не читаються прямим опитуванням всередині таска вони потрапляють у окрему чергу xRxQueue напряму з переривання:

vUartTask тепер блокується на

і це вже справжня RTOS-блокуюча операція. Символи з’являються в черзі напряму з переривання, таск більше ніколи не займає процесор порожнім опитуванням.

⚠️ Критичний момент, успадкований з Частини 15, і вперше реально застосований: за документацією FreeRTOS пріоритет цього переривання в NVIC не може бути вищим за configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY (5) — інакше xQueueSendFromISR усередині обробника буде undefined behavior. STM32F103 має 4 біти пріоритету в NVIC (не всі 8), і регістр NVIC_IPR — байт-адресований, один байт на кожне з 240 переривань, з активними лише старшими 4 бітами кожного байта. Тому зсув <<4 — не довільна константа, а прямий наслідок цієї 4-бітної моделі, той самий зсув, що й у configKERNEL_INTERRUPT_PRIORITY з FreeRTOSConfig.h:

Оскільки це стосується роботи з перериваннями в FreeRTOS на мікроконтролері STM32F103 і містить кілька важливих технічних нюансів. Давайте, вже розберемо його по частинах:


STM32F103 реалізує чотири старші біти кожного поля пріоритету NVIC. Тому при прямому записі логічний пріоритет 5 перетворюється на 5U << 4, або 0x50. CMSIS-функція NVIC_SetPriority() виконує цей зсув сама.

У Cortex-M менше число означає вищу терміновість. Якщо configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY дорівнює 5, переривання з пріоритетами 0–4 не можуть викликати FreeRTOS API. Для USART1 встановлюю логічний пріоритет 5.


Отже

  • 5 це не магічне число, а конфігуроване значення проєкту.
  • Воно визначає межу пріоритетів, вище якої виклик API FreeRTOS з переривання стає небезпечним.
  • Для STM32F103 це значення часто встановлюють на 5, щоб залишити місце для критичних переривань (з пріоритетами 0-4) і водночас дозволити викликати API FreeRTOS з переривань з пріоритетом 5.

Розібрались з пріоритетом переривання, рухаємось далі. Тож я повернув пріоритети до попередніх (UART=2, LED=1, Debug=1) тепер це безпечно й навіть корисно: vUartTask більше не монополізує CPU, ну а блокування в черзі означає, що вищий пріоритет просто гарантує негайну реакцію на новий символ, не створюючи простою.

Перевірив, без жодної “розгонки”  спрацювало миттєво з першого разу:

debug tick рахує рівномірно, без пауз і без стрибків — жодна команда більше не краде в нього процесорний час. Швидкий стрес-набір команд поспіль, без пауз між Enter — теж чисто, жодного скремблу, жодної затримки, жодного “5 разів”.

◆ Дрібніші виправлення, зловлені під час фінального рев’ю коду

Готуючи статтю, пройшовся ще раз по всьому main.c і hal_uart.c уважніше, ніж зазвичай і знайшов кілька речей, які “працювали” тільки тому, що ніхто досі не перевіряв працездатність:

  • RX-пін (PA10) ніде явно не конфігурувався. uart_init() викликав gpio_init() тільки для PA9 (TX). RX працював лише завдяки GPIO reset-дефолту (INPUT floating = 0x4) тому самому режиму, який потрібен для RX. Сумнівно і ненадійно: будь-який майбутній код, що випадково займе PA10 під щось інше (наприклад, переплутавши піни при додаванні нової периферії), мовчки зламає прийом без жодної помилки компіляції і буду довго і нудно шукати в чому справа. Виставив явно.
  • Overrun Error (ORE) не очищувався перед стартом. USART на F103 має лише один байт буфера прийому. Якщо новий байт приходить до того, як попередній прочитаний, апаратура виставляє прапорець ORE, і подальший прийом може поводитись нестабільно, поки прапорець не скинеться правильною послідовністю. Додав явне читання SR, потім DR в кінці uart_init() про всяк випадок.
  • Ініціалізація периферії відбувалась усередині vUartTask, вже після старту планувальника. vLedTask і vDebugTask мали той самий пріоритет 1 і теж писали в UART. Якщо планувальник дав би слот vDebugTask раніше за vUartTask вона писала б у ще не увімкнений USART1 (CR1.UE=0), символи мовчки губились би. Працювало це раніше лише тому, що vUartTask створювалась першою й випадково стартувала першою, просто везло. Переніс uart_init()/gpio_init() у main(), до vTaskStartScheduler().
  • Жоден результат xQueueCreate/xSemaphoreCreateMutex/xTaskCreate не перевірявся. При нестачі heap ці функції повертають NULL/ pdFAIL, і наступний виклик (наприклад xQueueSend(NULL, ...))  це буде тихий undefined behavior, не гучний Бабах. Додав перевірки з діагностичним блиманням LED при збої (той самий підхід, що й у PLL-функції з Частини 15, де gpio_init() ще не факт що безпечно викликати).
  • uart_printf мав тільки %d, не %u. Лічильник у vDebugTaskuint32_t, а %d читає int32_t через va_arg. При значеннях за межами int32_t вивід був би некоректним. Додав окремий case 'u'.
  • Парсинг команд перевіряв лише перші 2-3 символи. "offXYZ" чи "onZZZ" теж спрацювали б як валідні команди. Для навчального демо не критично, але все одно звузив перевірку до повного збігу рядка з термінатором '\0'.

◆ Бюджет пам’яті: ціна кожного шару

Кожен крок цієї статті мав свою вартість у Flash це корисно відслідковувати, скільки коштує саме черга, скільки mutex, скільки перехід на переривання:

Етап text (Flash) Приріст Що додалось
Частина 15 (два таски, без черги) 5837 база
Етап 1: UART-таск + xQueue 8713 +2876 queue.c увійшов у збірку
Етап 2: mutex + третій таск 9341 +628 vDebugTask, xSemaphoreCreateMutex
Фінал: UART через переривання 10973 +1632 ISR-обробник, друга черга, перевірки NULL

SRAM за весь цей шлях зріс мінімально — з 12532 до 12540 байт (+8), бо черги й семафор виділяються динамічно з уже зарезервованого heap_4 бюджету, а не як нові статичні глобальні змінні. Flash тут інша історія: кожен рівень ускладнення (сама черга, потім mutex-логіка, потім ISR-інфраструктура) додає реального коду, який компілюється в прошивку. На 64KB Flash це все ще мізерна частка (~17% на фінальному етапі), але ми вже бачимо, що “просто додати чергу” це не безкоштовна дія.

◆ Підсумок

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

Наступна стаття про Python + pytest автотести для STM32: тепер, коли UART працює через переривання надійно і передбачувано, прийшов час будувати автоматизовану перевірку прошивки з хоста.

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

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

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