DMA+Timer生成PWM波形错位根因分析与解决实操
2026/9/19 5:11:52 网站建设 项目流程

最近在做一个STM32的PWM音频播放项目,原理很直接:用DMA把音频采样值按节奏搬运到定时器的CCR寄存器,让PWM的占空比跟随音频幅度变化,输出端接个低通滤波器还原声音。硬件焊好,固件烧进去,示波器一夹,出来的波形却不对——前几个周期的占空比落在完全意外的值上,后面的序列也整体晚了一个周期,看起来就像整段波形被往前挤了一格。这种“DMA+Timer生成PWM波形错位”的问题,玩过定时器和DMA的人大概率都踩过,它不是偶发干扰,而是DMA请求响应机制、定时器更新事件和寄存器影子装载这几个环节共同作用下必然会出现的结果。这篇文章把整个分析过程和解决方案完整还原出来,适合正在用CubeMX+HAL库做PWM输出、DMA搬运数据的开发者,也适合想彻底搞懂“DMA到底什么时候搬数据”的人。

1. 场景与现象:DMA+Timer生成PWM时,波形“错位”到底是什么

1.1 哪些场景在靠DMA+Timer输出动态PWM

先对齐一下使用场景。DMA+Timer生成PWM并不是什么新鲜玩法,凡是“占空比需要按照一段预先算好的数组连续变化,又不希望CPU每个PWM周期都进一次中断”的地方,基本都会用到这个组合。

我随手列几个常见的:

  • PWM音频播放:把WAV采样点转成占空比数组,用DMA逐一搬到CCR,配合低通滤波还原音频,是低成本播放声音的经典方案。
  • LED呼吸灯和渐变灯带:正弦波表或者调光曲线表,通过DMA循环搬运,实现柔和无级渐变,CPU几乎零负担。
  • 步进电机S型加减速:速度曲线本质上是一串随时间变化的脉冲频率/占空比,DMA自动搬运就能让电机平滑起停。
  • 舵机多路控制:多路PWM需要同时更新占空比,DMA可以在一个周期内把所有通道的CCR值一次性刷新。
  • 逆变器和开关电源:SPWM调制波形需要高频、连续、精确的占空比切换,CPU中断可能来不及,DMA就成了首选。

这些场景的共同特点是:波形数据是预先算好的一块数组,DMA负责定期把数组中的值喂给定时器。凡是这种结构,波形错位都是潜在风险。区别只是有的场景错一两个周期看不出来(比如呼吸灯),有的场景一错就是致命的(比如SPWM逆变),所以搞清楚错位机制非常重要。

1.2 波形错位的三种典型表现

把错位问题摊开看,实际示波器上无非三种样子:

现象肉眼/示波器看到的样子大概率根因
首周期异常序列第一个周期占空比明显不对,后面从第二个周期开始正常启动时序问题,首帧数据没预置
序列整体延后看到的占空比序列比预期慢一拍,比如预期[50,30,70,20],实际看到[预设值,50,30,70]DMA搬运晚了一个更新事件,加上CCR预装载的叠加效果
占空比随机跳动波形毫无规律,每次上电表现还不太一样DMA数据宽度、内存地址对齐出问题,高字节低字节被拆散

我自己第一次遇到时,先入为主以为是数组写错了,把波形表打印出来反复核对,浪费了大半天。后来冷静下来,把问题按照“现象在哪个周期出现”来分类,定位速度快了很多。先确认现象类型,再往下面找原因,基本不会跑偏。

2. 根因拆解:错位不是玄学,是时序链路上的必然

2.1 从更新事件到CCR装载,一个PWM周期的搬运流水线

要理解错位,必须先把这条链路从头到尾走一遍。

定时器产生PWM的流程是这样:计数器CNT从0往上数,数到ARR值后溢出归零,这个过程产生更新事件(Update Event,简称UEV)。同时,CNT会和一个叫CCR的寄存器比较,CNT小于CCR时输出高电平,大于等于CCR时输出低电平,这样就形成了PWM波形。换句话说,CCR寄存器里的值直接决定了当前周期的占空比。

这时候DMA插进来,当定时器触发DMA请求时,DMA会从内存数组里取一个数,写入CCR寄存器,从而改变下一周期的占空比。

问题来了:CCR寄存器通常有预装载机制。你可以把CCR理解成两个格子,一个是程序员能写的“预装载寄存器”,一个是真正参与比较的“影子寄存器”。如果开启了预装载,你在代码里写的值先进预装载寄存器,要等到下一个更新事件来临,预装载寄存器里的值才会被“搬运”到影子寄存器真正生效。

