资讯详情

资讯详情

STM32 PID算法实战:位置式、增量式与抗积分饱和的工程实现

简介这份资源面向嵌入式初学者与需要落地控制算法的开发者围绕STM32F1系列微控制器实现PID闭环调节并以光照强度控制作为典型应用场景帮助读者理解比例、积分、微分三项如何协同改善系统稳定性与响应速度。压缩包共727个文件、约51.75MB以C源文件与头文件构成算法与驱动主体配套Keil工程文件、编译中间产物与hex烧录文件另含库函数开发指南、read_me说明及电路原理图便于对照硬件接口与定时器、ADC、PWM配置。已有4358人学习下载可作为从理论到实物的完整实践案例读者能据此梳理采样、误差计算与输出调节的代码组织方式并借助原理图核对传感器连接为参数整定与调试排错提供可复用的参考框架。1. 从一块跑偏的云台说起这个 STM32 PID 程序到底解决什么问题去年帮朋友调一个两轴云台MPU6050 数据读出来挺干净电机一上电就开始画圈越调越抖最后直接自激。翻遍代码才发现问题不在传感器也不在电机驱动而是控制环里那段 PID 写成了“位置式 无积分限幅 微分直接差分”参数一动就发散。这类问题在嵌入式控制里太常见了算法本身不复杂难的是把它写成能在 MCU 上稳定跑、能调、能复用的工程代码。这份 STM32 PID 算法程序就是冲着这个痛点来的它把位置式、增量式、积分分离、抗积分饱和这些常见变体整理成一套可移植的 C 模块配套一个光照 PID 的闭环示例——用光敏电阻采样、PWM 调 LED 亮度把“设定值—采样—误差—输出”这条链路完整跑通。适合正在做电机控制、温控、光照调节、平衡车这类闭环项目的开发者尤其是那些 PID 公式背得滚瓜烂熟、一上板子就翻车的人。2. 工程结构与技术选型为什么是裸机 C 而不是 RTOS2.1 目录分层与模块职责拿到源码先别急着编译把目录扫一遍比什么都强。这类 STM32 PID 工程通常按“硬件抽象—算法—应用”三层切分结构大致如下Project/ ├── Core/ │ ├── Inc/ │ │ ├── pid.h // PID 算法接口 │ │ ├── adc_sample.h // 光照采样 │ │ └── pwm_out.h // PWM 输出 │ └── Src/ │ ├── main.c // 初始化 主循环 │ ├── pid.c // PID 核心实现 │ ├── adc_sample.c // ADC 采集与滤波 │ └── pwm_out.c // 定时器 PWM ├── Drivers/ │ ├── CMSIS/ // 内核头文件 │ └── STM32F1xx_HAL_Driver/ // HAL 库 └── MDK-ARM/ 或 STM32CubeIDE/ // 工程文件pid.c是纯算法不碰任何寄存器adc_sample.c和pwm_out.c负责把硬件细节包起来main.c只做编排。这种切法的好处是算法可以整段拷到别的芯片上换 MCU 只改外设层。我一般会先看pid.h里的结构体定义它决定了这个模块能不能复用。2.2 裸机主循环 vs 定时器中断这类项目基本不跑 RTOS控制周期靠定时器中断保证。常见做法是 TIM3 配成 1ms 或 10ms 中断在中断里置标志位主循环检测到标志再执行一次 PID 计算和 PWM 更新。为什么不直接在中断里算完因为 PID 里可能有浮点运算中断里待太久会影响其他外设响应。主循环结构大概长这样int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ADC1_Init(); MX_TIM3_Init(); // 控制周期定时器 MX_TIM2_Init(); // PWM 输出定时器 PID_Init(pid_light, 2.0f, 0.5f, 0.1f); // Kp Ki Kd 初值 PID_SetTarget(pid_light, 2000); // 目标光照 ADC 值 HAL_TIM_Base_Start_IT(htim3); // 启动控制周期 HAL_TIM_PWM_Start(htim2, TIM_CHANNEL_1); while (1) { if (g_control_flag) { g_control_flag 0; uint16_t adc_val ADC_GetFilteredValue(); // 采样 float out PID_Calc(pid_light, adc_val); // 计算 PWM_SetDuty((uint16_t)out); // 输出 } } }g_control_flag在 TIM3 中断里置 1主循环消费。PID_Calc返回的是占空比数值直接喂给 PWM 比较寄存器。这里有个细节ADC 值和 PWM 占空比的量纲要统一否则参数调起来毫无手感。我见过有人 ADC 是 0~4095、PWM 是 0~1000Kp 设 1.0 结果输出直接饱和还以为是算法写错了。2.3 定点还是浮点选型背后的取舍STM32F1 没有 FPU浮点运算是软件模拟一次乘加大概几十个周期。控制周期 10ms 的话完全够用但如果做到 1ms 甚至更快的电流环就得考虑定点化。这份程序默认用float可读性好、调参直观如果移植到 F0 或需要高频控制把float换成int32_t配合 Q 格式定点即可结构体里的字段类型改一下运算部分做移位。常见做法是 Q12 或 Q15具体看误差范围。选浮点不是偷懒是这个场景下 10ms 周期绰绰有余没必要为了省那点周期把代码搞得难读。3. 核心算法拆解位置式、增量式与抗饱和怎么落地3.1 PID 结构体与初始化先看头文件里的定义这是整个模块的骨架typedef struct { float Kp; // 比例系数 float Ki; // 积分系数 float Kd; // 微分系数 float target; // 目标值 float actual; // 实际值 float err; // 当前误差 float err_last; // 上次误差 float integral; // 积分累加 float integral_max; // 积分限幅 float out_max; // 输出限幅 float out_min; float output; // 本次输出 } PID_TypeDef;初始化函数把系数和目标值写进去同时给积分和输出限幅设默认值void PID_Init(PID_TypeDef *pid, float kp, float ki, float kd) { pid-Kp kp; pid-Ki ki; pid-Kd kd; pid-target 0; pid-actual 0; pid-err 0; pid-err_last 0; pid-integral 0; pid-integral_max 5000.0f; // 积分项上限防止饱和 pid-out_max 1000.0f; // 对应 PWM 满占空比 pid-out_min 0.0f; pid-output 0; }integral_max和out_max这两个限幅字段是这套代码和教科书示例最大的区别。教科书只讲公式工程代码必须回答“积分累到多大要停”“输出超了怎么办”。integral_max一般取输出上限的 0.5~1 倍具体看系统惯性out_max要和 PWM 定时器的 ARR 值对齐否则限幅形同虚设。3.2 位置式 PID 的完整实现位置式是最直观的形式输出直接对应执行器绝对量float PID_Calc(PID_TypeDef *pid, float actual) { pid-actual actual; pid-err pid-target - pid-actual; // 积分累加 抗饱和只在输出未饱和时累加 if (pid-output pid-out_max pid-output pid-out_min) { pid-integral pid-err; if (pid-integral pid-integral_max) pid-integral pid-integral_max; else if (pid-integral -pid-integral_max) pid-integral -pid-integral_max; } // 位置式公式 pid-output pid-Kp * pid-err pid-Ki * pid-integral pid-Kd * (pid-err - pid-err_last); // 输出限幅 if (pid-output pid-out_max) pid-output pid-out_max; if (pid-output pid-out_min) pid-output pid-out_min; pid-err_last pid-err; return pid-output; }逐段看。误差计算是基础target - actual的方向决定了正反馈还是负反馈光照场景下目标 ADC 值大于实际值说明偏暗需要加大 PWM方向是对的。积分部分外面套了一层if这是抗积分饱和的关键输出已经顶到上限了还继续累积分等误差反向时积分要很久才能退回来表现就是“超调后长时间拉不回”。微分项用的是误差差分err - err_last不是测量值差分两者在目标值突变时表现不同——误差微分会在设定值跳变时产生一个尖峰测量值微分则平滑些。这份代码用的是误差微分设定值固定时没问题如果目标值会动态变化建议改成-(actual - actual_last)。3.3 增量式 PID 与积分分离增量式输出的是“本次比上次增加多少”适合步进电机或需要平滑过渡的场景float PID_Calc_Increment(PID_TypeDef *pid, float actual) { pid-actual actual; pid-err pid-target - pid-actual; float delta pid-Kp * (pid-err - pid-err_last) pid-Ki * pid-err pid-Kd * (pid-err - 2 * pid-err_last pid-err_prev); pid-output delta; if (pid-output pid-out_max) pid-output pid-out_max; if (pid-output pid-out_min) pid-output pid-out_min; pid-err_prev pid-err_last; pid-err_last pid-err; return pid-output; }增量式天然没有积分累加器所以不存在积分饱和问题但它对历史误差的依赖更强err_prev这个字段不能漏。注意增量式的Ki和位置式的Ki物理意义不同同一组参数不能直接互换移植时参数要重调。积分分离是另一个实用变体误差大的时候不积分避免启动阶段积分猛涨误差进入小范围后再启用积分消除静差。实现上就是在积分累加前加一个判断if (fabsf(pid-err) pid-integral_sep_threshold) { pid-integral pid-err; }integral_sep_threshold一般取目标值的 5%~10%。光照控制里刚上电时 ADC 和目标差几百这时候积分没意义等接近目标了再让积分干活超调会小很多。3.4 采样滤波被低估的一半PID 效果好不好一半看采样。光敏电阻的 ADC 读数带噪声直接进 PID 会让微分项疯狂放大噪声。这份程序在adc_sample.c里做了滑动平均#define FILTER_LEN 8 static uint16_t buf[FILTER_LEN]; static uint8_t idx 0; uint16_t ADC_GetFilteredValue(void) { buf[idx] ADC_ReadRaw(); idx (idx 1) % FILTER_LEN; uint32_t sum 0; for (uint8_t i 0; i FILTER_LEN; i) sum buf[i]; return (uint16_t)(sum / FILTER_LEN); }8 点滑动平均计算量小、内存占用低对光照这种慢变量足够。如果是电机电流环滑动平均会引入相位滞后得换一阶低通或中值滤波。这里的选择逻辑是控制周期 10ms、被控量变化慢平均滤波的滞后可以接受换成 1ms 电流环就不行了滞后会吃掉相位裕度。4. 跑起来与调参从编译到第一次闭环4.1 编译烧录与硬件接线工程支持 Keil MDK 和 STM32CubeIDE 两套工程文件打开对应目录直接编译。硬件侧需要三根线光敏电阻分压点接 ADC 通道比如 PA0LED 驱动接 PWM 通道比如 PA1/TIM2_CH1串口接 PA9/PA10 用于打印调试。烧录前确认SystemClock_Config里的主频和实际晶振匹配F1 系列常见 72MHz配错了定时器周期全乱PID 周期跟着变参数怎么调都不对。上电后先别急着看闭环效果把PID_Calc的返回值通过串口打出来同时打印 ADC 原始值。观察三件事ADC 值是否随遮挡变化、PWM 占空比是否跟着动、输出有没有顶到限幅。这三步过了闭环链路就是通的。4.2 参数整定的实操顺序调参顺序我一般按“先 P 后 I 再 D”走但有几个前提先把Ki、Kd置 0integral_max设小一点防止意外积分out_max按实际 PWM 上限设。然后Kp从 0 开始加每次翻倍直到系统开始等幅振荡记下这个临界值取它的 60% 作为初值。加Ki从临界值的 1/10 开始观察静差消除速度太快会超调太慢拖沓。最后加Kd光照这种大惯性系统Kd可以稍大但噪声大的时候Kd会放大抖动配合滤波一起调。串口打印建议带上时间戳把 target、actual、output 三列一起输出用串口助手或简单脚本画个曲线比盯着数字猜强得多。我见过有人不看曲线硬调调了两天以为算法有问题其实是 ADC 参考电压接错了。4.3 用阶跃响应验证闭环质量调完参数要做一次阶跃测试把 target 从 1000 直接跳到 2500看 actual 的响应曲线。合格的响应应该是上升时间可控、超调小于 20%、能在几个控制周期内稳定。如果超调大先降Kp或加积分分离如果稳态有静差加Ki如果振荡频率高多半是Kd太大或采样噪声没滤干净。这套验证方法比“看着差不多就行”靠谱也是二次开发时回归测试的基础。5. 避坑与排查那些让 PID 看起来“失灵”的坑5.1 输出方向反了越调越远现象一上电 LED 亮度往反方向走误差越来越大直到饱和。原因target - actual的符号和執行器方向不匹配比如 ADC 值越大表示越亮但 PWM 占空比越大也越亮这时候误差为正应该减小 PWM 而不是增大。解决在PID_Calc返回前加符号或者初始化时把Kp/Ki/Kd整体取负。我一般倾向于在输出层取反算法层保持“误差正→输出增”的统一约定换执行器时只改一处。5.2 积分饱和导致长时间拉不回现象启动时输出迅速顶满之后目标达到了但输出迟迟不降超调巨大。原因积分项在输出饱和期间继续累加累到了很大的值反向误差需要很久才能抵消。解决确认PID_Calc里的抗饱和判断生效integral_max不要设得过大或者直接用积分分离误差大时不积分。血泪经验是integral_max一开始就设成out_max的一半调稳了再放宽。5.3 控制周期不稳定参数全白调现象同样的参数有时候稳有时候振。原因PID 计算放在主循环里主循环里还有其他耗时操作比如串口打印导致实际控制周期抖动。解决把 PID 计算和 PWM 更新放进定时器中断或者主循环里只做标志位判断、确保计算路径短。串口打印这种操作要么放到低优先级任务要么降低打印频率。控制周期抖动是隐形的参数杀手很多人调不出来就是栽在这。5.4 浮点运算在中断里拖垮时序现象加了 PID 后串口丢数据、其他中断响应变慢。原因F1 没有 FPU浮点乘加在中断里执行时间长高频中断下累积开销明显。解决控制周期不紧的话把计算挪到主循环周期紧就定点化或者换带 FPU 的型号。注意fabsf、浮点除法这些操作比乘法贵得多能避免就避免。5.5 采样与输出量纲不一致现象参数怎么调都别扭要么响应迟钝要么剧烈振荡。原因ADC 范围 0~4095PWM 比较值 0~1000Kp设 1.0 时误差 100 就产生 100 的输出相对量纲完全失衡。解决在采样或输出环节做归一化把两边都映射到 0~1 或 0~1000让Kp的量级落在 0.1~10 这个好调的区间。量纲统一之后参数整定会顺很多。6. 二次开发把这套 PID 接到你的项目上拿到这份代码最值的改造不是调参数而是把算法层和硬件层彻底解耦让它能直接嵌进你现有的工程。我的做法是保留pid.c/pid.h原样只替换采样和输出两个函数指针。具体来说在结构体里加两个回调typedef float (*PID_ReadFunc)(void); typedef void (*PID_WriteFunc)(float); typedef struct { /* 原有字段 */ PID_ReadFunc read_actual; // 采样回调 PID_WriteFunc write_output; // 输出回调 } PID_TypeDef;这样PID_Calc不再依赖具体外设光照项目传 ADC 读取和 PWM 设置温控项目传 DS18B20 读温和继电器/加热丝控制算法一行不改。改造后的调用变成pid_light.read_actual ADC_GetFilteredValue; pid_light.write_output PWM_SetDutyFloat; /* 主循环里 */ float actual pid_light.read_actual(); float out PID_Calc(pid_light, actual); pid_light.write_output(out);第二个改造点是参数存储。调好的Kp/Ki/Kd每次上电重烧太麻烦用 Flash 最后一页存一组参数上电读出来串口命令在线改。注意 Flash 写之前要擦除整页写频率别太高调参阶段可以先放 RAM 里定型后再固化。第三个点是多环场景。云台、平衡车这类项目往往串级 PID外环输出作为内环目标。这套代码的结构体是独立的声明两个实例pid_outer、pid_inner即可外环周期慢、内环周期快分别挂不同定时器。串级调参先调内环再调外环内环没稳就调外环纯属给自己找麻烦。验证改造是否成功别只看现象做一次阶跃响应对比改造前后同一组参数、同一个目标跳变响应曲线应该基本重合。如果差很多多半是回调里多了耗时操作或者量纲变了。从那以后我每次移植 PID 模块都强制先跑一遍开环输出测试——给定固定误差看输出是否等于手算值确认算法层没问题再进闭环。这个习惯帮我省掉了无数次“到底是算法错还是硬件错”的扯皮。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →