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 的通信流程其实不复杂,说白了就是“主设备发号施令,从设备响应”。基本步骤是这样的:
- 起始条件:SCL 高电平时,SDA 从高变低
- 发送地址:主设备发送 7 位从设备地址 + 1 位读写位
- 从设备应答:从设备拉低 SDA 表示 ACK
- 数据传输:每 8 位数据后跟一个 ACK/NACK
- 停止条件: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 数据的标准流程是这样的:
- 读取校准参数(一次性操作,存到结构体里)
- 配置测量模式(比如正常模式、强制模式)
- 等待测量完成(通常几毫秒)
- 读取原始数据(温度、气压、湿度各 3 字节)
- 用校准参数计算真实值
我个人习惯把校准参数读取放在初始化阶段,后面每次读取只做第 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 总线,那又是另一番天地了。