3、DMA与定时器结合:定时器触发DMA传输、DMA传输完成中断、环形缓冲区模式
好,咱们进入第三章。这一章可以说是整个课程里最实用的组合拳——定时器触发DMA。你想想看,定时器负责精准的时间控制,DMA负责无CPU干预的数据搬运,这两者一结合,很多高性能外设驱动就变得非常简单。
我个人习惯把这种模式叫做“硬件流水线”。CPU只需要在两头做点配置和收尾工作,中间的数据流动完全由硬件自动完成。咱们今天就把这条流水线彻底拆开来看。
3.1 定时器触发DMA:硬件自动化的核心
先问一个问题:为什么需要定时器去触发DMA?
直接回答:因为很多应用场景需要周期性、精确时间点的数据传输。比如ADC以固定采样率采集数据,然后DMA把数据搬走。如果没有定时器触发,你就得用定时器中断,在中断里手动启动DMA——这又回到CPU干预的老路上了。
STM32的定时器有一个专门的功能叫DMA请求输出。定时器在特定事件发生时(比如更新事件、比较匹配事件),会向DMA控制器发送一个请求信号。DMA收到这个信号,就开始执行预设的传输。
我在项目中遇到过这样一个场景:需要以1MHz的速率输出PWM波形,同时每个周期更新一次占空比。如果用定时器中断去更新,1MHz的中断频率直接把CPU占满了。后来改用定时器比较事件触发DMA,DMA从内存中的波形表自动搬运数据到CCR寄存器,CPU占用率直接降到接近0%。
3.2 配置流程:从定时器到DMA的握手
配置定时器触发DMA,其实就三步。我习惯按这个顺序来:
- 配置DMA:设置传输方向、地址、数据宽度、传输模式
- 配置定时器:设置时钟源、预分频、自动重装值,以及DMA请求源
- 使能链路:先使能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);
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();
}
}
3.4 环形缓冲区模式:无间断的数据流
环形缓冲区(Circular Buffer)是DMA最强大的模式之一。说白了就是DMA在内存中画了一个圈,数据写满一圈后又从头开始写,覆盖旧数据。这样就能实现连续无间断的数据采集或输出。
我画了一张图,帮你理解环形缓冲区的工作方式:
配置环形缓冲区,关键就在DMA的DMA_Mode_Circular这个参数。一旦设置了这个模式,DMA传输完最后一个数据后,会自动把地址指针重置到缓冲区起始位置,继续传输。
使用环形缓冲区有几个要点:
- 缓冲区大小必须是2的幂(比如256、512、1024),这样地址回绕可以用位运算快速实现
- 读写指针要分开管理:DMA维护写指针(硬件自动),CPU维护读指针(软件管理)
- 注意数据覆盖:如果CPU读取速度跟不上DMA写入速度,旧数据会被覆盖
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);
}
}
3.6 常见问题与调试技巧
最后分享几个我在实际调试中遇到的坑:
- DMA传输没启动:检查定时器是否真的产生了DMA请求。可以用逻辑分析仪看TIMx_DMA脚的电平变化。
- 数据错位:检查DMA的数据宽度设置是否与外设寄存器宽度一致。ADC数据寄存器是16位,DMA也要设成HalfWord。
- 环形缓冲区数据混乱:检查读写指针是否冲突。我习惯在缓冲区头部加一个序列号,每次写入时递增,读取时检查序列号连续性。
- 中断丢失:如果DMA传输频率很高,中断服务函数要尽量短。可以把数据处理放在主循环,中断只置标志位。