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:
- TI — the protection comparators are ADC digital
comparators (
DigitalComparatorConfig, steps 3 and 4) whose outputs drive the PWM fault inputs. Their counts come from the motor board’sBoardCharacteristics; the total-current sense is biased to mid-rail, soOvercurrentThresholdCountsis an offset above half scale (6.6 A, 80 % of the +8.25 A a 5 A/V sense spans on a 3.3 V ADC). That ADC is triggered by the PWM generator, so once the bridge is stopped it stops converting, the comparators stop evaluating, and no status register distinguishes a cleared condition from an unevaluated one. Answering this honestly needs either a sampling path that survives the bridge stopping —PowerSupplyVoltage()already runs on a separately triggered ADC and could verify overvoltage againstBoardCharacteristics::OvervoltageThresholdCounts— or a software/continuous ADC trigger inhal-tiso the comparators keep evaluating. Overcurrent is arguably not observable at all while stopped: no current flows through a disabled bridge. - ST, QEMU — neither wires board protection, so there is no condition to observe.
- host — settable, so tests can hold the condition asserted across a re-arm attempt.
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.