The Loup Freestanding Book — contents
  1. Chapter 1: What Is Freestanding Mode?
  2. Chapter 2: Building a Bare-Metal Binary
  3. Chapter 3: Integer Types and Wrapping Arithmetic
  4. Chapter 4: Functions and Control Flow
  5. Chapter 5: Structs and Layout Control
  6. Chapter 6: Enums and Pattern Matching
  7. Chapter 7: Classes and Allocators
  8. Chapter 8: Interfaces and Generics
  9. Chapter 9: Error Handling with Result
  10. Chapter 10: Ownership Verbs
  11. Chapter 11: Borrow and Lifetimes
  12. Chapter 12: Unique Ownership
  13. Chapter 13: Memory Primitives
  14. Chapter 14: Inline Assembly
  15. Chapter 15: MMIO with Volatile
  16. Chapter 16: Interrupts and Naked Functions
  17. Chapter 17: Entry Points and Panic
  18. Chapter 18: Sections and the Linker
  19. Chapter 19: Multicore and Atomics
  20. Chapter 20: The Available Standard Library Subset
  21. Chapter 21: Example — x86_64 Kernel Stub
  22. Chapter 22: Testing and Deploying Firmware
  23. Chapter 23: Memory Safety and Kernel Development Patterns
  24. Chapter 24: synchronized Classes and Lock Ordering

Chapter 14: Inline Assembly

Some instructions have no Loup equivalent: cli and sti, rdmsr/wrmsr, lidt, reading cr2, mrs/msr on Arm, csrr/csrw on RISC-V, the barrier instructions, the first few instructions after reset. For those, freestanding code uses asm { } blocks (Language Reference §3-3.7). This chapter covers the block's grammar, operands and clobbers, which modes accept it, what the 1.25 checker verifies about it and what it leaves to you, and examples for x86_64, AArch64 and RISC-V.

The basic form

An asm { } block holds one or more string literals — the instructions, one per string — optionally followed by operand lists. With no operands:

@mode(freestanding)

func disableInterrupts(): void { asm { "cli" } }
func enableInterrupts(): void { asm { "sti" } }
func cpuRelax(): void { asm { "pause" } }

# Park the CPU until the next interrupt, forever.
func idle(): void {
    while (true) {
        asm { "hlt" }
    }
}

Listing 14-1: One-instruction wrappers

The instructions are passed to the target's assembler as written; loup check does not assemble them, so a typo in an instruction shows up only when you build for the target. Several strings form a sequence, and numeric local labels (1: … jmp 1b) work as in GNU assembler syntax. On x86_64 the syntax is Intel (mov rax, cr2, destination first).

Operands — out:, in:, clobbers:

After the instruction strings come up to three lists, in this order:

  • out: name = "constraint", … — binds a Loup variable to an output register;
  • in: "constraint" = expression, … — loads a Loup value into a register before the instructions run;
  • clobbers: ["…", …] — names registers and "memory" that the instructions change without listing them as outputs.
func readMsr(msr: uint32): uint64 {
    var lo: uint32 = 0
    var hi: uint32 = 0
    asm {
        "rdmsr"
        out: lo = "=a", hi = "=d"
        in: "ecx" = msr
        clobbers: []
    }
    return ((hi as uint64) << 32) | (lo as uint64)
}

func writeMsr(msr: uint32, value: uint64): void {
    let lo = (value & 0xFFFF_FFFFu64) as uint32
    let hi = (value >> 32) as uint32
    asm {
        "wrmsr"
        in: "ecx" = msr, "eax" = lo, "edx" = hi
        clobbers: ["memory"]
    }
}

# Port I/O: the 16550 UART on a PC is at I/O port 0x3F8.
func outb(port: uint16, value: uint8): void {
    asm { "out dx, al" in: "dx" = port, "al" = value clobbers: [] }
}

func inb(port: uint16): uint8 {
    var value: uint64 = 0
    asm { "in al, dx" out: value = "=a" in: "dx" = port clobbers: [] }
    return (value & 0xFFu64) as uint8
}

Listing 14-2: Model-specific registers and port I/O

Constraint strings follow the GCC/LLVM conventions: "=a" is "output in rax/eax/al", "=d" the rdx family, "r" any general register, and a constraint may simply name a register — "ecx", "dx", "al" on x86, "x0" on AArch64, "a0" on RISC-V. Output variables must be vars. Clobber "memory" whenever the instructions read or write memory the compiler cannot see (a TLB flush, a control-register write, a barrier), so it does not keep values cached in registers across the block.

