RayBench EmbeddedInteractive engineering labs
FOUNDATIONS

Number Representation

Bases and why hex is the one you read, two's complement and the asymmetry that makes abs(INT_MIN) undefined, the bit-manipulation idioms with the edge case in each, and byte order - which matters in exactly three places and in none of the arithmetic.

Reviewed 2026-08-224,786 words

The mental model

A bit pattern has no meaning by itself. Width and interpretation give it meaning. The same eight bits can represent an unsigned count, a negative two's-complement value, a character, flags, or part of an instruction.

Firmware constantly crosses representation boundaries: ADC codes become voltages, register fields become states, signed sensor values arrive as bytes, and narrow arithmetic is promoted before it is evaluated. Most surprising integer bugs come from losing track of width, signedness, or the point where truncation occurs.

Core rules

Width defines the range

An unsigned N-bit value covers 0 through 2^N - 1. A two's-complement signed N-bit value covers -2^(N-1) through 2^(N-1) - 1.

Hex is a compact bit view

One hexadecimal digit represents exactly four bits. Group binary from the right in groups of four to convert without decimal arithmetic.

Unsigned arithmetic wraps

Unsigned operations are defined modulo 2^N. Signed overflow is undefined behavior in C, so the compiler may assume it never happens.

Promotions happen first

Types narrower than int are normally promoted before arithmetic. The result may be signed int even when both stored operands are uint8_t.

Conversion is not validation

A cast documents or forces a conversion, but it does not prove that the source value fits the destination range.

Workflow

  1. Write the exact source and destination types.
  2. State the width and signedness of every intermediate.
  3. Perform the operation at a deliberately wide type.
  4. Check range before narrowing.
  5. Test zero, maximum, minimum, and the first value outside the valid range.

Worked example

Decode a signed 12-bit sample
int16_t decode_s12(uint16_t raw)
{
    raw &= 0x0FFFu;
    if ((raw & 0x0800u) != 0u) {
        raw |= 0xF000u;  // sign-extend bit 11
    }
    return (int16_t)raw;
}

The mask removes unrelated upper bits. Bit 11 is the sign bit of the 12-bit representation. Filling bits 15:12 with ones preserves the negative value when interpreted as int16_t.

Vocabulary

radix
The base of a number system, such as 2, 10, or 16.
two's complement
The standard signed integer representation where negation is invert plus one.
sign extension
Copying the sign bit into newly added upper bits when widening a signed value.
truncation
Discarding upper bits when narrowing a value.
integer promotion
The C rules that widen small integer types before most expressions are evaluated.

The mental model

A hardware register is a packed structure. Each bit or field has an independent meaning, access policy, and reset value. Bitwise code is serialization code for that structure.

Clock gates, GPIO modes, interrupt flags, protocol status, and peripheral configuration all use packed fields. One careless read-modify-write can change reserved bits, acknowledge an event, or race with hardware.

Core rules

Mask before inserting

Clear the destination field, mask the input to the field width, shift it into position, then combine it with OR.

Use unsigned operands

Use fixed-width unsigned types and unsigned literals for masks and shifts. Avoid shifting signed values or shifting by the type width.

Know the access policy

Read/write, read-only, write-one-to-clear, write-one-to-set, and read-to-clear registers require different code. Do not apply a generic read-modify-write to all of them.

Reserved bits are not yours

Preserve reserved bits when the manual requires it and write documented reset values when it does not. Never infer behavior from a neighboring MCU.

Bit-fields are not register maps

C bit-field layout, allocation order, and access width are implementation-defined. Masks and shifts are portable and reviewable.

Workflow

  1. Copy the field position, width, reset value, and access policy from the exact reference manual.
  2. Build a named mask using a fixed-width unsigned type.
  3. Validate the input range.
  4. Choose direct write or read-modify-write based on access policy.
  5. Read back only when the peripheral documents that readback is meaningful.

Worked example

Update one field
#define MODE_Pos  4u
#define MODE_Msk  (0x7u << MODE_Pos)

