Getting Hello World from the Non-Secure World (Part 1)

The First Switch to the Non-Secure World

1. Catch up

In last post, we created a QEMU-based test environment to test our ARMv8-M code. In this post, we will perform the first switch from the Secure world.

TrustZone-M chips always default to starting user code in the Secure world. It is our responsibility to configure the memory (text/RAM) regions for the Non-Secure world to use. Since all resources are Secure by default, if we skip the setup process in the Secure world and jump straight to Non-Secure code, the CPU will trigger a Secure Fault or Bus Fault when it attempts to fetch the first instruction.

2. Create a Non-Secure App.

Since code executed in the Secure world and Non-Secure world basically acts like two separate programs, our Secure code acts as a secure bootloader to set up the environment and start the Non-Secure code.

First, we need to create a Non-Secure project. We can follow the same process as the last post, but this time we will name the project trustzone_non_secure_helloworld. Most steps will be the same, but we will change the print message to:

hprintln!("Hello from the Non-Secure World!");

We also have to modify the memory.x file. We cannot use the same memory space as the Secure world, as this would cause a conflict.

memory.x

MEMORY
{
  /* Code stays in SSRAM1 (0x00000000) */
  FLASH : ORIGIN = 0x00200000, LENGTH = 2M
  
  /* Stack/Data moves to SSRAM3 (Non-Secure Address) */
  /* AN505 SSRAM3 starts at 0x28200000 and is 2MB */
  RAM   : ORIGIN = 0x28200000, LENGTH = 2M
}

Notice now we are using a different code reigion. And instead of SSRAM2 we are now using SSRAM3. And both addresses are using the non-Secure address.

Now we just need to run cargo build to build the binary file.

$ file target/thumbv8m.main-none-eabi/debug/trustzone_non_secure_helloworld
target/thumbv8m.main-none-eabi/debug/trustzone_non_secure_helloworld: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, with debug_info, not stripped

3. The Secure World Bootloader

Now we need to write the Secure World code (main.rs). This code acts as the system manager. It has three main jobs:

  • Configure the SAU (Security Attribution Unit): Tell the CPU core which memory addresses are Non-Secure.
  • Configure the MPC (Memory Protection Controller): Tell the memory bus that specific RAM banks (or halves of them) are Non-Secure.
  • Jump: Switch the CPU state and hand over control.

Let's break down the implementation function by function.

Step 1: Imports and Setup

We need a standard no_std setup. We use cortex_m for hardware access and cortex_m_semihosting for debug printing.

#![no_std]
#![no_main]

use cortex_m::peripheral::sau;
use cortex_m_rt::entry;
use cortex_m_semihosting::hprintln;
use core::arch::asm;
use panic_halt as _;

Step 2: Configuring the SAU (CPU Level)

The SAU (Security Attribution Unit) is inside the CPU. It checks every address the CPU tries to access. By default, everything is Secure. We must explicitly mark regions as Non-Secure (NS) so the NS application can run.

In this function, we define two regions:

  • Region 0 (Flash/Code): 0x0020_0000 to 0x003F_FFFF.
  • Region 1 (RAM/Data): 0x2820_0000 to 0x283F_FFFF.
fn configure_sau(sau: &mut cortex_m::peripheral::SAU) {
    unsafe {
        sau.ctrl.write(sau::Ctrl(0));

        sau.rnr.write(sau::Rnr(0));
        sau.rbar.write(sau::Rbar(0x0020_0000));
        sau.rlar.write(sau::Rlar((0x003F_FFFF & 0xFFFF_FFE0) | 1));

        sau.rnr.write(sau::Rnr(1));
        sau.rbar.write(sau::Rbar(0x2820_0000));
        sau.rlar.write(sau::Rlar((0x283F_FFFF & 0xFFFF_FFE0) | 1));

        sau.ctrl.write(sau::Ctrl(1));
    }
}

Step 3: Configuring the MPC (Bus Level)

While the SAU lives inside the CPU, the MPC (Memory Protection Controller) lives on the bus. It prevents Secure data from leaking even if the CPU allows access.

