For Applications & Industries
Free Online Training Events
Free Technical Resources
The full project code is available on Doulos' GitHub repository at https://github.com/Doulos/Rust-TrustZone.
In this last post of the series, we turn to the question that makes a dual-image TrustZone-M project different from any other bare-metal Rust build: the two final firmware images cannot be linked in arbitrary order. The Secure image must be linked first because its link produces veneer.o, the Secure Gateway import library required to resolve Secure-service symbols in the Non-secure image. Get the order wrong and the Non-Secure build fails with unresolved symbols. Get the veneer address wrong and the build succeeds but the system faults at runtime the moment Non-Secure code attempts a cross-world call.
An ordinary stable Cargo workspace has no direct mechanism for expressing this kind of generated binary-artifact dependency between the two firmware links. Cargo understands library dependencies between crates, but veneer.o is not a library in the Rust sense; it is a linker input produced by one firmware build and consumed by another. There is no [dependencies] entry that would cause Cargo to build secure before nonsecure automatically.
The project solves this with three cooperating mechanisms. Each firmware crate has a build.rs that handles its own linker configuration. A shared target directory keeps the veneer.o path predictable across both crates. And an xtask crate provides a single command, cargo xtask build, that enforces the build order, passes the veneer path explicitly to the Non-Secure build, and packages both ELF files into a single flashable Intel HEX image. Figure 1 shows the full dependency graph across all three phases.


