ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

基于STM32的桌面空气质量监测仪:PM2.5、甲醛实时显示全攻略

2026/9/7 22:37:57 拓冰建站 浏览量
基于STM32的桌面空气质量监测仪:PM2.5、甲醛实时显示全攻略 卧室窗户关了一整晚早上测出来的PM2.5数据经常比客厅还高夏天开空调门窗紧闭的时候甲醛更是容易悄悄超标。我之前一直想做个能摆在桌面上实时显示空气质量的小设备看了不少方案最后用了STM32F103C8T6加一块Y01-3IN1三合一空气质量模块再接一个0.96寸OLED屏把PM2.5、温湿度和甲醛浓度直接显示出来效果相当不错。这篇教程就是完整记录我从选型、接线、读协议、写代码到联调踩坑的全过程适合已经会点STM32基础、想做个真实有用的小项目的朋友参考。1. 开工前先盘点硬件选型与开发环境这些坑1.1 这套系统里每块板子都负责什么先说说整套系统的分工。STM32F103C8T6作为主控负责通过串口读取Y01-3IN1模块上传的数据帧解析出PM2.5、PM10、温度、湿度和甲醛浓度然后通过I2C接口驱动OLED屏幕把数据渲染出来。三块核心硬件各自的信息如下硬件型号/规格接口关键参数主控STM32F103C8T6蓝色Pill板UART/I2C/GPIO72MHz主频64KB Flash20KB RAM传感器Y01-3IN1三合一空气质量模块UART TTL输出PM2.5、PM10、温湿度、甲醛默认波特率9600显示屏0.96寸OLEDSSD1306控制器I2C128x64分辨率地址通常为0x3C提示Y01-3IN1这个“3IN1”在不同厂家手里含义略有差异有的是“PM2.5温湿度甲醛”有的是“PM2.5温湿度TVOC”。我手里这块是包含甲醛的版本如果你买到的模块功能组合不同数据帧结构和解析偏移位置也要相应调整这个后面会细说。1.2 环境搭建中的几个容易出问题的地方开发环境我用的还是Keil MDK5加STM32CubeMX。很多人觉得CubeMX生成的代码“太啰嗦”但在这个项目里它帮了大忙——串口DMA的初始化、I2C的时序参数、时钟树配置这些手写容易出错的地方CubeMX生成后基本不用动。环境相关注意点STM32CubeMX版本建议1.10以上固件包选F1系列最新的老版本固件包在生成I2C代码时可能缺少某些HAL回调函数的完整实现。ST-Link V2驱动要装好否则MDK的Download按钮一直灰色报错信息经常是“No ST-Link detected”。板载串口芯片如果是CH340Windows 10以上一般免驱但Windows 7需要手动装CH340驱动否则设备管理器里一直显示“USB-SERIAL CH340 (COMx)”旁边带黄色感叹号。连接下载器时注意ST-Link的SWDIO接PA13、SWCLK接PA14、GND必须共地3.3V一般由板子上的LDO自己提供不必额外接。另外提一句用CubeMX配置串口时我习惯把USART1的NVIC中断打开同时把DMA的RX通道设置为Circular模式这样后面做不定长数据接收会省很多事情。时钟树里HSE用外部8M晶振SYSCLK拉到72MHzAPB1分频后36MHz串口波特率计算才准确。2. Y01-3IN1的串口协议解读9600波特率背后的数据帧怎么拆2.1 模块接线和电平适配Y01-3IN1模块的接口一般是4针或5针VCC、GND、TXD、RXD部分版本还有RST。接线非常简单Y01-3IN1引脚STM32引脚说明VCC5V模块供电部分版本支持3.3V但5V更稳GNDGND必须和STM32共地TXDPA10USART1_RX模块发送数据给主控RXDPA9USART1_TX主控发送指令给模块查询模式下需要这里有个容易忽略的点很多Y01模块标注的UART电平是TTL但实际模块板载了电平转换可以直接和3.3V的STM32串口互联。不过如果你买的是裸传感器版本不带外围电路那就要看数据手册确认是不是需要加电平转换。我的经验是上电前先拿万用表量一下模块TXD引脚的空闲电平如果是3.3V左右就可以直连如果是5V就最好串个电阻分压免得把MCU的引脚打坏。2.2 数据帧结构解析Y01-3IN1模块上电后默认工作在主动上传模式大概每秒输出一组数据帧。我手头这块模块的帧格式如下字节偏移内容示例值说明0帧头10xAA固定1帧头20xC0固定2数据长度LEN0x0B从帧头到校验位前的总字节数3PM2.5高字节0x024PM2.5低字节0x34PM2.5 0x0234 564 ug/m³5PM10高字节0x026PM10低字节0x9APM10 0x029A 666 ug/m³7温度高字节0x018温度低字节0x2C温度 0x012C 300除以10 30.0°C9湿度高字节0x0110湿度低字节0x50湿度 0x0150 336除以10 33.6%RH11甲醛高字节0x0012甲醛低字节0x1E甲醛 0x001E 30除以100 0.30 mg/m³13校验和0xXX第0字节到第12字节累加和的低8位校验规则是所有前面字节的算术累加取低8位。比如帧头0xAA 0xC0 0x0B ... 加完以后取低字节等于最后一个字节的值。这个帧结构在不同批次的产品上可能略有不同有的版本没有PM10有的版本温湿度是单字节整数而不是两字节带小数所以我强烈建议拿到模块之后先用USB转TTL接电脑打开串口助手看几秒原始hex数据再对数据手册确认每个字节的含义。串口助手推荐SSCOM设置9600波特率、8数据位、1停止位、无校验就能看到类似上面的15字节数据帧。2.3 解析程序设计时的设计要点解析这类主动上传的串口数据帧我的建议是不要按“固定位置直接取字节”而是用状态机做帧同步。原因是串口数据在传输过程中可能丢字节、错位固定位置取值容易把上一帧残留的数据当成有效数据导致PM2.5值突然跳到几千。状态机思路简单说就是四个状态找帧头1、找帧头2、读长度和剩余数据、校验。代码骨架长这样typedef enum { FRAME_STATE_HEAD1, FRAME_STATE_HEAD2, FRAME_STATE_LEN, FRAME_STATE_DATA, FRAME_STATE_CHECK } FrameState; uint8_t rx_buf[64]; uint8_t rx_index 0; uint8_t frame_len 0; FrameState state FRAME_STATE_HEAD1; void ParseFrameByte(uint8_t data) { switch (state) { case FRAME_STATE_HEAD1: if (data 0xAA) { rx_buf[0] data; rx_index 1; state FRAME_STATE_HEAD2; } break; case FRAME_STATE_HEAD2: if (data 0xC0) { rx_buf[1] data; rx_index 2; state FRAME_STATE_LEN; } else { state FRAME_STATE_HEAD1; } break; case FRAME_STATE_LEN: rx_buf[2] data; frame_len data; rx_index 3; state FRAME_STATE_DATA; break; case FRAME_STATE_DATA: rx_buf[rx_index] data; if (rx_index frame_len) { state FRAME_STATE_CHECK; } break; case FRAME_STATE_CHECK: // 校验累加和通过后解析数据 if (CheckSum(rx_buf, frame_len - 1) data) { ParseData(rx_buf); } state FRAME_STATE_HEAD1; break; } }解析数据的时候注意PM2.5是16位无符号整数直接组合高字节和低字节温度和湿度是补码表示的有符号数温度如果出现负值比如0xF12C要先判断最高位是负数就取反加一再加负号甲醛浓度的小数点位数各厂家不统一常见的是除以100也有除以10的务必以手册为准。3. OLED显示这块点亮只是开始不闪烁才是关键3.1 I2C还是SPI地址确认的细节0.96寸OLED屏有两种常见接口I2C和SPI。我在这个项目里选了I2C版本理由很简单只占用两根线而且SSD1306的控制逻辑在I2C模式下更清晰代码量也更少。接线OLED引脚STM32引脚VCC3.3VGNDGNDSCLPB6I2C1_SCLSDAPB7I2C1_SDAI2C地址是新手最容易踩的坑。SSD1306的7位地址默认是0x3C但有些屏幕背面有一个0x3D/0x3C的电阻选择焊盘改焊之后地址会变成0x3D。判断方法有两种一是看屏幕背面的丝印标注二是写一个I2C扫描程序循环发送地址能收到ACK的就是正确地址。我刚开始用0x3C死活点不亮后来扫描发现屏幕实际地址是0x3D排查了半天居然是地址问题。I2C速率配置在CubeMX里默认100KHz这个速度够用。如果要调到400KHz快速模式也没问题但注意SCL和SDA的上拉电阻不能省很多模块板上已经自带如果不是板载的就要外接4.7K到3.3V。3.2 汉字取模与显示实现SSD1306本身不带字库显示字符需要自己准备点阵数据。ASCII字符可以用自带8x16或者6x12的小字库但显示“温度”“湿度”“甲醛”这些汉字就必须用取模工具生成16x16点阵。推荐工具PCtoLCD2002配置要点点阵格式选择“阴码”也就是1表示点亮、0表示熄灭OLED写显存时对应方式简单。取模走向选择“逐行式”每行两个字节左侧高位。每行显示数、字节数这些参数不用管生成的是模板最终按16字节一组送显存。生成后得到的汉字点阵是一串十六进制数组比如“温”字差不多是这个风格const uint8_t han_wen[32] { 0x04, 0x04, 0x04, 0x44, 0xC4, 0x44, 0x44, 0x44, 0x44, 0x54, 0xCE, 0x44, 0x04, 0x00, 0x00, 0x00, 0x20, 0x22, 0x22, 0x22, 0x3E, 0x22, 0x22, 0x42, 0x82, 0x02, 0x02, 0x02, 0x02, 0x00, 0x00, 0x00 };显示一个16x16汉字的核心操作是向显存对应区域逐字节填充关键函数如下void OLED_ShowCHinese(uint8_t x, uint8_t y, const uint8_t *font) { // 上半部分8行 for (uint8_t i 0; i 8; i) { OLED_SetPos(x, y i); // 设置页地址和列地址 OLED_WriteData(font[i]); // 左半字节 OLED_WriteData(font[i 16]); // 右半字节 } // 下半部分8行 for (uint8_t i 0; i 8; i) { OLED_SetPos(x, y 8 i); OLED_WriteData(font[i 8]); OLED_WriteData(font[i 24]); } }注意SSD1306的显存是按页组织的一页8个像素行128列正好128字节。16x16汉字跨两页所以要先写上半页的16字节再写下半页的16字节顺序错乱显示就会花屏。3.3 如何做到不闪烁的实时刷新这是OLED显示里最有技术含量的一件事。新手最容易犯的错误是每秒钟把整屏Clear一遍再全部重新画结果屏幕肉眼可见地闪尤其在显示数值变化的时候整个屏会“抖”一下。正确的做法是局部更新分区刷新。OLED屏只有需要变化的区域才重写不变的标签和边框完全不碰。我的布局方案是这样的区域内容刷新频率左上角“PM2.5”标签不刷新左上角右侧PM2.5数值每秒刷新左下角“温度”和“湿度”每秒或每两秒刷新右下角“甲醛”数值每秒刷新状态栏采集点数/时间按需刷新局部更新在代码上的体现是把ClearScreen只在上电初始化时调用一次之后每次更新数据只往数值所在的坐标写内容。比如// 每秒执行一次 void OLED_RefreshData(EnvData_t *env) { char buf[16]; // 只更新PM2.5数值区域坐标 (32, 0) snprintf(buf, sizeof(buf), %4d, env-pm25); OLED_ShowString(32, 0, buf, FONT_8X16); // 只更新温度区域 snprintf(buf, sizeof(buf), %d.%d C, env-temp_int, env-temp_dec); OLED_ShowString(40, 3, buf, FONT_8X16); // 湿度 snprintf(buf, sizeof(buf), %d.%d %%, env-humi_int, env-humi_dec); OLED_ShowString(40, 5, buf, FONT_8X16); // 甲醛 snprintf(buf, sizeof(buf), %d.%02d mg/m3, env-hcho_int, env-hcho_dec); OLED_ShowString(40, 7, buf, FONT_8X16); }数值位数变化的时候比如从“999”变成“1000”数字宽度变了旧值的尾巴会有残影。解决办法是格式化字符串时用定宽字符比如%4d代表至少4位不足补空格这样每次覆盖的宽度一致残影问题就没了。另外OLED写入I2C总线的速度比较慢整屏写一次大概要几十毫秒。如果刷得太频繁串口解析和OLED写显存会互相抢CPU时间。我把数据显示周期定为1秒每秒从环形缓冲区里取最新一组数据刷新一次屏幕这样就完全够用CPU还有大量余量处理其他任务。4. 主控代码这样组织串口DMA解析和UI刷新各司其职4.1 整个程序的初始化流程代码整体结构并不复杂初始化部分在CubeMX生成的main函数里完成。顺序是初始化HAL库和时钟树CubeMX自动生成。初始化I2C1配置为100KHz标准模式。初始化USART1波特率96008N1使能空闲中断和DMA接收。OLED初始化清屏画好固定边框和标签文字。进入while主循环轮询处理数据。我在实际项目里用了一个简单的标志位机制串口DMA每收到一帧数据通过串口空闲中断判断帧结束就把标志位设为1主循环检测到标志位后调用解析函数解析完清零。这样避免在中断服务函数里做耗时操作也避免解析和接收互相干扰。4.2 串口DMA接收的具体实现Y01-3IN1是每秒钟主动发一帧帧最长15字节属于典型的小包低频数据。用DMA加空闲中断是最优雅的解法比逐字节进中断再判断效率高得多。CubeMX里配置USART1的DMA接收DMA Settings里Add一个USART1_RX通道Mode选Circular。然后在main函数里启动接收uint8_t uart1_rx_buf[128]; HAL_UART_Receive_DMA(huart1, uart1_rx_buf, sizeof(uart1_rx_buf));空闲中断的回调需要自己实现void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart huart1) { // Size表示本次空闲时收到的字节数 // 把这Size个字节交给解析状态机处理 for (uint16_t i 0; i Size; i) { ParseFrameByte(uart1_rx_buf[i]); } // 重新启动DMA接收 HAL_UART_Receive_DMA(huart1, uart1_rx_buf, sizeof(uart1_rx_buf)); } }注意HAL_UARTEx_RxEventCallback和HAL_UART_RxCpltCallback是两个不同的回调函数。只有使能了空闲中断并且调用HAL_UARTEx_ReceiveToIdle_DMA或HAL_UART_Receive_DMA后触发空闲事件时才会走RxEventCallback别搞混了。用HAL_UART_Receive_DMA启动接收时如果使能了UART_IT_IDLE空闲事件发生时Size参数就是当前收到的字节数。这里有个小坑如果DMA是Circular模式它会在缓冲区里循环写入。如果两帧数据间隔时间很短第二帧的字节覆盖了第一帧还没被处理的部分数据就会错乱。解决办法是把接收缓冲区开大一点比如128字节而实际一帧最长才15字节余量充足基本不会出现覆盖问题。4.3 解析结果怎么传给显示层解析函数拿到原始帧后把各物理量提取出来放到一个全局结构体里typedef struct { uint16_t pm25; uint16_t pm10; int16_t temp; // 实际温度 temp / 10.0 uint16_t humi; // 实际湿度 humi / 10.0 uint16_t hcho; // 实际甲醛 hcho / 100.0 uint8_t valid; // 校验通过标志 } EnvData_t; EnvData_t g_env;主循环非常简单while (1) { if (g_frame_ready) { g_frame_ready 0; OLED_RefreshData(g_env); } }实际项目里我还在这个循环里加了一个LED指示灯每收到一帧有效数据就翻转一次这样不用连屏幕也能判断模块工作状态。调试的时候特别有用推荐你也试试。5. 联调实测现场那些最容易卡住的问题和排查链路5.1 串口乱码先怀疑波特率再怀疑共地第一次上电我在串口调试助手看到的是满屏乱码。排查链路如下检查波特率设置是否和模块一致。Y01-3IN1默认9600但如果模块是二手的或者被之前的主人改过配置可能已经变成4800或者115200。我试过用1200到115200的每个整波特率扫一遍在某个特定波特率下数据帧能对齐基本就能确认模块实际速率。检查电平是否匹配。如果模块TXD输出的是5V电平STM32的RX引脚能承受5V吗?F1系列引脚标注的是5V容忍但长期接还是有风险。最好用示波器或者万用表确认空闲电平。检查GND是否共地。STM32和模块各自的电源来自不同的USB口时GND之间可能有电位差串口信号就会乱。解决方法是把两个板子的GND用杜邦线连起来。这个原因占了乱码问题的大概一半。5.2 帧错位校验和成了唯一的救命稻草串口不乱了但显示出来的PM2.5数值偶尔跳到2000多然后秒回正常值。这是典型的帧错位问题解析状态机在帧中间开始接收把数据字节当成了帧头。我的解决方法是加了两层防护状态机的帧头检测必须是连续两个字节0xAA 0xC0同时匹配光一个0xAA不能进数据态。校验和这一步必须严格比对如果不通过就整个帧丢掉回到找帧头状态。这两层防护加上以后数据跳变的情况基本消失了。即便偶尔收到错帧校验不过直接丢弃屏幕上最多显示上一次的有效值不会出现极端跳变的数字。另一个容易忽略的是如果模块上电瞬间输出的是半帧数据状态机可能滞留在DATA状态。可以加一个超时机制如果超过500ms没收到完整帧强制重置状态机到找帧头状态。5.3 OLED点不亮地址、初始化序列、对比度三件事OLED点不亮是另一个高频问题我的排查顺序是现象可能原因排查方法屏幕完全不亮I2C扫描没ACK接线错误/地址不对万用表量OLED的VCC和GND确认SCL/SDA没接反跑I2C扫描程序确认地址I2C有ACK但屏幕没内容初始化序列不完整确认用的是SSD1306标准初始化序列或者刷SH1106的屏用了SSD1306的序列两者命令集有差异屏幕亮了但非常暗对比度设置太低发送0x81命令后跟一个0xCF对比度值调到最大就是0xFF显示内容有残影或重影刷新方式问题检查页地址设置确认写显存前SetPos的坐标是否正确特别注意1.3寸的OLED很多用的是SH1106控制器虽然和SSD1306高度兼容但显存是132列而不是128列直接套SSD1306的驱动会出现显示偏移一列或者边缘花屏的情况。使用前先确认屏幕控制芯片型号这个信息一般会在商品详情页或者屏幕背面标注。5.4 数据合理性检查别让传感器极端值骗了你模块输出值也不是永远靠谱。有一次我把模块放在刚喷过酒精的桌面上测试甲醛数值飙到1.2 mg/m³明显是传感器被酒精干扰了。这种极端值不是程序bug但会影响UI显示的可信度。我在解析后加了一个数值上限判断PM2.5超过1000、甲醛超过2.0时认为是异常或干扰显示“---”而不是打印一个吓人的数字。这样设备既保留了原始数据的真实性又避免了用户看到假报警。6. 跑通之后还能再做点什么联网、报警与联动控制6.1 把数据存下来外挂Flash或SD卡做历史趋势OLED屏只能显示当前值看不到历史变化趋势。如果你想分析“今天下午3点室内PM2.5为什么突然升高”就需要把数据存下来。两个方案外挂一个W25Q64 SPI Flash每次采样后追加写容量8MB能存几十万条记录。用SD卡通过FATFS文件系统保存成CSV文件直接插电脑上用Excel分析。我推荐先用W25Q64原因是SPI接口比SDIO简单得多调试成本低。存的时候按固定格式时间戳用RTC 五项数据读取的时候按时间范围过滤。6.2 加报警功能蜂鸣器加继电器超标自动处理空气质量数据的价值在于发现异常的时候能提醒人或者自动处理。我后来又加了两个外设有源蜂鸣器接到PA0当PM2.5连续3次超过75 ug/m³时启动报警蜂鸣器一秒响一次。继电器模块接到PA1控制一个USB风扇。当甲醛超过0.1 mg/m³时自动打开风扇通风降到0.08以下再关闭。连续3次判断是为了防止瞬时尖峰触发误报警这个阈值和次数可以根据实际环境调整。实现方式是在解析成功的地方维护一个计数器和标志位不涉及中断主循环顺序判断就行。6.3 联云ESP8266透传和状态上报如果你想要手机远程看数据给STM32加个ESP8266模块走AT指令透传是比较快的路子。ESP8266用UART2和STM32通信波特率115200通过AT指令连上家里的WiFi然后以MQTT或HTTP POST的方式把数据发到云端。我在这个项目里是往巴法云上推的数据格式是简单的POST请求上报JSON// 伪代码示意 snprintf(msg, sizeof(msg), {\pm25\:%d,\temp\:%.1f,\humi\:%.1f,\hcho\:%.2f}, env-pm25, env-temp / 10.0, env-humi / 10.0, env-hcho / 100.0); ESP8266_SendPOST(api.bemfa.com, /api/device/xxx/, msg);注意ESP8266模块和Y01模块不要接在同一个UART上否则两个外设的接收缓冲区会互相干扰。串口1给Y01串口2给ESP8266两个任务用标志位错开执行实测完全没有冲突。6.4 功耗优化和部署细节最后说点部署时的经验。这套系统如果不做功耗处理整机工作电流在60mA左右用USB供电完全没问题。但如果想用锂电池供电Y01模块本身是最大的耗电源激光颗粒物传感器的风扇和激光管是耗电大户。做低功耗版本可以考虑给OLED加一个定时休眠逻辑比如30秒无操作就关屏按键唤醒。Y01模块的测量周期尽量拉长从1秒改成10秒一次单次测量功耗不变但平均电流大幅下降。STM32在两次测量之间进入Stop模式用RTC定时唤醒。不过说实话对于桌面级空气质量监测仪USB供电是最省事的方案功耗优化只在你做便携版或者产品化的时候才需要动。代码整体跑通之后我最大的感受是这类传感器模块的难度不在硬件连接而在对通信协议的敬畏。拿到任何新模块第一件事永远是去拿串口助手看原始数据流对着手册做字节级别的核对千万别想当然。我甚至建议你准备一个小本子把每一帧样本数据的hex值和手册上的偏移位一一对上确认每个字节的含义后再写解析代码。这个习惯帮我避免了好几次“显示数据看起来对但实际解析错了字段”的低级错误。如果你准备自己动手做一套我建议的顺序是先点亮OLED再读串口原始数据再写解析状态机最后联调显示。每一步都在前一板块确认无误的基础上进行遇到问题也容易定位。祝你在做项目的时候少踩坑一次点亮。