Build a TCP/IP Stack from Scratch · Module 02

Parsing Ethernet Headers — Giving Meaning to the Bytes

Parsing Ethernet Headers — Giving Meaning to the Bytes

Until now, your program has been showing mysterious numbers like:

ff ff ff ff ff ff 0e fa a3 02 a3 17 08 06

But what does that actually mean?

It's time to teach your program to understand those bytes — to turn raw binary data into something human-readable like:

"Ethernet frame: from 0e:fa:a3:02:a3:17 to ff:ff:ff:ff:ff:ff, type = ARP (0x0806)"

That's what we'll do in this section.

Quick recap: what's inside an Ethernet frame?

The first 14 bytes of every Ethernet frame contain the Ethernet header:

FieldSize (bytes)Description
Destination MAC6Who the frame is meant for
Source MAC6Who sent it
EtherType2What kind of payload follows (e.g. IPv4, ARP, IPv6)

Everything that comes after these 14 bytes is the "payload" (the next protocol — like ARP or IP)

5.1 Define the Ethernet header and a helper function

Create a new file: stack/eth.h

#pragma once
#include <stdint.h>
#include <arpa/inet.h>
#include <stdio.h>
 
// Structure describing the Ethernet header (14 bytes)
struct __attribute__((packed)) eth_hdr {
 uint8_t dmac[6]; // destination MAC address
 uint8_t smac[6]; // source MAC address
 uint16_t ethertype; // protocol type (big-endian)
};
 
// Turn a 6-byte MAC address into a readable string "xx:xx:xx:xx:xx:xx"
static inline void mac_to_str(const uint8_t mac[6], char out[18]) {
 snprintf(out, 18, "%02x:%02x:%02x:%02x:%02x:%02x",
 mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}

Notes

  • __attribute__((packed)) tells the compiler not to insert padding bytes. We want our structure to match the exact byte layout on the wire.

  • ethertype is stored in network byte order (big-endian), so we'll use ntohs() to convert it to our machine's order later.

5.2 Update main.c to parse and print the header

Edit stack/main.c to look like this:

#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
#include <arpa/inet.h>
#include "tap.h"
#include "eth.h"
 
// Interpret and print one Ethernet frame
static void handle_frame(const unsigned char *buf, ssize_t n) {
 if (n < (ssize_t)sizeof(struct eth_hdr)) return;
 
 const struct eth_hdr *h = (const struct eth_hdr*)buf;
 uint16_t et = ntohs(h->ethertype); // convert from network to host byte order
 
 char dmac[18], smac[18];
 mac_to_str(h->dmac, dmac);
 mac_to_str(h->smac, smac);
 
 // Filter out IPv6 (0x86DD) noise for now
 if (et == 0x86DD) return;
 
 printf("Ethernet: %s%s type=0x%04x len=%zd\n", smac, dmac, et, n);
 
 if (et == 0x0806) {
 printf(" ↳ This is an ARP frame\n");
 } else if (et == 0x0800) {
 printf(" ↳ This is an IPv4 frame\n");
 } else {
 printf(" ↳ Unknown EtherType\n");
 }
}
 
int main(void) {
 int fd = tap_open("tap0");
 
 unsigned char buf[2048];
 while (1) {
 ssize_t n = read(fd, buf, sizeof(buf));
 if (n < 0) { perror("read tap"); break; }
 handle_frame(buf, n);
 }
 return 0;
}

Let's break this down a bit

  • ntohs() means Network TO Host Short. It converts a 16-bit number from network byte order (big-endian) to your computer's native order. (On x86 it reverses the bytes.)

  • mac_to_str() just prints the six MAC bytes as a string like 02:42:ac:11:00:04.

  • The if-statements check et (EtherType):

  • 0x0806 → ARP

  • 0x0800 → IPv4

  • 0x86DD → IPv6

IPv6 frames are ignored here to reduce noise (those 33:33:00:00:00:02 ones you saw before).

5.3 Build and test it

Run your updated code inside the stack container:

docker compose exec stack bash -lc 'cd /stack && make && ./bin/stack'

Now open another terminal and send a simple broadcast from the same container:

docker compose exec stack bash -lc 'arping -I tap0 -c 1 10.10.0.2 >/dev/null || true'

You should see something like:

Ethernet: 7a:79:73:8f:9c:5d → ff:ff:ff:ff:ff:ff type=0x0806 len=58
 ↳ This is an ARP frame

That means your program successfully read the Ethernet header and translated it into human-friendly form!

What you've accomplished

You now have:

  • A program that captures real Ethernet frames via TAP.
  • Code that decodes the destination and source MAC addresses.
  • Basic logic to recognize whether a frame is ARP, IPv4, or something else.

In short: your custom network stack can now see and understand what's happening at Layer 2.

What's next?

Next, we'll go one level deeper: we'll open the ARP packets you're detecting, parse their fields, and craft a reply — so that when another machine asks, "Who has 10.10.0.1?" your program can proudly answer, "That's me!"

That will be your very first real, functional network protocol implementation.