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 bad (unmapped) 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.
  • STM32CubeMX + CLion with the OpenOCD debug configuration (per the project workflow) — this lab lives in the debugger.
  • 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.

Wiring & bench setup

There is no circuit: everything rides the single ST-LINK USB cable, and the only bench hookup is an optional Saleae probe on a toggled pin to time the watchdog reset interval (Part B, step 8).

flowchart LR
  MAC["Mac<br/>CLion debugger + serial log"]
  MCU["NUCLEO-L476RG<br/>IWDG + fault handler<br/>LD2 heartbeat"]
  SAL["Saleae Logic 8<br/>(optional, timeout timing)"]
  MAC -- "USB: flash/debug + VCP" --> MCU
  MCU -. "D7 toggle → CH0<br/>GND → GND" .-> SAL
  SAL -. USB .-> MAC

flowchart LR
  MAC["Mac<br/>CLion debugger + serial log"]
  MCU["NUCLEO-L476RG<br/>IWDG + fault handler<br/>LD2 heartbeat"]
  SAL["Saleae Logic 8<br/>(optional, timeout timing)"]
  MAC -- "USB: flash/debug + VCP" --> MCU
  MCU -. "D7 toggle → CH0<br/>GND → GND" .-> SAL
  SAL -. USB .-> MAC

Pin map (the Saleae rows only matter for the timeout measurement):

From To Pin/jack
Nucleo ST-LINK USB Mac USB (power + debug + /dev/tty.usbmodem* VCP)
Timing toggle D7 (PA8) Saleae CH0 Arduino header D7
Nucleo GND Saleae GND lead any GND pin

LD2 (PA5) needs no wiring — it is the on-board LED. If you’d rather watch the heartbeat pin itself on the Saleae, PA5 is also brought out at Arduino pin D13.

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, the .ioc’s SYS → “Debug IWDG stopped” setting) 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: connect under resetSTM32_Programmer_CLI -c port=SWD mode=UR (or the CubeProgrammer GUI’s “Connect under reset” mode, or hold NRST while starting the 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.

Project & environment setup

Firmware — create the Module 7 project firmware/m7-rtos/ (CubeMX → NUCLEO-L476RG → Toolchain/IDE = CMake, per the project workflow; Labs 7.1–7.3 share it — Lab 7.2 adds FreeRTOS, Lab 7.3 the full pipeline). In the .ioc:

CubeMX page Setting
Timers → IWDG Activated; Prescaler = 32, Reload (RLR) = 4095\(t_\text{IWDG} = 32\times4096/f_\text{LSI} \approx 4.10\) s at the nominal 32 kHz LSI
System Core → SYS (debug) “Debug IWDG stopped” (DBG_IWDG_STOP) enabled — the watchdog freezes when the core halts (see Safety)
System Core → GPIO PA5 (LD2) output — heartbeat; PA8 (D7) output — timeout-timing toggle for Part B step 8
Connectivity → USART2 Asynchronous, 115200 8-N-1 — the ST-LINK VCP for the reset-cause log
Clock Configuration 80 MHz HCLK per the setup essentials

No NVIC rows: the IWDG ends in a reset, not an interrupt, and the HardFault vector is always live. The handler/trap code in the Procedure is yours to write.

Host — the only host-side tooling is a serial logger to catch the boot/reset messages (course venv, see Toolchain):

source venv/bin/activate        # pyserial is all this lab needs
mkdir -p labs/lab-7-1/host labs/lab-7-1/captures

Log the VCP with python -m serial.tools.miniterm /dev/tty.usbmodem* 115200 (tee it to the capture file), or put a tiny timestamping logger at labs/lab-7-1/host/log_serial.py (pyserial — you write it; ~10 lines).

Where results go:

Artifact Path
Bench note (tables below + decoded CFSR/HFSR bits + addr2line output) labs/lab-7-1/notes.md
Serial boot/reset log (“Recovered from IWDG reset”) labs/lab-7-1/captures/boot-log.txt
Saleae capture of the timeout measurement labs/lab-7-1/captures/iwdg-timeout.sal

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 (D7 = PA8, per Wiring & bench setup) 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 precise bus fault by reading a word-aligned but unmapped address (bus fault escalating to HardFault, CFSR.BusFault.PRECISERR set, BFAR holds the bad address):

    /* Use a WORD-ALIGNED address in an UNIMPLEMENTED region so this is a clean,
       *precise* BusFault. 0xCCCCCCCC is aligned and lands in a reserved region on
       the L4 map — confirm the region is unimplemented in the reference manual. */
    volatile uint32_t *bad = (uint32_t *)0xCCCCCCCC;  /* aligned + unmapped */
    volatile uint32_t x = *bad;   /* precise bus fault here */
    (void)x;
    /* NOTE: do NOT use an *unaligned* address like 0x00000001 for the bus-fault
       demo — with UNALIGN_TRP enabled (step 9) it faults as UsageFault.UNALIGNED
       *before* any bus access, so you'd get a UsageFault, not the BusFault/BFAR
       predicted below. (Trying that on purpose is a good second experiment.) */
  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. Take the stacked_pc value and map it to a line: (a) in the CLion debug session’s GDB console, info line *0x0800XXXX (or list *0x0800XXXX / the disassembly view), or (b) from a terminal, arm-none-eabi-addr2line -e build/debug/m7-rtos.elf 0x0800XXXX for 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 (labs/lab-7-1/notes.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\))
