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:
| Field | Size (bytes) | Description |
|---|---|---|
| Destination MAC | 6 | Who the frame is meant for |
| Source MAC | 6 | Who sent it |
| EtherType | 2 | What 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. -
ethertypeis stored in network byte order (big-endian), so we'll usentohs()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 like02: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.