Skip to content

Byte order on the wire

Level: 201 · working knowledge

One line: A number wider than one byte has to be stored in some order, the machine picks one you did not choose (and the two machines this library builds on happen to pick the same one), and the wire picks the other kind of choice — one fixed order both ends agree on, big-endian, called network byte order — so the way to serialize a number portably is with shifts, which produce the order you asked for on any machine, rather than by copying the integer's memory, which produces the machine's.

Endianness is the question "when you write a four-byte number, which byte goes first?" and it has two defensible answers. A single machine picks one and is consistent, so within one program you never notice. The moment the bytes leave — to a file another machine reads, to a socket — the two ends must agree, and "whatever my machine does" is not an agreement. The fix is old and simple: pick an order, write it down, and serialize with arithmetic that does not depend on the machine.

Shifts give the order you name

byte_order_c.c takes 0x01020304 and lays it out both ways with shifts, then checks htonl against the result:

byte_order_c.c in full — pasted here by tools/run_examples.py from the file CI runs.

/* A value wider than one byte has to be written in some order, and the machine
   picks one you did not choose. The wire picks the other kind of choice: one
   fixed order both ends agree on, "network byte order", which is big-endian.
   Serialising with shifts gives that order on every machine -- which is the
   point of doing it with shifts rather than by copying the integer's memory. */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <arpa/inet.h>

static void dump(const char *label, const void *p, size_t n)
{
    const unsigned char *b = p;
    printf("%-34s", label);
    for (size_t i = 0; i < n; i++)
        printf(" %02x", b[i]);
    printf("\n");
}

int main(void)
{
    uint32_t v = 0x01020304u;

    /* Big-endian, by shifting -- most significant byte first. Same four bytes
       on a little-endian machine, a big-endian one, anywhere. */
    unsigned char be[4] = { v >> 24, v >> 16, v >> 8, v };
    dump("0x01020304 big-endian (shifts):", be, 4);

    /* Little-endian, the same way. */
    unsigned char le[4] = { v, v >> 8, v >> 16, v >> 24 };
    dump("0x01020304 little-endian (shifts):", le, 4);

    /* htonl converts host order to network (big-endian) order. Dumped as bytes
       the result is big-endian whatever the host is -- so this line is the
       same on both machines, while the raw memory of `v` would not be. */
    uint32_t net = htonl(v);
    dump("htonl(0x01020304), as bytes:", &net, 4);

    /* Read it back with shifts: no endianness assumption on the way in. */
    uint32_t back = (uint32_t)be[0] << 24 | (uint32_t)be[1] << 16
                  | (uint32_t)be[2] << 8  | be[3];
    printf("read back from big-endian bytes: 0x%08x, round trip ok: %s\n",
           back, back == v ? "yes" : "no");
    return 0;
}

Verified output of byte_order_c.c — regenerated by tools/run_examples.py, never hand-typed.

0x01020304 big-endian (shifts):    01 02 03 04
0x01020304 little-endian (shifts): 04 03 02 01
htonl(0x01020304), as bytes:       01 02 03 04
read back from big-endian bytes: 0x01020304, round trip ok: yes

Building the bytes with shifts — v >> 24 for the most significant, down to v for the least — produces 01 02 03 04 for big-endian and 04 03 02 01 for little-endian, and those lines are the same on every machine, because a shift is arithmetic on the value, not a read of its storage. That is the whole trick. htonl (host-to-network-long) converts to network order, and dumping its result as bytes gives 01 02 03 04 whatever the host is — which is why that line is safe to record as an answer, while dumping the raw memory of v would print the host's order and would be a different four bytes on a big-endian machine. Reading back with shifts makes no assumption either, so the round trip holds anywhere.

Rust names the order in the method

Rust does not leave the order implicit — it is a word in the method name. byte_order_rs.rs:

byte_order_rs.rs in full — pasted here by tools/run_examples.py from the file CI runs.

// Rust names the choice in the method. `to_be_bytes` and `to_le_bytes` write a
// number's bytes in a stated order, `from_be_bytes` reads them back, and none
// of them depends on the machine this runs on -- the order is in the name.
fn main() {
    let v: u32 = 0x0102_0304;
    println!("0x01020304 to_be_bytes() = {:02x?}", v.to_be_bytes());
    println!("0x01020304 to_le_bytes() = {:02x?}", v.to_le_bytes());
    println!("0x01020304 to_ne_bytes() = {:02x?}  (native: this machine's order)",
             v.to_ne_bytes());

    let wire = v.to_be_bytes();
    let back = u32::from_be_bytes(wire);
    println!("from_be_bytes(...) = 0x{back:08x}, round trip ok: {}", back == v);

    // The native order, named rather than assumed.
    println!("cfg!(target_endian = \"little\") = {}", cfg!(target_endian = "little"));
}

Verified output of byte_order_rs.rs — regenerated by tools/run_examples.py, never hand-typed.

0x01020304 to_be_bytes() = [01, 02, 03, 04]
0x01020304 to_le_bytes() = [04, 03, 02, 01]
0x01020304 to_ne_bytes() = [04, 03, 02, 01]  (native: this machine's order)
from_be_bytes(...) = 0x01020304, round trip ok: true
cfg!(target_endian = "little") = true

to_be_bytes and to_le_bytes state the order and are identical on any machine; from_be_bytes reads it back. to_ne_bytes is the native order — and here it prints 04 03 02 01, and cfg!(target_endian = "little") is true, because both of this library's CI machines are little-endian (x86-64 on the Linux runner, arm64 on the macOS one; both little-endian). Those two lines are the ones that would change on a big-endian machine, which is exactly why to_ne_bytes is the method you do not send over a wire — it is in the name, ne for "native", as a warning.

If you are coming from another language

Python. int.to_bytes(4, "big") and int.from_bytes(data, "big") are the direct equivalents, with the order as a plain argument — and struct.pack(">I", n) does the same with a format string, where > means big-endian, covered in the next lesson. Byte order there is byte order and the BOM ↗, the same question where the multi-byte unit is a character.

ABAP. (Not machine-checked — CI cannot run ABAP.) Historically the interesting case, because SAP application servers ran on both big-endian and little-endian hardware, so a number written by one and read by another had to agree on an order — the reason RFC and the database layer serialize in a defined order rather than dumping memory. It is the same lesson, learned on a fleet.

See also