# 利用 STM32 的 DAC + DMA 生成高精度任意波形时,如何规避缓冲区切换的毛刺 ## 1. 问题背景与毛刺根源 在嵌入式系统中,使用 STM32 的 DAC(数模转换器)配合 DMA(直接内存访问)生成任意波形(如正弦波、任意函数信号)是常见需求。DMA 负责将内存中的波形数据连续搬运到 DAC 数据寄存器,实现零 CPU 干预的波形输出。然而,当波形数据需要更新(例如切换频率、幅度或波形类型)时,若直接修改缓冲区或切换 DMA 目标地址,往往会在输出端产生短暂的毛刺(Glitch),表现为电压尖峰或异常跳变。 毛刺产生的核心原因有三: - **DMA 传输间隙**:当 DMA 完成一个缓冲区传输并切换到下一个缓冲区时,存在短暂的停止-重启过程,若此时 DAC 仍在采样,就会因数据缺失产生输出跳变。 - **DAC 更新时序**:DAC 输出寄存器的更新通常发生在 DMA 写入数据寄存器的下一个时钟沿,若写入时机与 DAC 转换周期不同步,可能导致部分数据被覆盖或延迟。 - **缓存一致性问题**:在带缓存(如 Cortex-M7 的 D-Cache)的 MCU 上,若 DMA 读取的缓冲区被 CPU 修改,而缓存未刷新,DMA 可能读到旧数据,造成波形不连续。 ## 2. 规避策略概览 针对上述根源,业界常用以下三种策略,可单独或组合使用: - **策略一:双缓冲 + 预加载**(软件层面)——使用两个缓冲区,在 DMA 正在使用缓冲区 A 时,CPU 预先填充缓冲区 B,并通过 DMA 的传输完成中断切换,确保切换时数据已就绪。 - **策略二:定时器触发同步**(硬件层面)——利用定时器产生更新事件,触发 DAC 转换,使 DMA 传输与 DAC 采样严格同步,消除异步写入导致的毛刺。 - **策略三:DMA 循环模式 + 乒乓切换**(优化层面)——使用 DMA 的循环模式,通过修改内存地址实现无缝切换,避免 DMA 重启。 下面以 STM32F407(Cortex-M4,无 D-Cache)为例,重点讲解策略一和策略二的结合实现,因为这是最通用且效果显著的方法。 ## 3. 原理详解:双缓冲与定时器同步 ### 3.1 双缓冲预加载原理 双缓冲模式使用两个大小相同的缓冲区(Buffer0 和 Buffer1)。DMA 先传输 Buffer0,传输完成后触发中断,在中断服务函数(ISR)中切换 DMA 的目标地址到 Buffer1,同时 CPU 可以开始填充 Buffer0 的新数据。关键点在于:**切换动作必须在 DMA 完全停止后执行**,否则会导致数据错位。但 DMA 停止和重启的间隙会产生毛刺,因此需要结合定时器同步来掩盖这个间隙。 ### 3.2 定时器触发同步原理 STM32 的 DAC 支持外部触发转换(如定时器 TRGO 事件)。配置定时器(如 TIM6)工作在 PWM 模式,产生固定频率的更新事件,作为 DAC 的触发源。这样,DAC 只在定时器触发时采样一次,DMA 则在触发前准备好数据。当 DMA 切换缓冲区时,即使有短暂停止,只要在下一个触发沿到来前完成切换,输出就不会中断。 ## 4. 配置步骤(以 STM32CubeMX + HAL 库为例) ### 4.1 基本配置 1. **DAC 配置**:启用 DAC 输出通道(如 PA4),选择触发方式为“定时器触发”,触发源选择 TIM6_TRGO。 2. **定时器配置**:TIM6 时钟源设为内部时钟,预分频和自动重载值决定触发频率(即波形采样率)。设置 TIM6 的 TRGO 事件为“更新事件”。 3. **DMA 配置**:添加 DMA 流(如 DMA1_Stream5),方向为内存到外设,外设地址固定为 DAC 数据寄存器,内存地址为缓冲区首地址,模式为循环模式(或普通模式,但需在中断中切换)。数据宽度为半字(16位,因为 DAC 为 12 位)。 ### 4.2 双缓冲实现 在代码中定义两个缓冲区,并启用 DMA 传输完成中断。 ```c // 缓冲区定义(示例:256点正弦波) #define WAVE_SIZE 256 uint16_t buf0[WAVE_SIZE]; uint16_t buf1[WAVE_SIZE]; volatile uint8_t current_buf = 0; // 当前 DMA 使用的缓冲区索引 // 初始化 DMA 使用 buf0 HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t*)buf0, WAVE_SIZE, DAC_ALIGN_12B_R); ``` ### 4.3 中断处理与切换 在 DMA 传输完成中断回调中,切换缓冲区并准备新数据。 ```c void HAL_DAC_ConvCpltCallbackCh1(DAC_HandleTypeDef *hdac) { // 切换 DMA 目标缓冲区 if (current_buf == 0) { HAL_DAC_Stop_DMA(&hdac, DAC_CHANNEL_1); // 停止 DMA(注意:此操作会立即停止传输) // 填充 buf1 的新波形数据(此处为示例,实际可调用波形生成函数) generate_waveform(buf1, WAVE_SIZE, new_freq); HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t*)buf1, WAVE_SIZE, DAC_ALIGN_12B_R); current_buf = 1; } else { HAL_DAC_Stop_DMA(&hdac, DAC_CHANNEL_1); generate_waveform(buf0, WAVE_SIZE, new_freq); HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t*)buf0, WAVE_SIZE, DAC_ALIGN_12B_R); current_buf = 0; } } ``` **注意**:上述代码中 `HAL_DAC_Stop_DMA` 和 `HAL_DAC_Start_DMA` 会停止和重启 DMA,这期间 DAC 可能无数据可转换,但得益于定时器触发,只要在下一个触发沿前完成重启,输出不会出现毛刺。实际测试中,这个间隙通常为几微秒,而定时器周期(如 10kHz 采样率)为 100 微秒,因此足够安全。 ## 5. 完整代码示例(基于 HAL) 以下是一个完整的初始化函数,展示了如何配置 DAC、TIM6 和 DMA。 ```c void MX_DAC1_Init(void) { DAC_ChannelConfTypeDef sConfig = {0}; hdac.Instance = DAC; HAL_DAC_Init(&hdac); sConfig.DAC_SampleAndHold = DAC_SAMPLEANDHOLD_DISABLE; sConfig.DAC_Trigger = DAC_TRIGGER_T6_TRGO; // 定时器6触发 sConfig.DAC_OutputBuffer = DAC_OUTPUTBUFFER_ENABLE; HAL_DAC_ConfigChannel(&hdac, &sConfig, DAC_CHANNEL_1); } void MX_TIM6_Init(void) { TIM_MasterConfigTypeDef sMasterConfig = {0}; htim6.Instance = TIM6; htim6.Init.Prescaler = 84-1; // 84MHz/84 = 1MHz htim6.Init.CounterMode = TIM_COUNTERMODE_UP; htim6.Init.Period = 100-1; // 1MHz/100 = 10kHz 采样率 HAL_TIM_Base_Init(&htim6); sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim6, &sMasterConfig); } void MX_DMA_Init(void) { hdma_dac1.Instance = DMA1_Stream5; hdma_dac1.Init.Channel = DMA_CHANNEL_7; hdma_dac1.Init.Direction = DMA_MEMORY_TO_PERIPH; hdma_dac1.Init.PeriphInc = DMA_PINC_DISABLE; hdma_dac1.Init.MemInc = DMA_MINC_ENABLE; hdma_dac1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_dac1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; hdma_dac1.Init.Mode = DMA_NORMAL; // 使用普通模式,在中断中切换 hdma_dac1.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_dac1); __HAL_LINKDMA(&hdac, DMA_Handle1, hdma_dac1); HAL_NVIC_SetPriority(DMA1_Stream5_IRQn, 0, 0); HAL_NVIC_EnableIRQ(DMA1_Stream5_IRQn); } // 启动波形生成 void start_waveform_generation(void) { generate_waveform(buf0, WAVE_SIZE, 1000); // 初始频率 1kHz HAL_TIM_Base_Start(&htim6); HAL_DAC_Start_DMA(&hdac, DAC_CHANNEL_1, (uint32_t*)buf0, WAVE_SIZE, DAC_ALIGN_12B_R); } ``` ## 6. 注意事项与进阶优化 - **缓冲区大小与采样率匹配**:缓冲区大小应足够大,以容纳至少一个完整波形周期,否则波形会不连续。 - **DMA 停止/重启的时序**:在 ISR 中执行 `HAL_DAC_Stop_DMA` 时,确保 DAC 已停止转换(可查询状态),否则可能丢失最后一个数据。 - **缓存刷新(针对 Cortex-M7)**:若使用 STM32H7 等带 D-Cache 的芯片,在 CPU 修改缓冲区后,需调用 `SCB_CleanDCache_by_Addr` 刷新缓存,否则 DMA 可能读到旧数据。 - **使用循环模式 + 乒乓切换**:更高级的做法是使用 DMA 循环模式,通过修改 `NDTR` 寄存器和内存地址实现无缝切换,避免停止/重启。但实现复杂,建议先用双缓冲方案。 - **触发频率限制**:定时器触发频率不能超过 DAC 的最大转换速率(如 1Msps),否则数据会丢失。 ## 7. 总结 通过双缓冲预加载和定时器触发同步,可以有效规避 STM32 DAC+DMA 生成任意波形时的缓冲区切换毛刺。核心思想是:**让 DMA 的切换发生在 DAC 非采样时刻**。本文提供的代码示例可直接应用于 STM32F4 系列,对于其他系列只需调整时钟和引脚配置。在实际项目中,建议先用示波器观察毛刺,再逐步调整定时器周期和缓冲区大小,以达到最佳波形质量。