DMA-Friendly Buffers: Mastering High-Speed Camera Capture

DMA-Friendly Buffers: Mastering High-Speed Camera Capture

Building a production-ready camera system with 45 MB/s sustained throughput

Direct Memory Access promises zero-CPU data transfers, but poor buffer design can turn that promise into corrupted frames, system crashes, and unpredictable behavior. This post shares hard-won lessons from building Hoomanely's camera module, a dual-sensor system capturing and processing visual and thermal data in real time on an STM32 microcontroller running at 250 MHz. We'll cover the design decisions around buffer alignment, cache coherence, and interrupt handling that enable reliable, sustained throughput with minimal CPU intervention.

The naive approach, letting the CPU copy this data byte by byte, would consume 100% of our processing budget, leaving nothing for actual application logic. DMA looked like the perfect fix: configure it once, let hardware handle the transfers, and free the CPU for useful work. But here's what we learned the hard way: DMA without proper buffer design is worse than no DMA at all.

Initial symptoms included random pixel corruption in captured images, occasional system freezes during high-throughput periods, DMA transfers taking 50% longer than the theoretical maximum, and mysterious "ghost pixels" from previous frames bleeding through. The root causes: misaligned buffers, cache coherence violations, and interrupt handlers blocking for milliseconds.

Understanding STM32 memory architecture

The STM32 provides several distinct memory regions, each with different characteristics that matter for DMA:

/* Memory regions from our linker script */
MEMORY {
    DTCM  (xrw) : ORIGIN = 0x20000000, LENGTH = 128K   /* Fastest, no cache */
    SRAM1 (xrw) : ORIGIN = 0x20020000, LENGTH = 256K   /* Fast, cached */
    SRAM2 (xrw) : ORIGIN = 0x20060000, LENGTH = 256K   /* Fast, cached */
    SRAM3 (xrw) : ORIGIN = 0x200A0000, LENGTH = 128K   /* Fast, cached */
    PSRAM (xrw) : ORIGIN = 0x60000000, LENGTH = 8M     /* External, no cache */
}

The critical insight: DMA controllers bypass CPU caches entirely. When DMA writes to SRAM1/2/3, the CPU's data cache may still hold old data, so when the CPU reads that location, it gets stale cache data instead of the fresh transfer. This is the root of most DMA data corruption bugs.

For the camera frame buffer, we kept it in internal SRAM with careful alignment:

/* From dcmi_camera.c - 517KB frame buffer in internal SRAM */
uint32_t dcmi_frame_buffer1[BUFFER_SIZE / 4]
    __attribute__((aligned(32)));  /* 32-byte alignment critical for DMA */

Buffer alignment: the 32-byte rule

ARM Cortex-M33 processors use 32-byte cache lines. If your DMA buffer isn't aligned to 32 bytes, cache operations have to work with partial cache lines, reading, modifying, and writing back more data than necessary. That causes a real performance penalty (3 to 4 bus cycles instead of 1 for burst transfers), cache thrashing (neighboring data gets evicted unnecessarily), and subtle corruption (adjacent variables get overwritten if they share a cache line).

Our sensor configuration needs careful calculation:

#define IMAGE_WIDTH  640
#define IMAGE_HEIGHT 400  
#define EMBEDDED_ROWS 4   // 2 for data + 2 for statistics
#define TOTAL_HEIGHT (IMAGE_HEIGHT + EMBEDDED_ROWS)  // 404 lines

/* Each pixel is 12 bits, packed into 16-bit words */
#define BUFFER_SIZE (IMAGE_WIDTH * TOTAL_HEIGHT * 2)  
// = 640 x 404 x 2 = 517,120 bytes

The embedded rows carry metadata, rows 0-1 for frame counter and exposure settings, rows 2-3 for histogram statistics used in auto-exposure, and rows 4-403 for the actual image. Missing these rows causes DMA overruns because the controller expects more data than your buffer can hold.

During development, we added runtime alignment verification in debug builds, catching several bugs where stack-allocated or dynamically-allocated buffers weren't properly aligned.

Interrupt context vs. task context

This was our biggest realization. Early versions processed camera frames directly in the DMA complete interrupt:

/* WRONG: Heavy processing in interrupt context - DON'T DO THIS */
void HAL_DCMI_FrameEventCallback(DCMI_HandleTypeDef *hdcmi) {
    // This blocks ALL interrupts for milliseconds!
    process_image(frame_buffer, size);        // 2-3ms processing
    calculate_histogram(frame_buffer);        // 1ms
    apply_auto_exposure();                    // 0.5ms
    send_via_usb(frame_buffer, size);        // 5-10ms!
}

Interrupts disabled for 10-plus milliseconds meant we were dropping CAN bus messages, missing thermal sensor data, and occasionally hitting watchdog resets. FreeRTOS scheduler diagnostics showed 15 to 20% of CPU time wasted in interrupt overhead.

The fix was a deferred processing pattern. The interrupt handler should only record what happened and notify who cares, all actual processing happens in task context where it can be preempted, scheduled fairly, and won't block other interrupts. After implementing this, interrupt handler execution dropped from 12ms to about 9.2 microseconds, we saw zero missed CAN bus messages, FreeRTOS scheduler overhead dropped from 18% to 3%, and frame timing became consistent with under 100 microseconds of jitter.

Linked-list DMA: zero-copy camera capture

Standard DMA needs CPU intervention after each transfer to reconfigure for the next one, for a 30 FPS camera, that's 30 interrupts per second just for setup overhead. The STM32's GPDMA supports linked-list mode where multiple transfers get pre-configured and execute autonomously.

/* From dcmi_linked_list.c - Simplified for clarity */
HAL_StatusTypeDef MX_DCMI_LinkedList_Config(void) {
    DMA_NodeConfTypeDef pNodeConfig;
  
    handle_GPDMA1_Channel7.InitLinkedList.Priority = DMA_LOW_PRIORITY_LOW_WEIGHT;
    handle_GPDMA1_Channel7.InitLinkedList.LinkStepMode = DMA_LSM_FULL_EXECUTION;
    handle_GPDMA1_Channel7.InitLinkedList.LinkedListMode = DMA_LINKEDLIST_CIRCULAR;
  
    pNodeConfig.NodeType = DMA_GPDMA_2D_NODE;
    pNodeConfig.Init.Request = GPDMA1_REQUEST_DCMI;
    pNodeConfig.Init.BlkHWRequest = DMA_BREQ_BLOCK;
    pNodeConfig.Init.Direction = DMA_PERIPH_TO_MEMORY;
  
    pNodeConfig.Init.SrcDataWidth = DMA_SRC_DATAWIDTH_WORD;
    pNodeConfig.Init.DestDataWidth = DMA_DEST_DATAWIDTH_WORD;
    pNodeConfig.Init.SrcBurstLength = 1;  
    pNodeConfig.Init.DestBurstLength = 1;
  
    HAL_DMAEx_List_BuildNode(&pNodeConfig, &DCMI_Node1);
    HAL_DMAEx_List_InsertNode_Tail(&DCMI_Queue, &DCMI_Node1);
    HAL_DMAEx_List_BuildNode(&pNodeConfig, &DCMI_Node2);
    HAL_DMAEx_List_InsertNode_Tail(&DCMI_Queue, &DCMI_Node2);
  
    HAL_DMAEx_List_SetCircularModeConfig(&DCMI_Queue, &DCMI_Node1);
  
    return HAL_OK;
}

Two parameters mattered more than expected. BlkHWRequest = DMA_BREQ_BLOCK means the DCMI peripheral signals the DMA controller only when it has a complete line of pixels ready, preventing a transfer from starting on partial data. SrcBurstLength = 1 looks counter-intuitive, but the DCMI FIFO is only 4 words deep, setting burst length above that causes the DMA to try reading more than the FIFO holds, leading to overruns, we tested 2, 4, and 8 and all performed worse or corrupted data. Frame capture now happens entirely in hardware with zero CPU intervention until completion, and DMA efficiency rose from 68% to 92% of theoretical maximum.

Cache coherence: the hidden killer

Even with perfect alignment and deferred processing, we still saw occasional corruption, one or two wrong pixels every few hundred frames. The cause was cache coherence violations: the CPU reads a pixel and caches a copy, DMA writes a new value to RAM, and the CPU later writes its stale cached copy back, overwriting the fresh DMA data.

The cleanest fix is uncached memory, placing large buffers like camera frames in PSRAM, which bypasses the cache entirely, zero cache management overhead, no coherence bugs, deterministic performance, at the cost of higher latency (roughly 50ns versus 0ns for DTCM). Our current implementation instead uses internal SRAM with explicit cache maintenance:

