Endianness and Byte Order
A 32-bit integer is four bytes. Which byte goes first? The answer depends on the machine -- and it matters every time data crosses a boundary: network sockets, file formats, shared memory between architectures, or talking to hardware.
Little-Endian vs Big-Endian
The value 0x01020304 stored in memory:
Little-endian (x86, ARM default, RISC-V):
Address: 0x00 0x01 0x02 0x03
Content: 0x04 0x03 0x02 0x01
LSB MSB (least significant byte first)
Big-endian (network byte order, SPARC, some ARM modes):
Address: 0x00 0x01 0x02 0x03
Content: 0x01 0x02 0x03 0x04
MSB LSB (most significant byte first)
The term comes from Gulliver's Travels -- which end of the egg do you crack first? In systems programming, you crack whichever end the spec says.
Why It Matters
Within a single machine, endianness is invisible. The CPU loads and stores multi-byte values in its native order, and everything just works.
Problems appear when bytes cross boundaries:
- Network protocols. TCP/IP headers are big-endian (by convention, "network byte order"). An x86 machine must swap bytes before sending and after receiving.
- File formats. Some use big-endian (Java
.classfiles), some use little-endian (most Windows formats), some specify per-file (TIFF). - Hardware registers. PCI is little-endian. Some SoC peripherals are big-endian. The driver must know.
Seeing It In C: The Union Trick
#include <stdio.h>
#include <stdint.h>
union endian_check {
uint32_t word;
uint8_t bytes[4];
};
int main(void) {
union endian_check ec;
ec.word = 0x01020304;
printf("word = 0x%08X\n", ec.word);
printf("bytes: [0]=0x%02X [1]=0x%02X [2]=0x%02X [3]=0x%02X\n",
ec.bytes[0], ec.bytes[1], ec.bytes[2], ec.bytes[3]);
if (ec.bytes[0] == 0x04)
printf("Little-endian\n");
else if (ec.bytes[0] == 0x01)
printf("Big-endian\n");
else
printf("Mixed endian (?)\n");
return 0;
}
On x86 you will see:
word = 0x01020304
bytes: [0]=0x04 [1]=0x03 [2]=0x02 [3]=0x01
Little-endian
Caution: Accessing a union through a different member than the one last written is technically undefined behavior in C99+ (it is defined in C11 as a "type-pun"). In practice, every major compiler supports it. For strictly-conforming code, use
memcpyinstead.
Detecting Endianness at Runtime
The union trick above is one approach. Here is another, using a pointer cast:
#include <stdio.h>
#include <stdint.h>
int is_little_endian(void) {
uint16_t val = 1;
uint8_t *byte = (uint8_t *)&val;
return byte[0] == 1;
}
int main(void) {
printf("This machine is %s-endian\n",
is_little_endian() ? "little" : "big");
return 0;
}
In Rust:
fn is_little_endian() -> bool { let val: u16 = 1; let bytes = val.to_ne_bytes(); // native endian bytes[0] == 1 } fn main() { if is_little_endian() { println!("Little-endian"); } else { println!("Big-endian"); } }
Rust Note: In practice you rarely need runtime detection. Rust's byte conversion methods (
to_be_bytes,to_le_bytes, etc.) handle the conversion for you regardless of the host platform.
The C Conversion Functions: htons, htonl, ntohs, ntohl
POSIX provides four functions to convert between host and network byte order.
htons -- host to network, short (16-bit)
htonl -- host to network, long (32-bit)
ntohs -- network to host, short (16-bit)
ntohl -- network to host, long (32-bit)
On a little-endian machine, these swap bytes. On a big-endian machine, they are no-ops.
#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h>
int main(void) {
uint16_t host_port = 8080;
uint16_t net_port = htons(host_port);
printf("Host order: 0x%04X\n", host_port); /* 0x1F90 */
printf("Net order: 0x%04X\n", net_port); /* 0x901F on LE */
uint32_t host_addr = 0xC0A80001; /* 192.168.0.1 */
uint32_t net_addr = htonl(host_addr);
printf("Host order: 0x%08X\n", host_addr);
printf("Net order: 0x%08X\n", net_addr);
/* Round-trip */
printf("Back: 0x%08X\n", ntohl(net_addr));
return 0;
}
Compile: gcc -o endian endian.c && ./endian
What About 64-bit?
POSIX does not define htonll. You can build your own or use compiler builtins:
#include <stdio.h>
#include <stdint.h>
#include <arpa/inet.h>
uint64_t htonll(uint64_t val) {
#if __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
return __builtin_bswap64(val);
#else
return val;
#endif
}
uint64_t ntohll(uint64_t val) {
return htonll(val); /* same operation -- swap is its own inverse */
}
int main(void) {
uint64_t host_val = 0x0102030405060708ULL;
uint64_t net_val = htonll(host_val);
printf("Host: 0x%016lX\n", host_val);
printf("Net: 0x%016lX\n", net_val);
printf("Back: 0x%016lX\n", ntohll(net_val));
return 0;
}
Rust: to_be_bytes / to_le_bytes / from_be_bytes
Rust takes a different approach: no separate functions, just methods on integer types.
fn main() { let port: u16 = 8080; // Convert to big-endian (network order) bytes let net_bytes = port.to_be_bytes(); println!("port {port} as network bytes: {:02X} {:02X}", net_bytes[0], net_bytes[1]); // Convert back let recovered = u16::from_be_bytes(net_bytes); println!("recovered: {recovered}"); // 32-bit example let addr: u32 = 0xC0A80001; // 192.168.0.1 let net = addr.to_be_bytes(); println!("IP as network bytes: {}.{}.{}.{}", net[0], net[1], net[2], net[3]); // 64-bit -- just works, no special function needed let val: u64 = 0x0102030405060708; let be = val.to_be_bytes(); println!("64-bit big-endian: {:02X?}", be); let back = u64::from_be_bytes(be); println!("recovered: 0x{back:016X}"); }
The full set of methods:
.to_be_bytes() -- convert to big-endian byte array
.to_le_bytes() -- convert to little-endian byte array
.to_ne_bytes() -- convert to native-endian byte array
from_be_bytes() -- construct from big-endian bytes
from_le_bytes() -- construct from little-endian bytes
from_ne_bytes() -- construct from native-endian bytes
Rust Note: These methods return and consume fixed-size arrays (
[u8; 2],[u8; 4],[u8; 8]), not slices. This means the conversion is zero-cost when the compiler can see both the conversion and the use -- it often just emits abswapinstruction or nothing at all.
Wire Format Patterns
When parsing a network packet, always convert from network byte order to host order. When building a packet, always convert from host to network order.
Parsing a Simple Packet Header in C
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <arpa/inet.h>
struct __attribute__((packed)) msg_header {
uint16_t msg_type;
uint16_t msg_length;
uint32_t sequence;
};
void parse_header(const uint8_t *data) {
struct msg_header hdr;
memcpy(&hdr, data, sizeof(hdr));
/* Convert from network byte order */
uint16_t type = ntohs(hdr.msg_type);
uint16_t len = ntohs(hdr.msg_length);
uint32_t seq = ntohl(hdr.sequence);
printf("Type: %u Length: %u Seq: %u\n", type, len, seq);
}
void build_header(uint8_t *buf, uint16_t type, uint16_t len, uint32_t seq) {
struct msg_header hdr;
hdr.msg_type = htons(type);
hdr.msg_length = htons(len);
hdr.sequence = htonl(seq);
memcpy(buf, &hdr, sizeof(hdr));
}
int main(void) {
uint8_t wire[8];
build_header(wire, 1, 128, 42);
printf("Wire bytes: ");
for (int i = 0; i < 8; i++)
printf("%02X ", wire[i]);
printf("\n");
parse_header(wire);
return 0;
}
Same Pattern in Rust
fn parse_header(data: &[u8]) { if data.len() < 8 { eprintln!("Short packet"); return; } let msg_type = u16::from_be_bytes([data[0], data[1]]); let msg_len = u16::from_be_bytes([data[2], data[3]]); let sequence = u32::from_be_bytes([data[4], data[5], data[6], data[7]]); println!("Type: {msg_type} Length: {msg_len} Seq: {sequence}"); } fn build_header(msg_type: u16, msg_len: u16, sequence: u32) -> [u8; 8] { let mut buf = [0u8; 8]; buf[0..2].copy_from_slice(&msg_type.to_be_bytes()); buf[2..4].copy_from_slice(&msg_len.to_be_bytes()); buf[4..8].copy_from_slice(&sequence.to_be_bytes()); buf } fn main() { let wire = build_header(1, 128, 42); print!("Wire bytes: "); for b in &wire { print!("{b:02X} "); } println!(); parse_header(&wire); }
Try It: Extend both programs to include a
uint64_ttimestamp field in the header. Usehtonll/ntohllin C andto_be_bytes/from_be_bytesin Rust.
Byte Swapping Internals
What does a byte swap actually do?
Original (LE): 0x04 0x03 0x02 0x01
Swapped (BE): 0x01 0x02 0x03 0x04
A manual 32-bit swap:
#include <stdio.h>
#include <stdint.h>
uint32_t swap32(uint32_t x) {
return ((x & 0x000000FFu) << 24)
| ((x & 0x0000FF00u) << 8)
| ((x & 0x00FF0000u) >> 8)
| ((x & 0xFF000000u) >> 24);
}
int main(void) {
uint32_t val = 0x01020304;
uint32_t swapped = swap32(val);
printf("0x%08X -> 0x%08X\n", val, swapped);
return 0;
}
Modern compilers recognize this pattern and emit a single bswap instruction. You
can also use the builtins directly:
/* GCC/Clang */
uint16_t s = __builtin_bswap16(0x0102); /* 0x0201 */
uint32_t w = __builtin_bswap32(0x01020304);
uint64_t d = __builtin_bswap64(0x0102030405060708ULL);
In Rust:
fn main() { let val: u32 = 0x01020304; let swapped = val.swap_bytes(); println!("0x{val:08X} -> 0x{swapped:08X}"); // Also available on u16, u64, u128, i32, etc. let s: u16 = 0x0102; println!("0x{s:04X} -> 0x{:04X}", s.swap_bytes()); }
Endianness in Structs: A Complete Example
Suppose a sensor sends data in big-endian format. Here is how you would parse it.
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <arpa/inet.h>
struct __attribute__((packed)) sensor_reading {
uint8_t sensor_id;
uint16_t temperature; /* big-endian, units: 0.1 deg C */
uint32_t timestamp; /* big-endian, Unix epoch */
};
void decode_reading(const uint8_t *raw) {
struct sensor_reading r;
memcpy(&r, raw, sizeof(r));
uint8_t id = r.sensor_id;
uint16_t temp = ntohs(r.temperature);
uint32_t ts = ntohl(r.timestamp);
printf("Sensor %u: %.1f C at t=%u\n", id, temp / 10.0, ts);
}
int main(void) {
/* Simulated wire data: sensor 3, 25.6 C (256 = 0x0100), ts=1000 */
uint8_t wire[] = {
0x03, /* sensor_id */
0x01, 0x00, /* temperature = 256 (big-endian) */
0x00, 0x00, 0x03, 0xE8 /* timestamp = 1000 (big-endian) */
};
decode_reading(wire);
return 0;
}
fn decode_reading(raw: &[u8]) { if raw.len() < 7 { eprintln!("Short reading"); return; } let id = raw[0]; let temp = u16::from_be_bytes([raw[1], raw[2]]); let ts = u32::from_be_bytes([raw[3], raw[4], raw[5], raw[6]]); println!("Sensor {id}: {:.1} C at t={ts}", temp as f64 / 10.0); } fn main() { let wire: &[u8] = &[ 0x03, 0x01, 0x00, 0x00, 0x00, 0x03, 0xE8, ]; decode_reading(wire); }
Driver Prep: PCI and PCIe are little-endian by specification. When your driver reads a register on an x86 host, no swapping is needed. But some SoC buses are big-endian, and the kernel provides
ioread32be/iowrite32befor those. Always check the hardware manual for the device's byte order.
Mixed Endianness in the Wild
Some formats mix endianness. The classic example: the ELF file format. The ELF identification bytes specify the endianness of the rest of the file.
Byte 5 of ELF header (e_ident[EI_DATA]):
1 = ELFDATA2LSB (little-endian)
2 = ELFDATA2MSB (big-endian)
Your parser must read this byte first, then decide how to interpret all subsequent multi-byte fields.
#include <stdio.h>
#include <stdint.h>
#include <string.h>
uint16_t read_u16(const uint8_t *p, int big_endian) {
if (big_endian)
return ((uint16_t)p[0] << 8) | p[1];
else
return ((uint16_t)p[1] << 8) | p[0];
}
uint32_t read_u32(const uint8_t *p, int big_endian) {
if (big_endian)
return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16)
| ((uint32_t)p[2] << 8) | p[3];
else
return ((uint32_t)p[3] << 24) | ((uint32_t)p[2] << 16)
| ((uint32_t)p[1] << 8) | p[0];
}
int main(void) {
uint8_t be_data[] = {0x00, 0x01, 0x00, 0x02};
uint8_t le_data[] = {0x01, 0x00, 0x02, 0x00};
printf("BE u16: %u\n", read_u16(be_data, 1)); /* 1 */
printf("LE u16: %u\n", read_u16(le_data, 0)); /* 1 */
printf("BE u32: %u\n", read_u32(be_data, 1)); /* 65538 */
printf("LE u32: %u\n", read_u32(le_data, 0)); /* 131073 */
return 0;
}
fn read_u16(p: &[u8], big_endian: bool) -> u16 { if big_endian { u16::from_be_bytes([p[0], p[1]]) } else { u16::from_le_bytes([p[0], p[1]]) } } fn read_u32(p: &[u8], big_endian: bool) -> u32 { if big_endian { u32::from_be_bytes([p[0], p[1], p[2], p[3]]) } else { u32::from_le_bytes([p[0], p[1], p[2], p[3]]) } } fn main() { let be_data = [0x00u8, 0x01, 0x00, 0x02]; let le_data = [0x01u8, 0x00, 0x02, 0x00]; println!("BE u16: {}", read_u16(&be_data, true)); println!("LE u16: {}", read_u16(&le_data, false)); println!("BE u32: {}", read_u32(&be_data, true)); println!("LE u32: {}", read_u32(&le_data, false)); }
Quick Knowledge Check
- On a little-endian machine,
uint32_t x = 1;-- what is the value of the byte at((uint8_t *)&x)[0]? What about[3]? - A protocol spec says the port field is "in network byte order." You receive bytes
0x1F 0x90. What is the port number? - You see
htonl(INADDR_ANY)in network code.INADDR_ANYis0. Doeshtonlchange it? Why or why not?
Common Pitfalls
- Forgetting to convert. The most common network bug: sending
uint32_twithouthtonl. It works on big-endian machines, fails on little-endian, and the test server was big-endian. - Double-converting. Calling
htonlon a value that is already in network order swaps it back. Convert exactly once. - Assuming endianness. Your code might run on ARM big-endian someday. Always use explicit conversions for portable code.
- Casting instead of memcpy.
*(uint32_t *)bufis an alignment violation ifbufis not 4-byte aligned. Usememcpyorfrom_be_bytes. - No 64-bit POSIX function.
htonllis not standard. Roll your own or use__builtin_bswap64.