第10章:通信协议入门——I2C、SPI、UART详解

各位同学,今天我们来聊聊嵌入式开发中最基础也最重要的三个通信协议。说实话,我刚开始做驱动开发那会儿,被这几个协议搞得晕头转向。尤其是I2C的时序图,看着就像天书。但后来我发现,只要抓住几个关键点,这些协议其实没那么复杂。

咱们一个一个来。先讲I2C,再讲SPI,最后聊UART。这三个协议,你搞懂了,市面上90%的传感器驱动你都能拿得下。

一、I2C协议详解

I2C,全称Inter-Integrated Circuit,是飞利浦公司搞出来的。它最大的特点就是——只用两根线!一根时钟线SCL,一根数据线SDA。你想想看,一根线上能挂几十个设备,多省IO口啊。

时序要点

I2C的时序,说白了就是几个状态:起始、停止、应答、非应答。我画个简单的流程:

  • 起始条件:SCL高电平时,SDA从高变低
  • 停止条件:SCL高电平时,SDA从低变高
  • 数据采样:SCL低电平时改变SDA,高电平时采样
  • 应答位:每发送8位数据后,接收方拉低SDA表示应答

避坑指南:我曾经在一个项目里,I2C死活通信不上。查了两天,最后发现是起始条件没满足时序要求。SCL高电平的保持时间不够,从机根本不认。所以啊,时序参数一定要看数据手册,别想当然。

地址与读写操作

I2C的地址是7位的,但实际发送时是8位——左移一位,最低位表示读写方向。0表示写,1表示读。比如地址0x50,写操作就是0xA0,读操作就是0xA1。

读写流程是这样的:

  1. 主机发送起始条件
  2. 主机发送7位地址+读写位
  3. 从机应答(拉低SDA)
  4. 主机发送寄存器地址(如果是写操作)
  5. 从机应答
  6. 数据传输(每字节后跟应答)
  7. 主机发送停止条件
// I2C写操作示例(伪代码)
void i2c_write(uint8_t dev_addr, uint8_t reg_addr, uint8_t data) {
    i2c_start();                    // 起始条件
    i2c_send_byte(dev_addr << 1);  // 地址+写位
    i2c_wait_ack();                 // 等待应答
    i2c_send_byte(reg_addr);        // 寄存器地址
    i2c_wait_ack();
    i2c_send_byte(data);            // 发送数据
    i2c_wait_ack();
    i2c_stop();                     // 停止条件
}

个人经验:我习惯在I2C通信前后加个延时,尤其是高速模式。有些从机反应慢,连续操作容易丢数据。另外,记得加上超时处理,别让程序死等应答。

二、SPI协议详解

SPI,Serial Peripheral Interface,摩托罗拉发明的。它比I2C快,但用的线也多——四根线:SCK(时钟)、MOSI(主出从入)、MISO(主入从出)、CS(片选)。

四种模式

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

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

说白了,CPOL决定空闲时时钟是高还是低,CPHA决定数据在哪个边沿采样。我遇到过不少新手,SPI通信失败,十有八九是模式没配对。

注意:我曾经在一个项目中,传感器手册写的是模式0,但实际测试发现模式3才能正常工作。后来发现是手册版本错了。所以啊,别完全信手册,实际测试才是王道。

片选与全双工

SPI的片选信号是低电平有效。你要跟哪个设备通信,就把对应的CS拉低。注意,片选切换时最好留点时间,别太快。

全双工是SPI的一大优势。什么意思?就是发送和接收可以同时进行。你发一个字节的同时,也能收到一个字节。这在I2C里是做不到的。

// SPI读写示例(伪代码)
uint8_t spi_transfer(uint8_t data) {
    // 发送一个字节,同时接收一个字节
    for (int i = 7; i >= 0; i--) {
        // 发送位
        if (data & (1 << i))
            MOSI_HIGH();
        else
            MOSI_LOW();
        
        // 时钟上升沿
        SCK_HIGH();
        delay();
        
        // 接收位
        if (MISO_READ())
            rx_data |= (1 << i);
        
        // 时钟下降沿
        SCK_LOW();
        delay();
    }
    return rx_data;
}

小技巧:我习惯用DMA做SPI传输,尤其是大数据量的时候。CPU不用一直等着,效率高很多。但要注意DMA的配置,别搞错了传输方向。

三、UART协议详解

UART,Universal Asynchronous Receiver/Transmitter。它是最简单的串行通信协议,就两根线——TX和RX。没有时钟线,全靠约定的波特率来同步。

数据帧格式

UART的一帧数据包括:起始位(1位)、数据位(5-8位)、校验位(可选)、停止位(1-2位)。最常见的是8N1——8位数据、无校验、1位停止位。

通信过程是这样的:

  • 空闲时,TX线为高电平
  • 起始位:TX拉低一个位时间
  • 数据位:从低位到高位依次发送
  • 校验位:奇校验或偶校验(可选)
  • 停止位:TX拉高一个或多个位时间
// UART发送一个字节(伪代码)
void uart_send_byte(uint8_t data) {
    // 起始位
    TX_LOW();
    delay_bit_time();
    
    // 发送8位数据(低位在前)
    for (int i = 0; i < 8; i++) {
        if (data & (1 << i))
            TX_HIGH();
        else
            TX_LOW();
        delay_bit_time();
    }
    
    // 停止位
    TX_HIGH();
    delay_bit_time();
}

避坑指南:我曾经在一个项目里,UART通信偶尔会丢字节。查了半天,发现是波特率误差太大。晶振频率和波特率不匹配,导致采样点偏移。所以啊,选波特率时一定要算一下误差,最好在2%以内。

波特率与同步

UART没有时钟线,收发双方必须用相同的波特率。常见的波特率有9600、115200等。波特率越高,传输越快,但对时钟精度要求也越高。

接收端怎么知道什么时候采样?它会在检测到起始位(TX从高变低)后,等待半个位时间,然后开始采样。之后每个位时间采样一次。这就是所谓的「异步」——没有时钟线,全靠约定好的速率。

个人习惯:我一般用115200作为调试串口的波特率。9600太慢,再高的话容易受干扰。另外,记得加个环形缓冲区,防止数据丢失。尤其是中断接收的时候,缓冲区能帮你缓冲一下处理不过来的数据。

小结

好了,三个协议都讲完了。我总结一下:

  • I2C:两根线,多设备,速度慢,适合传感器配置和少量数据传输
  • SPI:四根线,速度快,全双工,适合高速数据采集
  • UART:两根线,异步,简单,适合调试和低速通信

你想想看,这三个协议各有各的适用场景。做能耗监测传感器,我一般用I2C配置传感器参数,用SPI读取高速数据,用UART输出调试信息。选对协议,事半功倍。

下一章,咱们会结合具体的传感器芯片,讲讲怎么把这些协议用在实际的驱动开发中。到时候我会拿一个真实的项目案例来演示,保证你们能学到干货。