Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

System call reference

A process reaches the kernel in one way: an ecall with a call number in a0 and its arguments in a1-a7. This page is the reference for that interface: the register layout, which is the same on rv32 and rv64; every call’s number, arguments and result; the records that carry what does not fit in registers; the error codes; and the exact order in which the kernel checks a call’s arguments. The crate redoubt-sys (libs/sys) is the encoding, shared by the kernel and every process; what each call does is explained on the page linked from its row.

Purpose

The system call interface is the whole of the kernel’s surface as a process sees it. Every action an agent takes, hostile or not, arrives as eight registers and perhaps a record. So the interface has three jobs: give every value exactly one encoding, so nothing can hide in a register the kernel ignores; refuse every malformed value with an error before the kernel acts on it (I14 (no call panics the kernel)); and report the same first error that the executable model reports, so a trace means the same thing to both.

One register layout on both widths

Status: built · partly tested: that the kernel preserves the registers outside `a0`-`a7` is not attacked by a case · tested (4)
  • host:redoubt-sys::every_call_round_trips
  • host:redoubt-sys::every_result_and_error_round_trips
  • host:redoubt-sys::random_registers
  • bench:budget-syscall-attack

A call is an ecall from user mode with a0 = the call’s number and its arguments in a1, a2, … in the order its row lists them. The kernel answers in the same eight registers: a0 = 0 and the result in a1 onward, or a0 = an error code and a1-a7 = 0. call is the one exception: its lend and reply dispositions are in a1 and a2 on every return, errors included (IPC). The kernel preserves every other register of the thread.

No register ever holds more than 32 bits or one usize, so rv32 carries every encoding and redoubt-sys has no width cfg in any of them. USER_AREA_END, the end of user space (memory layout), is its one per-width constant.

Argument kindRegistersRule
address, length, word (usize)1
64-bit value (ids, badges, accounts, time, random’s value, a timeout)2: low half, then high halfeach half fits in 32 bits
small value (u32: tid, pid, exit code, count)1fits in 32 bits
mapping flags1only READ (1), WRITE (2), EXECUTE (4); never WRITE with EXECUTE
handle1an index, 1 to 2^32 - 1
optional handle10 is none (index 0 is never allocated)
message id, badge2: low half, then high halfnever 0 as an argument
optional page run (lend, transfer)2: address, then pages(0, 0) is none; exactly one of them 0 is InvalidArgument
tag (reset kind, mint source)1numbered from 1; 0 is never a valid tag

Registers a call does not use must be 0. Timeouts are relative microseconds; the kernel adds one to the current time with saturation, so FOREVER (2^64 - 1) never wraps and never expires (timer, I13 (every blocking call returns by its timeout)). A budget deadline is absolute, in microseconds since boot, and FOREVER means none.

Registerecall: callreturn from call
a00x10e, the number of call0, or the error code
a1endpoint handlelend: 0 none, 1 returned, 2 consumed
a2body record addressreply: 0 absent, 1 present
a3lend address, 0 for none0
a4lend pages, 0 for none0
a5timeout, low 32 bits0
a6timeout, high 32 bits0
a70, unused0
Table: register use for call and its return. The 64-bit timeout takes two registers on both widths.

On the process’s side a call travels through a transport (redoubt_sys::Transport): on the machine Ecall, the ecall itself (the crate’s only unsafe code), whose result redoubt_sys::decode_result reads; the runtime takes whichever transport is installed, the ecall by default. decode_result refuses any result the kernel could not have produced: an unknown code, a non-zero register the result does not use, a value too wide for its field, and for call and reply a disposition that contradicts the status (IPC).

Call numbers and arguments

Status: built · tested: host:redoubt-sys::every_call_round_trips, host:redoubt-sys::numbers_and_codes_are_dense_from_one, host:redoubt-sys::malformed_calls_are_refused

A call’s number is NUMBER_BASE (0x100) plus its place in the table, from 1. The numbers are dense, and a number once given is never reassigned: a call added to the table takes the next one. “(2)” marks a 64-bit value in two registers. A field named ..._rec is the address of a record.

