Rust Firmware Coding Guidelines

March 4, 2026 · View on GitHub

ARM64 Type-1 Hypervisor — Project-Specific Standards

Based on Safety-Critical Rust Coding Guidelines (Rust Foundation), The Embedded Rust Book, High Assurance Rust, and project experience.


1. Foundational Principles

1.1 Language Subset

#![no_std]                    // Mandatory — no standard library
#![no_main]                   // Bare-metal entry point via linker
  • Core crate only (core::, alloc:: with custom allocator).
  • No dynamic dispatch — enum-dispatch over trait objects (dyn Trait). Vtable overhead and indirection are unacceptable at EL2.
  • No recursion — all call graphs must be statically bounded (MISRA Rule 17.2). Stack is fixed-size (128KB per pCPU).
  • No floating-pointCPTR_EL3.TFP may trap FP/SIMD from S-EL2. Avoid f32/f64 in all code paths. Rust debug builds may emit NEON instructions for read_volatile alignment checks — build with opt-level >= 1 for S-EL2.

1.2 Memory Allocation

  • Boot-time only — all heap allocation (Box, Vec, page tables) occurs during initialization.
  • No runtime allocation on the hot path (exception handlers, MMIO dispatch, SMC forwarding).
  • BumpAllocator with free-list — deterministic, O(1) alloc/free. No fragmentation risk from bump-only; free-list handles page table tear-down.
  • Allocation failure = panic — acceptable at boot (alloc_page().expect("...")), forbidden in exception paths.

1.3 Error Handling Strategy

ContextPatternExample
Boot init.expect("message")alloc_page().expect("L0 table")
State machine.expect("transition")sp.transition_to(Idle).expect(...)
FF-A protocolResult<T, i32>reclaim_share() -> Result<(), i32>
Exception handlerNever panicReturn bool / SmcResult8
Device emulationOption<u64> / boolread() -> Option<u64>

Rule: Exception handlers (handle_exception, handle_irq_exception, handle_fiq_exception) must never call .expect(), .unwrap(), or any panicking function. A panic inside an exception handler is unrecoverable.


2. Unsafe Code Discipline

2.1 Minimize and Isolate

  • Every unsafe block must have a // SAFETY: comment explaining why the operation is safe.
  • Wrap unsafe operations in safe abstractions where possible. Consumers should not need unsafe.
  • Maximum unsafe block size: 5 statements. If more, extract a helper function with a # Safety doc comment.

2.2 SAFETY Comment Format

// SAFETY: <precondition> — <why it holds>
unsafe {
    core::ptr::write_volatile(addr as *mut u32, value);
}

Good examples:

// SAFETY: addr is GICD_IGROUPR0 (0x08000080), identity-mapped and within GICD MMIO range
unsafe {
    let val = core::ptr::read_volatile(addr as *const u32);
}

// SAFETY: VTTBR_EL2 valid — set during boot via Stage2Config::install().
// Identity-mapped page tables guarantee l0_table PA == VA.
unsafe {
    let l0_entry = *(self.l0_table as *const u64).add(l0_idx);
}

// SAFETY: Single-threaded init — called once from rust_main() before any concurrent access
unsafe {
    MANIFEST = Some(manifest);
}

Bad examples (insufficient justification):

// SAFETY: we need unsafe here          ← WHY is it safe?
// SAFETY: trust me                     ← Not a justification
// SAFETY: same as above                ← Each block needs its own reasoning

2.3 Permitted Unsafe Patterns

PatternRiskRequired Justification
asm!("mrs/msr ...")LOWRegister name + expected context (EL2/S-EL2)
core::ptr::read_volatileMEDIUMAddress validity + alignment
core::ptr::write_volatileMEDIUMAddress validity + alignment + no concurrent writers
core::ptr::read_unalignedMEDIUMBounds check before read + source validity
UnsafeCell::get() derefHIGHExclusive access proof (single-pCPU / lock held / init-only)
static mut accessHIGHSingle-threaded context proof
Page table pointer derefHIGHVTTBR valid + identity-mapped + entry within table bounds
core::ptr::copy_nonoverlappingMEDIUMsrc/dst valid + no overlap + size within allocation

