引言

在嵌入式信号生成领域,STM32 的 DAC(数模转换器)配合 DMA(直接内存访问)是实现高效、连续波形输出的黄金组合。然而,当追求高精度(如 12 位分辨率)和低失真时,开发者常遇到两个棘手问题:缓冲区分割不当导致波形不连续,以及 DMA 传输时序抖动影响输出稳定性。本文将从原理出发,结合实战代码,分享解决这些问题的系统性技巧。

一、DAC+DMA 工作原理与挑战

1.1 基本架构

STM32 的 DAC 模块支持定时器触发,DMA 可在触发后自动从内存搬运数据到 DAC 数据寄存器(DHRx),无需 CPU 干预。典型配置:

  • 定时器:产生固定频率的触发事件(如 TIM6 更新事件)。
  • DMA:在触发事件后,将波形数据表逐点传输至 DAC。
  • DAC:转换数据并输出模拟电压。

1.2 核心挑战

  • 缓冲区分割:若使用单缓冲区,DMA 传输完一轮后需重新配置,期间输出中断,产生波形毛刺。
  • 时序抖动:DMA 中断处理、内存访问冲突或缓存未刷新,导致相邻采样点间隔不均匀,引起频谱杂散。

二、缓冲区分割策略:环形与双缓冲

2.1 环形缓冲区(Circular Mode)

DMA 支持循环模式(Circular),当传输计数归零后自动重载初始地址,实现无缝循环。这是最简单的方式,但存在隐患:当 DMA 正在读取缓冲区尾部时,若 CPU 同时更新缓冲区头部,可能造成数据撕裂

适用场景:波形数据固定不变(如标准正弦波)。

2.2 双缓冲区(Double Buffer Mode)

针对动态波形(如频率可调、实时合成),推荐使用双缓冲区。DMA 在传输完当前缓冲区后,自动切换至另一缓冲区,同时触发中断,CPU 可在中断中更新已完成的缓冲区。

优势

  • 消除数据竞争,保证数据一致性。
  • 中断处理时间充裕,不阻塞波形输出。

实现要点

  • 配置 DMA 为双缓冲模式(需支持该特性的 DMA 控制器,如 STM32F4 系列)。
  • 使用 DMA_SetCurrentTarget 或类似 API 切换目标。

三、消除时序抖动的关键技巧

3.1 合理配置 DMA 中断优先级

DMA 传输完成中断应设置为高优先级(如抢占优先级 1),确保及时切换缓冲区。但需避免与更关键的中断(如系统滴答)冲突。建议:

  • 将 DMA 中断设为抢占优先级 1,子优先级 0。
  • 在中断服务函数中仅做快速操作(如切换标志位),避免耗时处理。

3.2 双缓冲切换同步

使用 DMA 的传输完成中断(TC)来触发缓冲区切换。在中断中,通过检查当前活动缓冲区索引,更新另一个缓冲区数据。关键点:

  • 在中断中先读取 DMA 的当前目标寄存器(如 DMA_GetCurrentMemoryTarget),确定哪个缓冲区正在被使用。
  • 更新另一个缓冲区时,确保写入操作在 DMA 读取之前完成,避免撕裂。

3.3 缓存一致性处理(适用于带 Cache 的 MCU)

若使用 STM32H7 等带 D-Cache 的型号,DMA 访问内存时可能绕过 Cache,导致 CPU 写入的数据未同步。必须:

  • 在更新缓冲区前,调用 SCB_CleanDCache_by_Addr 将脏数据写回内存。
  • 在 DMA 读取前,调用 SCB_InvalidateDCache_by_Addr 使 Cache 失效,确保 DMA 看到最新数据。

3.4 避免内存访问冲突

DMA 与 CPU 同时访问同一总线时可能产生等待周期,导致抖动。建议:

  • 将波形缓冲区放置在 SRAM1(若 MCU 有多个 SRAM),并配置 DMA 使用独立的存储器接口。
  • 使用 __ALIGN(32) 对齐缓冲区,提高访问效率。

四、完整代码示例(以 STM32F407 为例)

以下代码演示使用 TIM6 触发 DAC,DMA 双缓冲模式生成 1kHz 正弦波,采样率 100kHz。

// 波形缓冲区定义(双缓冲)
#define SAMPLE_NUM 100
uint16_t buf1[SAMPLE_NUM] __ALIGN(32);
uint16_t buf2[SAMPLE_NUM] __ALIGN(32);

// 初始化正弦波数据
void GenerateSineWave(uint16_t* buf, uint16_t amplitude, uint16_t offset) {
    for (int i = 0; i < SAMPLE_NUM; i++) {
        float angle = 2.0f * 3.14159f * i / SAMPLE_NUM;
        buf[i] = (uint16_t)(offset + amplitude * (sinf(angle) + 1.0f) / 2.0f);
    }
}

// DMA 中断服务函数(在 stm32f4xx_it.c 中)
void DMA1_Stream5_IRQHandler(void) {
    if (DMA_GetITStatus(DMA1_Stream5, DMA_IT_TCIF5)) {
        DMA_ClearITPendingBit(DMA1_Stream5, DMA_IT_TCIF5);
        
        // 判断当前 DMA 正在使用哪个缓冲区
        uint32_t currentTarget = DMA_GetCurrentMemoryTarget(DMA1_Stream5);
        if (currentTarget == DMA_Memory_0) {
            // 正在使用 buf1,则更新 buf2
            GenerateSineWave(buf2, 2048, 2048); // 示例:重新生成波形(可改为动态频率)
        } else {
            GenerateSineWave(buf1, 2048, 2048);
        }
    }
}

