1. 从需求出发:这套直流电机闭环到底解决什么问题
做电机控制的朋友应该都有过这种体会:开环调速就是"看天吃饭",电池电压掉一点、负载变一点、电机温度升一点,转速立马跟着飘。想让一台普通的直流有刷电机稳稳停在设定转速上,还得靠闭环反馈,而PID控制就是这套反馈里最实用、最容易落地的算法。这次要聊的这套东西,核心就是用STM32配合HAL库和CubeMX,把一台直流电机的转速闭环跑起来——从PWM驱动、编码器测速,到PID参数整定,整条链路手把手走一遍。
这套方案适合谁?如果你正在做STM32项目、毕业设计,或者想给自己的小车、云台、传送带做恒速控制,又或者单纯想把裸机PID控制这件事吃透,那这篇内容基本能覆盖你的需求。它不需要多高深的控制理论功底,只要你会点C语言、能看懂定时器和GPIO的基础操作就行。我会尽量把每个"为什么这么做"讲清楚,让你调参数的时候心里有底,而不是靠瞎试。
先说清楚这套方案的边界:控制对象是永磁有刷直流电机(带霍尔编码器或光电编码器),驱动方式用常见的H桥驱动模块,主控用STM32F103系列,软件层用HAL库加CubeMX生成框架,PID用位置式或增量式都可以。整套硬件成本不高,代码也完全能自己复现代码。
2. 整体方案设计与选型思路
2.1 为什么是STM32 + HAL库 + CubeMX这套组合
STM32F103这颗芯片在电机控制圈子里属于"万金油"级别的存在:72MHz主频,带多个高级和通用定时器,每个定时器都能输出多路PWM,还自带编码器接口模式。做单电机闭环,资源绰绰有余。选它不是因为性能多强,而是因为资料多、踩坑成本低,出了问题随手一搜就有答案。
HAL库相比早期的标准库和寄存器操作,最大的优势是可移植性和开发速度。写电机控制最怕什么?最怕底层寄存器配错,一个AFIO重映射没搞对,PWM就是不出波形。HAL库把这些容易翻车的细节封装成了函数,你只要在CubeMX里点几下,初始化代码自动生成,能省掉大量调试时间。有人担心HAL库效率低,实话讲,对于转速环这种控制频率在1kHz左右的场合,HAL的执行开销完全不影响,真正跑起来毫秒级的控制周期里,多几条指令根本不敏感。
CubeMX则是把时钟树、外设、引脚分配这些"配置苦力活"图形化了。它的价值不在于帮你写逻辑代码,而在于帮你生成一份不会出低级错误的初始化框架。
注意:HAL和LL库可以混用。如果你后面发现PWM更新或编码器读取有实时性要求,可以把关键中断里的操作换成LL库的API,速度会快不少,但初版完全没必要。
2.2 直流电机转速闭环的完整链路拆解
把整条链路摊开看,就是一条"指令到执行到反馈"的环路:
- 设定值:你想要的目标转速(单位可以是RPM,也可以是编码器脉冲数/采样周期)。
- 控制器:PID算法,输入是设定值与实际值的偏差。
- 执行器:定时器输出的PWM波,占空比决定电机平均电压。
- 被控对象:直流电机,PWM占空比变化会改变电枢电压,进而改变转速。
- 传感器:编码器,实时测量电机实际转速。
- 反馈回路:把编码器测得的转速送回PID输入端,形成闭环。
这里有个关键点:PWM占空比改变的其实是电机的平均电枢电压,不是直接给转速。电网电压、负载转矩、电机内阻都会影响占空比到转速的传递关系,这也是开环不稳定的根源。闭环的意义,就是让PID自动去补偿这些扰动。
2.3 控制频率和采样周期的取舍
转速环的控制频率不用太高,一般1kHz(也就是1ms一次)就足够应付大多数有刷电机。为什么?
- 频率太高(比如10kHz):1ms内编码器可能只测到几个脉冲,量化误差大,PID算出来的偏差跳变严重。
- 频率太低(比如100Hz):响应慢,遇到扰动恢复时间长,感觉"发木"。
1kHz是个比较舒服的平衡点:用M法测速(固定时间数脉冲),1ms的采样窗口里,假设编码器分辨率够,能数到足够多的脉冲保证精度。实测下来,大多数带霍尔编码器的电机在这个频率下表现都不错。
3. CubeMX工程配置与底层外设搭建
3.1 时钟树和外设基础配置
第一步新建工程,选好芯片型号(比如STM32F103C8T6),进来先把系统时钟搞定。用外部晶振的话,HSE接8MHz,经过PLL倍频到72MHz,AHB、APB1、APB2按标准分频走:APB1留36MHz,APB2和主频保持72MHz。这个别乱改,尤其APB1定时器时钟要注意,它有个倍频关系,APB1分频不为1时定时器时钟是APB1时钟的2倍,别算错了。
时钟树配完,把调试口SWD打开,不然烧录一次之后可能锁死。这一步新手特别容易忽略,一旦把SWD的引脚复用成别的功能又没留调试口,芯片就进不去调试了,只能靠BOOT0跳线救回来。
3.2 PWM输出的定时器配置要点
PWM这块,用一个通用定时器(比如TIM3的某个通道)。模式选PWM Generation CHx,关键参数两个:
- Prescaler(预分频):先确定PWM频率。假设定时器时钟72MHz,想要20kHz的PWM频率,那总计数 = 72M / 20k = 3600。预分频和自动重装载值(ARR)的乘积约等于3600。比如预分频设0(不分频),ARR设3599,计数频率就是72MHz,每3600个数一个周期,正好20kHz。
- Counter Period(ARR):决定PWM分辨率。ARR越大,占空比调节越细,但频率会降低。20kHz配3600的分辨率,占空比能调到约0.028%的精度,够用了。
为什么选20kHz?因为低于20kHz人耳能听到电机的"啸叫",高于20kHz又会让MOS管开关损耗上升,20kHz左右是听觉和效率的折中点。当然,如果你的驱动器是专门的驱动芯片,按芯片手册推荐的频率来更稳妥。
// CubeMX生成的PWM初始化片段(关键部分) htim3.Instance = TIM3; htim3.Init.Prescaler = 0; htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 3599; // 72MHz / 3600 = 20kHz htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; HAL_TIM_PWM_Init(&htim3); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1);启动PWM用HAL_TIM_PWM_Start,改变占空比就调__HAL_TIM_SET_COMPARE,这个宏比HAL库函数快,适合放在控制中断里频繁调用。
3.3 编码器接口模式的配置细节
编码器是闭环的"眼睛",配不好后面全是坑。STM32的定时器自带Encoder Interface Mode,把编码器的A、B两相接到定时器的两个通道上,硬件自动计数,不用写中断去数脉冲,省CPU又准。
CubeMX里把对应定时器(比如TIM2)的Combined Channels选成Encoder Mode,然后:
- Encoder Mode选TI1 and TI2(两相都计数,四倍频,分辨率最高)。
- Polarity两个通道都设Rising。
- Counter Period设成65535(16位最大值)。
- 编码器输入最好加输入滤波(Input Filter),设个几十的滤波值,能滤掉机械抖动引起的误计数。
这里有个隐藏坑:四倍频意味着实际脉冲数 = 编码器线数 × 4 × 减速比。比如编码器13线,减速比30,那么转一圈电机输出轴对应13×4×30 = 1560个计数。算转速时一定要用这个真实值,不然算出来的RPM直接错几倍。
3.4 串口调试通路和定时中断
调试时总得看数据,开一个USART,配上DMA发送,把转速、占空比这些变量周期性打印出来。另外要开一个定时器中断作为控制周期的心跳,比如TIM1或某个基本定时器,1ms进一次中断,在中断里做测速和PID计算。
提示:控制中断的优先级要给高一点,但别和编码器相关的中断冲突。如果测速用M法(定时读计数差值),编码器根本不用中断,中断里只干"读计数、算速、跑PID"三件事就行。
4. PID算法实现与参数整定
4.1 位置式还是增量式,怎么选
位置式PID输出的是绝对占空比,公式是:
output = Kp·e + Ki·Σe + Kd·(e - e_last)
增量式PID输出的是占空比的增量:
Δoutput = Kp·(e - e_last) + Ki·e + Kd·(e - 2e_last + e_last2)
两者在转速环里都能用。位置式思路直观,但对积分项处理要小心,容易积分饱和;增量式天然带积分限幅效果,适合执行器本身带"记忆"(比如电机占空比不会突变)的场合。我的建议是初学先用位置式,把积分限幅和抗饱和做扎实,理解起来更顺;等熟练了再切增量式对比效果。
4.2 代码实现里的几个关键处理
直接贴一份我常用的位置式PID结构体和实现,注释里标注了几个容易做错的地方:
typedef struct { float Kp, Ki, Kd; float target; // 目标转速 float actual; // 实际转速 float err; // 当前偏差 float err_last; // 上次偏差 float integral; // 积分累加 float integral_max; // 积分限幅 float output; // 输出占空比 float output_max; // 输出上限 float output_min; // 输出下限 } PID_TypeDef; float PID_Calc(PID_TypeDef *pid, float target, float actual) { pid->target = target; pid->actual = actual; pid->err = pid->target - pid->actual; // 积分累加并限幅,防止积分饱和 pid->integral += pid->err; if (pid->integral > pid->integral_max) pid->integral = pid->integral_max; if (pid->integral < -pid->integral_max) pid->integral = -pid->integral_max; // 位置式PID计算 pid->output = pid->Kp * pid->err + pid->Ki * pid->integral + pid->Kd * (pid->err - pid->err_last); pid->err_last = pid->err; // 输出限幅,映射到PWM占空比范围 if (pid->output > pid->output_max) pid->output = pid->output_max; if (pid->output < pid->output_min) pid->output = pid->output_min; return pid->output; }几个必须注意的点:积分限幅不做,电机一堵转积分就会累加到天上,松开的瞬间电机猛冲;输出限幅不做,极端情况下占空比溢出可能让驱动模块直接保护甚至损坏。另外Kd那一项如果传感器噪声大,会放大抖动,必要时对偏差做一阶低通滤波再送进微分项。
4.3 Kp、Ki、Kd三个参数到底怎么调
这是最多人卡住的地方。我推荐先P后I再D的顺序,而且每一步都要"单变量"调,别三个一起动。
调Kp:把Ki和Kd都设0,Kp从小往大加。加到电机转速能跟上目标、但开始出现小幅振荡(也就是超调后来回摆)时,把Kp退回来一点,退到刚好不振荡。此时系统响应快但没有稳态误差消除能力。
加Ki:逐渐增大Ki,用来消除稳态误差。你会发现转速慢慢逼近目标值了。Ki加到什么时候停?加到稳态误差基本消失、且不引起明显振荡为止。Ki太大表现为响应初期冲过头、恢复慢。
补Kd:最后上Kd,主要用来抑制超调和振荡,让曲线更"稳"。Kd对噪声敏感,一般给得很小,甚至很多转速环干脆不用D。
| 参数 | 作用 | 加大的效果 | 过大的表现 |
|---|---|---|---|
| Kp | 快速响应偏差 | 响应变快 | 振荡、超调 |
| Ki | 消除稳态误差 | 稳态精度提高 | 超调、恢复慢 |
| Kd | 抑制振荡 | 曲线更平稳 | 对噪声敏感、抖动 |
实操心得:调参时别用串口一帧帧看,直接开个上位机把转速曲线画出来,超调多少、恢复要几个周期,一目了然。靠感觉调效率极低。
4.4 抗积分饱和和输出映射
积分饱和是转速环最常见的"隐形杀手"。除了累加限幅,还有更狠的一招叫积分分离:当偏差大于某个阈值时,直接不给积分项,等偏差小了再启用。这样启动阶段不会因为偏差巨大而疯狂积分。
另外输出限幅后要做占空比映射:PID输出的是"控制量",得线性映射到PWM的CCR寄存器值。比如输出范围限定在0~100,而ARR是3599,那实际CCR = output / 100 * 3599。这个映射系数建议单独写成宏,改的时候不容易错。
5. 电机驱动与硬件接线
5.1 H桥驱动的选型
单片机IO口的电流带不动电机,必须用驱动器。常见的有L298N、TB6612、DRV8833以及各类MOS管搭建的H桥模块。选型看三个指标:工作电压范围、持续电流能力、控制接口类型。
STM32的PWM信号接驱动器的PWM输入脚,方向控制接两个普通GPIO。如果驱动器支持单PWM加方向引脚(PH/EN模式),接线更简单。注意共地:STM32的地和驱动器逻辑地必须连一起,否则信号电平没有参考,PWM根本不认。
5.2 供电隔离与滤波
电机和单片机最好分开供电。电机启动瞬间电流冲击大,会把电源电压拉低,如果共用一个电源,单片机可能复位。做法是电机用一组电源,单片机用另一组(或者同一电源但加大量滤波电容和二极管隔离)。实测这招能解决一大半"电机一转单片机就重启"的玄学问题。
注意:驱动器电源输入端一定要并大电解电容(几百微法以上),吸收电机换向和制动时产生的浪涌电压,不然容易烧驱动。
5.3 编码器接线与信号整形
编码器的A、B、VCC、GND接到STM32对应定时器通道。多数增量式编码器输出是开集或推挽,如果是开集输出,要加上拉电阻。走线尽量短,和电机动力线分开走,避免PWM的强干扰耦合进编码器信号,导致计数乱跳。
如果发现静止时计数值还在缓慢变化,多半是干扰或者滤波没设好,加大输入滤波值,或者给编码器信号加RC低通。
6. 常见问题排查实录
调试这类项目,问题基本就集中在几个点上,我把踩过的、帮别人解决过的整理成速查表,出问题时按这个顺序排查效率最高。
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 电机完全不转 | PWM没启动/驱动器没使能 | 万用表量PWM脚有无方波,检查使能脚电平 |
| 只往一个方向转 | 方向引脚没切换 | 检查方向GPIO是否随正负设定翻转 |
| 转速始终为0 | 编码器没接对/定时器模式错 | 手动转电机看计数是否变化 |
| 转速数值跳变剧烈 | 滤波不足/干扰 | 加大编码器输入滤波,检查走线 |
| 一堵转就飞车 | 积分饱和 | 加积分限幅和积分分离 |
| 电机一转单片机复位 | 电源耦合 | 分开供电,加大电容 |
6.1 "编码器读数为零"的定位思路
这是新手遇到最多的。别急着怀疑代码,先手动转动电机轴,用调试器实时看编码器定时器的计数值(CNT寄存器)。如果手动转都不变,问题一定在硬件或配置:通道引脚接错、定时器没进Encoder模式、编码器没供电。如果手动转CNT会变,但运行时读不到,那就是读计数的逻辑或时间窗口有问题。
6.2 转速波动大的分层排查
转速波动分两种:低频摆动和高频抖动。
低频摆动,通常是PID参数问题,Kp太大或Ki太冲,重新整定即可。高频抖动,多半是测速本身的量化噪声——采样窗口太短、脉冲数太少,导致每周期算出的转速在真实值附近乱跳。解决办法是拉长采样窗口,或者用M/T法测速,在低速时自动切换到测周期方式,精度会好很多。
6.3 一个容易被忽略的坑:定时器中断里的耗时操作
有些人习惯在控制中断里直接调HAL_UART_Transmit打印调试信息,这是大忌。串口发送是阻塞的,一帧数据可能几十微秒到几百微秒,直接把1ms的控制周期撑爆,PID变得不规律,转速跟着乱。正确做法是用DMA发送,或者只在中断里更新变量,回主循环再打印。
// 反面教材:中断里阻塞发送 void TIM1_UP_IRQHandler(void) { // ... HAL_UART_Transmit(&huart1, buf, len, 100); // 千万别这么干 } // 正确:中断只算,主循环发 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { speed = Encoder_GetSpeed(); duty = PID_Calc(&pid, target, speed); __HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_1, Duty_Map(duty)); } }我个人在好几个项目里用过这套组合,从简单的恒速小车到带负载的卷绕装置,STMF103加HAL库加CubeMX的方案基本没掉过链子。真正花时间的从来不是代码本身,而是编码器分辨率算错、积分饱和没处理、电源没隔离这些细节。把这些地方提前想清楚,剩下的调参就是个体力活。后续如果想再往上做,可以把这单个转速环外面再套一层位置环,做成级联PID控制,那就是做云台和机械臂定位的套路了,转速环作为内环,位置环作为外环,思路和现在完全一样,只是多了一个PID结构体在跑。