☰
STM32 PID闭环控制实战:从光照采样到PWM输出的完整实现
2026/10/26 2:33:07 网站建设 项目流程

简介:这份资源面向正在学习嵌入式控制算法的开发者与高校学生,聚焦如何在STM32F1系列微控制器上落地PID控制,并以光照强度调节作为完整实践场景。压缩包共727个文件、约51.75MB,以C源文件与头文件构成算法与驱动主体,配套Keil工程文件、编译中间产物、HEX与AXF可执行文件,另含库函数开发指南与read_me说明文档、电路原理图,便于对照硬件接口理解定时器采样、ADC采集与PWM调光之间的配合关系。已有4358人学习下载,说明其在入门PID调试人群中具备一定参考价值。读者可从中获得比例、积分、微分三项参数整定的实验思路,理解误差累积与超调抑制的取舍,并借助原理图与工程配置还原从传感器读数到输出调节的完整链路,适合作为课程设计或自学STM32控制策略的实操样本。

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

有个做温室补光的朋友找我,说他的灯板亮度总是忽明忽暗,手动调电位器根本稳不住。我让他把控制逻辑从“人肉闭环”换成 PID,他一脸茫然:PID 不是电机和温控才用吗?其实光照闭环是 PID 最典型的落地场景之一——光敏电阻采样、ADC 转换、误差累积、PWM 输出,一条完整的控制链路。这份 023_STM32PID 算法程序就是围绕这个场景写的:STM32 上跑位置式/增量式 PID,驱动 LED 或补光灯做恒光照控制。适合刚接触嵌入式闭环控制、想拿一份能跑能改的 PID 模板的开发者,也适合手里有 STM32 板子、想把算法从公式变成代码的人。

2. 项目定位与技术选型:为什么是 STM32 加 PID

2.1 这个程序到底解决什么问题

光照 PID 的本质是一个负反馈系统:目标光照值设定后,传感器实时采集当前光照,两者相减得到误差,PID 根据误差算出 PWM 占空比,调节灯板亮度,让实际光照逼近设定值。听起来简单,但真写起来坑不少——采样噪声、积分饱和、输出限幅、执行器死区,任何一个没处理好,灯就会抖或者响应迟钝。

这份程序把这些问题都摆在了明面上。它不是一个只演示公式的玩具,而是按工程可用的方式组织的:采样、滤波、PID 计算、PWM 输出、串口调试各占一块,改起来不用满文件找。对新手来说,它是一份能直接烧进板子看效果的模板;对熟手来说,它的分层方式值得借鉴——算法和硬件驱动解耦,换传感器或换执行器时只动驱动层。

从技术选型看,STM32 做这件事有几个现实理由。第一,片上 ADC 多通道、12 位精度,接光敏电阻分压电路足够用;第二,定时器 PWM 输出稳定,占空比分辨率高,调光不闪;第三,Cortex-M 内核跑 PID 这种浮点或定点运算绰绰有余,主频几十兆就能做到毫秒级控制周期。相比用纯模拟电路搭闭环,STM32 方案的好处是参数可调、逻辑可改、状态可观测,调试时串口一打,误差曲线一目了然。

2.2 技术栈与工程组织

程序基于 STM32 标准外设库或 HAL 库(常见做法是 HAL,配合 CubeMX 生成初始化代码),核心外设用到 ADC、定时器 PWM、USART。开发环境一般是 Keil MDK 或 STM32CubeIDE,编译链 arm-none-eabi-gcc 也能用。整个工程按功能切成几层,目录结构大致如下:

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 // 占空比设置 ├── Drivers/ // HAL 库与启动文件 └── Debug/ // 编译输出

这种切法的好处是职责清晰:pid.c 里只有数学,不碰寄存器;adc_sample.c 只管把电压变成光照值;pwm_out.c 只管把占空比写进比较寄存器。二次开发时想换滤波算法,只改 adc_sample.c;想换控制周期,只动 main.c 里的定时逻辑。我一般会建议把 PID 参数也单独放一个配置文件或结构体初始化里,别散落在代码各处,否则调参时来回翻文件,血泪经验。

