Skip to content

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.

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:

OffsetRegisterUse
0x00KRkey register: 0xCCCC start, 0xAAAA reload, 0x5555 unlock PR/RLR
0x04PRprescaler, 3 bits: 0 = /4, 1 = /8, 2 = /16, 3 = /32, 4 = /64, 5 = /128, 6 and 7 = /256
0x08RLRreload value, 12 bits (0 to 4095)
0x0CSRPVU (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 s

The 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 (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.

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:

  1. Each task or subsystem has a bit in an “alive” mask.
  2. When the subsystem completes a unit of real work (processed a sample, handled a message, finished a control cycle), it sets its bit.
  3. 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.

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:

BitFlagSet by
24RMVFwrite 1 to clear all the flags below
25BORRSTFbrown-out reset (also set on power-on)
26PINRSTFNRST pin low (also set by every internal reset, which drives NRST)
27PORRSTFpower-on or power-down reset
28SFTRSTFsoftware reset via AIRCR.SYSRESETREQ
29IWDGRSTFindependent watchdog
30WWDGRSTFwindow watchdog
31LPWRRSTFillegal 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.

When the CPU cannot complete an instruction, it raises a fault exception. The Cortex-M4 has four:

ExceptionNumberVector offsetCaused by
HardFault30x0Cescalated faults, vector table read errors; always enabled
MemManage40x10MPU violations, execute from an execute-never region
BusFault50x14bus error on instruction fetch, data access, or exception stacking
UsageFault60x18undefined 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.)

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 addresses

The 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:

AddressRegisterContents
0xE000ED28CFSRConfigurable Fault Status: MMFSR (bits 7:0), BFSR (15:8), UFSR (31:16). Write 1 to clear.
0xE000ED2CHFSRVECTTBL (1) vector read error, FORCED (30) escalated, DEBUGEVT (31)
0xE000ED34MMFARaddress that caused a MemManage fault, valid if MMARVALID
0xE000ED38BFARaddress 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.

fault.h
#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
fault.c
#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, #4 tests bit 2 of EXC_RETURN (which the hardware placed in lr). ite eq makes the next two instructions conditional: if the bit is 0, mrseq r0, msp copies the main stack pointer; otherwise mrsne r0, psp copies the process stack pointer. Now r0 points at the stacked r0.
  • mov r1, lr passes EXC_RETURN as the second argument, so the C code can tell whether an FPU frame was pushed.
  • b fault_c is a plain branch, not a call. By the AAPCS, fault_c receives its two arguments in r0 and r1. It never returns, so the link register does not matter.
  • naked guarantees 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 why fault_c is a non-static function marked used (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.

CFSR is three registers in one word. The bits you will meet, with their usual cause:

BitNameMeaningUsual cause
0IACCVIOLMPU: instruction fetch violationjumping into an execute-never region, corrupted function pointer
1DACCVIOLMPU: data access violation (MMFAR valid)null-pointer deref with an MPU guard at 0, stack hitting a guard region
3MUNSTKERRMPU fault on exception return unstackingcorrupted stack pointer in an RTOS task
4MSTKERRMPU fault while stacking on exception entrystack overflow into a guard region
8IBUSERRbus error on instruction fetchbranch to an unmapped address
9PRECISERRprecise data bus error (BFAR valid)read or write of an unmapped or disabled peripheral address
10IMPRECISERRimprecise data bus error, BFAR not validbuffered write to a bad address; stacked pc is after the culprit
11UNSTKERRbus fault on exception returncorrupted stack during return from interrupt
12STKERRbus fault while stackingstack pointer outside RAM: stack overflow
16UNDEFINSTRundefined instructionexecuting data, a corrupted code pointer, wrong -mcpu
17INVSTATEinvalid EPSR state (Thumb bit 0)calling a function pointer with bit 0 clear, e.g. a raw even address
18INVPCinvalid EXC_RETURN on exception returnstack corruption in an ISR, bad context switch
19NOCPno coprocessorFPU instruction with the FPU disabled in CPACR
24UNALIGNEDunaligned access trappedLDRD/LDM on an unaligned address, or any unaligned access with UNALIGN_TRP
25DIVBYZEROdivide by zero trappedinteger 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.

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):

Terminal window
arm-none-eabi-addr2line -e build/firmware.elf -f -i 0x080003A8 0x08000446
test_bad_read
/home/me/fw/src/main.c:58
main
/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:

Terminal window
arm-none-eabi-objdump -d -S --start-address=0x08000390 --stop-address=0x080003B0 build/firmware.elf
static 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.

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.

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 MSPLIM and PSPLIM registers. 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.

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.

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:

stm32f407_regs.h (additions)
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.

main.c
#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 BFARVALID
Resetting.
Reset cause: software reset (RCC_CSR = 0x14000000)
Previous crash #1: BusFault pc=0x080003A8 lr=0x08000447 CFSR=0x00008200 HFSR=0x00000000
Press the button to run test 1: bad write (imprecise BusFault)
Running test 1: bad write (imprecise BusFault)
*** BusFault
...
CFSR = 0x00000400
CFSR bits: IMPRECISERR
Resetting.
...
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_CSR shows PORRSTF, BORRSTF and PINRSTF together (0x0E000000). After the fault handler’s reset, SFTRSTF and PINRSTF (0x14000000). After the hang, IWDGRSTF and PINRSTF (0x24000000). This is why the code checks specific causes before the pin flag.
  • The precise fault reports BFAR = 0x30000000, the exact bad address, and pc is the ldr. The imprecise one has no BFAR, and pc is wherever the CPU had got to. Build with -DPRECISE_BUSFAULTS and 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 ASSERT test 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.

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:

  1. Every wait has a timeout (peripherals, buses, queues, network).
  2. Every fault ends in a record and a reset, never a silent loop.
  3. The watchdog is fed only from one low-priority place, only when every subsystem has proven progress.
  4. The watchdog timeout is longer than the longest legitimate blocking operation and shorter than what the user or the safety case can tolerate.
  5. 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.

  • Feeding the watchdog from a timer interrupt. It keeps feeding while main is 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_STOP in debug builds.
  • Trusting PINRSTF alone. Every internal reset sets it. Check watchdog, software and power flags first, then clear with RMVF.
  • Writing the fault handler in plain C. The prologue moves sp and you dump the wrong frame, and you cannot select PSP. Use a naked stub.
  • Calling printf in the fault handler. It may fault again on a broken heap or stack. Use a minimal polled output.
  • Reading BFAR/MMFAR without checking the valid bits. You chase stale addresses. Check BFARVALID/MMARVALID.
  • Crash record in .bss. Startup zeroes it. Put it in a NOLOAD .noinit section with a magic and checksum.
  1. Run the example and step through all seven tests. For each, write down CFSR and decode it by hand with the table before reading the printed bit names.
  2. Use arm-none-eabi-addr2line and arm-none-eabi-objdump -d -S on your ELF to map the stacked pc and lr of tests 0, 2 and 4 to source lines. For test 4, explain why pc is 0x08000200 and where lr points.
  3. Comment out fault_init’s SHCSR write and rerun test 2. Show that it now arrives as HardFault with HFSR.FORCED set and the same CFSR bits. Then disable DIV_0_TRP and show that test 2 “returns without a fault”.
  4. 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.
  5. Make the fault handler robust to stack overflow: reserve a 256-byte fault stack in .noinit, switch sp to it in the naked stub, and test by recursing without bound in a function with a large local array. Confirm that STKERR or a precise fault is reported instead of a lockup.

Question 1:A product feeds the IWDG from the SysTick interrupt. The main loop deadlocks waiting for an I2C flag. What happens?

Question 2:In the HardFault stub, lr contains 0xFFFFFFFD. Where is the stacked frame?

Question 3:CFSR reads 0x00000400 and BFAR holds 0x20001234. What do you conclude?

Question 4:After a watchdog reset, RCC_CSR reads 0x24000000. Which flags are set and what caused the reset?

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?

Question 6:Calling ((void (*)(void))0x08000200)() faults. Which CFSR bit do you expect?

  • 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_CSR once at boot, interpret specific causes before PINRSTF, clear with RMVF, and log it.
  • Enable MemManage, BusFault and UsageFault in SHCSR; a naked stub picks MSP or PSP from EXC_RETURN bit 2 and hands the stacked frame to C.
  • CFSR, HFSR, BFAR and MMFAR plus the stacked pc and lr, fed to addr2line, locate almost every crash; imprecise bus faults need DISDEFWBUF.
  • 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.