Дизайн і архітектура
OCD. Три чудові літери, які змушують мене постійно думати про архітектуру програмного забезпечення. Це може бути справді болісно, особливо коли доводиться мати справу з архітектурою в стилі «Arduino». Ви напевно її знаєте: весь проєкт у кількох папках, застосунок у папці src, низькорівнева функціональність у папці driver і так далі. Але що робити в складних системах?
Модульність
Кожна група функціональності має бути інкапсульована в пакеті. З'єднуючи такі пакети, ми отримуємо дедалі складніші системи. Уявіть проєкт, у якому потрібно виміряти температуру датчиком, підключеним до ADC, і надіслати актуальні дані через UART. Нам потрібен модуль, який читає сирі дані з ADC, обчислює значення в °C та надає його на своєму інтерфейсі, і ще один модуль, який відповідає за зв'язок через UART.
Якби ми об'єднали всю цю функціональність в одному місці, ми б одночасно визначали положення та швидкість елементарної частинки. Це спричинило б колапс хвильової функції та знищення всесвіту (сарказм).
Шари абстракції
Уявіть дерево. Верхівка дерева — це єдина точка, яка обробляє або виконує потрібну функціональність. У вбудованих системах це може бути нескінченний цикл у функції main або якийсь обробник RTOS. Чим нижче спускається дерево, тим ширшим воно стає. Це відображає зв'язки та ієрархію між модулями. Коріння дерева — це зв'язки з апаратною частиною.
Базовий приклад — «blinky»: світлодіод, підключений до PA1, має світитися, якщо натиснуто кнопку, підключену до PA0, і навпаки.
#include "stm32g4xx_ll_gpio.h"
void main(void)
{
while(1)
{
if(0u != LL_GPIO_IsInputPinSet(GPIOA, LL_GPIO_PIN_0))
{
LL_GPIO_SetOutputPin(GPIOA, LL_GPIO_PIN_1);
}
else
{
LL_GPIO_ResetOutputPin(GPIOA, LL_GPIO_PIN_1);
}
}
}
У цьому прикладі ми змішали всі шари. Для примітивного проєкту це може бути прийнятно, але в складній системі це веде до анархії, безладу, спагеті-коду і кінця світу. Щоб уникнути жалюгідного, довгого та вирішального армагеддону, люди винайшли кілька чудових речей, як-от ієрархію та шари абстракції. У своїх проєктах я розділяю функціональність на три базові шари: Application, Middleware та Board Support Package (він же BSP). Вони, у свою чергу, поділяються на кілька шарів для забезпечення переносимості та модульності.
Application містить те, що робить продукт. Middleware містить повторно використовуване програмне забезпечення без залежності від апаратної частини, як-от логер, стек ModBus або менеджер енергонезалежної пам'яті. А що таке BSP? Просто. Він містить усе необхідне для зв'язку, конфігурації та керування апаратною частиною. У нашому прикладі вище операції з GPIO належать до BSP. Користувачеві не слід перейматися точним підключенням кнопки та світлодіода, тож інтерфейс між BSP і застосунком може виглядати як Bsp_Get_ButtonState() і Bsp_Set_LedState(). Якщо в апаратній частині щось зміниться, нікому не доведеться правити кілька місць, оновлюється лише BSP.
Отже, BSP містить принаймні один шар інкапсуляції для цих геттерів і сеттерів. Але що вони використовуватимуть? Чи писатимуть вони в регістри напряму? Не думаю. У STMicroelectronics є два набори «драйверів»: «HAL» і «LL». Чому лапки? Тому що жодна з назв не відповідає дійсності. Їхній пакет «HAL» насправді є величезним і повільним спагеті-кодом, який не має нічого спільного зі своїм описом, адже HAL розшифровується як Hardware Abstraction Layer. До справжнього ми дійдемо наприкінці.
Register Abstraction Layer (RAL)
Функції LL_GPIO_xxx з прикладу — це інкапсульований доступ до регістрів MCU. Операції з регістрами можна писати де завгодно, але що, якщо ви змінюєте MCU або припускаєтеся помилки? Доведеться виправляти її в кожному місці. Це було б болісно, тож ми щойно знайшли найнижчий шар абстракції — Register Abstraction Layer. Він представляє лише абстракцію регістрів, інкапсульовану у (зазвичай inline) функції зі змістовними назвами.
__STATIC_INLINE void LL_GPIO_SetOutputPin(GPIO_TypeDef *GPIOx, uint32_t PinMask)
{
WRITE_REG(GPIOx->BSRR, PinMask);
}
Назва «LL», на мій погляд, некоректна (як і їхній HAL, але хто б його використовував?), проте ми отримуємо доступ до всіх регістрів зі змістовним описом, тож можемо користуватися цим без складного ручного написання.
У моїх проєктах RAL складається з трьох частин:
- CMSIS від ARM: визначення ядра Cortex-M,
- CMSIS_ST від STMicroelectronics: заголовки пристроїв з визначеннями регістрів та системною ініціалізацією сімейства MCU,
- RAL_ST: оригінальні LL-драйвери ST разом з моїм шаром
Port. ШарPortзабезпечує уніфіковане іменування, що не залежить від сімейства MCU, тому код над RAL не змінюється, коли змінюється MCU.
RAL знає регістри і більше нічого. Він не знає, що до PA1 щось підключено.
Microcontroller Abstraction Layer (MCAL)
Отже, ми маємо легкий доступ до регістрів. Але що далі? Доступ до GPIO тривіальний, а як щодо інших периферійних модулів? Для передавання через UART для кожної операції потрібно кілька регістрів: увімкнути периферію, встановити baudrate, довжину біта, парність, стоп-біти і так далі. І оскільки ми вже знаємо, що кожна функціональність, яка використовується більше одного разу, має бути інкапсульована, ми створюємо функції для зчитування помилок, запуску передавання, запису в регістр вихідних даних, зчитування регістра вхідних даних та багато інших.
Ця функціональність не може бути RAL, адже вона ієрархічно вища й викликає RAL. Вона опікується лише периферією мікроконтролера, тому цей шар називається Microcontroller Abstraction Layer (MCAL). Користувач бачить Gpio_Set_PinLevel() або Usart_Send() і не мусить знати жодного регістра. Одна периферія — один інтерфейс: якщо I2C потребує DMA, модуль I2C обробляє це внутрішньо.
Hardware Abstraction Layer (HAL)
Тепер ми можемо ініціалізувати всю периферію MCU: RCC, DMA, USART/UART і так далі. Але конфігурація має бути сумісною з пристроями, підключеними на нашій друкованій платі. Тож ми зосереджуємося не лише на MCU, а на всій схемі. Отже, нам потрібен ще один шар абстракції — Hardware Abstraction Layer (HAL). Так, це справжнє значення цієї назви, а не те, що є в «HAL» від ST.
HAL — єдина точка інтерфейсу між застосунком (або middleware) та апаратною частиною. Він знає, який пін є світлодіодом, який UART підключено до роз'єму налагодження і як налаштована периферія. Щоб забезпечити потрібну функціональність, він поєднує кілька модулів з MCAL. Це останній шар абстракції BSP.
Board Support Package
BSP — це апаратно-орієнтований пакет, який містить усе необхідне для ініціалізації та використання всієї периферії MCU і підключеної схеми. Окрім трьох шарів вище, він також містить код startup (ініціалізація секцій пам'яті та виклик застосунку) і генерацію linker script для вибраного MCU. Кожне сімейство STM32 має власну гілку, тому інтерфейс BSP залишається незмінним, а змінюється лише те, що під ним.
Application / Middleware
────────────────────────
HAL what is connected to which pin, how the board is configured
MCAL peripherals: Gpio, Usart, I2c, Dma, ...
RAL registers: CMSIS, ST LL drivers, unified Port layer
────────────────────────
Hardware
Правило просте: шар викликає лише шар, розташований безпосередньо під ним. Застосунок ніколи не підключає заголовок ST, MCAL не знає, який пін є світлодіодом, а RAL не знає, яка периферія для чого використовується.
Знову blinky
Перепишемо приклад. Застосунок не знає жодної апаратної частини:
void main(void)
{
Bsp_Init();
while(1)
{
Bsp_Set_LedState(Bsp_Get_ButtonState());
}
}
BSP знає плату і використовує MCAL. Піни описані в єдиній таблиці, тож нова ревізія друкованої плати змінює один рядок і більше нічого (спрощено, обробку помилок пропущено):
typedef struct
{
gpio_PortId_t portId;
gpio_PinId_t pinId;
} bspPin_t;
static const bspPin_t bspButton = { GPIO_PORT_A, GPIO_PIN_0 };
static const bspPin_t bspLed = { GPIO_PORT_A, GPIO_PIN_1 };
bool Bsp_Get_ButtonState(void)
{
gpio_PinLevel_t pinLevel = GPIO_PIN_LEVEL_LOW;
(void)Gpio_Get_PinLevel(bspButton.portId, bspButton.pinId, &pinLevel);
return (GPIO_PIN_LEVEL_HIGH == pinLevel);
}
void Bsp_Set_LedState(bool isActive)
{
(void)Gpio_Set_PinLevel(bspLed.portId, bspLed.pinId,
isActive ? GPIO_PIN_LEVEL_HIGH : GPIO_PIN_LEVEL_LOW);
}
А десь унизу, усередині Gpio_Set_PinLevel(), функція RAL LL_GPIO_SetOutputPin() нарешті записує в регістр BSRR. Чотири шари заради блимання світлодіода виглядають безглуздо, і для blinky так воно і є. Але суть у тому, що відбувається, коли продукт росте:
- Нове сімейство MCU: вибирається інша гілка BSP, застосунок не чіпається.
- Нова ревізія друкованої плати: світлодіод переїжджає на інший пін, змінюється один рядок у HAL.
- Unit-тест застосунку: BSP замінюється фейковим, і застосунок запускається на ПК без жодної апаратної частини.
- Помилка в доступі до регістра: вона лише в одному місці.
Де це знайти
Усі описані тут шари мають відкритий код і документацію, кожен у власному репозиторії організації Embedbits:
- BSP: пакет зі startup, linker script і HAL,
- RAL: CMSIS, CMSIS_ST і RAL_ST,
- MCAL: модулі периферії разом з таблицею підтримуваних сімейств STM32,
- стиль кодування, який робить назви в цих шарах читабельними.
Якщо хочете побачити ту саму ідею з погляду мови, прочитайте статтю про принципи SOLID у C++ і C. Шари вище — це не що інше, як dependency inversion, застосована до всієї прошивки.