STM32驱动Y01-3IN1空气质量模块+OLED显示完整教程

前言

本篇文章基于 STM32F103C8T6 单片机,实现 Y01‑3IN1 三合一空气质量模块数据读取,通过串口接收解析 TVOC、HCHO 甲醛、CO₂参考浓度,最终在 OLED 屏幕上实时展示测量数据。

Y01‑3IN1 模块内部完成气体信号采集与换算,采用 UART 串口每秒主动上报数据,和传统 MQ‑2 这类模拟输出传感器实现方式有很大区别,非常适合单片机串口通信、数据帧解析、中断接收的教学实验。

视频讲解:STM32驱动Y01-3IN1空气质量模块+OLED显示完整教程https://www.bilibili.com/video/BV1WV4D6DEvB/?share_source=copy_web&vd_source=91b967b41a17a5686913521545a8c3fb

注意:Y01‑3IN1 的 HCHO、CO₂为算法推算参考值,并非高精度专用传感器,仅用于教学演示、观察空气质量变化趋势,不能用于精密检测、安全防护。

一、项目实现功能流程

整体工作流程:

  1. 环境中挥发性气体发生改变,Y01‑3IN1 内部气敏元件产生响应
  2. 模块内部电路采集气敏信号,算法换算出 TVOC、HCHO、CO₂数据
  3. 模块通过 UART 串口,每秒主动发送一帧 9 字节数据
  4. STM32 的 USART3 使用中断方式接收字节,查找帧头、校验数据帧
  5. 解析成功之后把浓度数值送到 OLED 屏幕进行显示

简单概括:Y01‑3in1 将气体变化转为串口数字帧,STM32 完成接收、校验、解析,最后 OLED 展示结果。

和 MQ‑2 模拟传感器对比

对比项 MQ‑2 演示 Y01‑3in1 演示
模块输出 AO 模拟电压 UART 数字数据帧
STM32 外设 ADC1 USART3
数据处理 ADC 采样、滤波、电压换算 帧头同步、校验和验证、数据字段解析
输出内容 ADC 采样值、电压电平 TVOC、CO₂、HCHO 浓度参考值

二、硬件接线说明

使用 STM32F103C8T6 最小系统板,Y01‑3IN1 空气质量模块,I2C 接口 OLED 屏幕。

接线重点

串口交叉接线:模块 TX 接单片机 RX,模块 RX 接单片机 TX

  • Y01 A(RX) ←── PB10(USART3_TX)
  • Y01 B(TX) ──→ PB11(USART3_RX)

本项目使用模块默认主动上报模式,单片机主要工作在接收数据,PB10 发送引脚硬件完整配置,但程序中不需要发送指令。

供电与电平提醒

  1. Y01‑3IN1 模块需要5V 供电,串口输出为 5V 电平;STM32F103 的 PB11 引脚可以容忍 5V 输入。如果换成其他 MCU,一定要确认 IO 耐压,必要时增加电平转换或者限流电阻。
  2. 所有器件必须共地! 不共地会出现收不到数据、校验全部报错的问题。

三、Y01‑3IN1 传感器检测原理

模块内部是 TVOC 广谱气敏元件,当空气中酒精、香水、油烟等挥发性气体接触传感器,元件电阻 / 电导会发生改变。模块内部 MCU 采集这个变化,运行算法计算出 TVOC、HCHO、CO₂三组参考数值,通过串口向外发送。

⚠️重要知识点:

  1. TVOC 代表总挥发性有机物,是多种气体的综合响应,不能识别单一气体。
  2. 本模块 CO₂、HCHO 属于软件推算参考值,不是红外二氧化碳传感器,也不是电化学甲醛传感器。
  3. 酒精、香水、清洁剂等挥发物,会同时干扰三个测量读数。
  4. 定位:教学演示、观察空气质量变化趋势,不可做精密计量、安全报警设备。

四、串口通信协议详解

4.1 串口基础参数

使用 STM32 的 USART3,参数 9600 8N1

