Error Handling Design

Field Value
Title Error Handling Design
Type design
Status stable
Version 1.0.0
Component error-handling
Date 2026-04-13

Responsibilities

Is responsible for: - Capturing Cortex-M CPU register state and a stack-address backtrace when a HardFault exception occurs, before the system resets. - Persisting that diagnostic snapshot in a memory region that is not cleared on reset, so it survives the subsequent boot. - Reporting the cause of the most recent reset (power-up, brown-out, software, hardware, or watchdog) to the application layer via PlatformFactory. - Exposing the formatted fault snapshot and reset cause to the CLI at boot time. - Providing a platform-initiated software reset via PlatformFactory::Reset().

Is NOT responsible for: - Deciding what the application does after a fault (handled by the application layer). - Supervising the event dispatcher with the MCU watchdog — that is the watchdog component’s work (Watchdog Design). This component only reports a watchdog reset once it has happened. - Correcting or masking the fault condition. - Logging to persistent storage beyond the single 1 KiB fault region. - CAN-bus boot notification or any network-layer diagnostics.


Component Details

Fault capture — HardFault handler

A naked assembly trampoline runs in HardFault exception context before any C function prologue executes. It determines whether the exception was entered from thread mode (using PSP as the stack pointer) or from handler mode (using MSP), then passes the correct stack frame pointer to the C-level capture routine.

The capture routine’s first action is to call CutPowerStage(). Everything after it — the register capture, the stack scan, the reset — takes time during which six gate drivers otherwise hold whatever state they had when the fault hit.

CutPowerStage() is declared by the handler and defined by each platform; it is deliberately not a weak fallback. A weak definition in the handler’s own translation unit is the one the linker settles on, because the archive member carrying the real definition is never pulled in — nothing references a symbol only it defines. The result is a hook that is called on every fault and cuts nothing. Requiring the definition means a platform that forgets it fails to link.

On TI, the definition clears PWMENABLE on each PWM module that SYSCTL->PRPWM reports as clocked, which drives every pwmN output low and so turns all six gate signals off. It is a handful of instructions with no loop and no dependency on driver state, and it touches no unclocked peripheral — a write to one would fault again, and a nested fault here is the lockup this call exists to prevent.

On ST the definition is empty, and says so: that target does not drive a bridge yet — both PinsAndPeripherals.hpp are empty and the encoder reports a resolution of zero — so there is no timer instance to name. Cutting the stage there means clearing TIM_BDTR_MOE on whichever advanced-control timer the board wires to the gate drivers, and that belongs with the work that makes the target functional.

Board protection state — BoardProtectionStatus()

PlatformFactory::BoardProtectionStatus() answers asserted, clear or unknown for the board-protection condition, and is what lets the state machine refuse to re-arm a drive whose hardware is still faulted (REQ-SM-025, REQ-SM-026). It is a dispatcher-context query — an implementation may read a peripheral status register, but nothing calls it from an interrupt, so it need not be interrupt-safe.

unknown is a first-class answer, not a failure. A platform reports it whenever it cannot observe the condition, and the state machine then falls back to the retry budget alone — which is the behaviour those platforms had before the query existed. Reporting clear instead would claim a verification the hardware never performed, and on a board with no protection comparators the fault being cleared came from software anyway.

Every platform reports unknown today:

The capture routine then writes all eight words of the ARM exception stack frame (R0–R3, R12, LR, PC, xPSR) plus three Cortex-M fault status registers (CFSR, MMFAR, BFAR) into the persistent region. It also scans the stack above the exception frame and records any word whose value falls within the .text section as a probable return address — this constitutes the backtrace. The scan is bounded to a fixed number of words, not run to _estack: on a bus or MPU fault the stack pointer itself may be the corrupt value, and dereferencing it can nest-fault into lockup, which leaves the motor energised indefinitely because the reset never happens. A validity sentinel is written last; this is the atomic commit point. If the MCU loses power or watchdog-resets mid-write, an incomplete capture is detected and discarded on the next boot.

After capture, the handler calls abort(), which the platform startup maps to NVIC_SystemReset().

Persistent fault region

The fault data structure (PersistentFaultData) is placed in a dedicated .error_handling linker section that is marked NOLOAD. The linker scripts for both TI and ST targets locate this section inside RAM but exclude it from the BSS zero-fill at startup, so its contents survive across a warm reset. The region is exactly 1 KiB and the linker asserts this at link time.

