Rust no_std DMA on Cortex-M: What the Tutorials Skip
Getting DMA transfers working in a no_std Rust environment on Cortex-M is straightforward until it isn't. Memory ownership and interrupt safety are the hard parts.

Getting DMA transfers working in a no_std Rust environment on Cortex-M is straightforward until it isn't. Memory ownership and interrupt safety are the hard parts — the HAL examples compile, then you add a second buffer and the borrow checker tells you truths the C tutorials never did.
Answer first
Use 'static mut or static with UnsafeCell for DMA buffers, register one ISR handler that owns swap logic, and treat every peripheral register write as unsafe with documented invariants. Prefer cortex-m-rt + chip PAC/ HAL (stm32h7xx-hal 0.16.x on our last project) over wrapping C HAL unless you enjoy fighting bindgen across toolchain updates.
Setup
Target: thumbv7em-none-eabihf, STM32H743, embassy-stm32 0.1 was too opinionated for our legacy codebase — we stayed on stm32h7xx-hal with custom DMA glue.
[dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"
stm32h7xx-hal = { version = "0.16", features = ["stm32h743", "rt"] }
Goal: SPI + DMA RX ring, same shape as our C firmware on cache coherency work.
What the tutorials skip
1. Buffers must outlive the transfer
Rust won't let you pass a stack [u8; 1024] to DMA — correctly. Patterns that work:
static mut RX_BUF: [u32; 4096] = [0; 4096];
// Or linker section for non-cacheable (see memory.x):
#[link_section = ".dma_buffers"]
static mut RX: AlignedBuffer<4096> = AlignedBuffer::new();
AlignedBuffer enforces 32-byte alignment for M7 cache lines. We copied the pattern from stm32h7xx-hal examples then extended it.
2. The borrow checker vs. interrupts
Main loop wants &mut [u32]; ISR also writes indices. Options:
- Atomic indices only — buffer is
'static mut, coordination viaAtomicU8(useportable-atomicon older cores). CriticalSectionmutex —cortex-mcritical section around consumer reads; keep ISR work minimal.embedded-hal-async— if you're on Embassy, DMA is a Future; different tradeoff (see scheduling notes in Zephyr vs FreeRTOS migration for why we didn't go full async on that product).
We rejected Mutex<[u32; 4096]> — too heavy, and priority inversion in ISR context is unacceptable.
3. unsafe is the whole DMA API
Every write() to DMA1.st[0].cr is unsafe. Document invariants:
/// SAFETY: Called only from DMA1_Stream0 ISR or with interrupts masked.
/// RX_BUF is exclusively owned by DMA except during consumer window.
unsafe fn set_consumer_half(half: Half) { /* ... */ }
Clippy won't save you. Code review checklist will.
4. Cache maintenance has no safe abstraction
Either MPU non-cacheable section in memory.x:
SECTIONS {
.dma_buffers (NOLOAD) : ALIGN(32) {
*(.dma_buffers .dma_buffers.*);
} > RAM_D2
}
Or call SCB.invalidate_dcache_by_addr in unsafe blocks after TC — same rules as C.
Working ISR pattern
#[interrupt]
fn DMA1_STREAM0() {
let dma = unsafe { &*stm32h7xx_hal::pac::DMA1::ptr() };
// read ISR flags, clear, update atomic half index
// DO NOT allocate, DO NOT lock mutexes held by lower-priority code
}
Enable with:
unsafe {
cortex_m::peripheral::NVIC::unmask(stm32h7xx_hal::pac::Interrupt::DMA1_STREAM0);
}
Priority set before unmask — same lesson as SPI drift from NVIC inversion.
Ownership diagram (mental model)
DMA hardware ──writes──▶ 'static mut RX_BUF
▲
│ invalidate / or non-cacheable MPU
Main loop ──reads─────────────┘
via AtomicU8 half index set in ISR
No shared RefCell across interrupt boundary unless you enjoy borrow already active at 192 kHz.
Testing without std
- Unit tests on host: mock register block with
mockall— limited value. - Hardware-in-loop:
defmtover RTT (defmt-rtt 0.4), assert sample counters match DRDY edges. - ** Miri**: doesn't model DMA. Don't pretend.
Our CI builds + flashes a nucleo-H743ZI and runs a probe-rs run --bin selftest step. Took a week to wire; saved repeated "works on my desk" regressions.
repo note
Example scaffold lives at the placeholder repo in metadata — we haven't open-sourced the full HAL glue because it's 40% #![allow(unused)] register pokes. The patterns above are the extract worth copying.
Tradeoffs vs. C
| Aspect | Rust no_std | C + HAL |
|---|---|---|
| Buffer lifetime bugs | Compile-time + 'static discipline | Silent until soak |
| ISR sharing | Forces atomics/critical sections early | Easy to race |
| Build time | Slower; cargo build --release ~90s | Faster incremental |
| Hiring | Smaller pool for bare-metal Rust | Larger pool |
We kept Rust on the radio coprocessor firmware only; sensor front-end stayed C until cache/MPU story was boring.
Bindgen path we rejected
Wrapping ST HAL via bindgen on stm32h7xx_hal.h compiled — linking was miserable (static inline hell), and every CubeMX regen broke the build. PAC + thin safe wrappers cost two weeks upfront; saved recurring merge pain.
If your team already standardized on C HAL: FFI boundary at the ring buffer API, not at register level.
static mut audit script
We grep CI for static mut outside dma_buffers.rs and interrupt/mod.rs. Any new static mut requires PR comment with ISR/main ownership diagram. Heavy-handed; caught two RefCell in ISR attempts in month one.
Embassy 0.2 note (March 2026 eval)
embassy_stm32::spi::Spi with ReadableDMA trait — DMA future completes on buffer swap; no manual HT/TC if you accept executor overhead. Measured ~4 µs extra latency vs bare ISR on H743 @ 480 MHz — fine for BLE, not for 192 kSPS without dedicated core pinning.
What I'd do next
Evaluate embassy-stm32 0.2 for greenfield — DMA as async with executor pinning might eliminate half our index bugs. For brownfield, don't rewrite; wrap existing C DMA init and use Rust above the ring buffer API.
If you're starting fresh on H7: read cache coherency first, then write DMA. Order matters.
Manish Bookreader
Electronics enthusiast, Embedded Systems Expert, Linux/Networking programmer, and Software Engineer passionate about AI, electronics, books, and cooking.

