Skip to content

Rust Unsafe, Raw Pointers and FFI

Unsafe Rust does not disable ownership or make undefined behaviour acceptable. It permits specific operations whose safety obligations must be justified by the implementation and its callers. This lesson uses narrow examples with explicit contracts, then completes Part III with a worker that needs no unsafe code at all.

Learning goals and prerequisites

Use ownership and borrowing, resource cleanup, thread shutdown and edition configuration to explain why a boundary is safe. You will distinguish unsafe functions from unsafe blocks, trait implementation obligations from ordinary traits, and layout from a complete foreign-interface contract.

Use stable Rust, Cargo and edition 2024. No external libraries, GPIO or system settings are needed. Our C-ABI call links two declarations in one Rust executable; it does not claim to test a separately compiled C library.

Rust unsafe: permission is not proof

The unsafe operation list includes raw-pointer dereference, unsafe function calls, union field reads and unsafe trait implementations. Ordinary creation or comparison of a raw pointer is not the same as dereferencing it. Borrow checking and ordinary type checking still operate inside an unsafe block.

An unsafe fn states a requirement its caller must uphold. A small unsafe block identifies where the implementation relies on a justified requirement. The edition-2024 unsafe-operation lint helps separate these roles; our crate denies it so an unsafe fn body cannot silently omit the explicit block.

Run the complete dependency-free program

1
2
3
4
5
6
7
8
9
cargo new rust-unsafe-boundaries --edition 2024
cd rust-unsafe-boundaries
# Replace src/main.rs with the complete program below.
cargo fmt --check
cargo check --offline
cargo test --offline
cargo test --offline --release
cargo run --offline --quiet
cargo run --offline --quiet --release
#![deny(unsafe_op_in_unsafe_fn)]

use std::ffi::c_int;
use std::sync::mpsc;
use std::thread;

/// Read a single copied integer through a raw pointer.
///
/// # Safety
/// The pointer must be derived from storage containing a live, initialized,
/// aligned i32, readable for this call, with no conflicting mutation.
unsafe fn read_one(pointer: *const i32) -> i32 {
    // SAFETY: the caller must establish the documented pointer contract.
    unsafe { *pointer }
}

fn first_reading(values: &[i32]) -> Option<i32> {
    if values.is_empty() {
        return None;
    }
    // SAFETY: a nonempty shared i32 slice provides an initialized, aligned
    // first element; the borrow keeps its storage alive and prevents writes.
    Some(unsafe { read_one(values.as_ptr()) })
}

/// A type whose all-zero representation is a valid, initialized value.
///
/// # Safety
/// Implementers must guarantee that zero initialization produces a valid Self.
unsafe trait ZeroValid: Copy {}

// SAFETY: every i32 bit pattern is valid, including all-zero.
unsafe impl ZeroValid for i32 {}

fn zero_value<T: ZeroValid>() -> T {
    // SAFETY: ZeroValid's implementation contract guarantees validity.
    unsafe { std::mem::zeroed() }
}

#[repr(C)]
union Bits {
    signed: i32,
    unsigned: u32,
}

fn unsigned_bits(value: i32) -> u32 {
    let bits = Bits { signed: value };
    // SAFETY: i32/u32 have matching size/alignment and all bit patterns are
    // valid integers. The written field initializes the entire u32 storage.
    unsafe { bits.unsigned }
}

#[repr(C)]
struct WireReading {
    value: i32,
    present: u8,
}

mod exported {
    use std::ffi::c_int;

    // SAFETY: this uniquely named symbol is defined exactly once in this
    // teaching executable; its matching declaration uses the same C ABI.
    #[unsafe(no_mangle)]
    pub extern "C" fn rust_course_26_double(value: c_int) -> c_int {
        value.saturating_mul(2)
    }
}

unsafe extern "C" {
    #[link_name = "rust_course_26_double"]
    fn double_from_c_abi(value: c_int) -> c_int;
}

fn ffi_double(value: c_int) -> c_int {
    // SAFETY: the symbol above has this exact ABI/signature, accepts every
    // c_int, retains no pointers and neither panics nor unwinds.
    unsafe { double_from_c_abi(value) }
}

fn worker_report(input: &[i32]) -> Result<i64, &'static str> {
    thread::scope(|scope| {
        let (sender, receiver) = mpsc::sync_channel(1);
        let worker = scope.spawn(move || {
            let result = input.iter().try_fold(0_i64, |sum, &value| {
                if !(-100_000..=150_000).contains(&value) {
                    return Err("invalid fixture");
                }
                sum.checked_add(i64::from(value)).ok_or("sum overflow")
            });
            sender.send(result)
        });
        // Receive before joining: also works with a zero-capacity channel.
        let received = receiver.recv();
        let joined = worker.join();
        match joined {
            Ok(Ok(())) => {}
            Ok(Err(_)) => return Err("receiver disconnected"),
            Err(_) => return Err("worker panicked"),
        }
        received.map_err(|_| "no report")?
    })
}

