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

发布时间:2026/10/28 23:48:25
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. 初始化 DMATIM1_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 模式ARR999初始 CCR10输出低电平 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; // DBL2搬运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 的地址偏移是 0x2CCCR1 的偏移是 0x34如果 DBA 设为 0即从 CR1 开始那 DBL 最大可以覆盖到 0x34 区域内的一串寄存器。Burst 模式下 DMA 请求的频率不再是每个更新事件一次而是可以配置在特定事件比如比较事件触发后一次性搬走多个数据一次性完成频率和占空比的变更。stm32 的 tim dma burst 应用最常见的场景就是电机控制中同步修改周期和比较值避免先改占空比后改频率导致瞬间输出异常占空比。4.3 定时器主从联动硬件关断延迟为零的方法脉冲数要求极其苛刻、不允许任何中断延迟的应用建议放弃 DMA 中断改用定时器主从模式。原理是定时器 1 作为主定时器输出 PWM定时器 2 作为从定时器对主定时器的更新事件计数当从定时器计数值到达预设的 N 时从定时器输出一个触发信号通过硬件把主定时器的输出禁止。整个过程没有软件参与延迟纯粹是硬件门电路的传播延迟通常在纳秒级。// 主定时器 TIM1PWM 输出 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 选择 ITR0TIM1SMS 配置为外部时钟模式 // 从定时器计到 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 的 DBA0 对应 CR1DBA4 对应 PSCDBA5 对应 ARRDBA6 对应 RCRDBA7 对应 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 只做决策不做苦力这是做嵌入式控制长期最受益的思路。本文还有配套的精品资源点击获取