The Cortex-M4’s DSP extensions pack significant signal processing power into a microcontroller. But extracting that performance requires understanding the instruction set intimately. Let’s build a real-time audio filter that pushes the M4 to its limits.

Cortex-M4 DSP Architecture

The M4 adds single-cycle 16-bit SIMD operations to the Cortex-M3 base:

SIMD Operation: SMLAD (Dual Multiply Accumulate) Instruction
31272319151173
Rn[31:16] × Rm[31:16]
Rn[15:0] × Rm[15:0]
[31:16] Rn[31:16] × Rm[31:16] Upper halfword multiply
[15:0] Rn[15:0] × Rm[15:0] Lower halfword multiply

Key DSP instructions:

  • SMLAD: Dual 16×16 multiply-accumulate (1 cycle)
  • SMUAD: Dual 16×16 multiply-add (1 cycle)
  • QADD16/QSUB16: Saturating SIMD add/sub (1 cycle)
  • SSAT/USAT: Saturating shift (1 cycle)

Real-Time Constraints

For 48kHz audio with 128-sample buffers:

  • Buffer period: 2.67ms
  • At 168MHz (STM32F4): 448,000 cycles per buffer
  • Per-sample budget: 3,500 cycles
// Timing constraints
#define SAMPLE_RATE     48000
#define BUFFER_SIZE     128
#define CPU_FREQ        168000000

#define CYCLES_PER_BUFFER   (CPU_FREQ / SAMPLE_RATE * BUFFER_SIZE)
// = 448,000 cycles

#define CYCLES_PER_SAMPLE   (CPU_FREQ / SAMPLE_RATE)
// = 3,500 cycles

Naive FIR Filter Implementation

// Naive C implementation - baseline
void fir_filter_naive(
    const int16_t* input,
    int16_t* output,
    const int16_t* coeffs,
    int16_t* state,
    int block_size,
    int num_taps
) {
    for (int n = 0; n < block_size; n++) {
        // Shift state buffer
        for (int i = num_taps - 1; i > 0; i--) {
            state[i] = state[i-1];
        }
        state[0] = input[n];
        
        // Compute convolution
        int32_t acc = 0;
        for (int k = 0; k < num_taps; k++) {
            acc += (int32_t)state[k] * (int32_t)coeffs[k];
        }
        
        output[n] = (int16_t)(acc >> 15);  // Q15 format
    }
}
⚠️ Performance Disaster

With 64 taps: 64 multiplies + 64 shifts + memory ops = ~400 cycles/sample. That’s only 11% of the M4’s capability!

CMSIS-DSP Optimized Version

ARM’s CMSIS-DSP library provides optimized implementations:

#include "arm_math.h"

// CMSIS-DSP FIR instance
static arm_fir_instance_q15 fir_instance;
static q15_t fir_state[BLOCK_SIZE + NUM_TAPS - 1];
static q15_t fir_coeffs[NUM_TAPS];

void init_fir_cmsis(void) {
    arm_fir_init_q15(
        &fir_instance,
        NUM_TAPS,
        fir_coeffs,
        fir_state,
        BLOCK_SIZE
    );
}

void process_audio_cmsis(q15_t* input, q15_t* output) {
    arm_fir_q15(&fir_instance, input, output, BLOCK_SIZE);
}
📊

FIR Filter Performance (64 taps, 128 samples)

ImplementationCycles/SampleCycles/BufferCPU Load
Naive C 412 52,736 11.8%
GCC -O3 198 25,344 5.7%
CMSIS-DSP 47 6,016 1.3%
Hand-optimized ASM 31 3,968 0.9%
Note: STM32F407 @ 168MHz, Q15 format

Hand-Optimized Assembly

For maximum performance, use SMLAD directly:

@ FIR filter inner loop - processes 4 samples per iteration
@ Uses SMLAD for dual multiply-accumulate
@ 
@ Registers:
@   r0 = state pointer
@   r1 = coeffs pointer
@   r2 = accumulator
@   r3 = loop counter (num_taps / 4)
@   r4-r7 = temp registers

    .syntax unified
    .thumb
    .global fir_kernel_asm
    .type fir_kernel_asm, %function

fir_kernel_asm:
    push    {r4-r7, lr}
    
    @ Initialize accumulator
    mov     r2, #0
    
    @ Loop counter: num_taps / 4
    lsr     r3, r3, #2
    