2.4 Prohibited Unsafe Patterns

  • core::mem::transmute — use as casts or from_ne_bytes() / to_ne_bytes().
  • Raw pointer arithmetic beyond allocation bounds — always validate offset before deref.
  • static mut in concurrent code — use AtomicXxx or SpinLock<T> instead.

3. Inline Assembly Rules

3.1 General Rules

  • All assembly in .S files — no global_asm!(). The build.rs cross-compiles .S via aarch64-linux-gnu-gcc.
  • core::arch::asm!() for system register access only — MRS, MSR, ISB, DSB, TLB invalidation, SMC.
  • Always specify options(): nostack (no stack use), nomem (no memory side effects), or document why omitted.
// GOOD: options specified
unsafe {
    asm!("mrs {}, esr_el2", out(reg) esr, options(nostack, nomem));
}

// BAD: options omitted without justification
unsafe {
    asm!("mrs {}, esr_el2", out(reg) esr);
}

3.2 Barrier Requirements

After this operation...Required barrier
MSR to system register (except DAIF)isb
MSR to VTTBR_EL2 / TTBR0_EL2isb
Write to page table entrydsb ish + tlbi + dsb ish + isb
Write_volatile to GICDNone (device memory)
Write_volatile to page table entrydsb ish (before TLB invalidation)

3.3 Break-Before-Make Protocol (CRITICAL)

When modifying an existing valid page table descriptor, ARM architecture (D5.7) requires:

// Step 1: Write INVALID descriptor (break)
unsafe { core::ptr::write_volatile(pte_ptr, 0u64); }

// Step 2: TLB invalidation + barrier
Self::tlbi_all();   // tlbi alle2 + dsb ish + isb

// Step 3: Write NEW descriptor (make)
unsafe { core::ptr::write_volatile(pte_ptr, new_desc); }

// Step 4: Final TLB invalidation
Self::tlbi_all();

Violation consequence: Translation faults, TLB corruption, data loss. This sequence must never be optimized or reordered.


4. SMCCC Calling Convention

4.1 Register Preservation Rules

Per SMC Calling Convention (DEN0028):

RegistersPreservationRole
x0-x3Input/OutputFunction ID + args + return values
x4-x7Input/Output (FF-A 8-reg)Additional args for FFA_DIRECT_REQ/RESP
x8-x17CORRUPTEDCaller must NOT rely on these across SMC
x18Platform-specificDo not use
x19-x30Callee-savedPreserved across SMC calls
SPPreservedStack pointer maintained

4.2 Critical Rule: Never Store Values in x8-x17 Across SMC

@ BAD — x16/x17 are corrupted by SMC
mov     x16, x4          @ save handle_lo
mov     x17, x5          @ save handle_hi
smc     #0                @ x16, x17 now GARBAGE
cmp     x16, x0           @ UNDEFINED BEHAVIOR

@ GOOD — use callee-saved registers
mov     x24, x4          @ save handle_lo (callee-saved)
mov     x25, x5          @ save handle_hi (callee-saved)
smc     #0                @ x24, x25 preserved
cmp     x24, x0           @ correct

Lesson learned: This caused BL33 Test 14 failure. See bugs-and-issues.md.

4.3 SMC Forwarding Pattern (Rust)

// SAFETY: x0-x7 contain valid FF-A arguments. x4-x17 marked as clobbered per SMCCC.
unsafe {
    core::arch::asm!(
        "smc #0",
        inout("x0") x0 => r0,
        inout("x1") x1 => r1,
        inout("x2") x2 => r2,
        inout("x3") x3 => r3,
        in("x4") x4, in("x5") x5, in("x6") x6, in("x7") x7,
        lateout("x4") _, lateout("x5") _, lateout("x6") _,
        lateout("x7") _, lateout("x8") _, lateout("x9") _,
        lateout("x10") _, lateout("x11") _, lateout("x12") _,
        lateout("x13") _, lateout("x14") _, lateout("x15") _,
        lateout("x16") _, lateout("x17") _,
        options(nomem, nostack),
    );
}

