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

Calling Secure Services from Non-Secure Code with CMSE

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

Introduction

A Secure service is only useful if Non-secure code can call it, and only safe if that call cannot be abused to do anything other than what the Secure side intended. Post 1 introduced the Non-Secure Callable region as the only legitimate entry path into Secure code, and post 2 showed where that region sits in the memory layout and how the linker is made to honour it. What neither post covered is what actually makes a Secure function callable in the first place, and what guarantees that callability comes with.

This post covers that mechanism end to end, using the project's secure_increment() function as the running example: the cmse-nonsecure-entry attribute and what it does to a Rust function, the veneer the compiler generates as a result, how that veneer is packaged into an import library and handed to the Non-secure build, and what the Non-secure call site looks like from the calling side. It also covers, in full, the entry and return mechanics at the boundary (what SG does and does not do to the register file, and what the compiler-generated return wrapper is responsible for).

The build-time mechanics of locating and linking veneer.o are touched on only enough to explain the contract between the two crates. The full xtask build orchestration is covered in post 6.

The cmse-nonsecure-entry attribute

ns_entry.rs defines two Secure services. This post uses secure_increment as the running example for the calling mechanism; secure_call_nonsecure_callback is covered in post 4.

use cortex_m::asm::nop;
use cortex_m_semihosting::hprintln;

const DEMO_DELAY_LOOPS: u32 = 8_250_000;

#[unsafe(no_mangle)]
pub extern "cmse-nonsecure-entry" fn secure_increment(value: u32) -> u32 {
    let _ = hprintln!(
        "secure: secure_increment({}) called from non-secure world",
        value
    );
    let _ = hprintln!("secure: secure_increment({}) — entering demo delay", value);
    let mut i = 0u32;
    while i < DEMO_DELAY_LOOPS {
        nop();
        i = i.wrapping_add(1);
    }
    let _ = hprintln!("secure: secure_increment({}) — demo delay complete", value);
    value.wrapping_add(1)
}

Two things mark this function as different from an ordinary Secure function: the extern "cmse-nonsecure-entry" ABI and the #[unsafe(no_mangle)] attribute.

extern "cmse-nonsecure-entry" signals to the compiler that this function is a Secure entry point intended to be called from Non-secure code. It is the Rust surface for the same mechanism C exposes through the cmse_nonsecure_entry function attribute defined by Arm's CMSE specification. Declaring a function with this ABI changes what the compiler emits at the function's exit, not at its body. Inside secure_increment(), ordinary AAPCS rules govern register usage: callee-saved registers are preserved, caller-saved registers are free to use as scratch, just as in any other Secure function. The difference appears only at the return, covered later in this post.

#[unsafe(no_mangle)] is required for a more mundane reason. Without it, the Rust compiler would mangle secure_increment() into an unpredictable symbol name as part of its normal name resolution scheme. The Non-secure crate needs to link against this function by its literal name through an extern "C" declaration, so the symbol must be emitted exactly as written.

The compiler generates two symbols for this function. secure_increment names the veneer stub that lands in .gnu.sgstubs at the NSC region address; this is the symbol veneer.o exports and the Non-secure linker resolves against. __acle_se_secure_increment names the actual function body in .text, where the Rust code lives. The veneer branches to the body by that internal name. Both symbols are visible when disassembling the Secure binary; only secure_increment appears in veneer.o, since that is the only one the Non-secure side needs to know about.

> arm-none-eabi-nm secure | grep increment
c00097e T __acle_se_secure_increment
c03e000 T secure_increment

What the compiler generates: the veneer and the import library

When the Secure crate is built, the linker places a small stub in the .gnu.sgstubs section, one per cmse-nonsecure-entry function. For secure_increment, that stub is:

Disassembly of section .gnu.sgstubs:

0c03e000 : <secure_increment>:
    c03e000:   e97f e97f   sg
    c03e004:   f7c2 bcbb   b.w c00097e <__acle_se_secure_increment>

Two instructions: SG, then an unconditional branch to __acle_se_secure_increment, the internal symbol the compiler assigned to the actual function body. The stub carries the public name secure_increment so that the Non-secure linker can resolve the extern "C" declaration against it directly. The function body, living at __acle_se_secure_increment in ordinary Secure flash, is never directly addressable from the Non-secure side.

