手撕I2C和SPI协议实现

I2C协议原理

I2C(Inter-Integrated Circuit)是一种串行通信总线,使用两根线:SCL(时钟线)和SDA(数据线)。

基本特性

  • 主从架构,支持多主机
  • 双向半双工通信
  • 每个设备都有唯一地址(7位或10位)
  • 开漏输出 + 上拉电阻实现线与功能
  • 通信速率:100kHz(标准)、400kHz(快速)、1MHz+(高速)

I2C时序图

基本信号时序
起始信号(START):
SCL: ────────────
SDA: ────┐       
         └─────── (SDA从高到低,SCL保持高)

停止信号(STOP):
SCL: ────────────
SDA: ─────┌─────  
          └───── (SDA从低到高,SCL保持高)

数据位传输:
SCL: ───┐   ┌───┐   ┌───
        └───┘   └───┘    (时钟脉冲)
SDA: ─────┬───────┬─────  
          │ Bit7  │ Bit6  (数据在SCL低时变化,高时采样)
完整传输时序
I2C写操作时序:
  START 地址+W  ACK  寄存器  ACK  数据   ACK  STOP
     │    │     │     │     │    │     │     │
SCL: ┌─┐ ┌─────┐ ┌─┐ ┌─────┐ ┌─┐ ┌────┐ ┌─┐ ┌─┐
     │S│ │ 8位 │ │A│ │ 8位 │ │A│ │8位 │ │A│ │P│
SDA: │T│ │地址0│ │C│ │寄存│ │C│ │数据│ │C│ │ │
     │A│ │     │ │K│ │器  │ │K│ │    │ │K│ │ │
     │R│ │     │ │ │ │    │ │ │ │    │ │ │ │ │
     └─┘ └─────┘ └─┘ └────┘ └─┘ └────┘ └─┘ └─┘

I2C读操作时序:
  START 地址+W  ACK  寄存器  ACK RESTART 地址+R  ACK  数据  NACK STOP
     │    │     │     │     │     │     │     │    │     │    │
     └─── 写地址 ────┘     └──── 读数据 ──────────────────┘

信号状态

  • 空闲状态:SCL和SDA均为高电平
  • 起始信号(START):SCL高电平时,SDA从高变低
  • 停止信号(STOP):SCL高电平时,SDA从低变高
  • 数据位:SCL低电平时准备数据,SCL高电平时采样数据
  • 应答信号(ACK):接收方在第9个时钟周期将SDA拉低表示接收成功

通信流程

  1. 主设备发送起始信号(START)
  2. 发送从设备地址(7位)和读/写位(1位)
  3. 从设备发送应答(ACK)
  4. 数据传输(8位一组),每组后跟应答位
  5. 主设备发送停止信号(STOP)

I2C位操作实现

首先需要实现基本的I2C底层函数:

GPIO配置

// 配置GPIO为开漏输出模式
void I2C_GPIO_Config(void) {
    // 使能GPIO时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    
    GPIO_InitTypeDef GPIO_InitStructure;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;  // SCL: PB6, SDA: PB7
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD;        // 开漏输出
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &GPIO_InitStructure);
    
    // 空闲状态,均为高电平
    GPIO_SetBits(GPIOB, GPIO_Pin_6 | GPIO_Pin_7);
}

I2C基本操作函数

// SCL和SDA控制函数
#define SCL_H    GPIO_SetBits(GPIOB, GPIO_Pin_6)
#define SCL_L    GPIO_ResetBits(GPIOB, GPIO_Pin_6)
#define SDA_H    GPIO_SetBits(GPIOB, GPIO_Pin_7)
#define SDA_L    GPIO_ResetBits(GPIOB, GPIO_Pin_7)
#define SDA_READ GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7)

// 延迟函数
void I2C_Delay(void) {
    uint8_t i = 10;  // 可根据时钟频率调整
    while(i--);
}

// 起始信号
void I2C_Start(void) {
    SDA_H;
    SCL_H;
    I2C_Delay();
    SDA_L;         // SDA从高到低,产生起始信号
    I2C_Delay();
    SCL_L;         // 钳住I2C总线,准备发送或接收数据
}

// 停止信号
void I2C_Stop(void) {
    SDA_L;
    SCL_H;
    I2C_Delay();
    SDA_H;         // SDA从低到高,产生停止信号
    I2C_Delay();
}

// 等待应答
uint8_t I2C_WaitAck(void) {
    uint8_t ack;
    
    SDA_H;         // 释放SDA
    I2C_Delay();
    SCL_H;         // 产生时钟脉冲
    I2C_Delay();
    ack = SDA_READ; // 读取SDA状态
    SCL_L;
    return ack;    // 返回0表示有应答
}

