
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一次就足够应付大多数有刷电机。为什么频率太高比如10kHz1ms内编码器可能只测到几个脉冲量化误差大PID算出来的偏差跳变严重。频率太低比如100Hz响应慢遇到扰动恢复时间长感觉发木。1kHz是个比较舒服的平衡点用M法测速固定时间数脉冲1ms的采样窗口里假设编码器分辨率够能数到足够多的脉冲保证精度。实测下来大多数带霍尔编码器的电机在这个频率下表现都不错。3. CubeMX工程配置与底层外设搭建3.1 时钟树和外设基础配置第一步新建工程选好芯片型号比如STM32F103C8T6进来先把系统时钟搞定。用外部晶振的话HSE接8MHz经过PLL倍频到72MHzAHB、APB1、APB2按标准分频走APB1留36MHzAPB2和主频保持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 PeriodARR决定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设成6553516位最大值。编码器输入最好加输入滤波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都设0Kp从小往大加。加到电机转速能跟上目标、但开始出现小幅振荡也就是超调后来回摆时把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结构体在跑。