bool set_mode(volatile uint32_t *reg, uint32_t mode)
{
    if (mode > 0x7u) return false;
    uint32_t value = *reg;
    value = (value & ~MODE_Msk) | (mode << MODE_Pos);
    *reg = value;
    return true;
}

The field is cleared before insertion, and the range check prevents value bits from leaking into adjacent fields. This pattern is correct only for an ordinary read/write register.

Vocabulary

mask
A bit pattern selecting the positions an operation may affect.
field
A contiguous group of bits representing one value.
W1C
Write one to clear: writing a 1 acknowledges or clears that flag.
RMW
Read-modify-write: read a value, alter selected bits, then write it back.
reserved bit
A position without public software meaning that must follow the manual's write rule.

The mental model

Endianness is the order of bytes in memory, not the order of bits on paper. A protocol byte sequence and a CPU's native integer layout are separate contracts.

Binary protocols, flash formats, DMA buffers, register pairs, and file structures outlive the CPU that produced them. Explicit encoding keeps their meaning stable across architectures and alignment rules.

Core rules

Byte order needs a width

Calling one byte little-endian is meaningless. Endianness applies when two or more bytes form one value.

Wire order is a protocol rule

Decode according to the protocol specification even when it matches the current CPU. Native pointer casts hide that contract.

Alignment is separate

A byte buffer may start at any address. Casting it to uint32_t* can violate alignment even if byte order is correct.

Struct layout is not a packet format

Padding, alignment, field width, and byte order make a C struct an unsafe default serialization format.

Encode and decode at boundaries

Keep internal values in native types. Convert only when reading or writing a defined external representation.

Workflow

  1. Write the byte-level format with offsets and widths.
  2. Check bounds before reading each field.
  3. Assemble values with shifts or a tested decode helper.
  4. Validate lengths, ranges, and checksums after decoding.
  5. Test with a fixed byte vector whose expected value is written independently.

Worked example

Portable big-endian helpers
uint32_t read_be32(const uint8_t p[4])
{
    return ((uint32_t)p[0] << 24) |
           ((uint32_t)p[1] << 16) |
           ((uint32_t)p[2] <<  8) |
           ((uint32_t)p[3]);
}

void write_be16(uint8_t p[2], uint16_t v)
{
    p[0] = (uint8_t)(v >> 8);
    p[1] = (uint8_t)v;
}

Each byte is widened before shifting, so no signed promotion corrupts the result. The code makes both width and wire order explicit and works on aligned or unaligned buffers.

Vocabulary

little-endian
The least significant byte is stored at the lowest address.
big-endian
The most significant byte is stored at the lowest address.
network byte order
The big-endian byte order used by standard Internet protocol fields.
alignment
The address boundary required or preferred for a type or bus access.
serialization
Converting in-memory values into a stable byte representation.

Positional notation, and why hex is the one you read

Every base works the same way: each digit position carries a weight that is the base raised to the position index. Binary is how the hardware stores things and is unreadable at any length; decimal is how people think and maps onto nothing physical. Hexadecimal exists because 16 is 2^4, so exactly one hex digit covers exactly four bits with no arithmetic - which is why every register dump, memory address and protocol trace you will ever read is in hex.

How it is built

  • A digit at position i contributes digit x base^i, counting from zero at the right.
  • Binary needs eight characters to express one byte; hex needs two, and each maps to a fixed nibble.
  • Octal survives in exactly one common place - Unix file permissions - because three bits is one octal digit and permissions come in threes.
  • Conversion between binary and hex requires no arithmetic at all: group the bits in fours and substitute.
  • Decimal conversion does require arithmetic, which is why tools show hex and humans reach for a calculator for decimal.

Design procedure

  1. Read a hex value by expanding each digit to its four bits when you need the bit positions, and leave it as hex when you do not.
  2. Group long binary values in fours from the right, not the left, so the grouping survives a change in width.
  3. Write hex with a consistent prefix and width - 0x0F rather than 0xF - so a byte always looks like a byte.
  4. Use binary literals for bit masks where the pattern is the point, and hex where the value is.
  5. Check the width before interpreting: 0xFF is 255 in eight bits and 255 in thirty-two, but 0xFFFFFFFF is -1 in one interpretation and 4,294,967,295 in another.

