4. I2C 总线驱动开发:从协议到实战
I2C 总线,说实话,是嵌入式开发里最常用的串行通信协议之一。两根线,挂一堆设备,简单又灵活。但很多新手朋友在 STM32 上玩 I2C 时,总会遇到一些奇奇怪怪的问题——比如总线锁死、从机无应答、时钟延展没处理好等等。
我个人习惯,先把协议时序吃透,再去看硬件外设,最后套上 RT-Thread 的驱动框架。这样出了问题,你能快速定位是硬件问题还是软件问题。今天我们就拿 AT24C02 这个经典的 EEPROM 来练手。
4.1 I2C 协议时序:两根线上的舞蹈
I2C 总线就两条线:SCL(时钟)和 SDA(数据)。所有设备都挂在这两根线上,通过地址来区分。为什么它能做到?因为协议里定义了严格的时序。
核心知识点: I2C 通信的四个关键时序——起始条件、停止条件、数据有效性、应答信号。
4.1.1 起始与停止条件
SCL 高电平时,SDA 从高变低 → 起始条件。
SCL 高电平时,SDA 从低变高 → 停止条件。
这个时序是唯一的,总线上所有设备都会检测到。我曾经在一个项目里,因为代码里起始条件没拉够时间,导致从机一直没反应。查了半天,最后用逻辑分析仪一看,波形不对。嗯,从那以后我写 I2C 驱动,第一件事就是先抓波形。
4.1.2 数据有效性
SCL 低电平时,SDA 可以变化。
SCL 高电平时,SDA 必须稳定。
说白了,数据是在 SCL 的上升沿被锁存的。你想想看,如果 SCL 高电平时 SDA 还在跳变,那接收方就不知道你传的是啥了。
4.1.3 应答信号
每发送完一个字节(8位),接收方要在第9个时钟周期拉低 SDA,表示“我收到了”。如果 SDA 保持高电平,那就是 NACK,表示接收方不想理你。
| 信号 | SCL 状态 | SDA 动作 | 说明 |
|---|---|---|---|
| 起始 | 高 | 高→低 | 总线开始通信 |
| 数据位 | 低→高 | 保持稳定 | 上升沿采样 |
| 应答 | 高 | 被拉低 | ACK 确认 |
| 停止 | 高 | 低→高 | 总线释放 |
小技巧: 调试 I2C 时,逻辑分析仪是你的好朋友。设置好触发条件,抓一次通信过程,所有问题一目了然。
4.2 STM32 硬件 I2C:外设怎么用
STM32 的硬件 I2C 外设,说白了就是一个状态机。你配置好寄存器,它自动帮你拉 SCL、采样 SDA、产生应答。但很多开发者觉得 STM32 的硬件 I2C 不好用,容易卡死。我个人觉得,只要配置得当,硬件 I2C 比软件模拟稳定得多。
4.2.1 初始化配置
主要配置这几个东西:
- 时钟频率: 标准模式 100kHz,快速模式 400kHz。AT24C02 支持 400kHz,但我建议刚开始用 100kHz,稳一点。
- 自身地址: 主机模式下不需要,但寄存器里要填,防止意外。
- GPIO 配置: SCL 和 SDA 要配置为开漏输出,并启用内部上拉(或者外部加 4.7kΩ 上拉电阻)。
// 以 STM32F4 为例,I2C1 初始化
void MX_I2C1_Init(void)
{
hi2c1.Instance = I2C1;
hi2c1.Init.ClockSpeed = 100000; // 100kHz
hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;
hi2c1.Init.OwnAddress1 = 0; // 主机地址无所谓
hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;
hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;
hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;
hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;
HAL_I2C_Init(&hi2c1);
}
注意: NoStretchMode 建议关闭。如果从机需要时钟延展(比如 AT24C02 在写操作期间),主机必须等待。关闭 NoStretchMode 后,硬件会自动处理延展。
4.2.2 发送与接收流程
硬件 I2C 的发送流程其实很简单:
- 产生起始条件。
- 发送从机地址 + 写位(0)。
- 等待 ACK。
- 发送数据字节,每字节等待 ACK。
- 产生停止条件。
接收流程类似,只是地址后跟读位(1),然后主机在每字节后产生 ACK,最后一字节产生 NACK 然后停止。
HAL 库封装了这些操作,直接用 HAL_I2C_Master_Transmit() 和 HAL_I2C_Master_Receive() 就行。但我建议你了解一下底层寄存器操作,万一 HAL 库出 bug,你能自己修。
4.3 RT-Thread I2C 设备驱动框架
RT-Thread 的 I2C 框架,说白了就是帮你把硬件操作和上层应用解耦。你写一个驱动,注册到框架里,应用层就可以用统一的接口来读写任何 I2C 设备。
4.3.1 框架结构
RT-Thread 的 I2C 框架分三层:
- I2C 核心层: 提供
rt_i2c_transfer()等通用接口。 - I2C 总线设备层: 每个 I2C 外设对应一个总线设备,比如 i2c1、i2c2。
- I2C 从设备层: 挂载在总线上的具体设备,比如 at24c02。
4.3.2 注册与使用
在 RT-Thread 里,你只需要做两件事:
- 调用
rt_hw_i2c_init()初始化硬件并注册到框架。 - 调用
rt_i2c_bus_device_find()找到总线,然后用rt_i2c_transfer()通信。
// 查找 I2C 总线设备
struct rt_i2c_bus_device *i2c_bus;
i2c_bus = (struct rt_i2c_bus_device *)rt_device_find("i2c1");
// 构造消息结构体
struct rt_i2c_msg msgs[2];
// 先发送设备地址 + 寄存器地址
msgs[0].addr = AT24C02_ADDR;
msgs[0].flags = RT_I2C_WR;
msgs[0].len = 2;
msgs[0].buf = write_buf;
// 再读取数据
msgs[1].addr = AT24C02_ADDR;
msgs[1].flags = RT_I2C_RD;
msgs[1].len = 1;
msgs[1].buf = read_buf;
// 执行传输
rt_i2c_transfer(i2c_bus, msgs, 2);
经验之谈: 用 rt_i2c_transfer() 时,可以组合多个消息实现复合操作(比如先写地址再读数据)。框架会自动处理起始条件和停止条件,你只需要把消息数组传进去就行。
4.4 实战:驱动 AT24C02 EEPROM
AT24C02 是一个 2Kbit 的 EEPROM,也就是 256 字节。它通过 I2C 接口通信,设备地址是 0xA0(写)和 0xA1(读)。
4.4.1 写操作
写 AT24C02 有两种方式:
- 字节写: 先发设备地址,再发存储地址,最后发数据。
- 页写: 一次最多写 8 字节(AT24C02 的页大小是 8 字节)。
注意:写操作后,AT24C02 需要 5ms 左右的时间来完成内部编程。这段时间内它不会响应任何 I2C 请求。我建议你写完后加个延时,或者轮询等待 ACK。
// 字节写示例
static rt_err_t at24c02_write_byte(rt_uint8_t addr, rt_uint8_t data)
{
struct rt_i2c_msg msg;
rt_uint8_t buf[2];
buf[0] = addr; // 存储地址
buf[1] = data; // 要写的数据
msg.addr = AT24C02_ADDR;
msg.flags = RT_I2C_WR;
msg.len = 2;
msg.buf = buf;
if (rt_i2c_transfer(i2c_bus, &msg, 1) != 1)
return -RT_ERROR;
rt_thread_mdelay(5); // 等待内部编程完成
return RT_EOK;
}
4.4.2 读操作
读操作需要先发送存储地址,然后重新发起起始条件,再读取数据。这就是典型的“复合操作”。
// 随机读示例
static rt_err_t at24c02_read_byte(rt_uint8_t addr, rt_uint8_t *data)
{
struct rt_i2c_msg msgs[2];
// 先写地址
msgs[0].addr = AT24C02_ADDR;
msgs[0].flags = RT_I2C_WR;
msgs[0].len = 1;
msgs[0].buf = &addr;
// 再读数据
msgs[1].addr = AT24C02_ADDR;
msgs[1].flags = RT_I2C_RD;
msgs[1].len = 1;
msgs[1].buf = data;
if (rt_i2c_transfer(i2c_bus, msgs, 2) != 2)
return -RT_ERROR;
return RT_EOK;
}
避坑指南: 我曾经在连续读取多个字节时,忘记处理页边界。AT24C02 的地址是 8 位,但如果你跨页读取(比如从地址 0x07 读 2 个字节),它会自动回绕到页首。所以读操作一般没问题,但写操作一定要小心页边界。
4.4.3 完整驱动注册
在 RT-Thread 里,我们可以把 AT24C02 封装成一个设备驱动,注册到框架中。这样应用层就可以用 rt_device_open()、rt_device_read()、rt_device_write() 来操作了。
// 驱动注册示例
static rt_err_t at24c02_init(rt_device_t dev)
{
// 查找 I2C 总线
i2c_bus = (struct rt_i2c_bus_device *)rt_device_find("i2c1");
if (!i2c_bus)
return -RT_ERROR;
return RT_EOK;
}
static rt_size_t at24c02_read(rt_device_t dev, rt_off_t pos, void *buffer, rt_size_t size)
{
rt_uint8_t *buf = (rt_uint8_t *)buffer;
for (rt_size_t i = 0; i < size; i++)
{
at24c02_read_byte(pos + i, &buf[i]);
}
return size;
}
static rt_size_t at24c02_write(rt_device_t dev, rt_off_t pos, const void *buffer, rt_size_t size)
{
rt_uint8_t *buf = (rt_uint8_t *)buffer;
for (rt_size_t i = 0; i < size; i++)
{
at24c02_write_byte(pos + i, buf[i]);
}
return size;
}
注册完成后,你就可以像操作普通文件一样操作 EEPROM 了。是不是很方便?
总结一下: I2C 驱动开发,核心就是理解协议时序,用好硬件外设,然后套上 RT-Thread 的框架。AT24C02 只是一个开始,掌握了这套方法,其他 I2C 设备(比如传感器、RTC 芯片)你都能轻松搞定。