参数 设置
串口外设 USART3
TX 引脚 PB10
RX 引脚 PB11
波特率 9600
数据位 8
停止位 1
校验位
硬件流控 关闭

4.2 主动上传数据帧格式

模块默认每秒自动发送 1 帧,每帧固定 9 字节

字节序号 名称 说明
B1 帧头 1 固定 0x2C
B2 帧头 2 固定 0xE4
B3 TVOC_H TVOC 高字节
B4 TVOC_L TVOC 低字节
B5 HCHO_H HCHO 高字节
B6 HCHO_L HCHO 低字节
B7 CO2_H CO₂高字节
B8 CO2_L CO₂低字节
B9 SUM 校验和 前 8 字节累加,取低 8 位

帧格式简写:2C E4 TVOC_H TVOC_L HCHO_H HCHO_L CO2_H CO2_L SUM

4.3 校验和计算规则

cpp 复制代码
SUM = (B1 + B2 + B3 + B4 + B5 + B6 + B7 + B8) & 0xFF

举例接收帧:2C E4 00 0A 00 03 01 68 86 把前 8 个字节全部相加得到 0x186,取低 8 位 0x86,和最后一个字节相等,代表本帧数据有效。 校验失败的帧直接丢弃,不会更新屏幕数据,程序会统计校验错误计数。

4.4 数据换算公式

高低字节合并为 16 位原始数据: raw = (高字节 << 8) | 低字节

单位换算:

  • TVOC (mg/m³) = TVOC 原始值 × 0.001
  • HCHO (mg/m³) = HCHO 原始值 × 0.001
  • CO₂(ppm) = CO₂原始值

工程技巧:STM32F103(Cortex‑M3)浮点性能弱,项目中可以把 TVOC、HCHO 放大 1000 倍用整型存储,减少浮点数运算,避免显示误差。

示例解析: TVOC 原始 = 10 → 10 ×0.001 =0.010 mg/m³ HCHO 原始 = 3 →3 ×0.001 =0.003 mg/m³ CO₂原始 = 360 →360 ppm

五、程序设计思路

5.1 为什么用串口中断接收

模块随时上报数据,主循环在刷新 OLED 屏幕,如果程序阻塞等待串口,会造成界面卡顿;如果不及时读取串口寄存器,字节直接丢失。

使用 USART3 接收中断:每收到 1 个字节立刻进入中断服务函数,把字节交给帧解析状态机处理,处理完立刻返回主循环,不阻塞 OLED 刷新等业务。

5.2 帧同步状态机逻辑

串口是连续字节流,硬件不会自动按 9 字节分组,软件必须手动寻找帧头0x2C 0xE4

  1. 空闲状态循环查找第一个帧头字节0x2C
  2. 收到0x2C之后,判断下一字节是否等于0xE4
  3. 帧头匹配成功,继续接收剩下 7 个字节,凑齐完整 9 字节
  4. 执行校验和判断,校验成功保存数据;校验失败统计错误
  5. 无论帧解析成功或者失败,重新回到寻找帧头状态

就算上电的时候,单片机正好收到半帧数据,经过状态机同步,后续收到正确帧头就可以恢复正常解析,抗干扰能力更强。

5.3 中断与主循环数据保护

中断随时会改写传感器数据,主循环读取的时候如果正好触发中断,有可能读到半旧半新的混合错误数据。 解决方法:读取数据快照的时候短暂关闭串口中断,复制完成马上打开中断,获取一帧完整、一致性的数据。中断关闭时间极短,不会影响每秒一次的数据接收。

5.4 两种工作模式说明

  1. 主动上传模式(本项目使用) 上电默认开启,模块每秒主动输出一帧数据,单片机不需要发送任何指令,程序逻辑简单,适合教学。
  2. 问答模式 单片机发送命令 FF 01 86,模块切换问答模式,停止自动上报,需要单片机主动查询才返回数据。一旦进入问答模式,只有重新断电上电才能恢复主动上传。

坑点:如果代码误调用切换问答模式,之后不循环发送查询指令,OLED 屏幕会停止刷新。

