Чому черга на чотири елементи змусила систему реагувати лише на п’яту команду
У Частині 15 виявилося, що хардкод у конфігурації протягом чотирьох місяців передавав системі неправильну частоту тактування. Після цієї PLL-історії до черг і семафорів я підійшов обережніше. Якщо навіть базовий vTaskDelay() був налаштований неправильно, то що чекало попереду — там, де дані вже реально передаються між задачами?
Щоб зрозуміти, де виникали труднощі в інших дослідників з тим самим залізом, адже 100% хтось таке вже робив, трохи погуглив.
У Частині 2 я вчився розраховувати baud rate без бібліотек. Тоді я навмисно проігнорував переривання й використав опитування — polling. Пулінг це коли процесор сам, у циклі, безперервно опитує апаратний прапорець і чекає, поки той підніметься:
|
1 2 3 4 |
char uartgetc(void) { while (!(USART1SR & SRRXNE)) ; // крутимось тут, поки не прийде байт return (char) USART1DR; } |
Простота жодних переривань, жодного 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 без жодної підказки чому.
Увімкнув:
|
1 |
#define configCHECK_FOR_STACK_OVERFLOW 2 |
2 набагато краще для етапу розробки та налагодження:- Нульова перевірка (
0): Вимикає будь-який контроль. Економить кілька тактів процесора і трохи пам’яті, але якщо задача вичерпає свій стек, почнеться хаотичний перезапис сусідніх даних (наприклад, стек іншої задачі), що призведе до непередбачуваної поведінки системи.
- Перевірка способом
1: Здійснює перевірку лише під час перемикання контексту. Ядро перевіряє, чи не опустився вказівник стеку (Stack Pointer) нижче виділеної межі. Це працює швидко, але між перемиканнями задач може виникнути критичний стрибок пам’яті (наприклад, через виклик глибоких вкладених функцій), який пропустять. - Перевірка способом
2: Використовує апаратну особливість процесора та спеціальні значення (магічні байти), якими ініціалізується стек кожної задачі. Ядро аналізує стек кожного разу, а не лише під час перемикання контексту. Якщо задача затирає визначену межу, негайно спрацьовує перехоплення помилок.
І додав функцію-перехоплювач:
|
1 2 3 4 5 6 7 8 |
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { (void) xTask; uart_puts("STACK OVERFLOW: "); uart_puts(pcTaskName); uart_puts("\r\n"); while (1); } |
Надіюсь це допоможе мені, коли я почну ускладнювати код чергами. Другий момент, менш терміновий, але вартий занотувати на майбутнє: коли дійду до UART через переривання “ніяких звичайних викликів API всередині обробника переривань (ISR)” якщо цього не зробити, точно почнеться хаос.
- Багато функцій FreeRTOS (наприклад,
xQueueSendчиvTaskDelay) розроблені так, щоб призупиняти (блокувати) поточну задачу, якщо черга заповнена або ресурс зайнятий. Вони чекають, поки звільниться місце.
- Переривання (ISR) повинно виконуватися максимально швидко і ніколи не переходити в режим очікування або блокування. У мікроконтролерів немає «задачі переривання», яку можна призупинити. Якщо викликати звичайний API, який спробує перевести потік в режим очікування, ядро системи розгубиться, що призведе до фатальної помилки.
- Якщо ваш обробник переривання UART почне викликати звичайні функції, що взаємодіють з мьютексами (Mutexes) або семафорами для захисту ресурсів, він може випадково спробувати захопити мьютекс, який вже тримає головна задача. Це спричинить взаємне блокування або зависання виконання.
- Звичайні API-функції використовують захищені критичні секції та управляють внутрішніми списками, які діляться між різними задачами. Без спеціальної ізоляції з ISR-контексту, виклики почнуть конфліктувати із системним планувальником (Scheduler) і можуть пошкодити внутрішні структури даних FreeRTOS.
- Функції з приставкою
FromISRпрацюють оптимізовано та безпечно. Вони не ставлять задачу на паузу. Натомість вони повертають спеціальний прапор (наприклад,xHigherPriorityTaskWokenабо еквівалент), який сигналізує ядру про те, що якась задача тепер готова до виконання.
Озброївшись таким міцним фундаментом знань, думаю вже можна переходити до черг.
◆ Етап 1 — UART-таск + черга

Задача проста: UART-таск читає команди (on/off) через уже готовий uart_gets(), кладе в чергу. LED-таск читає з черги, виконує:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 |
#include "hal.h" #include "FreeRTOS.h" #include "task.h" #include "queue.h" #define CMD_LEN 16 typedef struct { char text[CMD_LEN]; } led_cmd_t; static QueueHandle_t xLedQueue; static void vUartTask(void *pvParameters) { (void) pvParameters; led_cmd_t cmd; uart_init(9600); uart_puts("FreeRTOS UART task ready\r\n"); while (1) { uart_gets(cmd.text, CMD_LEN); xQueueSend(xLedQueue, &cmd, portMAX_DELAY); } } static void vLedTask(void *pvParameters) { (void) pvParameters; led_cmd_t cmd; gpio_init(PIN_PC13, OUTPUT); while (1) { if (xQueueReceive(xLedQueue, &cmd, portMAX_DELAY) == pdTRUE) { if (cmd.text[0] == 'o' && cmd.text[1] == 'n') { gpio_write(PIN_PC13, LOW); uart_puts("LED on\r\n"); } else if (cmd.text[0] == 'o' && cmd.text[1] == 'f') { gpio_write(PIN_PC13, HIGH); uart_puts("LED off\r\n"); } else { uart_puts("unknown: "); uart_puts(cmd.text); uart_puts("\r\n"); } } } } int main(void) { rcc_set_pll_72mhz(); hal_init(); xLedQueue = xQueueCreate(4, sizeof(led_cmd_t)); xTaskCreate(vUartTask, "UART", 128, NULL, 2, NULL); xTaskCreate(vLedTask, "LED", 128, NULL, 1, NULL); vTaskStartScheduler(); while (1); return 0; } |
Зібралось, прошилось, підключив термінал і одразу зрозумів, що щось не так.
◆ Детектив, частина перша: дивна поведінка черги

Перший запуск:
|
1 2 3 4 5 6 |
LED on LED off unknown: on LED on LED off LED on |
Написав on а іноді видавало off. Написав on , а прийшло off. Перше, куди я глянув race condition у uart_gets(): тут, функція зупиняється на першому \r або \n, а термінал міг слати обидва символи парою. Якщо перший виклик зупиняється на \r, лишаючи \n непрочитаним у буфері USART, то наступний виклик одразу зʼїсть цей залишок як порожній рядок.
Гіпотеза не витримала перевірки: порожній рядок мав би йти в unknown: з пустим текстом, а не перетворюватись на off. Щось тут не так.
Додав діагностику вивід сирого вмісту команди одразу після xQueueReceive, перед будь-якою перевіркою:
|
1 2 3 4 5 |
uart_puts("got: ["); for (int i = 0; i < CMD_LEN && cmd.text[i]; i++) { uart_printf("%c", cmd.text[i]); } uart_puts("]\r\n"); |
І ось тут стало справді цікаво:
|
1 2 3 4 5 6 7 8 |
got: [wn: ] unknown: wn: got: [] unknown: got: [[ot:onn] unknown: [ot:onn got: [on] LED on |
wn: — це підрядок з "unknown: ", який плата щойно сама вивела в попередньому циклі. ot: — фрагмент власного "got: [". У RX потрапляли фрагменти даних, дуже схожі на власний TX-вивід плати. Після заміни джамперів проблема повністю зникла. Отже, причина була у фізичній лінії — найімовірніше, у наведенні між TX і RX або нестабільному контакті.
Перевірив пряму гіпотезу — фізично відключив RX-провід. Тиша, жодного got: без реального сигналу. Це виключило “floating pin” шум сам по собі: проблема виникала тільки коли обидва проводи, TX і RX, активно працювали одночасно.
Викинув перехідні дроти-джампери. Сміття зникло повністю навіть при максимально швидкому наборі команд поспіль, без жодних пауз:
|
1 2 3 4 5 6 |
got: [on] LED on got: [off] LED off got: [on] LED on |
Чисто, стабільно, жодного unknown.
Причина виявилась банальнішою за всі мої гіпотези: подовжувальні дроти-джампери на макетці. Не race condition, не переповнення черги, не порядок операцій у FreeRTOS. Просто погана лінія, яка або ловила наведення від сусіднього TX-проводу, або мала настільки нестабільний контакт, що породжувала випадкові фантомні біти на RX.

Залишив діагностичний got: [...]-вивід у фінальному коді, щоб коли знову буде щось незрозуміло на межі UART і черги, проаналізувати echo сирих даних, перш ніж підозрювати FreeRTOS.
◆ Семафори і mutex
Другий крок — mutex навколо UART, щоб кілька тасків могли писати одночасно без змішаного виводу. Щоб було з ким змагатися за ресурс додав третій, “шумний” таск (vDebugTask), який кожні 200мс виводить значення лічильника:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#include "semphr.h" static SemaphoreHandle_t xUartMutex; static void safe_uart_puts(const char *s) { xSemaphoreTake(xUartMutex, portMAX_DELAY); uart_puts(s); xSemaphoreGive(xUartMutex); } static void vDebugTask(void *pvParameters) { (void) pvParameters; uint32_t counter = 0; while (1) { uart_printf("debug tick: %d\r\n", counter++); /* без mutex — навмисно */ vTaskDelay(pdMS_TO_TICKS(200)); } } |
Перша версія навмисно мала помилку: vDebugTask викликав uart_printf() напряму, без safe_uart_puts()-обгортки. Хотів побачити реальну проблему і переконатись, що вона існує.
Результат передбачуваний:
|
1 2 3 4 5 6 |
ceLbEuDg otni k:L E0D off iLeEbDu go nt ockL:E D1 ff |
Для тих хто не зрозумів, це посимвольно перемішані "debug tick: 0" і "LED off", далі "debug tick: 1" і "LED off". Два таски писали в UART буквально одночасно, упереміш символ за символом.
Обгорнув uart_printf у vDebugTask через mutex:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
static void vDebugTask(void *pvParameters) { (void) pvParameters; uint32_t counter = 0; while (1) { xSemaphoreTake(xUartMutex, portMAX_DELAY); uart_printf("debug tick: %u\r\n", counter++); xSemaphoreGive(xUartMutex); vTaskDelay(pdMS_TO_TICKS(200)); } } |
Скремблений вивід зник повністю, навіть під навантаженням.
◆ П’ять спроб, шоста дає вивід в консоль — полювання за примарою
Ми дійшли до найцікавішого місця, полювання за багом. Симптом: набираю on, LED не реагує. off теж тиша. І тільки на п’ятій введеній команді Led змінює стан, а на шостій дає вивід ехо в консоль причому це виявляється першою командою, яку я вводив, а не останньою. Наступні команди після цього виконуються одна за одною підряд, ніби вивільняючись із стеку, перший прийшов перший пішов, але із затримкою, наче тре повністю зарядити обойму з 5 набоїв, щоб здійснити перший постріл
У мене був час, тому для вирішення цієї цікавої аномалії я зайшов здалеку.
Гіпотеза 1 — залишок апаратного шуму. Щойно вирішена проблема з дротами могла повернутись частково. Перевірив уважно — дроти нові, чисті, проблема відтворювалась стабільно і без жодного видимого шуму в даних.
Гіпотеза 2 — переповнення буфера UART чи \r\n-розсинхрон. uart_gets() зупиняється на першому \r або \n — якщо термінал шле обидва символи парою, наступний виклик міг би зʼїсти залишок як порожній рядок. Не витримала перевірки: порожній рядок мав би йти в unknown: з пустим текстом одразу.
Справжня причина — priority starvation. Подивився уважно на пріоритети тасків:
|
1 2 3 |
xTaskCreate(vUartTask, "UART", 128, NULL, 2, NULL); // пріоритет 2 xTaskCreate(vLedTask, "LED", 128, NULL, 1, NULL); // пріоритет 1 xTaskCreate(vDebugTask, "Debug", 128, NULL, 1, NULL); // пріоритет 1 |
І на те, як uart_getc() реально чекає символ:
|
1 2 |
while (!(USART1_SR & SR_RXNE)) ; // чисте опитування регістра return (char) USART1_DR; |
Це не блокуюча RTOS-операція, це busy-wait. У preemptive планувальнику вищий пріоритет ніколи не поступається нижчому, поки сам явно не заблокується через справжній RTOS-примітив (vTaskDelay, xQueueReceive, xSemaphoreTake з очікуванням). vUartTask (пріоритет 2) весь час залишається у стані Ready, крутячись у циклі опитування — і планувальник просто ніколи не дає шансу vLedTask чи vDebugTask (пріоритет 1), навіть коли в черзі вже лежить готова команда.
Єдине місце, де vUartTask реально блокується –
|
1 2 |
xQueueSend(xLedQueue, &cmd, portMAX_DELAY) |
і то тільки коли черга повна. Розмір черги — 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 напряму з переривання:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
static QueueHandle_t xRxQueue; /* окрема черга для сирих символів з ISR */ void USART1_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; char c = uart_getc(); /* читання DR скидає прапорець RXNE */ xQueueSendFromISR(xRxQueue, &c, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } static void vUartTask(void *pvParameters) { (void) pvParameters; led_cmd_t cmd; char c; int idx = 0; safe_uart_puts("FreeRTOS UART task ready (interrupt-driven)\r\n"); while (1) { if (xQueueReceive(xRxQueue, &c, portMAX_DELAY) == pdTRUE) { if (c == '\r' || c == '\n') { if (idx > 0) { cmd.text[idx] = '\0'; xQueueSend(xLedQueue, &cmd, portMAX_DELAY); idx = 0; } } else if (idx < CMD_LEN - 1) { cmd.text[idx++] = c; } } } } |
vUartTask тепер блокується на
|
1 2 |
xQueueReceive(xRxQueue, &c, portMAX_DELAY) |
і це вже справжня 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:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#define NVIC_IPR_BASE 0xE000E400 #define USART1_IRQ_NUM 37 void uart_enable_rx_interrupt(void) { USART1_CR1 |= CR1_RXNEIE; /* NVIC_IPR — масив байтів, по одному на IRQ. Активні старші 4 біти (F103 має тільки 4 біти пріоритету), тому зсув <<4 обов'язковий — без нього значення 5 потрапило б у молодші, неактивні біти, і реальний пріоритет лишився б 0 (найвищий можливий), що поламало б усі FromISR-виклики мовчки. */ volatile uint8_t *ipr = (volatile uint8_t *)(NVIC_IPR_BASE + USART1_IRQ_NUM); *ipr = (5 << 4); NVIC_ISER1 = (1 << (USART1_IRQ_NUM - 32)); } |
Оскільки це стосується роботи з перериваннями в 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, ну а блокування в черзі означає, що вищий пріоритет просто гарантує негайну реакцію на новий символ, не створюючи простою.
Перевірив, без жодної “розгонки” спрацювало миттєво з першого разу:
|
1 2 3 4 5 6 7 8 9 |
FreeRTOS UART task ready (interrupt-driven) debug tick: 60 debug tick: 61 ... debug tick: 66 got: [off] LED off debug tick: 67 debug tick: 68 |
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. Лічильник уvDebugTask—uint32_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 працює через переривання надійно і передбачувано, прийшов час будувати автоматизовану перевірку прошивки з хоста.