Перейти до основного вмісту

Налагодження HardFault на Cortex-M: від загадки до рядка коду

· 10 хв читання

Кожен розробник вбудованих систем знає цей момент: прошивка працювала, а потім перестала. Налагоджувач показує, що програма сидить у нескінченному циклі з назвою на кшталт HardFault_Handler або Default_Handler, а стек викликів — це кілька беззмістовних фреймів. Цей цикл — типовий обробник зі startup-коду, і він не каже абсолютно нічого про те, що сталося. У збою є причина, і процесор уже її записав: у вісім регістрів на стеку та в чотири регістри стану. Їх залишається лише прочитати.

Ця стаття показує, як перетворити цей цикл на рядок вихідного коду, на двох реальних прикладах, які я запускав у QEMU (моделі плати з STM32F4) і досліджував за допомогою GDB: виклик NULL-вказівника на функцію та запис за адресою, де нічого немає. Усі виводи справжні.

Що робить ядро, коли стається збій​

На Cortex-M процесор не падає мовчки. Коли він не може продовжувати (недійсна інструкція, помилка шини, недозволений доступ), він переходить до винятку (exception) і робить дві речі, важливі для налагодження:

  1. Заштовхує вісім регістрів на стек: R0, R1, R2, R3, R12, LR, PC і xPSR. Збережений PC — це інструкція, що спричинила збій, а збережений LR вказує, хто викликав функцію, у якій стався збій.
  2. Встановлює біти в регістрах стану збоїв, які показують, який це був збій: CFSR (configurable fault status, що складається з трьох частин для збоїв пам'яті, шини та використання), HFSR (hard fault status) і регістри адрес MMFAR та BFAR з адресою, до якої було звернення.

Збої пам'яті, шини та використання мають власні обробники, але після скидання вони вимкнені, тому кожен збій ескалується до HardFault. Ось чому один обробник бачить усе, а біт FORCED в HFSR повідомляє, що це сталося.

Обробник, який читає регістри​

Єдина складність — дістатися до стека, де лежать регістри. У ядра є два вказівники стека (MSP для винятків і основного коду, PSP для задач операційної системи), і біт 2 значення в LR на вході у виняток показує, який із них використовувався. Це потрібно зробити до того, як компілятор використає стек для власних потреб, тому обробник — це коротка функція на асемблері (naked: без пролога), яка передає вказівник у функцію на C:

fault.c (the report)
/* ---- the fault report ---- */
#define SCB_CFSR (*(volatile uint32_t *)0xE000ED28u) /* configurable fault status */
#define SCB_HFSR (*(volatile uint32_t *)0xE000ED2Cu) /* hard fault status */
#define SCB_MMFAR (*(volatile uint32_t *)0xE000ED34u)
#define SCB_BFAR (*(volatile uint32_t *)0xE000ED38u)

/* The registers that the core pushed on the stack when the fault happened. */
typedef struct
{
uint32_t r0, r1, r2, r3, r12;
uint32_t lr; /* where the faulting code was called from */
uint32_t pc; /* the instruction that caused the fault */
uint32_t xpsr;
} stackFrame_t;

volatile struct
{
stackFrame_t frame;
uint32_t cfsr, hfsr, mmfar, bfar;
} faultInfo;

void HardFault_Report(const stackFrame_t *frame) __attribute__((used));
void HardFault_Report(const stackFrame_t *frame)
{
faultInfo.frame = *frame;
faultInfo.cfsr = SCB_CFSR;
faultInfo.hfsr = SCB_HFSR;
faultInfo.mmfar = SCB_MMFAR;
faultInfo.bfar = SCB_BFAR;

Print("*** HardFault ***\n");
PrintHex("PC ", frame->pc);
PrintHex("LR ", frame->lr);
PrintHex("xPSR ", frame->xpsr);
PrintHex("CFSR ", faultInfo.cfsr);
PrintHex("HFSR ", faultInfo.hfsr);

for (;;) { } /* stay here, so that a debugger can look at the state */
}

/* The core uses the main or the process stack, depending on bit 2 of EXC_RETURN (in LR). */
__attribute__((naked)) void HardFault_Handler(void)
{
__asm__ volatile (
"tst lr, #4 \n"
"ite eq \n"
"mrseq r0, msp \n"
"mrsne r0, psp \n"
"b HardFault_Report \n"
);
}

Погляньте на три частини. Структура stackFrame_t — це розташування восьми регістрів у порядку, в якому їх заштовхує ядро. Функція HardFault_Report() копіює їх (разом з регістрами стану) у глобальну змінну faultInfo, друкує короткий звіт, а потім навмисно залишається в циклі, щоб налагоджувач міг підключитися й подивитися на змінну. Асемблерна заглушка вибирає правильний стек і переходить до функції. У продукті звіт записувався б у пам'ять, що переживає скидання, а прошивка самостійно перезавантажувалася б, але принцип той самий.

Приклад 1: виклик NULL-вказівника​

Ось застосунок із помилкою. Callback реєструється пізніше, а код, який його викликає, виконується раніше:

fault.c (the application)
/* ---- the application with a bug ---- */
typedef void (*callback_t)(void);

static callback_t onButtonPressed; /* will be registered later: it is NULL at the start */

void Button_Pressed(void)
{
onButtonPressed(); /* the bug: the callback is called before it is set */
}

int main(void)
{
Print("start\n");
Button_Pressed();
Print("never printed\n");
return 0;
}

Програма друкує start, а потім звіт:

start
*** HardFault ***
PC 0x00000000
LR 0x08000115
xPSR 0x60000000
CFSR 0x00020000
HFSR 0x40000000

Тепер його розбір, значення за значенням:

  • PC = 0x00000000: процесор спробував виконати інструкцію за нульовою адресою. Майже завжди це означає перехід через NULL-вказівник на функцію або повернення за пошкодженою адресою.
  • CFSR = 0x00020000: біт 17 — це INVSTATE, «недійсний стан». Процесор спробував виконувати код у стані ARM, якого в Cortex-M немає. Причина — в xPSR: його біт 24 — це біт T (Thumb), і тут він нульовий (0x60000000). Адреса Thumb-функції має встановлений наймолодший біт, а NULL його не має, тож перехід через вказівник скинув біт T. Нульовий біт T — типовий відбиток переходу на NULL.
  • HFSR = 0x40000000: біт 30 — це FORCED, збій було ескальовано до HardFault зі збою використання (usage fault), який не ввімкнено.
  • LR = 0x08000115: адреса повернення з встановленим наймолодшим бітом (Thumb). Це адреса викликача, 0x08000114, і саме там помилка.

З налагоджувачем останній крок займає лічені секунди. QEMU чекає на GDB (-S -gdb tcp::1234), і сесія виглядає так:

(gdb) break fault.c:64
(gdb) continue
Breakpoint 1, HardFault_Report (frame=0x2001ffc0) at fault.c:64
(gdb) print/x faultInfo.frame
$1 = {r0 = 0xdeadbeef, r1 = 0x80001f4, r2 = 0x8000220, r3 = 0x0, r12 = 0x0,
lr = 0x8000115, pc = 0x0, xpsr = 0x60000000}
(gdb) print/x faultInfo.cfsr
$2 = 0x20000
(gdb) info symbol faultInfo.frame.lr
Button_Pressed + 7 in section .text
(gdb) list *(faultInfo.frame.lr & ~1u)
0x8000114 is in Button_Pressed (fault.c:87).
82 static callback_t onButtonPressed; /* will be registered later: it is NULL at the start */
84 void Button_Pressed(void)
85 {
86 onButtonPressed(); /* the bug: the callback is called before it is set */
87 }
(gdb) x/2i (faultInfo.frame.lr & ~1u) - 4
0x8000110 <Button_Pressed+2>: movs r3, #0
0x8000112 <Button_Pressed+4>: blx r3

info symbol дає функцію, list * дає рядок (той, що після виклику, бо це адреса повернення), а дизасемблер показує пару інструкцій, яка і є самою помилкою: завантаження нуля в r3 та blx r3, виклик через регістр, що містить NULL. Зверніть увагу на r3 = 0x0 у збережених регістрах. Без налагоджувача ту саму відповідь дає одна команда toolchain, якій потрібні лише адреса зі звіту та файл з налагоджувальною інформацією (-g):

$ arm-none-eabi-addr2line -e fault.elf -f 0x08000114
Button_Pressed
fault.c:87

Приклад 2: запис за адресою, де нічого немає​

Я змінив один рядок застосунку: замість callback функція пише за адресою 0xA0000000, де в моделі чипа нічого не відображено. Звіт:

*** HardFault ***
PC 0x08000124
LR 0x08000199
xPSR 0x21000000
CFSR 0x00008200
HFSR 0x40000000

Цього разу PC — це дійсна адреса у flash, біт T встановлено (0x21000000), а CFSR інший: 0x8200 — це два біти, 9 (PRECISERR, точна помилка шини даних) і 15 (BFARVALID, регістр BFAR містить адресу). У GDB:

(gdb) print/x faultInfo.cfsr
$1 = 0x8200
(gdb) print/x faultInfo.bfar
$2 = 0xa0000000
(gdb) info symbol faultInfo.frame.pc
main + 12 in section .text

BFAR — це адреса, за якою програма намагалася писати, PC — інструкція, що намагалася це зробити. Разом вони кажуть: «ця інструкція писала за цією адресою», і це повний опис проблеми. Якщо адреса мала (0x00000008), це NULL-вказівник на структуру, і ви звертаєтеся до її поля. Якщо вона схожа на дані (0x20000000 і трохи більше), то це стек або буфер, який ви переповнили.

Біти CFSR, які ви зустрінете​

Регістр є комбінацією трьох регістрів (кожен має власний байт або два байти). Ось ті, які ви бачитимете найчастіше (два, які я спостерігав у прикладах, виділено):

БітНазваЗначення
0IACCVIOLвибірка інструкції з недозволеної області (MPU)
1DACCVIOLдоступ до даних у недозволеній області (MPU)
7MMARVALIDрегістр MMFAR містить адресу
8IBUSERRпомилка шини під час вибірки інструкції
9PRECISERRточна помилка шини під час доступу до даних: PC — це інструкція, що її спричинила
10IMPRECISERRнеточна помилка шини: інструкція вже виконалася, PC ненадійний
11, 12UNSTKERR, STKERRпомилка шини під час витягування або заштовхування регістрів (часто пошкоджений стек)
15BFARVALIDрегістр BFAR містить адресу
16UNDEFINSTRневизначена інструкція (виконання даних, пошкоджений код)
17INVSTATEспроба виконання в стані ARM: перехід за адресою з очищеним бітом 0
18INVPCнедійсний EXC_RETURN (пошкоджений стек винятку)
19NOCPдоступ до співпроцесора, який не ввімкнено (зазвичай FPU, який не було увімкнено)
24UNALIGNEDневирівняний доступ, коли trap увімкнено
25DIVBYZEROділення на нуль, коли trap увімкнено

Що зазвичай стоїть за HardFault​

Що ви бачитеЧим це зазвичай є
PC = 0, INVSTATEвиклик через NULL-вказівник на функцію або повернення за пошкодженою адресою
PRECISERR і малий BFARNULL-вказівник на структуру
PRECISERR і BFAR в діапазоні адрес периферіїдоступ до недоступної периферії, наприклад такої, тактування якої не ввімкнено (в одних сімействах такий доступ — це помилка шини, в інших він мовчки ігнорується)
STKERR / UNSTKERR або PC, що не має сенсупереповнення стека або буфера, яке пошкодило адресу повернення
NOCP на першій інструкції з плаваючою комоюFPU не було увімкнено у startup-коді
IMPRECISERRзапис, що пройшов через буфер запису: звіт приходить пізніше за інструкцію. Для налагодження буферизацію можна вимкнути (біт DISABLEDEFWRITEBUF в ACTLR), і тоді збій стає точним

Я продемонстрував перший рядок таблиці та принцип другого (помилка шини з адресою в BFAR). Решта рядків — це підсумок типових причин, вони випливають зі значення бітів у reference manual ядра.

Практика​

  1. Ніколи не залишайте типовий обробник порожнім циклом. Обробник вище займає близько тридцяти рядків і перетворює збій на звіт із місцем у коді. Додайте його до startup-коду проєкту один раз.
  2. Зберігайте налагоджувальну інформацію у файлі, який архівуєте разом з релізом (-g і .elf), щоб адресу зі звіту з поля можна було перетворити на рядок, навіть якщо прошивка постачається зі stripped-символами. Це ще одна причина для відтворюваної збірки: адреса має сенс лише для точного бінарного файлу, який її створив.
  3. Вмикайте тонші збої (біти в SHCSR), коли хочете окремі обробники UsageFault, BusFault і MemManage. Звіт буде багатшим, бо регістри стану не спільні.
  4. Зберігайте звіт у пам'яті, що переживає скидання (секція, яку startup-код не очищує, див. статтю про те, що відбувається перед main()), перезавантажуйтеся й надсилайте його після перезапуску. Звіт з пристрою в полі вартий більше за сотню здогадок.
  5. Запобігайте тому, чому можете. NULL-виклик у прикладі — це дефект, який виявили б правила MISRA та unit-тест модуля (вказівник має бути встановлений до першого виклику), а переповнення стека — це те, для чого потрібно знати використання стека (правило проти рекурсії).

Та сама сесія на платі​

Я користувався QEMU, бо йому не потрібна плата, а його GDB-сервер (-S -gdb tcp::1234) поводиться так само, як сервер налагоджувального зонда: команди в прикладах — це команди, які ви вводите із зондом. Платформа має артефакт для набору інструментів probe-rs (набір інструментів налагодження для ARM-цілей через налагоджувальний зонд, із DAP-сервером), який є способом підключити GDB або IDE до справжньої плати. Я не запускав його для цієї статті, бо в моєму середовищі немає зонда.

Підсумок​

  1. HardFault — не загадка: ядро заштовхнуло регістри та встановило біти стану.
  2. Обробнику потрібно знайти правильний стек (EXC_RETURN) і прочитати фрейм, CFSR, HFSR та BFAR.
  3. PC і LR дають місце, CFSR — вид збою, BFAR — адресу.
  4. PC = 0 з INVSTATE — це перехід на NULL, і це показує біт T в xPSR.
  5. Зберігайте .elf, і адреса зі звіту з поля стане рядком коду.