R0KernelPath
module 010 / 262

Types, Variables, Operators

Master how C maps your variables to raw bytes, how the compiler silently transforms your arithmetic, and why every kernel developer needs these rules memorized before touching a driver.

~12h focused study10 concepts

Overview

C is not a high-level language that hides the machine from you — it is a thin, transparent lens through which you see the hardware directly. Before you can write kernel code, before you can reason about memory layouts or race conditions or hardware registers, you need to understand C's type system at a deep level.

This module is about that foundation: how C thinks about memory (types are just interpretations of raw bytes), how the compiler is allowed to silently transform your values (integer promotion and implicit conversions), how expressions are evaluated (operator precedence), and how the compiler enforces certain guarantees or warnings (qualifiers). These are not just language rules to memorize — they are the bridge between the abstract C you write and the concrete machine instructions your CPU executes.

Why does this matter for kernel work? Because the Linux kernel is written in a very disciplined subset of C. It uses u8, u32, __le32 instead of int and unsigned long precisely because the kernel must be portable across 32-bit and 64-bit machines, little-endian and big-endian architectures. The kernel uses volatile to read hardware registers, and const to protect read-only data sections. A kernel developer who does not deeply understand integer promotion has a bug waiting to happen — possibly a security vulnerability. The kernel coding style guide (Documentation/process/coding-style.rst) exists largely because of how subtle these rules are.

Take this module slowly. Run every example. Modify it. Break it. The goal is not memorization — it is building a mental model of C as a structured way to talk to memory and the CPU.

why this matters for the kernel

The Linux kernel is a C program — one of the largest and most carefully written C programs in existence. Every subsystem, from memory management to device drivers to the scheduler, relies on a precise understanding of types and their sizes. When Linus Torvalds reviews patches, one of the first things he checks is whether the author used the right integer type. Using int where you need u64, or ignoring integer promotion, can cause silent data truncation, buffer overflows, and security exploits. CVE-2021-3490 (a Linux eBPF privilege escalation) was rooted in exactly this kind of subtle integer handling bug.

Beyond safety, understanding types is how you communicate intent to other kernel developers. When you see __u32 le32_to_cpu(...) in a driver, you know instantly: this is a 32-bit little-endian value being converted to host byte order. The type carries semantic information that a plain int never could. Mastering this module gives you the vocabulary to read kernel source intelligently, write patches that pass review, and think about correctness the way the kernel community does.

Integer Promotion and Usual Arithmetic Conversions

rendering diagram…

The C standard's 'usual arithmetic conversions' applied step by step. The dangerous path is the signed-to-unsigned conversion (center-right): a negative signed value becomes a very large unsigned value, making comparisons logically backwards.

The concepts

01Fundamental Types: Memory as Interpreted Bytes

In C, a type is not a property of a memory location — it is a property of an expression. Memory itself is just a sequence of bytes. The type tells the compiler (and you, the programmer) how to interpret those bytes when reading or writing them.

C's fundamental types and their minimum guaranteed sizes are:

TypeMinimum sizeTypical size (x86-64)Meaning
char8 bits8 bitsSmallest addressable unit; may be signed or unsigned
short16 bits16 bitsShort integer
int16 bits32 bits"Natural" machine word (at least 16 bits)
long32 bits64 bits on Linux x86-64Long integer
long long64 bits64 bitsLargest standard integer
float—32 bits (IEEE 754)Single-precision floating point
double—64 bits (IEEE 754)Double-precision floating point

The C standard deliberately says minimum sizes, not exact sizes, to allow C to run on DSPs, microcontrollers, and mainframes. On a 16-bit PIC microcontroller, int really is 16 bits. On Linux x86-64, long is 64 bits, but on Windows x64, long is still 32 bits. This is exactly why the kernel never uses bare int for things where the size matters — it uses u32, u64, etc.

The key mental model: when you write int x = 42;, you are asking the compiler to reserve enough storage to hold an int, and to interpret the bytes at that location as a two's-complement signed integer when you read x. The number 42 is stored as the bit pattern 0x0000002A in a 32-bit int.

#include <stdio.h>

int main(void) {
    int x = 0x0000002A;  /* 42 in hex */
    unsigned char *p = (unsigned char *)&x;

    printf("x = %d\n", x);  /* 42 */
    /* Print the raw bytes (little-endian on x86) */
    for (int i = 0; i < (int)sizeof(x); i++)
        printf("byte[%d] = 0x%02X\n", i, p[i]);
    /* Output on x86: 2A 00 00 00 */
    return 0;
}

