4. Zephyr 传感器子系统详解

好,咱们进入第一个真正的传感器驱动实战。我选BME280,原因很简单——这芯片太常见了,温湿度气压三合一,价格便宜,资料也多。你在智能家居、气象站、环境监测项目里十有八九会碰到它。

这一章,我会带你从头搭一个完整的I2C驱动。从总线配置、设备树节点编写,到驱动初始化、读写数据,最后把温湿度气压读出来。嗯,咱们不搞虚的,直接上手。

本章目标: 在Zephyr上为BME280编写完整I2C驱动,成功读取温度、湿度、气压数据。

4.1 I2C总线配置:先让总线跑起来

写驱动之前,得先把I2C总线配好。Zephyr里配I2C,说白了就是两步:在设备树里使能控制器,在Kconfig里打开驱动。

我个人习惯先查板子的原理图,看BME280挂在哪组I2C引脚上。比如我手头的nRF52840 DK,I2C1默认在P0.27和P0.26上。那设备树里就得这么写:

// boards/arm/nrf52840dk_nrf52840.overlay
&i2c1 {
    compatible = "nordic,nrf-twim";
    status = "okay";
    clock-frequency = <I2C_BITRATE_FAST>;  // 400kHz

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

这里有个坑——地址0x76还是0x77?看BME280的SDO引脚电平。SDO接地是0x76,接VDDIO是0x77。我遇到过好几次,板子焊好了死活读不到数据,最后发现是地址搞反了。所以,先量一下SDO电平,再写地址。

小技巧: 如果你不确定I2C地址,可以用Zephyr的i2c_scan示例程序扫一遍总线,所有应答的设备地址都会列出来。

4.2 设备树节点编写:把硬件描述清楚

设备树节点不只是写个地址就完事了。我建议把BME280的初始化参数也放进去,这样驱动启动时可以直接读设备树,不用硬编码。

来看一个更完整的节点:

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

    // 过采样配置
    osr-t = <2>;   // 温度过采样x2
    osr-p = <16>;  // 气压过采样x16
    osr-h = <1>;   // 湿度过采样x1

    // 滤波器配置
    filter = <4>;  // IIR滤波器系数

    // 工作模式
    mode = "normal";  // 正常模式,持续测量
};

你可能会问:这些参数非得放设备树吗?我的经验是,能放设备树的就别硬编码。为啥?因为换板子时改设备树比改C代码安全多了,不会不小心把别的逻辑改坏。

参数 设备树属性 可选值 说明
温度过采样 osr-t 0, 1, 2, 4, 8, 16 0表示跳过测量
气压过采样 osr-p 0, 1, 2, 4, 8, 16 过采样越高噪声越低
湿度过采样 osr-h 0, 1, 2, 4, 8, 16 湿度只有这6档
IIR滤波器 filter 0, 2, 4, 8, 16 0关闭滤波器
工作模式 mode sleep, forced, normal normal持续测量

4.3 驱动初始化:从设备树到运行时结构

设备树写好了,接下来是驱动代码。Zephyr的驱动模型要求你实现一个init函数,在系统启动时被调用。

先定义设备数据结构:

struct bme280_data {
    const struct device *i2c_dev;
    uint16_t i2c_addr;
    uint8_t osr_t;
    uint8_t osr_p;
    uint8_t osr_h;
    uint8_t filter;
    // 校准参数
    uint16_t dig_T1;
    int16_t dig_T2, dig_T3;
    uint16_t dig_P1;
    int16_t dig_P2, dig_P3, dig_P4, dig_P5, dig_P6, dig_P7, dig_P8, dig_P9;
    uint8_t dig_H1, dig_H3;
    int16_t dig_H2, dig_H4, dig_H5, dig_H6;
};

然后写init函数:

static int bme280_init(const struct device *dev)
{
    struct bme280_data *data = dev->data;
    const struct bme280_config *cfg = dev->config;

    // 1. 获取I2C设备
    data->i2c_dev = device_get_binding(cfg->i2c_name);
    if (!data->i2c_dev) {
        return -ENODEV;
    }

    // 2. 读取校准参数
    if (bme280_read_calibration(dev) < 0) {
        return -EIO;
    }

    // 3. 配置过采样和模式
    if (bme280_configure(dev) < 0) {
        return -EIO;
    }

    // 4. 软复位,确保芯片处于已知状态
    bme280_soft_reset(dev);

    printk("BME280 initialized at 0x%02x\n", data->i2c_addr);
    return 0;
}

这里有个细节:先复位再配置。我踩过这个坑——芯片上电后可能处于不确定状态,直接写配置寄存器可能不生效。所以init里先发复位命令(0xB6到0xE0寄存器),等10ms再配置。

注意: BME280复位后需要等待2ms才能访问寄存器。我一般等10ms,稳妥。别问为什么是10ms,问就是经验。

4.4 I2C读写操作:核心数据通路

I2C读写是驱动的灵魂。Zephyr提供了两套API:i2c_write_read和i2c_burst_read。我个人偏爱i2c_burst_read,因为它一次调用就能连续读多个寄存器,效率高。

