☰
STM32光照PID闭环控制项目:从ADC采样到PWM调光的可调参实现
2026/10/21 13:36:55 网站建设 项目流程

简介:这套资料是一套面向嵌入式开发者的比例积分微分算法实现与光照控制项目资料,基于STM32F1系列微控制器与官方固件库,适合正在学习闭环控制、需要完整工程参考的开发者。压缩包内共包含七百二十七个文件,总大小约五十一点七五兆字节,主要文件类型包括源代码、头文件、编译中间文件、工程配置文件、参考文档以及电路原理图,其中源文件与头文件对应功能模块,配置与输出文件便于直接查看,文档则提供开发指南与使用说明。目前已有四千三百五十七人学习或下载。资料附带了开发指南文档、说明文档以及原理图,详细讲解了定时器、中断、模数转换器等资源的用法,原理图清楚展示了微控制器与光照传感器的连接方式,目录结构完整,既可以对照学习参数整定,也能够借鉴接口设计和输出控制思路,是软硬件结合、便于移植和二次开发的实践案例。

1. 光照PID闭环控制:一个能跑的STM32调光项目

如果你手头有一块STM32开发板、一个光敏电阻和一个LED灯,想实现“环境变暗就自动调亮、环境变亮就自动调暗”的闭环调光,那这套STM32 PID算法程序就是可以直接烧录复现的参考实现。它把完整的STM32 PID控制过程拆成ADC采样、PID计算、PWM输出三个环节,光照PID的整定全部通过串口和按键可调,不再把PID当数学公式来展示,而是当成一个可以运行、可以改参、可以接业务代码的外设级模块。适合刚学会HAL库基础外设的开发者,也适合想快速给产品原型做恒光、恒温、恒压闭环的工程师。这套程序真正解决的是“PID到底怎么写进单片机”的落地问题,而不是再讲一遍比例积分微分。

2. 项目拆解与技术选型:从ADC采样到PWM输出的闭环链路

2.1 功能边界与适用场景

这个程序解决的是“单回路恒光控制”问题。被控对象是LED,反馈量是光敏电阻分压得到的电压。STM32通过ADC周期性采集光照电压,把它与目标光照值比较,PID控制器根据偏差计算控制量,最后通过定时器通道输出PWM来改LED的驱动电流。整套程序的实用价值在于它把PID的三个系数Kp、Ki、Kd做成了运行时变量,不需要改代码重新烧录就能完成光照PID整定,这对现场调试非常友好。

适用场景不止是光照。只要把反馈源换成温度传感器、湿度传感器、电机编码器,把执行器换成加热丝、风扇、电机,这套程序框架可以原封不动地迁移成温控PID、恒湿PID、速度环PID。所以在拆这个项目时,我更愿意把它看作一个“STM32单回路PID控制框架”,光照只是一条最直观的展示链路。如果你之前在其他平台的PID代码上翻过车,比如调参调不出来、一上电就抽风,这个Demo的最大价值是给了你一个“控制周期固定、参数可在线修改、输出限幅完整”的可靠起点。

2.2 硬件方案与芯片外设分配

我跑这套代码用的是常见STM32F103最小系统板。代码基于HAL库,其他STM32型号需要做的只是改启动文件和时钟配置,外设逻辑都能复用。光照反馈用光敏电阻与10K电阻串联分压,分压点接ADC输入引脚,这样光照越强,光敏电阻阻值越低,ADC读数越高。LED驱动推荐用NPN三极管或MOS管,STM32引脚直接驱动LED虽然简单,但电流能力有限,做光照PID大范围调光时会被限制住,占空比稍微拉高引脚就发热。

外设分配可以参考这张表:

外设通道/引脚用途参数建议
ADC1IN0(PA0)采集光照电压12位分辨率,采样时间55.5us
TIM2CH1(PA6)PWM输出周期1kHz,占空比0-100%
USART2PA2/PA3串口调参与曲线输出115200-8-N-1
GPIOPB0按键1手动切换目标值下降沿触发
GPIOPB1按键2启动/停止闭环下降沿触发

