☰
STM32定时器DMA与突发模式:高效生成动态PWM脉冲序列的实战指南
2026/9/28 1:13:51 网站建设 项目流程

上个月调一板四路SPWM正弦波逆变器,载波定在20kHz,四个桥臂需要互补PWM,每路占空比还得按正弦表一周期一周期地变。一开始图省事,直接在定时器更新中断里改CCR寄存器,结果20kHz等于每50us断一次,加上上下文切换、标志位处理、写寄存器这些开销,CPU占用一路飙到40%多。最不能忍的是抖动——串口中断一来,CCR写入时间就往后拖,逻辑分析仪放大一看,占空比跳变的地方总有那么一两个周期的毛刺。

后来我把STM32的DMA拉进来,让定时器更新事件自己去请求DMA,把内存里预置好的正弦表数据搬进CCR寄存器,CPU彻底松绑。再往后接触到定时器的DMA突发传输,一个事件同时刷新ARR和CCR,连频率和占空比需要同步变化的场景也能平滑过渡。这篇文章就把这套机制的底层原理、CubeMX+HAL库的配置过程、以及我在现场调试中踩过的坑完整写出来,给正在做电机控制、逆变器、舵机序列这类“动态PWM脉冲序列”的朋友一份能直接抄作业的参考。

1. 为什么要让DMA来“弹奏”PWM乐谱

1.1 动态PWM序列的核心工作:按节拍刷新CCR

先对齐一下基础知识。定时器输出PWM的时候,ARR决定周期,CCR决定高电平持续时间,占空比就是CCR/(ARR+1)。所谓“动态PWM脉冲序列”,就是周期或者占空比不能一直不变,要么每个周期换一个高电平时间,要么每隔一段时间换一组参数。最典型的例子就是SPWM,每个载波周期的占空比都要按正弦规律变化,本质工作只有一个:精确地在每个周期给CCR写入新值。

这个“写入时机”非常讲究。写早了,当前周期还没结束,波形把新值也体现出来,输出提前变了;写晚了,这个周期的占空比就还是旧值,波形丢了一拍。所以传统的做法是把写入动作放在定时器更新中断里,趁着计数器刚溢出、新周期刚要开始的那一刻,把目标值写进去。这个思路没错,但问题在于CPU不是只有这一件事要做。

打个比方,固定频率PWM像节拍器,动态序列是一张乐谱。CPU如果每个节拍都要亲自去按琴键,那这位乐手就永远只能弹这一首曲子,其他事一概别想干。DMA要承担的,就是把整张乐谱预先交给节拍器,让节拍器自己到点触发搬运,CPU只在曲子快弹完时去换下一张谱子。

1.2 更新中断方案的真实代价

有人会觉得,20kHz更新中断看起来也不高,进中断改个寄存器才几条指令,能有多大事?我实际测过,在72MHz的STM32F103上,进一次定时器更新中断,保存现场、判断标志、更新CCR、清标志、退出中断,大概要花2到5us。如果只算占比,20kHz乘上4us,也就是8%的CPU负载,确实不算失控。

但真实项目不是只跑一路PWM。逆变器至少四路,步进电机可能同时带三四个轴,再加上串口通信、显示刷新、闭环控制算法,中断一多,系统实时性急剧恶化。更麻烦的是抖动:定时器中断优先级如果不够高,会被其他中断插队,CCR写入时间就出现了几百纳秒到几微秒的不确定延迟。我用逻辑分析仪量过,串口中断频繁收发时,PWM占空比抖动可以达到±1个计数周期,在72MHz主频下就是大约270ns,这对20kHz的PWM来说已经接近0.5%的占空比误差,做精密调光或者小功率电机驱动时,肉眼都能看出亮度或转速在轻微浮动。

改成DMA之后,处理链路变成“定时器事件 → DMA控制器搬运 → CCR寄存器”,整个过程没有CPU参与。CPU只会在缓冲区快要耗尽的时候,进一次中断去准备下一段数据。这部分开销从“每个周期一次”降到了“每几百个周期一次”。

1.3 按节奏搬运为什么比中断更稳

