Как стать автором
Обновить

Архитектура Xорошего Кода Прошивки (Массив-Наше Всё)

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров7.2K

В этом тексте я написал о некоторых трюках в организации кода для микроконтроллеров. Может при прочтении покажется, что это всё очевидно, однако за 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;
}


Далее одна универсальная функция правильно обрабатывает каждый узел с конфигами. Один за другим для каждого элемента конфигурационного массива. Очень удобно.

Это ещё и потому удобно, что *.map файл покажет вам смещение в *.bin файле, где лежит эта структура. И вы сможете вручную аккуратно изменить константу перед прошивкой.

Массив функций инициализаций прошивки

Что такое инициализация? Это ведь упорядоченное множество Си-функций. То есть инициализация - это последовательность запуска Си-функций в правильном порядке. Только и всего... А почему бы тогда не организовать эту последовательность в массив функций? Да запросто... Вот.

#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, дергание GPIO импульса или сброс сторожевого таймера. Если прошивка зависнет в инициализации, то посчитав импульсы в GPIO вы поймете на какой функции (индекс в массиве указателей) прошивка зависла в инициализации.

Потом, прошивка, по хорошему, должна печатать и отчет о своей загрузке в 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

целое число (0<=)

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

Ссылки

#

название

URL

1

NVRAM для микроконтроллеров

https://habr.com/ru/articles/706972/

2

NVRAM Поверх off-chip SPI-NOR Flash

https://habr.com/ru/articles/732442/

3

Почему Нам Нужен UART-Shell?

https://habr.com/ru/articles/694408/

4

Автоматическая Генерация Конфигураций для Make Сборок

https://habr.com/ru/articles/798213/

5

11 Aтрибутов Хорошего Firmware

https://habr.com/ru/articles/655641/

6

51 Атрибут Хорошего С-кода

https://habr.com/ru/articles/679256/

7

16 Способов Отладки и Диагностики FirmWare

https://habr.com/ru/articles/681280/

8

Архитектура Хорошо Поддерживаемого драйвера

https://habr.com/ru/articles/683762/

9

Что Должно Быть в Каждом FirmWare Pепозитории

https://habr.com/ru/articles/689542/

10

Почему важно собирать код из скриптов

https://habr.com/ru/articles/723054/

11

Модульное Тестирование в Embedded

https://habr.com/ru/articles/698092/

12

Диспетчер Задач для Микроконтроллера

https://habr.com/ru/articles/757000/

13

23 Атрибута Хорошего Загрузчика

https://habr.com/ru/articles/754216/

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вы делаете инициализацию прошивки в виде массива указателей на функции?
12.12% да4
87.88% нет29
Проголосовали 33 пользователя. Воздержались 6 пользователей.
Теги:
Хабы:
Всего голосов 13: ↑11 и ↓2+14
Комментарии33

Публикации

Истории

Работа

Ближайшие события