commit a607d9e0169a971697a9073770e075477e079853
parent b05d4004256585236bb5b1aee44b177aea3cdb32
Author: Ryan Sepassi <rsepassi@gmail.com>
Date: Tue, 16 Jun 2026 17:19:18 -0700
arm32 Phase 2: global address materialization (MOVW/MOVT + ABS relocs)
Make `kit cc -target arm-none-eabi` compile programs that reference
globals/statics. The house strategy is MOVW/MOVT absolute (Cortex-M has no
literal pools and the bare-metal lane is non-PIC): a symbol address is
`MOVW rd,#:lower16:sym` + `MOVT rd,#:upper16:sym` with R_ARM_THM_MOVW_ABS_NC
then R_ARM_THM_MOVT_ABS, the byte offset folded into the explicit reloc addend.
Works (verified under qemu-system-arm mps2-an385, -O0, both ld.lld and kit ld):
- global int read+write (.data) and .bss scalar (zero-init, write, read)
- global array indexed by a runtime value (.data and .bss) — load_addr now
folds the scaled index (ADD.W rd, rd, idx, LSL #scale)
- global struct field read+write
- static const table in .rodata read by index
- pointer to a global: deref + store through it; &global into a local pointer
- function pointer call through a global (.data / .rodata table)
- &function taken as data keeps the Thumb bit (odd) so an indirect BLX reaches
Thumb — the MOVW/MOVT patcher does NOT strip bit 0 (unlike the branch relocs)
- scalar load_const (<=4 bytes) via MOV.W/MVN.W/MOVW+MOVT (arm_load_imm)
- &&label address-take (load_label_addr) via MOVW/MOVT ABS vs the label's
per-block local symbol
native.c:
- arm_emit_global_addr: shared MOVW/MOVT-ABS materializer.
- arm_addr_base: GLOBAL base materializes into ARM_TMP (lr), NOT the IP scratch
(r12) the NDT uses for operands — in `str rt,[base]` the transfer reg may be
r12; lr is reserved and dead in the body so it never aliases rt.
- arm_load_addr: GLOBAL straight into rd (offset folds into the addend);
FRAME/REG via base+offset; folds any scaled index for all base kinds (the NDT
routes addr_legal-rejected indexed addresses through here).
- arm_load_const / arm_load_label_addr implemented; arm_tls_addr_of left a
clean panic.
isa.h: arm_add_reg_lsl (ADD.W rd, rn, rm, LSL #sh) for scaled-index addressing.
reloc.c: R_ARM_THM_MOVW_ABS_NC / R_ARM_THM_MOVT_ABS patchers (imm4:i:imm3:imm8
scatter) + R_ARM_THM_JUMP19 patcher for completeness; rows added (width 4,
MOV* not a branch). kit ld now links all of the above end-to-end.
Clean documented leave-red:
- arm_tls_addr_of: TLS local-exec needs a thread pointer read from the CP15
thread-ID register, which the M-profile (Cortex-M) does not implement. The
bare-metal arm32 lane is non-TLS; a correct app-profile path is a follow-on,
so this stays a clean panic rather than emitting a wrong sequence.
- arm_load_const for size>4 panics ("wide8 track") — the NDT pre-lowers 8-byte
constants to two 32-bit lanes, so this is a backstop for the -O1 emit path.
Diffstat:
3 files changed, 146 insertions(+), 17 deletions(-)
diff --git a/src/arch/arm32/isa.h b/src/arch/arm32/isa.h
@@ -114,6 +114,12 @@ static inline u32 arm_dp_reg(u32 op4, u32 setflags, u32 rd, u32 rn, u32 rm) {
(rd << 8) | rm);
}
static inline u32 arm_add_reg(u32 rd, u32 rn, u32 rm) { return arm_dp_reg(8u, 0u, rd, rn, rm); }
+/* ADD.W rd, rn, rm, LSL #sh (T3, no flags): scaled-index base+offset for
+ * indexed addressing. type=00 (LSL); sh in imm3:imm2. */
+static inline u32 arm_add_reg_lsl(u32 rd, u32 rn, u32 rm, u32 sh) {
+ u32 imm3 = (sh >> 2) & 7u, imm2 = sh & 3u;
+ return arm_t32(0xeb00u | rn, (imm3 << 12) | (rd << 8) | (imm2 << 6) | rm);
+}
static inline u32 arm_adds_reg(u32 rd, u32 rn, u32 rm) { return arm_dp_reg(8u, 1u, rd, rn, rm); }
static inline u32 arm_sub_reg(u32 rd, u32 rn, u32 rm) { return arm_dp_reg(13u, 0u, rd, rn, rm); }
static inline u32 arm_and_reg(u32 rd, u32 rn, u32 rm) { return arm_dp_reg(0u, 0u, rd, rn, rm); }
diff --git a/src/arch/arm32/native.c b/src/arch/arm32/native.c
@@ -275,7 +275,36 @@ static int arm_addr_legal(NativeTarget* t, const NativeAddr* addr,
/* ============================ memory ============================ */
-/* Resolve a NativeAddr to a (base reg, signed byte offset) pair. */
+/* Materialize the runtime address of `sym` (+ addend) into register `rd`.
+ *
+ * arm32's house strategy is MOVW/MOVT absolute (Cortex-M has no literal pools
+ * and the bare-metal lane is non-PIC):
+ * MOVW rd, #:lower16:sym ; R_ARM_THM_MOVW_ABS_NC -> (S+addend)[15:0]
+ * MOVT rd, #:upper16:sym ; R_ARM_THM_MOVT_ABS -> (S+addend)[31:16]
+ * The 16-bit imm placeholders the encoders write are overwritten by the
+ * patcher (src/arch/arm32/reloc.c); the addend rides in the reloc record (an
+ * explicit addend), so the symbol's full address+addend lands in `rd` and no
+ * post-add is emitted. (A GOT-indirect variant would belong here if a PIC
+ * arm32 lane were ever added; bare-metal ELF resolves these absolutely.) */
+static void arm_emit_global_addr(Arm32NativeTarget* a, u32 rd, ObjSymId sym,
+ i64 addend) {
+ MCEmitter* mc = a->base.mc;
+ u32 pos = mc_pos(mc);
+ arm_emit_t32(mc, arm_movw(rd, 0u));
+ mc_emit_reloc_at(mc, mc->section_id, pos, R_ARM_THM_MOVW_ABS_NC, sym, addend,
+ 1, 0);
+ pos = mc_pos(mc);
+ arm_emit_t32(mc, arm_movt(rd, 0u));
+ mc_emit_reloc_at(mc, mc->section_id, pos, R_ARM_THM_MOVT_ABS, sym, addend, 1,
+ 0);
+}
+
+/* Resolve a NativeAddr to a (base reg, signed byte offset) pair. A GLOBAL base
+ * is materialized via MOVW/MOVT into ARM_TMP (lr) — NOT the IP scratch (r12),
+ * which the NDT uses to materialize operands: in a store `str rt, [base]` the
+ * transfer reg rt may itself be r12 (an NDT-materialized value or scratch),
+ * which the IP global-address would clobber. lr is reserved (never an NDT
+ * operand) and dead in the body, so it can never alias rt. */
static u32 arm_addr_base(Arm32NativeTarget* a, const NativeAddr* addr,
i32* off_out) {
switch (addr->base_kind) {
@@ -288,6 +317,11 @@ static u32 arm_addr_base(Arm32NativeTarget* a, const NativeAddr* addr,
case NATIVE_ADDR_BASE_REG:
*off_out = addr->offset;
return addr->base.reg & 0xfu;
+ case NATIVE_ADDR_BASE_GLOBAL:
+ arm_emit_global_addr(a, ARM_TMP, addr->base.global.sym,
+ addr->base.global.addend);
+ *off_out = addr->offset;
+ return ARM_TMP;
default:
arm_panic(a, "unsupported addressing mode");
}
@@ -379,17 +413,12 @@ static void arm_reload(NativeTarget* t, NativeLoc dst, NativeFrameSlot slot,
arm_emit_mem(arm_of(t), 1, dst, addr, mem);
}
-/* Load the address of a frame slot / reg base into a register (ADD rd, base,
- * #off via the modified-immediate or ADDW form; small offsets only in v1). */
-static void arm_load_addr(NativeTarget* t, NativeLoc dst, NativeAddr addr) {
- Arm32NativeTarget* a = arm_of(t);
- MCEmitter* mc = t->mc;
- i32 off;
- u32 base = arm_addr_base(a, &addr, &off);
- u32 rd = loc_reg(dst);
+/* Materialize a full address into `rd`: base + signed byte offset. */
+static void arm_emit_base_off(Arm32NativeTarget* a, u32 rd, u32 base, i32 off) {
+ MCEmitter* mc = a->base.mc;
u32 enc;
if (off == 0) {
- arm_emit_t16(mc, arm_mov_hi(rd, base));
+ if (rd != base) arm_emit_t16(mc, arm_mov_hi(rd, base));
} else if (off > 0 && off <= 0xfff) {
arm_emit_t32(mc, arm_add_imm12(rd, base, (u32)off));
} else if (off < 0 && thumb_expand_imm_encode((u32)(-off), &enc)) {
@@ -399,6 +428,34 @@ static void arm_load_addr(NativeTarget* t, NativeLoc dst, NativeAddr addr) {
}
}
+/* Materialize the address of a NativeAddr into the destination register.
+ *
+ * The NDT routes a base+index (or any addr the addr_legal hook rejects)
+ * through here and then treats `rd` as a plain BASE_REG, so this MUST fold any
+ * scaled index: rd = base + offset + (index << log2_scale). A GLOBAL base's
+ * byte offset folds into the MOVW/MOVT reloc addend; FRAME/REG bases add the
+ * offset explicitly. `rd` is a fresh NDT scratch, distinct from the pinned
+ * index register, so no aliasing post-add is needed. */
+static void arm_load_addr(NativeTarget* t, NativeLoc dst, NativeAddr addr) {
+ Arm32NativeTarget* a = arm_of(t);
+ MCEmitter* mc = t->mc;
+ i32 off;
+ u32 base, rd = loc_reg(dst);
+ if (addr.base_kind == NATIVE_ADDR_BASE_GLOBAL) {
+ arm_emit_global_addr(a, rd, addr.base.global.sym,
+ addr.base.global.addend + addr.offset);
+ } else {
+ base = arm_addr_base(a, &addr, &off);
+ arm_emit_base_off(a, rd, base, off);
+ }
+ if (addr.index_kind == NATIVE_ADDR_INDEX_REG) {
+ u32 idx = addr.index.reg & 0xfu;
+ arm_emit_t32(mc, arm_add_reg_lsl(rd, rd, idx, addr.log2_scale));
+ } else if (addr.index_kind != NATIVE_ADDR_INDEX_NONE) {
+ arm_panic(a, "unsupported address index kind in load_addr");
+ }
+}
+
/* ============================ ALU ============================ */
static void arm_binop(NativeTarget* t, BinOp op, NativeLoc dst, NativeLoc a_loc,
@@ -1064,14 +1121,25 @@ static void arm_set_loc(NativeTarget* t, SrcLoc loc) {
#define ARM_UNIMPL(name) arm_panic(arm_of(t), name " not implemented in Phase 1")
static void arm_load_const(NativeTarget* t, NativeLoc dst, ConstBytes c) {
- (void)dst;
- (void)c;
- ARM_UNIMPL("load_const");
+ Arm32NativeTarget* a = arm_of(t);
+ u32 v = 0, i;
+ /* Scalar constant <=4 bytes: reinterpret the ABI bytes (little-endian on
+ * arm32) as a u32 and materialize via arm_load_imm (MOV.W/MVN.W/MOVW+MOVT).
+ * 8-byte constants are the wide8 track's concern. */
+ if (c.size > 4u)
+ arm_panic(a, "8-byte load_const is the wide8 track");
+ for (i = 0; i < c.size && i < 4u; ++i) v |= (u32)c.bytes[i] << (i * 8u);
+ arm_load_imm(t, dst, (i64)(i32)v);
}
static void arm_load_label_addr(NativeTarget* t, NativeLoc dst, MCLabel l) {
- (void)dst;
- (void)l;
- ARM_UNIMPL("load_label_addr");
+ /* `&&label` address-take (computed goto / jump-table base): materialize the
+ * label's address via MOVW/MOVT ABS against its per-block local symbol — the
+ * same form arm_emit_global_addr uses for a global, so an encoding-divergent
+ * assembler recomputes the split immediate. (Setting the Thumb bit for a
+ * BX/BLX through this address is the indirect-branch consumer's job.) */
+ Arm32NativeTarget* a = arm_of(t);
+ ObjSymId sym = mc_label_symbol(t->mc, l);
+ arm_emit_global_addr(a, loc_reg(dst), sym, 0);
}
static void arm_indirect_branch(NativeTarget* t, NativeLoc addr,
const MCLabel* targets, u32 n) {
@@ -1085,7 +1153,14 @@ static void arm_tls_addr_of(NativeTarget* t, NativeLoc dst, ObjSymId sym,
(void)dst;
(void)sym;
(void)addend;
- ARM_UNIMPL("tls_addr_of");
+ /* TLS local-exec needs a thread pointer. The ARM EABI reads it from the
+ * CP15 thread-ID register (MRC p15,0,rd,c13,c0,3), which the M-profile
+ * (Cortex-M) does not implement — the bare-metal arm32 lane is non-TLS. A
+ * correct application-profile TLS path (R_ARM_TLS_LE32 + tp read) is a
+ * follow-on; emitting a wrong sequence here would silently miscompile, so
+ * this stays a clean panic. */
+ arm_panic(arm_of(t),
+ "tls_addr_of: TLS unsupported on M-profile (no CP15 thread-id reg)");
}
/* copy_bytes / set_bytes: aggregate (struct/array, and the wide8 i64/double)
* memory ops. Both keep the materialized src/dst NativeAddr bases as-is and bake
diff --git a/src/arch/arm32/reloc.c b/src/arch/arm32/reloc.c
@@ -23,6 +23,9 @@
static const RelocDescRow arm32_rows[] = {
{R_ARM_THM_CALL, {4, RELOC_IS_BRANCH}}, /* BL (T1), +-16 MiB */
{R_ARM_THM_JUMP24, {4, RELOC_IS_BRANCH}}, /* B.W (T4), +-16 MiB */
+ {R_ARM_THM_JUMP19, {4, RELOC_IS_BRANCH}}, /* B<cond>.W (T3), +-1 MiB */
+ {R_ARM_THM_MOVW_ABS_NC, {4, 0}}, /* MOVW (T3) low-16 of S+A; not a branch */
+ {R_ARM_THM_MOVT_ABS, {4, 0}}, /* MOVT (T3) high-16 of S+A; not a branch */
};
const RelocDesc* arm32_reloc_desc(RelocKind k) {
@@ -77,6 +80,51 @@ int arm32_reloc_apply_insn(Compiler* c, RelocKind k, u8* P_bytes, u64 S, i64 A,
arm_wr_t32(P_bytes, instr);
return 1;
}
+ case R_ARM_THM_JUMP19: {
+ /* B<cond>.W T3 21-bit field {S,J2,J1,imm6,imm11} (no XOR-with-S, unlike
+ * THM_CALL). PC base = instruction + 4 (Thumb pipeline). The branch
+ * targets code, so the callee's Thumb bit (S|1) is masked out. Mirrors
+ * arch.c:arm32_apply_label_fixup's JUMP19 encoder. */
+ i64 disp = (i64)(S & ~(u64)1) + A - (i64)P - 4;
+ u32 instr, sbit, j1, j2, imm6, imm11;
+ if (disp & 1)
+ compiler_panic(c, SRCLOC_NONE,
+ "link: THM_JUMP19 misaligned displacement");
+ if (disp < -(i64)(1 << 20) || disp >= (i64)(1 << 20))
+ compiler_panic(c, SRCLOC_NONE,
+ "link: THM_JUMP19 out of range (need +-1MiB)");
+ sbit = (u32)((disp >> 20) & 1u);
+ j2 = (u32)((disp >> 19) & 1u);
+ j1 = (u32)((disp >> 18) & 1u);
+ imm6 = (u32)((disp >> 12) & 0x3fu);
+ imm11 = (u32)((disp >> 1) & 0x7ffu);
+ instr = arm_rd_t32(P_bytes);
+ /* clear S(26), imm6(21:16), J1(13), J2(11), imm11(10:0); keep opcode,
+ * cond(25:22), hw2[15:14]=10, hw2[12]=0. */
+ instr &= ~((1u << 26) | (0x3fu << 16) | (1u << 13) | (1u << 11) | 0x7ffu);
+ instr |= (sbit << 26) | (imm6 << 16) | (j1 << 13) | (j2 << 11) | imm11;
+ arm_wr_t32(P_bytes, instr);
+ return 1;
+ }
+ case R_ARM_THM_MOVW_ABS_NC:
+ case R_ARM_THM_MOVT_ABS: {
+ /* MOVW/MOVT (T3): a 16-bit immediate scattered as imm4:i:imm3:imm8 ->
+ * hw1[3:0]=imm4, hw1[10]=i, hw2[14:12]=imm3, hw2[7:0]=imm8.
+ * MOVW fills (S+A)[15:0], MOVT fills (S+A)[31:16]. The Thumb bit is NOT
+ * stripped: a code symbol taken as data (`&fn`) must stay odd so a later
+ * BLX reaches Thumb, and a data symbol's bit 0 is part of its address. */
+ u32 instr = arm_rd_t32(P_bytes);
+ u32 full = (u32)((u64)S + (u64)A);
+ u32 imm16 = (k == R_ARM_THM_MOVT_ABS) ? (full >> 16) & 0xffffu
+ : full & 0xffffu;
+ u32 imm4 = (imm16 >> 12) & 0xfu, i = (imm16 >> 11) & 1u,
+ imm3 = (imm16 >> 8) & 7u, imm8 = imm16 & 0xffu;
+ /* clear the four immediate fields, keep the opcode + Rd. */
+ instr &= ~((0xfu << 16) | (1u << 26) | (7u << 12) | 0xffu);
+ instr |= (imm4 << 16) | (i << 26) | (imm3 << 12) | imm8;
+ arm_wr_t32(P_bytes, instr);
+ return 1;
+ }
default:
return 0;
}