用生活化的比喻就是:食堂窗口先把你点的菜记在小本子上(预装载),但窗口师傅要到下一次叫号(更新事件)才把菜送到你桌上。如果你在叫号的瞬间才去点菜,那这一轮你注定吃不上,只能等下一轮。

这就解释了为什么单纯的“定时器触发DMA、DMA写CCR”会出现一拍延迟:更新事件发生的那一刻,CCR装载的是更新事件之前写入的值;而DMA搬运动作是在更新事件之后才启动的,所以它写入的新值要到再下一个更新事件才生效。数据本身没错,但整体晚了一个周期。

2.2 首周期错位的根源:DMA被“请求”驱动,而不是“数据”驱动

很多人第一次调这个功能时习惯这么写:先启动定时器PWM输出,再启动DMA。结果示波器一看,第一个周期占空比直接是0%或者100%,异常刺眼。

原因在于DMA是“请求驱动”的,不是“数据驱动”的。DMA通道打开后,如果没有外设发出请求,它不会主动搬运任何数据。定时器启动后,要等CNT从0跑完一个完整的ARR周期,才会产生第一个更新事件,然后才能触发第一次DMA搬运。

那么在定时器启动到第一次更新事件之间的这第一个PWM周期里,CCR寄存器是什么状态?是复位后的默认值,通常是0。CNT小于0永远不会成立,所以输出全是低电平,占空比0%;如果某个平台复位值是0xFFFF,那输出就是一直高电平,占空比100%。

即使你启动了DMA,DMA也只是“等待中”,并不会提前把buf[0]放进CCR。于是第一个周期用的就是垃圾值,从第二个周期开始,DMA搬运的数据才开始生效。如果你又叠加了预装载导致的一拍延迟,那实际输出就是:一个异常周期 + 从buf[0]开始的完整数据。

2.3 数据宽度与对齐:看似随机,其实全是高字节低字节在捣乱

首周期错位还好判断,真正坑人的是第三种情况:占空比“随机”跳动。这种随机其实往往不随机,十有八九是DMA数据宽度配置错了。

CCR寄存器是16位的,所以DMA搬运时,外设数据宽度必须是半字(16bit)。如果外设数据宽度配成了8bit,DMA一次搬运只会写入CCR的低字节,高字节保持原来的值不变——这会导致什么?比如你想写入0x0190(400),实际写进去的可能是0x0000(低字节被覆盖,高字节还是0)或者0x0190被拆成两次搬运:先写低字节0x90,再写高字节0x01。

关键是,这两次搬运会被分配到两个不同的更新事件周期里执行。第一个PWM周期CCR=0x0090(看上去像是占空比突然变小),第二个周期CCR=0x0100(又跳一下)。如果你波形表的值跨度很大,视觉上就是占空比在乱跳。

另一个容易被忽略的是内存地址对齐。Cortex-M3内核虽然支持非对齐访问,但DMA对非对齐内存地址的传输效率很差,个别场景下甚至可能触发错误。所以务必将波形数组声明成uint16_t类型,编译器会默认2字节对齐;千万别用int数组然后强制类型转换传给DMA,高字节位序会完全错乱。

3. 解决实操:从CubeMX配置到代码级修复

3.1 基础配置:定时器、DMA请求源、数据宽度怎么选

先说结论,我用CubeMX+F103系列时的推荐配置如下:

以TIM2为例,生成一个2kHz的PWM,72MHz主频,PSC=71,ARR=999,频率计算公式:f = 72MHz / ((PSC+1) * (ARR+1)),也就是72000000 / (72 * 1000) = 1000Hz,即1kHz。

  • 定时器:TIM2,Channel1,PWM Generation CH1。
  • 预装载:使能CCR预装载(TIM_CR1的ARPE置1,CubeMX里对应Auto-reload preload和CCR preload都打开)
  • DMA请求源:在CubeMX的TIM2 DMA Settings里添加一个DMA请求,请求源选TIM2_UP(更新事件),方向内存到外设,外设地址自动指向CCR1。
  • DMA模式:Circular循环模式,这样DMA会不断搬运数组里的数据,搬运完一遍自动从头开始。
  • 数据宽度:外设16bit、内存16bit。
  • 内存地址增量:使能,因为我们要逐个搬运数组元素。

对应初始化和启动代码大概是这样的:

uint16_t pwm_buf[256]; // 波形表,uint16_t类型,天然2字节对齐 void pwm_dma_init(void) { // 假设CubeMX已经初始化了htim2和hdma_tim2_up HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); // 手动使能TIM2更新事件触发DMA __HAL_TIM_ENABLE_DMA(&htim2, TIM_DMA_UPDATE); }

