Zero-Copy Pipelines
In embedded systems there's often a wide gap between a system that "functions" and one that performs. A prototype might capture an image, but if that simple act paralyzes the device for 200 milliseconds, blocking network keep-alives, delaying sensor readings, freezing the interface, is it really a viable product?
When we started firmware development for our high-performance STM32H5-based imaging system, we ran into exactly this dilemma. The mandate was deceptively simple: capture high-resolution 1MP monochrome images from an AR0144 global shutter sensor and stream them to external storage. On paper it's a standard requirement for a modern MCU. We had the hardware for it: a 250MHz Cortex-M33 core, a dedicated Digital Camera Interface, and the newly introduced GPDMA controller.
As soon as we started implementing the pipeline, though, we learned that datasheet specs are optimistic and real silicon isn't. A simple data transfer turned into a weeks-long fight against bus contention, cache coherency ghosts, and CPU saturation.
This post covers how we moved from a stuttering, blocking implementation to a fluid zero-copy pipeline: the architectures we rejected, the debugging techniques that revealed the invisible wars happening inside the memory bus, and the final solution that let us process megabytes of data without the CPU's ALU ever getting involved.
The challenge: when math meets reality
To size the problem, start with the numbers. Our camera outputs a 1280x800 pixel image. At 8-bit monochrome depth, that's roughly 1.02 MB of raw data per frame. The STM32H5, powerful as it is, has a fragmented internal SRAM architecture, no single contiguous block large enough to hold a full 1MB frame while also reserving space for RTOS stacks, heaps, and application variables.
That physical constraint dictated the architecture. We had to rely on external memory, and we chose PSRAM connected via the high-speed OctoSPI interface: fast, cost-effective, plenty of space. The plan was simple on paper: the DCMI peripheral receives pixel data from the camera, buffers it temporarily, and we move it to the large external PSRAM.
The bottleneck: bus contention. Our first implementation followed the standard textbook approach for embedded drivers: set up the DCMI to trigger an interrupt when it filled a small internal RAM buffer, and inside that interrupt, copy the chunk to external PSRAM.
It failed immediately and spectacularly. The images we captured were torn. The top 20 percent would be perfect, and the rest would be a jumbled mess, shifted, corrupt, or missing entirely.
The root cause was bus contention on the AHB matrix. The STM32H5 uses a multi-layer bus matrix to arbitrate access between masters like the CPU and DMA, and slaves like memories and peripherals. The DCMI peripheral was the aggressor here: it has a very small internal FIFO, and pixel data pours into it at the relentless speed of the pixel clock. If you don't drain it instantly, it overflows. The CPU was the victim: to move data to external PSRAM it had to fetch instructions from Flash, read data from SRAM, and write data out to the OctoSPI peripheral.
In our first implementation the CPU was trying to saturate the bus writing to external memory while the DCMI screamed for bus access to write incoming pixels into internal RAM. In that high-speed tug-of-war, the DCMI often lost. The FIFO overflowed, pixels dropped, and the image's synchronization was gone for good. At these data rates the CPU isn't just a bottleneck, it's a liability. As long as the main core stayed involved in moving bulk data, the system would never be robust.
The alternative paths not taken
Before committing to a re-architecture, we ran a rigorous trade-off analysis. Zero-copy isn't the only way to solve this, just the optimal one for our specific constraints.
Option A: a bigger internal RAM. The simplest engineering fix would have been an MCU with much more internal SRAM, decoupling capture from storage entirely. The dealbreaker was cost and footprint. MCUs with that much internal SRAM are noticeably more expensive and typically come in larger BGA packages that wouldn't fit our compact PCB design. We were committed to the STM32H5.
Option B: external SDRAM with a dedicated FMC. Many high-end data loggers use external SDRAM driven by a dedicated Flexible Memory Controller, which handles refresh cycles and burst writes efficiently and often yields higher sustained throughput than OctoSPI. The dealbreaker was pin count. A typical 16-bit SDRAM interface needs 30 to 50 GPIO pins, and our package was pin-constrained. Devoting 40 pins to memory would have left nothing for our thermal sensors, radios, or user buttons.
Option C: a compressed stream. We briefly considered compressing the image line by line as it arrived, reducing data volume before it hit memory. The dealbreaker was computational cost. The AR0144 is a raw sensor, and doing software compression at line-rate would burn even more CPU cycles than the simple copy operation, likely making the tearing worse instead of solving it.
We were boxed in. We had to make our specific hardware, DCMI, OctoSPI PSRAM, a busy CPU, work together. The only way forward was optimizing the pipeline itself.

