ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 DMA+PWM输出指定脉冲数的6种实现方法与避坑指南

STM32 DMA+PWM输出指定脉冲数的6种实现方法与避坑指南

简介:针对需要精确输出指定个数PWM脉冲的嵌入式开发者,这份资源汇总了STM32平台下6种DMA与PWM组合实现方案,覆盖电机控制、步进驱动、LED调光等典型应用场景。压缩包共80个文件,包括34个h头文件、33个c源文件,另有5个RAR工程示例、HEX固件、启动汇编文件和uvprojx工程配置,整体仅1.61MB,按方法分类便于查阅。目前已有3089人学习下载,其中定时器DMA改变传输长度控制脉冲个数、双路输出、定时器主从模式、输出比较中断统计、翻转IO中断统计等方法均已给出对应工程代码,并用HAL/LL库说明编程思路。多个RAR示例对应不同实现细节,能帮助理解DMA传输长度修改、双缓冲切换、主从定时器联动等关键机制;读者可对比各方案在实时性、精度及资源占用上的差异,直接用于项目方案选型与二次开发。

1. 为什么“PWM输出指定脉冲数”非要拉着 DMA 一起用

做步进电机驱动、激光振镜控制、或者给下游设备发一串精确计数脉冲时,最头疼的事就是:PWM 一旦跑起来,它自己不会停。你算好要发 200 个脉冲,结果 CPU 进中断数到 200 再去关定时器,实际已经多发了七八个脉冲,因为中断延迟和关断指令执行的周期都是黑的不可控时间。PWM 调速场景里多一点少一点无所谓,但计数类应用多一个脉冲,机械位置就偏一个步距,后面全盘错位。

所以工程上最干净的做法,是把“数到多少个脉冲就停止”这件事交给硬件链路去完成,而不是靠中断里“软件数数然后关定时器”。DMA 加 PWM 的组合,就是让 DMA 把脉冲数当作传输长度来计数,传输完毕自动触发回调或事件,定时器在硬件层面被关停,整个过程 CPU 只负责启动这一次。本文把常见的 6 种用 PWM 配置指定个数脉冲的方法逐一拆开讲清楚,附可直接抄的寄存器配置,适合正在调步进电机、FOC 驱动、或者任何需要脉冲计数输出的工程师。

2. 先说透原理:PWM 的脉冲数由谁决定,DMA 在这里到底扮演什么角色

2.1 理解 ARR、CCR 与更新事件:脉冲个数的天然计数器

在进一步讨论 DMA 之前,先理清 STM32 定时器产生 PWM 的基本机制。定时器内部有一个自由运行的计数器 CNT,它不断从 0 向上计数到 ARR(自动重装载寄存器),然后归零重新开始。在计数过程中,当 CNT 的值小于等于 CCR(比较寄存器)时,输出引脚为高电平;当 CNT 大于 CCR 时,输出为低电平。所以一个完整的 PWM 周期由 ARR 决定,占空比由 CCR/ARR 决定,而“脉冲个数”本质上就是 CNT 从 0 到 ARR 的循环次数。

这里有个关键概念:定时器的更新事件(Update Event)是在 CNT 溢出归零时产生的,也就是每个 PWM 周期产生一次。如果把更新事件当作“一个脉冲已经发完”的信号,那么脉冲计数就变成了“数更新事件发生的次数”。这是 PWM 做指定脉冲数输出的理论基础。FOC 波形生成、背光亮度调节等应用里,大家关心的是占空比是否连续可调,而这里关心的是“发多少个周期就停”,两个维度要分开。

2.2 DMA 在 PWM 场景里的三种角色:搬运、计数、改波形

DMA 在 PWM 输出指定脉冲数的场景里,可以承担三种完全不同的任务。