The Language Reference also defines "all" (every caller-saved register) and per-architecture presets such as "msr-read" and "cpuid" for the clobber list; the checker accepts clobbers: ["all"].

Two syntax habits that do not carry over

The operand lists live inside the braces, after the instructions. GCC's parenthesized, colon-separated form does not parse:

This code does not compile
@mode(freestanding)

func readMsr(msr: uint32): uint64 {
    var lo: uint32 = 0
    var hi: uint32 = 0
    asm("rdmsr" : "=a"(lo), "=d"(hi) : "c"(msr))
    return ((hi as uint64) << 32) | (lo as uint64)
}
error[E0001]: expected RParen, found Colon
 --> src/main.lpfree:6:17
  |
6 |     asm("rdmsr" : "=a"(lo), "=d"(hi) : "c"(msr))
  |                 ^
  |

error[E0003]: expected `;`
 --> src/main.lpfree:6:17
  |
6 |     asm("rdmsr" : "=a"(lo), "=d"(hi) : "c"(msr))
  |                 ^
  |

And the instructions come first. Putting the operands before them — as in Rust's asm!, where operands follow the template but are separated by commas — fails at the first operand:

This code does not compile
@mode(freestanding)

func readCr2(): uint64 {
    var faultAddr: uint64 = 0
    asm {
        out: faultAddr = "=a"
        "mov rax, cr2"
    }
    return faultAddr
}
error[E0001]: asm block requires at least one template string literal
 --> src/main.lpfree:6:9
  |
6 |         out: faultAddr = "=a"
  |         ^^^
  |

error[E0001]: expected RBrace, found StringLiteral { quote: 34, is_raw: false }
 --> src/main.lpfree:7:9
  |
7 |         "mov rax, cr2"
  |         ^^^^^^^^^^^^^^
  |

error[E0001]: expected expression
  --> src/main.lpfree:10:1
   |
10 | }
   | ^
   |

The fixed version:

# The faulting address of the last page fault.
func readCr2(): uint64 {
    var faultAddr: uint64 = 0
    asm {
        "mov rax, cr2"
        out: faultAddr = "=a"
        clobbers: []
    }
    return faultAddr
}

Listing 14-3: Instructions first, then operands

Which modes accept asm

asm { } is compiled to machine code, so it exists only in the modes that produce machine code: freestanding, host.native (where the Language Reference requires an @unsafe("asm: …") acknowledgment on the enclosing function), and web.compiled (a WebAssembly form). In the interpreted and dual modes it is rejected. A helper written for a hosted tool fails like this:

This code does not compile
@mode(host.dual)

func disableInterrupts(): void {
    asm { "cli" }
}

func main(): void {
    disableInterrupts()
}
main.loup:4:5: error[E_ASM_NOT_COMPILED]: E_ASM_NOT_COMPILED: `asm { }` is only valid in a compiled mode (`web.compiled`, `host.native`, `freestanding`); the module mode is `host.dual` (§3-3.7)

(This listing is a host.dual program, so the message comes from loup run.) Keep hardware access in .lpfree files; a hosted tool that needs the same logic needs its own copy without the asm.

In freestanding, an asm block does not need @unsafe: inline assembly is too common in kernel code for every block to be an audit site. The Language Reference describes an asm-unchecked advisory per function instead; the 1.25 toolchain does not print it.

What the checker does not verify

loup check parses the block, resolves the operand variables and expressions, and stops there. In 1.25 it does not check:

  • Operand widths. The Language Reference makes a mismatch between an operand's type and its constraint E_ASM_OPERAND_TYPE. It is not implemented: this compiles, although rdtsc writes 32 bits to eax and the variable is a uint8.

    This code does not do what it should
    func cycleCounterLow(): uint8 {
        var low: uint8 = 0
        asm { "rdtsc" out: low = "=a" clobbers: ["rdx"] }
        return low
    }
    

    Listing 14-4: A width mismatch the checker accepts

    Give each output the width of the register it receives (uint32 for eax, uint64 for rax), as Listing 14-2 does, and narrow afterwards with as.

  • Mutability of outputs. An output bound to a let is accepted. Use var.

  • The instructions themselves, and whether the constraints exist on the target. Those errors come from the assembler at build time.

  • Expired borrows used as operands (E_ASM_EXPIRED_BORROW is specified, not raised).

Borrowed values as operands

A struct passed by borrow can be an input operand; the register receives its address. That is the natural way to hand a descriptor-table pointer to lidt:

# The 10-byte operand of lidt/lgdt: a 16-bit limit and a 64-bit base.
@repr(packed)
struct DescriptorTablePointer {
    var limit: uint16;
    var base: uint64;
}