DMA响应的特点是指定事件触发后,经过固定的几个总线周期就能启动传输,不依赖CPU的调度状态。中断则要看CPU当前在干什么、优先级够不够、有没有被更高优先级打断。所以DMA搬运CCR的抖动几乎可以忽略,每次更新事件到CCR被写入的延迟是确定性的。

另外DMA优先级是可配的。如果PWM数据链路的DMA优先级设为High,即使别的外设也在搬运数据,DMA控制器也会优先保证这条链路的时序。这种“硬件任务调度”正是实时控制里最需要的:不是靠提高CPU主频去抢时间,而是让硬件自己把节奏管好。

2. 定时器DMA和突发模式的工作机制

2.1 定时器的DMA请求源有哪些

STM32定时器能触发DMA的事件不止一种,常见的有更新事件(TIMx_UP)、捕获比较事件(TIMx_CHx)、触发事件(TIMx_TRIG)、换相事件(TIMx_COM)。对PWM动态刷新来说,最常用的是更新事件,因为更新事件发生在计数器溢出那一刻,意味着一整个周期已经结束,新周期马上开始,这时候写入新的CCR正好从下一周期生效,自然无缝。

捕获比较事件则是CNT计数到和CCR相等时触发,这个时刻在周期中间。用它做DMA请求也可以,好处是“更早”,新值在本周期剩余时间就能生效,适合实时性要求极高的控制;但代价是时序分析要小心,因为比较匹配那一刻CNT还在继续跑,如果DMA搬运稍有延迟,写入点可能错过有效的比较窗口。所以我个人习惯:能选更新事件就选更新事件,简单、安全、不易出错。

2.2 普通DMA传输:每次只搬一个数据

普通模式下,一次DMA请求只搬运一个数据。拿更新事件触发DMA搬运CCR来说,每个PWM周期,DMA从内存数组中取出当前值写入CCR寄存器,然后内存地址指针加1,等待下一次更新事件。配合DMA的循环模式,整个正弦表可以被无限循环搬运。这是最基础的动态PWM实现,也是我最早跑通的方案。

2.3 突发模式:一个事件搬运一整批

定时器突发模式是另一种玩法,它利用DCR和DMAR两个特殊寄存器实现。DCR里配置了突发基地址DBA和突发长度DBLK,DMAR是一个“虚拟外设地址”。DMA向DMAR连续写入N个数据时,定时器内部会按照DBA从0开始递增地址偏移,把这N个数据依次分发给对应的寄存器序列。

举个例子,配置DBA指向ARR,DBLK设为2。那么一次定时器更新事件触发后,DMA连续搬运两个半字,第一个写入ARR,第二个写入CCR1。这样ARR和CCR1就在同一次传输批次里完成了更新,天然原子,不会出现“ARR已经变了但CCR还是旧值”的中间状态。

这里还要提醒一句,很多初学者会把“定时器突发模式”和“DMA控制器自身的burst传输”搞混。F4及以上的STM32,DMA控制器的配置寄存器里有MBURST和PBURST字段,可以设置INCR4、INCR8、INCR16,那是指DMA在AHB总线上以突发方式连续读写内存,主要解决总线带宽和仲裁效率问题。定时器突发模式则是指利用DMAR把多个数据分发到多个定时器寄存器。两者可以同时使用,但解决的问题完全不一样。

对比项普通DMA传输定时器突发模式
每次请求搬运的数据量1个N个(由DBLK决定)
数据接收方单个外设寄存器定时器内一组寄存器序列
典型应用刷新CCR占空比同时更新ARR+CCR、多路CCR
时序一致性一般高,批量更新原子完成
配置关键点DMA内存到外设,循环模式DCR的DBA/DBLK + DMA连续写入DMAR

2.4 突发模式对脉冲序列的意义

做SPWM时,如果只更新CCR占空比,普通DMA循环就够了。但遇到变频调速、步进电机加减速这种需要同时改变PWM频率和占空比的场景,普通模式就有点力不从心。频率由ARR决定,占空比由CCR决定,如果分两次写,CPU写ARR和写CCR之间必然有时间差,这个时间差里CNT可能已经走到一个新值,输出会出现一段不预期的电平。