Reset cause detection

Each platform’s PlatformFactoryImpl reads the hardware-specific reset status register during construction, before anything else modifies it. It converts the raw vendor bits to the platform-agnostic ResetCause enumeration and clears the register so that subsequent resets are not misreported. The ResetCause value is then accessible for the lifetime of the application via PlatformFactory::GetResetCause().

Boot-time CLI reporting

The CLI banner reads both the reset cause and any pending fault snapshot from PlatformFactory during construction. If a valid snapshot exists, it is formatted into a human-readable string and the validity sentinel is cleared immediately after formatting, so the snapshot is consumed at most once. The formatted string is retained for the lifetime of the application for later fault_status CLI queries. The banner prints the reset cause unconditionally and surrounds fault data with visible delimiters.


Control-loop and bus statistics

Alongside the post-mortem fault record, the platform keeps two live sets of counters, both readable from the CLI (loop_stats, can_stats, clear_stats).

Control-interrupt execution. The Cortex-M cycle counter is left free-running and read on entry to and exit from the application callback that executes inside the control interrupt; the difference is one measured execution. Unsigned subtraction makes the measurement correct across the counter’s own wrap, which at 120 MHz comes around every 36 seconds. The measured window covers encoder read, control law and PWM write — the work the cycle budget applies to. Hardware ISR entry/exit overhead is excluded; REQ-PERF-001 reserves 25% of the control period for that overhead. The measurement is taken in the platform layer so it is consistent across targets. The emulated target has no cycle counter and reads a free-running timer of its instruction-counted clock instead (software-in-the-loop.md, Part B3).

From it the platform maintains the last, minimum, maximum and a filtered average duration, the execution count, and three failure counts: overruns (over the budget, margin gone, deadline still met), deadline misses (over the period itself), and re-entries (a conversion arriving while the previous interrupt had not returned — the same failure observed as it happens rather than inferred afterwards).

CAN errors. Every error class the adapter reports is counted, not only the bus-off condition that raises a fault. Counting happens on the adapter base class, so no adapter’s reporting path can omit it. This is what separates a bus with marginal termination from one that is unplugged.

Both sets saturate rather than wrap. The cost on the control path is a pair of register reads and about two dozen inlined instructions, under one percent of the 20 kHz period.


Interfaces

Provided

Interface Purpose Contract
PlatformFactory::Reset() Trigger an immediate software reset Called synchronously; does not return
PlatformFactory::GetResetCause() const Return the reset cause captured at boot Valid for the lifetime of the application; thread-safe by value semantics
PlatformFactory::FaultStatus() const Return the formatted fault string (empty if no fault) Valid for the lifetime of the application once the constructor returns

Required

Interface Purpose Contract
Platform linker script Provide .error_handling NOLOAD section in RAM Must be placed before .bss to avoid zero-fill
ARM Cortex-M NVIC Deliver HardFault exception to the handler trampoline Always-enabled; not configurable via NVIC_EnableIRQ
SYSCTL->RESC (TI) / RCC->CSR or RCC->RSR (ST) Provide reset source bits at boot Read once; cleared after read

Data Model

Entity Field Type / Unit Range Notes
PersistentFaultData magic 32-bit unsigned 0 or 0xDEADBEEF Written last to commit capture
PersistentFaultData r0–r3, r12, lr, pc, psr 32-bit unsigned Any ARM exception stack frame words
PersistentFaultData cfsr, mmfar, bfar 32-bit unsigned Any Cortex-M fault status registers
PersistentFaultData stackTrace[243] 32-bit unsigned array Any Addresses within .text found on stack
PersistentFaultData stackTraceCount 32-bit unsigned 0–243 Number of valid trace entries
ResetCause — enum powerUp, brownOut, software, hardware, watchdog MCU-agnostic

ResetCause::watchdog comes from the reset source bits alone. The watchdog never resets by software: on expiry it cuts the power stage and stops refreshing, and the peripheral’s own reset sets the watchdog bit. No marker has to survive the reset on an MCU; the emulated target, which has no reset source bits, keeps a semihosting marker file instead. See Watchdog Design.


State Machine

State diagram

Sequence Diagrams

Boot with prior fault

Sequence diagram

HardFault capture

Sequence diagram