Frames & the wall between us
The protocol brain never touches the hardware. Between the driver and the code that thinks about packets stands a wall made of copies — and that wall is the reason a hostile frame cannot reach the metal.
In Chapter 1 our ARP handler answered a question without ever seeing a register. That was not an accident. Everything that reads a packet in Alquist lives on the far side of a wall from everything that touches the wire. Today we look at the wall.
Two worlds, one cable
A network stack has two very different jobs, and they want very different things. The driver wants raw hardware: memory-mapped registers, DMA descriptor rings, cache maintenance, interrupts — the intimate, dangerous stuff that must be exactly right or the machine hangs. The protocol brain — ARP, ICMP, UDP, TCP — wants none of that. It wants a byte buffer and a length. It wants to parse, decide, and reply.
Alquist puts these on opposite sides of the exception-level boundary. The GENET Ethernet driver and a small frame pump run in the kernel, at EL1, and are the sole owner of the NIC. The entire protocol brain runs as an unprivileged service at EL0, with no memory map, no DMA, no way to name a hardware address at all. They meet only at a narrow, guarded crossing.
The copy-only channel
The crossing is two rings of fixed frame slots — one for received frames, one for frames to transmit — and the rule is absolute: nothing crosses the wall except copied bytes. No pointer that EL0 holds is ever dereferenced by the driver; no DMA buffer the hardware wrote is ever handed to EL0. A frame is copied at every step:
NIC (hardware)
| (1) driver copies out of the DMA buffer, releases the descriptor
v
RX ring (EL1-owned slots)
| (2) SYS_NET_RX_DEQUEUE copies one slot -> the EL0 buffer
v
EL0 protocol brain (ARP / ICMP / UDP / TCP)
| (3) SYS_NET_TX_SUBMIT copies the EL0 reply -> a TX slot
v
TX ring (EL1-owned slots)
| (4) driver copies the slot -> the NIC and sends
v
NIC (hardware)
Four copies to answer one packet. That sounds wasteful until you remember what it buys: the code that parses attacker-controlled bytes shares no memory with the code that drives the hardware. There is nothing to overrun into.
EL1 side: the pump
The pump is the only thing that ever speaks to the NIC. Each scheduler cycle it moves
a bounded number of frames in and out — never "drain until
empty," because on a single core an unbounded loop is a frozen machine
(src/network/pump.c):
static uint32_t net_pump_rx(void)
{
uint32_t moved = 0u, i;
for (i = 0u; i < NET_PUMP_RX_MAX; ++i) {
struct eth_rx_frame rx_frame;
if (!eth_genet_poll_frame(&rx_frame))
break; /* no more from the NIC */
if (rx_frame.frame_length >= NET_ETH_HEADER_SIZE
&& rx_frame.frame_length <= NET_FRAME_MAX)
(void)net_rx_push(&rx_frame.buffer[ETH_RX_ALIGN_BYTES],
rx_frame.frame_length);
eth_genet_release_frame(&rx_frame); /* EL0 only ever sees the copy */
++moved;
}
return moved;
}
net_rx_push() is where the byte copy happens: it drops the frame into the
next RX slot with net_copy_bytes() and advances the ring. If the ring is
full it does not block and it does not overwrite — it increments a
drops_full counter and discards the frame. A slow protocol brain costs
you dropped packets, never a corrupted kernel.
EL0 side: dequeue, and the four laws of the crossing
EL0 pulls one frame per call through SYS_NET_RX_DEQUEUE. The handler runs
at EL1 and is small, but every line of it is a law
(src/network/dispatch.c):
if (!scheduler_current_task_has_capability(CAP_NET_SERVER)) { /* law 1: gated */
frame->x[0] = (uint64_t)(int64_t)NET_E_PERM; return 1;
}
if (!net_ptr_ok(desc_ptr, sizeof(struct net_frame_desc), 0)) { /* law 2: validate */
frame->x[0] = (uint64_t)(int64_t)NET_E_INVAL; return 1;
}
slot = net_rx_peek();
if (slot == 0) { frame->x[0] = 0u; return 1; } /* ring empty */
if (capacity < slot->len || !net_ptr_ok(buf_ptr, slot->len, 0)) { /* law 3: refuse */
frame->x[0] = (uint64_t)(int64_t)NET_E_INVAL; return 1; /* don't truncate */
}
net_copy_bytes((uint8_t *)(uintptr_t)buf_ptr, slot->bytes, slot->len); /* law 4: copy */
net_rx_drop_head();
frame->x[0] = 1u;
- Capability-gated. Only a task holding
CAP_NET_SERVERmay touch the network syscalls at all. A random EL0 program cannot read frames or inject them; the network service is blessed, everything else is deaf. - Every pointer is validated.
net_ptr_ok()checks that the caller's buffer lies entirely inside the EL0 alias windows — writable targets in the stack window, read-only sources may also come from text/rodata. A pointer into the kernel, or off the end of a buffer, is rejected before a single byte moves. EL1 never trusts an address EL0 handed it. - Refuse, don't truncate. If the caller's buffer is smaller than
the frame, the call fails with
-EINVALand the frame stays queued. No silent half-packet, ever — a truncated frame is a bug waiting to be parsed as something it isn't. - Copy across, then commit. Only after all checks pass does
net_copy_bytes()move the bytes andnet_rx_drop_head()advance the ring. Peek-validate-then-drop, never drop-then-hope.
The transmit path is the mirror image: SYS_NET_TX_SUBMIT validates the
EL0 descriptor and buffer, copies the reply into a TX slot, and returns
0 if the TX ring is full — honest backpressure, so
a busy EL0 task yields and retries instead of trampling the ring.
Bounded work, or the box dies
Alquist's scheduler is cooperative and single-core: a task runs until it yields. So
every EL0 network pass drains at most NET_EL0_BATCH_MAX frames, sweeps
TCP retransmits, and then calls el0_net_yield_exit() — it does a
finite chunk of work and leaves. An earlier version looped forever inside EL0
and only yielded; it starved every other task and dropped the UART. The rule that
replaced it is the one law that spans the whole stack: no pass may run
forever. The ring mutations, by contrast, run to completion without yielding
— on a single core that is all the atomicity a head/tail update needs.
What an attacker actually reaches
Stack it all up and picture the worst case: a hostile host on the wire sends a deliberately malformed frame. It lands in a DMA buffer the driver owns; the driver copies its bytes into an RX slot and immediately releases the hardware descriptor; the blessed EL0 service copies those bytes into a plain stack array and parses them with the guard-clause paranoia we saw in Chapter 1. At no point does that attacker's data sit in memory the kernel executes, or in a buffer the NIC will DMA over, or behind a pointer anyone trusts. The blast radius of a bad packet is one byte buffer in one unprivileged, capability-gated task.
That is the wall. Everything from here up — ICMP, UDP, the whole TCP engine, eventually SSH — is written in the comfortable knowledge that it is parsing a copy, on the safe side. When we reach resilience in Chapter 11, this is the structural fact the floods and malformed frames will break against.
Seeing it
The wall is invisible on a good day, but it keeps score. The rings carry counters
— drops_full on each ring, g_net_tx_fail for frames
the driver refused — and those are exactly the numbers that tell you whether the
protocol brain is keeping up or the pump is starving. We wire them into a diagnostic
surface in Chapter 10, "Every drop counted." For now, just know they exist: on this
architecture, "a packet went missing" is a number you can read, not a mystery.
Wall built, laws written. From here on we are always on the safe side of it, holding a copy. Next we spend that safety on the simplest possible request and reply: are you still there?