Skip to content
MINH VO A working notebook
by an engineer in Vietnam
Foundation8 min read

Endianness changes byte order, not bit significance

Place 0x12345678 at four byte addresses, reconstruct it explicitly, and keep byte order separate from width, alignment and signedness.

Illustrated circuit board resting on drafting paper with faint technical sketches

Endianness defines the order of bytes used to store a multi-byte value. It does not reverse the significance of bits inside the integer. If you are reading a binary file or network message, the useful question is which byte belongs at the lowest address and how the parser reconstructs the value.

Take the 32-bit unsigned number 0x12345678. Its most significant byte is 0x12, and its least significant byte is 0x78. In little-endian storage, the least significant byte comes first at increasing addresses. In big-endian storage, the most significant byte comes first.

Put four bytes at explicit addresses

Suppose the value begins at address 0x1000. The address names the first byte, regardless of the integer’s byte order.

AddressLittle-endian byteBig-endian byte
0x10000x780x12
0x10010x560x34
0x10020x340x56
0x10030x120x78

A byte dump lists storage locations. A debugger displaying a typed 32-bit value may interpret those bytes before printing the number. The two views can therefore look reversed while describing the same memory correctly.

The RV32I memory specification discusses byte-addressed memory and endianness. Its distinction between byte accesses and wider accesses is useful here: loading one byte does not require ordering several bytes, while loading a word does.

The same 32-bit value occupies addresses 0x1000 through 0x1003 in opposite byte orders for little-endian and big-endian storageView full-size image ↗

The bits in byte 0x78 remain 01111000 in both columns. A serial link’s bit-transmission order is another protocol property and must be specified separately. Calling both properties endianness without qualification can hide which transformation is actually required.

Reconstruct the number from its bytes

For little-endian bytes b0 through b3, compute b0 + 256*b1 + 65536*b2 + 16777216*b3. Each successive byte contributes to a more significant position. Big-endian reconstruction assigns those powers in the reverse order.

little = bytes([0x78, 0x56, 0x34, 0x12])
big = bytes([0x12, 0x34, 0x56, 0x78])
assert int.from_bytes(little, "little") == 0x12345678
assert int.from_bytes(big, "big") == 0x12345678
wrong = int.from_bytes(little, "big")
assert wrong == 0x78563412
manual = sum(byte << (8 * i) for i, byte in enumerate(little))
assert manual == 0x12345678

The wrong-order result is still a perfectly valid 32-bit integer. A parser may not crash. It may instead interpret a modest length as an enormous allocation request, select an unknown message type, or reject a checksum later. Checking a recognizable test pattern near the decoding boundary makes the error easier to locate.

All-zero and all-one values are poor byte-order tests because reversal leaves them unchanged. A value such as 0x12345678 gives every byte a different position. Include a realistic boundary value as well, because correctness on a decorative pattern does not establish range handling.

Make the protocol choose the order

A network packet’s byte order comes from its specification, not from the CPU receiving it. RFC 768 lays out the UDP header’s fixed-width fields; implementations interpret Internet protocol fields according to their specified wire representation. A host may need conversion between that representation and its native integer layout.

A portable parser can decode explicitly from bytes. In JavaScript, DataView exposes a byte-order argument for multi-byte reads. In Python, int.from_bytes accepts an explicit byte-order argument; use it to document the format rather than relying on a default. These interfaces make the assumption visible during review. Native typed-array views or raw casts can instead inherit the host’s representation.

The parser also needs a length check before reading the bytes. Correct byte-order logic cannot make a truncated header contain four bytes. Treat bounds validation, width, signedness, and byte order as separate fields of the decoding contract. They can fail independently.

Decode a mixed-order header field by field

A format can assign different byte-order rules to different fields. Consider an invented eight-byte header: one version byte, one flags byte, a two-byte big-endian payload length, and a four-byte little-endian identifier. The format is deliberately mixed to make field boundaries visible; it is not a UDP header or a proposed replacement for an existing protocol.

For version one, flags 0xA5, payload length 512 and identifier 0x12345678, the header bytes are 01 A5 02 00 78 56 34 12. The length’s two bytes represent 512 because 0x02 occupies the high byte. The identifier’s first byte is 0x78 because its field uses little-endian order. Reversing the entire header would scramble field positions as well as the one-byte version and flags.

