STM32 з нуля без HAL: Місяць 5, Тиждень 1: FreeRTOS на Blue Pill. Частина 15

Як ви пам’ятаєте Місяць 4 закрився Частиною 14 — драйвером для HC-SR04 і загадковою помилкою —517, яка ламала старі туторіали на сучасному ядрі. Місяць 5 за планом це «FreeRTOS багатозадачність на Blue Pill F103 + Python-автотести». Сьогодні буде усе просто, лише вбудований LED і кнопка.

◆ Що вже було готово

Мій HAL (Місяці 1-4), тобто власний GPIO/UART/SysTick/I2C/Timer, написаний з нуля без CubeMX, ще з Частини 1, де я вперше писав pinMode/digitalWrite через прямий доступ до регістрів. Працював стабільно на 8MHz HSI, бо Blue Pill стартує саме на цій частоті без додаткового налаштування.

FreeRTOS якщо використовуєш vTaskDelay то одразу впираєшся в питання: а на якій частоті взагалі рахувати ці мілісекунди?

◆ Крок 1 — перевірка заліза

Перш ніж лізти в код, я перевірив реальний Flash на моїй платі:

st-info --probe

flash:      65536 (pagesize: 1024)
sram:       20480
chipid:     0x0410
descr:      F1xx Medium-density

⚠️ Знов таки, на хабрахабрі та інших ресурсах ардуіно зустрічав твердження, що «у китайських Blue Pill насправді 128KB Flash замість заявлених 64KB», але в мене можливо внаслідок того, що мої платки придбані років 10 тому тут не підтвердилась. Рівно 64KB, як і написано в даташиті. Сумно, але маємо, що маємо, може так навіть цікавіше буде. Але, що якщо не вистачить?

◆ Крок 2 — клонування FreeRTOS-Kernel

git clone  https://github.com/FreeRTOS/FreeRTOS-Kernel.git FreeRTOS-Kernel

Версія V11.2.0 (LTS, MIT). З репозиторію взяв тільки те, що потрібно для Cortex-M3 без корутин:

mkdir -p freertos/include