02Fixed-Width Integer Types: <stdint.h> and Why the Kernel Uses Them

The problem with int, long, etc. is portability: their sizes vary across platforms. For systems programming — especially OS kernels, device drivers, and network protocols — you often need exactly 32 bits, not "at least 32 bits".

C99 introduced <stdint.h> (and <inttypes.h> for printf/scanf format macros), which defines:

  • Exact-width types: int8_t, int16_t, int32_t, int64_t (and unsigned variants uint8_t, etc.) — guaranteed to be exactly N bits.
  • Minimum-width types: int_least8_t, etc. — at least N bits, useful for maximum portability.
  • Fastest minimum-width types: int_fast8_t, etc. — at least N bits, but the fastest type available (may be wider for alignment).
  • Maximum-width type: intmax_t / uintmax_t — the largest integer type the platform supports.
  • Pointer-sized integer: intptr_t / uintptr_t — wide enough to hold a pointer. Critical for converting between pointers and integers safely.
  • ptrdiff_t (from <stddef.h>): the signed result of pointer subtraction.
  • size_t (from <stddef.h>): the unsigned type returned by sizeof. Always use size_t for sizes and array indices.

The Linux kernel defines its own thin wrappers in <linux/types.h>: u8, u16, u32, u64, s8, s16, s32, s64. These expand to the same stdint.h types underneath. The kernel also has __le32, __be32 etc. for endian-annotated types — the compiler doesn't enforce them, but static analysis tools (sparse) do.

Rule of thumb for systems code: Use uint32_t/u32 when the bit-width matters (hardware registers, on-disk/on-wire formats, binary protocols). Use size_t for sizes. Use ptrdiff_t for pointer differences. Use int only for things that are genuinely "just a number" with no specific bit-width requirement (loop counters, error codes).

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

int main(void) {
    uint32_t reg = 0xDEADBEEF;  /* hardware register value */
    uint8_t  byte = reg & 0xFF; /* low byte: 0xEF */
    size_t   n    = sizeof(reg); /* 4 -- always */

    /* uintptr_t: safely round-trip a pointer through an integer */
    uintptr_t addr = (uintptr_t)&reg;
    uint32_t *back = (uint32_t *)addr;
    printf("reg via roundtrip = 0x%X\n", *back);

    /* intptr_t lets you do pointer arithmetic as integers */
    printf("byte=0x%X n=%zu addr=0x%lX\n", byte, n, (unsigned long)addr);
    return 0;
}

03Integer Promotion: The Hidden Transformation

This is one of the most surprising and bug-prone rules in C. Whenever you use a value of type char, short, or any integer type narrower than int in an arithmetic expression, C automatically promotes it to int (or unsigned int if int cannot represent all values of the original type) before the operation.

This is called the integer promotion rule (C11 §6.3.1.1). It exists because CPUs are most efficient working at their native word size — on a 32-bit or 64-bit machine, arithmetic on 8-bit values often requires explicit zero/sign-extension instructions. The rule bakes this into the language semantics.

What this means in practice:

  1. char a = 200; char b = 100; char c = a + b; — Before the addition, a and b are promoted to int (values 200 and 100). The sum is 300 as an int. Then 300 is truncated to char, giving... it depends on whether char is signed (implementation-defined!) or unsigned. On most x86 Linux systems, plain char is signed, so char c = 300 wraps to 300 - 256 = 44. This is silent. This is a real bug class.

  2. Comparisons between a signed char and an unsigned int will promote the signed char to int, then to unsigned int if the other operand is unsigned int. Negative values become huge positive numbers.

  3. Even bitwise operations: ~(uint8_t)0 is NOT 0xFF. It is ~(int)0, which is 0xFFFFFFFF (all bits set in a 32-bit int). Assigning back to uint8_t gives 0xFF, but if you use it as a uint32_t you have 0xFFFFFFFF. This trips up many beginners writing bitmasks.

The golden rule: be explicit about your types when doing arithmetic, especially with narrow integers. Cast, or use intermediate uint32_t variables.

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

