Global training solutions for engineers creating the world's electronics
Menu

What is TrustZone-M?

A Practical Introduction from a Rust no_std Experiment

The full project code is available on Doulos' GitHub repository at https://github.com/Doulos/Rust-TrustZone.

Introduction

Most production microcontrollers run mixed-trust firmware. A device might store a private key, verify a firmware signature at boot, or implement a cryptographic attestation protocol, while the same binary also runs application code that talks to sensors, drives a display, or handles network communication. These concerns have different trust requirements. Key material should not be reachable by application code, even if that code contains a bug or has been replaced by a third party. Attestation logic should not be bypassable regardless of what happens in the application layer.

Keeping those concerns apart is harder than it looks. Without hardware enforcement, the separation exists only as a software convention. A software convention is not a security boundary.

TrustZone-M is the Arm answer to this problem: a hardware extension available on certain Cortex-M profiles that partitions the system into two execution environments, Secure and Non-Secure, with access rules enforced on every memory access and peripheral transaction. Code running in the Non-Secure state cannot read Secure memory, cannot call Secure functions through an arbitrary address, and cannot reach peripherals attributed to the Secure world. The processor checks this on every cycle.

This post is the first in a series based on a working experiment using a NUCLEO-L552ZE-Q board, which carries an STM32L552 microcontroller built around a Cortex-M33 core. The Cortex-M33 supports TrustZone-M in full. Most of what follows applies equally to any TrustZone-M capable device. The STM32L552 is the concrete reference point, not the subject.

The goal here is to explain what TrustZone-M is, what the hardware actually enforces, and where it sits relative to the isolation mechanisms most embedded developers already use. Rust and the build system come later.

Core concepts and hardware building blocks

TrustZone-M introduces a small set of concepts worth understanding precisely before looking at any code. The terminology is consistent across all Arm implementations, even though some of the hardware machinery behind it is vendor-specific.

Security states

The processor is always in one of two security states: Secure or Non-Secure (see Figure 1). This is not a software flag or a writable register. It is a hardware condition the processor tracks internally, and it changes only through defined architectural transitions. Code in the Secure state can access any memory and any peripheral. Code in the Non-Secure state can only reach addresses and peripherals explicitly attributed as Non-Secure. An attempt to cross that line does not produce wrong data or a catchable exception. It faults.

TrustZone-M NSC call and return

Figure 1. Security states and transitions

Both states can use the standard privilege model independently. The Secure world has its own privileged and unprivileged Thread and Handler modes, and so does the Non-Secure world. Security state and privilege level are orthogonal.

Security attribution

Every address in the memory map carries a security attribute: Secure, Non-Secure Callable, or Non-Secure. Two hardware units determine that attribute in combination.

The Security Attribution Unit (SAU) is an Arm-defined register block present on every TrustZone-M capable core. It holds up to sixteen programmable regions, each with a base address, a limit address, and an attribute. Any address not covered by an enabled SAU region is Secure by default. The SAU is configured by Secure-state code early in the boot sequence, before the Non-Secure world starts. In this experiment, that configuration runs before the Non-Secure reset handler is called, attributing the NSC veneer table, the Non-Secure flash slot, the Non-Secure SRAM window, the peripheral bus, the external memory space, and an additional ST-specific option-bytes alias window. Everything outside those six regions stays Secure.

TrustZone-M memory attribution

Figure 2. Secure/Non-Secure/Secure Non-Secure Callable memory regions' definition

The Implementation Defined Attribution Unit (IDAU) is an optional unit provided by the SoC vendor. Where it exists, it adds attribution that combines with the SAU output. The final attribute for any address is the stricter of the two (See Figure 2). On the STM32L552, ST-Microelectronics implements this through the GTZC (Global TrustZone Controller), which provides block-level security configuration for SRAM at 256-byte granularity and peripheral-level configuration for the bus fabric. The GTZC and SAU must agree on what is Non-Secure. If the SAU marks a window as Non-Secure but the GTZC leaves the corresponding SRAM blocks at their Secure reset default, the effective attribute remains Secure and Non-Secure accesses fault, with no obvious indication of why. The IDAU is entirely SoC-specific. Its registers, its granularity, and the mechanism for configuring it differ across vendors. The SAU interface is always the same.

The code below shows the SAU initialisation from the experiment. Six regions are configured explicitly; every other address defaults to Secure.

// secure/src/partitions.rs
//
// The SAU partitions the address space into Secure, Non-Secure Callable (NSC),
// and Non-Secure regions. Any address not covered by an enabled SAU region is
// Secure by default once the SAU is enabled.
//
// ARMv8-M alignment constraints:
//   base_address  — bits[4:0] must be 0  (32-byte aligned)
//   limit_address — bits[4:0] must be 0x1F (end of a 32-byte block)

