Lowent Manual←↑→

30 Hardware — registers, interrupts, machine instructions

What to know first

chapter 13, Named types · layout, view and field access markers
chapter 20, Allocators and fixed memory · fixed windows on machines without an operating system
chapter 29, Meeting C · places that cannot be checked are confined by mark, right and effects line

Looking back

What were the three things an op calling C had to have in chapter 29, and whom did each tell?

A. The unsafe mark (the person), cap c (the processor) and the effects line (the caller). This chapter confines device registers and machine instructions the same way. Only the capability and effect names change.

The need for this chapter, and its context

The lowest place of a systems language is firmware reading and writing device registers directly, without an operating system. Defects here are quiet and fatal. A compiler deletes a register read that “looks useless”, a write-only register is read and moves the device, or an interrupt handler is called like an ordinary function and runs on the wrong stack. Lowent stops these at translation. This chapter gathers how the tools set up in earlier chapters — capabilities, effects, layout, fixed windows — are used on hardware.

By the end of this chapter

You will write a device’s register map as a struct with mmio <address>, and learn access through read_volatile and write_volatile. You will see that cap mmio, the device effect and the read-only and write-only markers are enforced at translation, and why a device cannot be received by value. You will also make interrupt handlers with the vector clause, decide the effects a machine supports with build tier, and confine machine instructions with the asm clause.

The questions this chapter answers

  1. C adds the volatile qualifier. What is different?

30.1 The register map#

examples/ch30/gpio.low

module gpio_demo .
rem run: drive 0 [0,0,0,0,7,0,0,0,0,0,0,0]

build tier t1 .

struct gpio do
  mmio 0x40020000 .
  moder u32 rw .
  idr u32 ro .
  bsrr u32 wo .
end

unsafe proc drive
  input dev cap mmio .
  input regs mut slice u8 .
  output u32 .
  effects device unsafe .
do
  var g gpio be view gpio regs .
  write_volatile g moder 2 .
  write_volatile g bsrr 1 .
  return read_volatile g idr .
end

Output

$ lowentc --run drive gpio.low 0 [0,0,0,0,7,0,0,0,0,0,0,0]
drive(0, [2,0,0,0,7,0,0,0,1,0,0,0]) = 7
  arg1 (written) = [2,0,0,0,7,0,0,0,1,0,0,0]

On the VM this really runs. Instead of a device, a byte buffer handed over by the caller takes the registers’ place, and view gpio regs lays the map over it. In the argument shown in the result, [2,0,0,0,7,0,0,0,1,0,0,0], you can see 2 written to moder, 1 to bsrr, and the 7 in idr read back. The first 0 in the run arguments is a placeholder filling the cap mmio position.

Laying the map over the buffer looks like this. Fields sit 4 bytes apart in declaration order.

 address      field  access   in the VM (one u32 = 4 bytes)
 0x40020000   moder  rw       [ 2 0 0 0 ]  ◀── write_volatile g moder 2
 0x40020004   idr    ro       [ 7 0 0 0 ]  ──▶ read_volatile g idr = 7
 0x40020008   bsrr   wo       [ 1 0 0 0 ]  ◀── write_volatile g bsrr 1

Q. C adds the volatile qualifier. What is different?

A. C’s volatile is a property of a variable and easy to forget. Leave it off and the optimiser may delete reads without warning. In Lowent, reaching a register is a named operation, read_volatile or write_volatile, and the head of an op using it carries the device effect and cap mmio. Whether a read may be deleted is decided by the operation’s name, not by remembering a qualifier on a variable.

30.2 Access is enforced at translation#

Writing a read-only register is rejected.

examples/ch30/ro_write.low

module ro_write .
rem expect: E-MMIO-PERM

struct gpio do
  mmio 0x40020000 .
  moder u32 rw .
  idr u32 ro .
end

unsafe proc poke input dev cap mmio . input regs mut slice u8 . output u32 . effects device unsafe .
do
  var g gpio be view gpio regs .
  write_volatile g idr 1 .
  return 0 .
end

Output

$ lowentc --check ro_write.low
ro_write.low:13:0 E-MMIO-PERM: this register is READ-ONLY (`ro`) — writing it is a compile error, not a runtime surprise (RFC-0042 D3). The device says what it will accept; the type says it back

