STM32定时器RCR与单脉冲模式实现步进电机精确脉冲控制
1. 项目背景与核心需求
最近在做一个桌面小装置,需要控制一个42步进电机精确旋转固定的角度,比如让一个转盘每次转动90度。手头正好有块STM32F103的开发板,第一反应就是用PWM输出脉冲来驱动步进电机驱动器。听起来很简单对吧?不就是配置个定时器输出PWM嘛。但实际一上手就发现,标准库或者HAL库里那些HAL_TIM_PWM_Start函数,一启动就是连续输出,根本停不下来。我需要的是输出固定数量的脉冲,比如7200个脉冲后自动停止,这样电机才能停在精确的位置上。
这个问题困扰了不少从Arduino转向STM32的开发者。在Arduino里,有现成的Stepper库或者AccelStepper库,设置好目标步数就行。但在STM32的HAL库环境下,你会发现它并没有直接提供一个“发送N个脉冲然后停止”的API。网上的资料要么讲得很散,要么直接上高级的定时器编码器模式或者用中断手动翻转IO,对于刚接触CubeMX和HAL库的朋友来说,理解成本有点高。
其实,STM32的定时器功能非常强大,完全可以通过其内置的“重复计数器”和“单脉冲模式”来实现这个需求,而且几乎不占用CPU资源。这次我就以最常用的STM32F103C8T6为例,使用CubeMX进行图形化配置,手把手带你实现用PWM输出固定脉冲数来控制步进电机。这个方法稳定可靠,是很多实际产品中的基础方案。
2. 硬件连接与核心原理剖析
在写代码之前,得先搞清楚硬件怎么接,以及STM32定时器是如何“数”脉冲的。
2.1 硬件连接方案
我使用的电机是常见的42步进电机(型号如42BYGH),驱动芯片是TB6600。这是一种非常经典的组合。连接方式如下:
- STM32 GPIO->TB6600 PUL+ (脉冲信号输入):这是本次配置的核心,我们将用一个定时器的PWM输出通道连接到这个引脚。
- STM32 GND->TB6600 PUL- (脉冲信号地):一定要共地,确保信号基准一致。
- TB6600->42步进电机:驱动器的A+, A-, B+, B-连接到电机的四根线。细分、电流等参数根据电机铭牌在驱动器上设置好,例如我设置为16细分,电流1.5A。
- 电源:为TB6600提供合适的直流电源(例如24V/36V),注意电机和驱动器的功率匹配。
这里的关键是PUL脉冲信号。TB6600每接收到一个脉冲的上升沿(或下降沿,可设置),就会驱动电机转动一个细分步。所以,控制发送脉冲的数量,就等于控制了电机转动的角度。
2.2 定时器如何输出“有限个”PWM脉冲?
STM32的通用定时器(如TIM1, TIM2, TIM3, TIM4)有一个叫做**重复计数器(Repetition Counter, RCR)**的寄存器。这是实现有限脉冲输出的关键。
普通PWM输出可以理解为:定时器根据你设置的频率(ARR寄存器)和占空比(CCR寄存器),在通道引脚上循环输出高低电平。这个循环是无限的。
而重复计数器的作用是:让这个“输出循环”只执行(RCR+1)次。当计数器从0向上计数到ARR值,算一个周期(输出一个完整的PWM脉冲)。重复计数器会记录这个周期数,当达到了设定的次数后,定时器便自动停止,或者产生一个更新事件(Update Event)后停止。
举个例子:
- 假设ARR设置为999,那么PWM频率 = 定时器时钟 / (999 + 1)。
- 设置RCR = 7199。那么定时器会输出 (7199 + 1) = 7200 个完整的PWM周期后停止。
- 这就正好输出了我们需要的7200个脉冲。
另一个需要搭配的功能是单脉冲模式(One Pulse Mode, OPM)。当使能OPM后,定时器在发生一次更新事件(比如RCR计数满)后,会自动停止计数,而不会重新开始。这确保了我们的脉冲序列只发送一轮,非常干净利落。
所以,我们的方案核心就是:利用定时器的PWM输出模式,配合重复计数器(RCR)设定脉冲总数,并启用单脉冲模式(OPM)。配置好后,只需要启动一次定时器,它就会自动发送指定数量的脉冲然后停下。
3. CubeMX工程配置详解
我们打开STM32CubeMX,新建一个工程,选择你的芯片型号(我的是STM32F103C8T6)。
3.1 时钟树配置
首先配置系统时钟。在RCC设置中,将HSE(外部高速时钟)设置为“Crystal/Ceramic Resonator”。然后进入Clock Configuration标签页。 对于STM32F103,一个常见的配置是:HSE输入8MHz,通过PLL倍频到72MHz作为系统时钟(SYSCLK)。APB1总线时钟(定时器时钟主要来源)最高36MHz,APB2总线时钟最高72MHz。确保你的定时器挂载在APB2上(如TIM1),这样可以得到72MHz的时钟源,提供更高的PWM频率分辨率。
3.2 定时器与PWM输出配置
假设我们使用TIM1的通道1(对应PA8引脚)来输出PWM脉冲。在Pinout视图找到PA8,将其功能设置为“TIM1_CH1”。
- 打开TIM1配置:在左侧Categories中找到“TIM1”,进入其配置。
- 选择时钟源:在“Clock Source”中选择“Internal Clock”。
- 参数配置:切换到“Parameter Settings”标签页。
- Prescaler (分频系数): 设置为
71。计算逻辑:定时器时钟为72MHz,分频后得到72MHz / (71+1) = 1MHz的计数频率。这样计数器每微秒计数一次,方便计算。 - Counter Mode (计数模式): “Up” (向上计数)。
- Counter Period (自动重载值 ARR): 设置为
999。这意味着每个PWM周期有1000个计数点 (0~999)。结合上面的分频,PWM频率 = 1MHz / 1000 =1kHz。这个频率对于大多数步进电机驱动器(如TB6600, 接收脉冲最高可达200kHz)来说非常安全且常用。 - Auto-reload preload (自动重载预装载): 务必选择“Enable”。这能确保你对ARR、RCR的修改在下次更新事件时才生效,避免产生中间状态的错误脉冲。
- Prescaler (分频系数): 设置为
- 重复计数器配置:
- Repetition Counter (RCR): 这是我们控制脉冲总数的关键。我们先填一个值,比如
99。这代表会输出99+1=100个脉冲。这个值我们后续在代码中动态修改。注意:这个选项可能被折叠,需要点击“Parameter Settings”旁边的“Show Advanced Parameters”才会出现。
- Repetition Counter (RCR): 这是我们控制脉冲总数的关键。我们先填一个值,比如
- PWM输出通道配置:
- 在下方找到“Channel 1”,模式选择“PWM Generation CH1”。
- Pulse (占空比 CCR1): 设置为
500。占空比 = 500 / 1000 = 50%。对于步进驱动器,通常需要20%-50%的占空比方波,50%是一个很稳妥的值。 - CH Polarity (通道极性): 保持“High”,即有效电平为高。这表示在计数值小于CCR1时,输出高电平。
- 关键模式使能:
- One Pulse Mode (单脉冲模式): 在“Master/Slave Mode”或者“Trigger Options”附近,找到“One Pulse Mode”,将其设置为 “Enable”。(在CubeMX的不同版本中,这个选项的位置可能略有不同,仔细找一下)。
- 刹车和死区: 对于TIM1这种高级定时器,如果不需要刹车功能,在“Break and Dead Time”配置中,将“Break Function”设为“Disable”。
配置完成后,你可以在右侧的引脚图上看到PA8已经被标记为TIM1_CH1。生成的初始化代码会自动将这个引脚配置为复用推挽输出。
3.3 生成工程代码
点击“Project Manager”,设置好工程名称、路径、IDE(MDK-ARM V5)。在“Code Generator”中,建议选择“Copy only necessary library files”以保持工程简洁,并勾选“Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral”,这样定时器的代码会独立成文件,结构清晰。
最后点击“GENERATE CODE”。第一次生成会询问是否打开工程,选择打开即可进入Keil MDK。
4. 代码实现与逻辑解析
打开工程后,我们主要关注两个文件:tim.c和main.c。
4.1 生成的定时器初始化代码分析
在tim.c中,找到MX_TIM1_Init函数。CubeMX已经帮我们生成了完整的配置,包括时钟使能、时基单元设置、输出比较通道配置和单脉冲模式使能。你需要检查一下关键参数是否和我们设定的一致:
htim1.Instance = TIM1; htim1.Init.Prescaler = 71; htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.Period = 999; // ARR htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 99; // RCR,初始值 htim1.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;在通道配置部分,可以看到占空比设置:
sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 500; // CCR1 sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCNPolarity = TIM_OCNPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; sConfigOC.OCIdleState = TIM_OCIDLESTATE_RESET; sConfigOC.OCNIdleState = TIM_OCNIDLESTATE_RESET;以及单脉冲模式的使能:
if (HAL_TIM_OnePulse_Init(&htim1, TIM_OPMODE_SINGLE) != HAL_OK) { Error_Handler(); }这里的TIM_OPMODE_SINGLE是单脉冲模式。注意,这个函数HAL_TIM_OnePulse_Init内部也会调用HAL_TIM_PWM_Init,所以PWM通道的初始化也包含在内了。
4.2 主程序逻辑与控制函数
现在来到main.c。我们首先需要定义一个函数,用来设置脉冲数量并启动定时器。
步骤一:编写脉冲发送函数
在/* USER CODE BEGIN 4 */区域添加以下函数:
/** * @brief 发送指定数量的PWM脉冲 * @param htim: 定时器句柄指针 * @param pulses: 需要发送的脉冲数量 * @retval HAL状态 */ HAL_StatusTypeDef PWM_Start_Pulses(TIM_HandleTypeDef *htim, uint32_t pulses) { /* 1. 检查参数有效性 */ if (pulses == 0 || pulses > 0xFFFF) { // RCR是16位寄存器,最大值65535 return HAL_ERROR; } /* 2. 停止定时器(确保从停止状态开始) */ HAL_TIM_PWM_Stop(htim, TIM_CHANNEL_1); /* 3. 设置重复计数器值:RCR = pulses - 1 */ __HAL_TIM_SET_REPETITIONCOUNTER(htim, pulses - 1); /* 4. 清除可能存在的更新中断标志 */ __HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_UPDATE); /* 5. 启动PWM输出(单脉冲模式已使能,输出完指定脉冲数后会自动停止) */ return HAL_TIM_PWM_Start(htim, TIM_CHANNEL_1); }代码逻辑解析:
- 参数检查:RCR寄存器是16位的,所以理论最大脉冲数是65536。实际使用中,脉冲数受限于电机和驱动器的性能,通常几千到几万。
- 停止定时器:这是一个好习惯。确保定时器处于停止状态,再修改其参数,防止产生混乱的输出。
- 设置RCR:这是核心。我们使用HAL库的宏
__HAL_TIM_SET_REPETITIONCOUNTER来设置RCR值。因为定时器输出(RCR+1)个脉冲,所以我们要传入pulses - 1。 - 清除更新标志:在启动前清除更新中断标志位,避免一启动就误触发更新事件。
- 启动PWM:调用
HAL_TIM_PWM_Start。由于我们在CubeMX中使能了单脉冲模式,定时器会在输出完(RCR+1)个脉冲后自动停止,无需我们手动干预。
步骤二:在主循环中调用
在main函数的while (1)循环中,我们可以这样测试:
/* USER CODE BEGIN 2 */ // 发送7200个脉冲,电机旋转一圈(假设驱动器设置为16细分,电机为200步/圈,则7200脉冲 = 7200/16/200 = 2.25圈) if (PWM_Start_Pulses(&htim1, 7200) != HAL_OK) { // 错误处理,例如点亮一个LED HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); } // 等待脉冲发送完成。可以通过查询定时器使能状态,或者使用延时。 // 方法1:简单延时。计算发送7200个1kHz脉冲所需时间:7200 / 1000Hz = 7.2秒 HAL_Delay(8000); // 给予充足余量 // 方法2:查询定时器状态(更精确) // while (__HAL_TIM_GET_FLAG(&htim1, TIM_FLAG_UPDATE) == RESET) {} // 等待更新标志置位 // __HAL_TIM_CLEAR_FLAG(&htim1, TIM_FLAG_UPDATE); // 然后可以发送下一组脉冲... // PWM_Start_Pulses(&htim1, 3600); // 旋转半圈 /* USER CODE END 2 */ while (1) { /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ // 主循环可以处理其他任务,比如按键扫描、通信等 // 脉冲发送是硬件自动完成的,不阻塞CPU。 }4.3 使用中断获取发送完成状态
上面的例子用了延时或轮询标志位来等待发送完成,这在实际项目中可能不够高效。更好的方法是使用更新中断(Update Interrupt)。当重复计数器溢出(即脉冲发送完毕)时,会产生一个更新事件,如果中断使能,就会进入中断服务函数。
配置步骤:
- CubeMX中使能中断:回到TIM1配置,在“NVIC Settings”中,勾选“TIM1 update interrupt”并设置合适的优先级。
- 重新生成代码:CubeMX会在
tim.c中生成中断使能代码HAL_TIM_Base_Start_IT(&htim1),但我们需要的是PWM模式下的更新中断。更简单的做法是,我们自己在代码中启动中断。 - 修改启动函数:
HAL_StatusTypeDef PWM_Start_Pulses_IT(TIM_HandleTypeDef *htim, uint32_t pulses) { if (pulses == 0 || pulses > 0xFFFF) { return HAL_ERROR; } HAL_TIM_PWM_Stop(htim, TIM_CHANNEL_1); __HAL_TIM_SET_REPETITIONCOUNTER(htim, pulses - 1); __HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_UPDATE); // 先开启更新中断,再启动PWM __HAL_TIM_ENABLE_IT(htim, TIM_IT_UPDATE); return HAL_TIM_PWM_Start(htim, TIM_CHANNEL_1); }- 编写中断回调函数:在
stm32f1xx_it.c中找到TIM1_UP_IRQHandler函数,它内部会调用HAL_TIM_IRQHandler。HAL库的中断处理逻辑最终会调用一个弱定义的回调函数。我们在main.c中重写这个回调函数:
/* USER CODE BEGIN 4 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { // 脉冲发送完成! __HAL_TIM_DISABLE_IT(htim, TIM_IT_UPDATE); // 关闭更新中断,防止重复进入 // 可以设置一个标志位,通知主程序或执行下一步动作 // 例如:motor_pulses_done = 1; HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); // 翻转LED指示 } } /* USER CODE END 4 */这样,主程序调用PWM_Start_Pulses_IT(&htim1, 7200)后,就可以立即去执行其他任务。7.2秒后,中断函数会自动被调用,我们在中断里处理完成后的动作(如切换任务状态、点亮指示灯等),效率高得多。
5. 实测中的关键问题与调试技巧
理论配置完成,下载程序到板子,用示波器或者逻辑分析仪探头连接到PA8引脚,你可能会遇到一些意想不到的情况。
5.1 第一个脉冲宽度异常问题
现象:使用单脉冲模式(OPM)时,你可能会发现第一个PWM脉冲的宽度(高电平时间)异常,不是设定的50%(500个计数点),而是一个很短或很长的脉冲。
根因分析:这个问题非常经典。当定时器从停止状态启动时,计数器CNT从0开始计数。但输出比较通道的生效需要时间,并且和计数器当前值、比较寄存器CCR的值关系复杂。在单脉冲模式下,定时器硬件为了确保只输出一个“脉冲包”(包含RCR+1个周期),对第一个周期的处理逻辑有些特殊,可能导致第一个脉冲的占空比失真。
解决方案:在启动PWM输出前,将计数器的值手动设置为一个非零值,以避开那个特殊的初始状态。通常设置为Period - 1(即ARR值)是一个有效的方法。
修改我们的启动函数:
HAL_StatusTypeDef PWM_Start_Pulses(TIM_HandleTypeDef *htim, uint32_t pulses) { /* ... 参数检查、停止定时器 ... */ /* 关键修复:设置计数器初始值 */ __HAL_TIM_SET_COUNTER(htim, htim->Init.Period); // 设置为ARR值 __HAL_TIM_SET_REPETITIONCOUNTER(htim, pulses - 1); __HAL_TIM_CLEAR_FLAG(htim, TIM_FLAG_UPDATE); return HAL_TIM_PWM_Start(htim, TIM_CHANNEL_1); }实测表明,这个操作能有效稳定第一个脉冲的宽度,使其与后续脉冲一致。
5.2 脉冲数量总是多一个或少一个?
现象:设置发送100个脉冲,实际用逻辑分析仪计数可能是101个或99个。
排查思路:
- 检查RCR公式:确认是
RCR = pulses - 1。如果你不小心设置成RCR = pulses,就会多输出一个脉冲。 - 检查边沿计数:TB6600驱动器可以设置是上升沿还是下降沿触发。我们的PWM是50%占空比的方波,上升沿和下降沿数量一样。但如果你的占空比不是50%,或者驱动器只对其中一种边沿敏感,就需要确认你数的“脉冲”和驱动器识别的“有效沿”是否一致。通常驱动器默认为上升沿触发。确保你用逻辑分析仪计数的也是上升沿。
- 检查单脉冲模式:确认OPM确实被正确使能。如果没有使能OPM,定时器在RCR计数满后,会产生一个更新事件,然后重新装载RCR和ARR,继续下一轮输出,这就变成了循环输出固定数量的脉冲,而不是只输出一次。这会导致你看到无数组脉冲序列,计数就乱了。
5.3 如何动态改变脉冲频率?
有时我们需要改变电机转速,即改变PWM脉冲的频率。这需要修改定时器的ARR值(因为预分频器PSC通常固定以获得较好的计时分辨率)。
重要原则:在定时器停止时修改ARR或PSC。同时,由于我们使用了自动重载预装载(AutoReloadPreload),修改ARR后,新的值会在下一次更新事件时才生效,这保证了波形切换的平滑性。
可以写一个函数来改变频率:
void PWM_Set_Frequency(TIM_HandleTypeDef *htim, uint32_t freq_hz) { // 假设定时器时钟为72MHz,预分频器PSC已固定为71(得到1MHz计数时钟) uint32_t timer_clock = 72000000 / (71 + 1); // 计算当前计数频率 = 1MHz uint32_t arr_value = (timer_clock / freq_hz) - 1; // 停止定时器 HAL_TIM_PWM_Stop(htim, TIM_CHANNEL_1); // 修改ARR __HAL_TIM_SET_AUTORELOAD(htim, arr_value); // 为了保证占空比不变,可能需要同步修改CCR。假设保持50%占空比: __HAL_TIM_SET_COMPARE(htim, TIM_CHANNEL_1, arr_value / 2); // 重新设置计数器初始值(规避第一个脉冲问题) __HAL_TIM_SET_COUNTER(htim, arr_value); }注意,改变频率后,发送固定脉冲数所需的时间也会变。例如,从1kHz变为2kHz,发送7200个脉冲的时间就从7.2秒缩短到3.6秒。
5.4 没有示波器/逻辑分析仪如何调试?
如果没有硬件调试工具,可以借助一个简单的“软件脉冲计数”方法来验证。在更新中断回调函数里,对一个全局变量进行累加,并通过串口打印出来。
volatile uint32_t pulse_counter = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { pulse_counter++; // 每发送完一组脉冲(RCR+1个),会进入一次中断 // 如果你设置RCR=99,这里pulse_counter加到1,代表100个脉冲发完了。 if(pulse_counter >= 1) { // 根据你的需求判断 __HAL_TIM_DISABLE_IT(htim, TIM_IT_UPDATE); // 通过串口打印信息 printf("Pulses sent completed.\r\n"); pulse_counter = 0; } } }同时,在主函数里启动定时器更新中断(HAL_TIM_Base_Start_IT(&htim1))和PWM输出(HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1)),但不开启单脉冲模式,并将RCR设为一个较小的值(如9)。这样,定时器每输出10个脉冲就会进入一次中断并打印,你可以通过观察串口输出的频率来间接验证脉冲输出是否正常。注意:这只是调试手段,最终产品代码要改回单脉冲模式。
6. 方案对比与进阶思考
6.1 与其他实现方式的对比
- 纯软件模拟(延时翻转IO):在循环里用
HAL_Delay和HAL_GPIO_TogglePin产生脉冲。这是最入门但最差的方法,CPU被完全阻塞,无法处理其他任务,且定时精度受中断和系统负载影响极大,不适用于任何严肃的场合。 - 定时器中断翻转IO:配置一个定时器在更新中断里手动翻转脉冲引脚。相比纯软件模拟,定时更准,但每个脉冲都要进一次中断,当脉冲频率高时(如10kHz),中断频率也是10kHz,会消耗大量CPU资源,导致系统响应变慢。
- PWM + 外部中断计数:用一路PWM连续输出,再用另一个IO的外部中断对脉冲上升沿计数,计满后关闭PWM。这种方法需要占用两个硬件资源(一个定时器通道和一个外部中断),并且关闭PWM的时机可能滞后,导致多输出一两个脉冲。
- 本文方案(PWM + RCR + OPM):硬件全自动完成。CPU只需要发起一次“开始”命令,硬件定时器就会精确地输出指定数量的脉冲,然后自动停止。整个过程零CPU干预,精度极高,资源占用最小(仅一个定时器通道)。这是最优的解决方案。
6.2 扩展到多轴控制与加减速
在实际的CNC或3D打印机项目中,需要多轴(X, Y, Z)同步控制,并且电机需要平滑的加减速(S曲线或梯形曲线)以避免失步和抖动。
- 多轴控制:可以为每个步进电机分配一个独立的定时器(如TIM1, TIM2, TIM3, TIM4),每个定时器独立配置其RCR和频率。通过协调启动多个定时器,可以实现多轴联动。更复杂的轨迹规划则需要上位机或更强大的算法。
- 加减速控制:本文的固定频率输出适用于匀速运动。要实现加减速,就需要动态地改变PWM的频率。这可以在定时器的更新中断中完成。例如,建立一个“速度曲线表”,中断服务函数根据当前时间查表得到目标频率,然后动态修改定时器的ARR值。由于修改ARR会影响脉冲总数,因此需要更复杂的算法来同时管理速度曲线和位置规划(总脉冲数)。通常会使用专门的步进电机驱动芯片(如TMC2209, DRV8825)或集成运动控制库(如SPI接口的步进驱动)来简化这部分工作,STM32则作为主控发送方向和步进脉冲。
6.3 关于高级定时器与通用定时器
本例使用了TIM1,它是一个高级定时器,具有重复计数器RCR。STM32F103的通用定时器(TIM2, TIM3, TIM4)没有重复计数器寄存器。那么通用定时器如何实现有限脉冲输出呢?
对于通用定时器,常用的方法是使用主从定时器门控模式或DMA传输。
- 主从模式:将一个定时器(TIM_A)配置为PWM输出模式,作为“从”定时器。将另一个定时器(TIM_B)配置为单脉冲输出模式,其输出作为TIM_A的触发源和门控信号。通过控制TIM_B的输出脉冲宽度(即高电平时间),来控制TIM_A输出PWM的时长,间接控制脉冲数。这种方法更复杂,但可行。
- DMA模式:将PWM脉冲的占空比数据(如一组0和1交替的数组)通过DMA循环发送到定时器的捕获/比较寄存器。通过控制DMA传输的数据量来控制脉冲数量。这种方法灵活性高,但需要仔细配置DMA,并且会占用一定的内存和DMA资源。
因此,如果项目需要控制多个步进电机且脉冲数固定,优先选择具有RCR的高级定时器(如TIM1, TIM8),或者型号更丰富的定时器(如STM32F4系列,其通用定时器也有RCR)。