Fighting the ghosts in the machine
We decided to implement a DMA pipeline, configuring the STM32's GPDMA controller to move data directly from the DCMI peripheral to external PSRAM, bypassing the internal SRAM copy entirely.
We set up the descriptors, fired the trigger, and dumped the memory. The transfer "worked," in that the correct number of bytes landed at the address. But when we visualized the data, it wasn't an image. It was garbage.
This wasn't random noise. It was a precise, repeating pattern of corruption. We spent days with logic analyzers and hex dumps staring at the raw memory, and noticed something specific: 32 bytes of perfect pixel data, then 32 bytes of zeros, then 32 bytes of perfect data, then 32 bytes of zeros, over and over. It looked like a barcode overlaid on the photo. That number, 32 bytes, was familiar: it's the exact size of a cache line on the Cortex-M33.
The villain: D-cache incoherency. We'd stumbled into one of the most classic traps in embedded systems. The Cortex-M33 uses a Data Cache to speed up memory access. When the CPU reads from a memory address, it loads that data, and its neighbors, into fast internal cache.
Here was the invisible sequence of failure. At startup, the CPU clears the frame buffer in PSRAM to zeros, and the cache "remembers" those addresses hold zeros. Then the DMA, a separate bus master, writes the new image data directly to PSRAM physical memory, and the CPU never sees this happen. Later, the CPU does some other work, looks at its cache, sees dirty lines, the zeros it touched earlier, and decides to flush them back to main memory to make room. That flush overwrites the new image data the DMA had just written, with stale cached zeros.
The DMA did its job perfectly. The CPU, working from stale information, unknowingly undid it. We were fighting a ghost, and the CPU believed it was helping by saving its data when it was actually destroying ours.


The solution: architecting the zero-copy pipeline
We couldn't just patch the code, we had to design a system that enforced data integrity by design. We called it the zero-copy pipeline, built on one philosophy: data ownership is passed, never copied.
Component 1: the circular linked-list DMA. We abandoned the idea of a linear buffer and configured the PSRAM as a circular ring buffer, using the STM32 GPDMA's linked-list feature. In a typical DMA setup, you set a source and destination and the transfer stops when it finishes, requiring the CPU to reconfigure it. With a linked list, we program the DMA with self-referential instructions: fill buffer A, and when done, automatically reconfigure to fill buffer B, then switch back to A. This creates an infinite capture loop. The DCMI can pour data forever without ever waiting for software to "reload" the DMA, and the hardware handles the wrapping logic instantly.
Component 2: the coordinator state machine. With the hardware running autonomously, we needed a traffic cop to prevent chaos. We built a software module, the Pipeline Coordinator, managing a strict state machine for every slot in the ring buffer: FREE means the slot is empty and available to the camera, CAPTURING means the DCMI is currently writing to it and it's locked, READY means capture finished and the slot holds a valid frame, and OFFLOADING means the storage thread is currently reading it and it's locked.
The Coordinator enforces strict mutual exclusion. The camera never writes to a slot marked OFFLOADING, and storage never reads a slot marked CAPTURING. Crucially, the handoff between states is just a pointer exchange, we pass the memory address from the Capture Task to the Storage Task rather than moving the 1MB of data itself, just a 4-byte reference to it.
Component 3: slaying the cache ghost. To fix the corruption, we implemented explicit cache invalidation. Whenever a buffer transitions from CAPTURING to READY, we issue SCB_InvalidateDCache_by_Addr, which effectively tells the CPU: whatever you think you know about the data at this address range is wrong, forget it, dump it. The next time the CPU or the storage DMA reads from that address, the cache misses and the system fetches fresh, valid pixel data directly from physical PSRAM. This adds negligible overhead, a few microseconds, and guarantees 100 percent data integrity.
Results: efficiency unleashed
The transformation was stark. CPU load went from roughly 95 percent usage, effectively paralyzed during capture and burning cycles in meaningless memcpy loops, down to under 5 percent, mostly idle and simply waiting for the "frame complete" interrupt.
Throughput is now limited only by the physical write speed of the flash chip itself, not by how fast the CPU can execute a for loop, we're limited by the laws of physics on the SPI bus rather than by software.
Robustness improved just as much. The tearing artifacts vanished completely, and the system genuinely behaves in a multithreaded sense, with camera capture and storage offload running independently and safely.

The lesson
The journey from a "simple" capture requirement to a robust zero-copy pipeline taught us something about modern embedded engineering. As microcontrollers like the STM32H5 get more powerful, they also get more complex. The old mental model, where the CPU is the master of every byte, doesn't hold anymore. The CPU isn't the worker, it's the manager. Its job isn't to move the boxes, it's to tell the DMA where the boxes go and then get out of the way.