来看读温度的例子:

static int bme280_read_temperature(const struct device *dev, int32_t *temp)
{
    struct bme280_data *data = dev->data;
    uint8_t buf[3];
    uint8_t reg = 0xFA;  // 温度数据起始寄存器
    int32_t adc_T;

    // 一次读取3字节温度数据
    if (i2c_burst_read(data->i2c_dev, data->i2c_addr, reg, buf, 3) < 0) {
        return -EIO;
    }

    // 组合成20位ADC值
    adc_T = ((int32_t)buf[0] << 12) |
            ((int32_t)buf[1] << 4) |
            ((int32_t)buf[2] >> 4);

    // 用校准参数补偿
    *temp = bme280_compensate_temperature(data, adc_T);

    return 0;
}

写寄存器更简单:

static int bme280_write_reg(const struct device *dev, uint8_t reg, uint8_t val)
{
    struct bme280_data *data = dev->data;
    uint8_t buf[2] = {reg, val};

    return i2c_write(data->i2c_dev, buf, 2, data->i2c_addr);
}

注意看,写操作是把寄存器地址和数据放在同一个buffer里发出去。这是I2C的标准做法——先发地址字节,再发数据字节。

4.5 数据补偿算法:从ADC到物理量

BME280的ADC原始值不能直接用,得用芯片自带的校准参数做补偿。这些参数在芯片出厂时烧录,每次上电都要读出来。

温度补偿公式(BME280数据手册第23页):

static int32_t bme280_compensate_temperature(struct bme280_data *data, int32_t adc_T)
{
    int32_t var1, var2, T;

    var1 = ((((adc_T >> 3) - ((int32_t)data->dig_T1 << 1))) *
            ((int32_t)data->dig_T2)) >> 11;

    var2 = (((((adc_T >> 4) - ((int32_t)data->dig_T1)) *
              ((adc_T >> 4) - ((int32_t)data->dig_T1))) >> 12) *
            ((int32_t)data->dig_T3)) >> 14;

    data->t_fine = var1 + var2;
    T = (data->t_fine * 5 + 128) >> 8;

    return T;  // 单位:0.01摄氏度
}

看着有点晕是吧?我第一次看也懵。说白了就是一组多项式拟合,把ADC值映射到真实温度。你不需要理解每步数学推导,照着数据手册抄就行。但要注意变量类型——BME280的中间计算容易溢出,int32是底线。

经验之谈: 湿度补偿里有个坑——dig_H4和dig_H5是12位有符号数,但存储格式是交叉的。读的时候要小心位操作,我在这上面浪费过半天时间。

4.6 完整驱动框架图

下面这张图把整个驱动的数据流和模块关系画清楚了。你可以看到从应用层到底层硬件的完整路径。

BME280 I2C驱动架构图 BME280 I2C驱动架构 应用层:sensor_value 读取 驱动API层:bme280_sample_fetch / bme280_channel_get 补偿算法层:温度/气压/湿度补偿函数 I2C读写层:i2c_burst_read / i2c_write 设备树配置层:地址/过采样/模式/滤波器 硬件层:BME280芯片 (I2C总线) 关键数据流 ADC原始值 → 校准参数 → 物理量 配置来源 设备树属性 → Kconfig默认 → 运行时API

4.7 完整读取流程

把上面所有模块串起来,一次完整的数据读取流程是这样的:

  1. 应用层调用:sensor_sample_fetch(dev)触发一次采样
  2. 驱动层判断:检查芯片状态,确认数据就绪
  3. I2C读取:连续读取温度(0xFA-0xFC)、气压(0xF7-0xF9)、湿度(0xFD-0xFE)寄存器
  4. ADC组合:把各字节拼成20位或16位ADC值
  5. 补偿计算:用校准参数做多项式补偿,得到物理量
  6. 数据返回:通过sensor_channel_get返回给应用
性能提示: BME280一次完整测量(温度+气压+湿度)大约需要7ms。如果你只需要温度,可以只读温度寄存器,省掉气压和湿度的读取时间。

4.8 避坑指南

最后,把我这些年踩过的坑列出来,你遇到了直接对照排查:

  • 地址错误:SDO引脚电平决定地址,0x76或0x77,别猜,用万用表量
  • 校准参数未读:每次上电必须重新读取校准参数,它们不会自动加载
  • 模式配置顺序:先配过采样和滤波器,再设模式。反过来可能不生效
  • 数据未就绪:读数据前检查状态寄存器的测量完成位(bit 3),别读旧数据
  • I2C时钟延展:BME280在测量时会拉低SCL,确保你的I2C控制器支持时钟延展
我曾经在一个低功耗项目里,BME280每隔10秒唤醒一次读数据。结果发现读出来的温度总是偏高。排查了两天,最后发现是I2C上拉电阻太大,信号上升沿太慢导致数据错位。换成4.7kΩ上拉就好了。嗯,硬件问题有时候比软件坑多了。