int main(void) {
    uint8_t a = 200, b = 100;

    /* Bug: the addition is done as int (fine), but result is stored
       in uint8_t: 300 mod 256 = 44. Silently wrong. */
    uint8_t wrong = a + b;   /* 44, not 300 */

    /* Correct: keep the wider type */
    uint16_t correct = (uint16_t)a + b;  /* 300 */

    /* Surprising bitwise NOT */
    uint8_t mask = ~(uint8_t)0xFF;  /* ~0xFF is ~(int)0xFF = -256 as int;
                                       truncated to uint8_t = 0x00 */
    /* To get a proper mask, use explicit cast AFTER the NOT */
    uint8_t mask2 = (uint8_t)~0xFF; /* same issue — be careful */
    uint32_t mask3 = ~(uint32_t)0;  /* 0xFFFFFFFF -- correct */

    printf("wrong=%u correct=%u mask=0x%X mask3=0x%X\n",
           wrong, correct, mask, mask3);
    return 0;
}

04Implicit Conversions and the Usual Arithmetic Conversions

When you mix types in a binary expression (like int + long or unsigned + signed), C applies the usual arithmetic conversions to bring both operands to a common type. The rules, applied in order:

  1. First, integer promotion is applied to both operands (widen everything to at least int).
  2. If both types are the same after step 1, done.
  3. If one is floating-point, convert the integer to that float type.
  4. Otherwise, both are integers: if one is unsigned and the other signed, the signed one is converted to unsigned. This is the famous source of bugs: if (n > -1) where n is unsigned int compares n against (unsigned int)-1 = UINT_MAX. The condition is always false for any n!
  5. If both are signed (or both unsigned) but different widths, the narrower is widened to match the wider.

The signed/unsigned mixing rule is particularly dangerous. GCC warns about it with -Wsign-compare (enabled by -Wall). The kernel compiles with strict warnings and treats them as errors.

Implicit truncation also happens on assignment: if you assign a wider type to a narrower type, high-order bits are silently discarded. uint8_t x = 0x1FF gives x = 0xFF. No warning by default in most compilers.

Floating-point conversions: converting double to float loses precision. Converting a large double to int causes undefined behavior if the value doesn't fit — not a silent truncation, but actual UB, which the compiler may exploit for optimizations in ways you cannot predict.

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

void demo_signed_unsigned_bug(void) {
    unsigned int n = 10;
    /* -1 is converted to unsigned: (unsigned)-1 = UINT_MAX = 4294967295 */
    /* So n < (unsigned)-1 is TRUE; this is NOT the bug.
       The classic bug is using n > -1 as a "n is non-negative" guard: */
    if (n > -1u) {  /* -1u is UINT_MAX: this is always FALSE for any n! */
        printf("never reached\n");
    }
    /* Correct guard for an unsigned variable: always non-negative */
    /* Just use: if (n >= 0) -- but compiler warns, since always true */
    /* Better: document that unsigned is always >= 0 and skip the check */
}

void demo_truncation(void) {
    uint32_t big = 0xDEADBEEF;
    uint8_t  small = (uint8_t)big;  /* 0xEF -- top 24 bits silently dropped */
    printf("small = 0x%X\n", small);
}

int main(void) {
    demo_signed_unsigned_bug();
    demo_truncation();

    /* Mixed signed/unsigned comparison -- compile with -Wsign-compare */
    int i = -1;
    unsigned u = 1;
    if ((unsigned)i > u)   /* explicit cast: compare apples to apples */
        printf("after cast: -1 as unsigned is huge\n");
    return 0;
}

05Operator Precedence and Associativity

C has 15 levels of operator precedence, which determine how expressions are parsed when parentheses are absent. Forgetting them causes subtle bugs. Rather than memorizing all 15 levels, focus on the tiers that cause the most bugs:

Tier 1 — Highest (postfix): () function call, [] array index, . member, -> pointer member, i++ i-- postfix increment/decrement.

Tier 2 — Unary prefix: ! ~ + - * (dereference) & (address-of) ++i --i (type) cast, sizeof. Right-to-left associativity.

Tier 3 — Multiplicative: * / %.

Tier 4 — Additive: + -.

Tier 5 — Shifts: << >>. Note: shift is lower precedence than add! So 1 + 2 << 3 is (1+2) << 3 = 24, not 1 + (2<<3) = 17.

Tier 6-7 — Comparisons: < <= > >= then == !=.

Tier 8-10 — Bitwise: & then ^ then |. Each is lower than comparison! So x & 0xFF == 0 means x & (0xFF == 0) which is x & 0 which is always 0. This is the single most common C bitmask bug. Always parenthesize bitwise operations.

Tier 11-12 — Logical: && then ||.

