Lab 7.1 — Watchdog / HardFault

Course 2 syllabus · Module 7 · Prev: « Lab 6.6 · Next: Lab 7.2 »

Goal

A real-time DSP system that hangs, wanders into a bad pointer, or divides by zero must not sit there dead — it has to detect the failure and recover. This lab builds the two firmware primitives that make embedded systems robust: the independent watchdog (IWDG), a hardware timer that resets the MCU if the firmware stops “petting” it, and a real HardFault_Handler that captures the stacked CPU registers so you can decode exactly where the fault occurred from a post-mortem. You will deliberately trigger both — stop refreshing the watchdog to force a reset, and dereference a null/misaligned pointer and divide by zero to force a HardFault — then read the program counter out of the stack frame and walk it back to the offending line in the debugger. These are the skills that separate a demo that runs on the bench from firmware you would trust in a shipped product.

Equipment & parts

  • STM32 Nucleo-64 (NUCLEO-L476RG) + USB cable to the on-board ST-LINK.
  • STM32CubeIDE (debugger + SWV console).
  • Optional: the ST-LINK virtual COM port (USART2, PA2/PA3) for printf diagnostics, and the on-board LD2 LED (PA5) as a liveness/heartbeat indicator.
  • No breadboard or external parts — this is a pure-firmware lab.

Safety & don’t-break-it

  • Nothing here is electrically dangerous — the board runs off USB at 3.3 V and there is no external circuit. The risks are all in firmware/debug flow.
  • The IWDG cannot be stopped once started. On the STM32L4, enabling the IWDG (writing the start key 0xCCCC to IWDG_KR) is a one-way action until the next reset — you cannot disable it in software. If your debug session halts at a breakpoint while the IWDG is free-running, the counter keeps counting and the board will reset out from under the debugger. Configure the DBGMCU freeze bit (DBGMCU_APB1FZR1 → DBG_IWDG_STOP, exposed in CubeIDE as “Debug IWDG stopped”) so the watchdog freezes when the core is halted — otherwise single-stepping is impossible.
  • Watchdog resets can look like a “bricked” board. A too-short timeout that fires before main() finishes init puts the board in a reset loop; it will appear that ST-LINK can’t connect. Recovery: use CubeIDE’s “Connect under reset” mode (or hold NRST while starting the debug/erase), then reflash a clean image. Nothing is physically damaged — flash is fine — but know this escape hatch before you arm a short watchdog.
  • Deliberate faults are safe but disruptive. A forced HardFault just parks the CPU in the handler (or resets); it does not harm the silicon. Do the fault experiments last, and keep a known-good build handy to reflash.
  • Flash wear is negligible for this lab, but avoid tight reflash loops on the option bytes (the DBGMCU/IWDG-stop bits are option-byte-adjacent on some tools) — set them once in CubeMX rather than rewriting per run.

Background

The independent watchdog

The IWDG is a free-running down-counter clocked by the low-speed internal oscillator (LSI, nominally \(f_\text{LSI} \approx 32\text{ kHz}\) on the STM32L4, with a wide tolerance). It is independent of the main clock tree, so it keeps counting even if the PLL, the main clock, or the CPU wedges. If the counter reaches zero, the IWDG issues a system reset. Firmware prevents that by periodically writing the reload key — “petting” or “refreshing” the dog — which reloads the counter to its start value.

The timeout is set by the prescaler \(P\) (a power-of-two divider, \(4\) to \(256\)) and the 12-bit reload value \(\text{RLR} \in [0, 4095]\):

\[ t_\text{IWDG} = \frac{P \,\cdot\, (\text{RLR}+1)}{f_\text{LSI}}. \]

For \(P = 32\), \(\text{RLR} = 4095\), \(f_\text{LSI} = 32\text{ kHz}\):

\[ t_\text{IWDG} = \frac{32 \times 4096}{32000} \approx 4.10\text{ s}. \]

The refresh period in the main loop must be comfortably shorter than \(t_\text{IWDG}\) — including the LSI tolerance (the real LSI can be ±several percent to ±10%+). A robust rule: refresh at no more than \(\tfrac{1}{2}\) to \(\tfrac{1}{3}\) of the nominal timeout so a slow LSI never resets you spuriously, while a genuine hang still trips within the deadline.

What a HardFault is, and how to read it

A HardFault is the Cortex-M’s catch-all fault exception — taken on a bad memory access, an illegal instruction, an unaligned access that the config traps, a divide-by-zero (when trapping is enabled), or when a lower-priority fault escalates. When the exception is taken, the core automatically stacks eight words onto the active stack: R0, R1, R2, R3, R12, LR, PC, xPSR. The stacked PC is the address of the faulting (or next) instruction — the single most useful number for a post-mortem.

