Сборка прошивки: тулчейн, стартовый код, компоновщик, образ
Первые полгода встраиваемой разработки сборка выглядит как кнопка. Вы нажимаете Build в CubeIDE или в Arduino IDE, откуда-то берётся .bin, кнопка Flash заливает его в плату, светодиод мигает. Устройство работы этой кнопки кажется несущественной деталью — примерно как устройство npm run build для фронтендера.
Иллюзия держится ровно до первого из пяти событий, и хотя бы одно из них случается всегда:
- нужно уместить прошивку в загрузчик и оставить второй слот под обновление — и внезапно программа не стартует, хотя «код тот же»;
- прошивка перестала влезать во флеш, и надо понять, кто съел 40 КБ, — а
.mapна 12 тысяч строк; - программа падает до
main, где ещё нет ни одной вашей строчки; - проект нужно собрать в CI, где нет IDE, нет графической оболочки и нет человека, который «настроил проект год назад»;
- одна и та же прошивка должна собираться под три ревизии платы с разным объёмом памяти и разными пинами.
Здесь и выясняется главное отличие от серверного мира: на сервере компоновщик решает, как связать код с библиотеками; в прошивке он решает, где физически будет лежать каждый байт вашей программы. Никакой операционной системы, которая загрузит образ, разложит секции и настроит виртуальную память, здесь не существует — эту работу вы пишете сами, один раз, в двух файлах: скрипте компоновщика и стартовом коде. Кто их не читал, тот отлаживает вслепую самый неудобный класс ошибок — те, что случаются до первой строчки main.
Эта статья — про путь от .c до образа в чипе. Общая механика трансляции и компоновки на больших машинах разобрана в сборке и линковке — здесь только то, что специфично для голого железа, и то, что чаще всего ломается.
Тулчейн — это не компилятор, а шесть программ
Строка arm-none-eabi-gcc — это драйвер, который последовательно зовёт разные бинарники. Понимать, кто именно ругается, критично: половина «непонятных ошибок сборки» приходит от компоновщика, а люди читают их как ошибки компилятора.
| Программа | Что делает | Типичное сообщение об ошибке |
|---|---|---|
cpp |
препроцессор: #include, макросы, условная компиляция |
fatal error: stm32f4xx.h: No such file |
cc1 |
собственно компилятор C → ассемблер | warning: implicit declaration of function |
as |
ассемблер → объектный файл .o |
Error: selected processor does not support ... |
ar |
архиватор: пачка .o → статическая библиотека .a |
— |
ld |
компоновщик: .o + .a + скрипт → .elf |
region 'RAM' overflowed by 2048 bytes |
objcopy |
вырезает из ELF чистый образ .bin или .hex |
— |
Плюс инструменты анализа, без которых работа превращается в гадание: size, nm, objdump, readelf, addr2line, gdb.
Префикс arm-none-eabi — это тройка (target triple): архитектура arm, производитель none, ОС… тоже отсутствует, а eabi — соглашение о вызовах. Именно отсутствие ОС во второй позиции означает: нет fork, нет файлов, нет exit — и main, из которого вы вернётесь, возвращаться некуда. Для сравнения, arm-linux-gnueabihf — тулчейн для Raspberry Pi под Linux, и подменять один другим нельзя: у них разные библиотеки времени исполнения и разные ожидания от среды.
HAL вендора"] --> CPP["Препроцессор:
-DSTM32F411xE, -I include/"] CPP --> CC["Компилятор:
-mcpu=cortex-m4 -mthumb -Os
-ffunction-sections -fdata-sections"] ASM["startup_stm32f411.s
таблица векторов, Reset_Handler"] --> AS["Ассемблер"] CC --> OBJ["Объектные файлы .o
секции без адресов"] AS --> OBJ LIB["libc_nano.a, libgcc.a,
libFreeRTOS.a"] --> LD LDS["stm32f411.ld
MEMORY + SECTIONS"] --> LD["Компоновщик ld"] OBJ --> LD LD --> ELF["firmware.elf
адреса + символы + DWARF"] LD --> MAP["firmware.map
кто занял каждый байт"] ELF --> SZ["size: влезли или нет"] ELF --> SU["*.su: глубина стека по функциям"] ELF --> BIN["objcopy -O binary
firmware.bin — только байты"] BIN --> HDR["Постобработка:
версия, CRC, подпись"] HDR --> PROG["Программатор или OTA"] ELF --> GDB["gdb и addr2line:
разбор дампов из поля"]
Ключевое наблюдение из этой схемы: .elf и .bin — разные артефакты, и второй получается из первого с потерей информации. В чип уезжает .bin — голые байты без символов. Все инструменты постмортема (разбор HardFault, дампы из поля) требуют именно .elf, причём тот самый, из которого сделан улетевший .bin. Отсюда правило, которое дешевле ввести сразу: .elf, .map и .su каждого релиза хранятся в артефактах CI вечно. Потерянный .elf означает, что аварийный дамп с объекта — это набор шестнадцатеричных чисел без смысла.
Флаги архитектуры: несовпадение не прощается
# Cortex-M4 с аппаратной плавающей точкой одинарной точности
CPUFLAGS = -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard
# Cortex-M0+: FPU нет вообще, попытка задать -mfpu приведёт к ошибке
CPUFLAGS = -mcpu=cortex-m0plus -mthumb -mfloat-abi=soft
# RISC-V, плата на ESP32-C3
CPUFLAGS = -march=rv32imc -mabi=ilp32
Эти флаги обязаны совпадать у всех объектных файлов и всех библиотек, иначе компоновщик выдаст классическое error: ... uses VFP register arguments, output does not. Причина в том, что -mfloat-abi=hard меняет соглашение о вызовах: float передаётся в регистрах FPU, а не в обычных. Собранная кем-то давно библиотека с softfp физически несовместима с вашим кодом, даже если оба «под Cortex-M4». Именно поэтому в GCC для ARM лежит multilib — по нескольку копий libc и libgcc под каждое сочетание; посмотреть, какую выбрал компилятор, можно так:
arm-none-eabi-gcc -mcpu=cortex-m4 -mfpu=fpv4-sp-d16 -mfloat-abi=hard -print-multi-directory
# thumb/v7e-m+fp/hard <- вот эта ветка библиотек и приедет в вашу прошивку
arm-none-eabi-gcc -print-file-name=libc.a # полный путь к выбранной libc
Родственный сюжет — набор инструкций и то, что компилятор из него выжимает, — разобран в треке про железо: ISA и RISC против CISC.
Библиотека времени исполнения: newlib, nano и picolibc
printf на микроконтроллере приезжает не из воздуха. Стандартная поставка GCC для ARM несёт newlib — реализацию libc для систем без ОС. У неё три варианта:
- newlib — полная, с потоками, локалями и
printf, умеющим всё. Занимает десятки килобайт. - newlib-nano (
--specs=nano.specs) — урезанная:printfбез плавающей точки, упрощённыйmalloc. Экономия обычно 6–12 КБ флеша, включается одним флагом. - picolibc — современная альтернатива (Кит Паккард), выросшая из newlib и AVR libc, ещё компактнее и с вменяемой моделью TLS. Стандарт в Zephyr.
Библиотека, не знающая об ОС, всё равно хочет системных вызовов: _write, _read, _sbrk, _close. Компоновщик потребует их у вас — это те самые «undefined reference to _sbrk». Заглушки пишутся руками:
// syscalls.c — минимальный набор заглушек для newlib на голом железе
extern char _end, _estack; /* конец .bss и вершина стека: символы из скрипта */
/* Куча растёт вверх от _end навстречу стеку. Проверка обязательна:
без неё malloc спокойно выдаст память, которую вот-вот перезапишет стек. */
void *_sbrk(ptrdiff_t incr)
{
static char *heap_end = &_end;
char *prev = heap_end;
if (heap_end + incr > (char *)&_estack - 1024) { /* 1 КБ запаса под стек */
errno = ENOMEM;
return (void *)-1;
}
heap_end += incr;
return prev;
}
/* printf в конечном счёте зовёт _write. Уводим его в RTT или UART-DMA,
а не в блокирующую отправку: почему — в главе про отладку. */
int _write(int fd, const char *buf, int len) { (void)fd; trace_write(buf, (size_t)len); return len; }
int _close(int fd) { (void)fd; return -1; }
int _isatty(int fd) { (void)fd; return 1; }
Самая частая ошибка новичка здесь — оставить _sbrk без проверки границы. Тогда куча и стек молча встречаются в середине ОЗУ, и устройство начинает «иногда перезагружаться». Почему динамическая память во встраиваемом коде вообще нежелательна, разобрано в главе про C без динамики.
Секции: единица, которой оперирует компоновщик
Компилятор не размещает код по адресам. Он складывает его в секции — именованные куски с атрибутами. Базовый набор:
| Секция | Что внутри | Где живёт при работе | Занимает флеш |
|---|---|---|---|
.text |
машинный код | Flash | да |
.rodata |
константы, строки, таблицы | Flash | да |
.data |
инициализированные ненулевыми значениями переменные | RAM | да (копия исходных значений) |
.bss |
глобальные переменные, инициализированные нулём | RAM | нет |
.noinit |
то, что должно пережить программный сброс | RAM | нет |
.isr_vector |
таблица векторов прерываний | Flash, по фиксированному адресу | да |
Флаги -ffunction-sections -fdata-sections заставляют компилятор класть каждую функцию и каждую переменную в отдельную секцию — .text.i2c_read, .bss.rx_buffer. Само по себе это ничего не даёт; смысл появляется в паре с -Wl,--gc-sections, которая выбрасывает из образа все секции, на которые никто не ссылается. Экономия на реальных проектах с вендорским HAL — десятки процентов флеша.
Ловушка ровно одна, зато злая: таблица векторов не нужна никому по ссылкам — на неё смотрит только железо. Без KEEP в скрипте компоновщика --gc-sections её честно удалит, и вы получите чип, который не стартует.
arm-none-eabi-readelf -S build/firmware.elf
# [Nr] Name Type Addr Off Size Flg
# [ 1] .isr_vector PROGBITS 08000000 010000 000198 A
# [ 2] .text PROGBITS 08000198 010198 0072a4 AX
# [ 4] .data PROGBITS 20000000 020000 000114 WA <- Addr в ОЗУ, Off во флеше
# [ 5] .bss NOBITS 20000114 020114 0031a8 WA <- NOBITS: в файле места не занимает
Обратите внимание на строку .data: её рабочий адрес — в ОЗУ, а физически она лежит во флеше. Это и есть различие VMA (Virtual Memory Address — где секция будет работать) и LMA (Load Memory Address — где она лежит в образе). Единственная секция, у которой они не совпадают, и порождает половину стартового кода.
Скрипт компоновщика построчно
Это обычный текстовый файл на собственном языке ld. Разберём рабочий скрипт для STM32F411 (512 КБ флеша, 128 КБ ОЗУ) — он же почти без изменений подойдёт любому Cortex-M.
ENTRY(Reset_Handler) /* точка входа: записывается в заголовок ELF, нужна отладчику */
MEMORY
{
/* Регионы: имя, права, начальный адрес, длина. Всё это берётся из даташита. */
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
_min_heap = 0; /* кучи нет — см. главу про C без динамики */
_min_stack = 4K; /* столько обязаны оставить стеку, иначе ошибка сборки */
SECTIONS
{
.isr_vector :
{
. = ALIGN(4);
KEEP(*(.isr_vector)) /* KEEP: не отдавать --gc-sections, ссылок на неё нет */
. = ALIGN(4);
} >FLASH /* и VMA, и LMA во флеше */
.text :
{
. = ALIGN(4);
*(.text) *(.text*) /* .text.main, .text.i2c_read — от -ffunction-sections */
KEEP(*(.init)) KEEP(*(.fini))
. = ALIGN(4);
_etext = .; /* символ: адрес конца кода */
} >FLASH
.rodata : { . = ALIGN(4); *(.rodata) *(.rodata*) . = ALIGN(4); } >FLASH
/* Таблицы конструкторов C++ и функций с __attribute__((constructor)).
Их зовёт __libc_init_array до main. Забыли KEEP — глобальные объекты
останутся неинициализированными, и это очень весёлая отладка. */
.init_array : { . = ALIGN(4); PROVIDE_HIDDEN(__init_array_start = .);
KEEP(*(SORT(.init_array.*))) KEEP(*(.init_array*));
PROVIDE_HIDDEN(__init_array_end = .); } >FLASH
/* Главный трюк файла: >RAM AT> FLASH.
VMA секции — в ОЗУ, LMA — во флеше. Стартовый код обязан скопировать. */
.data :
{
. = ALIGN(4);
_sdata = .; /* начало .data в ОЗУ */
*(.data) *(.data*)
. = ALIGN(4);
_edata = .; /* конец .data в ОЗУ */
} >RAM AT> FLASH
_sidata = LOADADDR(.data); /* адрес образа .data во флеше — источник копии */
.bss (NOLOAD) :
{
. = ALIGN(4);
_sbss = .;
*(.bss) *(.bss*) *(COMMON)
. = ALIGN(4);
_ebss = .;
} >RAM
/* Переменные, которые обязаны пережить программный сброс: причина сброса,
счётчик перезагрузок, кусок чёрного ящика. Стартовый код их НЕ трогает. */
.noinit (NOLOAD) : { . = ALIGN(4); *(.noinit) *(.noinit*) . = ALIGN(4); } >RAM
/* Проверка вместо надежды: не осталось 4 КБ на стек — ошибка сборки. */
._check_stack : { . = ALIGN(8); . = . + _min_heap + _min_stack; . = ALIGN(8); } >RAM
_estack = ORIGIN(RAM) + LENGTH(RAM); /* вершина стека: верх ОЗУ */
/DISCARD/ : { *(.ARM.exidx*) *(.gnu.linkonce.armexidx.*) } /* таблицы раскрутки C++ */
}
ASSERT(_ebss < _estack - _min_stack, "ОЗУ закончилось: .bss наехал на стек")
Четыре вещи, которые стоит унести из этого файла:
>RAM AT> FLASH— единственная запись, объясняющая, почему глобальнаяint counter = 42;вообще получает своё значение при включении питания. Значение лежит во флеше, стартовый код копирует его в ОЗУ.- Символы скрипта — это адреса, а не значения. В C они объявляются так, и путать здесь легко:
extern uint32_t _sdata, _edata, _sidata, _sbss, _ebss;
uint32_t *dst = &_sdata; /* ПРАВИЛЬНО: интересует адрес символа */
uint32_t n = _sdata; /* ОШИБКА: прочитает то, что лежит по этому адресу */
ASSERTи._check_stackпревращают тихую катастрофу в ошибку сборки. Без них проект «влезает» ровно до дня, когда кто-то добавил буфер на 8 КБ, а стек начал затирать.bss— раз в сутки, у одного устройства из двадцати.- Регион переполнен — это лучшая из ошибок.
region 'RAM' overflowed by 2048 bytesна сборке стоит десять минут; та же проблема, найденная на плате, — три дня.
Стартовый код: путь от вектора сброса до main
При сбросе Cortex-M делает ровно две вещи автоматически, и обе — по фиксированным адресам: читает первое слово таблицы векторов в указатель стека MSP и второе слово — в счётчик команд PC. Всё остальное — ваш код. Таблица векторов в C выглядит так:
/* Массив указателей, положенный компоновщиком по адресу 0x08000000.
used — чтобы не выбросил LTO, section — чтобы попал в KEEP-секцию. */
extern uint32_t _estack;
void Reset_Handler(void);
void Default_Handler(void);
/* Слабые символы: любой обработчик переопределяется в своём файле,
не трогая startup. Не определили — сработает заглушка. */
void NMI_Handler(void) __attribute__((weak, alias("Default_Handler")));
void HardFault_Handler(void) __attribute__((weak, alias("Default_Handler")));
void SysTick_Handler(void) __attribute__((weak, alias("Default_Handler")));
void TIM2_IRQHandler(void) __attribute__((weak, alias("Default_Handler")));
__attribute__((section(".isr_vector"), used))
void (* const vector_table[])(void) = {
(void (*)(void))&_estack, /* [0] начальная вершина стека */
Reset_Handler, /* [1] точка входа */
NMI_Handler,
HardFault_Handler,
/* ... дальше системные исключения и все прерывания периферии по номерам ... */
SysTick_Handler,
TIM2_IRQHandler,
};
void Default_Handler(void) { __asm volatile("bkpt #0"); for (;;) { } }
Дальше — сам Reset_Handler. Его можно писать на ассемблере, но на C он честнее и читается лучше:
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss;
extern void SystemInit(void);
extern void __libc_init_array(void);
extern int main(void);
__attribute__((naked, noreturn)) void Reset_Handler(void)
{
/* 1. Стек уже установлен железом из слова 0. Первым делом — тактирование,
FPU и VTOR: до этого нельзя ни считать float, ни разрешать прерывания. */
SystemInit();
/* 2. Копируем .data из флеша (LMA) в ОЗУ (VMA). */
for (uint32_t *src = &_sidata, *dst = &_sdata; dst < &_edata; ) *dst++ = *src++;
/* 3. Обнуляем .bss. Пока это не сделано, ЛЮБАЯ глобальная переменная —
мусор, оставшийся в ОЗУ от прошлой жизни. */
for (uint32_t *p = &_sbss; p < &_ebss; ) *p++ = 0u;
/* 4. Конструкторы глобальных объектов C++ и функции-constructor. */
__libc_init_array();
(void)main();
/* main вернулся. Возвращаться некуда: это конец света, не «выход». */
for (;;) { }
}
Пять способов упасть до main
Это самый неприятный класс ошибок: отладчик показывает HardFault где-то в недрах, ваших строк в стеке нет, printf не существует. Пятёрка причин покрывает почти все реальные случаи.
- Обращение к
floatдо включения FPU. На Cortex-M4F сопроцессор после сброса выключен; первая же инструкцияVLDRдаётUsageFaultс битомNOCP. Лечение — в самом началеSystemInit:
SCB->CPACR |= (0xFu << 20); /* полный доступ к CP10 и CP11 = включить FPU */
__DSB(); __ISB(); /* барьеры: изменение вступает в силу до следующих команд */
- Забыт
VTORпосле загрузчика. Приложение слинковано по0x08008000, а ядро продолжает искать вектора по0x08000000— первое же прерывание уводит на чужой обработчик. Разбор ниже. - Стек не помещается.
_estackуказывает за пределы ОЗУ или.bssдорос до стека. Симптом — падение при вызове функции с большим локальным буфером, причём «в случайном месте». - Слишком длинная инициализация при включённом сторожевом таймере.
IWDGна STM32 после аппаратного включения опциями стартует сам; если между сбросом и первымrefreshпрошло больше окна, устройство бесконечно перезагружается, а отладчик успевает увидеть только начало. - Пропущено обнуление
.bssили копирование.data— обычно в самописном стартовом коде. Классический симптом: «после подачи питания работает, после программного сброса — нет» (или наоборот), потому что ОЗУ хранит остатки предыдущего запуска.
Отдельная тонкость про naked и стек: атрибут naked запрещает компилятору вставлять пролог функции — это нужно, потому что на момент входа мы ещё не имеем права предполагать корректность окружения. Соответственно, локальные переменные в Reset_Handler использовать нельзя, а тело безопаснее писать так, как выше, — простыми циклами без вызовов до SystemInit.
Map-файл: кто съел мою память
-Wl,-Map=build/firmware.map -Wl,--cref даёт полный отчёт компоновщика. Он выглядит пугающе, но читается за пять минут, если знать четыре его раздела: Archive member included to satisfy reference (кто и зачем притащил библиотеку), Discarded input sections (что выбросил --gc-sections), Memory Configuration (регионы), Linker script and memory map (построчная раскладка).
Практический маршрут поиска жира занимает три команды:
# 1. Общая картина: сколько занято и сколько осталось
arm-none-eabi-size -A build/firmware.elf
arm-none-eabi-gcc ... -Wl,--print-memory-usage # печатает проценты прямо в лог сборки
# Memory region Used Size Region Size %age Used
# FLASH 31284 B 512 KB 5.97%
# RAM 13176 B 128 KB 10.05%
# 2. Кто именно жирный: топ символов по размеру
arm-none-eabi-nm --print-size --size-sort --radix=d build/firmware.elf | tail -20
# 00000652 _printf_float <- вот эти 1618 байт вы не заказывали
# 00001102 _dtoa_r
# 3. Кто их притащил: ищем в map-файле причину включения архива
grep -n -A2 "printf_float" build/firmware.map | head
Инструменты поудобнее, если проект живёт долго: puncover (pip install puncover) поднимает локальный веб-интерфейс с деревом символов, размерами и глубиной стека; bloaty от Google (bloaty --domain=vm build/firmware.elf -d compileunits) показывает размер в разрезе единиц трансляции и умеет сравнивать две сборки — незаменимо для вопроса «что выросло за спринт».
Таблица «что сколько стоит»
Порядки величин для Cortex-M с GCC и newlib-nano. Числа плавают от версии к версии, но соотношения устойчивы:
| Что | Цена | Чем заменить |
|---|---|---|
printf с %f |
+6…12 КБ флеша | целочисленный вывод, фиксированная точка |
malloc/free |
+2…4 КБ и недетерминированное время | статические пулы |
snprintf из полной newlib |
+8 КБ | --specs=nano.specs |
| исключения и RTTI в C++ | +20…60 КБ | -fno-exceptions -fno-rtti |
строки в assert |
по 40–80 байт на каждый | свой assert с номером строки и адресом |
double по недосмотру |
вдвое медленнее и +2 КБ | 1.0f/3.0f, -Wdouble-promotion |
таблицы раскрутки .ARM.exidx |
1–3 КБ | /DISCARD/ в скрипте |
Строка float x = 1.0 / 3; — самая частая случайная трата: литерал 1.0 имеет тип double, вся операция выполняется в двойной точности программно (на M4F аппаратный FPU только одинарный), и результат обрезается. Флаг -Wdouble-promotion ловит это на компиляции; про то, почему плавающая точка на микроконтроллере вообще под подозрением, — в главе про C для встраиваемых систем.
Оптимизация: что включать и чем за это платят
CFLAGS_RELEASE = -Os -flto -ffunction-sections -fdata-sections -fno-common \
-fno-exceptions -fstack-usage -ffile-prefix-map=$(PWD)=.
LDFLAGS = -Wl,--gc-sections -Wl,-Map=$@.map -Wl,--print-memory-usage \
-specs=nano.specs -nostartfiles -T linker/stm32f411.ld
-Osпротив-O2. Для прошивки размер обычно важнее пары процентов скорости, а на чипах, где флеш медленнее ядра и стоят такты ожидания, меньший код нередко оказывается ещё и быстрее. Отладочная сборка —-Og -g3: почти не мешает пошаговому проходу.-fltoдаёт 5–20 % экономии, но приносит две ловушки. Первая: слабые обработчики прерываний могут быть выброшены или переопределены неожиданным образом — спасает__attribute__((used))на таблице векторов. Вторая: LTO агрессивно доверяет отсутствию алиасинга, и код, который «работал» на неопределённом поведении, начинает падать. Это не баг компилятора — это неопределённое поведение, которое до сих пор просто везло. Практика: LTO включать, но релизную прошивку с ним прогонять через полный набор тестов, а не «собрали и залили».- Критичный код в ОЗУ. На чипах с медленным флешем (или при работе от QSPI) обработчик прерывания выгодно исполнять из ОЗУ. Это ещё одна секция с раздельными VMA и LMA:
__attribute__((section(".RamFunc"), noinline))
void fast_isr_body(void) { /* исполняется из ОЗУ: нет тактов ожидания флеша */ }
/* и в скрипте компоновщика, рядом с .data:
.RamFunc : { . = ALIGN(4); *(.RamFunc*) . = ALIGN(4); } >RAM AT> FLASH */
Копируется она тем же стартовым кодом, что и .data, — просто ещё одним циклом. На Cortex-M7 та же логика применяется к ITCM/DTCM, а тонкости кэшей и иерархии памяти — в главе подсистема памяти.
- Глубина стека.
-fstack-usageкладёт рядом с каждым.oфайл.suсо строчками видаmain.c:42:6:process_frame 512 static. Сложив их по графу вызовов, получаем статическую оценку худшего случая — именно её нужно сравнивать с размером стека, а не «на глаз».
cat build/*.su | sort -t$'\t' -k2 -n | tail -5 # самые прожорливые функции
# drivers/lcd.c:88:6:draw_screen 1088 static
Динамическая проверка — заливка стека узором 0xA5A5A5A5 в стартовом коде и периодический поиск верхней нетронутой границы (watermark). Для задач RTOS то же самое делает uxTaskGetStackHighWaterMark — см. главу про RTOS. Обе метрики стоит гнать в CI как обычный тест: рост стека на 30 % — такой же повод для разговора, как падение покрытия.
Смещение под загрузчик и VTOR
Как только появляется обновление по воздуху, флеш делится минимум на две части: загрузчик и приложение. Приложение перестаёт начинаться с нуля, и в сборке меняются три вещи, причём забыть можно любую.
/* linker/app.ld — приложение живёт со смещением 32 КБ */
MEMORY { FLASH (rx) : ORIGIN = 0x08008000, LENGTH = 480K /* 32 КБ отдано загрузчику */
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K }
/* и в SystemInit приложения — сказать ядру, где теперь таблица векторов:
SCB->VTOR = 0x08008000u; адрес обязан быть выровнен по размеру таблицы */
Три классические ошибки этого перехода: не сброшены источники прерываний загрузчика (первое же прерывание уйдёт в приложение, которое к нему не готово); не переустановлен MSP (приложение продолжает работать на стеке загрузчика); VTOR установлен по невыровненному адресу. Само устройство слотов, подписи и отката подробно разобрано в главе про отладку и производство, а криптографическая часть — в прикладной криптографии.
Заголовок образа: версия, которую можно прочитать с устройства
Правило, экономящее дни в поле: устройство обязано уметь ответить, какая прошивка в нём стоит, и ответ должен совпадать с коммитом в репозитории. Делается это заголовком по фиксированному адресу.
/* fw_header.c — кладётся сразу после таблицы векторов, адрес известен заранее */
typedef struct {
uint32_t magic; /* 'F','W','H','1' — по нему заголовок ищут снаружи */
uint32_t version; /* 0x00_01_04_00 = 1.4.0, из -DFW_VERSION */
uint32_t build_epoch; /* время сборки */
char git_hash[16]; /* git describe --always --dirty */
uint32_t image_size; /* заполняется постобработкой */
uint32_t crc32; /* считается по образу без этого поля */
} fw_header_t;
__attribute__((section(".fw_header"), used))
const fw_header_t g_fw_header = { .magic = 0x31485746u, .version = FW_VERSION,
.build_epoch = FW_BUILD_EPOCH, .git_hash = FW_GIT_HASH };
# Makefile: версия приезжает из git, а не из руками правленого заголовка
FW_GIT_HASH := $(shell git describe --always --dirty --tags)
CFLAGS += -DFW_GIT_HASH=\"$(FW_GIT_HASH)\" -DFW_BUILD_EPOCH=$(shell date +%s)
build/firmware.bin: build/firmware.elf
arm-none-eabi-objcopy -O binary $< $@
python3 tools/stamp_image.py $@ # дописать длину и CRC32 в заголовок
Для проектов на MCUboot ту же работу делает imgtool sign, добавляющий заголовок TLV и подпись. Отдельно стоит помнить: сборка должна падать, если рабочее дерево грязное, а собирается релиз, — иначе через полгода никто не восстановит, что именно уехало на устройства. Эта дисциплина — часть общей темы защиты цепочки поставок.
Системы сборки: чем это описывать
Практический разбор:
- Makefile вручную — годится для учебного проекта и для понимания. На проекте из двухсот файлов превращается в непереносимую рукопись, которую боится трогать вся команда.
- CMake + Ninja — фактический стандарт для собственных прошивок на C/C++. Кросс-компиляция описывается toolchain-файлом, IDE и
clangdпонимают проект черезcompile_commands.json. - Zephyr + west — когда нужна переносимость между чипами. Плата описывается devicetree, опции — Kconfig, и одна и та же прошивка собирается под nRF52 и STM32 сменой одного параметра. Цена — недели на освоение экосистемы.
- ESP-IDF — единственный вменяемый путь для ESP32: своя обёртка над CMake плюс
menuconfig. - PlatformIO — быстрый старт и огромная библиотека плат; за скорость платите слабым контролем над флагами.
- Rust:
cargo+probe-rs— сборка и прошивка одной командой из коробки,.cargo/config.tomlвместо трёх файлов. Подробности инструментария — в главе инструментарий Rust.
# cmake/arm-none-eabi.cmake — toolchain-файл
set(CMAKE_SYSTEM_NAME Generic) # обязательно: иначе CMake ищет ОС на цели
set(CMAKE_C_COMPILER arm-none-eabi-gcc)
set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) # пробный бинарник линковать нечем
# CMakeLists.txt — целевая часть
add_executable(firmware src/main.c src/startup.c src/syscalls.c)
target_compile_options(firmware PRIVATE -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16
-mfloat-abi=hard -Os -Wall -Wextra -Wconversion -Wdouble-promotion
-ffunction-sections -fdata-sections -fstack-usage)
target_link_options(firmware PRIVATE -T${CMAKE_SOURCE_DIR}/linker/stm32f411.ld
-Wl,--gc-sections -Wl,-Map=firmware.map -Wl,--print-memory-usage
--specs=nano.specs -nostartfiles)
add_custom_command(TARGET firmware POST_BUILD
COMMAND arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
COMMAND arm-none-eabi-size firmware.elf)
Воспроизводимость: сборка, которую можно повторить через два года
Прошивка живёт в изделии дольше, чем версия компилятора — в вашем ноутбуке. Через два года придёт рекламация с устройства с версией 1.4.0, и вам понадобится тот же .elf, чтобы разобрать дамп. Четыре меры закрывают вопрос:
- Тулчейн зафиксирован и лежит в образе контейнера, а не устанавливается «последним» из пакетного менеджера. Разница между GCC 12 и GCC 13 — это другие адреса, другие оптимизации и другой размер прошивки.
- Пути не протекают в бинарник:
-ffile-prefix-map=$(PWD)=.убирает домашний каталог сборщика из строк__FILE__и отладочной информации. Это и воспроизводимость, и минус несколько сотен байт.rodata. SOURCE_DATE_EPOCHвместоdateдля меток времени — иначе две сборки одного коммита никогда не совпадут побайтово.- Артефакты релиза —
.elf,.map, все.suи распечаткаsize— кладутся в хранилище и переживают все ротации CI.
# Проверка воспроизводимости в CI: собрать дважды и сравнить
make clean && make -j && sha256sum build/firmware.bin > /tmp/a
make clean && make -j && sha256sum build/firmware.bin > /tmp/b
diff /tmp/a /tmp/b || { echo "Сборка невоспроизводима: ищите дату или путь в образе"; exit 1; }
# Адрес из аварийного дампа обратно в строку исходника
arm-none-eabi-addr2line -e artifacts/1.4.0/firmware.elf -f -C 0x0800a3f2
# process_frame
# /src/protocol/frame.c:118
Организационная часть — какие шаги и в каком порядке гонять на каждый коммит — в главах основы CI и безопасность в пайплайне; специфика прошивочного пайплайна и того, как в него встроить железо, — в следующей главе трека.
Типичные ошибки
- Собирать релиз из IDE на своей машине. Через полгода никто не воспроизведёт сборку, а разбирать дампы с поля будет нечем.
- Не читать
.mapдо дня, когда прошивка не влезла. Проверка размера — обычный шаг сборки, а не аварийная процедура. - Считать, что «влезло по
size» означает «работает». Стек и куча в этот отчёт не входят; ОЗУ кончается в поле, а не на сборке. --gc-sectionsбезKEEPна таблице векторов — чип не стартует, и причина совершенно неочевидна.- Копировать чужой
.ldпод другой чип. Разные объёмы флеша и ОЗУ, разные базовые адреса — прошивка либо не влезет, либо будет писать в несуществующую память. - Использовать символы компоновщика как значения (
_sdataвместо&_sdata) — тихая порча памяти при старте. - Забыть
VTORи переустановкуMSPпри переходе из загрузчика — приложение стартует, но умирает на первом прерывании. - Оставить
_sbrkбез проверки границы — куча дорастает до стека, устройство «иногда перезагружается». - Включить LTO и не прогнать полный набор тестов — код, живший на неопределённом поведении, ломается ровно в релизной сборке.
- Не хранить
.elfрелиза — единственная ошибка из списка, которую нельзя исправить задним числом.
Мини-итог
Сборка прошивки отличается от серверной тремя вещами, и все три сводятся к одному: никто, кроме вас, не решает, где лежит ваш код.
- Скрипт компоновщика описывает физическую память чипа: регионы, секции, символы границ и проверки. Ключевой механизм — раздельные VMA и LMA у
.data: секция работает в ОЗУ, а хранится во флеше. - Стартовый код — это те двадцать строк, которые превращают «питание подано» в «
mainвызван»: вершина стека и вектор сброса из железа,SystemInitс тактированием и FPU, копия.data, обнуление.bss, конструкторы,main. Ошибки на этом отрезке дают самый неудобный класс падений — до появления первой вашей строчки. - Артефакты сборки — часть продукта.
.mapотвечает на вопрос «кто съел память»,.su— «хватит ли стека»,.elf— «что означает этот адрес в дампе с объекта через два года». Всё это стоит одной строчки в CI и не стоит ничего, если завести сразу.
Практическое задание на вечер: возьмите любой свой проект, добавьте -Wl,-Map=firmware.map -Wl,--print-memory-usage -fstack-usage, найдите пять самых крупных символов через nm --size-sort и объясните каждый. Затем включите --specs=nano.specs и сравните size до и после. Обычно это первые сэкономленные килобайты в жизни — и первое ощущение, что сборка стала прозрачной.
Источники
- GNU. Документация
ld, раздел о скриптах компоновщика — https://sourceware.org/binutils/docs/ld/Scripts.html - GCC. Опции для ARM — https://gcc.gnu.org/onlinedocs/gcc/ARM-Options.html ; опции оптимизации — https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
- Arm. GNU Toolchain и его сборки — https://developer.arm.com/Tools%20and%20Software/GNU%20Toolchain
- Arm. Cortex-M4 Devices Generic User Guide, раздел про вектор сброса и
VTOR— https://developer.arm.com/documentation/dui0553/latest/ - Memfault Interrupt. «Zero to main(): Bare metal C» — https://interrupt.memfault.com/blog/zero-to-main-1 ; «How to Write Linker Scripts for Firmware» — https://interrupt.memfault.com/blog/how-to-write-linker-scripts-for-firmware
- Memfault Interrupt. «Code Size Optimization: GCC Compiler Flags» — https://interrupt.memfault.com/blog/code-size-optimization-gcc-flags ; «Measuring Stack Usage in Embedded C» — https://interrupt.memfault.com/blog/measuring-stack-usage-in-embedded-c
- newlib — https://sourceware.org/newlib/ ; picolibc — https://github.com/picolibc/picolibc
- Zephyr. Система сборки, Kconfig и devicetree — https://docs.zephyrproject.org/latest/build/index.html
- Espressif. Система сборки ESP-IDF — https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/build-system.html
- CMake. Кросс-компиляция и toolchain-файлы — https://cmake.org/cmake/help/latest/manual/cmake-toolchains.7.html
- bloaty, анализ размера бинарников — https://github.com/google/bloaty ; puncover — https://github.com/HBehrens/puncover
- MCUboot и
imgtool— https://docs.mcuboot.com/imgtool.html - Reproducible Builds, общий свод практик — https://reproducible-builds.org/docs/
Что дальше
Тестирование прошивки: хост, симулятор, стенд и статический анализ — соберём вторую половину инженерной дисциплины: как устроить в коде шов, за который можно взяться тестом, как гонять логику прошивки обычным gcc с санитайзерами за миллисекунды, что реально проверяют Renode и native_sim, как выглядит стенд hardware-in-the-loop в ночном прогоне CI и почему размер прошивки, глубина стека и ток потребления — такие же регрессионные метрики, как упавший тест.