Segmentation Faults: The Complete Guide

Type this right now

// save as crash.c — compile: gcc -g -O0 -o crash crash.c
#include <stdio.h>

int main() {
    int *p = NULL;
    printf("About to dereference NULL...\n");
    *p = 42;
    printf("This line never runs.\n");
    return 0;
}
$ ./crash
About to dereference NULL...
Segmentation fault (core dumped)

$ dmesg | tail -1
crash[12345]: segfault at 0 ip 00005555555551a2 sp 00007fffffffde10 error 6 in crash[555555555000+1000]

That kernel log line tells you everything: the fault happened at address 0 (NULL), the instruction pointer was 0x5555555551a2, and error code 6 means "user-mode write to a non-present page."

Now you know where it crashed, what it was doing, and why.


What ACTUALLY happens

A segfault is not magic. It's a precise chain of hardware and software events:

    1. Your code: *p = 42;  (where p = NULL = 0x0)
           │
           ▼
    2. CPU: "Load effective address 0x0, store value 42"
           │
           ▼
    3. MMU: Walk page table for address 0x0
           │
           ▼
    4. MMU: PTE for page 0 has Present=0 (page 0 is intentionally unmapped)
           │
           ▼
    5. CPU: Store fault address (0x0) in CR2 register
           CPU: Raise #PF exception (interrupt vector 14)
           CPU: Switch to Ring 0 (kernel mode)
           CPU: Jump to kernel's page fault handler
           │
           ▼
    6. Kernel: Check if address 0x0 belongs to any VMA for this process
           │
           ▼
    7. Kernel: No valid VMA → this is an invalid access
           │
           ▼
    8. Kernel: Send SIGSEGV (signal 11) to the process
           │
           ▼
    9. Process: Default SIGSEGV handler runs
           → Print "Segmentation fault"
           → Generate core dump (if ulimit allows)
           → Terminate with exit code 139 (128 + 11)

The CPU doesn't know what a segfault is. It only knows page faults. The kernel decides whether a page fault is recoverable (minor/major fault) or fatal (segfault).


Cause #1: NULL pointer dereference

The most common segfault. Page zero is intentionally unmapped on every modern OS. This turns NULL dereference from silent corruption into an immediate crash.

In C

// null.c — compile: gcc -g -O0 -o null null.c
#include <stdio.h>
#include <stdlib.h>

struct Node {
    int value;
    struct Node *next;
};

int main() {
    struct Node *head = NULL;
    // Forgot to allocate! Accessing through NULL pointer:
    head->value = 42;  // CRASH: writing to address 0x0
    return 0;
}
    Memory at dereference:

    head ──────► 0x0000000000000000 (NULL)
                        │
                        ▼
              ┌─────────────────────┐
              │    Page 0 (4 KB)    │
              │                     │
              │  NOT MAPPED         │  ◄── Present=0 in page table
              │  Access here =      │
              │  immediate #PF      │
              └─────────────────────┘

In Rust

fn main() {
    let head: Option<Box<i32>> = None;

    // This won't compile — Rust forces you to handle None:
    // let val = *head;  // ERROR: cannot dereference Option

    // You must explicitly handle it:
    match head {
        Some(val) => println!("Value: {}", val),
        None => println!("No value!"),
    }
}

Rust's Option<T> makes NULL impossible in safe code. There is no null pointer — there's Some(value) or None, and the compiler forces you to handle both. You literally cannot compile code that dereferences without checking.

💡 Fun Fact: The first 64 KB of address space (pages 0-15) are unmapped on Linux. This catches not just NULL dereference but also NULL + small_offset, like ((struct s*)NULL)->field. This range is controlled by /proc/sys/vm/mmap_min_addr.


Cause #2: Stack overflow

Every thread has a fixed-size stack (default: 8 MB on Linux). Below the stack is a guard page — an unmapped page that triggers a fault if the stack grows too far.

In C

// stackoverflow.c — compile: gcc -g -O0 -o stackoverflow stackoverflow.c
#include <stdio.h>

