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

Exceptions, Interrupts, Preemption, and Fault Handling Across Security States

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

Introduction

The previous posts built up a picture of TrustZone-M as a system of controlled transitions: Non-Secure code calls into Secure services through NSC veneers, Secure code calls back into Non-Secure code through validated function pointers, and every crossing is deliberate and explicitly typed. That picture is accurate but incomplete. Interrupts arrive at arbitrary points in the execution flow, preempting whatever the processor happens to be doing at that moment, and the exception model has to handle them correctly regardless of which security state the processor is in when they fire.

That is the question this post addresses. What happens when a Non-Secure interrupt fires while Secure code is running? The security boundary does not suspend interrupt delivery; it changes how the processor manages context around it. The Non-Secure handler must not see Secure register state. The Secure execution must resume exactly where it was interrupted. The hardware enforces both properties automatically, but understanding what it does and why helps when something goes wrong.

This post covers two distinct aspects of the exception model. The first is the normal interrupt path: a Non-Secure SysTick handler that blinks an LED, configured to fire predictably while a Secure service is mid-execution. That demonstration makes the context preservation story observable rather than theoretical. In this experiment, a Secure HardFault handler dumps SFSRSFAR and the standard fault registers. On Cortex-M33, TrustZone security violations are architecturally reported through SecureFault; the HardFault path is still useful for forced or escalated cases, and SFSRSFAR remain valuable diagnostics there. These two cases are kept separate because they represent different operating conditions; one is expected behavior under normal operation, the other is a diagnostic tool for when something has gone wrong at the boundary.

The NUCLEO-L552ZE-Q board has three user LEDs: LD3 red on PA9, LD2 blue on PB7, and LD1 green on PC7. The Non-Secure SysTick handler in this experiment toggles LD3 red on each invocation. The LED blink is visible on the hardware without a debugger; the semihosting output makes the timing relationship between the interrupt and the Secure execution explicit.

The TrustZone-M exception model in brief

Post 1 introduced the TrustZone-M exception model at a conceptual level: separate Secure and Non-Secure vector tables, interrupt attribution, and the principle that exceptions are handled in the context of their attributed security state. This section makes those concepts concrete in terms of the hardware registers and the specific behavior relevant to this experiment.

Two vector tables

The processor maintains two independent vector tables (see Figure 1). The Secure vector table base is held in VTOR_S at 0xE000ED08; the Non-Secure vector table base is held in VTOR_NS at 0xE002ED08. Each table has its own complete set of exception vectors. Post 2 showed the Secure world writing VTOR_NS during boot to point at the base of Non-Secure flash. Both tables are active simultaneously once the Non-Secure world is running; the processor selects which one to use based on the security state of the exception being taken.

TrustZone-M dual vector tables

Figure 1. Security states based vector tables

Banked SysTick

On this STM32L552 Cortex-M33 system, SysTick is exposed through Secure and Non-secure register views. The Secure SysTick control registers are visible at 0xE000E010, while the Non-secure SysTick view is visible at 0xE002E010. In this experiment only the Non-secure SysTick is configured; the Secure SysTick is left at its reset state with the counter disabled.

TrustZone-M SysTick register views

Figure 2. SysTick timer aliasing

Interrupt attribution

External interrupts routed through the NVIC can be attributed as Secure or Non-Secure through the NVIC ITNS registers. A Secure interrupt uses the Secure vector table and runs in Secure Handler mode; a Non-Secure interrupt uses the Non-Secure vector table and runs in Non-Secure Handler mode. SysTick does not go through the NVIC; it is attributed by which of the two banked instances is enabled. Enabling the Non-Secure SysTick and its interrupt in the Non-Secure world is sufficient to route its exceptions to the Non-Secure vector table; no additional NVIC configuration is needed.

Priority and preemption

Secure and Non-Secure exceptions share the same physical priority scale. A higher-priority exception preempts a lower-priority one regardless of which security state it belongs to; a same-priority exception is pended rather than taken immediately. There is no implicit priority advantage for Secure exceptions by default.

The architecture provides a configuration bit, AIRCR.PRIS, that shifts the Non-Secure priority range into the lower half of the physical priority scale. Secure exceptions assigned to the upper half of the scale then cannot be preempted by any Non-Secure exception, while Secure exceptions in the lower half remain interleaved with Non-Secure exceptions on equal terms. That bit is not set in this project; the two worlds compete purely on their configured numeric priority values.

