1. 从一块板子到一个能转稳的电机:整体方案先搭骨架
直流电机的调速,说白了就是一句话:让电机按照我给的目标转速转,负载变了、电压变了、摩擦变了,它自己还能拉回来。这件事在工业现场被做烂了,但轮到自己拿STM32从头撸一遍,很多人第一块板子就会卡在“电机能转,但转不稳”上。标题里这几个词——STM32、直流电机、PID控制、HAL库、CubeMX——其实已经把这套东西的骨架交代清楚了:用CubeMX点出外设,用HAL库写业务,用PID闭环,让PWM驱动直流电机转出想要的转速。适合谁看?做过流水灯、用过GPIO和串口、但没接触过闭环控制的朋友最合适,纯小白也能看,只是要有点耐心。
我先说结论:这套方案的门槛不在算法,PID本身三个参数,写出来也就二十行代码。真正吃功夫的是信号链路上每一环的可信度——编码器测得准不准、PWM分辨率够不够、采样周期稳不稳、电机驱动扛不扛得住换向电流。很多人调PID调不出来,最后发现问题出在编码器少接了一根线,或者PWM频率设得太低导致电机啸叫,跟PID一点关系没有。所以下面我会按“方案选型 → 原理拆解 → CubeMX配置 → 代码落地 → 调参实录 → 排障”这个顺序走,你照着一步步来,基本能在半天内让电机稳在你想要的转速上。
1.1 硬件清单与各部件职责
先把要准备的东西列清楚,缺哪个都不行:
| 部件 | 作用 | 常见型号 | 备注 |
|---|---|---|---|
| 主控板 | 跑算法、出PWM、读编码器 | STM32F103C8T6(蓝板/最小系统) | 有硬件编码器接口的定时器就行 |
| 电机驱动 | 把MCU的弱信号放大成能推电机的功率 | TB6612FNG / L298N / DRV8871 | 强烈建议TB6612,压降小、发热低 |
| 直流电机 | 被控对象 | 带霍尔/光电编码器的减速电机 | 一定要带编码器,否则无法闭环 |
| 电源 | 给电机供电 | 12V/2A 以上适配器 | 电流余量要足,启动瞬间冲得厉害 |
| 调试通道 | 看波形、改参数 | USB转TTL(CH340) | 用串口打印实时数据 |
| 逻辑电源 | 给MCU供电 | 5V转3.3V,或板载LDO | 电机电源与MCU电源共地 |
选带编码器的减速电机是关键。减速比通常几十比一,输出轴转速几百RPM,编码器一圈几百到上千个脉冲,经过倍频后分辨率足够,低速时也不会因为脉冲太稀而测得抖。如果手头只有普通直流电机,那就得自己加光电码盘或者霍尔,麻烦不少,不建议新手走这条路。
1.2 为什么是HAL库加CubeMX,而不是寄存器或标准库
这个问题被问烂了。HAL库的坏处是代码臃肿、执行效率低、有些外设中断处理绕。但对PID调速这种场景,控制周期是毫秒级(比如10ms一次),HAL库那点开销完全在预算内,换来的是外设配置不用翻几百页参考手册。CubeMX把时钟树、引脚复用、定时器模式、NVIC优先级都图形化掉了,配错了它会红字警告,这比手写寄存器少踩一堆坑。
我的建议是:外设初始化交给CubeMX生成的HAL代码,控制和算法全部自己在回调里写。别在CubeMX生成的主循环里塞PID,那样时序不可控。PWM用HAL_TIM_PWM_Start启动后就不用管了,改转速只需要改__HAL_TIM_SET_COMPARE的寄存器值;编码器用HAL_TIM_Encoder_Start启动,读计数直接读CNT寄存器。HAL库在这里就是个“外设管家”,算法还是你自己的。
至于HAL和LL的区别,简单说LL更贴近寄存器、更轻,但CubeMX对LL的支持不如HAL全面,配置编码器接口这种复杂外设,LL写起来反而累。用HAL起步,等哪天你觉得中断响应不够快了再局部换LL,这是比较现实的技术路径。
2. 把PID这三个字母翻译成人话
很多教程一上来就是那个公式,新手看完全懵。我换个说法:你开车维持60码,看到路码表是55,脚就多踩一点油门(这是P,比例,偏差越大动作越大);如果一直差3码上不去,你就慢慢把油门再压深一点,直到误差消失(这是I,积分,专门消除稳态误差);快到60的时候你提前松一点油,免得冲过头(这是D,微分,提前预判)。PID就是这么个“看误差、按比例动作、积累历史、预判趋势”的控制器。
2.1 P、I、D三项各自的脾气
比例项Kp决定了响应速度。Kp太小,电机没劲,转速上不去;Kp太大,转速会在目标值附近来回震荡,甚至像抽搐一样。它作用的是“当前误差”。
积分项Ki是来收拾残局的。光有P的话,因为摩擦、负载这些因素,转速总会停在离目标差一点的地方,这叫静差。积分项把历史误差累加起来,慢慢把这点差距补掉。但Ki太大会“充过头”,转速冲过目标值再慢慢掉回来,甚至越积越大导致长时间满输出,这叫积分饱和。
微分项Kd是阻尼。它在误差开始减小的时候给一个反向的抑制力,减少超调。但编码器测速本身有量化噪声,微分对噪声极其敏感,一放大就是满屏尖刺。所以实际工程里,位置式PID用在电机转速上,Kd经常直接给0,靠Ki和输出限幅把曲线做平就够了。真要加D,必须先把速度信号低通滤波一遍。
2.2 位置式、增量式还是串级
标题里带“级联PID控制”这个热词,但新手第一版别上串级。串级需要内环(电流环)有快速采样能力,通常要靠ADC实时采电流,硬件和软件复杂度陡增。转速单闭环用位置式或者增量式就够。
- 位置式:输出直接等于三项之和,物理意义清楚,输出对应的是PWM占空比。缺点是每次都要算积分累加,容易饱和,需要加限幅。
- 增量式:输出的是“这次比上次多多少”,天然带积分效果,抗饱和好,切换手动自动平滑。缺点是启动瞬间可能丢一小步,需要额外处理。
我给的建议是:先用位置式,配上积分限幅和输出限幅,因为它的调试过程最直观,改一个参数就能看到对应的现象,学会之后再转增量式。
2.3 离散化:连续公式怎么落进MCU
连续形式的PID是积分和微分,MCU只能算离散点,所以要做离散化。位置式的离散形式是这样的:
// 每 T 毫秒执行一次 error = target_speed - actual_speed; // 当前偏差 integral += error; // 误差累加(对应积分) if (integral > INTEGRAL_MAX) integral = INTEGRAL_MAX; if (integral < -INTEGRAL_MAX) integral = -INTEGRAL_MAX; derivative = error - last_error; // 误差变化(对应微分) output = kp * error + ki * integral + kd * derivative; last_error = error;这里有个坑:离散化之后,ki 和 kd 的物理量纲变了。如果你想用“真实”的PID参数,积分项要乘采样周期 Ti,微分项要除采样周期 Ti。但工程上没人这么较真,直接把 ki、kd 当成“在这个采样周期下好用的系数”就行,调参调出来就是对的。前提是采样周期必须固定,不能今天10ms明天15ms,那样整定好的参数就废了。
3. CubeMX里的关键配置,一步错步步错
打开CubeMX新建工程,选芯片型号,然后是时钟配置。F103C8T6外部晶振一般8MHz,经过PLL倍频到72MHz作为主频,这条线CubeMX里点两下就好,HSE选Crystal/Ceramic Resonator,PLL Source选HSE,然后调倍频系数到72。时钟树配错,串口波特率会乱、PWM频率会偏,所以先用示波器或者示波器替代方案(串口打印验证)确认一下系统节拍。
3.1 定时器PWM输出配置
选一个支持PWM的定时器,比如TIM1的CH1(PA8)或者TIM3的CH1(PA6)。模式选PWM Generation CHx,然后四个参数决定波形:
- Prescaler(预分频):把72MHz降下来。设为 0 就是不分频。
- Counter Period(ARR):决定PWM分辨率。
- Pulse(CCR):初始占空比。
- PWM Mode:一般用 Mode 1。
频率计算公式是:
PWM频率 = 72MHz / (Prescaler + 1) / (Counter Period + 1)我想让PWM跑在10kHz,那么72,000,000 / (0+1) / (7199+1) = 10,000。所以预分频给0,ARR给7199。这样CCR从0到7199变化,对应0%到100%占空比,分辨率足够细。为什么选10kHz?因为低于5kHz电机线圈会有明显啸叫,人耳能听到;高于20kHz又对MOS管的开关损耗和驱动芯片要求变高,10到16kHz是个甜点区间。
如果用TIM1这种高级定时器,记得在CubeMX里把“Automatic Output”或者”PWM Generation 的 Break/MOE”勾上,否则PWM出不来。这个坑我踩过,调了半天以为是编码器坏了,结果是高级定时器的主输出没使能。
3.2 编码器接口配置
再选一个定时器做编码器接口,比如TIM3的CH1(PA6)和CH2(PA7)。Combined Channels选 Encoder Mode,然后:
- Encoder Mode:TI1 and TI2(TI12),表示两相都计数,四倍频。
- Counter Period:填65535。
- 极性:按编码器实际输出调,一般不勾反相。
启动代码里用HAL_TIM_Encoder_Start(&htim3, TIM_CHANNEL_ALL);就够了。读转速的时候直接读__HAL_TIM_GET_COUNTER(&htim3),然后清零。四倍频意味着编码器一圈600线的话,CNT变化2400。这个数在低速下也够用,采样10ms内变化个位数十几个数都能分辨。
注意:编码器电源一定要确认是3.3V还是5V。很多光电编码器标称5V,直接接3.3V的MCU引脚,轻则信号识别不到,重则把引脚打坏。要么用5V供电+电平转换,要么选宽压3.3V的编码器。
3.3 一个固定周期的定时中断
PID需要一个稳定的心跳。我一般再开一个基本定时器(比如TIM6或TIM7),配成10ms中断一次。为什么不用主循环轮询?因为主循环里还有串口打印、按键扫描,执行时间不确定,PID周期就不稳。放中断里,优先级比串口高一点,但别设成最高的,免得把系统tick耽误了。
TIM6配置:Prescaler给7199,Counter Period给99,这样中断频率 = 72MHz / 7200 / 100 = 100Hz,也就是10ms一次。NVIC里把TIM6中断使能,优先级设成1或2。串口的优先级设低一点,比如3或4,这样串口打印不会打断控制环。
4. 代码落地:从计数到PWM输出
工程生成之后,先别急着写PID,把三段基础代码跑通:电机能正反转、编码器能读数、串口能打印。这三件事独立验证过,再合起来,能省掉大量“到底是谁的错”的排查时间。
4.1 电机驱动的方向与调速
驱动芯片一般两个方向脚加一个PWM脚(TB6612是AIN1、AIN2、PWMA)。方向脚用GPIO输出控制,PWM脚接MCU的定时器通道。
// 正转 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); // 反转 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);PWM占空比通过改CCR寄存器实现:
__HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, ccr_value);如果PID输出是有正负号的(正转正、反转负),那么要拆成“方向 + 绝对值占空比”:
if (output >= 0) { set_direction(FORWARD); ccr = (uint16_t)output; } else { set_direction(BACKWARD); ccr = (uint16_t)(-output); } if (ccr > PWM_MAX) ccr = PWM_MAX; // 限幅,防止爆表 __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, ccr);这里PWM_MAX就是ARR值7199。output的范围也应该限制在-7199到7199之间。限幅必须在写CCR之前做,因为CCR寄存器只有16位,超了会回绕,电机行为会变得莫名其妙。
4.2 编码器测速:M法和T法怎么选
测速有两套思路:
M法(固定时间数脉冲):固定10ms读CNT并清零,转速正比于计数值。高速时精度高,低速时一个周期可能只有几个脉冲,误差大。绝大多数场景用M法就够。
T法(固定脉冲测时间):记录两个脉冲之间的时间间隔,低速时精度高,高速时计时器分辨率不够。工程上做低速精密控制才用。
我推荐先用M法:
int16_t speed_raw; speed_raw = (int16_t)__HAL_TIM_GET_COUNTER(&htim3); __HAL_TIM_SET_COUNTER(&htim3, 0); // speed_raw 就是这10ms内四倍频后的脉冲数读CNT后立即清零,保证每个周期都是从零开始数,累加不溢出。这里用int16_t强转很关键,因为编码器反转时CNT是从0递减到65535,硬转成无符号会得到一个巨大的正数,转成有符号就是负的,正负号方向就对了。
如果单位想统一到RPM,需要标定:手动让电机匀速转,看10ms内CNT变化量,同时用转速表量实际RPM,算个比例系数。或者按编码器线数和减速比理论推算:
RPM = (speed_raw / 4 / 编码器线数) / 减速比 / 0.01 * 604.3 PID结构体与中断里的实现
把PID写成结构体,方便以后做双环或者多电机:
typedef struct { float kp, ki, kd; float target; float actual; float error, last_error; float integral; float output; float integral_max; float output_max; } PID_t; float PID_Calc(PID_t *pid, float target, float actual) { pid->target = target; pid->actual = actual; pid->error = target - actual; pid->integral += pid->error; if (pid->integral > pid->integral_max) pid->integral = pid->integral_max; if (pid->integral < -pid->integral_max) pid->integral = -pid->integral_max; float derivative = pid->error - pid->last_error; pid->output = pid->kp * pid->error + pid->ki * pid->integral + pid->kd * derivative; if (pid->output > pid->output_max) pid->output = pid->output_max; if (pid->output < -pid->output_max) pid->output = -pid->output_max; pid->last_error = pid->error; return pid->output; }然后在TIM6的10ms中断回调里调用:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { int16_t raw = (int16_t)__HAL_TIM_GET_COUNTER(&htim3); __HAL_TIM_SET_COUNTER(&htim3, 0); float actual = raw * SPEED_SCALE; float out = PID_Calc(&pid, target_rpm, actual); Motor_SetOutput(out); } }注意HAL_TIM_PeriodElapsedCallback是个弱函数,自己重写一遍就行,不要动CubeMX生成的底层。中断里只做必要的计算和寄存器写操作,串口打印这种耗时的事情千万别放里面,会直接拖垮控制周期。
5. 调参现场:从抽搐到平稳的过程
整定PID,我按这个顺序走:先Kp,再Ki,最后看要不要Kd。
第一步,把Ki和Kd都置0,只留Kp。给一个目标转速(比如500RPM),慢慢加大Kp。你会看到转速从“爬不到目标”变成“快速接近”,再变大开始出现等幅震荡。记下这个临界震荡的Kp值,然后退回到它的一半,这时候系统响应快但还稳。
第二步,加Ki消静差。从小往大加,观察转速是否能停在目标值上。Ki加过头会看到转速冲过目标再慢慢回来,或者在一个范围内低频摆动。出现这个就把Ki减半。
第三步,Kd。如果超调能接受,直接用0。如果非要加,先给一个很小的值,同时把速度信号做一阶低通:actual_filtered = 0.7 * actual_filtered + 0.3 * actual;,再交给PID。D项对噪声极敏感,不加滤波基本没法用。
5.1 现象与参数对照表
调参的时候对着这张表看,能少走弯路:
| 现象 | 大概率原因 | 处理方向 |
|---|---|---|
| 转速上不去,离目标差一截 | Kp偏小,或输出限幅太低 | 加大Kp,检查output_max |
| 转速在目标附近小幅快速抖动 | Kp偏大,或PWM分辨率不足 | 减小Kp,加大ARR |
| 冲过目标再慢慢回落 | Ki偏大(积分饱和),或Kd为0 | 减小Ki,加积分限幅 |
| 转速长时间满输出不回落 | 积分饱和,integral爆了 | 引入积分分离或反计算 |
| 低速时转速读数跳 | M法脉冲太少 | 增大采样周期或改T法 |
| 电机有明显啸叫 | PWM频率落在音频段 | 提高到10kHz以上 |
5.2 积分饱和:调试里最烦人的一件事
启动瞬间,实际转速是0,目标转速500,误差是500,积分项嗖嗖往上累加。等电机真的转起来,积分项已经积累到很大,输出被顶在最大值下不来,转速冲过500一大截,然后积分才慢慢退,这就是典型的积分饱和。表现出来就是“启动时电机猛冲一下再回落”。
解决办法有两个:一是积分限幅,上面代码里已经做了,把integral钳在合理范围;二是积分分离,当误差大于某个阈值时干脆不加积分,让比例项先干活,误差降下来再加积分。实测下来这两个方法配合用,启动曲线能顺很多。
6. 排障实录:那些我亲手踩过的坑
6.1 电机只朝一个方向转,或者干脆不转
先量PWM脚有没有波形。没有波形,检查高级定时器的主输出使能(MOE),检查HAL_TIM_PWM_Start有没有调用。有波形但电机不动,量驱动芯片的使能脚(STBY)有没有拉高,量电机两端电压。TB6612的STBY不拉高,芯片是完全不工作的,这个容易忘。
只朝一个方向转,多半是方向脚接反了,或者两个方向脚都给了同一个电平。用万用表量两个方向脚的静态电平,正转和反转时应该正好相反。
6.2 转速抖动厉害,数字跳来跳去
编码器信号质量差是首因。A/B相没接反,但屏蔽没做好,电机换向时的电磁干扰窜进信号线,计数就乱。编码器线一定要用带屏蔽的双绞线,屏蔽层单端接地(接MCU地)。另一个原因是采样周期太短,10ms内脉冲太少导致量化误差大,把周期放宽到20ms试试。
还有一种情况是电源共地没做好。电机大电流在地线上产生压降,把MCU的参考地拉偏,编码器信号电平判断就出错。电机电源地和MCU电源地必须在一点汇合,不要串成一条线。
6.3 串口打印把控制环拖慢了
这是新手最常见的隐藏问题。在10ms中断里调用printf,而printf底层是HAL_UART_Transmit阻塞发送,一条几十字节的消息在115200波特率下要几毫秒。中断里等几毫秒,控制周期直接变成15ms甚至更久,PID参数全废。
我的做法是:中断里只算PID,把要观察的数据存进一个全局数组,主循环里慢慢往外发。或者用DMA方式发串口,几乎不占CPU。调试阶段也可以降低打印频率,比如每10个控制周期打印一次,虽然不连续,但看趋势足够了。
6.4 常见问题速查
| 问题 | 排查顺序 |
|---|---|
| 电机完全不转 | 供电→STBY→PWM波形→方向脚→电机本身 |
| 能转但不闭环 | 编码器供电→A/B相接线→CNT是否有变化 |
| 转速方向反了 | 交换A/B相,或取反PID输出符号 |
| 调速范围窄 | PWM频率过高导致驱动响应跟不上,降到10kHz |
| 参数调不动 | 检查采样周期是否固定,中断是否被抢占 |
| 换负载后回不来 | Ki太小,或输出限幅不足以对抗负载 |
7. 把单环往深处做的几个方向
单环转速控制跑通之后,你手上就有了一套可扩展的框架。想再往上走,有这么几条路。
一是加电流内环做串级。内环用ADC采驱动芯片的电流采样电阻,周期做到1ms以内,外环还是转速。内环响应快,能限制启动电流、保护电机,动态性能也比单环好。代价是硬件要改,采样电阻和运放得加上。
二是换更精细的测速方式。高速用M法、低速用T法,两者根据转速自动切换,这就是所谓的M/T法,全速段精度都能保证。
三是位置环叠加。在转速环外面再套一层位置控制,读编码器累计角度做反馈,就能做定点角度控制,云台、机械臂关节都是这个路子。
四是参数自整定。让MCU在启动时给一次阶跃,记录响应曲线,用简单规则估算Kp、Ki,省去手工整定。这个方法不复杂,但需要你对系统模型有基本认识,否则估出来的参数不一定能用。
我自己的习惯是先把单环做扎实,数据能稳定打印、参数能在线改(通过串口命令改kp、ki),再去折腾串级。上来就搞串级,两个环互相耦合,出了问题你根本分不清是内环还是外环的锅。
最后分享个我一直用的小习惯:每次改完参数,把kp、ki、kd、目标转速、实际转速、输出值一起打到串口,存成CSV,事后用表格软件画个曲线,比盯着屏幕看数字强太多。曲线一画出来,是超调还是震荡、是响应慢还是饱和,一眼就清楚,调参效率能翻好几倍。