Перейти до основного вмісту

Дизайн і архітектура

· 7 хв читання

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, Artifacts, Bsp (Hal, LinkerFiles, Mcal, Ral, Startup), Docs і Middlewares

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, застосована до всієї прошивки.