Rule: Always declare lateout("x4") through lateout("x17") as clobbered. Omitting these is a latent correctness bug.

4.4 SMC vs HVC PC Adjustment

InstructionELR_EL2 points to...Action needed
SMC #0The SMC instruction itselfcontext.pc += 4 after handling
HVC #0Instruction after HVCNo adjustment needed
WFI/WFEThe WFI/WFE instructionNo adjustment (re-execute or advance)
Data AbortFaulting instructionNo adjustment (emulate in-place)

5. Context Save/Restore

5.1 VcpuContext Layout

The VcpuContext struct at offset 0 maps directly to exception.S save/restore:

Offset   0: x0-x1     (16 bytes)
Offset  16: x2-x3     (16 bytes)
...
Offset 240: x30       (8 bytes)
Offset 248: sp_el1    (8 bytes)
Offset 256: elr_el1   (8 bytes)
Offset 264: spsr_el1  (8 bytes)
Offset 384: sp        (8 bytes, host)
Offset 392: pc        (8 bytes, guest ELR_EL2)
Offset 400: spsr_el2  (8 bytes, guest PSTATE)

Rule: Any modification to VcpuContext struct field order or size requires simultaneous update to exception.S offsets. Mismatch causes silent register corruption.

5.2 EL1 System Register Save/Restore

When switching between SPs or between SP and NWd:

// Save BEFORE context switch
sp.el1_state.save();    // 18 registers via MRS

// Restore AFTER context switch
sp.el1_state.restore(); // 18 registers via MSR + ISB

Required registers (SpEl1State): SCTLR_EL1, TTBR0/1_EL1, TCR_EL1, MAIR_EL1, VBAR_EL1, CPACR_EL1, CONTEXTIDR_EL1, TPIDR_EL0/EL1/TPIDRRO_EL0, PAR_EL1, CNTKCTL_EL1, AFSR0/1_EL1, ESR_EL1, FAR_EL1, AMAIR_EL1, MDSCR_EL1.

Lesson learned: Missing SpEl1State save/restore caused pKVM DIRECT_REQ failures. pKVM corrupts EL1 sysregs during world switch.

5.3 Guest SPSR_EL2 — Never Modify

// BAD — causes guest spinlock deadlock
context.spsr_el2 &= !(1 << 7); // Clearing PSTATE.I

// GOOD — guest manages its own interrupt mask
// (do not touch SPSR_EL2 from hypervisor)

Rationale: Guest OS assumes it has exclusive control of PSTATE.I (interrupt mask). Overriding it from EL2 causes spinlock holders to be interrupted while holding locks.


6. MMIO and Device Emulation

6.1 Volatile Access

All MMIO register access must use volatile operations:

// SAFETY: addr is a valid MMIO register within the device's mapped range
unsafe {
    let val = core::ptr::read_volatile(addr as *const u32);
    core::ptr::write_volatile(addr as *mut u32, new_val);
}

Never use plain pointer dereference (*ptr) for MMIO — the compiler may optimize away or reorder accesses.

6.2 HPFAR_EL2 for Guest IPA (CRITICAL)

When Stage-2 is enabled (HCR_EL2.VM=1), FAR_EL2 holds the guest VA, not IPA:

// CORRECT: IPA from HPFAR_EL2 + FAR_EL2
let ipa = (hpfar & 0x0000_0FFF_FFFF_FFF0) << 8 | (far_el2 & 0xFFF);

// WRONG: FAR_EL2 alone (only works when guest MMU is off)
let ipa = far_el2; // BUG — this is a VA!

6.3 Device Register Abstraction

Use the enum-dispatch pattern with contains() for address routing:

pub enum Device {
    Uart(VirtualUart),
    Gicd(VirtualGicd),
    // ...
}

impl Device {
    pub fn contains(&self, addr: u64) -> bool { /* base..base+size */ }
    pub fn read(&mut self, offset: u64, size: u8) -> Option<u64> { /* dispatch */ }
    pub fn write(&mut self, offset: u64, value: u64, size: u8) -> bool { /* dispatch */ }
}

Rule: Every new emulated device must implement PrimeCell ID registers (0xFE0-0xFFC) for Linux amba bus probe. Missing IDs cause OF_BAD_ADDR in dmesg.


