#bytecode Region
Purpose
A #bytecode region holds functions whose bodies are x86-64 instructions,
emitted exactly as written. It is how t0 kernel code reaches the processor:
port I/O, control registers, descriptor tables, MSRs, interrupt return.
It is t0 only, native only and x86-64 only.
@profile(t0);
fn main() -> i64 {
outb(0x3F8, 65); // 'A' to the first serial port
return 0;
}
#bytecode
pub fn outb(port: u16, value: u8) {
mov(al, value); // read the parameters first (below)
mov(dx, port);
out(dx, al);
}
pub fn read_cr3() -> u64 {
mov(rax, cr3); // the result is rax
}
pub fn halt_forever() {
cli();
label(again);
hlt();
jmp(again);
}Functions
[pub] fn name(params) [-> T] { instructions } declares a function that
#code (and, when pub, an importer) calls like any other. Its signature
is checked like any function’s; its body is a list of instructions, each
mnemonic(operands);, encoded with no prologue, no epilogue and no
optimisation. A ret is added after the last instruction unless that is a
ret, iretq, retfq or jmp. Calls to it are never inlined.
Parameters (at most four: integers, bool, enums, pointers) are named
by their registers, which depend on the target, as in every tape call:
| Parameter | Windows, UEFI | Linux, macOS, Uusi |
|---|---|---|
| 1st | rcx | rdi |
| 2nd | rdx | rsi |
| 3rd | r8 | rdx |
| 4th | r9 | rcx |
A parameter’s name takes the width of the other operand (mov(dx, port)
reads cx on Windows, di elsewhere), else 64 bits. Read the parameters
before writing registers: reading one after an instruction above wrote its
register by another name (mov(dx, port) before value, which arrives in
rdx on Windows) is a compile error, on every target.
The result is rax (an integer, bool, enum or pointer).
What the function must keep: rbx, rbp, r12–r15, and on
Windows and UEFI also rsi and rdi (save and restore them with push
and pop if you use them); rsp as it was. rax, rcx, rdx,
r8–r11 are free, and rsi, rdi on Linux, macOS and Uusi. Nothing
checks this.
Operands and instructions
- Registers:
rax…r15,eax…r15d,ax…r15w,al,cl,dl,bl,spl,bpl,sil,dil,r8b…r15b(notah…dh); control registerscr0,cr2,cr3,cr4,cr8(withmov). - Immediates:
42,-1,0x3F8, fitting the operand. - Memory:
[reg],[reg + 8],[param - 16](a 64-bit register or a parameter, plus a 32-bit displacement), sized by the register operand.[fs + 8]and[gs + 8]are an offset from the FS or GS segment’s base: where a kernel puts a thread’s thread-local block, or a processor’s own data (mov(rax, [fs])reads the block’s first word). - Labels:
label(name);marks a position forjmp,jzand the other conditional jumps, andlea(rax, name).
The instructions are mov, lea, push, pop, add, sub, and,
or, xor, cmp, test, shl, shr, sar, inc, dec, not,
neg, jmp, the conditional jumps, call (a register), ret, iretq,
retfq, in, out, cli, sti, hlt, pause, nop, int3, ud2,
cpuid, rdtsc, rdmsr, wrmsr, swapgs, wbinvd, monitor, mwait,
mfence, lfence,
sfence, lgdt, lidt, invlpg, clflush, clflushopt, ltr, fxsave, fxrstor, rdrand,
rdseed, syscall, sysretq, stac, clac, pushfq, popfq,
rep_movsb, rep_stosb, rep_movsq, rep_stosq, and the atomics
xchg, lock_cmpxchg and lock_xadd (below). syscall writes
rcx and r11 (and the kernel’s result comes back in rax), so a parameter
that arrived in one of them is read before it. monitor takes its
address in rax (and extensions and hints in ecx, edx) and mwait its
hint in eax and ecx: set them first, as for wrmsr. The full table, with
the operand forms each takes, is in the language design
(design/12_regions.md, §#bytecode). An instruction or operand the
compiler does not know is an error at its line, reported by tape check
and the editor too.
Random numbers from the processor
rdrand(r) and rdseed(r) put a random value in a 16, 32 or 64-bit
register and set the carry flag when they delivered one; jc and jnc
read it. The generator can run dry for a moment (rdseed often does), so
try a few times:
#bytecode
pub fn rdrand64(out: *u64) -> bool {
mov(r8d, 10); // tries
label(again);
rdrand(rdx);
jc(got);
dec(r8d);
jnz(again);
xor(eax, eax); // false: nothing in 10 tries
ret();
label(got);
mov([out], rdx);
mov(eax, 1);
}Ask @cpu_has(rdrand) (or @cpu_has(rdseed)) first: a processor without
the instruction faults on it.
Atomics
Threads sharing memory need a read-modify-write that nothing can split:
xchg([m], r) swaps a register with memory (locked by the processor),
lock_cmpxchg([m], r) stores r when the memory equals the accumulator
of the same width (eax for a 32-bit r) and sets the zero flag, or
loads the memory into the accumulator and clears it, and
lock_xadd([m], r) adds r to the memory and leaves the old value in
r. A compare-and-swap:
#bytecode
/* true when *word was `old` and is now `new` */
pub fn cas32(word: *u32, old: u32, new: u32) -> bool {
mov(eax, old);
mov(r10d, new);
lock_cmpxchg([word], r10d);
mov(eax, 0); // mov keeps the flags
jnz(done);
mov(eax, 1);
label(done);
}16-bit and 32-bit code (@bits)
An application processor woken by INIT and STARTUP IPIs starts in 16-bit
real mode and reaches long mode through 32-bit protected mode. That startup
is a piece of machine code the kernel copies to a page below 1 MiB, not a
tape function. Write it as a code blob: a #bytecode function with
@bits(16) or @bits(32) before it.
#bytecode
@bits(16)
@org(0x8000) // the address the copy runs at (optional; 0 by default)
pub fn ap_start() {
cli();
xor(ax, ax);
mov(ds, ax);
lgdt([gdtr]); // 16-bit code loads the 32-bit base (llvm's lgdtl)
mov(eax, cr0);
or(eax, 1);
mov(cr0, eax);
ljmpl(0x08, protected); // far jump to 32-bit code: selector, label
bits(32);
label(protected);
mov(ax, 0x10);
mov(ds, ax);
ljmp(0x18, long_mode);
bits(64);
label(long_mode);
mov(rax, [entry]);
jmp(rax);
align(8);
label(gdt);
dq(0);
dq(0x00CF9A000000FFFF);
label(gdtr);
dw(23);
dd(gdt); // the base: org + gdt's offset
label(entry);
dq(0);
}
// in the kernel, #code:
let code: []const u8 = @bytes_of(ap_start); // the machine code to copy
let at_entry: i64 = @label_offset(ap_start, entry); // where to write the entry point@bits(16)/@bits(32)make the function a code blob, assembled in that mode from the start.bits(16),bits(32),bits(64)switch the mode from that instruction on.@org(address)is the address the copy runs at. A label used as a value — an immediate (mov(ecx, gdt)),[label], a far jump’s target,dd(gdt)— is the origin plus the label’s offset. Without@orgit is the offset itself.- A blob has no parameters, no result and no implicit
ret. It is not tape code: you cannot call it or name it incall,jmporlea, and it may not name tape functions orarg0-arg3. - In 16- and 32-bit code the registers are the 16- and 32-bit ones (
eax…edi,ax…di,alcldlbl; nor8-r15, no 64-bit registers). Memory is[label]/[label ± n],[reg ± n]with a 32-bit register, and in 16-bit code[bx],[si],[di],[bp]with a displacement. - Far jumps:
ljmp(selector, label)uses the current mode’s offset size,ljmpl(selector, label)a 32-bit offset (the form to leave real mode). - Data:
db,dw,dd,dq(one to eight values each) andalign(n)(pads withnopto a multiple of n bytes). These are allowed in any#bytecodefunction. @bytes_of(f)is the blob’s machine code as a[]const u8, and@label_offset(f, name)thei64offset of a label in it, for the kernel to patch the copy. Both are compile-time constants, and@bytes_ofworks in the VM too (a blob is data, not code the VM runs).
Restrictions
- t0 only: the module needs
@profile(t0);. - Native only: the VM cannot run machine code; calling a
#bytecodefunction undertape run(ortape testwithout--native-only) stops with an error. Keep the logic around the instructions in ordinary tape, which the VM runs and tests, and the#bytecodefunctions small. A code blob is an exception:@bytes_ofreads its bytes at compile time, so it works in the VM. - x86-64 only: building for an arm64 target is an error.
Last modified: