4. RT-Thread内核基础

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 的发送流程其实很简单:

  1. 产生起始条件。
  2. 发送从机地址 + 写位(0)。
  3. 等待 ACK。
  4. 发送数据字节,每字节等待 ACK。
  5. 产生停止条件。

接收流程类似,只是地址后跟读位(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。
RT-Thread I2C 驱动框架结构 应用层(读写 AT24C02) AT24C02 设备驱动 I2C 核心层(rt_i2c_transfer) 硬件操作层(STM32 I2C 外设寄存器)

4.3.2 注册与使用

在 RT-Thread 里,你只需要做两件事:

  1. 调用 rt_hw_i2c_init() 初始化硬件并注册到框架。
  2. 调用 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 芯片)你都能轻松搞定。