Tier 13 — Ternary: ?:.

Tier 14 — Assignment: = += -= etc. Right-to-left.

Tier 15 — Comma: ,.

Practical rule: Parenthesize anything involving bitwise operators, any mix of comparison and bitwise, and any place you are unsure. Code is read many more times than it is written; clarity beats cleverness.

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

int main(void) {
    uint8_t x = 0xA5;

    /* Classic bug: bitwise & has LOWER precedence than == */
    /* This tests (x & (0x0F == 0)), not ((x & 0x0F) == 0) */
    if (x & 0x0F == 0)   /* compiler warns with -Wall; result: x & 0 = 0, always false */
        printf("low nibble is zero (BUG: never true)\n");

    /* Correct: explicit parens */
    if ((x & 0x0F) == 0)
        printf("low nibble is zero\n");
    else
        printf("low nibble = 0x%X\n", x & 0x0F);  /* 0x5 */

    /* Shift vs add precedence surprise */
    int a = 1 + 2 << 3;   /* (1+2) << 3 = 24, NOT 1 + (2<<3) = 17 */
    int b = 1 + (2 << 3); /* explicitly 17 */
    printf("a=%d b=%d\n", a, b);

    /* Postfix vs prefix: different semantics */
    int n = 5;
    int post = n++;  /* post=5, n=6 */
    int pre  = ++n;  /* pre=7,  n=7 */
    printf("post=%d pre=%d n=%d\n", post, pre, n);
    return 0;
}

06sizeof: Compile-Time Size Inquiry

sizeof is a compile-time operator (not a function) that returns the size of a type or expression in bytes, as a size_t. It is evaluated at compile time (except for C99 variable-length arrays, where it may be a runtime operation).

Key uses:

  1. Portability: sizeof(long) is 4 on 32-bit systems and 8 on 64-bit Linux. Never hardcode sizes; use sizeof.
  2. Safe array sizing: int arr[10]; size_t n = sizeof(arr) / sizeof(arr[0]); gives the element count. But beware: if you pass arr to a function, it decays to a pointer, and sizeof(arr_param) gives the pointer size, not the array size. This is a very common bug.
  3. sizeof a struct: includes padding bytes inserted for alignment. A struct {char c; int i;} is likely 8 bytes on x86-64, not 5, because i must be 4-byte aligned.
  4. sizeof(0) is sizeof(int) — the literal 0 has type int. sizeof('A') is sizeof(int) in C (unlike C++, where it would be sizeof(char)).

In kernel code, sizeof is everywhere: kmalloc(sizeof(struct foo), GFP_KERNEL) allocates exactly the right number of bytes for a structure, regardless of the architecture. The macro ARRAY_SIZE(arr) in <linux/kernel.h> is defined as (sizeof(arr) / sizeof((arr)[0])) plus a compile-time check that arr is actually an array, not a pointer.

sizeof and side effects: The expression inside sizeof is not evaluated. sizeof(i++) does not increment i. The compiler only looks at the type of the expression.

#include <stdio.h>
#include <stddef.h>

struct Example {
    char  c;   /* 1 byte */
    /* 3 bytes padding for alignment of 'i' */
    int   i;   /* 4 bytes */
    char  d;   /* 1 byte */
    /* 3 bytes padding to make struct size a multiple of 4 */
}; /* total: 12 bytes on most platforms */

void print_arr_size(int *arr) {
    /* WRONG: sizeof(arr) = sizeof(pointer) = 8 on 64-bit */
    printf("inside fn: sizeof(arr) = %zu (pointer size!)\n", sizeof(arr));
}

int main(void) {
    int arr[10];
    struct Example e;

    printf("sizeof(int)    = %zu\n", sizeof(int));
    printf("sizeof(long)   = %zu\n", sizeof(long));   /* 8 on Linux x86-64 */
    printf("sizeof(arr)    = %zu\n", sizeof(arr));     /* 40 (10*4) */
    printf("arr elements   = %zu\n", sizeof(arr)/sizeof(arr[0])); /* 10 */
    printf("sizeof(struct Example) = %zu\n", sizeof(e)); /* 12 */
    printf("sizeof(char literal) = %zu\n", sizeof('A')); /* 4 in C! */

    print_arr_size(arr);  /* demonstrates the decay-to-pointer bug */

    /* sizeof does not evaluate its argument */
    int x = 5;
    (void)sizeof(x++);   /* x is still 5 */
    printf("x after sizeof(x++) = %d\n", x);
    return 0;
}

