Raw Pointers (*const T, *mut T), Provenance & Aliasing Models
Master raw pointer semantics, pointer provenance models, Stacked Borrows, integer-to-pointer casting pitfalls, and pointer arithmetic.
Unlike standard Rust references (&T and &mut T), raw pointers (*const T and *mut T) do not carry compiler-enforced lifetimes, do not track borrow exclusivity, and do not execute automatic drop glue when they exit scope.
They can alias freely, can be null, and can point to unaligned memory. However, to dereference them, an engineer must understand the concept of Pointer Provenance.
A pointer is not just an integer address; it carries a compiler permission tag tied to a specific memory allocation.
1. What is Pointer Provenance?#
In low-level C programming, developers often treat pointers as plain 64-bit unsigned integers representing byte addresses in virtual memory. In modern optimizing compilers, this mental model is insufficient.
A pointer is mathematically modeled as a pair consisting of a memory address and an Allocation Provenance Tag :
The Valid Dereference Invariant#
A raw pointer dereference is valid if and only if the target address lies within the valid bounds of allocation and satisfies the hardware alignment requirement:
Casting a pointer to an integer (as usize) strips its provenance tag. If you cast that integer back to a pointer (as *const T), the compiler loses the ability to prove which memory allocation the pointer is authorized to access, which can trigger Undefined Behavior under memory models like Stacked Borrows and Tree Borrows.
2. Raw Pointer Arithmetic#
When performing arithmetic on raw pointers, Rust provides explicit methods that scale automatically by the size of type :
123456789101112131415fn main() { let numbers = [10u32, 20, 30, 40, 50]; let base_ptr: *const u32 = numbers.as_ptr(); unsafe { // .add(offset) scales by size_of::<u32>() (4 bytes per element): // Address: base + (2 * 4 bytes) let third_ptr = base_ptr.add(2); println!("Third value: {}", *third_ptr); // 30 // .offset() supports signed offsets: let second_ptr = third_ptr.offset(-1); println!("Second value: {}", *second_ptr); // 20 } }
.add(n) requires that both the starting address and the resulting address fall within (or one byte past) the same allocated object. Calculating an address completely outside the bounds of the allocation is immediate Undefined Behavior, even if you never dereference the pointer!3. Creating Raw Pointers Safely#
In early Rust, developers frequently cast references to raw pointers directly (&mut val as *mut T). However, creating intermediate references asserts borrow checker exclusivity rules that can accidentally invalidate existing raw pointers!
Modern Rust provides the std::ptr::addr_of! and std::ptr::addr_of_mut! macros:
123456789101112131415161718use std::ptr; struct Packet { header: u8, payload: [u8; 16], } fn main() { let mut packet = Packet { header: 1, payload: [0; 16] }; // IDIOMATIC: addr_of_mut! creates a raw pointer without // creating intermediate Rust references that could assert aliasing rules: let payload_ptr: *mut [u8; 16] = ptr::addr_of_mut!(packet.payload); unsafe { (*payload_ptr)[0] = 0xFF; } }
4. Concept Verification#
Test your understanding of raw pointers and memory provenance:
Why can casting a pointer to usize and back to *const T cause Undefined Behavior in optimizing compilers?
How does pointer.add(3) calculate the new memory address for a pointer of type *const u64?