简介:面向嵌入式开发者与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 位,最大 255;F4 系列大多为 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: 255,F4: 65535 | 取决于定时器时钟 | 中断后执行关断指令,数微秒级 | 低 | 中短脉冲串、周期性任务 |
| 主从定时器 | 从定时器外部时钟计数 | 32 位计数器可达 42.9 亿 | 取决于主定时器时钟 | 比较中断关断,可优化为硬件门控 | 极低 | 步进/伺服定位、大批量定数输出 |
| DMA 动态更新 ARR/CCR | DMA 按更新事件逐次装载 | 受内存和缓冲管理限制 | 可逐脉冲变化 | 可叠加主从方案 | 低 | S 型加减速、激光振镜扫描 |
3. 用 C 语言在 STM32 上实现主从定时器精确脉冲模块
下面这套代码的思路在 STM32F103C8T6 和 HAL 库上验证过,使用 TIM1 作为 PWM 主定时器,TIM2 作为外部时钟从计数器。整个模块对外只暴露一个函数pulse_emit(),传入目标脉冲数,发出指定数量的脉冲后自动停止。
3.1 CubeMX 配置主从定时器的关键参数
CubeMX 里 TIM1 和 TIM2 的配置直接决定脉冲频率和计数方式,参数如下表:
| 配置项 | 值 | 说明 |
|---|---|---|
| TIM1 时钟源 | 内部时钟,72 MHz | F103 的 APB2 定时器时钟 |
| TIM1 Prescaler | 71 | 72 MHz / 72 = 1 MHz 计数频率 |
| TIM1 Period | 9 | 每 10 个计数一个周期,输出 100 kHz |
| TIM1 Pulse | 4 | 占空比 50%,PWM 模式 1 |
| TIM1 Trigger Output | OC1REF | 关键:从定时器按 OC1REF 的上升沿计数 |
| TIM2 Clock Source | External Clock Mode 1 | 从外部触发输入 ITR0 获取时钟 |
| TIM2 Trigger Source | ITR0 | 对应 TIM1 的 TRGO,无需外部接线 |
| TIM2 Prescaler | 0 | 每个上升沿计数一次 |
| TIM2 Period | 0xFFFFFFFF | 32 位计数器,不产生溢出 |
| TIM2 Channel 1 | Output Compare,TIMING 模式 | 只用来产生比较事件,不占用引脚 |
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 = 0,ARR = 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。
这种结构下,加减速改变的只是脉冲之间的时间间隔,脉冲个数依然由主从定时器里的比较中断掌控。速度曲线数组耗尽时,脉冲周期回到巡航速度,直到从定时器计数到达目标值,整个运动过程既完成了速度规划,也保证了最后一步不多不少。这也是我目前在多轴运动控制里最常用的做法。
本文还有配套的精品资源,点击获取