In this experiment the Non-Secure SysTick fires at priority 0xE0 and preempts the Secure code running secure_increment(). That preemption happens not because of any security-state privilege, but because Thread mode has no configurable priority and is always preemptible by any enabled exception. If the Secure code were executing inside an exception handler at priority 0xE0 or higher, the Non-Secure SysTick would be pended rather than taken immediately.

Configuring the Non-Secure SysTick

The Non-Secure SysTick is configured by the Non-Secure application through the Non-Secure SysTick registers at 0xE002E010. The Secure world has full access to both the Secure and Non-Secure SysTick register sets and could configure the Non-Secure timer during boot if the design called for it. In this project it does not; the Non-Secure application owns its own timer configuration. The Non-Secure world, for its part, can only access the Non-Secure instance at 0xE002E010; a Non-Secure access to the Secure SysTick registers at 0xE000E010 would fault.

The configuration lives in systick_init() in nonsecure/src/main.rs:

fn systick_init() {
    let mut core_peripherals = unsafe { cortex_m::Peripherals::steal() };

    core_peripherals.SYST.set_clock_source(SystClkSource::Core);
    core_peripherals.SYST.set_reload(SYSTICK_RELOAD_250MS);
    core_peripherals.SYST.clear_current();
    let scb: &mut SCB = &mut core_peripherals.SCB;
    unsafe {
        scb.set_priority(SystemHandler::SysTick, 0xE0);
    }
    core_peripherals.SYST.enable_interrupt();
    core_peripherals.SYST.enable_counter();
}

set_clock_source(SystClkSource::Core) selects the processor clock as the SysTick clock source by setting CLKSOURCE in SYST_CSR. At 110 MHz SYSCLK this gives a predictable tick rate independent of any external clock dividers.

set_reload(SYSTICK_RELOAD_250MS) writes the reload register. The constant is defined as:

const SYSTICK_RELOAD_250MS: u32 = (110_000_000 / 4) - 1;

At 110 MHz, dividing by 4 gives 27,500,000 cycles per 250 ms period. The minus one accounts for the SysTick counter counting down from the reload value to zero inclusive before reloading, so the actual period is RELOAD + 1 cycles.

clear_current() writes zero to SYST_CVR, clearing the current counter value before enabling. Skipping this step risks the first interrupt firing at an unpredictable time depending on whatever residual value the register holds.

scb.set_priority(SystemHandler::SysTick, 0xE0) sets the Non-secure SysTick exception priority. Only the implemented high-order priority bits are significant on Cortex-M systems, so the exact encoded level depends on the device’s __NVIC_PRIO_BITS value. For this demonstration, the important point is that SysTick remains low priority but can still preempt Thread mode execution, which has no configurable exception priority.

enable_interrupt() sets TICKINT in SYST_CSR, enabling the SysTick exception. enable_counter() sets ENABLE in the same register, starting the countdown. The order matters: enabling the interrupt before the counter avoids a brief window where the counter runs but the interrupt is not yet armed.

The handler itself is minimal:

#[exception]
fn SysTick() {
    let dp = unsafe { pac::Peripherals::steal() };
    let gpioa_odr = dp.GPIOA.odr().read().bits();

    dp.GPIOA
        .odr()
        .write(|w| unsafe { w.bits(gpioa_odr ^ (1 << 9)) });
    let _ = hprintln!("nonsecure: SysTick");
}

The #[exception] attribute from cortex-m-rt places this function at the SysTick slot in the Non-Secure vector table. On each invocation it reads the current GPIOA output data register, XORs bit 9 to toggle PA9 (LD3 red), writes the result back, and prints a semihosting message. The read-modify-write uses the raw bits() accessor rather than a named field because the PAC does not expose individual ODR bits as named write fields on this device; the explicit XOR mask is the direct equivalent.

systick_init() is called from main() before the Secure service calls, so the timer is already running when execution reaches the nop() loop and when the Secure services are invoked:

unsafe {
    write_volatile(NONSECURE_ENTRY_MAGIC_ADDR, NONSECURE_ENTRY_MAGIC);
}

