简介:面向嵌入式初学者的STM32F1步进电机PWM脉冲控制工程,基于ARM Cortex-M3内核微控制器,解决步进电机角度、速度与位置控制中的定时器PWM及中断配置问题。资源共138个文件,压缩包约1.76MB,主要包含34个h头文件、33个c源程序、16个o及axf、hex等编译产物,另有uvproj工程配置与bat批处理脚本,可覆盖从定时器配置、PWM占空比调整到中断服务程序设计的完整代码链路,便于对照学习KEIL工程结构与底层驱动写法。已有1783人学习下载。内容围绕PWM模式1、预分频因子、ARR与CCR寄存器的实际设置展开,并给出定时器溢出中断与占空比实时更新的处理思路。读者可获得STM32F1定时器模块的完整示例代码、标准外设库工程模板,以及步进电机控制中脉冲频率与转速、占空比与转角关系的调试参考,适合用于自动化设备、小型精密定位等场景的入门实践与项目二次开发。
1. 步进电机为什么需要PWM脉冲,STM32F1怎么接这个活
步进电机的控制本质上不是“给多少电压”的问题,而是“给多少个脉冲、以多快的频率给”的问题。一个脉冲让转子走一个固定角度,也就是步距角;脉冲数量决定转过多少角度,脉冲频率决定转速。这个特性决定了驱动器想要的输入信号就是连续脉冲串,而STM32F1的定时器模块恰好是最廉价的脉冲发生器:它通过内部计数器、比较寄存器和输出引脚,在基本不占用CPU时间的情况下把PWM波形送到电机驱动器。这篇博文围绕一个实际的TIMER工程展开,从STM32F1的APB时钟树、PSC与ARR分频、PWM模式1与边沿对齐输出,到更新中断里做脉冲计数和动态调速,最后把工程源码里那些Keil文件的作用和常见坑位过一遍。适合正在用STM32做运动控制、或者想彻底搞懂定时器PWM底层细节的开发者,看完可以直接抄参数。
2. STM32F1定时器PWM生成的底层机制:从APB时钟到CCRx
2.1 定时器时钟树与PSC/ARR分频逻辑
STM32F1的定时器分布在不同总线上:TIM1和TIM8挂在APB2上,TIM2、TIM3、TIM4挂在APB1上。这里有一个容易翻车的细节:如果APB1预分频系数不是1,那么挂在这条总线上的定时器时钟会翻倍。以最常见的72MHz主频配置为例,APB1总线本身被限制在36MHz,因此APB1通常配置为2分频,此时TIM2/TIM3/TIM4的时钟反而恢复为72MHz,而不是APB1总线上的36MHz。若按总线时钟直接带入公式,算出的PWM周期会差出一倍,这在低速控制时表现得尤其明显。
PSC(预分频器)和ARR(自动重装载寄存器)共同决定PWM周期。计数器时钟等于定时器时钟除以(PSC+1),一个PWM周期需要的计数器时钟个数等于(ARR+1),所以输出频率公式是:
fPWM = TIMxCLK / ((PSC + 1) * (ARR + 1))预分频器可以理解为粗调,决定计数器每秒钟走多少步;ARR是细调,决定走多少步算一个周期。两者配合,就能在很宽的范围内得到目标频率。例如要在TIM2上输出1kHz的PWM,72MHz时钟下可以用PSC=71、ARR=999,也可以PSC=719、ARR=99。后者的占空比调节档位只有100级,对于需要精细调整脉冲宽度的场景明显不够用。我一般会把ARR放在500到2000之间,优先保证占空比分辨率,再用PSC去凑频率。
| 目标频率 | PSC(写寄存器) | ARR(写寄存器) | 占空比档位 |
|---|---|---|---|
| 1kHz | 71 | 999 | 1000 |
| 5kHz | 71 | 199 | 200 |
| 10kHz | 71 | 99 | 100 |
这个表格假设TIMxCLK为72MHz。如果实际时钟是36MHz,要达到同样的频率,PSC或ARR需要减半,调试时可以先用调试器观察CNT计数速率,反推时钟树配置是否正确。
2.2 PWM模式1与模式2在边沿对齐下的波形差异
定时器工作在边沿对齐向上计数模式时,计数器从0递增到ARR,然后回到0,如此循环。PWM模式1和模式2的区别在于有效电平出现的位置:模式1下,当CNT小于CCRx时输出有效电平,CNT大于等于CCRx时输出无效电平;模式2恰好相反,CNT大于等于CCRx时输出有效电平。绝大多数步进电机工程用的是模式1,配合高电平极性,得到的是前高后低的脉冲波形,边缘陡峭,便于驱动器光耦识别。
占空比由CCRx决定:
占空比 = CCRx / (ARR + 1)ARR=999时,CCRx=500就是50%占空比。但步进电机驱动器和直流电机驱动不一样,占空比本身不直接决定力矩或转速,它只需要保证脉冲电平的有效宽度大于驱动器要求的最小值。常见驱动器的光耦输入要求最小脉冲宽度不小于2.5微秒,所以我在配置PWM时通常把占空比固定在50%,不把它当作调速变量,速度全部通过修改频率实现。如果PWM不是送给驱动器,而是直接驱动H桥,那就要额外关注死区时间,防止上下桥臂同时导通。STM32高级定时器TIM1/TIM8自带死区插入功能,普通定时器则没有,这也是选型时需要考虑的一点。
2.3 用标准外设库直接验证波形生成
这个TIMER工程里出现的是stm32f10x_tim.c,也就是标准外设库(SPL)的定时器驱动。SPL和HAL库的API风格差异很大,但寄存器操作是一一对应的。用SPL初始化TIM2输出PWM的典型代码如下:
#include "stm32f10x_tim.h" #include "stm32f10x_gpio.h" #include "stm32f10x_rcc.h" void TIM2_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; /* 使能TIM2与GPIOA时钟 */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* PA0复用为TIM2_CH1 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); /* 时基:72MHz / 72 / 1000 = 1kHz */ TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 999; TIM_TimeBaseStructure.TIM_ClockDivision = 0; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); /* PWM模式1,CCR=500,占空比50% */ TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 500; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_Cmd(TIM2, ENABLE); }这里的关键参数有三个:TIM_Prescaler=71写入PSC寄存器,实际分频是72,因为计数器从0开始对齐;TIM_Period=999写入ARR寄存器,一个周期实际计数1000次;TIM_Pulse=500写入CCR1,决定占空比。PA0之所以能输出PWM,是因为GPIO被配置成了复用推挽输出GPIO_Mode_AF_PP,并把复用功能映射到TIM2_CH1。整个流程缺一不可,少了任何一步引脚上都量不到波形。
注意:如果改用TIM1或TIM8这类高级定时器,SPL代码里还需要在TIM_Cmd之后调用TIM_CtrlPWMOutputsEnable(TIM1, ENABLE),否则主输出闭关,引脚上始终是低电平。TIM2、TIM3、TIM4没有这个限制,直接TIM_Cmd即可。
3. 在HAL库下配置PWM脉冲输出与更新中断
3.1 HAL库初始化流程与时钟使能差异
HAL库的代码风格和SPL差别很大,但底层原理一一对应。使用HAL库时,要先定义TIM_HandleTypeDef结构体句柄,再通过HAL_TIM_PWM_Init完成时基初始化。时钟使能从SPL的RCC_APB1PeriphClockCmd变成__HAL_RCC_TIM3_CLK_ENABLE宏,GPIO初始化也变成了HAL_GPIO_Init。CubeMX生成的工程通常把时基初始化和PWM通道配置拆成两个函数,看起来比SPL冗长,但每一段只做一件事,定位问题反而更方便。
需要特别留意的是HAL_TIM_PWM_Init内部会调用HAL_TIM_Base_Init做时基配置,但不会配置输出通道;输出通道的PWM模式、占空比和极性是在HAL_TIM_PWM_ConfigChannel里设置的。如果漏掉第二步,定时器在跑,引脚上却看不到波形。这种问题在论坛里反复出现,排查时先确认是否调用了ConfigChannel,再怀疑硬件。
3.2 配置PWM通道参数并启动输出
下面这段代码用TIM3_CH1在PA6上输出PWM,并开启更新中断,作用是每到一个PWM周期就触发一次中断回调:
#include "stm32f1xx_hal.h" TIM_HandleTypeDef htim3; TIM_OC_InitTypeDef sConfigOC; void MX_TIM3_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_TIM3_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); /* PA6复用为TIM3_CH1,推挽输出 */ GPIO_InitStruct.Pin = GPIO_PIN_6; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); /* 时基:72MHz / 72 / 2000 = 500Hz */ htim3.Instance = TIM3; htim3.Init.Prescaler = 71; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 1999; htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.RepetitionCounter = 0; HAL_TIM_PWM_Init(&htim3); /* PWM模式1,CCR=1000,占空比50% */ sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 1000; sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&htim3, &sConfigOC, TIM_CHANNEL_1); /* 启动PWM输出并开启更新中断 */ HAL_TIM_PWM_Start_IT(&htim3, TIM_CHANNEL_1); }htim3.Init.Prescaler和htim3.Init.Period的含义与SPL完全相同,都是“写入寄存器值+1”才等于实际分频系数或重装载值。sConfigOC.Pulse对应CCR1,占空比是1000/2000=50%,PWM周期500Hz,意味着每2毫秒产生一个脉冲。把Period改成199或把Prescaler改成7,频率就会跳到5kHz甚至更高,具体取值取决于目标转速和驱动器允许的输入频率。
3.3 更新中断回调与脉冲计数
步进电机做位置控制时要数清楚脉冲个数:转90度需要多少个脉冲,就发多少个脉冲然后停止。HAL库把定时器更新中断统一封装到一个弱函数HAL_TIM_PWM_PulseFinishedCallback里,每个PWM周期结束都会进入一次。在其中对已发送脉冲计数,达到目标值后关闭PWM输出:
uint32_t target_pulses = 0; /* 目标脉冲数 */ volatile uint32_t sent_pulses = 0; /* 已发送脉冲数 */ void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { sent_pulses++; if (sent_pulses >= target_pulses) { HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_1); } } } void Move_Steps(uint32_t pulses) { target_pulses = pulses; sent_pulses = 0; HAL_TIM_PWM_Start_IT(&htim3, TIM_CHANNEL_1); }target_pulses由上层函数在启动前写入,sent_pulses在中断里递增。当已发送数量达到目标值,调用HAL_TIM_PWM_Stop关闭输出,电机停在目标位置。sent_pulses用volatile修饰是因为它在中断上下文被修改、在主循环里有可能被读取,避免编译器把它优化到寄存器里导致读到陈旧值。
这里有一个隐含前提:更新中断频率等于PWM频率。如果PWM频率拉到20kHz以上,逐脉冲进中断的CPU开销就不能忽略,此时常见做法是改用定时器主从模式或DMA来转移计数压力,中断里只做方向切换和保护逻辑。TIMER工程本身面向中小频率控制场景,中断计数完全够用。
3.4 中断服务函数、优先级与SPL/HAL对应关系
HAL库要求用户自己编写定时器中断服务函数,并在其中调用HAL_TIM_IRQHandler,否则标志位不会被清除,回调也不会执行:
void TIM3_IRQHandler(void) { HAL_TIM_IRQHandler(&htim3); }优先级通过HAL_NVIC_SetPriority(TIM3_IRQn, 1, 0)和HAL_NVIC_EnableIRQ(TIM3_IRQn)设置。多电机或带其他外设的项目里,定时器中断优先级要高于普通外设,但中断回调里不要做串口打印这类耗时操作,只更新计数和标志,由主循环消费这些状态。
SPL与HAL库的接口对应关系可以这样对照:
| 功能 | SPL接口 | HAL库接口 |
|---|---|---|
| GPIO复用为定时器输出 | GPIO_Init + GPIO_Mode_AF_PP | HAL_GPIO_Init |
| 时基配置 | TIM_TimeBaseInit | HAL_TIM_PWM_Init |
| PWM通道参数 | TIM_OC1Init | HAL_TIM_PWM_ConfigChannel |
| 启动PWM输出 | TIM_Cmd | HAL_TIM_PWM_Start |
| 更新中断启动 | TIM_ITConfig + NVIC | HAL_TIM_PWM_Start_IT |
从这张表能看出,SPL偏底层分散,需要自己手动调NVIC;HAL库用Start_IT一步完成启动与中断使能,但代价是代码编排更重。老工程换成HAL库时,按照这个映射逐项迁移,比对着固件库手册翻要快很多。
4. 步进电机控制参数换算与工程实战
4.1 脉冲频率、步距角、细分与转速的关系
步进电机的步距角常见是1.8度和0.9度,前者转一圈需要200个整步脉冲。驱动器设置细分倍数m后,每个PWM脉冲对应的角度变为步距角除以m,转一圈需要的脉冲数变为200乘以m。转速公式随之确定:
转速(rpm) = 脉冲频率(Hz) * 60 / (200 * m)以42步进电机配合16细分驱动板为例,脉冲当量是1.8/16=0.1125度,转一圈需要3200个脉冲。要让电机转60rpm,PWM频率需要60*3200/60=3200Hz。反过来,如果直接给800Hz的脉冲串,转速只有15rpm。调试时我先固定一个已知频率,比如1kHz,观察电机转向和转速是否与公式吻合,再改成目标频率,可以避免把参数错误混杂进排查过程。
| 细分m | 脉冲当量(度) | 每转脉冲数 | 1kHz对应转速(rpm) |
|---|---|---|---|
| 1 | 1.8 | 200 | 300 |
| 8 | 0.225 | 1600 | 37.5 |
| 16 | 0.1125 | 3200 | 18.75 |
4.2 转角与脉冲数量:中断里做位置控制
位置控制的换算更直接:
所需脉冲数 = 目标角度 / (步距角 / 细分)1.8度步距角、8细分,转90度需要90/(1.8/8)=400个脉冲。第三章的Move_Steps函数里放入400,中断到400就停机。脉冲当量这个说法在开环驱动器里很直观,在EtherCAT总线的闭环步进驱动里含义稍有不同,但本质仍然是“一个命令单位走多少角度”。代码里把步距角和细分定义成宏,方便不同电机间切换:
#define STEP_ANGLE_DEG 1.8f /* 步距角,单位度 */ #define MICRO_STEP 8 /* 驱动器细分 */ #define PULSE_PER_REV (360.0f / (STEP_ANGLE_DEG / MICRO_STEP))PULSE_PER_REV在初始化时被用来计算目标脉冲数,例如Move_Steps(PULSE_PER_REV / 4)就是转90度。这样即使换了不同步距角或细分,主逻辑不用动。
4.3 高、低速边界与加减速的取舍
PWM频率过低时,电机会出现明显振动和噪声,几个Hz到几十Hz区间整步运行几乎无法接受,通常要靠细分来平滑。频率过高则受两个因素制约:一是定时器ARR必须大于等于CCR,否则占空比指令失真;二是驱动器输入接口带宽,普通光耦隔离输入一般不支持几万赫兹以上的连续脉冲。开环步进控制通常把最高频率限制在10kHz到100kHz,具体以驱动器手册为准。
加减速是容易被忽略的点。静止状态下直接给高频脉冲,步进电机会因为跟不上脉冲而堵转丢步。常见做法是梯形加减速:加速阶段频率线性升高,匀速阶段保持,减速阶段对称下降。TIMER工程没有专门做加减速,但用更新中断扩展很容易,在回调里对脉冲计数分段判断,到达切换点后重写ARR就能实现近似梯形曲线。重写ARR前先关闭定时器,改完再重新使能,避免计数器运行到一半时周期突变,导致输出一个异常宽度的脉冲。
4.4 方向与使能信号的处理
多数步进驱动器都有DIR方向引脚和ENA使能引脚。方向切换要在脉冲发送之前完成,最好预留几微秒的建立时间。软件里先把方向GPIO拉到目标电平,再调用Move_Steps启动PWM。使能信号一般低有效,电机停止后可以关闭ENA让电机进入释放状态,降低发热;但如果系统要求停机后保持力矩,就不能关,只能让PWM继续输出但频率置零。这两种模式对应不同的机械场景,夹紧机构用保持力矩,传送带则可以释放。
驱动器一般还会输出故障信号,过流、过压或过热时拉低。把这个引脚接到STM32的外部中断输入,中断里立刻HAL_TIM_PWM_Stop所有通道,是最直观的PWM故障保护方式。处理这类信号时,要注意故障信号的电平逻辑是推挽还是开漏,开漏输出通常需要外部上拉电阻,否则单片机读到的电平会不稳定。
5. 从TIMER工程源码看Keil项目结构与排错技巧
5.1 文件清单暴露的工程信息
这个工程的文件列表很有代表性:TIMER.uvgui.Administrator是Keil的窗口布局配置,TIMER.uvproj.bak和TIMER_uvopt.bak是项目文件与选项配置的备份,TIMER.axf是编译生成的带调试信息可执行文件,keilkilll.bat是清理中间产物的批处理脚本。出现stm32f10x_tim.c说明定时器驱动来自标准外设库,stm32f10x_flash.c则用于读取片内Flash中的配置参数,比如目标转速和细分值。
拿到这类工程,先用keilkilll.bat清掉历史编译产物,再重新编译,能排除旧文件干扰。如果打开后编译报错找不到头文件,优先检查Keil里C/C++选项卡的Include Paths是否还指向本机实际路径,路径变了是换电脑后最常见的错误。
5.2 排错的三个入手点
第一,用调试器直接看寄存器。打开Keil的Peripherals菜单,找到对应定时器,实时观察CNT、PSC、ARR、CCR1。CNT不递增,说明定时器没启动或时钟没配置;CNT在跑但引脚没波形,检查GPIO复用和AFIO时钟。换成HAL库后还要确认是否执行了HAL_TIM_PWM_Start。
第二,检查中断链路的每一环。TIM3_IRQHandler里有没有调HAL_TIM_IRQHandler,更新中断是否在NVIC层被使能,回调里如果走不到关闭PWM的逻辑,是不是target_pulses写入了0。把sent_pulses打印出来对比实际脉冲数,是最直接的判断方式。很多“丢步”其实不是机械问题,而是中断被打断导致计数滞后。
第三,注意高级定时器TIM1/TIM8的主输出使能。SPL工程里漏写TIM_CtrlPWMOutputsEnable是最常见的无输出原因,检查这个函数是否被调用。脉冲方向反了直接对调驱动器两相绕组,比在软件里分析时序更快,但前提是确认方向引脚电平变化正确。
本文还有配套的精品资源,点击获取