1. 项目到底要做什么:Y01-3IN1和OLED的搭配逻辑
手里有一块Y01-3IN1空气质量模块,又有一块0.96寸OLED,想把两者接到STM32上,让屏幕实时显示PM2.5、温湿度和甲醛浓度。这个需求听起来简单,但实际做的时候,串口解析、传感器数据校验、OLED驱动、UI布局,每一步都有不少坑。
先说结论:这个项目非常适合作为STM32入门到进阶的过渡练习。它不涉及复杂的PWM、PID、中断嵌套,但把串口接收、数据校验、I2C通信、显示缓冲管理这些嵌入式开发里的高频知识点全串起来了。做完之后,你对“单片机怎么跟外部传感器通信”“怎么把数据显示出来”这件事,会有比看十遍教程都深的体感。
Y01-3IN1这个模块,本质上是一个把激光粉尘传感器、温湿度传感器、甲醛传感器集成在一块板子上的多合一空气质量检测方案。它通过UART串口输出数据,不需要自己做AD采集、不需要算浓度曲线,只需要按照协议解析数据帧就行。OLED则是标准的SSD1306驱动方案,走I2C接口,两线搞定,显示内容完全由你自己控制。
这套组合能干什么?放在桌面上当空气质量监测小工具,或者作为智能家居项目里的环境感知节点,把数据通过WiFi模块上传到云端。我见过有人拿它做婴儿房环境监控,也有人把它嵌到DIY的新风机控制器里——PM2.5超标就自动启动风扇。教程做到最后,你会发现这套底层的“串口收数据+解析+显示”逻辑,几乎可以复用到所有传感器项目上。
适用人群方面,我建议有三类人重点看:一是刚学完GPIO和定时器、想用真实传感器练手的STM32初学者;二是做毕设需要空气监测功能的学生,直接抄作业能省大量调协议的时间;三是想给自己的DIY项目加环境感知能力的玩家。如果你连STM32CubeMX都没装过,先补一下环境搭建,这篇教程默认你至少能新建一个空白工程并下载程序。
2. 硬件连接与准备工作
2.1 所需材料清单
先把东西备齐,免得做到一半发现缺线少件。我用的这套配置很常规,基本就是手边最常见的器件:
| 器件 | 型号/规格 | 说明 |
|---|---|---|
| 主控板 | STM32F103C8T6最小系统板 | 蓝色Pill板,性价比之王 |
| 空气质量模块 | Y01-3IN1 | 集成PM2.5、温湿度、甲醛,串口输出 |
| 显示屏 | 0.96寸OLED,SSD1306控制芯片,I2C接口 | 四针:VCC、GND、SCL、SDA |
| 供电 | 5V/2A USB电源或充电宝 | 模块需要5V供电,ST-Link的3.3V带不动 |
| 下载器 | ST-Link V2 | 也可以用J-Link或串口ISP,我推荐ST-Link |
| 杜邦线 | 母对母、公对母各若干 | 最好选不同颜色,方便区分 |
| 面包板 | 标准830孔 | 前期调试方便,不用焊 |
这里特别提醒:Y01-3IN1模块的激光粉尘传感器部分,工作电流在100mA左右,峰值可能更高。如果用开发板的3.3V引脚直接给它供电,大概率会出现数据不稳定或者干脆不工作的情况,因为3.3V LDO的输出能力不够。正确做法是给模块单独供5V,而且电源要有足够的电流余量。
2.2 引脚连接对照表
接线是整个项目里最容易出问题的地方,接错一根线,轻则没数据显示,重则烧模块或者烧单片机。先看清楚Y01-3IN1的接口定义,不同厂家的模块虽然都叫Y01-3IN1,但引脚排列可能有差异,拿到模块先看丝印确认。
我用的这块模块引脚定义和STM32的接线方式如下:
| Y01-3IN1引脚 | 功能 | 接STM32F103C8T6 | 注意事项 |
|---|---|---|---|
| VCC | 电源正极 | 5V(外部电源) | 切勿接3.3V,激光头需要5V |
| GND | 电源地 | GND | 与STM32共地,这是通信稳定的关键 |
| TX | 串口发送 | PA10(USART1_RX) | 模块发、单片机收,交叉连接 |
| RX | 串口接收 | PA9(USART1_TX) | 模块收、单片机发(本项目可不接) |
| AOUT | 模拟输出 | 不接 | 本项目不使用的可选引脚 |
OLED的接线更简单,I2C只需要两根数据线加两根电源线:
| OLED引脚 | 接STM32F103C8T6 |
|---|---|
| VCC | 3.3V |
| GND | GND |
| SCL | PB6(I2C1_SCL) |
| SDA | PB7(I2C1_SDA) |
关于共地这件事,我要多说一句。很多新手把所有设备都接到同一个面包板的电源轨上,觉得“反正都是GND,随便连”,结果串口数据乱码。原因是模块和单片机各自工作在不同电位参考下,不共地的话,IO电平的参考点不一致,接收端无法正确识别高电平和低电平。我见过有人折腾了一晚上,最后发现就是GND没接好,松了一根线。
2.3 使用前的模块自检方法
在写代码之前,花两分钟验证一下模块是不是好的,能省下后面一大半排错时间。方法很简单:用USB转TTL工具连接模块,打开串口助手,波特率设为9600,看有没有数据输出。
我用的调试步骤如下:
- 把USB转TTL的TX接模块RX,RX接模块TX,GND接GND,5V接VCC。
- 打开任意串口助手软件,选择对应COM口,波特率9600,数据位8,停止位1,无校验。
- 给模块上电,观察串口助手接收区是否有十六进制数据,正常情况每秒输出一帧。
如果能看到类似AA C0 01 02 03 04 ...开头的数据帧,说明模块是好的。如果完全没反应,优先检查供电是否充足、TX RX是否接反。用这个方法,可以清晰地判断问题出在“模块本身”还是“STM32通信配置”,避免后面在代码里瞎找原因。
有一点要注意:模块上电后前几秒输出的数据可能不稳定,因为激光传感器和甲醛传感器需要预热,尤其是甲醛传感器,化学原理的传感器通常需要几分钟甚至更久才能稳定。调试时别一上来就下结论说模块坏了,多等一会儿。
3. 数据协议拆解:Y01-3IN1到底在输出什么
3.1 串口数据帧结构详解
Y01-3IN1走的是标准的UART串口协议,波特率9600,8位数据位,1位停止位,无校验。模块每秒钟主动向上位机发送一帧数据,不需要单片机发送任何请求指令。这种“主动上报”模式非常省事,单片机只需要做一件事:被动接收并解析。
数据帧的格式如下(这是该类模块最常见的通用协议,我实际用的模块帧结构也与此一致):
| 字节序号 | 内容 | 说明 |
|---|---|---|
| 0 | 0xAA | 帧头 |
| 1 | 0xC0 | 帧类型标识,表示空气质量数据帧 |
| 2 | PM2.5低字节 | PM2.5浓度,单位µg/m³ |
| 3 | PM2.5高字节 | 浓度 = 低字节 + 高字节 × 256 |
| 4 | PM10低字节 | PM10浓度,单位µg/m³ |
| 5 | PM10高字节 | 浓度 = 低字节 + 高字节 × 256 |
| 6 | 温度低字节 | 温度,实际值 = 原始值 / 10,单位℃ |
| 7 | 温度高字节 | 例:0x01 0x2C → 300 → 30.0℃ |
| 8 | 湿度低字节 | 湿度,实际值 = 原始值 / 10,单位%RH |
| 9 | 湿度高字节 | 例:0x02 0x1A → 538 → 53.8%RH |
| 10 | 甲醛低字节 | 甲醛浓度,单位mg/m³ |
| 11 | 甲醛高字节 | 浓度 = 低字节 + 高字节 × 256,再除以1000 |
| 12 | 校验和 | 从字节0到字节11的累加和取低8位 |
帧头0xAA 0xC0是固定的,这是判断一帧数据是否完整的“门卫”。如果收到的第一字节不是0xAA,或者第二个字节不是0xC0,这一帧直接丢弃,继续等下一个。这样做的好处是即使在数据流中间开始监听(比如程序刚复位,或者串口缓冲里残留了半个帧),也能快速地重新对齐到正确的帧边界。
校验和的计算方式:把第0到第11共12个字节全部加起来,取结果的低8位,跟第12字节做比较。如果一致,说明这一帧在传输过程中没有发生位错误;不一致就丢弃。我在实际项目中还遇到过一种情况:模块开机后输出的一开始几帧,可能数据全为0或者明显异常,这是因为传感器还在自检和预热。程序里最好加一个简单的数据合理性判断,比如PM2.5值超过1000就认为是无效数据。
3.2 数据换算逻辑和实例验证
解析协议这件事,难的不是读代码,而是搞懂每个字节到底代表什么含义。我当初第一次拿到数据就想当然地把读出来的原始值直接当成PM2.5浓度显示,结果屏幕上的数字大得离谱。后来仔细看协议才发现,PM2.5和PM10是两个字节拼起来的16位整数,温度湿度要除以10,甲醛要除以1000。
举个例子,假设收到的一帧数据如下:
AA C0 12 00 1E 00 0C 01 1A 02 05 00 7B按协议拆解:
- PM2.5 = 0x0012 = 18 µg/m³,空气质量不错。
- PM10 = 0x001E = 30 µg/m³,也在正常范围。
- 温度 = 0x010C = 268,实际 26.8℃。
- 湿度 = 0x021A = 538,实际 53.8%RH。
- 甲醛 = 0x0005 = 5,实际 5/1000 = 0.005 mg/m³,非常安全。
- 校验和 = 0xAA + 0xC0 + 0x12 + 0x00 + 0x1E + 0x00 + 0x0C + 0x01 + 0x1A + 0x02 + 0x05 + 0x00 = 0x27A,取低8位 = 0x7A,这里的值是0x7B,说明这一帧有误或者我数据故意写错一个数,实际调试中遇到校验和不匹配就丢帧。
把这个计算过程在纸上走一遍,理解就到位了。后面代码里的解析部分,本质就是把这张表翻译成C语言。
3.3 为什么要自己写校验逻辑
你可能会想,串口数据传过来不就是字节流吗,直接在中断里一个个读不就行了?理论上可以,但实际项目里,串口是异步通信,数据到达的时间不确定,而且模块每秒钟都在发数据。如果不做帧同步和校验,程序极容易在一帧数据的中间开始处理,导致把上一帧的后半段跟下一帧的前半段拼在一起,解析出完全错误的结果。
帧同步的意义在于:告诉程序“从这里开始,接下来的12个字节才是有效的一帧”。而校验的意义在于:保证这12个字节在传输过程中没被干扰。这两个东西合在一起,就叫“通信协议的安全层”。在工业级的传感设备通信里,这种设计几乎是标配。
所以这一步别偷懒,把帧同步和校验逻辑写好,你就已经掌握了嵌入式通信中最重要的基本功之一。后面做GPS解析、LoRa数据传输、CAN总线报文处理,思路完全一样。
4. STM32工程搭建与代码实现
4.1 使用STM32CubeMX生成底层配置
先声明一下,我习惯用STM32CubeMX生成底层初始化代码,再用Keil MDK或VS Code + GCC写业务逻辑。这样比纯手写寄存器快得多,而且配置不会漏。你用标准库自己初始化也行,但时序和配置容易出错,尤其是I2C这种对时序敏感的接口。
本项目的CubeMX配置要点:
- 新建工程,选择芯片型号STM32F103C8Tx。
- RCC设置为HSE(外部晶振),如果最小系统板没有外部8MHz晶振,这里选Disable,然后时钟源用HSI,把主频配置成64MHz(F103最大到72MHz,用HSI最高64MHz)。我用的是带外部晶振的板子,所以选Crystal/Ceramic Resonator,系统时钟设72MHz。
- USART1:异步模式,波特率9600,8位数据,无校验,1位停止。PA9是TX,PA10是RX。注意模块只发不收,但USART的TX引脚还是要留着,以后可能要发指令给模块(比如部分版本的Y01支持查询命令)。
- I2C1:标准模式(100kHz)。地址长度用7位,因为SSD1306默认是7位地址。不需要开DMA,OLED显示的数据量不大,轮询就够。
- 引脚配置确认:PA9(USART1_TX)、PA10(USART1_RX)、PB6(I2C1_SCL)、PB7(I2C1_SDA)。
生成代码后,在main.c里要注意一个细节:CubeMX默认生成的HAL_UART_Receive_IT是接收单字节并触发一次中断,用起来麻烦。更好用的方式是开DMA接收,但我建议新手先别碰DMA,等把基础逻辑跑通了再优化不迟。我用的方案是“串口空闲中断+DMA接收”,代码量不大但效果接近工业级写法,后面会详细讲。
关于网上有人遇到的error: no stm32 target found问题,这里提前打个预防针:如果下载程序时报这个错,第一检查ST-Link和板子的接线是否正确,SWDIO、SWCLK、GND三根线缺一不可;第二检查板子是否供电;第三在Keil的Options -> Debug -> Settings里看能不能识别到芯片ID。多数情况下,都是SWD接口的杜邦线接触不良导致下载器找不到目标芯片。
4.2 OLED驱动核心知识:SSD1306和显存
0.96寸OLED用的是SSD1306控制芯片,分辨率为128×64,即128列×64行像素点。这块芯片内部有一块1KB的GRAM(图形显存),对应128×64个像素点,每个点1位(0熄灭,1点亮)。I2C通信的内容,本质上就是往这块显存里写数据。
关于热搜里那句“OLED屏的像素点由几层组成”,科普一下:OLED跟LCD不一样,它是自发光器件,每个像素点就是一个有机发光二极管,结构上像三明治——中间是发光层,两边分别是阳极(通常是ITO透明电极)和阴极(金属电极),再加上空穴注入层、空穴传输层、电子注入层、电子传输层等功能层,叠在一起总共六七层。厚度只有几百纳米。因为每个像素独立发光,所以OLED不需要背光,黑色就是纯粹的不发光,对比度极高,从侧面看也清晰。
SSD1306的驱动逻辑分两步:
第一步,命令操作。通过I2C发送控制字节,区分是发命令还是发数据。命令用来设置显示开关、对比度、寻址模式、列地址范围、页地址等参数。
第二步,显存操作。SSD1306把64行像素分成8页(Page0到Page7),每页8行。每一列有128个点,每一页的每一列用一个字节表示,这个字节的8个bit对应这一列从上到下的8个像素。所以写完整个显存需要 128列 × 8页 = 1024字节。
理解了这一层,写驱动就有方向了。网上能搜到很多现成的SSD1306驱动代码,但很多写得乱,而且跟自己的I2C底层不兼容。我把工作中整理的一套精简驱动思路分享出来,核心就三个函数:写命令、写数据、刷新全屏。
// SSD1306 I2C地址:0x78是8位写法(0x3C左移一位),有些屏是0x7A(0x3D左移一位) #define OLED_ADDR 0x78 // 发送命令 void OLED_WriteCmd(uint8_t cmd) { uint8_t buf[2] = {0x00, cmd}; // 控制字节0x00表示命令 HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 100); } // 发送数据 void OLED_WriteData(uint8_t data) { uint8_t buf[2] = {0x40, data}; // 控制字节0x40表示数据 HAL_I2C_Master_Transmit(&hi2c1, OLED_ADDR, buf, 2, 100); } // 初始化序列(精简版) void OLED_Init(void) { OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0x20); // 设置内存寻址模式 OLED_WriteCmd(0x00); // 水平寻址模式 OLED_WriteCmd(0xC8); // 扫描方向,从下到上(配合坐标计算) OLED_WriteCmd(0x40); // 显示起始行0 OLED_WriteCmd(0x81); // 设置对比度 OLED_WriteCmd(0x7F); // 128 OLED_WriteCmd(0xA1); // 段重映射,左右反一下 OLED_WriteCmd(0xA6); // 正常显示(0=A7反色) OLED_WriteCmd(0xA8); // 多路复用比 OLED_WriteCmd(0x3F); // 64 OLED_WriteCmd(0xD3); // 显示偏移 OLED_WriteCmd(0x00); // 0 OLED_WriteCmd(0xD5); // 时钟分频 OLED_WriteCmd(0x80); OLED_WriteCmd(0xD9); // 预充电周期 OLED_WriteCmd(0xF1); OLED_WriteCmd(0xDA); // COM引脚配置 OLED_WriteCmd(0x12); OLED_WriteCmd(0xDB); // VCOMH电平 OLED_WriteCmd(0x40); OLED_WriteCmd(0x8D); // 充电泵 OLED_WriteCmd(0x14); // 开启 OLED_WriteCmd(0xAF); // 打开显示 }注意跟屏幕实际效果的匹配点:不同厂家生产的OLED屏,虽然都叫SSD1306,但针对“屏幕左右镜像”和“扫描方向”的设置可能不一样。如果你的屏显示出来的文字是镜像的,把0xA1改成0xA0,0xC8改成0xC0,再试一次。这是我调过几十块屏之后的经验,基本每次都会碰到一两块需要反向的。
4.3 串口数据接收:中断方式还是DMA方式
Y01-3IN1每秒输出一帧12字节的数据,数据量不大,但问题是“什么时候到”是不可预测的。CPU不能一直傻等,所以一定要靠中断机制。我推荐用“串口空闲中断(IDLE)+ DMA”的方式:DMA负责把收到的字节自动搬运到内存缓冲区,空闲中断负责在“一帧数据结束”时通知CPU来处理。
为什么不用单字节中断?因为每个字节都触发一次中断,CPU频繁进入中断服务函数,浪费大量时间。而且如果中断里还要做协议解析,万一解析时间超过了下一帧的到达间隔,就会丢数据。DMA方式下,CPU全程不参与数据搬运,等一整帧接收完了,中断只进来一次,处理效率高得多。
还有一个好处:因为Y01模块是每秒匀速发数据,所以两次数据之间一定有时间间隔。空闲中断就是利用这个间隔来判断“一帧数据发完了”。你在CubeMX里把USART1的DMA接收通道和IDLE中断都打开,然后在中断回调里判断收到的字节数,恰好等于12字节就说明是一帧完整数据。
我实际使用的接收缓冲区是环形缓冲区的简化版:一个固定大小的数组,DMA不断往里面写,CPU读完后重置。判断的依据就是DMA计数器(HAL_GetState或者通过__HAL_DMA_GET_COUNTER)看了还剩多少个字节没传完,用“总长度减剩余长度”得到本次新接收的字节数。
#define RX_BUF_SIZE 128 uint8_t uart_rx_buf[RX_BUF_SIZE]; // 开启DMA接收 HAL_UART_Receive_DMA(&huart1, uart_rx_buf, RX_BUF_SIZE); // 在串口中断处理函数里判断空闲 void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usart1_rx); if (len >= 13) { // 至少收到完整一帧,进入解析流程 AirQuality_Parse(uart_rx_buf, len); } // 重新开始计数 HAL_UART_DMAStop(&huart1); HAL_UART_Receive_DMA(&huart1, uart_rx_buf, RX_BUF_SIZE); } HAL_UART_IRQHandler(&huart1); }要注意的是,DMA缓冲区的长度最好设置成比一帧数据大一些。因为DMA搬数据是连续的,如果恰好从帧头开始接收,12字节就是一帧;但如果从帧中间开始接收,缓冲区里可能同时存在旧帧的尾巴和新帧的头,这时候就需要一个查找帧头的过程。我的做法是把缓冲区长度设为若干帧的整数倍,然后从缓冲区里从头到尾查找0xAA 0xC0,找到后取出12字节,再做校验。
4.4 数据解析函数编写
解析函数的核心思想:在接收缓冲区里找帧头,找到之后确认有足够的字节数,再做校验和验证,通过之后提取数据。
typedef struct { uint16_t pm25; uint16_t pm10; float temperature; float humidity; float hcho; uint8_t valid; } AirQuality_Data; AirQuality_Data air_data; void AirQuality_Parse(uint8_t *buf, uint16_t len) { for (uint16_t i = 0; i + 12 < len; i++) { // 查找帧头 if (buf[i] == 0xAA && buf[i + 1] == 0xC0) { // 计算校验和 uint8_t checksum = 0; for (uint8_t j = 0; j < 12; j++) { checksum += buf[i + j]; } if (checksum == buf[i + 12]) { air_data.pm25 = buf[i + 2] | (buf[i + 3] << 8); air_data.pm10 = buf[i + 4] | (buf[i + 5] << 8); air_data.temperature = (buf[i + 6] | (buf[i + 7] << 8)) / 10.0f; air_data.humidity = (buf[i + 8] | (buf[i + 9] << 8)) / 10.0f; air_data.hcho = (buf[i + 10] | (buf[i + 11] << 8)) / 1000.0f; air_data.valid = 1; break; } } } }解析本身不复杂,但这个函数有几个细节值得注意。第一,循环条件是i + 12 < len,保证读到第12字节时不会越界。第二,如果缓冲区内有很多以前残留的数据,必须遍历查找0xAA 0xC0,不能只判断buf[0]和buf[1]。第三,校验和判断是最后一道防线,如果校验失败,说明当前帧头位置是伪同步或者传输有误,继续往下找就行。
在实际项目中,我通常还会加一个“接收超时”机制:如果连续多秒没收到合法数据,就把air_data.valid置0,显示界面提示“传感器离线”。这对判断模块是不是热插拔后失效或者线松了很有用。
4.5 OLED显示UI设计
显示内容比较多,PM2.5、PM10、温度、湿度、甲醛,一个屏幕要放下五组数据还要清晰易读,需要动点脑子布局。0.96寸OLED是128×64像素,我设计的布局如下:
- 顶部一行固定显示标题“AIR QUALITY”,用8×16大小的ASCII字符,占16像素高度。
- 第二行显示PM2.5:值用16×32的超大数字,单位µg/m³用小字,这样一眼扫过去就能看清关键指标。
- 第三行显示温度,格式
Temp: 26.8C。 - 第四行显示湿度,格式
Humi: 53.8%RH。 - 第五行显示甲醛,格式
HCHO: 0.005mg/m3。
UI设计的原则是:最重要的数据(PM2.5)占最大的显示区域,次要数据一屏展示但字号可以小。OLED信息密度有限,不要试图一屏装下所有信息,那样只会什么都看不清。如果你还要做历史曲线,建议换更大尺寸的屏或者用TFT彩屏。
字库方面,ASCII字符可以直接用8×6和16×8的点阵,网上随便一搜都有。中文的话要自己取模,16×16中文字模用在OLED上效果最好,因为8×8的汉字太小看不清楚。我推荐用PCtoLCD2002这个软件取模,设置成“阴码、逐列式、顺向”,生成C语言数组,直接放到代码里。
这里有个小技巧:字体取模的时候,注意选择“纵向取模”还是“横向取模”,这必须和你的显示函数对应。我的显示函数是纵向写点(按页写),所以取模方式选“纵向取模”。如果选错了,屏幕上会出现一个个斜着颠倒的方块字。这几乎是所有人都踩过的坑。
4.6 完整主循环逻辑
主循环的逻辑很简单:检查是否有新的合法数据帧,有就把数据格式化到显示缓冲区,然后刷新到OLED。如果没有新数据,就维持现有显示,不做任何操作。
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_I2C1_Init(); OLED_Init(); OLED_Clear(); // 启动DMA接收 HAL_UART_Receive_DMA(&huart1, uart_rx_buf, RX_BUF_SIZE); while (1) { if (air_data.valid) { // 格式化PM2.5 char pm25_str[8]; sprintf(pm25_str, "%d", air_data.pm25); OLED_ShowBigNumber(24, 16, pm25_str, FONT_16X32); // 格式化温湿度 char temp_str[16]; sprintf(temp_str, "Temp:%.1fC", air_data.temperature); OLED_ShowString(0, 36, temp_str); // 刷新显示 OLED_Refresh(); air_data.valid = 0; // 处理完就清标志,避免重复刷新 } } }OLED_ShowBigNumber这种函数,本质是把大数字的每个字符做一个映射表,把10个数字(0-9)的16×32点阵存成数组,然后根据要显示的数字找到对应的字模填入显存。实话说,大数字点阵数组占用的Flash不小,但F103有64KB,完全够用。
格式化浮点数用sprintf时有个坑:默认printf系列函数在Keil里如果不开启MicroLIB,会占用大量Flash,而且重定向fputc还要额外配置。我建议要么开启MicroLIB,要么干脆不用sprintf,自己写个简单的浮点转字符串函数。温度湿度这种一位小数的数据,拆成整数和小数分别拼字符串,成本最低:
void Float2Str(char *out, float val, uint8_t decimal) { int int_part = (int)val; int dec_part = (int)((val - int_part) * 10); sprintf(out, "%d.%d", int_part, dec_part); }4.7 定时刷新与数据处理策略
模块每秒输出一帧数据,但OLED没必要每帧都刷新。人眼对屏幕闪烁的感知在50Hz以下,60Hz以上就感觉不到更新了,但每秒刷新一次已经能满足监控需求。我建议在定时器中断里做一个1秒的刷新标志,主循环检测到标志就刷新一次屏幕。这样既不会漏掉数据更新,也不会因为频繁I2C写操作占用MCU太多时间。
我测试过,如果OLED每10ms刷新一次,程序占用率明显升高,而且肉眼根本看不出区别。设定1秒刷新还有另一个好处:数据变化时你能清楚感知到“一秒变一次”,如果变成2秒变一次或者不再变化,说明传感器状态异常,便于排查。
另外一个经验:数据显示前做一下合理的范围判断。比如PM2.5浓度突然变成5000,正常室内环境不可能这样,大概率是传感器被污染或者数据出错。遇到异常值不用直接显示出来吓自己,显示“--”或者上一次正常值更合适。这个逻辑在工业仪表里叫“量程检查和坏值剔除”,放在嵌入式里虽然听起来高大上,实现起来就几个if的事。
5. 完整代码整合与编译下载
5.1 工程文件组织建议
代码量不大,但组织得清晰一点,以后维护和复用都省心。我的目录结构是这样的:
Core/ Inc/ main.h oled.h air_quality.h Src/ main.c oled.c air_quality.c Drivers/ CMSIS/ STM32F1xx_HAL_Driver/OLED相关的代码独立成一个模块,空气质量解析独立成一个模块,main.c里只做初始化调用和主循环逻辑。这样的好处是,以后如果换一个传感器,只需要改air_quality.c里的解析函数,OLED完全不用动;如果换显示屏,只需要换oled.c,传感器逻辑不受影响。
5.2 关键代码细节处理
OLED驱动里,刷新全屏是最影响体验的函数。简单粗暴的做法是每次调用I2C发送1024字节数据,但SSD1306支持页地址模式,一次可以连续写多个字节。更好的做法是维护一个128×8字节的显存数组(对应8页),业务代码只往显存数组里填数据,刷新时一次性把数组通过I2C发出去。这样能避免频繁I2C操作之间的字节错位。
uint8_t oled_display_buf[8][128]; // 页为行,列为列 // 设置像素点 void OLED_SetPixel(uint8_t x, uint8_t y, uint8_t on) { if (x > 127 || y > 63) return; uint8_t page = y / 8; uint8_t bit = y % 8; if (on) { oled_display_buf[page][x] |= (0x01 << bit); } else { oled_display_buf[page][x] &= ~(0x01 << bit); } } // 刷新全屏 void OLED_Refresh(void) { for (uint8_t page = 0; page < 8; page++) { OLED_WriteCmd(0xB0 + page); // 设置页地址 OLED_WriteCmd(0x00); // 设置列地址低4位 OLED_WriteCmd(0x10); // 设置列地址高4位 for (uint8_t col = 0; col < 128; col++) { OLED_WriteData(oled_display_buf[page][col]); } } }这段代码的性能关键点:I2C一次传输带上控制字节,一共要发129个字节,加上命令设置,一页大约消耗1ms左右。8页全刷一次大约8ms,对于1秒刷新一次的场景完全没问题。如果你用软件模拟I2C,时间可能会长一些,但只要总耗时低于100ms都不影响体验。
5.3 下载调试的完整流程
编译之前,先确认Keil里的Target选项:Use MicroLIB打勾,这样sprintf等库函数体积小很多。优化等级我建议先用-O0,方便调试;如果Flash紧张再开到-O2。
下载时如果遇到问题,按照这个顺序排查:
- Keil识别不到ST-Link:检查驱动,打开设备管理器看有没有
ST-Link设备,如果显示感叹号,重新安装ST-Link驱动。有时代码里把SWD引脚重映射了,也会导致下次下载失败,解决办法是按住板子复位键,点击下载,在开始下载的瞬间松开复位。 - 提示
Error: Flash Download failed - Cortex-M3:一般是Flash算法选错了,在Options -> Debug -> Flash Download里选择STM32F10x High-density Flash,F103C8是64KB,选Medium-density。 - 程序能下载但没反应:先检查板子上有没有焊上LED,如果LED还能按原有程序闪,说明新程序没跑起来。这时候单步调试看卡在哪里,最常见的是HAL_Init里的时钟配置失败。
下载完成后,打开串口助手,RF模块的TX如果不接,电脑上看不到数据。要看实时数据,可以通过STM32的串口再转发到USB转TTL工具。我在调试时经常加一个调试串口,USART2接到USB转TTL,把解析后的数据打印出来,跟OLED显示对比,确认显示逻辑和数据解析都正确后再把调试打印删掉。
6. 常见问题与排查技巧实录
6.1 OLED不亮或者显示异常
这是这个问题出现频率最高的问题。OLED不亮,先把硬件和软件分开查。
- 硬件查法:用手触摸OLED的VCC和GND之间的电压,正常是3.3V或5V(取决于模块带不带稳压)。如果电压正常但屏幕完全没有反应,那就是I2C地址不对。SSD1306的7位地址一般是0x3C或0x3D,换算成8位写地址分别是0x78或0x7A。你可以在初始化时用I2C扫描代码,逐个地址尝试,看哪个地址有ACK应答。
- 软件查法:确认CubeMX配置的I2C引脚没有跟其他外设冲突。比如F103的I2C1默认在PB6/PB7,但如果你在CubeMX里同时使能了SPI1,它也会占用PB3/PB4等引脚,绝不会冲突,但要注意调试器的SWDIO在PA13、SWCLK在PA14,这些引脚被占用了会导致下载失败。
- 屏幕显示花屏或者雪花点:多半是初始化序列没执行完整,尤其是充电泵(Charge Pump)没开。SSD1306内部的DC-DC升压电路需要软件开启,不开启的话屏幕没有驱动电压,整个屏幕就是花的或者暗的。检查初始化序列里有没有
0x8D, 0x14这两条命令。
另外补充一个冷知识:OLED屏上电默认是不亮的,必须主控发0xAF命令打开显示。所以不用怀疑“是不是没接对所以不亮”,确定电压和I2C地址没问题,就静下心看初始化代码。
6.2 串口收到数据全是乱码或者收不到
乱码的根源极大概率是波特率不匹配。Y01模块的波特率固定9600,检查CubeMX里USART1的Baud Rate是不是9600,差一个0都不行。我接过一次用115200给别人调试,界面全是乱码,折腾了半小时才发现是这个问题。
收不到数据需要检查的事情按优先级排序:
- 模块供电够不够。摸一下模块上的激光头位置,如果发热严重,说明供电在硬撑;如果完全不热,可能是没供电。
- TX RX有没有接反。模块TX接单片机RX,这个交叉关系最容易搞错。
- 共地了吗?模块GND和单片机GND必须连在一起。
- 串口初始化代码执行了吗?CubeMX生成的初始化代码在MX_USART1_UART_Init()里,如果这句被注释或者放在死循环后面,串口根本不会工作。
如果是DMA方式接收,还有一个独有的坑:DMA接收正常启动了,但空闲中断没开或没清标志。检查USART1_IRQHandler里是否正确判断和清除了IDLE标志。如果IDLE标志没清除,会导致中断反复触发,程序看似卡死。
6.3 数据解析出来数值跳动剧烈
排除传感器本身的问题(比如在风口旁边AI测试、PM2.5值波动正常),核心要检查的是解析出来的数据是否稳定。如果数值在正常范围内跳动但幅度偏大,试试在代码里做“滑动平均滤波”:把最近5次解析结果存到数组里,每次显示取平均值。这个方案的延迟只有几秒,但显示效果稳定很多。
如果数据直接出现202、1024这种怪异值,十有八九是字节拼接方向错了。PM2.5是低字节在前、高字节在后,你用buf[i+2] | (buf[i+3] << 8)是对的;如果你反着写buf[i+3] | (buf[i+2] << 8),数值会大256倍,一看就不正常。
还有一个小概率但极高破坏力的问题:DMA缓冲区溢出。我的缓冲长度是128字节,如果模块数据帧率异常提高(比如模块故障连续发数据),缓冲满了之后DMA会停止,程序就永远收不到新数据。解决方法是每次解析完成后,把DMA重新初始化一次,相当于“在线重置”,确保缓冲区永远从0开始接收。
6.4 甲醛数据一直为0
Y01-3IN1里的甲醛传感器是电化学式的,功率低、响应慢,而且对温度和湿度敏感。刚上电那会儿,甲醛数据是0很正常,等三五分钟再看。如果半小时后还是0,检查模块的预热状态,有的版本需要特殊引脚使能加热,需要仔细看你的模块手册。
另外,电化学传感器的量程和分辨率有限,甲醛浓度低于0.001mg/m³时,输出可能直接显示0。这不是故障,是精度上限导致的。如果你想要更高精度的甲醛测量,就得换用更专业的传感器模块,成本也会上去。
6.5 下载失败:stm32 target not found
这个问题在《网络热词》里出现频率很高,我单独拎出来说。用ST-Link调试时,Error: no stm32 target found几乎是每个STM32新手都会遇到的错误。这个问题本身不是Y01项目特有,但既然大家都搜,我把自己总结的全套排查步骤放在这里:
| 检查项 | 操作内容 | 成功率 |
|---|---|---|
| 接线 | SWDIO连PA13,SWCLK连PA14,GND连GND,3.3V不要接或者只接一个参考电压 | 高 |
| 供电 | 目标板必须单独供电,ST-Link的3.3V输出电流能力很弱 | 高 |
| 驱动 | 设备管理器里ST-Link设备正常,无感叹号 | 中 |
| BOOT0设置 | 如果是新板子,确认BOOT0拉低(从Flash启动),拉高的话芯片会进入ISP模式,SWD不工作 | 中 |
| 复位时序 | 点下载的同时手动复位目标板 | 中 |
| 接线过长 | 杜邦线超过20cm,SWD信号质量差,缩短线或换粗线 | 低 |
最后这招“点下载时按复位”是万能的。因为有些板子上的程序把SWD引脚重映射做了别的用途,或者是进入了低功耗模式,导致下载器连不上芯片。按住复位键让芯片停住,下载器趁机连上,等下载开始再松开,百试不爽。
7. 扩展思路:这个项目还能往哪走
教程做到这里,一个能跑的STM32+空气质量检测+OLED显示的最小系统已经成型。但说实话,这才是一个开始。我见过很多人做完这个项目就停下来了,其实稍微加点东西,整个项目的实用性和技术含量能上一个台阶。
第一个扩展方向:数据存储与历史趋势。把OLED显示的数据通过串口或者SPI写到SD卡,或者存到内部Flash,每天记录一组平均数据,这样你就能看到家里空气质量一天的波动曲线。实现方式也不难,定时器里做整点统计,Flash存数据分析比SD卡简单。
第二个扩展方向:本地报警。空气质量超标时点亮LED或者驱动蜂鸣器。这个只需要一个GPIO和一个继电器,代码量增加不到100行,但“环境监测”瞬间变成了“环境监控”。
第三个扩展方向:联网上报。用ESP8266或者ESP32的串口跟STM32通信,把数据通过MQTT协议推到手机上的App或者微信小程序。这个方向对技能的提升特别大,你会接触到网络编程、JSON格式封装、MQTT协议,哪怕只是做一个最简单的TCP上报,收获都非常大。
第四个方向:低功耗改造。如果做成电池供电的便携设备,可以用STM32L0系列替代F1系列,让模块间歇工作:每10分钟采集一次,其他时间进入Stop模式。整机功耗能做到毫瓦级别,一节18650电池能用几个月。
我个人实际做的时候,把数据通过ESP8266上报到了阿里云物联网平台,然后在小程序里查看曲线。刚开始觉得挺神奇,回头看核心代码,本质还是这篇教程里的串口解析那一套,只是把Y01的数据又转发给了WiFi模块而已。所以把基础打牢,后面很多东西都是触类旁通的。
最后分享一个我在调试中养成的小习惯:每次拿到新的传感器模块,先不急着接单片机,而是用USB转TTL接电脑,把所有数据抓下来,对照协议文档一个字节一个字节地核对。这个习惯帮我避开了很多文档写得不清楚、或者模块出厂固件版本和手册对不上的坑。做嵌入式,慢就是快,前期多花点时间确认硬件行为和通信协议,后面写代码几乎不会遇到“灵异问题”。