六、源代码相关

这里包含驱动文件、main 主函数代码

主要工程文件清单:

  1. Y01_3IN1.h 头文件,结构体、函数声明
  2. Y01_3IN1.c 驱动实现:初始化、中断回调、字节处理、帧校验解析、读取数据快照
  3. main.c 主函数:硬件初始化、循环读取传感器数据,调用 OLED 显示函数

核心函数简介:

  • Y01_3IN1_Init():初始化串口驱动
  • USART3_IRQHandler():串口 3 中断服务函数
  • Y01_3IN1_HandleReceivedByte():单字节处理,状态机帧同步
  • Y01_3IN1_Read():获取传感器数据快照,供 main 函数调用

maic.c代码

cpp 复制代码
/* Y01-3in1 air-quality teaching demonstration for STM32F103C8T6. */
#include "delay.h"
#include "sys.h"
#include "oled.h"
#include "Y01_3IN1.h"

#define DISPLAY_POLL_TIME_MS  50U

static void Y01_3IN1_ShowWaiting(void);
static void Y01_3IN1_ShowData(const Y01_3IN1_Data *data);
static u8 Y01_3IN1_DigitCount(u16 value);

int main(void)
{
	Y01_3IN1_Data air_data;

	delay_init();
	NVIC_Configuration();
	OLED_Init();
	OLED_ColorTurn(0);
	OLED_DisplayTurn(0);
	Y01_3IN1_Init();
	Y01_3IN1_ShowWaiting();

	while(1)
	{
		/* The module uploads a frame automatically about once per second. */
		if(Y01_3IN1_Read(&air_data) != 0U)
		{
			Y01_3IN1_ShowData(&air_data);
		}

		delay_ms(DISPLAY_POLL_TIME_MS);
	}
}

static void Y01_3IN1_ShowWaiting(void)
{
	OLED_Clear();
	OLED_ShowString(0, 0, (u8 *)"Y01 AIR QUALITY", 12);
	OLED_ShowString(0, 20, (u8 *)"WARMING UP...", 12);
	OLED_ShowString(0, 36, (u8 *)"WAITING FOR DATA", 12);
	OLED_ShowString(0, 52, (u8 *)"USART3 PB10/PB11", 12);
	OLED_Refresh();
}

static void Y01_3IN1_ShowData(const Y01_3IN1_Data *data)
{
	u16 whole;
	u16 fraction;
	u8 digits;
	u8 x;

	OLED_Clear();
	OLED_ShowString(0, 0, (u8 *)"Y01 AIR QUALITY", 12);

	/* TVOC is displayed as x.xxx mg/m3. */
	OLED_ShowString(0, 16, (u8 *)"TVOC:", 12);
	whole = data->tvoc_mg_m3_x1000 / 1000U;
	fraction = data->tvoc_mg_m3_x1000 % 1000U;
	digits = Y01_3IN1_DigitCount(whole);
	OLED_ShowNum(30, 16, whole, digits, 12);
	x = (u8)(30U + digits * 6U);
	OLED_ShowChar(x, 16, '.', 12);
	OLED_ShowNum((u8)(x + 6U), 16, fraction, 3, 12);
	OLED_ShowString((u8)(x + 24U), 16, (u8 *)"mg/m3", 12);

	/* Keep the order requested for the teaching screen: TVOC, CO2, HCHO. */
	OLED_ShowString(0, 32, (u8 *)"CO2 :", 12);
	digits = Y01_3IN1_DigitCount(data->co2_ppm);
	OLED_ShowNum(30, 32, data->co2_ppm, digits, 12);
	OLED_ShowString((u8)(30U + digits * 6U), 32, (u8 *)"ppm", 12);

	OLED_ShowString(0, 48, (u8 *)"HCHO:", 12);
	whole = data->hcho_mg_m3_x1000 / 1000U;
	fraction = data->hcho_mg_m3_x1000 % 1000U;
	digits = Y01_3IN1_DigitCount(whole);
	OLED_ShowNum(30, 48, whole, digits, 12);
	x = (u8)(30U + digits * 6U);
	OLED_ShowChar(x, 48, '.', 12);
	OLED_ShowNum((u8)(x + 6U), 48, fraction, 3, 12);
	OLED_ShowString((u8)(x + 24U), 48, (u8 *)"mg/m3", 12);

	OLED_Refresh();
}

