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

Calling Non-Secure Code from the Secure World with CMSE

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

Introduction

The previous post covered the direction most people first associate with TrustZone-M: Non-secure code calling Secure services through an NSC veneer. That path is tightly controlled. The Non-secure side can only enter through a gateway in the Non-Secure Callable region, the first instruction at the entry point must be SG, and the compiler hardens the Secure return path before control goes back to the Non-secure caller.

The reverse direction is different. During boot, the Secure image eventually transfers control to the Non-secure reset handler by writing VTOR_NS, loading MSP_NS, converting the reset vector into a cmse-nonsecure-call function pointer, and branching to it. That is a real inter-state transfer and it correctly belongs in the boot-flow. However, what it does not provide is a model for a returning call. The Secure boot code is not making a subroutine call that expects to resume; the type makes that explicit: unsafe extern "cmse-nonsecure-call" fn() -> !. The ! says the function never returns.

A callback is different. The Secure world is already executing inside a Secure service that was itself entered from the Non-secure side. It receives a Non-secure function address, validates it, calls it, receives a return value, and continues in Secure state before eventually returning to the original Non-secure caller. That is the case where return semantics and caller state become relevant.

To study those properly, this post uses a concrete example. Non-secure code exports a function named nonsecure_double, passes its address to a Secure service named secure_call_nonsecure_callback, and the Secure service validates that address, calls the Non-secure callback, receives the result, and returns it to the original Non-secure caller. The detailed control flow is:

1. Non-secure main() calls a Secure service through an NSC veneer.

2. The Secure service validates the Non-secure callback address.

3. The Secure service calls that callback.

4. The Non-secure callback returns into the Secure service.

5. The Secure service returns to the original Non-secure caller.

The Figure 1 illustrates this sequence conceptually:

TrustZone-M Secure to Non-Secure round trip

Figure 1. Full callback sequence

A callback that really returns

The Non-secure side exposes a simple function named nonsecure_double:

// nonsecure/src/main.rs

#[unsafe(no_mangle)]
pub extern "C" fn nonsecure_double(value: u32) -> u32 {
    let doubled = value.wrapping_mul(2);
    let _ = hprintln!("nonsecure: nonsecure_double({}) -> {}", value, doubled);
    doubled
}

This function is intentionally basic. It carries no CMSE entry ABI because it does not need the Secure-entry gateway machinery from the previous post. From the Non-secure side's perspective, it is a function with a predictable signature.

Its address is later passed into a Secure service:

// nonsecure/src/main.rs

let doubled = unsafe { secure_call_nonsecure_callback(nonsecure_double as *const () as usize) };

That call enters the Secure world through the usual NSC mechanism, because secure_call_nonsecure_callback is itself a Secure entry function. It also hands the Secure world a raw code address originating in the Non-secure world; and that address is untrusted input. It is just a usize supplied by less-trusted code. The Secure side will have to decide whether branching to it is acceptable (safe) before it ever does so.

The complete Secure implementation lives in secure/src/ns_entry.rs. The validation constants and the callback type alias are module-level:

// secure/src/ns_entry.rs

const NONSECURE_FLASH_START: usize      = 0x0804_0000;
const NONSECURE_FLASH_END:   usize      = 0x0808_0000;
const INVALID_NONSECURE_CALLBACK: u32   = 0xFFFF_FFFE;

type NonSecureCallback = unsafe extern "cmse-nonsecure-call" fn(u32) -> u32;

The entry function itself:

// secure/src/ns_entry.rs

#[unsafe(no_mangle)]
pub extern "cmse-nonsecure-entry" fn secure_call_nonsecure_callback(
    callback_addr: usize,
) -> u32 {
    let value = 21;
    
    // Testing for the Thumb state
    let callable_addr = callback_addr & !1;
    let is_thumb = callback_addr & 1 == 1;
   
    // Testing for illegal access to secure address
    let is_nonsecure_flash =
        (NONSECURE_FLASH_START..NONSECURE_FLASH_END).contains(&callable_addr);

    // Reject the call is not Thumb of if the function's address is
    // inside the secure world
    if !is_thumb || !is_nonsecure_flash {
        let _ = hprintln!(
            "secure: rejected non-secure callback address 0x{:08X}",
            callback_addr
        );
        return INVALID_NONSECURE_CALLBACK;
    }

    let _ = hprintln!(
        "secure: calling non-secure callback at 0x{:08X}({})",
        callback_addr,
        value
    );

    // Access the Non-secure function via its pointer
    unsafe {
        let callback: NonSecureCallback =
            transmute::(callback_addr);
        let doubled = callback(value);
        let _ = hprintln!("secure: non-secure callback returned {}", doubled);
        doubled
    }
}

