Networking · lesson 03

Addresses, ports & byte order

You set the port to 8080. The server received a connection on port 36895. Nothing is corrupted and no bits were lost — your CPU and the wire simply disagree about which byte comes first.

in progress
12 min interactive

Port 8080 became port 36895

You write addr.sin_port = 8080;. You connect. The connection fails, or worse, reaches a different service. tcpdump shows the packet going to port 36895. You did not type 36895 anywhere.

8080 is 0x1F90. 36895 is 0x901F. The same two bytes, in the opposite order. Your CPU wrote them one way and the network read them the other. This lesson is about the three integers that identify a network endpoint — address, port, family — how they sit in memory, and why one function call stands between you and this bug.

Addresses and ports are integers

Lesson 01 showed the IPv4 header as a struct: src_addr and dst_addr are each a uint32_t. 192.168.1.7 is not a string the network understands. It is how humans write the four bytes C0 A8 01 07, which together are the number 3232235783. A port is a uint16_t: 0 to 65535. The pair (address, port) names one socket on one machine, the way (array, index) names one element.

Ports below 1024 need root to bind. Ports 1024–49151 are registered to services by convention (5432 is Postgres, 8080 is "some HTTP"). Ports 49152–65535 are ephemeral: when you connect without choosing a port, the kernel picks a free one from this range for you. That is the 51234 you saw as the source port in lesson 01.

Two byte orders

A uint16_t occupies two bytes. Which byte goes at the lower address? Your CPU — x86, ARM, almost everything you will run — puts the least significant byte first. That is little-endian. 0x1F90 sits in RAM as 90 1F.

The network made the opposite choice decades ago: the most significant byte goes first. That is big-endian, called network byte order. 0x1F90 travels as 1F 90. Every multi-byte field in every header from lesson 01 — ports, lengths, addresses, checksums — is big-endian on the wire.

So before an integer goes into a header, something has to swap its bytes. Four functions do this, and their names encode the direction and the size:

function reads as use for
htons(x) host to network, short (16-bit) ports, before filling a struct
htonl(x) host to network, long (32-bit) addresses built by hand, lengths
ntohs(x) network to host, short reading a port out of a struct or header
ntohl(x) network to host, long reading an address or 32-bit field
💡
Mental model: there are two kinds of integer in your program. Host-order integers you compute with. Network-order integers you store in structs and headers. Never mix them. htons converts at the boundary when a value goes in; ntohs converts when it comes out. On a big-endian CPU these functions do nothing, which is exactly why you call them anyway: the code stays correct everywhere.

struct sockaddr_in: 16 bytes

The kernel wants the family, port, and address together in one struct. Its layout is fixed and you should know it the way you know the layout of a struct you wrote yourself:

c
// <netinet/in.h> — simplified, offsets are exact struct sockaddr_in { sa_family_t sin_family; // +0, 2 bytes: AF_INET (2), host order uint16_t sin_port; // +2, 2 bytes: NETWORK order struct in_addr sin_addr; // +4, 4 bytes: NETWORK order (one uint32_t) char sin_zero[8]; // +8, 8 bytes: padding, must be 0 }; // sizeof == 16 // the generic one every socket call actually takes: struct sockaddr { sa_family_t sa_family; // +0, 2 bytes — same spot as sin_family char sa_data[14]; // the rest, interpreted per family }; // sizeof == 16

Both structs are 16 bytes and both begin with a 2-byte family field. That is the trick. connect, bind, sendto and friends are declared to take struct sockaddr * so one signature can serve IPv4, IPv6, and Unix sockets. You build the specific struct, cast its address to the generic pointer, and pass the size. The kernel reads the first two bytes, learns it is AF_INET, and reinterprets the remaining 14 as a sockaddr_in. This is C's version of polymorphism: a common prefix and a cast.

Watch the struct fill in

The visualization below first shows the two byte orders for a port and an address, then lays out the 16 bytes of a sockaddr_in on the stack and fills them one assignment at a time. Watch which bytes each call writes, and in which order.