07const: Read-Only Bindings and Pointer Constness

const declares that a variable or the data pointed to by a pointer should not be modified after initialization. It is a contract — the compiler enforces it syntactically, and it communicates intent to other programmers.

Simple const: const int MAX = 100; — MAX cannot be assigned to. Unlike #define, it has a type, a scope, and an address. It is stored in the .rodata segment (read-only data) in an executable.

Pointer constness — this is where beginners get confused. There are two orthogonal things that can be const: the pointer itself, and the data it points to:

  • const int *p — pointer to const int. You cannot do *p = 5. You can do p = &other.
  • int * const p — const pointer to int. You cannot do p = &other. You can do *p = 5.
  • const int * const p — both. Neither the pointer nor the pointed-to data can change.

A useful mnemonic: read the declaration right-to-left. const int *p = "p is a pointer to int that is const". int * const p = "p is a const pointer to int".

Why const matters in systems programming: In the Linux kernel, function parameters declared const char *buf communicate "I will not modify your buffer." String literals are implicitly const char * in C++ (and should be treated as such in C). The kernel's __user annotation (checked by sparse) is similar in spirit — it marks pointers to user-space memory that must be accessed via copy_to_user/copy_from_user.

const does not mean immutable at the hardware level — it is a compile-time contract. You can (badly) cast away const with (int *), and if the underlying storage is actually writable, it will work. But if you apply const to something in .rodata and then cast away constness and write to it, you get undefined behavior (and likely a segfault on modern systems where .rodata is a read-only memory page).

#include <stdio.h>

/* const parameter: caller's buffer is not modified */
size_t my_strlen(const char *s) {
    size_t n = 0;
    while (*s++) n++;  /* reads *s, does not write */
    return n;
}

int main(void) {
    const int MAX = 256;
    /* MAX = 512; */  /* compile error: assignment to read-only variable */

    int a = 10, b = 20;
    const int *p = &a;    /* pointer-to-const: can't modify *p */
    /* *p = 99; */         /* compile error */
    p = &b;               /* OK: p itself is not const */

    int * const q = &a;   /* const pointer: can't change q */
    *q = 99;              /* OK: the int it points to is mutable */
    /* q = &b; */          /* compile error */

    printf("a=%d MAX=%d strlen=%zu\n", a, MAX, my_strlen("hello"));
    return 0;
}

08volatile: Telling the Compiler Not to Optimize Away Reads/Writes

volatile is a type qualifier that tells the compiler: every access to this variable must be a real memory access; do not cache it in a register, do not reorder it, do not optimize it away.

Without volatile, the compiler may observe that a variable is never written in a loop and optimize it by loading it into a register once, or even eliminating the read entirely:

// Without volatile: compiler may turn this into an infinite loop
// by loading ready once and comparing forever in a register
while (!ready) { /* spin */ }

With volatile int ready;, every iteration reads from the memory address of ready.

The three canonical uses of volatile:

  1. Memory-mapped hardware registers: A hardware register at a fixed address changes its value independently of the CPU program. The compiler cannot know this. Every register access must be a real load/store. In Linux kernel drivers: volatile u32 *reg = ioremap(DEVICE_BASE, 4);

  2. Signal handlers: A variable modified by a signal handler and read in the main loop. (For multi-threaded code, use atomics instead.)

  3. setjmp/longjmp: Variables that must survive a non-local jump must be volatile or the compiler may have kept them only in registers.

What volatile does NOT do: It does not provide atomicity, and it does not provide memory ordering guarantees across CPUs. Two threads sharing a volatile variable are not safe without additional synchronization. This is a common misconception. For SMP (multi-processor) safety, the kernel uses READ_ONCE(), WRITE_ONCE(), and proper memory barriers — volatile alone is insufficient and often wrong for concurrency.

For kernel driver writers: the preferred pattern is ioread32()/iowrite32() rather than raw volatile pointer dereference, because these functions also include the necessary memory barriers.

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

/* Simulating a memory-mapped register: volatile tells the compiler
   every read must actually go to memory, not be cached in a register */
volatile uint32_t *DEVICE_STATUS = (volatile uint32_t *)0xDEAD0000;

void poll_device(void) {
    /* Without volatile, compiler MIGHT optimize this to a single read
       and infinite loop. With volatile, every iteration reads memory. */
    while ((*DEVICE_STATUS & 0x1) == 0)
        ;  /* spin-wait for bit 0 to be set by hardware */
}

