☰
STM32电机转速闭环控制:PID原理、代码与调参实战
2026/9/28 16:55:55 网站建设 项目流程

做电机控制这些年,最让我头疼的问题其实不是电机不转,而是转速不稳定。空载的时候转到 1200RPM,一加载瞬间掉到 900RPM,PID 参数没调好还会持续抖动,听起来就像电机在“打摆子”。后来我把控制器从 C51 换到 STM32,重新梳理了控制方案,把测速、PWM 驱动、PID 算法这一整套链路都走通之后,才明白问题的关键不在于“用 PID”,而在于“PID 用在一个闭环系统里”,前面的测速准不准、控制周期稳不稳、输出限幅做没做,每一项都在决定最终效果。这篇文章就从 STM32 的实际工程出发,把 PID 算法控制电机转速的原理、完整代码、调参过程一次讲清楚。适合刚接触 STM32 电机控制的同学,也适合那些已经在用 PID 但总是“调不出来”的朋友,你可以直接照着下面这套思路和代码搭项目,然后再去改自己的参数和逻辑。

1. 开环控制与闭环控制的差距,就是转速稳不稳的根源

先看一个很多人忽略的事实:如果把 PWM 占空比固定,电机的转速会随着负载、电源电压、温度的变化发生明显漂移,这是开环控制的天然缺陷。你用 50% 占空比驱动直流电机,空载时转速是 1000RPM,用手捏住轴之后转速可能掉到 600RPM,但 PWM 仍然输出一样的占空比,系统并不知道转速掉了。开环控制只“控制输出去”,不“关心输出结果”,所以不能解决转速稳定问题。

闭环控制的思路则是不断测量当前转速,和目标转速比较,用误差值计算新的 PWM 输出。误差大了就增大输出,误差小了就减小输出,这样即使负载变化,系统也会自动补偿。PID 算法就是这个“根据误差计算输出”的决策器,它负责回答三个问题:

  • 当前误差有多严重?——比例项P
  • 误差积累了多少历史欠账?——积分项I
  • 误差变化趋势是什么样的?——微分项D

很多人以为 PID 就是三个系数调一调,其实先要把控制架构搭对。STM32 做电机转速控制,典型闭环链路如下:

目标转速 -> 误差计算 -> PID控制器 -> PWM输出 -> 电机驱动 -> 电机 | 实际转速 <—— 编码器测速 <—— 编码器 <——电机轴

这也是我在这篇里要反复强调的核心框架:测速、执行、控制三大块缺一不可。实际做项目时,我见过不少同学把 PID 代码抄过来,却发现转速反馈根本是乱的,要么编码器没配置成正交模式,要么 PWM 频率错了电机啸叫。所以下两节先讲硬件和测速,它们和 PID 同样重要。

2. 硬件选型与接口设计,决定了 PID 能跑多稳

2.1 最小系统、电机与驱动器的搭配

我做这套实验时用的主控是 STM32F103C8T6,也就是最常见的“蓝色板”,价格便宜、资料多、定时器资源也比较丰富,用来驱动一路直流电机完全够用。电机我选的是带霍尔编码器的直流减速电机,常见型号如 MG513P30,大概几十块钱,自带 AB 相霍尔编码器输出脉冲,用来测转速非常方便。驱动板用 TB6612 或 L298N 都可以,推荐 TB6612,因为它的导通压降小、发热低,对电池电压利用更好。

电源方面需要注意:电机和单片机最好分开供电。STM32 用 USB 的 5V 或者单独 3.3V 稳压,电机用 6V~12V 电源,然后共地。如果不共地,编码器脉冲在传输时可能因为参考地不一致产生毛刺,直接导致测速抖动,这个我在实际调试中踩过坑,后面会展开说。

2.2 引脚分配与定时器选型逻辑

STM32 定时器资源非常多,但也不要随意分配。我最终采用的是:

  • 定时器 1 的 PWM 输出:PA8 输出 PWM,用来控制电机速度。
  • 定时器 2 的编码器接口模式:PA0、PA1 接编码器 A、B 相,用于测速。
  • 串口 1:PA9、PA10,用来打印目标转速、实际转速和 PID 输出,方便调试。
  • 一个普通 GPIO:接按键,用来切换目标转速或停止电机。