a0CallArguments, from a1Result, from a1Explained in
0x101map_anonlen, flagsaddressmemory
0x102unmapaddress, len-memory
0x103set_flagsaddress, len, flags-memory
0x104map_devicedevice handleaddress, lengthdevices
0x105dma_allocdevice handle, pagesaddress, physical address (2)devices
0x106thread_createentry, stack pointer, argumentTIDprocesses
0x107thread_exit-does not returnprocesses
0x108process_exitcodedoes not returnprocesses
0x109process_createbudget handle, exit endpoint handleprocess handleprocesses
0x10aprocess_mapprocess handle, source, destination, len, flags-processes
0x10bprocess_startprocess handle, entry, stack pointer, argument, handles_rec, count-processes
0x10cendpoint_create-endpoint handle (the receive right, badge 0)objects
0x10dmintsource tag (1 message id, 2 handle), source value (2), badge (2), budget handle or 0handleobjects
0x10ecallendpoint handle, body_rec, lend address, lend pages, timeout (2)a0 status; lend and reply dispositionsIPC
0x10fsendendpoint handle, body_rec, transfer address, transfer pages, timeout (2)-IPC
0x110receiveendpoint or IRQ handle or 0, timeout (2), max_transfer (pages), received_reca receive recordIPC
0x111replymessage id (2), body_recdelivered (1) or discarded (0), installed-handle maskIPC
0x112servemessage id (2)-IPC
0x113handle_closehandle-objects
0x114budget_createparent budget handle, spec_recbudget handlebudgets
0x115budget_destroybudget handle-, or does not return if the caller’s own budget is in the subtree, or its process object is charged therebudgets
0x116budget_usagebudget handle, usage_reca usage recordbudgets
0x117time_now-microseconds since boot (2)timer
0x118random-one value from the kernel’s CSPRNG (2)boot
0x119system_resetReset device handle, kind (1 power off, 2 reboot)does not return on successdevices
0x11amap_fixedaddress, len, flags-memory
0x11bdevice_infodevice handlekind (1 MMIO, 2 IRQ, 3 Reset), a (2), b (2), flags: the device’s Devs entrydevices
0x11cbudget_reapbudget handlethe children the budget still has, or does not return if the caller’s own budget is in the reaped child’s subtree, or its process object is charged therebudgets
0x11dconsole_holdconsole MMIO device handle, hold (1 take, 2 give back, 3 give back with the caller’s line unfinished)a take: 1 if a kernel line ended the caller’s unfinished line since it last gave the hold back, else 0; a give-back: 0devices

thread_create’s and process_start’s argument reaches the new thread unchanged in its first argument register; the kernel does not check it, nor the entry or stack pointer. A usize result (an address, a length) is one register; a handle or TID is one register holding at most 32 bits. device_info’s a and b are 64-bit on both widths, as in the Devs entry, because an Sv32 physical address has 34 bits; a field its kind does not use is 0 (boot).

Records

Status: built · tested (8)
  • host:redoubt-sys::records_round_trip
  • host:redoubt-sys::malformed_records_are_refused
  • host:redoubt-sys::random_records
  • fuzz:redoubt-sys/decode
  • bench:budget-syscall-attack
  • bench:ipc-outcomes
  • bench:process-attack
  • bench:map-fixed-attack

Layouts

A record is what does not fit in seven registers: a fixed-length array of 64-bit little-endian slots in the caller’s memory, passed by address, at an 8-byte-aligned address. Slot n is at byte 8n. The layout is the same on both widths. Unused slots must be 0. A usize field must fit the target’s usize: on rv32 a slot above 2^32 - 1 there is InvalidArgument. A list (labels, handles) is a count, then its capacity’s slots, the unused ones 0.

RecordSlotsLayoutUsed by
body9 (BODY_SLOTS)words 0-3; handle count (at most MAX_MSG_HANDLES (4)); handles 0-3call (request in, reply out), send, reply
receive record32 (RECEIVED_SLOTS)belowreceive (out)
budget spec22 (BUDGET_SPEC_SLOTS)pages; processes; weight; label count (at most MAX_LABELS (16)); labels 0-15; account; deadlinebudget_create (in)
usage6 (USAGE_SLOTS)page limit; pages used; process limit; processes used; weight limit; weight carved to childrenbudget_usage (out)
handle listthe call’s count, at most MAX_START_HANDLES (128)one handle per slotprocess_start (in)

