欢迎光临
我们一直在努力

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

文章目录

    • 一、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 三句话讲明白

  • 两根线:SCL 是时钟(主机给),SDA 是数据(双向)。
  • 两根线都是开漏,必须接上拉电阻,靠"线与"决定总线电平。
  • 每个从机有 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 位地址
    接 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 下标对应寄存器含义长度
    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 装修、清洁剂、油烟

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

  • 设备端的 main.c(决定风扇转不转);
  • MQTT 上报的 JSON(只传原始值,由前端判断);
  • H5 的 app.js(决定徽章颜色和文字)。
  • 有一个细节要提前知道:设备端用的是 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/ 目录。

    两点说明

  • 代码里的 hi2c3 由 CubeMX 生成的 i2c.c / i2c.h 提供,本文件只 #include "i2c.h",不要重复定义。
  • 原始注释里有几处与手册/正文最新结论略有出入的地方(如 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 最小验证步骤

  • 编译通过,烧录;
  • 串口应先打印 ENS160 init OK;
  • 每 2 秒打印一组 AQI / TVOC / eCO2;
  • 若一直打印 init FAIL,先按第 8 节排查表检查供电、共地、上拉、地址、CSn。
  • 赞(0)
    未经允许不得转载:171主机测评 » STM32 从 0 到 1 做智能家居(七):ENS160 空气质量(I2C)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址