In Rust, physical address 0x0 is treated as null: an invalid pointer that must never be accessed. However, this is not a universal assumption - more like biased towards the constraints of specific environments.
Treating a particular bit pattern as null contradicts the address space guaranteed by hardware, and this undermines the generality of the language.
Accordingly, I propose the null-free-ptr RFC, which removes the null value from pointers.
Demand and Significance
Robin Mueller, a researcher at the University of Stuttgart's Institute of Space Systems, was developing a Rust bootloader for the Vorago VA108xx and VA416xx - radiation-hardened Cortex-M4 MCUs deployed in aerospace - where programme RAM starts at 0x0. He needed to read the running application image from address 0x0 to flash it to non-volatile memory. Skipping the first few bytes was not an option - the image starts there. Standard Rust pointer operations were impossible; the only path was inline Armยฎ assembly.
I circumvented the issue by falling back to assembler, but this feels really hacky to me.. Shouldn't Rust be low level enough to allow me to deal with these issues? I found this pre-RFC: Pre-RFC: Conditionally-supported volatile access to address 0 - libs - Rust Internals.
This is a real developer, on a real chip, in a real aerospace mission, working around a real limitation.
The pattern generalises; On bare-metal targets, the address of a hardware structure is not always a design choice - it can be a constraint imposed by the hardware or the firmware, with zero way for the Rust programme to negotiate.
Consider a 16-bit target whose device tree is placed at 0x0 by the hardware with 64 kiB of RAM:
// This address is forced by the hardware.
// Rust does not get to choose it.
const BLOB_P: usize = 0;
const _: () = assert!(usize::BITS == 16);
#[unsafe(no_mangle)]
extern "C" fn ignite() -> ! {
// BLOB can never be read volatilely;
// There's no available RAM to copy the entire struct.
let mut blob = unsafe { &mut *(BLOB_P as *mut DevTreeBlob) };
// instant UB upon reference construction
let mapping = blob.foo();
blob.bar |= 0b1;
...
}
Even when spare RAM exists, the address may still come from outside the programme - and cannot be controlled:
use core::slice::from_raw_parts as mkslice;
// `map` address is reported by the firmware.
// Rust does not get to choose it.
// Caller ensures there's at least one entry
#[unsafe(no_mangle)]
extern "C" fn spark(map: *const RamLayout, len: NonZeroUsize) -> ! {
for entry in unsafe { mkslice(map, len.get()).iter() } {
// instant UB upon calling `from_raw_parts`
// as `from_raw_parts` constructs `&T`
...
}
...
}
Volatile operations cannot help here: they cover individual values, not reference construction, slice creation, or bulk operations like ptr::copy - and even worse in the first example, there is no spare RAM to copy into in the first place.
The only valid workaround for this is inline assembly: which is not for constructing a reference.
Required Changes (The lifeblood of this proposal)
-
Change the set of valid referenceable address values for
*const T/*mut Tto the full range ofusize -
Remove the invalidity assumption for 0x0 pointers, along with all associated validity checks and optimisations
Downstream considerations (Further opinions are welcome)
-
Change pointer-related assumptions in the Allocator API
Since the full range of
usizecan be a valid pointer, Allocators should be able to return 0x0 as a valid address. -
Reconsider the validity basis of
&T/&mut TIf only the pointer definition is changed, UB would be triggered immediately upon conversion to
&Tsolely due to the all-zero bit-pattern condition.Possible directions include:
- Changing the reference invariants directly
- Introducing a new vocabulary type in
corethat encapsulatesread_volatilesafety semantics with lifetime tracking, enabling 0x0 access without altering&Tor breaking niche optimisation
-
Et cetera - if there are further downstream effects, additions are welcome.
Cost
upon acceptance
of Required Changes
Zero. Raw pointers can already have all-zero bit-pattern, carry no niche optimisation, and their in-memory layout (usize) does not change. The only difference is that pointer or additional reference operations (e.g. ptr::copy, ptr::write_bytes, slice::from_raw_parts) cease to treat 0x0 as unconditionally invalid. The LLVM null_pointer_is_valid flag already supports this semantics.
of Downstream Considerations (which are discussed separately)
Should the downstream considerations be accepted, the following costs will be incurred:
- Loss of niche optimisation opportunities for
Option<&T> - Breach of the documented layout guarantee for
Option<&T>::None - Breaking changes to unsafe code and FFIs that depend upon it
Additionally, costs to be weighed according to scope will include:
- ABI inconsistencies between crates and ecosystem fragmentation [compiler flag / crate feature / etc...]
- Migration costs imposed on the entire ecosystem [global only]
This is no small cost and must not be overlooked, but at the same time, whether it is important enough to compromise generality and hardware faithfulness also warrants discussion.
upon rejection
Maintaining the status quo also carries its own cost:
- Inability to use the
core::ptrAPI on valid hardware addresses - Continued barrier to Rust adoption on bare-metal hardware
- Forced bypass of its safety guarantees on affected targets
This cost is already being paid - silently, as Rust shifts from being an alternative to needing one.
Feasibility
On Compiler Optimisations
LLVM has a flag called null_pointer_is_valid, which instructs the compiler to treat the 0x0 pointer as a valid address. This means my proposal does not conflict with LLVM's assumptions.
On the Nature of the Constraint
Not all constraints are equal. for example: Forbidding unaligned access is defensive: the hardware itself may fault when interpreting the instruction itself. Forbidding 0x0 is offensive: no instruction set treats a load from 0x0 as inherently illegal - the fault, if any, comes from the RAM management configuration, not from the instruction being interpreted and executed.
A systems language's constraints should protect the programmer from the hardware instead of attacking them to protect the language's optimisation. The null assumption falls into the latter, and this distinction is central to the motivation of the proposal.
When constructing abstractions, it is desirable to take the greatest common divisor. If counterexamples to a condition exist - and if those counterexamples are numerous - it is inappropriate for that condition to be included in the abstraction. The abstract-machine-level assumption about null pointers has the aforementioned demand (counterexamples), which are not only too numerous to isolate as exceptional configurations but also widespread.
Prior Discussion
This issue has been discussed on several occasions.
Within Rust
-
unsafe-code-guidelines#29 - prior discussion on null pointer semantics, closed by Rust PR #141260
-
rust-lang/rust#141260 - the volatile stopgap described below
-
rust-lang/rfcs#2400 - The Zero Page Optimization RFC, which proposed expanding the null range to the entire zero page, was closed after pushback from embedded developers who pointed out that the zero page, or even zero address contains valid RAM on their hardware.