第一种是搬运数据:把内存里的脉冲宽度数据逐个搬运到 CCR 寄存器,实现每个周期改变占空比的效果,实现任意波形输出。第二种是计数与停止:利用 DMA 传输完成中断来通知 CPU“数据传输完了”,然后在 DMA 中断里关闭定时器。这是最常用的方案,本质上是把“数 PWM 周期”转化成“数 DMA 传输次数”。第三种是突发读写:通过定时器的 DMA 突发模式,一次 DMA 请求搬运一批寄存器数据,快速修改 ARR、CCR 等多达十几个寄存器,实现频率和占空比同时跳变。

要注意区分 DMA 的两个工作模式:Normal 模式传输完指定长度后自动停止,Cyclic 循环模式传输完会自动重装载继续传输。对于“输出指定个数脉冲”这个需求,Normal 模式天然匹配,因为传输次数就是脉冲个数;而循环模式需要配合半传输中断或传输完成中断来手动停定时器,多一道手续。

2.3 六种方法的全景对比:选型只看两个维度

把标题里的“6 种方法”具体展开,按工程实现路径可以分为三组。第一组是软件介入型:方法一 PWM 中断计数、方法二 DMA Normal 模式传输完成中断。第二组是DMA 主导型:方法三 DMA 循环模式加半传输中断、方法四 TIM DMA Burst 突发传输。第三组是硬件全自动型:方法五定时器主从联动硬件关闭 PWM、方法六 PWM 加 N 通道比较事件输出。

方法CPU 占用最小额外脉冲实时性复杂度适用场景
1 PWM 中断计数高可到 0差低脉冲数少且允许 CPU 耗时
2 DMA Normal + 完成中断低0较好低最常用,推荐首选
3 DMA 循环 + 半传输中断低0较好中需要中途插播不同波形
4 TIM DMA Burst极低0高高一个周期内多寄存器联动修改
5 定时器主从联动无0极高中对中断延迟零容忍的场景
6 PWM + N 通道比较无0极高高固定 N 个脉冲的硬件化方案

选型时只问自己两件事:第一,允许 CPU 介入吗?第二,脉冲中途要不要改变频率或占空比?如果都不需要,方法五和六是最稳的;如果需要灵活修改,方法二或三是务实的选择,下面先讲最实用的一种。

3. 方法三最实用:DMA 循环模式 + 半传输中断,以 STM32 TIM1 为例全代码

3.1 为什么我首选循环模式而不是 Normal 模式

按常理,Normal 模式传输次数等于脉冲个数,传输完成中断来了就关定时器,这不是更直接吗?实际踩过坑之后我发现一个问题:很多场景要求“发完 N 个脉冲后,等待下一次触发再发新的一组”。用 Normal 模式,每次发完 DMA 就处于禁用状态,重新配置一遍 DMA 的传输长度、重设内存地址、重新使能 DMA 通道,代码路径很长,而且在重配置期间定时器如果还在跑,就会漏脉冲或多脉冲。

循环模式的好处是 DMA 永远处于 armed 状态,传输完一轮自动重装计数器继续下一轮。我们只需要在半传输中断里翻转“输出使能”的开关:启动时关闭输出,DMA 半传输中断到来时打开输出,传输完成中断到来时关闭输出。这样一组脉冲发完,DMA 还在后台空转,但 PWM 引脚已经被关掉了。下次要发新的一组脉冲,只需把内存缓冲区更新一下,重新打开输出,不需要碰 DMA 的配置寄存器。这个模式在需要连续多次发脉冲的应用中省掉了大量重复初始化代码。

3.2 寄存器配置代码:TIM1 更新事件触发 DMA 循环传输

直接用寄存器操作,避免 HAL 库把关键细节藏起来。目标:TIM1 的 CH1 输出 PWM,更新事件触发 DMA1 通道 2,循环传输 8 个半字数据到 TIM1 的 CCR1,每传一次占空比变一次,发完一轮停在最后一个占空比上。

