#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.

tape
@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:

ParameterWindows, UEFILinux, macOS, Uusi
1strcxrdi
2ndrdxrsi
3rdr8rdx
4thr9rcx

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 (not ah … dh); control registers cr0, cr2, cr3, cr4, cr8 (with mov).
  • 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 for jmp, jz and the other conditional jumps, and lea(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:

tape
#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:

tape
#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.

tape
#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 @org it 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 in call, jmp or lea, and it may not name tape functions or arg0-arg3.
  • In 16- and 32-bit code the registers are the 16- and 32-bit ones (eax … edi, ax … di, al cl dl bl; no r8-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) and align(n) (pads with nop to a multiple of n bytes). These are allowed in any #bytecode function.
  • @bytes_of(f) is the blob’s machine code as a []const u8, and @label_offset(f, name) the i64 offset of a label in it, for the kernel to patch the copy. Both are compile-time constants, and @bytes_of works 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 #bytecode function under tape run (or tape test without --native-only) stops with an error. Keep the logic around the instructions in ordinary tape, which the VM runs and tests, and the #bytecode functions small. A code blob is an exception: @bytes_of reads its bytes at compile time, so it works in the VM.
  • x86-64 only: building for an arm64 target is an error.

Last modified: