4. I2C 读写操作:单字节写、单字节读、多字节写(页写)、多字节读(顺序读)时序详解
在音频 Codec 驱动开发中,I2C 总线是配置内部寄存器(如采样率、增益、混音器路由)的主要通道。理解四种基本读写操作的时序,是编写正确驱动代码、排查通信故障的前提。本章将逐一拆解单字节写、单字节读、多字节写(页写)和多字节读(顺序读)的 SCL/SDA 波形与协议流程。
4.1 四种操作的核心差异
下表从传输方向、数据长度、起始/停止条件数量三个维度对比四种操作,帮助建立整体认知:
| 操作类型 | 主机发送 → 从机 | 从机发送 → 主机 | 数据字节数 | 起始/停止条件 | 典型用途 |
|---|---|---|---|---|---|
| 单字节写 | 从机地址(W) + 寄存器地址 + 数据 | 无 | 1 字节 | 1 Start + 1 Stop | 配置单个寄存器 |
| 单字节读 | 从机地址(W) + 寄存器地址 + 重复起始 + 从机地址(R) | 1 字节数据 | 1 字节 | 1 Start + 1 Repeated Start + 1 Stop | 读取单个寄存器值 |
| 多字节写(页写) | 从机地址(W) + 起始寄存器地址 + N 字节数据 | 无 | N 字节(通常 ≤ 16) | 1 Start + 1 Stop | 批量初始化寄存器组 |
| 多字节读(顺序读) | 从机地址(W) + 起始寄存器地址 + 重复起始 + 从机地址(R) | N 字节数据 | N 字节 | 1 Start + 1 Repeated Start + 1 Stop | 读取连续寄存器块 |
关键概念: 多字节写/读的“自动地址递增”特性由从机内部逻辑实现。主机只需发送起始寄存器地址,从机在每接收/发送一个字节后自动将地址加 1,无需主机逐字节发送地址。
4.2 单字节写(Single Byte Write)
单字节写是最基础的 I2C 写操作,用于向音频 Codec 的某个寄存器写入一个配置值。完整时序包含以下 7 个阶段:
- 起始条件(S): SCL 高电平时,SDA 由高变低。
- 从机地址 + 写位: 主机发送 7 位从机地址 + 第 8 位为 0(写方向)。
- ACK(从机应答): 从机在第 9 个 SCL 周期将 SDA 拉低,表示确认。
- 寄存器地址: 主机发送 8 位寄存器地址(如 0x02 表示 ALC 控制寄存器)。
- ACK(从机应答): 从机再次拉低 SDA 确认。
- 数据字节: 主机发送 8 位数据(如 0x3F 表示增益值)。
- ACK(从机应答) + 停止条件(P): 从机应答后,主机在 SCL 高电平时将 SDA 由低变高,结束传输。
驱动代码示例(Linux I2C 子系统):
// 单字节写:向 Codec 寄存器 0x02 写入 0x3F
int codec_write_reg(struct i2c_client *client, u8 reg, u8 val) {
u8 buf[2] = {reg, val};
struct i2c_msg msg = {
.addr = client->addr,
.flags = 0, // 写方向
.len = 2,
.buf = buf,
};
return i2c_transfer(client->adapter, &msg, 1);
}
4.3 单字节读(Single Byte Read)
单字节读需要两次传输:先写寄存器地址,再读数据。关键特征是使用 重复起始条件(Repeated Start, Sr) 代替 Stop + Start,避免总线释放后被其他主机抢占。
- 阶段 1(写地址段): Start → 从机地址(W) → ACK → 寄存器地址 → ACK
- 阶段 2(读数据段): Repeated Start → 从机地址(R) → ACK → 数据字节 → NACK(主机非应答)→ Stop
常见错误: 初学者容易在写地址段结束后发送 Stop 条件,再发 Start 进行读操作。这会导致总线释放,在多主系统中可能被其他设备插入。正确做法是使用 Repeated Start,保持总线控制权。
驱动代码示例:
// 单字节读:从 Codec 寄存器 0x02 读取当前值
int codec_read_reg(struct i2c_client *client, u8 reg, u8 *val) {
struct i2c_msg msgs[2] = {
{
.addr = client->addr,
.flags = 0, // 写:发送寄存器地址
.len = 1,
.buf = ®,
},
{
.addr = client->addr,
.flags = I2C_M_RD, // 读:接收数据
.len = 1,
.buf = val,
},
};
return i2c_transfer(client->adapter, msgs, 2);
}
4.4 多字节写(页写 / Page Write)
音频 Codec 初始化时通常需要连续配置多个寄存器(如时钟树、数字接口格式)。多字节写允许主机在一次传输中发送起始地址和多个数据字节,从机内部自动递增地址,大幅提高效率。
时序特点:
- 只使用 1 个 Start 和 1 个 Stop。
- 寄存器地址之后连续发送 N 个数据字节,每个字节后跟一个 ACK。
- 从机内部地址指针在每接收一个字节后自动加 1。
- 页写长度受从机限制:常见 Codec(如 WM8960、TLV320AIC3x)页写上限为 16 字节,超出部分行为未定义。
| 阶段 | 主机发送 | 从机响应 | 说明 |
|---|---|---|---|
| 1 | Start + 从机地址(W) | ACK | 开始写传输 |
| 2 | 起始寄存器地址 | ACK | 设置内部地址指针 |
| 3 | 数据字节 1 | ACK | 写入地址 N,指针 → N+1 |
| 4 | 数据字节 2 | ACK | 写入地址 N+1,指针 → N+2 |
| … | … | … | … |
| N+2 | 数据字节 N | ACK | 写入地址 N+(N-1) |
| N+3 | Stop | — | 结束传输 |
页写 vs 单字节写性能对比: 假设要初始化 8 个寄存器,单字节写需要 8 次 Start/Stop 序列,总开销约 8 × (2.5 字节协议开销) = 20 字节;页写只需 1 次 Start/Stop,总开销约 2.5 + 8 = 10.5 字节,效率提升近 50%。
驱动代码示例:
// 多字节写(页写):从起始寄存器地址开始连续写入 len 个字节
int codec_page_write(struct i2c_client *client, u8 start_reg,
const u8 *data, int len) {
u8 buf[256]; // 确保足够大,实际使用需动态分配或限制长度
buf[0] = start_reg;
memcpy(&buf[1], data, len);
struct i2c_msg msg = {
.addr = client->addr,
.flags = 0,
.len = len + 1, // 寄存器地址 + 数据
.buf = buf,
};
return i2c_transfer(client->adapter, &msg, 1);
}
4.5 多字节读(顺序读 / Sequential Read)
顺序读用于从 Codec 中读取连续寄存器块,例如读取整个 EQ 系数表或 ALC 状态。其结构与单字节读类似,但在读数据段中主机连续接收多个字节,除最后一个字节外均发送 ACK。
关键规则:
- 主机在接收完倒数第二个字节后发送 ACK,通知从机继续发送。
- 主机在接收完最后一个字节后发送 NACK,通知从机停止发送。
- 从机内部地址指针在每发送一个字节后自动加 1。
| 阶段 | 方向 | 内容 | 应答 |
|---|---|---|---|
| 1 | 主机→从机 | Start + 从机地址(W) | ACK |
| 2 | 主机→从机 | 起始寄存器地址 | ACK |
| 3 | 主机→从机 | Repeated Start + 从机地址(R) | ACK |
| 4 | 从机→主机 | 数据字节 1(地址 N) | 主机 ACK |
| 5 | 从机→主机 | 数据字节 2(地址 N+1) | 主机 ACK |
| … | 从机→主机 | … | 主机 ACK |
| N+3 | 从机→主机 | 数据字节 N(地址 N+N-1) | 主机 NACK |
| N+4 | 主机→从机 | Stop | — |
调试陷阱: 如果主机在顺序读的最后一个字节误发了 ACK 而不是 NACK,从机会认为主机还想继续接收,从而将 SDA 保持为输出状态。此时主机发送 Stop 条件时,SDA 可能被从机拉低,导致 Stop 条件无法形成,总线被锁死。这是 I2C 调试中最常见的挂死原因之一。