Build a TCP/IP Stack from Scratch · Module 02

Create and Attach a TAP Device

Create and attach a TAP device (your first "virtual NIC")

Now we're giving your program a wire.

You'll open /dev/net/tun, ask the kernel for a TAP interface named tap0, and get back a file descriptor that delivers raw Ethernet frames — literally the binary packets your network card would normally handle.

4.1 Minimal C: open TAP and print when frames arrive

Create stack/tap.h:

#pragma once
#include <linux/if_tun.h>
#include <linux/if.h>
#include <sys/ioctl.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
 
static int tap_open(const char *ifname) {
 struct ifreq ifr;
 int fd = open("/dev/net/tun", O_RDWR);
 if (fd < 0) { perror("open /dev/net/tun"); exit(1); }
 
 memset(&ifr, 0, sizeof(ifr));
 ifr.ifr_flags = IFF_TAP | IFF_NO_PI; // L2 frames, no extra header
 if (ifname && *ifname) {
 strncpy(ifr.ifr_name, ifname, IFNAMSIZ);
 }
 
 if (ioctl(fd, TUNSETIFF, (void *)&ifr) < 0) {
 perror("ioctl TUNSETIFF");
 exit(1);
 }
 
 printf("TAP up as %s (fd=%d)\n", ifr.ifr_name, fd);
 return fd;
}

Update stack/main.c:

#include <stdio.h>
#include <unistd.h>
#include <stdint.h>
#include "tap.h"
 
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; }
 
 printf("[tap0] got %zd bytes\n", n);
 // Print the first 14 bytes (Ethernet header) as hex
 for (int i = 0; i < n && i < 14; i++) printf("%02x ", buf[i]);
 printf("\n");
 }
 return 0;
}

Build and run inside the stack container:

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

You'll see:

TAP up as tap0 (fd=3)

That means your program successfully created the interface!

Aside: What's a file descriptor, really?

In Unix, everything is a file — including network interfaces, devices, and pipes.

When you call open("/dev/net/tun", O_RDWR), the kernel returns an integer, the file descriptor (fd).

  • fd = 3 just means "this is the 4th open file" (0 = stdin, 1 = stdout, 2 = stderr).
  • read(fd, buf, 2048) reads bytes from the TAP driver like a file.
  • write(fd, buf, n) would send bytes onto the virtual wire.

Same I/O model for text files, sockets, and Ethernet frames — that's the Unix superpower.

New to C? Key lines explained

  • memset(&ifr, 0, sizeof(ifr)); Zero-initialize the ifreq structure so there's no garbage data.

  • struct ifreq ifr; Kernel-facing descriptor for "do something to this interface." Comes from <linux/if.h>.

  • ioctl(fd, TUNSETIFF, (void *)&ifr) ioctl is "I/O control" — a special device command. Here it says: "Create a TAP device using the flags and (optional) name in ifr."

  • IFF_TAP | IFF_NO_PI Layer-2 frames please, and no extra 4-byte "packet info" header — we'll parse the raw frames ourselves.

  • exit(1) Bail out on failure. Later we'll improve error handling.

This is standard low-level Linux networking C: tiny syscalls, close to the metal.

4.2 Bring the interface up

We'll have Docker apply the ARP behavior for us at container start, so the kernel won't answer ARP on our behalf.

Edit lab/docker-compose.yml (stack service):

stack:
 build:
 context: ..
 dockerfile: lab/stack.Dockerfile
 command: sleep infinity
 cap_add: [ "NET_ADMIN" ]
 devices:
 - /dev/net/tun
 sysctls:
 net.ipv4.conf.all.arp_ignore: "8"
 net.ipv4.conf.default.arp_ignore: "8"
 networks:
 labnet:
 ipv4_address: 10.10.0.4
 volumes:
 - ../stack:/stack

Restart the lab so sysctls take effect:

cd lab
docker compose up -d --build

Now, in another terminal, bring tap0 up and assign an IP (no sysctl call needed inside the container):

docker compose exec stack bash -lc '
 ip link set tap0 up
 ip addr add 10.10.0.1/24 dev tap0 || true
 ip -brief addr show tap0
'

You should see tap0 with 10.10.0.1/24.

Because we set arp_ignore=8 via compose for all and default, the kernel won't answer ARP on tap0; your code will.

4.3 Generate a frame to see activity

Let's shove a packet across the wire and watch your program catch it.

From inside the stack container:

arping -I tap0 -c 1 10.10.0.2 >/dev/null || true

Your running program should print something like:

[tap0] got 58 bytes
ff ff ff ff ff ff 0e fa a3 02 a3 17 08 06 

That's an Ethernet broadcast (ff:ff:ff:ff:ff:ff) carrying ARP (EtherType 08 06).

Note: If you've run the arping command you might have captured two frames:

[tap0] got 70 bytes
33 33 00 00 00 02 0e fa a3 02 a3 17 86 dd 
[tap0] got 58 bytes
ff ff ff ff ff ff 0e fa a3 02 a3 17 08 06 

That first one is actually an IPv6 multicast, it's basically MLD/Neighbor Discovery chatter that Linux sends when an interface comes up. This is totally normal and reflects how a real interface works. We could disable them just like we disabled the ARP response from the kernel, but they won't bother us since we are not dealing with IPv6 (yet).

Aside: What's happening here

arping builds a broadcast ARP request in the kernel network stack and transmits it on tap0.

The TAP driver hands those bytes straight to your program's file descriptor; your read() loop logs them.

You've just intercepted raw network traffic — the very bytes a physical NIC would see.

4.4 What you just achieved

Created a real network interface (tap0) completely in user space. Read and printed raw Ethernet frames from it. Sent packets that your program intercepted directly from the kernel driver.

Huge milestone: you've crossed from networking user to networking implementer.

Next up: parsing those 14 bytes into a proper Ethernet header (dst MAC, src MAC, EtherType) — and laying the groundwork for your own ARP responder.