Embedded HandbookПриложения
Все разделы › V. Отладка и качество
STM32F103, STM32F407 · уровень продвинутый · 15 мин чтения
Открыть в интерактивном справочнике

35. Отладка

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

Как это называется в документации

Оригинал Перевод
breakpoint / watchpoint точка останова / точка наблюдения
single step / step over / step out шаг / шаг через вызов / выход из функции
call stack стек вызовов
fault отказ, аппаратное исключение
stacked frame кадр, уложенный процессором в стек
fault status register регистр состояния отказа
escalation эскалация — повышение отказа до HardFault
kernel awareness понимание ядра отладчиком
run time statistics статистика времени выполнения
high water mark наименьший остаток
cycle counter счётчик тактов

35.1 Что даёт отладчик

Четыре вещи, которые новичок использует одну.

Точка останова — остановить на строке. Все ею пользуются. Две тонкости: на Cortex‑M3 аппаратных точек шесть, и отладчик не сможет поставить седьмую; и точка в обработчике прерывания останавливает всё, включая тик ядра, — после продолжения задержки в приборе будут искажены.

Точка наблюдения — остановить, когда изменилась переменная. Отвечает на вопрос «кто это записал», который точкой останова не решается: запись может быть из десяти мест. Аппаратных точек наблюдения на Cortex‑M3 четыре, и ставятся они на адрес, а не на имя — отладчик переводит сам.

Стек вызовов — откуда сюда пришли. При остановке в ловушке configASSERT это единственное, что нужно смотреть: третья-четвёртая строка стека называет функцию ядра, которую вызвали неправильно, а пятая — вашу, из которой вызвали.

Окно памяти — сырые байты по адресу. Незаменимо для стека задач (35.4) и буферов DMA (раздел 9): видно не значение переменной, а что на самом деле лежит.

⚠️ Пошаговое выполнение под ядром обманывает. Шаг через vTaskDelay проходит мгновенно — потому что отладчик остановил всё, включая тик, и с точки зрения задачи время не идёт. Реальное поведение по времени смотрят не шагами, а точками останова в нужных местах и выводом через SWO (раздел 21).

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


35.2 Отказ процессора: как читать HardFault

Прибор встал, отладчик показывает HardFault_Handler и бесконечный цикл в нём. Дальше — по шагам, и ответ находится всегда.

Шаг 1: где

При входе в исключение процессор укладывает в стек восемь слов (раздел 7). Седьмое — PC в момент отказа: адрес инструкции, на которой сломалось.

Смещение Слово
0 r0
4 r1
8 r2
12 r3
16 r12
20 lr — откуда пришли
24 pc — где сломалось
28 xPSR

Обработчик, который достаёт кадр и отдаёт в C:

typedef struct {
    uint32_t r0, r1, r2, r3, r12, lr, pc, xpsr;   /* порядок — как в таблице кадра */
} fault_frame_t;

volatile uint32_t g_fault_pc, g_fault_lr, g_cfsr, g_bfar, g_mmfar;

__attribute__((naked)) void HardFault_Handler(void)
{
    __asm volatile (
        "tst   lr, #4     \n"       /* какой стек: MSP или PSP */
        "ite   eq         \n"
        "mrseq r0, msp    \n"
        "mrsne r0, psp    \n"
        "b     hardfault_c\n"
    );
}

void hardfault_c(const fault_frame_t *f)
{
    g_fault_pc = f->pc;
    g_fault_lr = f->lr;
    g_cfsr = SCB->CFSR;
    g_bfar = SCB->BFAR;             /* верен, если в CFSR стоит BFARVALID */
    g_mmfar = SCB->MMFAR;           /* верен, если стоит MMARVALID */
    for (;;) { }                    /* точка останова — сюда */
}

hardfault_c — обычная внешняя функция, не static: на неё по имени переходит команда b из ассемблерной вставки, и компилятор этой ссылки не видит (в опыте ей ставят ещё __attribute__((used))).