The Non-secure crate also needs to know the veneer exists and what its calling signature is. The mechanism for that is the --cmse-implib linker flag, which the Secure build.rs passes alongside --section-start:

println!("cargo:rustc-link-arg=--cmse-implib");
println!(
    "cargo:rustc-link-arg=--out-implib={}",
    veneer_path.display()
);

--cmse-implib tells the linker to produce, alongside the Secure ELF image, a separate import library containing only the veneer symbols (written to veneer.o in this project). This object file contains no Secure code or data; only the symbol secure_increment resolving to the veneer's address in the NSC region. It is the only artefact from the Secure build the Non-secure build ever touches.

The Non-secure crate does not link against the Secure ELF image, does not see Secure code, and has no way to discover Secure addresses other than through veneer.o. From the Non-secure linker's perspective, secure_increment is exactly as opaque as any other external symbol resolved against any other object file. How veneer.o is located and passed to the Non-secure build is covered in post 6.

The Non-secure call site

From the Non-secure side, calling a Secure service looks like calling any other external function. The declaration in nonsecure/src/main.rs is ordinary Rust FFI:

unsafe extern "C" {
    fn secure_increment(value: u32) -> u32;
    fn secure_call_nonsecure_callback(callback_addr: usize) -> u32;
}

There is nothing here that marks secure_increment as special. The Non-secure compiler has no concept of cmse-nonsecure-entry; that ABI only has meaning on the Secure side, where the function is defined. As far as the Non-secure crate is concerned, this is a foreign function at an address the linker will resolve.

The call site is equally unremarkable:

let input = 41;
let output = unsafe { secure_increment(input) };

The unsafe block is required because Rust cannot verify the safety of an FFI call, the same reason any other foreign function call needs one. There is no additional ceremony for the fact that this call crosses a hardware security boundary; and that is deliberate. The Non-secure application has no way to know from the source code that secure_increment resolves to a veneer in an NSC region, that the call triggers an SG instruction, or that execution briefly runs in a different security state before returning. The guarantees that make this safe regardless of what the Non-secure code does come entirely from the hardware and the compiler-generated machinery on the Secure side. The Non-secure caller is, by design, kept ignorant of all of it. This inverts the usual FFI assumption: here, the entire safety contract is enforced on the callee's side.

What happens at the boundary

Figure 1 illustrates the full call sequence. The steps below walk through it in order.

trustzone-m-sg-bxns-lr-detail.png

Figure 1. Full NS to S call sequence

Step 1: the BL and its effect on LR. The Non-secure compiler emits a standard BL instruction for the call to secure_increment. BL sets R0 to the argument (41, in this example) per the AAPCS calling convention, and sets LR to the address of the instruction immediately following the BL, with bit 0 set to 1. That set bit is ordinary Cortex-M behaviour unrelated to TrustZone: since Cortex-M only executes Thumb code, branch target addresses conventionally carry bit 0 set to indicate Thumb state via the T bit of EPSR, and LR values produced by BL follow the same convention. Nothing about this step differs from an ordinary same-state function call.

Step 2: the entry-point check. The branch target is the veneer address in the NSC region. Before executing anything there, the processor checks two conditions: that the target address falls within a region attributed as Non-Secure Callable, and that the first instruction at that address is SG. Both hold here. If either failed, the processor would fault rather than execute anything at the target address; this is the architectural guarantee that prevents the Non-secure world from branching into the middle of a Secure function or using the NSC region as a generic trampoline.

Step 3: SG executes. Two things happen as a single architectural step. The processor switches security state from Non-Secure to Secure, and clears bit 0 of LR from 1 to 0. This is the only change SG makes to LR; the rest of the register (the actual Non-secure return address set by the BL) is untouched. In an ordinary, non-TrustZone context that bit is just the T-bit indicator on a Thumb address. Here, SG clearing it gives the bit a TrustZone-specific meaning: a cleared bit 0 is what BXNS checks later to know it must perform an inter-state switch back to Non-Secure rather than an ordinary same-state return.

SG does not clear R0, does not clear any other general-purpose register, and does not install any special token value into LR beyond that one bit. The argument the Non-secure caller placed in R0 passes into Secure state exactly as it was. There is no register clearing on entry; the Secure side is the trusted side, and Non-secure register contents carry no information that needs hiding from it.