Why Secure code must validate the target

A raw callback address supplied by the Non-secure world cannot be treated as an ordinary function pointer; it is untrusted input. If the Secure side were to jump blindly to whatever value it received, it would be delegating control-flow policy to less-trusted code, which defeats the boundary.

The validation is explicit. The callback address is broken into two properties: whether bit 0 is set (indicating a Thumb entry point), and whether the even-aligned address falls inside the Non-secure flash window.

let callable_addr = callback_addr & !1;
let is_thumb = callback_addr & 1 == 1;
let is_nonsecure_flash =
    (NONSECURE_FLASH_START..NONSECURE_FLASH_END).contains(&callable_addr);

These two checks matter for different reasons. The Thumb check rejects clearly malformed targets; on Cortex-M, a code pointer must name Thumb code, so bit 0 must be set in the source address even though the fetch uses the aligned value with bit 0 cleared. The flash range check constrains the callback to the region the system has designated as Non-secure executable code, matching the Non-secure image slot used throughout the memory layout. The Secure side is essentially testing if this address inside the Non-secure policy zone?

If either check fails, the function returns a sentinel and does not branch:

if !is_thumb || !is_nonsecure_flash {
    let _ = hprintln!(
        "secure: rejected non-secure callback address 0x{:08X}",
        callback_addr
    );
    return INVALID_NONSECURE_CALLBACK;
}

That rejection path is important. The Secure service does not try to recover, does not silently coerce the target, and does not branch anyway. It logs the rejection and returns the sentinel.

A production design might restrict callbacks to a known export table or force the Non-secure side to register callbacks through a structured interface. But the core principle is already present: Secure code applies its own control-flow policy before branching into Non-secure code.

The cmse-nonsecure-call ABI and what BLXNS does

Once the address has passed validation, the Secure side needs to call it. Rather than constructing a raw function pointer inline, the code uses a named type alias declared at the top of secure/src/ns_entry.rs:

type NonSecureCallback = unsafe extern "cmse-nonsecure-call" fn(u32) -> u32;

Placing it at file scope rather than inside the function means any future Secure entry point that also needs to call a Non-secure callback can reuse the same type. More importantly here, the alias carries the cmse-nonsecure-call ABI, which is what tells the compiler this call crosses the security boundary outbound.

This is the reverse-direction counterpart to the cmse-nonsecure-entry ABI used in the previous post. There, the ABI marked a Secure function as callable from the Non-secure world. Here, the Secure side is the caller, and the ABI tells the compiler that the call crosses the security boundary outbound. An ordinary extern "C" function pointer would not communicate that; the compiler would generate a plain same-state call, which would be wrong.

The same ABI appeared earlier in the boot code:

type NonSecureReset = unsafe extern "cmse-nonsecure-call" fn() -> !;

The difference is only in what the call is for. The boot use is a one-way handoff; this use is a genuine round trip.

When the compiler sees a call through a cmse-nonsecure-call function pointer, it emits BLXNS (Branch with Link to Non-Secure) rather than an ordinary BLX. That instruction does several things in a single architectural step. It switches security state from Secure to Non-secure. It writes a sentinel value known as FNC_RETURN into the LR; this is not the real Secure return address but a placeholder. Leaking information about the Secure code's return address to the Non-secure code could be problematic. The processor recognizes this special value when assigned back to the PC (on return) and triggers a micro-coded operation. The actual Secure return address is saved inside the secure stack, which is not accessible from Non-secure code. The Non-secure callee therefore cannot read or branch to the real Secure return address; it sees only the sentinel.

When nonsecure_double finishes and executes its normal BX LR, the processor sees FNC_RETURN in LR, recognizes this as an inter-state return to Secure state, retrieves the real return address from the Secure stack, switches security state back to Secure, and resumes the Secure caller. Control returns to secure_call_nonsecure_callback immediately after the callback(value) call, in Secure state, with the result in R0.

From raw address to callable: the role of unsafe

The conversion from validated usize to typed function pointer is:

unsafe {
    let callback: NonSecureCallback =
        transmute::(callback_addr);
    let doubled = callback(value);
    let _ = hprintln!("secure: non-secure callback returned {}", doubled);
    doubled
}

Both the transmute and the call through the resulting function pointer sit inside a single unsafe block. The transmute is unsafe because the compiler cannot prove that callback_addr names a valid Non-secure function with the signature fn(u32) -> u32; that fact lives outside the type system. The call is unsafe because calling through a raw function pointer always is.

