3、DMA与定时器结合:定时器触发DMA传输、DMA传输完成中断、环形缓冲区模式

好,咱们进入第三章。这一章可以说是整个课程里最实用的组合拳——定时器触发DMA。你想想看,定时器负责精准的时间控制,DMA负责无CPU干预的数据搬运,这两者一结合,很多高性能外设驱动就变得非常简单。

我个人习惯把这种模式叫做“硬件流水线”。CPU只需要在两头做点配置和收尾工作,中间的数据流动完全由硬件自动完成。咱们今天就把这条流水线彻底拆开来看。

3.1 定时器触发DMA:硬件自动化的核心

先问一个问题:为什么需要定时器去触发DMA?

直接回答:因为很多应用场景需要周期性、精确时间点的数据传输。比如ADC以固定采样率采集数据,然后DMA把数据搬走。如果没有定时器触发,你就得用定时器中断,在中断里手动启动DMA——这又回到CPU干预的老路上了。

STM32的定时器有一个专门的功能叫DMA请求输出。定时器在特定事件发生时(比如更新事件、比较匹配事件),会向DMA控制器发送一个请求信号。DMA收到这个信号,就开始执行预设的传输。

关键点:定时器触发DMA传输是硬件级别的握手,延迟极低(通常几个时钟周期),而且完全不需要CPU参与。

我在项目中遇到过这样一个场景:需要以1MHz的速率输出PWM波形,同时每个周期更新一次占空比。如果用定时器中断去更新,1MHz的中断频率直接把CPU占满了。后来改用定时器比较事件触发DMA,DMA从内存中的波形表自动搬运数据到CCR寄存器,CPU占用率直接降到接近0%。

3.2 配置流程:从定时器到DMA的握手

配置定时器触发DMA,其实就三步。我习惯按这个顺序来:

  1. 配置DMA:设置传输方向、地址、数据宽度、传输模式
  2. 配置定时器:设置时钟源、预分频、自动重装值,以及DMA请求源
  3. 使能链路:先使能DMA,再使能定时器

下面是一个实际例子,用TIM2的更新事件触发DMA,把内存中的波形数据搬运到TIM2的CCR1寄存器:

// 1. 配置DMA
DMA_InitTypeDef DMA_InitStruct = {0};
DMA_InitStruct.DMA_Channel = DMA_Channel_2;
DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&TIM2->CCR1;
DMA_InitStruct.DMA_MemoryBaseAddr = (uint32_t)waveform_table;
DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralDST;
DMA_InitStruct.DMA_BufferSize = 256;
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_M2M = DMA_M2M_Disable;
DMA_Init(DMA1_Channel2, &DMA_InitStruct);

// 2. 配置定时器
TIM_TimeBaseInitTypeDef TIM_InitStruct = {0};
TIM_InitStruct.TIM_Prescaler = 84 - 1;      // 84MHz / 84 = 1MHz
TIM_InitStruct.TIM_Period = 100 - 1;         // 1MHz / 100 = 10kHz更新率
TIM_InitStruct.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_InitStruct);

// 3. 使能TIM2的DMA请求(更新事件)
TIM_DMACmd(TIM2, TIM_DMA_Update, ENABLE);

// 4. 先使能DMA,再使能定时器
DMA_Cmd(DMA1_Channel2, ENABLE);
TIM_Cmd(TIM2, ENABLE);
小技巧:使能顺序很重要。先使能DMA再使能定时器,可以避免定时器启动后第一个事件发生时DMA还没准备好的情况。我曾经因为这个顺序问题排查了半天,最后发现是使能顺序反了。

3.3 DMA传输完成中断:什么时候需要它?

DMA传输完成中断,说白了就是DMA告诉你:“我搬完一批数据了,你看着办吧。”但这里有个问题——如果DMA工作在循环模式,它永远不会“完成”,因为传输是连续的。

那什么时候需要这个中断?

  • 半传输中断:缓冲区前半部分传输完成时触发。常用于双缓冲处理。
  • 传输完成中断:整个缓冲区传输完成时触发。用于单次传输或循环模式的“一圈完成”。
  • 传输错误中断:DMA传输出错时触发。这个我建议永远使能,哪怕只是用来调试。

配置DMA中断的代码很简单:

// 使能DMA传输完成中断
DMA_ITConfig(DMA1_Channel2, DMA_IT_TC, ENABLE);

// 在中断服务函数中处理
void DMA1_Channel2_IRQHandler(void)
{
    if(DMA_GetITStatus(DMA1_IT_TC2))
    {
        // 清除中断标志
        DMA_ClearITPendingBit(DMA1_IT_TC2);
        
        // 处理数据:比如切换缓冲区、更新状态等
        process_dma_complete();
    }
}
注意:DMA中断服务函数要尽量短。因为DMA传输频率可能很高,如果中断处理时间过长,会阻塞后续的DMA传输。我一般只在中断里设置一个标志位,真正的数据处理放在主循环或任务中。

3.4 环形缓冲区模式:无间断的数据流

环形缓冲区(Circular Buffer)是DMA最强大的模式之一。说白了就是DMA在内存中画了一个圈,数据写满一圈后又从头开始写,覆盖旧数据。这样就能实现连续无间断的数据采集或输出。