Проверка бита 2 в LR обязательна под ядром: задачи работают на PSP, обработчики — на MSP, и кадр отказавшей задачи лежит в её стеке. Без этой проверки вы прочитаете не тот стек и получите мусор.

Дальше — адрес g_fault_pc в окно дизассемблера, и строка найдена.

Шаг 2: почему

Регистр SCB->CFSR делится на три поля под три вида отказов — байт, байт и полуслово:

Биты Отказ Что означает
0–7 MemManage нарушение защиты памяти; без MPU почти не бывает
8–15 BusFault обращение по несуществующему адресу
16–31 UsageFault неизвестная инструкция, деление на ноль, невыровненный доступ

Биты, которые встречаются в реальной жизни:

Бит Имя Обычная причина
25 DIVBYZERO деление на ноль при включённой проверке
24 UNALIGNED слово по адресу, не кратному 4, или полуслово по нечётному: у LDM/STM, LDRD/STRD, LDREX/STREX — всегда, у обычных LDR/STR — только при включённой проверке UNALIGN_TRP
19 NOCP команда FPU при выключенном сопроцессоре: проект собран под FPU, а SCB->CPACR не включён (F4, раздел 69.8)
17 INVSTATE переход на чётный адрес, без бита Thumb: испорченный адрес возврата или указатель на функцию
16 UNDEFINSTR прыжок в данные: испорченный указатель на функцию, переполнение стека
12 STKERR кадр не уложился в стек при входе в исключение: стек вышел за границу ОЗУ; с MPU то же даёт бит 4 MSTKERR
9 PRECISERR обращение по плохому адресу, адрес в BFAR — если стоит бит 15 BFARVALID; без него BFAR к этому отказу не относится
10 IMPRECISERR то же, но адрес неизвестен: запись ушла в буфер шины
7 MMARVALID не причина, а признак: адрес отказа защиты памяти записан в SCB->MMFAR
1 DACCVIOL чтение или запись туда, куда нельзя

⚠️ IMPRECISERR — самый неприятный. Запись по плохому адресу процессор не ждёт: она уходит в буфер шины, а отказ приходит несколькими инструкциями позже. PC в кадре показывает не ту инструкцию. Приём: на время отладки выключить буферизацию записи битом DISDEFWBUF в SCB->ACTLR — отказ станет точным, а PC верным.

Если в SCB->HFSR стоит бит 30 FORCED — отказ эскалирован: это был MemManage, BusFault или UsageFault, но его обработчик выключен или не мог прервать текущий код по приоритету, и процессор поднял отказ до HardFault. Причина и тогда в CFSR — она записывается и при эскалации. Пустой CFSR при HardFault значит, что смотреть надо другие биты HFSR: бит 1 VECTTBL — сбой при чтении вектора из таблицы, бит 31 DEBUGEVT — событие отладки без отладчика, например команда BKPT (раздел 35а; эмулятор там вместо него показывает FORCED).

Расшифровать оба регистра по битам помогает вкладка «Отказ» в калькуляторах приложения: вводите значения CFSR и HFSR — получаете взведённые биты и что проверять по каждому.

Так выглядит настоящий разбор: деление на ноль при включённом DIV_0_TRP, прогон в QEMU (Cortex-M4) с обработчиком из этого подраздела. Операнды команды деления видны прямо в кадре:

Окно отказа: кадр исключения, CFSR с DIVBYZERO, HFSR с FORCED и строка sdiv в дизассемблере

Шаг 3: типовые диагнозы