In a body going in, every handle within the count is a handle; a slot of 0 there is BadHandle. In a body coming out (the reply call writes back over its request, and a message’s body), a handle slot within the count may be 0 and keeps its place: a handle revoked while its message was queued (R10 (destruction)), or a reply handle the caller could not take (R4 (delivery)).

SlotBody: call, send, replySlotReceive record: what receive writes
0 to 3words 0 to 30kind
4handle count, 0 to 41msg_id
5 to 8handles 0 to 32badge
3account
4label count, 0 to 16
5 to 20labels 0 to 15
21 to 24words 0 to 3
25handle count, 0 to 4
26 to 29handles 0 to 3
30buffer address
31buffer pages
*Table: record slot layout of a body and of the receive record, the same on rv32 and rv64. Slot n
is at byte 8n, and every unused slot is 0.*

The record check

Before a call uses a record, the kernel checks it whole: the address is 8-byte aligned, and every slot lies in user space, in a page that is mapped, backed, readable (and writable, for a record the kernel writes), not either side of a lend, RAM, and credited to the caller in the kernel’s frame ownership table. Anything else is InvalidArgument. A page the caller was lent is its lender’s, so it is not a record; nor is a device mapping, which is not RAM. call’s body is checked readable and writable before anything is delivered, because the reply comes back into it (R13 (one outcome per call)).

The check never allocates. A page the caller reserved but never touched is InvalidArgument, not a page the kernel backs and charges in the middle of decoding. The Rust runtime’s records are written stack arrays (libs/rt/src/sys.rs, Record, 8-byte aligned), so every record it passes is backed.

A record may overlap the pages a call acts on. The kernel copies a record in before it changes those pages, and writes results only to memory still the caller’s: a call body may lie inside its own lend, and the lend is the caller’s again before the reply is written into it.

The kernel holds its memory lock from the check through the copy, so no other thread of the process can unmap or remap a record between the two. A record the kernel writes after a call has blocked (receive’s, or call’s reply) is checked again at that point (IPC).

The receive record

Status: built · tested (4)
  • host:redoubt-sys::received_layout
  • host:redoubt-sys::malformed_records_are_refused
  • host:redoubt-sys::random_records
  • fuzz:redoubt-sys/decode

receive writes one layout for every result: (kind, msg_id, badge, account, labels, words, handles, buffer, pages), in the slots of the figure above. Slot 0 is the kind; a field the kind does not use is 0. What each kind means is on the IPC page.

KindSlot 0Slots it fills
call1all; the buffer is the lend, at the address the kernel chose
send2all; the buffer is the transfer
interrupt3none
exit43 (blamed account), 4-20 (blamed labels), 21-23 (pid, cause, code)
abandoned51 (the abandoned call’s message id)

An exit’s cause is 1 exited, 2 faulted, 3 killed (processes). Timeout is an error, not a record. A record carries no handle kinds: a handle’s kind is checked when it is used, and the wrong kind is WrongObject.

Received::decode reads the kind first, and refuses any non-zero slot outside the fields that kind fills, a list’s count included, before it reads a field. So a process decoding a record never mistakes a notice for a message.

Errors and their codes

Status: built · tested (4)
  • host:redoubt-sys::numbers_and_codes_are_dense_from_one
  • host:redoubt-sys::every_result_and_error_round_trips
  • host:redoubt-sys::error_rows
  • bench:budget-syscall-attack

One error enum serves every call. Its code travels in a0; 0 there is success, so codes start at 1.

