☰
STM32 PID控制实战:从定点实现到光照闭环整定与避坑
2026/10/26 2:32:56 网站建设 项目流程

简介:该资源面向嵌入式初学者与需要落地控制算法的开发者,围绕STM32F1系列(Cortex-M3内核)展开PID算法实践,重点演示比例、积分、微分三项如何协同调节光照强度,帮助读者理解参数整定对稳定性、响应速度与超调量的影响。压缩包共727个文件、约51.75MB,以C源码与头文件、Keil工程配置及编译中间产物为主,另含HEX烧录文件、原理图与两份PDF参考文档,覆盖从硬件接口到固件库调用的完整链路。已有4358人学习下载,说明其在同类教程中具备一定参考价值。读者可据此对照定时器采样、ADC光照采集与PWM调光的实现思路,结合原理图理清传感器与主控的连接方式,并借助工程文件直接编译调试,是学习STM32与PID控制理论的实践案例。

1. 拿到一份 STM32 PID 程序,先别急着烧录

光照控制是嵌入式入门里最容易翻车的一类项目:传感器读数飘、执行器响应慢、参数一改就震荡。这份 023_STM32PID算法程序,核心就是把 PID 控制律落到 STM32 上,用一路光照采样驱动一路输出调节,典型场景是补光灯亮度闭环、遮阳帘开合、恒光培养箱这类需要把光强稳在设定值上的活。它适合两类人:一类是刚学完定时器和 ADC、想找个完整闭环练手的;另一类是手上已有硬件、想直接拿一套可改的 PID 骨架去套自己执行器的。程序本身不复杂,复杂的是参数怎么整定、采样怎么对齐、积分怎么不炸,后面几章就按这个顺序拆。

2. PID 控制律在 STM32 上的落地形态:位置式、增量式与定点取舍

2.1 先看清这份程序用的是哪种 PID 形态

拿到源码先别改参数,先确认它实现的是位置式还是增量式,这决定了你后面整定的手感。位置式输出直接对应执行器的绝对量,公式是 u(k)=Kp·e(k)+Ki·Σe(j)+Kd·[e(k)-e(k-1)],积分项累加全部历史误差,一旦输出饱和,积分会继续往一个方向堆,这就是积分饱和的根源。增量式输出的是本次相对上次的变化量 Δu,公式里只出现最近三次误差,天然不带历史累加包袱,抗饱和更省心,但输出需要你自己维护一个累加器。

这份程序按标题和常见写法,主体是位置式 PID,配一个输出限幅。判断方法很简单:看结构体里有没有 integral 这类累加成员,看输出函数返回的是绝对 PWM 值还是增量。如果是绝对 PWM,那就是位置式。位置式的好处是直观,设定值和反馈值一比,误差直接进公式,调参时 Kp 加大响应变快、Ki 加大消静差、Kd 加大压超调,因果关系清楚;坏处就是刚才说的饱和,以及积分项在启动瞬间被大误差灌满。

我一般会先跑一遍空载,把执行器断开,只打印误差和输出,看输出在设定值附近是收敛还是画圈。画圈就是 Kp 或 Kd 给大了,缓慢爬不到就是 Ki 不够。这一步不接硬件也能做,串口打点足够。

2.2 定点还是浮点:Cortex-M 上的现实选择

STM32 分带 FPU 和不带 FPU 两类。F4、F7、H7 这类带单精度硬件浮点,PID 里直接用 float 没问题,运算开销可以接受。F1、F0、G0 这类没有 FPU,浮点全靠软件模拟,一次乘加的开销是几十个周期,如果 PID 跑在 1kHz 的控制周期里,浮点会明显吃掉 CPU。常见做法是转定点:把误差和系数放大 2 的整数倍,用 int32 或 q15 格式运算,最后右移还原。

下面是一段定点位置式 PID 的骨架,放大倍数取 1024,够覆盖常见光照误差范围又不至于溢出:

/* 定点位置式 PID,Q10 格式,系数与误差均放大 1024 倍 */ #define PID_SCALE 1024 #define OUT_MAX 1000 /* 输出上限,对应 PWM 满占空比 */ #define OUT_MIN 0 typedef struct { int32_t kp, ki, kd; /* 已放大 1024 倍的系数 */ int32_t err_prev; /* 上一次误差 */ int32_t integral; /* 积分累加,已放大 */ int32_t out; } PID_Q10; int32_t pid_calc(PID_Q10 *p, int32_t setpoint, int32_t feedback) { int32_t err = setpoint - feedback; int32_t deriv = err - p->err_prev; /* 积分项先累加,再做限幅,防止饱和 */ p->integral += err; if (p->integral > (OUT_MAX * PID_SCALE)) p->integral = (OUT_MAX * PID_SCALE); if (p->integral < -(OUT_MAX * PID_SCALE)) p->integral = -(OUT_MAX * PID_SCALE); /* 三项分别乘系数后右移还原,注意先加后移避免精度丢失 */ int32_t out = (p->kp * err + p->ki * p->integral + p->kd * deriv) >> 10; if (out > OUT_MAX) out = OUT_MAX; if (out < OUT_MIN) out = OUT_MIN; p->err_prev = err; p->out = out; return out; }

逻辑上分三步:算误差和微分、累加并限幅积分、三项加权后右移还原。参数说明几个关键点。PID_SCALE 取 1024 是 Q10,误差范围假设在 ±512 以内,乘上系数后仍在 int32 安全区;如果你的光照 ADC 是 12 位、误差能到 ±4095,就要把 SCALE 降到 256 或者改用 int64 中间量,否则 kp 稍大就溢出,现象是输出突然跳到限幅值不动,这个坑后面还会提。integral 的限幅上限用 OUT_MAX*PID_SCALE,是让积分单独作用时也不超过输出上限,这是最朴素的抗饱和。右移用 >>10 而不是除以 1024,是因为对负数右移在多数编译器上是算术右移,行为一致,但要注意不同编译器对负数右移的实现细节,保守做法是写成一个带符号的除法宏。

浮点版本就简单得多,结构体里全用 float,公式照抄,限幅照做。带 FPU 的片子开一下编译选项里的硬件浮点,别让它走软件库。判断有没有走软件浮点,看反汇编里有没有 __aeabi_fmul 这类调用,有就是没开对。

2.3 控制周期与采样:PID 跑在哪个节拍上

PID 不是越快越好。控制周期要跟执行器和传感器的响应时间匹配。光照传感器本身有积分时间,常见模块的响应在几十毫秒量级,PWM 调光如果走人眼可见的频闪规避,载波在几百 Hz 到几 kHz。控制周期取 10ms 到 50ms 都合理,取 1ms 反而会把传感器噪声放大进微分项。

这份程序一般把 PID 计算放在定时器中断里,或者放主循环配一个软件计时。放中断里要注意中断服务函数别做浮点长运算、别打印,只算 PID 和更新 PWM 寄存器。采样和计算分离是更稳的做法:ADC 用 DMA 连续采,主循环里按固定节拍取最新值算 PID。下面是一个基于 HAL 的定时器节拍骨架:

/* 1kHz 定时器中断里只置标志,PID 在 20ms 节拍上算一次 */ volatile uint8_t pid_tick = 0; volatile uint32_t tick_cnt = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM3) { tick_cnt++; if (tick_cnt >= 20) { /* 20 * 1ms = 20ms 控制周期 */ tick_cnt = 0; pid_tick = 1; } } } /* 主循环 */ while (1) { if (pid_tick) { pid_tick = 0; uint16_t lux = adc_get_latest(); /* 取 DMA 最新采样 */ int32_t out = pid_calc(&pid, target, lux); pwm_set_duty(out); /* 更新占空比 */ } }

这样中断里只有计数,重活都在主循环,节拍稳定又不会阻塞采样。参数上,tick_cnt 的阈值就是控制周期除以定时器周期,改周期只改这一个数。要注意 adc_get_latest 读的是 DMA 缓冲,读的时候最好关一下对应通道的 DMA 或者用双缓冲,否则可能读到半更新的值,现象是 PID 输出偶尔抖一下,很难查。

3. 光照闭环的整定流程:从开环辨识到参数收敛

3.1 先做开环,拿到对象的大致增益和滞后

闭眼调 PID 是血泪经验的来源。正确顺序是先开环:固定 PWM 占空比,记录光照读数随时间的变化,看它多久稳定、稳态值多少、有没有明显滞后。比如占空比从 30% 跳到 60%,光照从 200lx 爬到 800lx 用了 300ms,那对象的响应时间就在几百毫秒量级,控制周期取 20ms 是合适的,取 5ms 就过采样了。

