RustSystems Programming

Unsafe Rust: Unlocking Raw Pointers and FFI Integration

TT
TopicTrick Team
Unsafe Rust: Unlocking Raw Pointers and FFI Integration

Unsafe Rust: Unlocking Raw Pointers and FFI Integration

For the entirety of this curriculum, we have celebrated the strict, unforgiving nature of the Rust Compiler. The Borrow Checker aggressively guarantees that variables do not outlive their references, that data is clean of race conditions, and that memory is properly managed without a GC.

But what happens when the compiler's static analysis engine is simply too strict?

There are times when you, as the engineer, absolutely know that a certain memory action is mathematically safe, but the compiler cannot figure it out mathematically and refuses to compile your code. Or, more commonly, what if you are writing an Operating System driver that must inherently interact with raw, uninitialized hardware memory mapped to physical addresses?

For these scenarios, Rust provides an escape hatch: Unsafe Rust.


1. The unsafe Keyword

Unsafe Rust is not a different language. It is simply a mode activated by using the unsafe keyword.

Entering an unsafe block does not turn off the borrow checker entirely! You still cannot bypass standard lifetime constraints or ownership rules on standard structs.

Instead, utilizing an unsafe block grants you exactly five superpowers:

  1. Dereference a Raw Pointer
  2. Call an unsafe function or method
  3. Access or modify a mutable static variable
  4. Implement an unsafe trait
  5. Access fields of a union

If a vulnerability exists in your application, you do not have to search one million lines of code; you only have to audit the lines specifically contained within the explicit unsafe blocks.


2. Dereferencing Raw Pointers

Standard Rust references (&T and &mut T) are guaranteed to always point to valid data.

Raw Pointers (*const T and *mut T) make zero such guarantees. They look and behave identical to pointers in C/C++.

  • They are arbitrarily allowed to be null.
  • They might point to completely uninitialized garbage memory.
  • They completely bypass the Borrow Checker's "Single Writer / Multiple Reader" restrictions, meaning you can have two *mut T pointers looking at the same data simultaneously, allowing Data Races.

A critical point: You can create a raw pointer in perfectly safe Rust. The danger (the unsafe action) only occurs when you attempt to dereference the pointer to physically read or write the actual underlying data.

rust
fn main() {
    let mut num = 5;

    // Creating raw pointers from references. This is 100% Safe.
    let r1 = &num as *const i32; // Raw immutable pointer
    let r2 = &mut num as *mut i32; // Raw mutable pointer

    // We can even create a raw pointer to an explicit, arbitrary hardware memory address!
    let arbitrary_address = 0x012345usize;
    let r3 = arbitrary_address as *const i32;

    // If we try to dereference them down here outside a block: ERROR.
    // println!("r1 is: {}", *r1);

    // We MUST use an unsafe block to read the data physical data!
    unsafe {
        println!("r1 is: {}", *r1);
        println!("r2 is: {}", *r2);
        
        // This next line would likely trigger a Segmentation Fault instantly
        // because 0x012345 is not mapped to valid RAM on our OS!
        // println!("r3 is: {}", *r3);
    }
}

The primary reason to dereference raw pointers is when interfacing with C code, or when writing hyper-optimized data structures (like a doubly-linked list or the Vec<T> module itself) where the raw algorithmic manipulation is too complex for the compile-time Borrow Checker to mathematically verify as safe.


3. Foreign Function Interfaces (FFI)

The most unavoidable trigger for unsafe Rust is communicating with code written in another language.

If your company possesses a million-line proprietary cryptographic library written perfectly in C++, you do not rewrite it from scratch in Rust. You simply invoke the binary via the Foreign Function Interface (FFI).

Because the Rust compiler cannot read the C++ source code, it cannot guarantee that the C++ bindings adhere to Rust's memory safety rules. The C function might crash the heap, double-free a pointer, or induce a race condition. Therefore, calling any foreign function is instantly classified as an unsafe action.

rust
// We define an 'extern' block to declare C functions we want to call.
// In this case, the `abs` (absolute value) function from the C Standard Math Library.
extern "C" {
    fn abs(input: i32) -> i32;
}

fn main() {
    // Because `abs` is foreign C code, we must evaluate it inside an Unsafe block.
    unsafe {
        println!("Absolute value of -3 according to C: {}", abs(-3));
    }
}