Key terms

Nibble
Four bits. Exactly one hex digit, which is the whole reason hex is used.
Positional weight
base^i for the digit at position i. The rule every base shares.
Radix
Another word for base. Radix point is the general term for a decimal point.
Width
How many bits hold the value. Not implied by the digits written, and required to interpret them.
Literal prefix
0x for hex, 0b for binary, a leading 0 for octal in C - which catches people who pad decimal with zeros.

Worked example

Why hex and not decimal, on a real register value:

  binary   1010 0101 1100 0011
  hex      A    5    C    3      -> 0xA5C3
  decimal  42435

  To find bit 11 from the hex, expand the digit it lives in. From
  the decimal, divide repeatedly by two. That is the entire
  argument.

And the C trap that comes from padding for alignment:

  int timeout = 010;     // 8, not 10 - a leading zero is OCTAL
  int port    = 022;     // 18, not 22

  Written to line up a table of decimal constants, and silently
  wrong. C23 added 0o for explicit octal partly because of this.

Common pitfalls

Two's complement: how negative numbers actually work

Two's complement represents a negative number by giving the most significant bit a negative weight instead of a positive one. That single change means addition, subtraction and comparison work on signed and unsigned values with exactly the same hardware - there is no separate signed adder - which is why every machine built since the 1970s uses it and why the alternatives are historical curiosities.

How it is built

  • In an n-bit two's complement value, the top bit has weight -2^(n-1) and the rest have their usual positive weights.
  • Negation is invert all bits and add one, which is a consequence of the representation rather than a rule bolted onto it.
  • The range is asymmetric: -2^(n-1) to 2^(n-1) - 1, so there is one more negative value than positive.
  • That asymmetry means the most negative value has no positive counterpart, so negating it overflows - INT_MIN is its own negation.
  • Sign extension when widening copies the top bit, which preserves the value; zero extension is correct for unsigned and wrong for signed.

Design procedure

  1. Read a signed byte by checking the top bit: if it is set, the value is the unsigned reading minus 256.
  2. Negate by inverting and adding one, and remember the result for the most negative value is itself.
  3. Sign-extend when widening a signed value and zero-extend when widening an unsigned one; the compiler does this from the type, so the type must be right.
  4. Guard against INT_MIN explicitly wherever you negate or take an absolute value.
  5. Prefer unsigned for bit patterns and signed for quantities that can genuinely go below zero.

Key terms

Two's complement
The representation where the top bit carries negative weight. Universal on modern hardware.
Sign extension
Copying the sign bit when widening, so the value is preserved.
Asymmetric range
One more negative value than positive, because zero occupies a positive slot.
Ones' complement
The older scheme with two representations of zero. Historical.
Sign-magnitude
A separate sign bit, still used inside floating point but not for integers.

Worked example

The same eight bits, two readings, and where the asymmetry bites:

  bits      1111 1111
  unsigned  255
  signed    -1        (128x1 negated, plus 127)

  bits      1000 0000
  unsigned  128
  signed    -128      <- the most negative value

  Negate -128:  invert 1000 0000 -> 0111 1111
                add one           -> 1000 0000
                = -128 again. It has no positive counterpart.

Which makes this ordinary-looking function wrong:

  int abs(int x) { return x < 0 ? -x : x; }

  abs(INT_MIN) negates INT_MIN, which overflows - and signed
  overflow is undefined behaviour, so the compiler may assume it
  cannot happen and optimise on that basis.

And sign extension, which is why the type matters at a widening:

  int8_t  b = -1;          // 1111 1111
  int32_t s = b;           // 1111 1111 1111 1111 1111 1111 1111 1111 = -1
  uint8_t u = 0xFF;
  int32_t z = u;           // 0000 0000 0000 0000 0000 0000 1111 1111 = 255

Common pitfalls

Bit manipulation: the operations and the idioms

