Operating Systems · Kernel Engineering

Word Size, Endianness, and Structure Layout Across Architectures

Why the same C source produces different bytes on a 32-bit and a 64-bit build, how endianness changes on-disk and on-wire structures, and where alignment padding hides.

Code that is correct on one architecture and wrong on another usually fails for one of three reasons: a type that changed width, a byte order that reversed, or an alignment rule that inserted padding the programmer did not account for. All three are invisible in the source and all three produce the same symptom (a value that is subtly wrong rather than a crash) which is why they survive testing on the development machine and appear on the first machine with a different ABI.

Sizes that are not what the names suggest

C’s integer types are defined by minimum ranges, not by widths. int is at least sixteen bits; long is at least thirty-two. On the common 32-bit ABIs, int and long are both 32 bits and pointers are 32 bits. On LP64 (the model used by 64-bit Linux and most 64-bit Unix systems) int stays 32 bits, long and pointers become 64 bits. On Windows the model is LLP64, where long stays 32 bits and only pointers widen, which is why code that stores a pointer in a long compiles without complaint there and truncates elsewhere.

The kernel has its own set of fixed-width types and its own conventions about them. u32 and s64 are exact, and code that interfaces with hardware or with a file format uses them rather than the C names. The subtlety is what happens in arithmetic: two u32 values multiply in 32-bit arithmetic and wrap, even if the result is assigned to a u64. The fix is an explicit cast on one operand, and its absence is one of the most common portability bugs in code that was written on a 64-bit machine, where unsigned long happened to be wide enough to hide the overflow.

#include <linux/types.h>

/* Wrong on any architecture: the product is computed in 32 bits. */
static u64 bad_product(u32 a, u32 b)
{
	return a * b;
}

/* Right: one operand is widened before the multiply. */
static u64 good_product(u32 a, u32 b)
{
	return (u64)a * b;
}

A second trap is the size of a pointer used as an integer. A structure field declared as unsigned int to hold a physical address is correct on a 32-bit machine with 32-bit physical addressing and wrong on a machine with more memory, where the address does not fit. The kernel’s phys_addr_t and dma_addr_t exist to make the width a build-time property of the target rather than an assumption in the code.

Format strings carry the same problem in a form the compiler can check but often does not. Printing a size_t with %u truncates on a 64-bit build; printing a 64-bit value with %d reads the wrong number of bytes from the variadic argument list. The kernel’s print format specifiers include width-explicit forms, and using them is the difference between a diagnostic that shows the value and one that shows a fragment of it.

Byte order at the boundary

Endianness only matters when bytes leave the machine’s registers and are interpreted elsewhere: in a file, on a network, in a device register, in shared memory between two processors. Within one process, on one machine, the order is irrelevant because the compiler and the hardware agree.

Two orders dominate. Little-endian stores the least significant byte at the lowest address; big-endian stores the most significant byte first. x86 and most Arm configurations are little-endian; some network protocols and some older architectures are big-endian; and a few architectures can be switched at boot, which means code cannot assume one order even for a given machine.

The rule that makes portable code is to convert at the boundary and store in a defined order inside. A file format specifies its byte order in its specification; a network protocol specifies big-endian, historically called network order; a device register’s order is in the device’s documentation. Code that reads such a value converts once on the way in, works with it in host order, and converts on the way out.

#include <linux/byteorder/generic.h>

struct disk_header {
	__be32 magic;      /* stored big-endian, whatever the host does */
	__le64 sector_count;
};

static int read_header(const struct disk_header *raw, u64 *sectors)
{
	if (be32_to_cpu(raw->magic) != 0x4b45524e)
		return -1;
	*sectors = le64_to_cpu(raw->sector_count);
	return 0;
}

The type names matter more than the conversion calls. Declaring the field as __be32 rather than u32 makes the byte order part of the type, so a conversion is required to use the value in arithmetic and the compiler warns when one is missing. The kernel’s sparse checker enforces exactly this: a __be32 used without conversion is reported, which turns a class of silent bugs into compile-time diagnostics.

