STM32 + Embassy + OctoSPI: A Rust Async Driver
May 11, 2026
The STM32U595 includes an OCTOSPI peripheral that can drive an 8-bit single-transfer-rate interface. Paired with Embassy — the async embedded Rust framework — it gives a clean, DMA-backed abstraction over a fast MCU ↔ FPGA link.
This post covers the peripheral setup, how I mapped a custom five-field FPGA frame onto
Embassy's TransferConfig, the driver layer for the four BRAM access patterns, and the
dual-buffer prefetch on the FPGA side that keeps the data phase free of bubbles.
The full source is at github.com/zephyr-atomi/octospi.
Why Embassy?
Embassy brings async/await to bare-metal Rust. For an FPGA link driver, that matters
because:
- DMA-backed transfers — the OCTOSPI peripheral DMAs straight from/to a buffer, so a
512-word burst costs one
awaitand no busy-waiting. - Structured concurrency — several tasks can share the link without a mutex in the hot path.
- No RTOS overhead — the executor is a cooperative scheduler with zero dynamic allocation.
Clock Tree and Peripheral Configuration
The MCU runs off HSI with PLL1 providing both the system clock and the OCTOSPI kernel clock:
// stm32/src/test_util.rs
// HSI (16 MHz) → PLL1 ×10/1 = 160 MHz
// → PLL1_R (/1) = 160 MHz sysclk
// → PLL1_Q (/2) = 80 MHz OCTOSPI kernel clock
config.rcc.pll1 = Some(Pll {
source: PllSource::HSI,
prediv: PllPreDiv::DIV1,
mul: PllMul::MUL10,
divp: None,
divq: Some(PllDiv::DIV2), // 80 MHz → OSPI
divr: Some(PllDiv::DIV1), // 160 MHz → sysclk
});
config.rcc.sys = Sysclk::PLL1_R;
config.rcc.mux.octospisel = mux::Octospisel::PLL1_Q;The peripheral config has to match the FPGA's expectations exactly:
// stm32/src/test_util.rs
let ospi_config = ospi::Config {
fifo_threshold: FIFOThresholdLevel::_1Bytes,
memory_type: MemoryType::Macronix, // frame-based, not memory-mapped
device_size: MemorySize::_64MiB, // matches the FPGA address space
chip_select_high_time: ChipSelectHighTime::_2Cycle,
free_running_clock: false,
clock_mode: false, // CPOL = 0, CPHA = 0
wrap_size: WrapSize::None,
clock_prescaler: 9, // 80 MHz / (9+1) = 8 MHz SCLK
sample_shifting: false,
delay_hold_quarter_cycle: false,
chip_select_boundary: 0,
delay_block_bypass: false,
max_transfer: 0,
refresh: 0,
};Two things worth calling out. MemoryType::Macronix selects frame-based transactions rather
than the memory-mapped mode you would use for a real flash part — we are talking to an FPGA,
not a memory device. And the link runs deliberately slow at 8 MHz SCLK during bring-up;
the FPGA side is timing-constrained for up to 25 MHz (create_clock -period 40.000 on
OCTOSPI_CLK), so there is headroom to raise the prescaler once the protocol is trusted.
The Protocol Frame
Every transaction uses the same five-field frame:
CMD (1 byte) → AUX (2 bytes) → ADDR (4 bytes) → DUMMY (8 cycles) → DATA (N bytes)
All fields are clocked on IO[7:0] in 8-bit STR mode. CMD selects one of four opcodes:
| Opcode | Hex | Semantics |
|---|---|---|
CMD_WRITE_INCR | 0xCA | Burst write, auto-increment address each word |
CMD_WRITE_FIXED | 0xFE | Burst write to the same address (FIFO push) |
CMD_READ_INCR | 0xBA | Burst read, auto-increment address |
CMD_READ_FIXED | 0xBE | Burst read from a fixed address (FIFO pop) |
AUX carries the pop count for READ_FIXED (zero otherwise), ADDR is the 32-bit
word-aligned FPGA address, and DUMMY gives the FPGA eight clock cycles to get an AXI read
in flight before the data phase starts.
Embassy has no notion of an "AUX" field, so the trick is to reuse the phases it does model:
| FPGA field | Embassy TransferConfig field | Width |
|---|---|---|
| CMD | instruction | 8-bit |
| AUX | address | 16-bit |
| ADDR | alternate_bytes | 32-bit |
// stm32/src/mmap.rs
fn get_read_config(cmd: u32, aux: u16, byte_addr: u32) -> TransferConfig {
TransferConfig {
iwidth: OspiWidth::OCTO,
instruction: Some(cmd),
isize: AddressSize::_8Bit,
adwidth: OspiWidth::OCTO,
address: Some(aux as u32), // AUX: pop count for READ_FIXED, else 0
adsize: AddressSize::_16Bit,
abwidth: OspiWidth::OCTO,
alternate_bytes: Some(byte_addr), // ADDR: word-aligned FPGA byte address
absize: AddressSize::_32bit,
dwidth: OspiWidth::OCTO,
dummy: DummyCycles::_9, // FPGA needs 8, +1 for the STM32 pipeline offset
..Default::default()
}
}That DummyCycles::_9 cost me an afternoon. The FPGA counts eight dummy cycles, but the
STM32 shifts the data phase by one extra cycle relative to what the register value suggests,
so the peripheral has to be told nine. Writes use DummyCycles::_0 — the FPGA accepts write
data immediately after ADDR.
The Driver Abstraction
OctoSpiMmap wraps the raw peripheral and exposes the four opcodes as typed async methods:
// stm32/src/mmap.rs
pub struct OctoSpiMmap<'a> {
ospi: Ospi<'a, OCTOSPI1, Async>,
}
impl<'a> OctoSpiMmap<'a> {
/// Burst read from incrementing addresses starting at `addr`.
pub async fn read_incr(&mut self, addr: usize, buf: &mut [u32]) -> Result<usize, MmapError> {
let n = buf.len();
assert!(n <= MAX_TRANSFER_WORDS, "transfer exceeds MAX_TRANSFER_WORDS");
let slice: &mut [u8] = bytemuck::cast_slice_mut(buf);
let config = Self::get_read_config(CMD_READ_INCR, 0, addr as u32);
self.ospi
.read(slice, config)
.await
.map_err(|_| MmapError::ReadError)?;
for word in buf.iter_mut() {
*word = u32::from_be(*word); // big-endian wire → native little-endian
}
Ok(n)
}
/// Pop `buf.len()` words from a fixed address. `aux` must match `buf.len()`.
pub async fn read_fixed(
&mut self,
addr: usize,
buf: &mut [u32],
aux: u16,
) -> Result<usize, MmapError> { /* … same shape, CMD_READ_FIXED, aux != 0 */ }
pub async fn write_incr(&mut self, addr: usize, buf: &[u32]) -> Result<usize, MmapError> { /* … */ }
pub async fn write_fixed(&mut self, addr: usize, buf: &[u32]) -> Result<usize, MmapError> { /* … */ }
}The endianness handling is easy to get wrong. The FPGA shifts each 32-bit word out
most-significant-byte-first, so reads run every word through u32::from_be afterwards and
writes stage the payload through to_be_bytes() into a byte buffer before handing it to DMA.
MAX_TRANSFER_WORDS is 512 — the STM32 OSPI DMA will not do more in one go.
The FSM on the FPGA Side
octo_spi_slave.sv parses the frame with a state machine over ST_IDLE, ST_CMD, ST_AUX,
ST_ADDR, ST_DUMMY, ST_DATA (the state_t enum also reserves ST_DONE, which the
current implementation does not use — a new transaction restarts on the CSn falling edge).
Each state advances on byte_ready, strobed by the synchronizer once a full byte has been
shifted in from IO[7:0]:
// Simplified — see fpga/ospi/rtl/octo_spi_slave.sv
if (csn_fall_q) begin
state <= ST_CMD; cnt <= 0;
end else if (byte_ready) begin
case (state)
ST_CMD: begin cmd <= captured_byte; state <= ST_AUX; end
ST_AUX: begin aux <= {aux[7:0], captured_byte}; state <= ST_ADDR; end
ST_ADDR: if (cnt == 3) begin
// writes skip the dummy phase entirely
state <= is_read(txn) ? ST_DUMMY : ST_DATA;
cnt <= is_read(txn) ? READ_DUMMY_CYCLES : 4;
end
ST_DUMMY: /* issue AXI prefetch reads here */ ;
ST_DATA: /* shift data in or out */ ;
endcase
endNote that writes go straight from ST_ADDR to ST_DATA. The dummy phase exists purely to
hide read latency.
Dual-Buffer Prefetch
ST_DUMMY is the load-bearing design choice. Two buffers get filled from AXI reads issued
during those eight cycles:
initial_buf— the first read, issued on the first dummy cycle, for word 0prefetch_buf— a second read, issued midway through the dummy phase, for word 1
Cycle: [ DUMMY 8 cycles ] [DATA word 0] [DATA word 1] [DATA word 2]
AXI req: addr+0 ─┐ addr+4 ─┐ addr+8 ─┐ addr+12 ─┐
Buffer: initial_buf prefetch_buf refilled refilled
By the time ST_DATA begins, word 0 is already sitting in initial_buf and word 1 is on its
way. As each word shifts out, the FSM fires the read for the word after next, so the AXI bus
stays busy and the data stream never stalls — even though a single AXI read takes several
cycles. For READ_FIXED the same machinery runs against a fixed slot, with the AUX pop count
tracked in words_remaining.
The Four BRAM Test Patterns
Each example exercises a different access semantic against a different BRAM slave. The address
decoder routes on addr[17:16]:
bram_a — READ_INCR against an auto-filling BRAM
bram_incr_fill_master.sv refills BRAM A with an incrementing counter every time it sees a
read request, so a read of N words returns a clean ramp:
// stm32/examples/bram_a.rs
let mut buf = [0u32; 8];
mmap.read_incr(BRAM_A_BASE + READ_OFFSET, &mut buf)
.await
.expect("read_incr failed");Reading it a second time returns the ramp shifted by BRAM_WORDS — which is how you know the
refill actually retriggered rather than the read being served from a stale buffer.
bram_c — WRITE_INCR + READ_INCR roundtrip
BRAM C is a plain synchronous BRAM. Write a pattern, read it back, compare:
// stm32/examples/bram_c.rs
mmap.write_incr(BRAM_C_BASE, &payload)
.await
.expect("write_incr failed");
let mut readback = [0u32; 8];
mmap.read_incr(BRAM_C_BASE, &mut readback)
.await
.expect("read_incr failed");
let pass = verify_buffer(&readback, &payload, "bram_c_write_read_incr");This is the test that catches endianness and off-by-one-word bugs first.
bram_b — READ_FIXED against a hardware FIFO
BRAM B is fed autonomously by bram_fifo_master.sv. Each READ_FIXED pops from the FIFO
head; a READ_INCR here would walk off into unrelated addresses instead. The first value is
unknown (the producer has been running), but subsequent values must be consecutive.
bram_d — WRITE_FIXED + READ_FIXED software FIFO
BRAM D is a circular buffer with head/tail pointers managed in the RTL. Push with
write_fixed, pop with read_fixed, and read a 0xDEADBEEF sentinel once it has been
drained.
Running the Tests
cd stm32
export PROBE_RS_PROBE=<your-probe-serial>
cargo run --example bram_a # READ_INCR against auto-fill
cargo run --example bram_b # READ_FIXED hardware FIFO pop
cargo run --example bram_c # WRITE + READ roundtrip
cargo run --example bram_d # WRITE + READ software FIFOEach example prints its verdict over defmt/RTT, so a failing bring-up tells you which
access pattern broke rather than just that the link is dead. When one of them does break, the
next stop is a Vivado ILA capture triggered on req_valid — the FSM state and both prefetch
buffers are wired up as probes, which makes it obvious whether the problem is on the wire, in
the frame decode, or on the AXI side.
For the FPGA-side module breakdown, the BRAM address map, and the ILA probe reference, see the OctoSPI FPGA IP Core project page.