C Part II · Module 14 · C Through a Hardware Engineer's Eyes
Endianness and Byte Order
Copy a 32-bit value into four bytes on this machine and you get 44 33 22 11. The specification says the wire carries 11 22 33 44. Both are the same number; only one of them is what you were asked to transmit. This chapter measures the difference, then measures something more useful: the portable way of writing those bytes costs exactly one extra machine instruction, which settles the performance objection permanently. It also spends a full section on when byte order does not matter at all — because a reader who swaps defensively corrupts data just as reliably as one who never swaps.
Advanced22 min readCPart IIHardwareRepresentation
Part II · Module 14 · Chapter 14.3 · Endianness and Byte Order
1. One Value, Two Orderings
A 32-bit value is a number. It has a most significant byte and a least significant byte, and those are properties of the number, fixed by arithmetic. 0x11223344 has a most significant byte of 0x11 and a least significant byte of 0x44, on every machine ever built.
An object holding that value occupies four consecutive bytes of storage, and something has to decide which byte goes first. That decision is byte order, or endianness, and C does not make it — the implementation does.
#include <stdio.h>
#include <stdint.h>
#include <string.h>
int main(void) {
uint32_t v = 0x11223344u;
unsigned char raw[4];
memcpy(raw, &v, sizeof v);
printf("in memory: %02x %02x %02x %02x\n", raw[0], raw[1], raw[2], raw[3]);
return 0;
}in memory: 44 33 22 11The least significant byte is stored first. This machine is little-endian, which is also what x86-64 and most ARM configurations do. A big-endian machine stores 11 22 33 44 — most significant first — and that ordering is what the majority of network and bus protocols specify, which is why it is often called network byte order.
Neither is more correct. The number is identical; only the storage order differs.
2. Where It Comes From, Briefly
It is worth one paragraph, because the names are otherwise arbitrary and arbitrary names are hard to keep straight.
Little-endian stores the little end — the least significant byte — at the lowest address. It has a tidy property: the address of a 32-bit object is also the address of its low 8 bits, so narrowing a uint32_t * to a uint8_t * and reading gives the bottom byte without any adjustment. Big-endian stores the big end first, which makes a hex dump of memory read in the same order as the number written on paper.
The terms come from Gulliver's Travels, where two factions go to war over which end of a boiled egg to crack. The reference was chosen deliberately, by Danny Cohen in 1980, to make the point that the choice is arbitrary and the argument is not worth having. He was right, and the practical consequence is the only part that matters: when bytes stay inside one program on one machine, the order is invisible; when they leave, it is a specification.
3. When It Does Not Matter — Which Is Most of the Time
This section comes before the problem, because getting this backwards causes more corruption than endianness itself does.
Arithmetic never exposes byte order. The operators work on values, not on representations.
uint32_t v = 0x11223344u;
printf("v + 1 = 0x%08x\n", v + 1);
printf("v >> 24 = 0x%02x (always the most significant byte)\n", v >> 24);
printf("v & 0xFF = 0x%02x (always the least significant byte)\n", v & 0xFFu);v + 1 = 0x11223345
v >> 24 = 0x11 (always the most significant byte)
v & 0xFF = 0x44 (always the least significant byte)v >> 24 gives 0x11 here and gives 0x11 on a big-endian machine too. The shift operator is defined in terms of the value's bits — multiply or divide by a power of two — not in terms of which byte sits at which address. Shifts and masks are endian-independent by construction. That single fact is the foundation of everything in §5, and it is why the portable answer is not a workaround but the natural way to write the code.
So byte order is irrelevant to:
- all arithmetic, comparison, shifting and masking
- assigning one
uint32_tto another, or passing one to a function - storing a value in a structure that only your program reads
sizeof, and the result of any comparison
It becomes relevant at exactly one kind of moment: when the bytes of an object are read or written as bytes by something that did not agree with your compiler about the order. That means crossing a bus, a network, a file, a shared memory region with a differently-built program, or a language boundary into a testbench. Everywhere else, not swapping is correct and swapping is a bug.
4. The Measurement That Matters
Here is the whole problem in one program: the same value, written out the way the machine happens to store it, and written out the way a specification demands.
#include <stdio.h>
#include <stdint.h>
#include <string.h>
static void dump(const char *tag, const unsigned char *b, size_t n) {
printf("%-34s", tag);
for (size_t i = 0; i < n; i++) printf("%02x ", b[i]);
putchar('\n');
}
/* The specification says: transmit the 32-bit value big-endian, MSB first. */
static void put_be32(unsigned char *p, uint32_t v) {
p[0] = (unsigned char)(v >> 24);
p[1] = (unsigned char)(v >> 16);
p[2] = (unsigned char)(v >> 8);
p[3] = (unsigned char)(v );
}
static uint32_t get_be32(const unsigned char *p) {
return (uint32_t)p[0] << 24 | (uint32_t)p[1] << 16
| (uint32_t)p[2] << 8 | (uint32_t)p[3];
}
int main(void) {
uint32_t v = 0x11223344u;
unsigned char raw[4], be[4];
memcpy(raw, &v, sizeof v); /* whatever this machine stores */
put_be32(be, v); /* what the specification asks for */
dump("memcpy of the object:", raw, 4);
dump("put_be32 (the wire format):", be, 4);
printf("\nget_be32(be) = 0x%08x round trip %s\n",
get_be32(be), get_be32(be) == v ? "OK" : "BROKEN");
return 0;
}memcpy of the object: 44 33 22 11
put_be32 (the wire format): 11 22 33 44
get_be32(be) = 0x11223344 round trip OKRead the middle and bottom rows together. The amber row is what you get by copying the object, and it is a property of the build. The green row is what you get by shifting the value, and it is a property of the specification. Only one of them is something you can promise to a hardware engineer.
5. The Portable Form, and What It Costs
put_be32 and get_be32 above contain no test of the host's byte order, no #ifdef, and no swap. They do not need any, because §3 established that shifts work on the value. On a big-endian machine the identical source produces the identical four bytes — not because it detects the host, but because it never asks.
That is the whole technique, and it generalises to every width and both orders:
static void put_le16(unsigned char *p, uint16_t v) {
p[0] = (unsigned char)(v );
p[1] = (unsigned char)(v >> 8);
}
static uint32_t get_le32(const unsigned char *p) {
return (uint32_t)p[0] | (uint32_t)p[1] << 8
| (uint32_t)p[2] << 16 | (uint32_t)p[3] << 24;
}Note the casts to uint32_t before shifting in get_le32. Without them, p[3] << 24 shifts an int — p[3] is a unsigned char promoted to int — and for a byte of 0x80 or more that shift reaches the sign bit of a 32-bit int, which is Chapter 13.4's undefined behaviour. This is Chapter 14.2 §6's point arriving in real code: the type you declared is not the type the arithmetic used.
The objection, and the measurement that answers it. Four byte loads and three shifts look expensive next to one 32-bit load. Rather than argue, compile all three forms and read what comes out:
uint32_t get_le32(const unsigned char *b) {
return (uint32_t)b[0] | (uint32_t)b[1] << 8
| (uint32_t)b[2] << 16 | (uint32_t)b[3] << 24;
}
uint32_t get_be32(const unsigned char *b) {
return (uint32_t)b[0] << 24 | (uint32_t)b[1] << 16
| (uint32_t)b[2] << 8 | (uint32_t)b[3];
}
void put_be32(unsigned char *b, uint32_t v) {
b[0]=(unsigned char)(v>>24); b[1]=(unsigned char)(v>>16);
b[2]=(unsigned char)(v>>8); b[3]=(unsigned char)v;
}$ cc -std=c17 -O2 -S genbe.c -o -
_get_le32:
ldr w0, [x0]
ret
_get_be32:
ldr w8, [x0]
rev w0, w8
ret
_put_be32:
rev w8, w1
str w8, [x0]
retThe little-endian reconstruction is one instruction. The compiler recognised the shift-and-or pattern as a 32-bit load and emitted exactly that. The big-endian form is two — a load and a byte-reverse — and that single rev is the entire cost of writing portable serialisation on a little-endian machine. On a big-endian host the two would swap places and the cost would be zero.
So the portable form is not a performance compromise that you accept for correctness. On this target it is one instruction, it is correct everywhere, and it contains no host-dependent code to get wrong.
6. The Misconception: "I Am Little-Endian, So I Must Swap"
"This machine is little-endian and the protocol is big-endian, so I byte-swap everything before sending."
This is the most common way endianness bugs are created rather than avoided, and it fails in two directions.
It swaps things that should not be swapped. A buffer of uint8_t has no byte order. A string has no byte order. A field that your own program wrote and your own program will read has no byte order that anyone else can observe. Swapping any of these corrupts them, and the corruption is invisible on a round trip through your own code — it only appears when the other side reads it.
It depends on a fact about the host that the code does not check. The swap is correct given that the host is little-endian. Move the same source to a big-endian build and every swap is now wrong, in a codebase that contains no mention of why.
The shift form in §5 has neither failure mode, because it never forms an opinion about the host. put_be32 writes the most significant byte first because that is what the specification said, full stop.
The correct mental model is not "my machine is little-endian, so convert". It is:
A value in a variable has no byte order. A value in a byte buffer has exactly the byte order you put there. Conversion happens once, at the boundary, in the function that moves between the two.
One put_be32 at the point of serialisation and one get_be32 at the point of parsing. No swaps anywhere else, no #ifdef, and no host test.
7. What This Chapter Is Not About
Bit order is a different question and is deliberately not answered here.
Byte order concerns which byte of a multi-byte object sits at which address. Bit numbering concerns which bit within a byte is called bit 0, and whether a datasheet's "bit 7" is the most or least significant. The two are routinely conflated, and the conflation produces code that swaps bytes when the real disagreement was about field positions.
For C specifically, the question mostly does not arise: &, |, << and >> are defined on the value, so bit 0 is the one worth 2⁰ and bit 31 is the one worth 2³¹, on every machine, regardless of byte order. Where it does arise is in reading a datasheet whose diagrams number bits the other way round, and in bit-fields, whose allocation order within a unit is implementation-defined — which Chapter 10.10 established in Part I and Chapter 16.7 revisits for register maps. Module 15 is where bit positions get their proper treatment.
8. What You Should Be Able to Say Now
- A value has a most and least significant byte; an object has a first and last byte. Endianness is the mapping between them, and C leaves it to the implementation. Measured little-endian here:
0x11223344stores as44 33 22 11. - Byte order is invisible to arithmetic.
v >> 24is0x11andv & 0xFFis0x44on every machine, because the operators are defined on the value. This is why the portable technique works. - It matters only when bytes cross a boundary — bus, network, file, shared memory, another language. Scan for the byte-level operations (
memcpyto a byte array,fwrite, a cast tounsigned char *, a byte-array union), not foruint32_t. - Serialise with shifts, not with a copy of the object.
put_be32/get_be32contain no host test and give the specified bytes on any machine. Cast to the wide type before shifting, orp[3] << 24is Chapter 13.4's undefined shift. - The cost is one instruction. Measured:
get_le32compiles toldr,get_be32toldr+rev. The portable form is not a performance compromise. - Swapping is a second choice.
htonland__builtin_bswap32are conditional on the host; the shift form is not.__builtin_bswap32is a compiler extension, not ISO C. - "I am little-endian so I swap" is the bug, not the fix. A variable has no byte order; a buffer has the order you put in it; conversion happens once, at the boundary.
- Union,
memcpyandunsigned char *are the three legal ways to see an object's bytes. A cast to an unrelated object pointer is not, and clang diagnoses it with nothing, including under-Weverything.
9. Exercises
- Run §1's program and record the four bytes. Then print
v >> 24andv & 0xFFand explain why those two lines would be unchanged on a big-endian host while the first four bytes would not. - Write
put_le32,get_le32,put_be16andget_be16. Test each by round-tripping a value with all four bytes distinct, and by checking the byte array against the order you intended by hand. - Compile
get_le32andget_be32at-O2 -Son your machine and count the instructions. If your target is not arm64, say what it emitted and whether the one-instruction result still held. - Deliberately omit one
(uint32_t)cast inget_be32, build with-fsanitize=undefined, and feed it a buffer whose first byte is0xFF. Record the diagnostic and name the chapter that first established that rule. - Take a structure your own code writes to a file and decide, field by field, whether each needs a byte order. Write down which fields do not and why — a field list where nothing needs swapping is a valid and common answer.
- Write the version of
put_be32that a defensive programmer would write — one that tests the host's endianness and swaps conditionally. Then list every way it can be wrong that the shift version cannot. - Reproduce the
bits_badmeasurement from §6's callout. Confirm the right answer and the absent diagnostic. Then write thememcpyversion and compare the generated code at-O2.
10. Summary
- A value has a most/least significant byte (arithmetic); an object has a first/last byte (storage). Endianness maps between them and is the implementation's choice. Measured here: little-endian,
0x11223344→44 33 22 11. - Arithmetic never exposes byte order.
v >> 24=0x11everywhere. Shifts and masks are endian-independent by construction. - It matters only at a byte-level boundary. Look for
memcpyto byte arrays,fwrite, casts tounsigned char *, byte-array unions — not for wide types. - Serialise with shifts. No host test, no
#ifdef. Cast to the wide type before shifting or the shift is UB. - Measured cost: one instruction.
ldrversusldr+rev. The performance objection does not survive contact with the compiler. htonl/__builtin_bswap32are conditional on the host, and the latter is a compiler extension. Second choice, not first.- A variable has no byte order; a buffer has the one you wrote. Convert once, at the boundary.
- Legal byte views: union,
memcpy,unsigned char *. A cast to an unrelated object pointer printed the right answer with no diagnostic under-Weverythingand is still undefined. - Bit order is a separate question and belongs to Module 15.
put_be32 wrote four bytes beginning at a pointer the caller supplied, and nothing in this chapter asked where that pointer came from. That was safe here because the buffer was a plain unsigned char array, which may begin anywhere. The moment the same four bytes are read back as a 32-bit object rather than written as four separate ones, a requirement appears that the array never had to satisfy — and the hardware, not the standard, is what enforces it.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
