Приветствую, сообщество Хабра. Недавно наша компания выпустила на рынок контрольно-измерительный прибор ИРИС. Являясь главным программистом этого проекта, хочу рассказать вам про разработку прошивки прибора (По оценке руководителя проекта прошивка составляет не более 30% от общего объема работ от идеи до серийного производства). Статья в первую очередь будет полезна начинающим разработчикам в плане понимания трудозатрат на «реальный» проект и пользователям, которые желают «заглянуть под капот».
Назначение прибора
ИРИС – многофункциональный измерительный прибор. Он умеет измеряет ток (амперметр), напряжение (вольтметр), мощность (ваттметр) и ряд других величин. КИП ИРИС запоминает их максимальные значения, пишет осциллограммы. С подробным описанием устройства можно ознакомиться на сайте компании.
Немножко статистики
Сроки
Первый коммит в SVN: 16 мая 2019.
Релиз: 19 июня 2020.
*это календарное время, а не фулл-тайм разработка на протяжении всего срока. Были отвлечения на другие проекты, ожидания ТЗ, итераций железа и т.д.
Коммиты
Количество в SVN: 928
Откуда столько?
1) Являюсь сторонником микрокоммитов при разработке
2) Дубли в ветках под железо и эмулятор
3) Документация
Так что, количество с полезной нагрузкой в виде нового кода (ветка trunk) не больше 300.
Количество строк кода
Статистика собиралась утилитой cloc с дефолтными параметрами без учета исходников HAL’a STM32 и ESP-IDF ESP32.
Прошивка STM32: 38334 строк кода. Из них:
60870-5-101: 18751
ModbusRTU: 3859
Осциллограф: 1944
Архиватор: 955
Прошивка ESP32: 1537 строк кода.
Аппаратные компоненты (задействованная периферия)
Основные функции прибора реализованы в прошивке STM32. За связь по Bluetooth отвечает прошивка ESP32. Общение между чипами осуществляется по UART’у (см. рисунок в шапке).
NVIC – контроллер прерываний.
IWDG – сторожевой таймер для перезапуска чипа в случае зависания прошивки.
Timers – прерывания по таймеру обеспечивают сердцебиение проекта.
EEPROM – память для хранения производственной информации, уставок, показаний максиметра, калибровочных коэффициентов АЦП.
I2C – интерфейс для доступа к чипу EEPROM.
NOR – память для хранения осциллограмм.
QSPI – интерфейс для доступа к чипу NOR памяти.
RTC – часы реального времени обеспечивают ход времени после выключения прибора.
ADC – АЦП.
RS485 – последовательный интерфейс для подключения по протоколам ModbusRTU и 60870-101.
DIN, DOUT – дискретный вход и выход.
Button – кнопка на передней панели прибора для переключения индикации между измерениями.
Архитектура ПО
Основные модули ПО
Поток данных измерений
Операционная система
С учетом ограничений объема флеш памяти (ОС вносит накладные расходы) и относительной простоты прибора было решено отказаться от использования операционной системы и обойтись прерываниями. Такой подход уже не раз освещался в статьях на хабре, поэтому приведу лишь блок-схемы задач внутри прерываний с их приоритетами.
Пример кода. Генерация отложенного прерывания в STM32.
// Инициализация прерывания с приоритетом 6
HAL_NVIC_SetPriority(CEC_IRQn, 6, 0);
HAL_NVIC_EnableIRQ(CEC_IRQn);
// Генерация прерывания
HAL_NVIC_SetPendingIRQ(CEC_IRQn);
// Обработчик
void CEC_IRQHandler(void) {
// user code
}
ШИМ 7 сегментной индикации
В приборе две строки по 4 символа, всего 8 индикаторов. У 7-и сегментных индикаторов имеются 8 спараллеленных линий данных (A,B,C,D,E,F,G,DP) и по 2 линии выбора цвета (зеленый и красный) для каждого.
Хранилище осциллограмм
Хранилище организовано по принципу циклического буфера со «слотами» по 64 КБ на осциллограмму (фиксированный размер).
Обеспечение целостности данных при неожиданном выключении
В EEPROM данные пишутся в двух копиях с добавленной контрольной суммой в конце. Если в момент записи данных прибор будет выключен, то хотя бы одна копия данных останется целостной. Контрольной суммой также дополняется каждый срез данных осциллографа (значения измерений на входах АЦП), таким образом, невалидная контрольная сумма среза будет признаком окончания осциллограммы.
Автоматическая генерация версии ПО
1) Создать файл version.fmt:
#define SVN_REV ($WCREV$)
2) Перед сборкой проекта добавить команду (для System Workbanch):
SubWCRev ${ProjDirPath} ${ProjDirPath}/version.fmt ${ProjDirPath}/version.h
После выполнения этой команды будет создан файл version.h с номером последнего коммита.
Аналогичная утилита есть и для GIT’a: GitWCRev. /version.fmt ./main/version.h
#define GIT_REV ($WCLOGCOUNT$)
Это позволяет однозначно сопоставлять коммит и версию ПО.
Эмулятор
Т.к. разработка прошивки началась до появления первого экземпляра железа, то часть кода начал писать как консольное приложение на ПК.
Преимущества:
— разработка и отладка под ПК проще, чем непосредственно на железе.
— возможность генерации любых входных сигналов.
— возможность отладки клиента на ПК без железа. На ПК ставится драйвер com0com, который создает пару com-портов. На одном из них запускается эмулятор, а на другом подключается клиент.
— способствует красивой архитектуре, т.к. приходится выделять интерфейс аппаратно-зависимых модулей и писать две реализации
Пример кода. Две реализации чтения данных из eeprom.
uint32_t eeprom_read(uint32_t offset, uint8_t * buf, uint32_t len);
ifdef STM32H7
uint32_t eeprom_read(uint32_t offset, uint8_t * buf, uint32_t len)
{
if (diag_isError(ERR_I2C))
return 0;
if (eeprom_wait_ready()) {
HAL_StatusTypeDef status = HAL_I2C_Mem_Read(&I2C_MEM_HANDLE, I2C_MEM_DEV_ADDR, offset, I2C_MEMADD_SIZE_16BIT, buf, len, I2C_MEM_TIMEOUT_MS);
if (status == HAL_OK)
return len;
}
diag_setError(ERR_I2C, true);
return 0;
}
#endif
#ifdef _WIN32
static FILE *fpEeprom = NULL;
#define EMUL_EEPROM_FILE "eeprom.bin"
void checkAndCreateEpromFile() {
if (fpEeprom == NULL) {
fopen_s(&fpEeprom, EMUL_EEPROM_FILE, "rb+");
if (fpEeprom == NULL)
fopen_s(&fpEeprom, EMUL_EEPROM_FILE, "wb+");
fseek(fpEeprom, EEPROM_SIZE, SEEK_SET);
fputc('\0', fpEeprom);
fflush(fpEeprom);
}
}
uint32_t eeprom_read(uint32_t offset, uint8_t * buf, uint32_t len)
{
checkAndCreateEpromFile();
fseek(fpEeprom, offset, SEEK_SET);
return (uint32_t)fread(buf, len, 1, fpEeprom);
}
#endif
Ускорение передачи данных (архивация)
Для увеличения скорости скачивания осциллограмм была реализована их архивация перед отправкой. В качестве архиватора использовалась библиотека uzlib. Распаковка этого формата на C# осуществляется в пару строк кода.
Пример кода. Архивация данных.
#define ARCHIVER_HASH_BITS (12)
uint8_t __RAM_288K archiver_hash_table[sizeof(uzlib_hash_entry_t) * (1 << ARCHIVER_HASH_BITS)];
bool archive(const uint8_t* src, uint32_t src_len, uint8_t* dst, uint32_t dst_len, uint32_t *archive_len)
{
struct uzlib_comp comp = { 0 };
comp.dict_size = 32768;
comp.hash_bits = ARCHIVER_HASH_BITS;
comp.hash_table = (uzlib_hash_entry_t*)&archiver_hash_table[0];
memset((void*)comp.hash_table, 0, sizeof(archiver_hash_table));
comp.out.outbuf = &dst[10]; // skip header 10 bytes
comp.out.outsize = dst_len - 10 - 8; // skip header 10 bytes and tail(crc+len) 8 bytes
comp.out.is_overflow = false;
zlib_start_block(&comp.out);
uzlib_compress(&comp, src, src_len);
zlib_finish_block(&comp.out);
if (comp.out.is_overflow)
comp.out.outlen = 0;
dst[0] = 0x1f;
dst[1] = 0x8b;
dst[2] = 0x08;
dst[3] = 0x00; // FLG
// mtime
dst[4] =
dst[5] =
dst[6] =
dst[7] = 0;
dst[8] = 0x04; // XFL
dst[9] = 0x03; // OS
unsigned crc = ~uzlib_crc32(src, src_len, ~0);
memcpy(&dst[10 + comp.out.outlen], &crc, sizeof(crc));
memcpy(&dst[14 + comp.out.outlen], &src_len, sizeof(src_len));
*archive_len = 18 + comp.out.outlen;
if (comp.out.is_overflow)
return false;
return true;
}
Пример кода. Распаковка данных.
// byte[] res; // сжатые данные
using (var msOut = new MemoryStream())
using (var ms = new MemoryStream(res))
using (var gzip = new GZipStream(ms, CompressionMode.Decompress))
{
int chunk = 4096;
var buffer = new byte[chunk];
int read;
do
{
read = gzip.Read(buffer, 0, chunk);
msOut.Write(buffer, 0, read);
} while (read == chunk);
//msOut.ToArray();// обработать распакованный массив данных
}
Про постоянные изменения в ТЗ
Мем с просторов интернета:
— Но вы же утвердили техническое задание!
— Техническое задание? Мы думали ТЗ — это «Точка зрения», и у нас их несколько.
Пример кода. Обработка клавиатуры.
enum {
IVA_KEY_MASK_NONE,
IVA_KEY_MASK_ENTER = 0x1,
IVA_KEY_MASK_ANY = IVA_KEY_MASK_ENTER,
}IVA_KEY;
uint8_t keyboard_isKeyDown(uint8_t keyMask) {
return ((keyMask & keyStatesMask) == keyMask);
}
Посмотрев такой кусочек кода вы можете подумать зачем он все это нагородил, если в приборе только одна кнопка? В первой версии ТЗ было 5 кнопок и с помощью них планировалось реализовать редактирование уставок непосредственно на приборе:
enum {
IVA_KEY_MASK_NONE = 0,
IVA_KEY_MASK_ENTER = 0x01,
IVA_KEY_MASK_LEFT = 0x02,
IVA_KEY_MASK_RIGHT = 0x04,
IVA_KEY_MASK_UP = 0x08,
IVA_KEY_MASK_DOWN = 0x10,
IVA_KEY_MASK_ANY = IVA_KEY_MASK_ENTER | IVA_KEY_MASK_LEFT | IVA_KEY_MASK_RIGHT | IVA_KEY_MASK_UP | IVA_KEY_MASK_DOWN,
}IVA_KEY;
Так что, если в коде вы обнаружили странность, то не нужно сразу же вспоминать предыдущего программиста нехорошими словами, возможно, на тот момент были причины такой реализации.
Некоторые проблемы при разработке
Закончилась флеш
В микроконтроллере имеется 128 Кб флеш памяти. В какой-то момент отладочная сборка превысила этот объем. Пришлось включать оптимизацию по объему -Os. Если же требовалась отладка на железе, то делалась специальная сборка с отключением некоторых программных модулей (модбас, 101-й).
Ошибка данных по QSPI
Иногда, при чтении данных по qspi, появлялся «лишний» байт. Проблема пропала после увеличения приоритета прерываний qspi.
Ошибка данных в осциллографе
Т.к. данные пересылает DMA, то процессор может их «не увидеть» и прочитать старые данных из кэша. Нужно выполнять валидацию кэша.
Пример кода. Валидация кэша.
// сброс данных из кэша в озу перед записью их в QSPI/DMA
SCB_CleanDCache_by_Addr((uint32_t*)(((uint32_t)&data[0]) & 0xFFFFFFE0), dataSize + 32);
// Обновление данных от ADC/DMA в кэше перед обращением из CPU
SCB_InvalidateDCache_by_Addr((uint32_t*)&s_pAlignedAdcBuffer[0], sizeof(s_pAlignedAdcBuffer));
Проблемы с АЦП(разные показания от включения к включению)
От включения к включению в приборе появлялось разное смещение показаний тока (порядка 10-30 мА). Решение помогли найти коллеги из Компэла в лице Владислава Барсова и Александра Квашина за что им огромное спасибо.
Пример кода. Инициализация АЦП.
// Нужно запомнить параметры калибровки и выставлять их при включении
HAL_ADCEx_Calibration_SetValue (&hadc1, ADC_SINGLE_ENDED, myCalibrationFactor[0]);
HAL_ADCEx_Calibration_SetValue (&hadc1, ADC_DIFFERENTIAL_ENDED, myCalibrationFactor[1]);
HAL_ADCEx_LinearCalibration_SetValue (&hadc1, &myLinearCalib_Buffer[0]);
Засветка индикации
На «пустых» 7-и сегментных индикаторах вместо полного отключения появлялась слабая засветка. Причина в том, что в реальном мире форма сигнала не идеальна, и если вы выполнили код gpio_set_level(0), то это еще не значит, что уровень сигнала сразу же изменился. Устранить засветку удалось добавлением ШИМа к линиям данных.
Ошибка uart в HAL
После возникновения ошибки Over-Run UART переставал работать. Устранить проблему удалось патчем HAL’a:
Пример кода. Патч для HAL’a.
--- if (((isrflags & USART_ISR_ORE) != 0U)
--- && (((cr1its & USART_CR1_RXNEIE_RXFNEIE) != 0U) ||
--- ((cr3its & (USART_CR3_RXFTIE | USART_CR3_EIE)) != 0U)))
+++ if ((isrflags & USART_ISR_ORE) != 0U)
{
__HAL_UART_CLEAR_FLAG(huart, UART_CLEAR_OREF);
Доступ к невыровненным данным
Ошибка проявлялась только на железе в сборке с уровнем оптимизации -Os. Вместо реальных данных клиент по модбас читал нули.
Пример кода. Ошибка чтения невыровненных данных.
float f_value;
uint16_t registerValue;
// Вместо реальных данных в registerValue оказывался 0
//registerValue = ((uint16_t*)&f_value)[(offsetInMaximeterData -
// offsetof(mbreg_Maximeter, primaryValue)) / 2];
// То же самое через memcpy работат корректно
memcpy(& registerValue, ((uint16_t*)&f_value) + (offsetInMaximeterData -
offsetof(mbreg_Maximeter, primaryValue)) / 2, sizeof(uint16_t));
Поиск причин HardFault’ов
Один из инструментов локализации исключений которые я использую — это «точки наблюдения». Разбрасываю по коду точки наблюдения, а после появления исключения подключаюсь отладчиком и смотрю какую точку прошел код.
Пример кода. SET_DEBUG_POINT(__LINE__).
//debug.h
#define USE_DEBUG_POINTS
#ifdef USE_DEBUG_POINTS
//инструмент для поиска места хардфолтов SET_DEBUG_POINT1(__LINE__)
void SET_DEBUG_POINT1(uint32_t val);
void SET_DEBUG_POINT2(uint32_t val);
#else
#define SET_DEBUG_POINT1(...)
#define SET_DEBUG_POINT2(...)
#endif
//debug.c
#ifdef USE_DEBUG_POINTS
volatile uint32_t dbg_point1 = 0;
volatile uint32_t dbg_point2 = 0;
void SET_DEBUG_POINT1(uint32_t val) {
dbg_point1 = val;
}
void SET_DEBUG_POINT2(uint32_t val) {
dbg_point2 = val;
}
#endif
// В нескольких местах в коде:
SET_DEBUG_POINT1(__line__);
Советы новичкам
1) Заглянуть в примеры кода. Для esp32 примеры ставятся вместе с SDK. Для stm32 в хранилище HAL’a STM32CubeMX \STM32Cube_FW_H7_V1.7.0\Projects\NUCLEO-H743ZI\Examples\
2) Гуглить: programming manual <ваш чип>, technical reference manual <ваш чип>, application note <ваш чип>, datasheet <ваш чип>.
3) Если возникли какие-то технические сложности и 2 верхних пункта не помогли, то не следует пренебрегать и обращением в поддержку, а лучше к дистрибьютерам, которые имеют прямой контакт с инженерами компании производителя.
4) Баги бывают не только в вашем коде, но и в HAL’e производителя.
Спасибо за внимание.
AlexFTF
А Вы один написали 38334 строк кода?
moggiozzi Автор
Нет конечно. Примерно половина или чуть больше кода перекочевала из других проектов.
AlexFTF
Уже теплее :) сколько программистов работало над проектом? Дело в том, что из той информации которая есть в заметке, можно сделать следующий вывод: один программист написал за год 38334 строк кода, попутно реализовав при этом 60870-5-101 и ModbusRTU. При этом его постоянно дёргали на другие проекты, да и ТЗ постоянно менялось… Так сколько времени в итоге было потрачено на проект?
moggiozzi Автор
В части прошивки — двое.
Календарный год, точнее в человекочасах не отвечу (не знаю).