STM32 从 0 到 1 做智能家居(七):ENS160 空气质量(I2C)

本文详解STM32通过I2C总线驱动ENS160空气质量传感器的完整实现,涵盖I2C硬件原理(开漏、上拉、仲裁)、地址配置(7位地址左移)、寄存器读写封装、初始化流程(复位+标准模式)、数据解析(小端拼接、AQI/TVOC/eCO2连续读取)及典型排错方法(PART_ID验证、NEWDAT状态判断)。强调硬件适配要点:VDD/VDDIO供电分离、模块与裸芯片差异、电平兼容性。

系列第七篇。DHT11 是"一根线",ENS160 是"两根线"。别看不起眼的两根线,I2C 的坑一点不比单总线少。

我第一次读 ENS160,HAL_I2C_Mem_Read 一直返回 HAL_ERROR,查了两个下午,最后发现是模块的 ADDR 引脚接法和我代码里的地址对不上。

一、I2C 三句话讲明白

  1. 两根线:SCL 是时钟(主机给),SDA 是数据(双向)。
  2. 两根线都是开漏,必须接上拉电阻,靠"线与"决定总线电平。
  3. 每个从机有 7 位地址,主机用"地址 + 读/写位"来点名,再指定寄存器号读写数据。

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 时序图 ↔ 代码对照表

图上的动作谁在做HAL 里的表现软件 I2C 里的表现
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 引脚决定

ADDR 引脚7 位地址传给 HAL 的 8 位地址
接 GND0x520xA4
接 3.3V0x530xA6

本工程代码用的是 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_ID0x002只读,正常依次读回 0x60、0x01
OPMODE0x1010xF0 软复位,0x02 标准模式
DEVICE_STATUS0x201bit1 = NEWDAT,有新数据;读数据后自动清零
DATA_AQI0x2111~5,越小越好
DATA_TVOC0x222小端,单位 ppb
DATA_ECO20x242小端,单位 ppm
TEMP_IN / RH_IN0x13 / 0x152写入外部温湿度做补偿(本工程未用)

注意 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 下标对应寄存器含义长度
buf[0]0x21 DATA_AQIAQI 等级1
buf[1]0x22 DATA_TVOC_LSBTVOC 低字节1
buf[2]0x23 DATA_TVOC_MSBTVOC 高字节1
buf[3]0x24 DATA_ECO2_LSBeCO2 低字节1
buf[4]0x25 DATA_ECO2_MSBeCO2 高字节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≥ 33 级已是"中等",需要通风
eCO2> 1000 ppm人多、密闭的典型值
TVOC> 650 ppb装修、清洁剂、油烟

这套阈值必须在三个地方保持一致:

  1. 设备端的 main.c(决定风扇转不转);
  2. MQTT 上报的 JSON(只传原始值,由前端判断);
  3. H5 的 app.js(决定徽章颜色和文字)。

有一个细节要提前知道:设备端用的是 AQI >= 3,H5 端把 AQI == 3 显示成"空气一般"、> 3 才显示"空气差"。所以当 AQI 正好等于 3 时,设备会开风扇,网页却显示"空气一般"。

这不是 bug,是显示粒度不同。介意的话,把两边统一成 >=3 或 >3 即可。

八、读不到数据时,按这个顺序查

I2C 的问题排查有一个好处:它只有那么几种可能。

顺序检查什么怎么查正常结果
1模块供电万用表量 VCC-GND3.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.3VVDD 必须 1.8V,VDDIO 才接 3.3V
地址对、供电对,就是不应答CSn 接了低电平,芯片进了 SPICSn 接高(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/ 目录。

两点说明

  1. 代码里的 hi2c3 由 CubeMX 生成的 i2c.c / i2c.h 提供,本文件只 #include "i2c.h",不要重复定义。
  2. 原始注释里有几处与手册/正文最新结论略有出入的地方(如 AQI 范围写"1~500",实际是 1~5;STATUS 注释写"bit2=STDR",实际 bit1 才是 NEWDAT;PART_ID 注释写"0x01、0x60",实际读回是 60 01)。正文已订正,代码功能本身不受影响,保留原样供对照。

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 秒刷新一次数据
    }
}

A.4 最小验证步骤

  1. 编译通过,烧录;
  2. 串口应先打印 ENS160 init OK;
  3. 每 2 秒打印一组 AQI / TVOC / eCO2;
  4. 若一直打印 init FAIL,先按第 8 节排查表检查供电、共地、上拉、地址、CSn。
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值