110. CI/CD для прошивки: сборка в контейнере, тесты, артефакты
«У меня собирается» — самая дорогая фраза в разработке прошивок. Собирается на одном компьютере, с одной версией компилятора, из дерева, где что-то не сохранено. Конвейер убирает человека из этой цепочки: сборка идёт в контейнере с закреплёнными версиями, тесты запускаются сами, а на выходе лежит образ с версией из тега и контрольной суммой. Раздел не про кнопки конкретного сервиса, а про устройство: что должен делать конвейер, почему повторяемость важнее скорости и что обязано останавливать выпуск.
Опыты — настоящий конвейер в контейнере: arm-none-eabi-gcc 12.2.1 собирает прошивку для F103 и F407, тот же исходник проверяется тестами на компьютере, а артефакты складываются с манифестом.
Как это называется в документации
Оригинал Перевод continuous integration (CI) непрерывная сборка: каждый коммит собирается и проверяется continuous delivery (CD) непрерывная поставка: готовый образ после успешной проверки pipeline, stage, job конвейер, этап, задание runner, agent исполнитель: машина, где идёт сборка artifact артефакт: файл, который конвейер отдаёт наружу build matrix матрица: одна сборка на несколько плат или настроек reproducible build повторяемая сборка: тот же вход — тот же байт в байт образ pinned version закреплённая версия инструмента red build красная сборка: конвейер упал manifest, checksum манифест, контрольная сумма cache кэш: сохранённые между запусками файлы
110.1 Что чинит конвейер
Три беды, знакомые любому, кто выпускал прошивку не один раз.
Собралось не тем. У инженера компилятор 2019 года, у коллеги — 2024; образы разные, ошибки разные. Лечится закреплённой версией в контейнере.
Собралось не из того. В дерево забыли внести правку, или собрали
из изменённого рабочего каталога. Лечится сборкой из чистого клона
и версией из git describe (раздел 109): -dirty в имени — сигнал,
что образ невоспроизводим.
Проверить забыли. Тесты есть, но их запускают «когда вспомнят». Лечится тем, что конвейер запускает их сам и не отдаёт артефакт, если хоть один упал.
110.2 Этапы
Порядок один и тот же почти везде: взять исходники по коммиту → собрать → проверить → упаковать. Каждый этап либо проходит целиком, либо валит конвейер. Дальше добавляются ветвления: выкладка только с основной ветки, подпись образа только на теге, ночная сборка с медленными тестами.
Тонкость для прошивки: этап «собрать» даёт не программу, которую можно запустить на том же компьютере, а образ для другого процессора. Поэтому проверка расщепляется: часть кода тестируется на компьютере (логика, разбор пакетов, контрольные суммы), а то, что без платы не проверить, остаётся на стенд и на наблюдение за приборами в работе (раздел 112).
110.3 Сборка в контейнере с закреплёнными версиями
Образ сборки описан файлом и собирается из него же — это и есть «машина сборки, которую можно повторить»:
FROM debian:12-slim
RUN apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
gcc-arm-none-eabi=15:12.2.rel1-1 \
libnewlib-arm-none-eabi \
build-essential \
git \
ca-certificates \
Версия компилятора указана явно, а не «последняя»: иначе однажды утром
обновление молча изменит размеры и поведение образа. В прогоне это видно:
arm-none-eabi-gcc 12.2.1. Но строка с версией работает, пока этот пакет
лежит на зеркале: при обновлениях дистрибутива старые версии убирают, и
через год сборка образа может упасть, а debian:12-slim под тем же именем
к тому времени будет другим образом. Чтобы образ сборки повторялся и через
год, базовый образ берут по хэшу (debian:12-slim@sha256:…), пакеты — из
архива снимков Debian на нужную дату, а готовый образ сборки хранят в своём
реестре и ссылаются на него тоже по хэшу.
110.4 Версия берётся из истории, а не из головы
Первое, что делает конвейер, — узнаёт, что он собирает:
VERSION=$(git describe --tags --dirty)
echo "version=$VERSION"
printf '#ifndef VERSION_H\n#define VERSION_H\n#define FW_VERSION "%s"\n#endif\n' "$VERSION" > src/version.h
Полученная строка — v2.0.0 в опыте — попадает и в прошивку (макросом),
и в имя артефакта, и в манифест. Дальше прибор сам отвечает, что в нём
прошито, а склад по имени файла знает, что он выдаёт.
110.5 Матрица: две платы за один прогон
Одна и та же прошивка собирается под F103 и F407 подряд — меняются только ключи ядра и карта памяти:
build f103 "-mcpu=cortex-m3 -mthumb" f103.ld
build f407 "-mcpu=cortex-m4 -mthumb -mfloat-abi=soft" f407.ld
-mfloat-abi=soft здесь потому, что в опыте нет плавающей точки и стартовый
код не включает FPU. В настоящей прошивке F407 ставят -mfpu=fpv4-sp-d16
-mfloat-abi=hard и включают FPU при старте, иначе блок FPU простаивает.
Обе сборки дали образ 160 байт, но содержимое разное — код собран под разные ядра. Это тоже проверка: если одна из плат перестала собираться, конвейер краснеет сразу, а не через месяц, когда до неё дошли руки.
110.6 Тесты: тот же исходник на компьютере
Кусок прошивки, не привязанный к регистрам, собирается обычным gcc и проверяется на компьютере за доли секунды. Контрольная сумма кадра — как раз такой кусок:
uint16_t crc16(const uint8_t *p, unsigned n)
{
uint16_t crc = 0xFFFFu;
while (n--) {
crc ^= (uint16_t)*p++ << 8;
for (unsigned b = 0; b < 8u; b++)
crc = (crc & 0x8000u) ? (uint16_t)(crc << 1 ^ 0x1021u) : (uint16_t)(crc << 1);
}
return crc;
}
Ожидаемые значения берут не из своей же реализации, а из независимого источника: для CRC-16/CCITT-FALSE строка «123456789» даёт 0x29B1 — это известное контрольное значение, его печатают в описаниях алгоритма. В прогоне тест так и ответил.
110.7 Красная сборка останавливает выпуск
Проверить, что конвейер умеет падать, важнее, чем проверить, что он умеет собирать: сломанная защита незаметна, пока не понадобится. В опыте многочлен CRC подменён на соседний, и тест сразу увидел расхождение — две проверки из трёх упали, программа вернула не ноль, конвейер остановился и артефакт не появился.
Отсюда правило: этап падает, если хоть одна проверка не прошла, и никакого «ну ладно, соберём вручную». Иначе выпуск однажды уедет с той самой ошибкой, ради которой всё и затевалось.
110.8 Повторяемость: тот же образ байт в байт
Конвейер собрал прошивку дважды — в разных каталогах — и получил байт в байт одинаковый образ. Само по себе это не получается: мешают три вещи.
- Время сборки внутри образа (
__DATE__,__TIME__) — фиксируется переменнойSOURCE_DATE_EPOCH. - Пути из каталога сборки попадают в отладочные данные — убираются
ключом
-ffile-prefix-map. - Идентификатор сборки, если он включён, — хэш всего файла вместе
с отладочными данными: разные пути меняют его, а заметка с ним попадает
в образ. Выключается
-Wl,--build-id=none; arm-none-eabi-gcc 12 сам его не добавляет, но многие сборки компилятора и IDE включают.
Зачем это нужно: одинаковый хэш — это доказательство, что выложенный образ собран ровно из того коммита. Иначе остаётся верить на слово. Для прошивки, которую подписывают и раздают на приборы (загрузчик и обновление — раздел 48), это основа доверия.
110.9 Артефакты и манифест
На выходе — три файла: два образа с версией в имени (fw-v2.0.0-f103.bin)
и манифест из четырёх строк: версия, компилятор и контрольные суммы обоих
образов. Правила простые:
- имя файла содержит версию — иначе через месяц никто не отличит два
fw.bin; - рядом лежит манифест с суммами — по нему сверяют то, что в приборе;
- артефакты хранятся отдельно от репозитория, но с указанием коммита;
- то, что не прошло тесты, артефактом не становится;
- сборка падает и на предупреждениях (
-Werror), и на размере: выводarm-none-eabi-sizeсравнивают с лимитом флеш-памяти и ОЗУ.
110.10 Грабли
Компилятор «последней версии». Однажды утром сборка меняется сама собой. Версия закрепляется в образе.
Сборка из рабочего каталога инженера. В образ уедет несохранённое:
git describe покажет -dirty. Собирать из чистого клона в контейнере.
Тест падает, а конвейер зелёный. Код возврата проверки теряется
в конвейере команд: у make test | tee log код возврата берётся от tee.
В сценарии — set -eu -o pipefail (bash), и проверять код возврата, а не вывод.
Кэш «ускоряет» сборку до неправильного результата. Кэшируются объектные файлы от другой версии компилятора. Кэшировать только то, что однозначно привязано к версиям, и уметь его сбрасывать.
Образ каждый раз другой. Время, пути и идентификатор сборки внутри.
SOURCE_DATE_EPOCH, -ffile-prefix-map, --build-id=none.
Артефакт без версии в имени. Через месяц на складе несколько fw.bin,
и никто не знает, какой из них в приборах.
Секреты в журнале конвейера. Ключ подписи, напечатанный в логе, считается утёкшим. Секреты — только переменными окружения и с маскировкой.
110.11 Итоги
- Конвейер отвечает на вопрос «из чего собрано»: контейнер с закреплённым arm-none-eabi-gcc 12.2.1, версия из тега — v2.0.0.
- Две платы в одном прогоне: образ 160 байт у каждой, содержимое разное.
- Часть прошивки проверяется на компьютере тем же исходником: CRC-16 «123456789» = 0x29B1.
- Упавший тест останавливает конвейер: артефакта нет.
- Повторная сборка даёт тот же образ байт в байт —
SOURCE_DATE_EPOCH,-ffile-prefix-map,--build-id=none. - Артефакт называется
fw-v2.0.0-f103.binи лежит рядом с манифестом контрольных сумм.
Полные исходники
pipeline.sh — конвейер целиком: версия из git, сборка для двух плат, тесты, повторная сборка, артефакты.
#!/bin/sh
# Раздел 110: НАСТОЯЩИЙ конвейер сборки прошивки в контейнере stm-ci:1.
#
# Этапы: версия из git → сборка для F103 и F407 → модульные тесты на компьютере
# → упаковка артефакта с манифестом. Проверяются два свойства, ради которых
# конвейер и заводят: сборка повторяется байт в байт и падение теста
# останавливает выпуск. Печатает ключ=значение для _verify/ci110_check.py.
set -eu
export GIT_CONFIG_GLOBAL=/tmp/gitconfig GIT_CONFIG_NOSYSTEM=1
export GIT_AUTHOR_NAME=CI GIT_AUTHOR_EMAIL=ci@example.test
export GIT_COMMITTER_NAME=CI GIT_COMMITTER_EMAIL=ci@example.test
export GIT_AUTHOR_DATE="2026-01-02T03:04:05+00:00" GIT_COMMITTER_DATE="2026-01-02T03:04:05+00:00"
# Одинаковое время сборки — иначе __DATE__ и пути сделали бы образы разными.
export SOURCE_DATE_EPOCH=1767322800 TZ=UTC LC_ALL=C
SRC=/v/ci110/fw
W=/tmp/ci
rm -rf $W
mkdir -p $W/work $W/out
cp -r $SRC/. $W/work/
cd $W/work
echo "cc_version=$(arm-none-eabi-gcc -dumpversion)"
# --- 1. Версия: репозиторий с тегом, как в разделе 109 ----------------------
git init -q -b main
git add -A
git commit -q -m "прошивка"
git tag -a v2.0.0 -m "выпуск 2.0.0"
VERSION=$(git describe --tags --dirty)
echo "version=$VERSION"
printf '#ifndef VERSION_H\n#define VERSION_H\n#define FW_VERSION "%s"\n#endif\n' "$VERSION" > src/version.h
CFLAGS="-Os -Wall -Wextra -Werror -ffunction-sections -fdata-sections -ffile-prefix-map=$W/work=. -g0"
LDFLAGS="-nostdlib -Wl,--gc-sections -Wl,--build-id=none"
build() { # build <плата> <ключи ядра> <карта памяти>
board=$1; cpu=$2; ld=$3
# shellcheck disable=SC2086
arm-none-eabi-gcc $cpu $CFLAGS -Isrc -c src/crc.c -o crc-$board.o
# shellcheck disable=SC2086
arm-none-eabi-gcc $cpu $CFLAGS -Isrc -c src/main.c -o main-$board.o
# shellcheck disable=SC2086
arm-none-eabi-gcc $cpu $CFLAGS -c crt0.S -o crt0-$board.o
# shellcheck disable=SC2086
arm-none-eabi-gcc $cpu $CFLAGS $LDFLAGS -T $ld -L. crt0-$board.o main-$board.o crc-$board.o -o fw-$board.elf
arm-none-eabi-objcopy -O binary fw-$board.elf fw-$board.bin
echo "size_$board=$(arm-none-eabi-size -A fw-$board.elf | awk '/^.text/ {t=$2} /^.data/ {d=$2} END {print t+d}')"
echo "bin_$board=$(wc -c < fw-$board.bin | tr -d ' ')"
echo "sha_$board=$(sha256sum fw-$board.bin | cut -c1-16)"
}
# --- 2. Сборка для двух плат в одном прогоне --------------------------------
build f103 "-mcpu=cortex-m3 -mthumb" f103.ld
build f407 "-mcpu=cortex-m4 -mthumb -mfloat-abi=soft" f407.ld
# --- 3. Повторяемость: та же сборка ещё раз в другом каталоге ---------------
mkdir -p $W/again
cp -r $SRC/. $W/again/
cp src/version.h $W/again/src/version.h
( cd $W/again
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -Os -Wall -Wextra -Werror -ffunction-sections -fdata-sections \
-ffile-prefix-map=$W/again=. -g0 -Isrc -c src/crc.c -o crc.o
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -Os -Wall -Wextra -Werror -ffunction-sections -fdata-sections \
-ffile-prefix-map=$W/again=. -g0 -Isrc -c src/main.c -o main.o
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -Os -ffile-prefix-map=$W/again=. -g0 -c crt0.S -o crt0.o
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -Os -nostdlib -Wl,--gc-sections -Wl,--build-id=none -T f103.ld -L. \
crt0.o main.o crc.o -o fw.elf
arm-none-eabi-objcopy -O binary fw.elf fw.bin )
A=$(sha256sum fw-f103.bin | cut -d' ' -f1)
B=$(sha256sum $W/again/fw.bin | cut -d' ' -f1)
echo "reproducible=$([ "$A" = "$B" ] && echo same || echo differ)"
# --- 4. Тесты на компьютере: тот же исходник, обычный gcc -------------------
gcc -O1 -Wall -Wextra -Werror -o $W/test tests/test_crc.c src/crc.c
$W/test > $W/test.log 2>&1 && TEST_RC=0 || TEST_RC=$?
echo "test_rc=$TEST_RC"
echo "test_crc_123456789=$(grep -o '123456789 = 0x[0-9A-F]*' $W/test.log | cut -d'x' -f2)"
# --- 5. Что будет, если тест не прошёл: конвейер обязан остановиться --------
sed 's/0x1021u/0x1022u/' src/crc.c > /tmp/broken.c
gcc -O1 -w -o $W/test_broken tests/test_crc.c /tmp/broken.c -I src
$W/test_broken > /tmp/broken.log 2>&1 && BROKEN_RC=0 || BROKEN_RC=$?
echo "broken_rc=$BROKEN_RC"
echo "broken_failed=$(grep -c '^FAIL' /tmp/broken.log || true)"
# --- 6. Упаковка артефакта: имя с версией и манифест ------------------------
if [ "$TEST_RC" -eq 0 ]; then
NAME="fw-$VERSION"
cp fw-f103.bin $W/out/$NAME-f103.bin
cp fw-f407.bin $W/out/$NAME-f407.bin
( cd $W/out
printf 'version=%s\ncc=%s\n' "$VERSION" "$(arm-none-eabi-gcc -dumpversion)" > manifest.txt
sha256sum ./*.bin >> manifest.txt )
echo "artifacts=$(ls $W/out | wc -l | tr -d ' ')"
echo "artifact_name=$(ls $W/out | grep f103 | head -1 | sed 's/-g[0-9a-f]*//')"
echo "manifest_lines=$(wc -l < $W/out/manifest.txt | tr -d ' ')"
fi
echo "done=1"
crc.c — контрольная сумма кадра — общий код прошивки и модульных тестов.
/* Раздел 110: кусочек прошивки, который проверяется и на плате, и на компьютере.
* Контрольная сумма кадра: тот же код собирается для F103/F407 и для тестов. */
#include "crc.h"
uint16_t crc16(const uint8_t *p, unsigned n)
{
uint16_t crc = 0xFFFFu;
while (n--) {
crc ^= (uint16_t)*p++ << 8;
for (unsigned b = 0; b < 8u; b++)
crc = (crc & 0x8000u) ? (uint16_t)(crc << 1 ^ 0x1021u) : (uint16_t)(crc << 1);
}
return crc;
}
Дальше: раздел 110а «Прошивка под присмотром конвейера: дельта размера на каждую правку и assert без строк в рабочей сборке» — как конвейер сторожит размер образа на каждом запросе слияния и почему утверждения в рабочей прошивке стоит оставлять, но без строк.