Zig Version
0.16.0
Steps to Reproduce and Observed Behavior
Zig ARM64 backend: method call uses wrong register for self pointer when second parameter is 0
Zig Version
0.16.0
Target
aarch64-freestanding (also affects aarch64-linux and aarch64-macos)
Description
When a function takes two parameters (info: *BootInfo, dtb_addr: u64) and calls a method on info (e.g. info.totalMemory()), the ARM64 backend generates incorrect code: it saves the second parameter (dtb_addr) to the stack slot used for the self pointer, instead of the first parameter (info). When dtb_addr = 0, the method reads fields from address 0 instead of from the actual struct, causing a crash or silent data corruption.
Reproducer
constMemoryRegion=struct{base:u64,size:u64,is_device:bool,};constBootInfo=struct{memory_regions:[16]MemoryRegion,num_regions:usize,kernel_start:u64,kernel_end:u64,dtb_addr:u64,pubfnaddRegion(self:*BootInfo,base:u64,size:u64,is_device:bool)void{if(self.num_regions<16){self.memory_regions[self.num_regions]=.{.base=base,.size=size,.is_device=is_device,};self.num_regions+=1;}}pubfntotalMemory(self:*constBootInfo)u64{vartotal:u64=0;vari:usize=0;while(i<self.num_regions){if(!self.memory_regions[i].is_device){total+=self.memory_regions[i].size;}i+=1;}returntotal;}};fnearlyInit(info:*BootInfo,dtb_addr:u64)void{info.dtb_addr=dtb_addr;info.num_regions=0;info.kernel_start=0;info.kernel_end=0;vari:usize=0;while(i<16):(i+=1){info.memory_regions[i].base=0;info.memory_regions[i].size=0;info.memory_regions[i].is_device=false;}_=info.addRegion(0x4000_0000,128*1024*1024,false);_=info.addRegion(0x0800_0000,0x10000,true);_=info.addRegion(0x0900_0000,0x1000,true);// BUG: reads from address 0 instead of &infoconsttotal=info.totalMemory();if(total!=128*1024*1024){@breakpoint();}}exportfn_start()noreturn{varinfo:BootInfo=undefined;earlyInit(&info,0);while(true){}}Compile with:
zig build-exe repro.zig -target aarch64-freestanding -femit-asm=repro.s
Disassembly Analysis
Call site in _start:
add x0, sp, #304 ; x0 = &info
str x0, [sp, #8] ; save &info
...
mov w8, #416 ; sizeof(BootInfo)
mov w2, w8 ; memset size
mov w1, #-86 ; memset fill byte (0xAA = undefined)
bl memset
ldr x0, [sp] ; x0 = WRONG pointer (sp+280, not &info!)
ldr x1, [sp, #8] ; x1 = &info (correct, but in wrong register!)
ldr x2, [sp, #16] ; x2 = 0
bl repro_bug.earlyInit ; earlyInit(x0=WRONG, x1=&info, x2=0)
The caller puts &info in x1 instead of x0. The second parameter 0 is in x2 instead of x1.
earlyInit function prologue:
repro_bug.earlyInit:
sub sp, sp, #160
stp x29, x30, [sp, #144]
add x29, sp, #144
stur x0, [x29, #-48] ; save x0 (WRONG pointer)
mov x8, x1
stur x8, [x29, #-40] ; save x1 (&info)
stur x2, [x29, #-32] ; save x2 (0 = dtb_addr)
stur x1, [x29, #-24] ; BUG: save x1 as "self" pointer
The compiler saves x1 to [x29, #-24] and uses this slot as the info pointer for all field accesses. In this particular compilation, x1 happens to contain &info (due to the caller-side bug), so it works by accident.
totalMemory call inside earlyInit:
ldur x0, [x29, #-48] ; x0 = WRONG pointer
ldur x1, [x29, #-24] ; x1 = [x29-24] = "self" pointer
bl repro_bug.BootInfo.totalMemory
totalMemory function prologue:
repro_bug.BootInfo.totalMemory:
sub sp, sp, #128
stp x29, x30, [sp, #112]
add x29, sp, #112
stur x0, [x29, #-40] ; save x0 (unused)
mov x8, x1
stur x8, [x29, #-32] ; save x1
stur x1, [x29, #-24] ; BUG: save x1 as "self" pointer
...
ldur x9, [x29, #-24] ; load "self" from [x29-24]
ldr x9, [x9, #384] ; read self.num_regions from *(x1 + 384)
In the original kernel (where the bug manifests as a crash):
The kernel calls earlyInit(&info, 0) where dtb_addr = 0:
; Call site in kmain:
mov x0, &info
mov x1, #0 ; dtb_addr = 0
bl earlyInit
; earlyInit prologue:
stur x0, [x29, #-48] ; save x0 = &info
mov x8, x1
stur x8, [x29, #-40] ; save x1 = 0 (dtb_addr)
stur x2, [x29, #-32] ; save x2
stur x1, [x29, #-24] ; BUG: save x1 = 0 as "self" pointer!
; All field accesses use [x29-24] = 0:
ldur x8, [x29, #-24] ; x8 = 0
str x2, [x8, #408] ; write to *(0 + 408) = address 0x198 !!!
; totalMemory call:
ldur x0, [x29, #-48] ; x0 = &info (correct)
ldur x1, [x29, #-24] ; x1 = 0 (BUG!)
bl totalMemory ; totalMemory reads from address 0!
Root Cause
The ARM64 backend has a register allocation bug where it consistently saves x1 to the stack slot [x29, #-24] and uses this slot as the self pointer for method calls and field accesses, instead of using x0.
The bug manifests in two ways:
- Caller side: The compiler sometimes passes the first pointer parameter in
x1instead ofx0(register swap). - Callee side: The compiler always saves
x1to[x29, #-24]and uses it asself, regardless of whetherx1actually contains the correct pointer.
When dtb_addr = 0, the method reads from physical address 0, causing an immediate crash in bare-metal/freestanding environments.
Workaround
Store the method result in a const variable before passing it to another function:
// BUG: crashes when dtb_addr = 0debug.log.print("Memory: {}MB\n",.{info.totalMemory()/1024/1024});// WORKAROUND: use a const variableconstmm=info.totalMemory()/1024/1024;debug.log.print("Memory: {}MB\n",.{mm});The const declaration triggers compile-time evaluation (when the struct fields are known at compile time), avoiding the runtime method call entirely.
Expected Behavior
debug.log.print("Memory: {}MB\n", .{info.totalMemory() / 1024 / 1024});
// WORKAROUND: use a const variable
const mm = info.totalMemory() / 1024 / 1024;
debug.log.print("Memory: {}MB\n", .{mm});