7. Concurrency and Synchronization

7.1 Single-pCPU vs Multi-pCPU

MechanismSingle-pCPUMulti-pCPU
Device accessUnsafeCell (no locking)SpinLock<T> (ticket lock)
SGI/SPI bitmasksAtomicU32AtomicU32
Per-CPU contextGlobal variableTPIDR_EL2 (hardware-banked)
SPI deliveryLocal injectionPhysical SGI 0 cross-CPU wake

Rule: Code that accesses shared state must be feature-gated:

#[cfg(not(feature = "multi_pcpu"))]
// UnsafeCell access OK — single pCPU, no concurrency

#[cfg(feature = "multi_pcpu")]
// Must hold SpinLock or use atomics

7.2 Atomic Ordering

OperationRequired OrderingRationale
Flag set/check (non-critical)RelaxedNo ordering dependency
CURRENT_VM_ID storeReleasePairs with Acquire loads in exception handler
SP_IRQ_PREEMPTED swapAcquireMust see SP register state written before flag
Lock acquireAcquireStandard lock semantics
Lock releaseReleaseStandard lock semantics

Rule: Do not use SeqCst — it generates unnecessary dmb barriers on ARM64. Acquire/Release pairs are sufficient.

7.3 Deadlock Prevention

inject_spi() must NOT acquire the DEVICES lock — it may be called from within a DEVICES lock (e.g., virtio interrupt signaling). In multi-pCPU mode, read physical GICD_IROUTER directly (EL2 bypasses Stage-2).

SpinLock is non-reentrant — re-locking on the same CPU will deadlock. Structure code to avoid nested lock acquisition.


8. Feature Flags and Conditional Compilation

8.1 Feature Hierarchy

default (unit tests only)
├── guest (Zephyr)
├── linux_guest
│   ├── multi_pcpu (1:1 vCPU-to-pCPU)
│   ├── multi_vm (2 VMs time-sliced)
│   └── tfa_boot (TF-A BL33 mode)
└── sel2 (S-EL2 SPMC)

8.2 Mutual Exclusivity

  • multi_pcpumulti_vm — different scheduling models
  • sel2linux_guest — different privilege levels
  • sel2guest — different boot paths

Rule: Add compile_error! guards for invalid combinations:

#[cfg(all(feature = "multi_pcpu", feature = "multi_vm"))]
compile_error!("multi_pcpu and multi_vm are mutually exclusive");

8.3 Gating Pattern

// Module-level: entire module gated
#[cfg(feature = "sel2")]
pub mod sel2_mmu;

// Function-level: alternate implementations
#[cfg(feature = "multi_pcpu")]
fn get_context() -> *mut VcpuContext { /* TPIDR_EL2 */ }

#[cfg(not(feature = "multi_pcpu"))]
fn get_context() -> *mut VcpuContext { /* global variable */ }

// Block-level: minimal scope
let hcr = HCR_BASE
    | HCR_TSC   // SMC trap
    #[cfg(not(feature = "sel2"))]
    { | HCR_FMO }  // FIQ routing (NS-EL2 only)
    ;

9. Testing Standards

9.1 Test Structure

Each test module in tests/ follows:

pub fn run_xxx_test() {
    uart_puts(b"\n========================================\n");
    uart_puts(b"  Test Suite Name\n");
    uart_puts(b"========================================\n\n");

    // Test 1: Description
    uart_puts(b"[TAG] Test 1: ...\n");
    if condition {
        uart_puts(b"[TAG] Test 1 PASSED\n\n");
    } else {
        uart_puts(b"[TAG] FAILED: reason\n");
    }

    uart_puts(b"=== Test Suite: All N tests PASSED ===\n");
}

9.2 Test Categories

CategoryLocationInvocation
Unit testssrc/tests/make run (33 suites, ~347 assertions)
BL33 integrationtfa/bl33_ffa_test/start.Smake run-spmc (14 tests)
Linux FF-Aguest/linux/ffa-test/make run-pkvm (kernel module)