为什么用定时器 2 做编码器接口模式而不是外部中断?因为编码器接口是硬件自动处理 A 相和 B 相的关系,正反转、四倍频计数都不需要 CPU 干预,你只需要定时读取计数值,CPU 负担非常小。如果用外部中断手动数脉冲,转速高的时候很容易丢脉冲,而且代码复杂度也高。

硬件连线表如下:

模块STM32引脚说明
TB6612 PWMAPA8PWM调速信号
TB6612 AIN1PB0方向控制
TB6612 AIN2PB1方向控制
编码器 A 相PA0定时器2通道1
编码器 B 相PA1定时器2通道2
编码器 VCC/GND3.3V/GND注意编码器工作电压,部分电机需5V
串口1 TXPA9调试信息输出
串口1 RXPA10接收指令(可选)

2.3 PWM 频率的选择细节

电机驱动 PWM 频率不是一个可以随便定的值。我用的是 20kHz,这个频率超出了人耳可听范围,电机不会发出尖啸声。如果 PWM 频率设为几百赫兹,电机会发出刺耳的“吱吱”声,负载越大声音越明显。同时,频率太高也不一定好,比如 TB6612 这类驱动芯片的开关损耗会增加,而且电机电感在高频下对电流的平滑作用已经足够,不需要远超 20kHz。实际经验是 10kHz~20kHz 都合适,我固定用 20kHz。

对应到代码上,就是配置定时器 1 的 ARR 为 899,PSC 为 3,在 72MHz 主频下得到 20kHz 的 PWM 频率。计算方式:

PWM频率 = 72MHz / ((ARR + 1) * (PSC + 1)) = 72e6 / (900 * 4) = 20000 Hz

占空比通过 CCR 寄存器控制,范围 0~899。这个数值就是 PID 输出最终要写入的变量。

3. 编码器测速不仅要测准,还要算得快

3.1 为什么测速环节不能随便做

PID 算法依赖“实际转速”这个反馈量。如果反馈值有延迟、有毛刺、单位不对,后面调得再努力也白搭。我在项目里最先尝试的是定时器外部脉冲计数法,也就是用一个定时器固定 1 秒统计另一个通道输入的脉冲数,然后算转速。这个方法最简单,但存在一个明显问题:转速刷新周期是 1 秒,PID 的控制周期远远小于 1 秒,导致整个控制系统相当于在“睁一只眼闭一只眼”——反馈信息严重滞后,电机很容易超调或振荡。

后来我改用 STM32 定时器的编码器接口模式,它在硬件层面处理 A/B 相信号,可以实现四倍频计数,而且我可以把控制周期设到 5ms~20ms,每 10ms 读取一次计数并计算转速,反馈新鲜度大幅提升。这个方案非常适合直流电机自带霍尔编码器的场景。

3.2 编码器接口模式的初始化要点

在 STM32CubeMX 里配置非常简单,但我更建议你理解底层寄存器。下面是定时器 2 编码器模式的初始化代码,基于标准外设库风格,Keil5 工程可以直接用:

void Encoder_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructInit(&TIM_TimeBaseStructure); TIM_TimeBaseStructure.TIM_Prescaler = 0; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 65535; // 16位计数器 TIM_TimeBaseInit(TIM2, &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 = 0x0A; // 输入滤波,抗毛刺 TIM_ICInit(TIM2, &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 = 0x0A; TIM_ICInit(TIM2, &TIM_ICInitStructure); TIM_EncoderInterfaceConfig(TIM2, TIM_EncoderMode_TI1, TIM_EncoderMode_TI2, TIM_ICPolarity_Rising); TIM_SetCounter(TIM2, 32768); // 设置中点,方便处理负值 TIM_Cmd(TIM2, ENABLE); }

这里把计数器初始值设置为 32768,是因为编码器计数可能朝两个方向走,设置到中间值可以方便处理反转。实际读取时把TIM_GetCounter(TIM2)减掉 32768,得到的就是有符号的脉冲增量。

3.3 从脉冲数换算成转速

