文章目录
-
- 一、I2C 三句话讲明白
-
- 1.1 硬件:为什么必须上拉,为什么不能推挽
- 1.2 起始信号 S 与终止信号 P
- 1.3 数据位:高电平读,低电平改
- 1.4 第 9 位:应答 ACK / 非应答 NACK
- 1.5 寻址:7 位地址 + 1 位读写
- 1.6 四种典型帧:就是四张"戏票"
- 1.7 时序图 ↔ 代码对照表
- 二、先确认"它在不在":读 PART_ID
- 三、地址与寄存器
-
- 3.1 地址由 ADDR 引脚决定
- 3.2 本工程用到的寄存器
- 3.3 硬件前提:裸芯片和模块不是一回事
- 四、读写寄存器的封装
- 五、初始化:先复位,再进标准模式
-
- 5.1 预热:前几分钟的数据不能当真
- 5.2 一次完整测量的时序(状态 + 读数流程)
- 六、读三个数据 + 小端拼接
-
- 6.1 什么是小端
- 6.2 AQI 只有 1 个字节
- 6.3 DEVICE_STATUS 可以判断"数据新不新"
- 七、空气好坏的判定
- 八、读不到数据时,按这个顺序查
- 九、本章易错点总结
- 十、小结与下一篇
- 附录 A:完整代码(ens160.h / ens160.c)
-
- A.1 `ens160.h`(对外接口)
- A.2 `ens160.c`(驱动实现)
- A.3 在 `main.c` 中调用
- A.4 最小验证步骤
系列第七篇。DHT11 是"一根线",ENS160 是"两根线"。别看不起眼的两根线,I2C 的坑一点不比单总线少。
我第一次读 ENS160,HAL_I2C_Mem_Read 一直返回 HAL_ERROR,查了两个下午,最后发现是模块的 ADDR 引脚接法和我代码里的地址对不上。
一、I2C 三句话讲明白
1.1 硬件:为什么必须上拉,为什么不能推挽
I2C 总线上挂着很多设备,每根线的电路结构都是"开漏":
- 设备只能做两件事:把线拉低(输出 0),或者松手不管(高阻,输出 1);
- 松手时线为什么是高电平?靠外部的上拉电阻把线拉到 VCC;
- 所以总线上只要有任何一个设备拉低,整条线就是低。这就是"线与"。

VCC
│
┌──┴──┐ 上拉电阻(模块上一般 4.7k / 10k)
│ │
SCL ──┴─────┴── … ── 主机 + 从机1 + 从机2
SDA ──┬─────┬── … ── 每个设备的引脚都是开漏
│ │
└──┬──┘
│
GND(设备内部 MOS 导通时才拉低)
为什么不能用推挽? 推挽会主动输出高电平。如果设备 A 输出高、设备 B 输出低,电源和地之间就被直接短路,电流瞬间很大,可能烧引脚。开漏没有这个问题。
多主设备同时说话怎么办? 开漏天生支持"仲裁":谁发 1(松手)却读到 0(被别人拉低),谁就知道自己抢输了,立刻退出。整个过程不会损坏硬件。
电平不匹配怎么办? 高电平由上拉电阻接的 VCC 决定,和芯片自身供电无关。所以 3.3V 的 STM32 和 5V 传感器可以挂同一条总线,只要上拉接到 5V、STM32 引脚耐压够。

上拉电阻的作用 避免总线冲突和实现“线与”(Wire-AND)功能 
这是最核心的原因。I²C协议支持多主多从(Multiple Masters and Slaves)的架构。
- 开漏输出:意味着芯片内部的输出级电路只有一个连接到地的MOSFET(像是一个开关),而没有直接连接到电源(VCC)的上拉部分。
- 当MOSFET关闭(开关断开)时,输出引脚为高阻态,不影响总线。
- 当MOSFET开启(开关闭合)时,输出引脚被强行拉低到地(GND),输出逻辑’0’。
- “线与”逻辑:由于所有设备的总线(SDA和SCL)都是开漏结构并通过上拉电阻连接到VCC,任何一个设备只要主动拉低总线(输出’0’),整条总线就是低电平。
- 逻辑“与”关系:总线的最终状态 = 设备1的输出 AND 设备2的输出 AND … 设备N的输出。只有所有设备都输出’1’(即释放总线,不拉低),总线才为高电平’1’。
- 如何避免冲突:这种“线与”特性使得总线天生就具备冲突检测(Arbitration) 机制。
- 假设两个主设备同时开始发送数据。它们一边发送,一边会读取总线上的实际电平,并与自己发送的电平进行比较。
- 如果某个主设备发送了一个高电平’1’(释放总线),但它检测到总线实际是低电平’0’(被另一个主设备拉低了),它就立刻知道自己失去了仲裁,会立即停止操作,转为从接收模式。
- 这个过程不会损坏硬件,也无需额外的冲突检测线路。这是推挽输出(Push-Pull)绝对无法实现的,如果两个推挽输出的设备一个输出高一个输出低,会造成电源对地短路,烧毁器件。 电平转换的灵活性(兼容不同电压的设备) 这是另一个巨大的优势。由于总线上的高电平电压完全由上拉电阻所连接的电源(VCC) 决定,而与各个设备自身的工作电压(VDD)无关。
- 场景:一个3.3V的STM32(主设备)想要和一个5V的传感器(从设备)通信。
- 实现:
- 将I²C总线的上拉电阻连接到5V电源。
- 3.3V的STM32的I²C引脚设置为开漏模式,并且其耐压需要能承受5V(很多现代MCU的IO口是5V tolerant的)。
- 工作原理:
- 当STM32要输出’0’时,它内部MOSFET导通,将总线拉低到GND(0V)。对于5V的传感器来说,0V显然是逻辑’0’。
- 当STM32要输出’1’时,它释放总线(高阻态),上拉电阻将总线拉高到5V。STM32会读取到这个5V的高电平,虽然超过了其本身的3.3V供电,但只要引脚耐压足够就没有问题。同时,5V的传感器也正确地看到了5V的逻辑’1’。
- 好处:你可以在一条I²C总线上混用不同工作电压的芯片,而不需要任何额外的电平转换电路。只需将上拉电阻接到一个所有设备都能接受的最高电压即可。 简化驱动电路 开漏结构非常简单,只需要一个MOSFET管来控制对地的导通,而不需要更复杂的推挽电路(既有上拉P-MOS,又有下拉N-MOS)。这使得IC内部的电路更简单,成本更低。
1.2 起始信号 S 与终止信号 P
这两条规则只有一句话,但方向记反就永远通信不上:
- 起始 S:SCL 为高期间,SDA 由高 → 低;
- 终止 P:SCL 为高期间,SDA 由低 → 高。
起始 S:
SCL ‾‾‾‾‾‾‾‾\\__________
SDA ‾‾‾‾\\______________
↑ SCL 高电平期间,SDA 下降沿 = 起始
终止 P:
SCL ________/‾‾‾‾‾‾‾‾‾
SDA ______/‾‾‾‾‾‾‾‾‾‾‾
↑ SCL 高电平期间,SDA 上升沿 = 终止
为什么必须在 SCL 高电平期间判断?因为数据位只允许在 SCL 低电平期间变化。高电平期间 SDA 的任何跳变都是"命令",不是数据。
起始之后总线处于"占用"状态,终止之后回到"空闲"。一次完整的通信必须成对出现 S 和 P,中间不能只发 S 不发 P,否则总线会一直被占着,下次通信直接失败。