突发模式把ARR、CCR放进同一个批次更新,相当于做了一次“原子提交”。对多路PWM来说,四路CCR如果分别提交,各路占空比生效时刻不一致,产生的相移在电机控制里会造成转矩波动。突发模式一次把四路CCR全部写满,各路变化严格同步,这是任何中断方案都很难做到的。

3. 数据链路与缓冲设计:代码上看不见的关键

3.1 内存数据表怎么排布

无论是普通DMA还是突发模式,数据都来自内存里预定好的一张表。普通模式最简单,直接用uint16_t pwm_table[N],数组里按顺序存放每个周期的CCR目标值,DMA循环搬运即可。

突发模式的数据表要按“寄存器顺序”预排列。比如要同时更新ARR和CCR1,那么内存中每个传输单元就是两个半字,seq[2*i]放第i个周期的ARR值,seq[2*i+1]放对应的CCR值。DMA每次连续搬运两个半字,第一个进ARR,第二个进CCR1。这个顺序如果排错,波形会完全对不上,轻则频率恒定、占空比乱跳,重则ARR变成极小值,输出频率完全失控。所以画表之前,先想清楚DCR配置的寄存器序列,再写内存索引。

3.2 循环DMA加乒乓缓冲:CPU写入和DMA读取不打架

DMA循环模式下,传输完整个数组后自动回到开头继续搬,CPU确实不用管。但数组内容不能一成不变,必须不断刷新。问题来了:DMA正在读第100个元素的时候,CPU突然把它改成了新值,那DMA这次搬走的就是新旧混合的数据,波形出现一次毛刺。

经典解法是乒乓缓冲思想。把数组长度做成2的整数次幂,比如512个元素,利用DMA的半传输中断和传输完成中断作为“安全分界线”。当DMA已经搬完前半段、正要进入后半段时,半传输中断触发,CPU可以安全更新前半段,因为前半段已经不会再被DMA读了。同理,DMA搬完整个数组准备循环回来之前,传输完成中断触发,CPU更新后半段。这样每一块数据的更新时刻,永远比DMA访问它提前至少半张表的距离,从根上避免竞争。

切换的时候还有个细节:中断里更新数据要快,不要把复杂计算放进回调。计算放在主循环或者低优先级任务里做,中断里只做内存拷贝或者标志置位。

3.3 数据类型必须和DMA宽度对齐

CCR寄存器在STM32几个主流系列里都是16位有效,ARR也一样。用uint16_t数组配DMA半字传输是最自然的组合。有人图省事用uint32_t数组,DMA传输宽度配置成Word,也能搬,但内存占用翻倍、总线带宽浪费一倍,关键是如果混搭了Half Word,高低字节就会错位,数据完全乱掉。

我踩过一次:uint32_t数组配Half Word,DMA把每个32位整数的高16位和低16位交错搬运,波形看起来像随机数塞进了CCR,查了大半天才意识到是宽度不匹配。从那以后,我的规矩是:PWM表一律uint16_t,DMA宽度一律Half Word,内存地址增量打开,外设地址固定。

3.4 影子寄存器:一个容易被忽略的时序问题

ARR默认带预装载功能,写入后不会立即生效,要等到下一次更新事件才从预装载寄存器拷贝到影子寄存器。CCR在OCxPE位为1时也一样,带预装载。如果PWM输出配置成预装载使能,DMA在更新事件时写入新的CCR,这个值要等到下一次更新事件才真正生效,相当于凭空多延迟一个周期。这在慢速场合看不出来,频率一高,相位就会和你预想的不一致。

所以动态PWM场景我建议把OCxPE清掉,也就是CCR写寄存器立即生效。配合更新事件触发DMA,实际效果就是:这个周期结束时突发的DMA写入CCR,新值立刻作用于下一个周期,逻辑清晰。ARR则可以保留预装载,因为它的“延迟到下一次更新事件”正好是期望行为,反而能让周期切换更平稳。

4. 用CubeMX和HAL库把整条链路搭起来

4.1 CubeMX里的关键配置