Six operators cover essentially all bit work: AND, OR, XOR, NOT, and the two shifts. What matters is not the operators but the small set of idioms built from them, because those idioms appear unchanged in every driver, protocol decoder and register access you will read - and each has an edge case that turns it from correct to undefined.

How it is built

  • AND with a mask clears every bit outside the mask; OR sets bits; XOR toggles them; AND with a complement clears them.
  • A left shift by n multiplies by 2^n and a right shift divides, but only for unsigned values and only within range.
  • Right-shifting a negative signed value is implementation-defined in older standards and arithmetic in practice, so it propagates the sign bit rather than shifting in zeros.
  • Shifting by an amount greater than or equal to the type's width is undefined behaviour, which is a genuine hazard when the shift count is computed.
  • Every narrow operand is promoted to int before the operation, so a bit trick written for uint8_t operates on 32 bits and must be masked back.

Design procedure

  1. Build masks from named constants with explicit shifts, and put the shift in the mask rather than at the point of use.
  2. Use unsigned types for anything bitwise; signed shifts and signed overflow both have rules you do not want to depend on.
  3. Mask after any operation that can produce bits above the intended width, particularly complement and left shift.
  4. Guard a computed shift count against the type width before shifting.
  5. Prefer a named helper over a repeated idiom, so the edge case is handled once rather than everywhere.

Key terms

Mask
A value with the bits of interest set, used to isolate or modify a field.
Arithmetic shift
A right shift that copies the sign bit. What signed values get in practice.
Logical shift
A right shift that brings in zeros. What unsigned values get.
Bit field extraction
Shift the field down, then mask to its width. Order matters for clarity, not correctness.
Promotion
Narrow operands becoming int before the operation, which is why ~ and << need care.

Worked example

The idioms, and the edge case in each:

  set     x |=  (1u << n)
  clear   x &= ~(1u << n)
  toggle  x ^=  (1u << n)
  test    (x >> n) & 1u
  extract (x >> POS) & MASK
  insert  x = (x & ~(MASK << POS)) | ((v & MASK) << POS)

  Every one uses 1u rather than 1. With signed 1:

    1 << 31    undefined behaviour - the result is not
               representable in a signed int
    1u << 31   well defined, 0x80000000

And the promotion trap, which produces a value that is always wrong:

  uint8_t flags = 0x0F;
  if (~flags == 0xF0) { }        // NEVER TRUE

    flags promotes to int 0x0000000F
    ~ gives 0xFFFFFFF0, which is not 0xF0

  correct:  ((uint8_t)~flags == 0xF0)
       or:  ((~flags & 0xFFu) == 0xF0)

And the computed shift, which is undefined rather than zero:

  uint32_t v = 1u << width;      // if width is 32, UNDEFINED
                                 // not 0, not 1 - undefined

Common pitfalls

Byte order, and where it actually matters

Endianness is the order in which a multi-byte value's bytes are stored in memory. Little-endian puts the least significant byte at the lowest address, big-endian the most significant. It matters in exactly three places - data crossing a machine boundary, data reinterpreted through a different type, and a memory dump you are reading by eye - and it matters in none of the arithmetic, which is why it can stay invisible until the moment it is expensive.

How it is built

  • Little-endian stores the low byte first: 0x12345678 appears in memory as 78 56 34 12. x86, Arm in its usual configuration, and RISC-V are little-endian.
  • Big-endian stores the high byte first, appearing as 12 34 56 78. It is also called network byte order, because the internet protocols specified it.
  • Arithmetic is unaffected: the CPU loads and stores whole values, so addition and comparison give identical results either way.
  • It becomes visible when a value is written as one type and read as another, which is what a byte-buffer cast does.
  • Bit numbering within a byte is a separate convention and is not affected by byte order, which is a common source of confusion in protocol documents.

Design procedure

  1. Serialise field by field with explicit shifts and masks rather than casting a struct to a byte buffer.
  2. Convert to network byte order on the way out and back on the way in, at the boundary rather than scattered through the code.
  3. Never memcpy a multi-byte value into a packet and expect the other end to agree; the layout is a property of the machine, not the protocol.
  4. Detect endianness at compile time from the toolchain's macros rather than at runtime, so the check costs nothing.
  5. When reading a memory dump, remember the debugger may already be presenting words rather than bytes - the two views disagree on a little-endian machine.

