Встраиваемые системы и робототехника Сборка прошивки: тулчейн, стартовый код, компоновщик, образ
0%

Сборка прошивки: тулчейн, стартовый код, компоновщик, образ

Сборка прошивки: тулчейн, стартовый код, компоновщик, образ

Первые полгода встраиваемой разработки сборка выглядит как кнопка. Вы нажимаете 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, и подменять один другим нельзя: у них разные библиотеки времени исполнения и разные ожидания от среды.

Ключевое наблюдение из этой схемы: .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 — где она лежит в образе). Единственная секция, у которой они не совпадают, и порождает половину стартового кода.

Как компоновщик раскладывает входные секции по регионам памяти и чем VMA отличается от LMA

Скрипт компоновщика построчно

Это обычный текстовый файл на собственном языке 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 наехал на стек")

Четыре вещи, которые стоит унести из этого файла:

  1. >RAM AT> FLASH — единственная запись, объясняющая, почему глобальная int counter = 42; вообще получает своё значение при включении питания. Значение лежит во флеше, стартовый код копирует его в ОЗУ.
  2. Символы скрипта — это адреса, а не значения. В C они объявляются так, и путать здесь легко:
extern uint32_t _sdata, _edata, _sidata, _sbss, _ebss;

uint32_t *dst = &_sdata;       /* ПРАВИЛЬНО: интересует адрес символа */
uint32_t  n   = _sdata;        /* ОШИБКА: прочитает то, что лежит по этому адресу */
  1. ASSERT и ._check_stack превращают тихую катастрофу в ошибку сборки. Без них проект «влезает» ровно до дня, когда кто-то добавил буфер на 8 КБ, а стек начал затирать .bss — раз в сутки, у одного устройства из двадцати.
  2. Регион переполнен — это лучшая из ошибок. 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 не существует. Пятёрка причин покрывает почти все реальные случаи.

  1. Обращение к float до включения FPU. На Cortex-M4F сопроцессор после сброса выключен; первая же инструкция VLDR даёт UsageFault с битом NOCP. Лечение — в самом начале SystemInit:
SCB->CPACR |= (0xFu << 20);   /* полный доступ к CP10 и CP11 = включить FPU */
__DSB(); __ISB();             /* барьеры: изменение вступает в силу до следующих команд */
  1. Забыт VTOR после загрузчика. Приложение слинковано по 0x08008000, а ядро продолжает искать вектора по 0x08000000 — первое же прерывание уводит на чужой обработчик. Разбор ниже.
  2. Стек не помещается. _estack указывает за пределы ОЗУ или .bss дорос до стека. Симптом — падение при вызове функции с большим локальным буфером, причём «в случайном месте».
  3. Слишком длинная инициализация при включённом сторожевом таймере. IWDG на STM32 после аппаратного включения опциями стартует сам; если между сбросом и первым refresh прошло больше окна, устройство бесконечно перезагружается, а отладчик успевает увидеть только начало.
  4. Пропущено обнуление .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, чтобы разобрать дамп. Четыре меры закрывают вопрос:

  1. Тулчейн зафиксирован и лежит в образе контейнера, а не устанавливается «последним» из пакетного менеджера. Разница между GCC 12 и GCC 13 — это другие адреса, другие оптимизации и другой размер прошивки.
  2. Пути не протекают в бинарник: -ffile-prefix-map=$(PWD)=. убирает домашний каталог сборщика из строк __FILE__ и отладочной информации. Это и воспроизводимость, и минус несколько сотен байт .rodata.
  3. SOURCE_DATE_EPOCH вместо date для меток времени — иначе две сборки одного коммита никогда не совпадут побайтово.
  4. Артефакты релиза.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 до и после. Обычно это первые сэкономленные килобайты в жизни — и первое ощущение, что сборка стала прозрачной.

Источники

Что дальше

Тестирование прошивки: хост, симулятор, стенд и статический анализ — соберём вторую половину инженерной дисциплины: как устроить в коде шов, за который можно взяться тестом, как гонять логику прошивки обычным gcc с санитайзерами за миллисекунды, что реально проверяют Renode и native_sim, как выглядит стенд hardware-in-the-loop в ночном прогоне CI и почему размер прошивки, глубина стека и ток потребления — такие же регрессионные метрики, как упавший тест.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов
Дальше