An invented eight-byte header has version and flags bytes, a big-endian length 02 00 and a little-endian identifier 78 56 34 12View full-size image ↗

The parser below accepts exactly one complete packet. It rejects a short header before slicing fields, then checks the supported version and the declared payload length against the entire supplied packet. A stream parser could instead buffer a partial packet or return the number of bytes consumed, but that is a different interface and needs its own contract.

def parse_packet(packet):
    if len(packet) < 8:
        raise ValueError("short header")
    if packet[0] != 1:
        raise ValueError("unsupported version")
    length = int.from_bytes(packet[2:4], "big")
    identifier = int.from_bytes(packet[4:8], "little")
    if len(packet) != 8 + length:
        raise ValueError("payload length mismatch")
    return packet[1], identifier, packet[8:]

header = bytes.fromhex("01 a5 02 00 78 56 34 12")
payload = bytes(512)
assert parse_packet(header + payload) == (0xa5, 0x12345678, payload)
for malformed in (header[:7], header, header + payload + b"x"):
    try:
        parse_packet(malformed)
    except ValueError:
        pass
    else:
        raise AssertionError("malformed packet accepted")
assert int.from_bytes(header[2:4], "little") == 2

wrong_wire = (512).to_bytes(2, "little")
assert int.from_bytes(wrong_wire, "little") == 512
assert wrong_wire != header[2:4]
print("mixed-order packet, three length failures and false round trip checked")

The short-header test is necessary even though Python slicing itself tolerates short slices. Without the explicit check, a missing four-byte identifier could be decoded from fewer bytes and quietly accepted as a different integer. A language that traps on an out-of-range fixed-width read exposes the same contract violation differently; the parser should define the error rather than depend on that accident of API behavior.

The final three lines demonstrate why a round trip is insufficient. An incorrect little-endian encoder and matching decoder recover 512 together, but their bytes differ from the declared big-endian length field. A known byte vector provides evidence independent of the two functions’ shared assumption. Keep that vector when porting the parser to another language, and compare every field before benchmarking a faster implementation.

For a follow-up exercise, add a maximum accepted payload length before allocating or copying data. Test the largest accepted value, the next value, a short packet and an extra trailing byte. Then decide whether an unknown flag bit is preserved, ignored or rejected. Those choices extend the format contract; changing byte order alone cannot answer them.

Avoid using a pointer cast as a parser

In C or C++, casting a byte-buffer address to a pointer to a wider integer can introduce alignment and object-model problems in addition to native endianness. Whether an unaligned access happens to work on one processor does not settle the language rules or portability to another target.

An explicit byte assembly or a suitable library function is easier to reason about. Copying bytes into an integer with a permitted operation addresses some object-access concerns, but the resulting integer still reflects the host byte order unless conversion follows. One correct step does not silently perform every required step.

Packed structures have similar limits. Removing padding does not by itself define the byte order of each integer field, and the structure’s representation may still depend on compiler extensions or ABI rules. A binary format needs a stable representation that survives a different compiler and architecture.

Keep byte swapping at the boundary

Once a parser has converted a field into an ordinary host-language integer, calculations should use that integer normally. Repeatedly swapping before arithmetic and swapping again afterward makes ownership of the representation hard to follow. Name the point where bytes become values and the point where values become bytes.

For example, an application may hold a packet length as the integer 512 internally. Encoding that field writes either 00 02 or 02 00 according to a two-byte wire format. The integer itself does not need to be described as little-endian while it participates in a bounds comparison.

Round-trip tests help: encode a value, decode the resulting bytes, and recover the original within the permitted range. They should also compare the encoded bytes against an external specification or known vector. Two functions with the same reversed convention can pass a round trip while remaining incompatible with every other implementation.

When a binary field looks implausible, retain the original bytes in a safe diagnostic and inspect one conversion path. A raw four-byte example, its declared width, and its expected integer value provide enough evidence to test endianness without guessing from the machine’s brand or the programming language.

Sources & further reading

  1. RISC-V unprivileged ISA: RV32I base integer instruction set
  2. RFC 768: User Datagram Protocol
  3. Python built-in types: int.from_bytes
  4. MDN: DataView.getUint32 byte order and bounds
← Back to the journal
All notes

Illustration

100%