CodeErrorMeans
1BadHandlea handle that is 0 where one is required, wider than 32 bits, or not in the caller’s table
2WrongObjectthe handle names the wrong kind of object for this call
3InvalidArgumenta malformed or out-of-range value: an unknown number, tag or flag bit, a bad range, a bad record, a message id that is not an open call
4OutOfMemorythe paying budget’s page limit, or no room in the address space, no free frame, no free DMA run
5OutOfProcessesa budget’s process limit
6TooManyThreadsthe process has MAX_THREADS (255) threads
7NotPermittedthe right object, without the standing: a badged handle where badge 0 is needed, a started process, a mint budget outside the default stamp, dma_alloc on a device without DMA
8ClassDeniedlabels added by a caller whose budget is not of class system
9LabelDenieda flow R1 (flow) forbids
10Busythe sender’s group already has WAIT_CAP (32) messages queued on the endpoint (R2 (fair waiting))
11Refusedthe receiver’s budget cannot pay for the message (R4)
12TooLargea count over its fixed limit: body handles, labels, the start list, a lend over MAX_LEND_PAGES (16), budget depth, MAX_HANDLES (4096)
13Timeouta blocking call’s timeout passed
14Deadthe object the call needs is gone: its endpoint was destroyed, its server died, or the handle it came through was revoked

Each call can return only the errors of its row in the table below, plus InvalidArgument for a malformed encoding; Number::can_return in redoubt-sys is that set. The kernel checks every error it returns against it in checked builds, so a case built with debug assertions fails on an error outside its row.

Inside the kernel, the page tables and the frame ownership table report failures in their own enum (PageError in kernel/src/mem.rs), which never reaches a process. Each call maps it to the error its row names, explicitly, at the call’s boundary; there is no automatic conversion. So a lent page and an exhausted budget, which the page layer tells apart, cannot collapse into one wrong code on their way out.

Errors and the order of checks

Status: built · partly tested: for most rows the order after decoding is argued from the code rather than pinned by a case; the model follows the kernel's order, but no trace has been replayed on the kernel (Residual risks) · tested (15)
  • bench:budget-syscall-attack
  • bench:syscall-attack
  • bench:ipc-outcomes
  • bench:process-attack
  • bench:process-map-untouched-attack
  • host:redoubt-sys::malformed_calls_are_refused
  • host:redoubt-model::every_call_and_error_is_reached
  • host:redoubt-model::process_map_destination_validation_precedes_started_state
  • host:redoubt-model::a_budget_spec_decodes_in_slot_order
  • host:redoubt-model::receive_clears_the_current_call_before_its_record_check
  • host:redoubt-model::every_page_of_a_record_is_checked
  • host:redoubt-model::a_device_has_32_dma_runs
  • host:redoubt-model::map_anons_placement_area_is_256_mib
  • host:redoubt-model::map_fixed_refuses_pages_it_cannot_pay_for_before_an_overlap
  • fuzz:redoubt-sys/decode

A call with several faults returns the first one found, in a fixed order, so that the kernel, the model and a replayed trace agree exactly. Checks go in stages, and within a stage by argument position:

  1. Decoding. The registers in order, a1 first; then each record the call passes: its alignment, then the record check slot by slot, then its slots in order. A required handle that is 0 or wider than 32 bits is BadHandle; a list longer than its limit is TooLarge; anything else malformed is InvalidArgument (an unknown call number, tag or flag bit, W+X flags, a value too wide for its field, a non-zero unused register or slot, a record that fails the check, a message id or badge of 0). These three are a classification, not a sequence: the first malformed value in register, then slot, order wins. Each register is checked whole when it is reached; unused registers come after the last argument. This stage never allocates, so it never returns OutOfMemory.
  2. Arguments, one by one: a handle exists (BadHandle) and names the right kind of object (WrongObject); a size is within its fixed limit (TooLarge); a range is page-aligned, non-empty, inside user space and mapped as the call needs (InvalidArgument).
  3. Permission: NotPermitted, ClassDenied, LabelDenied.
  4. Resources: OutOfMemory, OutOfProcesses, TooManyThreads, Busy. A call that adds a handle to its caller’s table (endpoint_create, mint, process_create, budget_create) checks the table last: OutOfMemory when it needs a new table page the caller’s budget cannot pay for, TooLarge when it already holds MAX_HANDLES.
  5. At delivery (call, send, receive, whether they blocked or not): Refused, Timeout, Dead; for call also InvalidArgument (the reply record can no longer be written) and OutOfMemory (reply handles that do not fit); for receive also InvalidArgument (its record can no longer be written). Decoding already refused records that were bad from the start; these cover memory that changed while the call waited.