Key terms

Little-endian
Least significant byte at the lowest address. x86, Arm, RISC-V.
Big-endian
Most significant byte first. Network byte order, and some older architectures.
Network byte order
Big-endian, as specified by the internet protocols. Hence htons and ntohl.
Bi-endian
An architecture configurable either way at reset, which Arm and PowerPC both are.
Explicit serialisation
Writing each byte with a shift and mask, so the layout is in the code rather than in the machine.

Worked example

The same 32-bit value, two machines:

  uint32_t v = 0x12345678;

  little-endian memory   78 56 34 12
  big-endian memory      12 34 56 78

  Both machines print 305419896 and compare identically. The
  difference is invisible until the bytes leave.

The cast that fails silently on one end:

  send(&v, 4);                     // sends 78 56 34 12 from x86
  uint32_t r; recv(&r, 4);         // big-endian peer reads 0x78563412

  A factor-of-16-million error that looks like data corruption.

The portable version, which has no endianness at all:

  buf[0] = (uint8_t)(v >> 24);     // explicit big-endian
  buf[1] = (uint8_t)(v >> 16);
  buf[2] = (uint8_t)(v >>  8);
  buf[3] = (uint8_t)(v      );

  This produces the same four bytes on every machine, because the
  shifts operate on the VALUE and not on its storage. That is the
  whole technique.

Common pitfalls

Number Representation: Two's Complement, Fixed Point and Their Edges

A processor stores numbers as fixed-width bit patterns, and the meaning of a pattern is a convention rather than a property. Two's complement is the convention for signed integers, chosen because it makes one adder handle both addition and subtraction and gives exactly one representation of zero. Its cost is an asymmetric range - the most negative number has no positive counterpart - and that asymmetry is the source of a surprising number of real defects.

How it is built

  • Unsigned interprets the pattern directly, giving 0 to 2^n - 1. Signed two's complement gives -2^(n-1) to 2^(n-1) - 1, with the top bit weighted negatively rather than acting as a separate sign flag.
  • Negation is inversion plus one. That is why one adder does subtraction, and why there is only one zero - which sign-magnitude and one's complement both fail to achieve.
  • The range is asymmetric: an 8-bit signed value spans -128 to +127. Negating -128 overflows back to -128, which is a genuine fixed point of the negation operation and a real bug source.
  • Overflow wraps silently in hardware. In C, unsigned overflow is defined to wrap and signed overflow is undefined behaviour, which means the compiler may optimise on the assumption it never happens.
  • Fixed point represents fractions by declaring an implicit binary point. A Q15 value is a 16-bit integer interpreted as a fraction of 32768, and multiplying two of them produces a Q30 result that must be shifted back.
  • Floating point trades exactness for range. On a part without an FPU it is emulated in software at perhaps a hundred times the cost of an integer operation, which is why fixed point remains standard in embedded DSP.

Design procedure

  1. Choose a representation from the actual range and precision needed, and write both down. Most numeric bugs come from a range assumption nobody stated.
  2. Prefer unsigned for quantities that cannot be negative, and be careful at boundaries - an unsigned loop counter compared against zero never terminates when decremented past it.
  3. For fixed point, track the Q format through every operation. Multiplication adds the fractional bits, so a Q15 times a Q15 is a Q30 and needs shifting before it is stored back.
  4. Check for overflow explicitly where it matters, before the operation rather than after - in C, testing the result of a signed overflow is testing undefined behaviour.
  5. Watch integer promotion in C: operands smaller than int are promoted, and mixing signed and unsigned converts the signed operand to unsigned, which turns a negative value into a very large positive one.
  6. Test the boundaries deliberately: zero, the maximum, the minimum, and one past each. That is where representation bugs live and where typical-value testing never reaches.

Key terms