这里有个细节要注意:HAL_TIM_PWM_Start_DMA()这个API内部启用的是**捕获比较事件(CC事件)**作为DMA请求源,而不是更新事件。两者都能工作,但时序细节略有不同:更新事件在周期结束、新周期开始那一刻触发请求,逻辑好推理;CC事件在计数等于CCR那一刻触发请求,触发点落在周期中间。我习惯用TIM_DMA_UPDATE,时序最干净,排查时少绕弯子。

如果只用HAL库的封装,那就是:

HAL_TIM_PWM_Start_DMA(&htim2, TIM_CHANNEL_1, (uint32_t *)pwm_buf, 256);

这种写法内部会把DMA配置成搬运到CCR1,也会正常工作。你选哪种都可以,但心里要清楚自己用的是哪个触发源。

3.2 启动顺序与首帧补偿:让第一个PWM周期就进入正确序列

针对首周期错位,我的通用做法是:在启动PWM之前,先把第一个波形值直接写进CCR

// 启动前先把第一个值塞进CCR,让第一个PWM周期用的是我们想要的值 __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pwm_buf[0]); // 启动定时器PWM输出 HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); // 再使能DMA,让后续值由DMA接管 __HAL_TIM_ENABLE_DMA(&htim2, TIM_DMA_UPDATE);

这样做的意义在于:第一个周期(从启动到第一次更新事件)CCR已经是buf[0]了;第一次更新事件到来后,定时器立刻装载CCR,装载的还是buf[0];随后DMA在新周期搬运buf[1]到预装载寄存器,第二个更新事件到来时,周期2才正式使用buf[1]。

波形输出序列变成:周期1=buf[0],周期2=buf[1],周期3=buf[2]……完全对齐。

注意:如果波形数组本身第一个值是0,那你什么都不用做,因为CCR复位默认也是0,第一周期的“异常”恰好被掩盖了。但写代码不能靠巧合,尤其当数组是实时计算出来的,第一个值可能是任何数,必须主动预置。

另一个思路是建立一个“哑首帧”偏移数组:把数组第0个元素设成任意占空比(比如50%),输出时忽略它。但这样浪费一个数组空间,还得处理索引偏移,不如直接预置CCR干净。

3.3 双缓冲机制:运行动态更新波形的进阶做法

如果波形数据是静态的,比如播放一段循环的正弦波,DMA循环模式就够了,CPU全程不用管。但很多场景需要一边播放一边更新波形,比如实时音频,这个时候单一数组就会被踩踏:CPU正在改写数组某个位置,DMA正好搬到这里,结果搬出去一个不完整的值。

解决办法是把数组拆成前后两半,DMA搬运前半段时,CPU更新后半段;DMA搬运后半段时,CPU更新前半段,这就是双缓冲(或者说半缓冲)机制。

配合DMA的半传输中断和传输完成中断,实现如下:

#define BUF_LEN 256 #define HALF_LEN 128 uint16_t pwm_buf[BUF_LEN]; void HAL_TIM_PWM_PulseFinishedHalfCpltCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { // DMA正在搬运前半段,此时可以安全填充后半段 generate_wave(pwm_buf + HALF_LEN, HALF_LEN); } } void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { // DMA正在搬运后半段,此时可以安全填充前半段 generate_wave(pwm_buf, HALF_LEN); } }

注意,用这个方案时DMA必须工作在循环模式。每次搬运到数组一半时触发半传输中断,搬运完一圈触发传输完成中断,CPU只需要在这两个中断里分段填充数据,永远不和DMA抢同一块内存。

提示:HAL库在不同版本里回调函数的命名有细微差别,有的版本用HAL_TIM_PWM_PulseFinishedHalfCpltCallback,有的早期版本可能没有半传输回调。以你当前CubeMX生成的代码注释为准,或者直接在stm32f1xx_hal_tim.h里搜HalfCplt字样。

3.4 高频率、多通道场景下的性能考量

PWM频率越高,一个周期的时间越短,DMA能用来搬运数据的时间窗口就越窄。以20kHz的PWM音频为例,周期是50微秒,DMA搬一个16位数据在72MHz下只要几百纳秒,完全充裕。但如果PWM频率推到几百kHz,同时还有多个DMA通道在抢总线,就可能出现一个更新事件触发后,DMA迟迟没有响应的情况。

我遇到过一次:SPI、串口DMA和PWM_DMA同时工作,SPI和串口的DMA优先级配得比PWM高,结果PWM的DMA请求总是被插队,波形出现偶发停顿。解决方法是调整DMA通道优先级,或者把定时器中断优先级调低。