以STM32F103C8T6、TIM1_CH1输出20kHz PWM为例。时钟树先配到72MHz,TIM1时钟源选Internal Clock。Prescaler设0,Counter Period设3599,计算一下:72MHz / (0+1) / (3599+1) = 20kHz,刚好。

接着在TIM1的PWM Generation CH1里选择PWM模式1,Pulse设一个初值,比如1800,对应50%占空比。然后切到DMA Settings页面,添加一条DMA Request。这里要注意选择TIM1_UP,这是更新事件请求,不是TIM1_CH1的比较事件请求。传输方向是Memory To Peripheral,Mode选Circular,Data Width选Half Word,优先级给High。

CubeMX里有个“DMA Continuous Requests”选项,很多新手不知道有什么用。这个选项在HAL库里对应定时器的连续DMA请求标志,勾选后定时器DMA请求可以持续触发,配合循环模式,DMA就会不停止地自动搬运;不勾选的话,DMA可能只响应一次请求就停了,波形只输出一个周期。做动态脉冲序列,必须勾上。

4.2 HAL库启动代码

生成工程后,全局定义正弦表:

#define SEQ_LEN 256 __IO uint16_t pwm_table[SEQ_LEN];

初始化时把表填好,不需要在中断里填。比如256点正弦表,周期性的占空比数据:

for (uint16_t i = 0; i < SEQ_LEN; i++) { pwm_table[i] = (uint16_t)(1800.0f + 1700.0f * sinf(2.0f * 3.14159f * i / SEQ_LEN)); }

启动DMA传输:

HAL_TIM_PWM_Start_DMA(&htim1, TIM_CHANNEL_1, (uint32_t *)pwm_table, SEQ_LEN); __HAL_TIM_MOE_ENABLE(&htim1);

第二行是给TIM1/TIM8高级定时器准备的,MOE位是主输出使能,不开的话TIM1的PWM引脚一直无波形,这个坑后面细说。

4.3 用突发模式同步更新ARR和CCR

如果想在同一个更新事件里同时改频率和占空比,CubeMX没有直接画突发长度的界面,需要手动调HAL接口:

uint16_t burst_seq[2 * SEQ_LEN]; for (uint16_t i = 0; i < SEQ_LEN; i++) { burst_seq[2 * i] = next_arr_value[i]; /* 写到ARR */ burst_seq[2 * i + 1] = next_ccr_value[i]; /* 写到CCR1 */ } HAL_TIM_DMABurst_Start(&htim1, TIM_DMA_UPDATE, (uint32_t *)burst_seq, TIM_DMABURST_BASE_ARR, TIM_DMABURST_LENGTH_2TRANSFERS);

这个调用的含义是:每次更新事件触发DMA,连续搬运burst_seq里的两个半字,先写ARR,再写CCR1。之后每个周期都在按表里预先算好的“ARR和CCR对”运行,频率和占空比同步变化,中间不可能出现“新ARR、旧CCR”的非法状态。

4.4 回调函数与乒乓更新

如果做的是静态正弦表,填完表启动后CPU就真的一点事都没有了。但动态系统里表内容通常要随控制算法实时变化,比如闭环调节占空比,那就得在DMA半传输和传输完成中断里更新下一段数据。

HAL库里,HAL_TIM_PWM_Start_DMA启动后,DMA的半传输和传输完成中断会被HAL内部处理,并映射到定时器层的回调。不同HAL版本的回调名不太一样,F1、F4、H7之间有小差异,最靠谱的办法是打开你当前的stm32f1xx_hal_tim.c搜一下__weak开头的回调函数。思路永远是同一个:在DMA快读到缓冲区末尾时,把下一段要输出的数据提前算好放进去。

void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { /* 在这里更新下一段pwm_table */ update_next_segment(); } }

如果你的HAL版本回调名不是这个,以IDE自动提示为准。也可以用DMA层的回调,本质一样,只是入口函数不同。

4.5 高级定时器的附加配置