// 初始化 DAC、DMA、定时器
void DAC_DMA_Init(void) {
    // 1. 使能时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_DAC | RCC_APB1Periph_TIM6, ENABLE);
    RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1 | RCC_AHB1Periph_GPIOA, ENABLE);
    
    // 2. 配置 PA4 为 DAC 输出
    GPIO_InitTypeDef GPIO_InitStruct;
    GPIO_InitStruct.Pin = GPIO_Pin_4;
    GPIO_InitStruct.Mode = GPIO_Mode_AN;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // 3. 配置 DAC 通道1,定时器触发
    DAC_InitTypeDef DAC_InitStruct;
    DAC_InitStruct.DAC_Trigger = DAC_Trigger_T6_TRGO;
    DAC_InitStruct.DAC_OutputBuffer = DAC_OutputBuffer_Enable;
    DAC_Init(DAC_Channel_1, &DAC_InitStruct);
    DAC_Cmd(DAC_Channel_1, ENABLE);
    
    // 4. 配置 DMA1 Stream5 为双缓冲模式
    DMA_InitTypeDef DMA_InitStruct;
    DMA_InitStruct.DMA_Channel = DMA_Channel_7;  // DAC1 映射到 Stream5 Channel7
    DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&DAC->DHR12R1;
    DMA_InitStruct.DMA_Memory0BaseAddr = (uint32_t)buf1;
    DMA_InitStruct.DMA_Memory1BaseAddr = (uint32_t)buf2;
    DMA_InitStruct.DMA_DIR = DMA_DIR_MemoryToPeripheral;
    DMA_InitStruct.DMA_BufferSize = SAMPLE_NUM;
    DMA_InitStruct.DMA_PeripheralInc = DMA_PeripheralInc_Disable;
    DMA_InitStruct.DMA_MemoryInc = DMA_MemoryInc_Enable;
    DMA_InitStruct.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord;
    DMA_InitStruct.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord;
    DMA_InitStruct.DMA_Mode = DMA_Mode_Circular; // 循环模式,但配合双缓冲
    DMA_InitStruct.DMA_Priority = DMA_Priority_High;
    DMA_InitStruct.DMA_FIFOMode = DMA_FIFOMode_Disable;
    DMA_Init(DMA1_Stream5, &DMA_InitStruct);
    
    // 使能双缓冲模式
    DMA_DoubleBufferModeConfig(DMA1_Stream5, (uint32_t)buf2, DMA_Memory_1);
    DMA_DoubleBufferModeCmd(DMA1_Stream5, ENABLE);
    
    // 5. 使能 DMA 传输完成中断
    DMA_ITConfig(DMA1_Stream5, DMA_IT_TC, ENABLE);
    NVIC_InitTypeDef NVIC_InitStruct;
    NVIC_InitStruct.NVIC_IRQChannel = DMA1_Stream5_IRQn;
    NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
    NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStruct);
    
    // 6. 配置定时器 TIM6 触发频率 100kHz
    TIM_TimeBaseInitTypeDef TIM_InitStruct;
    TIM_InitStruct.TIM_Prescaler = 84 - 1;  // 84MHz/84 = 1MHz
    TIM_InitStruct.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_InitStruct.TIM_Period = 10 - 1;     // 1MHz/10 = 100kHz
    TIM_TimeBaseInit(TIM6, &TIM_InitStruct);
    TIM_SelectOutputTrigger(TIM6, TIM_TRGO_Update);
    
    // 7. 启动 DMA、定时器
    DMA_Cmd(DMA1_Stream5, ENABLE);
    TIM_Cmd(TIM6, ENABLE);
    
    // 预填充两个缓冲区
    GenerateSineWave(buf1, 2048, 2048);
    GenerateSineWave(buf2, 2048, 2048);
}

代码说明

  • 使用双缓冲模式,DMA 自动在 buf1 和 buf2 间切换。
  • 在中断中根据当前目标更新另一个缓冲区,实现动态波形更新。
  • 定时器触发频率决定采样率,此处为 100kHz。

五、注意事项与调试建议

  • 缓冲区大小:应满足奈奎斯特采样定理,且为 2 的幂次方以优化 DMA 传输。
  • 中断服务函数:避免调用 sinf 等浮点函数,可预计算或查表,否则会阻塞中断导致抖动。
  • DMA 模式:双缓冲必须配合循环模式,否则传输一轮后停止。
  • 调试技巧:使用逻辑分析仪观察 DAC 输出,对比频谱分析,若出现杂散,优先检查缓存一致性和中断优先级。
  • 功耗考虑:若不需要动态更新,可关闭双缓冲中断,减少功耗。

结语

通过合理的缓冲区分割(双缓冲)和时序抖动消除技巧(中断优先级、缓存同步、内存对齐),STM32 的 DAC+DMA 能输出高精度、低抖动的任意波形。本文提供的代码框架可直接应用于信号发生器、音频播放等场景。实际项目中,还需根据具体 MCU 型号调整寄存器配置,但核心思路通用。希望这些技巧能助你在嵌入式波形生成领域游刃有余。