Because callback is typed as a cmse-nonsecure-call function pointer, the compiler knows the call crosses the security boundary and emits a specific instruction sequence rather than a plain branch. The disassembly around the BLXNS instruction makes the compiler's work visible:

@ More code...

c000b5e:   e92d 0ff0   stmdb   sp!, {r4, r5, r6, r7, r8, r9, sl, fp}
c000b62:   f021 0101   bic.w   r1, r1, #1
c000b66:   b0a2        sub     sp, #136
c000b68:   ec2d 0a00   vlstm   sp
c000b6c:   460a        mov     r2, r1
c000b6e:   460b        mov     r3, r1
c000b70:   460c        mov     r4, r1
c000b72:   460d        mov     r5, r1
c000b74:   460e        mov     r6, r1
c000b76:   460f        mov     r7, r1
c000b78:   4688        mov     r8, r1
c000b7a:   4689        mov     r9, r1
c000b7c:   468a        mov     sl, r1
c000b7e:   468b        mov     fp, r1
c000b80:   468c        mov     ip, r1
c000b82:   f381 8800   msr     CPSR_f, r1
c000b86:   478c        blxns   r1

@ More code...

There is quite a lot happening here. Working through it in order:

stmdb sp!, {r4-r11} saves the callee-saved registers to the Secure stack. This is the standard function-call prologue; these registers will be restored when secure_call_nonsecure_callback eventually returns to its own Non-Secure caller.

bic.w r1, r1, #1 clears bit 0 of r1, which at this point holds the callback address. The result is the actual fetch address with the Thumb bit removed. The compiler now uses this value for two purposes simultaneously: as the scrub value for every register that follows, and as the BLXNS branch target to effectuate a S to NS world change.

The mov r2, r1 through mov ip, r1 sequence fills every caller-saved register except r0 with the callback address. This is the detail worth pausing on. The compiler is not zeroing these registers; it is overwriting them with a known Non-Secure value, specifically the address of the Non-Secure function being called. No Secure scratch state survives in any of them, and the value left behind is derived entirely from the Non-Secure input the Secure code has already validated.

msr CPSR_f, r1 clears the condition flags.

r0 is deliberately untouched throughout all of this. It carries value = 21, the argument the callback expects.

blxns r1 fires. The processor switches to Non-Secure state, writes FNC_RETURN into the Non-Secure LR, saves the real Secure return address in the Secure stack, and branches to r1.

The direction of concern here is the reverse of what we saw in post 3. There, the compiler cleared registers on the return path from a Secure entry function, preventing Secure scratch state from leaking back to a Non-Secure caller. Here, the compiler clears registers before the outbound call, preventing Secure scratch state from leaking into a Non-Secure callee. The boundary works in both directions, and the compiler-generated hygiene is present on both sides of it.

Following the full round trip

With all the pieces in place, the complete control flow is:

Non-secure main() calls secure_call_nonsecure_callback, passing the address of nonsecure_double. That call enters the Secure world through an NSC veneer in the usual way (see post 3). Inside the Secure function, the address is validated, value is set to 21 before the callback is invoked:

secure: calling non-secure callback at 0x08xxxxxx(21)

BLXNS fires. The processor switches to Non-secure state, writes FNC_RETURN into LR, and transfers control to nonsecure_double. The function computes 21 * 2 = 42, prints its semihosted message, and returns normally with BX LR:

nonsecure: nonsecure_double(21) -> 42

The processor sees FNC_RETURN in LR, switches back to Secure state, and resumes in secure_call_nonsecure_callback with R0 = 42. The Secure function returns 42 through the Secure-entry return path back to the original Non-secure caller:

secure: non-secure callback returned 42
nonsecure: secure_call_nonsecure_callback(nonsecure_double) -> 42

Two boundary crossings nested inside one another. The Secure world is not a passive recipient of calls from the Non-secure side; it can act as a caller into Non-secure code, provided it does so with the correct ABI and explicit validation.

Conclusion

This experiment demonstrates the full inter-state call-and-return cycle. Secure code receives an untrusted address, validates it against explicit policy, converts it with transmute, calls it through the cmse-nonsecure-call ABI, and resumes in Secure state when the Non-secure callee returns. The hardware hides the real Secure return address from the Non-secure side throughout.

The series has now covered both directions of controlled cross-world execution. The next post turns to what happens when that bidirectional system is interrupted: exceptions, preemption, and faults at the boundary.


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