3. I2C 地址与数据传输格式:7位地址与10位地址、读写操作、多字节传输、寄存器映射
I2C 通信,说白了就是主设备跟从设备之间“对暗号”的过程。暗号对上了,才能开始传数据。这个暗号,就是地址。
我刚开始搞 I2C 驱动时,总觉得地址这东西很简单,不就是发个 7 位二进制数嘛。直到有一次,我拿了一个 10 位地址的传感器,照着 7 位地址的流程去读,结果死活读不到数据。折腾了大半天,才发现是地址格式搞错了。嗯,从那以后,我再也不敢小看这个“暗号”了。
3.1 7 位地址与 10 位地址
I2C 总线上的每个从设备,都有一个唯一的地址。标准模式下,地址长度是 7 位。后来设备多了,7 位地址不够用,就扩展出了 10 位地址模式。
3.1.1 7 位地址
7 位地址的范围是 0x00 ~ 0x7F。但实际可用的地址,并没有 128 个那么多。有些地址是保留的,比如 0x00 用于通用呼叫,0x01 ~ 0x07 用于其他特殊用途。
| 地址范围 | 用途 |
|---|---|
| 0x00 | 通用呼叫地址(保留) |
| 0x01 ~ 0x07 | 保留地址 |
| 0x08 ~ 0x77 | 可用地址(常用范围) |
| 0x78 ~ 0x7F | 保留地址 |
我常用的传感器,比如 MPU6050,它的 7 位地址是 0x68。如果 AD0 引脚拉高,就变成 0x69。你看,硬件上改一个引脚,地址就变了。这就是 7 位地址的灵活性。
3.1.2 10 位地址
10 位地址的格式,跟 7 位不太一样。它用两个字节来传输地址。第一个字节的前 5 位是固定的 11110,接着是高 2 位地址,最后是读写位。第二个字节是低 8 位地址。
说白了,就是主设备先发一个“特殊头”,告诉从设备:“我要用 10 位地址了”。然后从设备就知道,后面还有一个字节的地址要收。
第一个字节:11110 + Addr[9:8] + R/W
第二个字节:Addr[7:0]
我在项目中遇到过一种气压传感器,用的就是 10 位地址。当时我按 7 位地址去扫描总线,怎么也扫不到。后来用逻辑分析仪抓波形,才发现第一个字节是 0xF0 开头,这才反应过来是 10 位地址。
3.2 读写操作
I2C 的读写操作,核心区别就在那个 R/W 位上。R/W = 0 表示写,R/W = 1 表示读。
你想想看,主设备要跟从设备通信,总得先打个招呼吧?这个招呼,就是起始条件 + 地址字节。
3.2.1 写操作流程
- 主设备发送起始条件(S)
- 主设备发送 7 位地址 + 写位(0)
- 从设备回应 ACK
- 主设备发送数据字节
- 从设备回应 ACK
- 重复第 4、5 步,直到数据发完
- 主设备发送停止条件(P)
3.2.2 读操作流程
- 主设备发送起始条件(S)
- 主设备发送 7 位地址 + 读位(1)
- 从设备回应 ACK
- 从设备发送数据字节
- 主设备回应 ACK(如果还要继续读)或 NACK(如果这是最后一个字节)
- 重复第 4、5 步,直到数据收完
- 主设备发送停止条件(P)
3.2.3 复合操作(Combined Format)
很多时候,我们需要先写一个寄存器地址,再读数据。这就是复合操作。流程是这样的:
- 主设备发送起始条件(S)
- 主设备发送 7 位地址 + 写位(0)
- 从设备回应 ACK
- 主设备发送寄存器地址字节
- 从设备回应 ACK
- 主设备发送重复起始条件(Sr)
- 主设备发送 7 位地址 + 读位(1)
- 从设备回应 ACK
- 从设备发送数据字节
- 主设备回应 NACK
- 主设备发送停止条件(P)
这个流程,在 Linux 内核里对应的是 i2c_smbus_read_byte_data() 和 i2c_smbus_read_i2c_block_data() 这些函数。我写驱动时,最常用的就是这种复合操作。
3.3 多字节传输
单字节传输太慢了。实际项目中,我们经常要一次读好几个字节。比如读一个加速度计的 X、Y、Z 轴数据,一次读 6 个字节,比读 6 次快多了。
多字节传输的流程,跟单字节差不多。区别在于,数据字节可以连续发,中间不需要重新发地址。
多字节传输的时序图,我画了一个简单的 SVG,方便你理解:
3.4 寄存器映射
寄存器映射,说白了就是一张“地址-功能对照表”。每个寄存器都有一个地址,里面存着特定的数据。你往哪个地址写数据,就能控制对应的功能。你从哪个地址读数据,就能拿到对应的状态。
我拿一个常见的加速度计来举例。它的寄存器映射表大概长这样:
| 寄存器地址 | 寄存器名称 | 功能描述 | 默认值 |
|---|---|---|---|
| 0x00 | WHO_AM_I | 设备 ID,读出来固定是 0x71 | 0x71 |
| 0x10 | CTRL_REG1 | 控制寄存器 1,使能传感器、设置模式 | 0x00 |
| 0x28 | OUT_X_L | X 轴加速度低字节 | 0x00 |
| 0x29 | OUT_X_H | X 轴加速度高字节 | 0x00 |
| 0x2A | OUT_Y_L | Y 轴加速度低字节 | 0x00 |
| 0x2B | OUT_Y_H | Y 轴加速度高字节 | 0x00 |
| 0x2C | OUT_Z_L | Z 轴加速度低字节 | 0x00 |
| 0x2D | OUT_Z_H | Z 轴加速度高字节 | 0x00 |
你看,读 WHO_AM_I 寄存器,就能确认 I2C 通信是不是正常的。如果读出来不是 0x71,那要么地址错了,要么焊接有问题。
寄存器映射还有一个重要概念:位域(Bit Field)。一个寄存器里的不同位,可能控制不同的功能。比如 CTRL_REG1 的 bit 0 是传感器使能位,bit 1 是低功耗模式使能位,bit 2~3 是输出数据速率选择位。
写驱动时,我一般用位操作来设置这些位域。比如:
// 设置 CTRL_REG1,使能传感器,设置数据速率为 100Hz
uint8_t ctrl = 0x00;
ctrl |= (1 << 0); // bit 0: 使能
ctrl |= (2 << 2); // bit 2~3: 数据速率 = 01 (100Hz)
i2c_smbus_write_byte_data(fd, 0x10, ctrl);
寄存器映射的另一个关键点,是地址递增。很多传感器支持“突发读取”(Burst Read)。你只要写一次起始地址,然后连续读,它就会自动把后续寄存器的值发出来。比如读加速度计数据,从 0x28 开始,连续读 6 个字节,就能拿到 X、Y、Z 轴的完整数据。
这个特性,在 Linux 内核驱动里对应的是 i2c_smbus_read_i2c_block_data() 函数。我写驱动时,读多字节数据都用这个函数,效率比单字节读高多了。
- 7 位地址范围 0x08 ~ 0x77,10 位地址用两个字节传输。
- 读写操作靠 R/W 位区分,复合操作用于先写寄存器地址再读数据。
- 多字节传输利用地址递增特性,一次读多个寄存器。
- 寄存器映射是驱动开发的“地图”,读 WHO_AM_I 是调试的第一步。