EmbCCdocs Repository

LoongArch64 (LP64S, bare metal): the plan#

The target is 64-bit little-endian LoongArch, LA64, the LP64S calling convention (soft float): loongarch64-unknown-elf. The board it is tested on is QEMU's virt (qemu-system-loongarch64 -M virt). Every fact below was read off clang 23 (--target=loongarch64-unknown-elf -msoft-float, which is -mabi=lp64s -mfpu=none), llvm-mc or QEMU 11.1 rather than remembered; there is no LoongArch gcc on this machine.

Why the RISC-V backend is the model#

LoongArch's base integer ISA and its LP64 calling convention are, rule for rule, RISC-V's RV64 LP64 ones under different encodings. Read off clang for both triples:

  • a0-a7 carry arguments and a0-a1 results; a scalar of two registers (__int128, long double) is passed in a pair with no alignment rule when named, and in an EVEN-aligned pair when variadic (v(0, x) with an __int128 x skips a1 and uses a2:a3) -- the same asymmetry as RISC-V;
  • a 2*GRLEN scalar with one register left goes half in a7, half on the stack;
  • an aggregate of at most 16 bytes travels packed in up to two registers, splitting across a7 and the stack like anything else; a larger one goes BY REFERENCE to a copy the caller owns;
  • a struct result of at most 16 bytes comes back packed in a0:a1, a larger one through a hidden pointer in a0;
  • a 32-bit value in a 64-bit register is kept SIGN-EXTENDED, unsigned included (f(unsigned) loads its argument with ld.w, not ld.wu), and the .w instructions maintain it on the way out;
  • soft float: a float or double is its bits in a GPR, and every operation is a libgcc-named call (__adddf3, __extendsfdf2, ...);
  • va_list is a bare void *, 8 bytes; the stack is 16-aligned.

So src/arch/loongarch/codegen.c is RV64's code generator (src/arch/riscv/codegen.c) taken as a COPY, with the RV32-only machinery removed (register pairs, 64-bit-at-RV32 lowering, RV32's long double, the C extension) and every instruction re-selected for LoongArch. Sharing the file was considered and not done: the two would have to agree about immediates that are signed on one and unsigned on the other, branch reaches that differ by a factor of 32, two different PC-relative relocation schemes and the compressed-instruction machinery -- each an if in code that the RISC-V goldens and the exec boards prove today. A copy cannot destabilise RISC-V; a shared file with a second machine threaded through it could.

Registers (psABI)#

Register Name Role
r0 zero always 0
r1 ra return address
r2 tp thread pointer (never touched)
r3 sp stack pointer
r4-r11 a0-a7 arguments; a0-a1 results
r12-r20 t0-t8 temporaries (caller-saved)
r21 -- RESERVED by the psABI; EmbCC never names it
r22 fp (s9) frame pointer / callee-saved
r23-r31 s0-s8 callee-saved

