4. I2C总线协议与驱动开发:时序、仲裁与实战
I2C,说白了就是一根时钟线加一根数据线,却能挂一堆设备。我刚入行时觉得这协议太简单了,结果第一次调EEPROM就卡了两天——嗯,时序没搞对。这一章咱们就把I2C从里到外扒清楚,再手把手把驱动写出来。
4.1 I2C时序与寻址机制
I2C只有两条线:SCL(时钟)和SDA(数据)。所有通信都由主机控制时钟,从机只能被动响应。我个人习惯把I2C通信拆成四个基本动作:起始、停止、发送字节、应答。
- 起始条件:SCL高电平时,SDA从高变低
- 停止条件:SCL高电平时,SDA从低变高
- 数据有效:SCL高电平时,SDA必须稳定;SCL低电平时,SDA才能变化
- 应答:每发送8位数据后,接收方在第9个SCL周期拉低SDA表示ACK
寻址机制呢?标准模式是7位地址,加上读写位组成第一个字节。比如0x50左移一位变成0xA0(写)或0xA1(读)。我记得第一次用逻辑分析仪抓I2C波形,看到地址字节后面跟了个0(写)或1(读),才真正理解“7位地址+1位读写”是怎么回事。
| 模式 | 地址位 | 最大速率 | 总线电容 |
|---|---|---|---|
| 标准模式 | 7位 | 100 kHz | ≤400 pF |
| 快速模式 | 7位/10位 | 400 kHz | ≤400 pF |
| 快速模式+ | 7位/10位 | 1 MHz | ≤550 pF |
4.2 多主机仲裁与时钟拉伸
多主机仲裁,说白了就是多个主机同时抢总线时,谁先拉低SDA谁赢。I2C的仲裁机制很巧妙——它不会破坏数据,只是让输的那一方自动退出。
为什么会这样?因为I2C是开漏输出,多个设备同时拉低SDA时,低电平会覆盖高电平。每个主机在发送数据时都会回读SDA电平,如果发现自己发送的是1但读到的是0,就知道仲裁失败了,立刻释放总线。
- 仲裁在SDA上进行,SCL由主机共同驱动
- 发送低电平的设备会赢得仲裁
- 仲裁失败的设备必须释放SDA,等待下次起始条件
- 仲裁过程中不会丢失数据
时钟拉伸呢?这是从机用来“喘口气”的机制。从机在需要处理数据时,可以拉低SCL,强制主机等待。等从机准备好了,再释放SCL。我在驱动一个慢速温湿度传感器时就遇到过——它每次转换完成后会拉伸时钟约500μs,如果主机不支持时钟拉伸,通信就会失败。
4.3 硬件I2C与软件模拟I2C
这两种方式各有千秋。我个人的经验是:能上硬件I2C就别用软件模拟,除非引脚不够或者需要特殊时序。
| 对比项 | 硬件I2C | 软件模拟I2C |
|---|---|---|
| CPU占用 | 低(DMA+中断) | 高(轮询/忙等) |
| 引脚选择 | 固定引脚 | 任意GPIO |
| 时钟拉伸 | 自动处理 | 需手动实现 |
| 多主机仲裁 | 硬件支持 | 极难实现 |
| 代码量 | 配置寄存器即可 | 需逐位模拟时序 |
下面给一个软件模拟I2C的发送字节函数,你想想看,其实就是按位操作GPIO:
void I2C_Soft_SendByte(uint8_t data) {
for (int i = 0; i < 8; i++) {
// SCL低电平,设置SDA
I2C_SCL_LOW();
if (data & 0x80) {
I2C_SDA_HIGH();
} else {
I2C_SDA_LOW();
}
// SCL高电平,从机采样
delay_us(5); // 保持建立时间
I2C_SCL_HIGH();
delay_us(5); // 保持高电平时间
data <<= 1;
}
// 释放SDA,等待应答
I2C_SCL_LOW();
I2C_SDA_HIGH();
delay_us(5);
I2C_SCL_HIGH();
// 读取ACK
if (I2C_SDA_READ()) {
// NACK处理
}
I2C_SCL_LOW();
}
4.4 实战:驱动EEPROM(AT24C02)
AT24C02是2Kbit的EEPROM,内部组织成256字节,每页8字节。写操作需要先发设备地址,再发内部地址,最后发数据。读操作分两种:当前地址读和随机地址读。
我记得第一次写EEPROM驱动时,没注意页写限制——连续写超过8字节就会回卷覆盖。后来看了数据手册才发现,每次页写最多8字节,跨页必须重新发送起始条件。
// EEPROM页写函数
HAL_StatusTypeDef EEPROM_WritePage(uint16_t addr, uint8_t *data, uint8_t len) {
uint8_t buf[10]; // 设备地址 + 内部地址 + 最多8字节数据
buf[0] = (DEVICE_ADDR << 1) | 0; // 写操作
buf[1] = addr & 0xFF; // 内部地址
for (int i = 0; i < len && i < 8; i++) {
buf[2 + i] = data[i];
}
// 发送起始条件 + 地址 + 数据 + 停止条件
return HAL_I2C_Master_Transmit(&hi2c1, DEVICE_ADDR << 1, buf, 2 + len, 100);
}
4.5 实战:驱动温湿度传感器(SHT30)
SHT30是数字温湿度传感器,I2C接口,默认地址0x44。它支持多种测量模式:单次测量、周期测量、带CRC校验等。我个人习惯用单次测量模式,功耗低,控制简单。
驱动流程:发送测量命令 → 等待测量完成(约15ms) → 读取6字节数据(温度高、温度低、CRC、湿度高、湿度低、CRC)。
// SHT30单次测量命令
#define SHT30_CMD_MEASURE_HIGH 0x2C06
HAL_StatusTypeDef SHT30_ReadTempHum(float *temp, float *hum) {
uint8_t cmd[2] = {0x2C, 0x06};
uint8_t buf[6];
// 发送测量命令
HAL_I2C_Master_Transmit(&hi2c1, 0x44 << 1, cmd, 2, 100);
HAL_Delay(20); // 等待测量完成
// 读取数据
HAL_I2C_Master_Receive(&hi2c1, 0x44 << 1, buf, 6, 100);
// 计算温度(公式来自数据手册)
uint16_t raw_temp = (buf[0] << 8) | buf[1];
*temp = -45.0f + 175.0f * raw_temp / 65535.0f;
// 计算湿度
uint16_t raw_hum = (buf[3] << 8) | buf[4];
*hum = 100.0f * raw_hum / 65535.0f;
return HAL_OK;
}
4.6 I2C知识体系总览
下面这张图把I2C的核心知识点串起来了,从物理层到应用层,你一看就明白:
嗯,I2C的内容就这些。从时序到寻址,从仲裁到实战,你只要把每个环节吃透,以后遇到任何I2C设备都能快速上手。记住一点:示波器或逻辑分析仪是你调I2C最好的朋友——波形不会骗人。