fn main() {
    println!(
        "first={:?}, empty={:?}",
        first_reading(&[46_700]),
        first_reading(&[])
    );
    println!("zero={}, bits={}", zero_value::<i32>(), unsigned_bits(-1));
    println!("C ABI double={}", ffi_double(-500));
    println!(
        "wire size={}, align={}, present offset={}",
        std::mem::size_of::<WireReading>(),
        std::mem::align_of::<WireReading>(),
        std::mem::offset_of!(WireReading, present)
    );
    let input = [46_700, 0, -500];
    println!(
        "worker={:?}, retained={}",
        worker_report(&input),
        input.len()
    );
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn safe_pointer_wrapper_handles_empty_zero_and_negative() {
        assert_eq!(first_reading(&[]), None);
        assert_eq!(first_reading(&[0, 46_700]), Some(0));
        assert_eq!(first_reading(&[-500]), Some(-500));
    }

    #[test]
    fn pointer_wrapper_agrees_with_safe_slice_access() {
        for fixture in [vec![], vec![0], vec![-500, 46_700]] {
            assert_eq!(first_reading(&fixture), fixture.first().copied());
        }
    }

    #[test]
    fn only_promised_zero_representation_is_used() {
        assert_eq!(zero_value::<i32>(), 0);
    }

    #[test]
    fn union_integer_bits_agree_with_numeric_cast() {
        for value in [i32::MIN, -1, 0, i32::MAX] {
            assert_eq!(unsigned_bits(value), value as u32);
        }
    }

    #[test]
    fn c_abi_boundary_has_explicit_overflow_policy() {
        assert_eq!(ffi_double(0), 0);
        assert_eq!(ffi_double(-500), -1000);
        assert_eq!(ffi_double(c_int::MAX), c_int::MAX);
        assert_eq!(ffi_double(c_int::MIN), c_int::MIN);
    }

    #[test]
    fn c_layout_has_nonoverlapping_fields_and_tail_padding() {
        assert_eq!(std::mem::offset_of!(WireReading, value), 0);
        assert_eq!(std::mem::offset_of!(WireReading, present), 4);
        assert_eq!(std::mem::size_of::<WireReading>(), 8);
        assert_eq!(std::mem::align_of::<WireReading>(), 4);
    }

    #[test]
    fn worker_returns_sum_only_after_joining() {
        let input = [46_700, 0, -500];
        assert_eq!(worker_report(&input), Ok(46_200));
        assert_eq!(input, [46_700, 0, -500]);
    }

    #[test]
    fn worker_reports_invalid_input_instead_of_partial_success() {
        assert_eq!(worker_report(&[46_700, 150_001]), Err("invalid fixture"));
    }

    #[test]
    fn empty_worker_input_is_a_completed_zero_sum() {
        assert_eq!(worker_report(&[]), Ok(0));
    }
}

Expected output on the verified aarch64 Linux target:

1
2
3
4
5
first=Some(46700), empty=None
zero=0, bits=4294967295
C ABI double=-1000
wire size=8, align=4, present offset=4
worker=Ok(46200), retained=3

Audit the safe wrapper, not only its test results

first_reading accepts a slice, not an arbitrary pointer supplied by safe code. It rejects empty input before the read; the remaining pointer originates in a live shared slice. The wrapper returns a copied integer, so no borrowed pointer escapes. The unsafe function separately states what an unsafe caller must establish.

For production code, values.first().copied() is preferable here. Similarly, zero_value::() can simply be 0 and unsigned_bits can be a numeric cast. These unsafe examples demonstrate obligations, not speed improvements or reasons to replace safe standard APIs. mem::zeroed does not promise validity for arbitrary T: a reference or function pointer cannot be created by assuming a zero pattern is valid. An incorrect unsafe trait implementation could make a safe generic wrapper unsound; we implement only i32.

Union reads require validity, not a tracked active variant

Rust unions share field storage without a discriminant. Writing a field is different from reading one: the read must produce a valid value of its chosen type. The integer fields above occupy the same-sized initialized storage and permit all bit patterns. This argument does not generalise to bool, references or arbitrary structs. Use enums for ordinary tagged states rather than forcing callers to track a union's interpretation manually.

FFI needs layout, ABI, ownership and error policy

The layout Reference explains repr(C), alignment and padding. repr(C) does not turn String, Vec, references or trait objects into a universal C data format. Our present byte would need an agreed interpretation such as 0 or 1 at an actual foreign boundary; the struct itself does not enforce that logical policy. Do not serialize its entire memory, including padding, as a portable wire format.

