Архитектура Xорошего Кода Прошивки (Массив-Наше Всё)
В этом тексте я написал о некоторых трюках в организации кода для микроконтроллеров. Может при прочтении покажется, что это всё очевидно, однако за 12 лет я видел что-то похожее только в одном проекте, и то лишь от части. Итак, поехали…
Массивы конфигурационных структур
Дело в том, что для большинства прошивок конфиги на 100% статические. То есть конфиги для прошивки, как ни крути, известны до этапа компиляции программы. Да, это так…
Вот и получается что один из самых нормальных способов передавать конфиги в прошивки — это через переменные окружения. Про это у меня есть отдельный текст. https://habr.com/ru/articles/798213/. Это удобно с точки зрения масштабирования кодовой базы. Плюс в том, что переменные окружения можно определять прописывая прямо в скриптах (Make, CMake и т.п.).
Однако не всё удобно передавать через переменные окружения. Это происходит из-за того, что переменные конфигов имеют разные типы данных: целые числа, вещественные числа, комплексные числа, строки. К этому приходится приспосабливаться. Большинство просто добавляет тонны макроопределений и превращает свой код в свалку. Это не наш путь. Я решил пойти другим путём и остальные более детальные конфиги стал передавать через массивы структур. Вот как тут конфиг монохроматических светодиодов:
#include "led_mono_config.h"
#ifndef HAS_LED
#error "Add HAS_LED"
#endif /*HAS_LED*/
#include "data_utils.h"
const LedMonoConfig_t LedMonoConfig[] = {
{
.num = 1, .period_ms = 500,
.phase_ms = 500, .duty = 10,
.pad = {.port = PORT_D, .pin = 15},
.name = "Green", .mode = LED_MODE_BAM,
.active = GPIO_LVL_LOW, .valid = true,
},
{
.num = 2, .period_ms = 1000, .phase_ms = 0,
.duty = 10, .pad = {.port = PORT_D, .pin = 13},
.name = "Red", .mode = LED_MODE_OFF,
.active = GPIO_LVL_LOW, .valid = true,
},
{
.num = 3, .period_ms = 1000,
.phase_ms = 0, .duty = 10,
.pad = {.port = PORT_D, .pin = 14},
.name = "Yellow", .mode = LED_MODE_PWM,
.active = GPIO_LVL_LOW, .valid = true,
},
};
LedMonoHandle_t LedMonoInstance[] = {
{ .num = 1, .valid = true, },
{ .num = 2, .valid = true, },
{ .num = 3, .valid = true, },
};
uint32_t led_mono_get_cnt(void) {
uint32_t cnt = 0;
uint32_t cnt1 = 0;
uint32_t cnt2 = 0;
cnt1 = ARRAY_SIZE(LedMonoInstance);
cnt2 = ARRAY_SIZE(LedMonoConfig);
if(cnt1 == cnt2) {
cnt = cnt1;
}
return cnt;
}
Далее одна универсальная функция правильно обрабатывает каждый узел с конфигами. Один за другим для каждого элемента конфигурационного массива. Очень удобно…
Массив функций инициализаций прошивки
Что такое инициализация? Это ведь упорядоченное множество Си-функций. То есть инициализация — это последовательность запуска Си-функций в правильном порядке. Только и всего… А почему бы тогда не организовать эту последовательность в массив функций? Да запросто… Вот.
#ifdef HAS_MICROCONTROLLER
#include "board_config.h"
#define BOARD_INIT \
{ .init_function = board_init, .name = "board", },
#else /*HAS_MICROCONTROLLER*/
#define BOARD_INIT
#endif /*HAS_MICROCONTROLLER*/
/*Order matters!*/
define INIT_FUNCTIONS \
MCAL_INIT \
HW_INIT \
INTERFACES_INIT \
PROTOCOLS_INIT \
CONTROL_INIT \
STORAGE_SW_INIT \
SW_INIT \
UNIT_TEST_INIT \
ASICS_INIT \
BOARD_INIT
/*Order matter!*/
const SystemInitInstance_t SystemInitInstance[] = {INIT_FUNCTIONS};
Выигрыш тут тройной:
1--Вся инициализация в одном месте.
2--Прядок инициализации определён индексом в массиве.
3--Для каждой функции инициализации легко выполнить один какой-то общий пролог и эпилог-код. Например печать порядкового номера или сброс сторожевого таймера.
Потом, прошивка должна печатать отчет о своей загрузке в UART или в SD карту.
Лог позволит анализировать ошибки в конфигурациях или брак в аппаратуре PCB (железе) еще до запуска суперцикла.
Массив функций для суперцикла
У каждой прошивки так или иначе есть суперцикл. Либо он прописан явно внутри main (), либо в составе bare-bone потока на какой-нибудь RTOS. Тут тоже, функции суперцикла можно объединить в массив структур. При этом каждую функцию можно пропускать через компонент limiter и, тем самым, вызывать её с определённым в конфиге периодом. Не чаще чем, скажем, 500ms.
№ | поле структуры диспетчера | тип данных |
1 | указатель на Си-функцию | адрес в Flash |
2 | имя процедуры | текст |
3 | период с которым следует вызывать Си-функцию | натуральное число |
Про это у меня есть отдельный текст. Называется Диспетчер Задач для Микроконтроллера https://habr.com/ru/articles/757000/
#ifdef HAS_BOARD_PROC
#include "board_at_start_f437.h"
#define BOARD_TASK {.name="board", \
.period_us=BOARD_POLL_PERIOD_US, \
.limiter.function=board_proc,},
#else
#define BOARD_TASK
#endif /**/
#define TASK_LIST_ALL \
ASICS_TASK \
APPLICATIONS_TASKS \
BOARD_TASK \
MCAL_TASKS \
TASK_CORE \
COMPUTING_TASKS \
CONNECTIVITY_TASKS \
CONTROL_TASKS \
SENSITIVITY_TASKS \
STORAGE_TASKS
TaskConfig_t TaskInstance[] = {
TASK_LIST_ALL
};
Массив параметров в NVRAM
Любой прошивке надо запоминать какие-то параметры в энергонезависимой памяти. Это происходит по разным причинам. Про это есть отдельный текст: NVRAM для микроконтроллеров https://habr.com/ru/articles/706972/ У каждого NVRAM параметра есть минимум такие свойства как
№ | свойство NVRAM записи | тип данных |
1 | размер | натуральное число |
2 | имя | строка |
3 | тип данных | перечисление |
4 | адрес в NVRAM | натуральное число |
5 | принадлежность к SW компоненту | перечисление |
Поэтому в прошивке определяем массив структур, который явно покажет с какими параметрами прошивка будем работать после запуска.
const ParamItem_t ParamArray[] = {
FLASH_FS_PARAMS
PARAMS_GNSS
IWDG_PARAMS
PARAMS_PASTILDA
PARAMS_SDIO
PARAMS_TIME
PARAMS_BOOTLOADER
{.facility=BOOT, .id=PAR_ID_BOOT_CMD, .len=1, .type=TYPE_UINT8, .name="BootCmd"}, /*num*/
{.facility=BOOT, .id=PAR_ID_REBOOT_CNT, .len=2, .type=TYPE_UINT16, .name="ReBootCnt"}, /*num*/
{.facility=SYS, .id=PAR_ID_SERIAL_NUM, .len=4, .type=TYPE_UINT32, .name="SerialNum"}, /**/
};
Массив отладочных токенов для диагностики
Как правило в прошивках есть UART для printf () отладки. Одновременно с этим, каждая взрослая прошивка состоит из десятков программных компонентов. Для того, чтобы отличать какому именно программному компоненты принадлежат те или иные отладочные сообщения в коде прошивки должно быть определено перечисление, где каждому программному компоненту будет присвоено натуральное число. Таким образом, при печати лога (в UART или SD карту) каждому такому числу ставится в соответствие текстовый токен. Поэтому в прошивке должен быть определен массив структур, где каждая структура ставит в соответствие числу его текстовый токен.
const static FacilityInfo_t FacilityInfo[] = {
#ifdef HAS_AES
{ .facility = AES, .name = "AES", },
#endif /*HAS_AES*/
#ifdef HAS_AD9833
{ .facility = AD9833, .name = "AD9833", },
#endif /*HAS_AD9833*/
#ifdef HAS_NOR_FLASH
{ .facility = NOR_FLASH, .name = "NorFlash", },
#endif /*HAS_NOR_FLASH*/
#ifdef HAS_NVRAM
{ .facility = NVRAM, .name = "NvRam", },
#endif /*HAS_NVRAM*/
....
};
Благодаря этим токенам в этом логе явственно видно какому именно программному компоненту принадлежит каждая строчка в логе.
Удобно? Очень!
Массив команд для CLI
В каждой нормальный взрослой прошивке есть UART-CLI. Для отладки очень полезна UART-CLI. Функции CLI тоже складируем штабелями в массив структур. Подробнее про это можно почитать в тексте: Почему Нам Нужен UART-Shell? https://habr.com/ru/articles/694408/
#define CLI_CMD(LONG_CMD, SHORT_CMD, FUNC) \
{ .short_name = SHORT_CMD, .long_name = LONG_CMD, .handler = FUNC }
bool nau8814_i2c_ping_command(int32_t argc, char* argv[]);
bool nau8814_reg_map_command(int32_t argc, char* argv[]);
#define NAU8814_COMMANDS \ \
NAU8814_DAC_COMMANDS \
NAU8814_ADC_COMMANDS \
CLI_CMD("nau8814_ping", "nap", nau8814_i2c_ping_command ), \
CLI_CMD("nau8814_map", "nrm", nau8814_reg_map_command ),
#define CLI_COMMANDS \
ASICS_COMMANDS \
APPLICATIONS_COMMANDS \
CONTROL_COMMANDS \
CONNECTIVITY_COMMANDS \
COMPUTING_COMMANDS \
MCAL_COMMANDS \
MULTIMEDIA_COMMANDS \
PROTOTYPE_COMMANDS \
STORAGE_COMMANDS \
SENSITIVITY_COMMANDS
const CliCmdInfo_t CliCommands[] = {CLI_COMMANDS};
Массив функций-модульных тестов
Прошивка может из CLI вызывать модульные тесты, которые есть у неё на борту. Список модульных тестов это тоже массив структур, где каждая содержит указатель на функцию с тестом и название теста.
bool test_c_types(void) {
LOG_INFO(TEST, "%s()..", __FUNCTION__);
bool res = true;
EXPECT_EQ(4, sizeof(long));
EXPECT_EQ(4, sizeof(1UL));
EXPECT_EQ(4, sizeof(1L));
EXPECT_EQ(4, sizeof(size_t));
EXPECT_EQ(4, sizeof(1l));
EXPECT_EQ(4, sizeof(1));
EXPECT_EQ(4, sizeof(-1));
EXPECT_EQ(4, sizeof(0b1));
EXPECT_EQ(4, sizeof(1U));
EXPECT_EQ(4, sizeof(1u));
EXPECT_EQ(4, sizeof(1.f));
LOG_INFO(TEST, "%s() Ok", __FUNCTION__);
return res;
}
#define TEST_SUIT_SW \
{"array_init", test_array_init}, \
{"bit_fields", test_bit_fields}, \
{"c_types", test_c_types}, \
{"memset", test_memset}, \
{"memcpy", test_memcpy}, \
{"bit_shift", test_bit_shift}, \
{"int_overflow", test_int_overflow}, \
{"sprintf_minus", test_sprintf_minus}, \
{"endian", test_endian},
/*Compile time assemble array */
const UnitTestHandle_t TestArray[] = {
#ifdef HAS_SW_TESTS
TEST_SUIT_SW
#endif /*HAS_SW_TESTS*/
#ifdef HAS_HW_TESTS
TEST_SUIT_HW
#endif /*HAS_HW_TESTS*/
};
Массив функций потоков для RTOS
Если ваша прошивка работает какой-нибудь RTOS, то надо создать массив структур которые будут содержать указатели на функции потоков, размер стека и приоритет для каждой задачи аргументы к потоку и прочее. И так для каждой задачи. Очевидно, что если потоков больше чем два, то имеет смысл сделать массив структур с конфигами для каждого потока.
Вот как тут.
#include "FreeRTOSConfig.h"
#include "free_rtos_drv.h"
#include "data_utils.h"
#ifdef HAS_KEEPASS
#include "keepass.h"
#endif /*HAS_KEEPASS*/
const RtosTaskConfig_t RtosTaskConfig[] = {
{ .num=1, .TaskCode=bare_bone, .name="BareBone",
.stack_depth_byte=2048, .priority=PRIORITY_LOW, .valid=true,},
{ .num=2, .TaskCode=default_task, .name="DefTask",
.stack_depth_byte=256, .priority=PRIORITY_LOW, .valid=true,},
#ifdef HAS_KEEPASS
{ .num=3, .TaskCode=keepass_proc_task, .name="KeePass",
.stack_depth_byte=1024, .priority=PRIORITY_LOW, .valid=true,},
#endif /*HAS_KEEPASS*/
};
RtosTaskHandle_t RtosTaskInstance[] = {
{ .num=1, .valid=true, },
{ .num=2, .valid=true, },
#ifdef HAS_KEEPASS
{ .num=3, .valid=true, },
#endif
};
uint32_t rtos_task_get_cnt(void){
uint32_t cnt = 0 ;
uint32_t cnt1 = 0 ;
uint32_t cnt2 = 0 ;
cnt1 = ARRAY_SIZE(RtosTaskConfig);
cnt2 = ARRAY_SIZE(RtosTaskInstance);
if(cnt1==cnt2) {
cnt = cnt1;
}
return cnt;
}
Таким образом вы будите помнит про все потоки в данной сборке.
Итоги
Подводя черту можно заменить, что в программировании микроконтроллеров массив это универсальная структура данных. При добавлении в прошивку очередного программного компонента вы просто добавляете в каждый массив по одному элементу.
№ | массив | Количество элементов на SW компонент |
1 | функций инициализации | 1 |
2 | функций суперцикла\планировщика | несколько |
3 | параметры NVRAM | несколько |
4 | номеров и их токенов для логирования | 1 |
5 | команды CLI | несколько |
6 | потоков RTOS | 1 или несколько |
7 | функций модульных тестов | несколько |
А теперь внимание. Формирование этих всех массивов можно организовать на этапе отработки препроцессора! Да… Переменные окружения вызывают нужные скрипты сборки. Скрипты сборки передают макросы препроцессора. Препроцессор выбирает нужный код. Компилятор собирает только нужный код. Easy!
Благодаря тому что у вас каждая сущность хранится в массиве Вы можете также в RunTime проверять конфиги на наличие дубликатов, найти конфликты и прочее.
Можно и вовсе справедливо заметить, что любая программа как машинный код — это не что иное как массив assembler инструкций в ROM памяти. А микропроцессор — это эдакая электрическая цепочка, которая просто исполняет одну инструкцию за другой из ROM, пока не достигнет конца массива инструкций. Вот так, господа…
В сухом остатке, благодаря организации всех программных сущностей в массивы у Вас прошивка, как кристалл растёт из одной исходной точки. У вас одна точка отсчета для техподдержки и масштабирования проекта.
Надеюсь, что этот текст поможет другим программистам микроконтроллеров эффективнее организовывать свои сборки.
Словарь
Акроним | Расшифровка |
NVRAM | Non-Volatile Random-Access Memory |
CLI | Command line interface |
UART | Universal Asynchronous Receiver-Transmitter |
RTOS | real-time operating system |
Ссылки