STM32驱动Y01-3IN1空气质量模块:串口协议解析与OLED显示实战

发布时间:2026/9/5 14:48:21
STM32驱动Y01-3IN1空气质量模块:串口协议解析与OLED显示实战 拿到这块Y01-3IN1三合一空气质量模块那天我先是愣了一下。包装上没写什么协议淘宝详情页也只给了个“串口输出”的模糊说法模块背面印着几个跳线帽和一堆引脚看起来像是要做选择题。等翻到手册里面那页“主动上报模式”的数据帧表我才反应过来——这是个典型的UART协议传感器而且数据帧结构比DHT11那种单总线复杂得多涉及多字节拼接、校验和计算、正负温度处理这些细节。用STM32驱动它再配一块OLED屏做实时显示刚好是一套能打通“数据采集→协议解析→人机界面”全流程的组合特别适合想从点灯进阶到真实传感器项目的朋友。这篇文章我会按自己从零调通的完整过程来写先讲清楚Y01-3IN1模块的通信协议和内部构成再给硬件接线和STM32CubeMX的初始化配置重点放在串口数据帧解析的逻辑实现上然后是OLED显示驱动的核心代码和界面布局思路最后把调试过程中遇到的数据跳变、波特率偏差、显示乱码这些坑一并拿出来说。不管是刚学会CubeMX的新手还是想快速抄一份能跑的工程的老手都能在这篇文章里找到直接能用的东西。1. 先搞明白Y01-3IN1在输出什么数据帧格式与模块工作原理这模块名字里的“3IN1”不是随便叫的它把颗粒物浓度检测、温湿度测量、挥发性有机物TVOC或二氧化碳估算这三类功能集成到了一块板子上。市面上流行的版本一般用激光散射原理测PM2.5/PM10用SHT系列芯片测温湿度再搭配一个金属氧化物半导体传感器来做TVOC/CO2的等效估算。三个传感器共用一颗MCU做数据融合最终通过UART统一对外输出结构化的数据帧。1.1 为什么这类模块偏爱串口输出而不是I2C或单总线经常有人问直接把三颗传感器挂到I2C总线上让STM32分别读取多好为什么要绕一道MCU再转串口核心原因有两个。第一是品牌商要保护传感器校准数据。激光粉尘传感器的出厂校准系数、温度补偿曲线都以表格形式存在模块内置的Flash里这些数据由模块内部的MCU在每次上电时加载用来修正原始计数器值。如果直接把传感器裸片暴露给用户校准数据很容易被读走或者误改。通过串口输出已经修正过的最终结果既保护了知识产权也让二次开发者的工作量降到最低。第二是时序解耦。激光散射传感器内部有一个风扇或者气泵采样过程需要持续几百毫秒期间要控制激光器开关、读取光电二极管的脉冲信号这些实时性要求高的活儿交给模块内部MCU更稳妥。STM32这边只需要按照固定周期接收数据即可两边各干各的互不干扰。1.2 主动上报模式的数据帧怎么读这类模块通常支持两种工作模式主动上报和查询应答。主动上报就是模块通电后每隔固定时间一般是1秒自动向串口丢一帧数据查询应答则是主机发一帧命令模块回一帧数据。默认出厂设置多数是主动上报这也是最省事的模式——STM32不需要发送任何命令只管在串口中断里收数据就行。一帧完整数据通常长这样不同批次模块可能略有差异具体以你手头模块的手册为准字段长度字节说明帧头2固定值常见为0xAA 0xC0用于识别一帧开始数据长度2表示后面数据段的字节数通常包含颗粒物、温湿度、TVOC等字段颗粒物数据6按PM1.0、PM2.5、PM10顺序排列每个参数占2字节单位μg/m³温湿度数据4温度2字节、湿度2字节通常需要除以10得到实际值TVOC/CO2数据4TVOC 2字节、CO2 2字节单位ppb/ppm部分模块只输出其中一项状态字段1模块自检状态用于判断激光器、传感器是否正常校验和1或2从帧头到状态字段所有字节的累加和取低8位或16位帧尾2固定值常见为0xAA 0xF0用于辅助判断一帧结束读这种帧结构有一个核心技巧不要依赖帧尾来判断一帧数据是否完整应该以帧头加长度字段为准。因为串口是字节流你不知道模块是在什么时候上电的可能程序刚开始运行时串口缓冲区的第一个字节并不是帧头而是上一帧中间的数据。如果程序不加状态判断直接硬套解析逻辑出来的数据就全是乱的。1.3 校验和到底怎么算Y01-3IN1这类模块的校验算法很朴素就是累加和校验SUM Checksum。计算范围为从帧头的第一个字节开始一直到状态字段结束把所有字节加起来取结果的低8位或某几位然后与帧尾前紧挨着的校验字节比较。举个例子假设一帧数据是帧头AA C0长度00 1C数据段部分00 1E 00 2A 00 3C ...状态00校验字节??帧尾AA F0那么校验字节的计算方式是把AA C0 00 1C ... 00这些字节全部累加得到一个32位或16位的数只保留最低8位填入校验字节的位置。收到数据后STM32做同样的事情比对两边结果是否一致。这里有一个容易踩坑的地方很多模块的“长度”字段不是常数。比如某帧数据带了更多附加信息长度字段就变大某些状态下模块会附加错误码字段长度也可能变化。所以解析程序不能写死“固定读N个字节”必须先把长度字段提取出来再根据长度去决定后面要读多少字节。很多新手程序跑飞问题就出在这里——他把缓冲区设计成了固定大小赶上模块偶尔多输出两个字节后面的所有数据就全部错位了。2. 硬件接线与CubeMX配置看似简单但容易翻车的环节STM32F103C8T6也就是大家常说的“蓝丸”搭配Y01-3IN1模块和0.96寸OLED是这套方案最常见的组合。接线不复杂但有几个细节处理不好后面调试会让你怀疑人生。2.1 引脚分配与电平匹配Y01-3IN1模块的串口常见电平是3.3V与STM32的IO电平完美匹配可以直连。如果是5V版本就必须用电平转换电路否则STM32的串口接收引脚长期被5V电平灌入轻则数据乱码重则烧毁引脚。接线方案建议这样模块引脚STM32引脚说明VCC3.3V部分模块支持5V供电但建议先查手册GNDGND共地必须接否则串口电平没有参考点TXDPA10USART1_RX模块发送STM32接收交叉连接RXDPA9USART1_TXSTM32发送模块接收本场景可暂不接SCLOLEDPB8I2C1_SCL如果OLED是I2C接口SDAOLEDPB9I2C1_SDA如果OLED是I2C接口这里必须多说一句模块上的TXD引脚一定要接STM32的RX引脚。这个看起来是常识但我在实际帮人看代码的时候至少三次发现他们把两根线接反了模块TXD接STM32的TX结果收了一下午乱码。串口交叉连接这件事最好在焊线之前就在纸上画一遍。还有一点Y01-3IN1模块板载了上拉电阻所以它的TXD在空闲状态下是高电平这是UART的正常特性。如果你用示波器或者逻辑分析仪去测TXD引脚上电后应该能看到一条约3.3V的高电平线然后每秒出现一次数据脉冲这代表模块正在主动上报。如果TXD一直低电平说明模块没正常工作先查供电和模块上的使能引脚。2.2 CubeMX里串口和I2C参数的设置细节用STM32CubeMX初始化工程串口1和I2C1的配置并不复杂关键参数如下USART1异步模式Asynchronous波特率9600数据位8停止位1无校验。不要选偶校验虽然部分模块支持但默认都是无校验。I2C1标准模式Standard Mode或快速模式Fast Mode均可SSD1306屏幕常规选400kHz以下都没问题。I2C地址注意0.96寸OLED常见地址是0x787位地址是0x3C个别屏是0x7A7位地址0x3D驱动前先用I2C扫描程序确认一次免得花半小时排查“为什么屏幕不亮”。波特率这里要多说一句。Y01-3IN1模块出厂默认常见波特率是9600但也有部分批次是115200。如果你发现收到的数据全部是乱码别急着怀疑代码先用逻辑分析仪或者USB转TTL模块在电脑上直接听一下模块的输出确认真实波特率再继续。我见过有朋友拿着115200的模块代码里配9600折腾了整整一个晚上。2.3 I2C硬件模式还是软件模拟怎么选OLED驱动有两种实现方式使用STM32的硬件I2C外设或者用GPIO软件模拟I2C时序。网上的教程里软件模拟因为不受引脚重映射限制、时序可控性好被很多人推荐。但我的实际体验是STM32F103的硬件I2C并没有传说中那么难用只要正确配置了超时检测和错误中断用起来相当稳定。真正让很多人放弃硬件I2C的其实是早期标准库的例程写得不好导致了一堆人“一朝被蛇咬”。如果你是新手我建议先搞定硬件I2C因为代码更简洁CPU占用更少。如果后续移植到其他型号MCU硬件I2C的代码改动也小。3. 串口数据解析从裸字节流到干净的空气质量数值拿到一帧裸数据不代表拿到了能用的PM2.5数值中间还隔着协议解析这一层。这一章是整个项目的核心也是很多人容易写崩的地方。我先把完整的解析思路讲清楚再给出可以直接抄的代码。3.1 接收缓冲区的设计思路串口数据是一个字节一个字节到达的而Y01-3IN1模块每帧可能长达几十个字节。如果每次中断只处理单个字节很难拼出完整的帧结构。常规做法是在内存里划一块环形缓冲区或者数组配合状态机做逐字节解析。我推荐的状态机逻辑是这样的状态0等待帧头第一个字节0xAA。如果不是继续等待。状态1等待帧头第二个字节0xC0。如果收到0xC0则进入状态2否则退回状态0。状态2此时已经确认帧头开始接收“长度”字段的高字节和低字节由此得知整帧剩余长度。状态3根据长度字段把后续所有数据字节存入缓冲区。状态4接收完数据后读取校验字和帧尾做校验比对。实际工程中我一般不用状态机而是用一种更省事的办法在串口空闲中断IDLE Interrupt中判断DMA接收是否停止。STM32的串口外设支持总线空闲检测当串口线路上出现空闲状态即一帧数据结束时会产生IDLE中断。配合DMA可以实现“一次中断收完整帧数据”的效果。这种方案的代码量反而更少。CubeMX里配置DMA接收然后在中断回调里做帧校验// 假设已经用CubeMX生成工程串口1的DMA接收配置为循环模式 // RX_BUFFER_SIZE 根据最大帧长度定义比如 64 uint8_t uart_rx_buffer[RX_BUFFER_SIZE]; volatile uint8_t uart_rx_frame_ready 0; void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { // 收到一帧数据Size 是本次接收到的字节数 process_air_quality_frame(uart_rx_buffer, Size); uart_rx_frame_ready 1; // 重新开启DMA接收 HAL_UARTEx_ReceiveToIdle_DMA(huart1, uart_rx_buffer, RX_BUFFER_SIZE); } }3.2 帧校验的完整实现无论用状态机还是DMA空闲中断最终都要面对一个问题收到的这一包数据怎么确认它是完整的、正确的帧。这一步就靠校验和。以下是一个典型的解析函数能应对大部分Y01-3IN1变种模块typedef struct { uint16_t pm1_0; uint16_t pm2_5; uint16_t pm10; float temperature; float humidity; uint16_t tvoc; uint16_t co2; uint8_t status; } AirQualityData; uint8_t parse_air_quality_frame(uint8_t *buf, uint16_t len, AirQualityData *out) { if (len 20) return 0; if (buf[0] ! 0xAA || buf[1] ! 0xC0) return 0; uint16_t body_len (buf[2] 8) | buf[3]; if (len 4 body_len 3) return 0; // 帧头长度数据校验帧尾 // 累加和校验 uint8_t sum 0; for (uint16_t i 0; i 4 body_len; i) { sum buf[i]; } uint8_t check buf[4 body_len]; if (sum ! check) return 0; // 解析数据这里不同模块偏移可能不同以手册为准 out-pm1_0 (buf[4] 8) | buf[5]; out-pm2_5 (buf[6] 8) | buf[7]; out-pm10 (buf[8] 8) | buf[9]; int16_t temp_raw (buf[10] 8) | buf[11]; out-temperature temp_raw / 10.0f; uint16_t hum_raw (buf[12] 8) | buf[13]; out-humidity hum_raw / 10.0f; out-tvoc (buf[14] 8) | buf[15]; out-co2 (buf[16] 8) | buf[17]; out-status buf[18]; return 1; }这段代码里有几个关键点值得单独说。高位在前还是低位在前不同模块的字节序不一样如果你的模块手册写了“高字节在前”就用上面的写法写了“低字节在前”就要把移位方向反过来。最直接的确认方法是在串口助手里看一帧已知数据然后手动计算。温度是正负号怎么处理温度字段通常是带符号的所以我在代码里用了int16_t去接收原始值再除以10。如果直接用uint16_t接零下的温度就会变成65535这种大数显示出来就成了6553.5度一看就是错的。校验失败后怎么办最简单的做法是丢弃这一帧等下一帧。不要试图“修复”帧数据那只会让问题更隐蔽。因为模块每秒都会主动上报一帧丢一帧的影响微乎其微。3.3 把解析结果交给OLED显示解析函数拿到干净的AirQualityData结构体后剩下的就是显示层的事情了。OLED显示不是简单的“画几个数字”还涉及怎么排布界面、怎么刷新不闪烁、怎么让数据变化更直观。接下来就进入显示驱动的部分。4. OLED显示层从底层驱动到看得懂的数据面板0.96寸OLED屏的驱动芯片是SSD1306分辨率为128x64像素每个像素只有亮和不亮两种状态。它本身没有“字库”所有字符和图形都要我们自己发送像素数据。想要显示“PM2.5: 035”这样一串字符本质上是在逐像素告诉屏幕哪些点阵亮、哪些不亮。4.1 SSP1306驱动的初始化要点与底层函数SSD1306的协议并不复杂I2C接口下核心就是两个操作写命令Control Byte 0x00和写数据Control Byte 0x40。初始化序列有固定的几十条命令CubeMX生成工程后可以参照官方数据手册配置。如果不想从零啃手册直接用成熟的开源驱动库是最快的。比较常用的是SSD1306的纯C库如afiskon/stm32-ssd1306改一下I2C接口函数就能跑通。初始化之后核心的调用逻辑如下void OLED_Clear(void) { for (uint8_t page 0; page 8; page) { ssd1306_SetCursor(0, page); for (uint8_t col 0; col 128; col) { ssd1306_DrawPixel(col, page, 0); } } ssd1306_UpdateScreen(); }这段代码的意思是把屏幕分成8个页Page每个页有8行像素128列。每次清屏就是把所有像素点置0。注意OLED屏幕的像素状态是“亮”为1“灭”为0和LCD背光恰好相反刚上手的人经常把颜色逻辑搞反。补充一点基础知识0.96寸OLED的像素排列不是逐行扫描而是按页bank和列column来组织。屏幕每8行像素构成一页总共8页每页宽度128列。每个字节的8个bit正好对应一列上连续的8个像素。因此写一个16x16的中文字符需要拆成两页来写每页写两列——这就是为什么看起来是“按字节操作”的核心原因。4.2 中文字库、6x12字符与取模工具英文和数字字符可以自己定义一个6x12或8x16的字模数组需要显示什么字符就查表取模数据。常用的ASCII码表只有95个可见字符全部字模加起来也不到1KB完全塞得进STM32的Flash。如果是显示汉字就有两种选择提前把需要显示的汉字取模并固化成数组或者外挂Flash字库芯片。对空气质量显示这种场景我强烈建议用第一种方案——需要显示什么汉字就提前取什么字的模。你只需要确定“温度”“湿度”“PM2.5”“正常”“超标”这些固定的词个数可能不超过20个。每个16x16点阵汉字占32字节20个字才640字节比起挂一颗Flash芯片来成本低得多。取模工具有很多Windows下常见的是PCtoLCD2002。设置要点如下取模方式选“逐行式”或“逐列式”对应你的显示驱动函数读写方式。用ssd1306库的话通常选“逐列式”。取模方向选“低位在前”即字节的最低位对应最左边的像素。生成的C语言数组格式选“十六进制”方便直接粘贴。4.3 显示界面布局如何在128x64上把信息讲清楚128x64的屏幕面积很小想把六七项数据塞进去并不是一件容易事。如果全部用8x16的大字显示一行能放8个字符8行像素一页能显示的信息量很有限。我的布局方案是这样的第一行用16x16汉字显示“PM2.5”后面跟一个4位数字显示浓度值单位μg/m³。第二行用16x16汉字显示“PM10”同样跟数值。第三行显示温度格式如“25.3℃”。这里的“℃”符号字模需要单独取模ASCII标准字符集里没有。第四行显示湿度格式如“RH 58%”。第五行显示TVOC状态可以用“优/良/差”这种汉字状态或者直接显示浓度值。页面切换的设计也值得考虑。一种做法是固定只显示一页把PM2.5这些重点数据用大号字显示另一种做法是用按键切换多页比如第一页看颗粒物第二页看温湿度第三页看TVOC/CO2。我在最终版本里选择了后者因为用户最关心的是PM2.5其他数据放在附加页面里就够了。4.4 刷新策略直接整屏刷新 vs 局部区域刷新OLED的响应速度是微秒级别的整屏刷新一次的速度也很快但有一个问题如果CPU一边在解析串口数据一边不停整屏刷新会给系统增加不必要的负担而且肉眼会看到屏幕在轻微闪烁尤其是刷新的数据一直在变时。我的做法是按需刷新、区域更新数据值有变化时只清除该数值所在的矩形区域然后重新写入新数据。静态文字比如“PM2.5”标签只在初始化时写入一次不参与刷新。刷新间隔设置为300ms到500ms既不会太耗电也足够实时。void display_pm25(uint16_t value) { char buf[8]; sprintf(buf, %04d, value); OLED_ClearArea(16, 0, 64, 16); // 清除上一轮数值区域 OLED_ShowString(16, 0, buf, FONT_8X16); OLED_UpdateScreen(); // 只把改动写入屏幕 }这里有一个很多人容易踩的坑不要在主循环里频繁调用HAL_Delay尤其是在串口接收和OLED刷新共存的场景。万一你在延时期间模块的数据帧到达DMA接收缓冲区的处理就可能被延迟高负载下甚至会把校验字节遗漏。OLED刷新尽量不要阻塞串口中断更合理的做法是在主循环里查询“帧就绪标志位”处理完一帧数据后再去刷新屏幕。5. 调试期间我实际踩过的四个坑这一章算是我个人的“血泪史”合集每个问题我都花了不少时间排查。提前把这些写出来希望能帮大家省掉这些弯路。5.1 现象一上电后OLED全亮或者全黑排查过程我先检查了I2C地址。SSD1306的7位I2C地址由引脚SA0的电平决定大多数模块SA0接到GND地址是0x3C但也有一部分模块SA0悬空或接高电平地址就变成了0x3D。如果你在CubeMX配置里设了0x3C但实际屏是0x3D屏幕上什么都不会显示。我当时用了一个I2C扫描程序把总线上所有设备地址都打出来才发现实际地址是0x3D。解决办法确认实际地址后把CubeMX里的设备地址改过来重新生成代码。如果你用的是封装好的OLED库注意检查库内部把地址左移了1位还是直接用7位地址这是另一个极容易搞错的地方。5.2 现象二串口收到的数据全是0x00或者0xFF排查过程这类现象首先让我怀疑的是硬件连接尤其是GND有没有和STM32共地。其次我用逻辑分析仪去抓模块的TXD引脚波形发现波形确实存在但波特率对应的位宽和我设置的9600对不上。翻手册才发现这个模块的默认波特率是115200不是我以为的9600。解决办法把USART1的波特率改成115200重新编译下载数据立刻就正常了。从此以后我拿到任何模块的第一件事就是先看数据手册里的波特率参数而不是“想当然”地用9600。5.3 现象三PM2.5数值偶尔跳变到几千排查过程最开始时我以为是传感器本身的数据波动后来把原始帧数据通过串口打印出来做对比发现模块输出的数据本身没问题问题出在解析代码。我把高低字节拼接顺序弄反了比如模块输出的是0x01 0x2C我解析成了0x2C01结果就是11265μg/m³明显不对。还有一个隐藏问题就是我将uint8_t变量存储在uint16_t里的过程中没有做强制类型转换某些编译器优化下会出现截断。解决办法统一为“高字节在前”的解析方式把所有拼接操作强制写成(uint16_t)buf[4] 8 | buf[5]杜绝隐患。另外在显示之前增加一道数据合理性判断PM2.5超过1000就直接丢弃这属于程序上的“双保险”。5.4 现象四屏幕显示的数字在缓慢“漂移”或者偶尔闪烁一帧旧数据排查过程这个问题比较隐蔽。当时屏幕上显示的PM2.5数值偶尔会回到上一次的值持续几百毫秒后又跳到新值。我把OLED刷新代码和串口数据解析代码放在同一个循环里顺序是“先收帧、再处理、再显示”。表面看起来没问题但DMA接收到了新帧后数据还没校验完成主循环却显示了一次“旧帧新数据”的混合体。解决办法为解析结果增加一个“帧序号”或者“时间戳”只有新帧校验通过后才允许更新全局的空气质量结构体OLED每次只负责把当前结构体的内容显示出来不关心它怎么来的。这样屏幕上每一帧数据都是完整且一致的肉眼看到的“漂移感”也就消失了。6. 可选的进阶玩法数据滤波、超标告警与本地记录基础版本调通之后这套系统已经能作为一个小型桌面空气质量监测站来用了。但从工程角度看还有很多值得优化的地方。我个人建议按下面的优先级来推进每一项都不复杂但对整个项目的实用性和完成度提升非常明显。6.1 数据滤波滑动平均与中值滤波对比激光散射传感器在风扇转动、气流变化的时候数据会出现随机波动。每秒上报一个PM2.5值画成曲线可能锯齿感很强。滑动平均是最简单的办法维护一个长度为5的环形队列每次新值到来把队首弹出、队尾插入取平均值。中值滤波的抗脉冲干扰能力更强但计算量略大在STM32F103上跑5点中值排序的开销完全可以接受。我的建议是数据连续稳定时用滑动平均在偶尔受到干扰的环境比如有人在旁边抽烟、开窗下用中值滤波效果更好。如果你不想写排序算法简单方案是把5次采集中去掉最大值和最小值再求平均效果也不错。6.2 空气质量分级与本地告警空气质量不只是显示数值还可以做分级评价。比如参考常见的AQI划分方式PM2.5浓度0-35为优、35-75为良、75-115为轻度污染、115以上为中度或重度污染。程序里做一个简单的分级判断用不同颜色的背光或者状态文字在OLED上体现比如“优”显示绿色、“良”显示黄色、“差”显示红色。如果你的板子上有有源蜂鸣器还可以在超标时发出短促的提示音。6.3 把数据通过串口或Wi-Fi发到可视化平台显示在OLED上只是第一步如果想要留存数据或者远程查看可以定期把空气质量数据通过另一个串口发送给ESP8266或ESP32再转发到Node-RED、Home Assistant这类平台绘制趋势曲线。这块内容展开又是一篇长文但基础上来说只要你这边的解析代码足够健壮数据格式按JSON或者简单的CSV行输出上游平台接入并不是什么难事。关于这类串口协议传感器我最后想说的话做嵌入式传感器项目最难的部分往往不是写代码而是“把通信双方的语言对齐”。语言对齐包含两层意思一是电气层面电平、波特率、共地这些硬件参数必须一致二是协议层面帧结构、字节序、校验方式都必须理解透彻。我在调试Y01-3IN1的过程中最深的一个体会是拿到任何模块都不要急着一上来就写代码先花半小时在串口助手上观察原始数据把帧格式手工解析一遍确认自己完全理解了数据手册再动手写STM32代码。这样做看起来多花了点时间但后续调试会顺畅十倍。OLED显示部分说到底也只是锦上添花的最后一公里——数据解析这块地基打牢了你愿意接什么屏、发什么云平台都是随你高兴的事。