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:

  1. Field sizes: x is i32 (4 bytes), y is i32 (4 bytes).
  2. Field offsets: Fields are laid out sequentially. x is at offset 0, y is at offset 4. No padding is needed because i32 has 4-byte alignment and each field is already naturally aligned.
  3. Total size: 4 + 4 = 8 bytes.
  4. 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:

Keep in mind that a ptr doesn’t have to point to heap space. You can use salloc to allocate memory directly on the function’s stack and address it with ptr the 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

  1. Allocate: Push the struct size (8), call salloc. This reserves 8 bytes on the function’s stack and pushes a ptr to the region.
  2. Store x: The base pointer is already the address of x (offset 0). Push the value of x, then stm i32.
  3. Store y: Compute base + 4 with add.s i64, push the value of y, then stm i32.
  4. 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

  1. Compute address: The frontend knows y is at offset 4. Push the base pointer, push 4, add.s i64.
  2. Load: ldm i32 reads 4 bytes from the computed address.
  3. Why add.s i64? Pointer arithmetic must use the pointer width. On a 64-bit target, pointers are 8 bytes, so the addition is i64. On a 32-bit target, the frontend would emit add.s i32 instead.
// 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

  1. Address: x is at offset 0, so the base pointer is the address. No offset arithmetic needed.
  2. 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

  1. Flattening offsets: The frontend computes offset(end) + offset(y) = 8 + 4 = 12 at compile time. No matter how deeply nested the field access, the result is always a single constant offset.
  2. Single add + load: Accessing line.end.y is exactly one add.s i64 (with immediate 12) plus one ldm 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

  1. Index arithmetic: The element address is arr + i * sizeof(element). For i32 elements, sizeof is 4.
  2. Multiply then add: Push arr, push i, push 4, mul.s i64, add.s i64, then ldm i32.
  3. Strength reduction: When the element size is a power of two, the frontend can replace the multiply with a shift: i * 4 becomes i << 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