Two's complement
Top bit weighted negatively. One zero, and one adder for both operations.
Asymmetric range
-2^(n-1) to 2^(n-1)-1. The most negative value has no positive counterpart.
Wrapping
Overflow wrapping round. Defined for unsigned in C, undefined for signed.
Sign extension
Widening a signed value by replicating the top bit.
Q format
Fixed point with an implicit binary point. Q15 is a fraction of 32768.
Integer promotion
C widening small types to int before arithmetic.
Usual arithmetic conversions
Mixing signed and unsigned converts the signed one. A classic trap.

Worked example

A loop written as `for (unsigned i = n - 1; i >= 0; i--)` never terminates. An unsigned value is always at least zero, so the condition is permanently true and at i = 0 the decrement wraps to the maximum. The compiler may warn or may quietly optimise the comparison away entirely. The same loop with a signed counter works, and the same loop counting upward works - the bug exists only at the intersection of unsigned and a downward comparison against zero.

Common pitfalls

Bit Manipulation: Reading and Writing Hardware One Bit at a Time

Hardware registers pack unrelated controls into one word, so firmware spends much of its time setting, clearing and testing individual bits without disturbing their neighbours. The operations are simple and the mistakes are systematic: forgetting that a read-modify-write is three operations rather than one, shifting by more than the width, or letting integer promotion widen a value mid-expression.

How it is built

  • Set a bit with OR, clear it with AND of the complement, toggle it with XOR, and test it with AND. Those four cover almost everything, and each leaves the other bits untouched.
  • A mask names which bits an operation affects. Naming masks after what they mean rather than writing hex constants is what makes register code readable a year later.
  • Read-modify-write is three separate steps. If an interrupt handler touches the same register between the read and the write, its change is overwritten - which is why many peripherals provide separate set and clear registers that need no read.
  • Shifting by an amount greater than or equal to the type's width is undefined behaviour, not zero. Shifting a 32-bit value by 32 does not reliably give zero and on some architectures gives the original value.
  • A left shift of a signed value into or past the sign bit is undefined. Bit manipulation should be done on unsigned types, which is why register definitions use uint32_t rather than int.
  • Integer promotion widens anything narrower than int before the operation, so complementing an 8-bit mask produces a 32-bit value with the upper bits set - and ANDing with it clears nothing while appearing correct.

Design procedure

  1. Use unsigned types with explicit widths for anything touching hardware, and let the register definition header provide the masks.
  2. Prefer a peripheral's dedicated set and clear registers over read-modify-write, since they are atomic by construction and need no critical section.
  3. Where read-modify-write is unavoidable and the register is shared with an interrupt, protect it with a critical section and keep that section to the three instructions.
  4. Define named masks and shift amounts rather than literals. A bare 0x40 in a register write is a comment-free assertion nobody can check.
  5. Check every shift amount against the type width, especially where the amount is computed rather than constant.
  6. Verify with the actual register value rather than by inference - read it back and compare against what was intended.

Key terms

Mask
A pattern naming which bits an operation affects.
Set / clear / toggle
OR with the mask, AND with its complement, XOR with it.
Read-modify-write
Three operations. Interruptible, and therefore a race.
Atomic set/clear register
A peripheral register that sets or clears without a read. Race-free.
Integer promotion
Narrow types widened to int before the operation, which changes what a complement means.
Undefined shift
Shifting by the width or more. Not defined to be zero.
Bit field
A C language feature whose layout is implementation-defined. Poor for hardware.

Worked example

Clearing bit 3 of an 8-bit register with `reg &= ~(1 << 3)` looks correct and is, but `uint8_t mask = 1 << 3; reg &= ~mask;` is not. Integer promotion widens mask to int, the complement is 0xFFFFFFF7 rather than 0xF7, and after the AND the result is assigned back to 8 bits - which happens to work here. Change the type to uint16_t on a 16-bit int platform and the upper bits now matter. The reliable form casts back explicitly, and the reliable habit is to use the peripheral's clear register instead.

Common pitfalls

Endianness: The Order Bytes Are Stored In

A value wider than a byte has to be split across several addresses, and there are two conventions for which end goes first. Little-endian stores the least significant byte at the lowest address; big-endian stores the most significant. Neither is better and the choice only matters at boundaries - between machines, across a network, or when the same memory is read as two different types. Those boundaries are precisely where it produces bugs.

