Один BSP, багато родин STM32: чому гілки Git і чого вони коштують
STM32 - це не один мікроконтролер, а десяток родин: G4, H5, U5, F4 і так далі. Вони мають те саме ядро Cortex-M, але різну периферію, різні назви регістрів і різний пакет драйверів від виробника. Якщо ви хочете, щоб одна архітектура firmware працювала на всіх них, потрібно вирішити, де житимуть відмінності. Безкоштовного варіанту немає, і ця стаття описує той, який я обрав для Embedbits BSP, що про нього кажуть цифри зі справжніх репозиторіїв і чого він коштує.
Усі наведені нижче цифри взято з репозиторіїв на 5 жовтня 2026 року, виміряні невеликим скриптом, який ви знайдете наприкінці розділу про вартість. Кілька слів про дату: на той момент STM32F4 - єдина родина з повним релізом BSP, а реліз інших (першою U5) саме робився. Тож цифри показують платформу посеред міграції, а не кінцевий стан, і саме це робить їх цікавими. Запустіть скрипт ще раз після релізу й порівняйте.
Чотири способи підтримати багато родин
| Підхід | Як це виглядає | Проблема |
|---|---|---|
Одне дерево, #ifdef | #if defined(STM32G4) ... #elif defined(STM32U5) ... у коді | Код наповнюється умовами, більша його частина мертва для будь-якої конкретної збірки (див. правила щодо мертвого коду в статті про MISRA), і ніхто не наважується його чіпати |
| Copy and paste | Тека на родину, копія всього | Кожне виправлення треба робити N разів, вручну, і одне з них забувається |
| Один репозиторій на родину | Bsp-G4, Bsp-U5, ... | Те саме, що й копіювання, тільки з більшою кількістю репозиторіїв |
| Абстракція + гілка на родину | Один інтерфейс, родина живе в гілці | Гілка може віддалитися від інших (див. нижче) |
Я використовую останній. Ідея в тому, що користувач обирає родину один раз, і відтоді дерево містить лише код цієї родини.
Як це працює в BSP
Кожна родина STM32 - це гілка Git репозиторію BSP (STM32G4, STM32H5, STM32U5, ...). Те саме стосується модулів BSP: модулів MCAL, linker і RAL. Решту робить платформа. Коли ви запускаєте скрипт налаштування EmBi_Platform і обираєте Configure BSP module, він показує список гілок:
Available BSP Families:
[0]: STM32G4
[1]: STM32G4_Dev
[2]: STM32H5
[3]: STM32H5_Dev
[4]: STM32U5
[5]: master
Enter branch ID (numerical):
а після вашого вибору додає модулі BSP як підмодулі Git, робить checkout гілки та комітів родини й оновлює файли CMake. Проєкт прив'язаний до точних комітів, тож завтра збирається так само. Записи з _Dev - це гілки розробки родини, а master містить найновіші нерелізні зміни всіх родин, тому призначений для роботи над платформою, а не для продукту. Перемикання на іншу родину означає повторний запуск тієї самої опції.
Пакет драйверів виробника йде за тією самою схемою. RAL має гілки STM32<family> для родини, STM32<family>_<major>.x для мажорного релізу драйверів ST і STM32<family>_<major>.<minor> для мінорного. Коли ST публікує нову версію, з'являється нова гілка, а стара лишається доступною для проєктів, які її використовують.
Де насправді відмінності
Я хотів знати, яка частина коду справді відрізняється між родинами, тож порівняв гілки. Ось модуль GPIO, Bsp-Mcal-Gpio, з релізною STM32F4 як еталоном. Числа - це змінені рядки (додані плюс видалені) у кожному файлі:
file STM32F4 vs STM32G4 STM32F4 vs STM32H5 STM32F4 vs STM32U5
Gpio.c 306 142 303
Gpio.h 5 0 0
Gpio_Port.h 3 0 0
Gpio_Types.h 35 5 28
Публічний інтерфейс, Gpio_Port.h, ідентичний на F4 і H5, а на G4 та U5 відрізняється трьома рядками коментаря. G4 і U5 близькі одна до одної (їхні Gpio.c відрізняються трьома рядками, знову лише коментар): це старіше покоління модуля, яке чекає на реліз. Чому код модуля такий схожий у двох різних родинах? Тому що відмінність винесено з модуля на найнижчий рівень. RAL має теку Port з одним заголовком на кожну периферію, і вся різниця між родинами в Stm32_gpio.h - це ось що:
-#include "stm32g4xx_ll_gpio.h" /* GPIO peripheral access layer */
+#include "stm32u5xx_ll_gpio.h" /* GPIO peripheral access layer */
Один рядок. Модуль MCAL включає Stm32_gpio.h і ніколи не знає, який драйвер виробника за ним стоїть. Те саме стосується інших периферій, які я порівняв між G4 та U5 (Stm32_rcc.h, Stm32_usart.h, Stm32_tim.h): два відмінні рядки з 32. У цьому вся ідея шарової архітектури, застосована до питання родин.
Що справді відрізняється, так це набори периферії. Тека Port для G4 має заголовки для HRTIM і DMAMUX, яких немає в U5, а U5 має кеші, PKA, SDMMC і low-power GPIO, яких немає в G4. Модуль для периферії, якої родина не має, просто відсутній у її гілці, і звідси береться таблиця підтримки родин на сторінці про MCAL.
Два рівні: родина і MCU
Гілка вирішує відмінність між родинами. Існує менша відмінність всередині родини: окремі MCU мають різну кількість портів (MCU в малому корпусі має менше портів GPIO, ніж у великому). Її розв'язують умовою на макроси, які заголовок пристрою від виробника вже визначає, а не гілкою:
static const gpio_PortConfig_t gpio_PeriphConf[ GPIO_PORT_CNT ] =
{
#if defined(GPIOA)
{ .GpioReg = GPIOA, .GpioRcc = RCC_PERIPH_GPIOA },
#endif
/* ... */
#if defined(GPIOJ)
{ .GpioReg = GPIOJ, .GpioRcc = RCC_PERIPH_GPIOJ },
#endif
#if defined(GPIOK)
{ .GpioReg = GPIOK, .GpioRcc = RCC_PERIPH_GPIOK },
#endif
};
а в типах порт, якого не існує, відображається на значення GPIO_PORT_CNT, тож інші модулі й далі компілюються, а використання такого порту можна розпізнати як некоректне. Правило: гілка для іншої родини, #if defined макросу виробника для іншого MCU в тій самій родині.
Що ви отримуєте
- Лише код родини. Жодних лісів
#ifdef, нічого мертвого, а diff зміни показує тільки той код, який збирається. - Чітке відокремлення коду виробника. Драйвери ST для кожної родини віддзеркалені у власних гілках RAL і не модифікуються. Ваш код їх не містить.
- Стабільний інтерфейс для застосунку. Застосунок викликає
Gpio_Set_PinLevel()і не знає родини, тож зміна MCU - це інша гілка, а не переписування застосунку. - Зафіксовані версії. Проєкт точно каже, який коміт якої гілки він використовує, тож оновлення - це свідоме рішення.
Чого це коштує: дрейф
Слабкість гілок у тому, що вони живуть власним життям. Виправлення, зроблене в одній гілці, не потрапляє в інші, якщо хтось його туди не перенесе. Вимірювання вище показує два покоління того самого модуля одночасно. Релізна F4 (і H5, яка йде за нею) має вдосконалену ініціалізацію GPIO (рівень виходу встановлюється до перемикання режиму піна на вихід, тож пін не дає глітчу), задокументовані параметри та теку Tests з модульними й інтеграційними тестами. G4 і U5 нічого з цього ще не мають. Публічний інтерфейс USART у Usart_Port.h показує це ще краще:
| Гілка | Публічні функції | Порівняно з F4 |
|---|---|---|
| STM32F4 (реліз) | 79 | еталон |
| STM32H5 | 79 | той самий набір функцій, заголовок відрізняється у 2 рядках |
| STM32G4 | 82 | 12 функцій, яких немає в F4 (Usart_StartTransmit, Usart_Set_TransmitBytes, ...) і 9, які є в F4, а в G4 немає (Usart_Set_TxStart, Usart_Set_DataConfig, ...) |
| STM32U5 | 83 | 13 функцій лише в U5, 9 лише в F4 |
Наразі код, написаний під інтерфейс F4, компілюється на H5 і не компілюється на G4 та U5. Це не вада підходу, а нормальний стан платформи посеред релізу. Але він показує ціну: обіцянка «той самий інтерфейс BSP для всіх родин» вірна для родини лише після того, як її гілку випущено, а час між релізом першої та останньої родини - це період, коли гілки віддаляються одна від одної. У цей час ніхто не повинен писати застосунок для родини, яка ще не готова.
Як тримати дрейф під контролем
- Майте еталонну гілку та список того, що портовано. Одна родина веде (тут F4), інші наздоганяють, а статус видимий: це таблиця підтримки родин на сторінці MCAL, розширена колонкою «та сама версія інтерфейсу, що й в еталона».
- Зробіть реліз однією операцією. Скрипт релізу, що проходить усі модулі однієї родини (так робиться реліз U5), кращий за ручний merge: родина або готова вся, або не готова.
- Вимірюйте дрейф. Скрипт нижче порівнює один модуль на кількох гілках і виводить кількість змінених рядків на файл. Нуль або коментар означає «те саме», велике число - це робота, що чекає. Він виконується за секунду і вміщається в job CI.
- Нехай один набір тестів перевіряє всі гілки. Модульні тести, що існують на F4 (і H5), - це специфікація модуля. Якщо той самий
Test_Gpio.cмає проходити на кожній гілці родини, інтерфейс не може непомітно розійтися. Стаття про модульне тестування показує, як будується такий тест. - Тримайте відмінність внизу. Усе, що можна перенести в заголовки
Portу RAL (один#include, як показано вище), - це відмінність, яку не треба підтримувати на кожній гілці. Чим більше коду ідентичного, тим легше перенести виправлення з однієї гілки в іншу (git cherry-pick).
#!/usr/bin/env bash
# Shows how much a module differs between its family branches.
# Usage: branch-drift.sh <repository-url> <branch> <branch> [<branch>...]
set -euo pipefail
repo="$1"; shift
branches=("$@")
work="$(mktemp -d)"
git init -q "$work"
git -C "$work" remote add origin "$repo"
for branch in "${branches[@]}"; do
git -C "$work" fetch -q --depth 1 origin "refs/heads/$branch:refs/remotes/origin/$branch"
done
files="$(git -C "$work" ls-tree -r --name-only "origin/${branches[0]}" | grep -E '\.(c|h)$' || true)"
printf '%-44s' "file"
for ((i = 1; i < ${#branches[@]}; i++)); do printf '%-24s' "${branches[0]} vs ${branches[i]}"; done
printf '\n'
for file in $files; do
printf '%-44s' "$file"
for ((i = 1; i < ${#branches[@]}; i++)); do
changed="$(git -C "$work" diff --numstat "origin/${branches[0]}" "origin/${branches[i]}" -- "$file" | awk '{print $1 + $2}')"
printf '%-24s' "${changed:-0}"
done
printf '\n'
done
rm -rf "$work"
Використання на гілках модуля GPIO (таблиця вище - його вивід):
./branch-drift.sh https://github.com/Embedbits/Bsp-Mcal-Gpio STM32F4 STM32G4 STM32H5 STM32U5
Для інших модулів картина інша, і це корисно знати до того, як планувати роботу:
file F4 vs G4 F4 vs H5 F4 vs U5
Exti_Port.h 0 0 0
Exti.c 586 538 682
Usart_Port.h 55 2 28
Usart.c 3734 2301 3927
EXTI має незмінний публічний заголовок на всіх гілках і різну реалізацію (периферія відрізняється, інтерфейс ні). USART відрізняється інтерфейсом на G4 та U5 і значною частиною реалізації, що є мірою того, скільки роботи потребує реліз цих двох.
Який підхід обрати
- Гілки на родину підходять, коли родини відрізняються набором периферії та драйверами виробника, коли продукт збирається для кількох родин і коли ви можете дозволити собі підтримувати інтерфейс (дрейфом можна керувати при кількох родинах і з інструментом).
#ifdefв одному дереві прийнятний, коли відмінності малі: одна родина з кількома варіантами MCU, один регістр, що називається по-іншому.- Жоден із них не допоможе без абстракції внизу. Гілка лише вирішує, яка реалізація інтерфейсу лежить у дереві. Якщо застосунок включає заголовки виробника, гілка на родину ні від чого вас не врятує.
І що б ви не обрали, запишіть, яка родина еталонна, і тримайте в job CI одне число: наскільки від неї відстають інші.