static u8 Y01_3IN1_DigitCount(u16 value)
{
	if(value >= 10000U)
	{
		return 5U;
	}
	if(value >= 1000U)
	{
		return 4U;
	}
	if(value >= 100U)
	{
		return 3U;
	}
	if(value >= 10U)
	{
		return 2U;
	}
	return 1U;
}

Y01_3IN1.c代码

cpp 复制代码
#include "Y01_3IN1.h"

#include "misc.h"
#include "stm32f10x_gpio.h"
#include "stm32f10x_rcc.h"
#include "stm32f10x_usart.h"

/* Y01-3in1 使用 USART3 的默认引脚映射:PB10 发送、PB11 接收。 */
#define Y01_3IN1_USART              USART3
#define Y01_3IN1_USART_CLK          RCC_APB1Periph_USART3
#define Y01_3IN1_GPIO_PORT          GPIOB
#define Y01_3IN1_GPIO_CLK           RCC_APB2Periph_GPIOB
#define Y01_3IN1_TX_PIN             GPIO_Pin_10
#define Y01_3IN1_RX_PIN             GPIO_Pin_11

/* 串口中断逐字节填充此缓冲区,凑齐 9 字节后进行校验和解析。 */
static volatile u8 y01_rx_frame[Y01_3IN1_FRAME_SIZE];
static volatile u8 y01_rx_index;
static volatile u8 y01_new_frame;

/* 保存最近一帧有效数据及接收统计信息。 */
static volatile u16 y01_tvoc_x1000;
static volatile u16 y01_hcho_x1000;
static volatile u16 y01_co2_ppm;
static volatile u32 y01_rx_bytes;
static volatile u32 y01_valid_frames;
static volatile u32 y01_checksum_errors;

static u8 Y01_3IN1_Checksum(const volatile u8 *frame);
static void Y01_3IN1_SendByte(u8 byte);

/* 使用模块默认的 9600 波特率初始化驱动。 */
void Y01_3IN1_Init(void)
{
	Y01_3IN1_InitWithBaudRate(Y01_3IN1_DEFAULT_BAUDRATE);
}

/* 初始化 GPIO、USART3 和接收中断。 */
void Y01_3IN1_InitWithBaudRate(u32 baudrate)
{
	GPIO_InitTypeDef gpio;
	USART_InitTypeDef usart;
	NVIC_InitTypeDef nvic;

	if(baudrate == 0U)
	{
		baudrate = Y01_3IN1_DEFAULT_BAUDRATE;
	}

	/* 分别开启 GPIOB、复用功能模块和 USART3 的外设时钟。 */
	RCC_APB2PeriphClockCmd(Y01_3IN1_GPIO_CLK | RCC_APB2Periph_AFIO, ENABLE);
	RCC_APB1PeriphClockCmd(Y01_3IN1_USART_CLK, ENABLE);

	/* 关闭 USART3 重映射,使用默认的 TX/PB10、RX/PB11。 */
	GPIO_PinRemapConfig(GPIO_FullRemap_USART3, DISABLE);

	/* PB10 配置为复用推挽输出,用于向模块发送问答模式命令。 */
	GPIO_StructInit(&gpio);
	gpio.GPIO_Pin = Y01_3IN1_TX_PIN;
	gpio.GPIO_Speed = GPIO_Speed_50MHz;
	gpio.GPIO_Mode = GPIO_Mode_AF_PP;
	GPIO_Init(Y01_3IN1_GPIO_PORT, &gpio);

	/* PB11 配置为上拉输入,用于接收模块每秒主动上传的数据。 */
	gpio.GPIO_Pin = Y01_3IN1_RX_PIN;
	gpio.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_Init(Y01_3IN1_GPIO_PORT, &gpio);

	/* 串口工作方式为 8N1,同时启用发送和接收功能。 */
	USART_DeInit(Y01_3IN1_USART);
	USART_StructInit(&usart);
	usart.USART_BaudRate = baudrate;
	usart.USART_WordLength = USART_WordLength_8b;
	usart.USART_StopBits = USART_StopBits_1;
	usart.USART_Parity = USART_Parity_No;
	usart.USART_HardwareFlowControl = USART_HardwareFlowControl_None;
	usart.USART_Mode = USART_Mode_Rx | USART_Mode_Tx;
	USART_Init(Y01_3IN1_USART, &usart);

	/* 开启 USART3 中断通道,串口每收到一个字节便进入中断处理。 */
	nvic.NVIC_IRQChannel = USART3_IRQn;
	nvic.NVIC_IRQChannelPreemptionPriority = 2U;
	nvic.NVIC_IRQChannelSubPriority = 2U;
	nvic.NVIC_IRQChannelCmd = ENABLE;
	NVIC_Init(&nvic);

	Y01_3IN1_Reset();
	USART_ITConfig(Y01_3IN1_USART, USART_IT_RXNE, ENABLE);
	USART_Cmd(Y01_3IN1_USART, ENABLE);
}