byte order, then struct sockaddr_in
Click next to start the walkthrough.

Filling it correctly

c
#include <arpa/inet.h> // htons, inet_pton, inet_ntop #include <netinet/in.h> // struct sockaddr_in #include <string.h> struct sockaddr_in addr; memset(&addr, 0, sizeof addr); // sin_zero must be zero addr.sin_family = AF_INET; // host order: the kernel reads this, not the wire addr.sin_port = htons(8080); // 1F 90 in memory, ready for the header if (inet_pton(AF_INET, "192.168.1.7", &addr.sin_addr) != 1) { fprintf(stderr, "bad address\n"); return 1; // writes C0 A8 01 07 — network order already } // the cast: a sockaddr_in* becomes the generic sockaddr* every call expects if (connect(fd, (struct sockaddr *)&addr, sizeof addr) == -1) { perror("connect"); return 1; } // reading one back out — e.g. after accept() or recvfrom() filled it: char ip[INET_ADDRSTRLEN]; inet_ntop(AF_INET, &addr.sin_addr, ip, sizeof ip); printf("%s:%u\n", ip, ntohs(addr.sin_port)); // ntohs, or you print 36895

Notice the asymmetry. The port needs htons because you have it as a host-order integer. The address does not need htonl because inet_pton parses the string straight into network order — the string "192.168.1.7" already lists the bytes most-significant first. If you ever build an address from an integer by hand, you do need htonl.

⚠️
The bug that passes every test on one machine. addr.sin_port = 8080; without htons compiles cleanly and connects to port 36895. If a test server happens to listen on both, you will never notice. Printing addr.sin_port without ntohs is the mirror image: it prints 36895 for a socket that is really on 8080. Any time a port looks random, swap the bytes in your head first.

bind(): claiming an address

A client fills a sockaddr_in to say where it wants to go. A server fills one to say where it will listen, and passes it to bind. bind attaches the (address, port) pair to your socket in the kernel. From then on, packets arriving for that pair are delivered to your receive buffer. Two sockets cannot bind the same pair; the second bind fails with EADDRINUSE.

The address you bind decides who can reach you. 127.0.0.1 binds only the loopback interface: only programs on this machine. 0.0.0.0 (INADDR_ANY) binds every interface the machine has: the LAN, Wi-Fi, and anything else. A port of 0 asks the kernel to pick an ephemeral one.

✅
A connection is a 5-tuple. (protocol, local address, local port, remote address, remote port). The kernel uses exactly these five values to decide which socket's receive buffer an arriving packet belongs to. Two connections can share four of the five and still be distinct. This is why one server on port 8080 can talk to thousands of clients: each client has a different remote address or port.

Names instead of numbers

Nobody types 93.184.216.34. They type example.com, and something has to turn that name into a uint32_t. getaddrinfo does it: it asks DNS, fills in a linked list of ready-made sockaddr structs — already in network order — and you connect to the first one that works.

c
#include <netdb.h> struct addrinfo hints = { .ai_family = AF_INET, .ai_socktype = SOCK_STREAM }; struct addrinfo *res; int err = getaddrinfo("example.com", "8080", &hints, &res); if (err != 0) { fprintf(stderr, "%s\n", gai_strerror(err)); return 1; } // res->ai_addr is a struct sockaddr* with port and address already filled in int fd = socket(res->ai_family, res->ai_socktype, 0); connect(fd, res->ai_addr, res->ai_addrlen); freeaddrinfo(res); // it was malloc'd for you; free it

The result is the same 16 bytes you built by hand above. getaddrinfo just saved you the string parsing and the byte swapping. When something goes wrong, it is still those 16 bytes you print and inspect.

one-line takeaway

An endpoint is three integers in a 16-byte struct — family, port, address — and the port and address must be stored big-endian with htons/inet_pton, because the wire and your CPU disagree about which byte comes first.