led_init();
systick_init();

let _ = hprintln!("nonsecure: entered non-secure world");
let _ = hprintln!(
    "nonsecure: magic 0x{:08X} written to 0x{:08X}",
    NONSECURE_ENTRY_MAGIC,
    NONSECURE_ENTRY_MAGIC_ADDR as usize
);

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

GPIO attribution: a two-level security configuration

Getting the LED to blink from a Non-Secure interrupt handler required more than attributing the GPIO peripheral as Non-Secure in the GTZC. The STM32L552 implements a second, finer-grained layer of security configuration at the individual pin level. Both layers must be configured from the Secure world before the Non-Secure SysTick handler can drive the LED pins.

Layer 1: peripheral-level attribution via GTZC

The GTZC TZSC SECCFGR1 and SECCFGR2 registers control the security attribution of APB1 and APB2/AHB1 peripherals respectively. The Secure world first writes SECCFGR1 to retain LPUART1 and VREFBUF as Secure, then clears SECCFGR2 to mark all APB2/AHB1 peripherals (including GPIOA, GPIOB, and GPIOC) as Non-Secure.

dp.GTZC_TZSC
    .seccfgr1()
    .write(|w| w.lpuart1sec().set_bit().vrefbufsec().set_bit());

dp.GTZC_TZSC
    .seccfgr2()
    .write(|w| unsafe { w.bits(0x0000_0000) });

This makes GPIOA, GPIOB, and GPIOC accessible from the Non-Secure world at the bus level. Without the SECCFGR2 write, a Non-Secure access to any GPIOA register would fault.

Layer 2: pin-level attribution via GPIO SECCFGR

Clearing the peripheral-level bit in SECCFGR2 is necessary but not sufficient. Each GPIO port on the STM32L552 has its own SECCFGR register where each bit controls the security attribution of an individual pin. At reset these bits are all-ones, meaning every pin is Secure regardless of what the GTZC says about the port. Both must permit access for a Non-Secure access to succeed.

The Secure world clears the specific pin bits for the three LED pins:

dp.GPIOA.seccfgr.write(|w| w.sec9().clear_bit());  // PA9 = LD3 red
dp.GPIOB.seccfgr.write(|w| w.sec7().clear_bit());  // PB7 = LD2 blue
dp.GPIOC.seccfgr.write(|w| w.sec7().clear_bit());  // PC7 = LD1 green

This is an ST-specific implementation of the IDAU concept introduced in post 1. The architecture defines that security attribution is the stricter of what the SAU and the IDAU produce; on the STM32L552, the GTZC acts as the IDAU and operates at two granularities: the peripheral level through TZSC SECCFGR registers, and the pin level through each GPIO port's own SECCFGR.

Clock enable ordering

Before the Secure world can write the GPIO SECCFGR registers it must first enable the GPIO clocks. A write to a GPIO register while its clock is gated has no effect; worse, it may not fault either, leaving the configuration silently incomplete. The Secure world enables the GPIOA, GPIOB, and GPIOC clocks in configure_secure_peripherals() before touching the pin-level security registers:

dp.RCC.ahb2enr().modify(|_, w| {
    w.gpioaen()
        .enabled()
        .gpioben()
        .enabled()
        .gpiocen()
        .enabled()
});
dsb();

dp.GPIOA.seccfgr().write(|w| w.sec9().clear_bit());
dp.GPIOB.seccfgr().write(|w| w.sec7().clear_bit());
dp.GPIOC.seccfgr().write(|w| w.sec7().clear_bit());

The dsb() after the clock enable ensures the RCC write has propagated to the bus fabric before the GPIO registers are accessed. This ordering dependency is not specific to TrustZone; it applies to any peripheral register access on an STM32. It becomes more consequential here because the failure mode is silent: the SysTick handler runs, the LED does not blink, and there is no fault to diagnose.

What happens when the Non-Secure SysTick fires during Secure execution

With the SysTick configured and running, and secure_increment() containing a deliberate busy-wait delay, the experiment produces the observable event this post is built around: a Non-Secure interrupt fires while the processor is executing Secure code (see Figure 3). The semihosting output makes the sequence unambiguous:

secure: secure_increment(41) called from non-secure world
secure: secure_increment(41) — entering demo delay
nonsecure: SysTick
nonsecure: SysTick
secure: secure_increment(41) — demo delay complete
nonsecure: secure_increment(41) -> 42

The two (or more) nonsecure: SysTick lines appear between the start and end of the Secure delay. The Secure function did not return between them. The Non-Secure handler ran and returned, and Secure execution resumed exactly where it was interrupted. That is Secure context preservation working as specified.

Non-Secure SysTick preempts Secure execution

Figure 3. SysTick timer preemption secure execution

What the hardware does on Non-Secure exception entry from Secure thread mode

When the Non-Secure SysTick fires while the processor is in Secure Thread mode, the hardware performs the following steps automatically, without any software involvement.

When a Non-secure exception preempts Secure execution, the processor preserves the Secure context in Secure stack state, scrubs the shared register view so the Non-secure handler cannot observe Secure values, and restores the preserved Secure context on exception return. Armv8-M also uses integrity checks around these cross-state exception paths so that a compromised Non-secure handler cannot simply fabricate a function return back into Secure state. If the return path is inconsistent with the saved Secure state, the processor faults rather than resuming at an attacker-chosen Secure address.

The processor switches to Non-Secure Handler mode and begins executing the SysTick handler. From this point execution is identical to any ordinary exception handler. The handler toggles PA9, prints its semihosting message, and returns via BX LR.

What the hardware does on return

BX LR writes the EXC_RETURN value into PC. The processor recognizes this as a cross-boundary exception return, retrieves the full Secure context from the Secure stack, restores every register to its saved value, and resumes Secure Thread mode at the exact instruction that was interrupted in secure_increment().

From the perspective of secure_increment(), nothing happened. The register values it was using are exactly as it left them. The loop counter is where it was. The function continues, completes the delay, logs its completion message, and returns value.wrapping_add(1) to the Non-Secure caller.

Why this matters

The Secure context preservation described above is entirely automatic. The Secure code in secure_increment() does not need to know that a Non-Secure interrupt might fire during its execution; it neither saves nor restores any state explicitly in anticipation of that event. The architecture handles it. The Secure developer writes Secure functions without having to reason about what Non-Secure interrupts might do, because the hardware guarantees that any Non-Secure exception entry and return is transparent to the Secure execution it interrupts.

The semihosting timing constraint

The delay constant DEMO_DELAY_LOOPS is set to 8_250_000 rather than the value a raw cycle count at 110 MHz would suggest. Each hprintln! call in the SysTick handler halts the processor while the debugger services the semihosting request, extending the effective interrupt service time well beyond the nominal 250 ms period. The loop count was reduced to account for that overhead so the delay completes reliably between handler invocations and the expected semihosting output appears. This adjustment is specific to the debug build with semihosting active and has no bearing on the TrustZone mechanics being demonstrated.

Fault handling across the boundary

The experiment does not deliberately trigger a TrustZone boundary violation, but the Secure world is equipped to diagnose one if it occurs. The HardFault handler in secure/src/fault.rs overrides the default cortex-m-rt handler and dumps the fault registers over semihosting before spinning:

use core::ptr::read_volatile;

use cortex_m_rt::exception;
use cortex_m_semihosting::hprintln;

const SCB_CFSR: *const u32 = 0xE000_ED28 as *const u32;
const SCB_HFSR: *const u32 = 0xE000_ED2C as *const u32;
const SCB_MMFAR: *const u32 = 0xE000_ED34 as *const u32;
const SCB_BFAR: *const u32 = 0xE000_ED38 as *const u32;
const SCB_SFSR: *const u32 = 0xE000_EDE4 as *const u32;
const SCB_SFAR: *const u32 = 0xE000_EDE8 as *const u32;