Картина Диагноз
PC в области данных или мусор, UNDEFINSTR или INVSTATE переполнение стека затёрло адрес возврата
STKERR или MSTKERR стек кончился до входа в обработчик: задаче или прерыванию не хватило стека (35.4)
PRECISERR, BFAR = адрес периферии периферия не тактируется (раздел 6)
PRECISERR, BFAR = маленькое число разыменован нулевой указатель со смещением
IMPRECISERR запись через плохой указатель; включить DISDEFWBUF
UNALIGNED uint32_t * на байтовый буфер; чаще всего — разбор пакета. Проверка выключена, а отказ есть — компилятор склеил два чтения в LDRD или LDM; поля пакета разбирать через memcpy
отказ сразу после vTaskStartScheduler не подменены имена обработчиков (раздел 24)
отказ в vPortValidateInterruptPriority приоритет прерывания выше порога (раздел 28)

35.3 Ядро глазами отладчика

Отладчик сам по себе видит одну задачу — ту, что была прервана. Остальные для него — просто байты в памяти. Три способа увидеть больше.

Понимание ядра в среде

Среды с поддержкой FreeRTOS показывают окно задач: имя, состояние, приоритет, остаток стека, — и окна очередей, семафоров, таймеров. В Keil это подключается вместе с ядром из пака: описание объектов ядра для отладчика лежит там же, где исходники.

⚠️ Очереди и семафоры без имён. Отладчик видит их как адреса. Имена даёт реестр: vQueueAddToRegistry(q, "cmd") — и в окне появляется «cmd» вместо 0x20000A40. Мьютексы и семафоры — тоже очереди, регистрируются той же функцией. Размер реестра — configQUEUE_REGISTRY_SIZE; в изделии его ставят в ноль.

Снимок из программы

Без среды, по SWO или в переменную:

static TaskStatus_t status[8];
uint32_t total_run;

UBaseType_t n = uxTaskGetSystemState(status, 8, &total_run);
/* status[i]: pcTaskName, eCurrentState, uxCurrentPriority,
              uxBasePriority, usStackHighWaterMark, ulRunTimeCounter */

Требует configUSE_TRACE_FACILITY. Описание задачи — 36 байт, массив на восемь задач — 288; заводят его статически один раз.

Поле, на которое стоит смотреть отдельно: uxBasePriority против uxCurrentPriority. Различаются — значит, задача прямо сейчас держит мьютекс, которого ждёт кто-то старше, и её приоритет унаследован (раздел 30). Живая инверсия приоритетов, пойманная на месте.

Функции vTaskList и vTaskGetRunTimeStats печатают то же в текст — удобно, но требуют configUSE_STATS_FORMATTING_FUNCTIONS и тянут за собой форматирующий код со стеком под него.

Статистика загрузки

Единственный честный ответ на «почему тормозит». Ядру нужен счётчик точнее тика, и на Cortex‑M3 он есть — счётчик тактов DWT->CYCCNT:

static void cycle_counter_init(void)
{
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CYCCNT = 0;
    DWT->CTRL  |= DWT_CTRL_CYCCNTENA_Msk;
}

/* в FreeRTOSConfig.h */
#define configGENERATE_RUN_TIME_STATS             1
#define portCONFIGURE_TIMER_FOR_RUN_TIME_STATS()  cycle_counter_init()
#define portGET_RUN_TIME_COUNTER_VALUE()          (DWT->CYCCNT)

Ядро копит разность счётчика на каждом переключении и раскладывает по задачам. Результат — в ulRunTimeCounter каждой задачи и в total_run: доля задачи равна отношению.

⚠️ 32-разрядный счётчик при 72 МГц переполняется за 59 секунд. Для ядра это не беда — оно считает разности, — но доля задачи по абсолютным числам через минуту станет бессмысленной. Штатного сброса статистики в ядре нет. Для долгой работы берут счётчик медленнее — таймер, который тикает в 10–100 раз чаще системного тика: при 10 кГц 32 бит хватает почти на 5 суток. Другой путь — 64-битный тип счётчика (configRUN_TIME_COUNTER_TYPE = uint64_t) и CYCCNT, расширенный программно старшим словом.

Тот же счётчик — лучший инструмент измерения участка кода без осциллографа:

uint32_t t0 = DWT->CYCCNT;
work();
uint32_t cycles = DWT->CYCCNT - t0;     /* верно и через переполнение */

Разрешение — один такт (13,9 нс при 72 МГц). В результат входят и прерывания, пришедшие во время участка: для чистого замера их запрещают или повторяют замер и берут наименьшее значение. Пара тактов уходит на само чтение счётчика — их вычитают, замерив пустой участок.

Счётчик тактов есть у Cortex-M3, M4, M7 и M33. У M0 и M0+ (F0, G0, L0, C0) его нет вовсе: блока DWT в их заголовках нет, код выше не соберётся, и статистику там ведут на таймере общего назначения (раздел 73.1). У Cortex-M7 (F7, H7) у DWT есть замок записи — регистр DWT->LAR (в заголовках M3 и M4 такого поля нет): перед включением в него пишут ключ 0xC5ACCE55, иначе запись в DWT->CTRL не проходит, и CYCCNT остаётся нулём.


35.4 Стек: увидеть, измерить, поймать

Переполнение стека — самая частая причина «работает, но иногда странно». Три уровня защиты.

Увидеть

Стек задачи при создании заполняется байтом 0xA5 — если включено хотя бы одно: configCHECK_FOR_STACK_OVERFLOW больше 1, configUSE_TRACE_FACILITY = 1, INCLUDE_uxTaskGetStackHighWaterMark = 1 или INCLUDE_uxTaskGetStackHighWaterMark2 = 1. Без них заливки нет, в окне памяти лежит то, что было в ОЗУ, и приём ниже молча не работает. Растёт стек вниз, поэтому нетронутые A5 остаются у его начала, по младшим адресам. Окно памяти открывают по адресу начала стека: у статической задачи это имя её массива, у остальных — поле pxStackBase из uxTaskGetSystemState. Адреса в окне растут сверху вниз, и сверху пойдут ряды A5 A5 A5 A5, ниже — данные. Граница между ними — сколько стека было использовано за всё время. Если A5 не осталось вовсе — стек кончался.

Это и есть способ измерения без единой функции: открыть память по адресу начала стека и посмотреть, где кончаются A5.

Стек задачи в окне памяти: верхние строки затёрты данными кадров вызовов, нижние — нетронутый узор A5; красная граница между ними — наибольшее использование за всё время, именно эти слова считает uxTaskGetStackHighWaterMark, а последние 20 байт сверяет ловушка метода 2 при каждом переключении

На рисунке старшие адреса сверху, как стек рисуют обычно: он растёт вниз, от данных к нетронутым A5. В окне отладчика адреса растут сверху вниз, и та же картина перевёрнута — A5 сверху.

Измерить

UBaseType_t words = uxTaskGetStackHighWaterMark(handle);   /* NULL — про себя */

Считает ровно то же: сколько подряд 0xA5 осталось с конца. Отсюда два следствия. Число — в словах. И это наименьший остаток за всё время, а не текущий: затёртый A5 назад не возвращается.

Здоровый остаток — не меньше четверти стека. Измеряют на худшей нагрузке: все прерывания, все ветви, форматированный вывод. Стек, которого хватало на столе, кончается в поле на ветке, которая на столе не выполнялась.

Поймать

#define configCHECK_FOR_STACK_OVERFLOW  2
Метод Как Что ловит
1 при переключении сравнить указатель стека с границей переполнение, которое длится в момент переключения
2 при переключении сверить с образцом 0xA5A5A5A5 четыре слова в начале стека, у младших адресов, — 16 байт переполнение, которое затёрло хвост, даже если уже рассосалось

Метод 2 надёжнее и чуть дороже; на разработке берут его.

⚠️ Оба метода срабатывают при переключении — то есть после порчи. Переполнение уже затёрло то, что лежало ниже стека: чужой стек, блок управления, буфер. Ловушка называет виновника, но не отменяет последствий. Поэтому измерение — до, а ловушка — на всякий случай.