Reading a write-only register is the same. Reading one yields garbage or the read itself moves the device. That read is not useless but wrong. Once you have said what a device accepts, the type says it back.

Nor can a device be received by value.

examples/ch30/byvalue.low

module byvalue .
rem expect: E-MMIO-BYVALUE

struct gpio do
  mmio 0x40020000 .
  moder u32 rw .
end

unsafe proc setup input dev cap mmio . input g gpio . output u32 . effects device unsafe .
do
  write_volatile g moder 2 .
  return 0 .
end

Output

$ lowentc --check byvalue.low
9:0 E-MMIO-BYVALUE: an `mmio` register block cannot be a by-value parameter. A struct parameter is COPIED, and copying a device means reading EVERY register at once — including the `wo` ones, whose reads D3 promises to reject at compile time, at a moment the program never wrote. A register block is a DEVICE, not a value: pass the view instead (RFC-0042 D3 · §8-4)

Struct parameters are copied. Copying a device means reading every register at once, write-only ones included, and writes to the copy never reach the device. A program written that way does nothing and says nothing. So translation stops it. Groups of registers are handled by laying them over a slice with view. Trying to open an absolute address on a machine with an operating system is rejected with E-MMIO-NOHOST — that address is not a device.

30.3 Interrupt handlers#

Adding a vector <number> . clause to an op makes it an interrupt handler. Urgency is written with priority <number> ..

examples/ch30/isr.low

module isr .
rem flags: --target cortex_m

proc on_exti
  vector 6 .
  priority 2 .
  output void .
  effects device .
do
  return .
end

Output

$ lowentc --check isr.low
== check: ok ==

This example is checked with --target cortex_m. The machine calls interrupt handlers, so they keep four rules.

RuleWhen brokenWhy
Nobody calls itE-ISR-CALLEDCalled from our code it runs on the wrong stack and priority
No parametersE-ISR-PARAMSThe machine passes no arguments
Returns nothingE-ISR-OUTPUTThere is nobody to return to
Writes effects deviceE-ISR-EFFECTIt exists because of a device

Table 30.1 — What an interrupt handler must keep

examples/ch30/isr_called.low

module isr_called .
rem expect: E-ISR-CALLED

proc on_exti
  vector 6 .
  output void .
  effects device .
do
  return .
end

proc impatient output void . effects device .
do
  on_exti .
end

Output

$ lowentc --check isr_called.low
isr_called.low:14:0 E-ISR-CALLED: nobody calls an interrupt handler — the HARDWARE does. Calling it from Lowent code runs it on the wrong stack, at the wrong priority, with interrupts in the wrong state (RFC-0042 D5)

State shared by interrupt handlers and ordinary code moves by the discipline of priorities and queues. That discipline is a library’s job, not the language’s. The spsc ring buffer, through which one producer and one consumer pass values without locks, is used there (chapter 34).

30.4 What a machine supports — build tier#

build tier <name> . says what this machine supports. Each tier fixes the effects that may be used.

TierWhich machineNewly supported effects
t0Very small machine, no operating systemnone unsafe panic state wait cancel
t1Small machineThe above plus io device
t2Real-time operating systemThe above plus alloc lock atomic blocking concurrent
t3Machine with an operating systemThe above plus heap page_fault detach — that is, everything

Table 30.2 — Tiers and the effects they support

examples/ch30/tier.low

module tier .
rem expect: E-TIER-EFFECT

build tier t1 .

proc scratch input al cap allocator . output u64 . effects alloc .
do
  let g option mut slice u8 be alloc_bytes al capacity 16 .
  guard is_some g . else return 0 .
  return len (some_value g) .
end

Output

$ lowentc --check tier.low
tier.low:6:0 E-TIER-EFFECT: this effect is not available at the declared concurrency TIER. The tier says what the HARDWARE CAN CARRY — a thread pool on an ATtiny is not a runtime problem, it is a COMPILE ERROR (RFC-0039 D4). And because effects PROPAGATE along the call chain, the refusal lands where you CALL it, not at link time

A t1 machine was declared unable to support allocation (alloc), so it is rejected. This is not a matter of slowing down at run time; it simply cannot be loaded onto that machine. Effects rise along the call chain, so the rejection also happens at calling sites, not only where the effect first arises. Without a tier it is t3, and nothing is blocked.