// 发送应答
void I2C_Ack(void) {
    SDA_L;         // SDA拉低,表示ACK
    I2C_Delay();
    SCL_H;
    I2C_Delay();
    SCL_L;
    SDA_H;         // 释放SDA
}

// 发送非应答
void I2C_NAck(void) {
    SDA_H;         // SDA保持高电平,表示NACK
    I2C_Delay();
    SCL_H;
    I2C_Delay();
    SCL_L;
}

// 发送一个字节
void I2C_SendByte(uint8_t byte) {
    uint8_t i = 8;
    
    while(i--) {
        SCL_L;
        I2C_Delay();
        if(byte & 0x80)
            SDA_H;
        else
            SDA_L;
        byte <<= 1;
        I2C_Delay();
        SCL_H;
        I2C_Delay();
    }
    SCL_L;
}

// 读取一个字节
uint8_t I2C_ReadByte(uint8_t ack) {
    uint8_t i = 8;
    uint8_t byte = 0;
    
    SDA_H;         // 释放SDA,准备读取数据
    while(i--) {
        byte <<= 1;
        SCL_L;
        I2C_Delay();
        SCL_H;
        I2C_Delay();
        if(SDA_READ)
            byte |= 0x01;
    }
    SCL_L;
    
    if(ack)
        I2C_Ack();  // 发送应答
    else
        I2C_NAck(); // 发送非应答
    
    return byte;
}

I2C驱动代码编写

基于上面的底层函数,实现设备读写操作:

// 写入一个字节到指定设备的指定寄存器
uint8_t I2C_WriteReg(uint8_t DevAddr, uint8_t RegAddr, uint8_t data) {
    I2C_Start();
    I2C_SendByte(DevAddr << 1);  // 设备地址 + 写位(0)
    if(I2C_WaitAck()) {
        I2C_Stop();
        return 1;  // 无应答,失败
    }
    
    I2C_SendByte(RegAddr);       // 寄存器地址
    if(I2C_WaitAck()) {
        I2C_Stop();
        return 1;
    }
    
    I2C_SendByte(data);          // 写入数据
    if(I2C_WaitAck()) {
        I2C_Stop();
        return 1;
    }
    
    I2C_Stop();
    return 0;  // 成功
}

// 从指定设备的指定寄存器读取一个字节
uint8_t I2C_ReadReg(uint8_t DevAddr, uint8_t RegAddr) {
    uint8_t data;
    
    I2C_Start();
    I2C_SendByte(DevAddr << 1);  // 设备地址 + 写位(0)
    if(I2C_WaitAck()) {
        I2C_Stop();
        return 0xFF;  // 无应答,失败
    }
    
    I2C_SendByte(RegAddr);       // 寄存器地址
    if(I2C_WaitAck()) {
        I2C_Stop();
        return 0xFF;
    }
    
    I2C_Start();                 // 重复起始
    I2C_SendByte((DevAddr << 1) | 0x01);  // 设备地址 + 读位(1)
    if(I2C_WaitAck()) {
        I2C_Stop();
        return 0xFF;
    }
    
    data = I2C_ReadByte(0);      // 读取数据,发送非应答
    I2C_Stop();
    
    return data;
}

实际应用示例:MPU6050读取数据

#define MPU6050_ADDR 0x68  // MPU6050设备地址

void MPU6050_Init() {
    I2C_WriteReg(MPU6050_ADDR, 0x6B, 0x00);  // 唤醒MPU6050
    I2C_WriteReg(MPU6050_ADDR, 0x19, 0x07);  // 采样率设置
    I2C_WriteReg(MPU6050_ADDR, 0x1A, 0x06);  // 配置数字低通滤波器
    I2C_WriteReg(MPU6050_ADDR, 0x1B, 0x18);  // 陀螺仪量程:±2000dps
    I2C_WriteReg(MPU6050_ADDR, 0x1C, 0x01);  // 加速度计量程:±2g
}

void MPU6050_GetAcceleration(int16_t *ax, int16_t *ay, int16_t *az) {
    uint8_t buf[6];
    
    // 读取加速度计数据
    buf[0] = I2C_ReadReg(MPU6050_ADDR, 0x3B);
    buf[1] = I2C_ReadReg(MPU6050_ADDR, 0x3C);
    buf[2] = I2C_ReadReg(MPU6050_ADDR, 0x3D);
    buf[3] = I2C_ReadReg(MPU6050_ADDR, 0x3E);
    buf[4] = I2C_ReadReg(MPU6050_ADDR, 0x3F);
    buf[5] = I2C_ReadReg(MPU6050_ADDR, 0x40);
    
    *ax = (buf[0] << 8) | buf[1];
    *ay = (buf[2] << 8) | buf[3];
    *az = (buf[4] << 8) | buf[5];
}

