Plan: 32-bit ARM (ARMv7 Thumb-2 — freestanding, Linux, FreeBSD)
Status — 2026-06-17 — arm32 core closed
The arm32 core checklist is closed. The toy ARM32 cross lane is 423/0/2 (the
2 skips are the aarch64-only privileged-baremetal case), test-smoke-arm32 is
4/4, make rt-arm-eabi-thumb2 self-builds AEABI + coro with kit's assembler,
and the bare coroutine runtime case now returns correctly under qemu. The
Cortex-M reset stub in test/lib/exec_bare.sh is kit-assembled, ARM ELF objects
emit .ARM.attributes, ARM32 .thumb_func symbols carry the ABI Thumb bit, and
C inline asm templates now route real Thumb-2 mnemonics through
src/arch/arm32/asm.c.
Backend-refactor audit — 2026-07-19
The post-8d7927db audit is complete. The two O1 aggregate-return failures were
reproduced on that revision and are covered by a shared full-four-register
return regression; frame/stack parts load directly into their final ABI result
registers, without consuming an extra instruction-local temporary.
The audit corrected the soft-float AAPCS32 aggregate contract: composites larger than one word return indirectly through r0, while fixed-size composite parameters of any size remain by-value across r0-r3 and the stack. Independent Clang↔Kit tests cover both directions for an 8-byte return and a 32-byte argument at O0/O1. Known-frame inline-asm clobber preservation is now shared across native targets and covers ARM32 r4; structural r7/sp/pc clobbers are rejected. See BACKEND_REFACTOR_CROSS_TEST.md for the full evidence and the two isolated-green QEMU timeout artifacts from the full ARM32 cross run.
Follow-on landed — 2026-06-17 — DSP, flag/macro parsing, code density, disasm
Four follow-on items landed (parallel worktree agents, serial cherry-pick behind the qemu gate). See the "Follow-on variants / polish" checklist below for status:
- Full
-mcpu/-march/-mfpu/-mfloat-abiparsing + the__ARM_*macro set — done. NewArchImplhookstarget_feature_apply_cpu(the previously unconsumedopts->cpunow maps cortex-m3→armv7-m, cortex-m4/m7→armv7e-m+dsp) andfeature_predefines(feature-keyed__ARM_ARCH_7M__/7EM__,__ARM_FEATURE_DSP/SIMD32, and the__ARM_FP/__SOFTFP__/__ARM_PCS_VFPfloat set). Validated against host clang (test/smoke/arm32_macros.sh, 8/0). - ARMv7E-M DSP saturating — SSAT/USAT/QADD/QSUB/QDADD/QDSUB encoders + ISA rows + asm/disasm (assembler-permissive, inline-asm usable). Packed-SIMD and automatic saturating-builtin lowering deferred (no frontend builtin exists).
- 16-bit-encoding density — narrow PUSH/POP, LDR/STR (T1 imm5 + T2 sp), ADD/
SUB sp, ADDS/SUBS/MOVS/LSLS/LSRS/ASRS where operands fit (−5%…−25% .text on
samples). Shifted-register operand folding is implemented but gated off —
enabling it tripped a shared
src/opt/pass_combine.caddr-synth rule that drops the L7 shift rider forbase+(i<<k)(k≥4); re-enable once that's fixed. - Cosmetic disasm — shifted-MOV now renders
lsl.w/lsr.w/asr.w/ror.w/rrx(and MVN shift suffix) matching llvm-objdump.
Verification note: make test-cross TARGET=freestanding-arm32 DEPTH=full toy lane
is 423/0/2; the broad parse lane is now 929 pass / 0 fail / 64 skip (the
run also showed one transient 6_5_41_ptr_sub_ptr/O1/E qemu timeout under
parallel load — got 124 = GNU timeout's exit code at 22 s vs the usual 140 ms;
it passes 3/3 isolated, an infrastructure flake, not a red). The 64 skips are the
i128 + binary128-long-double cases, the two data-model-only cases
(6_5_64_unsigned_size_division, rv64_atomic_widths_orders), and the two
aa64-template / no-cycle-counter cases (asm_01_grammar,
builtin_31_readcyclecounter), all .arm32.skip-sidecar'd. The 5 emit/codegen
gaps are CLOSED (2026-06-17) — gnu_labels_as_values_threaded fixed (computed-
goto scratch), asm_01_grammar + builtin_31_readcyclecounter skipped (see
"Broad parse-corpus triage" below); the 12 wrong-runtime-result reds were already
CLOSED (2026-06-17). The earlier fix that took the
lane from 33 to 17 fails: the O1 frameless-leaf tier returned through a bare
BX lr while the backend uses LR as a backend-private temporary
(global-address staging, overflow/popcount/bitfield/byte-copy), so any such
leaf clobbered its return address (the 16-case O1 globals/statics/attributes
rc 134 cluster). The slim
tier now saves lr (PUSH {lr}/POP {pc}).
Broad parse-corpus triage — ALL reds CLOSED 2026-06-17
The 12 wrong-runtime-result reds are all resolved (3 codegen fixes, 2 data-model-agnostic test rewrites, 2 data-model skips). The 5 remaining emit/codegen gaps are now closed too (1 codegen fix, 1 inline-asm fix + the assembler bug it surfaced, 2 aa64-template / no-cycle-counter skips):
- Emit/codegen gaps (5) — DONE:
gnu_labels_as_values_threadedO1 —opt native emit: no scratch registerexposed the old emitter's implicit two-register (ip+lr) staging policy: a computed-goto store consumed both for base+index and still needed to materialize the value. The immediate fix reused a dead address base in place. The later location-MIR redesign removed that policy entirely: ARM32 now declares r0..r3 as instruction-scoped O1 operand temporaries, while IP/LR remain backend-private. Runs35at O0+O1.asm_01_grammarO0+O1 —.arm32.skip. The templates are aarch64-specific (%w0modifiers,mov w0,dmb sy,x0clobbers) and never assemble on arm32, exactly as on rv32/rv64/x64. The underlying error (optimizer asm input not in a register) was a real arm32 bug too — a register- constrained input whose value is a frame-resident local (e.g. a value that is+rinout AND returned) arrives as a frame loc, not a register. The original ARM backend fix staged such inputs before binding. The current shared asm staging path leases an operand register within the instruction scope, with LR exposed separately as an asm-only temporary; IP remains backend-private. Surfaced and fixed a second bug inarm32 asm.c:add/sub rd, rn, {#imm | rm}only had the 2-operand hi-registeraddand the sp-adjust rows, soadd r4, r4, #1assembled asadd r4, r4(×2) andsub r4, r4, #1assub sp, sp, #0. A sharedarm_asm_addsub_thirdnow picks the densest legal form (16-bit sp-adjust → 32-bitadd.w/sub.wmodimm →addw/subw), byte-matching llvm-mc; the standalonekit as/inline-asmadd/subimmediate + 3-register forms now encode correctly.builtin_31_readcyclecounterO0+O1 —.arm32.skip. ARMv7-M has no architectural always-on cycle counter reachable as a single instruction (DWT CYCCNT is optional and needs privileged DEMCR/DWT_CTRL enable), so the intrinsic is unsupported — the same rationale as the wasm skip.
- Wrong runtime result (12) — DONE:
6_5_2_2_06_struct_param_mixed_fp_intO0+O1 — AAPCS32 8-byte-aligned aggregate (struct{double;long;}) was mis-split between core regs and stack; caller/callee disagreed. Fixed: surface the record alignment on parts[0], onearm_arg_needs_evenpredicate everywhere, round-to-even + naive straddle (drop the no-straddle override). (commit 8d022009)6_8_29_switch_signed_negativeO1 — the jump-tableidx = sel - vminwith vmin=-3 emittedsubw #4093(the −3 constant truncated to 12 bits); legality accepted the negate-trick but emit never implemented it. Fixed: sharedarm_resolve_addsub_immfor legality + emit. (commit e98cc403)variadic_02_many_intsO1 — under pressure the spilled constant call args exposed the old round-robin temporary policy: rematerialized call args could alias simultaneously-live register args. The immediate fix kept those spill stores and wrote register-resident stack args directly; the location-MIR redesign now prevents the class of bug with scoped leases plus phase-owned completed ABI destinations through the full argument shuffle, followed by a post-call phase barrier. (commit 51681b45)far_slot_large_frame— was a data-model artifact (longis 32-bit on arm32, sox's 8-byte check failed and the 8-byte far slot wasn't even exercised). Made the shared caselong long→ tests the 8-byte far slot on every arch and passes (arm32 + LP64). (commit 8d022009)builtin_generic_overflow— LP64-only by construction; the one data-model-dependent line used a magic 0x7fff…ffffL. Switched to(long)__LONG_MAX__(overflowslongunder both ILP32 and LP64) → now data-model-agnostic, passes on arm32 + LP64. (commit 8d022009)6_5_64_unsigned_size_division— 64-bit-size_t overflow idiom; on a 32-bit target size_t IS 32-bit and the clamp legitimately fires (clang returns 10 too)..arm32.skip(matches the rv32 skip). (commit 8d022009)rv64_atomic_widths_orders— exercises rv64's 64-bit A-extension atomics throughlong; 64-bit atomics are out of ILP32 scope (32-bit atomics covered by builtin_06..25)..arm32.skip(matches rv32). (commit 8d022009)
Core checklist — remaining items (single source of truth)
What is left to call the arm32 core complete, plus the follow-on variants that are explicitly out of "core" scope. This list is authoritative; the prose "Remaining work" §1–§5 below is the original design rationale (its done items are now checked there too).
Core — outstanding: none.
Core — done (closed; details under "Residual reds" + §1/§2 below):
- Unified wide/addressing memory layer; i64/wide stores to global/frame
(§1). [x] Tail/sibling calls,
@memmove, varargs edge cases, wide hint intrinsics (§2). [x] The five -O0/-O1 edge reds 112/123/124/140/154. [x] Inline-asm operand binding (case 20 green). - Full rt self-build (AEABI + coro, no exclusions) —
make rt-arm-eabi-thumb2builds the whole runtime with kit's own assembler. All 19__aeabi_*entry points assemble with correct encodings; the dcmp/fcmp compare helpers run correctly under qemu via an external direct call. Enablers:kit ashardening (cmp/mov#imm, SP-relative add/str/ldr + high-reg T3, UDF,;separator,#imm-in-macro,__ELF__/empty__USER_LABEL_PREFIX__, soft-float__ARM_FPundefined); per-cond named labels instead of GNU numeric locals;__divmodsi4added to the rt; and the cross-object BL/B.W/Bcc.W relocation fix (assembler placeholders now match codegen — the first kit-assembled cross-object branches to run). The coro layer self-builds (save/restore verified via setjmp/longjmp); the fullcoro_init/coro_resume/coro_yieldruntime fault was fixed by moving the Thumb-bit rule into ARM32.thumb_funcsymbol semantics, so__kit_coro_trampolinematerializes as an odd function pointer through normal MOVW/MOVT relocations. - kit-compiled Cortex-M reset stub —
test/lib/exec_bare.shnow emits a kit-as-compatible startup stub (MOVW/MOVT symbol materialization, explicit ADDs instead of post-indexed memory, named labels,.thumb_funcreset entry) and assembles it withkit as -target arm-none-eabi. The vector table namesReset_Handlerdirectly; the symbol value supplies the Thumb bit, so there is no manual+ 1. .ARM.attributes— fresh ARM32 ELF objects synthesize a minimalSHT_ARM_ATTRIBUTESAEABI section with ARMv7-M, Thumb-2, and baseTag_ABI_VFP_argsattributes; existing input attributes are preserved.- Inline asm real mnemonics via
asm.c— arm32 inline-asm templates use the descriptor-driven assembler path after operand substitution. - ARM32 Thumb function symbol semantics —
.thumb_funcmarks the next label or named symbol as an odd-valuedSTT_FUNC; data/function-pointer relocations preserve that bit while branch relocations mask it for displacement..size sym, .-symmasks the tag when computing byte size.
Follow-on variants / polish (NOT required for "core"; the variant seam, §4/§5):
- Hard-float (FPv4-SP,
arm-none-eabihf): VFP codegen + softfp/hard axis. (The macro/feature machinery —vfp/fpv5features,__ARM_FP/__ARM_PCS_VFP, the softfp/hardresolve_float_abibranch — is in; the driver still rejects non-soft-mfloat-abiuntil VFP codegen lands.) - [~] DSP / ARMv7E-M saturating behind the
dspfeature — SSAT/USAT/QADD/QSUB/ QDADD/QDSUB encoders + ISA rows + asm/disasm landed (2026-06-17), usable from inline asm. Remaining: packed-SIMD (SADD16/SEL/… + GE flags) and automatic saturating-builtin lowering (no frontend builtin / CG intrinsic exists yet; theKIT_CG_INTOP_SATURATE_*flags are unwired). - ARMv6-M / Thumb-1 (no IT / MOVW-MOVT / hardware divide / 32-bit Thumb).
- Full
-mcpu/-march/-mfpu/-mfloat-abiparsing + the__ARM_*predefined-macro set (2026-06-17;target_feature_apply_cpu+feature_predefineshooks, clang-cross-validated). - Multi-threaded TLS (variant-I tp-relative; replaces TLS-as-static).
- [~] 16-bit-encoding density (landed 2026-06-17, −5%…−25% .text) +
shifted-register-operand peephole (implemented but gated off — a shared
src/opt/pass_combine.caddr-synth rule drops the L7 shift rider onbase+(i<<k), k≥4; re-enablearm_can_fold_shift_into_aluonce fixed). Narrow intra-function branches (16-bit B/Bcond) still deferred. Note: the O1 frameless-leaf tier now costs +2 bytes/leaf (PUSH {lr}/POP {pc}) for return-address correctness — see the O1-leaf fix above. - (cosmetic) disassembler renders shifted-MOV as
lsl.w/lsr.w/asr.w/ror.w/ rrx(+ MVN shift suffix), matching llvm-objdump (2026-06-17).
Phase 3 — hosted ARM32: Linux + FreeBSD
Background
Linux, FreeBSD, and (historically) Windows ship ARM32 for A-profile processors
(ARMv7-A, Cortex-A8/A9/A15) — not M-profile. A-profile has a full MMU, CP15
system registers, and supports both A32 and Thumb-2 execution states. The key
fact for kit is that user-space code on A-profile runs in Thumb-2 by default
on all these OSes (Linux armhf/armel, FreeBSD armv7, Windows ARM32 all compile to
Thumb-2). The Thumb-2 ISA is identical between M-profile and A-profile, so the
entire src/arch/arm32/ backend — ISA encoding, AAPCS32 calling convention,
ELF32 output, relocations — carries over unchanged.
The differences between the bare-metal core and a hosted target are:
- Thread pointer: M-profile has no CP15; the bare-metal lane uses
TLS-as-static. A-profile reads the thread pointer via
MRC p15, 0, Rd, c13, c0, 3(TPIDRURO). This is the mechanism Linux, FreeBSD, and Android all use. - ARM attributes profile:
src/obj/elf/emit.ccurrently emitsTag_CPU_arch_profile = 'M'; hosted objects must emit'A'. - Dynamic linking: the bare-metal lane is static-only. Hosted targets need a
PLT/GOT and the standard
R_ARM_JUMP_SLOT/R_ARM_GLOB_DAT/R_ARM_RELATIVE/R_ARM_COPYrelocations. This is the dominant work item. - Startup: hosted targets use
_start→main→exitvia the C runtime and dynamic linker; no semihosting. - Hard-float ABI (armhf):
arm-linux-gnueabihfpasses float/double in VFP registers (s0–s7 / d0–d3) rather than core registers. This requires the VFP codegen follow-on (listed under "core follow-on variants" above) before it can be tackled; it is a separate ABI vtable and is sequenced last.
Windows ARM32 is out of scope (would need COFF/PE ARM support, which does not exist, and ARM32 Windows is extremely niche today).
Phase 3 checklist (single source of truth)
Sequence A — arm-linux-gnueabi (soft-float):
- A-profile TLS — replace
arm_tls_addr_of's static fallback insrc/arch/arm32/native.cwithMRC p15, 0, Rd, c13, c0, 3(TPIDRURO) conditioned onKIT_OS_LINUX. The M-profile path stays for freestanding. - ARM attributes profile field —
src/obj/elf/emit.c:158emitsTag_CPU_arch_profile = 'M'; emit'A'whentarget.os != KIT_OS_FREESTANDING. - Driver triple — add
arm-linux-gnueabi→{KIT_ARCH_ARM_32, KIT_OS_LINUX}indriver/lib/target.c; add aarm-linux-gnueabiruntime variant row indriver/lib/runtime.cselectingarm-eabi-thumb2rt. - Predefined macros —
__linux__,__gnu_linux__,__ELF__,__ARM_ARCH=7,__ARM_ARCH_7A__,__ARMEL__, soft-float guards (__SOFTFP__,__ARM_FPundefined) forKIT_OS_LINUXinsrc/arch/arm32/arch.c. - Dynamic linking / PLT/GOT — the dominant item; see §3.1 below. New
relocations:
R_ARM_JUMP_SLOT,R_ARM_GLOB_DAT,R_ARM_RELATIVE,R_ARM_COPY,R_ARM_TLS_DTPMOD32,R_ARM_TLS_DTPOFF32,R_ARM_TLS_TPOFF32. PLT stub + GOT generation insrc/arch/arm32/link.c; dynamic TLS via__tls_get_addr. - Test lane —
arm32-linuxexec lane viaqemu-user-arm+ Debian armhf sysroot (or a native arm32 Linux container); add toscripts/hosted.sh+test/lib/exec_target.sh;make provision TARGET=arm32-linux. - Smoke test —
test/smoke/arm32_linux.sh: compile + link (dynamic) + run underqemu-user-arm; cover i64, soft-double, TLS (__thread), and a shared-library call. - Toy corpus lane —
arm32-linuxcross lane (DEPTH=smokethenfull); gate on run-correctness, reds left red.
Sequence B — arm-freebsd-eabi (soft-float, after A is green):
- Driver triple —
arm-freebsd-eabi→{KIT_ARCH_ARM_32, KIT_OS_FREEBSD}; FreeBSD predefined macros (__FreeBSD__,__ARM_ARCH_7A__, etc.). - Dynamic interp — FreeBSD's interpreter path is
/libexec/ld-elf.so.1; confirm kit's linker writes the correct.interpforKIT_OS_FREEBSDarm32. - Test lane —
arm32-freebsdexec lane via the FreeBSD ARM32 VM orqemu-user-arm+ FreeBSD armv7 sysroot;make provision TARGET=arm32-freebsd. - Smoke test —
test/smoke/arm32_freebsd.sh; same coverage as arm32-linux. - Toy corpus lane —
arm32-freebsdcross lane.
Sequence C — arm-linux-gnueabihf (hard-float, after VFP follow-on lands):
- Prerequisite: hard-float VFP codegen follow-on (listed in core follow-on
variants above:
arm-none-eabihf, FPv4-SP, VFP register args). - ABI vtable —
src/abi/abi_aapcs32hf.c: float/double args in s0–s7/d0–d3 (AAPCS VFP variant);{KIT_ARCH_ARM_32, KIT_OBJ_ELF, KIT_OS_LINUX, float_abi=hard, &aapcs32hf_vtable}insrc/abi/registry.c. - Driver triple —
arm-linux-gnueabihf→{KIT_ARCH_ARM_32, KIT_OS_LINUX, float_abi=hard}; runtime variantarm-eabihf-thumb2inmk/rt.mk. - ELF e_flags —
EF_ARM_ABI_FLOAT_HARDon hard-float objects; linker rejects hard/soft mismatch. - Predefined macros —
__ARM_FP=4(FPv4-SP),__ARM_NEON_FP=4if NEON,__ARM_PCS_VFP=1; remove__SOFTFP__. - Test lane + smoke —
arm32-linux-hflane +test/smoke/arm32_linux_hf.sh.
§3.1 Dynamic linking design (the dominant item)
The static-only src/arch/arm32/link.c currently provides LinkArchDesc with no
PLT. The hosted path follows the same structure as src/arch/aa64/link.c and
src/arch/x64/link.c:
PLT stub layout (Thumb-2, position-independent):
PUSH {r0–r3, lr} @ preserve arg regs + lr (per GNU ld Thumb PLT)
LDR r12, [pc, #off] @ load GOT entry address (literal pool or MOVW/MOVT)
LDR pc, [r12] @ tail-branch through GOT slot
.word <GOT entry abs addr>
The resolver stub (PLT[0]) jumps to the dynamic linker; subsequent stubs are 12
or 16 bytes depending on whether the GOT slot is PC-relative-reachable. Use the
Thumb-2 interworking BX for indirect branches since the dynamic linker may be in
ARM state. Mark each PLT entry STT_FUNC with the Thumb bit set.
Relocations to add (src/obj/obj.h RelocKind + src/obj/elf/reloc_arm.c):
| kit RelocKind | ELF R_ARM_* | Purpose |
|---|---|---|
R_ARM_JUMP_SLOT |
same | PLT GOT slot — patched by dynamic linker at load |
R_ARM_GLOB_DAT |
same | GOT data slot for a symbol |
R_ARM_RELATIVE |
same | base-relative fixup (for PIE / ASLR) |
R_ARM_COPY |
same | copy reloc for non-function symbols |
R_ARM_TLS_DTPMOD32 |
same | TLS module index (dynamic TLS) |
R_ARM_TLS_DTPOFF32 |
same | TLS offset within module |
R_ARM_TLS_TPOFF32 |
same | TLS TP-relative offset (static TLS via LE) |
TLS models for A-profile hosted:
- Local-exec (static,
-fno-pic):R_ARM_TLS_LE32; thread pointer fromMRC p15, 0, Rd, c13, c0, 3; offset isS + A − tp. - Initial-exec (GOT-indirect):
R_ARM_TLS_TPOFF32in the GOT; two-instruction sequence (LDR r0, [tp, got_off]). - General-dynamic (cross-DSO): call
__tls_get_addr({module, offset}); needsR_ARM_TLS_DTPMOD32+R_ARM_TLS_DTPOFF32GOT pair. This is the default for shared objects.
Kit's driver should default to local-exec for executables and general-dynamic for shared objects, matching GCC/clang defaults.
PIE: R_ARM_RELATIVE applied at load time for all absolute data refs in a
position-independent executable. The linker emits a .rel.dyn section. This
enables ASLR on modern Linux/FreeBSD and is required for executables on hardened
distributions.
§3.2 A-profile vs M-profile divergence in the backend
The Thumb-2 ISA is binary-identical across profiles. The divergences are localized to:
arm_tls_addr_of(native.c) — gate onKIT_OS_FREESTANDINGfor the static path; emitMRC p15, 0, Rd, c13, c0, 3for Linux/FreeBSD..ARM.attributes(obj/elf/emit.c:158) —Tag_CPU_arch_profile = 'A'for hosted;'M'for freestanding. AlsoTag_CPU_arch = 10(ARMv7) stays the same;Tag_CPU_arch_profileis the only field that changes.arch.cpredefined macros —__ARM_ARCH_7A__vs__ARM_ARCH_7M__;__linux__/__FreeBSD__gated on OS.link.c— PLT/GOT gated onKIT_OS != KIT_OS_FREESTANDING.
No fork of native.c or the ABI vtable is needed for the soft-float hosted path.
§3.3 Test infrastructure
qemu-user-arm is the right exec backend for arm32-linux. It runs A-profile
ELF32 user-space binaries directly on the host without a full system emulator,
intercepts Linux syscalls, and is widely available (apt install qemu-user-static
on Debian/Ubuntu). This is the same model used for other hosted cross targets
(exec_target seam in test/lib/exec_target.sh).
FreeBSD armv7: options are (a) the FreeBSD armv7 QEMU system image (heavier,
already the model for FreeBSD aa64), or (b) qemu-user-arm + a FreeBSD armv7
sysroot (lighter, but less battle-tested). The system-image path is more
consistent with the existing exec_vm.sh infrastructure.
armhf (gnueabihf): qemu-user-arm handles hard-float ARMv7-A binaries; no
separate emulator is needed. The sysroot must be an armhf sysroot (e.g., Debian
armhf cross-compilation sysroot).
The exec lane for arm32-linux slots naturally into the existing exec_target
harness alongside the other cross-hosted lanes. make provision TARGET=arm32-linux
installs qemu-user-arm, the cross sysroot, and any required binfmt_misc
registration.
§3.4 Sequencing rationale
arm-linux-gnueabi first, because:
- Soft-float AAPCS32 is already implemented; no new ABI vtable needed.
- ELF32 output is already correct; work is limited to dynamic linking + TLS.
- arm-freebsd-eabi follows almost for free: same ISA, same ABI, same ELF32, same
CP15 TLS — only driver triples, the
.interppath, and a test lane differ. - arm-linux-gnueabihf is gated on the VFP codegen follow-on, so it cannot start until that lands regardless of hosted progress.
The dynamic linking work (§3.1) is the critical-path item for both Linux and
FreeBSD. It can be developed once and reused; the only FreeBSD-specific linker
concern is .interp and DT_NEEDED for libc.so.7 vs libc.so.6.
Status — 2026-06-16 — Phase 2b — memory/frame/ABI correctness landed
The arm32 backend is a working -O0/-O1 Cortex-M C toolchain: it compiles, links
(both kit ld and ld.lld), and runs the full test-smoke-arm32 set and almost
all of the cross corpus. The Phase-2b correctness pass (below) redesigned the
memory layer correct-by-construction and fixed frame/ABI/varargs across the
board; the toy corpus went from 355/425 to ~418/425.
Phase 2b — landed (the memory / frame / ABI correctness pass)
- §1 unified memory-access layer — one
arm_resolve_memaddress resolver ({frame|reg|global} × offset × width) with ONE asserted scratch invariant (arm_pick_scratch), retiring the ad-hoc lr/ip rescue paths. Fixes the i64/wide store-to-global miscompile, initialized-global array/record/tuple reads at a non-zero element offset, and the switch jump-table crashes. Globals apply a non-zero byte offset as a separate add (ARM REL MOVW/MOVT addends are carry-lossy), matchingelf_arm_reloc_field_addend's contract. - -O1 operand-temp ownership —
NativeAllocClassInfo.emit_tempsdeclares r0..r3 as the optimizer emitter's instruction-scoped bank, distinct from the O0scratchpolicy (IP) and the asm-only bank (LR). IP/LR stay private to backend hook internals, so operand materialization cannot overlap an internal address or arithmetic temporary. This supersedes the earlier shared IP/LR emission policy and fixes the "no register available" pressure failures. - Tail/sibling calls — full AAPCS lowering (direct/indirect/void/sret/ variadic/stack-args), frame-size-independent epilogue, in-window stack args.
- Frame correctness — sp kept 8-byte aligned at calls (odd-word saved block padded); the variadic prologue's r0..r3 GP-save is reclaimed in the epilogue.
- Varargs at -O1 — GP-save spill in the known-frame prologue, va_start
anchored at
saved_block_bytes, wide-result dst viaarm_loc_addr, va_arg cursor distinct from dst. - Intrinsics — IMM args materialized (clz/ctz/bswap/popcount/expect/ overflow); bswap uses the 32-bit REV.W (the 16-bit form truncates high regs); wide @expect moves both lanes; @memmove (overlap-safe, direction-aware).
- TLS-as-static —
.tdata/.tbssplaced + seeded by the bare reset stub. - Atomics — CAS/RMW use the pointer's own register (an LR-staged operand no longer aliases the base).
Residual reds — ALL CLOSED (2026-06-17); toy DEPTH=full arm32 = 423/0/2
The toy corpus is fully green (the 2 skips are the aarch64-only privileged- baremetal case). Each red was fixed correct-by-construction, not patched:
- 112/123 O0 indirect call + large stack window — the indirect callee held in LR was clobbered by large-offset arg-stage address staging (LR is the address scratch). Spill the callee to a frame home before staging when the call has stack args; reload into IP just before BLX (native.c). 123's O0 crash was actually a SEPARATE wide-value bug: a wide (i64) switch scrutinee materialized into the lone NDT scratch ip(r12) put its high lane in r13/sp. Fixed in the shared NDT (span-aware scratch acquire keeps a wide value off a reserved high lane) — cg/native_direct_target.c.
- 124 O1 atomic CAS/RMW — fixed r4/r5 STREX temps could alias an optimizer-allocated operand. Operand-aware borrow picks callee-saved temps clear of base + all operands (native.c arm_atomic_borrow).
- 140 O1 "f64 callee-save" — was actually an i64/i32-array miscompile: a "large" (8-byte) indexed load on a 32-bit target routes through emit_agg_move, which did not legalize its address, so a folded array index was dropped (every element aliased element 0). Fixed by legalizing both agg-move addresses (opt/pass_native_emit.c) — matches the scalar load/store path.
- 154 O1 @frame_address / @return_address — fp-at-pair split prologue: push callee-saves ABOVE a tight {r7,lr} pair so r7 anchors the frame record (also fixes backtraces); saved_block_bytes / [r7,#-off] offsets unchanged (native.c).
- 20 inline asm (case 20) — bind immediate ("i") and memory ("m") operands at
-O0 and -O1 (native.c). Real Thumb-2 template mnemonics now route through the
descriptor-driven
asm.cpath after operand substitution.
§3 toolchain — full rt self-builds (2026-06-17)
make rt-arm-eabi-thumb2 now builds the whole runtime with kit's own
assembler — AEABI alias layer + coroutine layer, no exclusions (the §3 exit
criterion). Enablers:
- Assembler/CPP for hand-written asm: cmp/mov modified-immediate; SP-relative
add/str/ldr incl. the 32-bit T3 form for a high transfer register; UDF;
;GNU statement separator;#imm-in-macro under__ASSEMBLER__;__ELF__+ empty__USER_LABEL_PREFIX__(so compiler-rt's assembly.h takes the ELF path, not COFF);__ARM_FPleft undefined on soft-float; and the cross-object BL/B.W/Bcc.W relocation fix (the assembler's branch placeholders now match codegen's encoders — the first kit-assembled cross-object branches to be linked and run). - AEABI (
RT_arm-eabi-thumb2_AEABI = thumb2): all 19__aeabi_*assemble with correct encodings; per-cond named labels replace GNU numeric locals;__divmodsi4added to the rt; dcmp/fcmp verified under qemu via direct call. - Coro layer (
RT_arm-eabi-thumb2_CORO = arm32): coro/arm32.c + coro/coro.c assemble/compile with kit; save/restore verified (setjmp/longjmp round-trips). The full coro_init/resume/yield path now passes on bare-metal arm32; the trampoline address is supplied by.thumb_funcsymbol semantics rather than a C-side| 1workaround.
Still TODO (Remaining work, below)
- Core §2/§3 items are closed: the full coro runtime fault, kit-compiled
Cortex-M reset stub,
.ARM.attributes, and descriptor-driven inline asm are all done. Remaining unchecked items are follow-on variants/polish only.
(Minor: the disassembler renders shifted-MOV forms — LSR/ASR mov.w rd,rs — as a
plain mov, losing the shift in listings. Cosmetic; the encodings are correct.)
The original Phase-2 inventory below remains the implementation map. It is not yet a complete, correctly-designed backend across the board — the "Remaining work" checklist records what is left, and (this is the point) those items must be designed and implemented correctly, not patched in piecemeal:
- -O1 known-frame path (
func_begin_known_frame, callee-save PUSH/POP, slim leaf tier) + large frames/offsets staged through IP. The cross lane runs at -O1 (scripts/cross_test.shno longer forces -O0). - Global address materialization — MOVW/MOVT-absolute +
R_ARM_THM_MOVW_ABS_NC/_MOVT_ABS(scaled-index folded intoload_addr);load_const/load_label_addr. - i64 + soft-double (wide8) — AAPCS even-register pair alignment, per-lane
call/return passing, 8-byte
load_const; mul/div/shift + alldouble/floatvia the kit-built runtime's libgcc helpers (__udivdi3/__adddf3/…). Correct for locals, args, returns, and i64 global reads; i64/wide stores to a global/static address (and an i64 struct field at a non-zero offset) are still miscompiled — Remaining work §1. - Aggregates / varargs / atomics / inline asm —
copy_bytes/set_bytes, INDIRECT struct-by-value params/args/returns, AAPCS32va_*(8-byte-aligned), LDREX/STREX + DMB ≤4-byte atomics (8-byte → spinlock libcall), a minimal inline-asm template runner. - Op-groups / intrinsics — switch jump tables (
indirect_branch), C bitfields (BFI/UBFX/SBFX), CLZ/CTZ/BSWAP, barriers (DMB/DSB/ISB) + hints, the 6 32-bit__builtin_*_overflow,alloca,__builtin_frame_address/return_address. - Single-thread TLS-as-static (M-profile has no CP15 thread reg).
- ARM ELF REL emission (
.rel.text, addend in-field) — kit objects now link with stock ARM linkers (ld.lld), not justkit ld. - Descriptor-driven disassembler + standalone assembler (
isa.c/disasm.c/asm.c,test/arch/arm32_decode_test→test-isa, 314/0). - kit self-compiles
libkit_rt.aforarm-eabi-thumb2(make rt-arm-eabi-thumb2; C compiler-rt sources plus the hand-written AEABI alias layer + coroutine asm switch). - Tests:
test-cross TARGET=freestanding-arm32smoke at -O1 + full (toy X + parse E, bare);test-smoke-arm32(ld.lld + kit-ld lanes); the decode unit test. The freestanding fix to kit ld's linker-script location counter (interiorsym = .after a glob) landed alongside.
make test-cross TARGET=freestanding-arm32 DEPTH=full (toy X) runs 355/425
green; the ~70 reds are left RED on purpose (not skipped) and map onto the
checklist below.
Remaining work — design these correctly; do NOT keep patching
For the scannable list of what is left, see the Core checklist near the top — it is the single source of truth. The §1–§5 below are the original design rationale, with done items checked in place.
Phase 2 surfaced a clear lesson. Several separate correctness bugs — a branch
placeholder addend, two scratch-register clobbers (str lr,[lr];
ip-resident-base clobber), and an incomplete wide-access split — all lived in the
memory-access / addressing / scratch layer and were each fixed cell-by-cell.
The matrix that layer must cover —
{base: frame | reg | global} × {offset: in-range | out-of-range}
× {width: 1/2/4/8} × {scratch aliases neither the transfer reg nor a live base}
— was never designed as a whole, so each new combination found a new hole (the i64/wide-store-to-global bug in §1 is the latest). The items below must be done as coherent, correct-by-construction designs, not as more spot patches. Each names the design, not a workaround.
1. Correctness — the wide / addressing memory layer (DONE — Phase 2b)
- i64/wide stores to global/static addresses, and an i64 struct field at a non-zero offset, are miscompiled (i64 global reads work; writes do not). Located: the 8-byte split's high lane + the GLOBAL-base address/offset + scratch selection are not uniform across base kinds.
- Design: one memory-access lowering, correct by construction —
(a) a single address-resolver that maps any
NativeAddr× byte offset × width to a legal(base, in-range-offset), or materializes the full effective address once into a reserved scratch, choosing a scratch that provably aliases neither the transfer registerrtnorbase; (b) a uniform wide-N decomposition over the register pair whose high-lane address is computed correctly for frame / reg / global; (c) the scratch-reservation invariant stated andassert-enforced in one place. This retires the ad-hoclr/iprescue paths added during Phase 2.
2. Functional features — real implementations, not stubs
- Tail / sibling calls (
arm_emit_callpanics onCG_CALL_TAIL) — AAPCS tail-call lowering (epilogue-before-jump, in-place arg shuffle,BX/B.Wto the callee) covering musttail / sret / variadic forms. @memmove— overlap-safe, direction-aware copy (compare dst vs src), sharing the granule engine withcopy_bytes.- Varargs edge cases — mixed int/fp/i64, stack-arg + result-on-stack:
complete
va_argover the unified arg window, validated against the GP-save-area layout (no per-width special cases). - Wide (i64) hint intrinsics —
@expect/assume_alignedon 8-byte values currently move only the low lane; fold them through the wide path. - Full inline asm — operand binding (r/i/m/=&r/inout/clobbers) lands and templates route real Thumb-2 mnemonics through the descriptor-driven assembler.
3. Toolchain / runtime completeness
- kit-self-built rt with no exclusions —
make rt-arm-eabi-thumb2builds the full runtime (AEABI + coro) with kit-as; the coroutine runtime now round-tripscoro_init/resume/yieldon bare-metal arm32. - Multi-threaded TLS — replace single-thread TLS-as-static with a real variant-I tp-relative model (software thread pointer + reset-stub seed).
4. Profiles / ABIs — new variants via the clean variant seam, not forks
- Hard-float (FPv4-SP,
arm-none-eabihf): VFP codegen + the softfp/hard float-abi axis (doublestays soft on FPv4-SP). - [~] DSP / ARMv7E-M saturating behind the
dspfeature — SSAT/USAT/QADD/ QSUB/QDADD/QDSUB asm+disasm landed (2026-06-17); packed-SIMD + automatic saturating-builtin lowering deferred. - ARMv6-M / Thumb-1 (no IT / MOVW-MOVT / hardware divide / 32-bit Thumb).
- Full
-mcpu/-march/-mfpu/-mfloat-abiparsing + the__ARM_*predefined-macro set (2026-06-17).
5. Polish (correctness-neutral, do last)
.ARM.attributes(Tag_ABI build attributes) for external-toolchain interop.- [~] 16-bit-encoding density landed (2026-06-17); shifted-register-operand peephole implemented but gated off (pass_combine shift-rider drop).
- The kit-compiled Cortex-M reset stub.
- (cosmetic) disassembler shifted-MOV rendering (2026-06-17).
Exit criterion
Core closure is the top checklist: toy X 423/0/2, test-smoke-arm32, the
kit-built runtime/coro repro, kit-assembled reset stub, ARM attributes, and
inline asm via asm.c. The broader
make test-cross TARGET=freestanding-arm32 DEPTH=full parse lane remains a
follow-on portability/skip-triage task; it is not part of the closed core
checklist above.
Status — 2026-06-16 — Phase 1 (walking skeleton) LANDED
Phase 1 is green: make test-cross TARGET=freestanding-arm32 DEPTH=smoke
passes — kit cc -target arm-none-eabi -mcpu=cortex-m3 -ffreestanding compiles
the smoke set, kit ld links it with the Cortex-M reset stub + linker script,
qemu-system-arm -M mps2-an385 runs it, and the ARM-semihosting
SYS_EXIT_EXTENDED exit code matches. The reset stub was clang-assembled during
the initial walking skeleton and is kit-assembled in the closed core. The whole
src/arch/arm32/ backend
(arch/isa/native/reloc/link/regs/variant/dbg/disasm/asm), the AAPCS32 ABI
vtable (src/abi/abi_aapcs32.c), the ARM ELF relocations
(src/obj/elf/reloc_arm.c + EM_ARM/EF_ARM_*/R_ARM_*), the
KIT_ARCH_ARM32_ENABLED gate + arch_impl_arm32 registration, and the
freestanding-arm32 cross-test lane are in. Validated at -O0: return-const, ALU
(add/sub/mul/and/or/eor/shift), sdiv/udiv + mls remainder, signed/unsigned
compare + if/else, recursion, and ≤4-arg + >4-arg (stack) calls — all correct
under qemu; disassembly matches llvm-objdump.
Scoped to Phase 2 (panic-stubbed or deferred today, per the plan below): the
-O1 known-frame path (Phase 1 is -O0 only — the cross smoke lane forces -O0
for arm32), i64/soft-double wide8 lowering, atomics/TLS/varargs/inline-asm/
struct-by-value, the descriptor-driven disassembler + standalone assembler, the
runtime build with kit (the smoke set needs no libkit_rt.a), and a kit-ld
linker script with a real SRAM .data LMA (kit ld's script front end lacks
AT>, so the global-free Phase-1 lane keeps everything in flash).
The "not started" inventory + ISA/backend/cross-test spec below remains the source of truth for the remaining (Phase 2) work.
The target is a freestanding Cortex-M toolchain: kit cc/as/ld/objdump/disas
producing and consuming correct arm-none-eabi ELFCLASS32 objects and static
executables for ARMv7-M (Cortex-M3) and ARMv7E-M (Cortex-M4/M7),
validated under qemu-system-arm exactly the way riscv32-none-elf is validated
under qemu-system-riscv32 (the cross matrix, freestanding-* only — see
PORT.md). The recently-closed rv32 work is the direct precedent: it
solved 32-bit-on-a-32-bit-arch legalization and the freestanding bare-metal
test lane; arm32 reuses both.
Already in place (head start)
KIT_ARCH_ARM_32enum —include/kit/core.h:134.- Triple parse —
arm/armv7→KIT_ARCH_ARM_32,ptr_size=4(src/api/target.c:61); reverse namearm(:253);arch_kind_name→"arm32"(src/arch/registry.c:78). - Runtime AEABI helpers — built and shipping —
rt/lib/arm/aeabi_thumb2.S(__aeabi_{l,ul}divmod,__aeabi_{i,ui}divmod, soft-float compares, sized__aeabi_mem*),aeabi_thumb1.S(ARMv6-M, future),aeabi.c(__aeabi_drsub/__aeabi_frsub). - Coro —
rt/lib/coro/arm32.c(+arm32_thumb1.c). mk/rt.mkvariant recipes —RT_arm-eabi-thumb2(ilp32,CORO=arm32,AEABI=thumb2) andRT_arm-eabi-thumb1; both reuseRT_ABI_SRCS_ilp32(int32.c) +-Irt/lib/include/ilp32_le, shared with rv32.- 64-bit-value legalization — the wide8 path (
src/cg/wide.c,src/cg/arith.c) built for rv32 (i64/soft-doublemade memory-resident, inline lane add/sub/logic, libcalls for the rest) is arch-neutral and applies to arm32 unchanged. ilp32_ledata model — runtime includes +RT_ABI_SRCS_ilp32exist.
Not yet built (the work)
The whole src/arch/arm32/ backend, the AAPCS32 ABI vtable, the ARM ELF
relocations, the KIT_ARCH_ARM32_ENABLED gate + arch_impl_arm32 registration,
the driver -mcpu/-march/-mfpu/-mfloat-abi plumbing + predefined macros +
runtime-variant selection, and the freestanding-arm32 cross-test lane.
Context
kit today has no 32-bit ARM. We add a freestanding microcontroller target:
--target=arm-none-eabi (alias: thumbv7m-none-eabi / thumbv7em-none-eabi)
-mcpu=cortex-m3 | cortex-m4 | cortex-m7
-march=armv7-m | armv7e-m
-mfloat-abi=soft (v1; softfp/hard are follow-ons)
-mthumb (implied — M-profile is Thumb-only)
This is Thumb-2 only: the M-profile has no ARM (A32) execution state, so
every instruction is a Thumb 16- or 32-bit encoding and every code address has
its low bit (the Thumb bit) set. double and long long are not native to the
integer core and are lowered (reusing the rv32 wide8 path). v1 is
soft-float so it runs on a plain Cortex-M3 with no FPU; single-precision
hard-float (FPv4-SP, Cortex-M4F) is a fast follow.
ISA spec — exactly what we support
Profile: ARMv7-M (Cortex-M3) is the baseline; ARMv7E-M (Cortex-M4/M7) is ARMv7-M plus the DSP extension (saturating + packed-SIMD instructions). The core integer backend targets the common ARMv7-M subset; the E-M DSP ops are gated behind a target feature and used only to lower saturating builtins (a follow-on). One backend serves both — the difference is a feature flag, not a fork.
Endianness: little-endian only (__ARMEL__). Big-endian (BE-8) is out.
Registers (AAPCS)
| reg | role |
|---|---|
| r0–r3 | argument / result — caller-saved; r0(:r1) returns scalars; O1 instruction-scoped operand-temp bank |
| r4–r11 | callee-saved variable registers (r9 is general here — bare-metal, no platform/static-base reservation) |
| r12 (IP) | intra-procedure temporary — caller-saved; O0 scratch policy and backend-private address/immediate staging |
| r13 (SP) | stack pointer — 8-byte aligned at public interfaces (AAPCS) |
| r14 (LR) | link register |
| r15 (PC) | program counter (reads as +4, Thumb) |
| APSR | flags N, Z, C, V (+ Q saturation, GE[3:0] on E-M) |
| s0–s31 / d0–d15 | VFP (hard-float only, a follow-on): s0–s15/d0–d7 args, s16–s31/d8–d15 callee-saved |
Special registers (PRIMASK/FAULTMASK/BASEPRI/CONTROL via MSR/MRS, plus ISB/DSB/DMB barriers and WFI/WFE) are assembler-only — emitted for inline asm and the reset stub, not by the C codegen.
Instruction classes in scope
- Full Thumb-2, mixed 16-bit and 32-bit encodings. The backend prefers the
16-bit form when operands fit (
r0–r7, small immediates) and falls back to the 32-bit (.W) form otherwise; the assembler/disassembler handle both widths. - Constant / address materialization via MOVW/MOVT — no literal pools. An
arbitrary 32-bit immediate is
MOVW rd,#imm16+MOVT rd,#imm16:16. A symbol address is the same pair withR_ARM_THM_MOVW_ABS_NC+R_ARM_THM_MOVT_ABS(static/absolute — no GOT in the freestanding model). This is the explicit house strategy: it keeps I-cache clean and matches the request. ALU ops still use the Thumb modified-immediate (ThumbExpandImm, the rotated-8-bit form) when the constant fits; otherwise MOVW/MOVT into IP (r12) then operate. - Data processing: ADD/SUB/RSB/ADC/SBC, AND/ORR/EOR/BIC/ORN, MVN, TST/TEQ,
CMP/CMN, MOV/MOVS, shifts LSL/LSR/ASR/ROR (immediate and register), and the
shifted-register operand form (
add r0,r1,r2,lsl #3). - Multiply / divide: MUL, MLA, MLS; long multiply UMULL/SMULL/UMLAL/SMLAL
(for
i64). Hardware divide: SDIV / UDIV (present on all ARMv7-M). No hardware modulo —a % blowers toSDIV/UDIV+MLS(no__aeabi_idivneeded for 32-bit; the AEABI div helpers remain fori64and Thumb-1). - Bitfield: BFI (insert), BFC (clear), SBFX / UBFX (signed/unsigned extract) — the primitives for C bitfield load/store. CLZ, RBIT, REV/REV16/REVSH (byte-swap), SXTB/SXTH/UXTB/UXTH (sign/zero extend).
- Load / store: LDR/STR (+ LDRB/LDRH/LDRSB/LDRSH) with immediate, register,
and shifted-register offsets; LDRD/STRD (register pair —
i64/double); LDM/STM and PUSH/POP (prologue/epilogue, register-list save/restore). - Branches: B (uncond and B
<cond>), BL, BX/BLX (register, indirect call / return), CBZ/CBNZ (16-bit compare-and-branch-against-zero, forward short), and TBB/TBH (table-branch — switch jump tables). Range discipline (the linker range-checks, like aa64 CALL26): BL/B.W (T1/T4) ±16 MB, B<cond>.W (T3) ±1 MB, 16-bit B ±2 KB / B<cond>±256 B, CBZ/CBNZ +0..126 B forward only. - IT blocks:
IT{x{y{z}}} <cond>predicates the next 1–4 instructions. This is how Thumb-2 does branchless conditional-select (CMP; ITE <cond>; MOV<cond>; MOV<!cond>) — there are no per-instruction condition codes outside IT. The native backend emits IT forcmp→select and small conditional sequences; the assembler tracks IT state and the disassembler renders it. - DSP / saturating (ARMv7E-M only, follow-on): SSAT/USAT, QADD/QSUB, and the
packed SIMD set — emitted only to lower saturating/
__builtinintrinsics, gated on thedsptarget feature. - Floating-point (hard-float only, follow-on): VADD/VSUB/VMUL/VDIV.F32, VCVT, VMOV (core↔FP), VLDR/VSTR, VCMP. FPv4-SP (Cortex-M4F, single-only) and FPv5/FPv5-D16 (Cortex-M7).
Explicitly out of scope
- ARM (A32) execution state — M-profile has none; Thumb-only.
- Thumb-1-only subset / ARMv6-M (Cortex-M0/M0+) — no IT, no MOVW/MOVT, no
hardware divide, no 32-bit Thumb-2. A future variant (the rt
thumb1recipe + coro already anticipate it) — a follow-on. - NEON / Advanced SIMD — not present on M-profile.
__int128—INT128=0(as on rv32); reject at the frontend or leave-red.- ELF32 dynamic / PIE, GOT/PLT — arm32 is static-only for v1 (mirrors the rv32 "static-only, clean-panic on dynamic" decision).
- The emulator (
src/emu,src/os) — stays non-arm32, as for rv32.
Confirmed scope decisions
- One backend, ARMv7-M Thumb-2. ARMv7-M vs ARMv7E-M is a target-feature
(
dsp) difference, and soft vs hard float is theKitTargetSpec.float_abiaxis (already exists from rv32) — neither forks the backend. Build the backend with a cleanconst-descriptor seam (reached through the codegen context, per "no global state") so a future ARMv6-M/Thumb-1 or A-profile/A32 variant can be added the wayriscv/variant.hadded rv32 — but v1 ships a single variant. - Subsystems in scope: compile + assemble + link + disasm; runtime; JIT
run/dbgplumbing wired but host-gated (an arm32 JIT must execute AArch32 in-process, which this arm64-macOS dev host cannot do — gate to exit-77 exactly as rv32's JIT test does on non-rv hosts). Emulator out. - ABIs: AAPCS / ARM EABI (AEABI). v1
-mfloat-abi=soft(arm-none-eabi): FP args in core registers, all FP via soft helpers. A follow-on adds single-precision hard-float (arm-none-eabihf, FPv4-SP);doublestays soft on FPv4-SP, the exact parallel of rv32ilp32f. - Object format: ELF only (ELFCLASS32, EABI version 5).
EF_ARM_ABI_FLOAT_*e_flags carry the float ABI (like rv32's RISC-V float-ABI e_flags). Fresh ARM32 objects now synthesize a minimal.ARM.attributes/ AEABI build attributes section for external-toolchain interop. - Addressing: MOVW/MOVT absolute (static), no literal pools, no GOT. Large frame offsets / out-of-range immediates go through IP (r12).
Backend shape (mirror src/arch/riscv / src/arch/aa64)
New src/arch/arm32/, one file per concern, following the reference backends
(aa64 is "the reference"; riscv is the closest analog as a 32-bit RISC-style
target):
| file | role | reference |
|---|---|---|
arch.c |
arch_impl_arm32 (the ArchImpl — src/arch/arch.h:263): factories, register metadata, CFI defaults (return_addr_reg=14, code_align=2, data_align=-4, cfa_init_reg=13), predefined macros, target features, backend_features=STRICT_ALIGNMENT, atomic_lock_free_max=4, supports_call_conv/supports_intrinsic |
aa64/arch.c:208 |
isa.h / isa.c |
Thumb-2 format enums, the descriptor table ({mnemonic, match, mask, fmt, flags, av}), match/mask macros, inline encoders, operand printers. Two width families — a 16-bit and a 32-bit half-word table, decoded by leading bits |
riscv/isa.{h,c}, aa64/isa.{h,c} |
disasm.c |
descriptor-driven decode (read first half-word; if [15:11] ∈ {0b11101,0b11110,0b11111} it is a 32-bit instruction → read second half-word); IT-state tracking; .inst/.hword fallback |
riscv/disasm.c |
asm.c |
operand parsing, ThumbExpandImm/MOVW-MOVT/branch immediate encoding, IT-block syntax, .syntax unified |
aa64/asm.c |
regs.h / regs.c |
DWARF index ↔ name table (r0–r15, then s0–s31/d0–d15 for VFP) | aa64/regs.c |
native.c |
the NativeTarget (defined in src/arch/native_target.h) + NativeOps: O0 cache pool r0–r3 and scratch IP; O1 persistent allocation r4–r11, scoped emit_temps r0–r3, and asm-only LR; frame layout (PUSH/POP, prologue/epilogue); all ops; IT-based cmp→select; MOVW/MOVT materialization; SDIV/UDIV+MLS; BFI/UBFX bitfields; LDRD/STRD + lane ops for i64 |
riscv/native.c, aa64/native.c |
reloc.c |
arm32_reloc_desc table + arm32_reloc_apply_insn (the Thumb-2 split-immediate byte patcher) + link_arch_arm32 |
riscv/reloc.c, aa64/reloc.c |
link.c |
LinkArchDesc (src/link/link_arch.h:67) — static-only, no PLT for v1 |
riscv/link.c |
dbg.c |
breakpoint (BKPT) + displaced-step shim; min_insn_len=2, max_insn_len=4 |
riscv/dbg.c |
Instruction emission goes through the shared MCEmitter (src/arch/mc.h):
emit 16-bit half-words via mc_emit_bytes/a thin arm_emit16/arm_emit32
wrapper that also feeds debug rows, exactly as rv64_emit32 does. Label fixups
flow through arch_impl_arm32.apply_label_fixup.
ABI lives in src/abi/abi_aapcs32.c + a registry.c entry
{KIT_ARCH_ARM_32, KIT_OBJ_ELF, &aapcs32_vtable} (src/abi/registry.c:21). The
ABIVtable.compute_func_info (src/abi/abi_internal.h:13) implements AAPCS:
r0–r3 then stack; 8-byte-aligned scalars (i64/double) consume an even/odd
register pair (r0:r1 or r2:r3) and 8-byte-align on the stack — the notable
32-bit rule, structurally the same as rv32's aligned-GPR-pair
(abi_rv64.c RiscvAbiDesc); aggregates ≤ 4 words pass in registers, larger via
sret pointer in r0; va_list is a plain void* (ABI_VA_LIST_POINTER, 4 B,
gp_slot 4) — like rv32. scalar_split_lane_size returns 4 (so i64 splits into
two 32-bit GPR lanes), reusing the rv32 mechanism.
Relocations. Add ARM-specific RelocKinds (src/obj/obj.h) and an ELF
mapper src/obj/elf/reloc_arm.c (elf_arm_reloc_{to,from,name}, paralleling
reloc_riscv32.c). Reuse neutral R_ABS32/R_REL32/R_ABS16/R_ABS8 for
data; the ARM-specific set is:
| kit RelocKind | ELF | site | field |
|---|---|---|---|
R_ARM_THM_CALL |
R_ARM_THM_CALL |
BL/BLX (T1) | 25-bit {S,I1,I2,imm10,imm11}, I1=¬(J1⊕S), I2=¬(J2⊕S); ±16 MB |
R_ARM_THM_JUMP24 |
R_ARM_THM_JUMP24 |
B.W (T4) | same 25-bit field |
R_ARM_THM_JUMP19 |
R_ARM_THM_JUMP19 |
B<cond>.W (T3) |
21-bit {S,J2,J1,imm6,imm11}; ±1 MB |
R_ARM_THM_MOVW_ABS_NC |
same | MOVW (T3) | imm16 imm4:i:imm3:imm8, = (S+A)[15:0] |
R_ARM_THM_MOVT_ABS |
same | MOVT (T3) | imm16, = (S+A)[31:16] |
R_ARM_THM_MOVW_PREL_NC / ..._MOVT_PREL |
same | PC-rel MOVW/MOVT | (S+A−P) (PIC; follow-on) |
R_ARM_TLS_LE32 |
same | TLS local-exec | S+A−tp (static TLS, variant I) |
The split-immediate patcher is the single trickiest piece — the BL/B.W
J1/J2-XOR-S encoding and the MOVW/MOVT imm4:i:imm3:imm8 scatter are error-prone;
write it test-first against llvm-mc/clang goldens. The Thumb bit
(symbol low bit set on STT_FUNC Thumb symbols) must be masked correctly in
branch targets; since everything is Thumb on Cortex-M, BL never relaxes to BLX.
Runtime (rt/) — mostly built; document in ../RUNTIME.md
- Soft-float + AEABI:
rt/lib/arm/aeabi_thumb2.S+aeabi.calready provide the AEABI div/mod,i64div/mod, soft-float compares, and__aeabi_mem*. The generic soft-float (rt/lib/fp/fp.c) and 64-bit int helpers (rt/lib/int32/int32.c:__ashldi3/__ashrdi3/__lshrdi3/__muldi3+ 64-bit divide) are the same ones rv32 uses. Frontend routesi64mul/div/shift,double, andi64↔float to these. - TLS: variant I (TCB ahead of
.tdata), the same model as AArch64/RISC-V. The freestanding image's reset stub sets the thread pointer and seeds the static.tdata/.tbssimage (the bare lane does this — see Testing). Document the TCB size +tpconvention in RUNTIME.md. - 64-bit atomics: ARMv7-M has
LDREX/STREX(32-bit) but no 64-bit exclusive (LDREXD/STREXDis A/R-profile only on most M parts) → 8-byte_Atomiclowers to the spinlock-backed__atomic_*_8(rt/lib/atomic/atomic_freestanding.c), and__atomic_always_lock_free(8,…)reports false — identical to the rv32 contract. Document in RUNTIME.md. - Driver runtime variants (
driver/lib/runtime.c): addarm-none-eabi(soft) → rt variantarm-eabi-thumb2, selected by the EABI float-ABI e_flag via the existingfloat_abi/isa/abiaxis onRuntimeVariant(added for rv32). A follow-on adds the hard-float variant (a newarm-eabihf-thumb2rt recipe: ilp32 +ARCH_FLAGS=-mfpu=fpv4-sp-d16 -mfloat-abi=hard).
Testing strategy — test-cross over qemu-system-arm (mirror rv32)
arm32 is freestanding, cross-only: it joins the cross matrix as
freestanding-arm32, never selfhost (no OS to run a compiler on). The exec
seam already has the right backend — test/lib/exec_bare.sh, the qemu-system
bare-metal front door (PORT.md "One exec front door, three
backends"). We add an arm32 arm to it, plus a smoke oracle and the corpus
lanes.
Support-set wiring (scripts/hosted.sh)
Add freestanding-arm32 to SUPPORT_SET; teach parse_target the aliases
(arm32/armv7m/armv7em/cortex-m3… → canonical arm32), triple_of →
arm-none-eabi, and tag_of → arm32-freestanding. Then
scripts/hosted.sh list freestanding includes it and make test-cross TARGET=freestanding-arm32 resolves.
QEMU machine + exit oracle (test/lib/exec_bare.sh)
Emulator:
qemu-system-arm(already the family used for aa64 semihosting; not yet wired for any arch). Machine:mps2-an385(Cortex-M3, the maintained ARM MPS2 FPGA model; flash at0x00000000, SRAM at0x20000000), withmps2-an386/an500/an505available for M4/M7 lanes;lm3s6965evbis a fallback Cortex-M3 model. One machine for the default lane.Exit-code oracle: ARM semihosting via
BKPT #0xAB, callingSYS_EXIT_EXTENDED(0x20) with{ADP_Stopped_ApplicationExit, code}so an arbitrary exit code reaches qemu's process exit (plainSYS_EXIT(0x18) can only signal 0/1). Run with-semihosting-config enable=on,target=native -nographic -no-reboot. This slots into the PORT.md per-arch oracle table:arch mechanism decode arm32 ARM semihosting BKPT #0xAB+SYS_EXIT_EXTENDEDqemu rc = guest code (aa64 uses the same family with
HLT #0xF000; rv32/rv64 use the SiFive finisher MMIO; x64 usesisa-debug-exit.)Reset stub + linker script (the per-arch scaffolding
exec_bare.showns, generalizing the rv32 stub): Cortex-M boots from a vector table at0x0— first word = initial MSP, second word =Reset_Handlerwhose.thumb_funcsymbol value carries the Thumb bit. The stub vector-table + reset handler: set SP, copy.datafrom its flash LMA to the SRAM VMA, zero.bss, seed the static.tdata/.tbssTLS image and settp(mirroring the rv32 reset stub), thenBL main, then semihosting-exit with the return value. Linker script:.text(vector table first) at0x0;.data/.bss/.tdata/.tbssat0x20000000withAT>LMA in flash. Loaded via-kernel image.elf. The startup stub is kit-assembled and avoids the former manual+ 1vector-table workaround.
Lanes
- Smoke
test/smoke/arm32.sh(→make test-smoke-arm32): prereq check (clangarm-none-eabitarget +qemu-system-arm, like rv32.sh'scheck_rv32_env), thenkit cc -target arm-none-eabi -mcpu=cortex-m3 -ffreestanding -c→kit ld(auto-links the runtime, no explicitlibkit_rt.a) → run under qemu, assert the semihosting exit code. Coveri64, soft-double,i64div/shift, a switch (TBB/TBH), and a negative control. - Toy / parse corpus (
DEPTH=full): addcross_one_arm32totest/toy/run.sh(path X) and thearm32branch totest/parse/run.sh'skit_lane_E+kit_test_target.h, both routing throughexec_bare_run arm32. Reds left red (no skip sidecars), enumerated as gaps — same discipline as rv32 (expecti64atomics, TLS-needs-thread-pointer,__int128to surface). - Unit:
test/arch/arm32_decode_test.c(→test-isa, ISA encode/decode round-trip for the 16- and 32-bit families + IT + MOVW/MOVT + BL split),test/elf/unit/arm32_class32.c(ELFCLASS32 round-trip, →test-elf),test/link/arm32_jit_test.c(→test-arm32-jit, host-gated exit-77 — no AArch32 execution on this host), and atest/asm/byte-golden lane +regen-arm32.shagainst clang/llvm-objdump. - External-linker check: add arm32 objects to
test/extlink(link kit objects withld.lld) once relocations land — catches.ARM.attributes/ e_flags / Thumb-bit issues kit's ownldwould tolerate.
Provisioning
make provision TARGET=freestanding-arm32 installs qemu-system-arm + a clang
with the arm-none-eabi target (provision-or-error, never silent skip —
PORT.md). Update the PORT.md cross status table with a freestanding-arm32
row.
Bring-up & parallelization
The strategy is tracer-bullet: drive one thin thread through every layer first,
then thicken each layer independently. The reason the later work parallelizes is
the internal seams — NativeTarget (src/arch/native_target.h), ArchImpl
(src/arch/arch.h), the ABI vtable (src/abi/abi_internal.h), and
reloc_apply_insn. Each later track lives behind exactly one of them:
- frame/ABI work behind
func_begin/marshal_call/spill/frame_slot - op-group work behind
binop/emit+ the isa table - reloc work behind
arm32_reloc_apply_insn+ the descriptor table - runtime entirely in
rt/
So bring-up's real deliverable is nailing those boundaries, not running a trivial program: an op-group agent that only touches isa rows + its op's emit + asm parse never touches frame internals, and the frame agent can rewrite the prologue underneath it without conflict. The tracks then collide only on the shared isa table, which is append-mostly.
Phase 1 — the walking skeleton — DELIVERED (main 3b67636b)
make test-cross TARGET=freestanding-arm32 DEPTH=smoke is green: kit cc compiles
arm-none-eabi objects, kit ld links them with the Cortex-M reset stub + an
AT>-based linker script, qemu-system-arm -M mps2-an385 runs the image, and
the semihosting SYS_EXIT_EXTENDED exit code matches. The reset stub is now
kit-assembled in the closed core.
Landed:
- Gate + registration:
KIT_ARCH_ARM32_ENABLED,arch_impl_arm32,aapcs32_vtableacross the arch/abi/obj registries +config_assert+mk/lib_srcs.mk+ the disasm/dbg/link stub files. src/arch/arm32/:arch.c(ArchImpl + the intra-function Thumb-2 branch label-fixup patcher for B.W / B<cond>.W + predefined macros + target-feature hooks),isa.h(header-only Thumb-2 encoders +ThumbExpandImm— no descriptor table /isa.cyet),native.c(the -O0 spine, below),reloc.c(THM_CALL/JUMP24 split-immediate patcher),link.c,regs,variant,dbg.c(BKPT, len 2/4);disasm.c/asm.care NULL stubs.abi_aapcs32.c: soft-float AAPCS32 — r0–r3 then stack, i64/double as a 2-lane GPR pair, sret in r0, aggregates ≤16 B in regs else INDIRECT,scalar_split_lane_size=4.- ELF:
EM_ARM+EF_ARM_*+R_ARM_*(obj.h tail + elf.h),reloc_arm.cmapper,obj/registry.carch-ops row,EM_ARMe_flags synthesis (soft = no float bit, matching clang/gas). - Cross-test lane:
hosted.sh(freestanding-arm32),exec_bare.sh(_bare_emit_arm32: mps2-an385,BKPT #0xAB+SYS_EXIT_EXTENDEDoracle, kit-assembled reset stub,MEMORY/AT>linker script),cross_test.sh(arm32 smoke forced to -O0),mk/test.mk+test/smoke/arm32.sh+check_arm32_env.sh.
The -O0 native spine (native.c): FP-anchored frame (r7 anchor; deferred
SUB sp patched in func_end; FP-relative [r7,#-off] slots; PUSH {r7,lr} /
POP {r7,pc}). r0–r3 NDT cache pool with Lever-1 arg-fronting; IP(r12) NDT
scratch; LR(r14) the backend div/mod temp. MOVW/MOVT + ThumbExpandImm
immediates; ADD/SUB/AND/ORR/EOR/MUL/SDIV/UDIV/(SDIV+MLS rem)/LSL/LSR/ASR;
SXT/UXT/trunc converts; CMP+IT compare-materialize and CMP+B<cond>.W
cmp_branch; AAPCS calls/returns (≤4 int args in regs, the rest on the stack);
Thumb bit on STT_FUNC symbols. Validated under qemu: return-const, ALU,
signed/unsigned compare + if/else, recursion, mul/div/mod, ≤4- and >4-arg calls;
disassembly matches llvm-objdump.
Bring-up findings (carry into Phase 2): clang start.o uses REL relocs
(implicit addend; Thumb bit in st_value) — the BL/B.W patcher uses
(S&~1)+A−P−4 and the label-fixup applies the same −4 Thumb PC bias; func_end
must publish the function symbol address (else multi-function objects
misresolve); kit must not set EF_ARM_ABI_FLOAT_SOFT (soft is the ecosystem
default — the freestanding linker's strict e_flags check rejects a mismatch with
float-agnostic asm stubs). Lanes A/B/C (qemu-lane de-risk, reloc/ELF, harness)
were built by parallel agents.
Phase 2 — remaining tracks
Same seams as before (func_begin/marshal_call/spill; binop/emit + the isa
table; arm32_reloc_apply_insn; rt/). Most of native.c/abi/reloc is in
place; the unimplemented ops compiler_panic with a clear message. Gate stays
run-correctness under qemu, reds left red. Op-groups contend on native.c /
the new isa.c at merge time — run them worktree-isolated, small diffs, serial
cherry-pick behind the qemu gate (anchor cwd to the main checkout).
Highest priority — unblocks real C programs:
- Global address materialization —
NATIVE_ADDR_BASE_GLOBALpanics today, so any global variable is uncompilable. Needs MOVW/MOVT-of-symbol +R_ARM_THM_MOVW_ABS_NC/_MOVT_ABSrelocs (the patcher'simm4:i:imm3:imm8scatter) + realload_addr/load_const/load_label_addr/tls_addr_of. -O1known-frame path —func_begin_known_framepanics; the cross smoke lane forces -O0 for arm32 (smopt=O0incross_test.sh) until this lands.- 64-bit / soft-double —
binop/convertpanic on 8-byte / FP. Reuses the arch-neutral rv32 wide8 path; needs LDRD/STRD + UMULL/SMULL lane-emit + ADDS/ADCS + the__aeabi_*/__*di3libcalls, which in turn needs the driver runtime-variant row (driver/lib/runtime.c) + a kit-builtlibkit_rt.a(the smoke set needs none today).
Op-groups (isa + native-emit + test): bitfield (BFI/UBFX/SBFX), byte-reverse
(REV/REV16/REVSH), shifted-register operands, switch tables (CBZ/CBNZ/TBB/TBH via
indirect_branch), long multiply, 16-bit-encoding density (only mov-hi/bx/bkpt
are 16-bit today — the rest are .W), large frames/offsets via IP (frame >4095
panics).
Struct-by-value / varargs / atomics / TLS / inline asm (all panic-stubbed):
copy_bytes/set_bytes + INDIRECT params & returns; va_*; LDREX/STREX + DMB
for ≤4-byte atomics (8-byte → spinlock libcall); TLS_LE32 + the runtime tp
contract; asm_block + ARM register/memory constraints.
Tooling + tests: descriptor-driven disassembler (isa.c table + disasm.c,
IT-state + 16/32-bit width edges) and standalone assembler (asm.c,
.syntax unified + IT syntax, byte-golden vs llvm-mc); unit tests
(arm32_decode_test → test-isa, elf/unit/arm32_class32 → test-elf, host-gated
arm32_jit_test); the DEPTH=full toy/parse corpus lanes (cross_one_arm32,
parse E-lane + kit_test_target.h); test/extlink ld.lld check; provisioning +
the PORT.md cross-status row.
Follow-on variants: hard-float (FPv4-SP, arm-none-eabihf), DSP/saturating
builtins (ARMv7E-M), ARMv6-M/Thumb-1, A-profile; full
-mcpu/-march/-mfpu/-mfloat-abi parsing + the complete __ARM_FEATURE_*
macro set; runtime verification breadth; -O0 density toward tcc.
Where to look
- Backend:
src/arch/arm32/{arch,isa,disasm,asm,regs,native,reloc,link,dbg}.c— pattern offsrc/arch/riscv/*(32-bit analog) andsrc/arch/aa64/*(reference). Shared emittersrc/arch/mc.{h,c}; contractssrc/arch/arch.h,src/arch/native_target.h,src/cg/native_direct_target.h. - ABI:
src/abi/abi_aapcs32.c(new) +src/abi/registry.c; templatesrc/abi/abi_rv64.c(pair/lane rules) andsrc/abi/abi_aapcs64.c(AAPCS shape). Vtable contractsrc/abi/abi_internal.h. - 64-bit legalization:
src/cg/wide.c,src/cg/arith.c,src/cg/native_direct_target.c(nd_*panics),src/opt/{cg_ir_lower, pass_native_emit}.c— arch-neutral, already proven on rv32. - ELF / link / reloc:
src/obj/obj.h(RelocKind),src/obj/reloc.{h,c}(neutral descriptors),src/obj/elf/{elf.h,emit.c,read.c,link.c}+reloc_arm.c(new, templatereloc_riscv32.c),src/link/{link_reloc_apply.c, link_reloc_desc.h,link_arch.h}. - Driver / frontend:
src/api/target.c(triple),src/api/object_detect.c(e_flags → float_abi),driver/cmd/{cc,ld}.c,driver/lib/{runtime,target}.c. - Runtime:
rt/lib/arm/aeabi_thumb2.S,rt/lib/coro/arm32.c,rt/lib/{int32,fp,atomic}/…,mk/rt.mk(RT_arm-eabi-thumb2*). ../RUNTIME.md for the TLS / atomics / soft-float contracts. - Tests:
test/lib/exec_bare.sh(add arm32),test/smoke/arm32.sh(new, templaterv32.sh),test/toy/run.sh(cross_one_arm32),test/parse/run.sh(E lane) +test/lib/kit_test_target.h,test/arch/arm32_decode_test.c,test/elf/unit/arm32_class32.c,test/link/arm32_jit_test.c,scripts/hosted.sh,mk/{test,test_unit}.mk. Design of record: PORT.md.