简介:这份资源面向STM32嵌入式开发者与运动控制方向的学习者,聚焦F103与F407两款芯片的定时器主从模式,解决步进电机精准脉冲个数输出的实际问题。内容基于HAL/LL库函数实现,涵盖主从定时器协同、TRGO触发、预分频与比较寄存器配置等关键环节,适合具备一定定时器基础、希望深入掌握PWM同步输出与电机精确定位的中级开发者。压缩包共264个文件,以c与h源码为主,辅以工程编译产物如o、d、crf、axf、hex及Keil工程文件uvprojx、uvoptx,整体约1.85MB,结构完整可直接编译验证。目前已有8229人学习下载,热度较高。读者可从中获得F103与F407两套独立示例工程,对照理解主从定时器同步机制与脉冲计数控制逻辑,并借助map、lst等文件分析资源占用与汇编细节,为步进电机驱动与自动化项目提供可复用的参考方案。
1. 定时器主从模式:让STM32输出精确到个数的脉冲
做步进电机控制的朋友大概率遇到过这个场景:想让电机走一个精确的角度,比如丝杆滑台移动 50mm,算下来需要发 2000 个脉冲,一个不多一个不少。用软件循环翻转 IO 吧,中断一多就丢脉冲;用 PWM 连续输出吧,又不知道什么时候该停。STM32 的定时器主从模式就是专门解决这个问题的——一个定时器负责输出 PWM 波形,另一个定时器负责数脉冲个数,数够了自动关断。这套方案在 F103 和 F407 上都适用,前者是经典入门款,后者主频高、定时器资源更丰富。本文基于一份 F103/F407 双平台的工程源码,把主从模式的配置流程、寄存器逻辑和实际调试中踩过的坑拆开讲清楚,适合正在做步进电机、伺服驱动器或者任何需要定量脉冲输出的开发者。
2. 主从模式架构:谁当主、谁当从、门控怎么走
2.1 主从定时器的角色划分
STM32 的定时器主从模式,本质上是把两个(或多个)定时器通过内部触发线 ITR 连接起来,让一个定时器的输出事件去控制另一个定时器的启停或时钟。在这个项目里,角色划分很明确:
- 主定时器(Master):负责输出 PWM 波形,同时它的更新事件或比较事件作为触发输出(TRGO),送给从定时器。
- 从定时器(Slave):工作在从模式,接收主定时器的触发信号,对脉冲进行计数。当计数值达到预设的脉冲个数时,产生中断,在中断里关闭主定时器的 PWM 输出。
以 F103 为例,常见搭配是 TIM1 做主定时器输出 PWM,TIM2 或 TIM3 做从定时器计数。F407 上因为定时器更多,可以用 TIM8 做主、TIM3 做从,或者反过来,具体看引脚分配和通道需求。
这里有个容易混淆的点:主从模式里的“主”和“从”是触发关系,不是主频高低的关系。从定时器完全可以用一个更高频率的时钟来计数,只要触发信号接对了就行。
2.2 内部触发连接与 TRGO 选择
主定时器要把自己的某个事件“广播”出去,靠的是 TRGO 配置。在 HAL 库里对应的是HAL_TIMEx_MasterConfigSynchronization()函数,关键参数是MasterOutputTrigger。可选的事件源包括:
| TRGO 源 | 含义 | 适用场景 |
|---|---|---|
| RESET | 更新事件触发 | 每次溢出都发一个触发 |
| ENABLE | 使能时触发 | 只在启动时发一次 |
| UPDATE | 更新事件 | 最常用,配合 PWM 模式 |
| COMPARE_PULSE | 比较脉冲 | OC1REF 作为触发 |
| COMPARE_OC1REF | OC1 比较 | 需要精确对齐时用 |
| COMPARE_OC2REF | OC2 比较 | 同上,通道 2 |
| COMPARE_OC3REF | OC3 比较 | 同上,通道 3 |
| COMPARE_OC4REF | OC4 比较 | 同上,通道 4 |
这个项目里,主定时器配置为 PWM 模式,TRGO 选TIM_TRGO_UPDATE,也就是每次计数器溢出(一个 PWM 周期结束)时发出一个触发脉冲。从定时器收到这个脉冲,计数值加一。
从定时器的触发输入选择,对应的是TIM_SlaveConfigTypeDef结构体里的InputTrigger字段。F103 的 TIM2 可以选 ITR0 到 ITR3,分别对应不同的内部触发源。具体哪个 ITR 对应哪个定时器,必须查参考手册的“TIMx 内部触发连接”表,这个不能猜。
2.3 从定时器的门控模式与计数逻辑
从定时器的工作模式选的是“门控模式”(Gated Mode)还是“触发模式”(Trigger Mode),直接决定了计数行为。这个项目用的是门控模式,从定时器的计数器只在主定时器输出高电平(或有效电平)时才计数。但更常见的做法是外部时钟模式——把主定时器的 TRGO 当作从定时器的时钟源,每来一个触发脉冲,从定时器计数值加一。
在 HAL 库里配置从定时器:
TIM_SlaveConfigTypeDef sSlaveConfig = {0}; sSlaveConfig.SlaveMode = TIM_SLAVEMODE_EXTERNAL1; // 外部时钟模式1 sSlaveConfig.InputTrigger = TIM_TS_ITR0; // 触发源选ITR0 sSlaveConfig.TriggerPolarity = TIM_TRIGGERPOLARITY_RISING; sSlaveConfig.TriggerPrescaler = TIM_TRIGGERPRESCALER_DIV1; sSlaveConfig.TriggerFilter = 0; HAL_TIM_SlaveConfigSynchro(&htim2, &sSlaveConfig);SlaveMode选TIM_SLAVEMODE_EXTERNAL1表示从定时器把选定的触发输入当作外部时钟来用。InputTrigger选TIM_TS_ITR0,在 F103 上 TIM2 的 ITR0 对应的是 TIM1 的 TRGO。TriggerPolarity选上升沿,因为主定时器的更新事件在计数器溢出时产生,对应一个上升沿脉冲。
从定时器的自动重装载值(ARR)设成需要的脉冲个数减一。比如要发 2000 个脉冲,ARR 就设 1999。当从定时器计数到 ARR 时产生更新中断,在中断回调里关掉主定时器的 PWM 输出。
注意:从定时器的 CNT 寄存器在外部时钟模式下,每个触发脉冲加一,不是每个系统时钟加一。所以从定时器的预分频器(PSC)要设成 0,否则会漏计脉冲。
3. 工程落地:从 CubeMX 配置到中断回调的完整链路
3.1 时钟树与定时器参数计算
F103 和 F407 的时钟树差异较大,配置前先确认总线频率。F103 通常跑 72MHz,APB1 定时器时钟 72MHz,APB2 定时器时钟也是 72MHz。F407 跑 168MHz 时,APB1 定时器时钟 84MHz,APB2 定时器时钟 168MHz。
主定时器输出 PWM 的频率和占空比由 PSC 和 ARR 决定:
// 主定时器 TIM1 配置:输出 10kHz PWM,占空比 50% htim1.Instance = TIM1; htim1.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz 计数频率 htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.Period = 100 - 1; // 1MHz / 100 = 10kHz PWM 频率 htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 0; htim1.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;PSC 设 71,计数频率就是 1MHz。ARR 设 99,PWM 周期就是 100 个计数,频率 10kHz。占空比通过比较寄存器 CCR 设置,比如 CCR 设 50,占空比就是 50%。
从定时器的 ARR 决定脉冲个数:
// 从定时器 TIM2 配置:计数 2000 个脉冲 htim2.Instance = TIM2; htim2.Init.Prescaler = 0; // 不分频,每个触发脉冲计一次 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 2000 - 1; // 2000 个脉冲后溢出 htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;这里AutoReloadPreload建议关掉,因为从定时器的 ARR 在运行中不需要修改,关掉可以减少一个影子寄存器的延迟。
3.2 主从联动的启动顺序
启动顺序很关键,搞反了会丢脉冲。正确的流程是:
- 先启动从定时器,让它进入等待触发状态。
- 再启动主定时器,开始输出 PWM。
- 主定时器第一个更新事件到来时,从定时器开始计数。
代码里这样写:
// 先启动从定时器,开启中断 HAL_TIM_Base_Start_IT(&htim2); // 再启动主定时器 PWM 输出 HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);如果反过来先启动主定时器,从定时器还没准备好,前几个脉冲就丢了。这个坑我在第一次调试时踩过,电机实际走的步数比预期少了几步,查了半天才发现是启动顺序的问题。
3.3 中断回调里关断 PWM
从定时器计数到 ARR 时触发更新中断,在回调函数里关掉主定时器的 PWM 输出:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { // 从定时器计数完成,关断主定时器 PWM HAL_TIM_PWM_Stop(&htim1, TIM_CHANNEL_1); // 可选:关闭从定时器,等待下次启动 HAL_TIM_Base_Stop_IT(&htim2); // 置标志位,通知主循环脉冲发送完成 g_pulse_done_flag = 1; } }HAL_TIM_PWM_Stop()会关闭主定时器的 PWM 输出,同时停止计数器。HAL_TIM_Base_Stop_IT()关闭从定时器的中断。置一个全局标志位,主循环里检测到这个标志就可以执行下一步动作,比如改变电机方向、发送下一组脉冲。
注意:在中断里调用
HAL_TIM_PWM_Stop()会关闭定时器,如果下次还要发脉冲,需要重新调用HAL_TIM_PWM_Start()。如果频繁启停,建议用__HAL_TIM_SET_COUNTER()复位计数器,而不是完全关闭定时器。
4. 避坑与排查:主从模式调试中的五个血泪教训
4.1 脉冲个数总是少几个
现象:设了 2000 个脉冲,实际只发了 1997 个左右,每次少的数量还不固定。
原因:启动顺序不对。先启动了主定时器,从定时器还没进入等待状态,前几个 PWM 周期没有被计数。另外,如果从定时器的中断优先级低于其他中断,中断响应延迟也可能导致最后一个脉冲被漏掉。
解决:严格按“先从后主”的顺序启动。从定时器的中断优先级设到最高,或者至少高于可能打断它的其他中断。如果还是少,可以在关断 PWM 后读一下从定时器的 CNT 寄存器,确认实际计数值。
4.2 从定时器完全不计数
现象:主定时器 PWM 正常输出,但从定时器的 CNT 一直是 0。
原因:ITR 选错了。F103 和 F407 的内部触发连接表不一样,同一个 ITR 编号对应的主定时器不同。比如 F103 的 TIM2_ITR0 对应 TIM1,但 F407 的 TIM2_ITR0 对应 TIM1,TIM3_ITR0 对应 TIM2。选错了就收不到触发信号。
解决:查对应芯片参考手册的“TIMx 内部触发连接”表,确认 ITR 编号。用示波器测主定时器 TRGO 对应的引脚(如果有引出的话),或者直接在调试器里看从定时器的 CNT 寄存器有没有变化。
4.3 PWM 频率不对
现象:设了 10kHz,实际测出来是 5kHz 或者 20kHz。
原因:时钟树配置错了。F103 和 F407 的 APB1、APB2 定时器时钟倍频关系不同。F103 的 APB1 定时器时钟在 APB1 预分频不为 1 时是 APB1 时钟的 2 倍。F407 同理,但总线频率不同。
解决:在 CubeMX 里确认时钟树,看定时器时钟输入频率是多少。然后按PWM频率 = 定时器时钟 / ((PSC+1) * (ARR+1))重新计算 PSC 和 ARR。
4.4 中断里关断 PWM 后电机还在走
现象:中断回调里调用了HAL_TIM_PWM_Stop(),但电机还多走了几步才停。
原因:HAL_TIM_PWM_Stop()关闭的是 PWM 输出使能位,但定时器计数器可能还在跑,当前 PWM 周期可能还没结束。另外,步进电机驱动器本身有响应延迟,收到停止信号后还会走几个脉冲。
解决:在关断 PWM 后,把主定时器的计数器也复位,确保当前周期立即结束。对于电机驱动器的延迟,可以在软件里提前几个脉冲关断,或者选带使能端的驱动器,直接拉低使能信号。
4.5 F407 上从定时器计数频率上不去
现象:F103 上跑得好好的配置,移植到 F407 上从定时器计数频率明显偏低。
原因:F407 的定时器时钟频率更高,从定时器在外部时钟模式下,触发信号的频率不能超过从定时器时钟频率的一半。如果主定时器 PWM 频率太高,从定时器可能来不及响应。
解决:降低主定时器 PWM 频率,或者换一个时钟频率更高的从定时器。F407 的 TIM2 和 TIM5 是 32 位定时器,时钟频率也高,适合做高速计数。
5. 二次开发:把主从模式封装成可复用的脉冲输出模块
5.1 封装思路与接口设计
原始工程里主从模式的配置散落在 main.c 和回调函数里,改一个参数要翻好几个地方。二次开发的第一步就是把它封装成一个独立的模块,对外只暴露几个简单的接口。
我一般会这样设计:
// pulse_gen.h typedef struct { TIM_HandleTypeDef *master_tim; // 主定时器句柄 uint32_t master_channel; // 主定时器通道 TIM_HandleTypeDef *slave_tim; // 从定时器句柄 uint32_t pulse_count; // 当前设定的脉冲个数 volatile uint8_t is_busy; // 忙标志 volatile uint8_t is_done; // 完成标志 } PulseGen_HandleTypeDef; // 初始化主从定时器联动 void PulseGen_Init(PulseGen_HandleTypeDef *hpg, TIM_HandleTypeDef *master, uint32_t ch, TIM_HandleTypeDef *slave); // 发送指定个数的脉冲,非阻塞 void PulseGen_Start(PulseGen_HandleTypeDef *hpg, uint32_t count); // 查询是否发送完成 uint8_t PulseGen_IsDone(PulseGen_HandleTypeDef *hpg); // 紧急停止 void PulseGen_Stop(PulseGen_HandleTypeDef *hpg);PulseGen_Start()里做三件事:设置从定时器的 ARR 为count - 1,复位从定时器 CNT,按“先从后主”的顺序启动。PulseGen_IsDone()返回完成标志,主循环轮询或者用回调通知。
5.2 动态修改脉冲个数的技巧
有些场景需要在运行中动态调整脉冲个数,比如根据传感器反馈实时修正。直接改 ARR 寄存器有风险,因为从定时器可能正在计数。安全的做法是:
void PulseGen_UpdateCount(PulseGen_HandleTypeDef *hpg, uint32_t new_count) { // 只在空闲时允许修改 if (hpg->is_busy) { return; } // 关闭从定时器,修改 ARR,重新使能 __HAL_TIM_DISABLE(hpg->slave_tim); __HAL_TIM_SET_AUTORELOAD(hpg->slave_tim, new_count - 1); __HAL_TIM_SET_COUNTER(hpg->slave_tim, 0); __HAL_TIM_ENABLE(hpg->slave_tim); hpg->pulse_count = new_count; }__HAL_TIM_DISABLE()和__HAL_TIM_ENABLE()是寄存器级操作,比 HAL 库的HAL_TIM_Base_Stop()和HAL_TIM_Base_Start()更轻量,不会触发多余的回调。
5.3 用 DMA 突发模式进一步降低 CPU 占用
如果脉冲个数很多(比如几万个),每次都在中断里处理会增加 CPU 负担。F407 的定时器支持 DMA 突发模式,可以把从定时器的更新事件映射到 DMA 请求,每计满一定个数才触发一次中断。
配置方法是在从定时器的 DMA/中断使能寄存器里打开更新 DMA 请求,然后配置 DMA 通道,设置传输长度。这样 CPU 只在 DMA 传输完成时才介入,中间过程完全不占 CPU。
这个技巧在需要连续发送大量脉冲的场景下特别有用,比如多轴联动的步进电机控制。不过 F103 的 DMA 资源有限,用之前先确认 DMA 通道有没有被其他外设占用。
5.4 验证方法:用逻辑分析仪抓波形
改完代码怎么验证?最直接的办法是用逻辑分析仪抓主定时器 PWM 输出引脚和从定时器的 CNT 寄存器(通过调试器实时查看)。逻辑分析仪上数一下实际脉冲个数,和设定值对比。
如果没有逻辑分析仪,可以用另一个定时器做输入捕获,测量脉冲个数和频率。或者简单点,在中断回调里翻转一个 IO,用示波器看这个 IO 的翻转次数。
我自己的习惯是:每次改完主从模式的配置,先用示波器确认 PWM 频率和占空比正确,再用调试器单步跟一遍启动流程,确认从定时器的 CNT 在第一个触发脉冲后变成 1。这两步过了,基本就不会有大问题。从那以后我每次调定时器主从模式,都强制走一遍“先示波器后调试器”的流程,省得后面返工。希望帮到你。
本文还有配套的精品资源,点击获取