Data Representation: Integers, Floats, Text
Every bit you'll ever flip in the kernel comes down to how numbers, fractions, and text are encoded in binary — master these fundamentals and nothing at the low level will ever surprise you again.
Overview
Computers are, at their core, enormous machines for manipulating patterns of zeros and ones. Everything — a kernel panic message, a floating-point division result, a Unicode emoji rendered in a terminal — reduces to a sequence of bits stored in memory or a register. Before you can read kernel source code, reason about a memory dump, or understand why 0.1 + 0.2 != 0.3 in C, you need a precise, working mental model of how data is represented in binary.
This module gives you that model from the ground up. We start with unsigned integers, the simplest possible encoding, and work up through two's-complement signed integers, IEEE-754 floating point (the standard that governs every float and double in existence), text encodings from ASCII to UTF-8, and finally endianness — the question of which byte goes first that trips up every programmer who first writes network code or reads a binary file format.
These are not abstract academic topics. The Linux kernel's header files are full of explicit integer widths (u8, __le32, __be16), careful endianness conversions, and comments about floating-point avoidance in kernel space. Kernel exploit mitigations hinge on integer overflow behavior. The ext4 filesystem stores on-disk structures in little-endian and must byteswap on big-endian hosts. You will encounter every single topic in this module the first week you read kernel code seriously.
Read actively. Open a terminal. Try every code snippet. When you finish this module you will be able to look at a raw hex dump of any simple data structure and decode it by hand — a skill that will serve you for your entire career as a systems programmer.
The Linux kernel is written entirely in C, with assembly for architecture-specific hot paths. C gives you almost no abstraction over the hardware's data representation: integers are exactly the hardware's two's-complement words, float is exactly IEEE-754 single-precision, and strings are just arrays of bytes with a zero terminator. When you grep through include/linux/types.h you see __u8, __le32, __be64 — each name encodes a width and an endianness that must be respected exactly or the system breaks in ways that are silent on the native architecture but catastrophic when the code runs on a different CPU or on data from the network.
Beyond correctness, security is at stake. Integer overflow in kernel code has historically led to heap overflows and privilege escalation. The kernel's check_add_overflow() / check_mul_overflow() helpers exist precisely because you cannot take integer arithmetic for granted. Understanding two's-complement overflow is not optional background knowledge — it is a prerequisite for reading the security patches that stream through the kernel mailing list every week. The same is true of floating point: the kernel deliberately avoids FPU instructions in most contexts (saving/restoring FPU state is expensive), and when it must use floating point it must know exactly what the hardware will do. Master this module and you will have the mental foundation that separates a systems programmer from someone who merely writes code that happens to run on systems.
IEEE-754 Single-Precision Float: Bit Field Layout and Special Values
Every possible 32-bit float falls into exactly one category determined by the exponent and mantissa fields. The exponent values 0 and 255 are reserved for special cases; values 1-254 encode normal numbers with the implicit leading 1 in the mantissa. This tree lets you decode any float bit pattern by hand.
The concepts
01Bits, Bytes, and Positional Notation
A bit is the atom of computing: a physical quantity (voltage, charge, magnetic polarity) that we interpret as either 0 or 1. Eight bits form a byte — the smallest addressable unit on virtually every modern CPU. A byte can hold 2^8 = 256 distinct patterns.
Numbers in binary follow the same positional logic as decimal, but the base is 2 instead of 10. In decimal, 357 means 3×10² + 5×10¹ + 7×10⁰. In binary, 1011 means 1×2³ + 0×2² + 1×2¹ + 1×2⁰ = 8 + 0 + 2 + 1 = 11.
Hexadecimal (base-16) is the systems programmer's shorthand: one hex digit maps to exactly four bits, so a 32-bit word fits neatly in 8 hex digits. The digits 0–9 plus A–F (or a–f) give sixteen symbols. 0xFF = 1111 1111 = 255.
Convention: in C source code, binary literals use the 0b prefix (GCC extension), octal uses 0, and hex uses 0x. In kernel code you will almost always see hex.
#include <stdio.h>
int main(void) {
unsigned char b = 0b10110011; /* GCC extension */
printf("decimal: %u\n", b); /* 179 */
printf("hex: 0x%02X\n", b); /* 0xB3 */
/* Manually compute positional value */
unsigned val = (1<<7)|(0<<6)|(1<<5)|(1<<4)|
(0<<3)|(0<<2)|(1<<1)|(1<<0);
printf("manual: %u\n", val); /* 179 */
return 0;
}
02Unsigned Integers
An unsigned integer of width n represents the values 0 through 2ⁿ−1 by interpreting all n bits as a non-negative positional binary number. The C types uint8_t, uint16_t, uint32_t, uint64_t (from <stdint.h>, or u8/u16/u32/u64 in kernel headers) are unsigned integers of exactly 8, 16, 32, and 64 bits respectively.
Arithmetic wraps modulo 2ⁿ. Adding 255 + 1 in a uint8_t gives 0 — the carry bit that would make it 256 is simply discarded. This is not a bug; the C standard defines unsigned overflow as well-defined wrap-around. The kernel exploits this: (u32)(a - b) < threshold is a common idiom to check whether a is within threshold of b even when a < b (the subtraction wraps around but the comparison still works).
Bit manipulation is at the heart of kernel programming. Setting bit k: x |= (1u << k). Clearing bit k: x &= ~(1u << k). Testing bit k: (x >> k) & 1. Isolating the lowest set bit: x & (-x) (this works because of two's-complement negation, covered next).
The range of common unsigned types:
u8: 0 – 255u16: 0 – 65,535u32: 0 – 4,294,967,295 (~4 billion)u64: 0 – 18,446,744,073,709,551,615 (~18 × 10¹⁸)
#include <stdint.h>
#include <stdio.h>
int main(void) {
uint8_t x = 255;
x++; /* wraps to 0 -- well-defined for unsigned */
printf("%u\n", x); /* 0 */
uint32_t flags = 0;
flags |= (1u << 3); /* set bit 3 -> 0x00000008 */
flags &= ~(1u << 3); /* clear bit 3 -> 0x00000000 */
uint32_t v = 0b10110000u;
printf("lowest set bit position: %u\n",
__builtin_ctz(v)); /* 4 -- count trailing zeros */
return 0;
}
03Two's-Complement Signed Integers
How do you represent negative numbers? There are three historic schemes: sign-magnitude, ones' complement, and two's complement. Modern hardware (x86, ARM, RISC-V) uses two's complement exclusively, and since C23 the standard mandates it too.
The key idea: in an n-bit two's-complement integer, the most-significant bit (MSB) has a place value of −2^(n−1) instead of +2^(n−1). All other bits remain positive.
For an 8-bit signed byte:
0111 1111= +127 (MSB=0, so all-positive contributions)1000 0000= -128 (MSB=1 contributes −128, rest contribute 0)1111 1111= -1 (−128 + 64 + 32 + 16 + 8 + 4 + 2 + 1 = −1)
Negation rule: to negate a two's-complement number, flip all bits then add 1. Try it: +5 = 0000 0101. Flip: 1111 1010. Add 1: 1111 1011 = -5. Verify: -128 + 64 + 32 + 16 + 8 + 0 + 2 + 1 = -5. ✓
Why two's complement is beautiful: addition and subtraction use identical hardware circuits for signed and unsigned numbers. The CPU doesn't need separate ALUs. The carry flag handles unsigned overflow; the overflow (V) flag handles signed overflow. This elegance is why the entire industry converged on this encoding.
Signed overflow is undefined behavior in C (unlike unsigned wrap-around). The compiler is allowed to assume it never happens and optimize accordingly. This is a real source of kernel security bugs. The kernel uses check_add_overflow() etc. from <linux/overflow.h> to detect it safely.
Ranges for signed types:
s8/int8_t: −128 to +127s16/int16_t: −32,768 to +32,767s32/int32_t: −2,147,483,648 to +2,147,483,647s64/int64_t: −9,223,372,036,854,775,808 to +9,223,372,036,854,775,807
#include <stdint.h>
#include <stdio.h>
int main(void) {
int8_t a = 127;
a++; /* UNDEFINED BEHAVIOR for signed -- may not be 128 */
/* But the kernel avoids this -- let's show why: */
/* Safe check for signed addition overflow */
int32_t x = 2000000000, y = 2000000000;
int32_t result;
if (__builtin_add_overflow(x, y, &result))
printf("overflow detected!\n");
else
printf("result: %d\n", result);
/* Output: overflow detected! */
/* Two's-complement negation by hand */
uint8_t pos = 5; /* 0000 0101 */
uint8_t neg = (~pos) + 1; /* 1111 1011 */
printf("neg of 5 as u8: %u (=%d as s8)\n",
neg, (int8_t)neg); /* 251, -5 */
return 0;
}
04IEEE-754 Floating-Point: Structure
Real-valued computation (physics simulations, graphics, signal processing) needs numbers with fractional parts and enormous dynamic range. IEEE-754-2008 is the universal standard defining how floating-point numbers are stored and computed.
A 32-bit float (single precision) is divided into three fields:
31 30 23 22 0
S EEEEEEEE MMMMMMMMMMMMMMMMMMMMMMM
^ 8 bits 23 bits
sign exponent mantissa (fraction)
- Sign (1 bit): 0 = positive, 1 = negative.
- Exponent (8 bits): stored with a bias of 127. The actual exponent is
e_stored − 127. Range: −126 to +127 (the values 0 and 255 are reserved for special cases). - Mantissa (23 bits): represents the fractional part of a number in the form
1.fraction × 2^exponent. The leading1is implicit (not stored) — this is the hidden bit trick that gives you one free bit of precision.
The value of a normal float is therefore:
(−1)^S × 1.M × 2^(E−127)
For double (64-bit): sign=1, exponent=11 bits (bias=1023), mantissa=52 bits. Same structure, more room.
Example: decoding 0x40490FDB (π ≈ 3.14159)
- Sign: 0 (positive)
- Exponent bits:
10000000= 128, actual = 128 − 127 = 1 - Mantissa bits:
10010000111111011011+ zeros →1.100 1001 0000 1111 1101 1011 - Value:
1.10010010000111111011011 × 2¹ = 11.0010010...₂ = 3.14159265...✓
Single precision gives about 7 significant decimal digits; double gives about 15-16.
#include <stdio.h>
#include <stdint.h>
#include <string.h>
void decode_float(float f) {
uint32_t bits;
memcpy(&bits, &f, 4); /* safe type-punning via memcpy */
uint32_t sign = (bits >> 31) & 0x1;
uint32_t exponent = (bits >> 23) & 0xFF;
uint32_t mantissa = bits & 0x7FFFFF;
printf("float %f\n", f);
printf(" hex: 0x%08X\n", bits);
printf(" sign: %u\n", sign);
printf(" exponent: %u (actual: %d)\n",
exponent, (int)exponent - 127);
printf(" mantissa: 0x%06X\n\n", mantissa);
}
int main(void) {
decode_float(3.14159f);
decode_float(-0.5f);
decode_float(1.0f);
return 0;
}
05IEEE-754 Special Values: NaN, Infinity, Denormals, Zero
IEEE-754 reserves certain bit patterns for special mathematical entities. Understanding these is critical for writing robust numerical code and for debugging mysterious floating-point results.
Zeros: Both +0.0 and −0.0 exist (only the sign bit differs). They compare equal (+0.0 == -0.0 is true), but 1.0/+0.0 = +∞ while 1.0/-0.0 = -∞.
Infinities: Exponent field = 11111111 (all ones), mantissa = 0. +∞ arises from overflow or 1.0/0.0; −∞ from the corresponding negative cases. Arithmetic on infinity follows mathematical convention: ∞ + 1 = ∞, ∞ - ∞ = NaN.
NaN (Not a Number): Exponent = all ones, mantissa ≠ 0. Represents undefined results: 0.0/0.0, sqrt(-1.0), ∞ - ∞. NaN is contagious — any arithmetic involving NaN produces NaN. Most critically: NaN is not equal to itself — nan != nan is true. This is the canonical NaN test.
Denormalized (subnormal) numbers: Exponent = 00000000, mantissa ≠ 0. These cover the range between 0 and the smallest normal number by giving up the implicit leading 1, allowing gradual underflow. They are slightly slower on some hardware (software emulation needed on older CPUs) but prevent sudden loss of all precision at the boundary.
Why NaN self-inequality matters in the kernel: The kernel avoids FPU in most contexts, but when it does use floats (e.g., in power-management code, or in bpf programs with float-typed map values), mishandling NaN can silently corrupt comparisons.
Quick reference for float exponent=0xFF:
| Mantissa | Meaning |
|---|---|
0x000000 | ±Infinity |
0x400000 | Quiet NaN (qNaN) |
0x000001–0x3FFFFF | Signaling NaN (sNaN) |
0x400001–0x7FFFFF | qNaN with payload |
#include <stdio.h>
#include <math.h> /* -lm */
int main(void) {
float inf = 1.0f / 0.0f;
float nan = 0.0f / 0.0f;
printf("inf: %f\n", inf); /* inf */
printf("inf+1: %f\n", inf + 1.0f); /* inf */
printf("inf-inf: %f\n", inf - inf); /* nan */
printf("nan==nan: %d\n", nan == nan); /* 0 -- NaN != NaN! */
printf("isnan: %d\n", isnan(nan)); /* 1 */
printf("isinf: %d\n", isinf(inf)); /* 1 */
/* Denormal: smallest positive float */
float denorm = 1.4e-45f;
printf("denormal: %e\n", denorm);
printf("normal min:%e\n", 1.175494e-38f);
return 0;
}
06Rounding, Precision Limits, and Why 0.1 + 0.2 != 0.3
Floating-point is an approximation of the real numbers. Most decimal fractions cannot be represented exactly in binary — just as 1/3 cannot be represented exactly in decimal. The number 0.1 in binary is a repeating fraction: 0.000110011001100...₂. The closest 32-bit float to 0.1 is slightly more than 0.1.
When you write 0.1f + 0.2f in C, the CPU adds two approximate values and then rounds the result. The answer is approximately 0.30000001192 — close to 0.3, but not exactly equal to 0.3f (which is a different approximation). Hence 0.1f + 0.2f == 0.3f is false.
The four IEEE-754 rounding modes:
- Round to nearest, ties to even (default) — the most commonly used, minimizes statistical bias.
- Round toward +∞ (ceiling)
- Round toward −∞ (floor)
- Round toward zero (truncation)
The C99 <fenv.h> header lets you query/set the current rounding mode.
Machine epsilon (ε): The smallest value such that 1.0 + ε ≠ 1.0. For float, ε ≈ 1.19×10⁻⁷; for double, ε ≈ 2.22×10⁻¹⁶. This bounds the relative error of a single rounding step.
Catastrophic cancellation: When two nearly-equal large numbers are subtracted, most significant bits cancel and you're left with mostly noise bits. Example: (1.000001 - 1.000000) in single precision can lose 6 digits of accuracy.
Practical rules for kernel/systems code:
- Never use
==to compare floats; compare with a tolerance:fabsf(a - b) < epsilon. - Avoid float in the kernel when possible — use fixed-point arithmetic instead.
- When you must use float, consider using
doublefor intermediate calculations.
#include <stdio.h>
#include <math.h>
#include <fenv.h>
int main(void) {
/* The classic surprise */
float a = 0.1f, b = 0.2f, c = 0.3f;
printf("0.1 + 0.2 == 0.3? %d\n", a + b == c); /* 0 */
printf("0.1 + 0.2 = %.10f\n", a + b); /* 0.3000000119 */
printf("0.3 = %.10f\n", c); /* 0.2999999970 */
/* Correct comparison */
float eps = 1e-6f;
printf("close enough? %d\n", fabsf((a+b) - c) < eps); /* 1 */
/* Machine epsilon */
float e = 1.0f;
while (1.0f + e != 1.0f)
e /= 2.0f;
e *= 2.0f;
printf("float epsilon: %.2e\n", e); /* ~1.19e-07 */
return 0;
}
07ASCII: The Original Text Encoding
Before Unicode, before UTF-8, there was ASCII — the American Standard Code for Information Interchange, standardized in 1963. ASCII maps 128 codepoints (0–127, fitting in 7 bits) to characters: 0–31 are control characters (non-printable: newline=10, tab=9, null=0, backspace=8, escape=27, carriage return=13...), 32–126 are printable characters (space, punctuation, digits 0–9, uppercase A–Z, lowercase a–z), and 127 is DEL.
Key structure that every systems programmer internalizes:
'A'= 65 =0x41.'Z'= 90 =0x5A. The alphabetic uppercase letters are contiguous.'a'= 97 =0x61.'z'= 122 =0x7A. Lowercase is contiguous and exactly 32 more than uppercase ('a' - 'A' == 32). This means toggling bit 5 (0x20) flips case.'0'= 48 =0x30.'9'= 57 =0x39. Digits are contiguous.'5' - '0' == 5converts a digit character to its integer value.'\0'(null, codepoint 0) terminates C strings. This is why C strings cannot contain embedded nulls.
In the kernel, ASCII is everywhere: printk format strings, /proc file contents, device driver error messages, sysfs attributes. The kernel's lib/string.c operates on byte sequences, treating each byte as a character — for purely ASCII text, that's fine. It becomes wrong the moment you introduce non-ASCII characters.
ASCII's fatal limitation: it was designed for English. No accented characters, no Cyrillic, no Chinese, no Arabic. The world needed more.
#include <stdio.h>
#include <ctype.h>
int main(void) {
/* ASCII structure */
printf("'A'=%d 'a'=%d diff=%d\n",
'A', 'a', 'a'-'A'); /* 65, 97, 32 */
printf("'0'=%d '9'=%d\n", '0', '9'); /* 48, 57 */
/* Bit-5 flip converts case */
char upper = 'G';
char lower = upper | 0x20; /* set bit 5 */
char back = lower & ~0x20; /* clear bit 5 */
printf("%c -> %c -> %c\n", upper, lower, back); /* G -> g -> G */
/* Digit to integer */
char digit = '7';
int value = digit - '0'; /* 7 */
printf("'%c' as int: %d\n", digit, value);
/* Print the printable ASCII table */
for (int i = 32; i < 127; i++)
printf("%3d %c%s", i, i, (i%8==7)?"\n":" ");
return 0;
}
08Unicode: A Universal Character Set
Unicode is a project to assign a unique number — called a codepoint — to every character in every writing system ever used. As of Unicode 15.1 (2023), it defines 149,813 characters across 161 scripts plus a vast collection of symbols and emoji.
A codepoint is written U+XXXX (or U+XXXXX for the supplementary planes) in hex. Examples:
U+0041→ 'A' (Basic Latin, same as ASCII)U+00E9→ 'é' (Latin-1 Supplement)U+4E2D→ '中' (CJK Unified Ideograph)U+1F600→ '😀' (Emoji)
Unicode is organized into 17 planes of 65,536 codepoints each. The Basic Multilingual Plane (BMP, plane 0, U+0000–U+FFFF) contains almost all characters in common use. Planes 1–16 are supplementary planes containing historic scripts, emoji, musical symbols, etc.
An important distinction: Unicode is a character set, not an encoding. It defines what number corresponds to each character, but does not specify how those numbers are stored in memory. That is the job of encodings like UTF-8, UTF-16, and UTF-32.
Why this matters for the kernel: The kernel itself mostly lives in ASCII-land, but user-space filesystems (ext4, btrfs) store filenames as byte sequences and must handle Unicode filenames correctly. The utf8proc and nls (National Language Support) subsystems in the kernel deal with Unicode normalization and case-folding for filenames. When you implement a filesystem, you need to know whether two different byte sequences represent the same Unicode character (normalization equivalence).
/* In C11+, you can write Unicode literals directly */
#include <stdio.h>
#include <locale.h>
int main(void) {
setlocale(LC_ALL, ""); /* use the system locale */
/* Unicode escape in string literal */
printf("%s\n", "élève"); /* élève */
printf("%s\n", "\U0001F600"); /* 😀 */
/* Codepoints of 'A' and '中' */
printf("U+%04X\n", (unsigned)'A'); /* U+0041 */
/* char32_t holds one codepoint (UTF-32) */
char32_t cp = U'中';
printf("U+%04X\n", (unsigned)cp); /* U+4E2D */
return 0;
}
09UTF-8: Variable-Length Unicode Encoding
UTF-8 is the dominant encoding of the internet, Linux filesystems, and terminal output. It encodes every Unicode codepoint as a sequence of 1 to 4 bytes, with the brilliant property that it is backward-compatible with ASCII: any byte value 0–127 is exactly the corresponding ASCII character (1 byte), and no multi-byte sequence ever contains a byte in the 0–127 range.
The encoding rules by codepoint range:
| Codepoint range | Bytes | Byte pattern |
|---|---|---|
| U+0000 – U+007F | 1 | 0xxxxxxx |
| U+0080 – U+07FF | 2 | 110xxxxx 10xxxxxx |
| U+0800 – U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000 – U+10FFFF | 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
The leading byte tells you the total sequence length (by counting leading 1-bits). Continuation bytes always start with 10. This means:
- You can always tell where a character starts, even if you jump into the middle of a string.
- No valid UTF-8 sequence contains the bytes 0xFE or 0xFF (those are used in UTF-16/UTF-32 BOMs).
- Searching for ASCII characters (like
/in a path) is safe even in UTF-8 strings — a/byte (0x2F) never appears as a continuation byte of a multi-byte sequence.
Example: encoding U+4E2D (中)
- Codepoint:
0x4E2D = 0100 1110 0010 1101 - Falls in U+0800–U+FFFF → 3-byte encoding
- Split bits:
xxxx=0100,xxxxxx=111000,xxxxxx=101101 - Wait, let me redo:
4E2D=0100 1110 0010 1101 - Pattern
1110xxxx 10xxxxxx 10xxxxxx→ take 16 bits:0100 111000 1011011110_010010_11100010_101101=0xE4 0xB8 0xAD
Verify in a shell: printf '%s' '中' | xxd → e4 b8 ad ✓
In the kernel, fs/unicode/ implements UTF-8 for case-folding in ext4. The CONFIG_UNICODE Kconfig option enables it. The kernel is careful to validate UTF-8 byte sequences before processing them, because malformed UTF-8 can be a security vector.
/* UTF-8 decoder in C: illustrates the bit patterns */
#include <stdio.h>
#include <stdint.h>
/* Decode one UTF-8 sequence. Returns codepoint, advances *p. */
uint32_t utf8_decode(const uint8_t **p) {
uint8_t b = *(*p)++;
if (b < 0x80) return b; /* 1-byte: ASCII */
if (b < 0xE0) { /* 2-byte */
uint32_t cp = (b & 0x1F) << 6;
cp |= (*(*p)++ & 0x3F);
return cp;
}
if (b < 0xF0) { /* 3-byte */
uint32_t cp = (b & 0x0F) << 12;
cp |= (*(*p)++ & 0x3F) << 6;
cp |= (*(*p)++ & 0x3F);
return cp;
}
/* 4-byte */
uint32_t cp = (b & 0x07) << 18;
cp |= (*(*p)++ & 0x3F) << 12;
cp |= (*(*p)++ & 0x3F) << 6;
cp |= *(*p)++ & 0x3F;
return cp;
}
int main(void) {
/* UTF-8 bytes for '中' */
const uint8_t buf[] = {0xE4, 0xB8, 0xAD, 0};
const uint8_t *p = buf;
printf("U+%04X\n", utf8_decode(&p)); /* U+4E2D */
return 0;
}
10Endianness: Little-Endian vs Big-Endian
When a multi-byte value (like a 32-bit integer) is stored in memory — which occupies multiple consecutive addresses — there is a choice: do the most-significant bytes go at the lower addresses, or the least-significant?
-
Big-endian (BE): Most-significant byte at the lowest address. The bytes are laid out in memory in the same order you'd write the hex number on paper. Network byte order is big-endian. SPARC, PowerPC in big-endian mode, and many embedded chips are big-endian. Think of it like writing a number left-to-right:
0x12345678in memory is12 34 56 78. -
Little-endian (LE): Least-significant byte at the lowest address. x86, x86-64, ARM (in LE mode, the default), and RISC-V are little-endian.
0x12345678in memory is78 56 34 12. This feels counter-intuitive but has arithmetic advantages: adding numbers starting from the least-significant byte aligns with carry propagation.
Why it matters in the kernel: The kernel runs on both big-endian and little-endian architectures. Filesystem on-disk formats must be consistent regardless of the host CPU's endianness. The kernel uses __le32 (little-endian 32-bit) and __be32 (big-endian 32-bit) types (from <linux/types.h>) as documentation and to enable the sparse tool to detect endianness bugs. Conversion macros like cpu_to_le32(), le32_to_cpu(), cpu_to_be32(), etc., generate zero instructions on the native-endian architecture and a bswap instruction on the opposite one.
Byte-swap by hand for a 32-bit value v:
((v & 0xFF000000) >> 24) | ((v & 0x00FF0000) >> 8) | ((v & 0x0000FF00) << 8) | ((v & 0x000000FF) << 24)
Or use __builtin_bswap32(v) in GCC/Clang, which compiles to a single bswap instruction on x86.
#include <stdio.h>
#include <stdint.h>
#include <string.h>
static void dump_bytes(const char *label, const void *p, int n) {
printf("%s: ", label);
const uint8_t *b = (const uint8_t *)p;
for (int i = 0; i < n; i++) printf("%02X ", b[i]);
printf("\n");
}
int main(void) {
uint32_t v = 0x12345678;
dump_bytes("0x12345678 in mem", &v, 4);
/* On x86 (LE): 78 56 34 12 */
/* On SPARC (BE): 12 34 56 78 */
/* Detect endianness at runtime */
uint16_t word = 0x0001;
uint8_t byte;
memcpy(&byte, &word, 1);
printf("this CPU is %s-endian\n",
byte ? "little" : "big");
/* Manual byte-swap (equivalent to cpu_to_be32 on LE) */
uint32_t be = __builtin_bswap32(v);
dump_bytes("byte-swapped", &be, 4); /* 12 34 56 78 */
return 0;
}
11Integer Widths and Alignment in C
In portable C, the types int, long, and pointer have implementation-defined sizes. On a 32-bit x86 Linux, long is 4 bytes; on 64-bit x86-64, it's 8 bytes. This variability is a nightmare for data structures that must match hardware layouts or be portable across architectures.
The kernel solves this with explicit-width types from <linux/types.h>:
u8,u16,u32,u64— unsigned exact widthss8,s16,s32,s64— signed exact widths__le16,__le32,__le64,__be16,__be32,__be64— endianness-annotated types forsparsechecking
Alignment is the requirement that a value of type T should be stored at a memory address that is a multiple of sizeof(T). Most hardware requires this (or performs better with it). A uint32_t at address 0x3 is misaligned on most architectures and will either cause a bus error (ARM, MIPS) or a slow unaligned access (x86). The kernel is full of __attribute__((packed)) and __attribute__((aligned(N))) annotations to control this precisely.
Why alignment matters for kernel data structures: Network packet headers, filesystem on-disk structures, hardware register maps, and DMA descriptors all have exact byte offsets specified by standards or hardware. Using __packed collapses padding, ensuring the C struct exactly mirrors the wire format. Without it, the compiler might insert invisible padding bytes between fields to satisfy alignment, silently corrupting your data.
A concrete hazard: struct { uint8_t a; uint32_t b; } has a 3-byte hole after a on most platforms due to alignment padding. sizeof this struct is 8, not 5. Always verify with offsetof() and sizeof().
#include <stddef.h>
#include <stdio.h>
#include <stdint.h>
struct padded {
uint8_t a; /* offset 0 */
/* 3 bytes padding inserted by compiler */
uint32_t b; /* offset 4 */
uint8_t c; /* offset 8 */
/* 3 bytes padding at end */
}; /* sizeof = 12 */
struct __attribute__((packed)) packed_s {
uint8_t a; /* offset 0 */
uint32_t b; /* offset 1 -- unaligned! risky on ARM */
uint8_t c; /* offset 5 */
}; /* sizeof = 6 */
int main(void) {
printf("padded: size=%zu, offsetof(b)=%zu\n",
sizeof(struct padded),
offsetof(struct padded, b)); /* 12, 4 */
printf("packed: size=%zu, offsetof(b)=%zu\n",
sizeof(struct packed_s),
offsetof(struct packed_s, b)); /* 6, 1 */
return 0;
}
12Viewing Data in Memory: xxd, gdb, and Hex Dumps
Theory becomes concrete when you can see the raw bytes. Several tools let you inspect memory representations directly.
xxd (hex dump of a file or stdin): xxd file.bin shows bytes in hex with their ASCII representations on the right. xxd -e shows in little-endian groupings, xxd -b shows in binary. This is your first tool for decoding on-disk structures, network packets, and binary files.
printf with %x / %02X / %016llX: In C programs, print raw values in hex. The format %08X zero-pads to 8 digits, making 32-bit values easy to read.
gdb x command: x/16xb ptr shows 16 bytes in hex starting at ptr. x/4xw ptr shows 4 32-bit words. x/s ptr shows a C string. This is invaluable for debugging kernel modules with QEMU/gdb.
/proc/pid/mem and dd: On Linux you can read the raw memory of a running process. dd if=/proc/self/mem bs=1 skip=$(printf '%d' 0x400000) count=16 reads 16 bytes from the text segment at the ELF load address.
od (octal dump): od -t x1z shows hex bytes with printable ASCII; od -t d4 shows signed 32-bit integers. A standard POSIX tool available everywhere.
Practicing with these tools will make every concept in this module viscerally real — you will see the IEEE-754 bits, the UTF-8 continuation bytes, the little-endian byte order, and the padding holes in structs.
# Shell session illustrating these tools
# Write a float to a file, then hexdump it
python3 -c "
import struct
with open('/tmp/test.bin','wb') as f:
f.write(struct.pack('<f', 3.14159)) # little-endian float
f.write(struct.pack('<I', 0xDEADBEEF))
f.write('中'.encode('utf-8')) # 3 UTF-8 bytes
"
xxd /tmp/test.bin
# Expected output:
# 00000000: db0f 4940 efbe adde e4b8 ad ..I@.......
#
# db 0f 49 40 = 0x40490fdb in LE = pi as float
# ef be ad de = 0xDEADBEEF in LE
# e4 b8 ad = UTF-8 for '中' (U+4E2D)
# Verify pi:
python3 -c "import struct; print(struct.unpack('<f', bytes.fromhex('db0f4940'))[0])"
Key insights
- ✦Two's-complement is not arbitrary: it makes signed and unsigned addition use identical hardware, which is why every CPU on earth uses it. The sign bit has a negative place value — that one insight unlocks the entire scheme.
- ✦IEEE-754 floating-point numbers are not approximations in the sloppy sense — they are *exact rational numbers* that happen to be close to the value you wanted. Understanding this precision makes you write better numerical code.
- ✦NaN is the only value in any language that is not equal to itself. This is not a bug — it is a deliberate IEEE-754 design decision. `x != x` is the canonical, portable NaN test.
- ✦UTF-8's genius: the leading-1-bit count in the first byte tells you exactly how many bytes the sequence is, and continuation bytes (10xxxxxx) can never be confused with ASCII or leading bytes. You can always resynchronize.
- ✦Endianness only affects multi-byte values read/written to memory as individual bytes. Arithmetic inside a register is endianness-agnostic — the CPU handles it transparently. The issue arises at the boundary between memory and the outside world.
- ✦Unsigned integer overflow is defined (wraps mod 2ⁿ) and often exploited intentionally in kernel code for range checks. Signed integer overflow is *undefined behavior* in C and can be silently miscompiled. Know which one you have.
- ✦The kernel's `__le32` / `__be32` types are not just documentation — the `sparse` static analysis tool enforces them and catches endianness bugs at compile time without requiring you to run on both CPU families.
- ✦sizeof a struct is almost never the sum of its members' sizes, because of alignment padding. Always use offsetof() to find where a field actually lives — never compute it by hand.
Common pitfalls
- ⚠Using == to compare floats. The correct approach is `fabsf(a - b) < epsilon` for an appropriate epsilon, or using integer comparison on the raw bits when you need exact equality of representation.
- ⚠Assuming char is signed or unsigned. In C, char signedness is implementation-defined. Use uint8_t for raw bytes and signed char / int8_t when you need signed 8-bit arithmetic. The kernel uses u8/s8 explicitly for this reason.
- ⚠Treating strlen() as the number of characters in a UTF-8 string. It returns the number of *bytes*, which can be 1–4× the number of Unicode codepoints. Use a proper UTF-8 library for character-level operations.
- ⚠Accessing a multi-byte value through a misaligned pointer. The cast `*(uint32_t *)some_uint8_ptr` is undefined behavior if the pointer is not 4-byte aligned. Use memcpy() into a properly aligned variable instead.
- ⚠Forgetting that signed integer overflow is undefined behavior. The compiler may optimize `if (x + 1 > x)` to `true` (always), removing your intended overflow check. Use __builtin_add_overflow() or cast to unsigned first.
- ⚠Confusing endianness of storage with endianness of arithmetic. You don't need to byteswap when doing math on the native CPU — only when reading/writing multi-byte values to/from memory that will be shared with other systems.
- ⚠Writing (1 << 31) to set the highest bit of a uint32_t. The literal 1 is a signed int; shifting it to the sign bit is undefined behavior. Write (1u << 31) or (UINT32_C(1) << 31) instead.
- ⚠Confusing Unicode codepoints with UTF-16 code units. Characters above U+FFFF require a surrogate pair in UTF-16 but a single 4-byte sequence in UTF-8. Never assume one codepoint = two bytes unless you have verified the encoding.
Practice
- 01medium
Write a C program that prints the IEEE-754 bit representation of every float in the sequence: 0.0, -0.0, 1.0, -1.0, 0.5, 0.1, INFINITY, -INFINITY, NAN, the smallest positive normal float (FLT_MIN), and the smallest positive subnormal float (FLT_TRUE_MIN). For each, print the hex representation and decode the sign, biased exponent, actual exponent, and mantissa. Then verify your decoding using the formula (−1)^S × 1.M × 2^(E−127).
- 02medium
Implement a minimal UTF-8 encoder and decoder in C with no library dependencies. The encoder takes a uint32_t codepoint and writes 1-4 bytes to a buffer; the decoder takes a byte buffer and returns the codepoint and the number of bytes consumed. Test it on: 'A' (U+0041), 'é' (U+00E9), '中' (U+4E2D), and '😀' (U+1F600). Verify by piping the encoder output to xxd and comparing with the output of `printf '%s' '😀' | xxd`.
- 03easy
Write a C program that detects the endianness of the host machine at compile time (using a union or the preprocessor) and at runtime (using a uint16_t = 0x0001 trick). Then write a function `uint32_t to_network_order(uint32_t host_val)` that converts a 32-bit integer to big-endian (network byte order) on both little-endian and big-endian hosts, using only bit shifts and masks (no htonl). Verify it gives 0x12345678 in memory as bytes `12 34 56 78` by dumping with a loop over uint8_t pointers.
- 04hard
Investigate a real kernel header. Run `find /usr/src/linux-headers-$(uname -r) -name types.h | head -5` or look at https://elixir.bootlin.com/linux/latest/source/include/linux/types.h. Find three uses of __le32 or __be32 in a real kernel struct (try the ext4 or network headers). For each: write down the struct name, the field, and explain why that specific endianness annotation is required for correctness.
- 05hard
Write a program that demonstrates catastrophic cancellation in floating point. Compute the quadratic formula x = (-b ± sqrt(b²-4ac)) / 2a for a=1, b=100000, c=1. Use both the naive formula and the numerically stable alternative (multiply top and bottom by the conjugate) for the root that exhibits cancellation. Print both results as doubles and as floats, and show the relative error compared to the analytically exact roots.
Resources
The canonical reference on floating-point: exhaustive, rigorous, and free. Essential reading before you write any numerical code.
Type any decimal number and see its exact IEEE-754 bit decomposition in real time. Invaluable for building intuition.
Compile C snippets and instantly see the generated x86/ARM/RISC-V assembly. Use it to see how the compiler handles bswap, float operations, and integer overflow.
The authoritative specification. Chapter 2 covers general structure; Chapter 3 covers conformance requirements. Dense but worth skimming.
The original exhaustive UTF-8 reference by one of its popularizers. Covers history, encoding details, and common pitfalls.
Read the actual kernel type definitions: u8/u16/u32/u64, __le32/__be32, and the sparse annotations. This is the ground truth.
The standard undergraduate architecture textbook. The RISC-V edition is modern and the integer/float coverage is exemplary.
The authoritative Linux manual pages. Read man 3 stdint, man 3 endian, man 3 fenv for the C library interfaces to the topics in this module.
Explains how the kernel uses sparse to catch endianness bugs via __le32/__be32 type checking. Relevant once you start reading kernel code.
Every systems programmer's secret weapon. Packed with clever bit-twiddling algorithms, many of which appear verbatim in kernel code.
Checkpoints — you can now…
- ✓You can decode any 32-bit IEEE-754 float by hand given its hex representation: extract sign, exponent, and mantissa fields, apply the bias, and compute the value.
- ✓You can explain why 0.1 + 0.2 != 0.3 in floating point and write a correct floating-point comparison using an epsilon tolerance.
- ✓You can convert any Unicode codepoint in the range U+0000 to U+10FFFF to its UTF-8 byte sequence by hand, and decode a UTF-8 byte sequence back to a codepoint.
- ✓You can read a hex dump (from xxd or gdb) of a 32-bit integer and determine whether it was written on a little-endian or big-endian machine.
- ✓You can explain the difference between __le32 and __be32 in kernel headers, and describe what cpu_to_le32() does on a big-endian architecture.
- ✓You can predict the sizeof and offsetof any simple C struct, accounting for compiler alignment padding, and use __attribute__((packed)) to eliminate it when required.
- ✓You can use __builtin_add_overflow() to detect signed integer overflow without triggering undefined behavior, and explain why signed overflow is UB while unsigned overflow is not.
- ✓You can write a C program that opens a binary file, reads multi-byte values correctly accounting for endianness, decodes embedded UTF-8 strings, and prints a human-readable summary.