STM32标准库实战:霍尔编码器测速与电机控制
1. 从零开始霍尔编码器与STM32的初次握手大家好我是老张在嵌入式这行摸爬滚打十几年了玩过的电机和编码器能堆满半个仓库。今天咱们不聊那些虚头巴脑的理论就手把手地带你用STM32的标准库搞定霍尔编码器测速并把它用在电机控制上。这绝对是做小车、机械臂或者任何需要精准转速反馈的项目时你绕不开的硬核技能。霍尔编码器到底是什么你可以把它想象成电机的一双“眼睛”。它通常由两部分组成一个跟着电机轴一起转的磁环也叫霍尔马盘上面均匀地镶嵌着南北极交替的小磁铁还有一个固定不动的霍尔传感器。电机一转磁铁依次掠过传感器就会产生电信号。为了能判断电机是正转还是反转聪明的工程师们设计了两个传感器它们安装的位置错开一点点这样就会输出两路存在相位差的方波脉冲。我们通过分析这两路脉冲的先后顺序就能知道转向而通过数一定时间内脉冲的个数或者测量两个脉冲之间的时间就能算出转速。为什么我强烈推荐你用STM32的标准库来做这件事因为它足够“原始”也足够“透明”。相比HAL库的层层封装标准库让你能清晰地看到每一个寄存器是如何被操作的这对于理解定时器输入捕获、中断这些底层机制至关重要。理解透了以后无论遇到多复杂的时序问题你都能心里有数。市面上很多教程一上来就丢给你一堆代码看得人云里雾里。咱们今天反着来我不仅告诉你每一步怎么配置还会掰开了揉碎了讲清楚为什么要这么配踩过的坑、省时间的窍门一并分享给你。2. 硬件连接与核心思想把脉冲变成时间在动手写代码之前咱们得先把硬件理清楚。假设你手头有一个常见的N20减速电机上面带了一个三线制的霍尔编码器VCC GND OUT。OUT线就是信号线它会输出那一路方波。我个人的习惯是拿到一个传感器先用示波器或者逻辑分析仪看一下它的输出特性这是最稳妥的。关键点来了电平逻辑与捕获边沿的选择。很多教程包括正点原子的例程都默认传感器空闲时输出低电平有信号时跳变成高电平所以它们配置为“上升沿捕获”。但据我实测市面上超过一半的霍尔编码模块为了抗干扰设计成了上拉输出。也就是说空闲时没有磁铁经过输出的是高电平当磁铁靠近时才会短暂地变为低电平。这个细节至关重要如果配置反了你永远也捕获不到信号所以我们的核心任务就非常明确了使用STM32定时器的输入捕获功能精准地测量霍尔传感器输出低电平的持续时间。这个时间就是磁铁通过传感器所用的时间。知道了这个时间再结合磁环上一共有多少对磁极比如N极和S极算一对我们就能轻松换算出电机的转速。整个系统的思路就像警察测超速在一条固定长度的路段磁极间距两端设卡捕获下降沿和上升沿测量车辆磁极通过这段路的时间时间越短速度越快。2.1 硬件连接实战我这里以STM32F103C8T6这款经典的“蓝色小药丸”为例它资源丰富价格便宜是学习和原型开发的神器。供电编码器的VCC接3.3V或5V看清模块支持电压GND接单片机GND。信号线编码器的OUT信号线我们接到单片机的PA6引脚。为什么是PA6因为查数据手册可知PA6是定时器3TIM3的通道1CH1的复用功能引脚我们正好用它来做输入捕获。电机驱动为了后续的控制你还需要一个电机驱动模块比如TB6612、DRV8833或者经典的L298N。将驱动模块的控制引脚接到STM32的其他GPIO如PA1、PA2电源接好。这部分电路今天不是重点但需要提前准备好。连接好之后给电机驱动一个固定的电压让电机转起来此时用万用表量一下PA6的电压如果大部分时间是高电平比如3.3V偶尔跳变到低电平那就验证了我们的判断这是一个高电平有效下降沿触发的传感器。3. 软件配置五步走庖丁解牛定时器理解了硬件逻辑软件配置就是按部就班的流程。我用的是STM32标准库3.5版本代码风格清晰。整个配置分为五个关键步骤我会把每一步的“坑”和“窍门”都点出来。3.1 第一步GPIO初始化——打好地基首先得告诉单片机PA6这个引脚是用来“输入”信号的而且内部要上拉这样引脚悬空时也能保持稳定的高电平防止误触发。void Encoder_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 打开GPIOA时钟 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入模式这是关键 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); }这里和很多教程的“下拉输入”不同。GPIO_Mode_IPU就是上拉输入它内部通过一个电阻连接到VCC确保空闲高电平。如果你发现捕获不到信号十有八九是这里模式设错了。3.2 第二步定时器基础初始化——搭建时钟骨架接下来初始化TIM3定时器本身决定它的“心跳”多快以及怎么“数数”。void Encoder_TIM3_Init(u16 arr, u16 psc) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); // 打开TIM3时钟 TIM_TimeBaseStructure.TIM_Period arr; // 自动重装载值决定计数上限 TIM_TimeBaseStructure.TIM_Prescaler psc; // 预分频系数决定计数频率 TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; // 时钟分割与滤波器相关这里用默认 TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; // 向上计数模式 TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); }这里的arr和psc是两个非常重要的参数。psc预分频将系统时钟分频后得到定时器的计数时钟。假设系统时钟是72MHz设置psc71那么计数时钟就是 72MHz / (711) 1MHz即每1微秒计数器加1。arr周期是计数器的上限达到这个值后会产生溢出更新事件。我们通常设为最大值6553516位定时器以获得最宽的测量范围。3.3 第三步输入捕获通道配置——设置“触发器”这是核心步骤配置定时器如何响应PA6引脚上的信号变化。void Encoder_IC_Init(void) { TIM_ICInitTypeDef TIM_ICInitStructure; TIM_ICInitStructure.TIM_Channel TIM_Channel_1; // 通道1对应PA6 TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Falling; // 首次捕获下降沿 TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; // 直接映射到TI1输入 TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; // 每个边沿都捕获 TIM_ICInitStructure.TIM_ICFilter 0x00; // 滤波器值0表示不滤波 TIM_ICInit(TIM3, TIM_ICInitStructure); }TIM_ICPolarity_Falling明确指定了我们先捕获下降沿高电平变低电平。TIM_ICPrescaler设为DIV1意味着每个有效的边沿都触发捕获这对于测速是必要的。TIM_ICFilter是数字滤波器可以滤除高频毛刺如果你的环境干扰大可以适当设置一个值比如0x08但初次调试建议设为0确保信号能进来。3.4 第四步中断配置——让CPU“动起来”光有硬件捕获还不够我们需要在捕获发生时让CPU知道并处理数据这就需要中断。void Encoder_NVIC_Init(void) { NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // 设置优先级分组 // 配置定时器3全局中断包含更新和捕获 NVIC_InitStructure.NVIC_IRQChannel TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; // 抢占优先级 NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; // 子优先级 NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // 使能定时器3的更新中断和捕获/比较1中断 TIM_ITConfig(TIM3, TIM_IT_Update | TIM_IT_CC1, ENABLE); // 最后启动定时器3 TIM_Cmd(TIM3, ENABLE); }这里使能了两个中断TIM_IT_Update更新中断和TIM_IT_CC1通道1捕获中断。为什么需要更新中断想象一下电机转速很慢两次边沿间隔时间很长可能长到定时器计数器从0数到65535arr值溢出了好几次。如果我们只记录两次捕获时刻的计数器值相减就会丢失中间溢出次数的时间导致计算结果严重错误。更新中断就是用来记录这个溢出次数的。3.5 第五步中断服务函数——数据处理的“心脏”这是整个代码逻辑最精妙的部分。我们需要在中断里区分是“溢出中断”还是“捕获中断”并妥善记录时间和溢出次数。// 定义全局变量 volatile u8 g_capture_sta 0; // 状态标志位 volatile u16 g_capture_val 0; // 捕获到的计数器值 volatile u32 g_total_time 0; // 计算出的总时间单位us // 状态标志位定义使用位域更清晰这里用位操作示意 #define CAPTURE_STA_READY 0x80 // 第7位一次完整捕获完成标志 (0x80 1000 0000) #define CAPTURE_STA_FALL 0x40 // 第6位已捕获到下降沿标志 // 低5位bit0~bit4用来记录溢出次数最多31次 void TIM3_IRQHandler(void) { if(TIM_GetITStatus(TIM3, TIM_IT_Update) ! RESET) // 处理更新溢出中断 { if((g_capture_sta CAPTURE_STA_FALL) ! 0) // 如果已经捕获到下降沿 { // 在等待上升沿期间计数器溢出了 if((g_capture_sta 0x1F) 0x1F) // 低5位记录溢出最多记31次 { // 溢出次数太多认为时间过长转速过低或卡住强制结束 g_capture_sta | CAPTURE_STA_READY; // 标记完成 g_capture_val 0xFFFF; // 给一个最大值 } else { g_capture_sta; // 溢出次数加1只加低5位 } } TIM_ClearITPendingBit(TIM3, TIM_IT_Update); // 清除更新中断标志 } if(TIM_GetITStatus(TIM3, TIM_IT_CC1) ! RESET) // 处理通道1捕获中断 { if((g_capture_sta CAPTURE_STA_FALL) 0) // 第一次捕获下降沿 { // 1. 清空状态和溢出计数 g_capture_sta 0; g_capture_val 0; // 2. 标记已捕获到下降沿 g_capture_sta | CAPTURE_STA_FALL; // 3. 清除计数器从0开始计这段时间 TIM_SetCounter(TIM3, 0); // 4. 改变捕获极性为上升沿准备捕获高电平 TIM_OC1PolarityConfig(TIM3, TIM_ICPolarity_Rising); } else // 第二次捕获上升沿 { // 1. 获取捕获到的计数器值 g_capture_val TIM_GetCapture1(TIM3); // 2. 标记一次完整的低电平时间捕获完成 g_capture_sta | CAPTURE_STA_READY; // 3. 改变捕获极性为下降沿准备下一次测量 TIM_OC1PolarityConfig(TIM3, TIM_ICPolarity_Falling); } TIM_ClearITPendingBit(TIM3, TIM_IT_CC1); // 清除捕获中断标志 } }这个中断函数需要仔细理解。它实现了一个状态机初始状态等待下降沿 - 抓到下降沿后开始计时并等待上升沿 - 在等待期间用变量记录定时器溢出次数 - 抓到上升沿后计算总时间并标记完成。TIM_SetCounter(TIM3, 0)这句很关键它在捕获到下降沿时将硬件计数器清零这样我们后续计算的时间就是从这个下降沿开始到上升沿结束的净时间非常干净。4. 主函数与速度计算从时间到RPM中断服务函数帮我们准备好了原材料溢出次数和最后一次捕获的计数值。主函数的任务就是把这些“原材料”加工成我们需要的转速。int main(void) { u32 temp_time 0; float speed_rpm 0.0; // 初始化系统时钟、延时等 SystemInit(); delay_init(); // 初始化编码器相关硬件 Encoder_GPIO_Init(); Encoder_TIM3_Init(65535, 71); // 1MHz计数频率1us加一次 Encoder_IC_Init(); Encoder_NVIC_Init(); while(1) { if((g_capture_sta CAPTURE_STA_READY) ! 0) // 检测是否完成一次完整捕获 { // 计算总时间 溢出次数 * 65536 最后捕获的计数值 // 注意g_capture_sta的低5位是溢出次数 temp_time (g_capture_sta 0x1F); temp_time * 65536; // 每次溢出是65536个计数周期因为从0到65535 temp_time g_capture_val; // 加上最后一次捕获的计数值 // 此时temp_time的单位是“微秒”(us)因为我们的计数时钟是1MHz // 假设你的霍尔编码器磁盘有N对磁极常见的有13线即13对极 #define HALL_PPR 13 // 每转脉冲数 (Pairs Per Revolution) // 计算转速时间temp_time是一个脉冲低电平的持续时间 // 一个完整的方波周期高低时间大约是 2 * temp_time // 电机转一圈会产生 HALL_PPR 个完整的方波周期 // 所以转一圈的时间 HALL_PPR * 2 * temp_time (us) // 转速 RPM (60秒 * 10^6微秒/秒) / (转一圈的时间) speed_rpm (60.0 * 1000000.0) / ( (float)(temp_time) * 2.0 * HALL_PPR ); printf(Low-level time: %lu us, Speed: %.2f RPM\r\n, temp_time, speed_rpm); // 数据处理完成后清除完成标志等待下一次测量 g_capture_sta 0; g_capture_val 0; } delay_ms(10); // 主循环适当延时 } }计算转速的公式是核心。temp_time是我们测量到的一个低电平脉冲的宽度单位微秒。对于占空比50%的方波一个完整周期高低的时间大约是它的两倍。电机旋转一圈会产生HALL_PPR每转脉冲数个完整周期。因此转一圈的总时间就是2 * temp_time * HALL_PPR微秒。一分钟有60秒即60,000,000微秒用这个值除以转一圈的时间就得到了每分钟转数RPM。这里有个常见问题如果电机转速很快temp_time会很小计算出的RPM会很大。如果转速很慢temp_time可能接近溢出极限。所以你需要根据电机的实际转速范围调整定时器的预分频psc。转速快可以加大psc让计数变慢提高时间分辨率转速慢就减小psc或考虑使用捕获脉冲频率数固定时间内的脉冲数的测频法。5. 融入电机控制构建闭环调速系统测速不是最终目的控制才是。有了准确的转速反馈我们就可以构建一个简单的闭环控制系统比如让电机稳定在某个目标转速上。这里介绍最经典也最实用的PID控制。PID是什么你可以把它想象成一个经验丰富的司机在踩油门。P比例代表当前误差有多大误差大就猛踩油门I积分代表历史误差的累积如果一直达不到目标就慢慢加深油门D微分代表误差变化的趋势发现速度正在快速接近目标就提前松点油门防止冲过头。我们利用测速得到的speed_rpm作为反馈值与我们设定的目标值比如1000 RPM进行比较得到误差。PID算法根据这个误差计算出一个控制量比如PWM的占空比输出给电机驱动模块从而调整电机转速。// 一个极其简化的位置式PID结构体和计算函数 typedef struct { float Target; // 目标值 float Current; // 当前值反馈值 float Err; // 当前误差 float Err_Last; // 上次误差 float Kp, Ki, Kd; // PID参数 float Integral; // 积分项累积 float Output; // 输出值 } PID_TypeDef; void PID_Calculate(PID_TypeDef *pid) { pid-Err pid-Target - pid-Current; // 计算误差 pid-Integral pid-Err; // 积分项累加 // 积分限幅防止积分饱和 if(pid-Integral 1000) pid-Integral 1000; if(pid-Integral -1000) pid-Integral -1000; // PID输出 P I D pid-Output pid-Kp * pid-Err pid-Ki * pid-Integral pid-Kd * (pid-Err - pid-Err_Last); pid-Err_Last pid-Err; // 更新上次误差 }在主循环中我们定期比如每50ms执行一次PID计算// 全局PID结构体 PID_TypeDef motor_pid; // 初始化PID参数和目标值 motor_pid.Target 1000.0; // 目标1000 RPM motor_pid.Kp 0.5; motor_pid.Ki 0.01; // Ki要很小慢慢积分 motor_pid.Kd 0.05; motor_pid.Integral 0; while(1) { if(/* 到达控制周期例如每50ms */) { // 1. 获取最新的速度反馈值 speed_rpm (来自前面的测速代码) motor_pid.Current speed_rpm; // 2. 计算PID PID_Calculate(motor_pid); // 3. 将输出值转换为PWM占空比并设置到电机驱动引脚 // 假设PWM范围是0-1000输出限幅 int pwm_duty (int)motor_pid.Output; if(pwm_duty 1000) pwm_duty 1000; if(pwm_duty 0) pwm_duty 0; TIM_SetCompare1(TIM2, pwm_duty); // 假设用TIM2通道1输出PWM } // ... 其他逻辑 }调参是个经验活Kp决定了系统的响应速度太大容易震荡太小响应慢。Ki用来消除静差最终稳定值与目标值的偏差但太大会引起超调或震荡。Kd能抑制超调提高稳定性但对噪声敏感。我的经验是先调Kp让系统基本能跟上再加一点Ki消除静差最后看情况加一点Kd让曲线更平滑。可以用串口把目标速度、反馈速度和PWM输出值实时打印出来用电脑软件画个曲线调起来就直观多了。6. 避坑指南与性能优化走通了整个流程最后分享几个我踩过的坑和优化技巧能让你少走很多弯路。坑1信号抖动与滤波。电机尤其是碳刷电机在换向时会产生强烈的电火花干扰可能导致编码器信号出现毛刺引发误捕获。解决方案有两个层面硬件上在编码器信号线和电源线附近加1040.1uF瓷片电容滤波软件上可以适当增大输入捕获的滤波器参数TIM_ICFilter比如设置为0x08或0x0F它会在几个时钟周期内对信号进行采样只有连续多次采样一致才认为是有效边沿。坑2转速计算波动大。即使电机转速恒定单次测量计算出的RPM也可能跳动。这是因为我们只测量了一个脉冲的时间任何微小的抖动都会被放大。滑动平均滤波是简单有效的软件方法。例如维护一个包含最近10次速度值的数组每次计算新速度时取这个数组的平均值作为输出平滑效果立竿见影。坑3极低速与极高速测量。本文介绍的“测周法”测量一个脉冲的时间在低速时精度高但在高速时脉冲时间太短可能小于一次中断响应时间导致测量不准。反之在极低速时脉冲间隔可能超过定时器最大溢出时间。对于宽速度范围的应用可以采用“M/T法”或动态切换策略高速时用“测频法”固定时间内数脉冲个数低速时自动切换到“测周法”。坑4中断处理时间过长。中断服务函数里不要做复杂运算如浮点除法和大量打印。像转速计算这种费时的操作应该放在主循环中中断里只做最必要的标志位设置和数值记录。确保中断能快速进出不影响系统其他任务的实时性。一个高级技巧使用定时器的编码器接口模式。STM32的定时器硬件直接支持正交编码器模式可以自动根据两路脉冲的相位差判断方向并计数。这对于需要同时知道转速和转向且脉冲频率很高的场景是终极解决方案。配置起来比输入捕获模式更简单性能也更高。如果你的编码器是AB双相输出强烈建议研究一下这个功能。把这些都实现一遍你对STM32定时器的理解会上一个大台阶。电机控制的世界很大从简单的PID到更先进的无刷电机FOC控制底层都离不开精准的转速反馈。希望这篇长文能成为你手上那枚可靠的“钥匙”帮你打开这扇门。在实际项目中最享受的莫过于看着电机从狂野不羁到服服帖帖地稳定在预设转速上的那个过程所有的调试和折腾在那一刻都值了。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2409887.html
如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!