Secure/Non-secure Program Design
This guide covers the rules, guidelines and principles for structuring Astrobe Oberon programs that use ARM TrustZone Secure/Non-secure separation on Cortex-M33 processors.
Two scenarios are considered throughout:
- Cooperative (“friendly”): the Secure and Non-secure sides are developed together. NS uses the generated interface modules as intended and does not tamper with compiler output. The goal is robustness through separation, not defence against attack.
- Adversarial (“hostile”): the Secure side must be resilient against a compromised or malicious NS image. S must assume NS can supply arbitrary register values, modify NS memory at any time, and attempt to extract Secure data or to influence Secure execution.
Both scenarios share the same hardware boundary. The difference is in what the S programmer must defend against.
Everything stated about the cooperative scenario in this document depends on the NS_ interface modules being up to date with respect to the S side. Any change to exposed S modules – types, constants, or procedure signatures – mandates running gen-secure again before recompiling the NS program. Stale NS_ modules invalidate the layout agreement that the cooperative scenario relies on.
NS_ modules are generated using gen-secure.
The Network Analogy
A useful way to think about S/NS separation is as communication between two programs across a data network. Both sides agree on data structures (the NS_ interface modules serve as the protocol specification), but across the boundary these structures are reduced to raw data elements – register values and memory contents – with no language-level type safety.
Data received from the other side must be checked and re-allocated to local programming-level structures before processing, exactly as with network packets:
- No cross-image type checking. Just as two programs on different machines do not share a compiler, S and NS have no cross-image type checking. The generated NS_ modules are an agreed protocol, not a shared type system.
- Copy-in is natural (adversarial). Nobody operates directly on a network receive buffer; the data is parsed into local structures first. In the adversarial scenario, the same applies – S copies NS data into local variables before use.
- Validation depends on trust. In the adversarial scenario, validation is mandatory – treat all received data as potentially hostile. In the cooperative scenario, the caller ensures data consistency before the call, the same as for any procedure call.
- Overhead depends on scenario. The adversarial scenario incurs copy-in and validation cost. The cooperative scenario can avoid this when S provides lightweight control services with scalar parameters.
The key difference from typical network programming: the memory layout is the same on both sides. This is significant for the cooperative scenario (see below).
Compiler Support at the Boundary
The compiler provides layout agreement, not data integrity.
In the cooperative scenario, NS uses the generated NS_ modules, which contain the same type definitions as the S side. Both sides are compiled from matching source, so the compiler independently produces identical memory layouts:
- Field offsets are the same
- Record sizes are the same
- Array element layout is the same
- The compiler generates correct field access code on both sides
This is meaningful support: S can rely on the fact that a RECORD passed from NS has the expected structure, because the NS compiler placed the fields at the correct offsets from the same type definition. The cooperative scenario can rely on this layout agreement.
In the adversarial scenario, layout agreement is irrelevant. The data could be hand-crafted bytes with no compiler involved. Correct structure is guaranteed only after S validates it.
The distinction matters for understanding which rules apply where:
- No type extension applies in both scenarios. This is a structural limitation – type tests always fail across separately compiled images, regardless of trust.
- Open arrays work in the cooperative scenario (the hidden length is a plain integer, correctly generated by the compiler). In the adversarial scenario, the NS-supplied length is unverifiable and open arrays are not permitted.
- POINTER TO RECORD works in the cooperative scenario for concrete record types (no type extension). In the adversarial scenario, the pointed-to data must be copied and validated.
- Copy-in and field validation apply in the adversarial scenario only. In the cooperative scenario, layout agreement and caller-side synchronisation make them unnecessary.
When to Use S/NS Separation
TrustZone S/NS separation imposes constraints on parameter types and API design. The weight of these constraints depends on the scenario:
- Cooperative: the main type restriction is no type extension across the boundary (a structural limitation – type tests always fail across separate images). Open arrays, POINTER TO RECORD (with concrete types), and RECORD parameters all work correctly thanks to compiler-generated layout agreement (see Compiler Support at the Boundary). Data consistency is the caller’s responsibility – the same discipline as for any procedure call. If NS ensures that shared data is stable before calling S (disabling interrupts, using a mutex, or simply not sharing the data with interrupt handlers), S can operate directly on NS memory without copy-in.
- Adversarial: full copy-in, validation of every field, every pointer, every array index. The S programmer must treat all NS-supplied data as potentially hostile.
S/NS separation is justified when:
- hardware-enforced memory isolation is a genuine requirement (protecting cryptographic keys, secure boot state, privileged peripheral access);
- regulatory or certification requirements demand it;
- the system has distinct trust domains (eg. user-updatable firmware running alongside a fixed secure core);
- protecting fundamental hardware infrastructure (clocks, memories, power) from application-level failures – see Infrastructure Protection below.
It is not justified as a substitute for careful programming when simpler isolation mechanisms (MPU regions, privilege separation) would suffice.
Infrastructure Protection: S as a Sandbox
There is, however, a cooperative use case where S/NS separation is lightweight and natural: protecting the fundamental hardware infrastructure from a misbehaving NS application.
Microcontroller programs often operate in harsh environments – signal noise, unreliable connectors, degraded sensors. Data acquisition and processing must cope with that, but there are limits. Additionally, non-trivial programs will most likely contain programming defects that can cause the same ill effects. A corrupted value propagating through the program – whether from a noisy sensor or a defect – can cause a runaway condition: stuck loops, wild pointer dereferences, writes to arbitrary peripheral registers. What must not happen is that such a failure impedes the processor’s ability to function at all: clock tree configuration, SRAM controller settings, flash wait states, power management.
S/NS separation can protect exactly this. The Secure program, which always runs first after reset:
- performs the fundamental hardware setup (clocks, memories, power, peripheral access control);
- defines precisely which resources are available to NS – if a peripheral is not used by the application, it is blocked from NS access entirely (via SAU/GTZC, enforced in hardware);
- optionally provides a small set of S calls for NS to control specific functions such as sleep states or watchdog servicing.
The NS program remains the application – the control program as it exists today, with full access to its assigned peripherals and memory. S defines a sandbox: the boundaries within which NS operates.
This use case avoids the overhead concerns:
- No data-heavy S services. The S API surface is small: clock configuration, sleep control, peripheral gating. All scalar (base type) parameters, no RECORDs crossing the boundary, no copy-in needed.
- Infrequent calls. These are usually system setup and state transitions, not on the data processing path.
- Natural reset ownership. S runs first after reset and owns the hardware initialisation sequence – no change from how the setup code works today, except that it stays protected after NS takes over.
If NS crashes, a watchdog or S-controlled reset recovers the system. The fundamental hardware configuration cannot be corrupted because it is in Secure memory, inaccessible to NS regardless of what NS does.
The S/NS Boundary: What Crosses
At the hardware level, NS calls S through Non-secure Callable (NSC) veneer code containing SG (Secure Gateway) instructions. The processor transitions to Secure state. S returns to NS via BXNS, which transitions back.
What crosses the boundary:
- Register values (R0–R11): all parameters, passed by the Oberon calling convention. These are the only values S receives directly from NS.
- NS memory references: RECORD and ARRAY parameters are addresses in NS memory. S must fetch the data from there.
- Procedure return values: S returns results in register R0. All other registers must be sanitised before the transition to NS (see Register Sanitisation below).
What does not cross:
- The stack: Oberon passes all parameters in registers, never on the stack. This eliminates the stack-spill vulnerability that motivates CMSE’s four-register restriction in C.
- Type descriptors: the compiler’s hidden type tag is a flash address that cannot be verified across the boundary.
- Heap metadata: pointer-to-record type tags reside in the heap prefix, inaccessible to meaningful validation.
Data Types at the Boundary
Scalars: INTEGER, SET, REAL, CHAR, BYTE, BOOLEAN
Base type parameters (32-bit) are safe in both scenarios. The value is in a register; there is no memory reference, no copy needed, no TOCTOU (Time Of Check to Time Of Use) concern.
Prefer base types for all boundary parameters. If BYTE, CHAR, or BOOLEAN is used, S should explicitly “mask off” the unused bits (the Oberon equivalent of ARM’s recommendation to prefer uint32_t at the boundary, avoiding sub-word narrowing concerns).
RECORD Parameters
A RECORD parameter means S receives an address (one register) pointing to NS memory, plus a hidden type tag (another register).
Cooperative scenario – S can operate directly on the NS memory, provided NS ensures data consistency before the call (the same discipline as for any procedure call – disabling interrupts, using a mutex, or not sharing the data with interrupt handlers). An NS interrupt modifying shared data during a procedure call is a defect regardless of S/NS separation; the responsibility to prevent it lies with the caller.
Adversarial scenario –
S must copy the record into a local variable before use (see the
copy-in pattern below). S validates every field of the local copy.
Field values could be anything. POINTER fields within the record
require separate Secure.IsNonSecure validation.
No Type Extension Across the Boundary
Type tests (IS, CASE, type guard) do not work across the S/NS boundary. This is not merely a security recommendation – it is a hard technical constraint arising from how the compiler implements type tests.
The hidden type tag passed alongside RECORD parameters is the address of the type descriptor in the caller’s flash image. Type tests work by comparing this address against the type descriptor address in the callee’s image, computed via PC-relative arithmetic. Since S and NS are separately compiled images loaded at different addresses, their type descriptors are at different locations. The comparison always fails, regardless of whether the types are structurally identical.
Concretely: if NS passes a SomeTypeExt record to an S procedure
that declares the parameter as SomeType, the type tag from NS points
to the NS_S0 type descriptor (eg. 008100388), while S computes its
own SomeTypeExt descriptor address (eg. 00C000398). The type tests
compares these two addresses and always produce FALSE. This happens
even in the cooperative scenario with no tampering.
Rule: no extended type parameters across the S/NS boundary. Each exposed S procedure takes a specific, concrete RECORD type. No base types that rely on IS, CASE, or type guard to determine the actual type. S must know the exact type and size at compile time.
This applies in both scenarios. It is a structural limitation of the separate images, not a trust issue.
Impact on framework patterns: type extension hierarchies (eg. TextIO.DeviceDesc extended by UART.DeviceDesc) cannot span the boundary. The type extension lives entirely within S.
No Private Fields in Boundary Records
Non-exported RECORD fields provide no protection at the S/NS boundary.
gen-secure emits complete RECORD definitions – all fields, exported and
non-exported – because the NS compiler needs the full type layout (size,
field offsets) to generate correct code.
This applies to the entire extension chain. If a base record type defined in one Secure module has non-exported fields, and an extension defined in another Secure module adds further non-exported fields, all of them are visible in the generated NS_ stub modules. NS code can access non-exported fields by direct memory writes – as is always possible, even without S/NS separation.
Requiring all fields to be exported enforces a clean separation between S and NS data. A boundary record contains only data that is meant to cross the boundary – nothing more. Data that belongs to S stays in S-internal structures, never exposed in a boundary record.
In the adversarial scenario, non-exported fields additionally risk leaking Secure data to NS, and provide known offsets for targeted manipulation of S-side logic.
Rule: all fields in records that cross the S/NS boundary must be exported. This makes the visibility explicit and enforces the principle that boundary records carry only boundary data. If a field should not be visible to NS, it does not belong in a boundary record – it belongs in an S-internal data structure.
POINTER Parameters
S receives the pointer value (an NS address) in a register. The same rules as RECORD apply: in the cooperative scenario, S can operate directly on NS memory if the caller ensures consistency; in the adversarial scenario, S must copy, validate, and use the local copy.
The same type extension restriction applies. With POINTER,
the type tag is stored on the heap in the record’s hidden prefix
(written by NEW on the NS side) rather than passed in a register.
But the tag is still an NS-side type descriptor address. When S reads
the hidden prefix and compares against its own type descriptor, the
addresses are different – same structural failure as with plain
RECORD parameters. Type tests do not work regardless of where the
tag is stored.
ARRAY Parameters
Open arrays (PROCEDURE Foo(VAR a: ARRAY OF BYTE)): the array
length is passed from NS as a hidden parameter in a register.
Cooperative scenario: the compiler generates the correct length value from the actual array declaration. Unlike the RECORD type tag (which is an address that fails structurally across images), the array length is a plain integer – no address comparison involved. Open arrays work correctly in the cooperative scenario.
Adversarial scenario: S has no way to verify the NS-supplied length. A hostile NS could pass an arbitrary value, causing S to read or write beyond bounds. Rule: no open array parameters across the S/NS boundary in the adversarial scenario.
TYPE-defined arrays (TYPE Buffer = ARRAY 256 OF BYTE): the size is
fixed and known at compile time via SYSTEM.SIZE(Buffer). Safe in
both scenarios – cooperative: S can operate directly if NS ensures
consistency; adversarial: copy, validate, use.
PROCEDURE Type Parameters (Callbacks)
A PROCEDURE type variable is a code address. When NS passes one to S (eg. as a callback), S receives the address in a register.
S calls the NS procedure via BLXNS, which transitions the processor to NS state. All subsequent instruction fetches and data accesses are NS transactions. The callback runs as ordinary NS code and cannot access Secure memory.
Validation: S must verify the procedure address is in NS memory before calling it. Only the entry point needs checking – the hardware prevents NS-state instruction fetches from S regions.
No copy step is needed. The code executes in place in NS memory, in NS state.
The Copy-in Pattern (Adversarial Scenario)
In the adversarial scenario, S never operates on NS data directly. For any reference parameter (RECORD, ARRAY, POINTER), the pattern is:
- determine the size:
SYSTEM.SIZE(ConcreteType)(compile-time); - check the NS address range (hardware attribute, cannot change asynchronously);
- copy the data into a secure-local variable (assignment);
- validate the local copy (field ranges, invariants, pointer fields);
- operate on the validated local copy
This ordering eliminates TOCTOU. It does not matter if NS changes the data during the copy. S validates and uses only the secure-local snapshot. If the copy caught inconsistent data (half-old, half-new), validation rejects it. If it passes, it is safe – it is now in secure memory and cannot be modified externally.
In the cooperative scenario, this pattern is not required. Data consistency is the caller’s responsibility – the same as for any procedure call within a single program. If NS ensures that shared data is stable before calling S (by disabling interrupts, using a mutex, or not sharing the data with interrupt handlers), S can operate directly on NS memory, but should ensure that the data address range is within NS memory anyway. An NS interrupt modifying data during a procedure call is a programming error regardless of S/NS separation.
Overhead Assessment
The copy-in pattern (adversarial) imposes runtime cost proportional to the data size:
- Per-call overhead: one
SYSTEM.SIZE(free, compile-time), one TT instruction (1–2 cycles), one record assignment (compiler-generated bounds-safe copy loop) - Validation overhead: depends on field count and complexity
- Memory overhead: one local copy per reference parameter, on the secure stack
For small records (8–32 bytes), the overhead is negligible. For larger data transfers, the copy cost is proportional to size and unavoidable – it is the price of TOCTOU safety.
The cooperative scenario avoids this overhead entirely when the caller ensures data consistency.
Opaque Handles Instead of Pointers
S must never expose POINTER values (Secure memory addresses) to NS. Instead, use flat INTEGER identifiers that NS passes back to S, where S translates them to internal pointers.
General Pattern
Any S-side data structure that holds objects in an array or table can expose integer indices as opaque handles to NS. S validates the index (bounds check) and translates to the internal pointer. This applies to threads, devices, channels, timers, or any managed resource.
Case Study: Kernel Thread Identity
The Oberon RTK Kernel uses dual identity for threads:
Thread= POINTER TO ThreadDesc – internal S pointertid= INTEGER – index intoCoreContext.threads[]
In a S/NS split (Kernel on S, application on NS):
- Insecure: NS receives and passes back
Kernel.Threadpointers. This leaks Secure heap addresses. - Secure: NS only sees
tid: INTEGER. S translates viathreads[tid]. Validation is a bounds check.
Case Study: TextIO / UART Device Identity
The RTK I/O stack uses type extension:
TextIO.DeviceDesc– empty base recordUART.DeviceDesc– extends TextIO, adds hardware fieldsUARTstr.PutString(dev: TextIO.Device; ...)– type guard to UART.Device
In a S/NS split (UART/TextIO/UARTstr on S):
- Insecure: NS passes
UART.Devicepointers and type extension crosses the boundary. - Secure: NS identifies a terminal by integer key. S looks up the device internally. No pointer, no type extension crosses the boundary.
Secure Pointers and Return Values
S procedures exposed via veneers must not return pointers to Secure memory. Return values must not leak Secure addresses.
Never write Secure addresses into NS-accessible memory (shared buffers, NS RAM). This leaks address layout information.
Register Sanitisation
Every transition from Secure to Non-secure state can leak Secure data through registers that still hold values from Secure execution. There are two such transitions:
- Return from S procedure (BXNS after an NS-to-S call via veneer)
- Initial launch of the NS program (S branches to NS entry point after completing hardware setup)
Both require register sanitisation. The current version of Astrobe does
not yet support Secure compilation, so all sanitisation must be inserted
manually by the S programmer, or using the tool sec-epilogue.
Registers to Clear
ARM’s CMSE specification (requirement 45) defines what must be sanitised before returning to NS. The same applies to the initial NS launch:
- General-purpose registers R0–R12: clear all except those holding return values. For the initial NS launch, clear all except any register holding the NS entry address.
- LR: set to a safe value (typically
0xFEFFFFFF, the standard non-secure return sentinel, or cleared to zero). - APSR flags (N, Z, C, V, Q, GE bits): clear to prevent leaking information about Secure computations. On Armv8-M (Cortex-M33), bits [27:0] of xPSR must be cleared.
- FP registers (S0–S31 / D0–D15): clear all if the FPU was used during Secure execution. The SFPA bit in the CONTROL register indicates whether Secure code has used the FPU; if set, all FP registers must be cleared.
- FPSCR bits [27:0]: clear to prevent leaking FP status from Secure computations.
Initial NS Launch
When S launches the NS program for the first time, the same register set must be sanitised. This is easy to overlook because it is not a “return” – it is a forward branch. But from the hardware’s perspective, the transition to NS state exposes whatever is in the registers, including values left over from S’s clock setup, memory configuration, or peripheral initialisation.
The recommended pattern:
- complete all S-side hardware setup;
- clear all general-purpose registers, APSR flags, FP registers, FPSCR;
- load the NS entry address (from the NS vector table);
- branch to NS (typically via BXNS with the NS reset handler address).
Calling NS Callbacks (BLXNS)
When S calls an NS procedure via BLXNS (eg. a callback), the same principle applies in both directions:
- Before the call: clear all registers except those holding the call arguments, to prevent leaking Secure data to the NS callback;
- After return: the NS callback will have used, and could have placed any values in registers; S must not trust their contents.
Data Protection Responsibility
No type system provides security enforcement across separately compiled images connected only at runtime via hardware call gates. In particular:
- non-exported RECORD fields are hidden from the NS compiler but
accessible via
SYSTEM.GETat known offsets; - a RECORD passed from NS to S could contain arbitrary data;
Secure data protection is 100% the S programmer’s responsibility.
Bus Transaction Security Attributes
TrustZone determines the security attribute of every bus transaction based on the target address, not only the processor’s current security state. The SAU (Security Attribution Unit) and IDAU (Implementation Defined Attribution Unit) define the address S and NS ranges, and the bus matrix assign the transaction as Secure or Non-secure accordingly.
Beyond SAU and IDAU, MCU implementations typically provide additional configuration registers that control security attributes for specific peripherals, memory regions, or bus masters. Examples include GTZC/TZSC on the STM32U585 and ACCESSCTRL on the RP2350. The S programmer must consult the MCU reference manual for the full set of security configuration mechanisms.
This has a key consequence: Secure code accessing a peripheral via an address marked as Non-secure produces an NS-attributed bus transaction – even though the processor is in Secure state. The peripheral sees a Non-secure access.
S/NS Address Aliases (STM32U585)
Some implementations provide dual address aliases for peripherals. The
STM32U585, for example, maps GPIOH at both a Secure alias (52021C00H)
and a Non-secure alias (42021C00H). Secure code choosing which alias to
use effectively controls the transaction’s security attribute.
This mechanism allows S to configure a peripheral as Secure-only (by not enabling the NS alias in the security controller), or to share it with NS by enabling both aliases – with the bus transaction’s security attribute determined entirely by which address is used, not by who is executing.
Implementations Without Aliases (RP2350)
Not all implementations provide S/NS address aliases. The RP2350, for example, has a single address for each peripheral. The security attribute of an access to that peripheral is fixed by the security configuration (SAU/IDAU and ACCESSCTRL). Secure code cannot choose to make an NS-attributed access by using a different address – there is no alternative address to use. Peripheral sharing between S and NS must be configured entirely through the security controller registers.
The S programmer must be aware of whether the target MCU provides address aliases, as this affects how peripherals can be shared between S and NS.
Summary of Rules
Both Scenarios
| Rule | Rationale |
|---|---|
| All parameters via registers (R0–R11) | Satisfied by design; no stack crossing |
| No type extension across the boundary | Type tests fail structurally across separate images |
| All fields in boundary records must be exported | Non-exported fields give false sense of privacy |
| No Secure pointers exposed to NS | Prevents address layout leakage |
| Prefer 32-bit basic types at the boundary | Avoids sub-word narrowing concerns |
| Use opaque integer handles for resources | Replaces pointer passing |
| Sanitise registers on every S-to-NS transition | Prevents Secure data leakage |
| Sanitise registers before initial NS launch | Same transition, same leakage risk |
Cooperative Scenario (Additional)
| Rule | Rationale |
|---|---|
| NS ensures data consistency before calling S | Same discipline as any procedure call |
| S can operate directly on NS memory | No copy-in needed when caller synchronises |
Adversarial Scenario (Additional)
| Rule | Rationale |
|---|---|
| No open array parameters | NS-supplied length is unverifiable |
| S never operates on NS data, only copies | Eliminates TOCTOU |
| Validate NS address range before copy-in | via TT instructions |
SYSTEM.SIZE(Type) for copy size |
Compile-time, correct for concrete types |
| Validate every field of the local copy | NS data can be anything |
| Validate POINTER fields within RECORDs separately | Each is an independent address |
| Validate PROCEDURE entry points | via TT instructions |
| Bounds-check all integer handles | Index could be out of range |
| Do not trust any NS-supplied value | Treat all input as potentially hostile |