Tier (what is supported) and profile (chapter 25 — which concurrency arrangement is used) are different axes. There are small machines with operating systems and large ones without.

A common misconception. Giving --target cortex_m sets the tier automatically

The target (--target) decides machine code and machine.* constants (whether there is a heap, and so on), which is why the heap is rejected by target alone (chapter 18). A tier is a promise written in the source. On the same target, writing t0 can block even input/output. Only the one who writes a promise carries it, and the processor does not invent promises nobody wrote.

30.5 Machine instructions — asm#

Some things portable code cannot reach: privileged instructions, system calls, exact cycle counts. There, an op’s body is written in machine instructions.

examples/ch30/asm.low

module asm_add .

unsafe proc add_native
  input k cap machine .
  input a u64 .
  input b u64 .
  output u64 .
  effects unsafe .
  asm x86_64 .
    reg a .
    reg b .
    out reg r .
    clobber flags .
    options pure nomem nostack .
do
  text ASM
    mov {a}, {r}
    add {b}, {r}
  ASM
end

Output

$ lowentc --check asm.low
== check: ok ==

The processor cannot read inside the template. But the operand list and the template’s {name}s are two expressions of the same thing, so each is checked against the other.

examples/ch30/asm_unbound.low

module asm_unbound .
rem expect: E-ASM-UNBOUND

unsafe proc add_native
  input k cap machine .
  input a u64 .
  input b u64 .
  output u64 .
  effects unsafe .
  asm x86_64 .
    reg a .
    reg b .
    out reg r .
    clobber flags .
do
  text ASM
    mov {a}, {r}
    add {c}, {r}
  ASM
end

Output

$ lowentc --check asm_unbound.low
asm_unbound.low:4:0 E-ASM-UNBOUND: the template names an operand the `asm` clause never declared — the assembler would either reject it or, worse, read WHATEVER register happens to be there

If the template names an undeclared {c}, the assembler rejects it or, worse, just reads whatever register was there. Conversely, a declared operand the template does not use is rejected too (E-ASM-UNUSED). options pure promises “no side effects”, so the processor may trust it and delete or merge calls; if the effects line says it touches input/output or devices, both cannot be true, so it is rejected (E-ASM-OPTLIE). The VM cannot run machine instructions and says so (E-VM-ASM). It runs only natively.

30.6 The absorbing boundary — where unsafe stops#

The four guards of the previous section carry a price: effects unsafe travels with every call. Write one line of assembly and the op that calls it must declare unsafe, and so must its caller, all the way up. That is why machine instructions were never used in the standard library (measured 2026-09-23: zero asm in lib/‘s sixty-four modules).

The travelling is right — if the caller does not know about work the processor cannot see, the effect row is a lie. What was missing was a place that takes responsibility.

export proc add2 input a u64 . input b u64 . output u64 . effects none .
  absorbs machine k .          rem it stops here; `k` is this body's `cap machine`
  reference add2_soft .        rem a pure version that must give the same answer
  why "it adds two registers and touches no memory (options pure nomem nostack)." .
  requires ge a 0 .
do
  return asm_add2 k a b .
end

As a call chain, the difference looks like this.

 without absorbs                           with absorbs
 main      effects unsafe  ▲               main      (nothing to write)
 add2      effects unsafe  │ spreads up    add2      absorbs machine k ◀ stops here
 asm_add2  effects unsafe  │               asm_add2  effects unsafe

Leave a prerequisite out and it is refused: a non-empty effect row is E-ABSORB-IMPURE, a missing reference implementation E-ABSORB-NOREF, a missing requires E-ABSORB-NOCONTRACT, an empty why E-ABSORB-NOWHY.

Who may absorb is the manifest’s call. Absorption says a human vouches, so the source that wants the right cannot grant it to itself. Only a module named in pkg.low with build absorb <module> . may use the clause; otherwise it is E-ABSORB-PLACE. With no manifest at all, nobody may absorb.

The tool names the places. lowentc --absorbs f.low prints, one line each, which op absorbed what and why. A region the tool cannot see is made visible, not hidden.

  add2                      absorbs machine as `k`  ref=add2_soft  line 40
      why: it adds two registers and touches no memory…
absorbs: 1 op(s) stop `unsafe` here