SPI协议原理

SPI(Serial Peripheral Interface)是一种同步串行通信接口,使用四根线:

SPI信号定义

  • MOSI (Master Out Slave In):主设备发送,从设备接收
  • MISO (Master In Slave Out):主设备接收,从设备发送
  • SCK (Serial Clock):时钟信号,由主设备产生
  • SS/CS (Slave Select/Chip Select):片选信号,用于选择从设备

SPI时序图

四种工作模式时序
模式0 (CPOL=0, CPHA=0):空闲低电平,上升沿采样
CS:  ┌─────────────────────────────┐
     │                             │
  ───┘                             └───

SCK: ────┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─────
         └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ 
         ↑   ↑   ↑   ↑   ↑   ↑   ↑   
       采样 采样 采样 采样 采样 采样 采样

MOSI:────┬───┬───┬───┬───┬───┬───┬─────
         │D7 │D6 │D5 │D4 │D3 │D2 │D1 │D0

模式1 (CPOL=0, CPHA=1):空闲低电平,下降沿采样
SCK: ────┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─────
         └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ 
           ↓   ↓   ↓   ↓   ↓   ↓   ↓   
         采样 采样 采样 采样 采样 采样 采样

模式2 (CPOL=1, CPHA=0):空闲高电平,上升沿采样
CS:  ┌─────────────────────────────┐
     │                             │
  ───┘                             └───

SCK: ─────┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─────
          └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ 
          ↑   ↑   ↑   ↑   ↑   ↑   ↑   
        采样 采样 采样 采样 采样 采样 采样

MOSI:────┬───┬───┬───┬───┬───┬───┬─────
         │D7 │D6 │D5 │D4 │D3 │D2 │D1 │D0

模式3 (CPOL=1, CPHA=1):空闲高电平,下降沿采样
SCK: ─────┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─────
          └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ 
            ↓   ↓   ↓   ↓   ↓   ↓   ↓   
          采样 采样 采样 采样 采样 采样 采样
SPI全双工传输时序
SPI全双工传输(模式0):
CS:  ┌─────────────────────────────────────┐
     │                                     │
  ───┘                                     └───

SCK: ────┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─────
         └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ 

MOSI:────┬───┬───┬───┬───┬───┬───┬───┬─────
         │D7 │D6 │D5 │D4 │D3 │D2 │D1 │D0  
         └───┴───┴───┴───┴───┴───┴───┴─

MISO:────┬───┬───┬───┬───┬───┬───┬───┬─────
         │Q7 │Q6 │Q5 │Q4 │Q3 │Q2 │Q1 │Q0  
         └───┴───┴───┴───┴───┴───┴───┴─

说明:主机发送数据D7-D0,同时接收从机数据Q7-Q0

工作模式

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

  • 模式0:CPOL=0, CPHA=0,空闲时SCK低电平,第一个边沿采样
  • 模式1:CPOL=0, CPHA=1,空闲时SCK低电平,第二个边沿采样
  • 模式2:CPOL=1, CPHA=0,空闲时SCK高电平,第一个边沿采样
  • 模式3:CPOL=1, CPHA=1,空闲时SCK高电平,第二个边沿采样

通信流程

  1. 主设备将对应从设备的CS线拉低(激活)
  2. 主设备通过SCK产生时钟信号
  3. 数据通过MOSI和MISO线同时双向传输
  4. 传输完成后,主设备将CS线拉高(释放)

SPI位操作实现

GPIO配置

// 配置SPI GPIO
void SPI_GPIO_Config(void) {
    // 使能GPIO时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    
    GPIO_InitTypeDef GPIO_InitStructure;
    
    // 配置SCK、MOSI为推挽输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5 | GPIO_Pin_7;  // SCK: PA5, MOSI: PA7
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    // 配置MISO为浮空输入
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;  // MISO: PA6
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    // 配置CS为推挽输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4;  // CS: PA4
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    // 初始状态:CS高,SCK低
    GPIO_SetBits(GPIOA, GPIO_Pin_4);   // CS高电平,不选中从设备
    GPIO_ResetBits(GPIOA, GPIO_Pin_5); // SCK低电平,模式0初始状态
}

