文章目录
-
- 一、前言
-
- [1.1 为什么要做新风控制器](#1.1 为什么要做新风控制器)
- [1.2 你能从这篇教程学到什么](#1.2 你能从这篇教程学到什么)
- [1.3 技术栈](#1.3 技术栈)
- 二、系统架构设计
-
- [2.1 整体方案](#2.1 整体方案)
- [2.2 工作流程](#2.2 工作流程)
- 三、硬件准备与电路设计
-
- [3.1 硬件清单](#3.1 硬件清单)
- [3.2 引脚分配](#3.2 引脚分配)
- [3.3 关键电路说明](#3.3 关键电路说明)
-
- [PM2.5 传感器电路](#PM2.5 传感器电路)
- 风机驱动电路
- 四、软件开发
-
- [4.1 项目文件结构](#4.1 项目文件结构)
- [4.2 系统配置文件](#4.2 系统配置文件)
- [4.3 ADC 驱动](#4.3 ADC 驱动)
- [4.4 PM2.5 传感器驱动](#4.4 PM2.5 传感器驱动)
- [4.5 CO2 传感器驱动](#4.5 CO2 传感器驱动)
- [4.6 PID 控制器](#4.6 PID 控制器)
- [4.7 风机 PWM 控制](#4.7 风机 PWM 控制)
- [4.8 SHT30 温湿度驱动(I2C)](#4.8 SHT30 温湿度驱动(I2C))
- [4.9 主程序](#4.9 主程序)
- [五、PID 参数调试](#五、PID 参数调试)
-
- [5.1 先理解参数各有什么用](#5.1 先理解参数各有什么用)
- [5.2 调参步骤(实际操作)](#5.2 调参步骤(实际操作))
- 六、测试与验证
-
- [6.1 分模块测试](#6.1 分模块测试)
- [6.2 系统联调](#6.2 系统联调)
- 七、故障排查与问题解决
-
- [7.1 PM2.5 传感器读数为零或固定值](#7.1 PM2.5 传感器读数为零或固定值)
- [7.2 CO2 传感器返回 9999](#7.2 CO2 传感器返回 9999)
- [7.3 风机不转或转速不稳定](#7.3 风机不转或转速不稳定)
- [7.4 I2C 通信失败(SHT30 和 OLED)](#7.4 I2C 通信失败(SHT30 和 OLED))
- [7.5 PID 调参时风机剧烈震荡](#7.5 PID 调参时风机剧烈震荡)
- 八、扩展方向
一、前言
1.1 为什么要做新风控制器
北方冬天开暖气,南方梅雨季关窗防潮,室内通风几乎全靠"开窗散味"这个原始操作。但外面雾霾、尾气、扬尘一大堆,开窗等于用一种空气污染换另一种。新风系统的思路很简单:用管道把室外的空气过滤后再送进来,同时把室内的污浊空气排出去。问题在于,市面上大多数新风设备只有"开/关"两档,或者最多三档风速手动切换。空气好的时候全速运转浪费电,空气差的时候低档又不够用。
本项目的目标是用 STM32F103 做一个新风系统的智能控制器,让它能根据室内空气质量自动调节风机转速,PM2.5 高了就加大风量,空气质量好了就降低转速省电。核心就是两个字:自动。
1.2 你能从这篇教程学到什么
- STM32F103 的 ADC 采集、PWM 输出、串口通信等外设的实际使用方法
- PID 控制算法在嵌入式环境中的工程实现(不是纯理论推导)
- 空气质量传感器(PM2.5 + CO2)的驱动和数据读取
- 一个完整的小型项目从硬件选型到代码调试的全过程
1.3 技术栈
技术栈:
- 主控芯片:STM32F103C8T6(Cortex-M3,72MHz)
- 开发环境:STM32CubeIDE(ST 官方免费 IDE)
- 调试工具:ST-Link V2
- 编程语言:C(标准库 + 寄存器混用)
- 传感器:GP2Y1014AU0F(PM2.5 粉尘传感器)、MH-Z19C(CO2 传感器)
- 执行器:直流无刷风机 + MOS 管驱动(PWM 调速)
二、系统架构设计
2.1 整体方案
先画个系统框图,把各个模块的关系理清楚:
#mermaid-svg-MSGYQLf76tm80fX9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ffffff;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MSGYQLf76tm80fX9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MSGYQLf76tm80fX9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MSGYQLf76tm80fX9 .error-icon{fill:#a44141;}#mermaid-svg-MSGYQLf76tm80fX9 .error-text{fill:#ddd;stroke:#ddd;}#mermaid-svg-MSGYQLf76tm80fX9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MSGYQLf76tm80fX9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MSGYQLf76tm80fX9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MSGYQLf76tm80fX9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MSGYQLf76tm80fX9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MSGYQLf76tm80fX9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MSGYQLf76tm80fX9 .marker{fill:#60a5fa;stroke:#60a5fa;}#mermaid-svg-MSGYQLf76tm80fX9 .marker.cross{stroke:#60a5fa;}#mermaid-svg-MSGYQLf76tm80fX9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MSGYQLf76tm80fX9 p{margin:0;}#mermaid-svg-MSGYQLf76tm80fX9 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#ffffff;}#mermaid-svg-MSGYQLf76tm80fX9 .cluster-label text{fill:#F9FFFE;}#mermaid-svg-MSGYQLf76tm80fX9 .cluster-label span{color:#F9FFFE;}#mermaid-svg-MSGYQLf76tm80fX9 .cluster-label span p{background-color:transparent;}#mermaid-svg-MSGYQLf76tm80fX9 .label text,#mermaid-svg-MSGYQLf76tm80fX9 span{fill:#ffffff;color:#ffffff;}#mermaid-svg-MSGYQLf76tm80fX9 .node rect,#mermaid-svg-MSGYQLf76tm80fX9 .node circle,#mermaid-svg-MSGYQLf76tm80fX9 .node ellipse,#mermaid-svg-MSGYQLf76tm80fX9 .node polygon,#mermaid-svg-MSGYQLf76tm80fX9 .node path{fill:#1e293b;stroke:#3b82f6;stroke-width:1px;}#mermaid-svg-MSGYQLf76tm80fX9 .rough-node .label text,#mermaid-svg-MSGYQLf76tm80fX9 .node .label text,#mermaid-svg-MSGYQLf76tm80fX9 .image-shape .label,#mermaid-svg-MSGYQLf76tm80fX9 .icon-shape .label{text-anchor:middle;}#mermaid-svg-MSGYQLf76tm80fX9 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MSGYQLf76tm80fX9 .rough-node .label,#mermaid-svg-MSGYQLf76tm80fX9 .node .label,#mermaid-svg-MSGYQLf76tm80fX9 .image-shape .label,#mermaid-svg-MSGYQLf76tm80fX9 .icon-shape .label{text-align:center;}#mermaid-svg-MSGYQLf76tm80fX9 .node.clickable{cursor:pointer;}#mermaid-svg-MSGYQLf76tm80fX9 .root .anchor path{fill:#60a5fa!important;stroke-width:0;stroke:#60a5fa;}#mermaid-svg-MSGYQLf76tm80fX9 .arrowheadPath{fill:lightgrey;}#mermaid-svg-MSGYQLf76tm80fX9 .edgePath .path{stroke:#60a5fa;stroke-width:2.0px;}#mermaid-svg-MSGYQLf76tm80fX9 .flowchart-link{stroke:#60a5fa;fill:none;}#mermaid-svg-MSGYQLf76tm80fX9 .edgeLabel{background-color:#1e293b;text-align:center;}#mermaid-svg-MSGYQLf76tm80fX9 .edgeLabel p{background-color:#1e293b;}#mermaid-svg-MSGYQLf76tm80fX9 .edgeLabel rect{opacity:0.5;background-color:#1e293b;fill:#1e293b;}#mermaid-svg-MSGYQLf76tm80fX9 .labelBkg{background-color:rgba(30, 41, 59, 0.5);}#mermaid-svg-MSGYQLf76tm80fX9 .cluster rect{fill:#1e293b;stroke:#3b82f6;stroke-width:1px;}#mermaid-svg-MSGYQLf76tm80fX9 .cluster text{fill:#F9FFFE;}#mermaid-svg-MSGYQLf76tm80fX9 .cluster span{color:#F9FFFE;}#mermaid-svg-MSGYQLf76tm80fX9 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:#1e293b;border:1px solid rgba(255, 255, 255, 0.25);border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MSGYQLf76tm80fX9 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#ffffff;}#mermaid-svg-MSGYQLf76tm80fX9 rect.text{fill:none;stroke-width:0;}#mermaid-svg-MSGYQLf76tm80fX9 .icon-shape,#mermaid-svg-MSGYQLf76tm80fX9 .image-shape{background-color:#1e293b;text-align:center;}#mermaid-svg-MSGYQLf76tm80fX9 .icon-shape p,#mermaid-svg-MSGYQLf76tm80fX9 .image-shape p{background-color:#1e293b;padding:2px;}#mermaid-svg-MSGYQLf76tm80fX9 .icon-shape .label rect,#mermaid-svg-MSGYQLf76tm80fX9 .image-shape .label rect{opacity:0.5;background-color:#1e293b;fill:#1e293b;}#mermaid-svg-MSGYQLf76tm80fX9 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MSGYQLf76tm80fX9 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MSGYQLf76tm80fX9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} ADC 采样
UART 通信
I2C 通信
PWM 控制信号
功率输出
显示数据
串口通信
GPIO
PM2.5 传感器
STM32F103 主控
CO2 传感器
温湿度传感器
MOS 管驱动板
直流无刷风机
OLED 显示屏
调试串口 / 上位机
按键输入
2.2 工作流程
#mermaid-svg-vfX4QtahLZSvi5yL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ffffff;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-vfX4QtahLZSvi5yL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-vfX4QtahLZSvi5yL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-vfX4QtahLZSvi5yL .error-icon{fill:#a44141;}#mermaid-svg-vfX4QtahLZSvi5yL .error-text{fill:#ddd;stroke:#ddd;}#mermaid-svg-vfX4QtahLZSvi5yL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-vfX4QtahLZSvi5yL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-vfX4QtahLZSvi5yL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-vfX4QtahLZSvi5yL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-vfX4QtahLZSvi5yL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-vfX4QtahLZSvi5yL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-vfX4QtahLZSvi5yL .marker{fill:#60a5fa;stroke:#60a5fa;}#mermaid-svg-vfX4QtahLZSvi5yL .marker.cross{stroke:#60a5fa;}#mermaid-svg-vfX4QtahLZSvi5yL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-vfX4QtahLZSvi5yL p{margin:0;}#mermaid-svg-vfX4QtahLZSvi5yL .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#ffffff;}#mermaid-svg-vfX4QtahLZSvi5yL .cluster-label text{fill:#F9FFFE;}#mermaid-svg-vfX4QtahLZSvi5yL .cluster-label span{color:#F9FFFE;}#mermaid-svg-vfX4QtahLZSvi5yL .cluster-label span p{background-color:transparent;}#mermaid-svg-vfX4QtahLZSvi5yL .label text,#mermaid-svg-vfX4QtahLZSvi5yL span{fill:#ffffff;color:#ffffff;}#mermaid-svg-vfX4QtahLZSvi5yL .node rect,#mermaid-svg-vfX4QtahLZSvi5yL .node circle,#mermaid-svg-vfX4QtahLZSvi5yL .node ellipse,#mermaid-svg-vfX4QtahLZSvi5yL .node polygon,#mermaid-svg-vfX4QtahLZSvi5yL .node path{fill:#1e293b;stroke:#3b82f6;stroke-width:1px;}#mermaid-svg-vfX4QtahLZSvi5yL .rough-node .label text,#mermaid-svg-vfX4QtahLZSvi5yL .node .label text,#mermaid-svg-vfX4QtahLZSvi5yL .image-shape .label,#mermaid-svg-vfX4QtahLZSvi5yL .icon-shape .label{text-anchor:middle;}#mermaid-svg-vfX4QtahLZSvi5yL .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-vfX4QtahLZSvi5yL .rough-node .label,#mermaid-svg-vfX4QtahLZSvi5yL .node .label,#mermaid-svg-vfX4QtahLZSvi5yL .image-shape .label,#mermaid-svg-vfX4QtahLZSvi5yL .icon-shape .label{text-align:center;}#mermaid-svg-vfX4QtahLZSvi5yL .node.clickable{cursor:pointer;}#mermaid-svg-vfX4QtahLZSvi5yL .root .anchor path{fill:#60a5fa!important;stroke-width:0;stroke:#60a5fa;}#mermaid-svg-vfX4QtahLZSvi5yL .arrowheadPath{fill:lightgrey;}#mermaid-svg-vfX4QtahLZSvi5yL .edgePath .path{stroke:#60a5fa;stroke-width:2.0px;}#mermaid-svg-vfX4QtahLZSvi5yL .flowchart-link{stroke:#60a5fa;fill:none;}#mermaid-svg-vfX4QtahLZSvi5yL .edgeLabel{background-color:#1e293b;text-align:center;}#mermaid-svg-vfX4QtahLZSvi5yL .edgeLabel p{background-color:#1e293b;}#mermaid-svg-vfX4QtahLZSvi5yL .edgeLabel rect{opacity:0.5;background-color:#1e293b;fill:#1e293b;}#mermaid-svg-vfX4QtahLZSvi5yL .labelBkg{background-color:rgba(30, 41, 59, 0.5);}#mermaid-svg-vfX4QtahLZSvi5yL .cluster rect{fill:#1e293b;stroke:#3b82f6;stroke-width:1px;}#mermaid-svg-vfX4QtahLZSvi5yL .cluster text{fill:#F9FFFE;}#mermaid-svg-vfX4QtahLZSvi5yL .cluster span{color:#F9FFFE;}#mermaid-svg-vfX4QtahLZSvi5yL div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:#1e293b;border:1px solid rgba(255, 255, 255, 0.25);border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-vfX4QtahLZSvi5yL .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#ffffff;}#mermaid-svg-vfX4QtahLZSvi5yL rect.text{fill:none;stroke-width:0;}#mermaid-svg-vfX4QtahLZSvi5yL .icon-shape,#mermaid-svg-vfX4QtahLZSvi5yL .image-shape{background-color:#1e293b;text-align:center;}#mermaid-svg-vfX4QtahLZSvi5yL .icon-shape p,#mermaid-svg-vfX4QtahLZSvi5yL .image-shape p{background-color:#1e293b;padding:2px;}#mermaid-svg-vfX4QtahLZSvi5yL .icon-shape .label rect,#mermaid-svg-vfX4QtahLZSvi5yL .image-shape .label rect{opacity:0.5;background-color:#1e293b;fill:#1e293b;}#mermaid-svg-vfX4QtahLZSvi5yL .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-vfX4QtahLZSvi5yL .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-vfX4QtahLZSvi5yL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 达标
不达标
系统上电初始化
读取传感器数据
空气质量达标?
降低风机转速 / 待机
PID 计算目标风速
输出 PWM 调节风机
更新 OLED 显示
串口上报数据
三、硬件准备与电路设计
3.1 硬件清单
| 模块 | 型号/规格 | 数量 | 参考价格 |
|---|---|---|---|
| 主控板 | STM32F103C8T6 最小系统板 | 1 | 12 元 |
| PM2.5 传感器 | 夏普 GP2Y1014AU0F | 1 | 25 元 |
| CO2 传感器 | MH-Z19C | 1 | 45 元 |
| 温湿度传感器 | SHT30 | 1 | 15 元 |
| OLED 显示屏 | 0.96 寸 I2C 接口 SSD1306 | 1 | 12 元 |
| 风机驱动 | IRF3205 MOS 管 + 栅极驱动电路 | 1 套 | 8 元 |
| 直流无刷风机 | 12V / 0.3A | 1 | 30 元 |
| 按键 | 6×6 轻触开关 | 3 | 1 元 |
| 其他 | 杜邦线、面包板、电阻电容若干 | - | 10 元 |
总计约 150 元。
3.2 引脚分配
下面这个引脚分配表是实际测试过的,可以直接照着接线:
| 外设 | STM32 引脚 | 功能 |
|---|---|---|
| PM2.5 传感器 Vout | PA0(ADC1_CH0) | ADC 采集 |
| PM2.5 传感器 LED 脉冲 | PA1 | GPIO 输出 |
| MH-Z19C CO2 TX | PA10(USART1_RX) | UART 接收 |
| MH-Z19C CO2 RX | PA9(USART1_TX) | UART 发送 |
| SHT30 SDA | PB7(I2C1_SDA) | I2C 数据 |
| SHT30 SCL | PB6(I2C1_SCL) | I2C 时钟 |
| OLED SDA | PB7(与 SHT30 共享 I2C 总线) | I2C 数据 |
| OLED SCL | PB6(与 SHT30 共享 I2C 总线) | I2C 时钟 |
| 风机 PWM | PA8(TIM1_CH1) | PWM 输出 |
| 按键 1(模式切换) | PA2 | GPIO 输入 |
| 按键 2(目标设定+) | PA3 | GPIO 输入 |
| 按键 3(目标设定-) | PA4 | GPIO 输入 |
| 调试串口 TX | PA9(复用) | UART 发送 |
| 调试串口 RX | PA10(复用) | UART 接收 |
💡 提示:CO2 传感器的串口和调试串口共用了 USART1。实际使用时,通过一个跳线帽切换:调试时接 USB 转串口模块,运行时接 MH-Z19C。如果觉得麻烦,可以把 CO2 传感器接到 USART2(PA2/PA3),把按键换到其他 GPIO。
3.3 关键电路说明
PM2.5 传感器电路
GP2Y1014AU0F 的工作方式是:先给 LED 引脚一个低电平脉冲(约 0.32ms),然后采样输出引脚的模拟电压。输出电压和粉尘浓度成正比。需要加一个 150Ω 电阻和一个 220μF 电容做低通滤波。
STM32 PA1 ─── 150Ω ─── PM2.5 LED(-)
PM2.5 LED(+) ─── 3.3V
PM2.5 Vout ─── 220μF ─── GND
|
└──── STM32 PA0 (ADC)
风机驱动电路
12V 风机不能用 STM32 的 GPIO 直接驱动,电流不够。这里用一个 N 沟道 MOS 管(IRF3205)做开关,STM32 输出的 PWM 信号经过一个栅极驱动电路(或者简单的三极管反相电路)接到 MOS 管的栅极。
STM32 PA8 (PWM) ─── 1kΩ ─── 三极管基极
三极管发射极 ─── GND
三极管集电极 ─── IRF3205 栅极
IRF3205 漏极 ─── 风机(-)
IRF3205 源极 ─── GND
风机(+) ─── 12V
⚠️ 注意:IRF3205 的栅极阈值电压约 4V,STM32 的 3.3V PWM 有可能无法完全导通。解决办法是在栅极加一个 10V 的上拉,或者换用逻辑电平 MOS 管(如 IRLZ44N)。
四、软件开发
4.1 项目文件结构
在开始写代码之前,先把整个项目的文件结构列出来,方便后面逐个创建:
FreshAirController/
├── Core/
│ ├── Inc/
│ │ ├── main.h
│ │ ├── adc_driver.h
│ │ ├── pid_controller.h
│ │ ├── pm25_sensor.h
│ │ ├── co2_sensor.h
│ │ ├── sht30_driver.h
│ │ ├── oled_driver.h
│ │ ├── fan_control.h
│ │ └── system_config.h
│ └── Src/
│ ├── main.c
│ ├── adc_driver.c
│ ├── pid_controller.c
│ ├── pm25_sensor.c
│ ├── co2_sensor.c
│ ├── sht30_driver.c
│ ├── oled_driver.c
│ └── fan_control.c
4.2 系统配置文件
📄 创建文件:
Core/Inc/system_config.h
c
/**
* @file system_config.h
* @brief 系统全局配置
* @note 所有模块的参数集中在这里定义,方便统一调整
*/
#ifndef __SYSTEM_CONFIG_H
#define __SYSTEM_CONFIG_H
#include "stm32f10x.h"
/* ---- PID 控制参数 ---- */
#define PID_KP 2.5f /* 比例系数 */
#define PID_KI 0.08f /* 积分系数 */
#define PID_KD 0.5f /* 微分系数 */
#define PID_OUTPUT_MAX 100.0f /* PID 输出上限(对应 PWM 占空比 100%) */
#define PID_OUTPUT_MIN 0.0f /* PID 输出下限 */
#define PID_INTEGRAL_LIMIT 50.0f /* 积分限幅,防止积分饱和 */
/* ---- 空气质量阈值 ---- */
#define PM25_TARGET 35.0f /* PM2.5 目标值(μg/m³),对应国标优 */
#define PM25_MAX 150.0f /* PM2.5 上限,超过就全速运转 */
#define CO2_TARGET 800.0f /* CO2 目标值(ppm) */
#define CO2_MAX 2000.0f /* CO2 上限 */
/* ---- 传感器采样参数 ---- */
#define ADC_SAMPLE_COUNT 16 /* ADC 多次采样取平均 */
#define PM25_SAMPLE_PERIOD 280 /* PM2.5 采样周期(ms) */
#define CO2_SAMPLE_PERIOD 2000 /* CO2 采样周期(ms) */
#define SHT30_SAMPLE_PERIOD 5000 /* 温湿度采样周期(ms) */
#define MAIN_LOOP_PERIOD 100 /* 主循环周期(ms) */
/* ---- 风机控制 ---- */
#define FAN_PWM_FREQUENCY 10000 /* PWM 频率 10kHz */
#define FAN_MIN_DUTY 20 /* 最低占空比 20%,低于此风机不转 */
#define FAN_MAX_DUTY 100 /* 最高占空比 100% */
/* ---- OLED 显示 ---- */
#define OLED_ADDR 0x78 /* SSD1306 I2C 地址(7位地址左移一位) */
#define OLED_WIDTH 128
#define OLED_HEIGHT 64
#endif /* __SYSTEM_CONFIG_H */
4.3 ADC 驱动
📄 创建文件:
Core/Inc/adc_driver.h
c
/**
* @file adc_driver.h
* @brief ADC 采集驱动
*/
#ifndef __ADC_DRIVER_H
#define __ADC_DRIVER_H
#include "stm32f10x.h"
/**
* @brief 初始化 ADC1 通道 0(PA0)
*/
void ADC_Init(void);
/**
* @brief 多次采样取平均值,减少噪声干扰
* @param channel: ADC 通道号
* @param count: 采样次数
* @return 平均 ADC 值(12位,0~4095)
*/
uint16_t ADC_MultiSample(uint8_t channel, uint16_t count);
/**
* @brief 单次 ADC 读取
* @param channel: ADC 通道号
* @return ADC 值(12位,0~4095)
*/
uint16_t ADC_ReadSingle(uint8_t channel);
#endif /* __ADC_DRIVER_H */
📄 创建文件:
Core/Src/adc_driver.c
c
/**
* @file adc_driver.c
* @brief ADC 采集驱动实现
* @note 使用 ADC1,配置为单次转换模式,软件触发
*/
#include "adc_driver.h"
void ADC_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
ADC_InitTypeDef ADC_InitStruct;
/* 开启 GPIOA 和 ADC1 时钟 */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE);
/* 配置 PA0 为模拟输入 */
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AIN;
GPIO_Init(GPIOA, &GPIO_InitStruct);
/* ADC 基本配置 */
ADC_DeInit(ADC1);
ADC_InitStruct.ADC_Mode = ADC_Mode_Independent;
ADC_InitStruct.ADC_ScanConvMode = DISABLE; /* 单通道转换 */
ADC_InitStruct.ADC_ContinuousConvMode = DISABLE; /* 单次转换 */
ADC_InitStruct.ADC_ExternalTrigConv = ADC_ExternalTrigConv_None;
ADC_InitStruct.ADC_DataAlign = ADC_DataAlign_Right; /* 右对齐 */
ADC_InitStruct.ADC_NbrOfChannel = 1;
ADC_Init(ADC1, &ADC_InitStruct);
/* 使能 ADC */
ADC_Cmd(ADC1, ENABLE);
/* ADC 校准(必须执行,否则数据不准确) */
ADC_ResetCalibration(ADC1);
while (ADC_GetResetCalibrationStatus(ADC1) == SET);
ADC_StartCalibration(ADC1);
while (ADC_GetCalibrationStatus(ADC1) == SET);
}
uint16_t ADC_ReadSingle(uint8_t channel)
{
/* 规则组通道 1~16 */
ADC_RegularChannelConfig(ADC1, channel, 1, ADC_SampleTime_239Cycles5);
/* 启动转换 */
ADC_SoftwareStartConvCmd(ADC1, ENABLE);
/* 等待转换完成 */
while (ADC_GetFlagStatus(ADC1, ADC_FLAG_EOC) == RESET);
return ADC_GetConversionValue(ADC1);
}
uint16_t ADC_MultiSample(uint8_t channel, uint16_t count)
{
uint32_t sum = 0;
uint16_t i;
for (i = 0; i < count; i++) {
sum += ADC_ReadSingle(channel);
}
return (uint16_t)(sum / count);
}
4.4 PM2.5 传感器驱动
📄 创建文件:
Core/Inc/pm25_sensor.h
c
/**
* @file pm25_sensor.h
* @brief 夏普 GP2Y1014AU0F PM2.5 传感器驱动
*/
#ifndef __PM25_SENSOR_H
#define __PM25_SENSOR_H
#include "stm32f10x.h"
/**
* @brief 初始化 PM2.5 传感器引脚
*/
void PM25_Init(void);
/**
* @brief 采集一次 PM2.5 数据
* @note 采样流程:LED 脉冲 → 延时 → ADC 读取 → 换算浓度
* @return PM2.5 浓度(μg/m³)
*/
float PM25_Read(void);
/**
* @brief 获取原始 ADC 电压值(调试用)
* @return 电压值(V)
*/
float PM25_GetRawVoltage(void);
#endif /* __PM25_SENSOR_H */
📄 创建文件:
Core/Src/pm25_sensor.c
c
/**
* @file pm25_sensor.c
* @brief GP2Y1014AU0F 驱动实现
* @note 时序要求严格:LED 脉冲 0.32ms → 延时 0.28ms → 采样
* 总采样窗口 0.4ms → 采样后等 LED 灭掉稳定
*
* 电压与浓度的换算公式(经验公式):
* PM2.5 = (Vout - Vo_clean) * k
* 其中 Vo_clean ≈ 0.6V(无尘环境下输出),k ≈ 0.17
*/
#include "pm25_sensor.h"
#include "adc_driver.h"
#include "system_config.h"
/* PM2.5 LED 控制引脚:PA1 */
#define PM25_LED_PORT GPIOA
#define PM25_LED_PIN GPIO_Pin_1
/* 时序参数(us) */
#define PM25_LED_PULSE_ON 320 /* LED 点亮时间 0.32ms */
#define PM25_SAMPLE_DELAY 280 /* 采样点延时 0.28ms */
/* 换算参数 */
#define PM25_VOLTAGE_CLEAN 0.6f /* 无尘环境输出电压 */
#define PM25_SENSITIVITY 0.17f /* 灵敏度系数 */
static volatile uint32_t systick_count = 0;
void PM25_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
/* PA1 配置为推挽输出,控制传感器 LED */
GPIO_InitStruct.GPIO_Pin = PM25_LED_PIN;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(PM25_LED_PORT, &GPIO_InitStruct);
/* LED 默认关闭 */
GPIO_SetBits(PM25_LED_PORT, PM25_LED_PIN);
}
/**
* @brief 简单的微秒级延时
*/
static void delay_us(uint32_t us)
{
uint32_t ticks = us * (SystemCoreClock / 1000000) / 5;
while (ticks--) {
__NOP();
}
}
float PM25_GetRawVoltage(void)
{
uint16_t adc_val;
uint32_t sum = 0;
uint8_t i;
/* 触发 LED 脉冲 */
GPIO_ResetBits(PM25_LED_PORT, PM25_LED_PIN); /* LED ON */
delay_us(PM25_LED_PULSE_ON);
/* 在脉冲上升沿采样(0.28ms 后) */
delay_us(PM25_SAMPLE_DELAY);
/* 连续采样 4 次取平均 */
for (i = 0; i < 4; i++) {
sum += ADC_ReadSingle(ADC_Channel_0);
}
/* 关闭 LED */
GPIO_SetBits(PM25_LED_PORT, PM25_LED_PIN);
adc_val = (uint16_t)(sum / 4);
/* ADC 12位,参考电压 3.3V */
return (float)adc_val * 3.3f / 4096.0f;
}
float PM25_Read(void)
{
float voltage;
float pm25_value;
voltage = PM25_GetRawVoltage();
/* 电压低于无尘电压,说明接近零粉尘 */
if (voltage < PM25_VOLTAGE_CLEAN) {
return 0.0f;
}
/* 经验公式换算 */
pm25_value = (voltage - PM25_VOLTAGE_CLEAN) * PM25_SENSITIVITY;
/* 如果没有加 150Ω 分压电阻,电压范围不同,需要乘以系数 */
/* 实测中 GP2Y1014AU0F 输出范围约 0.5V~3.5V,对应 0~500μg/m³ */
pm25_value = pm25_value * 100.0f;
return (pm25_value < 0.0f) ? 0.0f : pm25_value;
}
4.5 CO2 传感器驱动
📄 创建文件:
Core/Inc/co2_sensor.h
c
/**
* @file co2_sensor.h
* @brief MH-Z19C CO2 传感器驱动(UART 接口)
*/
#ifndef __CO2_SENSOR_H
#define __CO2_SENSOR_H
#include "stm32f10x.h"
/**
* @brief 初始化 USART1(CO2 传感器通信)
*/
void CO2_Init(void);
/**
* @brief 读取 CO2 浓度
* @return CO2 浓度(ppm),读取失败返回 9999
*/
uint16_t CO2_Read(void);
#endif /* __CO2_SENSOR_H */
📄 创建文件:
Core/Src/co2_sensor.c
c
/**
* @file co2_sensor.c
* @brief MH-Z19C CO2 传感器驱动
* @note MH-Z19C 支持两种读数方式:
* 1. 被动读取:发送读取命令,等待应答(本驱动采用此方式)
* 2. 主动上报:传感器每隔一段时间自动发送数据
*
* 被动读取命令格式(9字节):
* FF 01 86 00 00 00 00 00 79
*
* 应答格式(9字节):
* FF 86 [高字节] [低字节] [ST] [校验和低] [校验和高] [空] [空]
*/
#include "co2_sensor.h"
#include <string.h>
/* 接收缓冲区 */
static uint8_t co2_rx_buf[9];
void CO2_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
USART_InitTypeDef USART_InitStruct;
/* 开启 GPIOA 和 USART1 时钟 */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_USART1, ENABLE);
/* PA9 = USART1_TX, PA10 = USART1_RX */
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStruct);
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_10;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStruct);
/* USART1 配置:9600 波特率,8N1(和 MH-Z19C 默认一致) */
USART_InitStruct.USART_BaudRate = 9600;
USART_InitStruct.USART_WordLength = USART_WordLength_8b;
USART_InitStruct.USART_StopBits = USART_StopBits_1;
USART_InitStruct.USART_Parity = USART_Parity_No;
USART_InitStruct.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
USART_InitStruct.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
USART_Init(USART1, &USART_InitStruct);
USART_Cmd(USART1, ENABLE);
}
/**
* @brief 通过 USART1 发送读取命令
*/
static void CO2_SendReadCmd(void)
{
/* MH-Z19C 被动读取命令 */
uint8_t cmd[9] = {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79};
uint8_t i;
for (i = 0; i < 9; i++) {
USART_SendData(USART1, cmd[i]);
while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET);
}
}
/**
* @brief 等待接收 9 字节应答
* @param timeout_ms: 超时时间(ms)
* @return 0=成功,-1=超时
*/
static int CO2_WaitResponse(uint16_t timeout_ms)
{
uint16_t i;
uint32_t start = 0;
uint8_t idx = 0;
/* 简单的超时等待(利用 SysTick 或循环计数) */
/* 这里用循环计数近似 */
for (i = 0; i < timeout_ms * 1000; i++) {
if (USART_GetFlagStatus(USART1, USART_FLAG_RXNE) == SET) {
co2_rx_buf[idx++] = USART_ReceiveData(USART1);
if (idx >= 9) return 0;
}
}
return -1;
}
/**
* @brief 校验应答数据
* @return 1=校验通过,0=失败
*/
static uint8_t CO2_CheckChecksum(void)
{
uint8_t checksum = 0;
uint8_t i;
for (i = 1; i < 8; i++) {
checksum += co2_rx_buf[i];
}
checksum = 0xFF - checksum + 1;
return (checksum == co2_rx_buf[8]) ? 1 : 0;
}
uint16_t CO2_Read(void)
{
uint16_t co2_ppm;
/* 发送读取命令 */
CO2_SendReadCmd();
/* 等待应答,超时 500ms */
if (CO2_WaitResponse(500) != 0) {
return 9999;
}
/* 校验应答帧头 */
if (co2_rx_buf[0] != 0xFF || co2_rx_buf[1] != 0x86) {
return 9999;
}
/* 校验和验证 */
if (!CO2_CheckChecksum()) {
return 9999;
}
/* 拼接 CO2 浓度值(高字节在前) */
co2_ppm = ((uint16_t)co2_rx_buf[2] << 8) | co2_rx_buf[3];
return co2_ppm;
}
4.6 PID 控制器
PID 算法是这个项目的大脑。简单来说:根据当前值和目标值的偏差,算出一个控制量来消除偏差。P(比例)看当前偏差有多大,I(积分)看过去积累了多少偏差,D(微分)看偏差变化趋势快不快。三者配合,既响应快又不会过冲。
📄 创建文件:
Core/Inc/pid_controller.h
c
/**
* @file pid_controller.h
* @brief PID 控制器(增量式 + 积分限幅 + 输出限幅)
*/
#ifndef __PID_CONTROLLER_H
#define __PID_CONTROLLER_H
#include "stm32f10x.h"
typedef struct {
float kp; /* 比例系数 */
float ki; /* 积分系数 */
float kd; /* 微分系数 */
float setpoint; /* 目标值 */
float input; /* 当前测量值 */
float output; /* 控制输出 */
float integral; /* 积分累积值 */
float prev_error; /* 上一次偏差 */
float output_min; /* 输出下限 */
float output_max; /* 输出上限 */
float integral_limit; /* 积分限幅 */
} PID_t;
/**
* @brief 初始化 PID 控制器
* @param pid: PID 结构体指针
* @param kp: 比例系数
* @param ki: 积分系数
* @param kd: 微分系数
* @param setpoint: 目标值
* @param out_min: 输出下限
* @param out_max: 输出上限
* @param int_limit: 积分限幅
*/
void PID_Init(PID_t *pid, float kp, float ki, float kd,
float setpoint, float out_min, float out_max, float int_limit);
/**
* @brief 执行一次 PID 计算
* @param pid: PID 结构体指针
* @param input: 当前测量值
* @return 控制输出值
*/
float PID_Calculate(PID_t *pid, float input);
/**
* @brief 重置 PID 状态(消除积分累积)
* @param pid: PID 结构体指针
*/
void PID_Reset(PID_t *pid);
/**
* @brief 修改目标值
* @param pid: PID 结构体指针
* @param setpoint: 新的目标值
*/
void PID_SetSetpoint(PID_t *pid, float setpoint);
#endif /* __PID_CONTROLLER_H */
📄 创建文件:
Core/Src/pid_controller.c
c
/**
* @file pid_controller.c
* @brief PID 控制器实现
* @note 位置式 PID,适合风机调速这种输出直接对应控制量的场景
*
* 计算公式:
* output = Kp * e + Ki * Σe * dt + Kd * (e - e_last) / dt
*
* 由于本项目中 dt 固定(主循环 100ms),所以 Kp/Ki/Kd 已经
* 包含了 dt 因子,不需要在代码里再乘 dt。
*/
#include "pid_controller.h"
#include <math.h>
void PID_Init(PID_t *pid, float kp, float ki, float kd,
float setpoint, float out_min, float out_max, float int_limit)
{
pid->kp = kp;
pid->ki = ki;
pid->kd = kd;
pid->setpoint = setpoint;
pid->input = 0.0f;
pid->output = 0.0f;
pid->integral = 0.0f;
pid->prev_error = 0.0f;
pid->output_min = out_min;
pid->output_max = out_max;
pid->integral_limit = int_limit;
}
float PID_Calculate(PID_t *pid, float input)
{
float error;
float p_term, i_term, d_term;
pid->input = input;
error = pid->setpoint - input;
/* P:比例项 */
p_term = pid->kp * error;
/* I:积分项(带限幅) */
pid->integral += error * pid->ki;
/* 积分限幅,防止长时间偏差导致积分爆炸 */
if (pid->integral > pid->integral_limit) {
pid->integral = pid->integral_limit;
} else if (pid->integral < -pid->integral_limit) {
pid->integral = -pid->integral_limit;
}
i_term = pid->integral;
/* D:微分项 */
d_term = pid->kd * (error - pid->prev_error);
pid->prev_error = error;
/* 合成输出 */
pid->output = p_term + i_term + d_term;
/* 输出限幅 */
if (pid->output > pid->output_max) {
pid->output = pid->output_max;
} else if (pid->output < pid->output_min) {
pid->output = pid->output_min;
}
return pid->output;
}
void PID_Reset(PID_t *pid)
{
pid->integral = 0.0f;
pid->prev_error = 0.0f;
pid->output = 0.0f;
}
void PID_SetSetpoint(PID_t *pid, float setpoint)
{
pid->setpoint = setpoint;
}
4.7 风机 PWM 控制
📄 创建文件:
Core/Inc/fan_control.h
c
/**
* @file fan_control.h
* @brief 风机 PWM 速度控制
*/
#ifndef __FAN_CONTROL_H
#define __FAN_CONTROL_H
#include "stm32f10x.h"
/**
* @brief 初始化风机 PWM 输出(TIM1 CH1 → PA8)
*/
void Fan_Init(void);
/**
* @brief 设置风机转速
* @param duty_percent: 占空比百分比(0~100)
*/
void Fan_SetSpeed(uint8_t duty_percent);
/**
* @brief 关闭风机
*/
void Fan_Stop(void);
/**
* @brief 获取当前风机占空比
* @return 当前占空比(0~100)
*/
uint8_t Fan_GetSpeed(void);
#endif /* __FAN_CONTROL_H */
📄 创建文件:
Core/Src/fan_control.c
c
/**
* @file fan_control.c
* @brief 风机 PWM 控制实现
* @note 使用 TIM1 CH1(PA8)输出 PWM 信号
* TIM1 是高级定时器,需要额外使能 MOE(主输出使能)
*/
#include "fan_control.h"
#include "system_config.h"
static uint8_t current_duty = 0;
void Fan_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
TIM_TimeBaseInitTypeDef TIM_TimeBaseStruct;
TIM_OCInitTypeDef TIM_OCInitStruct;
/* 开启时钟:GPIOA、TIM1 */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_TIM1, ENABLE);
/* PA8 配置为复用推挽输出(TIM1 CH1) */
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_8;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStruct);
/*
* 定时器基础配置
* 系统时钟 72MHz,预分频后计数频率 = 72MHz / (PSC+1)
* PWM 频率 = 计数频率 / (ARR+1)
*
* 目标 PWM 频率 10kHz:
* PSC = 71 → 计数频率 = 72MHz / 72 = 1MHz
* ARR = 99 → PWM 频率 = 1MHz / 100 = 10kHz
*/
TIM_TimeBaseStruct.TIM_Prescaler = 71;
TIM_TimeBaseStruct.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseStruct.TIM_Period = 99;
TIM_TimeBaseStruct.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseStruct.TIM_RepetitionCounter = 0;
TIM_TimeBaseInit(TIM1, &TIM_TimeBaseStruct);
/* PWM 模式 1:CNT < CCR 时输出有效电平 */
TIM_OCInitStruct.TIM_OCMode = TIM_OCMode_PWM1;
TIM_OCInitStruct.TIM_OutputState = TIM_OutputState_Enable;
TIM_OCInitStruct.TIM_Pulse = 0; /* 初始占空比 0% */
TIM_OCInitStruct.TIM_OCPolarity = TIM_OCPolarity_High;
TIM_OC1Init(TIM1, &TIM_OCInitStruct);
/* 使能预装载寄存器 */
TIM_OC1PreloadConfig(TIM1, TIM_OCPreload_Enable);
/* 使能定时器自动重载预装载 */
TIM_ARRPreloadConfig(TIM1, ENABLE);
/* TIM1 是高级定时器,必须额外使能主输出 */
TIM_CtrlPWMOutputs(TIM1, ENABLE);
/* 启动定时器 */
TIM_Cmd(TIM1, ENABLE);
current_duty = 0;
}
void Fan_SetSpeed(uint8_t duty_percent)
{
uint16_t pulse;
if (duty_percent > 100) duty_percent = 100;
/* CCR1 = ARR * duty% / 100 */
pulse = (uint16_t)((uint32_t)99 * duty_percent / 100);
/*
* 风机有最低启动占空比,低于 FAN_MIN_DUTY 不转
* 所以占空比低于 FAN_MIN_DUTY 时直接设为 0(停止)
*/
if (duty_percent > 0 && duty_percent < FAN_MIN_DUTY) {
duty_percent = FAN_MIN_DUTY;
pulse = (uint16_t)((uint32_t)99 * duty_percent / 100);
}
TIM_SetCompare1(TIM1, pulse);
current_duty = duty_percent;
}
void Fan_Stop(void)
{
Fan_SetSpeed(0);
}
uint8_t Fan_GetSpeed(void)
{
return current_duty;
}
4.8 SHT30 温湿度驱动(I2C)
📄 创建文件:
Core/Inc/sht30_driver.h
c
/**
* @file sht30_driver.h
* @brief SHT30 温湿度传感器驱动(I2C 接口)
*/
#ifndef __SHT30_DRIVER_H
#define __SHT30_DRIVER_H
#include "stm32f10x.h"
/**
* @brief 初始化 I2C1(PB6=SCL, PB7=SDA)
*/
void SHT30_Init(void);
/**
* @brief 读取温湿度
* @param temperature: 温度指针(℃)
* @param humidity: 湿度指针(%RH)
* @return 0=成功,-1=失败
*/
int SHT30_Read(float *temperature, float *humidity);
#endif /* __SHT30_DRIVER_H */
📄 创建文件:
Core/Src/sht30_driver.c
c
/**
* @file sht30_driver.c
* @brief SHT30 I2C 驱动实现
* @note SHT30 I2C 地址 0x44(7位),写入 0x88,读取 0x89
* 重复性高精度命令:0x2C 0x06
*/
#include "sht30_driver.h"
#include <math.h>
#define SHT30_ADDR_WRITE 0x88
#define SHT30_ADDR_READ 0x89
#define SHT30_CMD_HIGH 0x2C /* 高重复性 */
#define SHT30_CMD_MID 0x2C /* 中重复性 */
#define SHT30_MEAS_HIGH 0x06 /* 高精度,时钟拉伸使能 */
#define SHT30_MEAS_MID 0x0D /* 中精度 */
void SHT30_Init(void)
{
GPIO_InitTypeDef GPIO_InitStruct;
I2C_InitTypeDef I2C_InitStruct;
/* 开启 GPIOB 和 I2C1 时钟 */
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE);
/* PB6 = SCL, PB7 = SDA,配置为复用开漏输出 */
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_OD;
GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOB, &GPIO_InitStruct);
/* I2C1 配置:标准模式,400kHz */
I2C_InitStruct.I2C_Mode = I2C_Mode_I2C;
I2C_InitStruct.I2C_DutyCycle = I2C_DutyCycle_2;
I2C_InitStruct.I2C_OwnAddress1 = 0x00;
I2C_InitStruct.I2C_Ack = I2C_Ack_Enable;
I2C_InitStruct.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit;
I2C_InitStruct.I2C_ClockSpeed = 400000;
I2C_Init(I2C1, &I2C_InitStruct);
I2C_Cmd(I2C1, ENABLE);
}
/**
* @brief I2C 等待特定事件
*/
static uint8_t I2C_WaitEvent(uint32_t event)
{
uint32_t timeout = 10000;
while (!I2C_CheckEvent(I2C1, event)) {
if (--timeout == 0) return 1;
}
return 0;
}
int SHT30_Read(float *temperature, float *humidity)
{
uint8_t data[6];
uint16_t raw_temp, raw_humi;
uint32_t i;
/* 发送起始信号 + 写地址 */
I2C_GenerateSTART(I2C1, ENABLE);
if (I2C_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT)) return -1;
I2C_Send7bitAddress(I2C1, SHT30_ADDR_WRITE, I2C_Direction_Transmitter);
if (I2C_WaitEvent(I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)) return -1;
/* 发送测量命令:0x2C 0x06(高精度) */
I2C_SendData(I2C1, SHT30_CMD_HIGH);
if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED)) return -1;
I2C_SendData(I2C1, SHT30_MEAS_HIGH);
if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_TRANSMITTED)) return -1;
/* 等待测量完成(高精度模式约 15ms) */
for (i = 0; i < 20000; i++) { __NOP(); }
/* 重新起始 + 读地址 */
I2C_GenerateSTART(I2C1, ENABLE);
if (I2C_WaitEvent(I2C_EVENT_MASTER_MODE_SELECT)) return -1;
I2C_Send7bitAddress(I2C1, SHT30_ADDR_READ, I2C_Direction_Receiver);
if (I2C_WaitEvent(I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)) return -1;
/* 连续读取 6 字节(温度 2 + CRC 1 + 湿度 2 + CRC 1) */
for (i = 0; i < 5; i++) {
I2C_AcknowledgeConfig(I2C1, ENABLE);
if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_RECEIVED)) return -1;
data[i] = I2C_ReceiveData(I2C1);
}
/* 最后一个字节发送 NACK + STOP */
I2C_AcknowledgeConfig(I2C1, DISABLE);
if (I2C_WaitEvent(I2C_EVENT_MASTER_BYTE_RECEIVED)) return -1;
data[5] = I2C_ReceiveData(I2C1);
I2C_GenerateSTOP(I2C1, ENABLE);
/* 换算温度:-45 + 175 * raw / 65535 */
raw_temp = ((uint16_t)data[0] << 8) | data[1];
*temperature = -45.0f + 175.0f * (float)raw_temp / 65535.0f;
/* 换算湿度:100 * raw / 65535 */
raw_humi = ((uint16_t)data[3] << 8) | data[4];
*humidity = 100.0f * (float)raw_humi / 65535.0f;
return 0;
}
4.9 主程序
📄 创建文件:
Core/Src/main.c
c
/**
* @file main.c
* @brief 新风系统主控制器
* @note 主循环每 100ms 执行一次:
* 1. 读取传感器数据(按各自周期轮询)
* 2. 判断空气质量
* 3. PID 计算输出
* 4. 调节风机转速
* 5. 更新 OLED 显示
* 6. 串口上报
*/
#include "stm32f10x.h"
#include "system_config.h"
#include "adc_driver.h"
#include "pm25_sensor.h"
#include "co2_sensor.h"
#include "sht30_driver.h"
#include "fan_control.h"
#include "pid_controller.h"
/* ---- PID 控制器实例 ---- */
PID_t pm25_pid;
PID_t co2_pid;
/* ---- 系统运行状态 ---- */
typedef struct {
float pm25; /* 当前 PM2.5 值 */
float co2; /* 当前 CO2 值 */
float temperature; /* 室内温度 */
float humidity; /* 室内湿度 */
uint8_t fan_duty; /* 风机占空比 */
uint8_t mode; /* 运行模式:0=自动,1=手动 */
uint8_t target_level; /* 手动模式下的档位 1~5 */
uint32_t tick_100ms; /* 100ms 计时器 */
uint32_t tick_1s; /* 1s 计时器 */
} SystemState_t;
static SystemState_t sys_state;
/**
* @brief 初始化 SysTick 定时器,1ms 中断
*/
static void SysTick_Configuration(void)
{
/* SystemCoreClock = 72MHz,1ms = 72000 counts */
SysTick_Config(SystemCoreClock / 1000);
}
/**
* @brief SysTick 中断服务:每 1ms 调用
*/
void SysTick_Handler(void)
{
static uint16_t cnt_100ms = 0;
static uint16_t cnt_1s = 0;
cnt_100ms++;
cnt_1s++;
if (cnt_100ms >= 100) {
cnt_100ms = 0;
sys_state.tick_100ms++;
}
if (cnt_1s >= 1000) {
cnt_1s = 0;
sys_state.tick_1s++;
}
}
/**
* @brief 根据空气质量综合评分计算 PID 目标
* @note 将 PM2.5 和 CO2 综合为 0~100 的"污染指数"
*/
static float calc_pollution_index(void)
{
float pm25_score, co2_score;
/* PM2.5 归一化到 0~100 分 */
if (sys_state.pm25 <= PM25_TARGET) {
pm25_score = 0.0f;
} else if (sys_state.pm25 >= PM25_MAX) {
pm25_score = 100.0f;
} else {
pm25_score = (sys_state.pm25 - PM25_TARGET) * 100.0f /
(PM25_MAX - PM25_TARGET);
}
/* CO2 归一化到 0~100 分 */
if (sys_state.co2 <= CO2_TARGET) {
co2_score = 0.0f;
} else if (sys_state.co2 >= CO2_MAX) {
co2_score = 100.0f;
} else {
co2_score = (sys_state.co2 - CO2_TARGET) * 100.0f /
(CO2_MAX - CO2_TARGET);
}
/* 取两者中较高的值(任何一个超标都要加大风量) */
return (pm25_score > co2_score) ? pm25_score : co2_score;
}
/**
* @brief 自动模式下控制逻辑
*/
static void auto_mode_control(void)
{
float pollution;
float pid_output;
uint8_t fan_duty;
pollution = calc_pollution_index();
/*
* 污染指数就是 PID 的 setpoint(希望风机输出的占空比百分比)
* 污染指数 0 → 目标输出 0%,污染指数 100 → 目标输出 100%
*
* 但 PID 不能直接用污染指数当 setpoint,需要反过来:
* setpoint = 0(希望污染指数降到 0)
* input = pollution(当前污染指数)
* output → 风机占空比
*/
pid_output = PID_Calculate(&pm25_pid, pollution);
fan_duty = (uint8_t)pid_output;
Fan_SetSpeed(fan_duty);
sys_state.fan_duty = fan_duty;
}
/**
* @brief 手动模式:按键设置档位
* @param level: 1~5 档
*/
static void manual_mode_set_level(uint8_t level)
{
uint8_t duty_map[5] = {20, 40, 60, 80, 100};
if (level < 1) level = 1;
if (level > 5) level = 5;
sys_state.target_level = level;
Fan_SetSpeed(duty_map[level - 1]);
sys_state.fan_duty = duty_map[level - 1];
}
/**
* @brief 系统硬件初始化
*/
static void hardware_init(void)
{
/* SysTick 配置 */
SysTick_Configuration();
/* ADC 初始化 */
ADC_Init();
/* 传感器初始化 */
PM25_Init();
CO2_Init();
SHT30_Init();
/* 风机 PWM 初始化 */
Fan_Init();
/* PID 初始化 */
PID_Init(&pm25_pid, PID_KP, PID_KI, PID_KD,
0.0f, PID_OUTPUT_MIN, PID_OUTPUT_MAX, PID_INTEGRAL_LIMIT);
/* 初始化系统状态 */
sys_state.pm25 = 0.0f;
sys_state.co2 = 0.0f;
sys_state.temperature = 0.0f;
sys_state.humidity = 0.0f;
sys_state.fan_duty = 0;
sys_state.mode = 0; /* 默认自动模式 */
sys_state.target_level = 1;
sys_state.tick_100ms = 0;
sys_state.tick_1s = 0;
}
int main(void)
{
uint32_t last_pm25_tick = 0;
uint32_t last_co2_tick = 0;
uint32_t last_sht30_tick = 0;
uint32_t last_tick;
/* 硬件初始化 */
hardware_init();
/* 首次传感器读取(启动时就要有数据) */
sys_state.pm25 = PM25_Read();
sys_state.co2 = (float)CO2_Read();
SHT30_Read(&sys_state.temperature, &sys_state.humidity);
last_tick = sys_state.tick_100ms;
while (1) {
/* 等待下一个 100ms 周期 */
if (sys_state.tick_100ms == last_tick) {
continue;
}
last_tick = sys_state.tick_100ms;
/* ---- PM2.5 采样(每 280ms) ---- */
if (sys_state.tick_100ms - last_pm25_tick >= PM25_SAMPLE_PERIOD / 100) {
last_pm25_tick = sys_state.tick_100ms;
sys_state.pm25 = PM25_Read();
}
/* ---- CO2 采样(每 2s) ---- */
if (sys_state.tick_1s - last_co2_tick >= CO2_SAMPLE_PERIOD / 1000) {
last_co2_tick = sys_state.tick_1s;
sys_state.co2 = (float)CO2_Read();
}
/* ---- 温湿度采样(每 5s) ---- */
if (sys_state.tick_1s - last_sht30_tick >= SHT30_SAMPLE_PERIOD / 1000) {
last_sht30_tick = sys_state.tick_1s;
SHT30_Read(&sys_state.temperature, &sys_state.humidity);
}
/* ---- 风机控制 ---- */
if (sys_state.mode == 0) {
auto_mode_control();
}
/* 手动模式由按键中断处理,这里不做额外操作 */
/* ---- 每秒输出一次调试信息(通过串口 printf) ---- */
/* 此处省略 OLED 刷新和串口打印代码,
实际项目中根据 OLED 驱动实现添加 */
}
}
五、PID 参数调试
5.1 先理解参数各有什么用
PID 三个参数调得好,风机响应又快又稳;调不好,要么来回震荡,要么半天没反应。下面是我实际调试中观察到的现象:
Kp(比例系数)偏大:
- 风机转速变化剧烈,PM2.5 一有波动就猛加速
- 在目标值附近来回震荡,停不下来
Kp(比例系数)偏小:
- 响应太慢,PM2.5 已经超标很久了风机才慢慢加速
- 稳态偏差大,始终达不到目标值
Ki(积分系数):
- 消除稳态偏差。光靠 P 的话,系统会稳定在一个比目标值稍高的水平
- Ki 偏大会导致超调:风量过大,PM2.5 降到目标值以下才开始减速
Kd(微分系数):
- 抑制超调和震荡。当 PM2.5 下降速度很快时,提前减速
- Kd 偏大对噪声敏感,ADC 采样的波动会被放大
5.2 调参步骤(实际操作)
#mermaid-svg-4kQjGAgyEu8CrP4Y{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ffffff;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-4kQjGAgyEu8CrP4Y .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-4kQjGAgyEu8CrP4Y .error-icon{fill:#a44141;}#mermaid-svg-4kQjGAgyEu8CrP4Y .error-text{fill:#ddd;stroke:#ddd;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-4kQjGAgyEu8CrP4Y .marker{fill:#60a5fa;stroke:#60a5fa;}#mermaid-svg-4kQjGAgyEu8CrP4Y .marker.cross{stroke:#60a5fa;}#mermaid-svg-4kQjGAgyEu8CrP4Y svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-4kQjGAgyEu8CrP4Y p{margin:0;}#mermaid-svg-4kQjGAgyEu8CrP4Y .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#ffffff;}#mermaid-svg-4kQjGAgyEu8CrP4Y .cluster-label text{fill:#F9FFFE;}#mermaid-svg-4kQjGAgyEu8CrP4Y .cluster-label span{color:#F9FFFE;}#mermaid-svg-4kQjGAgyEu8CrP4Y .cluster-label span p{background-color:transparent;}#mermaid-svg-4kQjGAgyEu8CrP4Y .label text,#mermaid-svg-4kQjGAgyEu8CrP4Y span{fill:#ffffff;color:#ffffff;}#mermaid-svg-4kQjGAgyEu8CrP4Y .node rect,#mermaid-svg-4kQjGAgyEu8CrP4Y .node circle,#mermaid-svg-4kQjGAgyEu8CrP4Y .node ellipse,#mermaid-svg-4kQjGAgyEu8CrP4Y .node polygon,#mermaid-svg-4kQjGAgyEu8CrP4Y .node path{fill:#1e293b;stroke:#3b82f6;stroke-width:1px;}#mermaid-svg-4kQjGAgyEu8CrP4Y .rough-node .label text,#mermaid-svg-4kQjGAgyEu8CrP4Y .node .label text,#mermaid-svg-4kQjGAgyEu8CrP4Y .image-shape .label,#mermaid-svg-4kQjGAgyEu8CrP4Y .icon-shape .label{text-anchor:middle;}#mermaid-svg-4kQjGAgyEu8CrP4Y .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-4kQjGAgyEu8CrP4Y .rough-node .label,#mermaid-svg-4kQjGAgyEu8CrP4Y .node .label,#mermaid-svg-4kQjGAgyEu8CrP4Y .image-shape .label,#mermaid-svg-4kQjGAgyEu8CrP4Y .icon-shape .label{text-align:center;}#mermaid-svg-4kQjGAgyEu8CrP4Y .node.clickable{cursor:pointer;}#mermaid-svg-4kQjGAgyEu8CrP4Y .root .anchor path{fill:#60a5fa!important;stroke-width:0;stroke:#60a5fa;}#mermaid-svg-4kQjGAgyEu8CrP4Y .arrowheadPath{fill:lightgrey;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edgePath .path{stroke:#60a5fa;stroke-width:2.0px;}#mermaid-svg-4kQjGAgyEu8CrP4Y .flowchart-link{stroke:#60a5fa;fill:none;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edgeLabel{background-color:#1e293b;text-align:center;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edgeLabel p{background-color:#1e293b;}#mermaid-svg-4kQjGAgyEu8CrP4Y .edgeLabel rect{opacity:0.5;background-color:#1e293b;fill:#1e293b;}#mermaid-svg-4kQjGAgyEu8CrP4Y .labelBkg{background-color:rgba(30, 41, 59, 0.5);}#mermaid-svg-4kQjGAgyEu8CrP4Y .cluster rect{fill:#1e293b;stroke:#3b82f6;stroke-width:1px;}#mermaid-svg-4kQjGAgyEu8CrP4Y .cluster text{fill:#F9FFFE;}#mermaid-svg-4kQjGAgyEu8CrP4Y .cluster span{color:#F9FFFE;}#mermaid-svg-4kQjGAgyEu8CrP4Y div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:#1e293b;border:1px solid rgba(255, 255, 255, 0.25);border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-4kQjGAgyEu8CrP4Y .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#ffffff;}#mermaid-svg-4kQjGAgyEu8CrP4Y rect.text{fill:none;stroke-width:0;}#mermaid-svg-4kQjGAgyEu8CrP4Y .icon-shape,#mermaid-svg-4kQjGAgyEu8CrP4Y .image-shape{background-color:#1e293b;text-align:center;}#mermaid-svg-4kQjGAgyEu8CrP4Y .icon-shape p,#mermaid-svg-4kQjGAgyEu8CrP4Y .image-shape p{background-color:#1e293b;padding:2px;}#mermaid-svg-4kQjGAgyEu8CrP4Y .icon-shape .label rect,#mermaid-svg-4kQjGAgyEu8CrP4Y .image-shape .label rect{opacity:0.5;background-color:#1e293b;fill:#1e293b;}#mermaid-svg-4kQjGAgyEu8CrP4Y .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-4kQjGAgyEu8CrP4Y .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-4kQjGAgyEu8CrP4Y :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
先设 Ki=0, Kd=0
逐渐增大 Kp
直到出现小幅震荡
记录此时的 Kp 值(Kp_critical)
设 Kp = 0.6 * Kp_critical
逐渐增大 Ki
消除稳态偏差
如果出现超调
增大 Kd 抑制
响应满意?
调参完成
我实际调试的参数值是 Kp=2.5, Ki=0.08, Kd=0.5,室内环境下:
- PM2.5 从 80 降到 35(目标值),大约需要 3~5 分钟
- 稳态波动在 ±5μg/m³ 以内
- 没有明显的超调
不过每个环境不一样,我用的参数不一定适合你,建议按流程自己试。
六、测试与验证
6.1 分模块测试
不要一上来就跑整个系统,先逐个模块测通:
测试 1:ADC 读取
c
/* 在 main.c 中临时测试代码 */
ADC_Init();
while(1) {
uint16_t val = ADC_MultiSample(ADC_Channel_0, 16);
printf("ADC = %d\r\n", val);
/* 用万用表同时测 PA0 电压,对比是否一致 */
for(int i = 0; i < 1000000; i++) __NOP();
}
正常情况下,PA0 悬空应该读到 0~4095 之间的随机值(噪声)。接上 PM2.5 传感器后,应该在 1.5V 左右稳定。
测试 2:PM2.5 传感器
c
PM25_Init();
ADC_Init();
while(1) {
float pm25 = PM25_Read();
printf("PM2.5 = %.1f ug/m3\r\n", pm25);
for(int i = 0; i < 5000000; i++) __NOP();
}
在干净空气中读数应该在 10~30μg/m³。对着传感器吹一口烟,数值应该飙升到 100 以上,烟散去后慢慢回落。如果数值不变化,检查 LED 引脚接线和 ADC 引脚连接。
测试 3:CO2 传感器
c
CO2_Init();
while(1) {
uint16_t co2 = CO2_Read();
printf("CO2 = %d ppm\r\n", co2);
for(int i = 0; i < 5000000; i++) __NOP();
}
正常室内空气中 CO2 浓度在 400~800ppm。如果一直返回 9999,检查串口接线(TX/RX 是否接反)和波特率(MH-Z19C 默认 9600)。
测试 4:风机 PWM
c
Fan_Init();
while(1) {
Fan_SetSpeed(30); /* 30% 占空比 */
for(int i = 0; i < 10000000; i++) __NOP();
Fan_SetSpeed(60); /* 60% 占空比 */
for(int i = 0; i < 10000000; i++) __NOP();
Fan_SetSpeed(100); /* 100% 占空比 */
for(int i = 0; i < 10000000; i++) __NOP();
Fan_Stop();
for(int i = 0; i < 5000000; i++) __NOP();
}
风机应该有明显的三档风速变化。如果风机不转,用示波器测 PA8 是否有 PWM 波形,检查 MOS 管栅极驱动电路。
6.2 系统联调
各模块单独测通后,烧录完整的 main.c。观察以下内容:
- OLED 显示屏实时显示 PM2.5、CO2、温度、湿度、风机转速
- 在传感器附近点一根蚊香或蜡烛,观察 PM2.5 升高后风机是否自动加速
- 几分钟后 PM2.5 应该逐渐下降,风机随之减速
- 切换到手动模式,按键切换档位,风机应该立即响应
七、故障排查与问题解决
7.1 PM2.5 传感器读数为零或固定值
错误现象:
PM2.5 = 0.0 ug/m3(持续不变)
原因排查:
- LED 控制引脚(PA1)是否正确接线,LED 是否有脉冲信号(用示波器或万用表观察)
- ADC 引脚(PA0)是否悬空或接触不良
- 滤波电容(220μF)是否装反或容量不足
- 传感器 Vcc 供电是否为 5V(不是 3.3V)
解决方案:
- 用示波器检查 PA1 引脚是否有 0.32ms 的低电平脉冲
- 确认 ADC 采样延时(0.28ms)是否正确
- 试试调整
PM25_SENSITIVITY和PM25_VOLTAGE_CLEAN参数来校准
7.2 CO2 传感器返回 9999
原因分析:
- USART1 的 TX/RX 接反了(PA9 和 PA10 互换试试)
- 波特率不匹配(MH-Z19C 出厂默认 9600,但有些批次是 115200)
- MH-Z19C 需要预热 3 分钟,首次上电读数不准
解决方案:
c
/* 尝试切换波特率 */
USART_InitStruct.USART_BaudRate = 115200; /* 如果 9600 不行试试这个 */
/* 增加等待时间 */
if (CO2_WaitResponse(2000) != 0) { /* 超时从 500ms 增加到 2000ms */
return 9999;
}
7.3 风机不转或转速不稳定
原因排查:
- MOS 管没有完全导通(3.3V 逻辑电平驱动 IRF3205 栅极阈值不够)
- PWM 频率太高导致 MOS 管开关损耗过大,发热严重
- 缺少栅极下拉电阻,PWM 信号有毛刺
解决方案:
- 换用逻辑电平 MOS 管(IRLZ44N 或 IRLZ34N),栅极阈值 1~2V
- 降低 PWM 频率到 1~5kHz,减小开关损耗
- 在 MOS 管栅极加 10kΩ 下拉电阻
7.4 I2C 通信失败(SHT30 和 OLED)
常见原因:
- PB6(SCL)和 PB7(SDA)接反了
- 缺少上拉电阻(I2C 总线需要 4.7kΩ 上拉到 Vcc)
- I2C 地址错误(SHT30 是 0x44,OLED 是 0x3C)
调试方法:
- 用逻辑分析仪或示波器抓 I2C 总线波形
- 先单独测试 OLED(I2C 扫描器代码),确认总线正常
- 确认两个设备地址不同,不会冲突
7.5 PID 调参时风机剧烈震荡
现象:
风机转速来回快速变化,嗡嗡响,PM2.5 值波动剧烈。
原因:
- Kp 过大,系统不稳定
- ADC 采样噪声直接喂给了 PID
解决方案:
c
/* 方法 1:降低 Kp */
#define PID_KP 1.2f /* 从 2.5 降到 1.2 */
/* 方法 2:增大 ADC 平滑滤波 */
#define ADC_SAMPLE_COUNT 32 /* 从 16 增加到 32 */
/* 方法 3:增加 PID 输出变化限幅(在 PID_Calculate 中) */
float output_rate_limit = 5.0f; /* 每次最多变化 5% */
float new_output = p_term + i_term + d_term;
if (new_output - pid->output > output_rate_limit)
new_output = pid->output + output_rate_limit;
八、扩展方向
这个项目用了一块十几块钱的 STM32F103 最小系统板,加上几个传感器和一片 MOS 管,做出了一个能根据空气质量自动调速的新风控制器。调试过程中踩的坑主要集中在传感器时序和 PID 参数整定上。GP2Y1014AU0F 的 LED 脉冲时序要求比较严格,差几十微秒数据就不准;PID 调参没有捷径,只能一边观察风机响应一边慢慢调。
后续可以扩展的方向:
- 加一个 ESP8266 WiFi 模块,把数据推到手机或云平台
- 用 OLED 做一个菜单界面,支持目标值设定和历史数据查看
- 接入 HomeAssistant 或米家等智能家居平台
- 加上滤网寿命检测和更换提醒功能