Handle kinds are checked by use: no call reports a handle’s kind, and the wrong kind is WrongObject at the first call that needs another. Two checks of stage 1 are made again by the code that acts on them, so neither rests on one check: W+X flags (by the mapping code, R11 (memory)) and a mint badge of 0 (by mint, I3 (minted badges are non-zero and narrow)).

Per call, in the order checked. “Then” lists stages 2 to 5; “each handle” is every handle of a list, in order.

CallDecodingThen
map_anonflags: InvalidArgumentInvalidArgument (len 0 or not page-aligned), InvalidArgument (flags 0, or W without R), OutOfMemory (no room in the placement area; then each page and its page tables)
unmap-InvalidArgument (range: len 0, not page-aligned, wraps, or past USER_AREA_END), InvalidArgument (a page that is not a live mapping of the caller’s, is either side of a lend, or is RAM not credited to the caller; the whole range before any page goes)
set_flagsflags: InvalidArgumentInvalidArgument (range), InvalidArgument (flags 0), InvalidArgument (a page not the caller’s own mapping, as for unmap, or EXECUTE on a page of device registers or a dma_alloc page; the whole range before any page changes), InvalidArgument (W without R)
map_devicedevice: BadHandleBadHandle, WrongObject (not an MMIO device), OutOfMemory (no room in the placement area, or the page tables)
dma_allocdevice: BadHandleBadHandle, WrongObject (not an MMIO device), InvalidArgument (pages 0), NotPermitted (the device has no DMA flag), OutOfMemory (all 32 of the device’s runs in use; then the pages; then no contiguous frames; then the placement area and page tables)
thread_create-TooManyThreads, OutOfMemory (the thread’s page)
thread_exit--
process_exitcode wider than 32 bits: InvalidArgument-
process_createeach handle: BadHandleBadHandle, WrongObject (budget), BadHandle, WrongObject (exit endpoint), InvalidArgument (the budget’s free weight is 0), NotPermitted (the exit endpoint’s badge is not 0), OutOfProcesses (the budget’s process limit, which the new PID counts against; with room there a free PID exists), OutOfMemory (the budget: page tables; then the caller: the process object), handle table
process_mapprocess: BadHandle; flags: InvalidArgumentBadHandle, WrongObject, InvalidArgument (source or destination range), InvalidArgument (flags 0, or W without R), InvalidArgument (a source page unmapped, lent, not the caller’s own RAM, or a dma_alloc page; untouched source pages are counted, and backed once nothing can refuse the call; the caller’s budget too short to back them is this error), NotPermitted (the process has ended), InvalidArgument (a destination page occupied), NotPermitted (started), OutOfMemory (the child’s budget: page tables, and the pages, or only the untouched source pages when the budget is the caller’s)
process_startprocess: BadHandle; entry, stack pointer, argument: not checked; count over MAX_START_HANDLES: TooLarge; handle list: the record, then each slot BadHandleBadHandle, WrongObject, BadHandle (each handle), NotPermitted (started or ended), TooLarge (the child’s table past MAX_HANDLES), OutOfMemory (the child’s budget: its first thread and table pages)
endpoint_create-OutOfMemory (the endpoint’s page), handle table
mintsource: each half too wide InvalidArgument (both halves are read before the tag), then an unknown tag InvalidArgument, a message id of 0 InvalidArgument, or a handle of 0 or wider than 32 bits BadHandle; badge 0: InvalidArgument; budget: BadHandlesource: a message id that is not an open call of the caller’s thread (a send’s id included) InvalidArgument, its endpoint or stamp gone Dead; or a handle BadHandle, WrongObject (not an endpoint); budget: BadHandle, WrongObject; NotPermitted (a handle source’s badge is not 0), NotPermitted (the budget is not the default stamp or below it), handle table
callendpoint: BadHandle; lend: exactly one of address and pages 0 InvalidArgument; body: the record (read and written), handle count TooLarge, each handle slot BadHandleBadHandle, WrongObject (not an endpoint), BadHandle (each handle), TooLarge (lend over MAX_LEND_PAGES), InvalidArgument (lend not page-aligned, wraps, unmapped, lent, not the caller’s own writable RAM, or a dma_alloc page; untouched lend pages are backed first), LabelDenied (R1), Busy (R2); at delivery: Refused (R4), Timeout, Dead, InvalidArgument (the reply record cannot be written; reply handles rolled back, reply absent), OutOfMemory (reply handles that do not fit; the reply arrives without them, R4). Dispositions on every return
sendas call, with the transfer for the lend and the body read onlyas call, with no fixed limit on a transfer; at delivery only Refused (R4, a transfer over the receiver’s max_transfer included), Timeout, Dead
receivehandle (0 is none): BadHandle; the record (written)none given: sleep until Timeout; BadHandle, WrongObject (not an endpoint or an IRQ device), NotPermitted (badge not 0, I4 (only badge-0 handles receive)); at delivery: Timeout, Dead (endpoint destroyed), InvalidArgument (the record can no longer be written)
replymessage id 0: InvalidArgument; body: the record (read), handle count TooLarge, each handle slot BadHandleInvalidArgument (not an open call of the caller’s thread, a send’s id included), BadHandle (each handle); nothing after these fails
servemessage id 0: InvalidArgumentInvalidArgument (not an open call of the caller’s thread)
handle_closehandle: BadHandleBadHandle
budget_createparent: BadHandle; spec: the record (read), then its slots: processes or weight wider than 32 bits InvalidArgument, label count over MAX_LABELS TooLarge, a label slot past the count non-zero InvalidArgumentBadHandle, WrongObject, TooLarge (the child would be at depth MAX_DEPTH (16)), LabelDenied (the labels, sorted and deduplicated, are not a superset of the parent’s), ClassDenied (labels added by a caller not of class system), OutOfMemory (pages plus the budget’s own page over the parent’s free pages), OutOfProcesses (over the parent’s free processes), InvalidArgument (weight over the parent’s free weight, or all of it from a parent that holds a process; R7 (carving)), OutOfMemory (no free frame for the budget object), handle table
budget_destroybudget: BadHandleBadHandle, WrongObject
budget_usagebudget: BadHandle; counters: the record (written)BadHandle, WrongObject, LabelDenied (R1: a caller not of class system whose labels do not include the target’s)
time_now--
random--
system_resetdevice: BadHandle; kind: InvalidArgumentBadHandle, WrongObject (not the Reset device)
map_fixedflags: InvalidArgumentInvalidArgument (range: address or len not page-aligned, len 0, wraps, or past USER_AREA_END), InvalidArgument (flags 0, or W without R), OutOfMemory (the pages alone), InvalidArgument (overlaps any mapping or reservation of the caller’s, lent pages included), OutOfMemory (the pages and the page tables they need); nothing mapped or charged on failure
device_infodevice: BadHandleBadHandle, WrongObject (not a device object); nothing after these fails
budget_reapbudget: BadHandleBadHandle, WrongObject; nothing after these fails (a budget with no children returns 0)
console_holddevice: BadHandle; hold: InvalidArgumentBadHandle, WrongObject (not the console’s MMIO device), Busy (another process holds it); giving back a hold the caller does not have changes nothing

