简介:面向刚接触 STM32 温控开发的嵌入式学习者与项目实践者,这份文档围绕 PID 加温从原理到落地展开,重点解决接近设定温度后控温波动大、PID 参数难整定以及计算结果如何转成加热时间等实际问题。压缩包仅含 1 个 docx 文件,约 18KB,以图文说明与代码片段结合的方式整理了完整调试记录。内容覆盖 65℃目标温度下的 PID 启停阈值、定时器 1 每 2 秒执行一次运算、定时器 2 以 10ms 中断计时,以及将 0~100 的输出映射为加热时长的实现思路;同时给出先调 P、再调 I、最后调 D 的实测过程,并记录 P≈15、I≈0.05、D≈0.03 等参数参考。读者可据此理解分段加热与 PID 闭环控制的差异,掌握定时器中断、占空比式加热与参数整定的对应关系。文档还附有源代码使用说明与排错心得,已有 2394 人学习下载,适合希望少走弯路、快速把 PID 温控方案用于加热项目的读者参考。
1. 从继电器闪烁到PID闭环:65℃加温项目为什么换算法
手上一个加温设备,要求把腔体稳在 65℃。第一版我写的是最朴素的做法:低于 64℃ 通电,高于 66℃ 断电,跟发光二极管闪烁一个思路。台架上一跑就露馅了——温度在 58℃ 到 70℃ 之间来回冲,超调大、回落也深。开关控制只有全开全关两个状态,精度基本由回差带决定,想再进一步只能换算法。
这篇讲的就是那套 STM32 PID温度控制 的完整落地过程:误差怎么算、输出值 0~100 怎么来的、两个定时器怎么分工、PID调试 里 P/I/D 三个参数怎么从一堆乱跳的数字整定到 ±0.5℃ 以内。适合手上有恒温、保温、加温需求,但卡在「公式看懂了、代码不会用」这一步的嵌入式开发者。
2. 硬件链路与软件骨架:加热回路、温度采样和两个定时器的分工
动手写代码之前,得先把物理链路理清楚。PID 再好,采样慢半拍、执行器不听话,参数怎么调都是玄学。这一章把拓扑、目录结构和节拍分配定下来,后面调参才有基准。
2.1 加热执行、温度采集与隔离
一个典型的加温回路是三段式:MCU 出控制信号 → 隔离驱动 → 功率器件 → 加热棒,温度传感器贴在加热腔体或加热块上回采。220V 侧和 MCU 侧必须做隔离,光耦或者带光耦的继电器模块是常见做法。
传感器选型直接决定精度天花板,65℃ 这个温区可选项不少:
| 方案 | 接口 | 典型分辨率 | 65℃ 场景适配度 |
|---|---|---|---|
| NTC 10K(B=3950)+ 10K 分压 | ADC 单通道 | 12 位 ADC 约 0.1℃ | 便宜、响应快,必须做查表或 Steinhart-Hart 换算 |
| DS18B20 单总线 | 1-Wire | 0.0625℃ | 免标定,转换时间 750ms,需要 4.7K 上拉 |
| MAX6675 + K 型热电偶 | SPI | 0.25℃ | 量程高但低温段误差大,65℃ 不合适 |
我最后用的是 NTC 加分压,原因是探头能塞进结构件缝隙里,热耦合比贴片式的好。代价是每块板子都要标定一次,不然个体差异能到 ±1℃。
执行器这块有坑,后面避坑章节会展开。机械继电器的吸合寿命通常只有十万次量级,按 1 秒一次的周期通断,一天就是八万多次,撑不了几天。要么换固态继电器,要么把整个控制周期放大到十秒级。
2.2 软件分层与目录结构
代码不要全塞进 main.c。加温逻辑会随参数、随工艺反复改,分层是为了改的时候只动一个文件。我用的结构大概是这样:
Project/ ├── Core/ │ ├── Src/ │ │ ├── main.c // 初始化、主循环、状态机调度 │ │ ├── stm32f1xx_it.c // 中断入口,只做标志和计数 │ │ ├── temp_sample.c // ADC 采样、滤波、温度换算 │ │ ├── pid.c // PID 运算与输出限幅 │ │ ├── heater.c // 时间片比较、加热开关、超温保护 │ │ └── param.c // 参数读写、Flash 保存 │ └── Inc/ │ ├── pid.h │ ├── heater.h │ └── param.h └── Drivers/ // 标准外设库或 HAL分层的判断标准很简单:pid.c 里不允许出现任何GPIO_SetBits,heater.c 里不允许出现任何浮点乘加。PID 只负责算出一个 0~100 的整数丢给全局变量,执行层拿着这个数去比时间片。这样调参的时候只改 pid.c 的系数,不用碰硬件代码;换执行器的时候只改 heater.c,PID 部分原样搬走。
2.3 两个定时器的节拍分配
整套控制的核心是两个不同频率的定时器,各自干一件事,频率依据也不一样:
| 定时器 | 溢出时间 | 用途 | 频率依据 |
|---|---|---|---|
| TIM4 | 1s | PID 运算节拍,中断里计数到 2 再算一次,即 2s 一次 | 加热系统热惯性大,算太快没有意义 |
| TIM3 | 10ms | 占空比时间片,计到 100 归零,构成 1s 的加热周期 | 100 档分辨率的代价 |
TIM4 的中断里别做运算,只累加计数、置个标志位,真正的 PID 计算放到主循环里做。中断里做浮点运算是很常见的翻车点,尤其 Cortex-M3 没有硬件 FPU,一次浮点除法几百个周期,容易把别的中断挤掉。
/* TIM4 中断:1s 一次,只负责节拍计数 */ void TIM4_IRQHandler(void) { if (TIM4->SR & 0x0001) { /* 溢出标志 */ TIM4->SR &= ~0x0001; /* 手动清标志 */ if (flag.pid_enable == 1) { /* 温度到 60.1℃ 后才开始计 */ g_pid_tick++; if (g_pid_tick >= 2) { /* 1s * 2 = 2s,走一次 PID */ g_pid_tick = 0; flag.pid_run = 1; /* 只置标志,运算交给主循环 */ } } } }g_pid_tick >= 2里的 2 就是 PID 周期,想改成 3 秒就写 3,不需要动别的地方。flag.pid_run在主循环里被消费掉之后立刻清零,避免重复运算。
2.4 使能标志与温度状态机
PID 不是全程都在跑的。冷启动阶段温度离目标十万八千里,误差项巨大,让 PID 介入只会把积分项一瞬间顶到天上,这就是后面要讲的积分饱和。所以要有状态机:
typedef struct { uint8_t pid_enable; /* 1 = PID 已介入 */ uint8_t pid_run; /* 1 = 本周期的 PID 待计算 */ uint8_t heat_on; /* 当前加热输出状态 */ uint8_t over_temp; /* 超温保护标志 */ } sys_flag_t; sys_flag_t flag; /* 主循环里每 200ms 调一次 */ void Temp_StateMachine(void) { if (g_temp_cur >= 70.0f) { /* 硬件失效兜底 */ flag.over_temp = 1; flag.pid_enable = 0; Heater_ForceOff(); return; } if (flag.over_temp && g_temp_cur < 60.0f) { flag.over_temp = 0; /* 回差复位,避免反复跳 */ } if (g_temp_cur <= 60.0f) { flag.pid_enable = 0; /* 全速加热阶段 */ Heater_FullOn(); } else if (g_temp_cur >= 60.1f) { flag.pid_enable = 1; /* 进入 PID 区间 */ } }60.0和60.1之间刻意留了 0.1℃ 的回差,这是从开关控制那边继承下来的习惯。不留回差的话,温度在 60.05 上下抖一下,pid_enable就会反复翻转,PID 的积分项被反复清零,永远稳不下来。
目标 65℃,60℃ 开始接管,这个 5℃ 的提前量不是随便定的。加热棒断电之后还有热惯性,余温能往上冲一到两度,提前量太小会直接冲过目标值;提前量太大又会让 PID 在低温段长时间满占空比输出,积分照样积累。5℃ 是我这台设备上的折中,功率大的设备这个值还得加大。
3. PID核心代码拆解:从误差计算到0~100输出映射
这一章是整篇最硬的部分。网上能搜到的 PID 公式推导一大堆,但很少有人讲清楚算出来那个数到底怎么用。我把这段代码拆成三块:核心运算、限幅抗饱和、时间片映射,每块都能直接抄。
3.1 位置式PID的九行核心
/* ---- 可调参数,全部用 float,别用 int ---- */ float g_kp = 15.0f; /* 比例系数 */ float g_ki = 0.05f; /* 积分系数 */ float g_kd = 0.03f; /* 微分系数 */ float g_temp_set = 65.0f; /* 目标温度 */ float g_temp_cur = 0.0f; /* 实测温度,由采样层更新 */ static float g_err_sum = 0.0f; /* 误差累加 */ static float g_err_last = 0.0f; /* 上一次误差 */ uint16_t g_pid_out = 0; /* 最终输出,0~100 */ void Temp_PID(void) { float err = g_temp_set - g_temp_cur; /* 当前误差 */ float derr = err - g_err_last; /* 误差变化量,D 项输入 */ float b; /* 未限幅的原始输出 */ g_err_sum += err; /* 误差累加,I 项输入 */ g_err_last = err; b = g_kp * err + g_ki * g_err_sum + g_kd * derr; if (b > 100.0f) g_pid_out = 100; /* 上限封顶 */ else if (b < 0.0f) g_pid_out = 0; /* 下限封底,不许出负数 */ else g_pid_out = (uint16_t)b; }逐段看。err是设定减实测,温度低于目标时为正,正好对应「需要加热」。换成制冷场景就要反过来,这个符号方向搞错,上电就是一路加热到保护。derr是两次误差的差,不需要除以时间间隔——因为 PID 是固定周期调用的,时间间隔是常数,直接并进g_kd里就行,省一次除法。
g_err_sum是历史误差的累加。这一项最容易出事,它没有天然的边界,运行一小时、一天,累加值会一直长。后面 3.2 会专门处理。
b是三项加权和,量纲上完全没有物理意义。这是很多人卡住的地方:以为 PID 输出应该是温度、是电压、是电流,其实不是。它就是个数,你说它是 0~100 的百分数,它就是百分数;你说它是 0~1000 的 PWM 比较值,它也是。原文里那句「我想要一个什么样的结果就让 PID 计算出什么样的结果」,说的就是这件事。工程上选 0~100 是因为下面要拿它跟 100 个时间片做比较,整除方便。
限幅那三行是必须的。不封顶的话,冷启动阶段误差三十多度,15 * 30 = 450,输出直接飙到四位数,后面拿它去比时间片就变成永久导通,PID 等于没接。
3.2 输出限幅与积分饱和
只对外部输出限幅是不够的,g_err_sum自己会继续长。表现是:温度冲到 65℃ 了,输出还是 100,非得等温度过冲到 70℃ 才回落,然后一路跌到 60℃ 以下再重新冲。这就是积分饱和,温控项目里最经典的一种翻车。
两种改法我一般一起上:
void Temp_PID(void) { float err = g_temp_set - g_temp_cur; float derr = err - g_err_last; float b; /* 改法一:积分分离,误差大时干脆不累加 */ if (err > -3.0f && err < 3.0f) { g_err_sum += err; } /* 改法二:累加值本身也做限幅,防止长时间单向偏差 */ if (g_err_sum > 300.0f) g_err_sum = 300.0f; if (g_err_sum < -300.0f) g_err_sum = -300.0f; g_err_last = err; b = g_kp * err + g_ki * g_err_sum + g_kd * derr; /* 改法三:已经饱和时,反向积分不许继续累加 */ if ((b > 100.0f && err > 0) || (b < 0.0f && err < 0)) { g_err_sum -= err; /* 撤销这一次累加 */ } if (b > 100.0f) g_pid_out = 100; else if (b < 0.0f) g_pid_out = 0; else g_pid_out = (uint16_t)b; }±3.0这个门槛是从「PID 介入点距目标 5℃」推出来的:进 PID 区间时误差就是 5℃,第一二次运算误差会迅速掉到 3℃ 以内,再开始累积分量。门槛设太小,积分迟迟起不来,稳态误差消不掉;设太大,积分在过冲阶段就攒够了,反而加剧超调。
±300的上限配合g_ki = 0.05,单独积分项最大贡献 15 个输出百分点,加上比例项差不多了。这个上限按你自己的g_ki反推:上限 × ki 大约等于总输出的 15%~20%。
第三条是最有效的。饱和的时候还在往里加,等于给一辆已经踩死油门的车继续加油,等你想刹车的时候发现刹车片磨没了。
3.3 10ms时间片:把PID输出翻译成加热占空比
PID 算出 0~100 的数,接下来要变成实际通电时间。TIM3 每 10ms 中断一次,中断里维护一个 0~99 的计数器,跟g_pid_out比较:
/* TIM3 中断:10ms 一次,构成 1s 的加热周期 */ void TIM3_IRQHandler(void) { if (TIM3->SR & 0x0001) { TIM3->SR &= ~0x0001; if (flag.pid_enable == 1) { g_slice++; /* 时间片计数 0~99 */ if (g_slice >= 100) { g_slice = 0; /* 周期归零 */ g_duty_latch = g_pid_out; /* 锁存本周期的占空比 */ } if (g_duty_latch > g_slice) { HEAT_ON(); /* 前 N 个片通电 */ } else { HEAT_OFF(); /* 剩余片断电 */ } } else { HEAT_OFF(); g_slice = 0; } } }g_duty_latch这个锁存变量是我加了很久之后才补上的。一开始直接拿g_pid_out比较,偶尔会出现某个周期通电时间比预期多一截或少一截。原因很隐蔽:PID 是主循环里算的,时间片是中断里比的,如果 PID 刚好在g_slice走到 50 的时候把这个值从 30 改成了 80,那这个周期实际通电就是 80 个片,下一个周期又从头开始。周期边界上被切了一刀。锁存到g_slice == 0才更新,占空比就永远是整周期的。
映射关系对照一下就很清楚:
g_pid_out | 通电时间 | 断电时间 | 等效占空比 |
|---|---|---|---|
| 100 | 1000ms | 0ms | 100%,持续加热 |
| 50 | 500ms | 500ms | 50% |
| 25 | 250ms | 750ms | 25% |
| 0 | 0ms | 1000ms | 0%,停止加热 |
注意g_duty_latch > g_slice用的是大于号,等于 100 时g_slice最大到 99,条件恒成立,正好对应持续加热。如果写成>=,在g_pid_out = 0且g_slice = 0的瞬间会误触发一个 10ms 的加热脉冲,虽然影响很小,但边界上这种问题不值得留。
3.4 从占空比到执行器:继电器和SSR不是一回事
10ms 分辨率意味着加热周期是 1 秒。这个频率下,机械继电器是完全撑不住的——吸合时间本身就有 5~15ms,加上触点抖动和电弧,实际通电时间跟你算的完全对不上,还会加速损耗。两种情况:
- 用固态继电器(SSR):过零触发型 SSR 的最小分辨是半个工频周期,50Hz 下正好 10ms,跟这个时间片完全匹配,可以放心用。
- 用直流加热棒 + MOS 管:可以更快,直接把时间片调到 1ms,周期 100ms,纹波更小。
如果手上只有机械继电器,就得改结构:把整个周期的长度放大到 10 秒,g_slice计到 1000 归零,中断改成 10ms 但计数步长改成 10。这样最小时片是 100ms,继电器勉强能跟上,代价是温度纹波会变大,因为一个周期内通电时段和断电时段拉长了。
4. 参数整定:P=15、I=0.05、D=0.03 是怎么试出来的
代码跑通只是开始,真正耗时间的是整定。原文作者说调了三天、第四天才稳,这个时长我完全信,因为参数之间会互相干扰,改一个另一个就得重调。这一章按我自己的调试顺序走一遍。
4.1 先调P:让温度在目标值附近等幅徘徊
I 和 D 全部置 0,只留 P。P 从 1 开始往上加,每次加完观察至少三个完整的热循环。判断标准就一条:温度开始围绕目标值上下摆动,幅度稳定不发散。
P 太小的时候,温度压根到不了 65℃。因为比例控制的本质是「误差越小输出越小」,误差掉到某个值,输出小到抵消不了散热,温度就卡在那里不动了,形成稳态误差。P=5 的时候我这边稳定在 62℃ 左右,怎么等都上不去。
P 继续加,温度能爬到目标了,但会冲过。P=15 时实测最高 66.3℃、最低 63.9℃,波动 ±1.2℃,形成等幅振荡。这个状态就是「临界振荡」,再往上加 P,振幅会越来越大直至发散。
实际取值要在临界值上退一步,取临界 P 的 60%~70%。我最后用 15 是偏激进的选择,因为这台设备对超调不敏感,如果你控的是不能过温的样品,P 就得往回收。
| P 值 | 现象 | 判断 |
|---|---|---|
| 1~5 | 温度爬不到 65℃,卡在 60~63℃ | 稳态误差过大 |
| 8~12 | 能到 65℃,超调后缓慢回落 | 可用,收敛慢 |
| 15 | 63.9~66.3℃ 等幅振荡 | 临界附近 |
| 25 以上 | 振幅越来越大,不收敛 | 发散,必须回调 |
4.2 再调I:0.1起步直接失控的量级陷阱
P 定下来之后加 I。原文里从 1 开始调,整个系统直接失控,这个我没觉得意外——I 的量纲很多人没算过。
g_err_sum的单位是「℃·次」,2 秒累加一次。假设稳态误差 0.5℃,跑 10 分钟就是 300 次累加,g_err_sum = 150。这时候g_ki = 1,积分项单独就有 150 的输出,直接把输出顶到满。而g_ki = 0.05,积分项是 7.5,正好是能微调稳态的量级。
所以 I 的量级完全取决于累加频率。如果 PID 周期是 200ms 而不是 2s,同样的g_ki效果会放大十倍,必须重新整定。
我的调整路径是 0.01 → 0.03 → 0.05 → 0.08,每次加完观察稳态误差有没有变小,波动有没有变大。0.05 时温度稳定在 64.5~65.4℃,比纯 P 的 ±1.2℃ 收窄了一半多,而且运行时间越长越贴目标值——这就是积分项在慢慢把残余误差磨掉。
0.08 的时候开始出现低频摆动,周期大概两分钟,振幅 ±0.8℃,这就是积分过强引起的振荡。判断方法:如果摆动的周期很长(分钟级),基本就是 I 太大;如果摆动周期短(秒级),那是 P 太大。
4.3 最后调D:用一把扇子做扰动测试
D 项在恒温稳态下几乎不出力,因为温度不变,误差变化率接近 0。它的价值只在扰动出现时才体现。所以调 D 必须有扰动源,光看着稳态数据永远调不出来。
原文用的办法很土但很有效:拿扇子对着加热器件吹,人为加速散热。这相当于给系统叠加一个阶跃扰动,观察温度跌下去之后多久能拉回来、跌多深。
D=0 的时候,扇子一吹温度掉 1.5℃,要两三分钟才爬回 65℃。D=0.03 的时候,同样的风,温度只掉 0.4℃,而且很快就回来了,跟不吹的时候几乎没差别。加到 0.08 反而变差,温度开始高频抖动,因为微分项把采样噪声放大了。
这里有个细节值得说:D 项如果直接用err - g_err_last,设定值突变时(比如从 60℃ 改成 70℃)会产生一个巨大的微分冲击。改进做法是对测量值微分:
/* 微分先行:只对实测值求微分,避免设定值跳变时的冲击 */ static float g_temp_last = 0.0f; float d_term = -g_kd * (g_temp_cur - g_temp_last); g_temp_last = g_temp_cur; b = g_kp * err + g_ki * g_err_sum + d_term;g_temp_cur相邻两次的差值通常很小,噪声影响也就小;而err在改设定值时是阶跃的,差值是几度甚至十几度,乘上g_kd后能瞬间把输出打到 0 或 100。温控场景里改设定值是常事,这个改动省心很多。
4.4 整定记录与参数表
把整定过程记录下来,下次换设备可以直接照着走:
| 阶段 | P | I | D | 实测范围 | 结论 |
|---|---|---|---|---|---|
| 开关控制 | - | - | - | 58~70℃ | 回差过大,弃用 |
| 纯 P | 15 | 0 | 0 | 63.9~66.3℃ | 等幅振荡,有稳态误差 |
| P + I | 15 | 0.05 | 0 | 64.5~65.4℃ | 稳态误差消除 |
| P + I + D | 15 | 0.05 | 0.03 | 稳态同左,扰动恢复明显变快 | 最终参数 |
三个参数最终落成三个宏或者 Flash 里的三个 float,别写死在函数体里。后面要在线整定,就必须能改。
4.5 计算周期怎么定:2秒和1毫秒差在哪
原文说电机调速 2 秒一次肯定不行,这话对。控制周期的选取有个粗糙的经验:取系统时间常数的 1/10 到 1/20。加热棒加热一个腔体,时间常数动辄几十秒到几分钟,2 秒一次完全够;直流电机转速环的时间常数是几十毫秒,控制周期就得 1~5ms。
周期定太慢的后果是响应滞后,等 PID 算出该减功率了,温度已经冲上去了;定太快的后果是白烧 CPU,而且微分项对噪声更敏感。TIM3 那个 10ms 时间片跟 PID 周期是两回事——时间片决定的是输出的时间分辨率,PID 周期决定的是决策频率。两者可以差两个数量级,我这套是 10ms 和 2s,差 200 倍。
5. 避坑与排查:控温波动、继电器粘连、积分饱和这些坑
下面五条都是我自己或者身边人真实踩过的,按「现象 → 原因 → 解决」写,遇到类似症状可以直接对号入座。
5.1 温度冲过目标值停不下来,到 70℃ 才往回掉
现象:第一次跑闭环,65℃ 的目标,一路冲到 71℃,然后才开始慢慢回落,落回 63℃ 又冲一次,来回三四趟才稳。
原因:PID 介入点设在 60.1℃,介入时误差还有 5℃,积分项从第一秒就开始疯狂累加,等温度到 65℃ 时g_err_sum已经攒了几百度·次,比例项归零了积分项还在推着输出跑。
解决:加 3.2 里的积分分离,误差大于 3℃ 时完全不累加;同时把累加上限压到 ±300。改完之后超调从 6℃ 降到 1.5℃ 以内。另外把 PID 介入点往前挪到 58℃,让系统有更多时间收敛,代价是升温末期速度会慢一点。
5.2 继电器咔咔响了一周,触点粘连了
现象:调试阶段一切正常,装到设备上跑了一周,发现加热关不掉,温度一路往上,最后靠超温保护断电。
原因:10ms 时间片配 1 秒周期,机械继电器在 50% 占空比下每秒动作一次,一天八万多次。吸合时的电弧会把触点烧蚀,一周下来就粘死了。
解决:换过零触发的固态继电器。这个东西没有机械动作,寿命按百万次算,而且过零点切换对电网干扰小。如果成本压不住,就把控制周期从 1 秒放大到 10 秒,动作次数降到十分之一,同时给继电器加续流二极管和 RC 吸收。这是整个项目里最值得花钱的一处改动,别在这上面省。
5.3 温度读数跳变 ±2℃,PID输出跟着乱抖
现象:串口打印的温度值在 64.8、66.1、63.5 之间来回跳,PID 输出也跟着上蹿下跳,加热指示灯闪得毫无规律。
原因:两个问题叠在一起。一是 ADC 单次采样直接用,没滤波;二是采样时刻跟加热开关撞上了,加热棒通电瞬间的电流突变通过共地路径耦合进 ADC 参考。
解决:采样层做两级滤波,先中值再滑动平均:
/* 中值 + 滑动平均:先去脉冲干扰,再平滑 */ #define FILTER_N 8 static uint16_t s_buf[FILTER_N]; static uint8_t s_idx = 0; float Temp_Read(void) { uint16_t tmp[FILTER_N]; uint8_t i, j; uint32_t sum = 0; s_buf[s_idx++ % FILTER_N] = ADC_Read_Once(); for (i = 0; i < FILTER_N; i++) tmp[i] = s_buf[i]; /* 冒泡排序取中值,N 小的时候开销可以接受 */ for (i = 0; i < FILTER_N - 1; i++) for (j = 0; j < FILTER_N - 1 - i; j++) if (tmp[j] > tmp[j + 1]) { uint16_t t = tmp[j]; tmp[j] = tmp[j+1]; tmp[j+1] = t; } sum = tmp[2] + tmp[3] + tmp[4] + tmp[5]; /* 掐头去尾取中间四个 */ return (float)sum / 4.0f * ADC_TO_TEMP; }FILTER_N取 8 是折中,再大响应就迟钝了。另外把采样时刻固定放在时间片归零那一刻(g_slice == 0且刚断电),避开开关瞬间,这一点比滤波本身更管用。ADC 的参考电压脚要加 0.1uF 去耦,模拟地和功率地单点连接。
5.4 参数调好上电就变差,冷启动要「热身」
现象:台架上调好的参数,装到设备上第一次开机,前十分钟温度波动特别大,跑一会儿又好了。
原因:冷启动时腔体温度等于室温,从 25℃ 到 60℃ 这段是全功率开环加热,进入 PID 区间时加热棒本身已经是热的,热惯性比稳态时大得多,同样的参数自然控不住。另外参数存在 RAM 里,重新烧录或者断电就回默认值,你以为在调参,其实调的是另一组。
解决:两件事分开做。参数存 Flash,上电读出来校验,加 magic number 防止空白 Flash 读出乱码;冷启动时先开环跑到 55℃,然后停 30 秒让热惯性释放,再进 PID 区间。这 30 秒的等待很值,能省掉一半的整定时间。
5.5 夏天调好的参数,入冬精度就散了
现象:八月调出来的 ±0.5℃,到十二月变成 ±1.5℃,稳态值还偏低 1℃。
原因:环境温度低了,散热功率变大,同样的输出维持不住目标温度;NTC 的阻值曲线本身也随环境变化,分压电路没有做温度补偿。
解决:短期内先加大 D 项,D 的作用就是对变化趋势做预判,能在一定程度上抵消环境漂移;中期做法是给 NTC 分压的上拉电阻换成精密低温漂的(25ppm/℃ 以内),并在固件里做两点标定(0℃ 冰水、100℃ 沸水);长期做法是加一路环境温度传感器,按环境温度分几段存不同的 PID 参数,进哪一段用哪一组。这套分段参数不难写,就是结构体数组加一次查表。
6. 二次开发:把写死的参数改成可在线整定与串口标定
参数写死在代码里,每改一次就得重新编译烧录,整定效率低到让人怀疑人生。二次开发的第一件事就是把参数搬出来,能在串口上改。
6.1 参数结构与串口指令
/* 参数块,整体存 Flash,注意对齐和 magic */ typedef struct { uint32_t magic; /* 0x50494431,用来判断是否已初始化 */ float kp; float ki; float kd; float set_temp; uint16_t pid_period_s; /* PID 周期,秒 */ uint16_t reserved; } pid_cfg_t; pid_cfg_t g_cfg = { 0x50494431, 15.0f, 0.05f, 0.03f, 65.0f, 2, 0 };串口指令用最简单的文本协议就行,SET P 18.5、SET I 0.06、GET ALL、SAVE。解析的时候按空格切三段,第一段匹配命令,第二段匹配参数名,第三段atof。别在这个地方上 JSON,MCU 上解 JSON 是给自己找麻烦。
SAVE命令触发一次 Flash 擦写,注意 STM32 的 Flash 擦除是按页的,一页通常 1K 或 2K,参数块不到 32 字节,剩下空间留着以后加。擦写期间要关中断,因为 Flash 操作会阻塞总线,时间片中断被拖长会导致占空比失真——这个失真在示波器上能看出来,加热波形会有一个明显的缺口。
6.2 从软件时间片换成硬件PWM
软件时间片的好处是不挑引脚,坏处是精度受中断响应影响,而且 CPU 一直被 10ms 中断打断。如果加热器是 MOS 管驱动的直流负载,可以直接上硬件 PWM:
/* TIM2 CH1 输出 PWM,ARR=999,即 1000 档分辨率 */ void Heater_PWM_Init(void) { /* 72MHz / (71+1) = 1MHz 计数频率 */ TIM2->PSC = 71; TIM2->ARR = 999; /* 1MHz / 1000 = 1kHz PWM */ TIM2->CCR1 = 0; /* 初始占空比 0 */ TIM2->CCMR1 |= (6 << 4); /* CH1 配置为 PWM 模式 1 */ TIM2->CCER |= 0x01; /* 使能 CH1 输出 */ TIM2->CR1 |= 0x01; /* 启动定时器 */ } /* PID 输出 0~100 直接映射到 0~1000 的比较值 */ void Heater_SetDuty(uint16_t duty) { if (duty > 100) duty = 100; TIM2->CCR1 = duty * 10; }1kHz 的 PWM 对加热棒来说太快了,MOS 管的开关损耗会上升,实际用 100Hz~1kHz 都行,看驱动能力。换成硬件 PWM 之后,TIM3 那个 10ms 中断可以直接关掉,CPU 负载降一大截。固态继电器就不能用这个方案,SSR 的响应速度跟不上 kHz 级切换,还是得走时间片那条路。
6.3 验证方法:三个指标就够
改完之后怎么确认没改坏?我固定看三个东西:
| 指标 | 测法 | 合格线 |
|---|---|---|
| 超调量 | 冷启动到 65℃,记录最高温度 | 不超过目标值 2℃ |
| 稳态误差 | 稳定运行 30 分钟后,记录温度区间 | 落在 ±0.5℃ 内 |
| 扰动恢复时间 | 开盖或吹风 10 秒,记录回到 ±0.5℃ 的时间 | 3 分钟内 |
第三个指标最能反映 D 项调得对不对。前两个达标只能说明 P 和 I 到位了,扰动恢复才是 D 的考场。测试的时候一定要重
本文还有配套的精品资源,点击获取