void recurse(int depth) {
    char buffer[4096];  // 4 KB per frame — eat stack fast
    buffer[0] = 'A';
    printf("Depth: %d, &buffer = %p\n", depth, (void *)buffer);
    recurse(depth + 1);
}

int main() {
    recurse(0);
    return 0;
}
    Stack layout during deep recursion:

    0x7FFFFFFFE000 ┌────────────────────┐  ◄── Stack top
                   │ main() frame       │
                   ├────────────────────┤
                   │ recurse(0) frame   │
                   │   buffer[4096]     │
                   ├────────────────────┤
                   │ recurse(1) frame   │
                   │   buffer[4096]     │
                   ├────────────────────┤
                   │        ...         │
                   │   ~2000 frames     │
                   │        ...         │
                   ├────────────────────┤
                   │ recurse(2047)      │
    0x7FFFFFF7E000 ├────────────────────┤  ◄── Stack bottom (8 MB limit)
                   │   GUARD PAGE       │  ◄── Unmapped! Touching = SIGSEGV
                   ├────────────────────┤
                   │   (unmapped)       │
                   └────────────────────┘

In Rust

fn recurse(depth: u64) {
    let buffer = [0u8; 4096];
    println!("Depth: {}, addr: {:p}", depth, &buffer);
    recurse(depth + 1);
}

fn main() {
    recurse(0);
}
thread 'main' has overflowed its stack
fatal runtime error: stack overflow
Aborted (core dumped)

Rust detects the stack overflow and prints a clear message instead of just "Segmentation fault." But the underlying mechanism is identical — the guard page triggers a fault, and the runtime catches it.


Cause #3: Writing to read-only memory

String literals in C live in the .rodata section, which is mapped read-only (r--p).

In C

// rodata.c — compile: gcc -g -O0 -o rodata rodata.c
#include <stdio.h>

int main() {
    char *s = "Hello";  // s points into .rodata (read-only)
    s[0] = 'h';         // CRASH: writing to read-only page
    return 0;
}
    Memory layout:

    .rodata section (mapped with permissions: r--p)
    ┌───────────────────────────────┐
    │ 'H' 'e' 'l' 'l' 'o' '\0'   │
    │  ▲                           │
    │  │                           │
    │  s points here               │
    └───────────────────────────────┘

    PTE flags: Present=1, Read/Write=0 (read-only)

    CPU tries to WRITE → MMU: "Present=1 but Write=0" → #PF
    Kernel: "Valid mapping but wrong permissions" → SIGSEGV

In Rust

fn main() {
    let s = "Hello";  // &str — immutable by definition
    // s[0] = 'h';    // Won't compile: &str is not mutable
    // There's no way to accidentally write to a string literal in safe Rust.

    // Even with a mutable String, the original literal stays safe:
    let mut owned = String::from("Hello");
    owned.replace_range(0..1, "h");  // This modifies a HEAP copy
    println!("{}", owned);  // "hello"
}

Rust's type system distinguishes &str (immutable reference to string data) from String (owned, heap-allocated, mutable). You can't accidentally modify a string literal.


Cause #4: Use-after-free

In C

// uaf.c — compile: gcc -g -O0 -o uaf uaf.c
#include <stdio.h>
#include <stdlib.h>

int main() {
    int *p = malloc(sizeof(int));
    *p = 42;
    printf("Before free: *p = %d\n", *p);

    free(p);
    // p still holds the old address, but the memory may be:
    // - returned to the allocator's free list
    // - unmapped entirely (for large allocations)
    // - reused by a later malloc

    *p = 99;  // UNDEFINED BEHAVIOR
    // Might segfault (if page was unmapped)
    // Might silently corrupt other data (if reused)
    // Might appear to "work" (if page still mapped but unused)
    printf("After free: *p = %d\n", *p);
    return 0;
}
    Before free(p):

    p ──────► ┌──────────────┐ 0x55a000 (heap)
              │    42        │ ◄── valid, allocated
              └──────────────┘

    After free(p):

    p ──────► ┌──────────────┐ 0x55a000 (heap)
              │  free list   │ ◄── returned to allocator
              │  metadata    │    (or possibly unmapped)
              └──────────────┘

    Writing *p = 99 here either:
    - Overwrites free-list metadata → heap corruption
    - Hits an unmapped page → SIGSEGV
    - Appears to work → ticking time bomb

