Part 2 · C Meets the Metal39 min read
Watchdogs, Faults, and Recovering from Them
Firmware ships with bugs. Some of them crash the CPU and some just make it wait forever for a flag that never comes. On a desktop a crashed program gets restarted by the OS and the user sees a dialog. A thermostat or an infusion pump has no OS and no user watching, so the firmware itself must detect that it has failed, get back to a known state, and leave enough evidence behind for you to find the bug. In this lesson you will configure the independent watchdog so it proves the whole system is alive, read why the chip last reset, write a complete Cortex-M4 HardFault handler that dumps the faulting context, decode the fault status registers, find the faulting source line from a program counter, and save a crash record that survives the reset.
The independent watchdog
Section titled “The independent watchdog”A watchdog is a down-counter that resets the chip when it reaches zero. Software must reload (“feed”, “kick”) it regularly. If the software hangs, it stops feeding, and the watchdog resets the system.
The STM32F407’s independent watchdog (IWDG) is clocked by the internal LSI RC oscillator, nominally 32 kHz, which is separate from the main clock tree. If your clock configuration breaks, the PLL loses lock or the code disables the HSE, the IWDG keeps counting. Once started it cannot be stopped by anything except a reset.
IWDG sits at 0x40003000:
| Offset | Register | Use |
|---|---|---|
0x00 | KR | key register: 0xCCCC start, 0xAAAA reload, 0x5555 unlock PR/RLR |
0x04 | PR | prescaler, 3 bits: 0 = /4, 1 = /8, 2 = /16, 3 = /32, 4 = /64, 5 = /128, 6 and 7 = /256 |
0x08 | RLR | reload value, 12 bits (0 to 4095) |
0x0C | SR | PVU (bit 0), RVU (bit 1): a PR or RLR update is still in progress |
The keys are there so that a wild pointer write cannot accidentally reconfigure or feed the watchdog. PR and RLR are write-protected until 0x5555 is written to KR, and the protection comes back on the next reload.
The timeout is:
t = 4 * 2^PR * (RLR + 1) / f_LSI
PR = 3 (/32), RLR = 999, f_LSI = 32 kHz:t = 32 * 1000 / 32000 = 1.0 sThe LSI is an RC oscillator, and the datasheet allows it anywhere from about 17 kHz to 47 kHz. The same configuration gives a timeout between roughly 0.68 s and 1.9 s across chips and temperature. Design the feeding interval for the shortest timeout and the recovery-time requirement for the longest. If you need better, measure the LSI against a timer at boot (TIM5 can capture the LSI on the F4) and compute RLR from the real frequency.
Starting it:
IWDG->KR = 0xCCCCu; /* start: also forces the LSI on */IWDG->KR = 0x5555u; /* unlock PR and RLR */IWDG->PR = 3u; /* /32: 1 kHz tick at 32 kHz */IWDG->RLR = 999u; /* 1000 ticks: about 1 s */while (IWDG->SR != 0u) { } /* wait until the values cross into the LSI domain */IWDG->KR = 0xAAAAu; /* reload with the new value */The SR wait exists because the IWDG registers live in the slow LSI clock domain; a write takes several LSI cycles (hundreds of microseconds) to land. Writing PR again before PVU clears is ignored.
Two other ways the IWDG gets started: the hardware watchdog option bit (WDG_SW in the option bytes) makes it start automatically at reset before any code runs, which protects against a firmware bug that never reaches the start code. And it keeps counting while a debugger has the core halted, so a breakpoint becomes a reset a second later. Set the freeze bit DBG_IWDG_STOP (bit 12 of DBGMCU_APB1_FZ at 0xE0042008) in debug builds.
The window watchdog
Section titled “The window watchdog”The window watchdog (WWDG) is clocked from APB1 and has a 7-bit down-counter. It resets the chip when the counter drops from 0x40 to 0x3F, as you would expect, but also if software reloads it too early, while the counter is still above a programmed window value. So it catches code that runs too slowly and code that runs too fast, such as a control loop that skips its work and spins around feeding the dog. It can also raise an early-warning interrupt one tick before the reset, which is a last chance to save state. Because it depends on the main clock, it does not catch a dead clock tree; the IWDG does. Products with strict safety requirements often run both.
A feeding strategy that proves liveness
Section titled “A feeding strategy that proves liveness”The watchdog only proves what your feeding code proves. The classic mistake:
void TIM3_IRQHandler(void){ TIM3->SR = 0u; IWDG->KR = 0xAAAAu; /* WRONG: feeds even if main() is stuck forever */}A timer interrupt keeps firing while main is deadlocked on a flag, so the watchdog never bites. It has been reduced to a check that the timer peripheral is still clocked.
A watchdog should be fed only when every part of the system that matters has shown recent progress. The pattern:
- Each task or subsystem has a bit in an “alive” mask.
- When the subsystem completes a unit of real work (processed a sample, handled a message, finished a control cycle), it sets its bit.
- A single place, the lowest-priority loop in the system (the superloop, or the idle task under an RTOS), checks the mask. Only if all bits are set does it feed the watchdog and clear the mask.
enum { ALIVE_TICK = 1u << 0, /* SysTick ISR ran */ ALIVE_BLINK = 1u << 1, /* LED task ran */ ALIVE_BUTTON = 1u << 2, /* input task ran */ ALIVE_ALL = ALIVE_TICK | ALIVE_BLINK | ALIVE_BUTTON,};Now if any subsystem stalls, its bit stays clear, nobody feeds, and the system resets. If an interrupt storm starves the main loop, the main loop does not run, nobody feeds, and the system resets. Feeding from the lowest priority level also proves that higher priorities are not hogging the CPU.
Rules that go with it:
- Never feed inside a loop that waits for hardware. A
while (!(SPI1->SR & TXE)) { feed(); }turns a hung peripheral into a hung product that the watchdog cannot rescue. Waits get timeouts instead (see SPI and I2C). - Do not feed during long flash erases by sprinkling feeds everywhere. Pick a timeout longer than the longest legitimate operation, or feed once in a well-defined place around it.
- Check in after work, not at the top of a loop. A task that checks in before blocking on a dead queue proves nothing.
Brown-out and the reset flags
Section titled “Brown-out and the reset flags”The other cause of mysterious behaviour is power. If the supply sags below the voltage at which the flash and logic work reliably, the CPU can execute garbage without crashing cleanly. The brown-out reset (BOR) holds the chip in reset while the supply is below a threshold. On the F4 the threshold is set by the BOR_LEV option bits (in FLASH_OPTCR bits 3:2): 11 = BOR off (only the fixed power-on/power-down reset near 1.8 V), 10 = level 1 (about 2.1 V), 01 = level 2 (about 2.4 V), 00 = level 3 (about 2.7 V). For a 3.3 V product, level 3 is the usual choice. Option bytes are covered in Flash, EEPROM, and non-volatile storage.
When the chip resets, RCC_CSR (at 0x40023874, offset 0x74 in RCC) records why:
| Bit | Flag | Set by |
|---|---|---|
| 24 | RMVF | write 1 to clear all the flags below |
| 25 | BORRSTF | brown-out reset (also set on power-on) |
| 26 | PINRSTF | NRST pin low (also set by every internal reset, which drives NRST) |
| 27 | PORRSTF | power-on or power-down reset |
| 28 | SFTRSTF | software reset via AIRCR.SYSRESETREQ |
| 29 | IWDGRSTF | independent watchdog |
| 30 | WWDGRSTF | window watchdog |
| 31 | LPWRRSTF | illegal low-power mode entry |
The flags are sticky: they accumulate across resets until you write RMVF. So read the register once at boot, clear it, and interpret the value you read. Check the specific causes first and PINRSTF last, because every reset sets it. And log the cause: a field device that reports “47 watchdog resets this week” has told you something no lab test will.
The Cortex-M fault exceptions
Section titled “The Cortex-M fault exceptions”When the CPU cannot complete an instruction, it raises a fault exception. The Cortex-M4 has four:
| Exception | Number | Vector offset | Caused by |
|---|---|---|---|
| HardFault | 3 | 0x0C | escalated faults, vector table read errors; always enabled |
| MemManage | 4 | 0x10 | MPU violations, execute from an execute-never region |
| BusFault | 5 | 0x14 | bus error on instruction fetch, data access, or exception stacking |
| UsageFault | 6 | 0x18 | undefined instruction, invalid state, divide by zero (if enabled), unaligned access (if enabled) |
MemManage, BusFault and UsageFault are configurable: they are disabled after reset. A disabled configurable fault escalates to HardFault, and so does any fault that occurs while its handler cannot run (inside a handler of equal or higher priority, or with PRIMASK set). So on a fresh chip everything arrives at HardFault, with HFSR.FORCED (bit 30) set to say “this was escalated, look at CFSR for the original”.
Enable the separate handlers in SHCSR so each fault arrives at its own vector at a configurable priority:
SCB->SHCSR |= (1u << 16) /* MEMFAULTENA */ | (1u << 17) /* BUSFAULTENA */ | (1u << 18); /* USGFAULTENA */SCB->CCR |= (1u << 4); /* DIV_0_TRP: trap integer divide by zero */Without DIV_0_TRP, SDIV/UDIV by zero silently returns 0. (CCR bit 3, UNALIGN_TRP, similarly traps all unaligned accesses; leave it off unless you are hunting for them.)
What the hardware leaves you
Section titled “What the hardware leaves you”When any exception is taken, the core pushes eight words onto the active stack, then loads lr with a special EXC_RETURN value. If the faulting code was using the FPU, an extended frame including s0–s15 and FPSCR is pushed (26 words), but the first eight words are laid out the same either way.
higher addresses +-----------------+ | (s0-s15, FPSCR) | only if EXC_RETURN bit 4 == 0 +1C | xPSR | +18 | PC | address of the faulting instruction (precise faults) +14 | LR | return address of the function that faulted +10 | R12 | +0C | R3 | +08 | R2 | +04 | R1 | +00 | R0 | <- SP after stacking (MSP or PSP) +-----------------+ lower addressesThe frame went onto whichever stack the interrupted code was using: MSP for bare metal and for handlers, PSP for RTOS tasks. EXC_RETURN tells you which. Its bit 2 is 0 if the frame is on MSP and 1 if it is on PSP; bit 4 is 0 if the FPU frame was pushed. Typical values: 0xFFFFFFF9 (thread mode, MSP, no FPU frame), 0xFFFFFFFD (thread mode, PSP), 0xFFFFFFF1 (handler mode, MSP), and 0xFFFFFFE9/0xFFFFFFED with an FPU frame.
The fault status registers in the SCB explain what went wrong:
| Address | Register | Contents |
|---|---|---|
0xE000ED28 | CFSR | Configurable Fault Status: MMFSR (bits 7:0), BFSR (15:8), UFSR (31:16). Write 1 to clear. |
0xE000ED2C | HFSR | VECTTBL (1) vector read error, FORCED (30) escalated, DEBUGEVT (31) |
0xE000ED34 | MMFAR | address that caused a MemManage fault, valid if MMARVALID |
0xE000ED38 | BFAR | address that caused a BusFault, valid if BFARVALID |
The handler: a naked stub and a C function
Section titled “The handler: a naked stub and a C function”The handler must find the stacked frame before the C compiler has touched the stack. A normal C function starts with a prologue that may push registers and move sp, and it has no way to choose between MSP and PSP. So the entry point is a naked function (no prologue, no epilogue, body is pure assembly) that selects the right stack pointer and branches to a C function with it as the first argument.
#ifndef FAULT_H#define FAULT_H
#include <stdbool.h>#include <stdint.h>
typedef struct { uint32_t magic; uint32_t count; /* faults since the record was last cleared */ uint32_t ipsr; /* 3 HardFault, 4 MemManage, 5 BusFault, 6 UsageFault, 0 assert */ uint32_t r0, r1, r2, r3, r12, lr, pc, xpsr; uint32_t sp, exc_return; uint32_t cfsr, hfsr, mmfar, bfar; uint32_t assert_file; /* address of __FILE__ string in flash */ uint32_t assert_line; uint32_t check; /* ~sum of every word above */} crash_record_t;
void fault_init(void);bool fault_take_record(crash_record_t *out);_Noreturn void assert_failed(const char *file, int line);
#define ASSERT(c) do { if (!(c)) { assert_failed(__FILE__, __LINE__); } } while (0)
#endif#include <stddef.h>#include <stdint.h>#include <stdbool.h>#include "stm32f407_regs.h"#include "fault.h"
#define CRASH_NEW 0xC0FFEE42u /* record present, not yet reported */#define CRASH_SEEN 0x5EE45EE4u /* record reported, count still valid */
#define SCB_SHCSR_MEMFAULTENA (1u << 16)#define SCB_SHCSR_BUSFAULTENA (1u << 17)#define SCB_SHCSR_USGFAULTENA (1u << 18)#define SCB_CCR_DIV_0_TRP (1u << 4)#define SCB_AIRCR_VECTKEY (0x5FAu << 16)#define SCB_AIRCR_SYSRESETREQ (1u << 2)#define USART_SR_TC (1u << 6)#define USART_SR_TXE (1u << 7)
typedef struct { uint32_t r0, r1, r2, r3, r12, lr, pc, xpsr;} exc_frame_t;
/* Not zeroed by startup: survives a reset (needs .noinit in link.ld). */__attribute__((section(".noinit"))) static crash_record_t crash;
__attribute__((used, noreturn)) void fault_c(const exc_frame_t *f, uint32_t exc_return);
static uint32_t record_sum(const crash_record_t *r){ const uint32_t *w = (const uint32_t *)r; uint32_t s = 0u; for (size_t i = 0; i < offsetof(crash_record_t, check) / sizeof(uint32_t); i++) { s += w[i]; } return ~s;}
static bool record_valid(void){ return (crash.magic == CRASH_NEW || crash.magic == CRASH_SEEN) && crash.check == record_sum(&crash);}
void fault_init(void){ if (!record_valid()) { /* power-on: RAM holds garbage */ crash_record_t empty = { 0 }; empty.magic = CRASH_SEEN; crash = empty; crash.check = record_sum(&crash); } SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA | SCB_SHCSR_BUSFAULTENA | SCB_SHCSR_USGFAULTENA; SCB->CCR |= SCB_CCR_DIV_0_TRP;}
bool fault_take_record(crash_record_t *out){ if (!record_valid() || crash.magic != CRASH_NEW) { return false; } *out = crash; crash.magic = CRASH_SEEN; crash.check = record_sum(&crash); return true;}
/* ---- Minimal polled output: no printf, no heap, little stack ---- */static void fput(const char *s){ while (*s != '\0') { while ((USART2->SR & USART_SR_TXE) == 0u) { } USART2->DR = (uint8_t)*s++; }}
static void fhex(const char *label, uint32_t v){ char b[11] = "0x00000000"; for (uint32_t i = 0; i < 8u; i++) { uint32_t n = (v >> (28u - 4u * i)) & 0xFu; b[2u + i] = (char)(n < 10u ? '0' + n : 'A' + (n - 10u)); } fput(label); fput(b); fput("\r\n");}
static const char *exc_name(uint32_t ipsr){ switch (ipsr) { case 0u: return "assert"; case 3u: return "HardFault"; case 4u: return "MemManage"; case 5u: return "BusFault"; case 6u: return "UsageFault"; default: return "unexpected exception"; }}
static const struct { uint8_t bit; const char *name;} cfsr_bits[] = { { 0, "IACCVIOL" }, { 1, "DACCVIOL" }, { 3, "MUNSTKERR" }, { 4, "MSTKERR" }, { 5, "MLSPERR" }, { 7, "MMARVALID" }, { 8, "IBUSERR" }, { 9, "PRECISERR" }, { 10, "IMPRECISERR" }, { 11, "UNSTKERR" }, { 12, "STKERR" }, { 13, "LSPERR" }, { 15, "BFARVALID" }, { 16, "UNDEFINSTR" }, { 17, "INVSTATE" }, { 18, "INVPC" }, { 19, "NOCP" }, { 24, "UNALIGNED" }, { 25, "DIVBYZERO" },};
static _Noreturn void system_reset(void){ while ((USART2->SR & USART_SR_TC) == 0u) { } /* let the last byte out */ __asm volatile ("dsb" ::: "memory"); /* all stores complete */ SCB->AIRCR = SCB_AIRCR_VECTKEY | SCB_AIRCR_SYSRESETREQ; __asm volatile ("dsb" ::: "memory"); for (;;) { }}
static _Noreturn void commit_and_reset(crash_record_t *r){ r->count = crash.count + 1u; r->cfsr = SCB->CFSR; r->hfsr = SCB->HFSR; r->mmfar = SCB->MMFAR; r->bfar = SCB->BFAR; r->magic = CRASH_NEW; crash = *r; crash.check = record_sum(&crash);
fput("\r\n*** "); fput(exc_name(r->ipsr)); fput("\r\n"); fhex(" r0 = ", r->r0); fhex(" r1 = ", r->r1); fhex(" r2 = ", r->r2); fhex(" r3 = ", r->r3); fhex(" r12 = ", r->r12); fhex(" lr = ", r->lr); fhex(" pc = ", r->pc); fhex(" xpsr = ", r->xpsr); fhex(" sp = ", r->sp); fhex(" EXC_RETURN = ", r->exc_return); fhex(" CFSR = ", r->cfsr); fhex(" HFSR = ", r->hfsr); fhex(" MMFAR= ", r->mmfar); fhex(" BFAR = ", r->bfar); fput(" CFSR bits:"); for (size_t i = 0; i < sizeof cfsr_bits / sizeof cfsr_bits[0]; i++) { if ((r->cfsr & (1u << cfsr_bits[i].bit)) != 0u) { fput(" "); fput(cfsr_bits[i].name); } } fput("\r\nResetting.\r\n"); system_reset();}
void fault_c(const exc_frame_t *f, uint32_t exc_return){ crash_record_t r = { 0 }; uint32_t ipsr;
__asm volatile ("mrs %0, ipsr" : "=r" (ipsr)); r.ipsr = ipsr & 0x1FFu; r.r0 = f->r0; r.r1 = f->r1; r.r2 = f->r2; r.r3 = f->r3; r.r12 = f->r12; r.lr = f->lr; r.pc = f->pc; r.xpsr = f->xpsr; r.exc_return = exc_return;
/* SP before the exception: frame + frame size + optional alignment pad */ uint32_t frame = ((exc_return & (1u << 4)) == 0u) ? 0x68u : 0x20u; uint32_t pad = ((f->xpsr & (1u << 9)) != 0u) ? 4u : 0u; r.sp = (uint32_t)(uintptr_t)f + frame + pad;
commit_and_reset(&r);}
__attribute__((naked)) void HardFault_Handler(void){ __asm volatile ( "tst lr, #4 \n" /* EXC_RETURN bit 2: 0 = MSP, 1 = PSP */ "ite eq \n" "mrseq r0, msp \n" /* r0 = address of the stacked frame */ "mrsne r0, psp \n" "mov r1, lr \n" /* r1 = EXC_RETURN */ "b fault_c \n" );}
/* All configurable faults share the same entry. */void MemManage_Handler(void) __attribute__((alias("HardFault_Handler")));void BusFault_Handler(void) __attribute__((alias("HardFault_Handler")));void UsageFault_Handler(void) __attribute__((alias("HardFault_Handler")));
_Noreturn void assert_failed(const char *file, int line){ crash_record_t r = { 0 };
__asm volatile ("cpsid i" ::: "memory"); r.ipsr = 0u; r.pc = (uint32_t)(uintptr_t)__builtin_return_address(0); /* call site */ r.assert_file = (uint32_t)(uintptr_t)file; r.assert_line = (uint32_t)line; commit_and_reset(&r);}The stub is six instructions, and each one matters:
tst lr, #4tests bit 2 ofEXC_RETURN(which the hardware placed inlr).ite eqmakes the next two instructions conditional: if the bit is 0,mrseq r0, mspcopies the main stack pointer; otherwisemrsne r0, pspcopies the process stack pointer. Nowr0points at the stackedr0.mov r1, lrpassesEXC_RETURNas the second argument, so the C code can tell whether an FPU frame was pushed.b fault_cis a plain branch, not a call. By the AAPCS,fault_creceives its two arguments inr0andr1. It never returns, so the link register does not matter.nakedguarantees the compiler adds nothing before the first instruction. The asm must not use C operands or locals; it can only reference global symbols, which is whyfault_cis a non-static function markedused(so it is not discarded or renamed even though C code never calls it).
The C side copies the frame into a record, computes the pre-fault sp (the frame address plus 32 or 104 bytes, plus 4 if xPSR bit 9 says the hardware inserted an alignment word), reads the four status registers, and prints everything with a tiny polled UART routine. It does not use printf: after a fault the heap, the stdout buffer or the stack may be the very thing that is broken, and printf uses a lot of all three.
The three other fault vectors are aliases of HardFault_Handler, so whichever fault fires, the same code runs, and IPSR (read with mrs) tells which exception is active. Because these definitions are strong, they override the weak defaults in the startup file from Startup code and the vector table.
Decoding CFSR
Section titled “Decoding CFSR”CFSR is three registers in one word. The bits you will meet, with their usual cause:
| Bit | Name | Meaning | Usual cause |
|---|---|---|---|
| 0 | IACCVIOL | MPU: instruction fetch violation | jumping into an execute-never region, corrupted function pointer |
| 1 | DACCVIOL | MPU: data access violation (MMFAR valid) | null-pointer deref with an MPU guard at 0, stack hitting a guard region |
| 3 | MUNSTKERR | MPU fault on exception return unstacking | corrupted stack pointer in an RTOS task |
| 4 | MSTKERR | MPU fault while stacking on exception entry | stack overflow into a guard region |
| 8 | IBUSERR | bus error on instruction fetch | branch to an unmapped address |
| 9 | PRECISERR | precise data bus error (BFAR valid) | read or write of an unmapped or disabled peripheral address |
| 10 | IMPRECISERR | imprecise data bus error, BFAR not valid | buffered write to a bad address; stacked pc is after the culprit |
| 11 | UNSTKERR | bus fault on exception return | corrupted stack during return from interrupt |
| 12 | STKERR | bus fault while stacking | stack pointer outside RAM: stack overflow |
| 16 | UNDEFINSTR | undefined instruction | executing data, a corrupted code pointer, wrong -mcpu |
| 17 | INVSTATE | invalid EPSR state (Thumb bit 0) | calling a function pointer with bit 0 clear, e.g. a raw even address |
| 18 | INVPC | invalid EXC_RETURN on exception return | stack corruption in an ISR, bad context switch |
| 19 | NOCP | no coprocessor | FPU instruction with the FPU disabled in CPACR |
| 24 | UNALIGNED | unaligned access trapped | LDRD/LDM on an unaligned address, or any unaligned access with UNALIGN_TRP |
| 25 | DIVBYZERO | divide by zero trapped | integer division by zero with DIV_0_TRP |
MMARVALID (bit 7) and BFARVALID (bit 15) say whether MMFAR and BFAR hold the faulting address. Ignore those registers when the bit is clear; they contain stale values.
Finding the line from pc
Section titled “Finding the line from pc”For most faults the stacked pc is the address of the instruction that faulted, and the stacked lr is the return address into its caller. Turn them into source lines with the ELF file that was flashed (it must be the exact build, with -g):
arm-none-eabi-addr2line -e build/firmware.elf -f -i 0x080003A8 0x08000446test_bad_read/home/me/fw/src/main.c:58main/home/me/fw/src/main.c:142-f prints the function name and -i unwinds inlined functions, which matters at -O2. To see the exact instruction and the registers it used:
arm-none-eabi-objdump -d -S --start-address=0x08000390 --stop-address=0x080003B0 build/firmware.elfstatic void test_bad_read(void){ volatile uint32_t v = *(volatile uint32_t *)0x30000000u; 80003a4: f04f 5340 mov.w r3, #805306368 @ 0x30000000 80003a8: 681b ldr r3, [r3, #0]Now the dumped registers make sense: you can see which register held the bad address. lr with bit 0 set (Thumb) is normal; subtract 1 for the address. If lr is 0xFFFFFFF9-style, the fault happened inside an exception handler, and the interrupted context is one frame further up the stack.
Imprecise bus faults
Section titled “Imprecise bus faults”Stores on the Cortex-M4 go through a write buffer, so the CPU continues executing after issuing a store. If that store hits a bad address, the bus error arrives several instructions later. The fault is imprecise: IMPRECISERR is set, BFAR is invalid, and the stacked pc points somewhere after the offending store, possibly in a different function.
To find the culprit, disable the write buffer: set DISDEFWBUF (bit 1) of the Auxiliary Control Register ACTLR at 0xE000E008. Every store then completes before the next instruction, the fault becomes precise, and BFAR is valid. It costs performance, so do it in a debug build or temporarily while hunting.
Stack overflow detection
Section titled “Stack overflow detection”On a Cortex-M4 without MPU guard regions, a stack overflow silently writes into whatever lies below the stack, usually .bss or the heap. The symptoms appear later and elsewhere. Three cheap defences:
- Stack painting: fill the stack region with a known pattern at boot. Later, scan from the bottom for the first word that is not the pattern; that is the high-water mark, the deepest the stack has ever been. Report it in a debug command and size stacks with margin from real measurements, not guesses.
- Guard word: check that the lowest word of the stack still holds the pattern, periodically from the main loop or in the watchdog service routine. If not, the stack has overflowed: record it and reset before the corruption spreads.
- Hardware limit: the Cortex-M33 (ARMv8-M) has
MSPLIMandPSPLIMregisters. Any push below the limit raises a UsageFault immediately, at the instruction that overflowed. On the M4 the MPU can approximate this with a no-access guard region, as Memory protection, stack safety, and robustness shows.
#include <stdint.h>
extern uint32_t _sstack; /* lowest address of the stack, from link.ld */#define STACK_PAINT 0xA5A5A5A5u
void stack_paint(void){ uint32_t *p = &_sstack; uint32_t *sp; __asm volatile ("mov %0, sp" : "=r" (sp)); while (p < sp - 16) { /* stay clear of the live frame */ *p++ = STACK_PAINT; }}
uint32_t stack_unused_bytes(void){ const uint32_t *p = &_sstack; while (*p == STACK_PAINT) { p++; } return (uint32_t)((uintptr_t)p - (uintptr_t)&_sstack);}This assumes your linker script defines _sstack at the bottom of a reserved stack region of fixed size (Linker scripts shows how to reserve one with a _Min_Stack_Size-style symbol). Call stack_paint first thing in main or, better, in Reset_Handler before anything deep runs.
Keeping the crash record across reset
Section titled “Keeping the crash record across reset”The record has to survive the reset it triggers. Static variables in .bss are zeroed by startup and .data is overwritten from flash, so the record lives in a .noinit section: placed in RAM, marked NOLOAD in the linker script, and skipped by the startup code. SRAM keeps its contents through a system reset (it is not cleared by hardware); it only loses them on power loss.
After power-on, .noinit holds random bits. That is why the record carries a magic number and a checksum, and why fault_init throws away anything that does not validate. The two magic values separate “new crash, report it” from “already reported”, while the count keeps accumulating across consecutive crashes so you can detect a crash loop. A product might, after three faults within a minute, boot into a safe mode that leaves the risky subsystem disabled.
Reporting happens after the reboot, in a normal, healthy context: print it, write it to flash (see the next lesson), send it to a server, or show it on a display. That is far more reliable than trying to do anything complicated inside the fault handler.
The complete example
Section titled “The complete example”This program combines everything: it reads and clears the reset cause, reports any crash record from the previous run, starts the IWDG with liveness checking, and blinks the green LED. Each press of the user button (PA0) runs the next of seven failure tests, chosen by a counter that also lives in .noinit. Add the IWDG overlay to the register header:
typedef struct { volatile uint32_t KR; /* 0x00 */ volatile uint32_t PR; /* 0x04 */ volatile uint32_t RLR; /* 0x08 */ volatile uint32_t SR; /* 0x0C */} IWDG_TypeDef;
#define IWDG ((IWDG_TypeDef *)0x40003000U)The code also needs RCC->CSR at offset 0x74, SysTick, and the SCB fields SHCSR, CCR, AIRCR, CFSR, HFSR, MMFAR and BFAR, all of which a CMSIS-style header defines. The _Static_assert below catches a header that lacks CSR at the right offset. console.c and gpio_util.h are from SPI and I2C.
#include <stdbool.h>#include <stddef.h>#include <stdint.h>#include <stdio.h>#include "stm32f407_regs.h"#include "gpio_util.h"#include "fault.h"
void console_init(void);void SysTick_Handler(void);
_Static_assert(offsetof(RCC_TypeDef, CSR) == 0x74u, "RCC_TypeDef needs CSR at 0x74");
#define RCC_CSR_RMVF (1u << 24)#define RCC_CSR_BORRSTF (1u << 25)#define RCC_CSR_PINRSTF (1u << 26)#define RCC_CSR_PORRSTF (1u << 27)#define RCC_CSR_SFTRSTF (1u << 28)#define RCC_CSR_IWDGRSTF (1u << 29)#define RCC_CSR_WWDGRSTF (1u << 30)#define RCC_CSR_LPWRRSTF (1u << 31)
#define DBGMCU_APB1_FZ (*(volatile uint32_t *)0xE0042008u)#define DBG_IWDG_STOP (1u << 12)#define ACTLR (*(volatile uint32_t *)0xE000E008u)#define ACTLR_DISDEFWBUF (1u << 1)
enum { ALIVE_TICK = 1u << 0, ALIVE_BLINK = 1u << 1, ALIVE_BUTTON = 1u << 2, ALIVE_ALL = ALIVE_TICK | ALIVE_BLINK | ALIVE_BUTTON,};
static volatile uint32_t alive;static volatile uint32_t ms;__attribute__((section(".noinit"))) static uint32_t test_step;
static inline uint32_t irq_save(void){ uint32_t primask; __asm volatile ("mrs %0, primask\n\tcpsid i" : "=r" (primask) :: "memory"); return primask;}
static inline void irq_restore(uint32_t primask){ __asm volatile ("msr primask, %0" :: "r" (primask) : "memory");}
static void wdg_checkin(uint32_t bit){ uint32_t key = irq_save(); /* alive |= bit must not be torn */ alive |= bit; irq_restore(key);}
static void wdg_service(void){ uint32_t key = irq_save(); if ((alive & ALIVE_ALL) == ALIVE_ALL) { IWDG->KR = 0xAAAAu; alive = 0u; } irq_restore(key);}
static bool iwdg_start(void){ IWDG->KR = 0xCCCCu; IWDG->KR = 0x5555u; IWDG->PR = 3u; /* /32 */ IWDG->RLR = 999u; /* about 1 s at 32 kHz */ for (uint32_t n = 1000000u; n != 0u; n--) { if (IWDG->SR == 0u) { IWDG->KR = 0xAAAAu; return true; } } return false;}
void SysTick_Handler(void){ ms++; wdg_checkin(ALIVE_TICK);}
static const char *reset_cause(uint32_t csr){ if ((csr & RCC_CSR_IWDGRSTF) != 0u) return "independent watchdog"; if ((csr & RCC_CSR_WWDGRSTF) != 0u) return "window watchdog"; if ((csr & RCC_CSR_LPWRRSTF) != 0u) return "low-power reset"; if ((csr & RCC_CSR_SFTRSTF) != 0u) return "software reset"; if ((csr & RCC_CSR_PORRSTF) != 0u) return "power-on"; if ((csr & RCC_CSR_BORRSTF) != 0u) return "brown-out"; if ((csr & RCC_CSR_PINRSTF) != 0u) return "reset pin"; return "unknown";}
/* ---- The failure tests ---- */static void test_bad_read(void){ volatile uint32_t v = *(volatile uint32_t *)0x30000000u; /* reserved area */ (void)v;}
static void test_bad_write(void){ *(volatile uint32_t *)0x30000000u = 0x12345678u; /* imprecise */}
static void test_div_zero(void){ volatile uint32_t zero = 0u; volatile uint32_t r = 100u / zero; (void)r;}
static void test_undefined(void){ __asm volatile ("udf #0");}
static void test_bad_call(void){ void (*fp)(void) = (void (*)(void))0x08000200u; /* bit 0 clear: not Thumb */ fp();}
static void test_assert(void){ volatile int x = 2; ASSERT(x == 3);}
static void test_hang(void){ for (;;) { } /* SysTick still checks in; main never does */}
static const struct { const char *name; void (*fn)(void);} tests[] = { { "bad read (precise BusFault)", test_bad_read }, { "bad write (imprecise BusFault)", test_bad_write }, { "divide by zero (UsageFault)", test_div_zero }, { "undefined instruction (UsageFault)", test_undefined }, { "call even address (INVSTATE)", test_bad_call }, { "failed assert", test_assert }, { "hang in main loop (watchdog)", test_hang },};#define NTESTS (sizeof tests / sizeof tests[0])
int main(void){ uint32_t csr = RCC->CSR; RCC->CSR |= RCC_CSR_RMVF;
fault_init(); console_init();#ifdef PRECISE_BUSFAULTS ACTLR |= ACTLR_DISDEFWBUF;#endif
RCC->AHB1ENR |= RCC_AHB1ENR_GPIODEN | RCC_AHB1ENR_GPIOAEN; (void)RCC->AHB1ENR; gpio_mode(GPIOD, 12u, GPIO_OUT); gpio_mode(GPIOA, 0u, GPIO_IN);
printf("\nReset cause: %s (RCC_CSR = 0x%08lX)\n", reset_cause(csr), (unsigned long)csr);
crash_record_t r; if (fault_take_record(&r)) { printf("Previous crash #%lu: %s pc=0x%08lX lr=0x%08lX CFSR=0x%08lX HFSR=0x%08lX\n", (unsigned long)r.count, r.ipsr == 0u ? "assert" : (r.ipsr == 3u ? "HardFault" : r.ipsr == 4u ? "MemManage" : r.ipsr == 5u ? "BusFault" : "UsageFault"), (unsigned long)r.pc, (unsigned long)r.lr, (unsigned long)r.cfsr, (unsigned long)r.hfsr); if (r.ipsr == 0u) { printf(" assert at %s:%lu\n", (const char *)(uintptr_t)r.assert_file, (unsigned long)r.assert_line); } } else { printf("No new crash record.\n"); }
DBGMCU_APB1_FZ |= DBG_IWDG_STOP; /* halting in gdb will not reset */ SysTick->LOAD = 16000u - 1u; /* 1 ms at 16 MHz */ SysTick->VAL = 0u; SysTick->CTRL = 7u; /* CLKSOURCE | TICKINT | ENABLE */ if (!iwdg_start()) { printf("IWDG did not start\n"); }
uint32_t idx = test_step % NTESTS; printf("Press the button to run test %lu: %s\n", (unsigned long)idx, tests[idx].name);
uint32_t last_blink = 0u; uint32_t last_press = 0u; bool was_down = false;
for (;;) { uint32_t now = ms;
/* Task 1: blink */ if (now - last_blink >= 250u) { last_blink = now; GPIOD->ODR ^= 1u << 12; wdg_checkin(ALIVE_BLINK); }
/* Task 2: button, with a 50 ms debounce lockout */ bool down = (GPIOA->IDR & 1u) != 0u; if (down && !was_down && now - last_press > 50u) { last_press = now; idx = test_step % NTESTS; test_step = idx + 1u; /* advance before the test kills us */ printf("Running test %lu: %s\n", (unsigned long)idx, tests[idx].name); tests[idx].fn(); printf("Test returned without a fault.\n"); } was_down = down; wdg_checkin(ALIVE_BUTTON);
wdg_service(); __asm volatile ("wfi"); /* SysTick wakes us every 1 ms */ }}A session pressing the button a few times:
Reset cause: power-on (RCC_CSR = 0x0E000000)No new crash record.Press the button to run test 0: bad read (precise BusFault)Running test 0: bad read (precise BusFault)
*** BusFault r0 = 0x00000000 ... lr = 0x08000447 pc = 0x080003A8 ... CFSR = 0x00008200 HFSR = 0x00000000 MMFAR= 0x00000000 BFAR = 0x30000000 CFSR bits: PRECISERR BFARVALIDResetting.
Reset cause: software reset (RCC_CSR = 0x14000000)Previous crash #1: BusFault pc=0x080003A8 lr=0x08000447 CFSR=0x00008200 HFSR=0x00000000Press the button to run test 1: bad write (imprecise BusFault)Running test 1: bad write (imprecise BusFault)
*** BusFault ... CFSR = 0x00000400 CFSR bits: IMPRECISERRResetting....Running test 6: hang in main loop (watchdog)
Reset cause: independent watchdog (RCC_CSR = 0x24000000)No new crash record.Addresses depend on your build. Points to notice:
- After the power-on,
RCC_CSRshowsPORRSTF,BORRSTFandPINRSTFtogether (0x0E000000). After the fault handler’s reset,SFTRSTFandPINRSTF(0x14000000). After the hang,IWDGRSTFandPINRSTF(0x24000000). This is why the code checks specific causes before the pin flag. - The precise fault reports
BFAR = 0x30000000, the exact bad address, andpcis theldr. The imprecise one has noBFAR, andpcis wherever the CPU had got to. Build with-DPRECISE_BUSFAULTSand test 1 becomes precise. - Test 6 is the important one. During the hang the SysTick interrupt keeps running and keeps checking in. A watchdog fed from SysTick would never have fired. Because the main loop must also check in, the IWDG resets the board about a second later.
- The
ASSERTtest records the file and line. The file pointer stored in RAM points at a string in flash, which is still valid after the reset because the same firmware is running.
assert in production, and never hanging
Section titled “assert in production, and never hanging”What should a failed assertion do in a shipped product? Halting (bkpt or for (;;)) is right on the bench, where a debugger is attached. In the field it is wrong: the device sits dead until the watchdog fires, and if the watchdog is misconfigured, forever. The usual production policy is the one implemented here: record what failed, reset, report after reboot, and count. Keep assertions enabled in release builds for invariants whose violation means memory is already corrupt; continuing would only make things worse. Disable only those on hot paths that you have proven unnecessary.
Design the whole system around the rule the device never hangs:
- Every wait has a timeout (peripherals, buses, queues, network).
- Every fault ends in a record and a reset, never a silent loop.
- The watchdog is fed only from one low-priority place, only when every subsystem has proven progress.
- The watchdog timeout is longer than the longest legitimate blocking operation and shorter than what the user or the safety case can tolerate.
- After reset, the firmware reports why, and repeated crashes trigger a degraded safe mode rather than an endless reboot loop.
The watchdog is the last line of defence, not the first. If it fires regularly in the field, that is a bug report, not a working recovery system.
Common mistakes
Section titled “Common mistakes”- Feeding the watchdog from a timer interrupt. It keeps feeding while
mainis deadlocked. Feed only from the lowest-priority loop, after all subsystems check in. - Timeout sized for the nominal LSI. The LSI ranges from about 17 to 47 kHz. Compute feeding intervals against the shortest possible timeout.
- Debugger halts cause resets. Set
DBG_IWDG_STOPin debug builds. - Trusting
PINRSTFalone. Every internal reset sets it. Check watchdog, software and power flags first, then clear withRMVF. - Writing the fault handler in plain C. The prologue moves
spand you dump the wrong frame, and you cannot select PSP. Use a naked stub. - Calling
printfin the fault handler. It may fault again on a broken heap or stack. Use a minimal polled output. - Reading
BFAR/MMFARwithout checking the valid bits. You chase stale addresses. CheckBFARVALID/MMARVALID. - Crash record in
.bss. Startup zeroes it. Put it in aNOLOAD.noinitsection with a magic and checksum.
Exercises
Section titled “Exercises”- Run the example and step through all seven tests. For each, write down
CFSRand decode it by hand with the table before reading the printed bit names. - Use
arm-none-eabi-addr2lineandarm-none-eabi-objdump -d -Son your ELF to map the stackedpcandlrof tests 0, 2 and 4 to source lines. For test 4, explain whypcis0x08000200and wherelrpoints. - Comment out
fault_init’sSHCSRwrite and rerun test 2. Show that it now arrives as HardFault withHFSR.FORCEDset and the sameCFSRbits. Then disableDIV_0_TRPand show that test 2 “returns without a fault”. - Add a fourth subsystem that only checks in when a UART byte has been processed in the last 5 seconds. Decide what the system should do when nobody is typing (hint: liveness is “the task ran its loop”, not “data arrived”), and implement it.
- Make the fault handler robust to stack overflow: reserve a 256-byte fault stack in
.noinit, switchspto it in the naked stub, and test by recursing without bound in a function with a large local array. Confirm thatSTKERRor a precise fault is reported instead of a lockup.
Check yourself
Section titled “Check yourself”Question 1:A product feeds the IWDG from the SysTick interrupt. The main loop deadlocks waiting for an I2C flag. What happens?
Interrupts still run while main is stuck, so feeding from an ISR proves only that the ISR runs; feed from the lowest-priority loop instead.
Question 2:In the HardFault stub, lr contains 0xFFFFFFFD. Where is the stacked frame?
0xFFFFFFFD has bit 2 set (return to thread mode using PSP) and bit 4 set (no FPU frame), so the frame is on the process stack.
Question 3:CFSR reads 0x00000400 and BFAR holds 0x20001234. What do you conclude?
Bit 10 is IMPRECISERR and BFARVALID (bit 15) is clear, so BFAR is stale; set ACTLR.DISDEFWBUF to make the fault precise.
Question 4:After a watchdog reset, RCC_CSR reads 0x24000000. Which flags are set and what caused the reset?
0x24000000 is bits 29 and 26; every internal reset also drives NRST, so PINRSTF accompanies the real cause.
Question 5:Your crash record is declared as a normal static global. After the fault handler resets the chip, it is always all zeros. Why?
SRAM survives a system reset, but startup zeroes .bss; the record must live in a NOLOAD .noinit section validated by a magic and checksum.
Question 6:Calling ((void (*)(void))0x08000200)() faults. Which CFSR bit do you expect?
An even target address clears the Thumb bit in EPSR, and the Cortex-M cannot execute in ARM state, so it raises a UsageFault with INVSTATE.
Key takeaways
Section titled “Key takeaways”- The IWDG runs from the independent LSI, cannot be stopped once started, and has a timeout that varies widely with LSI tolerance.
- Feed the watchdog from one low-priority place, only after every subsystem has checked in; feeding from an ISR proves nothing.
- Read
RCC_CSRonce at boot, interpret specific causes beforePINRSTF, clear withRMVF, and log it. - Enable MemManage, BusFault and UsageFault in
SHCSR; a naked stub picks MSP or PSP fromEXC_RETURNbit 2 and hands the stacked frame to C. CFSR,HFSR,BFARandMMFARplus the stackedpcandlr, fed toaddr2line, locate almost every crash; imprecise bus faults needDISDEFWBUF.- Save a validated crash record in
.noinit, reset, and report it after reboot: a device that never hangs and always explains itself.
Next: Flash, EEPROM, and non-volatile storage, where you learn to write data that survives power loss without wearing out the chip.