Unsafe Rust
What you'll learn: What
unsafepermits (raw pointers, FFI, unchecked casts), safe wrapper patterns, C# P/Invoke vs Rust FFI for calling native code, and the safety checklist forunsafeblocks.Difficulty: 🔴 Advanced
Unsafe Rust allows you to perform operations that the borrow checker cannot verify. Use it sparingly and with clear documentation.
Advanced coverage: For safe abstraction patterns over unsafe code (arena allocators, lock-free structures, custom vtables), see Rust Patterns.
When You Need Unsafe
// 1. Dereferencing raw pointers
let mut value = 42;
let ptr = &mut value as *mut i32;
unsafe {
*ptr = 100; // Must be in unsafe block
}
// 2. Calling unsafe functions
unsafe fn dangerous() {
// Internal implementation that requires caller to maintain invariants
}
unsafe {
dangerous(); // Caller takes responsibility
}
// 3. Accessing mutable static variables
static mut COUNTER: u32 = 0;
unsafe {
COUNTER += 1; // Not thread-safe — caller must ensure synchronization
}
// 4. Implementing unsafe traits
unsafe trait UnsafeTrait {
fn do_something(&self);
}
C# Comparison: unsafe Keyword
// C# unsafe - similar concept, different scope
unsafe void UnsafeExample()
{
int value = 42;
int* ptr = &value;
*ptr = 100;
// C# unsafe is about pointer arithmetic
// Rust unsafe is about ownership/borrow rule relaxation
}
// C# fixed - pinning managed objects
unsafe void PinnedExample()
{
byte[] buffer = new byte[100];
fixed (byte* ptr = buffer)
{
// ptr is valid only within this block
}
}
Safe Wrappers
/// The key pattern: wrap unsafe code in a safe API
pub struct SafeBuffer {
data: Vec<u8>,
}
impl SafeBuffer {
pub fn new(size: usize) -> Self {
SafeBuffer { data: vec![0; size] }
}
/// Safe API — bounds-checked access
pub fn get(&self, index: usize) -> Option<u8> {
self.data.get(index).copied()
}
/// Fast unchecked access — unsafe but wrapped safely with bounds check
pub fn get_unchecked_safe(&self, index: usize) -> Option<u8> {
if index < self.data.len() {
// SAFETY: we just checked that index is in bounds
Some(unsafe { *self.data.get_unchecked(index) })
} else {
None
}
}
}
Interop with C# via FFI
Rust can expose C-compatible functions that C# can call via P/Invoke.
graph LR
subgraph "C# Process"
CS["C# Code"] -->|"P/Invoke"| MI["Marshal Layer\nUTF-16 → UTF-8\nstruct layout"]
end
MI -->|"C ABI call"| FFI["FFI Boundary"]
subgraph "Rust cdylib (.so / .dll)"
FFI --> RF["extern \"C\" fn\n#[no_mangle]"]
RF --> Safe["Safe Rust\ninternals"]
end
style FFI fill:#fff9c4,color:#000
style MI fill:#bbdefb,color:#000
style Safe fill:#c8e6c9,color:#000
Rust Library (compiled as cdylib)
// src/lib.rs
#[no_mangle]
pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 {
a + b
}
#[no_mangle]
pub extern "C" fn process_string(input: *const std::os::raw::c_char) -> i32 {
let c_str = unsafe {
if input.is_null() {
return -1;
}
std::ffi::CStr::from_ptr(input)
};
match c_str.to_str() {
Ok(s) => s.len() as i32,
Err(_) => -1,
}
}
# Cargo.toml
[lib]
crate-type = ["cdylib"]
C# Consumer (P/Invoke)
using System.Runtime.InteropServices;
public static class RustInterop
{
[DllImport("my_rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern int add_numbers(int a, int b);
[DllImport("my_rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern int process_string(
[MarshalAs(UnmanagedType.LPUTF8Str)] string input);
}
// Usage
int sum = RustInterop.add_numbers(5, 3); // 8
int len = RustInterop.process_string("Hello from C#!"); // 15
FFI Safety Checklist
When exposing Rust functions to C#, these rules prevent the most common bugs:
-
Always use
extern "C"— without it, Rust uses its own (unstable) calling convention. C# P/Invoke expects the C ABI. -
#[no_mangle]— prevents the Rust compiler from mangling the function name. Without it, C# can't find the symbol. -
Never let a panic cross the FFI boundary — a Rust panic unwinding into C# is undefined behavior. Catch panics at FFI entry points:
#[no_mangle] pub extern "C" fn safe_ffi_function() -> i32 { match std::panic::catch_unwind(|| { // actual logic here 42 }) { Ok(result) => result, Err(_) => -1, // Return error code instead of panicking into C# } } -
Opaque vs transparent structs — if C# only holds a pointer (opaque handle),
#[repr(C)]is not needed. If C# reads struct fields viaStructLayout, you must use#[repr(C)]:// Opaque — C# only holds IntPtr. No #[repr(C)] needed. pub struct Connection { /* Rust-only fields */ } // Transparent — C# marshals fields directly. MUST use #[repr(C)]. #[repr(C)] pub struct Point { pub x: f64, pub y: f64 } -
Null pointer checks — always validate pointers before dereferencing. C# can pass
IntPtr.Zero. -
String encoding — C# uses UTF-16 internally.
MarshalAs(UnmanagedType.LPUTF8Str)converts to UTF-8 for Rust'sCStr. Document this contract explicitly.
End-to-End Example: Opaque Handle with Lifecycle Management
This pattern is common in production: Rust owns an object, C# holds an opaque handle, and explicit create/destroy functions manage the lifecycle.
Rust side (src/lib.rs):
use std::ffi::{c_char, CStr};
pub struct ImageProcessor {
width: u32,
height: u32,
pixels: Vec<u8>,
}
/// Create a new processor. Returns null on invalid dimensions.
#[no_mangle]
pub extern "C" fn processor_new(width: u32, height: u32) -> *mut ImageProcessor {
if width == 0 || height == 0 {
return std::ptr::null_mut();
}
let proc = ImageProcessor {
width,
height,
pixels: vec![0u8; (width * height * 4) as usize],
};
Box::into_raw(Box::new(proc)) // Allocate on heap, return raw pointer
}
/// Apply a grayscale filter. Returns 0 on success, -1 on null pointer.
#[no_mangle]
pub extern "C" fn processor_grayscale(ptr: *mut ImageProcessor) -> i32 {
let proc = match unsafe { ptr.as_mut() } {
Some(p) => p,
None => return -1,
};
for chunk in proc.pixels.chunks_exact_mut(4) {
let gray = (0.299 * chunk[0] as f64
+ 0.587 * chunk[1] as f64
+ 0.114 * chunk[2] as f64) as u8;
chunk[0] = gray;
chunk[1] = gray;
chunk[2] = gray;
}
0
}
/// Destroy the processor. Safe to call with null.
#[no_mangle]
pub extern "C" fn processor_free(ptr: *mut ImageProcessor) {
if !ptr.is_null() {
// SAFETY: ptr was created by processor_new via Box::into_raw
unsafe { drop(Box::from_raw(ptr)); }
}
}
C# side:
using System.Runtime.InteropServices;
public sealed class ImageProcessor : IDisposable
{
[DllImport("image_rust", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr processor_new(uint width, uint height);
[DllImport("image_rust", CallingConvention = CallingConvention.Cdecl)]
private static extern int processor_grayscale(IntPtr ptr);
[DllImport("image_rust", CallingConvention = CallingConvention.Cdecl)]
private static extern void processor_free(IntPtr ptr);
private IntPtr _handle;
public ImageProcessor(uint width, uint height)
{
_handle = processor_new(width, height);
if (_handle == IntPtr.Zero)
throw new ArgumentException("Invalid dimensions");
}
public void Grayscale()
{
if (processor_grayscale(_handle) != 0)
throw new InvalidOperationException("Processor is null");
}
public void Dispose()
{
if (_handle != IntPtr.Zero)
{
processor_free(_handle);
_handle = IntPtr.Zero;
}
}
}
// Usage — IDisposable ensures Rust memory is freed
using var proc = new ImageProcessor(1920, 1080);
proc.Grayscale();
// proc.Dispose() called automatically → processor_free() → Rust drops the Vec
Key insight: This is the Rust equivalent of C#'s
SafeHandlepattern. Rust'sBox::into_raw/Box::from_rawtransfers ownership across the FFI boundary, and the C#IDisposablewrapper ensures cleanup.
Exercises
<details> <summary><strong>🏋️ Exercise: Safe Wrapper for Raw Pointer</strong> (click to expand)</summary>You receive a raw pointer from a C library. Write a safe Rust wrapper:
// Simulated C API
extern "C" {
fn lib_create_buffer(size: usize) -> *mut u8;
fn lib_free_buffer(ptr: *mut u8);
}
Requirements:
- Create a
SafeBufferstruct that wraps the raw pointer - Implement
Dropto calllib_free_buffer - Provide a safe
&[u8]view viaas_slice() - Ensure
SafeBuffer::new()returnsNoneif the pointer is null
struct SafeBuffer {
ptr: *mut u8,
len: usize,
}
impl SafeBuffer {
fn new(size: usize) -> Option<Self> {
let ptr = unsafe { lib_create_buffer(size) };
if ptr.is_null() {
None
} else {
Some(SafeBuffer { ptr, len: size })
}
}
fn as_slice(&self) -> &[u8] {
// SAFETY: ptr is non-null (checked in new()), len is the
// allocated size, and we hold exclusive ownership.
unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
}
}
impl Drop for SafeBuffer {
fn drop(&mut self) {
// SAFETY: ptr was allocated by lib_create_buffer
unsafe { lib_free_buffer(self.ptr); }
}
}
// Usage: all unsafe is contained in SafeBuffer
fn process(buf: &SafeBuffer) {
let data = buf.as_slice(); // completely safe API
println!("First byte: {}", data[0]);
}
Key pattern: Encapsulate unsafe in a small module with // SAFETY: comments. Expose a 100% safe public API. This is how Rust's standard library works — Vec, String, HashMap all contain unsafe internally but present safe interfaces.