Systems Programming · Unsafe Code
Uninitialised Memory, Padding, and the Cost of Assuming Zero
Uninitialised bytes are not merely arbitrary values, they are a distinct state the optimiser tracks, and the distinction changes what a byte copy, a comparison, and a length assignment may legally do.
A freshly allocated page from the operating system reads as zeros, and this is an artefact of how virtual memory is implemented rather than a property of the language. The kernel maps a shared zero page or a zeroed frame on first touch, so an untouched malloc region appears zeroed. Reuse the same allocation after a free and the guarantee is gone: the bytes hold whatever the previous owner left. A program that depends on zeroed malloc memory is depending on an allocator’s internal state and on the size class of the request.
The more interesting problem is not what the bytes contain but what the compiler is permitted to believe about them. Uninitialised memory is a separate state in the abstract machine, not a byte value that happens to be unknown. The optimiser tracks that state, and the operations that are legal on an uninitialised byte are fewer than intuition suggests.
The state that has no value
In C, an object with automatic storage duration that is not initialised holds an indeterminate value. If the object has a type that could have a trap representation, reading it is undefined behaviour; for types without trap representations the value is indeterminate but the read is permitted, and each read may yield a different value. A compiler is entitled to assume that the indeterminate value never violates the type’s invariants, so a read of an uninitialised int used as an array index can be folded into any assumption about the index.
In LLVM’s model the state is undef, and its propagation rule is the one that catches programmers out. An operation on undef produces undef. A branch on undef is undefined behaviour, because the branch’s target is unconstrained. A store of undef writes an arbitrary value. The optimiser may choose a convenient concrete value for undef at each use, and it may choose differently at different uses of the same SSA value.
Rust makes the state explicit in the type system. MaybeUninit<T> is a union whose payload is not required to be valid, and the only way to obtain a T from it is assume_init, which is unsafe precisely because the caller asserts the bytes are valid. Reading a MaybeUninit’s payload through a reference to T before initialisation is undefined, and the compiler is entitled to optimise on the assumption that a T is always valid for its type.
The practical difference between “arbitrary bytes” and “uninitialised” shows up in three operations: branching, comparison, and validity-checked types.
use std::mem::MaybeUninit;
let mut slot: MaybeUninit<[u8; 4]> = MaybeUninit::uninit();
// Filling the bytes is fine; reading them is not, until every byte is written.
let p = slot.as_mut_ptr() as *mut u8;
unsafe {
for i in 0..4 { p.add(i).write(i as u8); }
let value = slot.assume_init(); // sound: all four bytes written
assert_eq!(value, [0, 1, 2, 3]);
}
The loop writes all four bytes before assume_init, and the assertion is on the fully initialised value. Removing one iteration of the loop makes the assume_init unsound even though the array type has no invalid bit patterns, because the uninitialised byte makes the value indeterminate, and using an indeterminate byte in a comparison is undefined.
Padding is uninitialised by construction
A structure with members of different alignments contains padding bytes between them and after the last member. Those bytes are not part of any member, they are never written by a field assignment, and they hold whatever the previous contents of the memory were. Two structures with equal members can therefore have different object representations.
This is why copying a structure wholesale and comparing the result to another structure is a bug in any language that compares bytes. It is also why hashing a structure’s object representation is unstable across compilers, since the layout and the padding contents both vary. And it is why transmitting a structure over a socket is a protocol error waiting to happen: the peer may receive bytes that were never intended to leave the process.
In Rust the situation is sharper because padding bytes are not merely unspecified, they are uninitialised, and copying them with a typed copy is undefined behaviour if the copy is treated as producing a value of the type. Byte-level copies are permitted: ptr::copy_nonoverlapping on u8 pointers moves the bytes without interpreting them, and the destination’s padding remains uninitialised, which is the correct outcome. The distinction between a typed copy and a byte copy is the distinction between moving a value and moving its representation.
The same reasoning explains why memcmp on structures is legal C but semantically wrong: the function reads bytes, which the character-type rule permits, and it may read padding, which is uninitialised. Reading an uninitialised byte through an unsigned char lvalue is permitted, but the resulting value is unspecified, so memcmp returns a result that depends on bytes the program never set. The legal formulation compares members.
The cost of zeroing, and when the kernel pays it
Allocating zeroed memory is cheap in the common case because the kernel hands out pages that are already zero and only writes when the process touches them. calloc exploits this: for large requests it can obtain fresh zero pages from the kernel rather than clearing a recycled block, and for small requests it clears the block itself. The boundary between the two strategies is a per-allocator tuning parameter, and it moves.
The cost appears when the memory is reused. A malloc of a block that came from the free list contains the previous owner’s data, and any code that reads it before writing gets that data. That is a correctness problem and an information-disclosure problem at once: a structure with a pointer field left uninitialised and then serialised leaks an address, which defeats address-space layout randomisation for that process.
The defensive response is to zero on allocation or on free. Zeroing on free costs a write per allocation; zeroing on allocation costs the same write but only for the blocks that are actually reused. Hardened allocators do one or the other, and the trade is measurable in allocation-heavy workloads.
Why the compiler cannot just pick a value
A reasonable objection is that the compiler could define uninitialised memory to be zero and remove the whole category. The cost is that every allocation would then have to be zeroed, including stack frames for locals that are written before they are read, which is most of them. Zeroing a frame on entry to every function would add stores to code that never needed them, and the optimiser’s ability to delete those stores depends on proving the values are dead, which is the analysis the definition was meant to avoid.
The compromise is the current one: uninitialised memory is a state the optimiser may treat as arbitrary, and code that needs a defined value must write one. The state propagates through operations, which is what makes the analysis local and cheap.
MemorySanitizer takes the complementary approach. It maintains a shadow map recording, for each byte, whether it is initialised, and it propagates the shadow through arithmetic and copies. A branch on a shadow-marked byte is reported. The instrumentation is precise enough to find the read that consumed the uninitialised byte rather than the crash that followed it, which is the practical advantage over waiting for a wrong value to cause a visible failure. The cost is roughly a doubling of memory use and a substantial slowdown, which is why it is a debugging configuration. What such a run does and does not establish is the subject of What Sanitizers and Miri Cannot Prove.
The boundary with validity
Uninitialised memory is one way for a value to be invalid; another is a bit pattern that is not a valid value of the type. A bool in Rust must be 0 or 1, and a char must be a Unicode scalar value. Transmuting a byte with value 2 into a bool produces a value that violates the type’s invariant, and the compiler may assume it never happens. This is a separate violation from reading uninitialised memory, and it survives a memcpy that copies the byte faithfully.
The two combine in the deserialisation path, which is where most of the risk lives: bytes arrive from an untrusted source, are copied into a structure, and are then interpreted as typed values. Validating the bytes before the interpretation is the only defence, and the validation must cover every field whose type has an invariant. The allocation and ownership side of the same boundary is treated in Ownership Across the FFI Boundary.
References
- The Rust standard library: MaybeUninit — Rust project
- ISO/IEC 9899:2011, 6.2.6 Representations and values — ISO/IEC
- LLVM Language Reference Manual: Undefined Values — LLVM project
- MemorySanitizer documentation — LLVM project