For Applications & Industries
Free Online Training Events
Free Technical Resources
The full project code is available on Doulos' GitHub repository at https://github.com/Doulos/Rust-TrustZone.
A TrustZone-M system is not a single firmware image with security features bolted on. It is two independent binaries, a Secure image and a Non-secure image, that run on the same core but occupy separate, non-overlapping regions of flash and SRAM. The relationship between them is asymmetric. The Secure image is built first and is entirely oblivious to what the Non-secure application contains. The Non-secure image links against a small import library (veneer.o) produced by the Secure build, but treats it as an opaque collection of external symbols. From the Non-secure linker's perspective, secure_increment is just a function at an address; the fact that calling it crosses a hardware security boundary is invisible at the source level. What both images must agree on is the partition layout: the flash and SRAM boundaries encoded in their respective memory.x files must be consistent with what the SAU and GTZC are programmed to enforce at runtime. A mismatch there surfaces as a fault, not a linker error.

This post covers how the STM32L552 (See Figure 1) address space is divided between the two worlds, how each image is positioned by its linker script, how the GTZC and SAU are initialised to match that layout, and how the Secure world hands execution to the Non-secure reset handler. The CMSE calling conventions and veneer mechanics that make cross-world calls possible are covered in posts 3 and 4. The build system that compiles and links both images in the right order is covered in post 6.
The STM32L552 has 512 KB of flash. ST-Microelectronics maps this flash array to two separate address ranges: a Non-secure range starting at 0x08000000 and a Secure range starting at 0x0C000000. Both ranges address the same physical flash, but the hardware enforces different security attribution depending on which range is accessed. This is an ST-specific implementation detail. The ARMv8-M architecture defines the security attribution rules, but the decision to expose the same flash array at two address ranges is a choice ST made in the STM32L552 memory map. Secure code in this project is linked at 0x0C000000 throughout. A Non-secure access to any address in the 0x0C range faults immediately; the Non-secure world has no path to Secure flash through this range regardless of what the SAU says about the 0x08 range.
The 512 KB of flash is partitioned as follows:
Region | Address range | Size | Address range type |
|---|---|---|---|
Secure flash |
| 248 KB | Secure ( |
NSC veneer table |
| 8 KB | Secure ( |
Non-secure flash |
| 256 KB | Secure ( |
The NSC region sits at the top 8 KB of Secure flash, immediately before the Non-secure slot begins. Placing it at this boundary keeps the veneer table at a fixed, predictable address that the Non-secure linker can resolve against, and it avoids scattering callable entry points across Secure flash.
SRAM is split across SRAM1 (192 KB) and SRAM2 (64 KB):
Region | Address range | Size |
|---|---|---|
Secure SRAM1 |
| 96 KB |
Non-secure SRAM1 |
| 96 KB |
Non-secure SRAM2 |
| 64 KB |
SRAM2 is entirely Non-secure. The Secure world has no need for it, and leaving it undivided avoids any ambiguity about ownership.
The two memory.x files encode this layout directly. The Non-secure script is deliberately minimal:
{
FLASH : ORIGIN = 0x08040000, LENGTH = 256K
RAM : ORIGIN = 0x20018000, LENGTH = 96K
}
The Secure script carries more weight:
{
/* Full secure flash alias (256K, 0x0C000000-0x0C03FFFF).
cortex-m-rt's link.x places .gnu.sgstubs into this region and
build.rs pins it at 0x0C03E000 via --section-start, so the FLASH
region must cover the whole 256K. The final 8K (0x0C03E000-0x0C03FFFF)
is reserved for NSC veneers; lld will error on section overlap if
.text/.rodata ever grows past 248K into the veneer window. */
FLASH : ORIGIN = 0x0C000000, LENGTH = 256K
/* NSC veneer sub-window, overlapping the top 8K of FLASH.
Defined here for __nsc_start/__nsc_end symbols and the ASSERTs below.
Must match SAU region 0 in partitions.rs. */
NSC : ORIGIN = 0x0C03E000, LENGTH = 8K
/* Secure SRAM alias for SRAM1[0..96K). Same physical memory partition
described by GTZC/SAU as 0x20000000-0x20017FFF. */
RAM : ORIGIN = 0x30000000, LENGTH = 96K
}
_stack_start = ORIGIN(RAM) + LENGTH(RAM);
__nsc_start = ORIGIN(NSC);
__nsc_end = ORIGIN(NSC) + LENGTH(NSC);
ASSERT(__veneer_base >= __nsc_start, "NSC veneers start before NSC region");
ASSERT(__veneer_limit <= __nsc_end, "NSC veneers exceed NSC region");
The Secure RAM origin is 0x30000000. As with flash, ST maps the SRAM1 array to two separate address ranges on the STM32L552: a Non-secure range starting at 0x20000000 and a Secure range starting at 0x30000000. Linking the Secure stack and data at 0x30000000 means a Non-secure access to that address range faults before it even reaches the GTZC block-level check.
The NSC region in the Secure memory.x is not an output region for .gnu.sgstubs. cortex-m-rt's generated link.x includes .gnu.sgstubs in the FLASH region but does not pin it to a specific address. Without intervention, the linker would place the veneer section wherever it fits after the other sections. The --section-start=.gnu.sgstubs=0x0C03E000 flag in build.rs overrides this by giving the section an absolute address, ensuring the veneer table always lands at the same location regardless of how the rest of the Secure image grows or shrinks.
println!("cargo:rustc-link-arg=--section-start=.gnu.sgstubs=0x0C03E000");The NSC region exists for two reasons: it documents the intended veneer range in the linker script itself, and it enables the two ASSERT statements that verify at link time that the generated veneers land inside it. If build.rs and memory.x ever disagree on the NSC address, the build fails. Silently mispositioned veneers are harder to diagnose than a build error. They produce a system that resets correctly but faults the moment Non-secure code attempts a cross-world call. The build.rs mechanics are covered in post 6.
Both the SAU and the GTZC must be programmed to match this layout before the Non-secure world starts. The SAU regions were shown in post 1; the GTZC block-vector configuration is covered later in this post. The next section covers the boot sequence and the order in which that initialisation runs.
The Secure reset handler runs first. This is a consequence of how TrustZone-M works at reset, not a configuration choice. The processor starts in Secure state, the Secure vector table is active, and the Non-secure world does not exist yet. Everything that needs to be in place before Non-secure code runs must be set up here, in the right order, before execution transfers.
The Secure main.rs runs the following sequence:
clocks::clock_init();
uart::lpuart1_init();
uart::lpuart1_write_str("secure: clock OK, SYSCLK = 110 MHz\r\n");
gtzc::gtzc_init();
partitions::sau_init()
The order matters and each step depends on the previous one. The first call emits a semihosting trace message confirming Secure entry. clock_init then runs before any other peripheral initialisation, because the STM32L552 starts on the MSI oscillator at 4 MHz after reset. The PLL configuration targets 110 MHz SYSCLK, and flash wait states are raised inside clock_init before the switch. If that order were reversed, the CPU would execute instructions from flash at a frequency the flash controller cannot sustain at its reset wait-state setting. LPUART1's baud rate calculation depends on PCLK1, so the UART cannot be initialised until the PLL is stable.
lpuart1_init follows. LPUART1 is kept Secure in this project. Its security bit is set in the GTZC TZSC configuration, and it is accessed through the Secure peripheral bus address at 0x5000_8000 rather than the Non-secure peripheral bus address at 0x4000_8000. From Secure state both addresses are reachable, but using the Secure bus address makes the intent explicit and is consistent with the rest of the Secure crate. Initialising LPUART1 early gives the Secure world a working serial output for the rest of the boot sequence, which is useful during bring-up.
gtzc_init runs before sau_init. The GTZC block-level configuration must be in place before the SAU opens Non-secure windows into SRAM. If the SAU were enabled first, there is a brief interval during which a Non-secure address is attributable as Non-secure by the SAU but the corresponding GTZC MPCBB blocks have not yet been configured. gtzc_init itself ends with DSB and ISB barriers, ensuring every GTZC write (the TZSC security configuration and both MPCBB block vectors) has actually reached the bus fabric before sau_init runs and the SAU starts permitting Non-secure accesses.
sau_init runs last. It programs the six SAU regions, enables the SAU, and issues DSB and ISB barriers. After those barriers complete, the full address map is active and the hardware boundary is enforced on every subsequent access.
With the hardware fully configured, the Secure world needs to find the Non-secure application and hand it execution. The first step is writing VTOR_NS, the Non-secure Vector Table Offset Register. On the STM32L552, the Non-secure alias of the Private Peripheral Bus sits at 0xE002_0000, placing VTOR_NS at 0xE002_ED08. The Secure world writes the base address of Non-secure flash into this register, then issues a DSB barrier:
const NONSECURE_VECTOR_TABLE: usize = 0x0804_0000;
const VTOR_NS: *mut u32 = 0xE002_ED08 as *mut u32;
unsafe {
write_volatile(VTOR_NS, NONSECURE_VECTOR_TABLE as u32);
dsb();
}
The DSB is not optional. Without it, the CPU could speculatively read the vector table before the VTOR_NS write has propagated through the memory system, producing stale values for the stack pointer and reset handler address. The barrier ensures the write is visible before any subsequent reads proceed.
With VTOR_NS set, the Secure world reads two words from the Non-secure vector table. By the ARMv8-M specification, word 0 is the initial stack pointer and word 1 is the reset handler address:
let ns_msp = unsafe { read_volatile(NONSECURE_VECTOR_TABLE as *const u32) };
let ns_reset = unsafe { read_volatile((NONSECURE_VECTOR_TABLE + 4) as *const u32) };
Before branching, the Non-secure stack pointer must be loaded into a special purpose register called MSP_NS. The MSR MSP_NS instruction is the Secure-state mechanism for writing the Non-secure main stack pointer directly, without switching worlds first. Non-secure code accesses the same register as its own MSP through the standard MSR MSP instruction; the banking is transparent to the Non-secure world. The Secure world uses the _NS suffix form here precisely because it needs to set up the Non-secure stack before handing over execution. There is no cortex-m API for this instruction at this level, so the write goes through inline assembly:
unsafe fn set_msp_ns(value: u32) {
unsafe {
asm!(
"msr MSP_NS, {value}",
value = in(reg) value,
options(nomem, nostack, preserves_flags)
);
}
}
The MSR MSP_NS instruction is only valid from Secure privileged mode. Executing it from Non-secure code or from Secure unprivileged mode faults. Here it runs during the Secure boot sequence before any privilege change has occurred, so the precondition holds.
With MSP_NS loaded, the next step is issuing an ISB barrier before branching. ARM recommends an instruction synchronisation barrier after writing MSP_NS and before any BLXNS, to guarantee that the pipeline has not already fetched instructions using a stale stack pointer value. Skipping this barrier is the kind of omission that produces an intermittent fault rather than a deterministic one, since whether the stale value actually gets used depends on pipeline timing.
if !valid_nonsecure_vectors(ns_msp, ns_reset) {
let _ = hprintln!(
"secure: invalid non-secure vectors: MSP=0x{:08X}, reset=0x{:08X}",
ns_msp,
ns_reset
);
loop {
cortex_m::asm::nop();
}
}
let _ = hprintln!("secure: jumping to non-secure world");
unsafe {
set_msp_ns(ns_msp);
isb();
let reset_handler: NonSecureReset = transmute::(ns_reset as usize);
reset_handler();
}
The last step is constructing a callable from the raw reset handler address. The address read from the vector table is a plain u32. Rust requires a typed function pointer, so transmute converts the raw value into a function pointer carrying the cmse-nonsecure-call ABI. That ABI tells the compiler to emit the specialised inter-state branch instruction BLXNS rather than a standard BLX:
type NonSecureReset = unsafe extern "cmse-nonsecure-call" fn() -> !;The transmute is unsafe for the usual reasons. The compiler cannot verify that ns_reset points to a valid function with the right signature. The correctness rests on the memory layout: the vector table is at the known base of Non-secure flash, and the Non-secure linker script places the reset handler there. What BLXNS does to processor state, clearing the security bit and transferring control to the Non-secure reset handler, is covered in post 4.
The partition works when all three layers agree: the linker scripts place each image where the hardware expects it, the GTZC block vectors match the SRAM split, and the SAU regions cover exactly the Non-secure windows the rest of the system assumes are open. A mismatch at any layer produces a fault. The linker assertions in the Secure memory.x catch one category of disagreement at build time, veneer placement outside the declared NSC range. Everything else shows up at runtime, usually as a HardFault with a non-zero SFSR, and requires a debugger and the memory map to make sense of it.
The next post covers the Secure services layer: how the Secure world exposes callable entry points through CMSE, how the compiler generates veneer stubs in the NSC region, and what the Non-secure side sees when it calls through them.
This article was written by Dr David Cabanis, principal member technical staff at Doulos and author of the book Embedded Software with Rust.