Ловушка:

void vApplicationStackOverflowHook(TaskHandle_t t, char *name)
{
    g_overflow_task_name = name;    /* имя виновника — сюда */
    for (;;) { }
}

Точка останова в ней и переменная name — диагноз за пять секунд.


35.5 Приёмы, которые экономят дни

configASSERT включён всегда на разработке. Ловит неверные приоритеты, вызовы не оттуда, порчу списков — до того, как они проявятся как что-то другое.

Бесконечный цикл в каждой ловушке. Не return, не мигание — цикл, на который можно поставить точку останова и посмотреть стек вызовов.

Отладочные переменные — volatile и глобальные. Иначе компилятор их выбросит, а отладчик покажет «optimized out».

-O0 только для поиска, -O2 для проверки. Ошибка, исчезающая при смене оптимизации, — почти всегда гонка или volatile (раздел 3), а не ошибка компилятора.

Вывод через SWO, а не через printf в UART. Первый стоит единицы тактов и не меняет тайминг; второй — миллисекунды и меняет.

Один канал SWO под метки времени. Записать DWT->CYCCNT в канал на входе и выходе участка — профиль без остановки.

Точка наблюдения на испорченную переменную. Вопрос «кто это записал» решается за минуту, а не за день.

Снимок задач по кнопке. uxTaskGetSystemState по нажатию — и видно, кто чем занят, когда прибор ведёт себя странно.

Заморозить периферию на точке останова. Ядро на паузе стоит, а таймеры, сторожа и RTC — нет: сторож перезагрузит плату прямо из-под отладчика, счётчик уйдёт вперёд, интервал измерится неверно. Блок DBGMCU умеет останавливать эти счётчики вместе с ядром, а HAL даёт на каждый свой макрос:

Семейство Куда пишут макросы Что можно заморозить
F4 DBGMCU->APB1FZ и DBGMCU->APB2FZ 22 макроса: TIM1…TIM14, RTC, IWDG, WWDG, CAN1, CAN2 и тайм-ауты I2C1…I2C3
F1 один DBGMCU->CR те же биты DBG_IWDG_STOP, DBG_TIM2_STOP и прочие, без разделения по шинам
__HAL_DBGMCU_FREEZE_IWDG();      /* сторож не перезагрузит плату на паузе */
__HAL_DBGMCU_FREEZE_TIM2();      /* счётчик остановится вместе с ядром   */
…
__HAL_DBGMCU_UNFREEZE_TIM2();    /* вернуть как было                     */

Биты заморозки действуют, только пока ядро остановлено отладчиком: в изделии без отладчика ядро не останавливается, и сторож работает как обычно (раздел 12). В выпускной сборке опасны соседние биты того же блока — DBG_SLEEP, DBG_STOP, DBG_STANDBY: они оставляют такт отладки во сне, ток сна растёт, а системный сброс их не снимает — только выключение питания (раздел 10).


35.6 Грабли

Под отладчиком vTaskDelay проходит мгновенно. Остановлено всё, включая тик; поведение во времени смотрят точками останова и выводом через SWO (35.1).

PC в кадре отказа показывает не ту команду. Это IMPRECISERR: запись ушла в буфер шины. На время отладки выключить буферизацию битом DISDEFWBUF (35.2).

Очереди в окне отладчика — голые адреса. Имена даёт реестр: vQueueAddToRegistry (35.3).

Статистика задач через минуту бессмысленна. 32-битный счётчик при 72 МГц переполняется за 59 секунд — читать чаще или сбрасывать (35.3).

Ловушка переполнения стека сработала, но данные уже испорчены. Проверка идёт при переключении, то есть после порчи; запас стека измеряют заранее (35.4).


35.7 Итоги

Дальше: раздел 35а «Semihosting: вывод и файлы через отладчик».

Открыть в интерактивном справочнике