开环还能估一个粗略增益:Δ光照/Δ占空比。假设 30% 对应 200lx、60% 对应 800lx,增益约 20lx/%。这个数决定了 Kp 的量级:如果增益很大,Kp 就要小,否则一有误差输出就顶到限幅。很多人上来 Kp 给 10,结果输出直接满占空比,灯全亮,还以为是程序坏了,其实是增益没估。

开环测试的代码很简单,固定占空比加串口打点:

/* 开环阶跃测试:占空比从 30% 跳到 60%,每 10ms 打印一次光照 */ pwm_set_duty(300); /* 假设满量程 1000 */ HAL_Delay(2000); /* 等稳定 */ pwm_set_duty(600); for (int i = 0; i < 100; i++) { printf("%lu,%u\r\n", HAL_GetTick(), adc_get_latest()); HAL_Delay(10); }

把串口数据丢进表格画条曲线,响应时间、稳态值、有没有超调一目了然。这一步花十分钟,后面调参能省两小时。

3.2 整定顺序:先 P 后 I 再 D,每步只动一个

有了开环数据,整定按经典顺序走。先把 Ki、Kd 置零,只留 Kp,从一个小值开始加,加到输出开始等幅振荡,记下这个临界 Kp,然后取它的 60% 到 70% 作为工作值。这一步看的是响应速度,Kp 太小爬得慢,太大就振荡。光照这类对象一般不会太激进,Kp 取临界值的一半偏保守但稳。

然后加 Ki 消静差。静差的来源是执行器和传感器的非线性,比如低占空比时灯珠效率低,同样的 PWM 增量带来的光照增量小,纯 P 控制会停在一个比设定值低的平衡点。Ki 从临界 Kp 的 1/10 量级开始加,加到静差在可接受范围内消失为止,加过头会出现低频摆动,周期比 P 振荡长得多,这是积分在追。判断方法是看输出:如果输出缓慢地来回爬,周期几秒,就是 Ki 大了。

最后加 Kd 压超调。光照对象通常滞后不大,Kd 往往可以很小甚至为零。Kd 的作用是对误差变化率敏感,传感器噪声会被它放大,所以加 Kd 之前先确认采样是平滑的,必要时对反馈值做一阶低通。Kd 加过头最典型的现象是输出高频抖动,PWM 占空比在小范围内快速跳,灯看起来在闪。

下面这张表是我一般会记的整定记录,方便回溯:

阶段KpKiKd现象结论
纯 P2.000稳态低于设定 15%有静差,加 I
加 I2.00.050静差消失,略超调可接受
加 D2.00.050.1超调减小,输出微抖D 偏大,降到 0.05

表里的数值只是量级示意,实际值取决于你的放大倍数和对象增益,别照抄。关键是每步只动一个参数,动完等系统稳定再看,别一边改一边看,那样永远调不出来。

3.3 设定值突变和抗饱和:两个必须单独测的场景

稳态调好只是及格。真正暴露问题的是设定值突变和输出饱和。设定值从 300lx 跳到 800lx,看输出多久跟上、超调多少、有没有回不来。如果超调后长时间贴在限幅值上,就是积分饱和了,位置式 PID 必须有抗饱和,常见做法是积分分离或者输出限幅时停止积分累加。

积分分离的思路是误差大时不积分,只让 P 和 D 起作用,误差进入一个小窗口再放开积分。改起来就是在累加前加个判断:

/* 积分分离:误差绝对值超过阈值时不累加积分 */ #define INTEGRAL_BAND 50 /* 误差窗口,按你的量程调 */ if (abs(err) < INTEGRAL_BAND) { p->integral += err; /* 限幅照旧 */ }

阈值取量程的 5% 到 10% 比较常见,太小起不到分离作用,太大等于没分离。另一个场景是执行器到顶,比如灯已经全亮但光照还不到设定值,这时候积分会一直涨,等光照上来后输出要很久才降下来。解决办法是输出饱和时冻结积分,或者用反计算抗饱和,把饱和量与积分项做差反馈回去。这份程序如果只做了简单限幅,这两个场景大概率会翻车,测的时候一定要手动构造。

4. 避坑与排查:光照 PID 最容易翻车的五个地方

4.1 现象:输出直接顶到限幅不动,改参数没反应