/* Before DMA write: Clean cache to ensure fresh data in RAM */
SCB_CleanDCache_by_Addr((uint32_t*)frame_buffer, BUFFER_SIZE);

HAL_DCMI_Start_DMA(&hdcmi, DCMI_MODE_SNAPSHOT,
                  (uint32_t)frame_buffer, BUFFER_SIZE / 4);

/* After DMA complete: Invalidate cache so CPU reads fresh data */
SCB_InvalidateDCache_by_Addr((uint32_t*)frame_buffer, BUFFER_SIZE);

We measured about 12% better end-to-end performance with internal SRAM because of lower latency for small reads, prefetching of adjacent cache lines during processing, and cache clean and invalidate operations taking only around 800 microseconds for 517KB. Critically, cache operations have to cover the entire buffer, not just the used portion, we learned this the hard way when processing only the image rows left the embedded metadata rows cached, corrupting metadata.

Memory barriers, __DSB(), __ISB(), __DMB(), show up throughout the HAL drivers at critical points: before cache maintenance, after DMA configuration, before peripheral enable/disable. Rule of thumb: if you're mixing CPU and DMA access to the same region, use __DMB() between cache operations and DMA start/stop.

Performance results

After full optimization, throughput reached 45.2 MB/s sustained (up from 12.3 MB/s with a naive CPU copy), CPU usage dropped to 12% (from 87%), frame jitter tightened to plus-or-minus 80 microseconds (from plus-or-minus 8ms), and corruption dropped to zero observed events, measured over 4 hours of continuous operation across 432,000 frames with concurrent thermal processing, storage writes, and CAN bus traffic.

With full optimization, the CPU budget breaks down to roughly 0.2% for camera DMA interrupt overhead, 8.5% for frame processing (histogram, auto-exposure, encoding), 3.2% for the thermal sensor, 4.1% for storage management, 2.0% for system overhead, leaving about 82% available for application logic, real headroom for computer vision, sensor fusion, or wireless communication.

Practical implementation guide

Declare buffers as global or static with an alignment attribute, or use an aligned dynamic allocator, never rely on stack allocation (alignment isn't guaranteed) or plain malloc() (typically only 8-byte aligned). Add runtime verification in development builds checking alignment, size, and that the buffer doesn't cross a memory region boundary. And follow a consistent DMA configuration template: verify buffer config, clean cache if using cached memory, configure the DMA channel, issue a memory barrier, start the transfer, then invalidate cache and issue another barrier before deferring processing to task context.

Troubleshooting common issues

Random pixel corruption in a small fraction of frames, with a stable system otherwise, almost always points to a cache coherence violation, fix by verifying cache operations cover the entire buffer (not just the image region), using uncached memory, or configuring the MPU to mark the region non-cacheable. DMA transfers running much slower than expected point to misalignment, wrong peripheral clock frequency, or suboptimal burst length, verify each systematically with printed diagnostics and cycle-counter timing. A DMA timeout error usually means a clock mismatch between DCMI and the sensor, verify PIXCLK is present at the right frequency on an oscilloscope, confirm DCMI polarity settings match the sensor, and check the sensor is actually streaming.

Why it matters at Hoomanely

These DMA optimization techniques directly enable intelligent, efficient edge computing. With 82% CPU budget remaining after camera and thermal capture, our module can run sophisticated computer vision, thermal anomaly detection, and predictive models directly on the edge with no cloud dependency. Cutting CPU load from 87% to 12% translates directly into battery life for field deployments. And zero data corruption over 432,000 consecutive frames is what production readiness actually looks like when monitoring can't tolerate dropped or corrupted data.

Key takeaways

Alignment is non-negotiable, always align DMA buffers to 32 bytes on ARM Cortex-M33, the performance difference is over 50% and the debugging cost of misalignment is enormous. Cache coherence must be managed, either use uncached memory or explicitly clean and invalidate caches around every DMA operation, there's no middle ground. Defer heavy processing, interrupt handlers should only record events and notify tasks. Linked-list DMA enables zero-copy for high-throughput streaming like camera capture. And measure everything, don't assume, use cycle counters, logic analyzers, and systematic benchmarking to verify your optimizations actually work.