pub fn sau_init() {
    let mut cp = unsafe { cortex_m::Peripherals::steal() };

    // Region 0: NSC veneer table in the Secure flash alias (0x0C000000 base).
    // NS code may branch here via SG instructions only. The CPU transitions to
    // Secure state only at a valid SG entry point; any other target faults.
    cp.SAU.set_region(0, SauRegion {
        base_address:  0x0C03_E000,
        limit_address: 0x0C03_FFFF,
        attribute: SauRegionAttribute::NonSecureCallable,
    }).unwrap();

    // Region 1: NS application flash (256 KB).
    // Covers the entire NS image slot in the lower flash alias.
    cp.SAU.set_region(1, SauRegion {
        base_address:  0x0804_0000,
        limit_address: 0x0807_FFFF,
        attribute: SauRegionAttribute::NonSecure,
    }).unwrap();

    // Region 2: NS SRAM window (SRAM1 upper 96 KB + all of SRAM2).
    // Must match the GTZC MPCBB configuration — disagreement causes a fault.
    cp.SAU.set_region(2, SauRegion {
        base_address:  0x2001_8000,
        limit_address: 0x2003_FFFF,
        attribute: SauRegionAttribute::NonSecure,
    }).unwrap();

    // Region 3: entire APB/AHB peripheral bus.
    // GTZC TZSC further restricts individual peripherals (e.g. LPUART1 stays Secure).
    cp.SAU.set_region(3, SauRegion {
        base_address:  0x4000_0000,
        limit_address: 0x4FFF_FFFF,
        attribute: SauRegionAttribute::NonSecure,
    }).unwrap();

    // Region 4: external memory space (FMC / OCTOSPI / SDMMC windows).
    cp.SAU.set_region(4, SauRegion {
        base_address:  0x6000_0000,
        limit_address: 0x9FFF_FFFF,
        attribute: SauRegionAttribute::NonSecure,
    }).unwrap();

    // Region 5: additional NS device/peripheral alias window used by some ST HAL
    // drivers (Option bytes bank 2 area and associated config registers).
    cp.SAU.set_region(5, SauRegion {
        base_address:  0x0BF9_0000,
        limit_address: 0x0BFA_8FFF,
        attribute: SauRegionAttribute::NonSecure,
    }).unwrap();

    // Enable the SAU. From this point every access is checked against the
    // regions above. All unlisted addresses remain Secure by default.
    cp.SAU.enable();

    // Ensure SAU writes are visible to subsequent instruction fetches and
    // data accesses before handing off to the Non-Secure world.
    dsb();
    isb();
}

Non-Secure Callable regions and secure gateways

A Non-Secure Callable (NSC) region sits between Secure and Non-Secure in the attribution hierarchy (See Figure 3). It is a small area of Secure flash, visible to the Non-Secure world, that contains the only legitimate entry points into Secure code.

Non-Secure code can branch into an NSC region, but the first instruction at the target address must be SG (Secure Gateway). If it is, the processor transitions to Secure state and continues with the remaining code inside the NSC memory block; typically a jump instruction (B.W) to the secure region of memory. If it is anything else, the processor faults. The Non-Secure world cannot jump into the middle of a Secure function, cannot call Secure code at an arbitrary address, and cannot use the NSC region as a generic trampoline. The only callable points are those the Secure image explicitly places there.

In practice, NSC regions contain short gateway stubs called veneers. The Non-Secure application calls through these stubs rather than directly into Secure flash. The location of the Secure implementation stays opaque to the Non-Secure side.

TrustZone-M NSC call and return

Figure 3. The secure, Non-secure callable region

The SG instruction also establishes the return path. When Secure code returns to its Non-Secure caller, it does so using BXNS LR with a return value whose LSB has been cleared by the SG instruction, signalling a Non-Secure return. The architecture checks this on every inter-state branch. The full calling convention and how Rust exposes it through CMSE attributes is covered in a later post.

Separate interrupt handling

Each external interrupt can be configured as Secure or Non-Secure, and the processor maintains separate vector tables for the two worlds. A Secure interrupt preempts Non-Secure execution transparently, saves a stripped context that does not expose Secure register state to the Non-Secure side, and returns cleanly when done. Fault handling across security state boundaries has its own considerations, covered in the post on the exception model.

TrustZone-M versus classic privilege separation

Embedded developers generally know two existing approaches to isolation: the Cortex-M privilege model and the Memory Protection Unit. Both are useful. Neither does what TrustZone-M does.

Privileged and unprivileged execution

Cortex-M processors distinguish between privileged and unprivileged execution modes. Privileged code can access all processor resources: system control registers, SysTick, the NVIC. Unprivileged code cannot. The separation is primarily about protecting the processor configuration from application code, not about protecting one region of application memory from another.