#[exception]
unsafe fn HardFault(frame: &cortex_m_rt::ExceptionFrame) -> ! {
    let cfsr  = unsafe { read_volatile(SCB_CFSR) };
    let hfsr  = unsafe { read_volatile(SCB_HFSR) };
    let sfsr  = unsafe { read_volatile(SCB_SFSR) };
    let sfar  = unsafe { read_volatile(SCB_SFAR) };
    let mmfar = unsafe { read_volatile(SCB_MMFAR) };
    let bfar  = unsafe { read_volatile(SCB_BFAR) };
    let _ = hprintln!("HARDFAULT");
    let _ = hprintln!("  pc   = 0x{:08X}", frame.pc());
    let _ = hprintln!("  CFSR = 0x{:08X}", cfsr);
    let _ = hprintln!("  HFSR = 0x{:08X}", hfsr);
    let _ = hprintln!("  SFSR = 0x{:08X}", sfsr);
    let _ = hprintln!("  SFAR = 0x{:08X}", sfar);
    let _ = hprintln!("  MMAR = 0x{:08X}", mmfar);
    let _ = hprintln!("  BFAR = 0x{:08X}", bfar);
    loop {
        cortex_m::asm::nop();
    }
}

Most of these registers are standard Cortex-M fault diagnostics. CFSR captures MemManage, BusFault, and UsageFault sub-fields. HFSR indicates whether the HardFault was forced from a lower-priority configurable fault or triggered directly. MMFAR and BFAR hold the faulting address when the corresponding valid bits in CFSR are set.

The two registers specific to TrustZone-M are SFSR and SFAR (see Figure 4).

SFSR (Secure Fault Status Register) at 0xE000EDE4 records the cause of a security violation. Its bits identify distinct fault categories: an invalid transition attempt, Non-Secure code trying to access a Secure address, an integrity signature check failure on exception return, an invalid EXC_RETURN value, a lazy floating-point state preservation fault, or an invalid entry to an NSC function. When SFSR is non-zero, a TrustZone boundary violation has occurred. Reading the specific bits identifies which rule was broken.

SFAR (Secure Fault Address Register) at 0xE000EDE8 holds the address that caused the violation, but only when the SFARVALID bit in SFSR is set. Not all fault types produce a valid address; an invalid EXC_RETURN or an integrity signature failure does not have an associated memory address. Checking SFARVALID before reading vvvSFAR is therefore necessary to avoid misdiagnosing a fault.

TrustZone-M fault diagnostic registers

The handler spins in a nop() loop after printing rather than resetting. That is deliberate: a spin loop leaves the processor in a debuggable state where a connected debugger can inspect registers and memory. An immediate reset would destroy the fault context before it could be examined.

The custom handler shown here is installed only in the Secure world. The Non-Secure image retains its own default HardFault handler from cortex-m-rt, which simply loops. A fault taken in Non-Secure state is therefore handled first by the Non-Secure exception machinery. On Cortex-M33, a security violation is architecturally reported through SecureFault rather than HardFault; HardFault becomes relevant when a fault is forced, escalated, or when the design routes diagnosis through that path. In this project, the Secure HardFault handler is still useful because it prints the standard fault registers together with SFSR and SFAR, which helps distinguish ordinary memory or bus faults from TrustZone-related failures. A non-zero SFSR is therefore a strong indication that the fault is security-related, such as an invalid transition, an attribution violation, an invalid exception return, or an invalid NSC entry, rather than a conventional MemManage or BusFault condition alone.

Conclusion

The exception model is where TrustZone-M has to prove that the security boundary holds not just during planned transitions but under the pressure of real execution. Interrupts do not wait for a convenient moment; they arrive whenever their priority condition is met, regardless of what the processor is doing. The hardware saves the full Secure context before entering a Non-Secure handler, zeros the shared registers to prevent leakage, and restores everything on return. No Secure code is written to make this happen; it is part of the architectural contract.

The integrity signature closes the loop. Without it, a Non-Secure handler could attempt to re-enter Secure state through a fabricated function return rather than the legitimate exception return path. The signature defeats that by ensuring any such attempt lands in an execute-never region and faults immediately.

The two-level GPIO attribution in gtzc.rs is a practical reminder that the STM32L552's IDAU implementation reaches below the peripheral bus to individual pin-level security bits. That detail is SoC-specific and invisible in a purely architectural discussion, but it is what stood between a running SysTick and a blinking LED.

The fault handler in fault.rs completes the picture. A non-zero SFSR in the semihosting output is the first thing to check when a fault appears to involve TrustZone security state, attribution, or cross-state control flow.

The next post covers the build system: how the two images are compiled, linked, and packaged reproducibly using xtask automation, and how the veneer object produced by the Secure build is located and passed to the Non-Secure linker.


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