Встраиваемые системы и робототехника Отладка и производство: SWD, логический анализатор, прошивки, OTA
0%

Отладка и производство: SWD, логический анализатор, прошивки, OTA

Отладка и производство: 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, переполнении стека, зависании в прерывании с запретом остальных.

Правильный вывод — не «никогда не печатать», а: вывод обязан стоить единицы тактов и не иметь права блокировать. Ниже три способа этого добиться и два способа увидеть то, что вообще не проходит через процессор.

Карта инструментов: чем смотрят на что

Инструментов ровно шесть, и выбор между ними почти механический — зависит от того, на какой вопрос вы отвечаете.

Физически это выглядит так: одна плата, три прибора, три разных слоя реальности.

Отладочный стенд: 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, а не в датчике.

Осциллограф: а это вообще ещё сигнал

Анализатор говорит «был ноль» или «была единица», потому что сравнил напряжение с фиксированным порогом. Он по построению не может сказать, что до этой единицы линия ползла девятьсот наносекунд и еле дотянулась.

Один и тот же байт I2C: логический анализатор против осциллографа

Ситуация на картинке типовая: подтяжка 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

Порядок здесь не случаен и выстрадан отраслью:

  1. Ток покоя до прошивки. Короткое замыкание из-за непропая обнаруживается за 200 мс, а не после того, как вы сожгли программатор.
  2. Тестовая прошивка отдельно от боевой. Она умеет то, чего боевая уметь не должна: гонять периферию по команде, крутить моторы, отвечать по UART текстом.
  3. Калибровка на конвейере. Опорное напряжение, смещение акселерометра, коэффициент датчика тока измеряются на месте и пишутся в заводской раздел — это дешевле, чем ставить точные компоненты.
  4. Provisioning. Серийный номер, MAC, приватный ключ и сертификат. Ключ обязан генерироваться внутри чипа или выдаваться HSM, а не лежать в CSV на компьютере тестировщика.
  5. Блокировка отладочного порта — последним действием. После неё плата уже не чинится.

Про блокировку. На 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, когда стало ясно, во что именно вы упираетесь. Первые три позиции закрывают процентов восемьдесят задач и стоят меньше одного рабочего дня инженера.

Типичные ошибки

  1. Отлаживать printf-ом задачи, связанные со временем. Наблюдатель тяжелее наблюдаемого: 3 мс на строку против 1 мс периода контура.
  2. Не вывести SWD и тест-точки на своей плате. Экономия четырёх площадок оборачивается партией неремонтопригодных изделий и невозможностью настроить конвейерный тест.
  3. Ставить брейкпоинт в контуре управления мотором, не заморозив таймеры. ШИМ остаётся в последнем состоянии, и дым идёт с платы, а не из отладчика.
  4. Верить логическому анализатору в вопросах физики. Он показывает идеальные прямоугольники ровно до момента, когда фронт перестанет дотягиваться до порога, — и не предупредит. Обратная ошибка — смотреть осциллографом через длинный земляной провод: звон, которого нет на плате, приводил к перепроектированию плат больше одного раза.
  5. Не различать причины сброса. Watchdog и brownout лечатся в разных плоскостях — код и схема.
  6. Кормить сторожевой таймер из прерывания. Он начинает сторожить таймер, а не программу.
  7. Подтверждать OTA-образ сразу после записи. A/B-схема без честной самопроверки не защищает ни от чего и стоит половины флеша впустую.
  8. Проверять образ только по CRC. Целостность — это не подлинность: нужны подпись, анти-откат и идентификатор ревизии платы.
  9. Раскатывать обновление на весь парк сразу. В вебе это откат за минуту, в поле — выезд техника к каждому устройству.
  10. Пережигать 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 обязано быть атомарным, подписанным и откатываемым, подтверждает образ сама прошивка после самопроверки, а раскат идёт поэтапно с автоматическим стоп-краном.

Источники

Что дальше

Трек «Встраиваемые системы и робототехника» на этом закончен: от карты трека и железа через C без динамики, периферию, прерывания, протоколы, RTOS, энергию, IoT-связь, датчики и приводы и робототехнику — до отладки и производства. Куда идти дальше, зависит от того, какая сторона задачи оказалась ближе:

Чтобы выбрать следующий трек осознанно, загляните в дорожную карту портала.

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

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

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

Доска запросов