TIM1/TIM8是高级定时器,比通用定时器多了一整套互补输出、死区、刹车和主输出使能逻辑。如果只输出单路PWM,会容易漏掉MOE位。HAL_TIM_PWM_Start_DMA启动的是普通PWM通道,它不会去碰BDTR寄存器的MOE位,所以你要手动补__HAL_TIM_MOE_ENABLE(&htim1)。

如果做互补PWM,那要调HAL_TIMEx_PWMN_Start或HAL_TIMEx_PWMN_Start_DMA,库会顺带把MOE打开。但死区时间、刹车极性这些还是要提前在CubeMX里配置好,不然上电一瞬间输出状态可能不是你想要的。

5. 现场调试中踩过的坑,逐条复盘

5.1 DMA请求源选错

CubeMX的DMA Request下拉框里,TIM1有TIM1_UP、TIM1_CH1、TIM1_CH2等好几个选项。我最初做常规PWM没问题,后来新工程想用DMA刷新CCR,图快直接在TIM1_CH1的DMA入口上配了内存到外设,结果波形像抽风一样:有的周期占空比正确,有的周期直接跳到上次的值。

排查时我先把DMA搬运的源地址和目的地址打出来,发现DMA确实在跑,但请求时机不对。TIM1_CH1请求是捕获比较事件触发的,而我要的是更新事件触发。改成TIM1_UP之后立马正常。这个坑看起来小,但现象很迷惑,建议配置时第一眼就确认请求源。

5.2 数据宽度不匹配

有一次调试多路PWM,用了一个uint32_t数组,DMA配置却是Half Word。示波器上看到的波形不是预期正弦,而是杂乱的高低电平跳变。单看代码逻辑挑不出毛病,最后用调试器看内存才知道DMA把32位整数的低16位和高16位分别当成两个独立数据搬运了。

从那以后我给自己定了个规矩:定义PWM数据表之前先看DMA宽度。打算用Half Word,就老老实实用uint16_t;决定用Word,就全程Word,不要混搭。数据表是十六位还是三十二位,跟寄存器有效位对齐,才能避免这种低级但极其耗时的错误。

5.3 ARR和CCR更新不同步导致脉冲异常

最初我只用DMA刷CCR,频率是固定的,一切正常。后来想把频率也动态变,就图方便加了一个更新中断去改ARR。结果调试步进电机时,只要ARR变小、CCR还没来得及跟着变,输出就会连续高电平,电机会猛地窜一下。

这个问题的根因就是“瞬时非法状态”:当新ARR小于旧CCR,CCR永远比ARR大,占空比在那一瞬间变成100%,输出一直拉高。解决办法就是把ARR和CCR放进同一批次,也就是用定时器突发模式。从此以后我所有“频率和占空比同时变”的项目,都走突发模式,绝不分两次写。

5.4 高级定时器MOE没使能,引脚静默

板子回来下载程序,TIM1的PWM死活没有输出,DMA计数在动、定时器也在跑,引脚就是没波形。我一度以为是引脚配置错了,反复检查GPIO没有问题,最后翻寄存器才发现BDTR的MOE位是0。

高级定时器的主输出默认是关闭的,这是硬件做的安全设计,防止上电瞬间输出不受控。解决办法特别简单:

__HAL_TIM_MOE_ENABLE(&htim1);

如果你用标准库,那就直接写TIM_CtrlPWMOutputs(TIM1, ENABLE);。这个动作很多人只在用互补PWM时才想起来,单路输出时也别忘了。

5.5 F7/H7的缓存一致性问题

用F103调通之后,我把同样的逻辑搬到H7上,结果波形完全不对。不是没有输出,而是更新数据后DMA读到的经常是旧值。排查到最后,是Cortex-M7的D-Cache在做怪。CPU写入pwm_table后,数据可能还躺在Cache里没有写回RAM,DMA直接访问RAM读到的自然是旧数据。

解决思路有两个:一是把pwm_table放到MPU配置的Non-Cacheable区域,一劳永逸;二是在每次更新完表之后调用SCB_CleanDCache(),把Cache内容强制写回。F1和F4没有D-Cache,不用管这件事,但凡是M7内核的芯片,只要DMA和CPU共享内存,就一定要把缓存一致性放在排查清单里。