/* For signal handlers: volatile sig_atomic_t is the correct type */
#include <signal.h>
volatile sig_atomic_t got_signal = 0;
void handler(int sig) { got_signal = 1; }

int main(void) {
    signal(SIGINT, handler);
    /* In real code: while (!got_signal) { do_work(); } */
    printf("volatile demo (don't call poll_device -- bad address)\n");
    printf("got_signal = %d\n", got_signal);
    return 0;
}

09Scope and Lifetime: Where Variables Live

Scope is a compile-time concept: which region of source code can name a variable. Lifetime (storage duration) is a runtime concept: for how long does the memory backing a variable actually exist.

C has four storage durations:

  1. Automatic (auto, the default for local variables): Lives on the stack. Created when execution enters the enclosing block, destroyed when it exits. Stack memory is recycled; reading an automatic variable after its scope ends is undefined behavior (dangling stack pointer).

  2. Static (static local variables, or file-scope variables): Lives for the entire program duration. A static local variable is initialized once (at program start) and retains its value across function calls. File-scope (static) variables are private to the translation unit.

  3. Thread-local (_Thread_local / __thread): One instance per thread, with thread lifetime.

  4. Allocated (malloc/free): Lives from allocation to deallocation. You manage this explicitly.

Scope levels:

  • Block scope: A variable declared inside { } is only visible inside that block.
  • File scope: Variables declared outside any function are visible from declaration to end of file. If static, they are invisible to other translation units; if extern, they are visible across translation units.
  • Function scope: Only applies to labels (goto targets).
  • Function prototype scope: Parameter names in a prototype declaration (rarely matters).

The shadowing trap: A local variable with the same name as a file-scope variable shadows it within its scope. The outer variable still exists; you just cannot access it by that name. GCC's -Wshadow warns about this. The Linux kernel style guide forbids variable shadowing.

Kernel relevance: Kernel code uses static extensively for module-level state. Kernel stacks are small (8KB or 16KB), so large objects must never be declared as automatic locals — use dynamic allocation (kmalloc) or static/global storage.

#include <stdio.h>

int global = 100;  /* file scope, static lifetime */

void counter(void) {
    static int count = 0;  /* static local: initialized once, persists */
    count++;               /* remembers value across calls */
    printf("count = %d\n", count);
}

void scope_demo(void) {
    int x = 1;             /* block scope, automatic lifetime */
    {
        int x = 2;         /* different x: shadows outer x (-Wshadow warns) */
        printf("inner x = %d\n", x);  /* 2 */
    }
    printf("outer x = %d\n", x);      /* 1 -- inner x is gone */

    /* Lifetime demo: don't return a pointer to an automatic local! */
    /* int *bad(void) { int local = 42; return &local; } -- UB! */
}

int main(void) {
    counter(); counter(); counter();  /* prints 1, 2, 3 */
    scope_demo();
    printf("global = %d\n", global);
    return 0;
}

10Two's Complement, Overflow, and Undefined Behavior

Since C99, C mandates that unsigned integer arithmetic wraps around modulo 2^N — this is defined behavior. A uint8_t that reaches 255 and is incremented becomes 0. This is relied upon by hash functions, checksums, and circular buffers.

However, signed integer overflow is undefined behavior in C. Not "implementation-defined" (where the standard allows implementations to define the result), but truly undefined — the compiler is allowed to assume it never happens and generate code accordingly. In practice, on x86 and ARM with gcc at -O0, signed overflow wraps two's complement (which is what virtually all modern hardware uses). But with optimizations enabled, GCC exploits the UB assumption:

/* GCC at -O2 can optimize this loop to run forever:
   it assumes (i+1 > i) is always true because signed overflow is UB */
for (int i = 0; i < INT_MAX; i++) { ... }

This is not theoretical. Real kernel CVEs have resulted from signed overflow UB being misoptimized. The kernel is compiled with -fno-strict-overflow (which implies -fwrapv for signed integers in recent GCC) and -fno-strict-aliasing to prevent these classes of misoptimization.

Two's complement representation (mandated by C20, and the de facto standard before): An N-bit signed integer represents values from -2^(N-1) to 2^(N-1)-1. The negative of a number is formed by inverting all bits and adding 1. The most significant bit is the sign bit (1 = negative). INT_MIN (-2147483648 for 32-bit) is special: -INT_MIN overflows because 2147483648 does not fit in a signed 32-bit integer.