霍尔编码器每圈输出的脉冲数取决于电机型号。比如我的 MG513P30,减速比是 1:30,编码器装在电机轴上,电机轴每转一圈霍尔输出 13 个脉冲(有些是 11,看具体规格),那么在输出轴测到的脉冲数就是 13 × 30 = 390,经过定时器四倍频后,每转一圈输出轴对应的计数增量是 13 × 30 × 4 = 1560。

我设定每 10ms 读取一次计数器并清零,因此 10ms 内测到的脉冲数delta,对应的转速可以用下面公式计算:

转速(RPM) = delta * 100 * 60 / 1560

100 是 10ms 与 1 秒的换算倍数,60 是秒钟与分钟的换算,1560 是每圈脉冲数。总结成代码:

int16_t delta = TIM_GetCounter(TIM2) - 32768; // 有符号增量 TIM_SetCounter(TIM2, 32768); float speed_rpm = (float)delta * 6000.0f / 1560.0f;

这个方法的核心是“定时清零计数并重算”,保证每次读取到的都是最近 10ms 内的增量。注意TIM_SetCounter不能在计数过程中影响硬件后续计数,实际测试下来没问题,因为读和写都是 16 位操作,中断优先级够高就不会丢脉冲。

3.4 没有编码器怎么办:测频法和测周法

如果电机没有编码器,也可以用霍尔传感器或者光电传感器测转速,思路与编码器类似。STM32 做外部脉冲计数有两种典型办法:

  • 测频法:1 秒内统计外部脉冲个数,适合高转速。公式是转速 = 脉冲数 / (每转脉冲数 * 时间)。
  • 测周法:用定时器捕获两个相邻上升沿之间经过的微秒数,适合低转速。转速越低,脉冲周期越长,测周法越准。

我的建议是:做闭环控制尽量用编码器,没有编码器可以先估算,但控制效果会差很多。测频法数据更新慢,测周法在转速突变时容易产生尖峰,都比不上编码器接口模式好用。

4. PID 控制器原理与离散化实现,逻辑比公式更重要

4.1 先理解三个项的“性格”

PID 控制器的连续表达式大家都熟:

u(t) = Kp * e(t) + Ki * ∫e(t)dt + Kd * de(t)/dt

但真正写进单片机的必须是离散化版本。理解三作用时,我习惯这样类比:

  • P(比例项):看到现在。误差大,输出就大。但它只会“跟随”,不会“记住”,所以单独用会产生稳态误差。
  • I(积分项):记住过去。把历史上所有误差累加起来,用来消除稳态误差。问题在于容易积分过头,产生超调,所以需要限幅。
  • D(微分项):预测未来。误差变化越快,D 输出越大,用来抑制超调,但也会放大噪声。

对于电机转速控制,标准的位置式 PID 够用,但实际工程里我更推荐增量式 PID,它输出的是“PWM 增量”,不是最终的 PWM 值,天然不会出现积分饱和失控的问题,而且从代码角度看更容易理解。

4.2 增量式 PID 的离散化推导

增量式 PID 公式并不复杂。先记住位置式:

u(k) = Kp * e(k) + Ki * sum(e(k)) + Kd * (e(k) - e(k-1))

再写上一拍:

u(k-1) = Kp * e(k-1) + Ki * sum(e(k-1)) + Kd * (e(k-1) - e(k-2))

两个式子相减,得到增量式:

du(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) + du(k)

注意,这里的u(k)对应 PWM 的比较值CCR,它必须限幅在 0~ARR 范围内,否则 PWM 占空比超过 100% 或变成负值,系统就会出问题。增量式的好处是du通常较小,我们可以在u(k-1)累加后再整体限幅,实现抗积分饱和。

4.3 代码结构:从结构体到控制函数

我习惯把 PID 相关状态封装成结构体,这样项目大了以后可以同时控制多个电机而不需要复制代码。定义如下:

typedef struct { float target; // 目标转速,单位RPM float feedback; // 实际转速,单位RPM float err_now; // 当前误差 float err_last; // 上一次误差 float err_prev; // 上上次误差 float Kp; float Ki; float Kd; float du; // 本次增量 float output; // PWM比较值输出 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->target = 0.0f; pid->feedback = 0.0f; pid->err_now = 0.0f; pid->err_last = 0.0f; pid->err_prev = 0.0f; pid->Kp = Kp; pid->Ki = Ki; pid->Kd = Kd; pid->du = 0.0f; pid->output = 0.0f; pid->out_min = out_min; pid->out_max = out_max; }

每一次控制周期都调用PID_Calc:

float PID_Calc(PID_TypeDef *pid, float target, float feedback) { pid->target = target; pid->feedback = feedback; pid->err_prev = pid->err_last; pid->err_last = pid->err_now; pid->err_now = pid->target - pid->feedback; pid->du = pid->Kp * (pid->err_now - pid->err_last) + pid->Ki * pid->err_now + pid->Kd * (pid->err_now - 2.0f * pid->err_last + pid->err_prev); pid->output += pid->du; if (pid->output > pid->out_max) pid->output = pid->out_max; else if (pid->output < pid->out_min) pid->output = pid->out_min; return pid->output; }

需要留意的是,我这里的err_prev保存的是上上拍的误差。变量名有点绕但含义清楚。如果你让别人维护代码,建议注释写清楚,否则后面调参时容易看晕。

4.4 为什么我不用位置式 PID

位置式 PID 需要累加所有历史误差,这就导致一个问题:如果电机长时间堵转,误差一直很大,积分项会无限增大,等堵转解除后控制器还保持一个很大的输出,电机猛地冲出去,非常危险。增量式 PID 的输出增量不受过去总量限制,只要误差减小,du就会改变方向,系统恢复更快。对于转速控制这种“执行机构可以快速变化”的场景,增量式更可靠。

当然位置式 PID 也不是一无是处,某些需要精确位置控制的场景下位置式更容易做目标规划。但如果你是入门光电编码器直流电机控制,增量式是我首推的方案。

5. 完整实战代码:主循环、定时中断与串口调试三件套

5.1 工程结构规划

实际工程里不要把所有代码塞进 main.c。我按功能模块拆开:

文件功能
main.c初始化与主循环
encoder.c/h编码器初始化与速度读取
pwm.c/hPWM输出初始化与占空比设置
pid.c/hPID结构体与运算
uart.c/h串口打印与调试

下面直接给出一份可以编译运行的核心代码框架,适配 Keil5 + STM32F103C8。

5.2 PWM 初始化代码

void PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_TIM1, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_8; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Prescaler = 3; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 899; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM1, &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(TIM1, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable); TIM_CtrlPWMOutputs(TIM1, ENABLE); // TIM1为高级定时器,必须使能主输出 TIM_Cmd(TIM1, ENABLE); }

注意 TIM1 是高级定时器,比通用定时器多一步TIM_CtrlPWMOutputs(TIM1, ENABLE),这一步漏掉的话 PWM 死活没输出,很多新手在这里卡一整天。

5.3 10ms 控制周期的定时器中断

PID 控制周期不能放在主循环里靠延时凑,那样会有 jitter,导致控制效果不稳定。我用 TIM3 做 10ms 定时中断:

void Control_TIM_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseStructure.TIM_Prescaler = 719; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 999; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); TIM_Cmd(TIM3, ENABLE); }

计算方式:72MHz / (720 * 1000) = 100Hz,也就是 10ms 一次。

5.4 定时中断里执行控制逻辑

核心控制逻辑写在TIM3_IRQHandler中,每次进入先清中断标志,然后读编码器、计算实际转速、调用 PID、设置 PWM:

PID_TypeDef speed_pid; extern float current_speed_rpm; extern float target_speed_rpm; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); // 1. 读取10ms内脉冲增量 int16_t delta = (int16_t)(TIM_GetCounter(TIM2) - 32768); TIM_SetCounter(TIM2, 32768); // 2. 换算转速 current_speed_rpm = (float)delta * 6000.0f / 1560.0f; // 3. 调用PID,得到PWM占空比比较值 float pwm_out = PID_Calc(&speed_pid, target_speed_rpm, current_speed_rpm); // 4. 写入PWM寄存器 TIM_SetCompare1(TIM1, (uint16_t)pwm_out); // 5. 串口打印 printf("target:%.1f rpm, speed:%.1f rpm, pwm:%.0f\r\n", target_speed_rpm, current_speed_rpm, pwm_out); } }

