Build Your Own TCP/IP Stack: IPv4 & ICMPv4
We've solved local communication. Ethernet frames can carry data between computers on the same network. ARP helps us discover MAC addresses. Everything works.
But here's the problem: your laptop is on your home WiFi. Google's servers are in a data center thousands of miles away, on completely different networks. How do you send them a message?
Ethernet frames only work locally. They can't jump between networks. We need something new.
Let's invent it.
The Global Routing Problem
Imagine the internet as millions of separate local networks. Your home network is one. Your office has another. Google has many. Each one is its own Ethernet segment with its own devices, switches, and MAC addresses.
You have data to send from your laptop (on your home network) to a server at Google (on a completely different network, thousands of miles away). How do you get it there when Ethernet frames can only travel locally?
Think about it. What would we need?
We need routers. These are devices that connect different networks and forward data between them. But if we're going to route data, we need addresses that work globally, not just locally.
MAC addresses won't work. They're only meaningful on the local network. We need a new addressing system.
Inventing IP Addresses
Let's design a new kind of address. What properties should it have?
Property 1: Globally Unique
Every device on the entire internet needs a different address. Otherwise, how would routers know where to send your data?
Property 2: Hierarchical
This is the clever part. If addresses have structure, routers can make smart decisions.
Think about postal addresses. You don't need to know every house in the world. You just need to know: "This letter goes to California → San Francisco → Mission District → 123 Main St." Each router only needs to know the next step.
Property 3: Software-Assigned
Unlike MAC addresses (burned into hardware), IP addresses should be configurable. When you connect your laptop to WiFi, it gets an IP address. When you connect somewhere else, it gets a different one.
Let's make IP addresses 32 bits (4 bytes). We'll write them in dotted decimal notation:
192.168.1.100
| | | |
byte byte byte byte
Each byte is 0-255. This gives us about 4.3 billion possible addresses. (Spoiler: turns out that's not enough, but we'll deal with that later.)
Now we need to wrap our data in a new layer.
Inventing the IP Packet
Remember our Ethernet frame format?
[Dest MAC][Source MAC][Type][Payload][Checksum]
The payload is where our new layer goes. Let's design an IP packet:
[Dest IP][Source IP][... other stuff ...][Data]
What else do we need?
Version Field: Are we using IPv4? IPv6? The receiver needs to know how to parse this.
Length Field: How long is this packet? The receiver needs to know when it ends.
Time to Live (TTL): Wait, why would we need this? Think about it. What if a router misconfiguration causes a packet to loop forever, bouncing between routers? We need a way to kill zombie packets. TTL counts down at each router. When it hits zero, the packet dies.
Protocol Field: What's inside the data? TCP? UDP? ICMP? We're building a stack of layers, and each layer needs to know what the next layer is.
Checksum: What if a bit gets flipped during transmission? We need to detect corrupted headers.
Let's see what this looks like:
Click on each field in the header. This is the second layer of our stack: IP packets that live inside Ethernet frames. Notice the source and destination IP addresses, along with all those control fields we just discussed.
We've added a new layer! Now data can include both a destination IP (for global routing) and a destination MAC (for local delivery). Routers will use the IP address to decide where to forward packets. The Ethernet layer handles the actual physical delivery on each network segment.
But there's a new problem we need to solve.
The Error Detection Problem
Picture this: your packet is traveling through a router. A cosmic ray flips a single bit in the header. Just one bit.
Suddenly, the destination IP changes from 8.8.8.8 (Google's DNS) to 8.8.0.8 (a completely different machine, or nothing at all). Your packet vanishes into the void. Or worse, it goes somewhere it shouldn't.
Bits can flip during transmission. Hardware fails. How do we detect when a packet's header has been corrupted? And can we do it efficiently, without expensive cryptographic operations on every packet?
We need some kind of error detection. But remember, routers need to be FAST. They process millions of packets per second. We can't do complex cryptographic hashes on every packet header.
What's the simplest thing that could work?
Inventing the Checksum
Here's an idea: what if we just add up all the numbers in the header?
When you send the packet, calculate the sum. Include it in the header. When a router receives the packet, recalculate the sum. If it doesn't match, something got corrupted. Drop the packet.
Let's try it. The header is made of 16-bit chunks. We could just add them all together:
Sum = word[0] + word[1] + word[2] + ... + word[n]
But wait, what if the sum overflows? If we're adding 16-bit numbers, the result could be bigger than 16 bits.
Solution: fold the overflow back in. If you get a 17-bit number, take that extra bit and add it back to the lower 16 bits.
Finally, flip all the bits (one's complement). This creates the checksum.
Here's the algorithm:
- Set checksum field to 0
- Sum all 16-bit words in the header
- While sum > 16 bits, fold overflow back in
- Flip all bits (one's complement)
- That's your checksum
Click Calculate Checksum and watch it step through the algorithm. Try changing some header bytes and see how the checksum changes. It's simple, but it catches transmission errors effectively.
When a router receives a packet, it runs the exact same calculation. If the result matches the checksum in the header, the header is probably fine. If it doesn't match, the header was corrupted.
And here's the harsh part: if the checksum fails, the router silently drops the packet. No error message. No notification. The packet just disappears. The sender's TCP layer might notice eventually (via timeout), but IP itself doesn't care.
The checksum protects against accidents (hardware errors, transmission noise), not deliberate attacks. A malicious actor could modify a packet and recalculate the checksum to match. That's why we need TLS and other security protocols at higher layers. The IP checksum is just a quick sanity check.
How Do We Test This?
We've built two layers now:
- Ethernet (local delivery using MAC addresses)
- IP (global routing using IP addresses)
But how do we know it actually works? We need a way to test the whole stack.
You want to check if a remote computer is reachable and your network stack is working correctly. What's the simplest test you could run?
The simplest test: send a message and see if you get a reply.
"Hey, are you there?" "Yes, I'm here!"
That's it. That's ping.
Inventing Ping with ICMP
We need a protocol just for diagnostics. Not for actual data transfer, but for testing and troubleshooting. Let's call it ICMP (Internet Control Message Protocol).
ICMP is simple. It's a protocol (protocol number = 1) that goes inside IP packets. It has several message types, but we'll start with the most important:
Type 8: Echo Request: "Are you there?" Type 0: Echo Reply: "Yes, I'm here!"
Here's how ping works:
- You send an ICMP Echo Request packet to
8.8.8.8 - The IP layer wraps it in an IP header (destination:
8.8.8.8) - The Ethernet layer wraps that in an Ethernet frame (destination: your router's MAC)
- Your router forwards it, hop by hop, until it reaches
8.8.8.8 - That machine sees the Echo Request
- It builds an Echo Reply packet
- The reply travels back through the internet to you
- You measure how long the round trip took
Click Send Ping. Watch the Echo Request travel out and the Echo Reply come back. Notice the round-trip time. Now enable packet loss and see what happens when packets go missing.
If you get an Echo Reply back, you know:
- Your IP stack can build valid headers ✓
- Your checksums are correct ✓
- Routers can forward your packets ✓
- The remote machine is alive ✓
- The whole path works ✓
That's why ping is the first tool everyone reaches for when debugging network problems.
The TTL Countdown
Remember that TTL (Time To Live) field we added to the IP header? Let's see why it's crucial.
Picture this: A router somewhere is misconfigured. It thinks packets for 10.0.0.4 should go to Router B. Router B thinks they should go back to Router A. Your packet bounces between them forever.
If routing loops can happen, packets could circulate forever, endlessly consuming bandwidth. How do we prevent zombie packets from clogging the network?
The solution is elegant: give each packet a limited lifespan.
TTL starts at some value (usually 64, sometimes 128 or 255)
Every router decrements it:
Packet arrives with TTL=64
Router processes it
Router decrements: TTL=63
Router forwards packet
When TTL hits 0, the router kills the packet and sends back an ICMP "Time Exceeded" message to the source.
This guarantees that even if routing loops exist, packets won't bounce forever. They'll die within a fixed number of hops.
Bonus: Traceroute
TTL also gave us an incredibly useful debugging tool. What if we deliberately send packets with low TTL values?
- Send packet with TTL=1 → First router kills it, sends "Time Exceeded" message revealing itself
- Send packet with TTL=2 → Second router kills it, reveals itself
- Send packet with TTL=3 → Third router reveals itself
- ...
- Eventually, you reach the destination
You've just mapped the entire path through the internet! This is how traceroute works.
Building It: Python Implementation
Let's implement the IP layer and checksum algorithm:
IP Packet Builder
import struct
import socket
class IPv4Packet:
"""Build and parse IPv4 packets."""
def __init__(self, src_ip, dest_ip, protocol, ttl, data):
self.version = 4
self.ihl = 5 # Header length in 32-bit words (5 = 20 bytes)
self.tos = 0 # Type of service
self.total_length = 20 + len(data)
self.identification = 0
self.flags = 0
self.fragment_offset = 0
self.ttl = ttl
self.protocol = protocol # 1=ICMP, 6=TCP, 17=UDP
self.checksum = 0 # Will calculate later
self.src_ip = src_ip
self.dest_ip = dest_ip
self.data = data
def calculate_checksum(self, header):
"""Calculate the Internet Checksum."""
# Step 1: Sum all 16-bit words
checksum = 0
for i in range(0, len(header), 2):
word = (header[i] << 8) + header[i + 1]
checksum += word
# Step 2: Fold any overflow back into the checksum
while checksum >> 16:
checksum = (checksum & 0xFFFF) + (checksum >> 16)
# Step 3: One's complement
checksum = ~checksum & 0xFFFF
return checksum
def to_bytes(self):
"""Convert packet to bytes."""
# Build header with checksum = 0
version_ihl = (self.version << 4) + self.ihl
flags_fragment = (self.flags << 13) + self.fragment_offset
# Convert IP addresses to 32-bit integers
src_ip_int = struct.unpack('!I', socket.inet_aton(self.src_ip))[0]
dest_ip_int = struct.unpack('!I', socket.inet_aton(self.dest_ip))[0]
header = struct.pack('!BBHHHBBH',
version_ihl,
self.tos,
self.total_length,
self.identification,
flags_fragment,
self.ttl,
self.protocol,
0 # Checksum placeholder
)
header += struct.pack('!II', src_ip_int, dest_ip_int)
# Calculate checksum
checksum = self.calculate_checksum(header)
# Rebuild header with correct checksum
header = struct.pack('!BBHHHBBHII',
version_ihl,
self.tos,
self.total_length,
self.identification,
flags_fragment,
self.ttl,
self.protocol,
checksum,
src_ip_int,
dest_ip_int
)
return header + self.data
# Example: Build an IP packet
packet = IPv4Packet(
src_ip='10.0.0.1',
dest_ip='8.8.8.8',
protocol=1, # ICMP
ttl=64,
data=b'Hello, Internet!'
)
packet_bytes = packet.to_bytes()
print(f"IP packet: {len(packet_bytes)} bytes")
print(f"Checksum: 0x{packet.calculate_checksum(packet_bytes[:20]):04x}")Checksum Calculator (Detailed)
def calculate_checksum_detailed(data):
"""
Calculate Internet Checksum with step-by-step explanation.
"""
print("Calculating Internet Checksum...")
print(f"Input: {len(data)} bytes")
# Ensure even number of bytes
if len(data) % 2 != 0:
data += b'\x00'
# Step 1: Sum all 16-bit words
checksum = 0
print("\nStep 1: Sum 16-bit words")
for i in range(0, len(data), 2):
word = (data[i] << 8) + data[i + 1]
checksum += word
print(f" Word {i//2}: 0x{word:04x}, Sum: 0x{checksum:08x}")
# Step 2: Fold overflow
print(f"\nStep 2: Fold overflow")
print(f" Before: 0x{checksum:08x}")
while checksum >> 16:
carry = checksum >> 16
checksum = (checksum & 0xFFFF) + carry
print(f" Fold carry 0x{carry:04x}: 0x{checksum:08x}")
# Step 3: One's complement
checksum = ~checksum & 0xFFFF
print(f"\nStep 3: One's complement")
print(f" Final checksum: 0x{checksum:04x}")
return checksum
# Example: Calculate checksum for IP header
header = bytes([
0x45, 0x00, # Version, IHL, TOS
0x00, 0x3c, # Total length
0x1c, 0x46, # Identification
0x40, 0x00, # Flags, Fragment offset
0x40, 0x06, # TTL, Protocol
0x00, 0x00, # Checksum (zeroed for calculation)
0xac, 0x10, 0x0a, 0x63, # Source IP (172.16.10.99)
0xac, 0x10, 0x0a, 0x0c # Dest IP (172.16.10.12)
])
checksum = calculate_checksum_detailed(header)ICMP Ping Implementation
import struct
import time
class ICMPPacket:
"""Build ICMP Echo Request/Reply packets."""
# ICMP Types
ECHO_REPLY = 0
ECHO_REQUEST = 8
def __init__(self, icmp_type, code, identifier, sequence, data):
self.type = icmp_type
self.code = code
self.checksum = 0
self.identifier = identifier
self.sequence = sequence
self.data = data
def to_bytes(self):
"""Convert ICMP packet to bytes."""
# Build packet with checksum = 0
header = struct.pack('!BBHHH',
self.type,
self.code,
0, # Checksum placeholder
self.identifier,
self.sequence
)
packet = header + self.data
# Calculate checksum
checksum = self.calculate_checksum(packet)
# Rebuild with correct checksum
header = struct.pack('!BBHHH',
self.type,
self.code,
checksum,
self.identifier,
self.sequence
)
return header + self.data
def calculate_checksum(self, data):
"""Calculate ICMP checksum (same algorithm as IP)."""
if len(data) % 2 != 0:
data += b'\x00'
checksum = 0
for i in range(0, len(data), 2):
word = (data[i] << 8) + data[i + 1]
checksum += word
while checksum >> 16:
checksum = (checksum & 0xFFFF) + (checksum >> 16)
return ~checksum & 0xFFFF
# Example: Create a ping request
ping = ICMPPacket(
icmp_type=ICMPPacket.ECHO_REQUEST,
code=0,
identifier=1234, # Random ID
sequence=1, # Sequence number
data=b'Ping!' + struct.pack('!d', time.time()) # Include timestamp
)
ping_bytes = ping.to_bytes()
print(f"ICMP Echo Request: {len(ping_bytes)} bytes")
# Wrap in IP packet
ip_packet = IPv4Packet(
src_ip='10.0.0.1',
dest_ip='8.8.8.8',
protocol=1, # ICMP
ttl=64,
data=ping_bytes
)
print(f"Ready to send ping to 8.8.8.8!")The checksum algorithm is beautifully simple: sum everything up, fold overflow back in, flip the bits. It catches transmission errors with minimal CPU cost. Modern networks have better error detection at lower layers, but the IP checksum remains for backwards compatibility.
What We've Invented
Let's review what we've built:
Layer 1: Ethernet
- Frames for local delivery
- MAC addresses (hardware identifiers)
- ARP (discovering MAC addresses)
Layer 2: IP
- Packets for global routing
- IP addresses (software-assigned, hierarchical)
- TTL (preventing zombie packets)
- Checksum (detecting corrupted headers)
Bonus: ICMP
- Echo Request/Reply (ping)
- Time Exceeded (revealing routers)
- Other diagnostic messages
These layers work together. When you send data:
- Your application says "send this to 8.8.8.8"
- IP builds a packet with destination IP = 8.8.8.8
- Your router determines the next hop (maybe another router)
- ARP discovers that router's MAC address
- Ethernet wraps everything in a frame
- The frame travels to the router
- The router unwraps it, decrements TTL, recalculates checksum
- Process repeats, hop by hop, until reaching 8.8.8.8
Click through to see the full encapsulation. Data gets wrapped in IP, which gets wrapped in Ethernet. At each hop, the Ethernet layer changes (new local delivery), but the IP layer stays the same (same destination).
We can now send packets anywhere on the internet. Victory!
Well, almost.
The Reliability Problem
There's a fundamental issue with what we've built. IP is "best effort." Routers try to deliver your packets, but they make no promises.
- Packets can get lost. Router queues fill up → packet dropped.
- Packets can arrive out of order. Different packets take different paths.
- Packets can be duplicated. Routing loops, retransmissions.
For a ping, this is fine. One lost Echo Request? Send another.
But imagine downloading a file. The file is split into thousands of packets. If packet #472 goes missing, you can't reconstruct the file. If packets arrive out of order, you don't know how to reassemble them. The whole thing breaks.
IP gives you unreliable, unordered delivery. How do you build reliable, ordered connections on top of this chaos?
That's the problem TCP solves. Next, we'll build reliable delivery on top of our unreliable network.