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)
с обработчиком из этого подраздела. Операнды команды деления видны прямо в кадре:
Шаг 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. В окне отладчика адреса растут сверху вниз, и та же картина
перевёрнута — 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 Итоги
- Точка наблюдения отвечает на «кто записал»; стек вызовов — на «откуда пришли»; окно памяти показывает, что лежит на самом деле.
- Пошаговое выполнение под ядром обманывает: время стоит.
HardFault: седьмое слово кадра —PCотказа; под ядром выбирать стек по биту 2 вLR.CFSR— байт, байт и полуслово под три отказа;IMPRECISERRпоказывает не ту инструкцию, лечитсяDISDEFWBUF;FORCEDвHFSR— эскалация.- Реестр очередей даёт имена объектам в отладчике; мьютексы — тоже очереди.
uxTaskGetSystemState— снимок всех задач; расхождение базового и текущего приоритета — живая инверсия.- Статистика загрузки — на
DWT->CYCCNT; счётчик переполняется за 59 секунд, читать чаще. - Стек заполнен
0xA5; остаток считается по нетронутым байтам, в словах, и это минимум за всё время. - Проверки переполнения срабатывают при переключении — после порчи; измерение важнее ловушки.
Дальше: раздел 35а «Semihosting: вывод и файлы через отладчик».