/* 清空解析器、最近测量值及所有接收统计信息。 */
void Y01_3IN1_Reset(void)
{
	u8 i;

	/* 清空共享变量时暂时关闭接收中断,避免中断同时修改这些变量。 */
	USART_ITConfig(Y01_3IN1_USART, USART_IT_RXNE, DISABLE);

	for(i = 0U; i < Y01_3IN1_FRAME_SIZE; i++)
	{
		y01_rx_frame[i] = 0U;
	}

	y01_rx_index = 0U;
	y01_new_frame = 0U;
	y01_tvoc_x1000 = 0U;
	y01_hcho_x1000 = 0U;
	y01_co2_ppm = 0U;
	y01_rx_bytes = 0U;
	y01_valid_frames = 0U;
	y01_checksum_errors = 0U;

	USART_ITConfig(Y01_3IN1_USART, USART_IT_RXNE, ENABLE);
}

/* 以原子快照的方式读取最近一帧有效数据。 */
u8 Y01_3IN1_Read(Y01_3IN1_Data *data)
{
	u8 is_new;

	if(data == 0)
	{
		return 0U;
	}

	/* 复制多个字段时关闭接收中断,防止得到前后两帧混合的数据。 */
	USART_ITConfig(Y01_3IN1_USART, USART_IT_RXNE, DISABLE);

	data->tvoc_mg_m3_x1000 = y01_tvoc_x1000;
	data->hcho_mg_m3_x1000 = y01_hcho_x1000;
	data->co2_ppm = y01_co2_ppm;
	data->rx_bytes = y01_rx_bytes;
	data->valid_frames = y01_valid_frames;
	data->checksum_errors = y01_checksum_errors;
	is_new = y01_new_frame;
	y01_new_frame = 0U;

	USART_ITConfig(Y01_3IN1_USART, USART_IT_RXNE, ENABLE);
	return is_new;
}

/* 校验帧头和累加校验和,然后将高低字节组合为 16 位数据。 */
u8 Y01_3IN1_ParseFrame(const u8 frame[Y01_3IN1_FRAME_SIZE],
						Y01_3IN1_Data *data)
{
	if((frame == 0) || (data == 0))
	{
		return Y01_3IN1_ERROR_ARGUMENT;
	}

	/* 前两个字节必须是模块固定地址 0x2C、0xE4。 */
	if((frame[0] != Y01_3IN1_FRAME_HEADER_1) ||
	   (frame[1] != Y01_3IN1_FRAME_HEADER_2))
	{
		return Y01_3IN1_ERROR_HEADER;
	}

	/* 第 9 字节必须等于前 8 字节累加结果的低 8 位。 */
	if(Y01_3IN1_Checksum(frame) != frame[8])
	{
		return Y01_3IN1_ERROR_CHECKSUM;
	}

	data->tvoc_mg_m3_x1000 = (u16)(((u16)frame[2] << 8) | frame[3]);
	data->hcho_mg_m3_x1000 = (u16)(((u16)frame[4] << 8) | frame[5]);
	data->co2_ppm = (u16)(((u16)frame[6] << 8) | frame[7]);

	return Y01_3IN1_OK;
}