The EXC_RETURN value in LR on entry tells you which stack was in use: bit 2 selects MSP (bit 2 = 0) vs PSP (bit 2 = 1). A correct handler reads that bit, grabs the right stack pointer, and passes it to a C routine that pulls PC, LR, and xPSR out of the frame. The Configurable Fault Status Register (CFSR) and HardFault Status Register (HFSR) then tell you the cause (e.g. HFSR.FORCED, CFSR.UsageFault.DIVBYZERO, CFSR.BusFault.PRECISERR with a faulting address in BFAR).

Divide-by-zero and unaligned-access traps are off by default — you opt in via SCB->CCR (DIV_0_TRP, UNALIGN_TRP). Without DIV_0_TRP, an integer x / 0 returns 0 rather than faulting; enabling it makes the bug loud, which is what you want in development.

Procedure

Part A — Configure and prove the IWDG

  1. In CubeMX (the .ioc), enable IWDG. Set Prescaler = 32 and Reload (RLR) = 4095 → nominal ~4.1 s timeout. In the SYS/debug settings enable “Debug IWDG stopped” so the watchdog freezes on a debugger halt.

  2. Configure LD2 (PA5) as a GPIO output heartbeat, and (optional) USART2 for printf so you can log resets.

  3. In main(), after init, start the watchdog and refresh it inside the main loop:

    /* Illustrative only — you write the real firmware. */
    HAL_IWDG_Init(&hiwdg);            /* Prescaler 32, Reload 4095 ≈ 4.1 s */
    
    while (1) {
        HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);   /* heartbeat */
        do_dsp_work();                            /* the real payload */
        HAL_IWDG_Refresh(&hiwdg);                 /* pet the dog */
        HAL_Delay(500);                           /* << 4.1 s timeout */
    }
  4. On boot, detect why you reset by reading the reset flags, and log it:

    if (__HAL_RCC_GET_FLAG(RCC_FLAG_IWDGRST)) {
        printf("Recovered from IWDG reset\r\n");
    }
    __HAL_RCC_CLEAR_RESET_FLAGS();
  5. Flash and run without the debugger attached (or with IWDG-stop enabled). Confirm normal operation: LD2 blinks, no resets, and the boot log does not report an IWDG reset after the first clean power-up.

Part B — Deliberately trip the watchdog

  1. Simulate a hang: after a few seconds of normal operation, enter an infinite loop that stops refreshing the dog — e.g. on a button press (B1/PC13) or after N iterations:

    if (simulate_hang) {
        for (;;) { /* stuck: no HAL_IWDG_Refresh() here */ }
    }
  2. Observe: the heartbeat LED freezes, and ~4 s later the board resets. On reboot, your RCC_FLAG_IWDGRST check fires and the log prints “Recovered from IWDG reset.” You have just watched the watchdog turn a dead hang into an automatic recovery.

  3. Measure the timeout: toggle a spare GPIO (or LD2) right before entering the hang and watch on the Saleae (or scope) how long until the pin activity resumes after reset. Compare to the predicted \(t_\text{IWDG}\).

Part C — Force a HardFault and capture the frame

  1. Enable the useful traps early in main():

    SCB->CCR |= SCB_CCR_DIV_0_TRP_Msk | SCB_CCR_UNALIGN_TRP_Msk;
  2. Write a HardFault handler that extracts the stacked frame. The standard idiom is a tiny naked/asm shim that selects MSP vs PSP and calls a C function:

    /* Illustrative post-mortem handler. */
    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"
            "mrsne r0, psp     \n"
            "b hard_fault_report\n"
        );
    }
    
    void hard_fault_report(uint32_t *sp) {
        uint32_t stacked_r0  = sp[0];
        uint32_t stacked_r1  = sp[1];
        uint32_t stacked_r2  = sp[2];
        uint32_t stacked_r3  = sp[3];
        uint32_t stacked_r12 = sp[4];
        uint32_t stacked_lr  = sp[5];   /* return address into faulting fn */
        uint32_t stacked_pc  = sp[6];   /* <-- where it faulted */
        uint32_t stacked_psr = sp[7];
        uint32_t cfsr = SCB->CFSR;      /* cause bits */
        uint32_t hfsr = SCB->HFSR;
        (void)stacked_r0; (void)stacked_r1; (void)stacked_r2; (void)stacked_r3;
        (void)stacked_r12; (void)stacked_lr; (void)stacked_psr;
        (void)cfsr; (void)hfsr;
        __BKPT(0);                      /* halt for the debugger */
        for (;;) { }                    /* or: NVIC_SystemReset(); to recover */
    }
  3. Trigger a null / misaligned pointer fault (bus fault escalating to HardFault, CFSR.BusFault set, BFAR holds the bad address):

    volatile uint32_t *bad = (uint32_t *)0x00000001;  /* unaligned + illegal */
    volatile uint32_t x = *bad;   /* faults here */
    (void)x;
  4. Separately, trigger a divide-by-zero (usage fault, CFSR.UsageFault.DIVBYZERO):

    volatile int a = 42, b = 0;
    volatile int q = a / b;       /* faults with DIV_0_TRP enabled */
    (void)q;
  5. Run each under the debugger. When it stops in hard_fault_report, inspect the locals (or the sp array) and read stacked_pc.