Timing is written down, not checked. Whether the code takes value-dependent time is something the tool cannot verify. So the absorb registry has a timing column a human fills in, and leaving it empty fails the gate — «unknown» is a valid answer.

Why demand a reference implementation

Without a pure version that computes the same answer there is no way to tell «right» from «consistently wrong». It happened here: a GHASH rewritten with machine instructions passed the seal-then-open test while computing a different product entirely. Sealing and opening share the code, so the round trip holds as long as it is self-consistent. What caught it was comparing against the plain computation.

30.7 Using what the machine has — --hw#

Whether to use the instructions is the builder’s choice.

What you pickWhat happens
--hw none (default)everything in plain code; stands on any machine
--hw pclmul,aes,sse2,avx2emitted assuming those instructions; will not run where they are missing
--hw autocarry them all and choose once at start; stands anywhere, fast where the instructions exist. Includes VAES and VPCLMULQDQ (two blocks per ymm register) — on first use they are checked against the AES-NI path and left unused if they differ

Table 30.3 — What --hw chooses

What is carried is not only an instruction that replaces a computation. What sse2 and avx2 give is width — the room to put four or eight independent pieces of work side by side in one register, which is exactly the shape of ChaCha20′s blocks. The rules are the same: the answer does not change, a machine that lacks the set cannot carry it, and the VM always runs the plain code.

30.8 Common mistakes#

Counter-example. Reading a write-only register to check what was just written

examples/ch30/mistake_woread.low

module mistake_woread .
rem expect: E-MMIO-PERM

struct gpio do
  mmio 0x40020000 .
  moder u32 rw .
  idr u32 ro .
  bsrr u32 wo .
end

unsafe proc last_set input dev cap mmio . input regs mut slice u8 . output u32 . effects device unsafe .
do
  var g gpio be view gpio regs .
  rem ✘ reads a write-only register to check what was just written
  return read_volatile g bsrr .
end

Output

$ lowentc --check mistake_woread.low
mistake_woread.low:15:0 E-MMIO-PERM: this register is WRITE-ONLY (`wo`) — reading it is a compile error. A wo register often reads as garbage (or has a read side effect), so the read is not merely useless: it is wrong

For an ordinary variable, writing and reading back is a good habit, but reading a write-only register yields garbage or makes the device do something. Hence E-MMIO-PERM. If you need the value you just wrote, keep it in a local before writing, and check the device’s real state through the read register the datasheet defines (here, idr).

Counter-example. Giving an interrupt handler parameters

examples/ch30/mistake_isrparams.low

module mistake_isrparams .
rem flags: --target cortex_m
rem expect: E-ISR-PARAMS

rem ✘ tries to receive which pin fired as an argument --- the hardware passes no arguments
proc on_exti
  vector 6 .
  input pin u32 .
  output void .
  effects device .
do
  return .
end

Output

$ lowentc --check --target cortex_m mistake_isrparams.low
mistake_isrparams.low:6:0 E-ISR-PARAMS: an interrupt handler takes NO parameters — the HARDWARE calls it, and hardware does not pass arguments. Shared state goes through the priority/queue discipline (RFC-0039 D1c), not through a parameter list

The hardware passes no arguments when it calls a handler. Which pin fired is learned inside the handler by reading the device’s status register. Values shared with ordinary code travel through the priority and queue discipline (the spsc ring buffer). Hence E-ISR-PARAMS.

Counter-example. Leaving an interrupt handler’s effects line empty

examples/ch30/mistake_isreffect.low

module mistake_isreffect .
rem flags: --target cortex_m
rem expect: E-ISR-EFFECT

rem ✘ the effects line is left out because there is nothing to do yet
proc on_exti
  vector 6 .
  output void .
do
  return .
end

Output

$ lowentc --check --target cortex_m mistake_isreffect.low
mistake_isreffect.low:6:0 E-ISR-EFFECT: an interrupt handler must declare `effects device` — it exists because the device asked for it

Even with nothing to do yet, an interrupt handler exists because of a device. Writing effects device . keeps in the head the fact that this op is device-side code, and the tier (build tier) and capability checks follow that fact. Without it, E-ISR-EFFECT.

Counter-example. Spelling the asm machine name the way another toolchain does

examples/ch30/mistake_asmtarget.low

module mistake_asmtarget .
rem expect: E-ASM-TARGET-UNKNOWN

