STM32实战:基于STM32F103的家用新风系统智能控制器(空气质量监测+PID调速)

文章目录

    • 一、前言
      • [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 关键电路说明)
    • 四、软件开发
      • [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。观察以下内容:

  1. OLED 显示屏实时显示 PM2.5、CO2、温度、湿度、风机转速
  2. 在传感器附近点一根蚊香或蜡烛,观察 PM2.5 升高后风机是否自动加速
  3. 几分钟后 PM2.5 应该逐渐下降,风机随之减速
  4. 切换到手动模式,按键切换档位,风机应该立即响应

七、故障排查与问题解决

7.1 PM2.5 传感器读数为零或固定值

错误现象:

复制代码
PM2.5 = 0.0 ug/m3(持续不变)

原因排查:

  1. LED 控制引脚(PA1)是否正确接线,LED 是否有脉冲信号(用示波器或万用表观察)
  2. ADC 引脚(PA0)是否悬空或接触不良
  3. 滤波电容(220μF)是否装反或容量不足
  4. 传感器 Vcc 供电是否为 5V(不是 3.3V)

解决方案:

  • 用示波器检查 PA1 引脚是否有 0.32ms 的低电平脉冲
  • 确认 ADC 采样延时(0.28ms)是否正确
  • 试试调整 PM25_SENSITIVITYPM25_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 或米家等智能家居平台
  • 加上滤网寿命检测和更换提醒功能
相关推荐
LCG元2 小时前
STM32实战:基于STM32F103的多通道工业数据采集与监控系统(Modbus RTU+上位机)
stm32·单片机·嵌入式硬件
资深流水灯工程师2 小时前
STM32 单片机 USB 通讯原理与 HAL 库实战详解
stm32·单片机·嵌入式硬件
资深流水灯工程师2 小时前
STM32 I2C 通讯原理与三种实现模式详解
stm32·单片机·嵌入式硬件
zlinear数据采集卡2 小时前
电源纹波杀手:LDO线性稳压电路的“降噪哲学”——基于ZLinear数据采集卡的深度解析
单片机·嵌入式硬件·fpga开发·硬件架构
资深流水灯工程师2 小时前
STM32 USART 通讯原理与三种模式详解
stm32·单片机·嵌入式硬件
资深流水灯工程师2 小时前
STM32 单片机 SPI 通讯原理详解
stm32·单片机·嵌入式硬件
EMTime3 小时前
玲珑GUI-工程设置
单片机·mcu·ui·用户界面
不做无法实现的梦~3 小时前
MAVLink 协议教程
linux·stm32·嵌入式硬件·算法
QiLinkOS3 小时前
【用呼吸重构创造价值关系——QiLink生态】
c语言·数据结构·c++·人工智能·单片机·嵌入式硬件·算法