For kernel code: Always use check_add_overflow(), check_mul_overflow() (from <linux/overflow.h>) when doing arithmetic where overflow is a security concern (especially array index calculations for memory allocations). A wrong size calculation can turn a fixed allocation into a smaller-than-expected one, leading to a heap overflow.

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

int main(void) {
    /* Unsigned overflow: defined, wraps mod 2^N */
    uint8_t u = 255;
    u++;              /* u = 0: defined behavior */
    printf("uint8 wraparound: %u\n", u);

    /* Signed overflow: undefined behavior in C standard */
    /* In practice with -fwrapv: wraps two's complement */
    int s = INT_MAX;
    /* s++; -- UB without -fwrapv; with -fwrapv: s = INT_MIN */
    printf("INT_MAX = %d, INT_MIN = %d\n", INT_MAX, INT_MIN);

    /* Two's complement: negate by flip-all-bits + 1 */
    int32_t x = 5;   /* 0x00000005 */
    int32_t neg = ~x + 1;  /* 0xFFFFFFFB = -5 */
    printf("5 in two's complement negated = %d\n", neg);

    /* INT_MIN is special: -INT_MIN overflows! */
    /* -INT_MIN == INT_MIN in two's complement (wraps): */
    /* 0x80000000 -> flip -> 0x7FFFFFFF -> +1 -> 0x80000000 */
    printf("sizeof(int) = %zu, INT_MIN = %d\n", sizeof(int), INT_MIN);
    return 0;
}

Key insights

  • ✦Types in C are interpretations of raw bytes, not intrinsic properties of memory. The same four bytes can be an int, a float, or four chars — the type just tells the compiler how to read them.
  • ✦Integer promotion silently widens char and short to int before arithmetic. This means expressions on narrow types may produce results wider than you expect, and assigning back truncates silently.
  • ✦Mixing signed and unsigned types in comparisons converts the signed operand to unsigned. Negative signed values become enormous positive unsigned values, producing logically backwards comparisons.
  • ✦Bitwise operators have LOWER precedence than comparison operators. Always parenthesize: write (x & MASK) == VALUE, not x & MASK == VALUE.
  • ✦sizeof is a compile-time operator. An array decays to a pointer when passed to a function, so sizeof inside the function gives pointer size, not array size — a very common source of off-by-one bugs.
  • ✦volatile prevents the compiler from caching a value in a register, but it does not provide atomicity or multi-processor memory ordering. For concurrent code, volatile alone is wrong; use atomics and memory barriers.
  • ✦Signed integer overflow is undefined behavior in C, which means the compiler can and will make optimizations that assume it never happens. Use unsigned types for arithmetic that might wrap, or compile with -fwrapv.
  • ✦The Linux kernel's u8/u16/u32/u64 types, along with __le32/__be32 endian-annotated types, exist precisely because fundamental C types have platform-dependent sizes. Never use bare int for kernel data structures that touch hardware or cross architectural boundaries.

Common pitfalls

  • ⚠Using plain char for arithmetic: plain char is implementation-defined as signed or unsigned. On most Linux x86 systems it is signed, meaning char values above 127 are negative. Always use uint8_t for byte-level arithmetic and signed char or unsigned char explicitly when the sign matters.
  • ⚠Forgetting that bitwise & and | have lower precedence than ==. The expression `x & MASK == 0` almost never does what you want. Always add parentheses: `(x & MASK) == 0`.
  • ⚠Passing an array to a function and using sizeof inside that function. The array decays to a pointer; sizeof gives pointer size (8 bytes on 64-bit), not array size. Pass the length explicitly or use ARRAY_SIZE() at the call site.
  • ⚠Assuming volatile is sufficient for thread-safe shared variables. It is not. volatile prevents compiler caching but does nothing about CPU cache coherency or instruction reordering on SMP systems.
  • ⚠Using signed int where an unsigned type is needed, or vice versa, in size calculations. `size_t len = end - start` is fine if end >= start, but assigning a negative difference to size_t silently wraps to a huge positive number.
  • ⚠Assuming sizeof('A') is 1. In C (not C++), character literals have type int, so sizeof('A') == sizeof(int) == 4 on most platforms. Use sizeof(char) if you mean one byte.
  • ⚠Relying on signed integer overflow for wrapping behavior without -fwrapv. Modern GCC uses signed overflow UB to elide range checks and transform loops, which can introduce subtle security vulnerabilities in security-critical code.