5.6 DMA半传输中断里的数据更新顺序

乒乓缓冲听起来简单,但很多人会在更新顺序上翻车。半传输中断触发时,DMA刚搬完前半段,后半段正要开始。此时CPU应该更新前半段,因为前半段已经不会再被DMA读了。反过来,传输完成中断触发时,整个数组都搬完了,DMA马上要循环回开头,这时CPU应该更新后半段。

我见过有人把顺序搞反,在传输完成中断里更新前半段,结果DMA回头来读前半段时,前半段正好被改了一半,波形又出现毛刺。其实中断标志的含义已经写得很清楚:HT代表“Half Transfer”,前一半完成;TC代表“Transfer Complete”,全部完成。只要把安全边界想明白,这块就不会再错。

6. 实测数据与延伸应用

6.1 和传统中断方案对比

我把三种方案在同一样板上做了对比,72MHz的STM32F103,20kHz PWM,256点正弦表:

方案单路PWM的CPU占用四路SPWM的CPU占用占空比抖动代码复杂度
定时器更新中断改写CCR约8%~10%约40%以上±1~2个计数周期简单
普通DMA循环搬运CCR基本为0基本为0几乎无抖动中等
定时器突发模式同步改ARR+CCR基本为0基本为0几乎无抖动中等

实测下来,DMA方案跑四路SPWM时,CPU几乎只是在DMA半传输和传输完成中断里做几十个周期的数据搬移,负载完全可忽略。波形方面,无论开不开串口中断,占空比跳变点都固定在计数器溢出后的几个总线周期内,不随系统负载浮动。

6.2 典型应用场景

SPWM逆变器是最直接的场景。正弦表放进数组,载波20kHz,如果表长256,那么输出的SPWM基波频率就是20kHz/256,大约78Hz。想要标准50Hz,可以改表长到400,或者把载波频率调到12.8kHz附近再用256点表,具体按实际需求算。配合TIM1的互补PWM和死区,直接驱动全桥,控制算法只负责算调制度,PWM细节完全交给DMA。

步进电机S形加减速也适合用突发模式。电机速度对应PWM频率,力矩对应占空比,加减速过程中两个参数都要变化。把加减速曲线预计算成“ARR、CCR成对表”,每个更新事件按表同步更新,速度切换平滑,也没有占空比越界的问题。电机丢步的一个重要原因就是频率跳变过快,突发模式相当于在硬件层面帮你做了“逐周期平滑”。

多路舵机控制同样适用。舵机要求每路PWM周期固定、占空比按角度变化,多路之间最好互不干扰。把每路的角度序列写到对应通道的CCR,DMA循环输出,CPU只负责解析上位机的控制指令和更新目标角度,不再每周期被定时器中断打断。

6.3 与ADC采样、PWM故障保护的联动

动态PWM闭环系统里经常要同时做电流电压采样。可以把定时器更新事件同时配置成ADC触发源,ADC转换完成后再触发DMA把结果搬到内存,和PWM的DMA放在同一条硬件链路上。整个控制周期,CPU只在算法阶段介入一次,其他时间都在做自己的事。这种“定时器→ADC→DMA”的硬件闭环,是我做数字电源之后越来越依赖的结构。

故障保护则建议直接走定时器的刹车输入。过流信号接到BKIN引脚,一旦触发,定时器立即强制关闭输出通道,响应时间是纳秒级的,完全不等CPU反应。DMA数据表就算因为计算延迟没来得及更新,刹车也能保证输出进入安全状态。PWM故障保护和DMA动态刷新并不冲突,一个是安全机制,一个是效率机制,两者可以同时存在。

如果只做固定占空比输出,其实没必要上这套机制;可一旦碰到动态脉冲序列,DMA加定时器突发模式就是性价比最高的方案。我最建议的入门路径是:先跑通普通DMA搬运CCR,再做突发模式同步修改ARR和CCR,然后把乒乓缓冲、影子寄存器、缓存一致性这些细节一个个吃透。等你把这条链路跑顺了,回头看那些被中断占满CPU的项目,会发现很多性能问题根本不需要升级主控,把活交给DMA就够了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询