3. I2C 总线与传感器通信:I2C 协议基础、Zephyr I2C API、使用 I2C 读取传感器数据(如 BME280)

好,咱们进入第三章。这一章聊的是嵌入式开发里最常用的通信方式之一——I2C。说实话,我做了这么多年嵌入式,I2C 几乎天天见。从温度传感器到加速度计,从 EEPROM 到 ADC 芯片,I2C 就像嵌入式世界的“普通话”。

你可能会问:SPI 不是更快吗?为什么还要用 I2C?嗯,这个问题我当年也纠结过。后来发现,I2C 最大的优势就是省引脚——两根线就能挂一堆设备。对于传感器这种数据量不大、但对引脚数量敏感的场景,I2C 简直是天选之子。

3.1 I2C 协议基础

I2C 全称 Inter-Integrated Circuit,由飞利浦(现在的 NXP)在 80 年代发明。它只有两根线:

  • SCL(Serial Clock):时钟线,由主设备驱动
  • SDA(Serial Data):数据线,双向传输

这两根线都是开漏输出,需要上拉电阻。我见过不少新手在这里翻车——忘了加上拉电阻,结果总线死活拉不高。嗯,这个坑我踩过。

3.1.1 通信流程

I2C 的通信流程其实不复杂,说白了就是“主设备发号施令,从设备响应”。基本步骤是这样的:

  1. 起始条件:SCL 高电平时,SDA 从高变低
  2. 发送地址:主设备发送 7 位从设备地址 + 1 位读写位
  3. 从设备应答:从设备拉低 SDA 表示 ACK
  4. 数据传输:每 8 位数据后跟一个 ACK/NACK
  5. 停止条件:SCL 高电平时,SDA 从低变高

关键点:I2C 地址是 7 位的,但很多数据手册给的是 8 位地址(左移了一位)。比如 BME280 的 7 位地址是 0x76,但有些手册写 0xEC(写地址)或 0xED(读地址)。我个人习惯直接用 7 位地址,让 Zephyr API 帮我处理移位。

3.1.2 传输模式

I2C 支持三种传输模式:

模式 速度 适用场景
标准模式 100 kbps 大多数传感器
快速模式 400 kbps 需要更高吞吐量时
高速模式 3.4 Mbps Zephyr 支持有限,慎用

对于 BME280 这种温湿度气压传感器,标准模式完全够用。我曾经试过用快速模式读它,结果发现数据根本没变化——传感器本身响应没那么快,总线再快也没用。

3.2 Zephyr I2C API

Zephyr 的 I2C API 设计得挺清爽。你不需要关心底层寄存器怎么配,只需要调用几个函数就行。我个人觉得,Zephyr 的驱动层抽象做得比很多 RTOS 都好。

3.2.1 设备树配置

在 Zephyr 里,I2C 外设是通过设备树(Device Tree)来描述的。以 BME280 为例,设备树里大概长这样:

/ {
    bme280@76 {
        compatible = "bosch,bme280";
        reg = <0x76>;
        label = "BME280";
    };
};

注意这里的 reg = <0x76>,就是 I2C 地址。我见过有人把地址写错,结果读回来的数据全是 0xFF。嗯,排查了半天才发现是地址移位搞反了。

3.2.2 核心 API 函数

Zephyr 的 I2C API 主要就这几个函数:

  • i2c_configure():配置 I2C 控制器参数(速度、模式等)
  • i2c_write():向从设备写入数据
  • i2c_read():从从设备读取数据
  • i2c_transfer():组合读写操作(比如先写寄存器地址再读数据)

个人经验:我建议优先用 i2c_transfer() 而不是分开调用 i2c_write()i2c_read()。因为组合操作能保证原子性,避免被其他任务打断。我在一个多线程项目里就吃过这个亏——分开调用时,写地址和读数据之间被插入了别的操作,结果读回来的数据全乱了。

3.2.3 初始化示例

来看看怎么初始化 I2C 并读取 BME280 的 ID 寄存器:

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/i2c.h>

#define BME280_I2C_ADDR    0x76
#define BME280_REG_ID      0xD0

void main(void)
{
    const struct device *i2c_dev;
    uint8_t chip_id;
    int ret;

    // 获取 I2C 控制器设备
    i2c_dev = DEVICE_DT_GET(DT_NODELABEL(i2c0));
    if (!device_is_ready(i2c_dev)) {
        printk("I2C 设备未就绪\n");
        return;
    }

    // 配置 I2C 为快速模式
    ret = i2c_configure(i2c_dev, I2C_SPEED_SET(I2C_SPEED_FAST));
    if (ret != 0) {
        printk("I2C 配置失败: %d\n", ret);
        return;
    }

    // 读取芯片 ID
    ret = i2c_write_read(i2c_dev, BME280_I2C_ADDR,
                         &BME280_REG_ID, 1,
                         &chip_id, 1);
    if (ret != 0) {
        printk("读取 BME280 ID 失败: %d\n", ret);
        return;
    }

    printk("BME280 芯片 ID: 0x%02X\n", chip_id);
}

这段代码里,i2c_write_read() 是个很方便的封装——先写寄存器地址,再读数据。你想想看,如果自己手动实现,得先发起始条件、地址、寄存器地址,再发重复起始条件、地址、读数据... 嗯,Zephyr 帮我们省了不少事。

3.3 使用 I2C 读取 BME280 数据

BME280 是 Bosch 出品的一款集成温湿度气压传感器。我最早接触它是在一个气象站项目里,当时选型时对比了好几款,最后选了 BME280——精度够用、功耗低、而且 Zephyr 有现成的驱动。

3.3.1 BME280 寄存器概览

