Structs and Fields
How to lower struct types and field access to SBBE IR using flat memory operations.
SBBE has no composite types. Structs, arrays, and unions are all lowered to flat memory operations by the frontend. This guide shows how a compiler translates struct definitions and field accesses into SBBE IR.
What we’re lowering
struct Point {
var x: Int32
var y: Int32
}
func makePoint(x: Int32, y: Int32) -> Point {
return Point(x: x, y: y)
}
func translate(p: Point, dx: Int32, dy: Int32) -> Point {
return Point(x: p.x + dx, y: p.y + dy)
}
At the source level, Point is a named type with named fields. The compiler must decide on a concrete memory layout and replace every field access with a load or store at a byte offset.
Step-by-step lowering decisions
Before emitting any IR, the frontend resolves the struct into a flat memory layout:
- Field sizes:
xisi32(4 bytes),yisi32(4 bytes). - Field offsets: Fields are laid out sequentially.
xis at offset 0,yis at offset 4. No padding is needed becausei32has 4-byte alignment and each field is already naturally aligned. - Total size: 4 + 4 = 8 bytes.
- Struct alignment: The alignment of the struct is the maximum alignment of its fields. Both are 4-byte aligned, so the struct is 4-byte aligned.
From this point forward, SBBE never sees “Point” as a type. It only sees:
sallocwith size 8 to allocate spacestm i32at offset 0 to writexstm i32at offset 4 to writeyldm i32at the corresponding offsets to read fields
Keep in mind that a
ptrdoesn’t have to point to heap space. You can usesallocto allocate memory directly on the function’s stack and address it withptrthe same way. Stack-allocated structs are common for small, short-lived values and avoid the overhead of heap management.
Allocating and initializing a struct
Pseudo-code
func makePoint(x: Int32, y: Int32) -> Point {
return Point(x: x, y: y)
}
Lowering walkthrough
- Allocate: Push the struct size (8), call
salloc. This reserves 8 bytes on the function’s stack and pushes aptrto the region. - Store
x: The base pointer is already the address ofx(offset 0). Push the value ofx, thenstm i32. - Store
y: Computebase + 4withadd.s i64, push the value ofy, thenstm i32. - Return: Push the base pointer and
ret.
SBBE lowering
func $make_point(i32, i32) -> ptr {
var $p ptr
entry:
// Allocate 8 bytes for the struct
ldi 8
salloc
str $p
// Store x at offset 0
ld $p // base address
ldl 0 // first parameter (x)
stm i32 // mem[p + 0] = x
// Store y at offset 4
ld $p
ldi 4
add.s i64 // p + 4
ldl 1 // second parameter (y)
stm i32 // mem[p + 4] = y
ld $p
ret
}
Reading a field
Pseudo-code
let y = point.y
Lowering walkthrough
- Compute address: The frontend knows
yis at offset 4. Push the base pointer, push 4,add.s i64. - Load:
ldm i32reads 4 bytes from the computed address. - Why
add.s i64? Pointer arithmetic must use the pointer width. On a 64-bit target, pointers are 8 bytes, so the addition isi64. On a 32-bit target, the frontend would emitadd.s i32instead.
// Assume $p (ptr to Point) is on the stack
ld $p
ldi 4
add.s i64 // addr = p + 4
ldm i32 // push(point.y)
Writing a field
Pseudo-code
point.x = 42
Lowering walkthrough
- Address:
xis at offset 0, so the base pointer is the address. No offset arithmetic needed. - Store: Push the base pointer, push the value,
stm i32. Note the stack order: address first, then value.
// Set point.x = 42
ld $p // base address
ldi 42 // new value
stm i32 // mem[p + 0] = 42
Nested structs
Pseudo-code
struct Line {
var start: Point // offset 0, size 8
var end: Point // offset 8, size 8
}
let y = line.end.y // need offset of end (8) + offset of y (4) = 12
Lowering walkthrough
- Flattening offsets: The frontend computes
offset(end) + offset(y) = 8 + 4 = 12at compile time. No matter how deeply nested the field access, the result is always a single constant offset. - Single add + load: Accessing
line.end.yis exactly oneadd.s i64(with immediate 12) plus oneldm i32. Nesting depth does not affect runtime cost.
ld $line
ldi 12 // offset of end.y
add.s i64
ldm i32 // push(line.end.y)
Passing structs to functions
Pseudo-code
func distance(a: Point, b: Point) -> Double { ... }
Lowering choices
The frontend must choose between flattening and passing by pointer.
Small structs (fitting in 1-2 registers) can be flattened into separate parameters:
// Instead of: func $distance(Point, Point) -> f64
// The frontend emits:
func $distance(i32, i32, i32, i32) -> f64 {
// params: x1=0, y1=1, x2=2, y2=3
entry:
ldl 2
ldl 0
sub.s i32 // dx = x2 - x1
// ... compute sqrt(dx*dx + dy*dy) ...
}
Large structs are passed by pointer:
func $process(ptr) {
entry:
ldl 0 // ptr to struct
ldm i32 // load first field
// ...
}
The choice of flattening vs. pointer passing follows the target’s ABI (e.g., System V AMD64 passes structs up to 16 bytes in registers).
Arrays
Pseudo-code
let value = arr[i] // element type is Int32
Lowering walkthrough
- Index arithmetic: The element address is
arr + i * sizeof(element). Fori32elements,sizeofis 4. - Multiply then add: Push
arr, pushi, push 4,mul.s i64,add.s i64, thenldm i32. - Strength reduction: When the element size is a power of two, the frontend can replace the multiply with a shift:
i * 4becomesi << 2. This is a standard optimization that avoids the more expensive multiply instruction.
ld $arr
ld $i
ldi 4
mul.s i64 // i * sizeof(element)
add.s i64 // arr + i * 4
ldm i32 // push(arr[i])
With strength reduction:
ld $arr
ld $i
ldi 2
shl i64 // i << 2 = i * 4
add.s i64
ldm i32