Secure/Non-secure Program Structure
The structure of an S/NS program for TrustZone-enabled Cortex-M33 MCUs
Overview
This document describes the technical structure of a Secure/Non-secure (S/NS) program for the TrustZone-enabled Cortex-M33 STM32 and RP2350 MCUs.
It is the middle of three S/NS documents:
-
Secure/Non-secure Program Design – the design rules: when and why to separate a program, and the discipline for data crossing the boundary;
-
this document – the technical structure and call mechanics, shared by both MCU families;
-
the per-program example descriptions (eg. Secure (STM32)) – how a specific example is assembled from these pieces.
A newcomer can read them in that order. This document assumes the motivation covered in Secure/Non-secure Program Design.
Basic Structure
Here are the relationships and the control flows for a program consisting of both Non-secure and Secure parts.
Non-secure world Secure world
+------------+
reset ---> | Program S |
+------------+
+------------+ start | | call
| |<-----------+ V
| Program NS | +------------+
| |--------->| lib S0 |
+------------+ call +------------+
| call ^
V |
+------------+ call |
| Lib NS0 |-----------------+
+------------+
-
The program as a whole always starts with the Secure program, that is, after reset, the code must run as
Secureprivileged. The Secure code sets up all the memory separation and other resource isolation components and parameters. -
The Secure program then passes control to the Non-secure program, which is usually the actual control program.
-
The Non-secure program and its library modules can make use of the Secure library modules.
-
The Secure software can access Non-secure memory, including calling procedures, and load/store operations in SRAM (not depicted above).
-
Program development is usually organised in two projects, one for Secure and one for the Non-secure world. The projects can be staffed by the same teams, of course, but the code of the two projects is completely separated on a technical level, with the Secure side only giving interface (gateway) code to the Non-secure side, but not the Secure modules, which are never directly imported into Non-secure modules.
-
In fact, both projects each result in separate program images that are loaded into the MCU program memory at address regions with different security attributes. The Non-secure project can update its modules and the resulting program image, with the same Secure code staying unchanged in place in the physical memory. The Secure project can change its code independently as well – under certain conditions, which should become clear below.
-
The Secure code usually provides services to the Non-secure program. For this, the Secure developers provide an Oberon Non-secure interface module – part of the aforementioned gateway – that is being linked into the Non-secure program, which will then execute the protocol to invoke the corresponding procedures contained in the Secure image.
-
The Secure code is not directly accessible to the Non-secure code. TrustZone and other security components enforce a strict separation in hardware. The separation parameters and their corresponding implementation in hardware are either defined in non-volatile memory in the MCU, and loaded upon reset before the software even runs, or defined by the Secure program after reset.
TrustZone and Other Security Components
The complete security architecture of an MCU can be pretty complex. It's important to realise that TrustZone alone cannot provide the complete security – additional components are required. Usually, TrustZone only restricts the CPU and so called TrustZone-aware peripheral devices. Apart from the processor, there can be other bus managers (controllers, "masters"), and the devices that are not natively TrustZone-aware may need to be isolated from Non-secure access as well. This is where the additional security components come into play.
As said, all security measures are enforced by hardware, either in the bus itself, or by security elements between the bus and the devices. TrustZone is enforced by the AHB itself, which carries "side-band" signals to designate transactions as either Secure or Non-secure.
We'll cover some of the security components below.
TrustZone Basics
Code is Secure if it fetches instructions from a Secure address, Non-secure if it does so from a Non-secure address. The address can be in flash memory or SRAM.
TrustZone defines two major hardware units to control Secure and Non-secure addresses:
- the Implementation Defined Attribution Unit (IDAU), and
- the Security Attribution Unit (SAU).
The IDAU is a controller that is usually completely defined and fixed in hardware, ie. without configuration options by the software, while the SAU requires to be configured by (Secure) software. IDAU and SAU work in tandem.
The IDAU divides the whole address space of the Cortex-M33 into segments: Non-secure, Secure, Non-secure Callable, and Exempt. Which security attribution is provided is up to the MCU designer/vendor. The IDAU of the STM32U585 only defines address ranges for Non-secure and Non-secure Callable, but not for the other security categories. Other MCU designers make different choices.
The SAU allows to define up to eight (STM32U585) regions that are overlaid on the IDAU-defined scheme. Note that SAU regions can only "downgrade" the security level, to either Non-Secure or Non-secure Callable. Secure addresses are achieved by the base rule that with the SAU enabled, all addresses are Secure.
There's a lot more to TrustZone, which we'll not touch on here, for example:
-
the stack pointers (MSP and PSP) are banked between security states, as are their limit check registers;
-
some configuration and status registers are banked between states, some are not; in some of these registers, only single bits or bit-fields are banked;
-
the NVIC is banked, each with its own vector table, and exceptions can run as Secure or Non-secure;
-
exception handling, and the related entry stacking, needs to take into account that a Non-secure exception handler can interrupt Secure code, and therefore protect the stacked values, and clear the CPU and FPU registers.
Other Security Components
Since TrustZone has a limited range of protection, additional components are required to isolate other bus managers and certain peripheral devices, extending IDAU and SAU.
STM32
The STM32 MCUs use the flash memory controller and Global TrustZone Controllers (GTZC) to extend TrustZone.
The flash memory controller can define banks and pages as Secure. The basic set-up is achieved with two watermark regions, one for each bank. These watermarks are loaded from non-volatile flash memory at reset, and can only be changed by Secure software using a procedure using memory-mapped register access – ie. not flash memory programming – that also requires a reset, after which the MCU always starts in Secure privileged mode. A common set-up is to define flash bank1 and Secure, and bank2 as Non-secure using these watermarks.
Furthermore, the STM32 also has security configuration options for the Reset and Clock Controller (RCC) and Power Management (PWR).
RP2350
The RP2350 uses the ACCESSCTRL block, where each component of the MCU has a dedicated register.
The reset configuration is not the same for each component and peripheral device. SRAM, for example, is fully open after reset, while GPIO, the UARTS, etc. are locked down to Secure privileged. Since the MCU starts in this mode, programs that don't employ Secure/Non-secure separation, simply work without having to configure anything.
Address Aliasing
STM32
The STM32 MCUs use address alias ranges for Secure and Non-secure separation: the same hardware can be accessed via two different addresses, one Secure and one Non-secure.
RP2350
The RP2350 does not use address alias ranges.
Calling Secure Code from the Non-secure Program
This is the standard ARM TrustZone mechanism, and the one Oberon RTK uses on both the STM32 and the RP2350. The addresses in the examples below are from an STM32 layout, but the mechanism is identical on the RP2350 (see Secure/Non-secure Part 3 for the RP2350-specific alternative that RTK does not use).
Calling a procedure in Secure code from Non-secure programs or library modules entails:
-
a branch to a fixed address in so-called Non-secure Callable (NSC) memory, which contains the gateway code (often called
veneer); -
from there, a branch to the Secure procedure prologue address;
-
a return directly back to the Non-Secure code, right to the address after the procedure call, so that from a Non-secure code's perspective, a Secure call is indistinguishable from a Non-secure one.
NS world Secure world
NS memory NSC memory S memory
+--------------+ +--------------+
| Module NS | | Module S0 |
| | return | |
| |<-----------------------------------| |
| | bxns | |
| | | |
+--------------+ +--------------+
| call +--------------+ ^
V bl, blx | gateways | invoke |
+--------------+ invoke | (veneers) | b, bx |
| Module NS_S0 |--------->| |--------------+
| | b, bx | |
| | +--------------+
| |
+--------------+
-
Module
NS_S0represents Secure moduleS0in the Non-secure world. Each procedure exported fromS0is also defined inNS_S0, with exactly the same signature. -
The procedures in
NS_S0, however, implement the branch to the gateway code.
The canonical implementation uses one gateway (veneer) per NS-callable S procedure:
Non-secure world Secure world
NS memory NSC memory S memory
Non-secure program veneers module S0 Secure module S0
+-----------------+ +-----------------+ +-----------------+
| |-------->| veneer S0.v0 |---------->| procedure S0.p0 |
| |-------->| veneer S0.v1 |---------->| procedure S0.p1 |
| |-------->| ... |---------->| ... |
| | +-----------------+ +-----------------+
| | veneers module S1 Secure module S1
| | +-----------------+ +-----------------+
| |-------->| veneer S1.v0 |---------->| procedure S1.p0 |
| |-------->| ... |---------->| ... |
+-----------------+ +-----------------+ +-----------------|
^ |
| return |
+---------------------------------------------------------+
For example, if a Secure module S0 exports ToggleLED to the Non-secure world, we would need the following modules.
For the gateway/veneer:
MODULE NSC;
PROCEDURE S0_ToggleLED;
BEGIN
. 0 00C0FE000 0E97FE97F sg
. 4 00C0FE004 0F8DFB004 ldr.w r11,[pc,#4]
. 8 00C0FE008 04758 bx r11
. 10 00C0FE00A 046C0 nop
. 12 00C0FE00C 00C005095 .word 0x0C005095
END S0_ToggleLED;
END NSC.
-
A S/NS gateway must begin with the
sgsecure gateway instruction.sgputs the CPU into Secure mode, and clears bit0of the link register. Thebxnsinstruction used in the Secure code to return to the Non-secure world uses the cleared bit to recognise the return to the NS world, and puts the CPU into Non-secure mode. -
Each gateway occupies 16 bytes. In memory, NSC gateway blocks are zero-padded to 32 byte blocks.
For the NS interface module:
MODULE NS_S0;
(* Secure module: S0 *)
(* NSC base address: 00C0FE000H *)
IMPORT SYSTEM;
PROCEDURE ToggleLED*(led: SET);
BEGIN
SYSTEM.EMITH(0B002H); (* add sp,#8, fix stack *)
SYSTEM.EMIT(0F8DFB004H); (* ldr.w r11,[pc,#4] *)
SYSTEM.EMITH(04758H); (* bx r11 *)
SYSTEM.ALIGN; (* word alignment *)
SYSTEM.DATA(00C0FE001H); (* nsc target address *)
END ToggleLED;
END NS_S0.
The RP2350 Bootrom Alternative
The RP2350 provides the standard ARM TrustZone hardware, so the per-procedure veneer approach described above works on it unchanged – and this is what Oberon RTK uses. The veneers are generated by gen-secure exactly as for the STM32; the NSC gateway block sits with the Secure code, and the SAU marks its address range as Non-secure Callable. Partition and loading specifics are covered in Build & Load: RP2350 (S/NS).
The RP2350 also offers a non-standard alternative built into the bootrom: a dispatcher (or handler) approach. If you're familiar with trap handlers in operating systems and kernels you'll recognise the pattern. Oberon RTK does not use it – the mechanism, and the reasons against it, follow.
Non-sec world Secure world
NS memory NSC memory S memory
Non-secure part of
program bootrom Secure module S0
+-----------+ +----------+ call +-----------------+
| | | | /<---->| procedure S0.p0 |
| | call | | call +---------+ |<---->| procedure S0.p1 |
| |<------->| 1 veneer |<----->| handler |--|<---->| ... |
| | return | | ret +---------+ | +-----------------+
| | | | | | Secure module S1
| | +----------+ +---------+ | +-----------------+
| | | lookup | |<---->| procedure S1.p0 |
| | | table | \<---->| ... |
+-----------+ | | ret +-----------------|
+---------+
There is basically one veneer, implemented in the bootrom, and the Secure program installs one handler that this single veneer calls when the Non-secure program wants to call a Secure procedure:
-
set_rom_callbackinstalls the handler (Secure side); -
secure_callinvokes a Secure procedure from the Non-secure side, via the handler; -
the bootrom sets SAU region 7 to designate the one veneer as NSC.
The handler receives four 32-bit parameters and a dispatch key, and looks up the target Secure procedure by that key. The lookup scheme (linear search, indexed array, CASE) is the Secure programmer's responsibility.
Why Oberon RTK Uses the Standard Veneers
-
Type safety. The bootrom API carries four untyped 32-bit values plus a key (in Oberon they become
INTEGER, the generic 32-bit stand-in, since a type is required); the matching procedure signature – and with it Oberon's static type checking – is lost at the most security-critical boundary in the system. Standard veneers keep the exact signature on both sides. -
Parameter passing. The dispatcher is limited to four parameters, following ARM's CMSE convention (NS-to-S calls use
R0-R3only). Oberon's calling convention uses up toR0-R11, twelve words; the generated veneers exploit this, the dispatcher cannot. -
Real-time predictability. A veneer is a direct branch with constant, minimal overhead, a handful of instructions, regardless of which procedure is called. The dispatcher path – bootrom trampoline, handler entry, key lookup, indirect call, return chain – is longer and less predictable, even with an O(1) lookup.
-
Attack surface. Each veneer gateway is self-contained and does the minimum (
SGplus a branch). The dispatcher concentrates all Secure entry into one handler that must be correct for every call pattern – a harder verification target.
The bootrom dispatcher's appeal is ease of adoption: no veneer-generation tooling, and a single ROM-resident gateway whose NSC region and SAU configuration are preset. For the RP2350's primary audience that is a reasonable trade. For systems programming with Oberon's type discipline and real-time requirements, the standard veneers are the better fit.
Note that the dispatcher does not save a flash partition: both approaches load the Secure and Non-secure images into two partitions (with the standard-veneer approach, the NSC gateways are co-located with the Secure code). Partitions here are a consequence of loading separate images from UF2 files, not of the call mechanism – and the standard-veneer images do not inherently depend on the partition machinery the way the bootrom mechanism does.
For the prototypes behind this decision, see Secure/Non-secure Part 3 (the bootrom-dispatcher experiment) and Secure/Non-secure Part 4 (the standard-veneer experiment with separate partitions).
Last updated: 27 May 2026