Ever tried to run a Rust program and it just crashes with Segmentation fault (core dumped)? Yeah, that’s the worst. I’ve stared at a green terminal, heart racing, thinking the compiler was at fault, only to discover an unsafe block was the culprit. Let’s break this down and arm you with the tricks I learned from late‑night debugging sessions.
What Is a Segmentation Fault?
Segfaults happen when a program touches memory it shouldn’t. Think of your operating system as a guard at the gate. If you try to walk through a locked door, the guard yells “No!” and the program dies. In C and Rust’s unsafe world, this guard is the memory protection unit.
// Wrong: Dereferencing an uninitialized raw pointer
fn main() {
let ptr: *const i32 = std::ptr::null(); // Null pointer
unsafe {
println!("{}", *ptr); // Crash!
}
}
The compiler happily compiles this, but at runtime the OS sees you trying to read from address 0x0 and throws a segfault. The fix? Don’t dereference null.
fn main() {
let ptr: Option<&i32> = None;
if let Some(val) = ptr {
println!("{}", val);
} else {
println!("Got a None, safe!");
}
}
Here the null check is explicit, and the compiler guarantees safety. Notice how the unsafe keyword vanished.
Why Rust Still Encounters Segfaults in Unsafe Blocks
Rust’s safety guarantees are baked into its type system and borrow checker. But when you drop the safety net with unsafe, you’re back in the land of raw pointers, manual memory management, and bit‑twiddling. The compiler can’t reason about what you’re doing inside that block, so if you slip, the OS will kill you.
// Wrong: Pointer arithmetic that goes out of bounds
fn main() {
let arr = [1, 2, 3];
unsafe {
let ptr = arr.as_ptr().add(5); // OOB
println!("{}", *ptr);
}
}
Here you’re adding 5 to the pointer, which lands you outside the array’s memory. The OS catches that and throws a segfault. The right way is to keep all pointer math inside bounds.
fn main() {
let arr = [1, 2, 3];
unsafe {
let ptr = arr.as_ptr();
if let Some(val) = ptr.add(2).as_ref() {
println!("{}", val); // Safe after check
}
}
}
Now you explicitly verify the pointer is still within the array. That extra check stops the segfault.
Common Causes of Segfaults in Unsafe Rust
Let’s run through the usual suspects. I’ve seen every one of these in production.
Null or dangling pointers
A pointer that points to freed memory or never got initialized.Buffer overrun
Writing past the end of an array or slice.Misaligned pointers
Dereferencing a pointer that isn’t properly aligned for the type.Double free
Freeing the same allocation twice.Incorrect slice length
Constructing a slice that claims to contain more elements than the backing data.
Each of these can trigger a segfault. Below is a quick illustration for two of them.
// Wrong: Dangling pointer after freeing
fn main() {
let b = Box::new(10);
let ptr = &*b as *const i32;
drop(b); // Memory freed
unsafe {
println!("{}", *ptr); // Segfault!
}
}
// Wrong: Buffer overrun via Vec
fn main() {
let mut v = vec![0; 3];
unsafe {
let ptr = v.as_mut_ptr();
for i in 0..5 { // Goes beyond v.len()
*ptr.add(i) = i as i32;
}
}
}
// Right: Use Vec bounds check
fn main() {
let mut v = vec![0; 3];
for i in 0..v.len() {
v[i] = i as i32;
}
}
Diagnosing the Fault: Tools & Techniques
When a segfault hits, the first thing you need is a stack trace. Rust ships a built‑in backtrace, but sometimes you need more. Here’s how I usually debug:
Compile with debug symbols
cargo build --debug(default).Run with
RUST_BACKTRACE=2RUST_BACKTRACE=2 cargo runprints a Rust‑style trace.Use
gdborlldb
Load the binary and runrun. When it crashes,btgives a C stack trace.addr2linefor demanglingaddr2line -f -C -e target/debug/your_bin 0xdeadbeefshows source lines.Valgrind for memory errors (on Linux)
valgrind --leak-check=full ./your_binhunts leaks and invalid accesses.Rust’s
cargo checkwith--tests
Detects many unsafe misuse patterns early.
Example: A segfault from a dangling pointer:
$ RUST_BACKTRACE=2 cargo run
thread 'main' panicked at 'attempt to access invalid memory', src/main.rs:12:13
stack backtrace:
#0 0x0000000000401d7b in main (src/main.rs:12:13)
#1 0x00007ffff7a1b3c8 in __libc_start_main
gdb reveals the exact address that caused the fault. Combine that with addr2line, and you get the line number.
Real‑World Example: Misusing Raw Pointers
What NOT to do
I once wrote a small image‑processing crate that used a raw pointer to a pixel buffer. The code looked neat, but the pointer wasn’t initialized correctly.
// Wrong
fn apply_filter(buffer: *mut u8, width: usize, height: usize) {
unsafe {
let ptr = buffer;
for y in 0..height {
for x in 0..width {
let idx = y * width + x;
*ptr.add(idx) = 255; // Crash if buffer is null
}
}
}
}
If buffer was null or smaller than width * height, the dereference would segfault. I’d run into this on CI when the test harness passed a smaller buffer.
The fix
Wrap the raw pointer in a Vec or Box and perform bounds checks.
fn apply_filter(buffer: &mut [u8]) {
let len = buffer.len();
for idx in 0..len {
buffer[idx] = 255; // Safe, compiler ensures bounds
}
}
If you must stay with raw pointers, always validate before use:
fn apply_filter(buffer: *mut u8, width: usize, height: usize) {
unsafe {
if buffer.is_null() || width * height > 10_000 {
eprintln!("Invalid buffer");
return;
}
for y in 0..height {
for x in 0..width {
let idx = y * width + x;
*buffer.add(idx) = 255;
}
}
}
}
Now the code will refuse to run with bad data instead of crashing.
Real‑World Example: Incorrect Slice Length
I once built a network protocol that parsed a header and expected a payload length. I sliced the buffer with bytes[header_len..header_len + payload_len] without checking that the slice didn’t exceed the buffer.
// Wrong
let payload = &bytes[header_len..header_len + payload_len]; // OOB
When a malformed packet arrived, the program crashed.
// Right
if header_len + payload_len <= bytes.len() {
let payload = &bytes[header_len..header_len + payload_len];
// Process payload
} else {
eprintln!("Packet too short");
}
Using get or split_at protects against overruns. In fact, I used split_at in a recent project to safely split a stream into header and body. The code looked like this:
let (header, body) = match bytes.split_at(header_len) {
Ok((h, b)) if h.len() == header_len => (h, b),
_ => { eprintln!("Invalid header"); return; }
};
Now the compiler guarantees the split is safe.
Solid Approach to Prevent Segfaults
Keep unsafe blocks tiny: 1–2 lines.
Document every unsafe block: Who wrote it, why it’s needed.
Write tests that exercise unsafe code: Use
#[test]functions and run them withcargo test.Prefer safe wrappers: If you need raw pointers, expose a safe API that hides them.
Use
#[allow(unsafe_code)]sparingly: Only at the module level if you’re sure.
Example of a safe wrapper:
pub struct RawBuffer {
ptr: *mut u8,
len: usize,
}
impl RawBuffer {
pub unsafe fn new(ptr: *mut u8, len: usize) -> Self {
RawBuffer { ptr, len }
}
pub fn get(&self, idx: usize) -> Option<u8> {
if idx < self.len {
unsafe { Some(*self.ptr.add(idx)) }
} else {
None
}
}
}
The rest of the crate can call get safely, and the only unsafe code lives in a single, well‑reviewed spot.
When to Use Unsafe and How to Contain Risks
Sometimes you have no choice. FFI calls, low‑latency kernels, or interacting with hardware may force you into unsafe territory. Here’s what I do:
Isolate: Put all unsafe code in a single module, named
unsafe_impl.Document: Add comments explaining preconditions and postconditions.
Test: Write property‑based tests with
proptestto cover edge cases.Lint: Run
cargo clippy -- -D warningsto catch misuse.Audit: Review unsafe blocks with a teammate before merging.
I once wrote a cross‑platform audio driver that used a C library. The unsafe code was wrapped in a module named ffi. The rest of the crate used safe Rust exclusively. That design made the code maintainable and let us ship updates without fearing accidental segfaults.
Quick Summary
Segfaults are OS-level memory violations.
Rust’s safety is only as strong as the parts you mark as
unsafe.Common unsafe pitfalls: null pointers, OOB access, dangling pointers, misaligned pointers.
Use tools: backtrace, gdb, addr2line, valgrind.
Keep unsafe blocks small, documented, and test‑covered.
Wrap unsafe in safe abstractions whenever possible.
I’ve spent dozens of hours chasing segfaults; the key is to treat unsafe like a hazardous material. Keep it in a sealed container, label it clearly, and never expose it to the rest of your codebase.
FAQs
Why does Rust still allow segmentation faults?
Becauseunsafetells the compiler, “I know what I’m doing.” The runtime can’t guard you if you break memory rules.How do I catch segfaults before shipping?
Compile with debug symbols, run tests, usevalgrindorASAN(Address Sanitizer) on Linux.Is it safe to use raw pointers for performance?
Only if you’re absolutely sure about alignment, lifetime, and bounds. Prefer safe abstractions unless profiling shows