How it is built

  • Little-endian is x86, ARM in its usual configuration and RISC-V. Big-endian, historically Motorola and SPARC, survives mainly as network byte order.
  • Within a byte the bit order is not affected. Endianness is about the order of bytes at addresses, and bit numbering is a separate convention that datasheets state independently.
  • Network byte order is big-endian by definition, so any protocol carrying multi-byte fields specifies conversion. That is what htons and ntohs exist for, and on a big-endian machine they do nothing.
  • Type punning exposes it: writing a 32-bit value and reading the same memory as four bytes gives different results on the two conventions. Any code doing that is endianness-dependent whether or not it says so.
  • Serialisation must state a byte order explicitly. A structure written to flash or sent over a link by copying its bytes is only readable by a machine with the same convention and the same padding.
  • Some architectures are bi-endian and select at boot. That makes endianness a runtime property rather than a compile-time one, so code must not assume from the architecture alone.

Design procedure

  1. Convert explicitly at every boundary - network, file, flash, or another processor - rather than relying on both ends matching.
  2. Serialise field by field with defined shifts and masks rather than copying a structure's bytes. That removes both the endianness and the padding assumptions at once.
  3. Use the standard conversion functions or explicit shifts. A shift-based conversion is endianness-independent by construction because it operates on values rather than on memory.
  4. Test on both conventions if the code will run on more than one, or at least construct a byte-level test that would fail if the order were reversed.
  5. Read the datasheet's bit numbering separately from byte order. A peripheral can be little-endian in bytes and number its bits from the most significant.
  6. Document the byte order in any format definition. It is the field most often omitted and most expensive to discover later.

Key terms

Little-endian
Least significant byte at the lowest address. x86, ARM, RISC-V.
Big-endian
Most significant byte first. Network byte order.
Network byte order
Big-endian, by definition, for every internet protocol.
Type punning
Reading memory as a different type. Where endianness becomes visible.
Serialisation
Converting a value to a defined byte sequence. Must state an order.
Bi-endian
Architectures selecting the convention at boot, making it a runtime property.
Structure padding
Compiler-inserted gaps. A second reason not to copy structures between machines.

Worked example

A sensor node writes a 32-bit reading to flash by memcpy of the struct, and a desktop tool reads it back. On a little-endian node the value 0x12345678 is stored as 78 56 34 12; the tool, also little-endian, reads it correctly and everyone concludes the format is fine. Port the node to a big-endian part and every reading is byte-swapped, which reads as corrupted data rather than as a format problem. Serialising with explicit shifts - store byte 0 as `(v >> 24) & 0xFF` and so on - is endianness-independent on both ends, and would never have had the bug.

Common pitfalls

More in Foundations

  • PointersComplete visual pointer laboratory: addresses, dereferencing, pointer arithmetic, arrays and decay, double pointers, dynamic memory, function pointers, const/volatile, MMIO, lifetime bugs — with a step-through memory simulator, 100+ interview questions and a mastery exam.
  • Computer Systems 0 → 100A beginner-first path from what a computer is through bits, CPUs, addresses, memory hierarchy, SRAM, DRAM, ROM, flash, SSDs, buses, PCIe, SATA, AHCI, NVMe, M.2, boot and performance—with comparisons and interview practice.
  • Cache Coherency 0 → 100A first-principles course from cache lines and the coherence problem through MSI, MESI, MOESI, snooping, directories, memory ordering, atomics, false sharing, DMA, NUMA, measurement and verification—with a live protocol engine and code lab.
  • From Power-On to main()What runs before main(): the Cortex-M reset sequence that gives C the machine it assumes, the linker script that decides where every section lives and why .data has two addresses, the four build stages and which one your error came from, and the order to work through a debug probe that will not connect.
  • FoundationsComputer systems from first principles—memory, storage and interconnects through cache coherence, ordering, atomics and DMA—then the firmware foundations of numbers, linking, interrupts and pointers.

References and further reading