C Basics · beginner · ~15 min
**What you will learn** - Explain what endianness is and the difference between **little-endian** and **big-endian** byte order. - Inspect the raw bytes of a multi-byte integer in memory using pointers and a hex view. - Convert between **host byte order** and **network byte order** with `htons`, `htonl`, `ntohs`, and `ntohl`. - Read and write multi-byte fields from a byte buffer **safely**, one byte at a time, using shifts and masks. - Detect your machine's endianness at runtime, and write code that produces the same on-disk/on-wire bytes on any CPU. - Recognize the classic endianness bugs in networking and binary file parsing, and avoid the undefined behavior that comes from misaligned pointer casts.
When you store a single byte (a char or uint8_t), there is only one byte, so there is nothing to order. But the moment a value needs more than one byte — a 16-bit short, a 32-bit int, a 64-bit long — the CPU must decide which byte goes at the lower memory address. That ordering decision is called endianness.
Think of the number 0x12345678. It is made of four bytes: 12, 34, 56, and 78, where 12 is the most-significant byte (the "big" end, worth the most) and 78 is the least-significant byte (the "little" end, worth the least). There are two common ways to lay those four bytes out in memory:
78 56 34 12.12 34 56 78.This builds directly on Data types, where you learned that an int is several bytes wide, and on Bitwise operations, where you learned to shift (<<, >>) and mask (&, |) bits — the exact tools you use to take a value apart byte by byte and put it back together.
As long as a value lives entirely inside one running program on one CPU, you never notice endianness: the CPU writes the bytes and the same CPU reads them back consistently. The topic becomes critical the instant bytes leave the process — onto a network socket, into a binary file, or out to a hardware register — because the reader on the other side might use a different byte order. A huge fraction of networking, file-format, and reverse-engineering bugs trace back to a single endianness mismatch.
C is one of the few languages that lets you look at the raw bytes of a value directly through a pointer. That power is exactly why endianness matters here more than in higher-level languages that hide the representation.
The danger zone is any boundary where bytes cross between two parties that must agree on the layout:
From a security standpoint, length and size fields decoded with the wrong endianness are a classic root cause of buffer overflows and denial-of-service bugs: a 2-byte length of 0x0010 (16) read with the bytes swapped becomes 0x1000 (4096), and now your code trusts a size that is 256x too large. Getting byte order right is part of writing parsers that do not crash on malformed input.
Definition. Endianness is the convention for ordering the bytes of a multi-byte scalar value in memory (or in a stream of bytes).
The name comes from Gulliver's Travels, where two factions argue over which end of a boiled egg to crack — a fitting metaphor for a choice that is arbitrary but must be agreed upon.
How it looks in memory. Take uint32_t v = 0x12345678; stored starting at address 0x1000:
addr: 0x1000 0x1001 0x1002 0x1003
little-endian: 0x78 0x56 0x34 0x12 (least-significant byte first)
big-endian: 0x12 0x34 0x56 0x78 (most-significant byte first)
Notice the value 0x12345678 is identical in both cases — only the in-memory byte sequence differs. Within a single program the CPU is self-consistent, so v == 0x12345678 regardless of endianness. The difference is only visible when you read the bytes one at a time or dump them to a file/socket.
When it matters / when it does not. It does not matter for arithmetic, comparisons, or anything that stays inside one process. It matters whenever bytes are serialized: sockets, files, shared memory between different architectures, and hardware.
Pitfall. Assuming "my machine is little-endian, so everyone is." Most desktops/servers are little-endian (x86-64, ARM in its usual mode), but network protocols are big-endian, and some older or specialized systems differ. Code that assumes one endianness is non-portable.
Knowledge check (predict the output): A little-endian machine stores
uint16_t x = 0x00FF;. What is the value of the first byte in memory (lowest address):0x00or0xFF?
Definition. Host byte order is whatever order your CPU uses natively. Network byte order is a fixed convention — always big-endian — used by the TCP/IP protocol family so that machines of different architectures can communicate.
How it works. POSIX provides four conversion helpers in <arpa/inet.h>:
| Function | Meaning | Width |
|---|---|---|
htons |
host to network | 16-bit (short) |
htonl |
host to network | 32-bit (long) |
ntohs |
network to host | 16-bit |
ntohl |
network to host | 32-bit |
On a little-endian host these functions swap the bytes; on a big-endian host they do nothing (a no-op), because host order already equals network order. You write the same code either way and it stays portable.
host (LE) 0x12345678 --htonl--> wire bytes: 12 34 56 78 (big-endian)
wire bytes 12 34 56 78 --ntohl--> host value: 0x12345678
When to use. Every time you put an integer onto a socket or read one off it. Convert on the way out with hton* and on the way in with ntoh*.
Pitfall. Converting on the way out but forgetting to convert back on the way in (or vice versa). Two conversions cancel out; one conversion leaves the value byte-swapped.
Knowledge check (concept): On a big-endian CPU, what does
htonl(x)return? Why is calling it still the correct, portable thing to do?
Definition. Building a multi-byte integer from individual bytes (or breaking one apart) using the bitwise shift and OR operators from the Bitwise operations lesson.
How it works. To read a 32-bit big-endian value from a 4-byte buffer b, place each byte at its correct bit position and OR them together:
byte: b[0] b[1] b[2] b[3]
shift: << 24 << 16 << 8 << 0
result: MMMMMMMM ........ ........ LLLLLLLL (M = most sig, L = least sig)
The key safety detail: cast each byte to the wide type before shifting. b[0] is an unsigned char that gets promoted to int; shifting by 24 is fine for int, but to be unambiguous and correct on all widths you write (uint32_t)b[0] << 24.
When to use. Always prefer this for parsing untrusted/external data. It is explicit, portable across every CPU, and avoids alignment problems.
Pitfall. Forgetting the cast and shifting a value that is too narrow, or omitting the mask & 0xFF when extracting a byte from a wider value that might have its high bits set.
Knowledge check (find the bug): A learner writes
uint32_t v = b[0] << 24 | b[1];to read a value. Two bytes are missing and the precedence may surprise them. What two problems do you see?
Conversion helpers (POSIX):
#include <arpa/inet.h> /* declares the htons/htonl family */
uint32_t htonl(uint32_t hostlong); /* host -> network (big-endian) */
uint16_t htons(uint16_t hostshort); /* 16-bit version */
uint32_t ntohl(uint32_t netlong); /* network -> host */
uint16_t ntohs(uint16_t netshort); /* 16-bit version */
Mnemonics: hton* = host to network; ntoh* = network to host. The trailing l = "long" (32-bit), s = "short" (16-bit).
Reading a big-endian value by hand (the portable pattern):
#include <stdint.h>
uint32_t read_be32(const uint8_t *b) {
return ((uint32_t)b[0] << 24) /* most-significant byte first */
| ((uint32_t)b[1] << 16)
| ((uint32_t)b[2] << 8)
| (uint32_t)b[3]; /* least-significant byte last */
}
Writing a big-endian value by hand:
void write_be32(uint8_t *b, uint32_t v) {
b[0] = (uint8_t)(v >> 24); /* mask is implicit: cast to uint8_t keeps low 8 bits */
b[1] = (uint8_t)(v >> 16);
b[2] = (uint8_t)(v >> 8);
b[3] = (uint8_t)(v);
}
On x86 and ARM, the least-significant byte of a 32-bit value comes first in memory. This is little-endian.
On the network, the most-significant byte comes first. This is big-endian.
Whenever you cross that boundary, you have to convert. Crossing the boundary means, for example:
#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h> /* htonl, ntohl */
/* Print the raw bytes of any object, lowest address first. */
static void dump_bytes(const char *label, const void *p, size_t n) {
const unsigned char *b = (const unsigned char *)p;
printf("%-18s", label);
for (size_t i = 0; i < n; i++)
printf("%02x ", b[i]);
putchar('\n');
}
/* Detect host endianness at runtime by inspecting the first byte of 1. */
static int host_is_little_endian(void) {
uint32_t one = 1;
const unsigned char *first = (const unsigned char *)&one;
return first[0] == 1; /* LE stores the least-significant byte (01) first */
}
/* Portable big-endian read: works the same on any CPU. */
static uint32_t read_be32(const uint8_t *b) {
return ((uint32_t)b[0] << 24)
| ((uint32_t)b[1] << 16)
| ((uint32_t)b[2] << 8)
| (uint32_t)b[3];
}
int main(void) {
uint32_t host = 0x12345678u;
printf("Host is %s-endian\n\n",
host_is_little_endian() ? "little" : "big");
/* 1. Show how the value sits in memory on THIS machine. */
dump_bytes("host value bytes:", &host, sizeof host);
/* 2. Convert to network (big-endian) order and show the wire bytes. */
uint32_t net = htonl(host);
dump_bytes("network bytes:", &net, sizeof net);
/* 3. A buffer that already holds big-endian bytes (e.g. read from a socket). */
uint8_t wire[4] = { 0x12, 0x34, 0x56, 0x78 };
/* Two correct ways to decode it back to a host integer: */
uint32_t via_ntohl;
/* copy bytes into an integer, then convert (avoids unaligned pointer cast) */
__builtin_memcpy(&via_ntohl, wire, sizeof via_ntohl);
via_ntohl = ntohl(via_ntohl);
uint32_t via_manual = read_be32(wire); /* portable, no conversion func needed */
printf("\ndecoded via ntohl: 0x%08x\n", via_ntohl);
printf("decoded manually: 0x%08x\n", via_manual);
return 0;
}
What it does. The program prints whether the CPU is little- or big-endian, dumps the in-memory bytes of 0x12345678, converts the value to network order and dumps those bytes (which are always 12 34 56 78), then decodes a known big-endian buffer two ways and confirms both give 0x12345678.
Expected output on a typical little-endian machine (x86-64, ARM):
Host is little-endian
host value bytes: 78 56 34 12
network bytes: 12 34 56 78
decoded via ntohl: 0x12345678
decoded manually: 0x12345678
On a big-endian machine, the first two byte dumps would both read 12 34 56 78 and htonl would be a no-op — but the decoded results stay the same, which is the whole point of writing portable code.
Edge cases. __builtin_memcpy (or plain memcpy from <string.h>) is used instead of *(uint32_t*)wire because wire may not be 4-byte aligned; the manual read_be32 path avoids the issue entirely. If you ever needed a 64-bit conversion, note that standard hton* only covers 16- and 32-bit; for 64-bit you assemble bytes manually or use htobe64 from <endian.h> on Linux.
We trace the program on a little-endian host.
uint32_t host = 0x12345678u; — the value is stored. In memory, because the host is little-endian, the bytes sit as 78 56 34 12 (least-significant first).host_is_little_endian() makes a uint32_t one = 1; (bytes 01 00 00 00 on LE), takes a char* to it, and returns first[0] == 1. Since the first byte is 01, it returns true.dump_bytes("host value bytes:", &host, 4) reads the four bytes of host from the lowest address up and prints 78 56 34 12 — direct proof of little-endian layout.uint32_t net = htonl(host); — on LE, htonl swaps the bytes, so net now holds a bit pattern whose in-memory bytes are 12 34 56 78. (The integer value of net, if printed with %x, would look swapped — that is expected; net is meant to be read as bytes, not as a host integer.)dump_bytes("network bytes:", &net, 4) prints 12 34 56 78 — the canonical big-endian wire order.uint8_t wire[4] = {0x12,0x34,0x56,0x78}; — simulates four bytes just received from a socket, already in network order.__builtin_memcpy(&via_ntohl, wire, 4) copies those bytes verbatim into the integer via_ntohl (now its in-memory bytes are 12 34 56 78). via_ntohl = ntohl(via_ntohl); swaps them back on LE so the integer value becomes 0x12345678.read_be32(wire) assembles the value directly: (0x12<<24) | (0x34<<16) | (0x56<<8) | 0x78 = 0x12345678, with no dependence on host endianness.Trace of read_be32:
| step | expression | partial result |
|---|---|---|
| 1 | (uint32_t)b[0] << 24 |
0x12000000 |
| 2 | ` | (uint32_t)b[1] << 16` |
| 3 | ` | (uint32_t)b[2] << 8` |
| 4 | ` | (uint32_t)b[3]` |
Both decode paths print 0x12345678, confirming the buffer was interpreted correctly.
memcpy-ing a host integer straight onto the wireWrong:
uint32_t len = 16;
memcpy(packet, &len, 4); /* sends 10 00 00 00 on a little-endian host */
The peer expects big-endian and reads 0x10000000 (268 million), not 16. Why it's wrong: you serialized host byte order instead of network byte order.
Corrected:
uint32_t len_net = htonl(16);
memcpy(packet, &len_net, 4); /* sends 00 00 00 10 */
Prevent it: make hton* the only way an integer reaches a buffer that leaves the process.
Wrong: call htonl when writing but read the field back with a plain copy. The value comes back byte-swapped. Why: a single conversion does not round-trip. Corrected: pair every hton* on send with an ntoh* on receive. Recognize it: values that look "shifted" by a power of 256 (e.g. you stored 1 and read 16777216).
Wrong:
uint32_t v = *(uint32_t *)(buf + 1); /* buf+1 may be unaligned */
Why it's wrong: on strict-alignment CPUs (some ARM, MIPS) this is undefined behavior and can crash; even on x86 it is non-portable and skips endianness handling. Corrected: use memcpy into an aligned integer (then convert), or assemble bytes with read_be32. Prevent it: never cast a char*/uint8_t* buffer to a wider integer pointer and dereference.
Wrong: uint32_t v = (b[0] << 24); where b[0] is unsigned char. After integer promotion this works on most platforms but is fragile and confusing; if the type were signed or narrower it could shift into the sign bit. Corrected: (uint32_t)b[0] << 24. Recognize it: sporadic wrong high bytes or compiler warnings about shift count/overflow.
Compiler-level:
implicit declaration of function 'htonl' → you forgot #include <arpa/inet.h>.-Wconversion / shift warnings → add the explicit (uint32_t) casts before shifting bytes.Runtime / logic:
0x78563412 instead of 0x12345678): you read the buffer in the wrong endianness; switch between the BE and LE assembly, or add the missing ntoh*.memcpy or byte assembly.Concrete steps:
xxd file.bin | head shows the on-disk bytes in order. In code, print with printf("%02x ", byte) in a loop, or printf("%08x", value) for a whole word.12 34 56 78 but your integer is 0x78563412, you have an endianness flip.host_is_little_endian() check rather than guessing.Questions to ask when it doesn't work: Where does this data cross a boundary? What endianness does the spec require there? Am I converting on exactly the boundary, and exactly once per direction? Is the buffer aligned for the cast I'm doing?
Endianness handling sits right next to several classic C memory hazards, so keep these in mind:
uint8_t *buf to uint32_t * and dereferencing it is UB when buf is not suitably aligned. On strict-alignment hardware it can raise SIGBUS; the compiler may also assume alignment and miscompile. Use memcpy into a properly typed local, or assemble the value byte-by-byte.read_be32(b), make sure at least 4 bytes are available: if (remaining < 4) { /* reject */ }. Reading 4 bytes from a 2-byte buffer is an out-of-bounds read. This is exactly where misparsed length fields turn into overflows.uint8_t, uint16_t, uint32_t from <stdint.h> so byte counts are unambiguous; int/long widths vary across platforms.The robust pattern for external data is: check bounds, copy/assemble bytes explicitly in the documented endianness, convert, then validate the resulting value's range.
Where endianness shows up in real systems:
sin_port and sin_addr in network order, which is why htons(port) appears in nearly every server you will ever write.Professional best practices.
Beginner rules: always use htons/htonl at the socket boundary; prefer byte-by-byte assembly for file/packet parsing; use <stdint.h> fixed-width types; never cast a byte buffer to a wider pointer and dereference; bounds-check before reading.
Advanced habits: centralize all (de)serialization in small, well-tested helper functions (read_be32, write_be16, …) so byte order is handled in one auditable place; document the endianness of every wire/file field in code comments or a schema; add unit tests that round-trip a value through encode→decode and compare bytes against a known-good hex literal; write code that is endianness-agnostic (manual assembly) rather than relying on the host happening to match.
Beginner 1 — Byte-swap a 32-bit value. Implement uint32_t bswap32(uint32_t x) that reverses the four bytes, so 0x11223344 becomes 0x44332211. Requirements: use only shifts and masks, no library calls. Hint: move byte 0 to position 3, byte 1 to position 2, etc., then OR them. Concepts: shifts, masks, byte position.
Beginner 2 — Detect host endianness. Write int is_little_endian(void) that returns 1 on a little-endian host and 0 otherwise by inspecting the bytes of (uint32_t)1. Requirements: no library functions. I/O example: prints little on x86-64. Hint: take a char* to the value and look at the first byte. Concepts: pointers to bytes, memory layout.
Intermediate 1 — Read a 16-bit big-endian length. Implement uint16_t read_be16(const uint8_t *b) that returns (b[0]<<8)|b[1]. Then write a main that reads bytes {0x01, 0x2C} and prints the value. Expected: 300. Requirements: cast before shifting; bounds-check assumed satisfied by the caller. Concepts: manual assembly, big-endian order.
Intermediate 2 — Round-trip encoder/decoder. Write write_be32 and read_be32, then in main encode 0xDEADBEEF into a 4-byte buffer, dump the buffer in hex, decode it back, and assert it equals the original. Requirements: the hex dump must read de ad be ef; use assert. Hint: writing masks with the low 8 bits via cast to uint8_t. Concepts: encode/decode symmetry, hex dumping.
Challenge — Safe length-prefixed message parser. Given a buffer and its length, parse a sequence of records where each record is a 2-byte big-endian length L followed by L payload bytes. Return the number of complete records, and reject the input safely if any length runs past the end of the buffer. Requirements: no out-of-bounds reads; validate L against remaining bytes before advancing; handle a truncated trailing record by stopping cleanly. I/O example: buffer 00 02 41 42 00 01 43 → 2 records ("AB", "C"). Hint: track a size_t offset and remaining = len - offset; check remaining >= 2 then remaining - 2 >= L. Concepts: big-endian read, bounds checking, defensive parsing of untrusted size fields.
htons/htonl on the way out and ntohs/ntohl on the way in — exactly once per direction. On a big-endian host these are no-ops, which is what keeps your code portable.(uint32_t)b[0]<<24 | ...), casting each byte to the wide type first. This works on any CPU and avoids alignment problems.