When you define an extern "C" block, you are telling the Rust compiler to use the "C Application Binary Interface (ABI)"—the machine code structural standard at the assembly level that C compilers (like GCC or Clang) use, ensuring the data aligns perfectly in memory.

Writing Rust for C to call

You can also do the inverse! You can write blazing fast, safe Rust code and export it so that a legacy Python, Java, or C system can call it safely as a Shared Memory Library. You must disable the Rust namespace mangler using #[no_mangle] so the C-compiler can locate the function name in the .dll or .so binary.

rust
// Rust function designed to be executed by a Python or C program natively!
#[no_mangle]
pub extern "C" fn call_from_c() {
    println!("Just called a perfectly safe Rust function from C!");
}

4. Mutable Statics and Unsafe Traits

The final two superpowers deal with global state and interface validation.

Global Mutable Statics

Earlier, we discussed const values. Constants cannot change. You can also define Global variables using the static keyword, and you can make those variables mut.

But if you have a global, mutable variable in a concurrent web-server, what happens if two threads try to overwrite it simultaneously? A Data Race.

Therefore, Rust enforces that merely reading or writing to a static mut variable is completely unsafe and requires a block validation. (In modern Rust 2026 architecture, using Arc<Mutex<T>> or the OnceLock initialization pattern is vastly preferred over static variables).

Unsafe Traits

Remember the Send and Sync concurrency traits? The compiler implemented those entirely automatically for us on our Structs if it detected that our variables passed its safety checks.

But what if you used Raw Pointers inside your custom struct?

Because raw pointers bypass the borrow checker, the compiler cannot automatically verify your struct is thread-safe. It strips the Send and Sync traits. If you, the developer, have mathematically audited your code and realize that you manually handled the locking behavior inside your custom struct, you can explicitly force the compiler to accept the struct as thread-safe by implementing an Unsafe Trait.

rust
unsafe impl Send for SuperAdvancedCustomType {}
unsafe impl Sync for SuperAdvancedCustomType {}

By doing this, you take ultimate responsibility. If a thread crashes due to a synchronization error, the fault lies squarely on the human author, not the compiler.

Safe RustUnsafe Rust
Memory DereferencingValidated via Borrow Checker. Structurally immune to Segfaults.Raw pointer unrolling. Directly exposes application to Segmentation Faults.
Scope Use-Case99.9% of application business logic and backend operations.0.1% wrapper layer. Strictly limited to FFI logic and critical IO Drivers.
Audit DifficultyZero. If it compiles, it mathematically executes as typed.Extreme. Requires manual C-level tracking of OS Heap boundaries.

Summary and Next Steps

The term "Unsafe" is incredibly misleading. It does not mean the code is dangerous, structurally flawed, or incorrect. It simply means: "The compiler is stepping aside. The Engineer is now personally verifying the memory boundaries."

The vast majority of systems programmers will never need to write an unsafe block directly. The true power of Unsafe Rust lies in building Safe Abstractions. By isolating raw pointer logic deep inside library modules (like Vec<T>), you can expose a perfectly safe, generic API outwards.

As we approach the end of the language curriculum, we must verify that our safe abstractions—and our unsafe FFI bindings—operate reliably without regression. In the next module, we deploy the native testing apparatus, executing localized assert functions and deep integration suites automatically.

Read next: Rust Testing: Unit, Integration, and Documentation Tests →



Quick Knowledge Check

*Why does creating a raw pointer (*const T) NOT require an 'unsafe' block, but dereferencing that same pointer (r1) completely demands one?

  1. Because raw pointers are implicitly converted back into Borrow Checked references as soon as they are dereferenced, triggering the compiler verification engine.
  2. Because simply creating an integer representing a memory address poses absolutely no danger. The danger (e.g., reading garbage data or causing a segfault) only physically occurs when the CPU attempts to extract the requested location. ✓
  3. Because the 'unsafe' keyword only activates compilation on Nightly toolchains, and raw pointer creation was stabilized in the 2026 Edition.
  4. It is a syntactic error. Creating a raw pointer absolutely requires an unsafe block.

Explanation: Holding a memory address (a pointer) is harmless. It is just an integer describing a location. However, traversing that pointer to extract the data (dereferencing) forces the OS to evaluate if that memory is valid. Since the compiler cannot prove validity, the operation demands human 'unsafe' verification.