原因多半是定点运算溢出。误差乘系数后超过 int32 范围,结果变成负数或者乱值,再经过限幅就贴在边界。光照 ADC 12 位、误差能到 4095,如果 PID_SCALE 还是 1024,kp 只要到 500 就溢出。解决是把 SCALE 降到 256,或者中间量用 int64_t 承接,算完再截断。排查时在乘法后打印原始值,看是不是突然变号。

4.2 现象:光照读数一直在小幅跳,PID 输出跟着抖

原因是传感器噪声进了微分项,或者采样没对齐。先看 ADC 是不是 DMA 连续采、读的是不是完整更新后的值;再看有没有对反馈做滤波。常见做法是给反馈加一阶低通,系数 0.1 到 0.3,或者干脆把 Kd 置零。注意滤波会引入相位滞后,系数别太小,否则等于给系统加了个大延迟,反而更容易振荡。

4.3 现象:稳态光照总是比设定值低一截,加 Ki 后开始慢摆

这是纯 P 的静差和 Ki 过大交替出现。静差本身正常,加 Ki 是对的,但 Ki 加太快会低频摆动。解决是 Ki 从小往大加,每次加完等至少三个摆动周期再看;同时确认积分限幅生效,别让积分项无限涨。如果摆动周期很长(几秒级),基本就是 Ki 大了,往回退一半。

4.4 现象:设定值跳变后输出长时间贴在满占空比,降不下来

积分饱和。位置式 PID 在大误差期间积分累满,误差反向后要先把积分卸掉才响应。解决是加积分分离或饱和冻结积分,参考 3.3 的写法。测这个场景要故意把设定值拉高到执行器达不到的值,保持几秒再降回来,看输出恢复快不快。

4.5 现象:换一块板子或换一个灯,参数全废

对象增益变了。PID 参数和对象强相关,灯珠型号、驱动电路、传感器安装位置都会改变增益和滞后。换硬件后必须重做开环辨识,至少重新估一遍增益,再按比例缩放 Kp。别指望一套参数通吃,这是最常见的误用。另外注意供电电压变化也会影响亮度,如果电源不稳,闭环会把电源波动也当成误差去补,表现是输出一直在微调。

5. 把 PID 做成可复用的模块:参数在线改与效果验证

调通一次不算完,能在线改参数、能验证效果,才算把这套东西用起来。我一般会给 PID 模块加一个串口命令入口,支持运行时改 Kp、Ki、Kd 和设定值,改完立即生效,不用反复烧录。命令解析不用复杂,一个字符加几个数字就够:

/* 极简串口调参:p200 表示 Kp 设为 2.00,s800 表示设定值 800 */ void cmd_parse(char *buf) { switch (buf[0]) { case 'p': pid.kp = atoi(&buf[1]) * PID_SCALE / 100; break; case 'i': pid.ki = atoi(&buf[1]) * PID_SCALE / 1000; break; case 'd': pid.kd = atoi(&buf[1]) * PID_SCALE / 100; break; case 's': target = atoi(&buf[1]); break; default: break; } }

参数说明:p 命令按百分之一缩放,i 命令按千分之一,是因为 Ki 的量级通常比 Kp 小一到两个数量级,这样输入整数更方便。改完参数记得把 integral 清零,否则旧的积分会带着新系数继续作用,现象是改完参数输出突然跳一下。这个细节不注意,会以为是参数本身的问题。

验证效果别只看稳态。我习惯做三组测试:阶跃响应看上升时间和超调,设定值来回跳看恢复,长时间运行看有没有漂移。阶跃响应可以用前面开环那套打点代码,把设定值从低跳到高,记录光照曲线,算上升时间(10% 到 90%)和超调量。恢复测试是把设定值在高低位之间来回切几次,看每次回到稳态的时间是否一致。长时间运行至少跑半小时,看积分项有没有缓慢爬升,那往往是传感器漂移或者执行器温漂,PID 在替硬件背锅。

还有一个容易被忽略的点:PWM 频率和光照传感器的采样要错开。如果 PWM 载波和 ADC 采样同步,采到的可能是开关瞬间的值,读数会周期性偏。常见做法是让 ADC 在 PWM 周期的中点触发,或者干脆用独立定时器错开相位。这个坑不常提,但一旦碰上,现象是光照读数带固定频率的纹波,PID 怎么调都不干净。

从那以后我每次拿到一份 PID 程序,都强制先跑开环、再单参数整定、最后做饱和和跳变测试,三步不走完不接实际负载。这套流程看着慢,实际比反复烧录试参数快得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询