You write uint32_t t = 1000000; in your firmware, you send it over UART, and
the other side prints 1078071040. Nothing is corrupt. Nothing is lost. You have
just been introduced to endianness: the order in which the bytes of a
multi-byte value sit in memory, and it is the same argument that makes htonl
exist at all. This is the whole subject, with real output from real programs on
an x86_64 box — no hand-waving about “it depends on the platform”.
Everything below is real. The C programs were compiled with gcc 16.2.1 and run
on this x86_64 Linux box; the Python programs were run under Python 3.14. I dumped
the bytes rather than asserting what they should be, because that is the only way
this lesson sticks.
Little vs big endian: what endianness actually is
A 32-bit value has four bytes. Endianness answers one question: when you store
that value at address A, which byte goes at A?
- Little endian — the least significant byte goes first.
0x11223344becomes44 33 22 11. Used by x86, x86-64, and modern ARM. - Big endian — the most significant byte goes first.
0x11223344becomes11 22 33 44. Used by the network stack (that is why it is called network byte order), by Modbus, by many older MCUs, and by a lot of peripherals where the datasheet simply says “MSB first”.
Two machines can agree on every type, every size, every alignment and still disagree on the bytes. That is the entire problem.
Who uses which, in practice: x86 and x86-64 are little endian, always. ARM in its
common configuration (Cortex-M, Cortex-A, Apple silicon) is little endian by
default but the architecture is bi-endian — some toolchains can build either.
PowerPC and Motorola/Freescale parts are usually big endian. RISC-V is little
endian in every mainstream implementation. MIPS is famous for existing in both
flavours with the same chip model numbers, which is why cross-compiling for
MIPS is a rite of passage in embedded. The network stack is big endian
everywhere, which is where htonl and the name “network byte order” come from.
Byte order is not bit order
A related confusion, because both get called “that endianness thing”. Byte order is about which byte of a multi-byte value comes first. Bit order is about which bit of a single byte goes out first on the wire — an asynchronous UART is LSB-first, whereas SPI, I2C and Modbus are MSB-first. They are independent: a UART can send a big-endian 16-bit value, LSB-first within each byte. When I am reverse-engineering an unknown bus I keep the two questions separate: which bit leaves first, and which byte leaves first.
How to SEE it: dump the bytes
Stop reasoning about endianness and start printing it. The trick is to look at
the object through an unsigned char *, which is the only pointer type the
standard guarantees to alias everything.
#include <stdint.h>
#include <stdio.h>
#include <arpa/inet.h>
int main(void) {
uint32_t v = 0x11223344u;
unsigned char *p = (unsigned char *)&v;
printf("bytes at increasing addresses: ");
for (size_t i = 0; i < sizeof v; i++) printf("%02x ", p[i]);
printf("\n");
printf("htonl(0x11223344) bytes: ");
uint32_t n = htonl(v);
unsigned char *q = (unsigned char *)&n;
for (size_t i = 0; i < sizeof n; i++) printf("%02x ", q[i]);
printf("\n");
return 0;
}
$ gcc -O2 -o dump dump.c && ./dump
bytes at increasing addresses: 44 33 22 11
htonl(0x11223344) bytes: 11 22 33 44
The least significant byte, 0x44, sits at the lowest address. That is a
little-endian host, which is what x86 gives you. And htonl handed me the
reversed bytes, which is what a big-endian peer expects.
In Python the same information is one attribute away, which makes it the fastest place to check when a script disagrees with a device:
$ python3 -c "import sys; print(sys.byteorder)"
little
xxd says the same thing about anything on disk. A Python program writing the
value 0xA1B2C3D4 twice:
import struct
open("le.bin", "wb").write(struct.pack("<Ii", 0xA1B2C3D4, 305419896))
open("be.bin", "wb").write(struct.pack(">Ii", 0xA1B2C3D4, 305419896))
$ xxd le.bin
00000000: d4c3 b2a1 7856 3412 ....xV4.
$ xxd be.bin
00000000: a1b2 c3d4 1234 5678 .....4Vx.
$ file le.bin be.bin
le.bin: pcap capture file, microsecond ts (little-endian) - version 22136.4660
be.bin: pcap capture file, microseconds ts (big-endian) - version 4660.22136
44 33 22 11 versus 11 22 33 44 is the whole article. Note the bonus: file
recognised my throwaway file as a pcap, purely from that magic number, and told
me the byte order. Byte-order sniffing is exactly how my pcap parser
decides how to read a capture written by somebody else’s machine.
Network byte order and what htonl really does
htons (host to network short) and htonl (host to network long) convert from
whatever order your CPU uses into big-endian. A slightly longer variant of the
program above, which also prints the network-order and byte-swapped forms:
$ ./bytes
uint32 0x11223344: 44 33 22 11
htonl(0x11223344): 11 22 33 44
endianness: little
htonl(1) = 16777216
__builtin_bswap32(v) = 1144201745
union { u32 i; u8 c[4]; }: c[0] = 1 -> little endian
Two things to take from that.
htonl(1) is 16777216 here. That is the portable endianness test: on a
big-endian host it would be 1, on a little-endian host it is 1 << 24. If you
need to know the byte order at runtime, this is a one-liner that works
everywhere and needs no configure.
htons and htonl are not byte swaps. They are host-to-network
conversions, and on a big-endian host they are complete no-ops. That is not a
detail — it is the reason a htons unit test is nearly worthless:
uint16_t v = 0x1234;
assert(htons(v) == 0x3412); /* passes on x86, fails nowhere-yet */
That assertion only proves you are on a little-endian machine. It is a real
gotcha for tests: a test suite written on x86 and run later on a big-endian CI
runner will start failing on an assertion that was never testing the protocol in
the first place. Assert on the bytes you actually put on the wire, not on the
return value of htons.
The reason the conversion exists is that the Internet engineers chose a byte
order and stuck to it, and BSD sockets follow that choice all the way down:
struct sockaddr_in’s sin_port and sin_addr are in network byte order, which
is why you pass htons(8080) to bind() and never 8080 directly. Everything
above the socket is your own business, so if your protocol goes over TCP you have
one boundary to convert at and one place to get it right.
The reverse conversions ntohs/ntohl are the same functions in the other
direction, and they round-trip: ntohl(htonl(x)) == x on every platform.
The classic bug: sending a raw struct over a socket
This is where endianness stops being trivia. It is tempting: write(fd, &msg, sizeof msg) takes four seconds to write. Here is what it actually puts on the
wire.
#define _GNU_SOURCE
#include <stdint.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/wait.h>
struct reading { /* 1 + 4 + 2 = 7 bytes of fields */
uint8_t id;
uint32_t t; /* milliseconds */
uint16_t value;
};
static void show(const char *label, const void *b, size_t n) {
printf("%-26s", label);
for (size_t i = 0; i < n; i++) printf("%02x ", ((const unsigned char *)b)[i]);
printf("\n");
}
int main(void) {
struct reading r = { .id = 7, .t = 1000000u, .value = 0x1122 };
unsigned char dirty[sizeof r];
memset(dirty, 0xAA, sizeof dirty); /* pretend stack garbage */
memcpy(dirty, &r, sizeof r);
show("we send (12 bytes):", dirty, sizeof r);
int sv[2]; /* child = the peer */
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
if (fork() == 0) { close(sv[0]); write(sv[1], dirty, sizeof r); _exit(0); }
unsigned char got[64]; /* parent = the receiver */
ssize_t n = read(sv[0], got, sizeof got);
wait(NULL);
show("peer reads (12 bytes):", got, n);
struct reading back;
memcpy(&back, got, sizeof back);
printf("receiver decodes: id=%u t=%u value=0x%04x\n",
back.id, back.t, back.value);
/* the same data from a big-endian MCU: 7 bytes, no padding */
unsigned char be[7] = { r.id, 0x00, 0x0f, 0x42, 0x40, 0x11, 0x22 };
show("MCU sends (7 bytes):", be, sizeof be);
uint32_t wrong, right;
memcpy(&wrong, be + 1, 4); /* host order -- a lie */
right = ((uint32_t)be[1] << 24) | ((uint32_t)be[2] << 16)
| ((uint32_t)be[3] << 8) | (uint32_t)be[4];
printf("t as host uint32: %u t big endian: %u ratio %.0f\n",
wrong, right, (double)wrong / right);
return 0;
}
$ gcc -O2 -Wall -Wextra -o wirebug wirebug.c && ./wirebug
we send (12 bytes): 07 aa aa aa 40 42 0f 00 22 11 aa aa
peer reads (12 bytes): 07 aa aa aa 40 42 0f 00 22 11 aa aa
receiver decodes: id=7 t=1000000 value=0x1122
MCU sends (7 bytes): 07 00 0f 42 40 11 22
t as host uint32: 1078071040 t big endian: 1000000 ratio 1078
Read the output, because it contains three separate bugs.
Padding. sizeof(struct reading) is 12, not 7 — offsetof(t) is 4 and
offsetof(value) is 8, so the compiler filled the gaps. The aa aa aa between
id and t are those padding bytes, which I forced to 0xAA to make them
visible; in real code they are whatever happened to be on the stack. You just sent
uninitialised stack memory to a peer — that is also how you leak data you did not
mean to send.
Endianness. t goes out as 40 42 0f 00. Look at what a Modbus-style MCU
sends for the same value: 00 0f 42 40. Reading those bytes as a host
uint32_t gives 1078071040 instead of 1000000 — off by a factor of 1078.
The direction is symmetric: the MCU reads my 40 42 0f 00 as 1078071040 too.
Length. Same struct, 12 bytes versus 7. A protocol that says “12 bytes” and a peer that sends 7 never even get to argue about the numbers.
The cruel part is the first three lines: the round trip through a real
socketpair() works perfectly. Both processes on x86, same struct definition,
same compiler — the bug is invisible until one end moves.
Detecting endianness, and byte-swapping
You rarely need to detect it. The compiler already knows. __BYTE_ORDER__ is
available at compile time on GCC and Clang:
#if __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__
/* big endian */
#endif
A runtime version via a union is legal, fast, and — at -O2 — folded away
entirely. objdump on the binary I compiled shows host_is_big_endian() inlined
and reduced to an immediate 0; the function does not exist in the binary at
all. And the htonl(1) trick above gives you the answer with no headers.
The one place you genuinely need to detect it is reading a file or a frame that
carries its own order, and the idiomatic way is to read a known magic value and
check which way round it is. If you read the four bytes a1 b2 c3 d4 you are
looking at a big-endian source; if you read d4 c3 b2 a1 it was little endian.
That single test decides how you read the rest of the file, which is exactly what
pcap, and every other self-describing binary format, does.
For explicit byte swap work, use __builtin_bswap32 / __builtin_bswap16
(and __builtin_bswap64 if you need it). I checked what gcc 16 actually emits
for a non-constant input — one instruction, no loop:
$ gcc -O2 -std=c11 -o swap swap.c && ./swap
bswap32(0x11223344) = 0x44332211
bswap16(0x1234) = 0x3412
$ objdump -d swap --no-show-raw-insn | sed -n '/<sw>:/,/^$/p'
1190: mov %edi,%eax
1192: bswap %eax
1194: ret
Pure Python’s equivalent is a slice reversal: x.to_bytes(4, "big")[::-1].
Where it bites in embedded
A sensor register. Many ADC and IMU chips stream multi-byte samples MSB
first. A 3-byte register reading 7f 12 34:
raw = bytes([0x7F, 0x12, 0x34])
raw.hex(" "), int.from_bytes(raw, "big"), int.from_bytes(raw, "little")
'7f 12 34' 8327732 3412735
Correct answer, per the datasheet: 8327732. The little-endian reading is 3412735
— off by a factor of about 2.4. Sign makes it worse: the same bytes read as
signed big-endian give -60876, while an unsigned little-endian read gives
3412735. When a value that should be negative comes out huge and positive,
suspect byte order first.
Modbus. Every field in a Modbus frame is big-endian, without exception, and there is no flag to say so. Registers 1000, 258 and 48879 look like this:
regs = [1000, 258, 0xBEEF]
wire = b"".join(struct.pack(">H", r) for r in regs)
for r in regs:
onwire = struct.pack(">H", r)
print("reg 0x%04x: big-endian read %6d | little-endian read %6d"
% (r, struct.unpack(">H", onwire)[0], struct.unpack("<H", onwire)[0]))
reg 0x03e8: big-endian read 1000 | little-endian read 59395
reg 0x0102: big-endian read 258 | little-endian read 513
reg 0xbeef: big-endian read 48879 | little-endian read 61374
If you have been reverse-engineering an unknown device, the same logic is your friend: my walkthrough on decoding a serial protocol — capture the bytes, form a hypothesis about their meaning, test it — is exactly the method for working out which byte order an undocumented device uses. Try both and see which one gives values that change plausibly when the physical input changes.
File formats. pcap sniffs its own magic (shown above). PNG is big-endian. BMP is little-endian. WAV chunks are little-endian. PostScript and JPEG are big-endian. The format tells you; you just have to read that part of the spec.
File formats on an MCU. SPI flash parts typically stream big-endian in their command protocol but little-endian — or vendor-specific — in their JEDEC ID and SFDP tables. If a flash chip returns an ID that is a byte-swapped version of what the datasheet shows, this is why.
The rule: define the wire format, convert at the boundary
The lesson from the struct bug is not “endianness is hard”. It is that a struct is not a serialisation format. A struct describes your compiler’s layout decisions: padding, alignment, field order, and byte order. None of those are part of your protocol unless you write them down.
So write it down, and then write the codec once. Here is the same struct reading done properly — an explicit nine-byte frame, every field’s offset,
width and byte order stated in a comment, and static_assert on the layout so a
compiler change cannot silently move a field:
struct reading { /* 1 + 4 + 2 = 7 bytes of fields */
uint8_t id;
uint32_t t; /* milliseconds */
uint16_t value;
};
static_assert(offsetof(struct reading, id) == 0, "id at 0");
static_assert(offsetof(struct reading, t) == 4, "t at 4");
static_assert(offsetof(struct reading, value) == 8, "value at 8");
static void put_u16(unsigned char *b, uint16_t v) { b[0] = v >> 8; b[1] = v; }
static void put_u32(unsigned char *b, uint32_t v) {
b[0] = v >> 24; b[1] = v >> 16; b[2] = v >> 8; b[3] = v;
}
static void put_reading(unsigned char *b, struct reading r) {
b[0] = r.id;
put_u32(b + 1, r.t); /* offset 1, 4 bytes, big endian */
put_u16(b + 5, r.value); /* offset 5, 2 bytes, big endian */
b[7] = 0; /* reserved */
b[8] = 0;
}
$ gcc -O2 -std=c11 -o codec codec.c && ./codec
sizeof(struct reading) = 12, sizeof(frame) = 9
frame on the wire: 07 00 0f 42 40 11 22 00 00
decoded back: id=7 t=1000000 value=0x1122
The frame is nine bytes, always nine bytes, on any host, with no padding and no host byte order in it. The cost is about five lines per field, which is the cheapest insurance in embedded work.
What I do instead of memcpying structs:
- Write the wire format down explicitly — byte offsets, widths, and byte order for every field. “u16 big endian at offset 4” is a specification; “send the struct” is not.
- Serialise field by field at the boundary —
put_u32(buf + 4, v), orstruct.pack(">Iih", …), or a codec with a schema. One place converts, everywhere else works in host types. - Never send a raw struct across a process, machine, or architecture boundary. Not over a socket, not into a file, not into shared memory.
- Test against bytes, not values — assert the frame is
00 0f 42 40, which is a statement about the protocol.assert(htons(x) == …)is a statement about your laptop. - Reserve bytes, do not let the compiler insert them. Packing the struct
(
__attribute__((packed))) looks tempting but trades padding for unaligned accesses, which some ARM and MIPS cores trap on. An explicitb[7] = 0costs nothing and is visible in the spec.
Endianness is not going away. It lives in every bus you touch, and the bytes really do come out backwards — on around half the hardware in the world.