Отладка и производство: SWD, логический анализатор, прошивки, OTA
На «большой» машине отладка — привилегия, о которой не думают. Упало? В логах стектрейс. Непонятно? Поставил брейкпоинт, покрутил переменные, перезапустил. Медленно? Профайлер нарисует флеймграф. Сломалось в проде? Sentry уже прислал письмо. Наблюдаемость здесь бесплатна: у процесса есть stdout, диск, сеть и гигабайты памяти, чтобы всё это буферизовать.
Встраиваемая система не даёт ничего из этого списка по умолчанию. Нет stdout — есть ножка, на которой, если её правильно настроить, появятся уровни напряжения. Нет диска — есть 512 КБ флеша, из которых 480 занято кодом. Нет перезапуска — устройство висит на столбе в тридцати километрах от офиса. И главное: сам акт наблюдения меняет наблюдаемое. Вставили printf в обработчик прерывания — обработчик стал в двести раз длиннее, и баг исчез. Остановились на брейкпоинте — ШИМ застыл в открытом состоянии, и силовой ключ выпустил дым.
Отсюда главный сдвиг мышления: наблюдаемость проектируется заранее, стоит пинов, флеша и денег, и её нельзя добавить постфактум. Отладочный разъём решается на этапе схемы, тест-точки — на этапе разводки, чёрный ящик и обновление по воздуху — на этапе архитектуры. Не решили — будете месяцами ловить «плавающий баг» вслепую, а потом ездить по объектам с программатором. Вторая половина статьи — про то, что происходит, когда прошивка уезжает в мир: конвейер, ключи, блокировка отладочного порта и OTA, которое либо спасёт парк устройств, либо превратит его в пять тысяч кирпичей за ночь.
Почему printf — не отладка
Честная арифметика, объясняющая всё остальное:
// «Вывод отладки» на STM32, UART 115200 бод, блокирующая отправка
printf("adc=%u temp=%.2f state=%d\r\n", adc, temp, state);
- Символ на 115200 бод — 10 бит со стартовым и стоповым, то есть 86,8 мкс.
- Строка выше, 34 символа — 2,95 мс блокирующей передачи.
printfс%fтянет форматирование плавающей точки изnewlib: +8…12 КБ флеша и сотни микросекунд на вызов даже без учёта UART.- Если вызов попал в ISR с периодом 1 мс, система не «замедлилась» — она умерла: следующее прерывание придёт раньше, чем закончится текущее.
Дальше начинается класс ошибок, которого нет в мире веб-сервисов, — гейзенбаг, исчезающий при попытке его наблюдать. Гонка между ISR и главным циклом воспроизводится раз в час; вы вставляете вывод, тайминги смещаются на три миллисекунды, окно гонки закрывается, и баг «чинится». Через неделю он приезжает обратно из поля. Третья беда: printf не работает тогда, когда нужен больше всего — при HardFault, переполнении стека, зависании в прерывании с запретом остальных.
Правильный вывод — не «никогда не печатать», а: вывод обязан стоить единицы тактов и не иметь права блокировать. Ниже три способа этого добиться и два способа увидеть то, что вообще не проходит через процессор.
Карта инструментов: чем смотрят на что
Инструментов ровно шесть, и выбор между ними почти механический — зависит от того, на какой вопрос вы отвечаете.
до нужного места?"} Q1 -->|"неизвестно"| SWD["SWD + gdb: остановить,
посмотреть PC, стек, переменные"] Q1 -->|"доходит"| Q2{"Проблема во времени?
Сроки, джиттер, порядок"} Q2 -->|"нужны микросекунды"| GPIO["GPIO-строб + анализатор
или осциллограф. Оверхед 2 такта"] Q2 -->|"нужна картина целиком"| TRACE["RTT / ITM / SystemView:
трасса событий без остановки"] Q2 -->|"нет"| Q3{"Данные ходят между
микросхемами по шине?"} Q3 -->|"байты неизвестны"| LA["Логический анализатор:
декодер I2C/SPI/UART/CAN"] Q3 -->|"байты правильные,
но устройство молчит"| SCOPE["Осциллограф: фронты,
уровни, звон, просадка питания"] Q3 -->|"нет"| Q5{"Падает редко и в поле?"} Q5 -->|"да"| BB["Чёрный ящик: HardFault-дамп,
причина сброса, журнал во флеше"] Q5 -->|"нет"| SWD
Физически это выглядит так: одна плата, три прибора, три разных слоя реальности.
SWD и JTAG: как отладчик попадает внутрь чипа
JTAG (IEEE 1149.1) придуман в 1985 году не для отладки, а для тестирования плат: цепочка boundary scan-регистров позволяла продёрнуть логические уровни через все ноги микросхем и проверить пайку без единого щупа. Отладочный доступ приехал позже, как ещё один режим той же машины состояний. Интерфейс — пять сигналов: TCK, TMS, TDI, TDO, TRST.
SWD (Serial Wire Debug) — придуманная ARM замена: те же возможности на двух проводах, SWDIO (двунаправленные данные) и SWCLK (такт). Для корпуса на 32 ноги разница между двумя и пятью пинами решающая, поэтому в мире Cortex-M SWD победил почти полностью. Плюс два необязательных, но крайне полезных сигнала: SWO (односторонний вывод трассировки) и NRST (аппаратный сброс).
Внутри чипа за интерфейсом стоит DAP (Debug Access Port). DP (Debug Port) — то, с чем непосредственно говорит зонд; AP (Access Port) — мост к внутренним шинам, и ключевой для нас AHB-AP даёт доступ к системной шине напрямую, минуя ядро. Отсюда неочевидное и очень практичное следствие: отладчик может читать и писать память работающей программы, не останавливая её. На этом построены RTT, live watch в IDE и половина приёмов ниже. Останов ядра нужен только для брейкпоинтов и пошагового выполнения.
Средства останова живут в железе и не бесконечны:
- FPB (Flash Patch and Breakpoint) — аппаратные точки останова по адресу: 6 на Cortex-M3/M4, 8 на M7, 4 на M0+. Больше поставить нельзя — отладчик начнёт молча подменять инструкции на
BKPT, что во флеше работает не всегда. - DWT (Data Watchpoint and Trace) — 4 компаратора, дающие останов по обращению к данным: «останови, когда кто-то запишет в эту переменную». Инструмент номер один для поиска того, кто портит вашу структуру, и почему-то почти неизвестный приходящим из прикладного софта.
# OpenOCD как gdb-сервер + arm-none-eabi-gdb
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg
arm-none-eabi-gdb build/firmware.elf
(gdb) target extended-remote :3333
(gdb) monitor reset halt # обязательно halt: иначе поймаете уже улетевшую программу
(gdb) load # прошить elf во флеш
(gdb) watch sensor_state # аппаратный watchpoint через DWT
# probe-rs — современная альтернатива: прошивка, отладка и RTT одной командой
probe-rs run --chip STM32F411CEUx build/firmware.elf
# ESP32-S3/C3 — JTAG встроен в USB-периферию, внешний зонд не нужен
openocd -f board/esp32s3-builtin.cfg
Три грабли, на которые наступают все
Плата не подключается, потому что программа сама себя отключила. Первое, что делает прошивка после старта, — настраивает пины, и очень часто в списке оказываются PA13/PA14 (это и есть SWDIO/SWCLK), или чип уходит в глубокий сон через 5 мс после сброса. Зонд не успевает. Лечение — connect under reset: зонд удерживает NRST низким, поднимает интерфейс и только потом отпускает сброс. Именно поэтому NRST стоит выводить на разъём: без него неудачная прошивка превращает плату в кирпич.
Останов ядра не останавливает периферию. Брейкпоинт в контуре управления мотором: ядро замерло, таймер продолжает считать и держит ШИМ в прежней скважности, мотор разгоняется, силовой каскад греется. На STM32 лечится регистрами DBGMCU:
// Заморозить таймеры и сторожевой таймер вместе с ядром при останове отладчиком
DBGMCU->APB1FZ |= DBGMCU_APB1_FZ_DBG_IWDG_STOP // иначе watchdog сбросит вас на брейкпоинте
| DBGMCU_APB1_FZ_DBG_TIM2_STOP;
DBGMCU->APB2FZ |= DBGMCU_APB2_FZ_DBG_TIM1_STOP; // ШИМ мотора замрёт вместе с программой
Чтение регистра периферии в отладчике меняет состояние. Многие статусные биты сбрасываются чтением (read-to-clear). Отладчик обновляет окно Peripherals, вычитывает USART->SR — и флаг RXNE, которого ждала ваша программа, исчезает. IDE, знающие об этом, помечают такие регистры; остальные превращают отладку в мистику.
Трассировка без остановки: RTT, ITM/SWO, DWT
В 80 % задач нужно не «замереть и посмотреть», а увидеть последовательность событий в живой системе.
RTT опирается на AHB-AP: прошивка пишет в обычный кольцевой буфер в ОЗУ, а отладчик фоново вычитывает его прямо из работающей памяти. Со стороны прошивки это memcpy — единицы микросекунд, никакой периферии, никакой блокировки.
#include "SEGGER_RTT.h"
void app_init(void) {
SEGGER_RTT_ConfigUpBuffer(0, "log", NULL, 0, SEGGER_RTT_MODE_NO_BLOCK_SKIP);
// NO_BLOCK_SKIP принципиален: не успевает хост — сообщение теряется, но прошивка
// НИКОГДА не встаёт в ожидание. Логи не имеют права быть дороже полезной работы.
}
void control_loop(void) {
SEGGER_RTT_printf(0, "err=%d duty=%u\n", err, duty); // ~2 мкс вместо ~3000 мкс у UART
}
ITM (Instrumentation Trace Macrocell) — блок в ядре Cortex-M3 и старше с 32 портами-стимулами. Запись слова в порт — одна инструкция, дальше блок сам сериализует пакет и выплёвывает его на пин SWO; ядро не ждёт. Скорость SWO = частота ядра, делённая на SWOPRESCALER: на 100 МГц реально получить 2–6 Мбит/с. Помимо печати через ITM идут аппаратные события — смены задач RTOS, попадания по watchpoint, периодическая выборка PC (статистический профайлер на железе, то самое, что на большой машине делает perf).
DWT даёт счётчик тактов — самый точный секундомер, который у вас есть:
static inline void cyccnt_enable(void) {
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CYCCNT = 0;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}
uint32_t t0 = DWT->CYCCNT;
fir_filter(buf, N);
uint32_t dt = DWT->CYCCNT - t0; // вычитание корректно даже при переполнении 32 бит
// при 100 МГц dt тактов = dt * 10 нс. Максимум за 1000 итераций — это и есть WCET.
| Механизм | Цена сообщения | Провода | Работает при HardFault | Условие |
|---|---|---|---|---|
printf по UART |
1–5 мс | TX (+ мост USB) | нет, блокируется | всегда |
printf по UART + DMA |
5–20 мкс | TX | частично | нужен свободный DMA-канал |
| RTT | 1–3 мкс | те же SWD | да, буфер читается по DAP | нужен отладочный зонд |
| ITM/SWO | 3–5 тактов | +1 пин SWO | да | Cortex-M3 и старше, зонд с SWO |
| GPIO-строб | 2 такта | +1 пин | да | нужен анализатор или осциллограф |
GPIO-строб: осциллограф как профайлер без бюджета
Самый недооценённый приём во всей встраиваемой отладке. Дёргаем ногу в начале и в конце интересного участка и смотрим её прибором. Оверхед — одна запись в регистр.
// Прямая запись в BSRR: атомарно, без чтения-модификации-записи, один такт на шине
#define DBG_HI(pin) (GPIOB->BSRR = (1u << (pin)))
#define DBG_LO(pin) (GPIOB->BSRR = (1u << ((pin) + 16)))
void TIM2_IRQHandler(void) {
DBG_HI(0); // канал 0: длительность ISR
TIM2->SR = ~TIM_SR_UIF;
read_sensors();
update_pid();
DBG_LO(0);
}
void control_task(void *arg) {
for (;;) {
xSemaphoreTake(tick_sem, portMAX_DELAY);
DBG_HI(1); // канал 1: задержка от прерывания до реакции задачи
apply_control();
DBG_LO(1);
}
}
Что даёт четырёхканальный анализатор за пять минут работы:
- Длительность ISR — ширина импульса. Максимум за час прогона и есть худший случай, который никаким усреднением не найти.
- Джиттер — разброс расстояния между фронтами; функция «гистограмма периода» есть в любом анализаторе, и именно на неё смотрят, когда спорят, укладывается ли контур в дедлайн.
- Задержка отклика — расстояние между фронтами каналов 0 и 1: сколько прошло от аппаратного события до реакции задачи RTOS.
- Загрузка процессора — доля времени, когда нога поднята. Бесплатный
topдля микроконтроллера.
Именно так измеряют то, о чём говорилось в статье про прерывания и реальное время: никакой printf не даст разрешения в наносекунды и не поймает один плохой цикл из миллиона.
Логический анализатор: что реально было на проводе
Инструмент отвечает ровно на один вопрос — какие уровни были на линиях и когда — и делает это, не влияя на систему совсем.
Частота дискретизации. Теорему Найквиста здесь применять нельзя: она про восстановление аналогового сигнала, а нам нужно не пропустить короткий импульс и точно поймать положение фронта. Практическое правило — 4× для распознавания и 10× для измерения времени. Для SPI на 8 МГц берите не меньше 80 Мвыб/с; дешёвая «24 МГц» коробка на таком SPI покажет вам ложь с уверенным видом.
Глубина памяти важнее частоты. Прибор на 500 Мвыб/с с буфером на 10 мс бесполезен, если сбой случается раз в минуту. Отсюда триггеры: по спаду CS, по паттерну на шине, по внешнему сигналу от GPIO-строба. Связка «прошивка сама себя триггерит на аномалии» плюс «анализатор пишет предысторию» ловит то, что руками не поймать никогда.
Декодеры протоколов превращают миллион отсчётов в текст. sigrok (свободный, поддерживает сотню дешёвых приборов) и Saleae Logic декодируют I2C, SPI, UART, CAN, 1-Wire, SWD и умеют стек декодеров: I2C → регистры конкретного датчика.
# Захват двух каналов на 4 МГц с декодированием I2C
sigrok-cli --driver fx2lafw --config samplerate=4M --samples 5M \
--channels D0=SCL,D1=SDA \
--protocol-decoder i2c:scl=SCL:sda=SDA \
--protocol-decoder-annotations i2c=address-write:data-write:nack
# Триггер по спаду на D2 — нашем GPIO-стробе «сработала аномалия»
sigrok-cli --driver fx2lafw --config samplerate=24M --samples 24M --triggers D2=f -o fail.sr
Что диагностируется за минуту, а без прибора — за неделю:
- NACK от датчика. Сразу видно, отвечает ли устройство и на каком байте отвалилось. Половина «не работает I2C» — неверный адрес (путаница 7-битного с уже сдвинутым) или отсутствующее питание датчика.
- Залипший SDA. Ведомый остался в середине передачи после сброса мастера и держит линию в нуле; лечится генерацией девяти тактов
SCLпри старте. - Не тот режим SPI или не та скорость UART. Данные читаются по не тому фронту; ширина бита курсором и
1/tмгновенно показывают, чтоHSE_VALUEв проекте не соответствует кварцу на плате. - Пропавшее прерывание. Линия
INTдатчика дёрнулась, а строб обработчика не появился — значит, дело в NVIC, а не в датчике.
Осциллограф: а это вообще ещё сигнал
Анализатор говорит «был ноль» или «была единица», потому что сравнил напряжение с фиксированным порогом. Он по построению не может сказать, что до этой единицы линия ползла девятьсот наносекунд и еле дотянулась.
Ситуация на картинке типовая: подтяжка 10 кОм, ёмкость шлейфа и входов 300 пФ, постоянная времени RC = 3 мкс, фронт 10–90 % ≈ 900 нс при допустимых 300. Работает. До первого мороза, до третьего датчика на шине, до партии плат с чуть другим флюсом. Анализатор в этот момент показывает идеальную картинку — он видит порог, а не физику.
Что можно увидеть только осциллографом:
- Фронты и звон. Правило полосы:
BW ≥ 0,35 / t_rise. Честно видеть фронт 5 нс — нужно 70 МГц; прибор с полосой 20 МГц скруглит всё до неузнаваемости и «докажет», что проблемы нет. - Несовместимость логических уровней. 5-вольтовый датчик на 3,3-вольтовом входе; выход 3,3 В, который не дотягивает до
0,7·VDDпятивольтового КМОП-приёмника. - Просадка питания — самая частая причина «загадочных перезагрузок». Радиомодуль уходит в передачу, тянет 120–250 мА импульсом, LDO не успевает, рельс проваливается ниже порога
BOR, микроконтроллер сбрасывается. Щуп наVDDв режиме AC-связи, 50 мВ/деление, триггер по началу передачи — и провал виден. Лечится конденсаторами, а не кодом. - Кварц, который не запустился. Только щупом 10:1: щуп 1:1 своей ёмкостью сорвёт генерацию.
- Дребезг контактов — те самые тридцать «нажатий» вместо одного.
Про щупы, о чём молчат даташиты. Пружинный контакт вместо провода-крокодила с зажимом на другом конце платы: длинный земляной провод — это 100–200 нГн индуктивности, которая в паре с ёмкостью щупа даёт колебательный контур и рисует «звон», которого на плате нет. Половина паники по поводу выбросов на фронтах — артефакт измерения. Режим 10:1 снижает ёмкостную нагрузку со ~100 пФ до ~10 пФ; на быстрых сигналах это разница между «увидел» и «испортил». Для дифференциальных шин вроде CAN одиночный щуп бесполезен — нужен дифференциальный пробник или математический канал CH1 − CH2; для энергопотребления — шунт, и подробности бюджета в статье про питание и ограничения.
Постмортем: баг случился ночью, в поле, один раз
Никакой стенд не поможет, если устройство падает раз в две недели у клиента. Работает единственная стратегия: прошивка должна уметь рассказать о собственной смерти после перезагрузки.
Разбор HardFault
При исключении Cortex-M аппаратно укладывает на стек восемь слов: R0-R3, R12, LR, PC, xPSR. PC — адрес инструкции, на которой всё сломалось; задача обработчика — этот кадр найти и сохранить.
void HardFault_Handler(void) __attribute__((naked));
void HardFault_Handler(void)
{
__asm volatile (
"tst lr, #4 \n" // бит 2 в EXC_RETURN: какой стек был активен
"ite eq \n"
"mrseq r0, msp \n" // 0 -> пришли из обработчика, кадр в MSP
"mrsne r0, psp \n" // 1 -> из задачи, кадр в PSP
"b hardfault_report \n"
);
}
// Секция .noinit не обнуляется стартовым кодом — переживает программный сброс
__attribute__((section(".noinit"))) static struct {
uint32_t magic, pc, lr, cfsr, hfsr, bfar, task;
} g_crash;
void hardfault_report(uint32_t *frame)
{
g_crash.magic = 0xDEADC0DE;
g_crash.pc = frame[6]; // адрес упавшей инструкции
g_crash.lr = frame[5]; // откуда пришли — почти всегда этого достаточно
g_crash.cfsr = SCB->CFSR; // конкретная причина, см. таблицу ниже
g_crash.bfar = SCB->BFAR; // адрес, по которому обратились неудачно
g_crash.task = current_task_id();
flash_append_crash(&g_crash); // журнал во флеше переживает и потерю питания
NVIC_SystemReset();
}
Расшифровка CFSR — половина диагноза:
| Бит | Класс | Что произошло |
|---|---|---|
IACCVIOL |
MemManage | выполнение кода там, где его быть не должно — прыжок по мусорному указателю |
PRECISERR |
BusFault | обращение по несуществующему адресу; в BFAR точный адрес |
IMPRECISERR |
BusFault | то же, но запись улетела через буфер записи, и PC неточен |
UNALIGNED |
UsageFault | доступ к 32-битному слову по нечётному адресу — привет разбор пакетов структурами |
UNDEFINSTR |
UsageFault | ушли туда, где нет кода: почти всегда повреждён стек или адрес возврата |
STKERR |
BusFault | стек не поместился в отведённую область |
Полученный PC превращается в строку исходника одной командой — не забывайте только сохранять .elf каждой выпущенной прошивки рядом с её версией:
arm-none-eabi-addr2line -e firmware.elf -f -C 0x08004f2a
# vTaskDelayUntil
# /src/control.c:214
На ESP32 то же делает встроенный core dump: панический обработчик пишет дамп в раздел флеша, а espcoredump.py info_corefile разворачивает его в стеки всех задач FreeRTOS.
Причина перезагрузки — первое, что читает прошивка
void log_reset_reason(void)
{
uint32_t csr = RCC->CSR;
if (csr & RCC_CSR_IWDGRSTF) event_log(EV_RESET_WATCHDOG); // зависли: ищите бесконечный цикл
if (csr & RCC_CSR_BORRSTF) event_log(EV_RESET_BROWNOUT); // просело питание: ищите ток, не код
if (csr & RCC_CSR_PINRSTF) event_log(EV_RESET_PIN); // внешний сброс или дребезг NRST
if (csr & RCC_CSR_SFTRSTF) event_log(EV_RESET_SOFT); // мы сами: OTA или обработка сбоя
RCC->CSR |= RCC_CSR_RMVF; // флаги липкие, обязательно сбросить
}
Разница между «сбросил сторожевой таймер» и «просело питание» — это разница между поиском бага в коде и поиском конденсатора на плате. Устройство, которое их не различает, обрекает вас на недели работы не в ту сторону.
Чёрный ящик и сторожевой таймер
Правило: в поле пишут коды, а не строки. Строка "Sensor timeout on bus 2" — 26 байт флеша; запись {ts:u32, code:u8, arg:u16, crc:u8} — восемь, а расшифровывается таблицей на сервере. Кольцевой журнал на 4 КБ хранит 512 событий, и этого хватает, чтобы восстановить последние минуты жизни устройства, не выезжая на объект. Механика износоустойчивой записи во флеш разобрана в статье про питание и ограничения.
Типичная ошибка со сторожевым таймером — кормить его из прерывания таймера: тогда он сторожит работоспособность таймера, а не программы, и намертво зависший главный цикл сброса не вызовет. Правильно — флаговая схема:
static volatile uint32_t task_alive_mask; // каждая задача ставит свой бит
void watchdog_task(void *arg) {
const uint32_t ALL = (1u << TASK_COUNT) - 1u;
for (;;) {
vTaskDelay(pdMS_TO_TICKS(200));
if ((task_alive_mask & ALL) == ALL) { // отчитались все — только тогда кормим
task_alive_mask = 0;
IWDG->KR = 0xAAAA;
} else {
event_log(EV_TASK_STUCK, task_alive_mask); // успеем записать до сброса
}
}
}
Оконный сторожевой таймер (WWDG) идёт дальше: сбрасывает не только когда его кормят слишком редко, но и когда слишком часто — то есть ловит сорвавшийся цикл, крутящийся вхолостую.
Отладка робота: три часовых пояса в одной машине
Робот — это обычно Linux с ROS 2 наверху и один-два микроконтроллера внизу, и главная сложность в том, что у них разное время и разные логи. ros2 topic hz показывает 50 Гц, оператор жалуется на дёрганое управление, а причина — 40 мс, которые команда проводит в USB-CDC между Raspberry Pi и STM32.
Работающий приём — сшить два мира одним физическим сигналом: микроконтроллер дёргает GPIO в момент, когда применил команду к мотору; тот же GPIO заведён на вход Raspberry Pi, и обработчик пишет метку в тот же rosbag. Разница между временем публикации и временем метки — честная сквозная задержка контура, измеренная без веры в чьи-либо часы.
#!/usr/bin/env python3
"""Сквозная задержка: от публикации команды до её физического применения."""
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
from std_msgs.msg import Header
class LatencyProbe(Node):
def __init__(self):
super().__init__("latency_probe")
self.sent_at, self.samples = None, []
self.pub = self.create_publisher(Twist, "/cmd_vel", 10)
self.create_subscription(Header, "/mcu_applied", self.on_applied, 10)
self.create_timer(0.1, self.send_cmd)
def send_cmd(self):
msg = Twist(); msg.linear.x = 0.2
self.sent_at = self.get_clock().now() # ROS-время публикации
self.pub.publish(msg)
def on_applied(self, _msg): # прервание от GPIO-строба MCU
if self.sent_at is None:
return
self.samples.append((self.get_clock().now() - self.sent_at).nanoseconds / 1e6)
s = sorted(self.samples) # среднее никого не интересует:
p99 = s[int(len(s) * 0.99)] # контур ломает хвост распределения
self.get_logger().info(f"latency p50={s[len(s)//2]:.1f} ms p99={p99:.1f} ms")
Дальше — обычный арсенал: ros2 bag record -a для записи сеанса, PlotJuggler для наложения заданной и фактической скорости (мгновенно видно перерегулирование), ros2 topic hz и ros2 topic delay. Что забывают: use_sim_time, синхронизацию часов между машинами через chrony и то, что задержка в DDS растёт нелинейно при потере Wi-Fi-пакетов — радиоканал робота нужно смотреть отдельно, как обычную сеть. Кинематика и структура управления разобраны в статье про робототехнику; архитектурная сторона распределённых роботизированных систем есть в научных работах раздела НИР — теоретическом обосновании микросервисной архитектуры и программной реализации сервисов.
От макетки к изделию: какую плату брать и когда
| Платформа | Ядро | Когда это правильный выбор | Чем платите |
|---|---|---|---|
| Arduino Uno / Nano | AVR 8 бит, 16 МГц, 2 КБ ОЗУ | обучение, разовая поделка, проверка идеи за вечер | нет отладки по SWD, нет RTOS, потолок наступает мгновенно |
| ESP32 / ESP32-S3 | Xtensa или RISC-V, 240 МГц, 512 КБ ОЗУ | нужен Wi-Fi или BLE из коробки, IoT, быстрый прототип с сетью | потребление в активном режиме, сертификация радио, «толстая» экосистема |
| STM32 (F0/G0 … H7) | Cortex-M0+ … M7 | серийный продукт, жёсткий реальный контур, низкое потребление, длинный жизненный цикл | нет сети без внешнего модуля, выше порог входа |
| Raspberry Pi / CM4 | Cortex-A, Linux | зрение, ROS 2, экран, файлы, Python-стек | нет детерминизма, секунды на загрузку, боль с питанием и SD-картой |
| Raspberry Pi Pico | Cortex-M0+ / M33 | дёшево, PIO для нестандартных протоколов, оснастка и обучение | скудная периферия против STM32, у RP2040 нет встроенного флеша |
Практическое правило: разделяйте контур и мозг. Микроконтроллер держит реальное время (моторы, безопасность, дедлайны), Linux-плата — восприятие и логику; связывает их UART или CAN, и при отвале Linux микроконтроллер обязан сам остановить машину. Подробнее об этой связке — в статьях про RTOS и обзоре трека.
Переход с отладочной платы на свою — отдельный проект, а не «повторить схему». С платы уезжают USB-UART-мост, стабилизатор, светодиоды, кварц и, самое главное, отладочный разъём. Всё это нужно осознанно вернуть: разведите SWD (или хотя бы пятачки под pogo-контакты) на любой плате, даже если уверены, что не понадобится. Стоимость — четыре площадки; цена отсутствия — партия неремонтопригодных изделий.
Производство: как прошить пять тысяч плат и не сойти с ума
Ключевая мысль диаграммы: тест-джиг разрабатывается параллельно с DVT, а не после. Задумавшись о тестировании на этапе серии, вы обнаружите, что тест-точки не выведены, а прошить плату можно только паяльником.
Плата ложится в оснастку, подпружиненные контакты (pogo pins) попадают на тест-точки, скрипт делает всю последовательность:
#!/usr/bin/env python3
"""Конвейерный тест: питание -> прошивка -> периферия -> калибровка -> ключи."""
import subprocess, serial
CHIP = "STM32G071KBTx"
def flash(fw: str) -> None:
subprocess.run(["probe-rs", "download", "--chip", CHIP, fw], check=True)
subprocess.run(["probe-rs", "reset", "--chip", CHIP], check=True)
def test_one(serial_no: str) -> bool:
supply.set_voltage(3.3); supply.on()
idle = supply.measure_current_ma()
if not 8.0 < idle < 25.0: # КЗ или непропай питания: ловим до прошивки
return fail(f"ток покоя {idle:.1f} мА вне допуска")
flash("test_firmware.elf") # сначала тестовая прошивка, не боевая
with serial.Serial(JIG_PORT, 115200, timeout=2.0) as port:
r = run_selftest(port) # текстовый протокол: ключ=значение до END
for name, ok, msg in [
("imu", r.get("imu") == "ok", "нет ответа IMU по I2C"),
("vref", 1.20 < float(r["vref"]) < 1.26, f"опорное {r['vref']} В вне допуска"),
("rssi", int(r["rssi"]) > -70, f"радио слабое: {r['rssi']} дБм"),
]:
if not ok:
return fail(f"[{name}] {msg}")
port.write(f"CAL {r['vref']}\n".encode()) # калибровки в заводской раздел флеша
provision(serial_no) # серийник и ключ устройства из HSM
flash("production_firmware.elf") # только теперь боевая прошивка
lock_debug_port() # RDP level 1 / eFuse — последним действием
return True
Порядок здесь не случаен и выстрадан отраслью:
- Ток покоя до прошивки. Короткое замыкание из-за непропая обнаруживается за 200 мс, а не после того, как вы сожгли программатор.
- Тестовая прошивка отдельно от боевой. Она умеет то, чего боевая уметь не должна: гонять периферию по команде, крутить моторы, отвечать по UART текстом.
- Калибровка на конвейере. Опорное напряжение, смещение акселерометра, коэффициент датчика тока измеряются на месте и пишутся в заводской раздел — это дешевле, чем ставить точные компоненты.
- Provisioning. Серийный номер, MAC, приватный ключ и сертификат. Ключ обязан генерироваться внутри чипа или выдаваться HSM, а не лежать в CSV на компьютере тестировщика.
- Блокировка отладочного порта — последним действием. После неё плата уже не чинится.
Про блокировку. На STM32 это уровни RDP: Level 0 — открыто; Level 1 — флеш не читается снаружи, но отладчик подключается, а возврат на Level 0 стирает всю флеш-память (это и механизм защиты, и способ вернуть плату в работу); Level 2 — интерфейс отключён физически и необратимо, ошиблись прошивкой — партия в мусор. На ESP32 то же делают однократно программируемые eFuse: secure boot v2, flash encryption, JTAG disable — тоже необратимо, и на этапе разработки регулярно превращает платы в сувениры. Компромисс большинства команд: RDP Level 1 на серии, полностью открытая отладка на инженерных образцах с явной маркировкой корпуса. Level 2 оправдан там, где извлечение ключей компрометирует весь парк. Модель угроз и хранение ключей — в статьях управление секретами и прикладная криптография.
OTA: обновление, которое не превращает парк в кирпичи
Устройство в поле нельзя перезалить руками. Значит, обновление обязано быть атомарным (либо старая прошивка, либо новая, никогда не половина), проверяемым (подпись, а не только CRC) и откатываемым (не завелась — вернулись сами). Флеш делится на неизменяемый загрузчик и два слота под образ; новый пишется в неактивный слот, проверяется, помечается пробным и становится основным только после подтверждения.
Состояния образа удобно держать в голове как автомат — именно он реализован в MCUboot и в esp_ota_*:
// ESP-IDF: новая прошивка обязана доказать работоспособность сразу после старта
void app_main(void)
{
const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_img_states_t state;
esp_ota_get_state_partition(running, &state);
hardware_init();
if (state == ESP_OTA_IMG_PENDING_VERIFY) {
if (selftest_ok()) { // сеть поднялась, датчики отвечают, контур считает
esp_ota_mark_app_valid_cancel_rollback();
} else {
esp_ota_mark_app_invalid_rollback_and_reboot(); // не вернётся
}
}
start_application();
}
Самая частая ошибка — подтверждать образ сразу после загрузки, до самопроверки. Тогда A/B-схема честно тратит половину флеша и не защищает ни от чего: прошивка, падающая через минуту, уже помечена как хорошая.
Что проверять обязательно:
- Подпись, а не CRC. CRC32 ловит помехи канала, но не злоумышленника. Стандарт индустрии — ECDSA P-256 или Ed25519: приватный ключ в HSM на сервере сборки, публичный — внутри загрузчика в защищённой от записи области.
- Анти-откат. Монотонный счётчик версии, который загрузчик хранит отдельно и не даёт понизить, иначе вас «обновят» на прошлогоднюю прошивку с известной дырой.
- Совместимость железа. Идентификатор платы и ревизии в заголовке образа: прошивка от версии платы с другим датчиком — тихий кирпич, выглядящий как загадочный отказ.
- Атомарность метаданных и энергия. Флаг
pendingпишется одной записью с CRC; стирание сектора — десятки миллисекунд при повышенном токе, поэтому обновление батарейного устройства при заряде 15 % даёт кирпич честнее любого бага.
# manifest.yaml — публикуется сервером и подписывается отдельно
firmware_version: "1.5.0"
hardware:
board: "atlas-sensor"
revisions: ["C", "D"] # ревизия B несовместима: другой датчик давления
image:
size: 214528
sha256: "9f2b...c41a"
signature: "MEUCIQD...==" # ECDSA P-256 по sha256 образа
constraints:
min_battery_percent: 40
forbid_while: ["motor_running", "ota_in_progress"]
rollout:
stage_1: { fraction: 0.01, hold_hours: 24 } # канарейка: 1 % парка на сутки
stage_2: { fraction: 0.10, hold_hours: 48 }
stage_3: { fraction: 1.00 }
abort_if:
crash_rate_above: 0.005 # 0,5 % устройств с падением — стоп раската
checkin_drop_above: 0.02 # 2 % перестали выходить на связь — стоп раската
Раскат — главное отличие от сервера. В вебе неудачный деплой откатывают за минуту; в поле неудачное обновление — это выезд техника к каждому устройству, и цена ошибки измеряется десятками тысяч USD. Отсюда: никогда не обновлять весь парк сразу (канарейка 1 %, сутки наблюдения, потом 10 %, потом всё — стратегии те же, что в релизных стратегиях DevOps, но цена ошибки на два порядка выше); стоп-кран обязан быть автоматическим по метрикам «доля вышедших на связь» и «частота падений»; устройство, которое не завелось, должно хотя бы поднять сеть и сообщить код ошибки, отсюда идея минимального recovery-образа, который не обновляется никогда. Дельта-обновления (bsdiff, detools) экономят трафик в разы, но применимы только к конкретной исходной версии — для LoRa и NB-IoT, где полный образ качается часами, деваться некуда (см. IoT-связь). Для автомобильной и медицинской техники есть готовые стандарты защиты цепочки обновлений — TUF и Uptane; изобретать своё там не нужно и опасно, а общая логика разобрана в статье про защиту цепочки поставок.
Сколько это стоит и что покупать первым
Последовательность закупки для одного разработчика: клон ST-LINK V2 (около 5 USD) → восьмиканальный анализатор на FX2 (около 10 USD) → лабораторный блок питания с измерением тока (около 60 USD) → осциллограф начального уровня 100 МГц (около 300 USD) → лицензия J-Link или Saleae, когда стало ясно, во что именно вы упираетесь. Первые три позиции закрывают процентов восемьдесят задач и стоят меньше одного рабочего дня инженера.
Типичные ошибки
- Отлаживать
printf-ом задачи, связанные со временем. Наблюдатель тяжелее наблюдаемого: 3 мс на строку против 1 мс периода контура. - Не вывести SWD и тест-точки на своей плате. Экономия четырёх площадок оборачивается партией неремонтопригодных изделий и невозможностью настроить конвейерный тест.
- Ставить брейкпоинт в контуре управления мотором, не заморозив таймеры. ШИМ остаётся в последнем состоянии, и дым идёт с платы, а не из отладчика.
- Верить логическому анализатору в вопросах физики. Он показывает идеальные прямоугольники ровно до момента, когда фронт перестанет дотягиваться до порога, — и не предупредит. Обратная ошибка — смотреть осциллографом через длинный земляной провод: звон, которого нет на плате, приводил к перепроектированию плат больше одного раза.
- Не различать причины сброса. Watchdog и brownout лечатся в разных плоскостях — код и схема.
- Кормить сторожевой таймер из прерывания. Он начинает сторожить таймер, а не программу.
- Подтверждать OTA-образ сразу после записи. A/B-схема без честной самопроверки не защищает ни от чего и стоит половины флеша впустую.
- Проверять образ только по CRC. Целостность — это не подлинность: нужны подпись, анти-откат и идентификатор ревизии платы.
- Раскатывать обновление на весь парк сразу. В вебе это откат за минуту, в поле — выезд техника к каждому устройству.
- Пережигать
RDP Level 2или eFuse на инженерных образцах. Необратимо. Всегда. И не оставляйте в серийном изделии тестовую прошивку с заводским UART-протоколом: команда «крути мотор на полную» в проде — бэкдор не в переносном смысле.
Мини-итог
- Наблюдаемость проектируется заранее и стоит пинов, флеша и денег; постфактум её не добавить.
- SWD — это два провода и DAP внутри чипа; отладчик читает память работающей программы через AHB-AP, и на этом стоят RTT и live watch. Аппаратных брейкпоинтов 4–8, watchpoint-компараторов четыре — вторые решают самые тяжёлые баги.
- Дешёвая наблюдаемость: RTT (микросекунды), ITM/SWO (такты), GPIO-строб (два такта).
printfпо UART — три миллисекунды и гарантированный гейзенбаг. - Логический анализатор отвечает на вопрос «что было передано», осциллограф — «а это ещё сигнал». Оба нужны и друг друга не заменяют.
- Баг из поля ловится только чёрным ящиком: разбор HardFault по стековому кадру,
CFSR, причина сброса изRCC_CSR, кольцевой журнал кодов во флеше. - Производство — это тест-джиг, разработанный параллельно с платой, строгий порядок «ток покоя → тестовая прошивка → периферия → калибровка → ключи → боевая прошивка → блокировка отладки» и осознанное решение по
RDP/eFuse. - OTA обязано быть атомарным, подписанным и откатываемым, подтверждает образ сама прошивка после самопроверки, а раскат идёт поэтапно с автоматическим стоп-краном.
Источники
- ARM. Cortex-M4 Technical Reference Manual (FPB, DWT, ITM) — https://developer.arm.com/documentation/100166/latest/ ; ADIv5 Debug Interface Architecture Specification (DAP, DP, AP) — https://developer.arm.com/documentation/ihi0031/latest/
- Joseph Yiu. «The Definitive Guide to ARM Cortex-M3 and Cortex-M4 Processors» — главы про отладку и обработку исключений
- SEGGER. RTT — https://www.segger.com/products/debug-probes/j-link/technology/about-real-time-transfer/ ; SystemView — https://www.segger.com/products/development-tools/systemview/
- probe-rs — https://probe.rs/ ; OpenOCD — https://openocd.org/ ; pyOCD — https://pyocd.io/
- sigrok и PulseView, свободный стек для логических анализаторов — https://sigrok.org/wiki/Main_Page ; Saleae Learning Portal — https://support.saleae.com/
- STMicroelectronics. AN4838 про защиту памяти и PM0214 про программирование Cortex-M4 — https://www.st.com/resource/en/application_note/an4838-managing-memory-protection-unit-in-stm32-mcus-stmicroelectronics.pdf
- Espressif. OTA и rollback — https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/system/ota.html ; Core dump — https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/core_dump.html ; Secure Boot V2 — https://docs.espressif.com/projects/esp-idf/en/stable/esp32/security/secure-boot-v2.html
- MCUboot, эталонный безопасный загрузчик — https://docs.mcuboot.com/ ; TUF — https://theupdateframework.io/ ; Uptane — https://uptane.github.io/
- Memfault Interrupt Blog — лучший практический источник по отладке прошивок: https://interrupt.memfault.com/blog/cortex-m-hardfault-debug и https://interrupt.memfault.com/blog/device-firmware-update-cookbook
- Jack Ganssle. «The Art of Designing Embedded Systems» — классика про инструменты и производство
- ROS 2, инструменты интроспекции — https://docs.ros.org/en/jazzy/Tutorials/Beginner-CLI-Tools.html ; PlotJuggler — https://plotjuggler.io/
Что дальше
Трек «Встраиваемые системы и робототехника» на этом закончен: от карты трека и железа через C без динамики, периферию, прерывания, протоколы, RTOS, энергию, IoT-связь, датчики и приводы и робототехнику — до отладки и производства. Куда идти дальше, зависит от того, какая сторона задачи оказалась ближе:
- Надёжность и процессы выпуска — релизные стратегии и CD и наблюдаемость и дежурства: те же канареечные раскаты и метрики здоровья, но с другой ценой ошибки.
- Защита устройств — моделирование угроз, прикладная криптография и защита цепочки поставок.
- Вниз, к системному уровню — процесс загрузки и измерение производительности: принцип «сначала померь, потом чини» на больших машинах.
- Вверх, к автономности роботов — машинное обучение и MLOps, а также научные работы портала: определение и классификация роботизированных систем и результаты проектирования.
Чтобы выбрать следующий трек осознанно, загляните в дорожную карту портала.