我画了一张图,帮你理解环形缓冲区的工作方式:

DMA环形缓冲区工作模式 D0 D1 D2 D3 D4 D5 D6 D7 D8 D9 写指针(DMA) 读指针(CPU) DMA持续向缓冲区写入新数据(绿色块),写指针循环移动。 CPU在空闲时读取已就绪的数据(橙色箭头),两者互不干扰。

配置环形缓冲区,关键就在DMA的DMA_Mode_Circular这个参数。一旦设置了这个模式,DMA传输完最后一个数据后,会自动把地址指针重置到缓冲区起始位置,继续传输。

使用环形缓冲区有几个要点:

  • 缓冲区大小必须是2的幂(比如256、512、1024),这样地址回绕可以用位运算快速实现
  • 读写指针要分开管理:DMA维护写指针(硬件自动),CPU维护读指针(软件管理)
  • 注意数据覆盖:如果CPU读取速度跟不上DMA写入速度,旧数据会被覆盖
避坑指南:我曾经在一个音频采集项目里,环形缓冲区设得太小(64字节),DMA写入频率又高,CPU还没来得及处理数据就被覆盖了。后来把缓冲区扩大到1024字节,问题就解决了。经验是:缓冲区大小至少要是DMA每次传输量的4倍以上。

3.5 实战案例:定时器触发DMA采集ADC数据

咱们把上面讲的知识点串起来,做一个完整的例子:用定时器触发DMA,从ADC采集数据,存入环形缓冲区,并在DMA半传输完成中断中处理数据。

组件 配置 说明
定时器TIM3 预分频84-1,周期100-1 10kHz触发频率
ADC1 规则通道,DMA请求使能 采集模拟信号
DMA1通道1 外设到内存,循环模式 搬运ADC数据到缓冲区
缓冲区 uint16_t buffer[512] 环形缓冲区
// 完整配置代码
void Timer_ADC_DMA_Init(void)
{
    // 1. 配置ADC
    ADC_InitTypeDef ADC_InitStruct;
    ADC_InitStruct.ADC_Resolution = ADC_Resolution_12b;
    ADC_InitStruct.ADC_ScanConvMode = DISABLE;
    ADC_InitStruct.ADC_ContinuousConvMode = DISABLE;  // 由定时器触发
    ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T3_TRGO;
    ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right;
    ADC_Init(ADC1, &ADC_InitStruct);
    
    // 2. 配置DMA(循环模式)
    DMA_InitTypeDef DMA_InitStruct;
    DMA_InitStruct.DMA_Channel = DMA_Channel_1;
    DMA_InitStruct.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR;
    DMA_InitStruct.DMA_MemoryBaseAddr = (uint32_t)adc_buffer;
    DMA_InitStruct.DMA_DIR = DMA_DIR_PeripheralSRC;
    DMA_InitStruct.DMA_BufferSize = 512;
    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_Init(DMA1_Channel1, &DMA_InitStruct);
    
    // 3. 使能DMA半传输中断
    DMA_ITConfig(DMA1_Channel1, DMA_IT_HT, ENABLE);
    
    // 4. 配置定时器
    TIM_TimeBaseInitTypeDef TIM_InitStruct;
    TIM_InitStruct.TIM_Prescaler = 84 - 1;
    TIM_InitStruct.TIM_Period = 100 - 1;
    TIM_InitStruct.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM3, &TIM_InitStruct);
    
    // 5. 配置TIM3的TRGO输出为更新事件
    TIM_SelectOutputTrigger(TIM3, TIM_TRGOSource_Update);
    
    // 6. 使能ADC的DMA请求
    ADC_DMACmd(ADC1, ENABLE);
    
    // 7. 使能顺序:DMA -> ADC -> 定时器
    DMA_Cmd(DMA1_Channel1, ENABLE);
    ADC_Cmd(ADC1, ENABLE);
    TIM_Cmd(TIM3, ENABLE);
}

// DMA半传输完成中断
void DMA1_Channel1_IRQHandler(void)
{
    if(DMA_GetITStatus(DMA1_IT_HT1))
    {
        DMA_ClearITPendingBit(DMA1_IT_HT1);
        
        // 处理缓冲区前半部分(0-255)
        process_adc_data(adc_buffer, 256);
    }
}
性能数据:这个配置下,10kHz的ADC采样率,DMA每51.2ms完成一次半传输中断。CPU在中断中处理256个数据点,处理时间约50μs,CPU占用率不到0.1%。相比用定时器中断逐点读取ADC,效率提升了近百倍。

3.6 常见问题与调试技巧

最后分享几个我在实际调试中遇到的坑:

  • DMA传输没启动:检查定时器是否真的产生了DMA请求。可以用逻辑分析仪看TIMx_DMA脚的电平变化。
  • 数据错位:检查DMA的数据宽度设置是否与外设寄存器宽度一致。ADC数据寄存器是16位,DMA也要设成HalfWord。
  • 环形缓冲区数据混乱:检查读写指针是否冲突。我习惯在缓冲区头部加一个序列号,每次写入时递增,读取时检查序列号连续性。
  • 中断丢失:如果DMA传输频率很高,中断服务函数要尽量短。可以把数据处理放在主循环,中断只置标志位。