SPI基本操作函数

// SPI引脚定义
#define SPI_CS_H   GPIO_SetBits(GPIOA, GPIO_Pin_4)
#define SPI_CS_L   GPIO_ResetBits(GPIOA, GPIO_Pin_4)
#define SPI_SCK_H  GPIO_SetBits(GPIOA, GPIO_Pin_5)
#define SPI_SCK_L  GPIO_ResetBits(GPIOA, GPIO_Pin_5)
#define SPI_MOSI_H GPIO_SetBits(GPIOA, GPIO_Pin_7)
#define SPI_MOSI_L GPIO_ResetBits(GPIOA, GPIO_Pin_7)
#define SPI_MISO   GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6)

// 延迟函数
void SPI_Delay(void) {
    uint8_t i = 2;
    while(i--);
}

// SPI发送并接收一个字节(模式0)
uint8_t SPI_ReadWriteByte(uint8_t data) {
    uint8_t i;
    uint8_t temp = 0;
    
    for(i = 0; i < 8; i++) {
        // 准备发送数据
        if(data & 0x80)
            SPI_MOSI_H;
        else
            SPI_MOSI_L;
        data <<= 1;  // 左移一位,准备下一位
        SPI_Delay();
        
        SPI_SCK_H;   // 时钟上升沿,从设备采样MOSI
        SPI_Delay();
        
        temp <<= 1;  // 左移一位,为接收新的数据位腾出空间
        if(SPI_MISO)
            temp++;  // 如果MISO为高,则置1
            
        SPI_SCK_L;   // 时钟下降沿,主设备采样MISO
        SPI_Delay();
    }
    
    return temp;     // 返回接收到的数据
}

// 支持不同模式的SPI传输
uint8_t SPI_TransferMode(uint8_t data, uint8_t mode) {
    uint8_t i, temp = 0;
    uint8_t cpol = (mode >> 1) & 1;  // 时钟极性
    uint8_t cpha = mode & 1;         // 时钟相位
    
    // 设置时钟空闲状态
    if(cpol) SPI_SCK_H; else SPI_SCK_L;
    
    for(i = 0; i < 8; i++) {
        // 根据CPHA决定数据变化时机
        if(!cpha) {
            if(data & 0x80) SPI_MOSI_H; else SPI_MOSI_L;
            data <<= 1;
        }
        
        // 产生时钟边沿
        if(!cpol) SPI_SCK_H; else SPI_SCK_L;
        SPI_Delay();
        
        if(cpha) {
            if(data & 0x80) SPI_MOSI_H; else SPI_MOSI_L;
            data <<= 1;
        }
        
        // 采样数据
        temp <<= 1;
        if(SPI_MISO) temp++;
        
        // 恢复时钟状态
        if(!cpol) SPI_SCK_L; else SPI_SCK_H;
        SPI_Delay();
    }
    
    return temp;
}

SPI驱动代码编写

基于上面的底层函数,实现设备读写操作:

// 向指定寄存器写入一个字节
void SPI_WriteReg(uint8_t reg, uint8_t value) {
    SPI_CS_L;                 // 使能片选
    SPI_ReadWriteByte(reg);   // 发送寄存器地址
    SPI_ReadWriteByte(value); // 发送数据
    SPI_CS_H;                 // 禁用片选
}

// 从指定寄存器读取一个字节
uint8_t SPI_ReadReg(uint8_t reg) {
    uint8_t value;
    
    SPI_CS_L;                   // 使能片选
    SPI_ReadWriteByte(reg | 0x80); // 发送寄存器地址(最高位置1表示读操作)
    value = SPI_ReadWriteByte(0xFF); // 发送任意值,读取结果
    SPI_CS_H;                   // 禁用片选
    
    return value;
}

// 从指定寄存器读取多个字节
void SPI_ReadMulti(uint8_t reg, uint8_t *buf, uint8_t len) {
    SPI_CS_L;                   // 使能片选
    SPI_ReadWriteByte(reg | 0x80); // 发送寄存器地址(最高位置1表示读操作)
    
    while(len--) {
        *buf = SPI_ReadWriteByte(0xFF);
        buf++;
    }
    
    SPI_CS_H;                   // 禁用片选
}

实际应用示例:读取W25Q64闪存

// W25Q64命令定义
#define W25Q64_READ_ID       0x90
#define W25Q64_READ_DATA     0x03
#define W25Q64_WRITE_ENABLE  0x06
#define W25Q64_PAGE_PROGRAM  0x02
#define W25Q64_ERASE_SECTOR  0x20
#define W25Q64_READ_STATUS   0x05