The repository is a Cargo workspace with three members:
[workspace]
members = ["secure", "nonsecure", "xtask"]
default-members = ["xtask"]
resolver = "2"
[workspace.package]
edition = "2024"
[workspace.dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"
cortex-m-semihosting = "0.5"
panic-semihosting = "0.6"
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
lto = true
codegen-units = 1The three crates serve distinct roles. secure and nonsecure are no_std bare-metal firmware crates targeting thumbv8m.main-none-eabihf. xtask is a host binary that runs on the development machine; it has no embedded dependencies and no cross-compilation settings. Keeping xtask in the workspace means it can be invoked with cargo run -p xtask or, via the alias defined in the workspace .cargo/config.toml, as cargo xtask. No separate binary installation is required; Cargo compiles and runs it on demand from source, using the host toolchain. The workspace-root alias makes the shorter cargo xtask … command available when Cargo is invoked within the workspace configuration hierarchy.
The default-members = ["xtask"] entry means that running cargo build at the workspace root without specifying a package builds only xtask. Without it, Cargo would attempt to build all three members, which would fail because the firmware crates require the nightly toolchain and their per-crate .cargo/config.toml settings. Setting xtask as the default member makes the workspace safe to invoke from the root without those preconditions in place.
The [workspace.dependencies] block is a version-pinning mechanism, not an injection mechanism. It declares shared dependency versions that member crates can inherit with { workspace = true }, but it does not automatically add those dependencies to any crate. xtask has an empty [dependencies] section and inherits nothing; the embedded crates are never compiled for it. The firmware crates inherit cortex-m, cortex-m-rt, cortex-m-semihosting, and panic-semihosting from the workspace. The stm32l5 PAC is not declared at workspace level because it carries a device-specific feature flag stm32l552) that each firmware crate declares independently.
Both profile configurations set panic = "abort". On a bare-metal target without an unwinding runtime this is the correct setting; a panic that attempts to unwind will link against support that does not exist. The release profile enables LTO and reduces code generation to a single unit, giving LLVM greater opportunity for whole-program optimization and often reducing duplicated code. Projects whose primary goal is minimum binary size may additionally choose opt-level = "s" or "z" after measuring the effect on their particular firmware.
The workspace root .cargo/config.toml is minimal:
[build]
target-dir = "target"
[alias]
xtask = "run -p xtask --"
tz-build = "run -p xtask -- build"
tz-build-release = "run -p xtask -- build --release"
tz-clean = "run -p xtask -- clean"Cargo workspace members share the workspace-root target/ directory by default. The explicit target-dir = "target" setting documents that convention and makes the path assumption visible, since the build scripts and xtask rely on the veneer object appearing beneath that shared directory.
Cross-compilation settings are not present at the workspace root. They live in per-crate .cargo/config.toml files inside secure/ and nonsecure/. If the workspace root config declared target = "thumbv8m.main-none-eabihf", Cargo would attempt to apply that target to xtask as well, which would fail. Scoping the settings to the firmware crates keeps each crate's build environment correct.
The directory layout reflects this separation:
turstzone/
├── Cargo.toml # workspace manifest
├── .cargo/
│ └── config.toml # target-dir + xtask alias only
├── secure/
│ ├── Cargo.toml
│ ├── build.rs
│ ├── memory.x
│ ├── .cargo/
│ │ └── config.toml # thumbv8m target + trustzone + build-std
│ └── src/
│ ├── main.rs
│ ├── clocks.rs
│ ├── gtzc.rs
│ ├── partitions.rs
│ ├── ns_entry.rs
│ ├── uart.rs
│ └── fault.rs
├── nonsecure/
│ ├── Cargo.toml
│ ├── build.rs
│ ├── memory.x
│ ├── .cargo/
│ │ └── config.toml # thumbv8m target + trustzone + build-std
│ └── src/
│ └── main.rs
└── xtask/
├── Cargo.toml
└── src/
└── main.rs.cargo/config.toml splitEach firmware crate has its own .cargo/config.toml containing the cross-compilation target, the TrustZone compiler flag, and the build-std configuration:
[build]
target = "thumbv8m.main-none-eabihf"
[target.thumbv8m.main-none-eabihf]
rustflags = ["-C", "target-feature=+trustzone"]
[unstable]
build-std = ["core", "compiler_builtins"]
build-std-features = ["compiler-builtins-mem"]Both secure/.cargo/config.toml and nonsecure/.cargo/config.toml are identical. Cargo configuration is selected according to the directory from which Cargo is invoked. The xtask implementation therefore launches each firmware build with the corresponding crate directory as its current working directory. This causes Cargo to load that crate’s .cargo/config.toml together with the workspace-root configuration.
target-feature=+trustzoneThe flag tells LLVM to treat the target as a TrustZone-capable Cortex-M33 and enables the code generation paths that produce SG-prefixed veneer stubs, BLXNS for outbound Non-Secure calls, and the register-clearing wrappers around cmse-nonsecure-entry functions discussed in posts 3 and 4.
build-stdWhen Rust is installed, it comes with precompiled versions of the standard library crates, including core, which is the subset of the standard library available in no_std environments. These precompiled versions are built once by the Rust project for each supported target and bundled with the toolchain. They are convenient but they carry a constraint: they are compiled with a fixed set of settings that may not match the firmware project's own configuration.
In this project, the firmware crates are compiled with -C target-feature=+trustzone. If core was precompiled without that flag, the compiler is mixing two incompatible sets of assumptions. build-std solves this by asking Cargo to recompile core and compiler_builtins from source as part of the firmware build, using the same target, profile, and feature flags as the rest of the project. compiler_builtins provides low-level memory operations such as memcpy, memmove, and memset. The LLVM backend sometimes lowers high-level operations to these routines; without a C library on a bare-metal target there would be nothing to link against. The compiler-builtins-mem feature enables those implementations so the firmware does not need to provide them manually or rely on a C runtime.
build-std is an unstable Cargo feature, which is why the project requires nightly Rust throughout. The project includes a rust-toolchain.toml at the workspace root that pins the nightly channel and declares both required components automatically:
[toolchain]
channel = "nightly"
components = ["rust-src", "llvm-tools"]Running any cargo or rustup command inside the workspace will install both components automatically. Once they are available, cargo xtask build handles the recompilation automatically before building the firmware itself.
build.rsReaders who followed posts 2 and 3 will recognize most of what the Secure build.rs does; those posts covered the linker flags in the context of the memory layout and CMSE veneer generation respectively. This section revisits the same material from the build-flow perspective. If you are confident in your understanding of --section-start, --cmse-implib, and --out-implib, skip to the profile_dir_from_out_dir discussion at the end, which was not covered previously.
fn main() {
println!("cargo:rerun-if-changed=memory.x");
let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is not set"));
let manifest_dir =
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is not set"));
copy_linker_script(&manifest_dir, &out_dir, "memory.x");
let veneer_path = profile_dir_from_out_dir(&out_dir).join("veneer.o");
println!("cargo:rustc-link-search={}", out_dir.display());
println!("cargo:rustc-link-arg=-Tlink.x");
println!("cargo:rustc-link-arg=--nmagic");
println!("cargo:rustc-link-arg=--section-start=.gnu.sgstubs=0x0C03E000");
println!("cargo:rustc-link-arg=--cmse-implib");
println!(
"cargo:rustc-link-arg=--out-implib={}",
veneer_path.display()
);
println!(
"cargo:warning=secure veneer object will be emitted to {}",
veneer_path.display()
);
}cargo:rerun-if-changed=memory.x narrows Cargo’s change tracking to the listed input memory.x).
When a Cargo build script needs to pass files to the linker, it copies them into its own OUT_DIR (a temporary directory Cargo allocates for each crate's build script output) and then tells Cargo to add that directory to the linker's search path using cargo:rustc-link-search. Any file placed there becomes visible to the linker.
Two build scripts cooperate here. The cortex-m-rt crate has its own build script that generates link.x, a linker script containing the standard Cortex-M section layout, and registers its OUT_DIR as a linker search path. The Secure crate's own build script copies memory.x (which defines the project-specific memory regions: flash origin, RAM origin, NSC region) into the Secure crate's OUT_DIR and registers that directory as well. The linker therefore has both directories in its search path and can find both files.
-Tlink.x tells the linker to use cortex-m-rt's script as the top-level linker script. Inside link.x there is an INCLUDE memory.x directive, which pulls in the project's memory layout at the point where cortex-m-rt expects it. The two scripts are designed to work together: link.x provides the section placement rules, memory.x provides the addresses and sizes those rules operate on.
--nmagic disables page alignment of output sections. This avoids page-alignment padding that is unnecessary in this bare-metal microcontroller image.
--section-start=.gnu.sgstubs=0x0C03E000 pins the veneer section to the base of the NSC region declared in memory.x. As post 3 explained, cortex-m-rt's link.x includes .gnu.sgstubs in the FLASH region but does not give it an absolute address. Without this flag, the linker would place the veneer section wherever it fits after the other Secure sections, and that position would shift every time the Secure image grew or shrank.
That would be a problem because veneer.o (the import library consumed by the Non-Secure build) records the veneer symbols at the addresses they had when the Secure image was last linked. If those addresses change, the symbol records in veneer.o become stale. The Non-Secure linker would use the old addresses, the Non-Secure image would call into the wrong locations in flash, and the system would fault at runtime. Pinning the veneer section to a fixed address breaks that dependency: the Non-Secure image can be linked against veneer.o at any time, and the addresses it records will always be valid regardless of how much the Secure image has changed.
--cmse-implib instructs the linker to produce a CMSE import library alongside the ELF output. --out-implib specifies where to write it. The path is constructed by profile_dir_from_out_dir, which walks up three levels from OUT_DIR to reach the Cargo profile directory:
fn profile_dir_from_out_dir(out_dir: &Path) -> PathBuf {
// Cargo's OUT_DIR layout is:
// target///build/-/out
// Ancestor 0 = out
// Ancestor 1 = -
// Ancestor 2 = build
// Ancestor 3 = <- this is what we want
out_dir
.ancestors()
.nth(3)
.expect(
"failed to locate Cargo profile directory from OUT_DIR; \
expected layout: target///build//out",
)
.to_path_buf()
}Because the workspace sets target-dir = "target", OUT_DIR resolves to something like <workspace>/target/thumbv8m.main-none-eabihf/debug/build/secure-<hash>/out, and ancestor 3 gives <workspace>/target/thumbv8m.main-none-eabihf/debug. Appending veneer.o gives the final path <workspace>/target/thumbv8m.main-none-eabihf/debug/veneer.o. This is the same path used by the Non-Secure build.rs fallback and by xtask.
build.rsPost 3 introduced the Non-Secure build.rs in the context of how veneer.o reaches the Non-Secure linker. This section covers the same script from the build-flow perspective, focusing on the two-path veneer location strategy and its implications for developer workflow. Readers comfortable with locate_secure_veneer and the SECURE_VENEER environment variable can skip to the end of this section.
fn main() {
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rerun-if-env-changed=SECURE_VENEER");
let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("OUT_DIR is not set"));
let manifest_dir =
PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR is not set"));
copy_linker_script(&manifest_dir, &out_dir, "memory.x");
println!("cargo:rustc-link-search={}", out_dir.display());
println!("cargo:rustc-link-arg=-Tlink.x");
println!("cargo:rustc-link-arg=--nmagic");
if let Some(veneer_path) = locate_secure_veneer(&out_dir) {
// Cargo otherwise has no dependency edge to this externally generated
// import library, so track replacement of the secure veneer explicitly.
println!("cargo:rerun-if-changed={}", veneer_path.display());
println!("cargo:rustc-link-arg={}", veneer_path.display());
} else {
println!(
"cargo:warning=secure veneer object not found yet; `cargo check` can proceed, but `cargo build` will require building `secure/` first"
);
}
}The linker flags serve the same purpose as in the Secure build.rs. The memory layout differs (Non-Secure flash at 0x08040000, Non-Secure SRAM at 0x20018000) but the mechanism is identical.
locate_secure_veneer tries two paths in order:
fn locate_secure_veneer(out_dir: &Path) -> Option {
if let Some(custom_path) = env::var_os("SECURE_VENEER") {
return Some(PathBuf::from(custom_path));
}
let candidate = profile_dir_from_out_dir(out_dir).join("veneer.o");
candidate.exists().then_some(candidate)
}If SECURE_VENEER is set in the environment, it is used directly. If not, the function constructs a candidate path by calling profile_dir_from_out_dir(out_dir), which walks three ancestor levels up from OUT_DIR to reach the Cargo profile directory, the same helper used in the Secure build.rs, then appends veneer.o. If that path exists, it is used; if not, the function returns None.
cargo:rerun-if-env-changed=SECURE_VENEER tells Cargo to re-run the build script whenever that environment variable changes. Without this, changing SECURE_VENEER between builds would not trigger a re-link.
When locate_secure_veneer returns None, the build script emits a warning rather than failing. This is deliberate: cargo check and IDE tooling can analyze the Non-Secure crate for errors without needing a fully linked binary, and they do not require veneer.o to do so. Making the build script fail on a missing veneer would break cargo check in a clean checkout before the Secure crate has been built. The warning is enough to alert a developer who tries cargo build directly in nonsecure/ without having built secure/ first; the linker will then fail with an unresolved symbol error that points to the same root cause.
The correct build path is cargo xtask build, which guarantees the Secure crate is built first, verifies veneer.o exists before proceeding, and passes its path via SECURE_VENEER explicitly rather than relying on the fallback convention.
xtask: enforcing build orderThe xtask crate is a standard host Rust binary. Its Cargo.toml is about as minimal as it gets:
[package]
name = "xtask"
version = "0.1.0"
edition = "2024"
[dependencies]It uses only the Rust standard library: std::env, std::fs, std::path, and std::process::Command. No third-party crates are needed.
The entry point delegates to try_main, which returns a Result and lets the outer main handle error display and exit code:
fn main() {
if let Err(error) = try_main() {
eprintln!("xtask failed: {error}");
std::process::exit(1);
}
}
fn try_main() -> Result<(), String> {
let mut args = env::args().skip(1);
match args.next().as_deref() {
Some("build") => {
let release = args.any(|arg| arg == "--release");
build_all(release)
}
Some("clean") => clean_all(),
Some(other) => Err(format!("unknown command `{other}`")),
None => Err(
"usage: cargo xtask \n\
note: the secure crate must be built before the nonsecure crate;\n\
`cargo xtask build` enforces this order automatically."
.into(),
),
}
}env::args() returns the full command-line argument list including the program name itself. .skip(1) discards that first entry so the iterator starts at the first user-supplied argument. When invoked as cargo xtask build --release, Cargo passes build and --release as the arguments after the program name, so args.next() returns "build" and the remaining iterator holds "--release".
args.next().as_deref() converts the Option<String> returned by next() into an Option<&str>, which allows the match arms to compare against string literals directly. Without .as_deref(), the match would need to compare against String values, which is more verbose.
Inside the "build" arm, args.any(|arg| arg == "--release") scans the remaining arguments for the --release flag. any consumes the iterator and returns true as soon as a match is found, or false if the flag is absent. The boolean is passed to build_all, which uses it to select the Cargo profile and construct the correct output paths.
The None arm handles the case where no subcommand was given at all. The usage string states the build-order constraint explicitly; a developer who runs cargo xtask with no arguments gets a reminder before they try anything else.
build_all is where the ordering is enforced:
fn build_all(release: bool) -> Result<(), String> {
let root = workspace_root()?;
let secure_dir = root.join("secure");
let nonsecure_dir = root.join("nonsecure");
let target_dir = target_dir(&root);
let profile = if release { "release" } else { "debug" };
run_cargo_build(&secure_dir, &target_dir, release, &[])?;
let veneer = target_dir.join(TARGET).join(profile).join("veneer.o");
if !veneer.exists() {
return Err(format!(
"expected secure veneer object at `{}`",
veneer.display()
));
}
run_cargo_build(
&nonsecure_dir,
&target_dir,
release,
&[("SECURE_VENEER", veneer.as_os_str())],
)?;
...
}First, run_cargo_build is called for secure/ with no extra environment variables. The ? propagates any build failure immediately; if the Secure build fails, xtask stops and the Non-Secure build never starts. Second, xtask verifies that veneer.o exists at the expected path. The Secure build could in principle succeed without producing veneer.o if the linker flags were misconfigured; the existence check makes that failure mode obvious rather than letting it propagate as a confusing unresolved-symbol error in the Non-Secure build. Third, run_cargo_build is called for nonsecure/ with SECURE_VENEER set to the confirmed veneer path, bypassing the fallback path convention entirely. The build order is enforced; there is no way to reach the Non-Secure build without a verified veneer.
run_cargo_build shells out to cargo +nightly build, running in the crate directory so that each crate's own .cargo/config.toml is picked up:
fn run_cargo_build(
crate_dir: &Path,
target_dir: &Path,
release: bool,
extra_env: &[(&str, &std::ffi::OsStr)],
) -> Result<(), String> {
let mut command = Command::new("cargo");
command.arg("+nightly").arg("build");
if release {
command.arg("--release");
}
command.current_dir(crate_dir);
command.env("CARGO_TARGET_DIR", target_dir);
for (key, value) in extra_env {
command.env(key, value);
}
...
}Command::new("cargo") creates a new child process that will run the cargo executable. The subsequent .arg() calls build up the command-line arguments one at a time. +nightly is a Cargo toolchain override that tells Cargo to use the nightly toolchain for this invocation rather than whatever the default is; it is equivalent to running cargo +nightly build in a terminal. The build argument is the Cargo subcommand. If release is true, --release is appended, selecting the release profile; otherwise the debug profile is used by default.
command.current_dir(crate_dir) sets the working directory for the child process. This is important because Cargo reads .cargo/config.toml by walking up the directory tree from wherever it is invoked. By running the Secure build from secure/ and the Non-Secure build from nonsecure/, each invocation picks up its own per-crate config, which is where the thumbv8m target and +trustzone flag are declared. If both builds were run from the workspace root, only the workspace-root config would apply and the per-crate configs would be ignored.
command.env("CARGO_TARGET_DIR", target_dir) passes the shared target directory explicitly to the child process. Without this, each firmware crate would use its own per-crate target/ directory (the Cargo default when no workspace config is inherited), and veneer.o would land in a different location than xtask expects. Setting CARGO_TARGET_DIR explicitly ensures both builds write their output to the same shared directory regardless of where they are invoked from.
extra_env is a slice of key-value pairs. The for loop calls .env(key, value) for each pair, injecting those variables into the child process's environment. This is how SECURE_VENEER is passed to the Non-Secure build: the xtask process itself does not have SECURE_VENEER set in its environment; it is added only for the specific cargo build invocation that needs it. That keeps the two builds isolated from each other's environment.
workspace_root derives the workspace root from CARGO_MANIFEST_DIR, which Cargo sets to the xtask/ directory at compile time:
fn workspace_root() -> Result {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.map(Path::to_path_buf)
.ok_or_else(|| "failed to determine workspace root".into())
}env! is a compile-time macro that reads an environment variable when xtask itself is being compiled. CARGO_MANIFEST_DIR is set by Cargo to the directory containing the Cargo.toml of the crate being compiled, which in this case is xtask/. The result is a string literal baked into the binary at build time, not a runtime environment lookup. .parent() walks up one level from xtask/ to reach the workspace root. .map(Path::to_path_buf) converts the resulting &Path reference into an owned PathBuf that can be returned from the function. .ok_or_else(...) converts the Option returned by .parent() into a Result, turning a None (which would mean xtask/ has no parent directory, an unlikely but theoretically possible condition) into an error string.
xtask: producing the combined hexOnce both ELF files exist, build_all calls build_combined_hex to package them:
let secure_elf = target_dir.join(TARGET).join(profile).join("secure");
let nonsecure_elf = target_dir.join(TARGET).join(profile).join("nonsecure");
let combined_hex = target_dir.join(format!("combined-{profile}.hex"));
build_combined_hex(&root, &secure_elf, &nonsecure_elf, &combined_hex)?;Both ELF paths are in the shared target directory, as is the combined output. The result lands at target/combined-debug.hex or target/combined-release.hex.
Each ELF is converted using arm-none-eabi-objcopy:
fn objcopy_to_hex(root: &Path, input_elf: &Path, output_hex: &Path) -> Result<(), String> {
let status = Command::new("arm-none-eabi-objcopy")
.arg("-O")
.arg("ihex")
.arg(input_elf)
.arg(output_hex)
.current_dir(root)
.status()
...
}arm-none-eabi-objcopy is part of the Arm GNU toolchain and must be present on the development machine. The intermediate files are named secure-temp.hex and nonsecure-temp.hex and are deleted after the merge completes. probe-rs can work directly from the individual ELF files and does not require this conversion. The combined HEX is produced as a convenient single programming artifact for STM32CubeProgrammer, OpenOCD-based workflows, and other tools that accept Intel HEX input.
Intel HEX is a line-based text format. Each line is a record with a type field at byte offset 7. The relevant types are:
Type | Meaning |
|---|---|
| Data record |
| End-of-file record |
| Extended segment address record |
| Start segment address record |
| Extended linear address record |
| Start linear address record |
An Intel HEX file should contain one EOF record as its final record. Simply concatenating two complete files leaves the first EOF in the middle of the combined stream; a parser may stop there and never process the second image. The merge therefore removes both input EOF records and appends one final EOF record after all data records. append_hex_without_eof filters each file before appending it to the output:
fn append_hex_without_eof(output: &mut String, contents: &str) {
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let record_type = trimmed.get(7..9);
let is_supported = matches!(record_type, Some("00" | "02" | "04"));
if is_supported {
output.push_str(line);
output.push('\n');
}
}
}Only data records 00), extended segment address records 02), and extended linear address records 04) are kept. EOF records 01) are dropped from both files; a single EOF is appended once after both files have been merged. Start-address records 03 and 05) are also omitted. These records carry an execution start address, but retaining one from each input image would leave two competing entry-point declarations in the combined file. This project uses the HEX file as a programming container: after reset, the processor obtains the Secure entry point from the Secure vector table. The start-address records are therefore unnecessary for the intended flashing workflow. A toolchain that relies on a HEX start-address record could instead retain only the Secure image’s record.
This is a project-specific merge routine, not a general-purpose Intel HEX parser. It assumes valid records produced by arm-none-eabi-objcopy, non-overlapping image address ranges, and no need to preserve metadata records beyond the address-extension records.
The merge itself is three lines:
let mut merged = String::new();
append_hex_without_eof(&mut merged, &secure_contents);
append_hex_without_eof(&mut merged, &nonsecure_contents);
merged.push_str(":00000001FF\n");Secure records appear first, followed by Non-Secure records, followed by a single EOF :00000001FF is the standard Intel HEX EOF record). Because the two images occupy completely different address ranges 0x0C000000 versus 0x08040000), the extended linear address records within each file already encode the correct base addresses; no rewriting is needed.
After the merge, xtask prints a build summary:
secure image built successfully
non-secure image built successfully
veneer object: /target/thumbv8m.main-none-eabihf/debug/veneer.o
combined image: /target/combined-debug.hexThe combined hex can be flashed to the NUCLEO-L552ZE-Q using any tool that supports Intel HEX and can address the full STM32L552 flash range. Both images are written in a single flash operation; the device boots directly into the Secure image, which performs the initialization sequence described in post 2 before handing off to the Non-Secure application.This post completes the series. Six posts have covered TrustZone-M from architectural concept to working firmware, using a concrete Rust no_std experiment on the NUCLEO-L552ZE-Q as the reference point throughout.
Post 1 built the conceptual foundation: security states, the SAU and IDAU, NSC regions, and the SG instruction as the only valid gateway into Secure code. Post 2 turned that into a concrete partition: two linker scripts, a GTZC and SAU configuration that must agree at the 256-byte block level, and a boot sequence ending with BLXNS into the Non-Secure reset handler. Posts 3 and 4 worked through cross-boundary calling in both directions: cmse-nonsecure-entry and its compiler-generated register-clearing wrapper on the inbound path, cmse-nonsecure-call and the FNC_RETURN mechanism on the outbound path. Post 5 covered interrupts and faults: the hardware's automatic Secure context preservation when a Non-Secure exception preempts Secure execution, the integrity signature that defeats fabricated function returns, and the two-level GPIO security configuration that a blinking LED demo made concrete. This post tied the build system together.
Running through all six posts is a single division of responsibility between the hardware, the compiler, and the developer. The hardware enforces where execution is permitted to go. The compiler enforces what state is permitted to cross the boundary. The developer's responsibility is narrower but still real: choosing the partition layout, configuring the SAU and GTZC consistently, validating Non-Secure callbacks before calling them, and structuring the build so the two images are always produced in the correct order with a known veneer.o connecting them.
Rust adds a further layer to that picture. It does not replace TrustZone-M; language-level memory safety and hardware-enforced security boundaries address different threats at different levels. Rust reduces the likelihood of several important classes of memory-safety vulnerability. TrustZone-M complements that protection by preventing compromised Non-secure code from directly accessing resources that have been correctly attributed as Secure. The remaining attack surface depends on correct hardware attribution and carefully designed Secure service interfaces; neither Rust nor TrustZone-M eliminates the need for that design work.
The full project code is available on Doulos' GitHub repository at https://github.com/Doulos/Rust-TrustZone. It includes all source files, linker scripts, and build configuration discussed across this series and can be built and flashed to a NUCLEO-L552ZE-Q with cargo xtask build followed by your preferred flash tool.
This article was written by Dr David Cabanis, principal member technical staff at Doulos and author of the book Embedded Software with Rust.