Setup a QEMU based TrustZone-M development environment (Part 0)
Setting up an emulated environment for bare-metal testing
0. Intro
Recently, I have been working on an embedded project based on the RP2350 microcontroller, which features TrustZone-M support. Unlike the general software development space, many small projects in the embedded world do not allocate many resources to securing their code. Consequently, most embedded projects can be easily reverse-engineered via exposed debug ports or by simply reading the flash memory.
Since the product I am working on requires firmware encryption, certain parts of the code must be encrypted and remain inaccessible to the user. While the device allows users to execute custom code, I could theoretically enforce permission control based on privilege levels. In that scenario, privileged code would be responsible for decrypting secrets and configuring the Memory Protection Unit (MPU) to prevent user code from reading or writing to sensitive areas. User code would then interact with the secret code via a trampoline syscall.
However, this safety model requires all security measures to be implemented within the privileged code. If the syscall or the memory barrier setup has a bug, a user could easily gain access to the encryption keys. The attack surface in privileged code is simply too large. Instead of relying on a single layer of privileged/unprivileged levels, TrustZone-M allows us to create a parallel "Secure World." This separates security-sensitive functions into a distinct processor state with its own privileged levels and a significantly smaller attack surface.
1. Select the QEMU target
Why QEMU?
While the RP2350 has excellent documentation and support, flashing a Pico 2 for every minor code change is tedious. While using a debugger is possible, constantly resetting and re-flashing the hardware is inconvenient for rapid iteration. The RP2350 is an SoC featuring dual Cortex-M33 cores based on the ARMv8-M Mainline architecture.
Instead of relying solely on physical hardware, we can use QEMU to emulate the target. Since QEMU does not yet have a dedicated machine model for the RP2350, we must select a similar target that shares the M33 design. An excellent option is the mps2-an521, which mirrors the RP2350’s dual-core Cortex-M33 configuration and TrustZone capabilities.
The TrustZone-M Boot Process
In the ARM TrustZone-M boot sequence, the processor always starts in Secure Privileged mode after a reset. In this stage, the secure code acts as a primary bootloader. Its responsibilities include:
- Stack Initialization: Setting up the Main Stack Pointer (MSP) for both the Secure and Non-secure worlds.
- Resource Partitioning: Configuring the SAU (Security Attribution Unit) to define which memory regions and peripherals belong to the Secure or Non-secure domains.
- Vector Table Setup: Defining the addresses for the Secure and Non-secure vector tables before transitioning execution to the Non-secure world.
2. Start with an Empty Rust Project
First, we need to add the ARMv8-M toolchain using rustup :
$ rustup target add thumbv8m.main-none-eabi
Next, let's create a new no_std Rust project:
$ cargo new --bin trustzone_helloworld
We also need to add a few crates for hardware support and basic functionality:
$ cargo add cortex-m-rt cortex-m-semihosting panic-halt
Dependency Breakdown:
cortex-m-rt: This crate provides the minimal runtime environment required for an embeddedno_stdRust project to run on Cortex-M processors (e.g., memory layout and reset handling).cortex-m-semihosting: This allows us to output our "Hello, World!" message to the host machine's console via the debugger, bypassing the need to initialize a complex UART peripheral.panic-halt: A simple panic handler that puts the processor into an infinite loop (halts) if a panic occurs.
3. Setting Up the Environment
To finalize our environment, we need to configure how Cargo handles the build process and how the memory is laid out for the MPS2-AN521 board.
Configuration: .cargo/config.toml
First, we create .cargo/config.toml. This file instructs Cargo to compile the project for the ARMv8-M architecture instead of our host machine's architecture (like x86_64). It also defines a runner, which allows us to use cargo run to automatically launch QEMU and execute our binary, streamlining the debug process.
[build]
target = "thumbv8m.main-none-eabi" # Cortex-M33 (ARMv8-M Mainline)
[target.thumbv8m.main-none-eabi]
runner = "qemu-system-arm -machine mps2-an521 -cpu cortex-m33 -nographic -serial mon:stdio -semihosting -kernel"
rustflags = [
"-C", "link-arg=-Tlink.x"
]
Memory Layout: memory.x
Next, we create memory.x. This file is required by the cortex-m-rt crate to define the device's memory map. By providing these addresses, the linker knows exactly where to place our code (Flash) and our variables (RAM) in the final binary.
MEMORY
{
/* Code in SSRAM1 (0x10000000) */
FLASH : ORIGIN = 0x10000000, LENGTH = 2M
/* Stack/Data in SSRAM2 (Secure Address) */
/* AN521 SSRAM2 starts at 0x38000000 and is 2MB */
RAM : ORIGIN = 0x38000000, LENGTH = 2M
}
A Note on Address Bit 28 and Security:
You might notice we are using origins like 0x10000000 instead of 0x00000000. In the MPS2-AN521 (and many other TrustZone-M implementations), bit 28 of the address acts as a hardware-level security filter.
- Bit 28 = 0 (e.g.,
0x1000_0000): Accesses the memory via the Secure alias. - Bit 28 = 1 (e.g.,
0x0000_0000): Accesses the memory via the Non-secure alias.
While the processor is in Secure Mode, it can technically access either alias without triggering a security fault. However, using the Secure alias addresses (0x1... and 0x3...) in our linker script is a best practice. It ensures that the Secure code explicitly operates within the Secure address space, making the security boundaries clear and preventing accidental leaks or "aliasing" bugs when we eventually transition to the Non-secure world.
4. Time to Write the First Line of Code
Now that the environment is fully configured, we can modify main.rs to transform it into a proper no_std project. This is the absolute minimum code required to verify that our QEMU runner, memory map, and semihosting are all working correctly.
main.rs
// Don't link the Rust standard library (requires an OS)
#![no_std]
// Disable the standard main() entry point; we use cortex-m-rt instead
#![no_main]
use cortex_m_rt::entry;
use cortex_m_semihosting::hprintln;
// If the program panics, stay in an infinite loop
use panic_halt as _;
// The #[entry] macro ensures the bootloader knows this is the starting point.
// In TrustZone, the CPU starts here in Secure Privileged mode by default.
#[entry]
fn main() -> ! {
// hprintln sends data back to QEMU's stdout through the debugger interface.
// This is much easier than writing a full UART driver for a simple test.
hprintln!("Hello from the Secure World!");
// Embedded programs must never return.
loop {
}
}
5. Hello World
With the configuration complete and our code written, we finally have a functional project. Testing it is as simple as executing cargo run.
Because we configured the runner in our .cargo/config.toml, Cargo handles the heavy lifting of compiling the binary and passing it to QEMU with the correct machine and CPU parameters.
$ cargo run
Compiling trustzone_helloworld v0.1.0 (/Users/peterw/Documents/armv8m/trustzone_helloworld)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.25s
Running `qemu-system-arm -machine mps2-an521 -cpu cortex-m33 -nographic -serial 'mon:stdio' -semihosting -kernel target/thumbv8m.main-none-eabi/debug/trustzone_helloworld`
Hello from the Secure World!