Your board was working. You added one feature, flashed it, and now nothing happens — no UART output, no LED, no response. The debugger says you are sitting somewhere unhelpful. Here is the checklist experienced firmware engineers run through, roughly in the order that finds the fault fastest.
First: is it hung, crashed, or never started?
These are three different problems and the fix for one will not touch the others. Attach the debugger and halt the core. Where the program counter stops tells you which you have.
- In
HardFault_Handleror a default handler — you crashed. Go to the fault analysis section below. - Spinning in a loop in your own code — you are hung waiting on something that never happens.
- Before
main(), or at the reset vector — startup or clock configuration failed.
It crashed: reading the fault
A Cortex-M HardFault is not a dead end — the core records why. Read the fault status registers before you do anything else.
CFSR(0xE000ED28) — the configurable fault status register, split into memory manage, bus fault and usage fault bytes. This names the category.HFSR(0xE000ED2C) — ifFORCEDis set, a lower-priority fault escalated, so the real cause is inCFSR.MMFAR/BFAR— the offending address, valid only when the corresponding valid bit inCFSRis set.
The common culprits map predictably:
- A null or wild pointer dereference — bus fault with a suspicious
BFAR, often 0x00000000 or stack garbage. - Unaligned access — usage fault with
UNALIGNEDset. Casting auint8_t*buffer to auint32_t*is the classic. - A function pointer that was never initialised — usage fault with
INVSTATE, because the Thumb bit is wrong. - Stack overflow — frequently presents as a memory manage fault or as impossible-looking corruption of unrelated variables.
Write a fault handler that captures this automatically. It costs an hour once and saves days forever:
void HardFault_Handler(void)
{
__asm volatile (
"tst lr, #4 \n" /* which stack was in use? */
"ite eq \n"
"mrseq r0, msp \n"
"mrsne r0, psp \n"
"b hard_fault_report \n"
);
}
void hard_fault_report(uint32_t *stack)
{
volatile uint32_t stacked_pc = stack[6];
volatile uint32_t stacked_lr = stack[5];
volatile uint32_t cfsr = *(volatile uint32_t *)0xE000ED28;
volatile uint32_t bfar = *(volatile uint32_t *)0xE000ED38;
(void)stacked_pc; (void)stacked_lr; (void)cfsr; (void)bfar;
__asm volatile ("bkpt #0"); /* break here and read the locals */
}
The stacked program counter is the instruction that faulted. Look it up in your map file or disassembly and you usually have the bug in a minute.
It hung: what are you waiting for?
A hang is almost always a wait with no timeout. Check these in order.
A polling loop on a flag that never sets
while (!(SPI->SR & SPI_SR_TXE)); is a hang waiting to happen. If the peripheral clock is not enabled, the peripheral is not configured, or the peripheral is in an error state, that bit never arrives. Every hardware wait in production firmware needs a timeout and an error path.
A missing volatile
If a flag is written by an ISR and read in a loop in main(), and it is not declared volatile, the compiler is entitled to hoist the read out of the loop and spin forever on a stale value. This bug famously appears only at higher optimisation levels, which is why “it works in debug build” is a symptom, not a defence.
Deadlock in RTOS code
Two tasks taking the same two mutexes in opposite orders. Or — the one that catches everybody — calling a blocking RTOS API from an interrupt context. Use the FromISR variants and check your RTOS’s configASSERT output.
Priority inversion
A low-priority task holds a mutex a high-priority task needs, and a medium-priority task starves the low one. The system is not hung, it is live-locked, and it will look identical from the outside. Mutexes with priority inheritance exist for this.
An interrupt storm
If an ISR does not clear its interrupt flag, it re-enters immediately and forever. Main loop code never runs again. Halt the debugger a few times — if you are always inside the same handler, that is your answer.
It never started: clocks and startup
If you never reach main(), suspect in this order:
- Clock configuration. Waiting for an external crystal that is not fitted or not oscillating will stall startup in the PLL wait loop. Fall back to the internal oscillator to test.
- Stack pointer. The first word of the vector table must be a valid initial stack pointer. A bad linker script puts garbage there.
- Wrong vector table location. If you are running under a bootloader,
VTORmust point at your application’s table. - Flash wait states. Raise the core clock without raising wait states and the core reads corrupt instructions.
- Watchdog. If a watchdog is enabled early and your init takes too long, you reset in a loop — which looks a lot like never starting.
When the debugger itself is no help
Sometimes you cannot halt the core, or halting changes the behaviour. Then fall back to physical evidence: toggle a spare GPIO at key points and watch it on a logic analyser. Timing bugs and race conditions that vanish under a debugger show up immediately on a scope trace, because the pin does not care that you stopped the CPU.
That instinct — reaching for an instrument rather than another print statement — is the thing that separates firmware engineers from people who write C. It is also why our embedded labs run on real boards with real test equipment rather than in a simulator.
Leave a Reply