Memory Allocation
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] = lrM 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,#60Locals – 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,#24Record 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 totalNested 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 totalRecord 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 bytesType 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 heapTopPointer 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 1The 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.