1.3 数据位:高电平读,低电平改
- SCL 低电平期间:发送方可以改 SDA(准备下一位);
- SCL 高电平期间:SDA 必须稳定,接收方在这时采样。
SCL ____/‾‾‾‾\\____/‾‾‾‾\\____/‾‾‾‾\\____
SDA ‾‾‾‾‾‾‾‾\\____/‾‾‾‾‾‾‾‾\\____/‾‾‾‾‾‾
保持 变化 保持 变化 保持
↑ 只在 SCL 低电平期间允许变化

一个 SCL 周期传 1 位,先传最高位(MSB)。所以一个字节 8 位要 8 个时钟周期。
这也是为什么软件模拟 I2C 时,代码总是这个套路:
SCL_LOW(); // 低电平期间改数据
SDA_WRITE(bit);
delay_us(...);
SCL_HIGH(); // 高电平期间数据稳定
delay_us(...);
SCL_LOW();
1.4 第 9 位:应答 ACK / 非应答 NACK
8 位数据发完,必须再来 1 个时钟,这一位由接收方控制:
- 接收方把 SDA 拉低 → ACK(应答),表示"我收到了";
- 接收方松手让 SDA 为高 → NACK(非应答),表示"我不要了 / 没有这个设备"。
第1位 … 第8位 第9位
SCL __/‾\\__/‾\\…__/‾\\__
SDA D7 D6 … D0 ACK
↑ 接收方在这期间拉低 = ACK
所以一帧是 8 + 1 = 9 个时钟。
HAL 返回值里的 HAL_ERROR,很大一部分就是"第 9 位没等到 ACK"。常见原因只有三个:地址不对、设备没供电、上拉缺失。

1.5 寻址:7 位地址 + 1 位读写
起始信号之后,主机发的第一个字节不是数据,而是"喊谁":
bit7 bit6 bit5 bit4 bit3 bit2 bit1 bit0
A6 A5 A4 A3 A2 A1 A0 R/W
└──────────── 7 位从机地址 ────────────┘ └ 0=写 1=读
ENS160 的 7 位地址是 0x53,套进这个格式:
0x53 = 101 0011
左移 1 位 → 1010 0110 = 0xA6 (最低位 0,写)
最低位置 1 → 1010 0111 = 0xA7 (最低位 1,读)
所以代码里必须写:
#define ENS160_ADDR_7BIT 0x53U
#define ENS160_ADDR (ENS160_ADDR_7BIT << 1) // 0xA6
直接把 0x53 传给 HAL,HAL 会把它当成 8 位地址用,结果就是永远 NACK、读不到任何数据。这是 I2C 驱动最常见的一个错。
//IIC地址的表示方式一般使用两位16进制表示 //但是IIC地址只有7位,两位16进制表示了8位二进制 //此时会有两种分配方式 // 假设IIC地址为1100 111 //1从最低位补零的方式 1100 1110=0xCE 此种方式写的话地址可以直接用,而读时需要或等于0x01 //2从最高位补零 0110 0111=0x67 此种方式发送时需要先左移1位然后根据都或者写操作进行或等于或者与等于
1.6 四种典型帧:就是四张"戏票"
HAL 的 Mem_Read / Mem_Write 本质上只做四件事,看懂这四张图,就再也不用死记参数:
① 主机给从机写 1 个字节(写寄存器)
S → 地址+W → ACK → 寄存器号 → ACK → 数据 → ACK → P