void PWM_DMA_Cyclic_Init(void) { // 1. 初始化 DMA:TIM1_UP 触发 DMA1_Channel2,循环模式,半字传输 RCC->AHBENR |= RCC_AHBENR_DMA1EN; DMA1_Channel2->CPAR = (uint32_t)&TIM1->CCR1; // 外设地址:TIM1 CCR1 DMA1_Channel2->CMAR = (uint32_t)pwm_buf; // 内存地址:占空比数组 DMA1_Channel2->CNDTR = 8; // 传输长度:8 个半字 DMA1_Channel2->CCR = DMA_CCR_PL_2 // 优先级:高 | DMA_CCR_MSIZE_0 // 内存数据宽度:16 位 | DMA_CCR_PSIZE_0 // 外设数据宽度:16 位 | DMA_CCR_MINC // 内存地址递增 | DMA_CCR_CIRC // 循环模式 | DMA_CCR_HTIE // 半传输中断使能 | DMA_CCR_TCIE // 传输完成中断使能 | DMA_CCR_EN; // 使能 DMA // 2. 配置 TIM1 更新事件作为 DMA 请求源 TIM1->DIER |= TIM_DIER_UDE; // Update 事件触发 DMA // 3. 配置 TIM1 CH1 PWM 模式,ARR=999,初始 CCR1=0(输出低电平) TIM1->PSC = 71; // 72MHz / 72 = 1MHz 计数频率 TIM1->ARR = 999; // 1MHz / 1000 = 1kHz PWM TIM1->CCMR1 = TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2; // PWM 模式 1 TIM1->CCER |= TIM_CCER_CC1E; // CH1 输出使能 TIM1->BDTR |= TIM_BDTR_MOE; // 主输出使能(TIM1 专用) TIM1->CR1 |= TIM_CR1_CEN; // 启动定时器 }

这段代码有几个至关重要的点需要讲清楚。第一,CPAR指向的是TIM1->CCR1而不是TIM1->ARR,因为我们要修改的是占空比。如果要同时改频率和占空比,就得用 4.2 小节的 Burst 模式,普通 DMA 无法在一个触发源下搬运两个不同的外设地址。第二,CNDTR设为 8 意味着 DMA 传输 8 次后一轮结束,但循环模式下 CNDTR 会自动重装回 8,这就实现了“永远待命,随时可写”。第三,中断使能开了HTIE和TCIE,分别对应半传输和全传输,实际使用中这两个中断配合当前输出状态来控制脉冲个数。

3.3 中断处理:用半传输开闸,用完成中断关闸

void DMA1_Channel2_IRQHandler(void) { if (DMA1->ISR & DMA_ISR_HTIF2) { DMA1->IFCR |= DMA_IFCR_HTIF2; // 清半传输标志 TIM1->CCER |= TIM_CCER_CC1E; // 开 PWM 输出 } if (DMA1->ISR & DMA_ISR_TCIF2) { DMA1->IFCR |= DMA_IFCR_TCIF2; // 清完成标志 TIM1->CCER &= ~TIM_CCER_CC1E; // 关 PWM 输出 pulse_done_flag = 1; // 通知应用层本轮完成 } }

为什么用半传输中断来“开闸”?假设我们要连续发 8 轮脉冲,第一次启动定时器时 PWM 输出是关闭的,DMA 从 CCR1 的一个初始值开始传输。半传输时点意味着数组前 4 个占空比已经被装载进 CCR1 了,此时打开输出,引脚上开始出现 PWM 波形;传输完成时意味着数组 8 个数据全部使用过一轮,此时关闭输出,正好发完 8 个周期的脉冲。这个方案把“输出了多少个脉冲”精确对应到“DMA 传了多少次”,而半传输中断的作用是让第一个半段波形不至于在开闸前丢失。

配置参数里最重要的数值是CNDTR,它直接决定脉冲个数。如果时钟主频是 72MHz、PSC 为 71,那么 PWM 频率等于 72MHz / (72 × 1000) = 1kHz,一个数组元素对应一个 PWM 周期,8 个元素就是 8 个脉冲。如果你要发 200 个脉冲,设置CNDTR = 200并准备 200 个占空比数据即可。需要留意的边界条件是CNDTR是 16 位寄存器,最大 65535,超过这个数要拆分成多段循环,或改用方法六的硬件方案。

4. 其余三种方法的差异化配置:Normal 模式、Burst 突发、主从联动

4.1 Normal 模式:唯一需要注意的寄存器是 CNDTR 的时机

Normal 模式的配置和循环模式几乎一样,唯一的区别是去掉DMA_CCR_CIRC标志,并且传输完成后 DMA 自动禁用。代码如下,这条路径适合“一次性发完 N 个脉冲就结束”的简单需求。

void PWM_DMA_Normal_Start(uint16_t *buf, uint16_t count) { DMA1_Channel2->CMAR = (uint32_t)buf; DMA1_Channel2->CNDTR = count; DMA1_Channel2->CCR |= DMA_CCR_EN; }

这里有一个非常隐蔽的坑:CNDTR 的值必须在 DMA 使能之前写入。DMA 使能后 CNDTR 会随着每次传输递减,如果你在使能状态下往 CNDTR 写了新值,写入的值会和内部递减逻辑竞争,结果大概率不是你想要的数。我的血泪经验是:先关 DMA 的 EN 位,然后写 CNDTR,再写内存地址,最后开 EN。顺序不能乱,否则会出现“明明写了 200,实际只发了 186 个”这类玄学问题。

另一个 Normal 模式和循环模式的差异体现在中断处理上:Normal 模式不需要半传输中断,只需要在传输完成中断里关闭 PWM 输出。因为 DMA 传输完成本身就意味着所有脉冲的占空比都已经装载完毕,最后一个 PWM 周期已经在硬件层面被 CCR 里最后的数据控制,软件关断输出的时间点即使稍微晚几个周期,引脚上也不会有多余的脉冲。

4.2 TIM DMA Burst:一次 DMA 请求搬走一排寄存器,改写频率和占空比

如果需求是“前 100 个脉冲频率 1kHz,后 100 个脉冲频率 2kHz”,用普通 DMA 改 CCR 会面临一个问题:每个周期 DMA 只搬一个数据,频率切换需要修改 ARR,而 ARR 的修改无法由同一个 DMA 通道搬运,因为 DMA 的外设地址是固定的。解决这个问题靠的是 TIM 的 DMA 突发模式。它的机制是:一次 DMA 请求到来时,定时器连续搬运多个寄存器,而不是只搬一个。

// 用 TIM1 的 DMABURST 同时搬运 ARR 和 CCR1 TIM1->DCR = TIM_DCR_DBL_1 | TIM_DCR_DBA_0; // DBL=2(搬运2个寄存器),DBA从ARR开始 // 内存缓冲区:第一个数据给 ARR,第二个数据给 CCR1 uint16_t burst_buf[2] = { 499, 250 }; // 频率 2kHz,占空比 50% DMA1_Channel2->CMAR = (uint32_t)burst_buf; DMA1_Channel2->CNDTR = 2; // DMA 传输完成后,TIM1 会自动把 burst_buf[0] 写入 ARR, // burst_buf[1] 写入 CCR1

理解了这段代码你就明白 Burst 的威力:它把定时器寄存器看成一组连续地址空间,DBA指定起始寄存器的地址偏移,DBL指定要搬运几个寄存器。在 STM32 上,ARR 的地址偏移是 0x2C,CCR1 的偏移是 0x34,如果 DBA 设为 0(即从 CR1 开始),那 DBL 最大可以覆盖到 0x34 区域内的一串寄存器。Burst 模式下 DMA 请求的频率不再是每个更新事件一次,而是可以配置在特定事件(比如比较事件)触发后一次性搬走多个数据,一次性完成频率和占空比的变更。stm32 的 tim dma burst 应用最常见的场景就是电机控制中同步修改周期和比较值,避免先改占空比后改频率导致瞬间输出异常占空比。

4.3 定时器主从联动:硬件关断延迟为零的方法

脉冲数要求极其苛刻、不允许任何中断延迟的应用,建议放弃 DMA 中断,改用定时器主从模式。原理是:定时器 1 作为主定时器输出 PWM,定时器 2 作为从定时器对主定时器的更新事件计数,当从定时器计数值到达预设的 N 时,从定时器输出一个触发信号,通过硬件把主定时器的输出禁止。整个过程没有软件参与,延迟纯粹是硬件门电路的传播延迟,通常在纳秒级。

// 主定时器 TIM1:PWM 输出 TIM1->CR2 |= TIM_CR2_MMS_1; // 主模式:更新事件作为TRGO输出 // 从定时器 TIM2:外部时钟模式 1,对 TRGO 计数 TIM2->SMCR = TIM_SMCR_ECE | TIM_SMCR_TS_0 | TIM_SMCR_SMS_0; // 注意:ECE 位让 TIM2 使用外部时钟,TS 选择 ITR0(TIM1),SMS 配置为外部时钟模式 // 从定时器计到 N 后触发 OC 中断或通过 TRGO2 反向控制 TIM1 的 MOE 位

这一条在 STM32F4 系列上有一个天然限制:TIME1 的 MOE 主输出使能位在BDTR寄存器里,它只能被软件置位或由刹车信号清除,不能直接被另一个定时器的触发信号控制。所以纯硬件联动在 F4 上做得不彻底,通常还要加一条 DMA 通道在从定时器更新事件时改写 BDTR。F0/G0 系列的定时器互联逻辑更灵活,可以直接通过联动寄存器做到。如果芯片不支持这种联动,方法六更直接。

4.4 方法六:PWM 加 N 通道比较事件,输出固定 N 个脉宽波形的硬件法

最后一招是利用定时器多个比较通道。假设要用 PWM 输出 3 个宽度各不相同的脉冲,可以配置 CH1、CH2、CH3 三个通道,每个通道设定不同的 CCR 值和输出极性,把三路 PWM 信号在外部用与门合成一路。这本质上是用定时器资源换中断延迟,适合脉冲个数极少(2 到 4 个)且对波形宽度有精确要求的场景。它不具备通用性,但在特殊需求中能救命,比如给 ADC 触发三个采样窗口。

5. DMA + PWM 输出指定脉冲的避坑记录与常见问题排查

5.1 现象:DMA 传输完成但定时器还在输出,多出了几十个脉冲

原因:把 DMA 传输完成理解成了“PWM 已经停止”。实际上 DMA 搬完数据,定时器依然自由运行,CCR 保持最后一个数据,PWM 波形持续输出。解决:在 DMA 中断里显式关闭TIMx->CCER的CCxE位,或关闭TIMx->CR1的CEN位。推荐关 CCER,因为这种方式不会复位 CNT 计数器和定时器分频状态,下次启动更平滑。

5.2 现象:半传输中断进入正常,但第二次启动后中断不再触发

原因:DMA 的HTIF标志在循环模式下如果不清除,下一个半传输事件到来时不会再次置位。部分 HAL 库驱动在HAL_DMA_IRQHandler里已经清了,但寄存器原生开发很容易漏掉。解决:中断处理函数第一行写DMA1->IFCR |= DMA_IFCR_HTIF2;或者DMA_ClearFlag(DMA1_Channel2, DMA_FLAG_HT),确保每个半传输事件都能重新触发中断。

5.3 现象:Burst 模式下寄存器值错乱,ARR 被写成了 CCR 的数据

原因:DBA的偏移计算错误。DBL 是“要传输的寄存器个数”,DBA 是“起始寄存器在偏移表中的编号”,两者都按半字单位算。TIM1 的 DBA=0 对应 CR1,DBA=4 对应 PSC,DBA=5 对应 ARR,DBA=6 对应 RCR,DBA=7 对应 CCR1。很多工程师把 DBA 直接写成寄存器地址的字节偏移,比如 ARR 的地址偏移 0x2C,结果定时器内部按索引解析,写到了完全不同的寄存器。解决:查阅参考手册 DMA 映射表,确认 DBA 索引与寄存器的对应关系。

5.4 现象:发完 100 个脉冲,实际示波器测量得到 99 个或 102 个

原因:启动时机和停止时机没有和 PWM 波形的相位对齐。如果 DMA 半传输中断打开输出使能时,CNT 恰好处于 ARR 附近,第一个不完整的周期也被算进去了,表现为多一个脉冲;如果停止时 CNT 已经走过去半个周期,最后一个脉冲宽度不足,逻辑分析仪可能不识别。解决:将开闸和关闸动作同步到更新事件发生后的时刻,比如在更新中断中操作 CCER,或者将 DMA 触发源设为更新事件,让数据传输、开启输出、关闭输出三个动作全部对齐到 PWM 周期的边缘。

5.5 现象:重新配置脉冲数时,当前正在跑的这一轮 PWM 周期突然拉长

原因:直接修改 ARR 寄存器导致的。在 PWM 运行中写 ARR,绝大多数 STM32 型号是在下一个更新事件才生效,但有些系列支持预装载,写入的值会在 CNT 计数到当前 ARR 后重新加载。如果新的 ARR 值比当前 CNT 值还小,CNT 会回绕,产生一个异常长的周期。解决:把 ARR 修改放到更新中断里完成,或者关闭 PWM 输出后再改参数。在动态修改脉冲个数的代码中,一定先停 CCER,再写 CNDTR,再开 DMA,最后恢复 CCER。

5.6 现象:DMA 配置了 Continuous Requests,外设却只触发了一次

原因:DMA_CCR里的EN位和定时器的UDE位是分开控制的。很多工程师只开了 DMA 的EN,忘记在TIMx->DIER里设UDE,导致定时器的事件没有连接到 DMA 请求线上。反过来,如果定时器没有启动,DMA 也不会被触发。检查顺序:定时器CEN置位、UDE置位、DMAEN置位,三个条件缺一个就表现为“DMA 一动不动”。这条排在排查清单第一位。

6. 验证技巧与进阶落地:示波器测脉冲数,以及动态切换脉冲数怎么做得稳

验证“指定个数脉冲”是否精确,靠眼数示波器波形是不现实的,尤其是几百上千个脉冲。正确做法是用逻辑分析仪抓 PWM 引脚,设置上升沿计数,然后检查计数结果。示波器上用 N 次触发模式,设置触发条件为第 N 个上升沿,观察停止位置是否在最后一个脉冲的下降沿附近。如果停止位置和预期脉冲的下降沿完全对齐,说明关断时机正确;如果停在上升沿之前,说明少了一个;如果停在下降沿之后一个完整周期以上,说明多发了。逻辑分析仪的优势是能直接读出两个沿之间的时间,确认最后一个脉冲宽度是否完整。

关于动态切换脉冲数,我踩过最深的一个坑是这样的:程序里用环形缓冲区管理多组脉冲参数,第一组 100 个脉冲发完后,应用层往 DMA 的 CNDTR 写新的数字,但 DMA 还在循环模式运行中,写入根本没生效。后来我把方案改成“脉冲参数预装载 + 应用层只写触发标志”的结构——所有脉冲数和占空比数据预先按时间顺序排布在内存中,DMA 按序传输,应用层只在一个完成中断之后决定是否修改内存内容。这样把“修改配置”和“DMA 正在运行”彻底隔离,就再也没有出现写入竞争。

如果你打算在新项目里大规模使用这个方案,我的建议是先花一天时间把示波器测量方法固定下来,每次改动代码后先跑 10 次、每次 100 个脉冲,连续 10 次计数结果完全一致后再进入联调。因为这类问题最大的特点是“偶尔多一个、偶尔少一个”,不复现就找不到根因,而一旦复现,90% 是 DMA 和定时器事件相位错位。希望今天的这些血泪经验能帮你在 PWN 脉冲输出这条路上少走几个弯路——把硬件的事情交给硬件,CPU 只做决策不做苦力,这是做嵌入式控制长期最受益的思路。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进