BME280 的寄存器挺多的,但常用的就这几个:

寄存器地址 名称 说明
0xD0 ID 芯片 ID,固定为 0x60
0xF2 ctrl_hum 湿度控制寄存器
0xF4 ctrl_meas 测量控制寄存器(温度+气压)
0xF7 press_msb 气压数据高字节
0xFA temp_msb 温度数据高字节
0xFD hum_msb 湿度数据高字节

注意:BME280 的校准参数存储在 0x88~0xA1 和 0xE1~0xE7 区域。这些参数是出厂时写入的,每个芯片都不一样。读取原始数据后,必须用这些校准参数做补偿计算,才能得到真实的物理值。我曾经直接拿原始数据当结果用,结果温度显示 85°C... 嗯,那天的确挺热的,但没热到那个程度。

3.3.2 读取流程

读取 BME280 数据的标准流程是这样的:

  1. 读取校准参数(一次性操作,存到结构体里)
  2. 配置测量模式(比如正常模式、强制模式)
  3. 等待测量完成(通常几毫秒)
  4. 读取原始数据(温度、气压、湿度各 3 字节)
  5. 用校准参数计算真实值

我个人习惯把校准参数读取放在初始化阶段,后面每次读取只做第 3~5 步。这样能减少 I2C 通信次数,提高效率。

3.3.3 完整代码示例

下面是一个完整的 BME280 读取示例,包含了校准参数读取和温度计算:

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/i2c.h>

#define BME280_ADDR         0x76
#define BME280_REG_DIG_T1   0x88
#define BME280_REG_TEMP_MSB 0xFA

struct bme280_calib {
    uint16_t dig_T1;
    int16_t  dig_T2;
    int16_t  dig_T3;
};

static int read_calib(const struct device *i2c_dev,
                      struct bme280_calib *calib)
{
    uint8_t buf[6];
    int ret;

    ret = i2c_write_read(i2c_dev, BME280_ADDR,
                         (uint8_t[]){BME280_REG_DIG_T1}, 1,
                         buf, 6);
    if (ret != 0) return ret;

    calib->dig_T1 = (uint16_t)(buf[1] << 8) | buf[0];
    calib->dig_T2 = (int16_t)(buf[3] << 8) | buf[2];
    calib->dig_T3 = (int16_t)(buf[5] << 8) | buf[4];

    return 0;
}

static int read_temp_raw(const struct device *i2c_dev,
                         int32_t *temp_raw)
{
    uint8_t buf[3];
    int ret;

    ret = i2c_write_read(i2c_dev, BME280_ADDR,
                         (uint8_t[]){BME280_REG_TEMP_MSB}, 1,
                         buf, 3);
    if (ret != 0) return ret;

    *temp_raw = ((int32_t)buf[0] << 12) |
                ((int32_t)buf[1] << 4) |
                ((int32_t)buf[2] >> 4);

    return 0;
}

static float compensate_temp(int32_t raw,
                             const struct bme280_calib *calib)
{
    int32_t var1, var2, T;

    var1 = ((((raw >> 3) - ((int32_t)calib->dig_T1 << 1))) *
            ((int32_t)calib->dig_T2)) >> 11;

    var2 = (((((raw >> 4) - ((int32_t)calib->dig_T1)) *
              ((raw >> 4) - ((int32_t)calib->dig_T1))) >> 12) *
            ((int32_t)calib->dig_T3)) >> 14;

    T = var1 + var2;
    return (T * 5 + 50) / 100.0f;
}

void main(void)
{
    const struct device *i2c_dev;
    struct bme280_calib calib;
    int32_t temp_raw;
    float temp_c;

    i2c_dev = DEVICE_DT_GET(DT_NODELABEL(i2c0));
    if (!device_is_ready(i2c_dev)) {
        printk("I2C 未就绪\n");
        return;
    }

    // 读取校准参数
    if (read_calib(i2c_dev, &calib) != 0) {
        printk("校准参数读取失败\n");
        return;
    }

    // 配置为正常模式(省略配置代码,可参考 BME280 数据手册)

    while (1) {
        if (read_temp_raw(i2c_dev, &temp_raw) == 0) {
            temp_c = compensate_temp(temp_raw, &calib);
            printk("温度: %.2f °C\n", temp_c);
        }
        k_sleep(K_MSEC(1000));
    }
}

避坑指南:BME280 的原始数据是 20 位有符号数,存储在 3 个字节里。注意看代码里的移位操作——高字节左移 12 位,中字节左移 4 位,低字节只取高 4 位。这个细节很容易搞错,我一开始就写成了左移 8 位和 0 位,结果算出来的温度忽高忽低。

3.4 常见问题与调试技巧

做 I2C 通信,难免会遇到问题。我总结几个最常见的坑:

  • 总线卡死:SDA 被拉低不放。解决办法:硬件复位或重新初始化 I2C 控制器。Zephyr 里可以调用 i2c_recover_bus()
  • 地址错误:读回来的数据全是 0xFF 或 0x00。检查地址是否左移了,或者从设备地址对不对。
  • 时序问题:SCL 频率太高,从设备跟不上。降低 I2C 速度试试。
  • 上拉电阻:没有上拉电阻,总线电平拉不上去。一般用 4.7kΩ 或 10kΩ。

我曾经在一个项目里,I2C 总线偶尔会卡死,查了两天才发现是上拉电阻焊错了——用了 100Ω 的电阻,电流太大把从设备的 I/O 口烧了。嗯,从那以后我每次画板子都会再三确认上拉电阻的阻值。

好了,这一章的内容就到这儿。I2C 看起来简单,但实际用起来还是有不少门道的。下一章我们会讲 SPI 总线,那又是另一番天地了。