注意PA0给了ADC,就不能再复用为TIM2_CH1。很多新人容易把ADC和PWM放在同一个引脚上,导致两个外设初始化时报错或者数值完全不对。我在这套程序里特意把PWM放到PA6,避开冲突。如果你用的是其它型号板子,一定要先对照数据手册的外设映射表确认复用关系再接线,这个步骤不值钱但能省下半小时排查时间。

电源方面也要提一句:光敏电阻分压电路和模拟电源尽量靠近单片机的VREF引脚供电,不要直接从LED的驱动电源上拉,LED开关瞬间的电流波动会串进ADC参考电压,造成光照读数上下跳。板子上如果有独立AGND和GND,建议单点接地。

2.3 工程目录与源文件职责

整套代码是标准HAL库工程结构。核心代码在Core/Src下的几个文件中,不是把PID逻辑散落在main里,而是单独抽出控制器模块,方便后续移植到其他芯片。源码包里的目录结构大致如下:

023_STM32PID/ ├── Core/ │ ├── Inc/ # 头文件:pid.h, adc_driver.h, pwm_driver.h │ └── Src/ # 源文件:main.c, pid.c, adc_driver.c, pwm_driver.c ├── Drivers/ │ └── STM32F1xx_HAL_Driver/ # HAL库源码 ├── MDK-ARM/ # Keil工程文件 ├── User/ │ └── usart_driver.c # 串口解析与调试输出 └── README.md # 运行说明与引脚表

我把pid.c单独拆开而不是写进main.c,是因为PID初始化、PID计算、PID参数更新这三个操作会在不同场景下被分别调用:初始化由启动代码调用,计算在中断里调用,参数更新由串口指令触发。拆开后每个文件的职责非常清楚,测试也方便,直接在编译环境里对pid.c做单元测试,不需要把整个板子跑起来。C文件之间通过头文件对外暴露接口,内部实现细节用static修饰,这样至少不会出现跨文件的全局变量污染。

从闭环链路看,实时性要求最严格的是ADC采集到PWM更新的时间,PWM周期1kHz时,每个周期都要读取一次ADC并计算PID输出。这个任务不适合放在主循环里用delay做,一旦主循环碰到串口阻塞或其他耗时操作,周期就漂移了。常见做法是用定时器触发中断,例如TIM6定时1ms后启动ADC转换,转换完成中断里读数据、算PID、更新PWM占空比。这套程序就是按这个思路组织的,这一点也决定了后面所有代码的实现方式。

选择HAL库而不是寄存器操作,主要是为了可移植性。HAL库的抽象层把寄存器操作包起来,同一份代码换到不同系列芯片时,改动的只是外设句柄类型和时钟初始化函数。缺点是多了一层函数调用开销,但对光照PID这种毫秒级控制周期完全没有压力。如果你追求极致性能或者控制频率要到几十kHz,那可能得退回寄存器版本,不过这个项目显然不属于那种场景。

3. 核心模块实现:PID控制器在STM32上的落地写法

3.1 数据结构:把PID状态封装成结构体

在单片机上写PID,最常见的翻车点是把Kp、Ki、Kd、误差都定义成全局变量,然后在中断里直接计算。这种写法跑起来很快,但多路控制、参数在线调整、代码维护全都变得很难受。这套程序的第一个优点是用结构体把单个控制回路的状态打包,每个回路一个实例,互不干扰。

