Summary
On synth v0.11.40, the dissolved k_mutex_unlock body compiles but silently miscompiles: the incoming mutex-pointer argument and a call return value are assigned the same spill slot ([sp,#0x24]) despite overlapping live ranges. The mutex pointer is clobbered by gale_w_unpend_first_thread's result, so a later field store that reloads the slot writes to the wrong address.
Concretely: in the no-waiter unlock path, mutex->lock_count = 0 is emitted but lands on linmem[0+12] instead of linmem[mutex+12], leaving lock_count = 1. On silicon (NUCLEO-G474RE, 170 MHz) this deadlocks: the next k_mutex_lock(&m, K_FOREVER) sees lock_count≠0 ∧ owner≠current → blocks forever on an ownerless mutex → CPU idles. Observed hung state: owner=0, lock_count=1 (an impossible-by-construction inconsistent state that this bug uniquely produces).
This is the same "live value clobbered → silent wrong-code" family as #311 (closed, i64 pair clobbered by mask-constant materialization), but a different mechanism: spill-slot collision between a live incoming-arg pointer and a call result, rather than a register clobber. Likely related to #326 (register-exhaustion at this same site) — the call-result-pair pressure that forces the spill is what then collides.
Evidence — disassembly of the actual compiled body.o (synth v0.11.40, --target cortex-m4f --native-pointer-abi --relocatable)
Entry spills the mutex ptr (arg0) to its home slot:
8: f8cd 0024 str.w r0, [sp, #0x24] ; HOME slot for mutex ptr (live to 0x1e2)
Tag-1 (release + unpend) path:
128: f8dd 7024 ldr.w r7, [sp, #0x24] ; r7 = mutex (register copy)
136: f7ff fffe bl gale_w_unpend_first_thread ; r0 = new_owner (NULL if no waiter)
13a: f8cd 0024 str.w r0, [sp, #0x24] ; *** CLOBBERS mutex home slot with call result ***
156: f84b 100c str.w r1, [r11, r12] ; (r12=r7+8) owner = new_owner -- OK, uses r7
168: d137 bne 0x1da ; new_owner==0 -> no-waiter sub-path
No-waiter sub-path — reloads the clobbered slot as the mutex base:
1e2: f8dd 3024 ldr.w r3, [sp, #0x24] ; r3 = [clobbered slot] = unpend result = 0 (no waiter)
1ea: f103 0c0c add.w r12, r3, #0xc ; r3 + 12 (intended: mutex + lock_count_off)
1ee: f84b 500c str.w r5, [r11, r12] ; lock_count = 0 -> writes linmem[0+12], NOT mutex->lock_count
The only writes to [sp,#0x24] between entry and 0x1e2 are 0x13a (unpend result) and 0x1aa (the other, has-waiter branch). In the no-waiter path the slot therefore holds 0, so the store misses. (Note the has-waiter path at 0x16a: ldr r7,[sp,#0x24] then 0x172: add r7,#0xc; str is also corrupted — it computes new_owner+12 for the lock_count=1 store — just not exercised by the single-thread selfcheck.) Relocation 0x136 R_ARM_THM_CALL gale_w_unpend_first_thread confirms the clobbering value's identity.
r7 still holds the valid mutex pointer at 0x1e2 — the allocator had a live copy available and chose the clobbered slot instead.
Kill-criterion
This claim is wrong if: a clean re-decode of synth_k_mutex_unlock_body on a fixed build shows the no-waiter lock_count store deriving its base from a slot/reg that holds the mutex pointer (not the unpend result) at 0x1e2; or if on-silicon k_mutex_lock/unlock round-trips without deadlock and lock_count reads 0 after a no-waiter unlock.
Fix direction
The mutex pointer (arg0) is live across the gale_w_unpend_first_thread call and used again afterward (no-waiter lock_count store, and the has-waiter lock_count store). The allocator must not reuse arg0's spill slot for a call return value while arg0 is still live — either keep arg0 in a callee-saved register across the whole body, or give the call result a distinct slot. This is a spill-slot extension of the register-level live-range tracking that #311/#226/#231/#232 exercised; the slot allocator appears to treat the home slot as dead once the value is also in a register, but then reloads the (now-clobbered) slot rather than the register.
Repro shape
k_mutex_lock(&m, K_FOREVER); k_mutex_unlock(&m); single-threaded (no waiter) on the dissolved/--native-pointer-abi mutex shim, cortex-m4f. Compiles clean; deadlocks at runtime. Faithful Zephyr v4.4.0 k_mutex layout (owner@8, lock_count@12).
Summary
On synth v0.11.40, the dissolved
k_mutex_unlockbody compiles but silently miscompiles: the incoming mutex-pointer argument and a call return value are assigned the same spill slot ([sp,#0x24]) despite overlapping live ranges. The mutex pointer is clobbered bygale_w_unpend_first_thread's result, so a later field store that reloads the slot writes to the wrong address.Concretely: in the no-waiter unlock path,
mutex->lock_count = 0is emitted but lands onlinmem[0+12]instead oflinmem[mutex+12], leavinglock_count = 1. On silicon (NUCLEO-G474RE, 170 MHz) this deadlocks: the nextk_mutex_lock(&m, K_FOREVER)seeslock_count≠0 ∧ owner≠current→ blocks forever on an ownerless mutex → CPU idles. Observed hung state:owner=0, lock_count=1(an impossible-by-construction inconsistent state that this bug uniquely produces).This is the same "live value clobbered → silent wrong-code" family as #311 (closed, i64 pair clobbered by mask-constant materialization), but a different mechanism: spill-slot collision between a live incoming-arg pointer and a call result, rather than a register clobber. Likely related to #326 (register-exhaustion at this same site) — the call-result-pair pressure that forces the spill is what then collides.
Evidence — disassembly of the actual compiled
body.o(synth v0.11.40,--target cortex-m4f --native-pointer-abi --relocatable)Entry spills the mutex ptr (arg0) to its home slot:
Tag-1 (release + unpend) path:
No-waiter sub-path — reloads the clobbered slot as the mutex base:
The only writes to
[sp,#0x24]between entry and0x1e2are0x13a(unpend result) and0x1aa(the other, has-waiter branch). In the no-waiter path the slot therefore holds0, so the store misses. (Note the has-waiter path at0x16a: ldr r7,[sp,#0x24]then0x172: add r7,#0xc; stris also corrupted — it computesnew_owner+12for thelock_count=1store — just not exercised by the single-thread selfcheck.) Relocation0x136 R_ARM_THM_CALL gale_w_unpend_first_threadconfirms the clobbering value's identity.r7still holds the valid mutex pointer at0x1e2— the allocator had a live copy available and chose the clobbered slot instead.Kill-criterion
This claim is wrong if: a clean re-decode of
synth_k_mutex_unlock_bodyon a fixed build shows the no-waiterlock_countstore deriving its base from a slot/reg that holds the mutex pointer (not the unpend result) at0x1e2; or if on-siliconk_mutex_lock/unlockround-trips without deadlock andlock_countreads 0 after a no-waiter unlock.Fix direction
The mutex pointer (arg0) is live across the
gale_w_unpend_first_threadcall and used again afterward (no-waiterlock_countstore, and the has-waiterlock_countstore). The allocator must not reuse arg0's spill slot for a call return value while arg0 is still live — either keep arg0 in a callee-saved register across the whole body, or give the call result a distinct slot. This is a spill-slot extension of the register-level live-range tracking that #311/#226/#231/#232 exercised; the slot allocator appears to treat the home slot as dead once the value is also in a register, but then reloads the (now-clobbered) slot rather than the register.Repro shape
k_mutex_lock(&m, K_FOREVER); k_mutex_unlock(&m);single-threaded (no waiter) on the dissolved/--native-pointer-abimutex shim, cortex-m4f. Compiles clean; deadlocks at runtime. Faithful Zephyr v4.4.0k_mutexlayout (owner@8, lock_count@12).