Build a TCP/IP Stack from Scratch · Module 02

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:

StepSenderMessageMeaning
110.10.0.2"Who has 10.10.0.1? Tell 10.10.0.2."ARP request (broadcast to everyone)
210.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:

FieldSizeDescription
Hardware type21 for Ethernet
Protocol type20x0800 for IPv4
Hardware size16 for MAC
Protocol size14 for IPv4
Opcode21 = request, 2 = reply
Sender MAC6MAC of sender
Sender IP4IP of sender
Target MAC6MAC of target (zero in request)
Target IP4IP 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.