/*
 * 流式数据帧解析状态机:
 * 先寻找连续的帧头 0x2C、0xE4,再接收剩余 7 个字节。
 */
void Y01_3IN1_HandleReceivedByte(u8 byte)
{
	y01_rx_bytes++;

	/* 等待第一个帧头字节 0x2C,其他无关字节直接丢弃。 */
	if(y01_rx_index == 0U)
	{
		if(byte == Y01_3IN1_FRAME_HEADER_1)
		{
			y01_rx_frame[0] = byte;
			y01_rx_index = 1U;
		}
		return;
	}

	/* 检查第二个帧头字节 0xE4,并在通信干扰后自动重新同步。 */
	if(y01_rx_index == 1U)
	{
		if(byte == Y01_3IN1_FRAME_HEADER_2)
		{
			y01_rx_frame[1] = byte;
			y01_rx_index = 2U;
		}
		else if(byte == Y01_3IN1_FRAME_HEADER_1)
		{
			y01_rx_frame[0] = byte;
		}
		else
		{
			y01_rx_index = 0U;
		}
		return;
	}

	/* 帧头正确后,按顺序保存剩余数据和校验和。 */
	y01_rx_frame[y01_rx_index] = byte;
	y01_rx_index++;

	if(y01_rx_index < Y01_3IN1_FRAME_SIZE)
	{
		return;
	}

	/* 只有校验正确的数据才会更新到主循环可读取的测量结果中。 */
	if(Y01_3IN1_Checksum(y01_rx_frame) == y01_rx_frame[8])
	{
		y01_tvoc_x1000 = (u16)(((u16)y01_rx_frame[2] << 8) |
								 y01_rx_frame[3]);
		y01_hcho_x1000 = (u16)(((u16)y01_rx_frame[4] << 8) |
								 y01_rx_frame[5]);
		y01_co2_ppm = (u16)(((u16)y01_rx_frame[6] << 8) |
							  y01_rx_frame[7]);
		y01_valid_frames++;
		y01_new_frame = 1U;
	}
	else
	{
		y01_checksum_errors++;
	}

	y01_rx_index = 0U;
}

/* 发送问答模式命令 FF 01 86;正常主动上传时无需调用。 */
void Y01_3IN1_RequestMeasurement(void)
{
	Y01_3IN1_SendByte(0xFFU);
	Y01_3IN1_SendByte(0x01U);
	Y01_3IN1_SendByte(0x86U);
}

/* 计算前 8 个字节累加和的低 8 位。 */
static u8 Y01_3IN1_Checksum(const volatile u8 *frame)
{
	u8 checksum = 0U;
	u8 i;

	for(i = 0U; i < 8U; i++)
	{
		checksum = (u8)(checksum + frame[i]);
	}
	return checksum;
}

/* 阻塞等待发送数据寄存器空闲,然后发送一个字节。 */
static void Y01_3IN1_SendByte(u8 byte)
{
	while(USART_GetFlagStatus(Y01_3IN1_USART, USART_FLAG_TXE) == RESET)
	{
	}
	USART_SendData(Y01_3IN1_USART, byte);
}