We use two different strategies here:

  • Split Strategy (SSRAM0): We calculate the midpoint of the memory block. We leave the lower half as Secure (default) and unlock the upper half for Non-Secure use. This is useful for shared memory.
  • Full Unlock Strategy (SSRAM3): We unlock the entire bank because the Non-Secure world needs it for its Stack.
fn configure_mpc() {
    // --- 1. Configure SSRAM0 (Split Region) ---
    // Physical Base: 0x5800_7000
    {
        let base = 0x5800_7000;
        let ctrl_reg = base as *mut u32;
        let blk_max  = (base + 0x10) as *mut u32;
        let blk_idx  = (base + 0x18) as *mut u32;
        let blk_lut  = (base + 0x1C) as *mut u32;

        unsafe { ctrl_reg.write_volatile(0x110); }

        let max_index = unsafe { blk_max.read_volatile() };
        let total_indices = max_index + 1;
        let midpoint = total_indices / 2;

        unsafe { blk_idx.write_volatile(midpoint); }

        for _ in midpoint..total_indices {
            unsafe { blk_lut.write_volatile(0xFFFF_FFFF); }
        }
    }

    // --- 2. Configure SSRAM3 (Full Unlock) ---
    // Physical Base: 0x5800_9000
    {
        let base = 0x5800_9000;
        let ctrl_reg = base as *mut u32;
        let blk_max  = (base + 0x10) as *mut u32;
        let blk_idx  = (base + 0x18) as *mut u32;
        let blk_lut  = (base + 0x1C) as *mut u32;

        unsafe {
            ctrl_reg.write_volatile(0x110);
            blk_idx.write_volatile(0);
        }
        
        let max_idx = unsafe { blk_max.read_volatile() };
        for _ in 0..=max_idx {
            unsafe { blk_lut.write_volatile(0xFFFF_FFFF); }
        }
    }

    cortex_m::asm::dsb();
    cortex_m::asm::isb();
}

Step 4: The Jump (Main)

Finally, we perform the switch. This requires reading the Non-Secure Vector Table to find the correct Stack Pointer and Reset Vector.

We then use inline assembly to:

  • msr msp_ns: Set the Non-Secure Main Stack Pointer.
  • bxns: Branch and Exchange to Non-Secure state. The address we jump to must have the Least Significant Bit (LSB) cleared (& !1) to indicate the target state.
#[entry]
fn main() -> ! {
    hprintln!("Secure World: Initializing...");

    let mut peripherals = cortex_m::Peripherals::take().unwrap();
    
    configure_sau(&mut peripherals.SAU);
    configure_mpc();

    let ns_vector_table_addr: *const u32 = 0x0020_0000 as *const u32;

    hprintln!("Secure World: Jumping to Non-Secure...");

    unsafe {
        let ns_msp = *ns_vector_table_addr;
        let ns_reset_vector = *ns_vector_table_addr.add(1);

        asm!(
            "msr msp_ns, {ns_msp}",
            "bxns {ns_reset_vector}",
            ns_msp = in(reg) ns_msp,
            ns_reset_vector = in(reg) ns_reset_vector & !1, 
        );
    }
    
    loop {}
}

Step 5: Full Source Code (main.rs)

#![no_std]
#![no_main]

use cortex_m::peripheral::sau;
use cortex_m_rt::entry;
use cortex_m_semihosting::hprintln;
use core::arch::asm;
use panic_halt as _;

fn configure_sau(sau: &mut cortex_m::peripheral::SAU) {
    unsafe {
        sau.ctrl.write(sau::Ctrl(0));

        sau.rnr.write(sau::Rnr(0));
        sau.rbar.write(sau::Rbar(0x0020_0000));
        sau.rlar.write(sau::Rlar((0x003F_FFFF & 0xFFFF_FFE0) | 1));

        sau.rnr.write(sau::Rnr(1));
        sau.rbar.write(sau::Rbar(0x2820_0000));
        sau.rlar.write(sau::Rlar((0x283F_FFFF & 0xFFFF_FFE0) | 1));

        sau.ctrl.write(sau::Ctrl(1));
    }
}