9.3 Test Rules

  • Every new FF-A handler must have a unit test in test_ffa.rs or test_spmc_handler.rs.
  • Every new device must have a dedicated test module with PrimeCell ID validation.
  • Update test counts in CLAUDE.md when adding/removing assertions.
  • Feature-gated tests must not break default builds. Guard with #[cfg(feature = "...")].
  • Stale VTTBR_EL2: Clear VTTBR_EL2 at the end of page table tests and start of FF-A tests to avoid cross-test contamination.

10. BL33 / SP Assembly Test Code

10.1 BL33 Test Pattern

@ Test N: Description
    adr     x0, str_tN
    bl      uart_print          @ Print test header

    @ Setup
    ldr     x0, =FFA_FUNCTION_ID
    mov     x1, #arg1
    smc     #0                  @ FF-A call

    @ Verify x0 == expected
    ldr     x8, =EXPECTED_VALUE
    cmp     x0, x8
    b.ne    .Lfail_N

    @ PASS
    adr     x0, str_pass
    bl      uart_print
    b       .Ltest_next

.Lfail_N:
    adr     x0, str_fail
    bl      uart_print

10.2 SP Message Loop Pattern

.Lmsg_loop:
    @ Check x0 == FFA_DIRECT_REQ_32
    ldr     x8, =FFA_DIRECT_REQ_32
    cmp     x0, x8
    b.ne    .Lunknown_msg

    @ Swap source/dest in x1
    lsr     x8, x1, #16        @ x8 = source
    and     x9, x1, #0xFFFF    @ x9 = dest
    orr     x1, x8, x9, lsl #16

    @ Process command (dispatch on x3)
    @ ...

    @ Respond
    ldr     x0, =FFA_DIRECT_RESP_32
    smc     #0
    b       .Lmsg_loop

10.3 SP Rules

  • Save callee-saved registers (x19-x30) before any SMC call from SP.
  • FFA_MSG_WAIT on unknown messages — never hang. Go back to idle.
  • Error responses: Always send DIRECT_RESP even on failure. Never leave the NWd waiting.
  • No infinite loops without SMC — SP must always yield back to SPMC.

11. Known Pitfalls (Project-Specific)

These are hard-won lessons from debugging. Treat as mandatory checklist items:

#PitfallConsequenceRule
1SMCCC x8-x17 corrupted across SMCSilent data corruptionUse x19-x30 for values surviving SMC
2FAR_EL2 is VA, not IPAWrong MMIO addressUse HPFAR_EL2 for IPA
3Modifying guest SPSR_EL2Spinlock deadlockNever touch PSTATE.I from EL2
4Missing ISB after MSRStale register valueAlways isb after MSR
5CPTR_EL3.TFP=1 traps FP from S-EL2Silent hang on read_volatileCTX_INCLUDE_FPREGS=1 in TF-A
6SPMD is per-CPUSecondary CPUs miss eventsEach CPU runs own event loop
7SpEl1State not saved across world switchEL1 sysreg corruptionSave/restore around enter_guest()
8inject_spi() inside DEVICES lockDeadlockRead GICD_IROUTER directly
9CNTHP timer preempts SP without owned INTIDsSpurious FFA_INTERRUPTRe-arm timer, return true
10VcpuContext struct/asm offset mismatchSilent register corruptionUpdate both simultaneously
11FIQ vectors routing to sync handlerStale ESR_EL2, hangsFIQ/IRQ vectors → irq_exception_handler
12HCR_EL2.VM not set on secondary CPUsNo Stage-2 translationSet explicitly in secondary init

12. Code Review Checklist

Before merging, verify:

  • Every unsafe block has a // SAFETY: comment
  • No .unwrap() or .expect() in exception handlers
  • asm!() blocks have options(nostack, nomem) or documented justification
  • SMC forwarding declares lateout("x4") through lateout("x17")
  • Page table modifications follow break-before-make
  • New device implements PrimeCell ID registers
  • VcpuContext changes have matching exception.S updates
  • Feature-gated code compiles under both feature on/off
  • Test counts updated in CLAUDE.md
  • No SeqCst atomics (use Acquire/Release)
  • No static mut in concurrent code paths
  • ISB after every MSR to system register

References