/* USART3 接收中断服务函数。 */
void USART3_IRQHandler(void)
{
	if(USART_GetITStatus(Y01_3IN1_USART, USART_IT_RXNE) != RESET)
	{
		Y01_3IN1_HandleReceivedByte(
			(u8)(USART_ReceiveData(Y01_3IN1_USART) & 0x00FFU));
	}
	else if((USART_GetFlagStatus(Y01_3IN1_USART, USART_FLAG_ORE) != RESET) ||
			(USART_GetFlagStatus(Y01_3IN1_USART, USART_FLAG_NE) != RESET) ||
			(USART_GetFlagStatus(Y01_3IN1_USART, USART_FLAG_FE) != RESET))
	{
		/* 先读取状态寄存器再读取数据寄存器,可以清除串口接收错误标志。 */
		(void)USART_ReceiveData(Y01_3IN1_USART);
	}
}

Y01_3IN1.h代码

cpp 复制代码
#ifndef __Y01_3IN1_H
#define __Y01_3IN1_H

#include "stm32f10x.h"

/*
 * Y01-3in1 模块接线:
 *   A(模块接收端)-> PB10(USART3_TX)
 *   B(模块发送端)-> PB11(USART3_RX)
 *
 * 串口参数:9600 波特率、8 位数据位、无校验、1 位停止位。
 * 模块默认每秒主动上传一帧数据,帧格式如下:
 *   2C E4 TVOC高字节 TVOC低字节 HCHO高字节 HCHO低字节
 *         CO2高字节  CO2低字节  累加校验和
 */
#define Y01_3IN1_FRAME_SIZE          9U
#define Y01_3IN1_FRAME_HEADER_1      0x2CU
#define Y01_3IN1_FRAME_HEADER_2      0xE4U
#define Y01_3IN1_DEFAULT_BAUDRATE    9600U

#define Y01_3IN1_OK                  0U
#define Y01_3IN1_ERROR_ARGUMENT      1U
#define Y01_3IN1_ERROR_HEADER        2U
#define Y01_3IN1_ERROR_CHECKSUM      3U

typedef struct
{
	/* TVOC 和 HCHO 的一个原始计数代表 0.001 mg/m3。 */
	u16 tvoc_mg_m3_x1000;
	u16 hcho_mg_m3_x1000;

	/* CO2 数据的单位为 ppm,不需要进行小数换算。 */
	u16 co2_ppm;

	/* 接收统计信息,可用于教学演示和串口通信故障排查。 */
	u32 rx_bytes;
	u32 valid_frames;
	u32 checksum_errors;
} Y01_3IN1_Data;

/* 配置 PB10、PB11 和 USART3,并清空数据帧解析状态。 */
void Y01_3IN1_Init(void);
void Y01_3IN1_InitWithBaudRate(u32 baudrate);

/* 清空最近一次测量值、接收统计信息和数据帧解析状态。 */
void Y01_3IN1_Reset(void);

/*
 * 将最近一次有效测量数据复制到 data。
 * 每收到一帧新的有效数据,只返回一次 1;没有新数据时返回 0。
 * 即使返回 0,data 中仍会保存最近一次接收到的测量值和统计信息。
 */
u8 Y01_3IN1_Read(Y01_3IN1_Data *data);

/* 校验并解析一帧完整的 9 字节数据,便于协议教学和独立测试。 */
u8 Y01_3IN1_ParseFrame(const u8 frame[Y01_3IN1_FRAME_SIZE],
						Y01_3IN1_Data *data);

/* 向流式数据帧解析器送入一个串口接收字节。 */
void Y01_3IN1_HandleReceivedByte(u8 byte);

/*
 * 发送 FF 01 86,将模块切换到问答模式。
 * 默认主动上传模式不需要调用此函数;模块重新上电后恢复主动上传模式。
 */
void Y01_3IN1_RequestMeasurement(void);

#endif

七、实物演示与实验现象

7.1 上电预热现象

气敏传感器需要预热,模块建议预热至少 2 分钟,教学实验建议预热 5‑10 分钟再做实验。上电初期的数据只做显示,不能当作稳定测量结果。

OLED 开机等待界面:

cpp 复制代码
Y01 AIR QUALITY
WARMING UP...
WAITING FOR DATA
USART3 PB10/PB11

收到第一帧合法数据后切换显示界面:

cpp 复制代码
Y01 AIR QUALITY
TVOC:0.005mg/m3
CO2 :364ppm
HCHO:0.001mg/m3

