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 .class files), 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 memcpy instead.

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 a bswap instruction 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_t timestamp field in the header. Use htonll/ntohll in C and to_be_bytes/from_be_bytes in 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 / iowrite32be for 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

  1. On a little-endian machine, uint32_t x = 1; -- what is the value of the byte at ((uint8_t *)&x)[0]? What about [3]?
  2. A protocol spec says the port field is "in network byte order." You receive bytes 0x1F 0x90. What is the port number?
  3. You see htonl(INADDR_ANY) in network code. INADDR_ANY is 0. Does htonl change it? Why or why not?

Common Pitfalls

  • Forgetting to convert. The most common network bug: sending uint32_t without htonl. It works on big-endian machines, fails on little-endian, and the test server was big-endian.
  • Double-converting. Calling htonl on 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 *)buf is an alignment violation if buf is not 4-byte aligned. Use memcpy or from_be_bytes.
  • No 64-bit POSIX function. htonll is not standard. Roll your own or use __builtin_bswap64.