unsafe proc add_native
  input k cap machine .
  input a u64 .
  input b u64 .
  output u64 .
  effects unsafe .
  rem ✘ the machine is spelt as another toolchain spells it (`aarch64`) --- this processor's name is `arm64`
  asm aarch64 .
    reg a .
    reg b .
    out reg r .
do
  text ASM
    add {r}, {a}, {b}
  ASM
end

Output

$ lowentc --check mistake_asmtarget.low
mistake_asmtarget.low:4:0 E-ASM-TARGET-UNKNOWN: this `asm` clause names something that is not a build target. The names are a CLOSED set — the target table is the authority (x86_64 · arm64 · cortex_m · riscv64 · mips_be). A name outside it matches NO build, so the op would be silently dropped or silently mis-built; both are what this RFC exists to prevent

GCC and LLVM say aarch64, but this processor’s target name is arm64. The names are a closed list (x86_64·arm64·cortex_m· riscv64·mips_be), and a name outside it matches no build, so the op would never be built. Instead of skipping it silently, the tool stops with E-ASM-TARGET-UNKNOWN.

Counter-example. Reading and writing registers as ordinary fields

examples/ch30/mistake_plainfield.low

module mistake_plainfield .
rem expect: E-MMIO-PLAIN

struct gpio do
  mmio 0x40020000 .
  moder u32 rw .
  idr u32 ro .
  bsrr u32 wo .
end

unsafe proc drive input dev cap mmio . input regs mut slice u8 . output u32 . effects device unsafe .
do
  var g gpio be view gpio regs .
  rem ✘ writes and reads registers as ordinary fields --- these read as accesses that may be merged or removed
  set (field g moder) 2 .
  return field g idr .
end

Output

$ lowentc --check mistake_plainfield.low
mistake_plainfield.low:15:0 E-MMIO-PLAIN: this is a DEVICE register, and this reads/writes it like ordinary memory. An ordinary access is one the translator may merge or delete (two reads of the same field become one; a store nothing reads goes away) — for a device the access ITSELF is the work, so deleting it makes the hardware do the wrong thing. Write `read_volatile <block> <reg>` or `write_volatile <block> <reg> <value>`, which say exactly once, in the written order (RFC-0042 D1). The `ro`/`wo` permission is checked there too

set (field g moder) 2 and field g idr are refused (E-MMIO-PLAIN). Ordinary field access is an operation the processor may merge or remove — two reads of one register becoming one read, or a store nothing reads going away, change nothing in ordinary memory. On a device the access itself is the work, so the answers change. For device registers write read_volatile and write_volatile, which is also where the access modes (ro·wo) are checked.

30.9 This chapter’s syntax at a glance#

ShapeMeaningWhy
struct gpio do mmio 0x40020000 . moder u32 rw . … enda device’s register mapone clause on a struct, no new words
rw · ro · woaccess modes — enforced at translationviolations: E-MMIO-PERM
read_volatile g idr · write_volatile g moder 2access that is never merged or removedreading is itself an action
input dev cap mmio . + effects devicedevice capability and effectno hardware access without authority
var g gpio be view gpio regs .lay the map over bytestaking a device by value: E-MMIO-BYVALUE
proc on_exti vector 6 . priority 2 . output void . effects device .an interrupt handlercalling it: E-ISR-CALLED · arguments: E-ISR-PARAMS
build tier t1 .the tier of effects this machine can bearwhat cannot be carried is stopped at translation
asm x86_64 . reg a . out reg r . clobber flags . options pure .head of a body written in machine codeconfined by unsafe, cap machine, effects line, machine name
text ASM … {a} … ASMthe template — checked against the operandsE-ASM-UNBOUND · E-ASM-UNUSED · E-ASM-OPTLIE

Table 30.4 — Hardware syntax — shape · meaning · why it looks this way

Recap

A struct with mmio <address> is a device’s map, and read_volatile and write_volatile are accesses never merged or deleted. They need cap mmio and the device effect, the ro and wo markers are enforced at translation, and devices cannot be received by value. An op with a vector clause is an interrupt handler that can neither be called nor take arguments. build tier fixes the effects a machine supports. The asm clause confines machine instructions with unsafe, cap machine, an effects line and a machine name, and checks operands and template against each other.