EmbCC's use: t0, t1, t2, t4, t5 and t6 are the code generator's scratch (the RV64 backend's t0-t2/t4-t6 roles), so the allocator's pool is a0-a7, t3, t7, t8 (caller-saved first) and fp, s0-s8 (callee-saved) -- 21 registers, as on RV64. A function with a variable-length array addresses its frame from fp.

Data model#

LP64: int 4, long and pointers 8, long long 8, __int128 16; long double is IEEE binary128 (16 bytes, __LDBL_MANT_DIG__ 113), done by lib/rt's soft-float helpers as at RV64. Plain char is SIGNED (clang defines no __CHAR_UNSIGNED__ here, unlike RISC-V); wchar_t is a signed int. An unnamed bit-field does not raise a structure's alignment (struct { char c; int :0; char d; } is 5 bytes, aligned 1). __BIGGEST_ALIGNMENT__ is 16.

Instructions#

Every instruction is one 32-bit little-endian word. The differences from RISC-V that the selection has to respect:

  • Immediates. addi.w/.d, slti, sltui and the loads and stores take a SIGNED 12-bit field; andi, ori and xori take an UNSIGNED one (0..4095). A RISC-V andi rd, rs, -16 or xori rd, rs, -1 has no direct counterpart: not is nor rd, rs, zero, and an AND with a negative constant goes through a register (or bstrpick/bstrins).
  • Constants are built as LLVM's LoongArchMatInt builds them -- ori or addi.w for 12 bits, lu12i.w + ori for 32, then lu32i.d (bits 51:32, sign-extended from 51) and lu52i.d (bits 63:52) only where the sign extension of what is below does not already give them.
  • 32-bit division. div.w, mod.w, div.wu and mod.wu are UNDEFINED when an operand is not the sign extension of its low 32 bits (clang emits addi.w r, r, 0 before them); the code generator reads their operands through rd32 for that reason. add.w, sub.w, mul.w and the .w shifts read only the low 32 bits.
  • Comparisons are slt/sltu/slti/sltui, as RISC-V's; sltui sign-extends its immediate and compares unsigned.
  • Branches compare two registers: beq/bne/blt/bge/bltu/bgeu rj, rd reach +-128 KiB, beqz/bnez rj +-4 MiB, b/bl +-128 MiB -- each 32 times RISC-V's reach. A function is generated with every branch in its short form; one that does not reach is made the inverse branch over a b, and the function generated again until every branch fits.
  • Select has maskeqz/masknez; zero and sign extension have bstrpick.d rd, rj, 31, 0, ext.w.b, ext.w.h and addi.w rd, rj, 0.
  • Unaligned access to ordinary memory is permitted (clang reads p[0] | p[1] << 8 of a char * with one ld.hu); only the atomics require natural alignment.
  • Atomics. amswap_db, amadd_db, amand_db, amor_db, amxor_db at .w and .d (full barrier), ll/sc for the rest; sc writes 1 on SUCCESS (RISC-V's writes 0). An am* instruction's rd may not be its rj or rk. There is no byte or halfword form in the base ISA, so a one- or two-byte atomic is an ll.w/sc.w loop on the word that holds it, changing only its field -- clang's lowering, atomic against the neighbouring bytes because a store to any of them breaks the reservation -- and all four __GCC_HAVE_SYNC_COMPARE_AND_SWAP_N are claimed, as clang claims them. dbar 0 is the full fence.
  • Traps. break 0 is IR_UD2.

Code model and relocations#

EmbCC emits the NORMAL code model, PC-relative and not PIC:

Use Instructions Relocations
a call bl sym R_LARCH_B26 (+-128 MiB)
a tail call b sym R_LARCH_B26
an address pcalau12i rd, %pc_hi20(sym) + addi.d rd, rd, %pc_lo12(sym) R_LARCH_PCALA_HI20, R_LARCH_PCALA_LO12
a pointer in data .dword sym R_LARCH_64

Unlike RISC-V's %pcrel_lo, the low half names the SYMBOL, not the high half's instruction: pcalau12i yields the 4 KiB PAGE of pc + (hi20 << 12), and the low half is the symbol's offset in its page. The linker computes hi20 as ((S + A + 0x800) & ~0xfff) - (P & ~0xfff) shifted right 12, and lo12 as (S + A) & 0xfff, the +0x800 because addi.d and the loads sign-extend lo12.

clang 23 itself defaults to the MEDIUM model (pcaddu18i ra + jirl, R_LARCH_CALL36) and reaches an external global through the GOT (R_LARCH_GOT_PC_HI20/LO12) even with -fno-pic, each with an R_LARCH_RELAX beside it. EmbLD links all of those, so clang-built objects and libraries mix with EmbCC's: CALL36 is patched in place, a GOT access is rewritten to the direct pcalau12i/addi.d pair (the linker relaxation GNU ld and lld perform when the symbol is in the image, which on bare metal it always is), and RELAX is a hint.

e_machine is EM_LOONGARCH (258), ELFCLASS64, little-endian, RELA; e_flags 0x41 = EF_LOONGARCH_ABI_SOFT_FLOAT | EF_LOONGARCH_OBJABI_V1, what clang writes for -mabi=lp64s.

Predefined macros#

clang's for --target=loongarch64-unknown-elf -msoft-float (generated by tools/gen-predef.sh, nothing excluded but the compiler-identity families): __loongarch__, __loongarch64, __loongarch_grlen 64, __loongarch_frlen 0, __loongarch_soft_float, __loongarch_lp64, __loongarch_arch "loongarch64". Without -mfpu=none clang would claim __loongarch_frlen 64 and LSX (__loongarch_sx), hardware the soft-float code never uses.

The board: QEMU virt#

  • -kernel loads an ELF at its physical address (the high bits of a 0x9000... kernel address are ignored, too) and starts it in direct address mode, so addresses equal physical ones. QEMU keeps its boot information at 0-0x100000 and the device tree at 0x100000-0x200000 (info roms), and refuses an image that overlaps them -- which one linked at 0x200000 did as soon as embld put its ELF header in the first page -- so the harness links at 0x1000000 (16 MiB) with the stack's top at 0x3000000, in the low 256 MiB of RAM.
  • Faults. EENTRY's low twelve bits are zero, so the harness writes the exception entry -- a b to its C reporter -- into a 4 KiB-aligned page of RAM and points EENTRY there with inline csrwr; the reporter reads ESTAT, ERA and BADV with csrrd. An exception prints ==FAULT ecode n ...== and powers off, so a run that faults never reports an exit status. The FPU is disabled at reset, so a floating-point instruction in soft-float code faults too.
  • Output. A 16550 UART at 0x1fe001e0 (the first serial port, so the board runs with -serial stdio).
  • Ending a run. The ACPI GED's registers sit at 0x100e001c (info mtree): writing 0x34 (SLP_TYP S5 | SLP_EN) to the sleep-control byte powers the machine off and QEMU exits at once; 0x42 to the reset byte at 0x100e001e resets it, which -no-reboot turns into an exit. The harness prints a sentinel ==EXIT n == first, and the runner (tests/harness/qrun.sh --until) stops at it in any case.

Status#

Done, each committed and pushed:

  1. This plan; the encoder and its referee (tests/golden/loongarch-encoding.sh: 1669 forms against llvm-mc -show-encoding, 6000 constants against llvm-mc's own li.d expansion and an interpreter, 26 range checks).

  2. The target (loongarch64-unknown-elf, aliases -none-elf, -elf, bare loongarch64), its data model and predefined macros, the code generator at -O0 and -O1/-O2/-Os with the register allocator, -g (frame base DW_OP_breg3, breg22 with alloca), C++ with -fno-exceptions.

  3. EmbLD for EM_LOONGARCH (EmbCC's and clang's objects), lib/rt and lib/libc (make rt-embedded libc-embedded), the QEMU virt harness.

  4. Goldens: loongarch-exec (the exec corpus, 209 of 209 at every level, 5 not applicable), loongarch-abi (EmbCC and clang calling each other: the shared embedded pair, the 128-bit pair and the LP64S pair), loongarch-refuse; and LoongArch64 joined predef, libc-embedded, debug-embedded and embedded-runtime.

  5. The assembler (src/arch/loongarch/asm.c): inline asm, .s/.S files, file-scope blocks and naked functions, the base integer ISA, the AM* atomics, the barriers and the privileged instructions an RTOS needs (csrrd/csrwr/csrxchg, ertn, idle, rdtime*, cpucfg, iocsr*), with llvm-mc's pseudos and symbol forms (b/bl, call36/tail36, la.pcrel, la.global, the %pc/%got_pc/%abs/%call36 operators); loongarch-asm compares all 403 vocabulary statements with llvm-mc and runs C, inline asm and a .S file (EmbCC's and clang's) on the board.

Not done, refused by name meanwhile:

  • la.abs and the TLS, extreme-model and absolute-GOT operators in assembly; floating-point and vector instructions;
  • sixteen-byte atomics (no 128-bit ll/sc in the base ISA);
  • __builtin_frame_address/__builtin_return_address (no frame-pointer chain), computed goto, interrupt functions, unwind tables and C++ exceptions, a scalar local aligned above 16;
  • the LP64D/LP64F hard-float conventions and the FPU (-mabi=lp64d), the extreme code model, LSX/LASX, TLS beyond one shared instance.

Next: LP64D and the FPU#

What clang (-mabi=lp64d -mno-lsx) does, gathered for the follow-up:

  • Arguments: float and double in fa0-fa7; past eight they go to the integer registers (the ninth double lands in a0), then the stack. Structs of at most two members are flattened as on RISC-V LP64D: {float, double} in fa0+fa1, {double, int} in fa0+a0. Variadic arguments always travel in integer registers (movfr2gr.d). Long double stays binary128 in integer pairs through __extenddftf2 and friends.
  • Code: fcmp.c{lt,le,ceq,une,un}.{s,d} + movcf2gr for compares, ffint/ftintrz with movgr2fr/movfr2gr for conversions, unsigned 64-bit conversions inline (no libcall), fsel fed by movgr2cf, fsqrt with a fcmp.cor check falling back to a sqrt call.
  • Predefined macros: __loongarch_double_float 1, __loongarch_hard_float 1, __loongarch_frlen 64, no __loongarch_soft_float. e_flags 0x43.
  • Encodings (checked with llvm-mc): fadd.s 0x01008000, fadd.d 0x01010000 (sub/mul/div step by 0x00020000), fneg/fabs/fsqrt/fmov .s/.d 0x011414xx/ 0x011404xx/0x011444xx/0x011494xx (.s 0x400, .d 0x800), movgr2fr.w/.d 0x0114a400/a800, movfr2gr.s/.d 0x0114b400/b800, movgr2cf 0x0114d800, movcf2gr 0x0114dc00, fsel 0x0d000000, ffint.s.w/.s.l/.d.w/.d.l 0x011d1000/1800/2000/2800, ftintrz.w.s/.w.d/.l.s/.l.d 0x011a8400/8800/a400/a800, fcvt.s.d/.d.s 0x01191800/2400, fld.s/.d 0x2b000000/2b800000, fst.s/.d 0x2b400000/2bc00000, bceqz/bcnez 0x48000000/0x48000100, fcmp.cond.s 0x0c100000 | cond<<15 (caf 0, clt 2, ceq 4, cle 6, cun 8, cult 0xa, cueq 0xc, cule 0xe, cne 0x10, cor 0x14, cune 0x18), .d at 0x0c200000.

Plan: keep float values in integer registers as now (the soft-float code's float_in_gpr shape) and use the FPRs only as scratch inside an operation and at call and return boundaries, so the allocator stays integer-only; a flattening classifier over the struct type (as hfa_walk in src/sema/type.c walks it) decides the boundary moves. Selected by -mabi=lp64d: its own predef table, rt/libc built twice, EmbLD accepting double-float objects but refusing a mix of ABIs, the harness setting EUEN.FPE, and loongarch-abi pairing against clang's lp64d objects.