② 主机给从机写多个字节(连续写)
S → 地址+W → ACK → 寄存器号 → ACK → 数据1 → ACK → 数据2 → ACK → … → P

③ 主机从从机读 1 个字节(读寄存器,最常用)
S → 地址+W → ACK → 寄存器号 → ACK → Sr → 地址+R → ACK → 数据 → NACK → P
↑
重复起始,不先发 P

④ 主机从从机读多个字节(连续读)
S → 地址+W → ACK → 寄存器号 → ACK → Sr → 地址+R → ACK
→ 数据1 → ACK → 数据2 → ACK → … → 最后一个数据 → NACK → P

两个关键点:
- 读寄存器必须"先写寄存器号,再读",中间用重复起始 Sr 换方向,不能先停止再起始(停止会释放总线,别的设备可能插进来);
- 最后一个字节主机要回 NACK,告诉从机"够了,别再发了",然后发 P。
HAL_I2C_Mem_Read 把这整套流程封装好了,你只要给"地址、寄存器号、长度"三个信息:
HAL_I2C_Mem_Read(&hi2c3, ENS160_ADDR, 0x21, I2C_MEMADD_SIZE_8BIT, buf, 1, 100);
// ↑寄存器号 ↑地址是 8 位 ↑读 1 字节
Mem_Write 同理:
HAL_I2C_Mem_Write(&hi2c3, ENS160_ADDR, 0x10, I2C_MEMADD_SIZE_8BIT, &mode, 1, 100);
“Mem” 的意思是:把这个芯片当成一块内存来访问,给它一个"寄存器地址",就能读写对应位置的值。
1.7 时序图 ↔ 代码对照表
| S(起始) | 主机 | HAL_I2C_Mem_Read 内部自动产生 | SCL_HIGH(); SDA_LOW(); |
| 地址+W | 主机 | 传进去的 DevAddress | 发送 addr<<1 |
| ACK | 从机 | 返回 HAL_OK 的前提 | 读第 9 位是否为 0 |
| 寄存器号 | 主机 | 传进去的 MemAddress | 发送寄存器地址 |
| Sr(重复起始) | 主机 | Mem_Read 内部自动产生 | 重新发 S,不发 P |
| 地址+R | 主机 | Mem_Read 内部自动切换 | 发送 `(addr<<1) |
| 数据 | 从机 | 写进你给的 buf | 逐位读 8 次 |
| NACK(最后一位) | 主机 | Mem_Read 内部自动发 | 第 9 个时钟松手 |
| P(终止) | 主机 | 函数返回前自动产生 | SCL_LOW(); SDA_LOW(); SCL_HIGH(); SDA_HIGH(); |
这张表就是排错地图:如果逻辑分析仪上看到"地址发了、没有 ACK",问题在硬件或地址;如果"ACK 都有、数据全 0",问题在寄存器号或工作模式。
二、先确认"它在不在":读 PART_ID
在写任何业务代码之前,先做一件事:读器件 ID。
ENS160 的 PART_ID 寄存器(0x00)正常应读回两个字节,顺序不能反:
第 1 个字节:0x60 (PART_ID_LSB,低字节)
第 2 个字节:0x01 (PART_ID_MSB,高字节)
uint8_t id[2];
HAL_I2C_Mem_Read(&hi2c3, 0x53 << 1, 0x00,
I2C_MEMADD_SIZE_8BIT, id, 2, 100);
printf("PART_ID = %02X %02X\\r\\n", id[0], id[1]);
- 读回 60 01:总线通、地址对、传感器活着,可以往下走;
- 读回 00 00 或超时:先别怀疑初始化,问题一定在硬件或地址;
- 如果你在串口上看到 01 60:先检查 printf 里是不是把 id[0] 和 id[1] 写反了。
手册里常把 PART_ID 写成 0x0160,这是把高字节和低字节拼在一起的写法:高字节 0x01、低字节 0x60。而寄存器是低字节先出,所以实际读回的顺序是 60 01。这两件事不矛盾,只是"数值表示"和"传输顺序"的区别。
这一步的价值在于:把"I2C 通信问题"和"传感器配置问题"分开。如果连 ID 都读不到,后面写 OPMODE、读 AQI 全是白费。
三、地址与寄存器
3.1 地址由 ADDR 引脚决定
| 接 GND | 0x52 | 0xA4 |
| 接 3.3V | 0x53 | 0xA6 |
本工程代码用的是 0x53,所以模块的 ADDR 应该接高(或模块内部已上拉)。如果你的模块 ADDR 是接地的,就读不到——这是"代码没错但读不到"的第一嫌疑。
调试时最省事的做法:
if(HAL_I2C_IsDeviceReady(&hi2c3, 0x52 << 1, 3, 100) == HAL_OK)
printf("found 0x52\\r\\n");
if(HAL_I2C_IsDeviceReady(&hi2c3, 0x53 << 1, 3, 100) == HAL_OK)
printf("found 0x53\\r\\n");
两个都试一遍,哪个 OK 用哪个。
3.2 本工程用到的寄存器
| PART_ID | 0x00 | 2 | 只读,正常依次读回 0x60、0x01 |
| OPMODE | 0x10 | 1 | 0xF0 软复位,0x02 标准模式 |
| DEVICE_STATUS | 0x20 | 1 | bit1 = NEWDAT,有新数据;读数据后自动清零 |
| DATA_AQI | 0x21 | 1 | 1~5,越小越好 |
| DATA_TVOC | 0x22 | 2 | 小端,单位 ppb |
| DATA_ECO2 | 0x24 | 2 | 小端,单位 ppm |
| TEMP_IN / RH_IN | 0x13 / 0x15 | 2 | 写入外部温湿度做补偿(本工程未用) |
注意 AQI 是 1 个字节,TVOC 和 eCO2 各 2 个字节。这三个量在地址上是连续的:0x21 ~ 0x25 一共 5 个字节,一次读回来就能全部拿到,后面第六节会用到。
一个概念要提前说清楚:ENS160 的 eCO2 不是真的 CO2 传感器测出来的,它是根据 TVOC 等数据"推算"的等效值(equivalent CO2)。所以它和专业的 NDIR CO2 传感器数值不会完全一致,不要拿它去校准空调。
3.3 硬件前提:裸芯片和模块不是一回事
这一节很重要,因为接错电源会直接烧芯片。
ENS160 有两种供电引脚:
| VDD | 芯片核心 + 内部加热器 | 1.71 ~ 1.98V(典型 1.8V) | 裸芯片必须经 LDO 降到 1.8V |
| VDDIO | 通信引脚的电平参考 | 1.71 ~ 3.6V | 直接接 3.3V |
为什么搞两个电源?因为芯片内部只能承受 1.8V,但 STM32 是 3.3V 电平。VDDIO 接 3.3V 后,SDA/SCL 输出的高电平也是 3.3V,STM32 才认得出来。
如果你买的是"ENS160 模块":板上通常已经有 LDO(把 3.3V 降到 1.8V)和上拉电阻,你只要给模块 VCC 接 3.3V、GND 共地即可。绝对不能接 5V。
如果你买的是"裸芯片":必须自己加 1.8V LDO,VDD 和 VDDIO 分开接。直接把 VDD 接 3.3V,芯片会瞬间损坏,而且这种损坏是不可逆的。
另外三个引脚也和"能不能通"直接相关:
| CSn | 选 I2C 还是 SPI | 接高(VDDIO) 才是 I2C;接低进 SPI |
| MISO/ADDR | 决定地址最低位 | 接低 = 0x52;接高 = 0x53 |
| INTn | 数据就绪中断 | 本工程不用,悬空或按手册处理即可 |
模块上的 SCL/SDA 一般已有 4.7k~10k 上拉。如果你用裸芯片,必须自己加,否则总线空闲时不是高电平,连起始信号都发不出去。
四、读写寄存器的封装
static HAL_StatusTypeDef ENS160_READ_REG(uint8_t reg, uint8_t *data, uint8_t len)
{
return HAL_I2C_Mem_Read(&hi2c3, ENS160_ADDR, reg,
I2C_MEMADD_SIZE_8BIT, data, len,
HAL_MAX_DELAY);
}
static HAL_StatusTypeDef ENS160_WriteReg(uint8_t reg, uint8_t *data, uint8_t len)
{
return HAL_I2C_Mem_Write(&hi2c3, ENS160_ADDR, reg,
I2C_MEMADD_SIZE_8BIT, data, len,
HAL_MAX_DELAY);
}
三个细节:
① I2C_MEMADD_SIZE_8BIT
ENS160 的寄存器地址是 8 位(一个字节)。有些芯片是 16 位地址,这时要选 I2C_MEMADD_SIZE_16BIT。选错会访问到完全不同的寄存器,读出来全是 0。
② hi2c3 来自 i2c.c,不要重复定义
extern I2C_HandleTypeDef hi2c3; // 在 i2c.h 里
如果你在 ens160.c 里再写一遍 I2C_HandleTypeDef hi2c3;,链接会报:
L6200E: Symbol hi2c3 multiply defined
③ HAL_MAX_DELAY 是一把双刃剑
它表示"永不超时"。总线正常时没问题;一旦 SDA 被拉死、模块没接好,程序会永久卡在 HAL_I2C_Mem_Read 里,现象是整机不响应、按键全无反应。
调试阶段建议先改成 100(100ms):
HAL_I2C_Mem_Read(..., 100);
等确认通信正常,再决定要不要恢复 HAL_MAX_DELAY。
五、初始化:先复位,再进标准模式
ENS160 有四种工作模式:
| 0x00 | 深度睡眠 | 上电默认,不测量、数据不刷新 |
| 0x01 | 空闲 | 内部算法预热,不输出数据 |
| 0x02 | 标准模式 | 连续测量 AQI / TVOC / eCO2 |
| 0xF0 | 软复位 | 恢复到上电默认状态 |
初始化就两步:
uint8_t ens160_init(void)
{
uint8_t mode = 0xF0; // ① 软复位
if(ENS160_WriteReg(0x10, &mode, 1) != HAL_OK) return 2;
HAL_Delay(20);
mode = 0x02; // ② 标准模式
if(ENS160_WriteReg(0x10, &mode, 1) != HAL_OK) return 3;
HAL_Delay(20);
return 0; // 成功
}
关键点:
- 不写 0x02,读出来永远是 0 或初值。因为芯片还在深度睡眠,数据寄存器根本不更新;
- 复位后要等 10~20ms,等芯片内部稳定;
- 每次写寄存器都要检查返回值。本项目用 ENS160_ERROR_CODE 记录失败在哪一步(2=复位失败,3=进标准模式失败,4~8=读各寄存器失败),调试时打印这个码,比猜快得多。
5.1 预热:前几分钟的数据不能当真
ENS160 上电后需要初始预热,刚上电的前几分钟读数会明显漂移,完全稳定通常要更久。
所以:
- 不要在上电 10 秒内判断"这个传感器是不是坏了";
- 开机初期 AQI 偏高、TVOC 乱跳,属于正常现象;
- 项目里 main() 的 HAL_Delay(2500) 只是等系统上电稳定,不等于传感器预热完成。
5.2 一次完整测量的时序(状态 + 读数流程)
把手册里的状态图和读数步骤画在一起,就是下面这张图。建议收藏,调试时逐格对照:
上电(VDD 1.8V、VDDIO 3.3V 稳定)
│
▼
深度睡眠 0x00(POR 默认)──写 OPMODE=0xF0 可重新复位
│ 写 OPMODE=0x01(可选,进入空闲,可配置指令)
▼
空闲模式 0x01
│ 写 OPMODE=0x02
▼
标准模式 0x02 ────────── 持续测量 ──────────┐
│ │
│ 读 DEVICE_STATUS(0x20) │
│ bit1 NEWDAT == 1 ? ────── 否 ────────┘
│ │ 是
│ ▼
│ 读 0x21 ~ 0x25 连续 5 个字节
│ │
│ ▼
│ AQI = buf[0]
│ TVOC = buf[1] | (buf[2] << 8)
│ eCO2 = buf[3] | (buf[4] << 8)
│ │
└────────────┘ NEWDAT 在首次读 DATA 后自动清零,等下一组
这张图里有三个"卡点",对应三种最常见的失败:
| 写 OPMODE 就失败 | 返回值不是 HAL_OK | 地址错 / 总线没通 / CSn 接低进了 SPI |
| 一直在 0x00 或 0x01 | 读数据全是 0 | 没进标准模式,或写进去被复位 |
| NEWDAT 永远是 0 | 数据不更新 | 芯片没测量、预热未完成、或读的不是 0x20 |
六、读三个数据 + 小端拼接
最省事的读法是一次读 5 个字节,因为 AQI、TVOC、eCO2 在地址上连续:
uint8_t buf[5];
ENS160_READ_REG(0x21, buf, 5); // 从 DATA_AQI 开始,连续读 5 字节
g_AQI = buf[0]; // 第 1 字节:AQI(1~5)
g_TVOC = buf[1] | (buf[2] << 8); // 第 2、3 字节:TVOC 小端
g_ECO2 = buf[3] | (buf[4] << 8); // 第 4、5 字节:eCO2 小端
5 个字节的排布:
| buf[0] | 0x21 DATA_AQI | AQI 等级 | 1 |
| buf[1] | 0x22 DATA_TVOC_LSB | TVOC 低字节 | 1 |
| buf[2] | 0x23 DATA_TVOC_MSB | TVOC 高字节 | 1 |
| buf[3] | 0x24 DATA_ECO2_LSB | eCO2 低字节 | 1 |
| buf[4] | 0x25 DATA_ECO2_MSB | eCO2 高字节 | 1 |
本工程实际是分三次读(读 AQI 1 字节、读 TVOC 2 字节、读 eCO2 2 字节),结果与连续读完全一样,只是多占了一点总线时间:
uint8_t buf[2];
getAQI(&g_AQI); // 1 字节,直接取
getDATA_TVOC(buf); g_TVOC = buf[0] | (buf[1] << 8); // 2 字节,小端
getDATA_ECO2(buf); g_ECO2 = buf[0] | (buf[1] << 8); // 2 字节,小端
6.1 什么是小端
“小端”= 低字节放在低地址。ENS160 的 TVOC 和 eCO2 都是小端,所以:
读回 buf[1] = 0x64, buf[2] = 0x02
正确:0x64 | (0x02 << 8) = 0x0264 = 612
拼反:0x64 << 8 | 0x02 = 0x6402 = 25602
612 ppb(约 0.6 ppm)对 TVOC 是正常室内水平,25602 一眼就知道不对。看到几万的 eCO2/TVOC,先查拼接顺序,不要怀疑传感器。
6.2 AQI 只有 1 个字节
AQI 是 1~5 的等级,不是 0~500 的指数。读 1 个字节就够,不要按 2 字节读,否则会把后面的寄存器也读进来。
6.3 DEVICE_STATUS 可以判断"数据新不新"
DEVICE_STATUS(0x20)的 bit1 是 NEWDAT:置 1 表示 DATA 寄存器里有一组新数据,第一次读 DATA 后会自动清零。
注意是 bit1,掩码是 0x02,不是 0x04。 这是很容易记错的一位——bit2 属于 VALIDITY FLAG(数据有效性的高位),不是"有新数据"。
本工程是每 2 秒直接读一次,没有查 NEWDAT。如果你发现"读到的值一直不变",可以加一句:
uint8_t st;
ENS160_READ_REG(0x20, &st, 1);
if(st & 0x02) { /* 有新数据,再读 AQI/TVOC/eCO2 */ }
另外,DEVICE_STATUS 的 bit3~bit2 是 VALIDITY FLAG:
| 00 | 正常工作 |
| 01 | 预热阶段(数据还不准) |
| 10 | 初始启动阶段 |
| 11 | 输出无效 |
所以如果读数明显不合理,除了查拼接,也可以读一下 DEVICE_STATUS,看看是不是还在预热。
七、空气好坏的判定
#define AIR_AQI_BAD 3 // AQI ≥ 3(中等及以上)
#define AIR_ECO2_BAD 1000 // eCO2 > 1000 ppm
#define AIR_TVOC_BAD 650 // TVOC > 650 ppb(= 0.65 ppm)
g_air_bad = (g_AQI >= AIR_AQI_BAD) ||
(g_ECO2 > AIR_ECO2_BAD) ||
(g_TVOC > AIR_TVOC_BAD);
三个条件任一满足就算空气差:
| AQI | ≥ 3 | 3 级已是"中等",需要通风 |
| eCO2 | > 1000 ppm | 人多、密闭的典型值 |
| TVOC | > 650 ppb | 装修、清洁剂、油烟 |
这套阈值必须在三个地方保持一致:
有一个细节要提前知道:设备端用的是 AQI >= 3,H5 端把 AQI == 3 显示成"空气一般"、> 3 才显示"空气差"。所以当 AQI 正好等于 3 时,设备会开风扇,网页却显示"空气一般"。
这不是 bug,是显示粒度不同。介意的话,把两边统一成 >=3 或 >3 即可。
八、读不到数据时,按这个顺序查
I2C 的问题排查有一个好处:它只有那么几种可能。
| 1 | 模块供电 | 万用表量 VCC-GND | 3.3V |
| 2 | 共地 | 通断档量模块 GND 与板子 GND | 导通 |
| 3 | 上拉电阻 | 看模块是否有 4.7k;没有就外挂 | SCL/SDA 空闲为高 |
| 4 | 引脚是否接反 | PA8=SCL、PC9=SDA | 与模块丝印一致 |
| 5 | 地址 | HAL_I2C_IsDeviceReady 试 0x52 / 0x53 | 其中一个返回 OK |
| 6 | 超时设置 | 把 HAL_MAX_DELAY 改成 100ms | 不再整机卡死 |
| 7 | 器件 ID | 读 0x00 两字节 | 60 01(先低字节) |
| 8 | 工作模式 | 读回 OPMODE(0x10) | 0x02 |
如果前 7 步都正常,第 8 步读到 0x00,说明模式没切成功——回去检查 ENS160_WriteReg 的返回值。
逻辑分析仪是降维打击:接上 SCL/SDA,看第一个字节是不是 0xA6。如果发出来的是 0x53,就是地址没左移;如果根本没有波形,就是引脚或供电问题。一眼就能定位。
九、本章易错点总结
| HAL_I2C_Mem_Read 一直返回 HAL_ERROR | 地址没左移,或 ADDR 接法与代码不符 | 用 0x53<<1;试 0x52/0x53 |
| 读 PART_ID 是 00 00 | 模块没供电、没共地、没上拉 | 依次量电压、通断、上拉 |
| 读回 60 01,以为 ID 错了 | 它就是低字节先出,60 01 才正确 | 不要按大端改成 01 60 |
| 链接报 L6200E: hi2c3 multiply defined | 在 ens160.c 里又定义了 hi2c3 | 只 #include "i2c.h",用现成的 |
| AQI / TVOC / eCO2 永远是 0 | 没写 OPMODE=0x02,还在深度睡眠 | 初始化必须切标准模式 |
| eCO2 出现几万 | 小端拼反了 | buf[1] | (buf[2]<<8) |
| AQI 读出来是几百 | 按 2 字节读了,或地址错位 | AQI 只读 1 字节,寄存器 0x21 |
| 查 NEWDAT 永远读不到新数据 | 掩码写成了 0x04(bit2) | NEWDAT 是 bit1,用 st & 0x02 |
| 程序卡死、按键无反应 | HAL_MAX_DELAY 下 SDA 被拉死 | 调试时超时改 100ms |
| 上电立刻读,数值乱跳 | 传感器还没预热 | 等几分钟再判断,查 VALIDITY FLAG |
| 模块发烫、直接损坏 | 模块 VCC 接了 5V | 模块只能 3.3V |
| 裸芯片上电即坏 | VDD 直接接了 3.3V | VDD 必须 1.8V,VDDIO 才接 3.3V |
| 地址对、供电对,就是不应答 | CSn 接了低电平,芯片进了 SPI | CSn 接高(VDDIO)才是 I2C |
| SCL/SDA 接反 | 引脚接错 | PA8=SCL、PC9=SDA |
| 读到的地址时对时错 | ADDR 引脚悬空 | ADDR 明确接 GND 或 3.3V |
| 设备开风扇,网页显示空气一般 | AQI=3 时两端判定不同 | 统一 >=3 或 >3 |
| eCO2 和真实 CO2 表差很多 | eCO2 是推算值,不是 NDIR | 接受差异,不要拿去校准 |
十、小结与下一篇
ENS160 这一章,记住五件事就够了:
- 地址要左移:0x53 << 1 = 0xA6;
- 先读 PART_ID:60 01 能区分"通信问题"和"配置问题";
- 必须进标准模式:OPMODE=0x02,否则数据永远不刷新;
- 连续读 5 字节:0x21~0x25 一次拿全 AQI / TVOC / eCO2;
- 小端拼接:buf[1] | (buf[2] << 8),拼反会得到离谱的大数。
下一篇进入执行器:PWM 控制风扇、加热片和 SG90 舵机。你会看到 1kHz 和 50Hz 是怎么算出来的、CCR 为什么能直接当微秒用,以及自动模式里那 1℃ 回差是干什么的。
如果这篇文章对你有帮助,欢迎点赞收藏。系列更新中,下一篇见。
附录 A:完整代码(ens160.h / ens160.c)
下面是本工程实际使用的 ens160.h 与 ens160.c,可直接复制进 App/ 目录。
两点说明
A.1 ens160.h(对外接口)
#ifndef __ENS160_H
#define __ENS160_H
/*
* ENS160 数字空气质量传感器驱动 —— 对外接口定义
* ————————————————
* ENS160 通过 I2C 总线通信,上电后需切到标准工作模式(0x02),
* 之后可输出三种空气质量数据:
* AQI :空气质量指数(1~5,数值越小空气越好)
* TVOC :总挥发性有机物浓度(单位 ppb,2 字节,小端)
* eCO2 :等效二氧化碳浓度(单位 ppm,2 字节,小端)
*/
#include <stdio.h>
#include "stm32f4xx_hal.h"
#define ENS160_I2C_ADDR (0x53<<1) // ADDR 引脚接高时的器件地址(左移 1 位)
#define ENS160_PART_ID_REG 0x00 // 器件ID寄存器(只读,依次读回 0x60、0x01)
#define ENS160_OPMODE_REG 0x10 // 工作模式寄存器(写 0x02 进入标准测量模式)
#define ENS160_CONFIG_REG 0x11 // 配置寄存器(中断使能、节能模式等)
#define ENS160_STATUS_REG 0x20 // 状态寄存器(bit1=NEWDAT:新数据就绪标志)
#define ENS160_DATA_AQI_REG 0x21 // AQI空气质量指数寄存器(1 字节,范围 1~5)
#define ENS160_DATA_TVOC_REG 0x22 // TVOC总挥发性有机物寄存器(2 字节,单位 ppb)
#define ENS160_DATA_ECO2_REG 0x24 // eCO2等效二氧化碳寄存器(2 字节,单位 ppm)
#define ENS160_DATA_T_REG 0x26 // 温度输出寄存器(2 字节有符号,单位K,分辨率1/64K)
#define ENS160_TEMP_IN_REG 0x13 // 温度补偿输入寄存器(写入外部温度,提高精度)
#define ENS160_RH_IN_REG 0x15 // 湿度补偿输入寄存器(写入外部湿度,提高精度)
/* —- 对外接口 —- */
extern uint8_t part_id[2]; // 读回的器件 ID 缓冲区(2 字节)
extern uint8_t temp_data[2]; // 读回的温度原始值缓冲区(2 字节)
extern void MX_I2C3_Init(void); // I2C3 初始化(由 i2c.c 提供,main.c 直接调用)
extern uint8_t getPART_ID(uint8_t *pID); // 读器件 ID,用于验证 I2C 通信
extern uint8_t getDATA_T(uint8_t *pT); // 读温度输出值(需先进入标准模式)
/* —- ens160_init / 数据读取 / 输出 —- */
extern uint8_t ens160_init(void); // 复位 ENS160 并切到标准模式(0x02)
extern uint8_t getAQI(uint8_t *pAQI); // 读 AQI(1 字节)
extern uint8_t getDATA_TVOC(uint8_t *pTVOC); // 读 TVOC(2 字节,小端)
extern uint8_t getDATA_ECO2(uint8_t *pECO2); // 读 eCO2(2 字节,小端)
extern void outputENS160(void); // 串口依次打印 AQI、TVOC、eCO2
extern uint8_t ENS160_ERROR_CODE; // 错误码:0 正常;2~8 对应各寄存器读写失败
#endif
A.2 ens160.c(驱动实现)
/**
******************************************************************************
* 文件名称 : ens160.c
* 功 能 : 通过 I2C3 端口控制 ENS160 模块
******************************************************************************
**/
#include "stm32f4xx_hal.h"
#include "stdio.h"
#include "i2c.h" // 复用 i2c.c(CubeMX 生成)提供的 hi2c3 句柄
#include "ens160.h"
void Error_Handler(void);
/* —————- 私有宏定义 —————- */
// 【I2C 寻址原理】主机通信时第一个字节 = 7位地址 << 1 | R/W位
#define ENS160_ADDR_7BIT 0x53U // ADDR 引脚接高时为 0x53,接低时为 0x52
#define ENS160_ADDR (ENS160_ADDR_7BIT << 1) // HAL 要求左移 1 位
// 寄存器地址
#define ENS160_REG_PART_ID 0x00U
#define ENS160_REG_OPMODE 0x10U
#define ENS160_REG_DATA_T 0x26U
// 四种工作模式
#define ENS160_OPMODE_DEEP_SLEEP 0x00U // 深度睡眠:不测量
#define ENS160_OPMODE_IDLE 0x01U // 空闲:算法预热
#define ENS160_OPMODE_STANDARD 0x02U // 标准模式:连续测量(常用)
#define ENS160_OPMODE_RESET 0xF0U // 软复位
/* —————- 私有变量 —————- */
// I2C3 句柄 hi2c3 统一由 i2c.c 提供,本文件不再定义,避免重复定义(L6200E)
typedef struct ENS160Data {
uint8_t AQI;
uint16_t TVOC;
uint16_t ECO2;
} ENS160DataType;
uint8_t part_id[2]; // 器件 ID(2 字节,正常依次为 0x60、0x01)
ENS160DataType data; // 三种数据
uint8_t temp_data[2]; // 温度原始值(2 字节)
/* —————- 私有函数 —————- */
static HAL_StatusTypeDef ENS160_READ_REG (uint8_t reg, uint8_t *data, uint8_t len);
static HAL_StatusTypeDef ENS160_WriteReg (uint8_t reg, uint8_t *data, uint8_t len);
uint8_t retvalue;
uint8_t ENS160_ERROR_CODE = 0; // 错误码:0 正常;2~8 对应各寄存器读写失败
/*
* 读寄存器:HAL_I2C_Mem_Read 会自动完成「发地址+W → 发寄存器号 →
* 重复起始 → 发地址+R → 接收 len 字节」整个流程
*/
static HAL_StatusTypeDef ENS160_READ_REG(uint8_t reg, uint8_t *data, uint8_t len)
{
return HAL_I2C_Mem_Read(&hi2c3, ENS160_ADDR, reg,
I2C_MEMADD_SIZE_8BIT, data, len, HAL_MAX_DELAY);
}
/*
* 写寄存器:HAL_I2C_Mem_Write 完成「发地址+W → 发寄存器号 → 写 len 字节」
*/
static HAL_StatusTypeDef ENS160_WriteReg(uint8_t reg, uint8_t *data, uint8_t len)
{
return HAL_I2C_Mem_Write(&hi2c3, ENS160_ADDR, reg,
I2C_MEMADD_SIZE_8BIT, data, len, HAL_MAX_DELAY);
}
// ENS160 初始化:软复位 → 标准模式
uint8_t ens160_init(void)
{
uint8_t mode = ENS160_OPMODE_RESET; // ① 软复位
retvalue = ENS160_WriteReg(ENS160_REG_OPMODE, &mode, 1);
if (retvalue != HAL_OK) ENS160_ERROR_CODE = 2;
HAL_Delay(20);
mode = ENS160_OPMODE_STANDARD; // ② 切到标准模式
retvalue = ENS160_WriteReg(ENS160_REG_OPMODE, &mode, 1);
if (retvalue != HAL_OK) ENS160_ERROR_CODE = 3;
HAL_Delay(20);
return ENS160_ERROR_CODE;
}
// 读器件 ID(PART_ID,2 字节):用于上电后验证 I2C 通信是否正常
uint8_t getPART_ID(uint8_t *pID)
{
retvalue = ENS160_READ_REG(ENS160_REG_PART_ID, pID, 2);
if (retvalue != HAL_OK) ENS160_ERROR_CODE = 4;
return ENS160_ERROR_CODE;
}
// 读温度输出(DATA_T 0x26,2 字节有符号,T[K]=原始值/64)
uint8_t getDATA_T(uint8_t *pT)
{
retvalue = ENS160_READ_REG(ENS160_REG_DATA_T, pT, 2);
if (retvalue != HAL_OK) ENS160_ERROR_CODE = 5;
return ENS160_ERROR_CODE;
}
// 读 AQI(1 字节,1~5)
uint8_t getAQI(uint8_t *pAQI)
{
retvalue = ENS160_READ_REG(ENS160_DATA_AQI_REG, pAQI, 1);
if (retvalue != HAL_OK) ENS160_ERROR_CODE = 6;
return ENS160_ERROR_CODE;
}
// 读 TVOC(2 字节,小端,单位 ppb)
uint8_t getDATA_TVOC(uint8_t *pDATA_TVOC)
{
retvalue = ENS160_READ_REG(ENS160_DATA_TVOC_REG, pDATA_TVOC, 2);
if (retvalue != HAL_OK) ENS160_ERROR_CODE = 7;
return ENS160_ERROR_CODE;
}
// 读 eCO2(2 字节,小端,单位 ppm)
uint8_t getDATA_ECO2(uint8_t *pDATA_ECO2)
{
retvalue = ENS160_READ_REG(ENS160_DATA_ECO2_REG, pDATA_ECO2, 2);
if (retvalue != HAL_OK) ENS160_ERROR_CODE = 8;
return ENS160_ERROR_CODE;
}
// 串口依次打印 AQI / TVOC / eCO2(调用前必须先 ens160_init)
void outputENS160(void)
{
uint8_t buf[2];
getAQI(&data.AQI); // 1 字节,直接取
getDATA_TVOC(buf); data.TVOC = buf[0] | (buf[1] << 8); // 小端拼接
getDATA_ECO2(buf); data.ECO2 = buf[0] | (buf[1] << 8); // 小端拼接
if (ENS160_ERROR_CODE == 0) {
printf("AQI = %d\\r\\n", data.AQI);
printf("TVOC = %d ppb\\r\\n", data.TVOC);
printf("eCO2 = %d ppm\\r\\n", data.ECO2);
} else {
printf("outputENS160_ERR:%d\\r\\n", ENS160_ERROR_CODE);
}
}
A.3 在 main.c 中调用
/* USER CODE BEGIN Includes */
#include "ens160.h"
/* USER CODE END Includes */
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_I2C3_Init(); // CubeMX 生成的 I2C3 初始化
MX_USART1_UART_Init(); // printf 重定向到 USART1
/* USER CODE BEGIN 2 */
if (ens160_init() == 0)
printf("ENS160 init OK\\r\\n");
else
printf("ENS160 init FAIL, code=%d\\r\\n", ENS160_ERROR_CODE);
/* USER CODE END 2 */
while (1) {
outputENS160();
HAL_Delay(2000); // ENS160 每 2 秒刷新一次数据
}
}





