我一直觉得温控风扇这类项目,是单片机入门里最容易做出“实物成就感”的一批——传感器、PWM、显示、电机驱动全都要串起来,做完是真的能放在桌上用的。不过多数人拿到网上那套“51温控风扇”资料后,习惯性做成开关式:温度高于阈值就开,低于阈值就关。这样确实能跑,但用起来很快会发现缺点:风扇要么全速狂响,要么彻底停转,在阈值附近还会频繁启停。
本文想聊的是我完整做完、实际调通的一版方案,核心改动有两个:一是用 DS18B20 精密测温,二是引入 PID 调节让转速随温差连续变化。文章覆盖了原理图设计思路、Proteus 仿真搭建、程序流程图逻辑、PID 参数整定方法、关键代码骨架以及调试中踩过的坑,适合正在做课程设计,或者想把普通温控风扇升级成平滑调速方案的开发者参考。标题里提到的“详细资料”,本质上就分布在这几个层面里,我会按项目复现的顺序把它们串起来讲。
1. 项目整体方案与核心组成拆解
1.1 温控风扇解决的痛点:从开关控制到连续调节
先说我为什么坚持要用 PID,而不是老老实实做“超阈值就开”。早期的想法很直接:设定 30℃,DS18B20 读到 30.5℃ 就开风扇,29.8℃ 就关。这个逻辑在 Proteus 里跑起来效果很好,但放到真实场景就有问题了——风扇一旦启动就是全速,转速不可控,噪声大不说,还很容易把温度拉到设定值以下又立即关闭,几秒后温度回升又启动,形成低频反复震荡。
这时候如果只给风扇一个中间占空比,例如永远输出 50% 转速,又会出现另一个问题:小温差时转速过高,大温差时转速又不够,达到热平衡后温度还是压不住。开关控制解决不了“连续调节”需求,固定占空比解决不了“按需调节”需求,所以最终方案选择了 PID:以当前温度与目标温度的差值为输入,通过比例、积分、微分三项综合计算出一个 0~255 的 PWM 占空比,让风扇转速随温差平滑变化。
这个改动带来的直观体验是:开机后温差大,风扇快速拉升;温度越接近目标,转速越缓;稳定后风扇几乎维持在一个很小的转速上。整个过程没有频繁启停,噪声也低很多。这个项目能练手的价值也在这里——它把传感器采样、定时器中断、输出限幅、数值滤波、按键设定这些工程问题全部结合到了一起。
1.2 系统的五大功能模块划分
整个系统可以拆成五个模块,每个模块在原理图和程序里都有明确边界:
| 模块 | 主要器件 | 作用 |
|---|---|---|
| 温度采集 | DS18B20 数字温度传感器 | 测量环境温度,输出 12 位数字量 |
| 控制核心 | AT89C51/STC89C52 单片机 | 温度读取、PID 运算、PWM 生成 |
| 电机驱动 | ULN2003 或 NPN 三极管+续流二极管 | 将单片机 PWM 信号放大,驱动直流风扇 |
| 人机交互 | LCD1602 显示屏、按键 | 显示温度/目标值,调整设定温度 |
| 电源与时钟 | 5V 电源、12MHz 晶振、复位电路 | 系统正常运行的基础 |
各模块之间的数据流是:DS18B20 把温度传给单片机,单片机把温度和用户设定值代入 PID 控制器,PID 输出 PWM 占空比,PWM 信号经驱动电路控制风扇转速。LCD1602 只负责显示,不参与闭环控制。
1.3 关键器件选型思路:为什么是 51 内核 + DS18B20 + PWM 驱动
选型不是拍脑袋,每个核心器件都对应一个明确理由。
单片机用 51 内核(AT89C51 或 STC89C52)是因为这类芯片的资料和指令集足够成熟。Proteus 仿真里 AT89C51 是内置模型,不需要额外加载固件就能跑;实物制作时 STC89C52 更常见,因为可以直接用串口下载程序,不需要专用编程器。
温度传感器选 DS18B20 而不是热敏电阻+ADC,是因为 DS18B20 是数字输出,直接通过单总线协议把温度值送到单片机,省去了 ADC 芯片和标定流程。12 位分辨率下精度是 0.0625℃,对温控风扇这种场景绰绰有余。虽然它的单总线时序对延时精度要求比较高,但这个难度对入门项目来说恰好是值得练的部分。
风扇驱动没有直接让单片机引脚接电机,是因为 51 的 P1 口输出电流只有毫安级,带不动直流电机。我最终用了 ULN2003 达林顿管阵列,内部自带续流二极管,单片机输出 PWM 信号进去,输出端直接驱动电机,硬件上非常省事。如果想省芯片,也可以用一个 NPN 三极管(如 S8050)加一个外部续流二极管,两种方案我都试过,后面原理图章节会展开对比。
2. DS18B20 温度采集与测量核心
2.1 单总线协议的时序实现要点
DS18B20 走的是单总线协议,意思是数据、时钟、命令全在一根线上完成。它不像 I2C 那样有两根线做握手,完全靠精确的微秒级时序判断 0 和 1。初始化时主机先拉低总线 480us 以上再释放,DS18B20 会在 60us 后拉低总线给出存在脉冲;读时序时主机拉低 2us 左右释放,再在短时间内采样总线电平;写时序则通过控制拉低后的释放时间长短来表示 1 或 0。
这里最容易犯的错是延时函数没有标定。12MHz 晶振下一条机器周期是 1us,但如果用多重循环做延时,还要加上汇编跳转的时间,不是文章里写多少 us 就真的是多少 us。我在 Keil 里调试时,直接用调试器断点测量延时函数的实际时间,再微调循环次数。如果没有调试器,也可以先让对方空转,用示波器看波形宽度。
以下是一段 51 下可用的 DS18B20 操作代码,包含复位、读写位、读写字节:
sbit DQ = P1^0; void delay_us(unsigned int us) { while (us--) { _nop_(); } } unsigned char ds18b20_reset(void) { unsigned char presence = 0; DQ = 0; delay_us(500); // 拉低至少480us DQ = 1; delay_us(70); // 释放总线,等待存在脉冲 presence = DQ; // 若为0,说明器件应答 delay_us(250); return presence; } void ds18b20_write_bit(unsigned char bitval) { DQ = 0; delay_us(3); if (bitval) { DQ = 1; } delay_us(60); DQ = 1; } unsigned char ds18b20_read_bit(void) { unsigned char val = 0; DQ = 0; delay_us(3); DQ = 1; delay_us(8); val = DQ; delay_us(55); return val; }关于这段代码有一个细节:_nop_()本身是 1 个机器周期,也就是 1us,但while(us--)每次循环实际大约要 3~5us。所以delay_us(500)并不是真的 500us,而是可能达到 1500us 以上。DS18B20 对复位低电平的要求是 480us 到 960us 之间,过长的复位时间反而可能导致初始化失败。因此实际使用时,要么用空指令连续写多个_nop_(),要么用示波器标定后给delay_us加一个系数。这个细节在 Proteus 里往往看不出来,因为仿真模型对时序的宽容度比真实芯片高,移植到实物后就容易翻车。
2.2 温度转换与编码细节
DS18B20 温度数据有 12 位:低字节低 4 位是小数部分,高字节的高 5 位是符号扩展位。负温度时用二进制补码表示,读取后需要判断符号位。
转换启动后默认需要 750ms 才能完成一次 12 位温度转换。如果你的项目是连续测温,没必要傻等 750ms,可以让单片机在启动转换后先去做显示刷新、按键扫描,等下一次循环再读结果。实际代码里我用了最简单的处理——在读取前延时 750ms,适合课程设计演示;如果追求实时性,可以设置一个“转换完成”标志,主循环里等标志置位再读取。
unsigned char ds18b20_read_byte(void) { unsigned char i, dat = 0; for (i = 0; i < 8; i++) { dat >>= 1; if (ds18b20_read_bit()) { dat |= 0x80; } } return dat; } float get_temperature(void) { unsigned char low, high; int raw; ds18b20_reset(); ds18b20_write_byte(0xCC); // 跳过ROM ds18b20_write_byte(0x44); // 启动温度转换 delay_ms(750); ds18b20_reset(); ds18b20_write_byte(0xCC); ds18b20_write_byte(0xBE); // 读取暂存器 low = ds18b20_read_byte(); high = ds18b20_read_byte(); raw = (high << 8) | low; if (raw & 0x8000) // 负温度 { raw = -(~raw + 1); } return raw * 0.0625f; }需要提醒的是:ds18b20_write_byte和ds18b20_read_byte也需要严格按照写时序和读时序来实现,方法和写位/读位类似,这里不过多展开。
2.3 采集环节的常见误差来源
DS18B20 虽然号称数字传感器,但实际使用中的误差来源并不少。
第一是上拉电阻缺失。单总线正常工作时,总线需要被 4.7kΩ 电阻上拉到 VCC。如果省掉这个电阻,短距离、低噪声环境也许能工作,但抗干扰能力会明显下降,温度偶尔会读出 85℃ 或 127℃ 这种典型错误值。
第二是寄生供电问题。DS18B20 支持寄生供电模式,但在这个模式下转换大电流期间要求主机强制拉高总线,处理不好就会出现转换错误。我强烈建议直接用外部供电:VDD 接 5V,GND 接地,DQ 只走数据。这样时序逻辑清晰,排查问题也容易。
第三是连续读取频率过高。DS18B20 完成一次转换需要时间,如果你频繁发送转换指令又去读,读到很可能是上一次的数据。我的做法是控制采样周期在 1s 左右,也就是主循环里 500ms 到 1s 才发起一次温度采集。温控风扇本身是热惯性系统,1s 的采样周期完全够用,PID 的积分项也不会因为采样不稳定而震荡。
3. PID 调节原理与 51 单片机上的落地实现
3.1 为什么开关控制不够用:PID 到底在解决什么
很多人听到 PID 觉得高大上,其实它的本质一句话就能概括:根据当前误差、历史误差和误差变化趋势,算出下一步输出量。
拿温控风扇来说,误差就是“目标温度 - 当前温度”。只根据比例项输出,相当于“温差大转速大,温差小转速小”,这种控制方式在大多数场合已经能工作。但假设目标 30℃,当前 30.2℃,比例项输出很小,风扇几乎不转,这时如果有微弱热源持续加热,温度会缓慢升到 31℃,比例项才会加大一点,整个过程响应慢、静差大。积分项的加入就是为了消除这种静差——它把过去所有的误差累积起来,只要持续存在正误差,输出就会不断增加。微分项则关注误差的变化速度,温度快速上升时它会提前加大输出,温度突然下降时它会限制输出减小,起到“阻尼”的作用。
放到这个项目里,PID 解决的最直接问题是“阈值抖动”:开关控制下,风扇在设定值附近反复启停;有了 PID 和死区,误差在零附近时输出稳定,风扇转速平滑过渡,不再有那种咔嗒咔嗒的开关声。
3.2 位置式 PID 的离散化公式与参数含义
连续 PID 公式是:
输出 = Kp × 误差 + Ki × 积分 + Kd × 误差变化率
在单片机里做的是离散化处理,每个采样周期计算一次,因此用位置式写成:
输出(k) = Kp × E(k) + Ki × ΣE(k) + Kd × (E(k) - E(k-1))其中 E 的单位是 0.1℃。这里我强烈建议用“温度 ×10”来做运算,因为 51 单片机处理浮点运算较慢,整型运算既快又避免了很多奇怪的舍入错误。显示的时候再除以 10 恢复成小数。
三个参数的作用区别可以这样理解:
- Kp 决定风扇对当前温差的“反应速度”,太大容易超调,太小升温时压不住。
- Ki 负责消除长期静差,让最终温度能稳定在目标值附近。但积分项如果无限累积,就会产生“积分饱和”——温差很大时积分项冲得过高,温差归零后输出还停留在高位,导致风扇长时间全速。因此积分项必须限幅。
- Kd 负责抑制温度快速变化时的过冲,但对采样噪声比较敏感,如果温度读数本身波动大,微分项反而会把噪声放大。
3.3 8 位 MCU 上的整数运算实现与防溢出
51 单片机做 PID 的一个现实问题是运算资源有限。我最初试着直接用float写 PID 公式,Keil 编译后程序能跑,但中断里每次计算的耗时明显偏长,而且浮点库会占用较多代码空间。优化之后改用整数定点运算,所有量都用int和long。
积分项我用了一个 16 位变量,并在每次累加后做饱和处理。由于温差为 0.1℃ 单位,设定 30.0℃,实际 25.3℃,误差就是 47。Kp 取 3 时比例项输出为 141,落在 0~255 范围内比较合适。积分限幅设为 ±500,也就是累积误差达到 50℃ 时不再增加,防止长时间大温差后积分冲过头。微分项系数 Kd 取 5,主要作用是提前抑制过冲。
#define KP 3 #define KI 1 #define KD 5 int error; int last_error; int integral; int integral_item; int pid_calc(int setpoint, int measured) { int p_term, d_term, out; error = setpoint - measured; // 单位0.1℃ // 死区:设定值附近±0.5℃内不调整,防止转速抖动 if ((error > -5) && (error < 5)) { error = 0; } integral += error; if (integral > 500) integral = 500; // 防积分饱和 if (integral < -500) integral = -500; p_term = KP * error; integral_item = KI * integral / 10; d_term = KD * (error - last_error); last_error = error; out = p_term + integral_item + d_term; if (out > 255) out = 255; // 输出限幅 if (out < 0) out = 0; return out; }这里的两个小设计需要解释清楚。一是死区,当误差在 ±0.5℃ 以内时强制把误差清零,避免风扇在设定温度附近因为温度读数微小波动而反复改变转速。二是输出限幅,PWM 占空比实际是 0~255,PID 运算结果超出范围时必须截断,否则在后续定时器比较时会出现逻辑错误。
3.4 手动整定方法与初始参数建议
关于 PID 参数,网上有很多理论整定方法,比如临界比例度法,但对这个项目最实用的还是试凑法。我的建议步骤是:
- 先只保留比例项,把 Kd 和 Ki 都设成 0。设定目标温度,观察风扇转速和温度曲线。
- 如果温度过冲很大,先减小 Kp;如果迟迟达不到设定温度,逐步加大 Kp,直到温度能在目标附近缓慢稳定。
- 加入积分项,Ki 从很小值开始加。注意观察温度是否出现周期性的缓慢波动,这往往是积分项过大的表现。
- 最后加入微分项,Kd 从较大值开始试,再逐步减小,直到升温过程不再明显超调。
下面这组参数是我实际调出来能稳定工作的初始值,环境温度 22℃、目标温度 32℃、风扇为 12V 直流无刷风扇:
| 参数 | 数值 | 说明 |
|---|---|---|
| Kp | 3 | 温差 1℃ 时输出约 30 的占空比变化 |
| Ki | 1 | 积分限幅 ±500,避免长期累积过大 |
| Kd | 5 | 对 50ms 采样周期的温度变化率敏感 |
| 采样周期 | 50ms | 定时器中断统一处理 |
| 死区 | ±0.5℃ | 避免设定点附近转速抖动 |
这套参数不是万能的,换了风扇、换了环境温升速率之后通常需要重调。我的经验是:每次只调一个参数,否则很容易陷入“越调越乱”的困境。
4. Proteus 仿真环境搭建与调试要点
4.1 元件清单与电路连接清单
Proteus 仿真是这个项目资料里最直观的部分。我用的版本是 Proteus 8 系列,元件清单如下:
| 元件 | 库内搜索名 | 数量 | 用途 |
|---|---|---|---|
| AT89C51 | AT89C51 | 1 | 主控芯片 |
| DS18B20 | DS18B20 | 1 | 温度采集 |
| ULN2003A | ULN2003A | 1 | 驱动风扇 |
| DC Motor | MOTOR | 1 | 模拟直流风扇 |
| LCD1602 | LM016L | 1 | 显示温度等信息 |
| 电阻 | RES | 若干 | 4.7kΩ 上拉、330Ω 限流 |
| 电容 | CAP | 2 | 晶振负载电容 |
| 晶振 | CRYSTAL | 1 | 12MHz |
| 按键 | BUTTON | 3 | 目标温度设定/模式切换 |
连接关系相对固定:P1.0 接 DS18B20 的 DQ,同时经 4.7kΩ 上拉到 VCC;P1.1 接 ULN2003A 的输入端,ULN2003A 输出端接电机;P0 口经排阻接 LCD1602 数据线;P2.0、P2.1、P2.2 分别接 LCD 的 RS、RW、E;P3.2、P3.3、P3.4 接三个按键。
注意在 Proteus 里给电机添加电压时,电机的输入电压决定了它的转速上限。仿真时如果把电机驱动电压设成 12V,而 ULN2003 的 COM 引脚没有正确接电源,电机会表现得很奇怪——要么不转,要么转速不受控制。
4.2 仿真中的 DS18B20 模型行为差异
Proteus 里的 DS18B20 模型和真实芯片有一个重要差异:它对时序的要求非常宽松,甚至你把复位延时写成 1000us,它也可能照常工作。而真实芯片对过长时间的复位是无法容忍的。
这个特性导致一个现象:我在仿真里调好的延时函数,烧到实物上 DS18B20 直接无应答。反过来也成立,有些在实物上能稳定运行的时序,放到 Proteus 里超出模型容差反而出错。因此建议把仿真当成“逻辑验证工具”,而不是“时序验证工具”。只要确认读到的温度值不是固定不变的 0 或 85,就可以放心去写实物代码。
还有一点,Proteus 中 DS18B20 的温度值可以通过点击元件后在属性里直接手动修改,这个功能特别适合测试 PID 逻辑。我先在属性里把温度设置成 35℃,观察风扇是否接近全速;再改成 31℃,观察风扇是否快速降速;最后改成 30.3℃,看死区是否让风扇保持稳定转速。这样一步步测下来,PID 的各个功能就能确认到位。
4.3 调试中常见三个仿真“坑”
第一个坑是风扇转速看起来没有变化。如果用软件 PWM 控制电机,在仿真里看起来电机转速变化可能不明显,因为仿真模型对 PWM 平均电压的模拟不像真实电机那么敏感。这时应该用虚拟示波器接在单片机 PWM 输出引脚,观察波形占空比是否随 PID 输出变化。如果占空比变了但电机转速看不出变化,问题多半是驱动模型配置不对,把电机的“Inertia”参数调低一档再试。
第二个坑是整个仿真卡死或跑得极慢。DS18B20 的转换等待如果用了while死等,Proteus 默认的仿真速度会让整个界面看起来像死机。解决办法是把读取温度改成“启动转换后延时 50ms,下次循环再读”,或者直接把仿真速度调快。但更推荐在代码里用状态机处理温度读取,而不是在主循环里长时间阻塞等待。
第三个坑是 LCD1602 显示乱码。Proteus 的 LM016L 模型对读写时序的宽容度一般,常见错误是初始化时序里缺少延时。我调试时发现,LCD 初始化的每一条指令后面至少要加 2ms 延时,尤其是 0x28(设为 4 位模式)和 0x0C(开显示)这两条,如果太紧凑就会出现首行或者次行不显示。代码里写一个lcd_delay_ms(2),在每条指令后调用即可。
5. 原理图设计与硬件电路详解
5.1 电源与最小系统电路
整个系统的电源是 5V,单片机、DS18B20、LCD1602 都共用这一路。如果是 USB 供电,注意线材压降问题,尤其是驱动风扇后电压会瞬间跌落。我实际遇到过一次:风扇启动瞬间,电压从 5.0V 掉到 4.5V,单片机直接复位,后来换了带线补的充电头才解决。
最小系统包含复位电路和晶振电路。复位电路用 10kΩ 电阻下拉,复位按键并联 10uF 电容到地,按键按下时 RST 引脚得到高电平复位。晶振电路用 12MHz 晶振加两个 30pF 电容。这里需要注意,Proteus 里晶振可以不接负载电容也能工作,但实物必须接,否则可能不起振或频率偏移。
原理图上还有一个容易忽略的引脚:EA/VPP 必须接高电平,否则单片机默认去读取外部程序存储器,而外部什么也没接,程序跑不起来。新手的原理图问题中,EA 悬空占了相当大比例。
5.2 风扇驱动方案对比:三极管、ULN2003 与 L298N
驱动直流风扇的电路有三种常见方案,我全部试过,这里给出对比:
| 方案 | 优势 | 劣势 | 适用场景 |
|---|---|---|---|
| NPN 三极管+续流二极管 | 成本低,3 个元件可完成 | 需要正确选基极电阻,发热略大 | 小功率风扇、教学验证 |
| ULN2003A | 内部达林顿,输入无需大电流,自带续流二极管 | 饱和压降约 1V,不能高速 PWM | 课程设计最推荐 |
| L298N 或 TB6612 | 支持正反转和调速,内阻小 | 成本高,接线多,51 下用不到正反转 | 做大功率或双电机项目 |
单片机 P1.1 输出 PWM 信号进 ULN2003 的 IN 端,OUT 端接电机,COM 引脚接 VCC(用来接续流二极管到电源)。ULN2003 的输出是集电极开路结构,内部达林顿管导通时电机接地,输入高电平时输出管截止,电机断开。由于内部已经集成了续流二极管,外电路不需要再加二极管,这是它比三极管方案省事的主要原因。
三极管方案需要计算基极电阻:假设驱动三极管是 S8050,hFE 约 100,电机额定电流 300mA,那么基极电流至少需要 3mA,基极限流电阻取 5V 减去 0.7V 的 VBE 再除以 3mA,约 1.4kΩ,取 1kΩ 可留余量。电机两端必须反向并联一个 1N5819 二极管,否则 PWM 关断瞬间的反向电动势会击穿三极管。
如果采用通用 PWM 输出,我建议占空比不要低于 10%,因为对大多数有刷风扇来说,低于这个占空比时电机可能完全转不起来,造成 PID 输出在低端死区里积压。代码里可以在pid_calc输出后加一个下限保护:如果输出大于 0 且小于 25,强制设为 25。
5.3 显示与按键模块设计
LCD1602 数据线接 P0 口,通常接 8 个 10kΩ 排阻上拉,因为 51 的 P0 口是开漏输出,必须接上拉才能输出高电平。如果省略排阻,LCD1602 显示会出现很淡的残影或者完全不显示。控制线 RS、RW、E 分别接 P2.0、P2.1、P2.2,RW 固定接地也可以,因为这里只写入不读取,但为了调试方便我保留了 RW 控制。
按键部分用了三个:S1 增加目标温度,S2 减少目标温度,S3 切换“温度显示”和“目标温度显示”。按键一端接 P3 口,另一端接地,程序里启用内部上拉或外部上拉电阻。为了防止按键抖动,我在扫描函数里加了 20ms 消抖延时,并在检测到有效按下后等待释放再执行加减操作,避免长按一次加多次。
原理图完成后,需要注意编号标识和网络标号的一致性。很多同学画图时手工连线交叉,到 PCB 或布线阶段会非常痛苦。我推荐的办法是:模块内部用物理连接,模块之间尽量用网络标号连接,这样后期修改一个引脚直接全局同步,不会出现“原理图看着对了,但网络没连上”的问题。
6. 软件流程与可复用的代码骨架
6.1 主循环与定时器中断的职责划分
软件结构对一个带 PID 的项目来说尤其重要,因为温度采样、PID 计算、PWM 生成、显示刷新如果全塞在主循环里,会导致系统响应乱套。我的划分是:
- 定时器 0 中断:生成 PWM 波形。每次中断到来时更新引脚翻转位置,中断里面只做极轻量的事情。
- 主循环:周期读取 DS18B20 温度、调用 PID、更新 PWM 占空比变量、刷新 LCD1602。
- 按键扫描:放在主循环里,带 20ms 消抖。
这种结构下,PWM 输出不受主循环阻塞影响,即使 DS18B20 读取比较慢,风扇转速也不会瞬间跳到全速或停机,因为 PWM 占空比变量是由主循环更新的,更新间隙的波形由中断维持。
DS18B20 读写期间最好关全局中断,否则定时器中断会打乱微秒级时序。我采用的方法是:
EA = 0; temp = get_temperature(); EA = 1;关中断的时间只有几毫秒,PWM 会中断几十个周期,但风扇因为有机械惯性,转速并不会明显波动。这个做法在仿真和实物上都能稳定工作。
6.2 主程序流程背后的流程图逻辑
实际项目的流程逻辑可以这样描述:
- 上电后先初始化:关闭全局中断,配置定时器 0 为 16 位定时器并启动,初始化 LCD1602,调用一次 DS18B20 复位。
- 设置默认目标温度 30℃,默认占空比 0。
- 主循环开始,每 50ms 执行一次以下流程:
- 读取 DS18B20 温度,如果读取失败,保持上次温度值并显示错误码 E。
- 调用
pid_calc(target, temperature),得到输出占空比 0~255。 - 将占空比换算成 PWM 比较值,更新全局变量
pwm_duty。 - 刷新 LCD1602 显示,第一行显示当前温度,第二行显示目标值和风扇占空比。
- 扫描三个按键,如果按了 S1 或 S2,目标温度加 1 或减 1,范围限制在 20℃~40℃。
流程图如果画出来,就是一个环形结构:初始化在中心上方,循环体在中心的左右两侧分别连接“采集→计算→输出→显示→按键”,然后回到采集。不需要复杂的工厂自动化状态机,主循环加一个定时器断点就是完整的逻辑。
6.3 可直接改用的关键代码片段
PWM 生成最讲究的其实是频率与中断开销的平衡。我之前多次调整后,决定采用定时器中断 2kHz、占空比 8 级调节的方案。2kHz 对小风扇有轻微噪声,但 12MHz 晶振下中断开销可控;如果对噪声敏感,可以适当降低 PWM 分辨率,提高频率。
#define PWM_PERIOD 8 // 每8个中断周期构成一个PWM周期 #define PWM_TIMER_H 0xFF // 定时器初值高位,按实际晶振计算 #define PWM_TIMER_L 0xBF // 定时器初值低位 volatile unsigned char pwm_phase = 0; volatile unsigned char pwm_duty = 0; // 0~8 void timer0_isr(void) interrupt 1 { TH0 = PWM_TIMER_H; TL0 = PWM_TIMER_L; pwm_phase++; if (pwm_phase >= PWM_PERIOD) { pwm_phase = 0; } if (pwm_phase < pwm_duty) { FAN = 1; // 导通风机 } else { FAN = 0; // 关闭风机 } }注意FAN是接在 ULN2003 输入端的方向极性问题:ULN2003 输入高电平、输出低电平时电机得电转动,所以 PWM 的高电平时间就是电机导通时间。如果你的驱动电路是 PNP 三极管高边开关,那高电平是关断,代码需要取反。这是从原理图到代码最容易弄反的一步。
主循环核心部分可以这样写:
void main(void) { unsigned int count = 0; system_init(); while (1) { if (count >= 50) // 大约50ms执行一次 { float temp = get_temperature(); int out = pid_calc(target_temp * 10, (int)(temp * 10)); pwm_duty = out / 32; // 0~255换算成0~8 display_update(temp, target_temp, out); count = 0; } scan_key(); count++; } }target_temp * 10是为了和温度读取的 0.1℃ 单位对齐。显示的时候再除以 10 还原成整数。
7. 实测调试经验与常见问题应急手册
7.1 低速抖动与 PWM 频率的选择
实物调试时最明显的问题是风扇在低速时抖动。有刷直流风扇在低占空比下会发出咯咯声,甚至出现脉动转动。原因不是 PID 算错了,而是 PWM 频率和占空比分辨率不匹配。
我的处理方案有三个:一是给输出加最低占空比保护,转速不为零时保证最低 12.5% 占空比;二是把 PWM 频率提高到 4kHz 以上,减少低占空比下的“一冲一冲”感;三是换用真正带霍尔传感器的无刷风扇,这类风扇对低占空比的适应性更好。
如果你的单片机资源富余,最理想的还是用带硬件 PWM 的增强型 51,例如 STC15 系列,它内部的 PWM 模块可以在不占用中断的前提下输出高频率、高分辨率的 PWM 波形。用软件模拟 PWM 始终是在中断开销和输出质量之间做取舍。
7.2 温度读数异常的现场定位
实物上 DS18B20 读出的温度固定为 85℃、127℃ 或 0℃ 时,分别对应不同问题。85℃ 通常是初始化不成功或内部寄存器处于上电默认值;127℃ 通常是总线电平异常,数据位读出来全部是 1;0℃ 通常是读到了符号位被截断的 0000,常见原因是读写字节的时序错误导致只读到了低字节。
排查顺序我建议固定为:测 VDD 和 GND 电压是否正确,测 DQ 是否被 4.7kΩ 上拉,用示波器观察复位时是否存在应答脉冲,最后再用逻辑分析仪看读时序里每一位的电平宽度。不要一上来就怀疑代码,传感器和连接的问题占一半以上。
7.3 仿真移植到实物必须重新核对的三个地方
仿真和实物的差异是永恒的,以下三点我每次都会核对:
| 检查项 | 仿真中的表现 | 实物中的常见结果 |
|---|---|---|
| 晶振频率 | 默认 12MHz,可在属性里看 | 实际晶振频率不对,导致延时整体偏移 |
| 延时函数 | 模型宽容度高,随便延时都可能过 | 必须用示波器或调试器实测精度 |
| 电机驱动方向 | 正负极接反可能仍能转 | 接反可能不转或发热严重 |
另外,真实环境下 DS18B20 的线缆如果超过 20cm,建议改用屏蔽线并缩短采样周期。项目里我用的是 1m 双绞线,发现温度读数偶尔出现跳变,后来把读取函数改成连续读三次取中间值,跳变问题解决了。这个经验来自真实调试,不是理论推导——信号完整性问题在小项目里同样存在。
最后再分享一个我的习惯:做这种带 PID 的单片机项目,我会先把 PID 参数做成可以在 LCD 上显示和修改的形式,这样不接电脑也能在现场整定。所有参数用一串数组存着,按键加加减减就能调。看起来像多写了几行代码,实际上省下的调试时间远比那几行代码多。如果之后你还想把普通温控风扇升级成带加热棒的双向温控系统,这套 PID 代码几乎可以平移到加热继电器控制上,只需要改一个输出逻辑,整体架构不用重写。