You’d think after decades of computing we’d have settled on which end of a number goes first in memory. We haven’t. Some processors put the big byte first, some put the little byte first, and both camps have been stubbornly coexisting since the 1970s. If you’re writing embedded code, this becomes your problem the moment two systems need to exchange data.
What’s a Byte Order?
Consider the 32-bit integer 0x12345678. It has four bytes: 0x12, 0x34, 0x56, and 0x78. When stored in memory, these bytes occupy four consecutive addresses. The question is: in what order?
Big endian stores the most significant byte first, at the lowest memory address. The most significant byte is the one that affects the value the most, just like the leftmost digit in a decimal number. In 0x12345678, that’s 0x12. Changing it from 0x12 to 0x13 adds 16 million to the value. Changing the least significant byte (0x78) by the same amount only adds 1.
If our integer starts at address 0x1000, memory looks like this:
Address: 0x1000 0x1001 0x1002 0x1003
Value: 12 34 56 78The “big end”, the byte with the most weight, comes first. This matches how we write numbers: left to right, most significant digit first.
Little endian stores the least significant byte first:
Address: 0x1000 0x1001 0x1002 0x1003
Value: 78 56 34 12The “little end” comes first. If you’re staring at a memory dump trying to find 0x12345678, you’ll see 78 56 34 12 and wonder if something has gone horribly wrong (it hasn’t).
The terms themselves come from Gulliver’s Travels, where two factions go to war over whether to crack eggs at the big end or the little end. The computer science usage is a deliberate joke about arbitrary, religious disputes. Fitting, given how much trouble byte order has caused over the decades.
Which Processors Use Which?
Most processors you’ll encounter in embedded work are little endian. The entire x86 and x86-64 family is little endian. ARM processors are predominantly little endian, though many ARM chips can operate in either mode (they’re “bi-endian”). Most microcontrollers, including AVR, ESP32, STM32, and others based on ARM Cortex-M cores, are little endian.
Big endian is less common but far from extinct. Network protocols standardised on big endian decades ago, which is why it’s often called “network byte order.” This was arguably the right call since big endian matches how humans write numbers, but it means that virtually every little-endian machine (which is most of them) has to byte-swap data at the network boundary. Every TCP/IP stack in the world is doing this constantly.
Some PowerPC systems are big endian. Certain Motorola processors are big endian. And you’ll encounter it in many file formats and communication protocols regardless of what processor you’re running on.
Why This Matters in Embedded Programming
In high-level application programming, you can often ignore endianness entirely. The compiler handles it, and your integers just work. Embedded programming is different. You’re frequently dealing with situations where byte order becomes explicit.
When you read data from a sensor over I2C or SPI, the sensor sends bytes in a specific order defined by its datasheet. If the sensor is big endian and your microcontroller is little endian, you can’t just cast the received bytes to an integer and expect the right answer.
When you’re parsing a binary protocol, say, reading packets from a GPS module or a CAN bus, the protocol specification defines the byte order. You need to decode the bytes correctly regardless of your processor’s native order.
When you’re writing to hardware registers that span multiple bytes, the peripheral’s documentation will tell you the expected byte order. Get it wrong and you’ll write garbage.
When you’re storing data to flash or EEPROM that might be read by a different system, or sending data over a network, you need to agree on a byte order with the other end.
Detecting Endianness
You can determine your system’s endianness at runtime:
int is_little_endian(void) {
uint16_t value = 0x0001;
uint8_t *byte = (uint8_t *)&value;
return byte[0] == 0x01; // If the first byte is 0x01, we're little endian
}This works by storing a known value and then examining the first byte. In little endian, the least significant byte (0x01) is at the lowest address. In big endian, the most significant byte (0x00) would be there.
For compile-time detection, most compilers provide predefined macros. GCC and Clang offer __BYTE_ORDER__, and many embedded toolchains have their own equivalents.
Converting Between Byte Orders
The standard approach is to convert to a known byte order when data crosses a boundary, then convert back on the other side. Network programming uses htons, htonl, ntohs, and ntohl (host-to-network and network-to-host for short and long integers), but these aren’t always available in embedded environments. You’ll often write your own.
Here’s a simple byte swap for a 16-bit value:
uint16_t swap16(uint16_t value) {
return (value << 8) | (value >> 8);
}And for 32-bit:
uint32_t swap32(uint32_t value) {
return ((value >> 24) & 0x000000FF) |
((value >> 8) & 0x0000FF00) |
((value << 8) & 0x00FF0000) |
((value << 24) & 0xFF000000);
}Many compilers provide built-in functions for this. GCC has __builtin_bswap16, __builtin_bswap32, and __builtin_bswap64. What looks like a mess of shifts and masks becomes a single REV instruction on ARM, or BSWAP on x86.
Building Values from Bytes: The Portable Way
Rather than relying on casts and hoping the byte order works out, you can construct multi-byte values explicitly from their component bytes. This approach is portable and makes the byte order obvious in the code.
Reading a big-endian 16-bit value from a byte buffer:
uint16_t read_be16(const uint8_t *buffer) {
return ((uint16_t)buffer[0] << 8) | buffer[1];
}Reading a little-endian 16-bit value:
uint16_t read_le16(const uint8_t *buffer) {
return ((uint16_t)buffer[1] << 8) | buffer[0];
}The same pattern extends to larger values:
uint32_t read_be32(const uint8_t *buffer) {
return ((uint32_t)buffer[0] << 24) |
((uint32_t)buffer[1] << 16) |
((uint32_t)buffer[2] << 8) |
buffer[3];
}
uint32_t read_le32(const uint8_t *buffer) {
return ((uint32_t)buffer[3] << 24) |
((uint32_t)buffer[2] << 16) |
((uint32_t)buffer[1] << 8) |
buffer[0];
}And for writing:
void write_be16(uint8_t *buffer, uint16_t value) {
buffer[0] = (value >> 8) & 0xFF;
buffer[1] = value & 0xFF;
}
void write_le16(uint8_t *buffer, uint16_t value) {
buffer[0] = value & 0xFF;
buffer[1] = (value >> 8) & 0xFF;
}This might look like extra work, but it’s actually cleaner than scattering byte-swap calls throughout your code, and it makes the expected byte order explicit at the point where data is read or written.
A Practical Example: Parsing a Sensor Reading
Say you’re reading from a temperature sensor that sends a 16-bit signed value in big-endian format over I2C. The raw bytes arrive in a buffer:
uint8_t buffer[2];
i2c_read(SENSOR_ADDR, buffer, 2); // Read two bytes from the sensor
// Wrong approach on a little-endian system:
int16_t temp_wrong = *(int16_t *)buffer; // Byte order is reversed!
// Correct approach:
int16_t temp_correct = (int16_t)((buffer[0] << 8) | buffer[1]);The cast approach treats the bytes in the processor’s native order, which gives you a garbage value if that order doesn’t match what the sensor sent. The explicit approach constructs the value correctly regardless of the processor’s endianness.
For signed values, you might need to handle sign extension explicitly if you’re building up from unsigned bytes:
int16_t read_be_int16(const uint8_t *buffer) {
uint16_t raw = ((uint16_t)buffer[0] << 8) | buffer[1];
return (int16_t)raw; // Cast handles sign correctly
}Keeping It Sane
Endianness bugs are nasty because they’re silent. The code compiles. It runs. It doesn’t crash. It just produces garbage, and sometimes that garbage looks almost plausible: off by a factor of 256 here, sign-flipped there. You can stare at the code for hours without seeing it.
A few practices help keep things manageable. Be explicit about byte order in code that crosses system boundaries. Use functions like read_be32 instead of pointer casts. Document the expected byte order in comments or function names. Use fixed-width types (uint16_t, int32_t) rather than int or short when dealing with binary data, so you know exactly how many bytes you’re working with. And when parsing a protocol or file format, keep the specification handy and double-check your byte order assumptions.
Byte order is one of those things that feels like it shouldn’t matter in 2025. We’ve abstracted away so much complexity in computing. Surely we could have standardised this by now. But we haven’t, and in embedded work, you’re close enough to the metal that the abstraction isn’t there to save you. Know your byte order. Be explicit about it in code. And when something’s giving you impossible values, check endianness before you tear apart your hardware.