cp FreeRTOS-Kernel/tasks.c freertos/
cp FreeRTOS-Kernel/queue.c freertos/
cp FreeRTOS-Kernel/list.c  freertos/
cp FreeRTOS-Kernel/portable/GCC/ARM_CM3/port.c       freertos/
cp FreeRTOS-Kernel/portable/GCC/ARM_CM3/portmacro.h  freertos/
cp FreeRTOS-Kernel/portable/MemMang/heap_4.c         freertos/
cp -r FreeRTOS-Kernel/include/*  freertos/include/

Свідомо не брав croutine.c — корутини для ultra-low-memory систем, F103 в цьому немає потреби.

◆ Крок 3 — де тримати два проєкти

Місяці 1-4 і Місяць 5 мають різну частоту ядра (8MHz проти 72MHz), тож потрібні два окремі build-конфіги. Але HAL — один. Правити hal_uart.c у двох копіях, таке собі задоволення.

Рішення: окремий каталог stm32_freertos/, а hal/ в ньому — symlink на оригінал:

mkdir -p stm32_freertos/src stm32_freertos/ld
cd stm32_freertos
ln -s ../HAL_stm32/hal hal
ln -s ../../HAL_stm32/src/startup.c src/startup.c
ln -s ../../HAL_stm32/ld/stm32f103.ld ld/stm32f103.ld
mv ../HAL_stm32/freertos ./freertos

Один hal/, дві точки використання. Правки видно одразу в обох проєктах, ізоляція — на рівні Makefile.

◆ Проблема № 1 — хардкод, який пощастило не помічати 4 місяці

Мій hal.h мав:

#define CPU_HZ  8000000UL

Простий план — перевизначити це на 72000000UL для FreeRTOS-збірки. Але перш ніж чіпати клок, я вирішив пошукати, де ще в HAL сидять приховані залежності від частоти. Знахідка виявилась серйознішою, ніж очікував.

Периферія STM32F103 тактується не від SYSCLK напряму, а від шин APB1/APB2. На 8MHz HSI усі прескалери дорівнюють 1, тому випадково всі шини рівні CPU_HZ — і хардкод 8000000 «працював» скрізь. На 72MHz це вирішується по-різному:

Шина8 MHz72 MHzПериферія
APB2 (PCLK2)8 MHz72 MHz = CPU_HZUSART1, SPI1, ADC1
APB1 (PCLK1)8 MHz36 MHz ≠ CPU_HZ (÷2)I2C1
Такт таймерів APB18 MHz72 MHz = CPU_HZ (×2 quirk)TIM2..7

Мій hal_uart.c мав окремий, повністю незалежний від CPU_HZ хардкод, той самий регістр BRR, математику якого я розбирав ще в Частині 2 серії, коли з’ясовував чому software UART видає «крякозябри» на високих швидкостях:

USART1_BRR = 8000000UL / baud;

Змінити тільки CPU_HZ і залишити це, тоді UART почне видавати сміття одразу після переходу на 72MHz.

Необхідні правки це три похідні макроси в hal.h, кожен зі своїм #ifndef:

#ifndef CPU_HZ
#define CPU_HZ  8000000UL
#endif

#ifndef PCLK1_HZ
#define PCLK1_HZ  CPU_HZ        // APB1: I2C1, TIM2..7
#endif
#ifndef PCLK2_HZ
#define PCLK2_HZ  CPU_HZ        // APB2: USART1, SPI1, ADC1
#endif

#ifndef TIMER_CLK_HZ
#define TIMER_CLK_HZ  CPU_HZ
#endif

І відповідні правки в трьох файлах:

// hal_uart.c
USART1_BRR = PCLK2_HZ / baud;

// hal_i2c.c
I2C1_CR2   = PCLK1_HZ / 1000000UL;
I2C1_CCR   = PCLK1_HZ / (2UL * 100000UL);
I2C1_TRISE = (PCLK1_HZ / 1000000UL) + 1;

// hal_tim.c
TIM2_PSC  = (TIMER_CLK_HZ / 1000UL) - 1;

⚠️ Окрема проблема в середині проблеми: TIM2_PSC на 72MHz дає PSC=71999, а це 16-бітний регістр з межею 65535.

◆ Проблема № 2 — #ifndef замало

Мій план був: #define CPU_HZ 72000000UL перед #include "hal.h" у новому main.c. Здавалось логічним. Не спрацювало.

Причина проста, коли розумієш, що кожен .c файл це окремий translation unit. Makefile компілює hal_uart.c, hal_i2c.c, hal_tim.c окремо від main.c. #define у main.c просто не доходить до інших файлів, які взагалі не бачать цього рядка.

Робочий варіант виставити прапорець компілятора, спільний для всіх translation units:

CFLAGS  = -mcpu=cortex-m3 -mthumb -O0 -g \
          -Wall -ffunction-sections -fdata-sections \
          -Ihal -Ifreertos -Ifreertos/include -I. \
          -DCPU_HZ=72000000UL -DPCLK1_HZ=36000000UL

#ifndef у hal.h — це саме те, що робить -D робочим. Форма «#define до #include» лишається валідною, але діє лише в межах одного файлу.

Перевірка, що стара збірка (без -D) не зламалась:

make clean && make
md5sum hal_blink.bin

Байт-у-байт ідентичний закоміченій версії, а HAL лишився зворотньо сумісним.

◆ Проблема № 3 — SysTick має двох власників одночасно

FreeRTOSConfig.h вимагає маперинг хендлерів:

#define xPortSysTickHandler   SysTick_Handler

Цей рядок буквально каже port.c визначити функцію з іменем SysTick_Handler. Але ж, мій hal_systick.c вже мав власний SysTick_Handler, interrupt-based, для рахунку _ticks:

void SysTick_Handler(void) {
    _ticks++;
}

Лінкер:

multiple definition of `SysTick_Handler'

SysTick може мати тільки одного власника. Без FreeRTOS це буде HAL. З FreeRTOS — це port.c, і HAL має делегувати йому керування.

Рішення — #ifdef FREERTOS розділення в hal_systick.c:

#ifdef FREERTOS
#include "FreeRTOS.h"
#include "task.h"

void systick_init(uint32_t cpu_hz) {
    (void) cpu_hz;   // FreeRTOS сам налаштує SysTick через port.c
}

void delay_ms(uint32_t ms) {
    vTaskDelay(pdMS_TO_TICKS(ms));
}

uint32_t get_ticks(void) {
    return xTaskGetTickCount();
}

#else
// старий interrupt-based код, без змін
#endif

І -DFREERTOS до CFLAGS у stm32_freertos/Makefile (тільки там, не в основному HAL-проєкті).

◆ Проблема № 4 — FreeRTOS не bare-metal-панк, на відміну від мого HAL

Знахідка Лінкер:

undefined reference to `memset'

Мій Makefile з самого початку серії використовує -nostdlib ми йдемо без стандартної бібліотеки C, бо суть в тому, що HAL пишеться «з нуля». Але heap_4.c і tasks.c у FreeRTOS використовують memset/memcpy з <string.h>, а без libc де їх взяти.

Два шляхи: дозволити мінімальний libc через --specs=nano.specs, або написати власні реалізації. Обрав друге:

// freertos/freertos_string.c
#include <stddef.h>

void *memset(void *dst, int val, size_t len) {
    unsigned char *d = dst;
    while (len--) *d++ = (unsigned char)val;
    return dst;
}

void *memcpy(void *dst, const void *src, size_t len) {
    unsigned char *d = dst;
    const unsigned char *s = src;
    while (len--) *d++ = *s++;
    return dst;
}

◆ Проблема № 5 — конфіг-макроси

V11.2.0 вимагає явних відповідей на питання, яких я перед цим не бачив, тому кожна помилка компіляції додавала по одному-два макроси:

#define configUSE_16_BIT_TICKS       0   // 32-біт лічильник тіків

#define INCLUDE_vTaskDelay           1   // без цього vTaskDelay не компілюється

#define configUSE_IDLE_HOOK          0
#define configUSE_TICK_HOOK          0
#define configUSE_MALLOC_FAILED_HOOK 0
#define configCHECK_FOR_STACK_OVERFLOW  0

#define configPRIO_BITS                       4
#define configLIBRARY_LOWEST_INTERRUPT_PRIORITY    15
#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY  5

#define configKERNEL_INTERRUPT_PRIORITY \
    ( configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS) )
#define configMAX_SYSCALL_INTERRUPT_PRIORITY \
    ( configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS) )

⚠️ configMAX_SYSCALL_INTERRUPT_PRIORITY це не довільне число. F103 має лише 4 біти пріоритету в NVIC (не всі 8), тому зсув << (8 - configPRIO_BITS) обов’язковий. Без нього завищений або занижений пріоритет тихенько ламає ISR-safe.

◆ Проблема № 6 — st-flash не бачить таргет після зміни коду

ERROR common.c: Can not connect to target. Please use 'connect under reset' and try again

st-info --probe бачив пристрій нормально, а st-flash write ні. MCU, схоже, встиг перейти в стан, де звичайний halt через SWD не проходить.

st-flash --connect-under-reset write freertos_blink.bin 0x08000000

Спрацювало одразу. Заніс у Makefile як стандартний flash target.

◆ Ґуля № 7 — обіцянка компілятору

Зібрав, прошив два таски (LED-блимання 500мс + кнопка на PA1). Перший запуск:

LED блимає з інтервалом ~5 секунд замість очікуваної 1 секунди.

configCPU_CLOCK_HZ = 72000000UL у FreeRTOSConfig.h — це лише число для розрахунку SysTick->LOAD. Воно нічого не каже реальному апаратному генератору тактової частоти. Тому не зважаючи на те, що я наказав FreeRTOS «рахуй так, ніби 72MHz», а RCC фізично досі стояв на HSI 8MHz — PLL ніхто не вмикав.

Математика сходиться ідеально: 72000000 / 8000000 = 9. Кожен запланований 1мс-тік реально триває 9мс. vTaskDelay(500) → 4500мс замість 500мс. Період блимання ~5 секунд — рівно те, що я бачу на платі.

Число в конфізі це просто лише обіцянка компілятору, що так буде, а от PLL-код це вже фізична дія безпосередньо на залізі.

◆ PLL: HSE × 9 = 72MHz

На платі є кварц HSE 8MHz (маркування «8.000» біля USB-роз’єму). Стандартний шлях для Blue Pill.

КрокРегістр (адреса)БітиДія
1. HSE ONRCC_CR (0×40021000)HSEON = біт 16 → 1, чекаємо HSERDY = біт 17Запуск зовнішнього кварца, busy-wait з таймаутом
2. Flash latencyFLASH_ACR (0×40022000)LATENCY [2:0] = 0b010 (2 WS), PRFTBE = біт 4 = 1Обов’язково до підняття частоти: 0 WS ≤24MHz, 1 WS ≤48MHz, 2 WS ≤72MHz
3. Прескалери шинRCC_CFGR (0×40021004)HPRE [7:4] = 0000 (AHB /1), PPRE1 [10:8] = 100 (APB1 /2), PPRE2 [13:11] = 0xx (APB2 /1)APB1 max 36MHz — ділимо. AHB і APB2 тримають 72MHz
4. Конфіг PLLRCC_CFGRPLLSRC = біт 16 = 1 (HSE), PLLXTPRE = біт 17 = 0, PLLMUL [21:18] = 0111 (×9)8MHz × 9 = 72MHz
5. PLL ONRCC_CRPLLON = біт 24 → 1, чекаємо PLLRDY = біт 25Локінг PLL, таймаут з fallback
6. Switch SYSCLKRCC_CFGRSW [1:0] = 10, чекаємо SWS [3:2] == 10Апаратне підтвердження реального переключення

Кожен таймаут це лічильник, а не while(1). При збої функція вимикає щойно ввімкнене (HSEON/PLLON), при цьому не перемикає SW, і плата лишається на HSI 8MHz — працює, хоч і повільніше.

rcc_set_pll_72mhz();   // ПЕРЕД hal_init()
hal_init();

Прошив, оп-ля інтервал точно 1 секунда. PLL реально на 72MHz, vTaskDelay рахує правильно.

Замкнув PA1 на GND пінцетом імітуючи натискання кнопки і LED почав швидко мигтіти 10 разів, потім повернувся до звичайного ритму, як і задумано.

◆ Бюджет пам’яті

   text    data     bss     dec     hex
   5837       8   12532   18377    47c9
ЗайнятоДоступно%
Flash5837 байт65536 байт8.9%
SRAM12532 байт20480 байт61.2%

Flash — величезний запас, майже 60KB вільно на весь код серії наперед.

SRAM — інша картина. heap_4.c виділяє статичний масив ucHeap[] одним шматком, розміром configTOTAL_HEAP_SIZE (12*1024) на перший погляд може здатися, що щось пішло не так, але це не «витрачені 12KB, а стеки деінде» — навпаки: стеки обох тасків (xTaskCreate(..., 128, ...) — 128 слів × 4 байти = 512 байт мінімум кожен) виділяються з цього самого 12KB-бюджету через pvPortMalloc, так само як і будь-яка черга чи семафор. 12KB — це запас під усе майбутнє динамічне виділення одразу, а не порожня трата.

⚠️ Історична довідка. Vanilla FreeRTOS (репозиторій FreeRTOS-Kernel з GitHub, ARM_CM3 GCC порт, який використовується) визначає usStackDepth у словах, а не байтах. Це стандартна поведінка, яка задокументована офіційно: StackType_t на 32-бітній архітектурі = uint32_t = 4 байти, і xTaskCreate(..., 128, ...) виділяє 128 × 4 = 512 байт.

Єдиний виняток — ESP-IDF-форк FreeRTOS (Espressif для ESP32), ось там розробники свідомо змінили конвенцію на байти замість слів, тепер це джерело плутанини в інтернеті (купа заплутаних форумних постів).

Залишок SRAM поза цим бюджетом (20480 — 12288 = 8192 байт) — стек main() до старту планувальника, глобальні змінні HAL, вектор переривань. Тому основна мораль для новачків в RTOS «плануй heap заздалегідь, бо на F103 з 20KB SRAM резерв під RTOS це відчутна частка всього бюджету, і краще знати про це на старті, ніж з’ясувати постфактум через pvPortMalloc що поверне тобі NULL».

Для порівняння: весь HAL без FreeRTOS (Місяці 1-4) важив 1859 байт Flash і 4 байти SRAM. Ціна входу в RTOS на цьому мікроконтролері реальна, і її варто знати наперед. Бо якось давно робив приблуду на Arduino proMini, класна плата 4 UART, але пам’яті малувато лише 2 КБ оперативної пам’яті (SRAM), довго я тоді оптимізував ардуїнськовий код, спочатку не вистачало 6 байт, а після оптимізації нормально так звільнилось. Класно тоді так розібрався в тому, по крайній мірі мені так здавалось. І нормально те все працювало і працює дотепер.

Наступна стаття — черги й семафори, де UART-таск буде читати команди, LED-таск виконувати, mutex захищати спільний ресурс. А поки доцільно освіжити пам’ять перечитавши STM32 з нуля без HAL: UART від bit-bang до HAL. Частина 2. Отже, поступово і нудно, але плавно вибудовується архітектура, яка знадобиться на шостому місяці для TDOA-мікрофонної мережі. Ну що ж, далі нас чекають справжні пригоди, підписуйтесь щоб не пропустити.

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

👍ПодобаєтьсяСподобалось7
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Надзвичайно актуальна й важлива для розвитку українського embedded енциклопедична багатоскладова робота вельмишановного пана автора.

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

Дякую за такий теплий і змістовний відгук, дуже приємно це читати.
Планую усе систематизувати і не тільки всередині цієї серії, зараз не видно увесь задум, адже насправді в мїй уяві він значно більший ніж те, що видно зараз. В планах перелінкувати матеріал і з паралельними серіями, які зараз на паузі: зокрема з «Збираємо комп’ютер як Neotron, але по-своєму» (Zephyr на Pi Pico) і з Автоботом.
Поки серія триває, почав вести окремий документ-довідник саме для вказаного вами формату. В кінці цієї серії, покажу результат в наступних постах

Підписатись на коментарі