The scariest part: use-after-free might not crash immediately. It might corrupt the heap silently and crash minutes later in a completely unrelated function. This is why use-after-free is the #1 source of security vulnerabilities in C/C++ code.

In Rust

fn main() {
    let p = Box::new(42);
    drop(p);    // Explicitly free
    // println!("{}", p);  // COMPILE ERROR: value used after move
    // The compiler will not let this happen. Period.
}
error[E0382]: borrow of moved value: `p`
 --> src/main.rs:4:20
  |
2 |     let p = Box::new(42);
  |         - move occurs because `p` has type `Box<i32>`
3 |     drop(p);
  |          - value moved here
4 |     println!("{}", p);
  |                    ^ value borrowed here after move

The borrow checker tracks ownership. After drop(p), the variable p is consumed. Any attempt to use it is a compile-time error. Not a runtime check. Not a sanitizer. The program never compiles.


Cause #5: Buffer overflow

In C

// overflow.c — compile: gcc -g -O0 -o overflow overflow.c
#include <stdio.h>

int main() {
    int arr[10];
    // Write way past the end of the array
    arr[1000000] = 42;  // 4 MB past the end — likely in unmapped space
    return 0;
}
    Stack layout:

    0x7FFFFFFFDE00 ┌────────────────────┐
                   │ arr[0] ... arr[9]  │  40 bytes (10 × 4)
    0x7FFFFFFFDE28 ├────────────────────┤
                   │ (other stack data) │
                   ├────────────────────┤
                   │        ...         │
                   │                    │
    0x7FFFFFF9DE00 │  arr[1000000]      │  ◄── 4 MB below arr
                   │  THIS IS UNMAPPED  │  ◄── SIGSEGV
                   └────────────────────┘

Small overflows (e.g., arr[10] or arr[20]) might NOT segfault — they silently overwrite adjacent stack data. This is how stack buffer overflows lead to arbitrary code execution. Only when you go far enough to land in an unmapped page does the hardware catch it.

In Rust

fn main() {
    let arr = [0i32; 10];
    let idx = 1_000_000;
    println!("{}", arr[idx]);  // Panic at runtime (bounds check)
}
thread 'main' panicked at 'index out of bounds: the len is 10 but the index is 1000000'

Rust inserts bounds checks on every array/slice access. The program panics with a clear message instead of corrupting memory. The panic is not a segfault — it's a controlled unwinding or abort.

🧠 What do you think happens?

In C, arr[11] = 42; when arr has 10 elements. Does it always segfault? Usually? Rarely? Why is the answer "it depends"?


Cause #6: Wild / uninitialized pointer

In C

// wild.c — compile: gcc -g -O0 -o wild wild.c
int main() {
    int *p;     // Uninitialized — contains whatever was on the stack
    *p = 42;    // Dereferences a garbage address
    return 0;
}
    p contains random stack data:

    p ──────► 0x??????????? (whatever bytes were on the stack)
                    │
                    ▼
              Could be:
              • 0x0000000000000000 → NULL deref → SIGSEGV
              • 0x00007FFF12340000 → might be mapped → silent corruption!
              • 0x0000DEADBEEF0000 → unmapped → SIGSEGV
              • 0xFFFF800000000000 → kernel space → SIGSEGV

    It's random. The behavior changes between runs, compilers,
    and optimization levels. This is undefined behavior.

In Rust

fn main() {
    let p: *mut i32;
    // unsafe { *p = 42; }  // COMPILE ERROR: use of possibly uninitialized `p`
    // Rust requires all variables to be initialized before use.
    // Even raw pointers must be given a value.
}
error[E0381]: used binding `p` isn't initialized
 --> src/main.rs:3:16
  |
2 |     let p: *mut i32;
  |         - binding declared here but left uninitialized
3 |     unsafe { *p = 42; }
  |                ^ `p` used here but it isn't initialized