The failure mode when the conversion is missing is a value that is byte-reversed rather than garbage. A magic number checked against the wrong constant fails cleanly. A length field read in the wrong order produces a plausible but wrong number, which is worse: a 0x00010000 read as 0x00000100 is a length that passes a sanity check and then reads the wrong amount of data.

Bit fields add a second layer. The C standard leaves the allocation order of bit fields within a storage unit implementation-defined, so a structure with three bit fields may pack them from the low end on one ABI and from the high end on another, and may or may not straddle the unit boundary. A bit-field layout in a file format or a hardware register is therefore not portable C; it must be described as a mask and a shift against a fixed-width integer, which is what device driver code does with explicit shift constants.

Padding that is not in the source

A structure’s size is not the sum of its members’ sizes. Each member must be aligned to a boundary that its type requires, and the compiler inserts padding before a member to satisfy that. The structure’s own alignment is the strictest of its members, and its size is rounded up to a multiple of that alignment so that arrays of it keep every element aligned.

On x86-64 the rules are: a char has alignment 1, a short 2, an int and a float 4, a long, a double, and a pointer 8. A structure whose members are a char, a long, and a char occupies 1 byte, 7 padding, 8 bytes, 1 byte, and 7 trailing padding, so 24 bytes for 10 bytes of data. Reordering to long, char, char yields 16 bytes, because the two chars share the tail padding. This is why a structure that is serialised by writing its bytes is not portable across ABIs, and not even between two compilers that target the same ABI when the structure uses constructs the ABI leaves implementation-defined, such as bit fields or enumerations whose width the compiler chooses. Code that must produce a defined byte sequence declares the fields it needs in a fixed-width form and either packs them explicitly or writes them field by field; the kernel’s own approach is the latter, a header describing an on-disk structure as a sequence of typed conversions rather than a memory image.

The diagnostic tool for this is pahole, which reads a compiled object’s debug information and prints each structure’s members with their offsets, their sizes, and the padding between them. It answers two questions at once: whether the layout is what was intended, and how much space is being wasted. On a structure with many members of mixed widths, sorting them by decreasing alignment usually removes most of the padding, at the cost of a member order that no longer reflects the conceptual grouping.

The kernel’s __packed attribute lets a structure remove the padding it would otherwise get, and __aligned(n) raises the alignment above what its members require. __packed is a promise that the code will tolerate unaligned access, which on architectures without unaligned load support means the compiler emits byte-by-byte sequences.

Where the differences surface

The portability failures that matter most are the ones that cross a boundary the compiler cannot check: a structure written to a file by one kernel and read by another, a shared memory region between two processors with different endianness, a device descriptor whose layout is fixed by hardware. In each case the code must commit to an explicit representation, and the commitment must be documented, because the ABI of the running kernel does not constrain it.

The kernel’s own interfaces encode this discipline. System calls that pass a structure across the boundary define its layout in the ABI, and a 32-bit process on a 64-bit kernel requires a compatibility layer that translates between the two layouts, with a separate entry point for each width. Module interfaces carry a version check because a structure’s layout depends on the configuration, as described in Kconfig and conditional compilation, and the resulting load failures are the ones a module loader reports when it compares what the module was built against with what is running. Both are the same underlying problem: the source is shared, the binary layout is not.

The honest summary is that C does not define a binary layout, an integer width, or a byte order. Every interface that crosses a machine boundary must state all three, and every structure that is shared must be checked against that statement rather than assumed to match it. Tools that print the actual layout, types that carry the byte order, and conversions at the boundary are cheaper than the class of bug they prevent, because that class of bug does not crash; it returns the wrong number.

References

  1. System V Application Binary Interface: AMD64 Architecture Processor Supplement — x86-64 psABI working group
  2. Procedure Call Standard for the Arm 64-bit Architecture (AAPCS64) — Arm Limited
  3. IEEE 754-2019: Standard for Floating-Point Arithmetic — IEEE
  4. POSIX.1-2017, Base Definitions — IEEE Std 1003.1-2017