Every call can also fail decoding in the general ways of stage 1 (a non-zero unused register, a value too wide). “Handle table” is stage 4’s last check. Three rows depart from the stages, and say so: a weight over the parent’s free weight is InvalidArgument, because no error names weight; mint from a message whose endpoint or stamp is gone is Dead at the argument stage; and receive clears the thread’s current call as soon as its registers decode, before its record check and every later stage, whatever it then returns (R21 (crash blame)). A receive refused while its registers are decoded (a handle wider than 32 bits, a non-zero unused register) leaves the current call as it was.

map_fixed’s order keeps its cost bounded: the charge for the pages alone comes before any walk, so a huge length the budget could never pay for is refused by arithmetic, and the overlap walk that follows skips page-table subtrees that are absent (R22 (range cost)).

Unknown call numbers

Status: built · tested (4)
  • bench:legacy-gone
  • bench:budget-syscall-attack
  • host:redoubt-sys::malformed_calls_are_refused
  • host:redoubt-sys::numbers_and_codes_are_dense_from_one

Every value of a0 outside 0x101-0x11d is an unknown number: InvalidArgument in a0 and 0 in a1-a7. That includes 0, every number up to and including NUMBER_BASE, the first number past the table, and on rv64 a value with bit 32 or bit 63 set: the whole register is compared, never a truncated part of it. An unknown number carries no call dispositions, and nothing happens.

