Who has this address?
Before a machine can ping, browse, or serve, it must answer one small, blunt question the network shouts into the dark. In the beginning was ARP.
Every conversation on a local network opens the same way: someone yells an address into a crowded room and waits to see who flinches. That yell is ARP. If our box cannot answer it, nothing else we build will ever get a single packet.
The problem ARP solves
Your program thinks in IP addresses —
192.168.111.2. The wire does not. Ethernet delivers frames to
MAC addresses, the six-byte hardware identifiers burned into each
network interface. So there is a translation gap: "I want to send to
192.168.111.2" has to become "put these bytes on the wire addressed to
dc:a6:32:..:..:..." ARP — the Address Resolution
Protocol — is the tiny protocol that fills that gap.
It works by broadcast. A host that wants our MAC sends a frame to the broadcast
address ff:ff:ff:ff:ff:ff — heard by everyone — carrying the
question: "Who has 192.168.111.2? Tell 192.168.111.5." The one machine that
owns that IP answers, unicast, directly back: "192.168.111.2 is
at dc:a6:32:...". Everyone else stays quiet. That reply is the first useful thing
our stack ever emits, and until it does, we are invisible — a `ping` never even
reaches us, because the sender cannot address the frame.
The shape of the packet
An ARP-over-Ethernet frame is gloriously small: a 14-byte Ethernet header followed by a 28-byte ARP body, 42 bytes in all. The fields we care about:
- Ethernet header (14 B): destination MAC, source MAC, ethertype
0x0806(= "this is ARP"). - Hardware type = 1 (Ethernet), protocol type =
0x0800(IPv4), hw len = 6, proto len = 4. Four constants that say "Ethernet + IPv4," the only combination we serve. - Operation: 1 = request, 2 = reply.
- Sender MAC / sender IP, then target MAC / target IP. A request fills in everything except the target MAC — that blank is precisely what it is asking for.
To answer, we do not build a new thought; we mostly swap sender and target, drop in our own MAC where the blank was, and flip the operation from request to reply. ARP is almost entirely a copy-and-swap.
How Alquist answers
Here is the real responder, from src/network/protocol.c. It runs in the
unprivileged (EL0) network service, and — this is the important part — it
only ever touches a copy of the received frame. It never sees the
hardware, never dereferences a kernel pointer. Bytes in, bytes out.
static void net_el0_handle_arp(const uint8_t *frame, uint32_t frame_length,
const uint8_t local_mac[6], const uint8_t local_ip[4])
{
uint8_t reply[NET_ARP_FRAME_SIZE];
const uint8_t *arp, *sender_mac, *sender_ip;
struct net_frame_desc desc;
if (frame_length < NET_ARP_FRAME_SIZE)
return; /* too short to be ARP */
/* Accept only broadcast, or a frame unicast directly to us. */
if (!net_mac_is_broadcast(&frame[0]) && !net_mac_equal(&frame[0], local_mac))
return;
arp = &frame[NET_ETH_HEADER_SIZE];
if (net_read_be16(arp, 0u) != NET_ARP_HTYPE_ETHERNET /* hw = Ethernet */
|| net_read_be16(arp, 2u) != NET_ARP_PTYPE_IPV4 /* proto = IPv4 */
|| arp[4] != 6u || arp[5] != 4u) /* lengths 6 and 4 */
return;
if (net_read_be16(arp, 6u) != NET_ARP_OPER_REQUEST) /* only requests */
return;
if (!net_ipv4_equal(&arp[24], local_ip)) /* is it asking US? */
return;
sender_mac = &frame[NET_ETH_HEADER_SIZE + 8u];
sender_ip = &frame[NET_ETH_HEADER_SIZE + 14u];
/* Ethernet header: back to the sender, from us, ethertype ARP. */
net_copy_bytes(&reply[0], sender_mac, 6u);
net_copy_bytes(&reply[6], local_mac, 6u);
net_write_be16(reply, 12u, NET_ETH_TYPE_ARP);
/* ARP body: same constants, operation = REPLY, sender/target swapped. */
net_write_be16(reply, 14u, NET_ARP_HTYPE_ETHERNET);
net_write_be16(reply, 16u, NET_ARP_PTYPE_IPV4);
reply[18] = 6u; reply[19] = 4u;
net_write_be16(reply, 20u, NET_ARP_OPER_REPLY);
net_copy_bytes(&reply[22], local_mac, 6u); /* sender = us */
net_copy_bytes(&reply[28], local_ip, 4u);
net_copy_bytes(&reply[32], sender_mac, 6u); /* target = the asker */
net_copy_bytes(&reply[38], sender_ip, 4u);
desc.len = NET_ARP_FRAME_SIZE; /* 42 bytes; driver pads to 60 */
desc.flags = 0u;
(void)el0_net_tx_submit(&desc, reply);
}
Read it as a series of reasons to say nothing. The function is mostly guard clauses: too short? leave. not addressed to our MAC or broadcast? leave. not Ethernet+IPv4 with the right lengths? leave. not a request? leave. not asking for our IP? leave. Only a frame that survives every check earns a reply. This is the temperament we want for every packet handler in the stack: silence is the default; a response is something you have to earn. A network handler that is eager to answer is a network handler that can be tricked or drowned.
The wall you cannot see in this function
Notice what is absent. There is no register poke, no DMA, no kernel address.
The handler receives frame as a plain buffer of copied bytes and writes
its answer into a local reply array, which it hands back with
el0_net_tx_submit(). The kernel's frame pump did the copying on the way
in and will copy reply back out to the hardware. That copy-only boundary
between the protocol brain (EL0) and the driver (EL1) is the single most important
design decision in the stack, and we will spend Chapter 02 on it. Here, just
register the shape: a hostile ARP frame, however malformed, reaches only a
byte buffer in an unprivileged task.
Try it
From a host on the isolated lab network (192.168.111.0/24), the smallest
possible test is to ask the box for its own address and watch it flinch:
# Ask "who has 192.168.111.2?" and wait for the unicast reply
arping -c 3 192.168.111.2
# Or watch the exchange on the wire while something pings the box
sudo tcpdump -nvi eth0 arp
ping -c 1 192.168.111.2 # the ping's ARP request comes first
A healthy box replies to the arping within a millisecond and the reply
shows up in tcpdump as ARP, Reply 192.168.111.2 is-at
dc:a6:32:.... If arping gets nothing, stop here — nothing
higher up will work until this does. (And if the box is on UART but silent on the
network, suspect the VPN on the host before you suspect the code.)
Gotchas worth remembering
- 42 in, 60 out. The true frame is 42 bytes, but Ethernet's minimum payload forces a 60-byte frame; the driver pads it. We submit the honest length and let the hardware layer add the zeros — the protocol code never lies about size.
- We answer requests, not gratuitous chatter. The handler ignores replies and anything not addressed to our IP. It does not build an ARP cache yet either — Alquist currently answers questions rather than asking them, because as a service it mostly responds. Initiating ARP (for when we need someone else's MAC) is a later addition, and a careful one: an ARP cache is a classic spoofing target (Chapter 11).
- Broadcast is a privilege, not a right. The broadcast/unicast filter is the first line of the "earn your reply" discipline. Everything nasty on a local network starts by abusing broadcast; we accept it only where the protocol genuinely requires it.
That is the whole of it: hear the question, check it five ways, swap the addresses, answer once. We are visible now. Next we build the wall that keeps every packet after this one from ever touching the metal directly.