STM32空气质量监测实战:串口解析传感器数据并驱动OLED显示 1. 项目概述与硬件准备先说结论Y01-3IN1这个模块加上一块0.96寸OLED屏幕用STM32F103C8T6就能搭出一个实时空气质量监测小站成本控制在40块钱以内。整条链路不复杂——传感器模块负责采集数据STM32通过串口把数据读回来解析出PM2.5、PM10、温湿度和TVOC再扔给OLED显示。做完这个小项目你对STM32的串口通信、中断接收、I2C外设驱动这些基本功都会有一个很扎实的掌握。这个项目最适合三类人一是准备做课程设计或毕业设计的电子类学生二是刚学完STM32基础想练手进阶的嵌入式爱好者三是想给家里或者工位做个桌面空气质量监测仪、但不想买成品的人。整个教程不会涉及太深的理论但我会把从硬件接线到代码实现的每一个环节都讲透尤其是那些容易踩坑的细节。硬件清单如下器件型号/规格说明主控STM32F103C8T6 最小系统板最常用的蓝色Pill板性价比高空气质量模块Y01-3IN1集成了粉尘、温湿度、TVOC三类传感显示屏0.96寸 OLEDSSD1306控制芯片I2C接口4针VCC/GND/SCL/SDAUSB转TTLCH340模块用来查看调试信息可选杜邦线公对母若干接线方便面包板830孔方便原型搭建Y01-3IN1模块需要额外说明一下。市面上这类“三合一”空气质量模块有好几个版本有的叫Y01-3IN1有的叫SGP30粉尘模块组合本质上都是把PM2.5/PM10颗粒物传感器、温湿度传感器、TVOC气体传感器集成在一块板子上通过一个串口输出所有数据。我用的这个模块粉尘部分基于激光散射原理可以测PM2.5和PM10温湿度部分是板载SHT系列芯片TVOC部分用的是金属氧化物半导体传感器能测出装修污染、烟雾等挥发性有机物的大致浓度。这类模块省去了自己焊接多个传感器再拼数据的麻烦非常适合快速原型验证。1.1 引脚分配与接线方案接线是整个项目里最容易出错的一步先看引脚定义。STM32F103C8T6的USART1默认在PA9TX和PA10RXI2C1的SCL和SDA在PB6和PB7。这里有个非常关键的细节Y01-3IN1模块的TXD要接STM32的RXRXD要接STM32的TX也就是交叉连接新手最容易在这一步接反。STM32引脚外设接Y01-3IN1接OLEDPA9 (USART1_TX)串口1RXD-PA10 (USART1_RX)串口1TXD-PB6 (I2C1_SCL)I2C1-SCLPB7 (I2C1_SDA)I2C1-SDA5V 或 3.3V电源VCCVCCGND地GNDGND供电这里我要多说一句。Y01-3IN1模块的粉尘传感器内部有一个小型风扇或激光发射管启动瞬间电流比正常工作时大不少。如果你用的是ST-Link或者板载调试器供电遇到模块屏幕一亮就复位、或者数据跳动很大的情况第一反应应该是量一下供电电压是否被拉低了。我实测下来用USB口经过稳压后的5V供电给模块STM32则用AMS1117出来的3.3V独立供电整条链路最稳定。OLED的VCC可以接3.3V因为SSD1306本身工作电压就是1.65V到3.3V接5V虽然不会立刻烧但长期运行有风险。1.2 开发环境与工程模板开发环境我推荐用Keil MDK 5稳定版配合STM32CubeMX生成初始化代码。我知道很多人还在纠结标准库和HAL库怎么选我的建议是新项目直接用HAL库原因有两点。第一ST官方已经停止标准库的技术支持新芯片都只有HAL库第二CubeMX生成的代码骨架非常规范能帮你少写很多外设初始化代码让你把精力集中在业务逻辑上。如果你手头只有标准库的工程模板后面我会把UART和I2C的初始化代码也贴出来照着改也能跑。2. Y01-3IN1串口协议拆解驱动任何传感器第一步不是写代码而是先把它的通信协议看懂。Y01-3IN1模块的串口参数是固定的波特率96008位数据位1位停止位无校验位。模块上电后会主动上报数据也可以由主机发指令查询。为了方便演示我采用主机主动查询的方式这样程序逻辑更可控OLED上的数据刷新节奏也好掌握。2.1 模块返回的原始帧分析模块返回的数据帧格式固定为9个字节结构如下字节偏移内容说明00xFF帧头10x01模块地址20x86指令3数据高位PM2.5浓度ug/m34数据低位PM2.5浓度ug/m35数据高位PM10浓度ug/m36数据低位PM10浓度ug/m37状态位保留通常为08校验和前7个字节累加和的低8位这个帧格式其实是很多空气检测模块通用的方案。数据都是大端模式也就是先高字节后低字节。比如PM2.5浓度是0x01 0xA5换算出来的十进制就是421代表当前PM2.5浓度是421微克每立方米。状态位这个字节在不同批次模块里含义不一样有的返回0有的返回传感器内部状态码我们日常使用可以忽略它。校验和的计算方法是把第0到第6字节的数字加起来取结果的低8位。比如前面几个字节是FF 01 86 01 A5 00 2B累加得到0xFF 0x01 0x86 0x01 0xA5 0x00 0x2B 0x261低8位就是0x61那第8字节就应该是0x61。主机查询指令是FF 01 86 00 00 00 00 00 79。注意最后两位的校验和前7字节累加是FF018600000000 0x186低8位是0x86但指令第8位却是0x79。这说明这类模块用的是自定义校验算法而非简单累加所以我强烈建议你只用文档提供的现成查询帧不要自己推导。这个教训我一开始也踩过后来看了芯片手册才发现模块内置了私有校验逻辑。2.2 解析逻辑的代码实现思路解析串口数据最忌讳的做法是“来一个字节读一个字节”。正确做法是把数据先收进一个缓冲区当收满9个字节后再统一进行校验和判断。我们先校验帧头0xFF、地址0x01、指令0x86再判断第8字节是否等于前7个字节累加和的低8位都通过了才提取数据。这样能最大程度避免因为电磁干扰导致的错误数据。串口接收方式上我推荐用中断接收而不是阻塞式等待因为主循环还要刷OLED阻塞等待会把整个系统卡死。用HAL库的做法是在初始化时调用HAL_UART_Receive_IT(huart1, rx_buffer, 1)让串口每次只接收单个字节并触发中断回调然后在回调函数里自己写一个状态机来拼接一个完整的9字节帧。虽然看起来多了一步但这是嵌入式串口数据接收最实用、最健壮的套路。3. CubeMX工程搭建与核心配置开始写代码之前先用CubeMX把底层的硬件初始化搞利索。用CubeMX的好处是引脚冲突、时钟树错误这些问题它会直接提示不会等到烧录了才发现。3.1 时钟树与调试接口配置打开CubeMX后芯片型号选STM32F103C8Tx。在SYS选项卡里Debug选项一定要选Serial Wire否则烧录一次后ST-Link就找不到芯片了。这一点是我见过出现频率最高的初学者问题烧录时提示“no stm32 target found”十有八九就是Debug配置没设对。时钟树配置选HSE外部晶振把主频拉到72MHz这是F103的极限频率。操作路径RCC → HSE选择Crystal/Ceramic Resonator然后在Clock Configuration页面把HCLK改成72。如果用的是内部RC时钟HSI也能跑但串口波特率的误差会大不少9600波特率下虽然还能容忍但不推荐。外部晶振才是稳定之选。3.2 USART1与I2C1参数配置UART配置参数如下参数值ModeAsynchronousBaud Rate9600Word Length8 BitsParityNoneStop Bits1USART1 中断开启NVIC里勾选 USART1 global interruptI2C配置比较简单Mode选I2C默认100KHz标准模式即可。SSD1306这颗OLED控制芯片本身对时序要求不苛刻100KHz和400KHz都能稳定工作。我们保持默认100K就好没必要为了快那么一点去冒险。NVIC设置里I2C1的全局中断也可以顺手开一下虽然用轮询方式驱动OLED时不太需要但后续如果改成中断方式SLAVE通信会省不少事。生成工程时Toolchain选MDK-ARMCode Generator里把“Generate peripheral initialization as a pair of .c/.h files”勾上这样每个外设单独一个文件可读性更好。另外在Advanced settings里把USART1和I2C1的初始化函数都放在main.c的初始化列表前面方便统筹查看。3.3 生成后的工程结构解读生成的工程里MX_GPIO_Init()会初始化引脚MX_USART1_UART_Init()配置串口MX_I2C1_Init()配置I2C。主循环里目前只有一个空的while(1)。我们的代码要添加的文件包括OLED驱动文件oled.c/oled.h、数据解析文件aq_data.c/aq_data.h。整体分层是底层外设CubeMX生成→ 硬件驱动层OLED、传感器模块→ 应用层主逻辑。4. 驱动代码编写与核心逻辑实现下面进入正题。代码部分我按照“OLED驱动、串口接收解析、主循环整合”三个模块逐个说明。4.1 OLED驱动移植与初始化流程SSD1306的OLED驱动网上的代码很多核心就是控制芯片的初始化序列和写入方式。我用的驱动文件结构大概是这样的oled.c里面主要包含以下接口OLED_Init()发送初始化命令序列OLED_Clear()把显存全部清零OLED_ShowString(uint8_t x, uint8_t y, char *str, uint8_t size)显示字符串OLED_ShowChinese(uint8_t x, uint8_t y, uint8_t no, uint8_t size)显示汉字关键点是OLED的写入方式。SSD1306内部有一块显存我们操作显存有两种思路一种是直接写数据到DDRAM另一种是在单片机内存里维护一块显存副本全部修改完成后一次性刷新到OLED。后者叫“双缓冲”好处是不会闪烁。我移植的驱动就用了128*8字节的显存数组每个像素对应一位修改后调用OLED_Refresh()把整个显存通过I2C批量写入。I2C写OLED寄存器的时序分为两步先发送控制字节0x00表示后续为命令0x40表示后续为数据。OLED的I2C地址默认为0x787位地址0x3C左移一位如果屏幕没反应先检查是不是买到了0x3D地址的版本。市面上90%的0.96寸OLED都是0x3C但确实存在0x3D的版本屏幕背面丝印上一般会标注。4.2 汉字取模方法与显示技巧OLED显示数字和英文很简单标准ASCII字库5x8或者8x16直接查表就行。但显示汉字就得单独处理“取模”这个环节。我用的取模软件是PCtoLCD2002这个老软件虽然界面朴素但功能完全够用。取模步骤打开PCtoLCD2002选择“字符模式”输入你要显示的汉字比如“空气质量”字体选宋体或黑体字号16x16在“选项”里设置取模方式阴码、逐行式、顺向、C51格式生成点阵数据复制到代码里取模格式里的几个术语值得解释一下。“阴码”指有像素的地方是1无像素是0“逐行式”指按从左到右、从上到下的顺序取“顺向”指高位在前“C51格式”指数据按照C语言的16进制数组格式输出0x00, 0x1F这样。一个16x16的汉字需要32个字节的数据。做空气质量显示界面时我建议第一行放“PM2.5: 012 ug/m3”第二行放“PM10: 034 ug/m3”第三行放“TEMP: 26.5 C”第四行放“TVOC: 0.12 mg/m3”。每行高度16像素4行刚好占满128x64的屏幕。这里提醒一句OLED屏幕在纯图形模式下像素点由多层结构组成驱动控制器负责把显存中的“1”映射为点亮、“0”映射为熄灭所以取模方向错了字显示出来就是镜像或者倒置的遇到这种情况不要怀疑屏幕坏了去检查取模设置。4.3 串口接收状态机的完整实现串口接收的核心代码我用HAL库的中断回调加状态机来写。思路是每收到一个字节就根据当前状态判断它是不是帧头、地址、指令……一步步填充缓冲数组。/* aq_data.c */ #define FRAME_SIZE 9 uint8_t uart_rx_byte 0; uint8_t rx_frame[FRAME_SIZE]; uint8_t frame_index 0; volatile uint8_t frame_ready 0; typedef enum { WAIT_HEAD 0, WAIT_ADDR, WAIT_CMD, WAIT_DATA, } parse_state_t; parse_state_t parse_state WAIT_HEAD; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { switch (parse_state) { case WAIT_HEAD: if (uart_rx_byte 0xFF) { rx_frame[0] uart_rx_byte; parse_state WAIT_ADDR; } break; case WAIT_ADDR: if (uart_rx_byte 0x01) { rx_frame[1] uart_rx_byte; parse_state WAIT_CMD; } else { parse_state WAIT_HEAD; // 地址不对重新等帧头 } break; case WAIT_CMD: if (uart_rx_byte 0x86) { rx_frame[2] uart_rx_byte; frame_index 3; parse_state WAIT_DATA; } else { parse_state WAIT_HEAD; } break; case WAIT_DATA: rx_frame[frame_index] uart_rx_byte; if (frame_index FRAME_SIZE) { frame_ready 1; parse_state WAIT_HEAD; } break; } HAL_UART_Receive_IT(huart1, uart_rx_byte, 1); } }这个状态机看起来很基础但非常可靠。它解决了一个经典难题串口数据流一旦中间丢了一个字节后面全乱。通过状态机逐字节校验帧头、地址、指令即使中间断流也能在下一帧重新同步。每次进入中断都会重新调用HAL_UART_Receive_IT保证串口始终处于接收状态。需要特别说明的是HAL库的HAL_UART_Receive_IT在接收完成一次后会自动关闭接收所以必须在回调里再次开启否则只会收到一帧数据就停住。很多初学者在这里卡住症状是程序烧进去屏幕正常但数值永远不变就是这个原因。4.4 数据校验与数值提取当frame_ready置1后在主循环里做一个校验通过之后才更新全局变量。校验函数如下/* aq_data.c */ uint8_t AQ_GetCheckSum(uint8_t *buf) { uint8_t sum 0; for (int i 0; i 7; i) { sum buf[i]; } return sum; } void AQ_ParseFrame(uint8_t *buf) { if (buf[0] ! 0xFF || buf[1] ! 0x01 || buf[2] ! 0x86) { return; } if (AQ_GetCheckSum(buf) ! buf[8]) { return; } pm25 (uint16_t)(buf[3] 8) | buf[4]; pm10 (uint16_t)(buf[5] 8) | buf[6]; }这个设计把“接收”和“解析”分开中断只负责收数据主循环负责校验和提取避免在中断里做太多运算。PM2.5和PM10的数据都是16位分别放在两个字节里所以要把高字节左移8位再和低字节按位或。有人喜欢用buf[3] * 256 buf[4]效果一样但移位写法更贴近硬件思维。4.5 主循环与显示刷新逻辑主循环的设计要把握一个原则不要每圈都发查询指令也不要每圈都刷OLED。传感器数据变化不会那么快过高频率的查询只会增加模块负担甚至导致串口数据混乱。我的做法是每2秒发一次查询指令收到有效数据后刷新一次OLED中间的时间主循环空转等待。如果连续5秒没收到有效数据OLED上显示“- -”表示离线。/* main.c while(1) 中 */ uint32_t last_query_ms 0; uint32_t last_refresh_ms 0; while (1) { if (HAL_GetTick() - last_query_ms 2000) { uint8_t cmd[] {0xFF, 0x01, 0x86, 0x00, 0x00, 0x00, 0x00, 0x00, 0x79}; HAL_UART_Transmit(huart1, cmd, 9, 100); last_query_ms HAL_GetTick(); } if (frame_ready) { AQ_ParseFrame(rx_frame); frame_ready 0; last_refresh_ms HAL_GetTick(); } if (HAL_GetTick() - last_refresh_ms 5000) { OLED_ShowString(8, 16, NO SIGNAL, 16); } }这里用HAL_GetTick()做非阻塞延时替代HAL_Delay()。为什么不用HAL_Delay()因为如果代码里任何一处用了阻塞延时串口中断照样能进但主循环在延时期间干不了别的活OLED刷新、按键检测这些都会被卡住。用时间戳的方式判断“是否到时间了”程序看起来是轮询实际上是变相的多任务调度这是嵌入式开发里特别基础又特别重要的思想。5. 常见问题与排查技巧实录项目做完了运行过程中总会遇到各种幺蛾子。我把实操中最常碰到的问题整理成了一张排查表按出现频率排序。现象可能原因解决方法烧录一次后ST-Link找不到芯片CubeMX里Debug模式没选Serial Wire按住开发板复位键再烧录重新配置Debug后再烧录OLED不亮或纯蓝屏I2C地址错误或接线松脱确认是0x3C还是0x3D重新插拔SCL/SDAOLED有背光但不显示内容初始化时序问题检查OLED_Init是否在I2C初始化之后调用串口收不到数据TX/RX接反或波特率错误交叉接线确认波特率9600数据偶尔清零或乱跳供电电压不稳定模块独立5V供电加100uF电解电容汉字显示成乱码取模方向设置错误重新取模用“阴码、逐行、顺向”显示的数字一直不变HAL_UART_Receive_IT没重新开启在回调函数末尾再次调用接收函数5.1 针对“no stm32 target found”的深入排查这个报错在网络热词里出现过也是我当年入坑时遇到的头号问题。它提示的信息很明确——调试器连不上芯片。原因通常有三个第一CubeMX生成工程时Debug没选Serial Wire导致烧录完成后PA13/PA14被配置成普通GPIO调试口被关掉了。解决办法按住开发板上的复位键不松开点击Keil里的Download按钮然后瞬间松开复位键这样能抢在程序启动前把调试器连上烧录一次修复后的固件就能永久解决。第二连接线接触不良。ST-Link的SWDIO和SWCLK线不要超过20厘米线越短越稳定。杜邦线氧化或者松动也会导致连接不上重新插拔可能就好了。第三芯片被低功耗模式锁死。如果程序里进了STOP模式调试口也会掉线处理方法和第一种一样按住复位抢烧。5.2 串口数据不对的现场排障思路如果OLED能点亮但显示的数值永远不变或者数值在0和实际值之间乱跳优先怀疑串口数据质量。我先用USB转TTL模块把Y01-3IN1的TXD直接接到电脑串口助手看模块本身有没有正常输出原始数据。这一步能快速定位问题在模块还是单片机是排障里最有效的手段。串口助手显示正常的原始帧之后再把USART1的TX通过USB转TTL连到电脑注意共地在代码里把主循环收到的原始帧头几个字节打印出来。如果打印出来是FF 01 86开头的说明连线没问题如果出现FF 00 86或者FF 01 00之类说明中间有字节丢失。这时候优先检查波特率误差和时钟配置。内部RC和外部晶振的误差在9600波特率下都不明显但如果你把波特率调高了晶振不准的问题就会被放大。还有一个坑是模块和STM32的GND没连上。TXD和RXD虽然接了但两个设备的地电位不一致串口通信就会丢字节甚至完全不通。手动接一根地线大多数奇怪问题都能解决。5.3 OLED显示异常的集中分析OLED屏幕如果只亮背光、没有内容绝大多数情况是初始化失败。初始化必须在I2C外设已经使能之后调用顺序反了就会收到NACK。另外I2C总线上拉了上拉电阻如果你的模块板上没有4.7K上拉需要自己外接。我们的OLED模块大多数都自带上拉但如果你自己飞线接传感器可能就需要手动补。我还遇到过一种情况OLED能显示但每隔几秒闪烁一次。这个不是屏幕问题而是显存刷新被串口中断频繁打断。因为OLED刷新函数内部有多条I2C写操作如果串口数据持续涌入会在刷新过程中反复抢占CPU导致刷新节奏不稳定。解决办法是合理设计刷新频率不要每收到一帧就刷一次屏提供一个“数据变化超过阈值才刷新”或者“固定500ms刷新一次”的策略闪烁问题基本能消除。6. 扩展思路从单机监测到多节点组网做完基础版之后很多朋友会问下一步能玩什么。其实这个项目的扩展空间非常大我简单说几个方向给有进阶需求的读者一个参考。第一个方向是加LCD显示换成TFT屏或者墨水屏。OLED的优点是亮度高、刷新快、功耗低缺点是尺寸小、信息量有限。如果你想把历史曲线画出来比如PM2.5过去24小时的变化趋势可以考虑换一块1.8寸TFT屏用SPI接口驱动刷新率更高显示内容更多。我在后续改进版里就换成了ST7735的TFT屏UI设计空间大了很多能同时显示波形和数字。第二个方向是增加存储功能。给STM32外挂一个SD卡模块或者SPI Flash芯片把每小时的数据记录下来配合RTC时钟芯片打上时间戳。这样你出门一天回来插上SD卡就能用电脑整理出一份空气质量日报。对室内空气质量检测这种需要长时间观测的场景这个功能特别实用。第三个方向是联网上报。Y01-3IN1把数据读出来之后通过ESP8266或ESP32的WiFi能力把数据POST到本地MQTT服务器或者第三方平台的物联网服务手机App实时查看。这个方案需要处理TCP协议栈但我强烈建议先从ESP8266的透传模式玩起——AT指令配好网络STM32只管把串口数据转发过去逻辑简单成功率极高。如果用的是ESP32甚至可以跳过STM32直接用ESP32的UART接模块MicroPython和Arduino框架都支持开发效率提升一个数量级。不过那就是另一个项目了本教程的核心——串口解析、数据校验、OLED驱动这些基本功到哪个平台都一样通用。第四个方向是设备联动这也是我最推荐的一个进阶玩法。空气质量数据不是光看就完了结合继电器模块当TVOC浓度超标时自动开启空气净化器当温湿度高于设定阈值时自动打开风扇搭配蜂鸣器检测到PM2.5爆表时发出报警提示。这些逻辑都是在现有代码基础上增加几个GPIO控制MCU内部增加几个阈值判断函数就行。我在实际改造中用STM32的一个串口接收传感器数据三个引脚分别控制风扇、加湿器、蜂鸣器整体成本不超过80元效果却很直观。7. 最后的实操心得与建议这个项目虽然入门但从零到最终稳定运行我前前后后调了一整天。回头复盘最值得记住的几点经验数据解析一定要分层。中断只负责收字节主循环负责校验界面只负责显示三个模块各干各的活。这样出了问题定位非常快串口没数据就查接收层有数据但不对就查解析层。一上来就想写一个函数串起所有功能调试的时候会很痛苦。模块数据不稳定的时候先怀疑供电再怀疑算法。我以前用模块的3.3V供电PM2.5数据跳动很厉害一度以为是通信干扰甚至去改代码滤波逻辑。后来用示波器量了电源轨发现纹波有200mV。换成独立5V供电之后数据稳如老狗。这也解释了为什么很多传感器模块的数据手册上要强调“电源质量直接影响测量精度”——物理层面的因素首先排除再碰代码。调试阶段可以多利用串口打印。OLED屏幕地方小不适合显示冗长的调试信息我习惯把所有原始帧通过USART2打印到串口助手。这样做的好处是能看到完整的数据流比如有没有丢字节、校验和不匹配的频率有多高这些信息比只看最终结果有用得多。项目稳定后再把调试打印关掉释放CPU资源。最后想说说关于芯片选型的一点体会。F103C8T6这颗芯片虽然老但生态极其成熟网上资料多到看不完对初学者非常友好。等哪天你发现它的Flash和RAM不够用了再往上跳到F407或者F103ZET6代码几乎不用改动太多这就是平台的积累优势。希望这篇教程能帮你把STM32传感器OLED这条最经典的嵌入式开发链路跑通后面再接触更复杂的项目你会发现很多套路都是相通的。