The trap handler sends every user-mode ecall to one decoder, redoubt::handle (kernel/src/redoubt.rs); there is no second dispatcher. The kernel’s scheduler enters its own trap handler with a private tag in a0 (SWITCH_TAG, kernel/src/sched.rs), by setting up the state of a supervisor-mode ecall and jumping to the trap vector (kernel/src/arch/riscv/syscall.rs); from user mode that value is an unknown number like any other.

bench:legacy-gone sweeps every number from 0 to NUMBER_BASE and the first past the table, with plausible arguments in a1-a7, and on rv64 each small number again with bit 32 and with bit 63 set. It checks every result is InvalidArgument with zeros, and that nothing such a call might ask for happened: no page appears at a fixed address, no callback runs when the console’s interrupt fires, and a child jumping to a fixed kernel return address faults.

Residual risks

  • No trace has been replayed on the kernel. The table above is the kernel’s, and the model follows it: host tests pin the rows where the two once differed (budget_create’s spec, receive’s current call, every page of a record, dma_alloc’s runs and the placement areas). A difference nobody has read for is found only by replaying traces on the kernel (model).
  • The order after decoding is mostly argued from the code. Cases pin the order of decoding, and of the first checks after it for budget_create, budget_usage, call, receive, serve and process_start; most later positions in most rows are read from the kernel, not attacked. A wrong order is a replay mismatch, not a way past a check: every check in a row is still made.
  • An error may leave a page backed. Stage 1 never allocates, but stage 2 backs untouched pages of a lend or a transfer before later checks run; if the call then fails (LabelDenied, Busy), those pages stay backed and charged to the caller, as if it had touched them (memory).

Why

  • One layout on both widths. A 64-bit value always takes two registers, so no encoding has a width cfg and one set of tests covers both targets. It costs rv64 an extra register for each 64-bit value.
  • One encoding per value. Unused registers and slots must be 0, and (0, 0) is the only “none”, so a malformed value cannot hide where the kernel does not look, and whatever decodes re-encodes to exactly its input (the fuzz target checks it). A trace of registers then means one thing.
  • Records are slots, not structs. An array of u64 slots has one layout on both widths and no padding, and the kernel copies it in once and decodes the copy, so another thread cannot change a value between its check and its use.
  • Decoding never allocates. An untouched record page is the caller’s error, so decoding can never fail for want of memory, never charges a budget halfway through, and needs no OutOfMemory in its stage.
  • A positional order. Checking in register order, stage by stage, makes the first error a function of the arguments alone, which is what lets the kernel, the model and a replayed trace agree exactly. BadHandle, TooLarge and InvalidArgument in decoding sort errors by kind; the position decides which one comes first.
  • Kinds checked by use. No call reports a handle’s kind and no record carries one: a query call would be more surface, and a process learns the kind the first time it uses the handle (WrongObject).
  • Numbers from 0x101. Small numbers are never calls, so a stray or guessed small a0, and a number from any other interface, lands on “unknown”. Numbers are appended, never reassigned, so a compiled program keeps working.
  • call’s dispositions on every return. A caller must know whether it still owns its lend even when an argument was bad, so the kernel sets them before decoding: none for a raw lend of (0, 0), returned otherwise (IPC).
  • A private error enum inside the kernel. Mapping the page layer’s errors to the ABI’s by hand at each call keeps each row’s errors a reviewed choice; an automatic conversion would let the wrong code leak out unnoticed.