typedef struct { float Kp; // 比例系数 float Ki; // 积分系数 float Kd; // 微分系数 float target; // 目标值(ADC原始值) float measured; // 当前反馈值(ADC原始值) float previous_error; // 上一次误差 float integral; // 积分累加值 float output; // 控制输出 0.0f - 100.0f float out_min; // 输出下限 float out_max; // 输出上限 float integral_limit; // 积分限幅 } PID_HandleTypeDef;

每个成员的含义都写在注释里。这里用的单位是ADC原始值,不是电压,也不是lux。选择ADC原始值的好处是省去无谓的浮点换算,整个闭环链路上只有ADC值和PWM占空比两种单位,调试时一眼能看懂。如果产品需要实际光照单位,只需要在采样后加一个线性映射函数,不影响PID核心。结构体里的out_min和out_max很关键,它们把PID输出限制在0到100之间,防止占空比算出一个负数或者大于100的数,赋值给PWM寄存器时产生异常。

3.2 位置式PID与增量式PID的代码实现

pid.c里有两种实现,默认启用位置式。位置式的表达式很直观:控制量等于比例项、积分项、微分项之和。注意积分项不是无限累加,程序里加了integral_limit防止长时间光照遮挡造成积分过度。微分项也不是直接用当前误差减上一次误差,而是减掉当前反馈与上一次反馈的差,这样可以避免目标值突变时的微分突跳,这也是很多初版PID程序会出现的抖动来源。

float PID_Process(PID_HandleTypeDef *pid, float measured) { float error = pid->target - measured; // 微分用反馈变化量而不是误差变化量,避免setpoint突变引起抑制 float derivative = measured - pid->previous_measured; pid->integral += error; if (pid->integral > pid->integral_limit) pid->integral = pid->integral_limit; if (pid->integral < -pid->integral_limit) pid->integral = -pid->integral_limit; float output = pid->Kp * error + pid->Ki * pid->integral - pid->Kd * derivative; // 输出限幅,单位是PWM百分比 if (output > pid->out_max) output = pid->out_max; if (output < pid->out_min) output = pid->out_min; pid->measured = measured; pid->previous_measured = measured; pid->previous_error = error; pid->output = output; return output; }

这段逻辑说明几点:第一,积分限幅用的是integral_limit而不是把最终输出简单截断,这样做的好处是积分项本身不会饱和,系统回复时很快;第二,微分项带负号,含义是反馈增长越快,越要减出力,这是标准的反馈微分形式,许多初版代码在目标值变化时会发生一个很大的微分冲击,就是因为用了误差求导;第三,返回值和pid->output保持一致,方便中断里直接调用后赋值给PWM占空比寄存器。

增量式PID的代码也放在同一个文件里,用宏开关切换:

float PID_ProcessIncremental(PID_HandleTypeDef *pid, float measured) { float error = pid->target - measured; float increment = pid->Kp * (error - pid->previous_error) + pid->Ki * error + pid->Kd * (error - 2.0f * pid->previous_error + pid->previous2_error); pid->previous2_error = pid->previous_error; pid->previous_error = error; pid->output += increment; if (pid->output > pid->out_max) pid->output = pid->out_max; if (pid->output < pid->out_min) pid->output = pid->out_min; return pid->output; }

增量式的好处是输出不会突变,适合后面接步进电机这样需要平滑运动的执行器。默认工程用位置式,因为LED调光直接给占空比更简单,而且位置式配合输出限幅后,占空比不会跑飞。如果你要改成增量式,记得把积分限幅注释掉,因为增量式本身没有累加积分项,再加积分限幅反而会让输出跑偏。

3.3 在中断里跑闭环:采样、计算、输出

闭环的驱动源用定时器中断。这里我建议用基础定时器TIM6或TIM7做1ms时基,在中断回调里不是直接启动ADC,而是先置一个标志位,主循环里头看到标志位后再处理ADC和PID。为什么这么绕?因为ADC转换完成中断如果嵌套在定时器中断里,容易引发优先级问题;而主循环处理1ms一次的控制任务,完全来得及。下面这段是main.c里的核心流程:

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { control_flag = 1; // 通知主循环执行一次闭环控制 } } int main(void) { HAL_Init(); // ... 时钟、ADC、PWM、串口初始化 ... HAL_TIM_Base_Start_IT(&htim6); while (1) { if (control_flag) { control_flag = 0; uint16_t light = ADC_GetValue(); // 读取光照ADC float duty = PID_Process(&pid_light, light); PWM_SetDuty(&htim2, TIM_CHANNEL_1, duty); // 每20次循环输出一次调试信息,约50Hz if (++debug_cnt >= 20) { debug_cnt = 0; printf("%d,%d,%d\r\n", (int)pid_light.target, light, (int)duty); } } } }

这段代码的逻辑顺序很清楚:定时器产生1ms节拍,每个节拍做一次读光敏、算PID、写PWM。printf输出控制在50Hz,避免串口成为瓶颈。如果直接把printf放在中断里,串口输出会占用大量时间,控制周期会被拖长。ARM Cortex-M3在中断里调用printf还会占用大量栈空间,有栈溢出的风险,所以我一直坚持在主循环里做打印输出。

3.4 参数说明与整定思路

初始化时建议给一组保守参数,例如Kp=1.0,Ki=0.05,Kd=0.2,目标值设在ADC值的一半。光照PID的整定套路和温控一致:先只保留比例,等系统出现等幅振荡,再把Kp回调到一半,然后加一点点积分消静态误差,微分用来压超调。这套程序把参数放在Run命令里,通过串口可以实时修改,不用每次重新烧录。

常见初值可以参考这张表:

参数初始值作用调大后的现象
Kp1.0快速响应系统变快但容易振荡
Ki0.05消除稳态误差残留误差变小但可能过冲
Kd0.2抑制超调响应变钝,噪声被放大

这里的采样周期是1ms,PI控制时最大输出100%占空比,所以参数量级是毫秒级控制周期的常见量级。如果你把采样周期改成10ms,那么Kp、Ki、Kd都要相应调整,具体调整原则会在第5章的避坑里讲。另外提醒一句,这里的目标值为ADC原始值,范围是0到4095,所以设置目标时不要写成电压或百分比,直接用ADC读数。

4. 快速运行与验证:从串口调参到看PID曲线

4.1 烧录前的工程配置

拿到工程后不要急着烧,先确认三处配置:目标芯片型号选对,我这里是STM32F103C8T6,如果用的是ZE系列需要改启动文件和C语言宏;外部时钟源如果板子上没有8MHz晶振,需要把HSE_VALUE改成25MHz或换成内部HSI;调试器接口选择ST-Link还是JLink,Keil中Debug页面对应设置。这三处不排查,烧录后十有八九会跑飞。最常见的现象是程序进不了main,停在硬件错误异常里,这时候先检查时钟树配置,再检查引脚复用。

串口调试助手波特率设置为115200,数据格式8N1。打开串口时注意不能和Keil的SWD调试功能抢占同一串口,否则会出现打印死循环。我一般用USB转TTL模块接PA2、PA3,开发板上不要直接用CH340的TX/RX连自己的RX/TX,要交叉连接,也就是板子的PA2 TXD对接模块的RXD,PA3 RXD对接模块的TXD。电源地也要连在一起,否则串口电平没有参考,收发内容全是乱码。

4.2 运行步骤与现象判断

上电后电源指示灯常亮,USART2每秒打印一次“PID demo ready”。此时LED应该保持初始占空比50%。按下按键1,目标光照值从当前的ADC读数切换到默认目标值,LED会开始调节亮度,这个过程在1到2秒内应该能稳定下来。按下按键2,闭环停止,输出固定占空比,这是用来判断PID是否在生效的对比操作。如果按了按键1后LED完全不动作,优先检查PWM通道是否输出,用示波器或者万用表频率挡测PA6引脚有没有1kHz方波。

用串口发送指令可以实时改参数:

set kp 2.5 set ki 0.1 set kd 0.3

程序收到后会把对应参数写入pid_light实例,并回显当前值。注意这里参数是float型,所以发送端直接写小数即可。如果不回显,说明串口中断没有配置好,检查USART2的中断是否在初始化时被启用,还要看接收缓冲区的换行符是LF还是CRLF,程序里我按\r\n做结束符,如果你用串口助手发送,记得勾选发送新行。

4.3 用串口上位机看PID曲线

程序每隔20ms输出一行“目标值,当前值,占空比”的CSV数据。用串口捕获后保存成csv文件,再用Python的matplotlib或者任何绘图工具画出曲线,就能直观看到PID响应过程。这样的数据格式比贴上位机界面的做法通用得多,任何一个开发环境都能处理。下面是一个简单的Python绘图脚本:

import serial import matplotlib.pyplot as plt ser = serial.Serial('COM5', 115200, timeout=0.5) target = [] measure = [] duty = [] for _ in range(200): line = ser.readline().decode('utf-8', errors='ignore').strip() if line: parts = line.split(',') if len(parts) == 3: target.append(float(parts[0])) measure.append(float(parts[1])) duty.append(float(parts[2])) plt.subplot(2, 1, 1) plt.plot(target, label='target') plt.plot(measure, label='measured') plt.legend() plt.subplot(2, 1, 2) plt.plot(duty, label='duty') plt.legend() plt.show()

这个脚本用来观察整定效果非常直观,调节Kp后重新跑一遍,看measured是趋近target还是有等幅振荡。注意串口号要根据自己电脑的设备管理器修改,如果读取不到数据,先检查上电是否进入了串口输出模式,很多板子的TXD引脚默认被其它外设占用。绘图时如果发现目标值和测量值之间总有一个固定偏差,说明积分系数太小或者目标值本身偏移。

4.4 常见参数改动位置

如果你拿到代码想改控制周期,需要同时改三处:TIM6的重载值决定中断频率,pid.c里采样周期相关的注释不做实际作用但会影响整定,printf输出频率的控制系数debug_cnt也需要按新周期调整。如果改成5ms一次控制,那么debug_cnt可以设成4,这样调试输出频率依然约50Hz。改PWM频率时,只需要改TIM2的周期寄存器,但ADC采样时间也要相应缩短,保证每个控制周期内能完成一次完整转换。

如果想让目标值跟踪外部设定,比如环境亮时自动把目标改成某个固定值,可以在串口指令里增加一个“set target 2000”命令,程序里已经有现成的接口,直接调用PID_SetTarget即可。很多二次开发的起点都是从这里开始的。另外要注意PWM分辨率,如果你把定时器周期改成5000,那么占空比输出0到100对应寄存器值0到4999,PID输出的是百分比,赋值时要做一次乘法换算,千万不能直接把PID的输出值写进捕获比较寄存器。

5. 实战避坑:光照PID最容易踩的五个坑

5.1 采样周期不固定导致调节发抖

现象:LED亮度不停波动,串口看测量值总是在目标附近上下乱跳,像水烧开一样。原因:主循环里有delay,或者串口printf占用了大量时间,导致1ms周期失真,控制周期不稳定。解决:把闭环的任务从主循环挪到定时器中断标志驱动,并且禁用调试期间多余的OLED刷新、串口打印等高耗时操作。排查时可以先用逻辑分析仪测PWM引脚,看两个相邻周期的时间间隔是否都在1ms附近,如果出现几十毫秒的间隙,基本就是采样周期漂移。

5.2 积分饱和让响应迟迟不回位

现象:光照被突然遮挡后,即使恢复,LED也长时间撑着高占空比不降下来。原因:积分项没限幅,持续累加到很大值,输出被限幅在100%,系统陷入饱和,退饱和时间太长。解决:在积分累加后单独限幅,integral_limit设为比如50%对应的误差累计量;同时输出限幅放在积分限幅之后,这样退饱和速度会快很多。我一般还会在代码里加一个抗积分饱和逻辑,当输出碰到限幅时,停止累加积分,等到误差反向后再继续累加,这样系统恢复会更快。

5.3 微分项放大传感器噪声

现象:系统静止时PWM占空比有小幅随机抖动,听LED供电有轻微噪声。原因:ADC读数本来有量化噪声,微分项对高频噪声敏感,把噪声放大。解决:在微分通道加低通滤波,比如用一阶惯性环节平滑measured的变化量;如果噪声不大,可以直接减小Kd甚至设0.0,光照控制通常不需要很强的微分。同时还要检查ADC采样时间,如果采样时间配置过短,输入阻抗又高,电容没充满就被读取,读数会跳几十个LSB,把这部分修掉后微分噪声会明显下降。

5.4 引脚复用冲突导致ADC和PWM同时失效

现象:初始化顺序正确,但ADC读取恒为4095,PWM也无输出。原因:把同一个引脚同时配置为ADC和定时器PWM复用。我在这套代码里用PA0做ADC,用PA6做PWM,避免了复用冲突。解决:对照数据手册的外设映射表,确认两个外设占用不同引脚。调试方法很直接,单独注释掉PWM初始化,读ADC值看是否正常;再单独注释ADC初始化,用固定占空比测PWM波形,就能定位是哪一步被另一个外设干扰了。

5.5 串口打印拖慢控制节奏

现象:增加串口打印后控制效果明显变差,调节波动变大。原因:printf发送是阻塞的,如果把调试信息放在控制节拍内,发送时间远大于控制周期。解决:只在计数到20次时才打印一次,或者用DMA发送,不让printf阻塞主循环。这个坑刚接触时很难发现,因为它不会报错,只表现为控制曲线变差。排查办法是临时把printf全部注释掉,看LED是否恢复稳定,如果明显变稳,就是打印拖累了控制节拍。

6. 二次开发:把单路调光扩展成多路智能照明

6.1 抽象出PID实例,支持多通道

目前代码里只有一个PID_HandleTypeDef实例pid_light。要支持多路光照控制,最直接的方式是定义数组或指针表。每个控制回路拥有自己的ADC通道、PWM通道、目标值和PID参数。中断里按通道编号依次计算,不会互相干扰。改造后的结构体可以增加一个uint8_t ch字段,用于关联硬件通道。这样一套代码就能同时控制多路LED,每路独立整定,适合做智能照明灯具。

#define PID_CH_NUM 4 PID_HandleTypeDef pid_channels[PID_CH_NUM]; // 初始化时逐一设置各通道的ADC、PWM和PID参数

然后写一个分时处理函数,避免在中断里一次算完4个环路导致时间过长:

void PID_Poll(void) { static uint8_t idx = 0; uint16_t light = ADC_GetValue(idx); float duty = PID_Process(&pid_channels[idx], light); PWM_SetDuty(idx, duty); idx = (idx + 1) % PID_CH_NUM; // 轮流处理 }

这样每个通道的控制周期变成原来的4倍,如果需要保持1ms控制频率,可以把核心定时器改成0.25ms触发一次PID_Poll。光照这类慢系统通常不需要太高频率,5ms一个环路也够用。分时处理的另一个好处是,将来增加通道时只需要改PID_CH_NUM,不需要改控制函数内部逻辑。

6.2 串口指令调参,不用重新编译

原有串口只支持set kp/ki/kd,可以扩展成按通道调整,指令格式:set 1 kp 2.0。解析时先用空格拆分,第一段是通道号,第二段是参数名,第三段是数值。注意strtok函数在多线程或中断里使用要小心,这里只在主循环里解析,没有问题。用strtok_r可重入版本更安全,解析过程中系统中断来了也不会把静态缓冲区的状态搞坏。

// 伪代码示意,实际使用时用sscanf解析更稳妥 char cmd[64]; sscanf(rx_buf, "set %d %3s %f", &ch, param, &value); if (strcmp(param, "kp") == 0) pid_channels[ch].Kp = value;

这种设计的意义在于,现场调试时不用电脑重新编译烧录,拿着串口工具就能完成整定。我一般在调试阶段保留这套指令,量产时再通过编译开关去掉,省下来的几十行代码还能减小固件体积。指令解析的容错也要做,比如参数名不认识就回显“invalid param”,通道越界就回显“channel out of range”,不然调试时输入错了还以为没反应。

6.3 自动整定与归一化

更进一步,可以把光照ADC值归一化到0到100的百分比,所有PID参数基于百分比设计,这样换传感器、换芯片时不需要重新整定。目标值、测量值、输出全部用百分比,内部实现一个可选的线性映射函数。通过串口发送“calibrate min max”记录光照范围,系统自动映射。例如ADC最小值200,最大值3900,那么映射到百分比时,先从读数减去200再除以3700,最后乘100统一输出。

自动整定功能可以实现一个简单的中继法:代码自动把目标值在设定点和50%之间切换,记录振荡频率,然后按临界比例度公式计算Kp、Ki、Kd。这个算法需要一定的任务量,但作为二次开发的加分项很受欢迎。光照系统滞后小,这种自动整定可以比较快收敛。如果你想做得更专业,还可以把整定结果写进Flash,断电后不丢失,下次开机直接沿用之前调好的参数。

改造完成后,需要验证的指标是:对固定目标值,稳态误差小于ADC的3个LSB;用手遮挡光敏电阻再放开,LED能在1秒内回到目标亮度;串口打印的曲线没有持续振荡。如果这三个都过了,这个项目基本就达到了实际可用的水平。验证时建议用纸板缓慢遮挡,不要用手猛拍,否则超调的锅不一定是PID系统的。

最后说个习惯:我从第一次调这个光照PID开始,每次都强制走一遍“固定时基、积分限幅、串口低速输出”这三个检查。正因为这个习惯,后面做的温控、电机闭环都少走了很多弯路,希望这篇留给你也用得上。

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

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

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

立即咨询