Step 4: the veneer branches into the function body. The veneer's second instruction, B.W __acle_se_secure_increment, is an ordinary branch with no further security checking, since the processor is already in Secure state. Execution reaches the secure_increment function body proper.

Step 5: the function body executes under ordinary AAPCS rules. Inside the function, callee-saved registers are preserved, caller-saved registers are available as scratch. secure_increment computes value.wrapping_add(1) and leaves the result in R0. Nothing here is specific to the cross-world nature of the call.

Step 6: the compiler-generated return wrapper. This is where the cmse-nonsecure-entry ABI does its work, and it happens entirely at compile time, not in hardware. The compiler wraps the function body with additional exit code that the source does not show. Looking at the disassembly of __acle_se_secure_increment:

0c00097e <__acle_se_secure_increment>:

@ function body here

@ compiler-generated register clearing before returning to Non-Secure world
  c0009c4:   ec4e eb10   vmov    d0, lr, lr
  c0009c8:   ec4e eb11   vmov    d1, lr, lr
  c0009cc:   ec4e eb12   vmov    d2, lr, lr
  c0009d0:   ec4e eb13   vmov    d3, lr, lr
  c0009d4:   ec4e eb14   vmov    d4, lr, lr
  c0009d8:   ec4e eb15   vmov    d5, lr, lr
  c0009dc:   ec4e eb16   vmov    d6, lr, lr
  c0009e0:   ec4e eb17   vmov    d7, lr, lr
  c0009e4:   eef1 ca10   vmrs    ip, fpscr
  c0009e8:   f02c 0c9f   bic.w   ip, ip, #159    @ 0x9f
  c0009ec:   f02c 4c70   bic.w   ip, ip, #4026531840 @ 0xf0000000
  c0009f0:   eee1 ca10   vmsr    fpscr, ip
  c0009f4:   4671        mov r1, lr
  c0009f6:   4672        mov r2, lr
  c0009f8:   4673        mov r3, lr
  c0009fa:   46f4        mov ip, lr
  c0009fc:   f38e 8800   msr CPSR_f, lr

  @ inter-state return to Non-Secure world
  c000a00:   4774        bxns    lr

The wrapper clears floating-point registers d0 through d7, scrubs the FPSCR status flags, clears integer caller-saved registers R1, R2, R3, and R12 (IP), and clears the CPSR condition flags. R0 is left untouched since it carries the return value. This is the mirror image of step 3: where entry needed no register clearing because Non-secure data is not sensitive to the Secure side, the return requires exactly this cleanup, because Secure-side scratch state must not leak back into a Non-secure caller through the shared register file.

Step 7: BXNS LR executes. The processor inspects bit 0 of LR, finds it clear (cleared by SG in step 3), and treats this as a Non-secure return: it switches security state from Secure back to Non-Secure and branches to the address LR holds, which is the original return address the BL set in step 1.

Step 8: Non-secure execution resumes. Control returns to the instruction following the original BL, in Non-secure state, with R0 holding 42.

let output = unsafe { secure_increment(input) };
// output == 42

The entire round trip involves register clearing in only one direction: none on entry (Non-secure data is not sensitive to the Secure side), and a full caller-saved scrub on return (Secure scratch state must not reach the Non-secure caller). That asymmetry is the core of what cmse-nonsecure-entry provides: compile-time-generated discipline that makes the boundary safe to cross without the Non-secure side needing to know anything about what it crossed.

Conclusion

SG does almost nothing. It checks the entry point is legitimate, switches security state, and flips a single bit in LR. Everything else (the register clearing, the flag scrubbing, the BXNS emission) comes from the compiler, generated automatically the moment a function is declared extern "cmse-nonsecure-entry". The hardware enforces where execution is allowed to go; the compiler enforces what state is allowed to leave with it.

The next post covers the reverse direction: the Secure world calling into Non-secure code. The boot handoff in post 2 already showed one instance of this (the jump to the Non-secure reset handler), but only at the level of intent. Post 4 covers the full mechanics, including the FNC_RETURN token this post deliberately set aside, and the additional care required when the trusted side is the one initiating a branch into code it does not control.


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