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 await and 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:

OpcodeHexSemantics
CMD_WRITE_INCR0xCABurst write, auto-increment address each word
CMD_WRITE_FIXED0xFEBurst write to the same address (FIFO push)
CMD_READ_INCR0xBABurst read, auto-increment address
CMD_READ_FIXED0xBEBurst 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 fieldEmbassy TransferConfig fieldWidth
CMDinstruction8-bit
AUXaddress16-bit
ADDRalternate_bytes32-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
end

Note 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 0
  • prefetch_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 FIFO

Each 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.