ASM.mbt is a small native-only x86-64 dynamic code emitter for MoonBit. A
JITContext owns a writable memory buffer while it appends machine-code bytes,
then finalizes the buffer into a callable JITFunction[() -> Int64].
The project is intentionally minimal. It is useful for experiments, tests, and learning how instruction encodings fit together; it is not a complete assembler.
///|
test {
let ctx = @asm.JITContext::new()
ctx.mov(@asm.reg(@asm.rax), @asm.imm32(18))
ctx.add(@asm.reg(@asm.rax), @asm.imm32(4))
ctx.sub(@asm.reg(@asm.rax), @asm.imm32(2))
ctx.imul(@asm.reg(@asm.rax), @asm.imm32(3))
ctx.ret()
let fn0 = ctx.finalize_fn_i64()
assert_eq(fn0.call(), 60)
}Register constants are Register values. Wrap them with reg(...) when
passing them to instruction helpers, and use explicit immediate or memory
helpers for the other operand kinds:
///|
test {
let ctx = @asm.JITContext::new()
ctx.mov(@asm.reg(@asm.r8), @asm.imm64(42L))
ctx.mov(@asm.mem64(base=@asm.rsp, disp=8), @asm.reg(@asm.r8))
ctx.mov(@asm.reg(@asm.rax), @asm.mem64(base=@asm.rsp, disp=8))
ctx.ret()
}Memory operands always carry a data width: mem8, mem16, mem32, mem64,
or the generic mem(size=...). Addressing is the 64-bit ModR/M + SIB form:
optional base, optional index, scale 1/2/4/8, signed displacement, optional
FS/GS, and dedicated RIP-relative helpers such as rip64(disp).
The public API currently emits these instructions:
movfor register, memory, and immediate integer formsadd,sub, andxorfor register/memory with register or immediate formsmuland two-operandimulfor integer register/memory or immediate formsinc,dec,push, andpopfor legal integer register/memory formsloop_rel8(Label)for short RCX-counted loopscall(Target),syscall,nop, andret
Unsupported operand combinations raise ASMContextError instead of aborting.
Labels capture the current byte offset in a single JITContext. Convert a label
to a call target with Label::target. External call targets can be built with
JITContext::extern_fn(name), which resolves a symbol in the current process and
stores the relative target used by x86-64 CALL rel32.
///|
test {
let ctx = @asm.JITContext::new()
let target = ctx.label()
ctx.call(target.target())
assert_eq(ctx.bytes().length(), 5)
}trace() returns a copy of the successfully emitted opcode-form trace. The trace
uses the public Instr enum, whose constructors name the concrete opcode form
chosen by the encoder.
///|
test {
let ctx = @asm.JITContext::new()
ctx.nop()
ctx.ret()
let trace = ctx.trace()
let expected : Array[@asm.Instr] = [NopOp90, RetNearOpC3]
assert_true(trace == expected)
trace.push(SyscallOp0F05)
assert_true(ctx.trace() == expected)
}- 16-bit operands emit the
0x66operand-size override. - 64-bit operands and extended registers emit REX prefixes as needed.
spl,bpl,sil, anddilforce a bare REX prefix;ah,ch,dh, andbhcannot be used in any instruction that needs REX.rspandr12as a memory base always require SIB.rbpandr13without a displacement are encoded withdisp8 = 0.rspcannot be a SIB index.r12can be an index because REX.X distinguishes it fromrsp.- RIP-relative memory always uses a signed disp32 field.
Most instruction helpers validate the requested form before appending bytes or
trace entries. ASMContextError variants cover unsupported instruction forms,
operand-width mismatches, out-of-range immediates, invalid memory addressing,
high-8 register conflicts with REX, short-loop displacement overflow, allocation
failure, mprotect failure, generated code that exceeds the executable
allocation, and repeated finalization.
call currently emits directly and does not raise an error for an out-of-range
rel32 displacement. Keep in-buffer labels and external call targets close
enough for x86-64 relative calls until range validation is implemented.
This package targets native only and uses a C stub for mmap, dlopen,
dlsym, and execution. The generated code must follow the host ABI well enough
to return through ret; when calling C functions, preserve stack alignment and
callee/caller-saved registers as required by the platform.
JITContext::new raises ASMContextError when executable memory allocation
fails. finalize_fn_i64 raises before copying if the generated code exceeds the
mapping size, then switches the mapping from writable to executable memory.
On Linux, linux_syscall_number("getpid") and other names in the C stub's
syscall table expose the platform's SYS_* values, which can be loaded into
rax before emitting syscall.
JITFunction::func_ref() returns the finalized code as the typed FuncRef
stored by the JITFunction. For JITFunction[() -> Int64],
JITFunction::call() is a convenience wrapper over that function reference.
imm32, normal memory displacements, RIP-relative displacements, andcall rel32are written as 4-byte fields. Very largeIntvalues are not all checked before emission yet, so keep them within the intended 32-bit range.JITContext::extern_fndoes not currently reportdlopenordlsymfailure. A missing symbol can still produce aTarget, so use known process symbols and avoid treating it as a general-purpose dynamic linker API.- Finalized mappings are intentionally not manually freed in v1, so any returned
FuncRefcannot dangle because of an explicit dispose call. - The width-specific memory helpers use internal high-8 register sentinels for
omitted
baseandindexarguments. Do not passahas a memory base orchas an index; use the genericmem(size=..., base=..., index=...)helper when you need explicit optional registers.