fn configure_mpc() {
    // --- 1. Configure SSRAM0 (Split Region) ---
    {
        let base = 0x5800_7000;
        let ctrl_reg = base as *mut u32;
        let blk_max  = (base + 0x10) as *mut u32;
        let blk_idx  = (base + 0x18) as *mut u32;
        let blk_lut  = (base + 0x1C) as *mut u32;

        unsafe { ctrl_reg.write_volatile(0x110); }

        let max_index = unsafe { blk_max.read_volatile() };
        let total_indices = max_index + 1;
        let midpoint = total_indices / 2;

        unsafe { blk_idx.write_volatile(midpoint); }

        for _ in midpoint..total_indices {
            unsafe { blk_lut.write_volatile(0xFFFF_FFFF); }
        }
    }

    // --- 2. Configure SSRAM3 (Full Unlock) ---
    {
        let base = 0x5800_9000;
        let ctrl_reg = base as *mut u32;
        let blk_max  = (base + 0x10) as *mut u32;
        let blk_idx  = (base + 0x18) as *mut u32;
        let blk_lut  = (base + 0x1C) as *mut u32;

        unsafe {
            ctrl_reg.write_volatile(0x110);
            blk_idx.write_volatile(0);
        }
        
        let max_idx = unsafe { blk_max.read_volatile() };
        for _ in 0..=max_idx {
            unsafe { blk_lut.write_volatile(0xFFFF_FFFF); }
        }
    }

    cortex_m::asm::dsb();
    cortex_m::asm::isb();
}

#[entry]
fn main() -> ! {
    hprintln!("Secure World: Initializing...");

    let mut peripherals = cortex_m::Peripherals::take().unwrap();
    
    configure_sau(&mut peripherals.SAU);
    configure_mpc();

    let ns_vector_table_addr: *const u32 = 0x0020_0000 as *const u32;

    hprintln!("Secure World: Jumping to Non-Secure...");

    unsafe {
        let ns_msp = *ns_vector_table_addr;
        let ns_reset_vector = *ns_vector_table_addr.add(1);

        asm!(
            "msr msp_ns, {ns_msp}",
            "bxns {ns_reset_vector}",
            ns_msp = in(reg) ns_msp,
            ns_reset_vector = in(reg) ns_reset_vector & !1, 
        );
    }
    
    loop {}
}

4. Configuring the Runner (.cargo/config.toml)

We have written the Secure bootloader and the Non-Secure application. Now, we need a way to run them together. This is where .cargo/config.toml comes in. It automates the complex QEMU command required to load two separate binaries into memory at once.

For a TrustZone setup, QEMU needs to know two things:

  • Where is the Secure code? (This is our current project, passed via -kernel).
  • Where is the Non-Secure code? (This is the external binary we built in Step 2, passed via -device loader).

The Configuration Breakdown

Create or edit .cargo/config.toml in your Secure project root:

[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 \
 -device loader,file=../trustzone_non_secure_helloworld/target/thumbv8m.main-none-eabi/debug/trustzone_non_secure_helloworld,addr=0x00200000 \
 -kernel """
rustflags = [
  "-C", "link-arg=-Tlink.x"
]
  • -device loader,file=...,addr=0x00200000: This is the crucial part for TrustZone. It tells QEMU to side-load the Non-Secure binary into memory at address 0x00200000 before starting the CPU.
    • Note: The path ../trustzone_non_secure_helloworld/... assumes your Non-Secure project folder is next to your Secure project folder. Adjust if necessary.
  • -kernel: This is where Cargo inserts the path to the Secure binary (the current project) automatically. This binary is loaded at the default reset address (usually 0x00000000 or 0x10000000) and the CPU starts executing here.

5. Running the System

Now, simply run:

$ cargo run
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
     Running `qemu-system-arm -machine mps2-an521 -cpu cortex-m33 -nographic -serial 'mon:stdio' -semihosting -device loader,file=../trustzone_non_secure_helloworld/target/thumbv8m.main-none-eabi/debug/trustzone_non_secure_helloworld,addr=0x00200000 -kernel target/thumbv8m.main-none-eabi/debug/trustzone_helloworld`
Secure World: Initializing...
Secure World: Jumping to Non-Secure...
Hello from the Non-Secure World!

Congratulations! We have successfully booted a Secure world manager and handed off control to a Non-Secure application.