printf走串口需要重定向fputc,这部分属于 Keil 标准做法,我在文末的完整代码里一并给出。

5.5 主循环与目标转速设置

主程序里初始化各模块后,可以在一个死循环里轮询按键,调整目标转速。下面这个例子简单直接:

int main(void) { NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); UART_Init(115200); PWM_Init(); Encoder_Init(); Control_TIM_Init(); PID_Init(&speed_pid, 2.0f, 0.3f, 0.1f, 0.0f, 899.0f); target_speed_rpm = 1000.0f; current_speed_rpm = 0.0f; printf("System start, target=1000rpm\r\n"); while (1) { // 这里可扩展按键逻辑、蓝牙/串口指令等 } }

代码的主体到这里已经完整。之所以串口打印放在中断里,是因为控制周期 10ms 时打印频率足够高,能清晰观察转速变化曲线;如果你觉得刷屏太快,也可以改成 100ms 打印一次,但要另做一个计数标志。

6. 调参实战:从振荡到稳定的完整链路

6.1 先定控制周期,再谈 PID 参数

不少人的调参顺序是“先调 PID 参数再看效果”,其实第一步应该先确认控制周期。控制周期太长,反馈滞后严重,不管你怎么调 P 都会振;控制周期太短,测速增量可能经常为 0,导致 PID 输出抖动。对中等惯性的直流电机转速系统,我建议从 10ms 开始,然后根据响应表现微调。我用 10ms 控制周期、20kHz PWM 频率的组合,整体响应和稳定性都不错。

6.2 经验整定法:先纯 Kp,再加 Ki,最后碰 Kd

下面是我常用的一套“三阶段整定法”,分享出来供你参考:

  1. 先把 Ki 和 Kd 设成 0,Kp 从一个比较小的值开始。逐渐加大 Kp,观察转速曲线。当我看到电机在目标转速附近来回震荡,且振幅不再收敛时,把当前 Kp 记下来,取它的一半作为最终 Kp 参考值。

  2. 加入 Ki。从小值开始,比如当前 Kp 的 10% 左右,观察稳态误差是否消失。如果转速最终稳定在目标值附近 no error,就说明 Ki 有效;如果出现持续低频振荡,说明 Ki 太大,减半再试。

  3. 最后加 Kd。小负载系统其实不加 Kd 也可以,但加入一定的 Kd 可以抑制超调。Kd 约取 Kp 的 5%~10%,如果发现电机噪声变大、PWM 输出跳动频繁,说明 Kd 偏大,立即减小。

我在这套硬件上最终用的参数是 Kp=2.0、Ki=0.3、Kd=0.1,在这个参数下,空载转速从 800RPM 直接给定到 1000RPM,大约 150ms 内稳定,超调量不到 5%,稳态误差在 ±3RPM 以内,表现足够满足大多数教学和轻负载场景。

6.3 从串口波形判断系统状态

调参时眼睛盯着串口数字不如看曲线直观。STM32 串口打印出来的数据可以先通过串口助手或虚拟示波器软件显示。我用的是 VOFA+ 之类的免费工具,把 target 和 speed 两列数据画成实时曲线,这样能够明显看出:

  • 转速曲线是否发散;
  • 是否收敛但慢;
  • 是否超调后持续振荡;
  • 是否有高频抖动。

根据曲线形态来调参,比盲目改参数高效得多。下面我整理了一个常见问题排查表:

现象可能原因解决方案
转速始终达不到目标Ki 太小或输出限幅太小加大 Ki,检查 PWM 最大值是否被限死
转速来回震荡Kp 太大或控制周期太长减小 Kp,缩短控制周期
启动瞬间猛冲积分饱和或 Kd 偏小使用增量式并限幅,必要时加 Kd
稳态是锯齿状抖动测速量化误差太大提高编码器线数,或增加滤波
负载变化后恢复慢Ki 偏小适当加大 Ki

6.4 我踩过的几个硬坑

这里想重点说几个实操中非常容易踩的坑,它们跟 PID 本身关系不大,但会直接毁掉整个控制效果。

第一个是编码器输入滤波没有配置。我的编码器线比较长,又没有加 RC 滤波,转速快速变化时 PA0/PA1 上会出现毛刺,定时器计数值偶尔跳变,导致转速反馈突然从 1000RPM 变成 1200RPM 又跳回来。PID 看到这种虚假误差会不断施加修正,最终表现为电机咔咔响。解决方法是把 GPIO 配置为浮空输入的同时,在定时器输入捕获里配置TIM_ICFilter = 0x0A,这个值相当于给输入加了数字滤波器,能有效滤除窄毛刺。

第二个是 PID 输出和 PWM 寄存器类型不匹配。我用float pwm_out,但TIM_SetCompare1形参是uint16_t,如果你忘了做类型转换,负值会变成很大的无符号数,电机直接全速反转。必须保证 PID 输出被限幅在0~899,再传给 PWM 寄存器。

第三个是电机和单片机不共地。刚开始我把电机电源、单片机的 USB 电源分开供电,结果编码器信号一直不稳定。后来把两个电源的 GND 连在一起,毛刺问题立刻缓解。测速系统一定要保证稳定的参考地电势。

第四个是关于串口打印的影响。如果调试信息打印频率太高,会影响控制中断的实时性。我把串口波特率提到 115200,并且避免在打印函数里做浮点格式化以外的耗时操作,基本没事。但如果你的控制周期是 1ms,打印 50 个字符在 115200 波特率大约耗时 4ms,那就会挤占控制时间,这时候要么降低打印频率,要么用 DMA 发送。

6.5 参数整定过程实录

最后分享一次我在这套平台上的完整调参记录,帮你建立“调参手感”。初始设 Kp=1.0、Ki=0.1、Kd=0,目标转速 1000RPM。上电后转速从 0 爬升到 1100RPM,然后缓慢降到 990RPM,来回滚了 3 个周期才稳定,说明 P 偏大、I 略大。把 Kp 降到 0.6 之后,超调减少,但仍然有稳态误差 15RPM。把 Ki 升到 0.2,稳态误差逐渐消除,但负载突变后恢复慢。加上 Kd=0.05 后,启动超调被明显压制。最后微调成 Kp=2.0、Ki=0.3、Kd=0.1 时,系统响应和抗负载扰动都做到比较满意的状态。整个过程大概花了 40 分钟,主要时间花在等待转速稳定上,所以调整后要观察足够长的时间,不要只看一两秒就急着改参数。

7. 从速度环到更复杂的系统:后续还能怎么扩展

速度环调好之后,如果你想继续深入,最自然的扩展方向是位置环与速度环的级联控制。外环是位置环,内环是速度环,位置环输出的是目标转速,速度环再输出 PWM。这种两级结构在云台、小车、机械臂里都很常见,代码上只需要再加一个 PID 结构体,控制周期通常外环 50ms,内环 10ms,形成级联。

另一个值得改进的是前馈控制。对于阶跃目标变化,PID 总是要等误差出现后才作出反应,这会导致响应偏慢。如果已知电机大致的“PWM-转速”关系,可以把理论前馈量直接加到 PID 输出上,即u = u_pid + feedforward,这样目标突变时能快速接近目标值,再由 PID 修正剩余误差。前馈项不是必须的,但加到系统里之后,我的启动响应时间又缩短了约 30%。

当然,PID 并不是万能的,如果你的电机带有严重的非线性摩擦,或者控制对象延时很大,可以考虑使用更高级的算法,比如模糊 PID、自适应控制。但这些算法的前提仍然是有一个可靠的测速链路和稳定的控制周期,所以我建议先把最基础的 PID 做得足够扎实,再往上层扩展。

我自己做完这个项目之后最大的体会是:PID 参数不是调出来的,而是“看”出来的。把反馈数据用串口可视化之后,你每一次改动都会有一个明确的方向。对于想从零开始做 STM32 电机控制的朋友,照这套思路,硬件搭一个最小系统,软件按测速、PWM、PID 三个模块独立实现,然后慢慢把不同模块接起来,你会发现表面上复杂的电机控制,其实每一步都有清晰的逻辑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询