小提示:帧顺序协议内部是 TVOC、HCHO、CO₂,屏幕为阅读友好调整为 TVOC、CO₂、HCHO,只是显示排版改变,解析逻辑不变。

正常工作状态,屏幕大约每秒刷新一次。可以通过调试器观察变量valid_frames有效帧计数,如果计数每秒 + 1,代表串口通信完全正常。

⚠️安全提醒:远离明火,禁止液体淋到传感器,不要长时间高浓度熏蒸传感器。该实验演示的是气敏元件交叉响应特性,不是精准定量测量。

7.3 故障排查表

故障现象 观察现象 排查方案
OLED 一直显示 WAITING FOR DATA 接收字节计数始终为 0 检查 Y01 模块 5V 供电、GND 共地、B 引脚接 PB11,确认 USART3 初始化
收到字节,但是没有有效帧 valid_frames 始终为 0 确认波特率 9600 8N1,A/B 引脚没有接反,排查电平问题
checksum_errors 持续上涨 大量校验失败 检查共地,缩短杜邦线,电源滤波,排查干扰
收到一帧后不再更新 valid_frames 不再增加 是否误发送命令切到问答模式;检查 B 引脚是否松动
数值不变,但是 valid_frames 每秒 + 1 通信一切正常 当前环境气体稳定;做挥发物实验验证传感器响应,不是程序卡死
对着吹气 CO₂读数几乎不变 帧计数正常 CO₂是推算参考值,非红外传感器,对人体呼气不敏感,属于正常现象
TVOC/HCHO/CO₂三个读数一起跳动 帧计数正常 广谱传感器交叉干扰,属于模块本身特性

八、总结

  1. Y01‑3IN1 属于串口输出空气质量模块,对比模拟传感器,硬件上省去 ADC,软件重点是帧同步、累加校验和、高低字节拼接解析。
  2. 使用串口中断 + 状态机,实现字节流识别完整数据帧,保证 OLED 界面不会被串口接收阻塞。
  3. 一定要区分:模块 CO₂、HCHO 是基于 TVOC 广谱传感器算法推算的参考值,仅适合教学实验看趋势,不能当作专业检测仪。
  4. 硬件调试最容易踩坑:串口收发接反、模块没有共地、误切换问答模式

一句话回顾整个项目:Y01‑3in1 感知环境气体变化生成 TVOC、HCHO、CO₂数字帧,UART 主动上报;STM32 通过 USART3 中断完成帧同步校验解析,最后 OLED 展示测量数据。

相关推荐
宣宣猪的小花园.29 分钟前
【控制算法】PID 不只是三个参数:比例、积分、微分各在解决什么问题
人工智能·嵌入式硬件·机器学习
玩转单片机与嵌入式42 分钟前
深入浅出TinyML 21:INT8量化改变了模型中的哪些数据?
stm32·深度学习·tinyml
别催小唐敲代码43 分钟前
stm32_mpu6050_滤波教程
stm32·单片机·嵌入式硬件
CPETW44 分钟前
USB TO I2C_(Excel)_Scan ---- 1000KHz总线速率测试_A
c语言·开发语言·网络·科技·单片机
一条破秋裤1 小时前
STM32 学习笔记:GPIO 输出实验——LED 闪烁、流水灯与蜂鸣器
笔记·stm32·学习
单片机仿真设计2 小时前
【proteus仿真】基于 51 单片机距离传感器警报装置设计(仿真图+程序)
单片机·嵌入式硬件·51单片机·proteus·毕设
一条破秋裤3 小时前
STM32 学习笔记:GPIO 输出原理与硬件设计
笔记·stm32·学习
单片机仿真设计4 小时前
【proteus仿真设计】基于 STM32 单片机火灾监测报警系统设计(仿真图+程序)
stm32·单片机·嵌入式硬件·proteus·毕设
一条破秋裤4 小时前
STM32 学习笔记:OLED 调试工具与 Keil 在线调试
笔记·stm32·学习