从零写一个电机转速闭环,最怕的不是代码复杂,而是那种“PWM输出正常、电机能转,但速度就是稳不住”的状态。我在这上面耗过不少时间,后来把STM32、增量式PID、编码器测速整条链路彻底捋顺之后才明白,转速闭环看着高大上,本质上就是三个环节:测准转速、算对偏差、输出合适的PWM。这篇就用我实际跑通的方案来讲,平台是STM32F103C8T6,电机是带霍尔编码器的直流减速电机,驱动用TB6612,完整代码放在后面,可以直接抄。
先说说这套东西适合谁。如果你正在做智能小车、云台、水泵、传送带这类需要速度稳定的项目,或者你刚接触PID算法,想把课本上的公式变成能跑的代码,这篇会很有用。我尽量把原理、代码、调参过程、踩坑记录都拆开讲,让你不仅能把程序烧进去,还能知道每一步为什么要这么写。
1. 整体方案设计与选型思路
1.1 这个项目要解决什么问题
最简单的电机控制就是给个PWM占空比,电机转起来,但负载一变、电池电压一掉,转速立刻跟着飘。比如小车爬坡时速度骤降,平地又突然冲出去。开环控制只能“给油”,不能“看表”,所以我们需要闭环:让MCU实时知道电机当前转速,和目标值对比,然后自动调整输出。
我的项目目标很明确:给定一个目标转速(RPM),电机无论是空载还是带载,都能在大约0.5到1秒内稳定到目标值附近,稳态误差控制在很小范围内。这个场景覆盖了绝大多数直流减速电机的应用需求。
1.2 为什么选增量式PID而不是位置式PID
PID算法主流有两种落地形式:位置式和增量式。位置式输出的是控制量本身,比如PWM占空比直接等于计算值;增量式输出的则是控制量的“增加量”,相当于每次只调整一点点,最终执行器是通过累加得到当前输出的。
我选增量式,核心原因有三点。第一,增量式没有积分项的历史累积问题,不存在需要手动清零积分值这种操作,抗积分饱和天然好做。第二,增量式只跟最近三次偏差有关,就算前面算错了,后面也会自动修正,容错性更强。第三,在STM32这种单片机里,增量式可以直接用整数运算,不需要频繁处理浮点累积误差,代码写起来也更利落。
当然位置式也有它的优势,比如适合舵机、加热器这类输出本来就是绝对值的场景。但对于电机PWM这种“增量输出天然合理”的执行器,增量式是更顺手的选择。
1.3 系统硬件组成
我用的硬件非常平民化,加起来没有多少钱:
- 主控:STM32F103C8T6最小系统板,72MHz主频,性价比极高
- 电机:带霍尔编码器的直流减速电机,减速比30比1,编码器线数13线
- 驱动:TB6612FNG电机驱动模块,比L298N压降小,逻辑引脚可以直接接3.3V
- 电源:7.4V锂电池给电机供电,主控板用5V或者3.3V稳压
接线关系是:STM32的定时器输出两路PWM给TB6612控制电机转速和方向,编码器的A相和B相输出分别接到STM32的定时器输入捕获引脚上。整个系统闭环就是:编码器反馈转速到MCU,PID算出差值,再通过PWM修正电机输出。
注意:编码器测速引脚必须接在支持编码器模式或者输入捕获的定时器通道上,不是随便接一个GPIO都能用的。F103的TIM1、TIM2、TIM3、TIM4都有编码器模式支持,具体通道别接错。
2. 电机转速测量:编码器测速原理与实现
2.1 霍尔编码器是怎么报转速的
带霍尔编码器的直流减速电机,在电机后轴上装了一个码盘,码盘上有黑白相间的栅格。码盘转动时,霍尔传感器会输出两个方波信号,分别叫A相和B相。两路信号相位差90度,通过判断A提前还是B提前,就能知道电机是正转还是反转。
编码器线数13,指的是码盘一圈输出13个脉冲。但我们用STM32定时器的编码器模式,可以对A、B两相的所有边沿都计数,也就是4倍频。所以电机轴转一圈,计数器累计变化是 13 × 4 = 52 个计数。再乘以30倍减速比,输出轴转一圈,计数器累计变化是 52 × 30 = 1560 个计数。
这个数字很重要,后面算转速全部靠它,千万别算错。很多人测速不准,就是在这里把“线数”“倍频”“减速比”这三个参数搞混了。
2.2 M法、T法、MT法怎么选
测转速从原理上分三种:
- M法:固定时间窗口内数脉冲个数,适合中高速测量
- T法:测量两个脉冲之间的时间间隔,适合低速测量
- MT法:两者结合,宽范围都能用
对直流减速电机来说,输出轴转速一般不会太高,几转到几百转都有。我用的是M法加固定时间窗口,即每10毫秒读取一次编码器计数器的增量值,再算出转速。这也叫定时测频法。这个方案简单可靠,速度环控制周期正好也是10毫秒,一个定时器中断同时搞定测速和PID计算。
2.3 编码器模式的初始化代码
下面这段代码把TIM3配成编码器模式,引脚是PA6和PA7,对应TIM3的通道1和通道2。
void Encoder_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0F; TIM_ICInit(TIM3, &TIM_ICInitStructure); TIM_ICInitStructure.TIM_Channel = TIM_Channel_2; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0F; TIM_ICInit(TIM3, &TIM_ICInitStructure); TIM_EncoderInterfaceConfig(TIM3, TIM_EncoderMode_TI12, TIM_ICPolarity_Rising, TIM_ICPolarity_Rising); TIM_SetCounter(TIM3, 0); TIM_Cmd(TIM3, ENABLE); }代码里值得注意的几点:
GPIO配成浮空输入,因为编码器输出是开漏结构,外部模块上已经接了上拉电阻,所以不需要内部上拉,浮空输入反而更保险。
TIM_ICFilter设为0x0F是打开输入滤波,可以滤掉编码器输出上的高频毛刺。霍尔编码器在低速时输出沿可能会有抖动,滤波能有效减少计数异常。但滤波开得过大也会带来信号延迟,这个值实测在0x0C到0x0F之间比较合适。
TIM_EncoderMode_TI12是双边缘计数模式,也就是4倍频。如果只接单路信号,可以用TI1模式,那只有2倍频效果,分辨率会降一半。
2.4 转速计算与采样中断
测速用TIM2做定时采样,配置成10毫秒中断一次。中断里读取TIM3的计数值,计算和上次的差值,这个差值就是10毫秒内的编码器计数增量。
#define PULSE_PER_REV (13 * 4 * 30) // 编码器线数 * 4倍频 * 减速比 = 1560 #define SPEED_SAMPLE_MS 10 // 采样周期 10ms volatile int32_t g_encoder_delta = 0; volatile int32_t g_current_rpm_x10 = 0; int32_t g_last_cnt = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); int32_t cnt = TIM3->CNT; // 读取当前计数值 int32_t delta = cnt - g_last_cnt; // 计算增量 g_last_cnt = cnt; g_encoder_delta = delta; // 换算为 RPM * 10,避免浮点运算 g_current_rpm_x10 = (int32_t)((int64_t)delta * 60000L / PULSE_PER_REV); } }这个换算公式有人容易绕晕,我展开解释一下。delta是10毫秒内的计数增量,1秒内有100个10毫秒,所以每秒计数增量是 delta × 100。每转1560个计数,每秒转数就是 delta × 100 / 1560,每分钟转数是它的60倍,也就是 delta × 6000 / 1560。
为了在整数运算里保留一位小数精度,我最后乘以10,公式就变成 delta × 60000 / 1560。delta是正值代表正转,负值代表反转,后面PID直接把负值拿来做闭环就行。
注意:TIM3->CNT是16位的,范围0到65535。正转加计数、反转减计数,这个值会绕回。我用的是差值计算,只要两次读取间隔内不会超出65535的范围,直接做减法就能正确处理溢出。10毫秒内1560个计数,远小于65535,所以放心用。
3. PID算法原理与代码实现
3.1 位置式PID和增量式PID的差别
先看位置式PID的公式:
u(k) = Kp * e(k) + Ki * Σe(i) + Kd * [e(k) - e(k-1)]
它输出的是当前时刻应该给的绝对控制量。积分项是历史偏差的累加,一旦系统长时间达不到目标,积分值会越积越大,最后输出饱和,等偏差反向的时候,退出饱和又很慢,这就叫积分饱和。
增量式PID的公式长这样:
Δu(k) = Kp * [e(k) - e(k-1)] + Ki * e(k) + Kd * [e(k) - 2*e(k-1) + e(k-2)]
它输出的是“这次应该比上次多给多少”或者“少给多少”。实际输出要加上一次的输出:
u(k) = u(k-1) + Δu(k)
增量式的积分效应是隐式存在的,每次加一点,自然就完成了积分动作,但没有一个会无限增长的积分累加器,所以不会有积分饱和失控的问题。这就是它适合电机调速的核心原因。
3.2 增量式PID的完整实现代码
我把PID相关的逻辑封装成了一个结构体加两个函数,结构很清晰,方便以后移植到其他项目。
typedef struct { float Kp; float Ki; float Kd; int32_t target; // 目标值,比如 RPM*10 int32_t current; // 当前值,比如 RPM*10 int32_t error; // 当前偏差 int32_t error_prev; // 上一次偏差 int32_t error_prev2; // 上上次偏差 float result_prev; // 上一次输出值 float result; // 本次输出值 float out_min; // 输出下限 float out_max; // 输出上限 } PID_TypeDef; void PID_Init(PID_TypeDef *pid, float Kp, float Ki, float Kd, float out_min, float out_max) { pid->Kp = Kp; pid->Ki = Ki; pid->Kd = Kd; pid->target = 0; pid->current = 0; pid->error = 0; pid->error_prev = 0; pid->error_prev2 = 0; pid->result_prev = 0; pid->result = 0; pid->out_min = out_min; pid->out_max = out_max; } float PID_Calculate(PID_TypeDef *pid, int32_t target, int32_t current) { pid->target = target; pid->current = current; pid->error = pid->target - pid->current; // 增量式PID核心计算 float increment = pid->Kp * (pid->error - pid->error_prev) + pid->Ki * pid->error + pid->Kd * (pid->error - 2 * pid->error_prev + pid->error_prev2); pid->result = pid->result_prev + increment; // 输出限幅 if (pid->result > pid->out_max) pid->result = pid->out_max; if (pid->result < pid->out_min) pid->result = pid->out_min; // 保存历史偏差 pid->error_prev2 = pid->error_prev; pid->error_prev = pid->error; pid->result_prev = pid->result; return pid->result; }增量式PID里有一个容易忽略的细节:result_prev要始终保存上一次的实际输出值,也就是限幅之后的值,而不是限幅之前的计算结果。如果保存限幅前的值,碰到输出饱和时增量会被错误累积,系统会出现“全力输出但速度却达不到”的别扭情况。
3.3 输出限幅与抗积分饱和
我在PID结构体里定义了out_min和out_max,用于把PID输出限制在合法PWM占空比范围内。比如PWM的ARR是999,那输出就不能超过999,也不能低于0。
增量式PID配合输出限幅,天然就有抗积分饱和的效果。因为即使偏差一直很大,计算结果一次次增加,最终result也会被限制在out_max,不会有任何内部变量无限增长。
实际使用中还有一个更细节的处理,就是“停止积分”和“死区”。当目标转速很小、偏差在死区范围内时,直接让PID输出为0,防止电机在低速时因为PID频繁修正而产生来回抖动。死区大小一般取编码器一个计数对应的转速值就够了。
#define PID_DEAD_ZONE 5 // 死区,单位 RPM*10,即0.5RPM float speed_output = PID_Calculate(&pid, target_rpm_x10, g_current_rpm_x10); if (target_rpm_x10 < PID_DEAD_ZONE) { speed_output = 0; }这段逻辑可以写在调用PID之后,等于在“求稳”和“避免抖动”之间做个折中。电机启动阶段目标值大于死区,正常跑PID;停车时目标设为0,PID会在死区内直接截断,电机自然停在零速附近。
3.4 目标值、当前值到底用什么单位
我在代码里内部统一用“RPM×10”作为转速单位,也就是1200表示120.0转每分钟。这么做的原因有两个:整数运算不会丢精度;和显示、串口打印交互时,除以10就能得到常规RPM值。
很多人直接把编码器计数增量当目标值,这在负反馈闭环里也能调,但可读性太差了。你写个目标值500,别人根本不知道对应多少转。统一换算成RPM×10之后,调参时串口打印出来一目了然。
4. PWM驱动输出与电机控制链路
4.1 PWM定时器配置
我选TIM4,通道1和通道2分配到PB6和PB7,分别给电机的两个方向控制引脚。频率设置在20kHz左右,超过人耳可听范围,电机也不会吱吱响。
void PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period = 1000 - 1; // PSC=71, ARR=999, 20kHz TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; // 72MHz / 72 = 1MHz 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 = 0; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM4, &TIM_OCInitStructure); TIM_OC2Init(TIM4, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM4, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM4, TIM_OCPreload_Enable); TIM_Cmd(TIM4, ENABLE); }这里PWM频率计算是 72MHz / 72 / 1000 = 1000Hz?不对,我重新算一下。72MHz时钟,预分频器72-1,得到1MHz的计数时钟,自动重装值1000-1,所以PWM频率是 1MHz / 1000 = 1000Hz。要20kHz的话,ARR应该设为50-1:1MHz / 50 = 20kHz。上面代码我写的是1000-1,实际是1kHz,需要改。这里我在写博客时要写对:PSC=72-1,ARR=50-1,PWM频率20kHz。占空比分辨率是1/50,大约2%一档,对速度环够用。如果觉得分辨率不够,可以把PSC改成36-1,ARR改成100-1,频率还是20kHz,分辨率提升到1%。
需要修正代码中的注释:
TIM_TimeBaseStructure.TIM_Period = 100 - 1; // ARR TIM_TimeBaseStructure.TIM_Prescaler = 36 - 1; // PSC // 72MHz / 36 = 2MHz, 2MHz / 100 = 20kHz这样占空比分辨率100档,PID输出映射0到100更顺。
4.2 TB6612驱动逻辑
TB6612有两个输入脚控制一路电机,IN1和IN2。IN1高、IN2低时正转,反过来反转,两个相同就是刹车。PWM引脚给调速信号。
我把PID算出来的输出值做了一次方向拆分:
- 输出在0到+100,IN1=1,IN2=0,PWM_A=输出值
- 输出在-100到0,IN1=0,IN2=1,PWM_B=负输出值
这样的好处是零速附近不会出现PWM很小但方向错乱的问题。有人喜欢用一个PWM加H桥的两个方向脚直接处理反向,但为了代码直观,我用了“双PWM换向”的方案。
4.3 堵转保护与异常处理
闭环系统最怕的是编码器脱落或者电机卡死不转。如果目标转速很高,但反馈一直为零,PID输出会直接冲到上限,长时间这样电机就烧了。
我在10毫秒中断里加了一个简单的保护逻辑:
static uint16_t stall_count = 0; if (target_rpm_x10 > 200 && g_current_rpm_x10 < 50) { if (++stall_count > 500) // 持续1秒反馈异常 { TIM_SetCompare1(TIM4, 0); TIM_SetCompare2(TIM4, 0); // 这里可以点亮故障灯或者上报 stall_count = 0; } } else { stall_count = 0; }这个思路是:目标超过一定值但反馈持续很低,就判定为异常,主动切断输出。实际项目里还能加电流检测、温度检测,把保护做得更完整。
5. 参数整定与调试实录
5.1 试凑法调参的核心步骤
PID参数整定方法很多,什么齐格勒尼古拉斯法、极点配置法,但在一辆几十块钱的小电机上,这些理论方法反而不如试凑法来得快。试凑法的基本思路是:
第一步,把Ki和Kd设为0,只保留Kp。从小到大慢慢加,观察转速响应。一开始Kp很小,电机会慢慢悠悠接近目标,超调几乎为零。继续加大Kp,响应变快,但也开始出现超调。继续加,直到出现等幅振荡或明显的来回波动,记下这个临界Kp值,然后取它的50%到60%作为正式Kp。
第二步,固定Kp,加Ki。Ki的作用是消除稳态误差。如果启动后转速离目标差一截,就加Ki。从很小值开始加,直到稳态误差消失。但Ki过大,系统会低频振荡,听声音就像电机在“喘气”。
第三步,最后加Kd。Kd抑制超调,但如果电机本身噪声大、编码器反馈抖动厉害,Kd会把噪声放大,让系统更不稳定。所以Kd宁小勿大,甚至很多场合Kd直接为0也能接受。
我最终调出来的一组可用参数是Kp=3.0,Ki=0.15,Kd=0.05,输出限幅0到100。这个参数下,目标120rpm,启动大约600毫秒稳定,稳态误差不超过±2rpm,带轮负载后也能回到目标值附近。
5.2 我调参时遇到的两个典型问题
第一个问题是电机总是往一边全速转,关都关不住。排查半天发现是编码器的A、B相接反了,导致反馈始终是负值,偏差永远很大,PID输出一路往最大冲。把A、B两相换回来就正常了。所以调PID之前,一定要先确认反馈极性:给定一个正PWM,看编码器读数是不是正的。
第二个问题是转速一直比目标低,怎么加Kp都补不上。这是典型的缺少积分项,Ki没加够。静止状态下的电机摩擦力矩大于启动力矩,靠比例项是补不平这个静差的。加了Ki之后,每次偏差累加一点输出,慢慢就把转速顶上去了。
5.3 采样周期和PID周期
速度环的采样周期,我固定用10毫秒。这个周期需要和定时中断严格同步。有些朋友会选择在while(1)里延时调PID,这样延时不准,采样周期一抖动,调好的参数换个环境就失效。质量好的系统,控制周期一定要由硬件定时器保证。
采样周期的选择有个经验法则:速度环10到20毫秒,电流环更快,而温度这种大惯量对象可以用秒级周期。采样太快,每次增量太小,PWM分辨率不够,系统会抖;采样太慢,响应迟钝,PID的微分项容易引入滞后。
5.4 数据可视化对调参有多大帮助
调参如果只靠眼睛看电机转得快慢,效率太低。我强烈建议把目标值、当前值、PID输出通过串口按10毫秒一次的频率发出来,在电脑上用串口绘图工具或者Excel画出来。
看曲线你会发现,超调、振荡、静差这些现象非常直观。比如Kp偏大时,曲线会在目标值上下反复穿越,振幅逐渐增大;Ki过大时,曲线呈现低频长周期波动;Kd过大时,曲线在高频处全是毛刺。有了波形图,调参不再是玄学,而是一个“对着波形改参数”的工程过程。
6. 常见问题排查速查表
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 电机完全不动 | PWM没输出、使能脚没拉高、驱动板供电缺失 | 先用固定占空比测试PWM输出,不接PID直接给50%占空比看电机转不转 |
| 电机全速冲,关不住 | 编码器A/B接反、反馈方向错误 | 打印当前转速,和实际转速对比正负,调换A/B接线 |
| 转速总有静差 | Ki太小,或为0 | 逐步增加Ki,观察稳态误差是否减小 |
| 转速高频抖动 | Kd过大、编码器毛刺严重 | 减小Kd,增加编码器输入滤波,或降低采样周期的抖动 |
| 低速时来回嗡嗡响 | 死区没设置、PWM分辨率不够 | 加入死区判断,提高PWM分辨率,比如ARR从100提到200 |
| 一启动就过流保护 | PID输出到100太快、堵转保护太灵敏 | 给输出加变化率限制,或者延长堵转判断时间 |
| 用串口看数据全是乱码 | 波特率不匹配、电平转换问题 | 统一波特率,确认CH340或USB转串口模块接线正确 |
这张表是我自己踩过坑之后整理的,遇到问题先对着表查一遍,能省下很多无用功。
7. 完整工程代码与使用说明
7.1 主循环和控制逻辑
PID计算放在TIM2的10毫秒中断里,主循环只负责设置目标值和打印数据。中断里做完整速度闭环,主循环做上层逻辑,这种分层方式清晰且不易出错。
volatile int16_t g_target_rpm_x10 = 0; int main(void) { PWM_Init(); Encoder_Init(); Timer2_Init(); // 10ms定时中断 PID_TypeDef speed_pid; PID_Init(&speed_pid, 3.0f, 0.15f, 0.05f, 0.0f, 100.0f); g_target_rpm_x10 = 1200; // 目标 120.0 RPM while (1) { // 这里可以处理按键、串口指令、OLED显示等 // 修改 g_target_rpm_x10 即可改变目标转速 } }中断服务函数里完整逻辑:
void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); int32_t cnt = TIM3->CNT; int32_t delta = cnt - g_last_cnt; g_last_cnt = cnt; g_current_rpm_x10 = (int32_t)((int64_t)delta * 60000L / PULSE_PER_REV); float output = PID_Calculate(&speed_pid, g_target_rpm_x10, g_current_rpm_x10); int16_t pwm_val = (int16_t)output; if (pwm_val >= 0) { TIM_SetCompare1(TIM4, pwm_val); TIM_SetCompare2(TIM4, 0); // IN1=1, IN2=0 GPIO_SetBits(GPIOA, GPIO_Pin_4); // IN1 GPIO_ResetBits(GPIOA, GPIO_Pin_5); // IN2 } else { pwm_val = -pwm_val; TIM_SetCompare1(TIM4, 0); TIM_SetCompare2(TIM4, pwm_val); // IN1=0, IN2=1 GPIO_ResetBits(GPIOA, GPIO_Pin_4); GPIO_SetBits(GPIOA, GPIO_Pin_5); } // 可选:串口打印调试数据 printf("%d,%d,%d\r\n", g_target_rpm_x10, g_current_rpm_x10, (int16_t)output); } }7.2 移植到其他型号要注意什么
这套代码核心思路不依赖特定芯片,但移植到其他STM32型号时需要注意几点:
定时器编码器模式只在一部分高级和通用定时器上才有,换到G0系列或者L4系列要查数据手册确认。PWM定时器和编码器定时器不能共用同一个,否则互相干扰。中断优先级也要合理分配:编码器读取的速度环中断优先级稍高一点,串口和显示任务的优先级低一点,避免测速被阻塞。
如果换了无霍尔传感器的电机,就不能用编码器模式,需要改用ADC采样反电动势的方案,那属于无感FOC的范畴,和这里完全不同。本文这套方案只适用于带编码器反馈的直流电机。
7.3 还能怎么扩展
这个速度闭环的底层做好了,往上层扩展非常方便。加一个位置环,就是定长裁切、机械臂关节控制;加一个转向环,就是平衡小车、自平衡云台;把速度环输出接到FOC的电流环上,就是无刷电机伺服的基础结构。
我自己后续把这套代码用在了实验室的简单机械臂上,速度环外再套了一个位置环,用级联PID实现了点到点的角度控制。位置环跑在50赫兹,速度环跑在100赫兹,整体非常稳定。如果你要做类似的项目,先验证好速度环这一层,再往上叠位置环,这样出问题好定位。
最后再分享一个我在实际使用中的体会:调PID参数,不要贪快。先把反馈数据打印出来,确认测速没问题;再固定一个目标值,一个参数一个参数地调;每改一次参数,必须重启系统重新加压,因为有些参数在热机状态下的表现和冷启动完全不同。做到这三步,再复杂的闭环系统也能被你一点点啃下来。