Aligned-unmapped read → 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.

Cross-platform ports & language variants

See the syllabus Implementation tracks for the framing; this is the watchdog/fault-handling version. This lab is pure systems firmware, so the comparison is not about arithmetic throughput — it’s about what “recovery” even means on a bare-metal MCU versus a Linux host, and how the two languages express the same fault-handling contract.

STM32 bare-metal — the raw hardware backstop. The IWDG is a free-running LSI counter that resets the whole chip unconditionally if it isn’t petted; there is no OS underneath to catch anything, so a HardFault either parks the CPU in your handler or escalates to a reset. That determinism is the feature: recovery scope is the entire system, and the latency is the one number \(t_\text{IWDG} = P(\text{RLR}+1)/f_\text{LSI}\) you computed above.

The Linux analog on the Pi 5. The hardware watchdog is /dev/watchdog (the BCM2712 timer) driven either directly or through systemd’s WatchdogSec=, and it is a kernel/systemd-mediated policy, not a raw backstop: it reboots the board only if a supervised service stops sending keep-alives. The fault analog is a SIGSEGV/SIGBUS caught by a sigaction handler installed with SA_SIGINFO, which reads the faulting address from si_addr and calls backtrace() for a post-mortem — the userspace cousin of pulling stacked_pc out of the exception frame. The lesson is the recovery-scope contrast: a segfault kills one process while the kernel and every other process survive, whereas the STM32 fault takes down everything. (The Jetson Orin Nano behaves identically here — same Linux signal + watchdog model, so there is nothing new to port.)

C ↔︎ Rust. On the MCU, Rust replaces the manual asm shim with a panic-reset or panic-halt handler plus the HAL IWDG — a panic becomes a defined reset instead of C’s manual CFSR/HFSR decoding. On Linux, Rust catches panics or uses the signal-hook crate for the segfault path and sd-notify to feed the systemd watchdog. The axis is Rust’s Result/panic model (faults are values or unwinds) versus C’s read-the-fault-status-register-by-hand approach.

Jetson Orin Nano — detailed procedure (embedded Linux)

Build both Linux primitives on the Jetson and trip them deliberately, exactly as Parts B–C did on the MCU. No wiring; everything runs over SSH. Board config: Jetson setup essentials.

  1. mkdir -p labs/lab-7-1/edge. Inventory the hardware backstop: ls -l /dev/watchdog* (the Tegra watchdog) and systemctl show --property=RuntimeWatchdogSec systemd — note whether the system-level dog is armed on your JetPack install before you add per-service policy.
  2. Supervised-service watchdog. Write a trivial C “pipeline” daemon in edge/ that calls sd_notify(0, "WATCHDOG=1") on each loop iteration (link libsystemd; illustrative — write your own), plus a unit file with WatchdogSec=4 and Restart=on-watchdog — the closest analog of the 4.1 s IWDG. Install it as a user service (systemctl --user is fine), start it, confirm it stays up.
  3. Trip it: add the same simulate_hang path (stop notifying on a signal or after N iterations). Watch journalctl -fu <service>: systemd declares the watchdog timeout and restarts the service. Measure hang-to-restart latency from the journal timestamps and set it against the MCU’s \(t_\text{IWDG}\) — and note the scope line in the table: the Jetson’s desktop, your SSH session, and every other process sailed on; the Nucleo took the whole chip down.
  4. Fault post-mortem. Add a sigaction(SIGSEGV, …, SA_SIGINFO) handler that prints si_addr (the faulting address — the BFAR analog) and a backtrace(); trip it with the same aligned-bad-pointer read from Part C. Then re-run without the handler, let it core-dump, and pull the exact faulting line from coredumpctl gdb — the production-grade equivalent of addr2line on stacked_pc.
  5. Save the journal excerpts, the handler’s output, and the coredumpctl backtrace to labs/lab-7-1/captures/, and record the recovery-scope/latency rows in the table.

Raspberry Pi 5 differences: identical procedure — /dev/watchdog is the BCM2712’s, and sd_notify/sigaction/coredumpctl are byte-for-byte the same. The one practical delta: check RuntimeWatchdogSec in /etc/systemd/system.conf if you want the kernel-level reboot backstop armed, since Pi OS ships it off.

Platform / build Watchdog mechanism Recovery scope Recovery latency Measured
STM32 bare-metal, C IWDG (raw LSI counter) whole-chip reset \(t_\text{IWDG}\approx 4.1\) s
STM32 bare-metal, Rust HAL IWDG + panic-reset whole-chip reset ≈ same
Pi 5 / Jetson, C /dev/watchdog + systemd per-process kill, kernel survives WatchdogSec policy
Pi 5 / Jetson, Rust signal-hook + sd-notify per-process kill, kernel survives ≈ same

Going further

  • Windowed watchdog (WWDG): add the STM32’s WWDG, which resets the MCU if you refresh too early as well as too late (it can also raise an early-wakeup interrupt just before the reset) — 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.