ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32 HAL库CubeMX直流电机PID闭环调速与编码器测速调参

2026/9/30 9:37:03 拓冰建站 浏览量
STM32 HAL库CubeMX直流电机PID闭环调速与编码器测速调参 很多人第一次做直流电机闭环代码烧进去之后遇到的现象都差不多电机要么一动不动静止不动要么转起来像在打摆子转速忽高忽低用手轻轻一按轴转速立刻掉一半松手又冲上去。我最早做这类项目时也踩过同样的坑当时以为是PID参数没调好换了七八组Kp、Ki反复试结果发现根本问题出在采样周期和编码器计数上参数怎么调都是在错误的信号上做补偿。这篇内容就把STM32上用HAL库和CubeMX做直流电机PID调速的完整链路讲清楚——从定时器选型、编码器测速、PID离散化实现一直到最后怎么把参数调稳。适合已经会点亮GPIO、跑过串口打印但还没把闭环控制真正做通的朋友也适合手上有个STM32F103板子加编码器电机、想拿来练PID的在校同学。1. 先想清楚闭环到底比开环多解决了什么1.1 开环调速为什么一定飘用PWM直接驱动直流电机本质上是在给电机喂一个固定的平均电压电机转多快取决于这个电压和当时的负载。问题在于负载是会变的小车爬坡、机械臂带重物、风扇进灰都会让负载转矩上升。负载一上去电机转速就往下掉而PWM占空比没变控制端完全不知道转速掉了。这就是开环的致命点——它只控制输入不关心输出。还有一种更隐蔽的漂移来自电源本身。电池从满电4.2V放到3.6V同样50%占空比对应的平均电压就少了一大截转速自然跟着降。你在桌面上调试时觉得挺稳的一装到机器上跑十分钟就发现速度不对了。所以只要项目对转速有稳定性要求闭环基本是绕不开的。闭环做的事情其实很朴素拿一个传感器把实际转速量出来和目标值相减得到误差再根据误差去实时修正占空比。误差为正说明转慢了就加大占空比误差为负说明转快了就减小占空比。这个根据误差修正的规则就是PID要解决的问题。1.2 编码器测速这条反馈链路的构成直流电机调速里最常见的反馈元件是增量式正交编码器它贴在电机轴尾输出A、B两路相位差90度的方波。电机每转过一个固定的机械角度A、B就跳变一次STM32的定时器工作在编码器接口模式下可以直接对这些边沿计数硬件层面完成四倍频和方向识别CPU几乎不占资源。整条链路是编码器 → 定时器编码器模式 → 速度计算 → PID运算 → PWM比较寄存器 → 驱动芯片 → 电机 → 编码器。这里面任何一环出问题表现出来的现象都是转速不稳但根因完全不同。很多人在调参阶段反复折腾其实测速环节本身就是错的等于在错误的输入上做闭环越调越乱。这里有个经验先把测速做准让串口每秒打印出来的转速和手持转速表、或者和上位机波形对得上再开始调PID。测速不准的情况下不要碰参数这是我踩过最多次的坑。1.3 位置式和增量式先把这个选了PID在单片机上有两种主流离散实现位置式和增量式。位置式直接算出本次的控制量绝对值结构直观积分项显式存在便于加积分限幅增量式算的是控制量的变化量累加到上一次输出上天然带积分作用抗积分饱和处理起来更简单而且执行器掉电重启时不会突然输出一个大值。对于直流电机调速这种被控对象两种都能用。我的习惯是如果需要严格限制控制量的绝对范围、并且要做积分分离或者前馈用位置式如果是纯速度环、参数整定想省事用增量式。后面第4节两套代码我都会给出来你可以直接对比着看。另外提一句真正要求高的场合会做成速度环电流环的级联结构那是更进阶的玩法本文先聚焦单速度环把基础打扎实。2. CubeMX里的外设分配配错一次后面步步是坑2.1 PWM定时器的频率与分辨率怎么定直流电机的PWM频率有个经验区间太低比如1kHz以下电机会发出明显的啸叫而且电流纹波大太高比如50kHz以上MOS管开关损耗上升普通驱动模块可能跟不上。我一般把PWM频率设在16kHz到25kHz之间取20kHz是个很舒服的值——刚好在人的听觉上限附近基本听不到啸叫又不会给驱动芯片太大压力。以常见的72MHz主频STM32F103系列算一下目标20kHz定时器计数时钟先不分频预分频器Prescaler设为0则计数时钟就是72MHz。自动重装载值ARR 72MHz / 20kHz - 1 3600 - 1 3599。这样PWM分辨率就是3600级占空比调节步进约为0.028%对速度控制绰绰有余最低有效占空比也不会因为分辨率不够而卡死。配置时把对应通道设为PWM Generation CHxPulse初值给0Mode选PWM Mode 1极性根据你的驱动芯片选择。如果用的是需要H桥双PWM输入的驱动比如DRV8833这类还要再开一个互补通道如果驱动芯片是PWM方向两线接口比如TB6612的单路用法那就一路PWM加一个普通GPIO做方向脚。2.2 编码器接口模式的三个关键设置编码器这边选一个带编码器接口的通用定时器比如TIM2、TIM3、TIM4。在CubeMX里把Combined Channels设为Encoder Mode然后进Parameter Settings配置。关键有三点第一Encoder Mode选TI1 and TI2也就是A、B两路都参与计数实现四倍频。只选TI1就是两倍频分辨率减半。第二Counter PeriodARR设成65535也就是16位满量程。因为编码器正反转会让计数器上下计数留足范围才能避免频繁溢出。读完之后立刻清零重新计这样每个采样周期读到的是增量值。第三输入捕获的极性过滤IC1 Filter、IC2 Filter建议设个几比如0x0F用来滤掉编码器信号上的毛刺。很多便宜的编码器在电机换向瞬间会有抖动不加滤波会看到转速读数跳变。GPIO这边要注意编码器A、B接的引脚如果是默认的复用功能CubeMX会自动配好如果是重映射引脚要确认引脚分配没冲突。我遇到过把编码器接到已经用作串口的引脚上结果串口一打印转速数据就乱查了半天才发现引脚复用冲突。2.3 采样定时器和串口打印要提前规划PID需要一个固定的采样周期来触发运算我通常再开一个基本定时器TIM6或TIM7配置成10ms中断一次也就是100Hz的采样率。算一下72MHz时钟Prescaler 7200-1则计数时钟10kHzCounter Period 100-1则中断频率10kHz/100 100Hz周期正好10ms。这个10ms是个很好的平衡点——比大多数电机的机械时间常数小得多又不会让中断太频繁。串口打印建议用UART加DMA或者至少用一个环形缓冲区不要在主循环里用阻塞式HAL_UART_Transmit。因为你要在PID中断里或者紧邻它的地方输出转速、目标值、输出量三条数据给上位机画波形阻塞发送会直接破坏采样周期波形会周期性抖动你以为的控制振荡其实是你自己打印阻塞造成的。串口波特率我一般开115200配合简单的帧头帧尾协议用VOFA或者串口示波器就能实时看曲线。2.4 NVIC优先级和代码生成选项中断优先级要排清楚采样定时器中断放最高串口DMA放低编码器因为是硬件计数不占中断。如果PID运算放在采样中断里要保证这个中断的执行时间远小于10ms否则会丢失采样节拍。后面第6节会专门讲中断里做浮点运算的注意事项。代码生成时Toolchain选MDK-ARMKeil就行勾选Generate peripheral initialization as a pair of .c/.h files方便管理。注意CubeMX每次重新生成代码会覆盖main.c里用户代码块之外的内容所以自己的PID代码一定要写在/* USER CODE BEGIN */和/* USER CODE END */之间这个规矩吃过一次亏就永远记住了。3. 从编码器脉冲到可用的转速值3.1 M法、T法、M/T法该选哪个测速方法有三种主流思路。M法是在固定时间内数脉冲个数适合高速段低速时脉冲太少误差大。T法是测两个脉冲之间的时间适合低速段高速时定时器分辨率不够。M/T法是两者结合高低速都兼顾但实现复杂。对直流电机调速项目绝大多数情况下用M法就够了前提是你的编码器线数别太低。举个计算假设编码器11线、四倍频后44脉冲/圈、减速比30那么输出轴转一圈对应44×30 1320个计数。采样周期10ms若转速是300rpm则10ms内转过的圈数是300/60×0.01 0.05圈对应计数1320×0.05 66个。66个计数的量化误差大概±1个相对误差约1.5%已经够用。但如果你的电机转速很低比如稳定在20rpm那10ms内只有1320×20/60×0.01 ≈ 4.4个计数量化误差就有±1即20%以上这时候M法就不行了得加长采样周期到50ms或者换T法。所以采样周期的选择离不开你的最低工作转速和编码器分辨率。3.2 增量读取、方向判断和四倍频的落地HAL库读编码器增量值的标准写法是这样/* 每10ms采样一次 */ int16_t Get_Encoder_Delta(TIM_HandleTypeDef *htim) { int16_t cnt (int16_t)__HAL_TIM_GET_COUNTER(htim); __HAL_TIM_SET_COUNTER(htim, 0); /* 读完清零下周期重新计 */ return cnt; }这里有个细节先转成int16_t再清零因为定时器计数器是16位无符号的向正方向溢出和向负方向借位在补码表示下恰好对应正负增量前提是一个采样周期内的计数不超过±32767。按上面的参数就算转速到3000rpm10ms内计数也就660个左右离溢出还很远所以可以放心用这种读完即清零的做法。方向判断是编码器硬件自动完成的A相超前B相90度时向上计数B相超前A相时向下计数所以计数器读出来的增量自带正负号正好对应正反转。这也是为什么必须用编码器接口模式而不是普通外部中断计数——普通中断你还要自己判断方向而且中断频率高到一定程度CPU就顶不住了。3.3 转速换算和必要的滤波把增量换算成rpm#define ENCODER_PPR 11.0f /* 编码器线数 */ #define GEAR_RATIO 30.0f /* 减速比 */ #define SAMPLE_PERIOD_S 0.01f /* 采样周期 10ms */ float delta (float)Get_Encoder_Delta(htim3); float counts_per_rev ENCODER_PPR * 4.0f * GEAR_RATIO; /* 1320 */ float speed_rpm delta / counts_per_rev / SAMPLE_PERIOD_S * 60.0f;代入counts_per_rev 1320、采样周期0.01s化简后speed_rpm delta × 4.545。也就是说每个计数约等于4.5rpm。原始测速值会有抖动尤其电机换向和齿轮间隙处。加一个一阶低通滤波就能显著改善float speed_filtered 0.7f * speed_filtered 0.3f * speed_rpm;系数0.3是滤波强度它同时引入了相位滞后。滤波越重曲线越平滑但响应越迟钝闭环带宽被压下来。这个系数要和后面的PID参数一起整定不要单独调死了。我的经验是先设0.2到0.3等PID基本调稳之后再看波形微调。滤波放在测速之后、进PID之前位置一定不能错。4. PID算法在MCU上的两种写法4.1 位置式PID的离散化与结构体设计连续PID的公式是u(t) Kp·e(t) Ki·∫e dt Kd·de/dt。离散化之后积分变成累加微分变成差分u(k) Kp·e(k) Ki·Σe(j)·Δt Kd·(e(k) - e(k-1))/Δt先定义结构体把状态都收进去避免用一堆全局变量typedef struct { float Kp, Ki, Kd; float err, err_last; float integral; float out, out_max, out_min; /* 输出限幅对应PWM的0~3599 */ float integral_max; /* 积分限幅 */ float dt; /* 采样周期单位秒 */ } PID_Pos; void PID_Pos_Init(PID_Pos *p, float kp, float ki, float kd, float dt, float out_max, float integral_max) { p-Kp kp; p-Ki ki; p-Kd kd; p-dt dt; p-out_max out_max; p-out_min 0.0f; /* 单方向驱动下限取0 */ p-integral_max integral_max; p-err p-err_last 0.0f; p-integral 0.0f; p-out 0.0f; } float PID_Pos_Calc(PID_Pos *p, float target, float measure) { p-err target - measure; /* 积分累加并限幅 */ p-integral p-err * p-dt; if (p-integral p-integral_max) p-integral p-integral_max; if (p-integral -p-integral_max) p-integral -p-integral_max; /* 微分项 */ float derivative (p-err - p-err_last) / p-dt; p-out p-Kp * p-err p-Ki * p-integral p-Kd * derivative; /* 输出限幅 简单抗积分饱和 */ if (p-out p-out_max) { p-out p-out_max; if (p-err 0) p-integral - p-err * p-dt; /* 回退这一步积分 */ } if (p-out p-out_min) { p-out p-out_min; if (p-err 0) p-integral - p-err * p-dt; } p-err_last p-err; return p-out; }注意Ki是每秒积分增益因为你乘了dt。有人写PID时不乘dt那Ki的量纲就变了换一个采样周期参数就得全重调。乘上dt之后采样周期改成5ms或20ms参数基本不用动。4.2 增量式PID的推导和更省事的抗饱和增量式从位置式相减得来把u(k) - u(k-1)展开整理得到Δu(k) Kp·(e(k) - e(k-1)) Ki·e(k) Kd·(e(k) - 2e(k-1) e(k-2))typedef struct { float Kp, Ki, Kd; float err, err_last, err_prev; float out, out_max, out_min; } PID_Inc; void PID_Inc_Init(PID_Inc *p, float kp, float ki, float kd, float out_max) { p-Kp kp; p-Ki ki; p-Kd kd; p-out_max out_max; p-out_min 0.0f; p-err p-err_last p-err_prev 0.0f; p-out 0.0f; } float PID_Inc_Calc(PID_Inc *p, float target, float measure) { p-err target - measure; float d_out p-Kp * (p-err - p-err_last) p-Ki * p-err p-Kd * (p-err - 2.0f * p-err_last p-err_prev); p-out d_out; if (p-out p-out_max) p-out p-out_max; if (p-out p-out_min) p-out p-out_min; p-err_prev p-err_last; p-err_last p-err; return p-out; }增量式的积分作用藏在输出累加里所以即使输出被限幅下次也会从限幅值继续累加不会像位置式那样积分项无限涨。这是它在工程上受欢迎的主要原因之一。但增量式也有缺点它对微分项的噪声更敏感因为公式里出现了e(k-2)二阶差分会把量化噪声放大。4.3 微分项噪声几乎所有人都要面对的问题不管哪种写法微分项都是噪声放大器。转速本身已经带量化误差每10ms差分一次再除以dt0.01噪声被放大100倍D一开大电机就开始咯咯响。解决办法有两个方向。一是给微分项加一阶低通把D变成不完全微分/* 不完全微分alpha越小滤波越重典型0.1~0.3 */ float d_raw (p-err - p-err_last) / p-dt; p-d_filtered 0.8f * p-d_filtered 0.2f * d_raw; float derivative p-d_filtered;二是微分作用加在测量值上而不是误差上即微分先行 / Derivative on Measurement这样目标值突变时不会产生微分冲击。/* 微分只针对测量值变化目标突变时不产生尖峰 */ float d_meas -(measure - p-meas_last) / p-dt;这两种做法在实际项目里都很常见我一般两个一起用微分对象改成测量值同时加轻度低通。这样D可以放心开大一些对抑制超调很有帮助。5. 调参实录从能转到稳的一步步过程5.1 先做开环标定找到死区调PID之前先别急着闭环。把PID关掉手动给PWM一个从0缓慢增加到最大值的斜坡同时看转速。你会发现占空比从0开始增加电机过了一段时间才转动这个临界占空比就是死区。普通有刷减速电机死区可能在10%到30%之间具体看减速比和摩擦。为什么要知道死区因为闭环输出在死区以下是无效的。如果你的PID输出限幅是0到3599而电机要300个计数才开始转那么误差很小的时候PID输出落在0到300之间电机根本没反应积分就一直攒等误差再大一点输出突然跨过死区电机啪地一下就冲上去然后超调、振荡你以为是参数问题其实是死区没处理。工程上常用死区补偿在输出上加一个前馈或者对输出做最小限幅。5.2 比例先行积分后补微分最后我的调参顺序固定是P → I → D。先把Ki和Kd设为0从小到大加Kp。Kp太小转速上不去、恢复慢Kp太大转速会在目标值附近来回摆甚至等幅振荡。找到那个再大一点点就开始明显振荡的值然后退回来取它的50%到60%作为Kp的起点。注意Kp的量纲因为误差单位是rpm输出单位是PWM计数所以Kp的单位是计数/rpm。假如目标300rpm、稳态误差导致需要1800计数那Kp大概在6左右。这个数量级能帮你快速估出Kp的初值不用从0.001开始瞎试。Kp定好后加Ki用来消除稳态误差。加的时候从很小开始观察转速能否慢慢爬到目标值。Ki太大就会过冲然后来回摆甚至出现低频的周期性波动积分振荡。经验上Ki取Kp的0.01到0.1倍除以采样周期意义下的值比较常见。最后加Kd抑制超调。加之前一定要先把微分滤波做好4.3节否则你看到的是噪声不是控制效果。Kd从小加看到超调减小、响应变快就合适出现高频抖动就过头了。5.3 用阶跃响应看控制质量调参最有效的辅助手段是看阶跃响应。做法很简单程序里周期性把目标转速在200rpm和400rpm之间切换间隔两三秒同时把目标值和实际转速通过串口发给上位机。几个典型的波形特征可以直接对应问题波形特征可能原因调整方向有稳态误差长期到不了目标积分不足或存在死区加大Ki或做死区补偿超调大、来回衰减几次才稳Kp偏大或D不足略减Kp适当加Kd等幅振荡、不衰减Kp过大接近临界明显减小Kp低频周期波动几秒一次Ki偏大导致积分振荡减小Ki高频毛刺、电机异响D过大且噪声未滤减小Kd加强微分滤波响应很慢、软绵绵Kp和Ki都偏小同时提高Kp、Ki这张表我几乎每次调参都会对着看比盲目试参数快得多。5.4 换负载之后参数还稳不稳调参要在实际负载下做。空载调得很漂亮的参数一带负载可能完全变样。测试方法是让电机带上典型负载比如小车压上重量、机械臂装夹具再跑一遍阶跃响应看超调和恢复时间是否还能接受。如果负载变化范围很大单纯一套PID很难兼顾可以考虑两种方案一是把Kp按误差分段误差大时用大Kp快速响应误差小时用小Kp防超调这其实就是变速积分的思路二是做增益调度根据转速区间选择不同参数组。真正高端的场合才上自适应一般项目用分段就够。6. 那些调试到半夜才发现的问题6.1 编码器计数溢出和方向误判前面说读完即清零要求一个周期内增量不超32767大多数情况没问题但如果你的采样周期设成了50ms、电机转速又很高就可能溢出表现为转速读数突然变成负数。解决办法是缩短采样周期或者用32位扩展计数。方向误判往往出在A、B相接线反了。这时你会发现设了正转目标电机却反转闭环直接把输出顶到最大。如果你用的是单极性驱动只能单向转它无法反转就只能堵在那里。排查方法手动转一下电机轴串口打印编码器增量看正转时是正数还是负数反了就把A、B对调或者软件里对增量取反。还有一个隐藏坑编码器电源和电机电源共用地线时电机大电流在公共地线上产生压降会导致编码器脉冲被干扰。表现是转速读数里夹杂随机的尖峰。解决方向是编码器供电单独走线、加大地线截面积必要时用光耦隔离。6.2 PWM占空比更新与预装载寄存器改占空比有两个函数__HAL_TIM_SET_COMPARE直接写比较寄存器立即生效如果开了ARR预装载Auto-Reload Preload也可以只在更新事件时生效。我一般推荐用__HAL_TIM_SET_COMPARE在中断里更新配合预装载让新的占空比在下一个PWM周期边界才切换避免出现半周期的残缺波形造成电流冲击。另外注意PWM通道的CCR值和ARR的关系ARR是3599时CCR范围0到3599写CCR3599就是100%占空比但要注意100%占空比时上桥臂常通某些驱动芯片需要留一点死区或者不允许100%我一般把输出限幅上限设成ARR的95%左右既保证有力矩又留安全余量。6.3 中断里做浮点运算的执行时间F103这类 Cortex-M3 没有硬件浮点单元FPU所有float运算都是软件模拟一次乘法几十个周期。PID一次运算里有十几次浮点乘加加上滤波执行时间可能到几微秒甚至十几微秒。放在10ms中断里本身没问题但如果你的中断里还有别的耗时操作比如阻塞打印就会超时。判断方法是在中断入口拉高一个GPIO出口拉低用示波器看高电平持续时间。如果逼近或超过采样周期的10%就必须优化。优化方向把PID计算里的float改成定点Q格式或者用F4、H7这类带FPU的芯片。如果只是打印调试把数据塞进环形缓冲区让主循环或DMA去发千万别在中断里直接发。6.4 采样周期抖动带来的假振荡最后说一个最隐蔽的问题。你的采样周期理论上是10ms但如果中断被更高优先级的中断打断或者主循环里有耗时操作影响了中断响应实际周期可能变成10ms、12ms、9.8ms地跳。PID运算里假设dt恒定周期一抖等效的Ki和Kd就跟着抖表现出来就是转速上有周期性扰动而且无论你怎么调参数都消不掉。排查办法是在采样中断里用另一个定时器或者DWT计数器测量实际间隔通过串口打出来看抖动有多大。如果抖动明显就要把采样中断优先级提到最高把其他耗时任务挪出去或者改用定时器的硬件触发来同步。这一点我在一个带串口打印的项目上折腾了很久最后发现是主循环里的字符串格式化拖慢了中断响应改成DMA发送之后波形立刻就干净了。7. 把这套东西复用到别的项目上这套框架其实不止能驱动直流有刷电机。你把编码器换成霍尔传感器、把PWM换成三相驱动同样的测速-误差-PID-输出结构就能撑起无刷电机的位置环和速度环把被控量换成温度、换成水位、换成姿态角也只是传感器和输出通道的替换PID内核一点不用改。所以这套代码值得写成独立模块结构体和计算函数分开参数通过接口传进去这样换项目时直接搬。我个人的做法是把PID、编码器测速、串口波形三块各写成一个.c/.h主函数里只负责串起来。调参数据每次保存一份文本记下来标明对应的电机型号、负载、采样周期下次遇到类似电机就能快速抄一个初值。这么积累几轮之后你会发现调参从玄学变成了有依据的工程活。