30 Hardware — registers, interrupts, machine instructions
What to know first
layout, view and field access markersLooking 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
By the end of this chapter
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
- C adds the
volatilequalifier. 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]
struct gpio do mmio 0x40020000 . … endis the device’s map. The number aftermmiois the start address, and the fields become registers in order. There is no new word; a struct just gained a clause.rw,roandwoafter a field are its access.read_volatileandwrite_volatilereach a register. The processor does not merge, delete or reorder these accesses. Reading a device register is itself work and can change state.- To reach registers, receive
cap mmioand writeeffects device. Hardware access without a capability is exactly the ambient-authority problem seen with allocators. build tier t1 .declares that this module is for a small machine (covered below).
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 1Q. 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.
| Rule | When broken | Why |
|---|---|---|
| Nobody calls it | E-ISR-CALLED | Called from our code it runs on the wrong stack and priority |
| No parameters | E-ISR-PARAMS | The machine passes no arguments |
| Returns nothing | E-ISR-OUTPUT | There is nobody to return to |
Writes effects device | E-ISR-EFFECT | It 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.
| Tier | Which machine | Newly supported effects |
|---|---|---|
t0 | Very small machine, no operating system | none unsafe panic state wait cancel |
t1 | Small machine | The above plus io device |
t2 | Real-time operating system | The above plus alloc lock atomic blocking concurrent |
t3 | Machine with an operating system | The 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
--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
asm x86_64 .clause fixes the machine. Assembly for a machine other than the one being built for is rejected. The processor does not pretend it is portable. reg a .loads inputainto a register andout reg r .receives the result.clobber flags .states what is clobbered, andoptions …states promises made to the processor.- The body is a single
text ASM … ASMheredoc. Assembly and ordinary code cannot be mixed. - There are four confinements — the
unsafemark,cap machine,effects unsafeand the machine name.
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- Callers of this op write nothing. That is the whole value of absorption.
absorbs machine <name>makes that name acap machineinside the body. This is where that right is born —machineis not in the list an entry point may receive.- Only
machinemay be absorbed. Capabilities that touch the world (io, C, heap) may not (E-ABSORB-SCOPE): minting one would create authority the caller cannot see.
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 unsafeLeave 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` hereTiming 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
30.7 Using what the machine has — --hw#
Whether to use the instructions is the builder’s choice.
| What you pick | What happens |
|---|---|
--hw none (default) | everything in plain code; stands on any machine |
--hw pclmul,aes,sse2,avx2 | emitted assuming those instructions; will not run where they are missing |
--hw auto | carry 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.
- The answer is the same either way. What differs is speed and timing behaviour — a computation that reads tables reads at a value-dependent place; these instructions do not.
- Asking for an instruction set the target does not have is refused (
E-HW-TARGET). There is no silent fallback: the builder must know what will run. - The VM always runs the plain code, so this repository’s oracle — «do the VM and native agree?» — is also the test for the machine path.
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#
| Shape | Meaning | Why |
|---|---|---|
struct gpio do mmio 0x40020000 . moder u32 rw . … end | a device’s register map | one clause on a struct, no new words |
rw · ro · wo | access modes — enforced at translation | violations: E-MMIO-PERM |
read_volatile g idr · write_volatile g moder 2 | access that is never merged or removed | reading is itself an action |
input dev cap mmio . + effects device | device capability and effect | no hardware access without authority |
var g gpio be view gpio regs . | lay the map over bytes | taking a device by value: E-MMIO-BYVALUE |
proc on_exti vector 6 . priority 2 . output void . effects device . | an interrupt handler | calling it: E-ISR-CALLED · arguments: E-ISR-PARAMS |
build tier t1 . | the tier of effects this machine can bear | what 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 code | confined by unsafe, cap machine, effects line, machine name |
text ASM … {a} … ASM | the template — checked against the operands | E-ASM-UNBOUND · E-ASM-UNUSED · E-ASM-OPTLIE |
Table 30.4 — Hardware syntax — shape · meaning · why it looks this way
Recap
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.