func loadIdt(borrow idtr: DescriptorTablePointer): void {
    asm {
        "lidt [rdi]"
        in: "rdi" = idtr
        clobbers: ["memory"]
    }
}

Listing 14-5: Passing a borrowed struct's address to lidt

When to use asm — and when not to

Inline assembly is hard to review and invisible to the checker, so use the Loup primitive where one exists:

  • Memory-mapped registers: Volatile[T] or a volatile borrow parameter (chapter 15).
  • Atomic operations and memory ordering: the atomic intrinsics atomicFetchAddU64, atomicCompareExchangeU64, atomicFence and friends (chapter 19), not lock xadd.
  • Disabling interrupts around a critical section: InterruptGuard or irqSaveDisable/irqRestore, which restore the previous state correctly on every architecture (chapter 16).
  • Layout arithmetic: size_of, align_of, addressOf.

Use asm for privileged and system instructions (lgdt, lidt, invlpg, wrmsr, cpuid), control and system registers (cr0–cr4, msr/mrs, csrr/csrw), I/O ports on x86, explicit hardware barriers (mfence, dsb, isb, fence), and the bodies of @naked functions — boot entries, vector tables and context switches (chapter 16).

Architecture examples

The constraint and register spellings below are the ones the QEMU-booted test kernels in the toolchain repository use.

x86_64

# The timestamp counter.
func rdtsc(): uint64 {
    var lo: uint32 = 0
    var hi: uint32 = 0
    asm { "rdtsc" out: lo = "=a", hi = "=d" clobbers: [] }
    return ((hi as uint64) << 32) | (lo as uint64)
}

# Load a new page-table root; this also flushes non-global TLB entries.
func loadCr3(root: uint64): void {
    asm { "mov cr3, rax" in: "rax" = root clobbers: ["memory"] }
}

# Invalidate one page's TLB entry.
func invlpg(virt: uint64): void {
    asm { "invlpg [rax]" in: "rax" = virt clobbers: ["memory"] }
}

Listing 14-6: x86_64 helpers

AArch64

# The counter frequency and the virtual counter.
func counterFrequency(): uint64 {
    var f: uint64 = 0
    asm { "mrs x0, cntfrq_el0" out: f = "=x0" clobbers: [] }
    return f
}

func virtualCount(): uint64 {
    var v: uint64 = 0
    asm { "mrs x0, cntvct_el0" out: v = "=x0" clobbers: [] }
    return v
}

# A 32-bit device-register store and the barriers around it.
func write32(addr: uint64, value: uint64): void {
    asm { "str w1, [x0]" in: "x0" = addr, "x1" = value clobbers: ["memory"] }
}

func dataBarrier(): void { asm { "dsb sy" clobbers: ["memory"] } }
func instructionBarrier(): void { asm { "isb" clobbers: [] } }

# Mask IRQs (DAIF.I).
func maskIrq(): void { asm { "msr daifset, #2" clobbers: ["memory"] } }

Listing 14-7: AArch64 helpers

RISC-V (riscv64)

# A 64-bit load from a device or memory address.
func load64(addr: uint64): uint64 {
    var v: uint64 = 0
    asm { "ld a0, 0(a0)" out: v = "=a0" in: "a0" = addr clobbers: ["memory"] }
    return v
}

# Machine-mode interrupt enable/disable (mstatus.MIE, bit 3).
func enableInterrupts(): void { asm { "csrsi mstatus, 8" clobbers: ["memory"] } }
func disableInterrupts(): void { asm { "csrci mstatus, 8" clobbers: ["memory"] } }

# Wait for an interrupt.
func waitForInterrupt(): void { asm { "wfi" clobbers: [] } }

Listing 14-8: RISC-V helpers

Summary

  • asm { "instr" … out: … in: … clobbers: [...] } — instructions first, then outputs, inputs and clobbers, all inside the braces. GCC's asm("…" : …) form does not parse.
  • Constraints follow GCC/LLVM conventions and may name registers directly; clobber "memory" around anything with memory effects.
  • asm is valid in freestanding, host.native and web.compiled; elsewhere it is E_ASM_NOT_COMPILED. In freestanding it needs no @unsafe.
  • The 1.25 checker does not verify operand widths, output mutability or the instructions; match register widths yourself.
  • A borrow struct operand passes its address.
  • Prefer Volatile, the atomic intrinsics, InterruptGuard and the layout queries where they fit; keep asm for system instructions, ports, barriers and naked functions.

Next: MMIO with Volatile.