An RTOS typically runs its kernel in privileged mode and drops tasks into unprivileged mode before handing them execution. Embassy, as an async executor for bare-metal Rust, does not enforce this split by default. The executor and all tasks run at the same privilege level. The distinction matters mainly when a kernel needs to protect its own state from misbehaving tasks. It does not prevent one task from reading another task's memory.

The MPU

The Memory Protection Unit extends the privilege model with configurable memory regions. Each region carries attributes the hardware enforces on every access: read/write permissions differentiated by privilege level through the AP field, execute-never through the XN bit, memory type and cacheability through the TEX and C/B fields. A typical RTOS uses the MPU to give each task a bounded memory region, faulting any access outside it.

The ceiling is this: the MPU operates within a single security domain. Anything running in privileged mode can reconfigure it. If the privileged context is compromised, the protection goes with it. There is no boundary that privileged code itself cannot cross or rewrite.

TrustZone-M versus MPU isolation

Figure 4. MPU versus TrustZone-M memory isolation

Software-only isolation

Some designs use software partitioning instead: separate linker regions, access discipline by coding convention, or partitioned RTOS configurations. These can reduce the attack surface when the codebase is well controlled, but they offer no hardware enforcement. A stack overflow, a corrupted function pointer, or a bug in a peripheral driver can bypass them with no indication that anything went wrong. The protection exists only as long as the software behaves correctly, which is exactly the assumption a threat model cannot make.

Where TrustZone-M sits

TrustZone-M operates at a different level. The Secure and Non-Secure boundary is enforced by the processor on every instruction fetch and every memory transaction. No software in the Non-Secure world, at any privilege level, can reconfigure the SAU. No Non-Secure code can read Secure memory, call Secure code at an arbitrary address, or reach a peripheral attributed to the Secure world (See Figure 4). The boundary does not depend on Non-Secure software behaving well.

It is also worth clarifying what TrustZone-M is not. TrustZone-A, found in application processors such as the Cortex-A53 or Cortex-A72, is a more complex architecture designed to host a full Trusted Execution Environment (TEE) alongside a rich OS such as Linux. TrustZone-M is simpler and more constrained. There is no secure OS, no dynamic TEE management, and no equivalent of the SMC instruction. The Secure side on a Cortex-M33 is firmware you write, running bare-metal, with a fixed set of entry points. Less flexible than TrustZone-A by design, and considerably easier to reason about.

The MPU remains useful inside a TrustZone-M system. The Secure world can run its own (secure) MPU configuration to protect its internal structure; the Non-Secure world can use the (non-secure) MPU to isolate tasks from one another. Where TrustZone-M draws the outer boundary, the MPU works within each side of it.

Why it matters for Rust

Rust's memory safety guarantees are real. The borrow checker eliminates a class of bugs such as use-after-free, buffer overruns, and data races. These have historically been a reliable source of vulnerabilities in C firmware. For a team moving from C to no_std Rust, that is a meaningful reduction in attack surface.

Those guarantees are language-level, though. They hold within the bounds of what the compiler can see and verify. They say nothing about hardware boundaries, and they do not constrain what a compromised binary can do at runtime.

Embedded Rust also has a structural property worth being direct about. Firmware is not purely safe Rust. Register access, DMA buffer management, peripheral initialisation, and interrupt handling all require unsafe blocks at the hardware-interfacing layer. The cortex-m crate, the PAC layer, and most HAL implementations are built on unsafe foundations because the hardware cannot be modeled without stepping outside what the type system can verify. A bug in an unsafe block corrupts memory. The borrow checker cannot help you there.

TrustZone-M addresses a different problem. It does not make Non-Secure code safer at the language level. What it does is contain the consequences of a failure in the Non-Secure world. If the Non-Secure application is compromised, through a vulnerability, a malformed input, or a bug in an unsafe block, the Secure world is unaffected. The key material, the attestation logic, the boot verification code: none of it is reachable from the Non-Secure side, regardless of what that side does. The hardware enforces this independently of what language the Non-Secure image is written in.

Rust reduces the probability of a vulnerability. TrustZone-M limits what an exploited vulnerability can reach. The two are stronger together than either is alone.

Conclusion

TrustZone-M is not a complicated idea. A single processor, two execution environments, access rules enforced by hardware on every transaction. The Secure world holds what needs protecting. The Non-Secure world runs everything else. The boundary between them is not a coding convention or a linker script. It is enforced by the silicon.

The concepts introduced here such as security states, attribution, NSC regions and secure gateways, are the vocabulary the rest of this series builds on. The SAU and IDAU are configured once, early in boot, before the Non-Secure world starts. Getting that configuration consistent is what subsequent posts are about.

The next post moves from concepts to implementation. It covers how a real TrustZone-M system is partitioned in practice: how flash and SRAM are divided between the two worlds, how the linker scripts reflect that layout, and how execution moves from Secure reset into the Non-secure application.


This article was written by Dr David Cabanis, principal member technical staff at Doulos and author of the book Embedded Software with Rust.