Part D — Decode where it faulted

  1. In CubeIDE, take the stacked_pc value and either (a) hover/paste it in the Disassembly view, (b) use “Instruction Stepping” + the address, or (c) from a terminal run arm-none-eabi-addr2line -e your.elf 0x0800XXXX to map the address to file:line. Confirm it points at your deliberate faulting statement.
  2. Decode the cause: check CFSR/HFSR bits against PM0214 — you should see PRECISERR+valid BFAR for the pointer fault, and DIVBYZERO for the divide fault. HFSR.FORCED will be set because both escalate into the HardFault.
  3. Repeat with the UsageFault/BusFault handlers enabled in SCB->SHCSR (so faults are taken by their specific handlers instead of escalating) and note how the more specific handler gives a cleaner diagnosis. This is the recommended production configuration.

Deliverable & expected results

A bench note (docs/lab-7-1.md) plus, in the repo, the firmware/m7-rtos/ project with the IWDG config and fault handler. Record:

  • The measured IWDG timeout vs. the predicted value.
  • A screenshot/log of the “Recovered from IWDG reset” message after a forced hang.
  • The captured stacked_pc for each fault and the addr2line output proving it points at the deliberate bug.
  • The decoded CFSR/HFSR cause bits for each fault.
Quantity Predicted Measured
IWDG timeout (\(P=32\), RLR \(=4095\), LSI 32 kHz) 4.10 s
Safe refresh period used 500 ms (\(\approx t/8\))
Null/misaligned-pointer fault → cause bits HFSR.FORCED + CFSR.BusFault.PRECISERR, BFAR=addr
Divide-by-zero fault → cause bits HFSR.FORCED + CFSR.UsageFault.DIVBYZERO
stacked_pc maps to (file:line) the deliberate faulting line

Analysis & reconciliation

Compute \(t_\text{IWDG} = P(\text{RLR}+1)/f_\text{LSI}\) by hand and compare to the measured reset interval. Expect a meaningful gap here, not rounding error: the LSI is an uncalibrated RC oscillator and can be off by several to ~10%, so a “4.1 s” nominal timeout might measure anywhere from ~3.7 s to ~4.5 s. This is exactly why you refresh at a small fraction of the nominal timeout — the LSI tolerance is a design margin, not a bug. If your measured timeout is wildly off (2× or more), re-check the prescaler value and confirm the LSI (not LSE) is the IWDG source.

For the faults, the reconciliation is binary: does stacked_pc land on the line you meant to break? If it points one instruction past your statement, remember the stacked PC can be the address of the next instruction for imprecise faults — enable precise bus faults (default on M4 for aligned accesses) and prefer the specific fault handlers for an unambiguous address. If BFAR/MMFAR shows VALID=0, the fault was imprecise (e.g. buffered write) — note that limitation.

Recovery strategy. Decide, and document, what the handler should do in production: for a transient fault a controlled NVIC_SystemReset() (logging the cause to a retained/backup register first, so the next boot can report it) is usually right; for a deterministic fault (a real bug) a reset just loops, so you want a safe-state + fault-flag path instead. The watchdog is the backstop that catches the case where the fault handler itself wedges. Write down which policy this system uses and why.

Going further

  • Windowed watchdog (WWDG): add the STM32’s WWDG, which faults if you refresh too early as well as too late — useful for catching a runaway loop that pets the dog at the wrong cadence.
  • Retained fault log: stash the CFSR/stacked_pc into an RTC backup register (or a noinit RAM section) before reset, and print a full post-mortem on the next boot — the embedded equivalent of a crash dump.
  • Stack-overflow detection: enable the MPU or a stack canary and force an overflow; watch it surface as a fault and confirm your handler still runs (put the handler’s own scratch on a known-good stack).
  • Fault injection under load: combine with Lab 6.1’s FIR pipeline — inject a fault mid-DSP and verify the watchdog + handler recover without corrupting the output buffers. This sets up the robustness requirements you’ll formalize under the RTOS in Lab 7.2.