STM32精确控制PWM脉冲个数:主从定时器方案与验证

发布时间:2026/9/16 21:35:50
STM32精确控制PWM脉冲个数:主从定时器方案与验证 简介面向嵌入式开发者与STM32初学者的精确脉冲控制工程示例基于C语言与HAL库展示如何通过通用定时器中断翻转GPIO输出指定数量脉冲。压缩包共181个文件、约6.11MB除核心的32个C源码与33个头文件外还包含Keil工程配置、编译生成的hex/axf镜像以及map、lst等调试文件从文件构成看标准外设库与用户代码分开存放层次清晰便于读者对照源码理解定时器预分频、自动重载和中断回调的配合方式。示例程序完整演示了从RCC时钟初始化到定时器启动、中断计数达到目标后自动停止的流程代码注释清晰可用于步进电机定位、脉冲计数、通信协议或PWM类应用工程可直接在常见STM32F10x开发板上编译烧录也可作为二次开发模板。资源已有394人学习浏览对希望快速掌握精确脉冲输出的开发者是一份值得收藏的实测参考。1. 精确控制脉冲个数难的不是“发”而是“停”对 STM32 来说持续输出 PWM 一点都不难难的是让它按你的要求“说停就停”。所谓精确控制脉冲个数不只是让定时器转起来再数够 5000 个脉冲而是要求最后一个脉冲的时间位置确定、不随中断抖动而偏差否则步进电机的定位就会差出几十微米双脉冲测试的关断时刻也不可信。这类需求在步进/伺服定位、测试治具、电源测试、EtherCAT 脉冲当量换算里反复出现。很多第一次做脉冲控制的开发者会踩同一个坑用 PWM 中断里做减法结果忽多忽少或者发出去了才发现停止命令晚了一个周期。精确控制脉冲个数的本质是设计一条“硬件计数、到数即停、延迟可测”的关断路径而不是靠 CPU 数数。这篇文章用一个可编译的 STM32 C 语言工程讲透这件事从三种实现方案的选型到主从定时器的具体代码、脉冲当量换算再到用逻辑分析仪把误差收到 1 个脉冲以内。2. 三种“到数即停”的脉冲生成方案原理与适用边界“精确”体现在两个地方数值精确说发 10000 个就发 10000 个停止相位稳定每次关断的延迟是固定且可测的。前者要求关闭逻辑由硬件计数完成后者要求关闭动作不依赖不可控的中断抢占。围绕这两点常用的有三套方案各自的原理和边界差别很大。2.1 重复计数寄存器 RCR硬件数脉冲但中断关闭仍有不确定性STM32 的定时器里有一个常被忽视的寄存器重复计数寄存器 RCR。它做的是“把更新事件攒起来”这件事。每产生一次更新事件RCR 减 1减到 0 时才真正产生更新中断。例如把 RCR 设为 99定时器每发 100 个脉冲才进一次中断。这个设计的直接好处是中断频率降低 100 倍CPU 负载大幅下降。但 RCR 方案有一个天生的短板它只管“什么时候进中断”不管“进中断之后怎么办”。你仍然要在中断服务函数里执行关闭定时器的指令而这条指令从脉冲发生到真正停止时基中间隔着中断响应时间、现场保护和指令周期。低频下看不出来到了 1 MHz 的脉冲频率这个延迟会折算成 1 到 2 个脉冲的误差而且不同优先级抢占时延迟不固定。RCR 的位数也限制了单次脉冲数量。STM32F1 系列的 RCR 是 8 位最大 255F4 系列大多为 16 位最大 65535。一次发 60000 个脉冲RCR 勉强够用但超过这个数就得软件分段。所以这个方案适合中短脉冲串或者不要求 1 个脉冲精度的场合。2.2 主从定时器组合把计数放在另一个硬件上绕过 CPU 数数更可靠的做法是让第二个定时器来数。主定时器输出 PWM主定时器的触发输出 TRGO 在特定事件时产生一个内部脉冲这个脉冲直接喂给从定时器作为外部时钟。从定时器每收到一个脉冲计数器加 1它数到的就是已经发出的脉冲数。把目标值写进从定时器的比较寄存器 CCR1当从定时器的 CNT 等于 CCR1 时产生比较中断中断里只做一件事关闭主定时器。这样 CPU 不参与数数也不需要在中断里做加减法错误源少了一个。如果连中断延迟都想省掉可以进一步把从定时器配置为主模式“比较输出”让“CNT 等于 CCR1”这个事件直接作为主定时器的触发输入用从模式强制停止主定时器。这是硬件门控延迟只有一个时钟周期级别是真正意义上的“到数即停”。主从方案对数量上限几乎没有压力。STM32 的 TIM2 和 TIM5 是 32 位计数器一次最多可发 42.9 亿个脉冲做步进电机行程规划绰绰有余。这是 RCR 方案做不到的。2.3 DMA 配合 PWM 比较更新在脉冲间隔可变的情况下保持计数精确前两种方案做主定位足够但如果要做 S 型加减速每个脉冲的间隔都不一样就需要第三种方案。主定时器每产生一次更新事件触发一次 DMA 传输把预先算好的下一个脉冲周期值写进自动重装载寄存器 ARR或者把比较值写进 CCR。这样每个脉冲的宽度和间隔由 DMA 从内存里取数决定速度曲线可以预先算好。这套方案的关键点在于DMA 请求由定时器更新事件触发而不是由 CPU 轮询触发所以加减速曲线再复杂脉冲间隔依然是硬件级精确。代价是需要预先把脉冲间隔数组放在内存里长距离运动时数组会比较大通常配合环形缓冲区使用。它和主从定时器方案不是竞争关系而是互补主从方案保证“个数精确”DMA 方案保证“每个间隔精确”。三套方案的适用边界如下表所示方案计数方式单次个数上限最小脉冲间隔停止延迟CPU 负载适合场景RCR 重复计数硬件累积更新事件F1: 255F4: 65535取决于定时器时钟中断后执行关断指令数微秒级低中短脉冲串、周期性任务主从定时器从定时器外部时钟计数32 位计数器可达 42.9 亿取决于主定时器时钟比较中断关断可优化为硬件门控极低步进/伺服定位、大批量定数输出DMA 动态更新 ARR/CCRDMA 按更新事件逐次装载受内存和缓冲管理限制可逐脉冲变化可叠加主从方案低S 型加减速、激光振镜扫描3. 用 C 语言在 STM32 上实现主从定时器精确脉冲模块下面这套代码的思路在 STM32F103C8T6 和 HAL 库上验证过使用 TIM1 作为 PWM 主定时器TIM2 作为外部时钟从计数器。整个模块对外只暴露一个函数pulse_emit()传入目标脉冲数发出指定数量的脉冲后自动停止。3.1 CubeMX 配置主从定时器的关键参数CubeMX 里 TIM1 和 TIM2 的配置直接决定脉冲频率和计数方式参数如下表配置项值说明TIM1 时钟源内部时钟72 MHzF103 的 APB2 定时器时钟TIM1 Prescaler7172 MHz / 72 1 MHz 计数频率TIM1 Period9每 10 个计数一个周期输出 100 kHzTIM1 Pulse4占空比 50%PWM 模式 1TIM1 Trigger OutputOC1REF关键从定时器按 OC1REF 的上升沿计数TIM2 Clock SourceExternal Clock Mode 1从外部触发输入 ITR0 获取时钟TIM2 Trigger SourceITR0对应 TIM1 的 TRGO无需外部接线TIM2 Prescaler0每个上升沿计数一次TIM2 Period0xFFFFFFFF32 位计数器不产生溢出TIM2 Channel 1Output CompareTIMING 模式只用来产生比较事件不占用引脚TIM1 的 Trigger Output 选 OC1REF 是整套方案的关键。如果选 Update从定时器数的是“完整周期数”关闭时刻会落在上升沿之后选 OC1REF从定时器的每个计数沿正好和 PWM 的上升沿对齐目标个数和实际发出的上升沿数量严格对应。3.2 初始化代码主从互联CubeMX 生成的基础初始化代码里需要补一段主从互联配置。核心代码如下void pulse_module_init(TIM_HandleTypeDef *htim_pwm, uint16_t channel, TIM_HandleTypeDef *htim_cnt) { // 先启动 PWM 再立即停止确保 CCR 输出使能位已经置位 HAL_TIM_PWM_Start(htim_pwm, channel); __HAL_TIM_DISABLE(htim_pwm); // 主定时器 TRGO 选择 OC1REF TIM_SelectOutputTrigger(htim_pwm-Instance, TIM_TRGO_OC1REF); // 从定时器工作在外部时钟模式 1时钟来自 ITR0 TIM_SlaveConfigTypeDef slave_config {0}; slave_config.SlaveMode TIM_SLAVEMODE_EXTERNAL1; slave_config.InputTrigger TIM_TS_ITR0; slave_config.TriggerPolarity TIM_TRIGGERPOLARITY_RISING; HAL_TIM_SlaveConfigSynch(htim_cnt, slave_config); // 从定时器通道 1 工作在比较输出 TIMING 模式不驱动引脚 TIM_OC_InitTypeDef oc_config {0}; oc_config.OCMode TIM_OCMODE_TIMING; oc_config.Pulse 0; oc_config.OCPolarity TIM_OCPOLARITY_HIGH; HAL_TIM_OC_ConfigChannel(htim_cnt, oc_config, TIM_CHANNEL_1); // 从定时器先使能再立即停止让 CEN 置位 HAL_TIM_Base_Start(htim_cnt); __HAL_TIM_DISABLE(htim_cnt); }这段代码的逻辑是先把外设“热起来”再停住。PWM 输出通道如果不先经过一次HAL_TIM_PWM_Start()CCER 里的输出使能位没有被置位后面单纯用__HAL_TIM_ENABLE()恢复定时器时引脚不会输出波形。从定时器同理先使能再停止只是为了让内部状态正确。TIM_SelectOutputTrigger()把 TIM1 的 TRGO 配成 OC1REF。注意这一步必须放在 HAL 初始化之后因为 HAL 初始化会重写主模式寄存器。从定时器使用HAL_TIM_SlaveConfigSynch()配置为外部时钟模式 1输入触发源为 ITR0。在 F103 里TIM2 的 ITR0 正好来自 TIM1 的 TRGO两个定时器在芯片内部已经连好不需要任何外部导线。3.3 脉冲发射函数与比较中断初始化完成后对外接口只需要一个函数设置目标脉冲数启动输出。代码如下static volatile uint8_t pulse_busy 0; void pulse_emit(TIM_HandleTypeDef *htim_pwm, TIM_HandleTypeDef *htim_cnt, uint32_t count) { if (count 0 || pulse_busy) { return; } // 停住两路定时器保证从干净状态开始 __HAL_TIM_DISABLE(htim_pwm); __HAL_TIM_DISABLE(htim_cnt); // 从定时器清零目标值写入 CCR1 __HAL_TIM_SET_COUNTER(htim_cnt, 0); __HAL_TIM_SET_COMPARE(htim_cnt, TIM_CHANNEL_1, count); // 清除可能残留的标志位使能比较中断 __HAL_TIM_CLEAR_FLAG(htim_cnt, TIM_FLAG_CC1 | TIM_FLAG_UPDATE); __HAL_TIM_ENABLE_IT(htim_cnt, TIM_IT_CC1); pulse_busy 1; // 先启动从定时器再启动主定时器 __HAL_TIM_ENABLE(htim_cnt); __HAL_TIM_ENABLE(htim_pwm); } void TIM2_IRQHandler(void) { if (__HAL_TIM_GET_FLAG(htim2, TIM_FLAG_CC1)) { __HAL_TIM_CLEAR_FLAG(htim2, TIM_FLAG_CC1); // 关闭主定时器脉冲停止 __HAL_TIM_DISABLE(htim1); __HAL_TIM_DISABLE_IT(htim2, TIM_IT_CC1); pulse_busy 0; } }这段代码里从定时器的 CCR1 就是目标脉冲数。TIM2 每收到一次 OC1REF 的上升沿CNT 加 1当 CNT 等于 CCR1 时比较中断触发关闭 TIM1。CPU 不需要知道“还剩多少个脉冲”所有计数都在硬件里完成。pulse_busy标志用来防止重复调用。如果上一个脉冲串还没有发完新的调用直接返回。这个标志是 volatile 的因为它在中断里被写入在主循环里被读取。发射前的__HAL_TIM_DISABLE()保证上一次停止后残留的计数状态不会影响新一轮输出。3.4 参数怎么改从 100 kHz 到任意频率上面配置输出的是 100 kHz 脉冲修改频率只需要动 TIM1 的 PSC 和 ARR。TIM1 的计数时钟是 72 MHz / (PSC 1)PWM 频率是计数时钟 / (ARR 1)。例如要 400 kHz 脉冲可以设 PSC 0ARR 179此时计数时钟 72 MHz每个周期 180 个计数频率正好 400 kHz。占空比由 Pulse 值决定Pulse 设为 ARR 的一半即可得到 50%。需要注意PWM 脉冲频率越高TIM2 的外部时钟输入频率也越高。F103 的 TIM2 外部时钟模式理论上可以跑到定时器时钟频率但实际布局和信号完整性会先成为瓶颈。对外输出时建议在脉冲引脚和驱动器之间串一个 100 到 220 欧姆的电阻减少振铃对边沿计数的干扰。4. 脉冲当量换算、方向信号时序与高频边界定时器能把脉冲个数发准只是第一步。到了真实电机系统里脉冲个数要换算成位移方向信号要比脉冲提前建立高频下还要考虑最后一个脉冲的完整性问题。4.1 脉冲当量从“几个脉冲”换算成“走多远”步进电机配上细分驱动器后一个脉冲对应一个细分步。以步进角 1.8° 的电机、驱动器 8 细分、丝杠导程 5 mm 为例电机转一圈需要 200 × 8 1600 个脉冲丝杠带动工作台移动 5 mm每个脉冲对应的位移是 5 mm / 1600 3.125 μm。这就是脉冲当量。实际工程里位置指令往往是毫米值需要换算成脉冲数再交给pulse_emit()#define STEPS_PER_REV 1600 // 200 步 * 8 细分 #define LEAD_MM 5.0f // 丝杠导程单位 mm int32_t mm_to_steps(float distance_mm) { float steps distance_mm * STEPS_PER_REV / LEAD_MM; return (int32_t)(steps 0.5f); // 四舍五入 }这段换算的代码放在主循环里不在中断里做。中断里只处理整数脉冲数任何浮点运算都不要出现在中断服务函数里。mm_to_steps()的舍入误差在半格以内也就是小于 1.56 μm对于大多数丝杠定位场景可以接受。如果在 EtherCAT 或者总线型伺服系统里收到“脉冲值不匹配报警”常见原因就是上位机计算脉冲数和驱动器实际反馈的脉冲数不一致。先用逻辑分析仪抓实际输出确认个数准确再回头查电子齿轮比和脉冲当量换算不要把问题归结到定时器身上。4.2 方向信号必须比脉冲先建立步进驱动器的 DIR 方向输入和 PUL 脉冲输入之间存在最小建立时间要求常见的标称值是 5 μs。如果方向信号和脉冲信号同时变化驱动器可能采样到错误的方向表现为电机偶发反向一步。正确做法是先写方向引脚延时一段时间后再发脉冲void step_move(int32_t steps, uint8_t dir_pin_state) { // 先切换方向再等待方向稳定 HAL_GPIO_WritePin(DIR_GPIO_Port, DIR_Pin, dir_pin_state); delay_us(10); // 实际值取决于驱动器手册的最小方向建立时间 uint32_t abs_steps (steps 0) ? (uint32_t)(-steps) : (uint32_t)steps; pulse_emit(htim1, htim2, abs_steps); }这里的延时使用定时器实现的微秒级延时不要用空循环做粗略延时否则不同编译器优化等级下时间差异很大。方向信号的建立时间宁多勿少10 μs 对绝大多数步进驱动器都足够。4.3 高频边界最后的脉冲是完整的吗主从定时器方案在比较中断里关闭主定时器关闭时刻和逻辑分析仪看到的最后一个上升沿之间相差一个中断响应时间。芯片的中断响应通常在几百纳秒到一微秒量级。对 100 kHz 脉冲一个周期 10 μs中断来得及在高电平期间关断最后一个脉冲的上升沿已经发出停止后引脚保持高电平步进驱动器按上升沿计数位置是准的。但如果应用是双脉冲测试里的门极驱动或者绝缘栅器件测试要求每个脉冲都是完整的“低-高-低”波形这种“上升沿触发后停在半高”的做法就不满足要求。此时有两条路第一把停止动作延后到下一个下降沿。在比较中断里不立即关闭定时器而是等上一次更新事件对应的下降沿之后再关。由于比较中断和上升沿同步CPU 在中断里需要等待若干个计数周期再关闭也可以直接把关闭操作放到更新中断里RCR 配置为 1让更新事件发生时脉冲已经完整走完。第二改用比较输出翻转模式。把引脚配置为 OC Toggle每次更新事件翻转一次电平配合另一个定时器计数翻转次数偶数次翻转结束波形天然完整。这个方案适合对波形完整性要求极高的场景但配置复杂度和中断频率都比 PWM 模式高通常只在测试类设备里使用。5. 用逻辑分析仪验证脉冲个数并把停止误差校准进 1 个脉冲以内写完代码先别接电机用逻辑分析仪抓波形验证个数和时序这一步能省下大量现场排查时间。5.1 统计脉冲个数的两个陷阱逻辑分析仪的采样率至少要达到脉冲频率的 8 倍100 kHz 脉冲建议开到 1 MHz 以上采样率。采样率不足时窄脉冲会被漏采统计出来的个数比实际少。另一个陷阱是毛刺被误计为上升沿。如果布线较长且没有串电阻脉冲边沿的振铃可能产生额外沿逻辑分析仪会把一个脉冲数成两个。抓波形时打开数字滤波脉宽小于半个周期的信号直接忽略。悬空输入也会导致误计数。没有连接的通道不要打开否则工频干扰会变成一串随机脉冲。对照地线尽量短用测试夹直接夹在脉冲输出电阻的后端也就是驱动器端子那侧。5.2 停止延迟的实测与补偿即使采用比较中断关断最后那个上升沿和 CPU 实际关闭 PWM 之间仍有一个固定延迟。验证方法是发 1000 个脉冲用逻辑分析仪数上升沿如果每一次都是 999说明关闭时刻比第 1000 个上升沿早了一个周期多一点如果每次都是 1000说明延迟恰好落在第 1000 个脉冲的高电平阶段这通常就是可接受的结果。如果误差是固定的可以在pulse_emit()的目标值上做一次修正。需要先测出从“第 count 个上升沿”到“PWM 引脚变成静态电平”的时间差再折算成周期数。多数情况下这个差不到一个周期修正方式是把目标值加 1。如果延迟超过一个周期就需要降低中断里保存现场的开销或者把关闭动作从软件中断改成硬件门控。5.3 进阶在加减速过程中依然保持脉冲个数精确步进电机高速启动容易丢步实际工程里必须做加减速。把加减速和精确脉冲个数结合起来最合理的结构是把速度曲线提前换算成每个脉冲的间隔时间存放在数组里然后让 DMA 在定时器更新事件驱动下逐个写入 ARR。这种结构下加减速改变的只是脉冲之间的时间间隔脉冲个数依然由主从定时器里的比较中断掌控。速度曲线数组耗尽时脉冲周期回到巡航速度直到从定时器计数到达目标值整个运动过程既完成了速度规划也保证了最后一步不多不少。这也是我目前在多轴运动控制里最常用的做法。本文还有配套的精品资源点击获取