STM32定时器DMA与突发模式:高效生成动态PWM脉冲序列的实战指南
上个月调一板四路SPWM正弦波逆变器载波定在20kHz四个桥臂需要互补PWM每路占空比还得按正弦表一周期一周期地变。一开始图省事直接在定时器更新中断里改CCR寄存器结果20kHz等于每50us断一次加上上下文切换、标志位处理、写寄存器这些开销CPU占用一路飙到40%多。最不能忍的是抖动——串口中断一来CCR写入时间就往后拖逻辑分析仪放大一看占空比跳变的地方总有那么一两个周期的毛刺。后来我把STM32的DMA拉进来让定时器更新事件自己去请求DMA把内存里预置好的正弦表数据搬进CCR寄存器CPU彻底松绑。再往后接触到定时器的DMA突发传输一个事件同时刷新ARR和CCR连频率和占空比需要同步变化的场景也能平滑过渡。这篇文章就把这套机制的底层原理、CubeMXHAL库的配置过程、以及我在现场调试中踩过的坑完整写出来给正在做电机控制、逆变器、舵机序列这类“动态PWM脉冲序列”的朋友一份能直接抄作业的参考。1. 为什么要让DMA来“弹奏”PWM乐谱1.1 动态PWM序列的核心工作按节拍刷新CCR先对齐一下基础知识。定时器输出PWM的时候ARR决定周期CCR决定高电平持续时间占空比就是CCR/(ARR1)。所谓“动态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和突发长度DBLKDMAR是一个“虚拟外设地址”。DMA向DMAR连续写入N个数据时定时器内部会按照DBA从0开始递增地址偏移把这N个数据依次分发给对应的寄存器序列。举个例子配置DBA指向ARRDBLK设为2。那么一次定时器更新事件触发后DMA连续搬运两个半字第一个写入ARR第二个写入CCR1。这样ARR和CCR1就在同一次传输批次里完成了更新天然原子不会出现“ARR已经变了但CCR还是旧值”的中间状态。这里还要提醒一句很多初学者会把“定时器突发模式”和“DMA控制器自身的burst传输”搞混。F4及以上的STM32DMA控制器的配置寄存器里有MBURST和PBURST字段可以设置INCR4、INCR8、INCR16那是指DMA在AHB总线上以突发方式连续读写内存主要解决总线带宽和仲裁效率问题。定时器突发模式则是指利用DMAR把多个数据分发到多个定时器寄存器。两者可以同时使用但解决的问题完全不一样。对比项普通DMA传输定时器突发模式每次请求搬运的数据量1个N个由DBLK决定数据接收方单个外设寄存器定时器内一组寄存器序列典型应用刷新CCR占空比同时更新ARRCCR、多路CCR时序一致性一般高批量更新原子完成配置关键点DMA内存到外设循环模式DCR的DBA/DBLK DMA连续写入DMAR2.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*i1]放对应的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 WordDMA把每个32位整数的高16位和低16位交错搬运波形看起来像随机数塞进了CCR查了大半天才意识到是宽度不匹配。从那以后我的规矩是PWM表一律uint16_tDMA宽度一律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为例。时钟树先配到72MHzTIM1时钟源选Internal Clock。Prescaler设0Counter Period设3599计算一下72MHz / (01) / (35991) 20kHz刚好。接着在TIM1的PWM Generation CH1里选择PWM模式1Pulse设一个初值比如1800对应50%占空比。然后切到DMA Settings页面添加一条DMA Request。这里要注意选择TIM1_UP这是更新事件请求不是TIM1_CH1的比较事件请求。传输方向是Memory To PeripheralMode选CircularData 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小于旧CCRCCR永远比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里没有写回RAMDMA直接访问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的STM32F10320kHz PWM256点正弦表方案单路PWM的CPU占用四路SPWM的CPU占用占空比抖动代码复杂度定时器更新中断改写CCR约8%~10%约40%以上±1~2个计数周期简单普通DMA循环搬运CCR基本为0基本为0几乎无抖动中等定时器突发模式同步改ARRCCR基本为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周期固定、占空比按角度变化多路之间最好互不干扰。把每路的角度序列写到对应通道的CCRDMA循环输出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就够了。