STM32+HAL直流电机PID控制实战:编码器测速到参数整定

发布时间:2026/10/14 18:52:11
STM32+HAL直流电机PID控制实战:编码器测速到参数整定
简介面向STM32初学者的直流减速电机PID闭环控制工程源码基于STM32F407ZGT6芯片与HAL库在STM32CubeMX中完成外设初始化配合正点原子4.3寸TFT LCD电阻屏显示实时转速通过L298N驱动MG310带编码器电机实现PWM调速与PID调节的完整样例。工程共127个文件压缩包仅938KB以76个h头文件、37个c源文件为主体涵盖LCD显示驱动、定时器编码器接口、UART调试输出、DMA配置等模块同时附带ioc配置工程、hex固件和uvprojx工程文件可直接用Keil打开烧录验证。已有985人学习浏览适合做小车、云台或电机调速项目的同学作为移植参考。资源内各外设驱动按HAL库分层组织代码结构清晰便于对照调试串口、PWM输出及编码器测速等关键环节可有效缩短入门阶段的排错时间。1. 免费的 STM32HAL 直流电机 PID 控制入门门槛比你想的低做电机控制最容易卡住的不是电机本身而是「明明代码逻辑没问题为什么电机就是抖、就是飘、就是过冲」。网上所谓免费的 STM32HAL 直流电机 PID 控制方案绝大多数是标准库时代的教程到了 HAL 库和 CubeMX 这一代很多人的配置逻辑还停在改寄存器上。这个方向要解决的问题很简单用 HAL 库的定时器输出 PWM用编码器测实际转速再跑一个位置式 PID让电机稳稳停在目标转速上。适合手里有一块 STM32 板子、一个带霍尔编码器的直流减速电机、一块驱动小板想快速跑通闭环速度控制的人。调 PID 看起来是玄学实际就是三个系数加一个控制周期今天我把整个链路拆开讲。2. 先把控制链路想清楚从 PWM 到编码器的四个环节2.1 位置式和增量式 PID 怎么选看执行机构不看出身很多初学者第一反应是去搜“PID 代码”然后复制粘贴结果发现电机完全不按预期走。PID 算法本身没有好坏之分关键看你的执行机构是什么输出形式。直流电机用 PWM 驱动占空比直接对应平均电压这是典型的「位置式输出」场景u(k) Kp*e(k) Ki*Σe(k)*dt Kd*(e(k)-e(k-1))/dt算出来的就是这一时刻的 PWM 占空比。增量式 PID 算的是Δu也就是这次输出相对上次输出的增量适合步进电机、伺服驱动器这类本身带积分记忆的执行机构。直流电机如果硬套增量式你还需要自己维护一个输出累加器多做一步不说还容易在复位和换向时出错。我平时做速度环默认就选位置式理由有三个输出量直接映射占空比调试时看串口打印一眼就懂比例项、积分项、微分项各自贡献多少能分开观察代码结构直观后续加输出限幅、积分限幅都方便。增量式不是不能用但在直流电机速度环这个场景里属于给自己找不自在。2.2 硬件链路的四个环节定时器、驱动、编码器、控制周期整套系统从信号流看是闭环的MCU 的定时器产生 PWM → 驱动芯片放大电流驱动电机 → 电机轴带着编码器转 → 编码器信号回到另一个定时器的编码器模式 → 主循环里读计数、算速度、跑 PID、更新占空比。这四段里任何一段配置错后面全白搭。PWM 输出建议用通用定时器 TIM2/TIM3/TIM4 的某个通道占用一个通道输出另一个通道做互补输出的情况在直流电机里基本用不到。驱动芯片用常见的 H 桥驱动即可比如某宝上常见的 TB6612 类小板A1/A2 控制方向PWM 引脚控制速度这种「方向引脚 单路 PWM」的接法比双 PWM 模式简单也更符合 PID 输出正负值来表达正反转的直觉。编码器接法要注意必须是支持编码器模式的定时器CubeMX 里把 Slave Mode 配成 Encoder Mode硬件上 A/B 相分别接到该定时器的 CH1/CH2。不要用外部中断去读编码器脉冲虽然能跑但中断频率高的时候主循环被插得千疮百孔控制周期抖到没法看。2.3 采样周期与控制周期为什么 PID 不是越快越好控制周期是 PID 里最容易被忽略的一个数。很多人觉得控制周期越短越好上来就 1ms 跑一次 PID结果编码器分辨率不够每周期只读到 0 或者 1 个计数速度信号全是量化噪声微分项被放得巨大电机嗡嗡响。做直流电机速度环我一般先看编码器分辨率。比如 13 线霍尔编码器、30:1 减速比输出轴每圈 13×4×30 1560 个计数4 倍频模式下。如果控制周期取 10ms那目标转速 600rpm 时每周期能读到约 156 个计数这个量级完全够算。如果控制周期压到 1ms每周期只有 15-16 个计数低速时直接变成跳变信号PID 没法正常工作。采样周期的选择要保证「每个周期至少有几十个编码器计数」。具体公式是控制周期(秒) 尽量小于等于期望最低测量精度(圈/秒) / (每秒计数/4)之类的换算但更实用的做法是先定 10ms跑起来看串口打印的实际速度波形如果锯齿明显再往 5ms 调。别忘了 HAL 库的延时和中断回调本身也占时间控制周期不是你设多少就是多少要用逻辑分析仪或翻转 GPIO 实测。3. 用 HAL 库搭出最小闭环PWM 和编码器的配置细节3.1 定时器输出 PWM先算 ARR 和 PSC再谈占空比分辨率用 CubeMX 配置 PWM 的正确顺序是先定 PWM 频率再反推分频系数和自动重装载值。以 F103 系列 72MHz 主频为例目标 PWM 频率 16kHz。PSC 决定计数时钟分频ARR 决定计数上限两者共同决定 PWM 频率PWM频率 72MHz / (PSC1) / (ARR1)。我常用的配置是 PSC3也就是 4 分频计数时钟 18MHz那么ARR1 18MHz / 16kHz 1125即 ARR1124。这样占空比的调节分辨率是 0~1124精度足够。千万不要把 PSC 设到 71 然后 ARR 只有 49那样占空比只有 50 级控制到后面会发现在目标转速附近来回跳一个挡位。// main.c 中 PWM 初始化片段F103 CubeMX 生成HAL 库 void MX_TIM3_Init(void) { htim3.Instance TIM3; htim3.Init.Prescaler 3; // 4分频计数时钟 18MHz htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 1124; // ARRPWM频率 18M/1125 16kHz htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(htim3); TIM_OC_InitTypeDef oc {0}; oc.OCMode TIM_OCMODE_PWM1; // PWM1CNTCCR 时输出高 oc.Pulse 0; // 初始占空比 0 oc.OCPolarity TIM_OCPOLARITY_HIGH; oc.OCFastMode TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(htim3, oc, TIM_CHANNEL_1); HAL_TIM_PWM_Start(htim3, TIM_CHANNEL_1); }PSC3 而不是 PSC0 的理由是让计数时钟别太满留一点余量应对中断和后续可能的定时器级联。PWM1 模式含义是计数值小于比较值时输出有效电平这样 CCR 越大占空比越大符合直觉。改占空比时用__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, duty);不要再反复 Start/Stop否则输出会闪断。3.2 编码器模式读转速HAL 的 Encoder Mode 配置与接线编码器模式是定时器硬件特性不是 HAL 库特有的功能但 CubeMX 把它图形化之后容易踩一个坑只知道在 Slave Mode 里选 Encoder Mode却忘了把两个输入通道都设成 TI1 和 TI2 都计数。正确配置是计数模式选TIM_ENCODERMODE_TI12这样 A/B 相每个上升沿和下降沿都计数也就是 4 倍频。// main.c 中编码器定时器初始化TIM4 的 CH1/CH2 接编码器 A/B 相 void MX_TIM4_Init(void) { htim4.Instance TIM4; htim4.Init.Prescaler 0; // 编码器计数不分频直接数边沿 htim4.Init.CounterMode TIM_COUNTERMODE_UP; htim4.Init.Period 65535; // 16位计数器满量程 htim4.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim4.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; HAL_TIM_Encoder_Init(htim4); HAL_TIM_Encoder_Start(htim4, TIM_CHANNEL_ALL); }编码器模式下定时器的 Counter 会随着电机正反转自动加或减不需要外部中断也不需要清标志位。读当前计数值用__HAL_TIM_GET_COUNTER(htim4)返回的是 int16 类型强转后的值注意它是有符号的负值代表反转。我这里把 Period 设成 65535 而不是某个精确值因为编码器模式下计数方向由硬件决定溢出逻辑与普通定时不同实际工程中一般配合读取频率保证不溢出到不可恢复。接线硬性要求是编码器 A/B 相必须接在支持编码器模式的定时器输入引脚上比如 TIM4_CH1 和 TIM4_CH2。如果接反了现象是电机正转时计数值反而减小PID 输出正占空比时实际转速显示为负误差越来越大积分饱和到限幅值。这个坑我见过太多次现象像 PID 参数不对其实是编码器方向反了。3.3 转速换算与计数清零控制周期里最关键的时序编码器读到的是脉冲计数要换算成转速必须知道每圈多少计数。这个数等于编码器线数 × 倍频系数 × 减速比。比如电机自带 13 线霍尔编码器、减速比 30:1、软件 4 倍频那么输出轴每圈就是 13×4×30 1560 个计数。控制周期内测速的常用做法是周期开始时读取计数并立刻清零用读取值 / 每圈计数 / 控制周期秒数 × 60得到 RPM。清零操作要放在读取之后紧接着执行两块代码之间不要插入其他逻辑否则可能少计数。如果怕主循环里清零被打断可以把读取和清零都放进定时器中断服务函数里保证原子性。// 控制周期中断里执行的测速函数返回 RPM // 每圈计数 need define: PULSE_PER_REV 13 * 4 * 30 1560 int16_t cnt __HAL_TIM_GET_COUNTER(htim4); __HAL_TIM_SET_COUNTER(htim4, 0); // 清零下一周期重新累计 float rpm (float)cnt / (float)PULSE_PER_REV; rpm rpm / CONTROL_PERIOD_SEC * 60.0f; // CONTROL_PERIOD_SEC 如 0.01注意清零和读取必须是同一个定时器实例的操作不要在主循环里再去读一遍然后再清零那样这两个动作之间有间隙高速旋转时每一周期会漏掉不少计数。换算公式里 CONTROL_PERIOD_SEC 必须和实际中断周期一致如果中断周期漂移测速也跟着漂这是很多人调 PID 调不稳的隐蔽原因。4. 写一个能跑的 PID结构体、计算函数与周期调用4.1 位置式 PID 结构体与计算函数把参数集中管理PID 代码不建议写在中断里散成全局变量那样调试和调参都是灾难。用一个结构体把比例、积分、微分三个系数和中间状态都包起来每次调用传指针代码清晰还能复用。下面的结构体是速度环场景的标准写法。我把dt也放进去这样调控制周期时只改一处初始化不用改公式。typedef struct { float Kp; // 比例系数 float Ki; // 积分系数 float Kd; // 微分系数 float dt; // 控制周期单位秒 float target; // 目标转速 RPM float actual; // 实际转速 RPM float err; // 当前误差 float err_last; // 上次误差 float integral; // 积分累计值 float out; // PID 输出映射到占空比 float out_max; // 输出限幅 float int_max; // 积分限幅防止积分饱和 } PID_t; float PID_Calc(PID_t *pid, float target, float actual) { pid-target target; pid-actual actual; pid-err target - actual; // 积分项累积带限幅 pid-integral pid-err * pid-dt; if (pid-integral pid-int_max) pid-integral pid-int_max; if (pid-integral -pid-int_max) pid-integral -pid-int_max; // 微分项误差变化率注意除 dt float derivative (pid-err - pid-err_last) / pid-dt; // 位置式输出 pid-out pid-Kp * pid-err pid-Ki * pid-integral pid-Kd * derivative; pid-err_last pid-err; // 输出限幅限幅范围对应占空比正负最大值 if (pid-out pid-out_max) pid-out pid-out_max; if (pid-out -pid-out_max) pid-out -pid-out_max; return pid-out; }积分限幅和输出限幅分开控制是这个结构的关键。输出限幅管的是 PID 最终给 PWM 的上限积分限幅管的是积分项自己的累计上限。很多入门代码只做输出限幅忽略积分限幅结果就是积分项在误差大的时候一路猛涨等到误差反向时积分项还停在最大值输出半天回不来这就是典型的积分饱和翻车现场。微分项这里用了(err - err_last) / dt而不是(err_last - err)符号要看清楚。如果直接把网上代码抄过来符号反了微分项不但不阻尼反而助振电机在目标转速附近高频抖动。写完之后先让目标为 0用手快速转动电机观察输出方向是最快的验证方法。4.2 控制周期内的完整流程采集、计算、限幅、换向HAL 库工程里启动一个定时器中断来产生控制周期比在 while(1) 里用延时更可靠。用 TIM6 基础定时器做 10ms 中断中断回调里做三件事读编码器、算 RPM、跑 PID。然后根据 PID 输出正负设置电机方向引脚绝对值映射到占空比。// stm32f1xx_it.c 或回调文件中控制周期中断服务 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM6) { // 1. 测速 int16_t cnt __HAL_TIM_GET_COUNTER(htim4); __HAL_TIM_SET_COUNTER(htim4, 0); float rpm (float)cnt / PULSE_PER_REV / CONTROL_PERIOD_SEC * 60.0f; // 2. PID 计算 float duty PID_Calc(g_pid, g_target_rpm, rpm); // 3. 方向与占空比输出 if (duty 0.0f) { HAL_GPIO_WritePin(DIR_GPIO_Port, DIR_Pin, GPIO_PIN_SET); __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, (uint32_t)duty); } else { HAL_GPIO_WritePin(DIR_GPIO_Port, DIR_Pin, GPIO_PIN_RESET); __HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, (uint32_t)(-duty)); } } }这段代码里最容易忽略的是duty是浮点数直接强转成 uint32_t 之前要先确保它在合法范围内。PID 输出限幅已经保证duty不会超过 1124但如果哪次改代码忘了初始化 out_max一个巨大的 float 强转成 uint32_t 后会发生不可预期的行为。我习惯在强转前加一层饱和判断双重保险。换向逻辑要理解PID 输出为正表示需要正转加速输出为负表示需要反转减速实际速度高于目标时PID 会给负值也就是反方向制动。4.3 启动阶段先开环验证再闭环保闭环不要一上来就把 PID 接进控制回路。先把 PID_Calc 屏蔽掉手动给固定占空比比如直接__HAL_TIM_SET_COMPARE(htim3, TIM_CHANNEL_1, 400)看电机转不转、方向对不对、编码器读数是不是正的。这一步能排除一大堆硬件问题包括 PWM 引脚接错、编码器 A/B 接反、驱动使能引脚没拉高。开环验证通过后再接 PID而且给一个小目标转速比如 100RPMKp 给到 1.0Ki 和 Kd 给 0。观察串口打印的实际转速如果电机越转越快冲到限幅说明误差方向反了或者编码器方向反了优先检查符号。如果电机在目标附近来回波动但收敛不了一直震荡那就是 Kp 太大。符号问题没查清楚之前调参数没有任何意义。5. 参数整定实战与避坑清单让电机从振荡到稳速5.1 先调 Kp从 1.0 开始观察临界振荡参数整定的顺序比具体数值重要。我每次都是先把 Ki 和 Kd 设 0只留 Kp目标转速设 400RPMKp 从 1.0 开始。观察实际转速曲线如果稳稳逼近目标并停在目标附近说明 Kp 在安全范围。然后每次增加 50%直到出现等幅振荡记下临界增益 Kpu 和振荡周期 Tu。等幅振荡是调参过程中的重要信号它说明系统的极限在哪里。临界增益出现后把 Kp 退回临界值的一半左右这是齐格勒-尼科尔斯法的粗略用法也是很多一线工程师实际做事的路径。不要追求一拍就稳那是控制理论课上的理想曲线真实电机带负载、带摩擦有点误差很正常。这一步最常见的误用是把 Kp 一点点加而不是成倍加结果调了一个小时还在 1.5 和 2.0 之间试。Kp 小的时候系统响应不敏感微调看不出区别。正确做法是大步探索找到振荡边界后再小步收敛。5.2 加 Ki 消除静差最后用 Kd 处理超调Kp 单独作用下电机速度会和目标有一个稳定误差也叫静差原因是摩擦力和负载需要持续的输出补偿。这时加 Ki积分项会把这部分误差累积起来补进输出。Ki 的起始值我给 0.05每个控制周期累加的贡献很小但胜在稳定。每次加 Ki 后观察稳态误差直到误差缩小到目标转速的 1% 以内。Kd 是最后才碰的。如果系统超调明显或者响应太慢才考虑加一点 Kd。Kd 的起始值在 0.01 到 0.1 之间它对噪声极其敏感编码器测速有一点毛刺就会被放大。一个典型的不良反应是目标转速 600RPM 时系统稳定但手碰一下电机轴系统就发出尖锐的嗡嗡声这就是 Kd 太大。这里要特别提一个实际案例某次我把控制周期从 10ms 改到 5ms然后忘了重新整定参数所有系数都按原来的习惯给。结果 Kd 项因为 dt 减半变得输出翻倍电机直接高频震荡。参数表和控制周期是绑定的改周期必须重调参数这是血泪经验。5.3 避坑5 条高频踩坑记录现象到解决一次讲清避坑一电机通电后不转PID 输出停在最大值。现象是串口打印 duty 一直是 1124实际转速是 0。原因是编码器 A/B 接反或者测速方向反了实际转速显示为负误差永远巨大积分项一路累积到限幅。解决交换编码器 A/B 接线或者在软件里把测速结果取反然后重新验证正转方向和计数值方向一致。避坑二低速时电机一顿一顿像在爬行。原因是 PWM 占空比在 0 到 3% 之间时电压不足以克服电机静摩擦电机不转PID 误差一直存在积分项慢慢累加占空比爬升到某个阈值电机突然转一下然后编码器读数上冲PID 又收回来。解决驱动输出加死区补偿当 PID 输出小于 30 时直接给 0大于 30 时才输出。这个阈值要实测不同电机不一样。避坑三目标转速附近来回震荡震荡周期约 0.5 秒到 2 秒。原因是 Kp 偏大或者控制周期偏长系统进入极限环。解决先确认实际控制周期和设定一致然后减小 Kp必要时缩短控制周期到 5ms。不要靠 Kd 硬压震荡Kd 会把噪声一起放大。避坑四用手捏住电机轴再松开转速过冲很大甚至反转。原因是积分饱和。误差大的 1 秒内积分项累积到 8.0 甚至更大松开后误差反向积分项要从 8.0 慢慢退回来这段时间里输出一直被积分项主导。解决加积分限幅把int_max设成输出限幅的一半这样松开后恢复时间明显缩短。避坑五用 PA6/PA7 同时做 PWM 和编码器输入结果两个信号互相干扰。原因是 STM32 的相邻引脚之间有内部关联PWM 高频翻转会耦合进编码器输入。解决PWM 用 TIM3 的 PA6/PA7 时编码器就换到 PB6/PB7 接 TIM4物理隔离。选引脚前先看定时器映射表不要把两个功能挤在同一个定时器上。6. 进阶目标值斜坡生成让 PID 不用硬扛阶跃冲击直接改g_target_rpm 600是最伤电机的做法。阶跃目标意味着控制周期开始瞬间误差就是 600比例项瞬间输出巨大积分项也在飞速累积PID 只能靠限幅硬扛。结果是电机全电压猛冲转速超调一大截再收回来机械部分和电流冲击都很大。我后来养成的习惯是目标转速永远走斜坡。// 目标转速斜坡生成每控制周期最多改变 accel*dt 的转速 float g_ramp_target 0.0f; void Ramp_Update(float final_target, float accel_rpm_per_s, float dt) { float diff final_target - g_ramp_target; if (diff accel_rpm_per_s * dt) g_ramp_target accel_rpm_per_s * dt; else if (diff -accel_rpm_per_s * dt) g_ramp_target - accel_rpm_per_s * dt; else g_ramp_target final_target; }调用方式是在每次控制周期中断里先执行Ramp_Update(600, 800, 0.01f)再把g_ramp_target传给PID_Calc而不是直接传 600。以 800RPM/s 的加速度从 0 爬到 600RPM 需要 0.75 秒这段时间里 PID 的误差始终在一个可控范围比例项不会瞬间爆表积分项也不会在启动阶段疯狂累积。斜坡的斜率要匹配电机和负载空载时 800~1500RPM/s 都合适带了减速箱大负载就降到 300~500RPM/s。配合调试串口输出每次控制周期打印一行目标转速, 实际转速, PID输出三个值用串口绘图工具看曲线比盯着串口数字文本直观得多。整定参数时重点看三件事响应延迟有多大、超调量是多少、稳态误差能不能收敛。这三个指标满足要求参数就算成熟了。有一次我在某个批次的电机上调好了参数换了一批电机直接震荡最后发现是减速箱阻尼差异导致机械时间常数变了斜坡加速度和 Kp 都要重新标定。从那之后我再也不迷信「万能参数」每一套硬件都花十分钟重新走一遍整定流程。希望帮到你。本文还有配套的精品资源点击获取