How to grow rexcode from an x86-only encoder/decoder into a multi-target library (x86, RISC-V, ARM64, MIPS, …) without flattening every architecture to a lowest common denominator and without adding runtime overhead to the single-target hot path.
Companion to x86_api.md. Written ahead of the RISC-V subpackage.
Share the bookkeeping, specialize the bytes.
An encoder/decoder is two things stitched together:
- Orchestration & bookkeeping — labels, relocations, the two-pass encode/decode loops, error/result reporting, the print framework, buffer management, the table-gen tooling pattern. This is the same problem on every ISA and should be written once.
- The instruction model & the bytes — what a register/memory/operand
is, what the encoding tables look like, and the actual
bit/byte-twiddling of
encode_one/decode_one. This is irreducibly per-architecture and must stay native and zero-cost.
Every decision below follows from drawing the line in exactly that place.
We do not try to invent one Instruction type that fits all ISAs —
that path forces x86's segment/SIB and ARM's writeback and RISC-V's
split immediates into one bloated struct, and it is precisely the
"compromise performance/effectiveness" outcome to avoid. Instead, each
arch owns its concrete types, and uniformity comes from a naming
contract (§6) plus a small shared core (§4) plus opt-in
generic glue (§5, §7).
Strip away the x86 specifics and every target needs the same nine things:
| # | Concept | Example in x86 |
|---|---|---|
| 1 | A register = (class, hw number, size) | Register distinct u16 |
| 2 | Operands tagged reg / mem / imm / relative | Operand + Operand_Kind |
| 3 | An instruction = mnemonic + operands + flags | Instruction |
| 4 | A mnemonic enum | Mnemonic (u16, INVALID=0) |
| 5 | Labels + forward refs + named labels | Label_Definition, Label_Map |
| 6 | Relocations left over after local resolution | Relocation |
| 7 | encode([]Inst) -> bytes (+relocs +errors) |
encode() |
| 8 | decode(bytes) -> []Inst (+info +labels +errors) |
decode() |
| 9 | print([]Inst) -> text (+tokens) |
print()/tprint()/… |
Plus two cross-cutting concerns: errors/result reporting and a table-driven core fed by codegen tooling.
The shape of items 5–9 (their signatures and the types they pass around) is architecture-independent. That is the surface we standardize.
This is the heart of the analysis. Ranked from "diverges hardest" to "barely diverges."
| ISA | Width | Mechanism |
|---|---|---|
| x86 | 1–15 B, variable | legacy prefixes → REX/VEX/EVEX → escape → opcode → ModRM → SIB → disp → imm |
| RISC-V | 4 B (2 B for "C") | pack fixed bitfields; ~6 formats (R/I/S/B/U/J) |
| ARM64 | 4 B fixed | pack per-class bitfields; many classes; bitmask-imm encoder |
| MIPS | 4 B fixed | 3 formats (R/I/J), very regular |
encode()'s ~500-line body and the whole Encoding/Encoding_Flags
schema (esc/prefix/vex_*) are x86-only. RISC-V's encode_one is a
dozen lines of shifts. Conclusion: the encode_one/decode_one core
and the Encoding struct do not generalize — but the loop that drives
them does (§7).
| ISA | Addressing modes |
|---|---|
| x86 | [base + index*scale + disp32], RIP-relative, segment override, addr-size override |
| RISC-V | disp12(base) only — no index, no scale |
| MIPS | imm16(base) only |
| ARM64 | [base], [base,#imm], [base,Xm{,LSL#n}], [base,Wm,SXTW], pre/post-index [base,#imm]! / [base],#imm, PC-rel literal |
The x86 Memory bit_field (with segment, addr_size_override,
index+scale) is deeply x86-flavored. RISC-V's memory is {base, i32 disp}.
ARM adds writeback (a mode x86 cannot express) and extend/shift on the
index. Conclusion: Memory is per-arch. What generalizes is only the
role: a MEMORY-kind operand carrying an arch-defined payload.
- The value (an
i64) generalizes perfectly. - The encoding does not: RISC-V scatters immediate bits across fields
(B-type, J-type) and shifts them; ARM has bitmask-immediate and shifted
forms. All of that lives inside
encode_one; theOperandjust holds the clean value. - Size association differs: x86 carries an explicit
size: u8and uses it to select an encoding; RISC-V/ARM bake width into the mnemonic (LWvsLD,W0vsX0). Keepsizein the shared operand shape as a carrier; let each arch decide how much it matters.
The Relocation struct (offset, symbol/label, addend, type, size)
mirrors ELF rela and is universal. The type enum is per-arch and much
larger on RISC-V (paired PCREL_HI20/PCREL_LO12, CALL, BRANCH,
JAL, HI20, LO12_I/S, …) because PC-relative addressing needs
instruction pairs (AUIPC+ADDI). Conclusion: share the struct shape,
make the type enum a per-arch parameter.
The (class, hw_number)-packed distinct u16 scheme generalizes well.
What differs:
- x86: REX/EVEX extension bits, AH↔SPL aliasing, RIP pseudo-reg.
- RISC-V: clean 5-bit fields,
x0=hardwired zero, ABI names (zero/ra/sp/gp/tp/t0../s0../a0..), separatef/vfiles. - ARM64: reg #31 means SP or XZR depending on instruction (a
decode/print-time disambiguation x86 never needs);
w/xandb/h/s/d/qviews. Conclusion: share the layout convention +reg_hw/reg_classaccessors; per-arch owns classes, enums, names, and extension semantics.
Per-arch enum u16, INVALID=0. Nothing to share but the convention.
labels.odin is pure bookkeeping. The array-index model
(Label_Definition, label, label_forward, label_set_at,
Label_Map, label_named, label_reserve, label_set) lives in
isa/labels.odin and is parametric over the Instruction type. Fully
shared. Each arch's encode() rewrites label_defs from instruction
indices to byte offsets between pass 1 and pass 2.
Result is universal. Error is universal in shape. Error_Code splits
into a shared core (NONE, BUFFER_OVERFLOW, INVALID_MNEMONIC, NO_MATCHING_ENCODING, BUFFER_TOO_SHORT, INVALID_OPCODE, LABEL_OUT_OF_RANGE, …) and arch-specific extras (INVALID_MODRM/SIB/VEX/EVEX, TOO_MANY_PREFIXES on x86; RISC-V would add MISALIGNED_IMMEDIATE,
INVALID_ROUNDING_MODE, …).
Shareable: Token, Token_Kind (the kinds are generic), Print_Options,
the builder/number-formatting helpers, and the whole family of output
sinks (sbprint/print/aprint/tprint/bprint/fprint/wprint + ln). Per-arch:
register_name, print_memory (syntax differs wildly),
mnemonic_to_string, and the size-suffix convention (x86's .b/.w/.d is
x86-only; RISC-V puts width in the mnemonic).
| Component | Verdict | What's shared | What's per-arch |
|---|---|---|---|
| Labels | ✅ shared | everything | — |
| Result / Error struct | ✅ shared | struct shapes | error-code extras |
| Relocation struct | ✅ shared | struct shape | type enum |
| Printer framework | ◑ split | tokens, options, sinks, num-fmt | reg/mem/mnemonic formatting |
| Register scheme | ◑ split | layout + reg_hw/reg_class |
classes, enums, names, ext bits |
| Operand model | ◑ split | kind tag + union discipline + size carrier |
Memory, flags payloads |
| Encode/decode driver | ◑ shared via generics | two-pass loops, label/reloc resolution | the per-instruction hook |
Instruction |
✗ per-arch | shape convention only | concrete struct |
Mnemonic |
✗ per-arch | convention (u16, INVALID=0) | the enum |
Encoding + tables |
✗ per-arch | codegen pattern | schema + data |
encode_one/decode_one |
✗ per-arch | nothing | all of it |
| Memory addressing | ✗ per-arch | operand role | the model |
Three tempting designs that violate the no-compromise rule:
-
One universal
Operand/Memoryfor all ISAs. Forces the union of x86 SIB+segment, ARM writeback+extend, and RISC-V's nothing into a single struct. Bloats every operand, leakssegmentinto RISC-V, and still can't represent ARM writeback cleanly. ✗ -
A runtime
interface/vtable the encoder calls per instruction. Adds an indirect call to the hottest loop (x86 does ~17 M inst/s — a per-instructionprocpointer is a measurable tax) and defeats inlining. ✗ on the default path. -
any/tagged-unionInstructionpassed through a genericencode. Same monomorphization loss + runtime type checks in the hot loop. ✗
The design instead gets uniformity from compile-time mechanisms (naming contract + parametric polymorphism), and reserves runtime dispatch for an opt-in facade (§5.3) that only multi-target tools pay for.
rexcode/
isa/ # shared, architecture-independent core
labels.odin # Label, Label_Definition, Label_Map, resolution
reloc.odin # Relocation (type field is generic/u8)
status.odin # Result, Error, shared Error_Code core
print.odin # Token, Token_Kind, Print_Options, sinks, num-fmt
register.odin # distinct-u16 layout convention + reg_hw/reg_class
pipeline.odin # parametric encode_stream/decode_stream (§7)
target.odin # optional runtime Target vtable (§5.3)
x86/ # exists today; refactor to import isa
registers.odin operands.odin instructions.odin mnemonics.odin
encoding_types.odin encoder.odin decoder.odin printer.odin
encoding_table.odin decoding_tables.odin mnemonic_builders.odin
tests/ tools/
riscv/ # next: same shape as x86/
registers.odin operands.odin instructions.odin mnemonics.odin
encoding_types.odin encoder.odin decoder.odin printer.odin
encoding_table.odin decoding_tables.odin mnemonic_builders.odin
tests/ tools/
arm64/ mips/ … # future, same template
isadepends on nothing. Each arch package depends onisaand re-exports the shared types (e.g.x86.Result,x86.Label_Map) so a consumer ofx86sees one coherent namespace and never importsisadirectly unless writing arch-generic tooling.- Each arch package is self-contained (its own tests/tools), matching the move already done for x86.
import "rexcode/x86"
code: [4096]u8
res := x86.encode(insts[:], labels[:], code[:], &relocs, &errors)Fully static, fully inlined, exactly as fast as today. 99% of consumers live here.
Because every arch package exposes the same names with the same
signatures (§6), code that only touches the shared vocabulary
(Label_Map, encode, tprint, Result, Relocation) can be written
against import arch "rexcode/x86" and re-pointed at rexcode/riscv by
changing one import — as long as the arch-specific operand construction is
isolated (e.g. behind your own per-arch helper). Still 100% compile-time,
zero overhead.
For a disassembler or JIT that selects the arch at runtime, isa
provides a vtable populated by each arch:
// isa/target.odin
Target :: struct {
name: string,
decode: proc(data: []u8, out: ^Decoded) -> Result, // bytes → generic Decoded
print: proc(d: ^Decoded, opts: ^Print_Options) -> string,
inst_align: u32, // 1 for x86, 4 for riscv/arm64/mips
max_inst: u32, // 15 for x86, 4 for riscv (8 for C-pairs), 4 for arm64
}
// each arch: x86.TARGET: isa.Target = { … }This boundary trades in bytes and a generic Decoded view, not the
concrete Instruction, so it never forces a unified instruction struct.
It carries a proc-pointer indirection — acceptable for a tool that has
already paid a switch arch somewhere, and never on Layer A's path.
Every architecture package MUST expose these names with these signatures. This is what makes the family feel like one library and what the RISC-V implementation is built against as a checklist.
Register Memory Operand Operand_Kind
Instruction Mnemonic Encoding Instruction_Info
Label Label_Definition Label_Map LABEL_UNDEFINED
Relocation Relocation_Type Error Error_Code Result
Token Token_Kind Print_Options DEFAULT_PRINT_OPTIONS
op_reg(r) op_mem(m, size) op_imm(v, size) op_label(id, size)
mem_*(…) # arch-specific set; at minimum mem_base_disp
# (mem_base in x86 is an accessor, not a constructor;
# use mem_base_only for the no-displacement case)
op_<class>(typed) # typed safe constructors where the arch has classes
Builder names spell out each operand kind separated by underscores (matches x86's existing convention):
inst_none / inst_r / inst_r_r / inst_r_i / inst_r_m / inst_m_r / …
emit_none / emit_r / emit_rr / emit_ri / emit_rm / emit_mr / …
# NB: emit_* uses concatenated suffixes (legacy x86 spelling)
inst_<mnemonic>(…) / emit_<mnemonic>(…) # generated typed overloads
encode(instructions: []Instruction, label_defs: []Label_Definition,
code: []u8, relocs: ^[dynamic]Relocation, errors: ^[dynamic]Error,
resolve := true, base_address: u64 = 0) -> Result
decode(data: []u8, relocs: []Relocation,
instructions: ^[dynamic]Instruction, inst_info: ^[dynamic]Instruction_Info,
label_defs: ^[dynamic]Label_Definition, errors: ^[dynamic]Error) -> Result
print/println/aprint/tprint/bprint/fprint/wprint(+ln)(
instructions: []Instruction, inst_info: []Instruction_Info,
label_defs: []Label_Definition, tokens=nil, options=nil, label_names=nil)reg_hw reg_class reg_size register_name mnemonic_to_string
label label_forward label_named label_reserve label_set
Anything an arch genuinely lacks (e.g. RISC-V has no
mem_base_index) is simply absent, not stubbed. Portable (Layer B) code stays within the intersection; arch-aware code uses the extras.
The encode/decode drivers are arch-independent control flow. Factor
them into isa as procedures generic over the instruction type $I,
parameterized by an arch-provided per-instruction hook. Odin monomorphizes
these at compile time → no runtime cost, real code sharing.
// isa/pipeline.odin (sketch)
encode_stream :: proc(
instructions: []$I,
label_defs: []Label_Definition,
code: []u8,
relocs: ^[dynamic]Relocation,
errors: ^[dynamic]Error,
encode_one: proc(inst: ^I, out: []u8, code_pos: u32,
relocs: ^[dynamic]Relocation, errors: ^[dynamic]Error) -> (n: u32, ok: bool),
resolve := true, base_address: u64 = 0,
) -> Result {
// PASS 1: for each inst → record offset, call encode_one, advance
// PASS 1.5: rewrite label_defs inst-index → byte-offset (identical on every arch)
// PASS 2: resolve relocations / patch / spill unresolved (identical on every arch)
}x86's current encode() becomes a thin wrapper that passes its
encode_one (the prefix/ModRM/SIB body); RISC-V's wrapper passes its
12-line bitfield packer. The label/relocation machinery — the part that's
easy to get subtly wrong — is written and tested once.
Caveats (arch-specific passes that stay out of the shared driver):
- RISC-V pseudo-ops (
li,call,la,j) expand to 1–2 real instructions; needs an arch pre-lowering pass. - Branch relaxation (short↔long form) is arch-specific.
- ARM literal pools / constant islands are an extra emission phase.
These plug in around the shared driver, not inside it.
What each contract item becomes, to validate the design before coding:
| Contract item | RISC-V realization |
|---|---|
Register |
distinct u16, classes REG_X (x0–31), REG_F (f0–31), REG_V (v0–31). No REX/EVEX bits. x0 semantic = zero. |
| typed enums | XREG{ZERO,RA,SP,GP,TP,T0,T1,T2,S0,S1,A0..A7,S2..S11,T3..T6}, FREG, VREG |
Memory |
struct { base: Register, disp: i32 } — no index/scale/segment |
mem_* |
mem_base(base), mem_base_disp(base, disp) only |
Operand |
same kind-tagged shape; size mostly informational (width is in the mnemonic) |
Mnemonic |
enum u16 — RV32I/64I + M,A,F,D,C,V (ADDI, LW, LD, BEQ, JAL, AUIPC, FADD_D, …) |
Encoding |
struct { format: Format, opcode, funct3, funct7: u8, … }, Format{R,I,S,B,U,J,R4,…} |
encode_one |
switch on format, pack fields, scatter immediate bits |
Encoding_Flags |
tiny (e.g. is_compressible, rounding_ok) vs x86's 11 fields |
Relocation_Type |
R_RISCV_BRANCH, JAL, CALL, PCREL_HI20, PCREL_LO12_I/S, HI20, LO12_I/S, RVC_BRANCH/JUMP, … |
Instruction_Info |
offset, is_compressed: bool, rounding mode — no prefix/VEX fields |
| printer | register_name uses ABI names; print_memory emits disp(base); width lives in the mnemonic (no .b/.w suffix) |
| tables | gen_decode_tables becomes near-trivial: a fixed-field instruction decodes by (opcode, funct3, funct7) keys |
MAX_INST_SIZE |
4 (or 8 to cover a compressed pair); inst_align = 2 |
Notable RISC-V-only concerns the design already accommodates:
- Split immediates → hidden in
encode_one; operand stays a clean value. - Paired PC-relative relocs (AUIPC+ADDI) → expressed via the shared
Relocationstruct with RISC-V's type enum; resolution of the pair is a RISC-V detail layered on the shared reloc list. - Compressed (C) extension → variable 2/4-byte width handled by
decode_onereturning a length, exactly like x86's variable length — the shared decode driver already threads instruction length.
If RISC-V slots cleanly into the contract (it does above), the contract is sound for the regular fixed-width ISAs (ARM64, MIPS) too.
- Stabilize x86 first. Resolve the constructor-rename drift noted in
x86_api.md (tests/README vs
operands.odin) so x86 is the clean reference the contract is extracted from. - Extract
isaby lifting the already-arch-independent files:labels.odin, theRelocation/Error/Resulttypes, and the printer framework (tokens/options/sinks/number-formatting). Makex86re-export them. This is a low-risk refactor that proves the split. - Add the parametric
encode_stream/decode_streamtoisaand reduce x86'sencode/decodeto wrappers. Validate against the existing test suite (same bytes out). - Write the RISC-V package against the contract (§6) and the mapping
(§8), reusing
isawholesale. Build itsencoding_table.odinby hand, then port the two generators. - Only if a runtime-multi-target tool appears, add the
Targetvtable (§5.3). Don't build it speculatively.
The deliverable order matters: every step is independently shippable, and x86 keeps working (and keeps its performance) throughout.
Make isa own the parts that are the same on every ISA — labels,
relocations, errors/result, the print framework, and (via Odin
parametric polymorphism) the encode/decode driver loops. Make each arch
package own its registers, memory model, operands, mnemonics, encoding
tables, and the actual encode_one/decode_one bytes. Bind the family
together with a strict naming contract so packages are drop-in
swappable at source level with zero runtime cost, and reserve a single
opt-in runtime Target vtable for the rare tool that needs to choose an
architecture dynamically. x86 keeps every cycle of its current
performance; RISC-V (and later ARM/MIPS) gets the boring 60% for free and
writes only the 40% that is genuinely its own.