Rust's compiler tracks initialization. You cannot use a variable — of any type, including raw pointers — until it has been assigned a value.


Summary: six causes at a glance

    Cause                  C behavior            Rust behavior
    ─────────────────────  ────────────────────   ─────────────────────────
    1. NULL deref          SIGSEGV               Option<T> — compile error
    2. Stack overflow      SIGSEGV (guard page)  Detected, clear message
    3. Write to rodata     SIGSEGV               &str is immutable — compile error
    4. Use-after-free      SIGSEGV or corruption Borrow checker — compile error
    5. Buffer overflow     SIGSEGV or corruption Bounds check — panic
    6. Wild pointer        SIGSEGV or corruption Must initialize — compile error

Rust eliminates four of these at compile time, detects one at runtime with a clear message, and handles the last (stack overflow) with a runtime check. In safe Rust, segfaults from your code are essentially impossible.


Debugging segfaults

1. dmesg — what the kernel saw

$ dmesg | tail -3
[12345.678] crash[9876]: segfault at 0 ip 00005555555551a2
    sp 00007fffffffde10 error 6 in crash[555555555000+1000]

Fields:

  • at 0: the faulting virtual address (0 = NULL)
  • ip 00005555555551a2: instruction pointer — what instruction caused the fault
  • error 6: error code bits (6 = user-mode write to non-present page)

Error code bits:

    Bit 0: 0=non-present page, 1=protection violation
    Bit 1: 0=read, 1=write
    Bit 2: 0=kernel mode, 1=user mode
    Bit 3: 1=reserved bit violation
    Bit 4: 1=instruction fetch (NX violation)

    Error 6 = 0b110 = user-mode(1) + write(1) + non-present(0)
    Error 4 = 0b100 = user-mode(1) + read(0) + non-present(0)
    Error 7 = 0b111 = user-mode(1) + write(1) + protection(1)

2. Core dumps

$ ulimit -c unlimited       # Enable core dumps
$ ./crash
Segmentation fault (core dumped)
$ gdb ./crash core
(gdb) bt                    # Backtrace — where exactly it crashed
#0  0x00005555555551a2 in main () at crash.c:5
(gdb) info registers        # CPU state at crash time
(gdb) print p               # The guilty pointer
$1 = (int *) 0x0

3. addr2line

$ addr2line -e crash 0x00005555555551a2
/home/user/crash.c:5

Converts an instruction address to a source file and line number. Requires -g (debug info) during compilation.

4. AddressSanitizer (the nuclear option)

$ gcc -g -fsanitize=address -o crash crash.c
$ ./crash
=================================================================
==9876==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000000
    #0 0x555555555192 in main /home/user/crash.c:5

ASan catches things the kernel can't — like small buffer overflows that stay within mapped pages. It adds ~2x memory overhead and ~2x slowdown, but it catches almost everything.


🔧 Task: Trigger and debug all six types

Create six C programs, one for each cause. For each one:

  1. Compile with gcc -g -O0
  2. Run it, confirm the segfault
  3. Check dmesg | tail -1 for the kernel's report
  4. Run under GDB:
    $ gdb ./program
    (gdb) run
    (gdb) bt
    (gdb) info registers
    (gdb) print <the pointer variable>
    
  5. Note the faulting address and error code
// 1_null.c
int main() { *(int *)0 = 42; return 0; }

// 2_stack.c
void f() { f(); }
int main() { f(); return 0; }

// 3_rodata.c
int main() { char *s = "hello"; s[0] = 'H'; return 0; }

// 4_uaf.c
#include <stdlib.h>
int main() { int *p = malloc(4); free(p); *p = 42; return 0; }

// 5_overflow.c
int main() { int a[10]; a[1000000] = 42; return 0; }

// 6_wild.c
int main() { int *p; *p = 42; return 0; }

Bonus: Compile each with -fsanitize=address and compare the output. ASan gives far more detail than a raw segfault.

Bonus 2: Write the Rust equivalent of each. See which ones the compiler refuses to compile and read the error messages carefully — they're telling you exactly what C doesn't.