Bit manipulation · about 45 min

Endianness and serialization

Why this matters

Inside one program, an integer is just an integer. The moment it leaves the program, written to a file, sent over a network, or shared with hardware, it becomes a sequence of bytes, and the two sides must agree on the order of those bytes. Get it wrong and a length of 256 arrives as 65536. Every file format (PNG, ZIP, WAV, ELF) and every network protocol (IP, TCP, DNS) specifies a byte order, and C programmers read and write these formats with the shift and mask techniques from the previous lessons.

Multi-byte values in byte-addressed memory

Memory is addressed byte by byte, but a uint32_t needs four bytes. Which byte goes at the lowest address? There are two conventions:

  value 0x12345678

                    address:  a     a+1   a+2   a+3
  big-endian                  12    34    56    78     most significant byte first
  little-endian               78    56    34    12     least significant byte first

Big-endian matches how we write numbers. Little-endian puts the "little end" first. x86 and x86-64 are little-endian, and so is ARM as used by almost every phone and Apple Silicon Mac. Some older and embedded architectures are big-endian, and network protocols use big-endian, which is why it is also called network byte order.

You can see your machine's order by looking at an integer's bytes through an unsigned char pointer (inspecting any object's bytes this way is always allowed in C):

#include <stdio.h>
#include <stdint.h>

int main(void)
{
    uint32_t x = 0x12345678;
    unsigned char *p = (unsigned char *)&x;
    for (size_t i = 0; i < sizeof x; i++)
        printf("%02X ", p[i]);
    printf("\n");
    return 0;
}
78 56 34 12

The sandbox is x86-64, so the least significant byte, 78, comes first.

The bug it causes

Suppose a file format says "a 4-byte big-endian length", and a file contains the bytes 00 00 01 00, meaning 256. The tempting code copies the bytes straight into an integer:

unsigned char raw[4] = {0x00, 0x00, 0x01, 0x00};
uint32_t naive;
memcpy(&naive, raw, 4);
printf("%u\n", naive);       /* 65536 on x86: the bytes were taken as little-endian */

The program reads the bytes in the machine's order, not the format's. It may even be correct on the developer's machine and wrong on the user's. You can look at raw bytes in a file with od:

$ od -A d -t x1 f.bin
0000000 00 00 01 00
0000004

The portable solution: shifts

Shifts and masks operate on values, not on memory layout, so they mean the same thing on every CPU. To write a value in big-endian order, extract each byte from the top down; to read it, shift each byte back into place:

void put_u32_be(unsigned char *buf, uint32_t v)
{
    buf[0] = (unsigned char)(v >> 24);
    buf[1] = (unsigned char)(v >> 16);
    buf[2] = (unsigned char)(v >> 8);
    buf[3] = (unsigned char)v;
}

uint32_t get_u32_be(const unsigned char *buf)
{
    return (uint32_t)buf[0] << 24 | (uint32_t)buf[1] << 16 |
           (uint32_t)buf[2] << 8  | (uint32_t)buf[3];
}

With these, get_u32_be(raw) returns 256 on any machine, and put_u32_be produces 12 34 56 78 for 0x12345678. No #ifdef for byte order is needed; the compiler turns the shifts into a single load and byte-swap instruction where that is faster.

Note the casts to uint32_t before shifting. Without them, buf[0] << 24 promotes the unsigned char to int, and a byte of 0x80 or more shifted by 24 lands in the sign bit: undefined behavior. The cast keeps the arithmetic unsigned.

The standard helpers

POSIX provides htonl, htons, ntohl and ntohs in <arpa/inet.h> ("host to network long", and so on). They convert a value between the host's order and big-endian, and you will see them in all networking code:

uint32_t n = htonl(0x12345678);   /* on x86 the bytes are swapped; on a big-endian CPU, no-op */

They return integers whose memory representation is big-endian, which you then memcpy into a packet. The shift functions are usually clearer because they go straight to bytes.

Serialization

Serialization means turning in-memory data into a sequence of bytes that can be stored or transmitted and turned back later (deserialization). For C structs, the robust approach is:

  1. Define the format precisely: each field's offset, size, signedness and byte order.
  2. Write each field with an explicit encoder (put_u32_le, put_u16_be, ...) into a byte buffer.
  3. Write the buffer with fwrite or write.
  4. Read the bytes and decode each field with the matching decoder, checking lengths.

Text formats (CSV, JSON) avoid the byte order question entirely at the cost of size and parsing speed, which is why many protocols are text based.

What you should not do is fwrite(&some_struct, sizeof some_struct, 1, f) for data that must outlive the build. As you saw in the structs module, structs contain padding chosen by the compiler, and types like int and long differ in size between platforms. A raw dump captures one compiler's layout, one CPU's byte order, and whatever garbage happens to be in the padding bytes.

Signed values and floating point add one more step: convert a signed value to the unsigned type of the same width before encoding ((uint32_t)x), and back afterwards. For a double, copy its bytes into a uint64_t with memcpy and encode that; nearly every modern system uses the IEEE 754 format, so only the byte order then needs agreeing.

Common mistakes

Recap

Going further: run od -A x -t x1z /bin/ls | head -2 in the sandbox: the first bytes are the ELF magic 7f 45 4c 46 (\x7fELF), followed by a byte that says the file is 64-bit and one that says it is little-endian. man 3 endian describes htobe32, le32toh and friends, glibc's explicit-width versions of htonl.

Exercises

Exercise 1 · quiz

Bytes in memory

On an x86-64 machine (little-endian), a uint32_t holds 0x0A0B0C0D. In what order are its four bytes stored, from the lowest address to the highest?

Answer
  • 0D 0C 0B 0A

Little-endian stores the least significant byte at the lowest address, so 0x0D comes first. Big-endian machines (and network protocols) store 0x0A first, the way we write numbers. The order is a property of the CPU (x86 and almost all ARM systems run little-endian), not of the compiler, which is why code that dumps raw integers to files or sockets breaks when the other side uses a different order.

Exercise 2 · write code

Read the bytes both ways

A file contains four bytes, given on standard input as four two-digit hex numbers (for example 00 00 01 00). Print the 32-bit unsigned value they represent when read as big-endian and when read as little-endian, in decimal, in exactly this format:

be=256 le=65536

Combine the bytes with shifts and |. Read each byte with scanf("%x", ...) into an unsigned.

File: main.c

Starting point

#include <stdio.h>
#include <stdint.h>

int main(void)
{
    unsigned b[4];
    for (int i = 0; i < 4; i++)
        if (scanf("%x", &b[i]) != 1)
            return 1;
    uint32_t be = 0, le = 0;
    /* combine b[0..3] */
    printf("be=%u le=%u\n", (unsigned)be, (unsigned)le);
    return 0;
}

Example

input

00 00 01 00

expected output

be=256 le=65536

Example

input

12 34 56 78

expected output

be=305419896 le=2018915346
Hint 1

Big-endian: the first byte is the most significant, so it is shifted left by 24.

Hint 2

Little-endian: the first byte is the least significant (shift 0) and the last is shifted by 24.

Reference solution
#include <stdio.h>
#include <stdint.h>

int main(void)
{
    unsigned b[4];
    for (int i = 0; i < 4; i++)
        if (scanf("%x", &b[i]) != 1)
            return 1;
    uint32_t be = (uint32_t)b[0] << 24 | (uint32_t)b[1] << 16 | (uint32_t)b[2] << 8 | b[3];
    uint32_t le = (uint32_t)b[3] << 24 | (uint32_t)b[2] << 16 | (uint32_t)b[1] << 8 | b[0];
    printf("be=%u le=%u\n", (unsigned)be, (unsigned)le);
    return 0;
}

Exercise 3 · write code

A portable encoder and decoder

Write two functions that convert between uint32_t values and 4-byte buffers in a fixed little-endian format, using only shifts and masks so they work on any CPU:

void     put_u32_le(unsigned char *buf, uint32_t v);   /* write v into buf[0..3] */
uint32_t get_u32_le(const unsigned char *buf);         /* read buf[0..3] back    */

Do not use memcpy or pointer casts: the point is to be independent of the machine's byte order. Be careful in get_u32_le: buf[3] << 24 promotes buf[3] to a signed int, which overflows if the byte is 0x80 or more. Convert each byte to uint32_t before shifting.

main encodes the number it reads, prints the bytes, and decodes them again:

$ echo 305419896 | ./prog
78 56 34 12
305419896

File: main.c

Starting point

#include <stdio.h>
#include <stdint.h>

void put_u32_le(unsigned char *buf, uint32_t v)
{
}

uint32_t get_u32_le(const unsigned char *buf)
{
    return 0;
}

int main(void)
{
    unsigned v;
    if (scanf("%u", &v) != 1)
        return 1;
    unsigned char buf[4];
    put_u32_le(buf, v);
    printf("%02X %02X %02X %02X\n", buf[0], buf[1], buf[2], buf[3]);
    printf("%u\n", (unsigned)get_u32_le(buf));
    return 0;
}

Example

input

305419896

expected output

78 56 34 12
305419896

Example

input

4294967295

expected output

FF FF FF FF
4294967295
Hint 1

buf[0] gets the lowest byte: v & 0xFF. buf[1] gets (v >> 8) & 0xFF, and so on.

Hint 2

Decoding reverses it: (uint32_t)buf[1] << 8 puts the second byte back in place. OR the four pieces together.

Reference solution
#include <stdio.h>
#include <stdint.h>

void put_u32_le(unsigned char *buf, uint32_t v)
{
    buf[0] = (unsigned char)(v & 0xFF);
    buf[1] = (unsigned char)((v >> 8) & 0xFF);
    buf[2] = (unsigned char)((v >> 16) & 0xFF);
    buf[3] = (unsigned char)((v >> 24) & 0xFF);
}

uint32_t get_u32_le(const unsigned char *buf)
{
    return (uint32_t)buf[0]
         | (uint32_t)buf[1] << 8
         | (uint32_t)buf[2] << 16
         | (uint32_t)buf[3] << 24;
}

int main(void)
{
    unsigned v;
    if (scanf("%u", &v) != 1)
        return 1;
    unsigned char buf[4];
    put_u32_le(buf, v);
    printf("%02X %02X %02X %02X\n", buf[0], buf[1], buf[2], buf[3]);
    printf("%u\n", (unsigned)get_u32_le(buf));
    return 0;
}

Exercise 4 · quiz

Why not just write the struct?

A program saves records with fwrite(&rec, sizeof rec, 1, f) where rec is a struct holding an int and a double. Which problems can appear when another program, or the same program built elsewhere, reads the file back? Select all that apply.

Answer
  • The reading machine may use a different byte order

  • int may have a different size on another platform or compiler

  • The struct may contain padding bytes whose amount differs between compilers

  • The padding bytes may contain leftover garbage from memory

A raw struct dump bakes in byte order, type sizes and padding, all of which belong to one CPU/compiler/ABI combination, and it writes whatever happens to be in the padding (which can leak data). fwrite writes any bytes you give it, which is exactly the issue. A portable format defines every field's size and byte order and encodes them explicitly, the way put_u32_le does. Writing raw structs is acceptable only for temporary files read by the same build.