简介:这份资源面向STM32嵌入式开发者与电机控制初学者,聚焦用PWM实现精确脉冲个数输出,解决步进电机步进次数与舵机转角难以精准控制的问题。包内共196个文件,以h头文件、c源文件、o目标文件、axf可执行文件、hex烧录文件、uvproj/uvprojx工程文件及map、lst编译列表为主,另有少量rar压缩包,整体约952KB,属于可直接编译下载的完整工程集合。内容围绕定时器配置、比较匹配与脉冲计数展开,涉及TIM1至TIM8等通用与高级定时器的PWM输出设置,并给出步进电机按1.8度步距、舵机按50Hz信号控制角度的实践思路。已有1798人学习下载,适合希望掌握精确脉冲定位控制、对照工程代码理解中断服务与PWM初始化流程的读者参考。
1. 从一次丢步说起:为什么PWM输出脉冲数必须精确
去年帮一个做自动化设备的朋友调一台步进电机滑台,他反馈说每次走完1000个脉冲,实际位移总差那么几个微米,反复跑几十次之后误差累积到肉眼可见。一开始怀疑是机械间隙,拆了重装还是老样子;后来用逻辑分析仪抓PWM波形,才发现问题出在脉冲计数上——定时器中断里做计数,中断响应延迟导致最后一个脉冲被截断或者多发了。这个场景在STM32控制步进电机、伺服电机、甚至某些需要定长脉冲输出的场合非常典型:PWM本身好生成,难的是“发完N个脉冲就干净利落地停”。
这份资源围绕的就是这个痛点:在STM32上用PWM实现精确输出脉冲数控制电机。它适合正在做运动控制、步进电机驱动、定长脉冲输出相关开发的工程师,也适合刚接触STM32定时器、想搞明白主从模式、单脉冲模式、重复计数器的朋友。核心思路不是靠中断里手动计数,而是让定时器硬件自己完成“发够N个就停”,把CPU解放出来,也把精度从“软件抖动”提升到“硬件确定性”。
2. 硬件定时器怎么替CPU数脉冲:三种方案对比与选型
2.1 为什么中断计数不靠谱
先看最常见的做法:用一个定时器输出PWM,同时开更新中断,在中断里累加计数,到了目标值就关PWM。代码大概长这样:
// 中断计数方案(不推荐用于高精度场景) volatile uint32_t pulse_count = 0; uint32_t target_pulses = 1000; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); pulse_count++; if (pulse_count >= target_pulses) { TIM_Cmd(TIM3, DISABLE); // 关定时器 pulse_count = 0; } } }这段代码逻辑上没问题,但实际跑起来有几个坑:中断响应有延迟,尤其当系统里还有别的中断(比如串口、SysTick)时,延迟可能达到几微秒;如果PWM频率较高(比如100kHz),一个周期才10微秒,中断还没进完下一个脉冲就来了,直接丢中断。结果就是发1000个脉冲,实际可能只发了998个或者发了1002个。对于步进电机,丢一个脉冲就是丢一步,累积起来位置就偏了。
2.2 方案一:单脉冲模式 + 中断重装
STM32的定时器支持单脉冲模式(One Pulse Mode, OPM)。配置好后,定时器收到一个触发信号就输出一个脉冲,然后自动停止。如果要发N个脉冲,就在中断里重新启动N次。这种方式比纯中断计数准,因为脉冲宽度由硬件保证,但中断次数还是N次,高频率下依然有压力。
配置的关键寄存器是TIMx_CR1的OPM位,以及TIMx_CNT和TIMx_ARR。常见做法是用一个主定时器做触发源,从定时器配成单脉冲模式,主定时器每发一个触发,从定时器出一个脉冲。中断里只需要重新使能从定时器。
// 单脉冲模式配置片段(以TIM2为主,TIM3为从) void OPM_Config(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 主定时器TIM2:产生触发信号 TIM_TimeBaseStructure.TIM_Period = 1000 - 1; // 触发间隔 TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // 1MHz计数 TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_UPDATE); // 更新事件作为触发 // 从定时器TIM3:单脉冲模式 TIM_TimeBaseStructure.TIM_Period = 500 - 1; // 脉冲宽度 TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_SelectInputTrigger(TIM3, TIM_TS_ITR1); // 触发源选TIM2 TIM_SelectSlaveMode(TIM3, TIM_SlaveMode_Trigger); TIM_SelectOnePulseMode(TIM3, TIM_OPMode_Single); // 单脉冲模式 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM2; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 250; TIM_OC2Init(TIM3, &TIM_OCInitStructure); TIM_Cmd(TIM3, ENABLE); }这种方案适合脉冲数不多(几百到几千)、频率不高的场景。缺点是每个脉冲都要进一次中断,如果发10万个脉冲,CPU基本被中断占满了。
2.3 方案二:重复计数器(RCR)——硬件自动计数
STM32的高级定时器(TIM1、TIM8)和部分通用定时器(如TIM15、TIM16、TIM17)带重复计数器(Repetition Counter, RCR)。这个计数器的作用是:每次更新事件发生时,RCR减1,减到0才产生真正的更新中断或DMA请求。换句话说,你可以让定时器发N+1个脉冲后才进一次中断。
配置很简单,设置TIMx_RCR寄存器即可:
// 使用重复计数器实现N个脉冲后中断一次 void RCR_Config(uint16_t pulse_num) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; TIM_TimeBaseStructure.TIM_Period = 1000 - 1; // PWM周期 TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // 1MHz TIM_TimeBaseStructure.TIM_RepetitionCounter = pulse_num - 1; // 关键:N-1 TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 500; // 占空比50% TIM_OC1Init(TIM1, &TIM_OCInitStructure); TIM_Cmd(TIM1, ENABLE); }注意RCR是16位寄存器,最大65535,所以单次最多发65536个脉冲。如果需要更多,可以在中断里重新设置RCR继续发。这个方案比单脉冲模式效率高得多,中断次数从N次降到1次(或几次)。但有个限制:不是所有STM32型号的定时器都有RCR,选型时要查参考手册。
2.4 方案三:主从门控 + DMA——大批量脉冲的首选
如果要发几万甚至几十万个脉冲,且对CPU占用敏感,主从门控配合DMA是更优解。思路是:用一个定时器(主)控制脉冲频率,另一个定时器(从)工作在门控模式,主定时器的某个通道输出作为从定时器的门控信号。从定时器只在门控信号有效时计数,计到设定值后自动停止。
更常见的做法是用DMA来搬运脉冲数:把目标脉冲数写入DMA传输数量,定时器每次更新事件触发DMA请求,DMA减到0时产生中断,在中断里关定时器。这样CPU只在最后进一次中断。
// DMA方式:定时器更新事件触发DMA,传输完成中断里停止 void DMA_Pulse_Config(uint32_t pulse_num) { DMA_InitTypeDef DMA_InitStructure; // 配置DMA:源地址为固定值,目的地址为定时器ARR(或虚拟寄存器) DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&TIM4->ARR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)&dummy_value; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralDST; DMA_InitStructure.DMA_BufferSize = pulse_num; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Disable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel7, &DMA_InitStructure); TIM_DMACmd(TIM4, TIM_DMA_Update, ENABLE); DMA_ITConfig(DMA1_Channel7, DMA_IT_TC, ENABLE); DMA_Cmd(DMA1_Channel7, ENABLE); TIM_Cmd(TIM4, ENABLE); } // DMA传输完成中断 void DMA1_Channel7_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC7)) { DMA_ClearITPendingBit(DMA1_IT_TC7); TIM_Cmd(TIM4, DISABLE); // 脉冲发完,停定时器 } }这个方案的精髓在于:DMA的传输数量就是脉冲数,硬件自动递减,减到0触发中断。CPU全程不参与计数,精度只取决于定时器时钟和DMA响应,实测在72MHz主频下,发10万个脉冲的误差可以控制在0个脉冲(只要时钟准确)。
三种方案怎么选?看下面这张表:
| 方案 | 适用脉冲数 | CPU占用 | 精度 | 硬件要求 |
|---|---|---|---|---|
| 中断计数 | <500 | 高 | 差 | 任意定时器 |
| 单脉冲模式 | <2000 | 中 | 好 | 带OPM的定时器 |
| 重复计数器 | <65536 | 低 | 好 | 带RCR的定时器 |
| 主从+DMA | 任意 | 极低 | 极好 | 带DMA的定时器 |
我一般会优先看芯片有没有RCR和DMA,有DMA就直接上DMA方案,省心。
3. 从零跑通:工程搭建与关键配置步骤
3.1 时钟与定时器初始化
不管用哪种方案,时钟配置是第一步。以常见的STM32F103为例,系统时钟72MHz,定时器时钟也是72MHz(APB1分频系数为1时)。如果APB1分频系数不为1,定时器时钟会倍频,这个细节容易翻车。
// 系统时钟配置(标准库) void RCC_Configuration(void) { RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB | RCC_APB2Periph_AFIO, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); } // GPIO配置:PWM输出引脚 void GPIO_Configuration(void) { GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; // TIM4_CH1 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); }这里有个常见坑:PWM输出引脚必须配成复用推挽输出(AF_PP),配成普通推挽输出的话引脚不受定时器控制,示波器上什么都看不到。另外,如果用的是高级定时器TIM1或TIM8,还需要额外使能PWM输出(TIM_CtrlPWMOutputs),否则引脚也没波形。
3.2 PWM频率与占空比计算
PWM频率由定时器时钟、预分频器(PSC)和自动重装载值(ARR)共同决定:
PWM频率 = 定时器时钟 / ((PSC + 1) * (ARR + 1))比如72MHz时钟,PSC=71,ARR=999,那么PWM频率 = 72,000,000 / (72 * 1000) = 1000Hz。占空比由捕获比较寄存器(CCR)决定:
占空比 = CCR / (ARR + 1)CCR=500时,占空比50%。步进电机驱动器一般要求脉冲频率在几百Hz到几十kHz之间,具体看驱动器手册。频率太低电机会抖动,频率太高驱动器可能响应不过来。
// 定时器基础配置:1kHz PWM,50%占空比 void TIM4_PWM_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; TIM_TimeBaseStructure.TIM_Period = 1000 - 1; // ARR TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // PSC TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 500; // CCR TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM4, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM4, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM4, ENABLE); }注意TIM_OCPreload_Enable和TIM_ARRPreloadConfig这两个预装载配置,它们保证在更新事件发生时CCR和ARR的值才生效,避免PWM波形出现毛刺。调试时如果发现波形有异常窄脉冲,先检查这两个配置。
3.3 脉冲数控制逻辑的接入点
把脉冲数控制逻辑接进去,关键是找到“发完最后一个脉冲”的时刻。用DMA方案时,DMA传输完成中断就是那个时刻;用RCR方案时,更新中断就是那个时刻。在中断里做两件事:关定时器、置完成标志。
// 脉冲输出完成标志 volatile uint8_t pulse_done = 0; // DMA传输完成中断:脉冲发完 void DMA1_Channel7_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC7) != RESET) { DMA_ClearITPendingBit(DMA1_IT_TC7); TIM_Cmd(TIM4, DISABLE); // 立即停止PWM输出 pulse_done = 1; // 通知主循环 } } // 启动一次定长脉冲输出 void Start_Pulse_Output(uint32_t num) { pulse_done = 0; DMA_Cmd(DMA1_Channel7, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel7, num); // 设置脉冲数 DMA_Cmd(DMA1_Channel7, ENABLE); TIM_SetCounter(TIM4, 0); // 计数器清零 TIM_Cmd(TIM4, ENABLE); }这里有个细节:关定时器之前最好先把计数器清零,否则下次启动时计数器从上次的值开始,第一个脉冲的宽度可能不对。另外,如果电机驱动器对脉冲的下降沿敏感,关定时器时引脚电平可能停在任意状态,最好在关定时器后把引脚拉低,确保最后一个脉冲完整。
4. 避坑与排查:脉冲数对不上时先查这五处
4.1 现象:实际脉冲数比设定值多1或少1
原因:定时器启动和停止的时机与计数器状态不同步。比如先使能定时器再设置DMA传输数量,第一个脉冲可能已经发出去了但没被计数;或者关定时器时正好在脉冲中间,导致最后一个脉冲不完整。
解决:启动顺序固定为“先配置DMA传输数量 → 清零计数器 → 使能定时器”。停止时在中断里先关定时器再清标志。如果对边沿敏感,可以在停止后手动拉低引脚。
4.2 现象:低频时正常,高频时丢脉冲
原因:中断响应不过来,或者DMA带宽不够。中断计数方案在高频下必然丢脉冲;DMA方案如果和别的外设抢总线,也可能丢。
解决:换用DMA方案或RCR方案,把中断次数降到最低。检查DMA优先级,把脉冲控制的DMA通道设为最高优先级。如果还是丢,降低PWM频率试试,确认是带宽问题还是逻辑问题。
4.3 现象:脉冲数对了但电机丢步
原因:脉冲频率超过了驱动器的最大响应频率,或者加减速没处理好。步进电机启动时如果直接给高频脉冲,会因为惯性丢步。
解决:加加减速曲线,用定时器动态改ARR来实现频率渐变。常见做法是梯形加减速或S形加减速,启动频率从几百Hz开始,逐步升到目标频率。这个项目里如果没带加减速,二次开发时可以补上。
4.4 现象:换了个STM32型号,代码跑不通
原因:不同型号的定时器资源不一样。比如F103的TIM4没有RCR,F4的TIM4有;F1的DMA请求映射和F4不同。
解决:查对应型号的参考手册,确认定时器是否支持RCR、DMA请求映射表是否一致。移植时重点检查TIM_DMACmd的请求源和DMA通道号。
4.5 现象:示波器上看波形有毛刺
原因:CCR或ARR在计数过程中被修改,导致占空比突变。或者预装载没使能。
解决:使能TIM_OCPreload和TIM_ARRPreload,确保修改在更新事件时生效。如果需要在运行中改频率,用TIM_SetAutoreload和TIM_SetComparex,它们会自动处理预装载。
5. 二次开发:把定长脉冲封装成可复用的电机控制接口
5.1 封装成独立模块
跑通之后,下一步是把这套逻辑封装成干净的接口,方便在多个项目里复用。我一般会建一个motor_pulse.c和motor_pulse.h,对外只暴露三个函数:初始化、启动、查询状态。
// motor_pulse.h #ifndef __MOTOR_PULSE_H #define __MOTOR_PULSE_H #include "stm32f10x.h" void MotorPulse_Init(void); void MotorPulse_Start(uint32_t pulses); uint8_t MotorPulse_IsDone(void); void MotorPulse_Stop(void); #endif实现里把定时器、DMA、中断的细节都藏起来,上层应用只关心“发多少个脉冲”和“发完没有”。这样换芯片时只需要改这一个文件,不用动业务代码。
5.2 加入加减速曲线
定长脉冲的进阶需求是加减速。思路是在脉冲输出过程中动态调整ARR,让频率按预设曲线变化。简单做法是用一个数组存频率表,每次更新中断里取下一个频率值写入ARR。
// 梯形加减速:加速段、匀速段、减速段 const uint16_t accel_table[] = {1000, 900, 800, 700, 600, 500}; // ARR值递减,频率递增 const uint16_t decel_table[] = {500, 600, 700, 800, 900, 1000}; void TIM4_IRQHandler(void) { static uint32_t step = 0; if (TIM_GetITStatus(TIM4, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM4, TIM_IT_Update); step++; if (step < ACCEL_STEPS) { TIM_SetAutoreload(TIM4, accel_table[step]); } else if (step > total_steps - DECEL_STEPS) { TIM_SetAutoreload(TIM4, decel_table[total_steps - step]); } } }这个表可以根据实际电机的矩频特性来调,加速段太陡会丢步,太缓则效率低。我一般先用保守参数跑通,再用示波器看波形逐步优化。
5.3 多轴联动的扩展思路
如果项目需要控制多个电机,每个电机用一个独立的定时器和DMA通道,互不干扰。启动时按顺序使能,或者用主定时器同步触发。注意DMA通道不能冲突,查手册分配好。中断优先级也要规划,脉冲完成中断优先级可以设高一点,保证及时停止。
5.4 验证方法:用逻辑分析仪抓波形
改完代码怎么验证?最直接的办法是用逻辑分析仪或者示波器抓PWM引脚波形,数脉冲个数。没有逻辑分析仪的话,可以用另一个定时器做输入捕获,数上升沿个数,和设定值对比。我习惯在代码里加一个调试变量,记录实际发出的脉冲数(用DMA传输剩余量反推),和设定值一起打印出来,跑个几百次看有没有偏差。
从那以后我每次做脉冲控制,都会先跑一个“发1000个脉冲,循环100次”的测试,用输入捕获数实际脉冲数,确认零误差再往下做。这个习惯帮我省了很多返工的时间。希望帮到你。
本文还有配套的精品资源,点击获取