The external-block rules describe the declared ABI and foreign items. A declaration is a promise, not verification of a library implementation. Our safe wrapper relies on the matching symbol defined in the same program and uses c_int for C's int. It crosses the C ABI, but no external C compiler or third-party library was tested. An actual FFI interface must also specify valid pointers/lengths, allocation and freeing responsibilities, retention, threads, errors and unwinding.

The unsafe-attribute rules require explicit acknowledgement for no_mangle in edition 2024. A symbol collision can affect safety outside the function body; our teaching symbol must remain unique. extern "C" is not an instruction to permit Rust panic unwinding through arbitrary C frames. This function uses saturating arithmetic and has no panic path; a broader exported API needs a considered error/unwind boundary.

Undefined behaviour is not an expected failing exercise

The undefined-behaviour Reference covers invalid values, data races, dangling/misaligned access, ABI violations and aliasing obligations. Its list is not a complete formal model. Unsafe code must remain valid for every safe caller of its API, not only the fixture that happened to run.

Do not test a null dereference, a dangling reference, an invalid union value or double free by running it and waiting for a crash. Undefined behaviour may appear to succeed and can affect the entire program. The failures below are compiler diagnostics; runtime tests use only valid inputs. Miri can be an additional tool for suitable Rust code, but is not a proof and does not generally validate arbitrary external FFI; this lesson does not claim a Miri run.

Deliberately failing: unsafe fn still needs an unsafe block

In a separate scratch project:

1
2
3
4
5
6
7
#![deny(unsafe_op_in_unsafe_fn)]

unsafe fn read_one(pointer: *const i32) -> i32 {
    *pointer
}

fn main() {}

cargo check reports E0133 under our lint policy. Repair with a narrow unsafe block and preserve the pointer safety contract, or replace the operation with a safe slice API. Do not add allow merely to remove the diagnostic.

Part III checkpoint: defend ownership and shutdown

worker_report is intentionally safe Rust. Explain this sequence before changing it:

  1. The calling thread retains the input; a scoped worker borrows its immutable slice.
  2. The worker owns its sender and produces an owned Result, not a borrowed pointer to worker-local data.
  3. The receiver waits before join, allowing a rendezvous channel to make progress too.
  4. The function records both receive and join outcomes before propagating an error. A received message alone is not a substitute for successful worker completion.
  5. Invalid fixture input rejects the entire report, rather than inventing a zero or returning a partial success.

The lexical scope prevents its worker borrow escaping. Explicit join lets us inspect the panic outcome; scoped threads also enforce completion on scope exit. Capacity one bounds this one-message channel, not all possible resource use. No async runtime is required, and adding unsafe would not repair a lifecycle or error-policy mistake.

Checkpoint tasks: change channel capacity to zero and four, add an invalid reading after a valid one, and confirm the empty input policy. Explain what would change if the worker retained owned input instead. For multiple workers, inspect every join outcome even if an earlier worker fails; cancellation requires an explicit protocol rather than merely dropping a result handle. Revisit lesson 21 for Stop versus sender disconnection, and lesson 22 for why dropping a future is not universal rollback.

Exercises and troubleshooting

  1. Replace first_reading's implementation with values.first().copied(). The same tests and output must pass without unsafe code in that wrapper.
  2. Remove the unsafe block around the union read. Expect E0133. Explain validity before restoring it.
  3. Remove unsafe from the trait implementation. Expect E0200: implementing an unsafe trait is an explicit obligation, not inference from Copy.
  4. Remove unsafe from the extern block, or use #[no_mangle] without its unsafe wrapper. Edition 2024 rejects these declarations. Do not assume older syntax means the safety obligation disappeared.
  5. Make a repr(C, packed) struct with a u8 followed by i32 and try to borrow the i32 field. Expect E0793. Copying a field or using an appropriately justified unaligned raw-pointer operation is different from creating an aligned reference; do not execute a misaligned reference workaround.
  6. Inspect every SAFETY comment. Can a safe caller violate any premise through the exposed API? A passing test does not replace that argument.

Verification and next step

On October 10, 2026, this lesson was verified on the authorised Raspberry Pi 4B with 64-bit user space, kernel 6.18.50+rpt-rpi-v8, Rust and Cargo 1.99.0, and edition 2024. Checks, nine debug/release tests, formatting, debug/release outputs and five further comparisons passed. The safe slice repair and capacity-zero/four checkpoint variants passed their tests and outputs. Six compile failures checked missing unsafe-operation blocks, unsafe trait implementation, union access, extern declaration, unsafe attributes and packed references. No undefined-behaviour example was executed, no external C library or Miri run was tested, and these checks do not establish soundness for arbitrary unsafe extensions.

Continue with files, arguments and a Linux CLI: validate input and expose a predictable process contract without unnecessary unsafe code.

Previous: configuration and editions · Course overview

Donate