Understanding and Implementing ARP
Understanding and Implementing ARP (Address Resolution Protocol)
You've successfully reached the point where your program can see Ethernet frames flying by and even identify which ones are ARP messages.
Now, let's take the next step — understanding what ARP is doing and building your own ARP responder inside the stack.
What is ARP, and why do we need it?
Imagine two hosts on the same local network:
- One has an IP address
10.10.0.1 - The other is
10.10.0.2
When 10.10.0.2 wants to send an IP packet to 10.10.0.1, it first needs to know the MAC address of 10.10.0.1.
Because Ethernet (Layer 2) uses MAC addresses, not IPs.
But how does it find out?
That's exactly what ARP (Address Resolution Protocol) does.
The conversation goes like this:
| Step | Sender | Message | Meaning |
|---|---|---|---|
| 1 | 10.10.0.2 | "Who has 10.10.0.1? Tell 10.10.0.2." | ARP request (broadcast to everyone) |
| 2 | 10.10.0.1 | "10.10.0.1 is at 02:42:0a:0a:00:01." | ARP reply (unicast back to the requester) |
Once the sender learns the MAC, it can build Ethernet frames and start normal IP communication.
6.1 The ARP packet structure
Let's define what an ARP message looks like inside the Ethernet frame.
After the 14-byte Ethernet header, the ARP payload starts and looks like this:
| Field | Size | Description |
|---|---|---|
| Hardware type | 2 | 1 for Ethernet |
| Protocol type | 2 | 0x0800 for IPv4 |
| Hardware size | 1 | 6 for MAC |
| Protocol size | 1 | 4 for IPv4 |
| Opcode | 2 | 1 = request, 2 = reply |
| Sender MAC | 6 | MAC of sender |
| Sender IP | 4 | IP of sender |
| Target MAC | 6 | MAC of target (zero in request) |
| Target IP | 4 | IP of target |
Total: 28 bytes.
6.2 Define the ARP struct
Create stack/arp.h:
#pragma once
#include <stdint.h>
#include <arpa/inet.h>
struct __attribute__((packed)) arp_hdr {
uint16_t htype;
uint16_t ptype;
uint8_t hlen;
uint8_t plen;
uint16_t oper;
uint8_t sha[6]; // sender hardware address
uint8_t sip[4]; // sender IP
uint8_t tha[6]; // target hardware address
uint8_t tip[4]; // target IP
};Again, we use __attribute__((packed)) to make sure there's no padding.
6.3 Detect ARP requests and print info
Now, in your main.c, extend handle_frame() when the EtherType is 0x0806:
#include "arp.h"
// ... (in handle_frame function)
if (et == 0x0806) {
printf(" ↳ This is an ARP frame\n");
const struct arp_hdr *arp = (const struct arp_hdr *)(buf + sizeof(struct eth_hdr));
uint16_t op = ntohs(arp->oper);
if( op == 1){
printf(" ARP Request: Who has %d.%d.%d.%d? Tell %d.%d.%d.%d\n",
arp->tip[0], arp->tip[1], arp->tip[2], arp->tip[3],
arp->sip[0], arp->sip[1], arp->sip[2], arp->sip[3]);
} else if( op == 2){
printf(" ARP Reply: %d.%d.%d.%d is at %02x:%02x:%02x:%02x:%02x:%02x\n",
arp->sip[0], arp->sip[1], arp->sip[2], arp->sip[3],
arp->sha[0], arp->sha[1], arp->sha[2],
arp->sha[3], arp->sha[4], arp->sha[5]);
} else {
printf(" Unknown ARP operation %d\n", op);
}
}Now when you send a broadcast from your stack container again:
docker compose exec stack bash -lc 'arping -I tap0 -c 1 10.10.0.2 >/dev/null || true'You'll see output like:
ARP Request: Who has 10.10.0.2? Tell 10.10.0.1
Nice — your stack now understands ARP packets!
Aside: little-endian vs big-endian
Ethernet and IP protocols always use network byte order = big-endian (most significant byte first).
Most PCs (x86) use little-endian, so you must always convert with ntohs() / ntohl() and htons() / htonl() when reading or writing network fields.
If you ever get weird reversed numbers (like 0x0100 instead of 0x0001), it's probably because you forgot a conversion!
6.4 Checkpoint summary
You've learned what ARP does and what its packet layout looks like. You wrote C code to detect and interpret ARP messages from raw frames. Your stack can now read and understand "Who has IP X?" messages.
Next, you'll learn to reply to these messages — your stack will start talking back on the network for real.
This is the first time your program will transmit a properly crafted frame — the moment your networking stack officially becomes alive.