多通道场景里,每个PWM通道可以配一个独立的DMA流/通道,触发源分别选对应的TIMx_CC1TIMx_CC2。要注意的是多个DMA同时触发时,优先级低的通道搬运时间会有抖动。如果你做的是多路LED渐变这种对时间不敏感的应用,无所谓;如果是多相电机控制这种需要严格同步的,建议用定时器的主从同步模式配合DMA突发传输来解决,这个展开又是一篇文章,这里先埋个伏笔。

4. 排查流程与常见坑:照着这份清单少熬夜

4.1 一套可复用的排查步骤

遇到波形错位,我现在的排查顺序很固定,推荐给你:

  1. 先静态核对数组。把波形数组打印出来,确认数据本身没写错。
  2. 用逻辑分析仪抓完整波形。不要只看几个周期,至少抓一个完整循环,数清楚从第几个周期开始异常。
  3. 记录异常的位置。第一个周期异常?全部延后?还是随机跳动?这一步直接决定排查方向。
  4. 检查DMA配置。重点看循环模式有没有开、数据宽度是不是16bit、外设地址是不是指向CCR、内存地址增量有没有使能。
  5. 检查定时器时序配置。ARPE预装载有没有开,更新事件有没有正确映射到DMA请求。
  6. 做一个最小化验证。固定数组为[100, 200, 300, 400, ...]这种等差序列,占空比是否按预期增长。如果连这个都对不上,问题基本锁定在DMA配置。

调试时还有一个非常好用的手段:用另一个GPIO翻转来观察中断/请求时刻。比如在定时器更新中断里翻转PB0,在DMA传输完成中断里翻转PB1,用示波器双通道同时看PB0、PB1的实际PWM输出,就能直观看到DMA搬运和更新事件之间的时间差到底有多大。

4.2 常见问题速查表

典型现象直接原因解决办法
第一个周期占空比异常,之后正常启动时序问题,CCR初始值是垃圾值启动前用__HAL_TIM_SET_COMPARE预置首值
波形序列整体延后一个周期CCR预装载+DMA搬运晚一拍叠加保证DMA在更新事件后尽快写完CCR;预置首帧避免视觉上的“错位”
DMA只搬运一次就停DMA没有配循环模式将DMA模式改为Circular
占空比随机跳动、每次上电结果不同DMA数据宽度配错,16位寄存器被当8位搬外设和内存数据宽度统一为16bit
波形偶尔丢一个周期多个DMA通道抢总线,PWM_DMA被高优先级挤掉调高PWM对应的DMA优先级,或错开其他DMA的突发时段
数组内容被改坏CPU和DMA访问同一块内存,发生踩踏使用双缓冲/半缓冲,中断里只填充对侧缓冲
使用HAL_TIM_PWM_Start_DMA后空白忘记启用通道对应DMA请求检查CubeMX里的DMA请求是否挂到了正确的TIM通道上

4.3 几个实测中值得记住的心得

调试这类问题,最忌讳一上来就怀疑编译器、怀疑库函数。我踩过最大的坑就是数据宽度,那次波形看起来“完全随机”,我整整排查了一个晚上,最后发现DMA配置里外设宽度被设成了Byte。从那以后,我再也不相信自己的肉眼,一律用逻辑分析仪抓完整波形,再用固定等差数组验证DMA行为,两步就能把问题缩小到具体环节。

还有一点是关于示波器采样率的。PWM频率本身不高,但占空比变化的细节需要足够高的采样率才能看清。我常用的逻辑分析仪采样率设置是8MHz以上,PWM频率2kHz的时候一个周期能采到几千个点,UART解码、PWM测量都不在话下,波形出了问题也能从时序细节里找到线索。

启动顺序这个问题我建议在项目初期就养成习惯:凡是DMA+外设的配合,先把外设的初始状态设置好,再启动外设,最后使能DMA。这个顺序在串口DMA、ADC DMA、SPI DMA里都通用,能帮你省掉一大半启动瞬间的诡异bug。

我个人在实际操作中的体会是:DMA+Timer生成PWM的错位问题,本质上就是“数据到达CCR的时机”和“定时器装载CCR的时机”没有对齐。只要你脑子里时刻装着这条时序关系,看到任何波形异常都能倒推出原因。最后再分享一个小技巧:调试时用正弦波表做测试数据,因为正弦波变化平滑,任何一处的错位都会在示波器上形成肉眼可见的台阶或跳变,比用随机数数组容易判断得多。

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

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

立即咨询