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 and zeroable-ref RFC, which eliminates the concept of null from the pointer model and introduces a new reference with extended semantics to the full address space.
Before reading
This proposal SHALL NOT change:
- Semantics, layouts, and niche optimisations of
&T,Option<&T>, andNonNull<T> - Validity invariant of non-zero reference primitive -
&Tand&mut T(0x0 remains null for them) - All existing safe and unsafe APIs that do not access 0x0
- ABIs
Need 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.
- Post src: Reading from physical address 0x0
- Code src: vorago-rs/va108xx/bootloader/main.rs
This is a real developer, on a real chip, in a real aerospace mission, working around a real limitation.
The limitation is not unique to that mission. The W65C02S - an 8-bit processor whose 16-bit address bus spans the entire 64 kiB address space from 0x0000 to 0xffff (datasheet pp. 5, 15) - leaves no room for a sentinel. There is not a single byte to spare or sacrifice. On such a target, "just avoid address zero" is not a workaround; it is a physical impossibility.
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.
Changes
Phase I (MUST be applied; MAY be done immediately)
- Making address 0x0 a valid address to dereference (e.g.
*ptr = val;) and usecore::ptrAPIs
Migration cost
Activation of LLVM `null_pointer_is_valid` flag (performance impact measured)
C benchmark suite & diff
// bench.c
// clang -v | Homebrew clang version 21.1.8
// clang --target=aarch64-unknown-none -O2 -S bench.c -o baseline.s
// clang --target=aarch64-unknown-none -O2 -fno-delete-null-pointer-checks -S bench.c -o zeroisvalid.s
// diff baseline.s zeroisvalid.s
#include <stdint.h>
#include <stddef.h>
// C1: null check elimination after dereference
uint32_t check_after_deref(const uint32_t *p) {
uint32_t v = *p; // accessed!
if (p) return v + 1; // validation after access
return 0; // baseline: this branch is eliminated
}
// C2: two paths merging on null knowledge
uint32_t branch_after_store(uint32_t *p, uint32_t val) {
*p = val; // accessed!
if (p) return *p + 1; // validation after access
return 42; // baseline: this branch is eliminated
}
// C3: loop with pointer increment
uint32_t sum_until_null(const uint32_t *const *ptrs) {
uint32_t sum = 0;
while (*ptrs) { // validation before access
// null-terminated array of pointers
sum += **(ptrs++); // accessed!
}
return sum;
}
// C4: devirtualisation / inlining based on nonnull
void copy_if_valid(uint32_t *dst, const uint32_t *src, size_t n) {
if (dst && src) { // validation before access
for (size_t i = 0; i < n; i++)
dst[i] = src[i]; // accessed!
}
}
// C5: struct access implying nonnull
uint32_t read_two_fields(const struct { uint32_t a; uint32_t b; } *s) {
uint32_t x = s->a; // accessed!
if (s) return x + s->b; // validation after access
return 0; // baseline: this branch is eliminated
}
8a9,10
> cbz x0, .LBB0_2
> // %bb.1:
10a13
> .LBB0_2:
21a25,26
> mov w9, #42 // =0x2a
> cmp x0, #0
23c28
< add w0, w1, #1
---
> csinc w0, w9, w1, eq
111a117,118
> cbz x0, .LBB4_2
> // %bb.1:
113a121
> .LBB4_2:
Rust PoC & benchmark suite & diff
//! bench.rs
//! ./x setup
//! ./x build --stage 2
//! ./x build library --target aarch64-unknown-none --stage 2
//! ln -s ./build/aarch64-apple-darwin/stage2/bin/rustc ./rustc
//!
//! rustc -V | rustc 1.93.1 (01f6ddf75 2026-02-11)
//! ./rustc -V | rustc 1.96.0-dev
//!
//! rustc --target aarch64-unknown-none -C opt-level=2 --emit asm -o baseline.s bench.rs
//! ./rustc --target aarch64-unknown-none -C opt-level=2 --emit asm -o zeroisvalid.s bench.rs
//! diff baseline.s zeroisvalid.s
#![no_std]
#![no_main]
/// R1: null check elimination after dereference
#[unsafe(no_mangle)]
extern "C" fn check_after_deref(p: *const u32) -> u32 {
let v = unsafe { *p }; // accessed!
if p as usize != 0 { // validation after access
v + 1
} else {
0 // baseline: this branch is eliminated
}
}
/// R2: two paths merging on null knowledge
#[unsafe(no_mangle)]
extern "C" fn branch_after_store(p: *mut u32, val: u32) -> u32 {
unsafe { *p = val }; // accessed!
if p as usize != 0 { // validation after access
unsafe { *p + 1 }
} else {
42 // baseline: this branch is eliminated
}
}
/// R3: loop with pointer increment
#[unsafe(no_mangle)]
extern "C" fn sum_until_null(mut ptrs: *const *const u32) -> u32 {
let mut sum = 0u32;
while unsafe { *ptrs as usize != 0 } { // validation before access
// null-terminated array of pointers
sum += unsafe { **ptrs }; // accessed!
ptrs = unsafe { ptrs.add(1) };
}
sum
}
/// R4: devirtualisation / inlining based on nonnull
#[unsafe(no_mangle)]
extern "C" fn copy_if_valid(dst: *mut u32, src: *const u32, n: usize) {
if dst as usize != 0 && src as usize != 0 { // validation before access
for i in 0..n {
unsafe { *dst.add(i) = *src.add(i) }; // accessed!
}
}
}
#[repr(C)]
struct TwoFields {
a: u32,
b: u32,
}
/// R5: struct access implying nonnull
#[unsafe(no_mangle)]
extern "C" fn read_two_fields(s: *const TwoFields) -> u32 {
let x = unsafe { (*s).a }; // accessed!
if s as usize != 0 { // validation after access
x + unsafe { (*s).b }
} else {
0 // baseline: this branch is eliminated
}
}
/// mock-up panic handler for no_std
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! { loop {} }
1,4c1,4
< .file "bench.e2ea9eb9f9388208-cgu.0"
< .section .text._RNvCsdBezzDwma51_7___rustc17rust_begin_unwind,"ax",@progbits
< .hidden _RNvCsdBezzDwma51_7___rustc17rust_begin_unwind
< .globl _RNvCsdBezzDwma51_7___rustc17rust_begin_unwind
---
> .file "bench.c8fcca67f257a880-cgu.0"
> .section .text._RNvCs3VXMJi7HSJc_7___rustc17rust_begin_unwind,"ax",@progbits
> .hidden _RNvCs3VXMJi7HSJc_7___rustc17rust_begin_unwind
> .globl _RNvCs3VXMJi7HSJc_7___rustc17rust_begin_unwind
6,7c6,7
< .type _RNvCsdBezzDwma51_7___rustc17rust_begin_unwind,@function
< _RNvCsdBezzDwma51_7___rustc17rust_begin_unwind:
---
> .type _RNvCs3VXMJi7HSJc_7___rustc17rust_begin_unwind,@function
> _RNvCs3VXMJi7HSJc_7___rustc17rust_begin_unwind:
12c12
< .size _RNvCsdBezzDwma51_7___rustc17rust_begin_unwind, .Lfunc_end0-_RNvCsdBezzDwma51_7___rustc17rust_begin_unwind
---
> .size _RNvCs3VXMJi7HSJc_7___rustc17rust_begin_unwind, .Lfunc_end0-_RNvCs3VXMJi7HSJc_7___rustc17rust_begin_unwind
20a21,22
> mov w9, #42
> cmp x0, #0
22c24
< add w0, w1, #1
---
> csinc w0, w9, w1, eq
34a37
> cbz x0, .LBB2_2
36a40
> .LBB2_2:
62c66
< mov x11, x8
---
> and x11, x2, #0xfffffffffffffff8
124a129
> cbz x0, .LBB4_2
126a132
> .LBB4_2:
156c162
< .ident "rustc version 1.93.1 (01f6ddf75 2026-02-11)"
---
> .ident "rustc version 1.96.0-dev"
The result: codegen differences appear only where the compiler would eliminate null-check-not-gating-accesses: 2-3 additional instructions per site. Null-check-gating-accesses (C3, C4) produce identical output.
I think the default assumption should be that the cost is manageable unless demonstrated otherwise - not assumed prohibitive without evidence to match.
- Divergence between
*const Tand&Tvalidity at zero: As seen in rust#138351, the divergence is worsened by Phase I alone; however, this is not resolved by forbidding access to 0x0 either - it requires a primitive that can bridge the gap. This is where Phase II comes in to fix it.
Phase II (MUST be applied after Phase I; SHOULD be at an edition boundary)
- Introducing a zeroable reference primitive (e.g.
@Tor&zref T) as an extension of&T(targeting self-hosted bare-metal environments) - Adding APIs returning zeroable reference primitive (e.g.
slice::zeroable::from_raw_parts) - Adding conversion methods between zeroable & non-zero reference primitive
Closed questions
- Zeroable reference primitive clones the implementation of
&T- it shares lifetime semantics, borrowing rules, etc. - Conversion from
&Tto zeroable reference primitive is always safe(dropping an invariant); the reverse requires a runtime check orunsafe.
Open questions
-
Syntax: Should it be a sigil, or a qualifier?
-
Syntax::Sigil => something like
@mut Tor%mut T?- No keyword reservation needed; compact
- Mirrors
&T/&mut Tstructure exactly
-
Syntax::Qualifier => something like
&zref mut Tor&zeroable mut T?- Consistent with
&raw const T/&raw mut Tprecedent - Requires one keyword reservation
zerois commonly used as an identifier;nullableimplies null semantics this proposal explicitly avoids
- Consistent with
-
-
Dereftrait: ShouldDereftrait return zeroable reference primitive, or add a new trait likeZeroDeref, or else? -
Conversion syntax: Implicit coercion from non-zero reference primitive, explicit
.into(), or a dedicated method? -
Slice integration: Shape of
slice::zeroable::from_raw_partsand related APIs.
Considerations (MAY be applied)
- Non-zero reference primitive could eventually be considered as a non-zero counterpart of a zeroable reference primitive
- The methodology described at
Changes - Phase IImay generalise to function pointers where Armยฎ Thumb interworking can produce a valid odd address of a function
Feasibility
On the claimed optimisation cost
The Linux kernel is built with -fno-delete-null-pointer-checks, which is the GCC/Clang equivalent of LLVM's null_pointer_is_valid. One of the most performance-sensitive C codebases in existence has operated without this optimisation for over a decade - originally adopted after a null-pointer-check removal led to a privilege escalation exploit in 2009(CVE-2009-1897).
Benchmark at Changes - Phase I - Migration cost section
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 fatal - it is inappropriate for that condition to be included in the abstraction. The abstract-machine-level assumption about null pointers has the aforementioned counterexamples, which are not only too numerous to isolate as exceptional configurations but also widespread.
Cost of the status quo
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
- Bypassed safety guarantee of the platform itself
- Audit failure on mission-critical environments
This cost is already being paid - silently, as Rust shifts from being an alternative to needing one.
Alternatives?
In bare-metal environments, the proposed workarounds - read_volatile, wrapper crates, and extra unsafe abstractions - may not be applicable at all (refer to Need and significance section).
In mission-critical environments, they are worse than inapplicable - they are disqualifying:
- MISRA-C:2023 Dir 4.3 (Required): Assembly language shall be encapsulated and isolated - inline assembly interleaved with application logic is a direct violation.
- IEC 61508-7 Table C.1 (SIL 2+): C is only Highly Recommended when used with a defined language subset, a coding standard, and static analysis - workarounds outside the language's defined semantics break this chain.
- IEC 61508-3 Table A.4: Use of a language subset is Highly Recommended at SIL 2+; escaping the subset requires documented justification per safety case.
- DO-178C Sect. 6.4.4.2.b (DAL A): Any code not directly traceable from source to object requires additional verification - inline assembly disrupts source-to-object traceability and MC/DC structural coverage.
- ISO 26262-6 Sect. 5.4.3, Table 1: Use of a language subset is recommended across all ASILs, with MISRA-C cited as the example - assembly workarounds fall outside this subset.
A workaround is not an alternative when the workaround itself is the defect.
Prior discussion
This issue has been discussed on several occasions.
Within Rust
- rust#138351 - soundness hole where
ptr::replacecreates&mut *dstinternally, causing raw pointer validity and reference validity to diverge at null; a zeroable reference primitive would structurally resolve this class of issue. - rust#141260 - the volatile stopgap described above
- unsafe-code-guidelines#29 - prior discussion on null pointer semantics, closed by Rust PR #141260
- 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 0x0 contains valid RAM on their hardware.
Outside Rust
- LWN.net - A zero pointer is not a null pointer - an article refuting the assumption that 0x0 is always null
- LWN.net - Fun with NULL pointers, part 2 - the 2009 incident: CVE-2009-1897 that led Linux to build with
-fno-delete-null-pointer-checksby default; the compiler's null assumption removed a security check and turned a bug into a privilege escalation - MSP430 - what happened to the NULL pointer !?! - a real-world case where standards restricted usage on a specific target