3、硬件接口基础:I2C协议详解(时序、地址、读写操作)、SPI协议详解(模式、片选、全双工)、GPIO模拟时序

各位同学,大家好。今天我们聊聊嵌入式开发里最基础、也最绕不开的两个协议——I2C和SPI。再加上GPIO模拟时序,这几乎是每个驱动工程师的必修课。

我个人习惯,讲协议之前先问一个问题:为什么需要这些协议?说白了,芯片之间要说话,就得有共同的“语言”。I2C和SPI就是两种最流行的“方言”。

3.1 I2C协议详解

I2C,全称Inter-Integrated Circuit,飞利浦公司发明的。它最大的特点就是省引脚——只用两根线:SCL(时钟线)和SDA(数据线)。

我在项目中遇到过不少新手,上来就问:“两根线怎么传数据?”嗯,这就是I2C的精妙之处。

3.1.1 时序基础

I2C的时序,说白了就是SCL和SDA两根线上的电平变化规则。我把它拆成几个关键动作:

  • 起始条件(Start Condition):SCL高电平时,SDA从高变低。这是通信开始的信号。
  • 停止条件(Stop Condition):SCL高电平时,SDA从低变高。通信结束。
  • 数据采样:数据在SCL低电平时变化,高电平时被采样。记住这个节奏。

关键点:I2C是同步协议,所有动作都跟着SCL走。没有时钟,就没有数据。

3.1.2 地址与读写操作

I2C总线上可以挂多个设备,每个设备有唯一的7位或10位地址。我常用的7位地址,最多挂127个设备。

读写操作流程是这样的:

  1. 主机发送起始条件
  2. 主机发送7位从机地址 + 1位读写位(0写,1读)
  3. 从机应答(ACK)
  4. 数据传输(每字节后跟一个ACK)
  5. 主机发送停止条件

避坑指南:我曾经在调试一个温度传感器时,死活读不到数据。折腾了半天,发现是地址搞错了——芯片手册上写的地址是7位,但我忘了左移一位。记住:I2C地址在发送时是左移一位,最低位是读写位。

举个例子,读一个寄存器:

// 伪代码:读I2C设备寄存器
i2c_start();                    // 起始条件
i2c_send_byte(0x50 << 1 | 0);  // 地址+写位
i2c_wait_ack();                 // 等待应答
i2c_send_byte(reg_addr);        // 发送寄存器地址
i2c_wait_ack();
i2c_start();                    // 重复起始条件
i2c_send_byte(0x50 << 1 | 1);  // 地址+读位
i2c_wait_ack();
data = i2c_read_byte();         // 读取数据
i2c_send_nack();                // 发送非应答
i2c_stop();                     // 停止条件

3.2 SPI协议详解

SPI,全称Serial Peripheral Interface。和I2C不同,SPI是全双工的,而且速度更快。你想想看,I2C最高也就几MHz,SPI轻松上几十MHz。

3.2.1 四根线

SPI用四根线:

  • SCLK:时钟线,由主机控制
  • MOSI:主机输出,从机输入
  • MISO:主机输入,从机输出
  • CS/SS:片选线,低电平有效

我刚开始学SPI时,总觉得MOSI和MISO容易搞混。后来记住一句话:“主出从入,主入从出”,再也没错过。

3.2.2 四种模式

SPI有四种模式,由时钟极性(CPOL)和时钟相位(CPHA)决定:

模式 CPOL CPHA 数据采样边沿
模式0 0 0 上升沿采样
模式1 0 1 下降沿采样
模式2 1 0 下降沿采样
模式3 1 1 上升沿采样

注意:我曾经在移植一个SPI Flash驱动时,发现读写数据全是乱的。查了半天,原来是主控和从设备的SPI模式不匹配。主控默认模式0,Flash要求模式3。记住:通信双方模式必须一致

3.2.3 片选与全双工

片选(CS)是SPI的一大特色。每个从设备独占一根CS线,主机通过拉低对应CS来选择通信对象。这就避免了I2C那种地址冲突的问题。

全双工是什么意思?就是主机发数据的同时,也能收数据。SPI的移位寄存器是同时工作的——你发一个字节,同时就收到一个字节。我习惯用“交换数据”来理解它。

// 伪代码:SPI全双工读写
uint8_t spi_transfer(uint8_t tx_data) {
    // 发送一个字节,同时接收一个字节
    SPI_DR = tx_data;          // 写入发送寄存器
    while(!(SPI_SR & TX_EMPTY)); // 等待发送完成
    while(!(SPI_SR & RX_FULL));  // 等待接收完成
    return SPI_DR;             // 读取接收数据
}

3.3 GPIO模拟时序

有时候,芯片的硬件I2C或SPI外设不够用,或者要接一些非标设备,就得用GPIO来“模拟”协议时序。说白了,就是用软件控制GPIO的高低电平,模拟出协议规定的波形

我做过一个项目,主控只有两个SPI外设,但接了三个SPI设备。没办法,第三个设备只能用GPIO模拟。嗯,这里要注意:GPIO模拟的时序精度取决于CPU的主频和中断响应时间

3.3.1 GPIO模拟I2C

模拟I2C的关键是精确控制SCL和SDA的时序。我常用的方法是:

  • 用定时器或延时函数控制时钟周期
  • 严格按照I2C规范,保证建立时间和保持时间
  • 注意开漏输出和上拉电阻
// 模拟I2C起始条件
void i2c_sim_start(void) {
    SDA_OUT();          // SDA设为输出
    SDA_HIGH();         // SDA高电平
    SCL_HIGH();         // SCL高电平
    delay_us(5);        // 保持时间
    SDA_LOW();          // SDA拉低
    delay_us(5);        // 保持时间
    SCL_LOW();          // SCL拉低
}

避坑指南:我曾经用GPIO模拟I2C时,发现通信偶尔会失败。后来用示波器一看,原来是延时函数不够准——系统中断导致时序抖动。解决办法:关中断,或者用硬件定时器做精确延时

3.3.2 GPIO模拟SPI

模拟SPI相对简单,因为SPI的时序要求没那么严格。关键是:

  • 控制好时钟频率,别太快
  • 注意数据在时钟边沿的建立时间
  • 片选信号要稳定
// 模拟SPI模式0发送一个字节
void spi_sim_send_byte(uint8_t data) {
    for(int i = 7; i >= 0; i--) {
        SCLK_LOW();                     // 时钟拉低
        if(data & (1 << i)) {
            MOSI_HIGH();                // 数据位1
        } else {
            MOSI_LOW();                 // 数据位0
        }
        delay_us(1);                    // 建立时间
        SCLK_HIGH();                    // 时钟拉高,采样数据
        delay_us(1);                    // 保持时间
    }
    SCLK_LOW();                         // 结束
}

3.3.3 什么时候用GPIO模拟?

我个人总结了几种场景:

  • 硬件外设不够用:比如上面说的,多个设备争抢外设
  • 非标协议:有些传感器时序和标准协议略有不同,用GPIO模拟更灵活
  • 调试阶段:先模拟验证,再切到硬件外设
  • 成本敏感:低端MCU没有硬件I2C/SPI,只能模拟

注意:GPIO模拟不适合高速通信。我一般只在100kHz以下的I2C和1MHz以下的SPI用模拟。再高的话,CPU就忙不过来了。

好了,这一章的内容就到这里。I2C和SPI是嵌入式开发的基石,GPIO模拟则是灵活应变的手段。下一章我们聊聊中断处理,这可是驱动开发的灵魂。