TamgaOS (yula)
RSS github
July 2026 TamgaOS FPU STM32H7 Cortex-M7 Debug Log GDB

FPU Context Switch
7 Bugs Between Enabling the FPU and Two Float Tasks Running in Parallel

The K64F (Cortex-M4) scheduler was stable — but both ports were running soft-float. When we decided to enable the FPU properly on the STM32H753ZI (Cortex-M7, 480 MHz), it introduced a new class of problems: floating-point context switching. Seven distinct root causes, multiple GDB sessions, one detour through STM32CubeIDE, and finally — two float tasks running in parallel with isolated FPU state.
This is the full debug log. Every crash, every wrong assumption, every register value that didn't match expectations.

— FPU Background —

Cortex-M7 FPU — what the hardware actually does

The Cortex-M7 implements the FPv5-D16 floating-point unit — 32 single-precision registers (S0–S31), addressable as 16 double-precision registers (D0–D15). Unlike the integer core registers, FPU registers are not automatically saved on every exception entry. The hardware uses a mechanism called lazy stacking to reduce exception latency.

CPACR — Coprocessor Access Control Register

FPU access is controlled by CPACR at 0xE000ED88. Bits [23:20] control CP10 and CP11 (the two coprocessor slots the FPU occupies). Both must be set to 0b11 (full access) before any floating-point instruction is executed. Without this, any FPU instruction causes a NOCP (No Coprocessor) UsageFault.

startup_stm32h753zi.s — FPU enable
/* ── 3.5. FPU enable — CPACR CP10/CP11 full access ── */
ldr  r0, =0xE000ED88        /* CPACR */
ldr  r1, [r0]
orr  r1, r1, #(0xF << 20)  /* CP10=11, CP11=11 */
str  r1, [r0]
dsb
isb

This goes in the startup assembly file, after cache initialization and before the BSS zero loop. The dsb/isb pair ensures the write is committed before the next instruction fetch, which matters if the very next instruction uses the FPU.

Lazy stacking — what it is and why it matters for RTOS

Here is what lazy stacking actually does: when an exception arrives and the FPU has been used (CONTROL.FPCA = 1), the hardware reserves 18 words of stack space for the FPU state but does not immediately write the register values. The space is there, but it is empty. If the exception handler never executes a float instruction, those 18 words are never filled — the handler exits faster. Only when a float instruction actually runs inside the handler does the hardware go back and write S0–S15 and FPSCR into the reserved space. This is the "lazy" part: write it later, only if needed.

For an RTOS this matters because PendSV (the context switch handler) needs to know whether the task being switched out was using the FPU. The answer is encoded in EXC_RETURN bit[4]: 0 means FPU state is present in the frame, 1 means it is not. PendSV checks this bit to decide whether to also save S16–S31 (the upper half, which the hardware never saves automatically). This is controlled by FPCCR.LSPEN (bit 30) at 0xE000EF34, which defaults to 1.

EXC_RETURN valueMeaningStack frame size
0xFFFFFFF1Handler mode, MSP, no FPU32 bytes (8 words)
0xFFFFFFF9Thread mode, MSP, no FPU32 bytes (8 words)
0xFFFFFFFDThread mode, PSP, no FPU32 bytes (8 words)
0xFFFFFFE1Handler mode, MSP, FPU active104 bytes (26 words)
0xFFFFFFE9Thread mode, MSP, FPU active104 bytes (26 words)
0xFFFFFFEDThread mode, PSP, FPU active104 bytes (26 words)

The key bit is bit[4] of EXC_RETURN: 1 means no FPU state in the frame, 0 means FPU state present. PendSV uses this to decide whether to save/restore S16–S31.

EXC_RETURN — the register that carries context state

When an exception is entered, the CPU writes a special value into LR called EXC_RETURN. This is not a real address — values in the 0xFFFFFFE_ and 0xFFFFFFF_ range are reserved as EXC_RETURN markers. The value encodes where to return: which mode, which stack pointer, and whether FPU state was saved.

For an RTOS, EXC_RETURN must be stored per-task. When task A uses floats and task B does not, their EXC_RETURN values differ. PendSV saves the current LR (which is EXC_RETURN on exception entry) alongside R4–R11 in the software frame, so each task restores the correct EXC_RETURN on context switch.

pendsv_handler.s — save EXC_RETURN with integer regs
PendSV_Handler:
    mrs     r0, psp
    isb
    ldr     r3, =g_current_task
    ldr     r2, [r3]
    cbz     r2, PendSV_restore

    tst     lr, #0x10           /* bit4=0 → FPU state present */
    it      eq
    vstmdbeq r0!, {s16-s31}    /* save high FPU regs if used */

    stmdb   r0!, {r4-r11, lr}  /* save integer regs + EXC_RETURN */
    str     r0, [r2, #0]        /* task->sp = r0 */

PendSV_restore:
    ldr     r3, =g_next_task
    ldr     r2, [r3]
    ldr     r3, =g_current_task
    str     r2, [r3]
    ldr     r0, [r2, #0]
    ldmia   r0!, {r4-r11, lr}  /* restore integer regs + EXC_RETURN */
    tst     lr, #0x10
    it      eq
    vldmiaeq r0!, {s16-s31}    /* restore high FPU regs if used */
    msr     psp, r0
    isb
    bx      lr                  /* EXC_RETURN → correct mode/stack/FPU */

— Debug Timeline —

How the FPU debug unfolded

Enable FPU in startup, add -mfpu=fpv5-d16 -mfloat-abi=hard
First attempt: add CPACR enable to startup.s and switch to hard-float ABI. Board immediately crashes with a new fault type.
NOCP fault — CPACR not set before first float instruction
The FPU enable was placed too late in the startup sequence. Cache initialization code ran before CPACR was written. Any FPU instruction before CPACR → NOCP UsageFault.
Task functions disappear from the binary
After fixing CPACR placement, the linker started dropping task functions entirely. arm-none-eabi-nm showed no task_float_a symbol. Root cause: -ffunction-sections in combination with function pointer references that the linker couldn't trace.
PendSV crashes immediately after first context switch
With task functions in the binary, the first task ran but crashed on the first context switch. The software frame layout in task_stack_init didn't match what PendSV expected to find.
bx lr with 0xFFFFFFFD from Thread mode — INVSTATE
The first-task launch assembly used bx lr with EXC_RETURN in LR from a non-exception context. This causes an INVSTATE UsageFault. Only valid inside an exception handler.
STM32CubeIDE debug attempt — ABI mismatch
Tried using STM32CubeIDE for step-debugging. The IDE compiled with -mfloat-abi=soft while our Makefile used hard-float. The debug binary was different from the flash binary. Abandoned CubeIDE, went back to pyocd + GDB.
SVC_Handler approach — SVC_Handler missing from vector table
Switched to FreeRTOS-style SVC-based first task launch. SVC triggered correctly but jumped to Default_Handler. The startup.s had .word Default_Handler for SVCall slot instead of .word SVC_Handler.
SVC_Handler runs but ldmia loads zeros — task_stack_init layout wrong
SVC_Handler reached correctly, but after ldmia r0!, {r4-r11, r14}, LR = 0. The task stack was initialized but the software frame layout didn't match the ldmia register ordering. Verified with GDB memory dump.
LDREX/STREX + lazy stacking = mutex deadlock
After the scheduler worked, mutex + FPU combination caused crashes. SysTick firing between LDREX and STREX cleared the exclusive monitor via lazy FPU stacking. STREX silently failed, mutex appeared acquired but wasn't.
Two float tasks running. FPU state isolated per-task. Mutex fixed.
task_float_a accumulates up, task_float_b decrements down — no cross-contamination of FPU registers across context switches.

— Root Causes —

Bug 1 — NOCP fault: CPACR written too late

Bug #1UsageFault CFSR=0x00080000 — No Coprocessor

The FPU enable block was originally placed after the BSS zero loop in startup.s. The hard-float ABI causes the compiler to emit FPU instructions for floating-point operations anywhere — including inside startup helper functions that run before main(). With CPACR still at reset value (CP10/CP11 = 0b00, access denied), the first FPU instruction causes a NOCP UsageFault.

CFSR bit 19 (NOCP) at 0xE000ED28 confirmed this. The fault address in BFAR was pointing at startup code, not at task code.

Fix Move CPACR enable to immediately after cache initialization, before any C code runs. In startup.s, this means placing it between the D-Cache enable block and the .data copy loop.

ldr r0, =0xE000ED88
ldr r1, [r0]
orr r1, r1, #(0xF << 20) /* CP10=11, CP11=11 */
str r1, [r0]
dsb
isb
CFSR register decode
0xE000ED28 = 0x00080000
           bit 19 = NOCP (No Coprocessor)
           → FPU instruction executed with CP10/CP11 access denied
           → Fix: set CPACR bits [23:20] = 0b1111 in startup

Bug 2 — -ffunction-sections drops task functions

Bug #2task_float_a absent from ELF — linker garbage-collected it

-ffunction-sections places each function in its own ELF section (.text.task_float_a etc.). The linker then removes sections with no incoming references — unless --gc-sections is explicitly used. But even without --gc-sections, sched_task_create(task_float_a, ...) passes a function pointer, and the linker sometimes cannot trace this reference through indirect calls, especially with link-time optimization or when the pointer is passed through a void* parameter.

The symptom was invisible at compile time — no warnings. arm-none-eabi-nm tamgaos_stm32h753zi.elf | grep task_float returned nothing. The binary was 5KB smaller than expected. Task stack init wrote 0x00000000 as the PC because the function didn't exist in the binary.

Fix Remove -ffunction-sections and -fdata-sections from CFLAGS.
Verify with: arm-none-eabi-nm tamgaos_stm32h753zi.elf | grep task_float
Functions must appear with T (text) type before flashing.
verification — symbols present after fix
PS> arm-none-eabi-nm tamgaos_stm32h753zi.elf | Select-String "task_float" 08001634 T task_float_a ← present, in .text 08001664 T task_float_b ← present ; Before fix: both lines missing entirely

Bug 3 — PendSV software frame layout mismatch

Bug #3task_stack_init wrote S16-S31 at wrong offset relative to R4-R11

The FPU-aware PendSV handler saves context in this order (stack grows down):

vstmdb r0!, {s16-s31} — S16–S31 pushed first (highest address in software frame)
stmdb r0!, {r4-r11, lr} — R4–R11 + EXC_RETURN pushed second (lowest address, SP points here)

The restore mirrors this exactly: ldmia reads R4–R11+LR first (from low address), then vldmia reads S16–S31. The issue was that task_stack_init was initializing the FPU slots in the wrong position — placing them below R4 instead of above LR. When PendSV restored the first task, it read garbage into R4–R11 and loaded 0x00000000 into LR (EXC_RETURN).

bx lr with LR=0 → branch to address 0x00000000 → IACCVIOL (Instruction Access Violation).

Fix task_stack_init must match PendSV save order exactly.
Stack grows down, so push order is top-to-bottom (-- operator):

*(--sp) = xPSR; /* highest addr */
*(--sp) = PC;
*(--sp) = LR (0xFFFFFFFD);
*(--sp) = R12, R3, R2, R1, R0; /* exception frame */
*(--sp) = EXC_RETURN (0xFFFFFFFD); /* software frame start */
*(--sp) = R11 ... R4; /* lowest addr — SP points here */
task_stack_init — correct layout
/* Stack grows down. Last *(--sp) = lowest address = SP value returned. */

/* Exception frame (hardware auto-save on exception entry) */
*(--sp) = 0x01000000UL;          /* xPSR — Thumb bit */
*(--sp) = (uint32_t)func | 1U;  /* PC (Thumb) */
*(--sp) = 0xFFFFFFFDUL;         /* LR — EXC_RETURN, Thread/PSP, no FPU */
*(--sp) = 0U;                   /* R12 */
*(--sp) = 0U;                   /* R3 */
*(--sp) = 0U;                   /* R2 */
*(--sp) = 0U;                   /* R1 */
*(--sp) = 0U;                   /* R0 */

/* Software frame (PendSV saves/restores this) */
*(--sp) = 0xFFFFFFFDUL;         /* EXC_RETURN ← PendSV stores LR here */
*(--sp) = 0U;                   /* R11 */
*(--sp) = 0U;                   /* R10 */
*(--sp) = 0U;                   /* R9 */
*(--sp) = 0U;                   /* R8 */
*(--sp) = 0U;                   /* R7 */
*(--sp) = 0U;                   /* R6 */
*(--sp) = 0U;                   /* R5 */
*(--sp) = 0U;                   /* R4 ← SP points here */

return sp;

Bug 4 — bx lr with EXC_RETURN from Thread mode

Bug #4INVSTATE fault — EXC_RETURN only valid inside exception handler

The updated sched_start_asm ended with bx lr after loading EXC_RETURN (0xFFFFFFFD) into LR from the task software frame. This looked correct — PendSV does the same thing. But PendSV executes from Handler mode. sched_start_asm is called as a normal C function from sched_start(), which runs in Thread mode.

Executing bx 0xFFFFFFFD from Thread mode is an invalid state transition — the CPU is not in an exception handler, so there's no exception frame to unstack. The result is an INVSTATE (Invalid State) UsageFault. CFSR bit 16 (INVSTATE) confirmed this.

The fix required getting into Handler mode first, then doing the exception return. This is exactly what FreeRTOS does: use an SVC instruction to enter Handler mode, then perform the context restore inside the SVC handler.

Fix Replace direct bx lr with SVC-based launch (see Bug 5).
sched_start_asm triggers SVC 0 → CPU enters Handler mode → SVC_Handler restores context → bx r14 is now a valid EXC_RETURN from Handler mode.

Bug 5 — SVC_Handler missing from vector table

Bug #5SVC instruction jumped to Default_Handler

After implementing the SVC approach, the svc 0 instruction executed correctly (confirmed in GDB by stepping through sched_start_asm), but instead of entering SVC_Handler, execution jumped to Default_Handler and looped forever.

The startup.s vector table had: .word Default_Handler /* 11 SVCall */. The SVCall exception slot (exception #11, vector table offset 44) pointed to the generic handler instead of our custom SVC_Handler. Even though SVC_Handler existed in the binary (confirmed with arm-none-eabi-nm), the vector table didn't know about it.

This is a common mistake when porting between targets — the vector table in startup.s must be manually updated for any new exception handler.

Fix Change vector table slot 11 in startup_stm32h753zi.s:

/* BEFORE */
.word Default_Handler /* 11 SVCall */

/* AFTER */
.word SVC_Handler /* 11 SVCall */
sched_start.s — SVC-based first task launch (FreeRTOS style)
sched_start_asm:
    cpsid   i
    cpsie   i
    cpsie   f
    dsb
    isb
    svc     0           ← triggers SVCall exception → Handler mode
    nop

SVC_Handler:
    ldr     r3, =g_current_task
    ldr     r1, [r3]
    ldr     r0, [r1]            /* r0 = task->sp */
    ldmia   r0!, {r4-r11, r14} /* restore R4-R11 + EXC_RETURN into LR */
    tst     r14, #0x10
    it      eq
    vldmiaeq r0!, {s16-s31}
    msr     psp, r0
    isb
    mov     r0, #0
    msr     basepri, r0
    bx      r14         ← valid EXC_RETURN from Handler mode ✓

Bug 6 — task_stack_init: stack was initialized but memory read as zero

Bug #6GDB showed LR=0 after ldmia — memory dump revealed all zeros

After fixing the vector table, SVC_Handler was reached. Stepping through with GDB, ldmia r0!, {r4-r11, r14} executed but R14 (LR) contained 0x00000000 instead of 0xFFFFFFFD. A memory dump of the task SP address showed 17 words of zeros.

The issue was subtle: task_stack_init was being called correctly and was writing the correct values — verified by adding a breakpoint at return sp and inspecting memory immediately. But by the time SVC_Handler ran, the memory was zeroed again.

Root cause: the BSS zero loop in startup.s ran after sched_task_create was called. This can't happen in normal execution (startup → main → sched_task_create). But under certain debug scenarios or if the startup assembly was restructured, the zero loop could wipe out task stacks that were initialized before startup completed.

In practice, the real cause was a startup.s edit that accidentally moved the BSS zero loop to the wrong position. After verifying the startup sequence was correct, the task stacks initialized properly and persisted to runtime.

Fix Verify startup sequence order:
1. ExitRun0Mode (STM32H7 specific)
2. Cache invalidate + enable
3. FPU enable (CPACR)
4. .data copy Flash→DTCM
5. BSS zero loop ← must be here, not after
6. bl main

Never initialize task stacks before startup completes. sched_init() and sched_task_create() must be called from main(), after BSS has been zeroed.
GDB — memory dump at SVC_Handler entry
(gdb) break SVC_Handler Breakpoint 2 at 0x80002aa: file kernel/arch/cortex_m7/sched_start.s, line 35. (gdb) continue Breakpoint 2, SVC_Handler () at sched_start.s:35 (gdb) stepi 3 ; after ldr r0, [r1] → r0 = task->sp (gdb) info registers r0 r0 0x200009fc 536873468 (gdb) x/17xw 0x200009fc 0x200009fc: 0x00000000 0x00000000 0x00000000 0x00000000 0x2000100c: 0x00000000 0x00000000 0x00000000 0x00000000 0x2000101c: 0xfffffffd 0x00000000 0x00000000 0x00000000 0x2000102c: 0x00000000 0x00000000 0xfffffffd 0x08001ca1 0x2000103c: 0x01000000 ; After fix — layout correct: ; [0x9fc] R4=0 R5=0 R6=0 R7=0 ; [0xa0c] R8=0 R9=0 R10=0 R11=0 ; [0xa1c] EXC_RETURN=0xFFFFFFFD ← LR slot ; [0xa2c] ... LR=0xFFFFFFFD PC=0x08001ca1 ← task entry ; [0xa3c] xPSR=0x01000000

Bug 7 — LDREX/STREX exclusive monitor cleared by lazy FPU stacking

Bug #7mutex_lock crashed or deadlocked when FPU was active

After the scheduler was working, adding mutex_lock() in tasks that also used floats caused crashes. The pattern was consistent: mutex + FPU = board freeze. mutex alone worked, FPU alone worked.

The root cause is documented in the ARMv7-M Architecture Reference Manual (section on exclusive monitors) and in the Cortex-M7 Generic User Guide section on lazy stacking:

Lazy stacking clears the exclusive monitor. When FPCCR.LSPEN=1 (default), a SysTick interrupt arriving between LDREX and STREX can trigger lazy FPU stacking. The lazy stacking operation internally performs a memory access that clears the local exclusive monitor. The subsequent STREX sees a cleared monitor and returns 1 (failure). If the code doesn't retry on STREX failure, the mutex appears acquired but wasn't — or the retry loop spins forever preventing scheduling.

The original mutex_try_acquire called STREX once without a retry loop. When STREX failed silently, the function returned "acquired" incorrectly (because it checked store_result == 0 which happened to be true for an uninitialized stack variable), leading to two tasks both thinking they held the mutex.

Fix Replace LDREX/STREX with PRIMASK-based critical section in mutex_try_acquire().
PRIMASK disables all interrupts (including SysTick) for the duration of the acquire, eliminating the exclusive monitor interaction entirely.

static int mutex_try_acquire(mutex_t *m) {
uint32_t p = sched_critical_enter(); /* cpsid i */
int result = 0;
if (m->task == NULL) {
m->task = g_current_task;
result = 1;
}
sched_critical_exit(p); /* restore PRIMASK */
return result;
}

Why not disable lazy stacking instead? Setting FPCCR.LSPEN=0 forces the hardware to always save FPU state on exception entry, eliminating the race. But this adds 18 words (72 bytes) to every exception entry when FPU registers are active — increasing interrupt latency. For a preemptive RTOS at 480 MHz this is acceptable, but PRIMASK for mutex acquire is simpler and has no latency impact on the interrupt path.

— Debug Tools —

The STM32CubeIDE detour — why it didn't work

With the task_stack_init mystery unsolved (GDB showed zeros but nm showed the function existed), the temptation was to use STM32CubeIDE's step debugger to watch task_stack_init write its values in real time. The IDE has a memory browser, live variable watch, and register view — exactly what was needed.

It didn't work. The problem was the compile flags.

CubeIDE compile flags vs Makefile
/* STM32CubeIDE default */
-mcpu=cortex-m7 -mthumb -mfloat-abi=soft --specs=nano.specs

/* Our Makefile */
-mcpu=cortex-m7 -mthumb -mfpu=fpv5-d16 -mfloat-abi=hard

The ABI mismatch is fatal for debugging. With -mfloat-abi=soft, the IDE compiled a completely different binary — float arguments passed in integer registers, no FPU instructions generated, different calling convention. Stepping through CubeIDE's binary would show completely different behavior from the actual flashed firmware.

CubeIDE can be configured to use hard-float ABI (Project Properties → C/C++ Build → Settings → MCU GCC Compiler → General), but getting it to match the Makefile exactly — including all the kernel assembly files and section placements — would require recreating the entire build system inside the IDE. Not worth it for a debugging session.

The real answer: pyocd + arm-none-eabi-gdb using the exact same ELF produced by the Makefile. No ABI mismatch, no build system discrepancy. The binary flashed and the binary debugged are identical.

GDB Session 1 — Identifying the NOCP fault

pyocd gdbserver -t stm32h743xx + arm-none-eabi-gdb
(gdb) target extended-remote localhost:3333 Remote debugging using localhost:3333 Default_Handler () at startup_stm32h753zi.s:323 (gdb) monitor reset halt (gdb) break Default_Handler (gdb) continue Breakpoint 1, Default_Handler () (gdb) x/1xw 0xE000ED28 0xe000ed28: 0x00080000 ← CFSR: bit19 = NOCP (gdb) info registers pc lr pc 0x08000374 Default_Handler lr 0xfffffff9 ← Thread/MSP EXC_RETURN — fault from Thread mode (gdb) x/1xw 0xE000EF34 0xe000ef34: 0xC0000000 ← FPCCR: ASPEN=1 LSPEN=1, but CPACR not set ; Conclusion: FPU instruction executed before CPACR was written ; Fix: move CPACR enable before BSS loop in startup.s

GDB Session 2 — tracing the zero stack

tracing task_stack_init write vs SVC_Handler read
(gdb) break task_stack_init (gdb) monitor reset halt (gdb) continue Breakpoint 1, task_stack_init (sp=0x20000a40, func=0x8001ca1 <task_float_a>) at kernel/core/scheduler.c:112 (gdb) finish Value returned is $4 = (uint32_t *) 0x200009fc <s_tasks+2524> (gdb) x/17xw 0x200009fc ; immediately after task_stack_init returns: 0x200009fc: 0x00000000 0x00000000 0x00000000 0x00000000 0x2000100c: 0x00000000 0x00000000 0x00000000 0x00000000 0x2000101c: 0xfffffffd 0x00000000 0x00000000 0x00000000 0x2000102c: 0x00000000 0x00000000 0xfffffffd 0x08001ca1 0x2000103c: 0x01000000 ← layout correct ✓ (gdb) break SVC_Handler (gdb) continue Breakpoint 2, SVC_Handler () at sched_start.s:35 (gdb) stepi 3 (gdb) info registers r0 r0 0x200009fc (gdb) x/17xw 0x200009fc ; same address, same time — still correct 0x200009fc: 0x00000000 0xfffffffd 0x08001ca1 0x01000000 ... (gdb) stepi ; ldmia r0!, {r4-r11, r14} (gdb) info registers r4 r5 r14 r4 0x0 ← correct r14 0xfffffffd ← EXC_RETURN loaded correctly ✓ ; Once the startup sequence was fixed, memory persisted correctly

GDB Session 3 — verifying SVC → SVC_Handler path

stepping through svc 0 to SVC_Handler
(gdb) break sched_start_asm (gdb) monitor reset halt (gdb) continue Breakpoint 1, sched_start_asm () at sched_start.s:17 (gdb) stepi 5 22 svc 0 (gdb) stepi SVC_Handler () at sched_start.s:34 ; CPU entered Handler mode — confirmed by: ; - PC now in SVC_Handler ; - LR = 0xFFFFFFF9 (Handler/Thread/MSP EXC_RETURN) (gdb) info registers lr control lr 0xfffffff9 ← Handler mode EXC_RETURN ✓ control 0x0 ← SPSEL=0 still (will be set by bx r14) ; Before fix: SVC landed in Default_Handler ; After adding .word SVC_Handler in vector table: reached SVC_Handler ✓

GDB Session 4 — verifying ldmia and bx r14

full SVC_Handler trace — from ldmia to task entry
(gdb) break SVC_Handler (gdb) continue Breakpoint, SVC_Handler () at sched_start.s:35 (gdb) stepi 4 ; after ldmia r0!, {r4-r11, r14} (gdb) info registers r4 r5 r6 r7 r8 r9 r10 r11 r14 r4 0x0 r5 0x0 r6 0x0 r7 0x0 r8 0x0 r9 0x0 r10 0x0 r11 0x0 r14 0xfffffffd ← EXC_RETURN (Thread/PSP/no FPU) ✓ (gdb) stepi 5 ; after msr psp, r0 / mov r0,#0 / msr basepri,r0 / bx r14 task_float_a () at src/stm32h753zi/main.c:43 (gdb) info registers pc psp control pc 0x8001ca0 ← task_float_a entry ✓ psp 0x20000a40 ← task stack pointer ✓ control 0x2 ← SPSEL=1, Thread/PSP mode ✓ ; bx r14 performed a valid EXC_RETURN from Handler mode ; CPU switched to Thread/PSP mode, exception frame unstacked ; task_float_a begins executing on PSP ✓

— Final State —

What works now

✓ FPU enabled — CPACR CP10/CP11 full access in startup.s
✓ Hard-float ABI — -mfpu=fpv5-d16 -mfloat-abi=hard
✓ PendSV FPU context switch — S16-S31 saved/restored via tst lr, #0x10
✓ EXC_RETURN stored per-task in software frame
✓ SVC-based first task launch — FreeRTOS style, valid from Handler mode
✓ FPU state isolated — task_float_a accumulates up, task_float_b decrements down, no cross-contamination
✓ Mutex fixed — PRIMASK instead of LDREX/STREX, no lazy stacking interaction

ComponentFileStatus
CPACR enablestartup_stm32h753zi.sFixed — before BSS loop
PendSV FPU savependsv_handler.sWorking — S16-S31, EXC_RETURN
task_stack_initscheduler.cFixed — software frame layout correct
SVC first task launchsched_start.sWorking — FreeRTOS style
Vector table SVCallstartup_stm32h753zi.sFixed — SVC_Handler in slot 11
mutex_try_acquiremutex.cFixed — PRIMASK, no LDREX/STREX
-ffunction-sectionsMakefileRemoved — task symbols stay in binary
uart_printf + FPUuart.cva_list + hard-float ABI stack usage — print after delay as workaround

Lessons worth writing down

EXC_RETURN is a Handler mode concept

This is the single most important thing to understand about Cortex-M exception handling. 0xFFFFFFFD in LR means "return to Thread/PSP" only when the CPU is currently in Handler mode. From Thread mode, it's just an address in reserved memory. The first task must be launched via an exception (SVC is the standard choice) so that the CPU is in Handler mode when bx lr executes.

-ffunction-sections and function pointers don't mix

-ffunction-sections is useful for reducing binary size by allowing the linker to discard unused functions. But when you pass a function pointer to another function (like sched_task_create(task_led, PRIORITY_NORMAL)), the linker sees the reference through a void pointer and may not count it as a "use" of the function. The function disappears silently. Always verify with arm-none-eabi-nm that your task functions appear in the binary before flashing.

LDREX/STREX and lazy FPU stacking are incompatible without a retry loop

The ARM architecture manual documents that exclusive monitor state is not preserved across exception boundaries. With lazy stacking enabled, a SysTick interrupt between LDREX and STREX can clear the monitor via the stacking mechanism. The correct LDREX/STREX pattern is a retry loop — if STREX returns 1 (fail), the whole LDREX/STREX sequence must repeat. For a mutex, PRIMASK-based atomic access is simpler and avoids the interaction entirely.

Debug with the same binary you flash

STM32CubeIDE uses -mfloat-abi=soft by default while hard-float projects need -mfloat-abi=hard. The two ABIs generate completely different code. Debugging a soft-float build while investigating a hard-float bug produces misleading results. Always use the same ELF for flashing and debugging — pyocd + GDB with your own Makefile binary is the safe choice.

The map file and nm are your first debuggers

Before touching GDB, arm-none-eabi-nm tamgaos_stm32h753zi.elf | grep task_float immediately showed whether the function was in the binary. The map file showed the vector table size (.vectors 0x298) and whether SVC_Handler appeared at offset 44. Five minutes reading the map saved hours of GDB stepping.

Startup sequence order matters for FPU

CPACR must be set before any C code runs — including startup helper functions that the compiler might optimize to use FPU instructions. Cache initialization, BSS zeroing with memset, even simple struct initialization can generate FPU instructions under hard-float ABI. The only safe place for CPACR enable is in the startup assembly, as early as possible, before calling any C functions.

References

ARM architecture

STM32H7

FreeRTOS reference

Project