Practice

  1. 01
    easy

    Write a C program that demonstrates the sizeof surprise for structs. Define at least three different structs with the same fields but in different orders (e.g., char + int + char vs int + char + char). Print the sizeof each struct and the offsetof each field. Explain the padding you observe by drawing the memory layout.

  2. 02
    easy

    Reproduce the signed/unsigned comparison bug in a safe test harness. Write a function that takes an unsigned length parameter and returns whether index i is in-bounds. Write a buggy version (using signed i compared to unsigned length) and a correct version. Use assertions to show the buggy version accepts i = -1 as 'in bounds'. Compile with -Wall -Wsign-compare and see what warnings you get.

  3. 03
    medium

    Write a portable byte-swap function for uint32_t and uint64_t that does NOT use compiler built-ins (no __builtin_bswap32). Implement it using only shifts and bitwise OR. Then write a test that shows it correctly converts between little-endian and big-endian representations by encoding a known value as bytes, byte-swapping, and verifying.

  4. 04
    medium

    Implement a simple ring buffer (circular buffer) of uint8_t values using only unsigned arithmetic for the index wraparound (no modulo operator -- use a power-of-two size and bitwise AND). Write push() and pop() functions, a full() predicate, and an empty() predicate. Demonstrate that push on a full buffer and pop on an empty buffer are handled gracefully.

  5. 05
    hard

    Build a minimal 'register emulator': define a volatile uint32_t that simulates a hardware status register. Write functions set_bit(reg, bit), clear_bit(reg, bit), test_bit(reg, bit), and a poll_until_set(reg, bit) that spin-waits. Then write a background thread (using pthreads) that toggles bit 0 every 100ms. Show that without volatile, the polling loop can be optimized away (use -O2 and inspect the generated assembly with gcc -S or godbolt.org).

Resources

📄C11 Standard (N1570 public draft)
docsfree

The authoritative source for integer promotion (§6.3.1.1), usual arithmetic conversions (§6.3.1.8), and undefined behavior. Dense but essential for precision.

⚡Compiler Explorer (godbolt.org)
interactivefree

Compile C code to x86/ARM/RISC-V assembly in your browser. Indispensable for seeing how types, volatile, and const affect code generation.

✎Beej's Guide to C Programming
articlefree

The best free introductory-to-intermediate C reference on the web. Covers types, conversions, and qualifiers with humor and precision.

📄Linux Kernel Coding Style
docsfree

Official style guide; explains why the kernel uses specific type choices and naming conventions.

📄Linux kernel include/linux/types.h
docsfree

See how the kernel defines u8, u32, __le32 etc. Bootlin cross-reference makes it easy to follow typedef chains.

📄GCC Integer Overflow and -fwrapv documentation
docsfree

Documents -fwrapv, -fno-strict-overflow, and related flags that the kernel uses to prevent UB-based misoptimization.

📕C Programming: A Modern Approach
bookpaidK.N. King, W.W. Norton & Company, 2nd ed. 2008

The most thorough undergraduate C textbook. Covers every type conversion rule with worked examples.

📕Expert C Programming: Deep C Secrets
bookpaidPeter van der Linden, Prentice Hall, 1994

Classic. Chapter 3 on types and Chapter 4 on pointers are the best explanations of C's strange corners ever written.

📕Linux Device Drivers, 3rd Edition
bookfree

Free online. Chapter 1-3 show real kernel code using u32, volatile ioread/iowrite, and the type idioms you will use as a kernel developer.

Checkpoints — you can now…

  • ✓You can explain why sizeof(int) may differ between platforms and why the Linux kernel uses u32/u64 instead.
  • ✓You can trace the integer promotion and usual arithmetic conversion rules for any given binary expression, including the dangerous signed-to-unsigned case.
  • ✓You can read a complex pointer declaration like 'const int * const *p' and state precisely what is const and what is mutable.
  • ✓You can identify the operator precedence bug in 'x & MASK == 0' and rewrite it correctly.
  • ✓You can explain why volatile is necessary for memory-mapped hardware registers but insufficient for multi-threaded shared state.
  • ✓You can write a struct, predict its size with padding, and verify with sizeof and offsetof.
  • ✓You can explain the difference between scope (compile-time) and lifetime (runtime), and name the four storage durations in C.
  • ✓You can describe why signed integer overflow is undefined behavior and what compiler flag the Linux kernel uses to prevent UB-based misoptimization.