Memory Allocation

How the Astrobe compiler allocates memory

What You Write Is What You Get

The Astrobe compiler for ARM Cortex-M follows specific, deterministic rules for memory allocation. The way types are written in the program text directly influences how memory is allocated and used – which can be particularly relevant for embedded programmers working with constrained resources.

This document describes the allocation rules as implemented by Astrobe for RP2350 version 10 and later. With no access to the compiler code, the rules are inferred from observations of code and address patterns in the generated assembly language, and some knowledge based on other compilers of Wirthian descent. Some evidence that the rules are correct is that the DWARF debug data generated based on a program’s absoute listing files match what is experienced during debug sessions. Then again, evidence is not proof, and there may be edge cases that are not covered yet.

Type Sizes and Alignment

Type Size Alignment
INTEGER 4 bytes 4
SET 4 bytes 4
REAL 4 bytes 4
CHAR 1 byte 1
BOOLEAN 1 byte 1
BYTE 1 byte 1
POINTER 4 bytes 4
ARRAY N OF T N × size(T), rounded up to a multiple of 4 for allocation 4
RECORD sum of fields with packing, padded to a multiple of 4 4

ARRAY M, N OF T is equivalent to ARRAY M OF ARRAY N OF T.

Sub-word types (CHAR, BOOLEAN, BYTE) are one byte each with byte alignment. All other types are word-aligned.

Global Variable Allocation

Global variables are allocated top-down from the module data top address. A free pointer starts at the data top and decreases with each variable, processed in declaration order.

Type category Rule
CHAR, BOOLEAN, BYTE subtract 1; no alignment
INTEGER, SET, REAL, POINTER word-align free pointer, then subtract 4
any array word-align free pointer, then subtract rounded-up size
any record word-align free pointer, then subtract record size

Consecutive sub-word scalars (CHAR, BOOLEAN, BYTE) are byte-packed with no gaps between them. A word-aligned item following sub-word scalars forces word-alignment of the free pointer, creating a gap.

Example

ptr := 20030000H
i: INTEGER           ptr := ptr - 4          2002FFFCH
r0: R0 (8 bytes)     ptr := ptr - 8          2002FFF4H
p: PP0 (POINTER)     ptr := ptr - 4          2002FFF0H
c: CHAR              ptr := ptr - 1          2002FFEFH
b: BOOLEAN           ptr := ptr - 1          2002FFEEH
a: ARRAY 5 OF CHAR   align, ptr := ptr - 8   2002FFE4H (5 -> 8 allocated)

Procedure Kinds

The compiler distinguishes four procedure kinds, each with a different prologue and allocation strategy.

Kind Syntax Has params Prologue
normal PROCEDURE Name(...) yes push {r0..rN, lr} + sub sp,#M
leaf PROCEDURE* Name(...) yes push {lr} + sub sp,#M
interrupt PROCEDURE Name[n] no push.w {rK..rJ, lr} + sub sp,#M
leaf interrupt PROCEDURE* Name[n] no push.w {rK..rJ, lr} + sub sp,#M

Normal procedures push all parameter registers plus LR. Leaf procedures push only LR; parameters stay in registers.

Both interrupt handler kinds push callee-saved registers (r4–r11) that they use, plus LR, using wide encoding. If no callee-saved registers are needed (all operations fit in r0–r3, which are hardware-saved), only LR is pushed. The callee-saved set includes registers assigned to local variables (from the leaf register assignment pass) and scratch temporaries used by the compiler.

Parameter Passing

Register Assignment

Parameters are assigned to registers r0 through r11 as needed, in declaration order. Each parameter consumes one or more registers:

Parameter kind Registers
value parameter: INTEGER, SET, REAL, CHAR, BOOLEAN, BYTE) 1 register (value in rN
value parameter: POINTER 1 register (address in rN)
value parameter: RECORD 2 registers (rN = address, rN+1 = type tag)
VAR parameter: any scalar or fixed array 1 register (address in rN)
VAR parameter: POINTER 1 register (address in rN)
VAR parameter: RECORD 2 registers (rN = address, rN+1 = hidden type tag)
open array: ARRAY OF T 2 registers (rN = base address, rN+1 = element count)
VAR open array: ARRAY OF T 2 registers (rN = base address, rN+1 = element count)

Type Tag

Every record parameter – whether VAR or value – has a type tag in the next register. The tag points to the actual object’s type descriptor, enabling type tests (IS, CASE, type guard) at runtime.

POINTER parameters do not have a type tag – the tag is stored on the heap at pointer_address - 4.

Open Array Element Count

The size register holds the element count (number of elements), not the byte size. This is the value used in bounds checks.

Normal Procedures – Parameters Pushed on the Stack

After push {r0..rN, lr} and sub sp,#M, the saved parameter registers are at:

[sp + M]      = r0 (1st parameter slot)
[sp + M + 4]  = r1 (2nd parameter slot)
[sp + M + 8]  = r2 (3rd parameter slot)
[sp + M + 12] = r3 (4th parameter slot)
[sp + M + 16] = lr

M is the number of bytes allocated for locals by sub sp,#M.

Leaf Procedures – Parameters in Registers

Parameters stay in their assigned registers throughout the procedure. No parameter registers are pushed to the stack.

Locals – Normal Procedures and Interrupt Handlers

Normal procedures and interrupt handlers use identical local variable layout. The only difference is the prologue (normal pushes parameters; interrupt does not, but pushes registers r4–r11 that are used).

Locals are allocated bottom-up on the stack in declaration order, starting at [sp+0].

Type category Rule
CHAR, BOOLEAN, BYTE 1 byte; byte-packed with adjacent sub-word items
INTEGER, SET, REAL, POINTER word-align current offset, then 4 bytes
any array word-align current offset, then rounded-up size
any record word-align current offset, then record size

Sub-word types (CHAR, BOOLEAN, BYTE) are byte-packed together regardless of type mixing. Word-alignment padding is inserted before the first word-sized, array, or record item that follows sub-word items. The total frame size is rounded up to a multiple of 4.

Example

offset = 0
b0: BOOLEAN              [sp+0],  1 byte
b1: BOOLEAN              [sp+1],  1 byte
(pad 2)                  word-align for la
la: ARRAY 7 OF INTEGER   [sp+4],  28 bytes
i: INTEGER               [sp+32], 4 bytes
l0: R0 (8 bytes)         [sp+36], 8 bytes
p: PP0 (POINTER)         [sp+44], 4 bytes
c0: CHAR                 [sp+48], 1 byte
(pad 3)                  word-align for ba
ba: ARRAY 7 OF BOOLEAN   [sp+52], 8 bytes (7 -> 8 allocated)
                         total: sub sp,#60

Locals – Leaf Procedures and Leaf Interrupt Handlers

Leaf procedures and leaf interrupt handlers use identical local variable layout. The compiler processes locals in declaration order, maintaining a register counter and a stack offset in parallel. Each local is dispatched to one or the other based on its type.

Register Assignment

The next available register depends on the procedure kind:

Procedure kind First available register
leaf (with parameters) r0 + number of parameter register slots
leaf interrupt (no parameters) r0

Types assigned to registers: INTEGER, CHAR, SET, REAL, BYTE, POINTER.

Types assigned to the stack: BOOLEAN, any array, any record.

Stack Assignment

BOOLEANs, arrays, and records are placed on the stack, bottom-up from [sp+0], in the order of their declarations:

Type category Rule
BOOLEAN 1 byte; byte-packed with adjacent BOOLEANs
any array word-align current offset, then rounded-up size
any record word-align current offset, then record size

Example

Leaf procedure with two INTEGER parameters consuming r0 and r1:

PROCEDURE* P(p0, p1: INTEGER);
  VAR l0: R0; i: INTEGER; b0: BOOLEAN; la: ARRAY 3 OF INTEGER; p: PP0;

Registers:

p0 -> r0 (assigned by caller)
p1 -> r1 (assigned by caller)
i  -> r2
p  -> r3
(l0 skipped -- record; b0 skipped -- BOOLEAN; la skipped -- array)

Stack:

l0: R0 (8 bytes)         [sp+0],  8 bytes
b0: BOOLEAN              [sp+8],  1 byte
(pad 3)                  word-align for la
la: ARRAY 3 OF INTEGER   [sp+12], 12 bytes
                         total: sub sp,#24

Record Field Layout

Record fields are allocated in declaration order from offset 0, with offsets increasing. The rules are identical to local variable allocation in normal procedures.

Type category Rule
CHAR, BOOLEAN, BYTE 1 byte; byte-packed with adjacent sub-word fields
INTEGER, SET, REAL, POINTER word-align offset, then 4 bytes
array field word-align offset, then rounded-up size
nested record field word-align offset, then nested record size

The total record size is padded to a multiple of 4 bytes.

Example

R0 = RECORD
  i: INTEGER       offset 0, 4 bytes
  c: CHAR          offset 4, 1 byte
  b: BOOLEAN       offset 5, 1 byte
  x: BYTE          offset 6, 1 byte
END                pad to 8 bytes total

Nested Records

A record field that is itself a record is treated as a record-type field with its own computed size:

R4 = RECORD
  i: INTEGER       offset 0, 4 bytes
  inner: RECORD    offset 4, 8 bytes
    c: CHAR
    j: INTEGER
  END
  b: BOOLEAN       offset 12, 1 byte
END                pad to 16 bytes total

Record Type Extensions

An extended record R1 = RECORD (R0) ... END inherits all fields from R0, including non-exported fields that are invisible to the extending module.

Extension fields start at the parent’s padded size boundary. From that point, normal field allocation rules apply. Multi-level chains (R2 extends R1 extends R0) resolve recursively.

Example

R0 = RECORD
  i: INTEGER       offset 0
  c0: CHAR         offset 4
END                8 bytes (padded)
R1 = RECORD (R0)   inherits 8 bytes from R0
  c1: CHAR         offset 8, 1 byte
  (pad 3)
  j: INTEGER       offset 12, 4 bytes
  b0: BYTE         offset 16, 1 byte
END                20 bytes (padded)

Cross-Module Extensions

When extending an imported type, non-exported fields of the base type are invisible to the extending module but still occupy memory:

(* in module TypeExt1 *)
T0* = RECORD
  c0*: CHAR        offset 0
  x*: INTEGER      offset 4
  z: INTEGER       offset 8  (non-exported, occupies space)
  b0: BYTE         offset 12 (non-exported, occupies space)
END                16 bytes

(* in extending module *)
E0 = RECORD (TypeExt1.T0)   inherits 16 bytes
  ei: INTEGER      offset 16
  eb: BOOLEAN      offset 20
END                24 bytes

Type Descriptor

Each record type has a type descriptor in flash memory: 5 words (size plus 4 extension level tags). The maximum number of extension levels is 8 (configurable). The type tag for heap-allocated records is stored at pointer_address - 4.

POINTER

A POINTER variable is 4 bytes (an address). NEW(p) allocates the target record on the heap:

  • the heap grows upward from HeapStart (configured in Astrobe configuration file)
  • a 4-byte type descriptor tag is stored at the current heap top
  • record data starts at heap top + 4
  • the heap top advances by 4 + record size

Heap Layout (Per Allocation)

heapTop       [type descriptor address]   4 bytes (tag)
heapTop + 4   [record field 0]            record data starts here
...
heapTop + 4 + record_size                 new heapTop

Pointer parameters use 1 register (the address), with no type tag – the tag is on the heap, not passed as a parameter.

Cross-Module Type Resolution

When a variable uses an imported type, the compiler fully resolves it to the underlying base types at compile time. The generated code is identical to using the resolved type directly.

Pattern Resolution
x: M.T where T = BASE M.T -> BASE
x: ARRAY N OF M.T where T = BASE element M.T -> BASE
x: M.T where T = ARRAY N OF U M.T -> ARRAY N OF resolved(U)
x: M.T where T = RECORD … END M.T -> record with resolved fields
x: M.P where P = POINTER TO T M.P -> pointer to resolved(T)

Import aliases (eg. IMPORT AR1 := Array1) are transparent – the alias is resolved through the original module name.

Array Access Patterns

Element Addressing

Element type Computation
INTEGER, SET, REAL (4 bytes) base + index × 4 (LSL #2)
CHAR, BOOLEAN, BYTE (1 byte) base + index (ADD, no shift)

Multi-Dimensional Arrays

For ARRAY M OF ARRAY N OF T (or ARRAY M, N OF T):

  • outer index: base + outer_index × (N × size(T)) – uses MLA instruction
  • inner index: result + inner_index × size(T) – uses LSL + ADD

Bounds Checking

Every array access is preceded by a bounds check:

cmp index, limit
bcc ok
svc 1

The limit is the array dimension (element count). An out-of-bounds access triggers a supervisor call trap.

DWARF Debug Data Generation

The DWARF data extraction by the tools of Oberon RTK must reproduce the exact same addresses and offsets that the compiler produces. Since Astrobe does not emit DWARF or any other standard debug format, the tools reconstruct variable locations from the allocation rules documented above.

For each global variable, local variable, parameter, and record field, the tools compute addresses and stack offsets by applying these rules to the declarations parsed from the Astrobe listing files. The computed locations are then encoded as DWARF location expressions in the generated ELF executable.

Updated: 2026-03-28