// 读取W25Q64芯片ID
uint16_t W25Q64_ReadID(void) {
    uint16_t id = 0;
    
    SPI_CS_L;
    SPI_ReadWriteByte(W25Q64_READ_ID);  // 发送读取ID命令
    SPI_ReadWriteByte(0x00);            // 发送3个虚拟地址
    SPI_ReadWriteByte(0x00);
    SPI_ReadWriteByte(0x00);
    id |= SPI_ReadWriteByte(0xFF) << 8; // 读取厂商ID
    id |= SPI_ReadWriteByte(0xFF);      // 读取设备ID
    SPI_CS_H;
    
    return id;
}

// 读取W25Q64状态寄存器
uint8_t W25Q64_ReadStatus(void) {
    uint8_t status;
    SPI_CS_L;
    SPI_ReadWriteByte(W25Q64_READ_STATUS);
    status = SPI_ReadWriteByte(0xFF);
    SPI_CS_H;
    return status;
}

// 等待W25Q64操作完成
void W25Q64_WaitBusy(void) {
    while((W25Q64_ReadStatus() & 0x01) == 0x01);
}

// 读取W25Q64数据
void W25Q64_ReadData(uint32_t addr, uint8_t *buf, uint16_t len) {
    SPI_CS_L;
    SPI_ReadWriteByte(W25Q64_READ_DATA);  // 发送读取命令
    SPI_ReadWriteByte((addr >> 16) & 0xFF); // 发送地址
    SPI_ReadWriteByte((addr >> 8) & 0xFF);
    SPI_ReadWriteByte(addr & 0xFF);
    
    while(len--) {
        *buf = SPI_ReadWriteByte(0xFF);
        buf++;
    }
    
    SPI_CS_H;
}

协议对比与应用

I2C vs SPI对比表

特性 I2C SPI
信号线数量 2线(SCL,SDA) 4线(SCK,MOSI,MISO,CS)
通信方式 半双工 全双工
最大速度 3.4MHz >100MHz
多主机 支持仲裁 需额外设计
设备寻址 地址寻址 片选线选择
错误检测 ACK/NACK

应用选择建议

选择I2C的场景:

  • 多个传感器组网(温度、湿度、气压等)
  • 系统配置器件(EEPROM、RTC、IO扩展)
  • 引脚资源受限的项目
  • 需要多主机通信的系统

选择SPI的场景:

  • 高速数据传输(Flash存储器、ADC、DAC)
  • 实时控制应用
  • 音频/视频数据流
  • 显示器驱动

调试技巧

  1. 使用示波器观察信号

    • 检查时序是否符合协议规范
    • 观察信号完整性和噪声干扰
  2. 软件调试方法

// I2C调试函数
void I2C_Debug_ScanBus(void) {
    printf("I2C总线扫描:\n");
    for(uint8_t addr = 0x08; addr < 0x78; addr++) {
        I2C_Start();
        if(I2C_SendByte_Check(addr << 1) == 0) {
            printf("发现设备地址: 0x%02X\n", addr);
        }
        I2C_Stop();
    }
}

// SPI调试函数  
void SPI_Debug_Test(void) {
    printf("SPI回环测试:\n");
    uint8_t test_data[] = {0xAA, 0x55, 0x00, 0xFF};
    
    SPI_CS_L;
    for(int i = 0; i < 4; i++) {
        uint8_t received = SPI_ReadWriteByte(test_data[i]);
        printf("发送: 0x%02X, 接收: 0x%02X\n", test_data[i], received);
    }
    SPI_CS_H;
}
  1. 常见问题排查
    • 上拉电阻值不当(I2C)
    • 时序参数不匹配
    • 电源和地线连接问题
    • 信号线过长导致的干扰

总结

I2C协议实现要点

  1. 使用开漏输出模式配置GPIO
  2. 实现起始、停止、发送、接收、应答等基本信号操作
  3. 按照协议时序编写读写函数
  4. 注意时钟速率控制和时序延迟

SPI协议实现要点

  1. 配置MOSI、SCK为输出,MISO为输入
  2. 确定使用的SPI模式(时钟极性和相位)
  3. 实现基本的读写字节函数
  4. 根据具体设备实现寄存器读写操作

注意事项

  1. 时序要严格遵循协议规范
  2. 延时函数需根据实际系统时钟频率调整
  3. 注意不同设备可能有特殊的地址或命令要求
  4. 调试时可以使用示波器观察信号波形
  5. 加入错误处理和超时机制提高鲁棒性
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