提示:不同板子的 ADC 通道和定时器编号不一样,移植时先确认引脚映射,别直接照抄初始化代码。

3. 核心模块拆解:从采样到 PWM 的完整链路

3.1 光照采样与滤波:别让噪声骗了 PID

光敏电阻接成分压电路后,ADC 读到的原始值波动很大,直接送进 PID 会让微分项疯狂放大噪声。常见做法是先做多次采样取平均,再加一阶低通滤波。下面这段是采样与滤波的典型写法:

#define SAMPLE_TIMES 8 // 读取一次 ADC 并转换为光照相对值(0~4095) uint16_t adc_read_once(void) { HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); uint16_t raw = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); return raw; } // 多次采样取平均,抑制随机噪声 uint16_t light_sample_filtered(void) { uint32_t sum = 0; for (int i = 0; i < SAMPLE_TIMES; i++) { sum += adc_read_once(); } return (uint16_t)(sum / SAMPLE_TIMES); }

SAMPLE_TIMES是平均次数,取 8 或 16 比较常见,次数越多越平滑但响应越慢,控制周期短的时候别设太大。adc_read_once里用轮询方式读转换结果,简单可靠;如果主循环任务多,可以改成 DMA 加中断,把采样放到后台。滤波后的值再经过一阶低通:

// 一阶低通滤波,alpha 越小越平滑 float low_pass_filter(float new_val, float old_val, float alpha) { return alpha * new_val + (1.0f - alpha) * old_val; }

alpha一般取 0.1 到 0.3,具体看采样频率和噪声水平。这里有个容易翻车的地方:滤波系数和 PID 的微分时间常数会互相影响,滤波太重会让系统响应变慢,看起来像“调不上去”,其实是信号被压平了。

3.2 PID 算法实现:位置式与增量式的取舍

PID 核心有两种常见形态。位置式直接算输出绝对值,适合 PWM 这种需要明确占空比的场景;增量式算输出增量,抗积分饱和更自然。这份程序以位置式为主,同时留了增量式的接口。位置式实现如下:

typedef struct { float kp, ki, kd; // 比例、积分、微分系数 float setpoint; // 目标光照值 float integral; // 积分累积 float prev_error; // 上次误差 float out_min, out_max; // 输出限幅 } PID_t; float pid_calc(PID_t *pid, float measured) { float error = pid->setpoint - measured; // 积分项累积 pid->integral += error; // 积分限幅,防止饱和 if (pid->integral > 1000.0f) pid->integral = 1000.0f; if (pid->integral < -1000.0f) pid->integral = -1000.0f; // 微分项 float derivative = error - pid->prev_error; // 位置式输出 float output = pid->kp * error + pid->ki * pid->integral + pid->kd * derivative; pid->prev_error = error; // 输出限幅 if (output > pid->out_max) output = pid->out_max; if (output < pid->out_min) output = pid->out_min; return output; }

逐段看:error是目标减实测,符号决定调节方向;integral累积历史误差,消除稳态偏差,但必须限幅,否则长时间偏差会让积分项涨到天上,回调时严重超调,这就是经典的积分饱和。derivative用误差差分近似微分,简单但受噪声影响大,所以前面才要滤波。输出限幅对应 PWM 占空比范围,比如 0 到 1000 对应 0% 到 100%。

参数上,kp决定响应速度,太大振荡、太小迟钝;ki消除静差,太大会超调;kd抑制超调,但对噪声敏感。调参顺序一般是先 P 后 I 再 D,先把 P 调到临界振荡再回退,然后加 I 消静差,最后少量 D 压超调。这套流程说起来简单,实际调的时候光照环境一变就得重来,所以程序里最好留串口在线改参的入口。

3.3 PWM 输出与执行器映射

PID 算出的输出是抽象的控制量,要映射成定时器比较值才能驱动灯板。封装一层的好处是换执行器时不动算法:

// 将 PID 输出(0~1000)映射为 PWM 比较值 void pwm_set_duty(TIM_HandleTypeDef *htim, uint32_t channel, float pid_out, uint32_t period) { if (pid_out < 0) pid_out = 0; if (pid_out > 1000) pid_out = 1000; uint32_t compare = (uint32_t)(pid_out / 1000.0f * period); __HAL_TIM_SET_COMPARE(htim, channel, compare); }

period是定时器自动重装值,决定 PWM 分辨率;pid_out归一化到 0 到 1000,和 PID 的输出限幅保持一致。这里要注意执行器的死区:很多 LED 驱动在低占空比下不亮,PID 输出在 0 附近时灯没反应,误差消不掉,积分项一直涨。解决办法是在映射时加一个最小启动占空比,或者把死区补偿写进输出函数。

3.4 主循环与控制周期

控制周期决定 PID 的离散化效果。周期太长,系统响应慢、容易振荡;周期太短,采样噪声占比大、CPU 负担重。光照控制一般 10 到 50 毫秒比较合适。主循环用定时器中断或系统滴答做节拍:

while (1) { if (control_tick) { // 由定时器置位 control_tick = 0; float light = light_sample_filtered(); float out = pid_calc(&pid, light); pwm_set_duty(&htim2, TIM_CHANNEL_1, out, 999); printf("light=%.1f out=%.1f\r\n", light, out); } }

control_tick由硬件定时器周期性置位,保证控制周期稳定,别用HAL_Delay凑,那个精度和阻塞特性都不适合闭环。串口打印放在控制循环里方便观察,但打印本身耗时,周期紧的时候要挪到低优先级任务或降低打印频率。

4. 跑起来:编译、烧录与调参步骤

4.1 环境准备与工程导入

先确认手头有 STM32 开发板和下载器(ST-Link 最常见)。软件侧装好 Keil MDK 或 STM32CubeIDE,以及对应的器件支持包。工程导入后第一件事是检查时钟配置:外部晶振频率、系统主频、ADC 和定时器时钟分频,这些在 CubeMX 生成的代码里都有,但换板子时经常对不上。编译前把main.h里的引脚定义和实际接线核对一遍——光敏电阻接哪个 ADC 通道、LED 驱动接哪个定时器通道,错了就是白烧。

编译通过后烧录,串口助手打开对应波特率(常见 115200),应该能看到光照值和 PID 输出的周期打印。如果没输出,先查串口引脚和波特率,再查printf重定向有没有配。

4.2 首次上电的验证顺序

别一上来就开闭环。我一般分三步验证:第一步,只跑采样,看光照值随遮挡变化是否合理,确认 ADC 通道和换算没错;第二步,手动给固定占空比,看灯亮度是否随比较值线性变化,确认 PWM 通道和执行器正常;第三步,再开 PID,给一个中间目标值,观察输出是否收敛。这个顺序能把硬件问题和算法问题分开,省得混在一起排查。

调参时先把ki和kd置零,只留kp,从小往大加,直到光照值开始等幅振荡,记下这个临界值,然后取它的六成左右作为初始kp。接着加ki,从很小开始,观察静差是否消除、有没有明显超调。最后加kd压超调,量不要大。每次只动一个参数,改完观察至少十几个控制周期再判断。

注意:调参时环境光要稳定,别在窗户边调,太阳一出来曲线全变,白调。

4.3 参数表与常见取值范围

参数含义典型范围调整方向
kp比例系数0.5 ~ 10增大加快响应,过大振荡
ki积分系数0.01 ~ 0.5增大消静差,过大超调
kd微分系数0 ~ 0.1增大抑制超调,过大放大噪声
控制周期PID 计算间隔10 ~ 50 ms过短噪声大,过长响应慢
采样平均次数ADC 平均8 ~ 16越多越平滑越迟钝

这张表只是起点,实际值跟灯板功率、传感器灵敏度、环境光强都有关,别照抄。

5. 避坑与排查:那些让灯抖起来的细节

5.1 灯一直满亮或全灭

现象是 PID 输出卡在限幅两端不动。原因多半是误差符号反了——目标减实测的方向和 PWM 调节方向不一致,形成正反馈。解决方法是先手动验证:增大占空比时光照值应该上升,如果反而下降,说明传感器或执行器接反,或者 PID 里setpoint - measured写成了反过来。改符号后重新从小kp调起。

5.2 光照值稳定但始终有静差

现象是曲线平了,但离目标值差一截。原因通常是积分项没起作用:要么ki太小,要么积分被限幅卡死,要么执行器有死区导致小输出无效。先确认ki非零且积分在累积,再检查输出映射有没有最小启动占空比。死区补偿的常见做法是在输出上加一个偏置,或者把死区以下的值直接跳到启动阈值。

5.3 曲线周期性振荡

现象是光照值围绕目标上下摆动,幅度不衰减。原因可能是kp太大、控制周期太长,或者微分项被噪声干扰。先把kd置零看是否改善,再降kp、缩短控制周期。如果采样滤波太重,也会造成相位滞后引发振荡,适当减小平均次数或提高滤波系数。

5.4 串口一打印就控制不稳

现象是关掉打印就正常,开着打印就抖。原因是printf阻塞占用控制周期,导致实际 PID 间隔不均匀。解决办法是把打印挪到独立低优先级任务,或者用环形缓冲加 DMA 发送,控制循环里只写缓冲不等待。这个坑很隐蔽,因为看代码逻辑没问题,问题出在时序上。

5.5 换块板子就不工作

现象是同一份代码在原板子上正常,换板子后采样或 PWM 无反应。原因基本是引脚映射、ADC 通道号、定时器编号、时钟配置不一致。移植时逐项核对 CubeMX 配置和原理图,别假设同系列芯片外设默认引脚相同。我习惯在main.c顶部集中写一份引脚注释表,换板子时对着改,比翻代码快。

6. 二次开发:把 PID 模板改成你自己的闭环

拿到这份程序后,最值钱的改造不是调参,而是把控制对象换掉。光照 PID 的骨架——采样、滤波、PID、输出映射、周期调度——换成温度、电机转速、电压稳压都能用,区别只在传感器换算和执行器映射。我的做法是把pid.c保持原样不动,只改adc_sample.c里的换算公式和pwm_out.c里的映射关系,这样算法层永远可复用。

第二个改造点是参数在线整定。串口加一个简单协议,比如收到P1.5就改kp,收到S800就改目标值,调参时不用反复烧录。实现上在串口中断里解析字符,写入全局参数结构体,PID 计算时读结构体即可。注意中断里别做浮点解析太重,简单状态机逐字符处理就够。

第三个点是抗饱和的升级。位置式的积分限幅是入门做法,更稳的方式是积分分离——误差大时暂停积分,接近目标再放开;或者用抗饱和回算,输出饱和时反向修正积分项。增量式天然抗饱和,如果执行器是积分特性(比如某些调光驱动),可以直接切增量式实现:

float pid_calc_incremental(PID_t *pid, float measured) { float error = pid->setpoint - measured; float delta = pid->kp * (error - pid->prev_error) + pid->ki * error + pid->kd * (error - 2 * pid->prev_error + pid->prev_prev_error); pid->prev_prev_error = pid->prev_error; pid->prev_error = error; return delta; // 累加到上次输出上使用 }

增量式输出的是变化量,要累加到执行器当前值上,累加后同样要限幅。它的好处是不需要单独存积分项,饱和问题轻;缺点是输出依赖历史累加,异常复位后需要重新对齐。

验证改造是否成功,我一般看三条曲线:阶跃响应有没有超调和振荡、稳态有没有静差、扰动(比如突然遮挡传感器)后恢复快不快。串口把目标值、实测值、输出值三列打出来,导进表格画图,比盯着数字猜靠谱。从那以后我每次改闭环参数,都强制先跑一遍阶跃和扰动两组数据再下结论,凭感觉调参翻车的次数少了很多。希望这份拆解帮到你,把这份 PID 程序跑起来、改成自己项目里的控制核心。

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

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

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

立即咨询