ARMv6-M: plan for a Cortex-M0 code generator#
This page is for EmbCC developers. It surveys what the Thumb backend
(src/arch/thumb/) does and states what an ARMv6-M mode needs:
Cortex-M0, M0+ and M1, triple thumbv6m-none-eabi. Function names are
given rather than line numbers, which move. The survey below was written
before the work; the status section says what was built and where it
departs from the plan.
Status#
Done (steps 1-7 of the order):
- Encoders: the
t1_*functions insrc/arch/thumb/emit.c, checked bytests/golden/thumb-v6m-encoding.sh. - Target:
thumbv6m-none-eabi(aliasesthumbv6m,armv6m-none-eabi), and-mcpu=cortex-m0,cortex-m0plus,cortex-m1on any ARM triple; the predefined macros are clang's (src/arch/thumbv6m/predef*.c,tools/gen-predef.sh thumbv6m);.ARM.attributessays v6S-M, Thumb-1 and no unaligned access;-mfpuand a non-soft float ABI are refused.-mcpu=cortex-m23stays refused. - Code generation:
src/arch/thumb/v6m.c, the separate instruction selection the plan recommends, sharing AAPCS32, the frame and the allocator setup withcodegen.cthroughsrc/arch/thumb/cg.h. backends.md describes it. Every finished function is decoded byv6_scan, which refuses one containing anything but ARMv6-M instructions. Inline asm operands are values in registers and-O0keeps its temporaries in registers, both as on ARMv7-M. - Runtime:
lib/rt/armv6m.c(the__aeabi_*routines and the__atomic_*calls, all weak);make rt-embeddedandmake libc-embeddedbuildlibrt.aandlibc.afor the triple. - Tests:
thumbv6m-target.sh,thumbv6m-asm-values.sh, andthumb-v6m-exec.sh, which runs thetests/execcorpus at four optimisation levels on QEMU's micro:bit (tests/harness/thumb-m0). That test scans every object it builds for ARMv6-M instructions.
Where the implementation departs from the plan:
- Scratch registers are reserved, not borrowed. r6 and r7 are the
lowering's two scratch registers and the allocator gets r0-r5. A third
register comes from
tmp_get, which pushes one the instruction does not touch. Borrowing (lo_free plus parking in r8-r12) would give the allocator all eight; it is the first code-quality step left. - Pool points, not islands per IR instruction. A long struct copy can
outrun a literal's reach inside one instruction, so the pool may also
go at each word of a copy and before a switch table. The decisions are
replayed by the points' order (
pool_point). - Unaligned accesses are fixed in the ARMv6-M lowering, not in the
IR.
IR_MEMCPY/IR_MEMZEROstill carry no alignment; ARMv6-M copies of up to 8 bytes use words only between frame slots and bytes otherwise, and longer ones call__aeabi_memcpy/__aeabi_memclr, which check alignment at run time. Two layout rules apply to ARMv6-M only, so that the other targets' objects are unchanged: wide string literals are aligned to their element (target_string_align), and static arrays of four bytes or more to a word (target_object_align, as GCC's ARM port does). - Atomics are library calls, as recommended.
Left to do: borrowing for a full allocation pool, the code-size work of
step 10 (the branch-free set_cc idioms, CMN/CMP fusions, LDM/STM block
copies, shifted-index addressing), tail calls through bx, the optimizer
gates of step 9, the inline assembler's ARMv6-M vocabulary, embas
literal pools for ARMv6-M, and EmbLD's Tag_CPU_arch check.
What ARMv6-M takes away#
ARMv6-M is the 16-bit Thumb instruction set plus six 32-bit instructions: BL, MRS, MSR, DMB, DSB and ISB. Any other 32-bit encoding is UNDEFINED and raises a HardFault. Most data processing works on r0-r7 only and always sets the flags. The exceptions are MOV, ADD and CMP with a high register.
| ARMv7-M feature | Used by the backend in | ARMv6-M |
|---|---|---|
| 32-bit encodings in general | every encoder that falls back to hw2() |
BL, MRS, MSR, DMB, DSB, ISB only |
| IT blocks | set_cc → t_setcc_low |
none (the encoding is 16-bit but absent) |
| CBZ/CBNZ | IR_BRZ/IR_BRNZ → t_cbz |
none (16-bit but absent) |
| MOVW/MOVT | t_mov_imm, t_mov_addr (every address) |
none |
| Modified immediates, ADDW/SUBW | t_alu_imm, t_cmp_imm, t_addw, t_subw, logic_half |
imm3/imm8 on ADDS/SUBS/MOVS/CMP only |
| Shifted register operand | t_alu_reg_shift (shift fusion, mul by 2^k±1, shift64_imm_to) |
none |
| SDIV/UDIV | IR_DIV/IR_MOD → t_div |
none |
| MLA/MLS/UMULL/SMULL | mla fusion, IR_MOD, 64-bit IR_MUL |
MULS (32×32→32) only |
| UBFX/SBFX, CLZ, RBIT | t_bfx (masks, exponent fields) |
none |
| LDRD/STRD | rd64/wr64, IR_MEMCPY, 64-bit loads |
none |
| Negative, 12-bit and write-back offsets | t_ldst_imm T3/T4, t_ldst_wb |
imm5 (scaled), sp + imm8×4 |
| Sign-extending loads with an immediate | t_ldst_imm with sign |
LDRSB/LDRSH register offset only |
| Byte and halfword access from sp | IR_LDVAR/IR_STVAR of narrow locals |
none |
| Register offset with a shift, or a high base | t_ldst_reg |
[Rn, Rm], low registers only |
| TBH, ADR.W | IR_SWITCH |
ADR T1 (forward 0..1020) |
| LDREX/STREX/CLREX | thumb_atomic |
none |
| B.W, B<c>.W | t_b, t_bcond (relaxation pass 0, IR_SELECT, shift64_var, t_copy_block, tail calls) |
B ±2 KB, B<c> −256..+254, BL ±16 MB |
| PUSH/POP of r8-r12, POP into lr | the prologue (r9-r11 saved), the variadic epilogue | r0-r7 + lr (push), r0-r7 + pc (pop) |
| Low-register ALU without flags | every s = 0 call |
none |
| SUB sp, sp, Rm | IR_ALLOCA |
ADD sp, Rm only |
| Unaligned LDR/STR/LDRH | word copies (t_copy_block, IR_MEMCPY, struct arguments) |
faults |
| FPU | fp_on_vfp, the VFP encoders |
none |
The encoders#
What emits 32-bit encodings today#
Every function in emit.c that calls hw2(), movw() or code_patch32(),
and what ARMv6-M code must do instead:
emit.c function |
32-bit form it can emit | ARMv6-M replacement |
|---|---|---|
t_movs_reg |
MOVS.W with a high register | t1_movs_reg (low); t_mov_reg when the flags must survive |
t_movs_imm, t_mov_imm, t_mov_imm_dead_flags |
MOV.W, MOVW, MOVW+MOVT | the constant chooser (Constants) |
movw, t_movw_movt, t_mov_addr |
MOVW/MOVT (RK_THM_MOVW/RK_THM_MOVT) |
t_ldr_lit16 + t1_patch_ldr_lit from a literal pool word (RK_ABS32) |
t_mvn_reg |
MVN.W whenever s = 0 or a register is high |
t1_mvns |
t_alu_reg |
the wide form whenever s = 0, a register is high, or a two-operand op has rd ≠ rn |
t1_addsub_reg, t1_alu_reg (copy rn into rd first) |
t_alu_reg_shift |
every call | t1_shift_imm into a scratch, then t1_alu_reg/t1_addsub_reg |
t_bfx |
UBFX/SBFX | lsls then lsrs/asrs; uxtb/uxth for an 8/16-bit field at bit 0 |
t_alu_imm |
modified immediates, RSB #0 without flags | t1_addsub_imm3/t1_addsub_imm8; t1_negs; else a constant in a scratch |
addsubw, t_addw, t_subw |
ADDW/SUBW | t1_addsub_imm8 (twice to ±510), t1_add_sp_imm, else a constant and t1_addsub_reg |
t_shift_imm, t_shift_reg |
the wide form for s = 0, high registers, ROR, rd ≠ rn |
t1_shift_imm, t1_shift_reg (ROR by register only) |
t_mul |
MUL.W unless rd = rm and both low | t1_muls |
t_mla, t_mls, t_mull, t_div |
MLA, MLS, UMULL/SMULL, SDIV/UDIV | t1_muls + t1_addsub_reg; helper calls (Helpers) |
t_cmp_imm |
CMP.W with a modified immediate or high register | t1_cmp_imm 0..255; adds into a scratch for −255..−1 (CMN has no immediate); else t1_cmp_reg |
t_tst_reg, t_tst_imm |
TST.W | t1_tst |
t_ext, t_rev, t_rev16 |
the wide form for high registers | t1_ext, t1_rev, t1_rev16, t1_revsh |
t_clz, t_rbit |
CLZ, RBIT | not used by codegen.c (irgen lowers the bit builtins to SWAR) |
t_ldst_imm |
LDR.W imm12, the T4 ±255 form, every signed form | t1_ldst_imm, t1_ldst_sp; signed: t1_ldst_reg or load + t1_ext |
t_ldst_pair, t_ldst_wb |
LDRD/STRD, write-back | two t1_ldst_imm, or t1_ldm_stm |
t_ldst_reg |
a shift, or a high register | t1_ldst_reg after t1_shift_imm of the index |
t_add_sp |
ADDW, MOVW + ADD | t1_add_sp_imm ≤ 1020; else a constant and t1_add_hi(rd, sp) |
t_sp_adjust |
SUBW/ADDW, MOVW + ADD via r12 | t1_sp_adjust ≤ 508, repeated, or a constant and t1_add_hi(sp, rX) |
t_push, t_pop |
PUSH.W/POP.W for high registers or POP lr | t1_push, t1_pop; high registers through low ones |
t_ldm_stm |
LDM.W/STM.W | t1_ldm_stm |
t_b, t_bcond, t_patch_b, t_patch_bcond |
B.W, B<c>.W | t_b16, t_bcond16, t_bl (Branches) |
t_adr_w, t_patch_adr_w, t_tbh |
ADR.W, TBH | t1_adr, t1_patch_adr, add pc tables (Switch) |
t_ldrex, t_strex, t_ldrexbh, t_strexbh, t_clrex |
the exclusives | none (Atomics) |
t_ldr_lit, t_ldr_const |
LDR.W literal, MOV.W/MVN.W/MOVW | t_ldr_lit16 |
t_vldm_vstm, vfp and every t_v* |
VFP | none: -mfpu is refused for ARMv6-M |
Three 16-bit encoders are also not ARMv6-M, and a grep for hw2( does not
find them: t_it, t_setcc_low (an IT block) and t_cbz/t_patch_cbz.
t_patch_pop only handles the 32-bit POP; codegen.c does not call it.
Already ARMv6-M#
These emit only ARMv6-M encodings for every operand, so ARMv6-M code uses
them as they are: t_mov_reg, t_bx, t_blx, t_nop, t_hint, t_svc,
t_bkpt, t_cps (i only), t_ldr_lit16, t_bcond16, t_b16,
t_patch_bcond16, t_patch_b16, t_patch_push (16-bit form), and the
32-bit t_bl, t_patch_bl, t_mrs, t_msr (without BASEPRI and
FAULTMASK) and t_barrier. IR_UD2 (udf #0, 0xde00) and IR_FENCE
(dmb sy) write ARMv6-M bytes directly.
The t1_* encoders#
Declared in emit.h under "ARMv6-M: the Thumb-1 forms". Each one emits
one 16-bit instruction or nothing:
- a register the form cannot name stops with an internal error, because that is a bug in the caller;
- an immediate or offset outside the field makes the
int-returning ones answer 0 (-1fort1_push/t1_pop) and write nothing, ast_ldst_immandt_ldr_lit16do.
| Function | Instruction |
|---|---|
t1_movs_imm, t1_movs_reg |
MOVS Rd, #imm8; MOVS Rd, Rm |
t1_addsub_reg, t1_addsub_imm3, t1_addsub_imm8 |
ADDS/SUBS Rd, Rn, Rm; Rd, Rn, #0..7; Rdn, #0..255 |
t1_alu_reg |
ANDS/EORS/ADCS/SBCS/ORRS/BICS Rdn, Rm |
t1_shift_reg, t1_shift_imm |
LSLS/LSRS/ASRS/RORS Rdn, Rm; LSLS #0..31, LSRS/ASRS #1..32 |
t1_cmp_reg, t1_cmp_imm, t1_cmn, t1_tst |
CMP (T1 or T2), CMP #imm8, CMN, TST |
t1_negs, t1_mvns, t1_muls |
RSBS Rd, Rn, #0; MVNS; MULS Rdm, Rn, Rdm |
t1_add_hi |
ADD Rdn, Rm with any registers, sp and pc included; no flags |
t1_ext, t1_rev, t1_rev16, t1_revsh |
SXTB/SXTH/UXTB/UXTH, REV, REV16, REVSH |
t1_ldst_imm, t1_ldst_reg, t1_ldst_sp |
[Rn, #imm5×size]; [Rn, Rm] incl. LDRSB/LDRSH; [sp, #imm8×4] |
t1_add_sp_imm, t1_sp_adjust |
ADD Rd, sp, #imm8×4; ADD/SUB sp, #imm7×4 |
t1_adr, t1_patch_adr, t1_patch_ldr_lit |
ADR Rd, #imm8×4, and re-aiming an ADR or a literal LDR at a code offset |
t1_ldm_stm, t1_push, t1_pop |
LDMIA/STMIA Rn{!}; PUSH/POP of r0-r7 + lr/pc |
t1_udf |
UDF #imm8 |
Where an ARMv7-M encoder already writes the form for every operand the
t1_* function accepts, the t1_* function checks its operands and calls
it (for example t1_ldst_reg → t_ldst_reg → narrow_ldst_reg), so each
encoding is written once. The new encodings are ADDS/SUBS #imm8 (T2),
RORS, CMN, RSBS #0, the high-register ADD, REVSH, ADR, LDMIA/STMIA, UDF and
the two pc-relative patchers.
tools/t1check/t1check.c sweeps every t1_* function and every encoder
in the list above across its operands: every register in every field,
every immediate and offset, every condition, every LDM/STM and PUSH/POP
list, and BL's offset bit by bit. That is 60,728 instructions from 47
encoders. tests/golden/thumb-v6m-encoding.sh assembles the listing with
llvm-mc -triple=thumbv6m-none-eabi -mcpu=cortex-m0 and compares the
bytes. The test also fails when:
- an encoder writes a size other than its form's (a widened form, or nothing);
- llvm-mc rejects or warns about a line, which means the form is not ARMv6-M;
- a
t1_*function declared inemit.his missing from the sweep; t1check --refusefinds an out-of-field operand that was encoded anyway (63 cases).
Encoder work still to do#
- A wide-form scan. A function
t1_first_wide(const struct code *, int from, int to)decodes halfwords (bits 15:11 of11101,11110or11111start a 32-bit instruction), skips the data ranges inc->drange, and returns the offset of the first 32-bit instruction that is not BL, MRS, MSR, DMB, DSB or ISB. Run it after every ARMv6-M function and refuse the function by name on a hit. A stray ARMv7-M encoder call is otherwise silent until the Cortex-M0 faults. - The inline assembler.
asm.ccalls the ARMv7-M encoders, sotasm_set_arch(6)must restrict the vocabulary.gas.c'sldr_literalusest_ldr_const(MOV.W/MVN.W/MOVW) and the 32-bitt_ldr_lit; for ARMv6-M it must always place a literal and uset_ldr_lit16.
One backend or two#
Recommendation: one backend. Keep the ABI, frame, allocator interface
and pass loop in codegen.c, shared and selected by a mode flag. Write the
ARMv6-M instruction selector as a separate file. The reasons, from the
code:
- AAPCS32 is the same on both, and the code insists on one copy.
place_arg,place_one,walk_init,outgoing_area,t_abi_hints,t_pair_hints, the prologue's parameter placement and the argument setup inIR_CALLall say that "no second copy of AAPCS32 is stated here". A separate generator would copy about 400 lines of ABI code, which the comments in these functions warn against. - Frame, allocator and relaxation carry over.
layoutalready orders slots for the 1020-byte reach ofldr rt, [sp, #imm], which is ARMv6-M's reach too.wide64_map,frame_addr_map,t_pair_alloc,gen_func_bestand the pass loop ingen_func(scratch-save discovery, branch shortening, theno_tbhrestart,far_mode) are the structure ARMv6-M needs, with different branch forms. Together that is about 2,100 lines. - Instruction selection is Thumb-2 in almost every case.
gen_ins(about 1,450 lines),gen_ins64,cmp64,shift64_*,logic_half,set_cc,thumb_atomicandIR_SWITCHuse IT, CBZ, TBH, ADR.W, UBFX, MLA/MLS, SDIV, UMULL, LDRD, shifted operands, modified immediates or ADDW. Addingif (v6m)to each case would write every case twice in one function. Every one of the 364 direct encoder calls incodegen.cwould then be a place where a wide form could slip into ARMv6-M output. - The register model is the other way round.
rdr,wreg,LO()andlo_freetreat high scratch registers as the rule and low ones as an optimisation. On ARMv6-M a low register is required for every computation (Registers).
Proposed layout:
src/arch/thumb/cg.h(new, internal):struct t_fn,struct t_sites, and the shared helperscodegen.ckeeps static today (rd,wr,rd64,wr64,slot_of,faddr,want_label,jump_to,jump_if,call_helper,note_*,place_one).src/arch/thumb/codegen.c: the driver, ABI, frame and allocator. A flagg_t_v6m(fromtarget_thumb_arch() == 6) selects the prologue, epilogue and frame-access forms, and callsgen_insorgen_ins_v6m.src/arch/thumb/isel_v6m.c(new):gen_ins_v6m,gen_ins64_v6m, the literal pool, the constant chooser,set_cc_v6m, the switch lowering and the atomics. It includes a header that declares only thet1_*encoders and the ARMv6-M set above, so an ARMv7-M encoder call there fails to compile.
Each new src/ file must be added to SRCS and EMBLS_SRCS in the
Makefile, and build.ebm regenerated in the same commit.
Registers#
Scratch roles#
| Role today | Register | Why it fails on ARMv6-M | ARMv6-M |
|---|---|---|---|
T_ACC |
r12 | nothing but MOV/ADD/CMP/BX can use it | low scratch S0 |
T_TMP, A_HI |
r11 | as above | low scratch S1 |
T_ADDR, B_LO |
r10 | as above; also a load/store base | low scratch S2 |
T_SCR, B_HI |
r9 | as above | low scratch S3 |
A_LO |
r12 | as above | S0 |
R_SCR (cycle breaker for ra_parallel_move) |
r9 | MOV works with any register, so it would still work | r12: caller-saved, never allocated, no push needed |
| indirect call target | r12 (rd(F, i->a, T_ACC); t_blx) |
a load into r12 is 32-bit | load into a low scratch before the argument registers are filled, mov r12, rX, then blx r12 |
The high registers keep one job: parking. r12 first, then r8-r11, hold a low register's value while that register serves as a scratch.
Where the low scratch comes from#
Reserving two to four of r0-r7 would take a quarter to half of the
registers that compute. Instead, generalise the existing lo_free
mechanism:
- Per IR instruction, the selector asks for up to k low scratch
registers (k ≤ 4).
lo_free(F, n)already gives the low registers that hold nothing live into or out of instructions n and n+1 (r0-r3 always, r4-r7 once the prologue saves them anyway). Extendlo_op_ok, which today excludes calls, helpers, asm, atomics and every 64-bit path, to every op. At-O0(no allocator) every low register except the frame base is free between instructions. - When fewer than k are free, borrow: take a low register that
instruction n neither reads nor writes,
mov rP, rLit into a parking register, andmov rL, rPit back after the instruction. Record the parking register throught_scr(), as r9-r11 are recorded today, so the prologue saves r8-r11 only when a pass used them. MOV does not touch the flags, so the restore can sit between a compare and its branch.jump_ifmust emit pending restores before the branch. - Rewrite the 64-bit slot paths to need two scratch registers, not four.
Process the words one at a time: the carry and borrow survive LDR,
STR,
add rd, sp, #immand MOV betweenaddsandadcs.
Calls, helpers and atomics place values in fixed registers. They take
scratch registers explicitly: stack arguments are stored before r0-r3 are
loaded, as IR_CALL already orders it, and an indirect target goes
through r12.
Allocation pool#
Pool today (t_pool_base) |
ARMv6-M |
|---|---|
T_POOL r0-r8 |
r0-r7: a value in r8 can only be moved |
T_POOL_VA r4-r8 |
r4-r7 |
T_POOL_FB r0-r6, r8 |
r0-r6 |
T_POOL_VA_FB r4-r6, r8 |
r4-r6 |
T_PAIRS r0:r1, r2:r3, r4:r5, r6:r7 |
unchanged (all low) |
t_callee_saved is unchanged. r8-r11 are never allocated. Using them as
cheap spill homes is a later optimisation.
Prologue and epilogue#
save_mask_fortoday ORs in r9-r11 and pads with r3. On ARMv6-M the mask is r4-r7, lr, the r3 pad, and enough extra low registers to stage any parked high registers. The full sequence ispush {r4-r7, lr}; mov r4, r8; …; push {r4-…}, and the reverse on exit.t_patch_pushalready handles the 16-bit form. The pass loop'sscr_savedecides which high registers are saved, as it does for r9-r11 today.- The frame:
t1_sp_adjustreaches 508 bytes. Use it twice up to 1016, and above thatldr rX, =-framewithadd sp, rX(t1_add_hi). rX must be a callee-saved low register that was just pushed, because r0-r3 still hold the arguments. - The variadic epilogue pops lr (
t_pop(t, mask)thenbx lr), which the 16-bit POP cannot encode. Usepop {r4-…},pop {r3},add sp, #16,bx r3. r3 is not a return register. IR_ALLOCAusessub sp, sp, rX, which ARMv6-M lacks. Uset1_negs+add sp, rX.- The leaf path (
nopush,bx lr) carries over unchanged, but a far branch through BL clobbers lr (Branches).
Flags#
Every low-register data-processing instruction sets N and Z, and most set
C and V. The ARMv7-M backend relies on one invariant, stated at
t_mov_imm_dead_flags: no flag value survives from one IR instruction
to the next. That invariant is what makes an ARMv6-M selector practical:
flags only need to be preserved inside a single lowering. These lowerings
compute between a flag-setting instruction and the instruction that reads
its result:
set_ccwrites 1 and 0 with flag-free MOVW. On ARMv6-M, build the 1 before the compare, into a register that is not an operand, then branch overmovs d, #0. Better are the branch-free idioms, one per condition. For example,a == bissubs d, a, b; rsbs t, d, #0; adcs d, t, anda <u biscmp a, b; sbcs d, d; negs d, d.IR_SELECTandgen_ins64'sIR_SELECTload an arm after the compare. Load both arms first, or compare last.- The 64-bit carry chains in
gen_ins64andcmp64. Only LDR, STR, MOV,add rd, sp, #imm, ADR and the sp adjustments may sit betweenaddsandadcs. MOVS keeps C and V but changes N and Z. shift64_var,t_copy_blockandthumb_atomicloop on a compare.
Flag-free ways to put a value in a register: MOV from a register, LDR
(including a literal), ADR, and add rd, sp, #imm.
Constants and addresses: literal pools#
Constants#
t_mov_imm today is MOVS, MOVW, MOV.W or MOVW+MOVT. The ARMv6-M chooser,
for a destination in r0-r7 with the flags dead:
| Value | Sequence | Bytes |
|---|---|---|
| 0..255 | movs |
2 |
| ~v in 0..255 | movs, mvns |
4 |
| byte << k | movs, lsls |
4 |
| 256..510 | movs #255, adds |
4 |
| −255..−1 (flags dead) | movs, negs |
4 |
| anything else, or the flags live | ldr rd, [pc, #off] + a pool word |
2 + 4 |
The same chooser serves IR_CONST, operand_b, operand_b64,
logic_half, IR_SWITCH's bound and the frame-offset fallbacks in rd,
wr, fb_addr, t_add_sp and t_sp_adjust.
Symbol addresses#
IR_GADDR, IR_STRADDR and IR_FADDR call t_mov_addr and record two
sites (RK_THM_MOVW/RK_THM_MOVT) with note_glob, note_str and
note_fn. On ARMv6-M each becomes a pool word with one site of kind
RK_ABS32 at the word's offset, recorded by the same note_* functions.
The rest of the chain already handles this kind:
- the driver writes text relocations with
code_relaandtarget_reloc_type, which mapsRK_ABS32toR_ARM_ABS32; - a string's offset travels in the RELA addend (
target_reloc_addendreturns the bias unchanged for Thumb); - EmbLD applies
R_ARM_ABS32asS + A, and S already carries the Thumb bit of a function symbol (link.c); asmout.cnamesRK_ABS32asR_ARM_ABS32for-S.
Pools and islands#
LDR (literal) T1 loads from Align(pc, 4) + imm8×4: forward only, at most
1020 bytes, from a word-aligned entry. The design:
- Per function, a pending pool of entries: a constant, or (kind, symbol,
addend). Identical entries share one word. A load emits
t_ldr_lit16(t, rd, 0)and records (offset, entry). - Dumping a pool: if code continues after the pool, emit
t_b16over it. Pad to four bytes witht_nop(0xbf00, valid on ARMv6-M), mark the words withcode_mark_data(for the$d/$tmapping symbols), emit each word (zero plus a site for a symbol), and patch every load witht1_patch_ldr_lit. A patch that fails is an internal error, never a dropped load. - Placement: track the earliest deadline,
Align(at + 4, 4) + 1020over the pending loads, less the pool's size. Between IR instructions, dump when the code could pass the deadline within the largest output one IR instruction can produce. That bound has to be enforced: straight-lineIR_MEMCPYmust switch to its loop below a fixed size. Prefer a natural barrier (after an unconditionalb, the return jump, abx), which needs no branch around the pool. Otherwise dump at the deadline with a branch. The function's last pool follows the epilogue. - With the pass loop: pass 0 decides where the islands go and records
them by IR index. Later passes put the pools at the same indexes. Code
between a load and its pool only shrinks, so every load still reaches.
A pool's alignment pad can grow by two bytes when the parity before it
changes. Branch measurements that cross a pool therefore need two bytes
of slack per pool. Without the slack, the "relaxed branch no longer
reaches" internal error in
gen_funccan fire. - This relies on
.textbeing four-aligned in the object, as the word jump table already does (thet->len % 4nop inIR_SWITCH).
src/as/gas.c has a working literal-pool implementation for assembly
files (pool_entry, pool_dump, ldr_literal). It shows the
deduplication and the $d marking. The compiler's pool must place its own
islands, because .ltorg is the programmer's job in an assembly file.
Frame access#
- Words:
t1_ldst_spreaches 1020 bytes, as the 16-bit formlayoutalready favours. Up to 1144:add rX, sp, #1020, then[rX, #off]. Beyond: a constant,add rX, sp(t1_add_hi),[rX].rdtoday falls back tot_mov_imm+t_ldst_reg(…, F->fb, …), an[sp, rX]access ARMv6-M does not have;wrfalls back to MOVW and a wide ADD. - Bytes and halfwords: there is no sp-relative LDRB/LDRH/STRB/STRH.
IR_LDVAR/IR_STVARof a narrow local needadd rX, sp, #offand then[rX, #0]. A non-address-taken narrow scalar can be kept in a word slot and accessed as a word, extended at the store. - Signed narrow loads: LDRSB/LDRSH take only
[Rn, Rm]. Useldrb/ldrht1_ext, ormovs rX, #off+t1_ldst_reg.
- A VLA function's frame base r7 reaches only 124 bytes with
ldr rt, [r7, #imm5×4]. Beyond that it costs two instructions per access (movs/literal + register offset). fb_addr's r7 path usest_addw. On ARMv6-M useadds(≤ 255 into the same register) or a constant +t1_addsub_reg.
Branches and relaxation#
ARMv6-M branch forms:
| Kind | Short | Medium | Far |
|---|---|---|---|
| unconditional | b ±2 KB (2 bytes) |
— | bl ±16 MB (4 bytes, clobbers lr) |
| conditional | b<c> −256..+254 (2) |
b<!c> .+2; b label ±2 KB (4) |
b<!c> .+4; bl label (6) |
Map the existing machinery onto these forms:
- Pass 0 emits the medium form for every conditional branch and the short
form for every unconditional one. It records each branch's size class
by ordinal, as
shortbdoes today. If pass 0 finds a target beyond ±2 KB, it reruns in far mode, as the existingfar_modedoes for B<c>.W's ±1 MB. Far mode forces lr into the push mask. - Shortening: the medium form shrinks to
b<c>when the target is in reach. Because medium is only 2 bytes larger, positions converge fast. Repeat measure-and-shrink while anything shrinks, beyond today's three passes. Each pass only shrinks code (pools aside, see above). invert_last_bcondmust re-emit at the same size class, as it already keeps the ordinal. CBZ does not exist:IR_BRZ/IR_BRNZarecmp r, #0+b<c>(ort1_movs_reg/lsls #0when that sets Z for free).- Internal branches that call
t_bcondandt_bdirectly (IR_SELECT,shift64_var,t_copy_block) span a few instructions and becomet_bcond16andt_b16.
Switch lowering#
IR_SWITCH today is cmp; bhs default followed by tbh with a halfword
table, or adr.w; ldr.w [.., lsl #2]; add; bx with a word table (T_TBH
and T_TAB fixups). Without TBB/TBH, the compact form is clang's for
thumbv6m:
cmp rI, #n @ t1_cmp_imm, or a constant and t1_cmp_reg
bhs default @ relaxed like any branch
adr rB, table @ t1_adr, patched by t1_patch_adr
ldrb rT, [rB, rI] @ t1_ldst_reg (halfword table: lsls rT, rI, #1; ldrh)
lsls rT, rT, #1
add pc, rT @ t1_add_hi(15, rT): pc reads as this + 4
.p2align 2
table: .byte (case - (add + 4)) / 2, ...
add pc writes pc without interworking (BranchWritePC), so the entries
are plain halfword offsets. A new fixup kind beside T_TBH fills them.
When an entry does not fit (a case behind the dispatch, or too far), the
no_tbh restart switches that switch to the word table:
lsls rT, rI, #2; adr rB, table; ldr rT, [rB, rT]; adds rT, rB; bx rT,
with the existing T_TAB entries ((target | 1) - table). This needs
two low scratch registers besides rI. rI may still be live in the case
blocks, so it is not reused as a scratch.
Multiplication, division and 64-bit integers#
| Operation | ARMv7-M today | ARMv6-M | Helper | In lib/rt? |
|---|---|---|---|---|
32-bit / |
sdiv/udiv |
call | __aeabi_idiv, __aeabi_uidiv |
no |
32-bit %, and / paired with % |
udiv + mls |
call, one for both | __aeabi_idivmod, __aeabi_uidivmod (quotient r0, remainder r1) |
no |
32-bit * |
mul/muls |
muls |
— | — |
64-bit * |
umull + mla |
call | __aeabi_lmul |
no (__muldi3 exists only in avr64.c, under __AVR__) |
| 64-bit shift by a variable | branch + 5 shifts, 4 scratch | call | __aeabi_llsl, __aeabi_llsr, __aeabi_lasr |
no |
| 64-bit shift by a constant | shift64_imm_to (shifted orr) |
lsls/lsrs/orrs, 3 registers |
— | — |
64-bit /, % |
__divdi3 etc. |
unchanged | __divdi3, __udivdi3, __moddi3, __umoddi3 |
yes (int64.c) |
| 64-bit add, sub, logic, neg, compare | inline | inline (adds/adcs, subs/sbcs, rsbs/sbcs) |
— | — |
| float, double | libgcc names | unchanged | __addsf3, __adddf3, … |
yes (softfp.c) |
| clz, ctz, popcount | irgen's SWAR sequence | unchanged (muls) |
— | — |
The 32-bit and 64-bit-multiply helpers use the RTABI names, which clang
calls for thumbv6m-none-eabi. __aeabi_idivmod returns both results
from one call, which no libgcc-named routine does. libgcc for ARM exports
the __aeabi_* names as well, so objects still link beside it. lib/rt
needs a new file, guarded by __ARM_ARCH_6M__, defining these helpers and
__aeabi_idiv0. Follow the rule in lib/rt/README.md: the shifts are
written on 32-bit halves, and division must not divide. Add
thumbv6m-none-eabi to the rt-embedded triples in the Makefile.
Optimizer passes that assume ARMv7-M instructions must be gated on the architecture level. They do not produce wrong code, but they cost size:
- the divide/remainder pairing in
opt.c(kokassumesudiv; mls; on ARMv6-M the pair becomes one__aeabi_idivmod); mla_keeps_reg(there is no MLA);target_mul_shift_addand theIR_MULcase ofthumb_imm_foldable(there is no shifted operand);thumb_imm_foldableandthumb_imm_foldable64inthumb/irgen.c, andconst_is_expensiveinopt.c, which uset_imm_okand 0..0xffff: the ARMv7-M immediates, not ARMv6-M's 0..255.
Atomics#
thumb_atomic is a dmb; ldrex; op; strex; cmp; bne; dmb loop. ARMv6-M
has no exclusives. Aligned loads and stores of up to four bytes are still
single-copy atomic. That covers plain atomic loads and stores (with dmb)
and IR_FENCE. For the read-modify-writes and compare-and-swap there are
two options:
- Library calls (
__atomic_fetch_add_4,__atomic_compare_exchange_4, …), which is what clang emits forthumbv6m. Objects interoperate, and the policy lives in one replaceable place.lib/rtcan provide PRIMASK-based versions. - Inline interrupt masking:
mrs r12, primask; cpsid i; ldr; op; str; msr primask, r12. MRS and MSR take r12, so no low register is spent saving PRIMASK. This is correct only on a single core with no other bus master, and only in privileged code. CPS is ignored in unprivileged Thread mode, so on a part with the unprivileged extension the sequence is silently not atomic.
Recommendation: library calls by default, with the predefined
__GCC_ATOMIC_*_LOCK_FREE values set to 1 as clang sets them for this
triple. Inline masking can follow as an opt-in for privileged-only
firmware. An RTOS on EmbCC will run tasks unprivileged, which is the case
where inline masking fails. 8-byte atomics also become calls
(__atomic_*_8) rather than the refusal they are today.
Alignment#
ARMv6-M faults on any unaligned LDR, STR, LDRH or STRH. The ARMv7-M
backend writes Tag_CPU_unaligned_access = 1 (attrs.c) and depends on
it in these places:
IR_MEMCPY/IR_MEMZEROcopy by words (straight-line, ort_copy_block), but the IR carries no alignment for them. A struct ofchararrays with alignment 1 is copied a word at a time.IR_CALLcopies a struct argument to the stack and into registers with word loads from the struct's address.IR_RETreads a three-byte composite as a word ("at least four-byte aligned"). This is true only if every such object is.IR_LOAD/IR_STOREwithnatural= 0 (a packed member) use an ordinary LDR/STR.
The fix: give IR_MEMCPY/IR_MEMZERO an alignment field (every
hand-built one must set it; default to 1, the safe value). Make the
ARMv6-M copies and struct-argument moves use the widest access the
alignment allows. Split every non-natural access into bytes. Write
Tag_CPU_unaligned_access = 0.
Calls#
bl(t_bl,RK_CALL→R_ARM_THM_CALL) andblx rXare ARMv6-M.call_helperneeds no change.- Tail calls are
t_b(B.W) withR_ARM_THM_JUMP24. ARMv6-M has no B.W, and the 16-bit B reaches ±2 KB with no call relocation, sot_tail_okreturns 0 for ARMv6-M. IR_ASM's scratch pool is{ 12, 0, 1, 2, 3 }, and its operand load/store goes throughldst_must(…, scr, …). With r12 as the base that is a 32-bit form. Use{ 0, 1, 2, 3 }on ARMv6-M.
Target plumbing#
src/arch/target.c: triplesthumbv6m-none-eabiandthumbv6m;g_thumb_arch= 6. Refuse-mfpu=and-mfloat-abi=hard(arm_float_resolvein the driver).src/arch/thumb/attrs.c:Tag_CPU_arch= 12 (v6S-M, what clang writes forthumbv6m),Tag_THUMB_ISA_use= 1,Tag_CPU_unaligned_access= 0, no FP tags.- Predefined macros:
src/arch/thumbv6m/predef.candpredef_cxx.c, generated bytools/gen-predef.shfrom clang asthumbv8m's are, then selected insrc/arch/predef.c. Againstthumbv7m, clang has__ARM_ARCH6,__ARM_ARCH_6M__,__ARM_ARCH_ISA_THUMB1,__ARM_FEATURE_COPROC0, the atomic lock-free values 1, no__thumb2__, and none of__ARM_ARCH_EXT_IDIV__,__ARM_FEATURE_IDIV,_CLZ,_LDREX,_QBIT,_SAT,_UNALIGNEDor__GCC_HAVE_SYNC_COMPARE_AND_SWAP_*. - EmbLD reads
Tag_CPU_archintoarm_arch(arm_attrs_scan), butarm_attrs_checknever compares it. An ARMv6-M image that links an ARMv7-Mlibrt.aorlibc.alinks without complaint and faults on the first Thumb-2 instruction. The linker should at least warn when it mixes v6-M/v6S-M objects with later ones.
Testing#
- Encoders:
thumb-v6m-encoding.sh(done). - A board: QEMU's
microbitmachine is a Cortex-M0 (nRF51: flash at 0, 16 KB of RAM at 0x20000000, UART0 at 0x40002000). Addtests/harness/thumb-m0/(boot, UART, link script) besidetests/harness/thumb/, and useqrun.sh --untilwith a sentinel. With 16 KB of RAM, the large exec tests also run on the existing Cortex-M3 board. ARMv6-M code is valid ARMv7-M. The wide-form scan replaces the undefined-instruction trap the M3 does not take. SettingCCR.UNALIGN_TRPin the M3 harness's boot code would supply the unaligned-access fault, if QEMU models that bit; check before relying on it. - Execution: a
thumb-v6m-exec.shon the pattern ofthumb-exec.sh, at every-Olevel, against the host and clang (clang -target thumbv6m-none-eabi). - Stress:
EMBCC_RA_MAXPOOLruns, which force borrowing and parking; the random-program fuzzer on the new board; the ABI fuzzer against clang'sthumbv6m(the ABI is AAPCS32 unchanged).
Order of implementation#
- Encoders and their referee. Done: the
t1_*functions,tools/t1check,thumb-v6m-encoding.sh. Steps 2-7 are done too (see Status); 8 is done with r0-r5 as the pool. - Target plumbing. Triple,
g_thumb_arch= 6, attributes, predefined macros,-mfpurefusal, and the EmbLD architecture check. Codegen refuses every function by name ("the ARMv6-M backend cannot lower …"). - The skeleton at
-O0. Thecg.hsplit,isel_v6m.c, the wide-form scan, prologue and epilogue, frame access, the constant chooser and literal pools, calls and returns, branches with the new relaxation, andIR_CONST/IR_MOV/ALU/IR_LOAD/IR_STORE/IR_CMP/IR_BRZ/IR_JMP. First image on themicrobitboard. lib/rtfor ARMv6-M. The__aeabi_*helpers and their test against the host, asrt.shdoes;build-rt.sh thumbv6m-none-eabi.- The rest of the 32-bit IR: division and remainder through the
helpers, shifts, extension, byte swap,
set_cc,IR_SELECT,IR_SWITCH,IR_MEMCPY/IR_MEMZEROwith the new alignment field, varargs,IR_ALLOCA, inline asm. - 64-bit integers and soft float:
gen_ins64_v6m, with the helper calls unchanged for float and double. - Atomics through library calls.
- The allocator (
-O1and up): the r0-r7 pool, pairs,lo_freefor every op, borrowing and parking;-O2exec runs andEMBCC_RA_MAXPOOLstress; a fuzz board. - The optimizer's ARMv7-M assumptions, gated on the level.
- Size against clang per function (
clang -fno-inline-functions, symbol sizes fromnm -S), then the branch-freeset_ccidioms, literal sharing,ldm/stmblock copies and the other code-size work.
Key risks#
- Silent widening. A single ARMv7-M encoder call reaching ARMv6-M output assembles, links and faults only when executed. Mitigations: the separate selector file with a restricted header, the per-function wide-form scan, and running tests on a real Cortex-M0 model.
- Unaligned accesses. The ARMv7-M backend leans on unaligned support
in four places (Alignment), and
IR_MEMCPYhas no alignment to consult. The fix touches the IR, and the M3 board will not catch a miss unlessUNALIGN_TRPworks there. - Literal pools and relaxation together. Forward-only 1020-byte
reach, islands inside long functions, and a pass loop whose
correctness argument is "code only shrinks", which a pool's alignment
pad breaks by two bytes. The pass-0 placement by IR index and the slack
in branch measurement both need tests at the reach limits, as
thumb-relax.shandthumb-far.shtest the branches today. - Flags. Every low-register ALU op clobbers them. Each ARMv7-M
lowering that computes between a compare and the instruction that
reads the flags (
set_cc,IR_SELECT, the 64-bit carry chains,shift64_var,t_copy_block,thumb_atomic) has to be re-sequenced, not translated. - Register pressure. Eight computing registers include the four argument registers. A 64-bit pair takes a quarter of them, and borrowing adds moves under pressure. The VLA frame base r7 reaches only 124 bytes.
- Mixed objects at link time. EmbLD ignores
Tag_CPU_arch, so an ARMv7-M runtime library in an ARMv6-M image goes unnoticed until it runs. - Atomics under an RTOS. Inline PRIMASK masking is not atomic in unprivileged code, which is where RTOS tasks run. This is why library calls are the recommended default.