.loop:
    @ Load 4 state values (2 per register, packed)
    ldrd    r4, r5, [r0], #8    @ state[0:3]
    
    @ Load 4 coefficients (2 per register, packed)
    ldrd    r6, r7, [r1], #8    @ coeffs[0:3]
    
    @ Dual multiply-accumulate: 2 MACs per instruction
    @ r2 += r4[15:0]*r6[15:0] + r4[31:16]*r6[31:16]
    smlad   r2, r4, r6, r2
    
    @ r2 += r5[15:0]*r7[15:0] + r5[31:16]*r7[31:16]
    smlad   r2, r5, r7, r2
    
    @ Decrement and loop
    subs    r3, r3, #1
    bne     .loop
    
    @ Saturate and shift result to Q15
    ssat    r0, #16, r2, asr #15
    
    pop     {r4-r7, pc}

.size fir_kernel_asm, . - fir_kernel_asm
Instruction Analysis

The inner loop executes 4 MAC operations in 6 cycles: 2× LDRD (2 cycles each) + 2× SMLAD (1 cycle each). That’s 0.67 cycles per tap—near theoretical maximum.

Memory Layout Optimization

Coefficient and state alignment critically affects performance:

// Aligned buffers for optimal LDRD performance
__attribute__((aligned(8)))
static int16_t fir_state[NUM_TAPS + 4];  // +4 for loop unrolling

__attribute__((aligned(8)))
static const int16_t fir_coeffs[NUM_TAPS] = {
    // Coefficients in reversed order for convolution
    // Pack adjacent coefficients for SMLAD
};

// Circular buffer implementation using hardware modulo
typedef struct {
    int16_t* buffer;
    uint32_t size;
    uint32_t mask;  // size - 1, for power-of-2 sizes
    uint32_t index;
} circular_buffer_t;

static inline int16_t circular_read(circular_buffer_t* cb, int offset) {
    return cb->buffer[(cb->index + offset) & cb->mask];
}

DMA Double-Buffering

Overlap computation with I/O:

// DMA configuration for I2S audio
#define BUFFER_A    0
#define BUFFER_B    1

static int16_t audio_buffers[2][BUFFER_SIZE];
static volatile uint8_t processing_buffer = BUFFER_A;
static volatile uint8_t dma_buffer = BUFFER_B;

void DMA1_Stream3_IRQHandler(void) {
    if (DMA1->HISR & DMA_HISR_TCIF3) {
        DMA1->HIFCR = DMA_HIFCR_CTCIF3;
        
        // Swap buffers
        uint8_t temp = processing_buffer;
        processing_buffer = dma_buffer;
        dma_buffer = temp;
        
        // Signal processing task
        signal_audio_ready();
    }
}

void audio_processing_task(void) {
    while (1) {
        wait_for_audio_ready();
        
        // Process while DMA fills other buffer
        uint32_t start_cycles = DWT->CYCCNT;
        
        fir_filter_optimized(
            audio_buffers[processing_buffer],
            output_buffer,
            BUFFER_SIZE
        );
        
        uint32_t elapsed = DWT->CYCCNT - start_cycles;
        update_cpu_load_stats(elapsed);
    }
}

CPU Load by Filter Complexity

(%)
16-tap FIR
0.2 %
64-tap FIR
0.9 %
128-tap FIR
1.8 %
256-tap FIR
3.6 %
512-tap FIR
7.2 %

Profiling with DWT

The Data Watchpoint and Trace unit provides cycle-accurate profiling:

// Enable DWT cycle counter
void enable_cycle_counter(void) {
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}

// Measure function execution time
#define PROFILE_START()     uint32_t _start = DWT->CYCCNT
#define PROFILE_END(name)   do { \
    uint32_t _cycles = DWT->CYCCNT - _start; \
    printf("%s: %lu cycles\n", name, _cycles); \
} while(0)

// Usage
void benchmark_fir(void) {
    PROFILE_START();
    fir_filter_optimized(input, output, BUFFER_SIZE);
    PROFILE_END("FIR 64-tap");
}

Conclusion

The Cortex-M4 can process significant DSP workloads when properly optimized:

  1. Use SIMD instructions (SMLAD, SMUAD) for 2× throughput
  2. Align data to 8 bytes for efficient LDRD
  3. Unroll loops by 4 to maximize SIMD utilization
  4. Use DMA double-buffering to overlap I/O and compute
  5. Profile with DWT for cycle-accurate measurements

A 64-tap FIR filter at 48kHz uses only 0.9% CPU—leaving 99% for your application.