#嵌入式通信协议学习记录:串口、I2C、SPI 从入门到实践

在嵌入式开发中,通信协议是设备间数据交互的“语言”。最近系统学习了串口(USART)、I2C、SPI 这三个最常用的协议,从硬件连接到代码实现踩了不少坑,也总结了一些实用经验。这篇记录就从基础概念、核心原理、代码实践到应用场景,梳理下三个协议的学习要点。

一、串口(USART)通信:最“通用”的异步协议

串口是我最早接触的通信协议,它的核心是“异步传输”——不需要时钟线,靠波特率同步数据,适合简单的点对点通信。

1. 硬件连接:两根线的“交叉对话”

串口通信最关键的是 TX(发送端)和 RX(接收端)交叉连接,比如单片机的 TX 要接电脑/其他设备的 RX,反之亦然。如果是双向通信,最少需要 3 根线:TX、RX、GND;单向通信只需要 1 根(TX 或 RX)+ GND。

  • 注意:如果两个设备电平标准不同(比如 5V 单片机和 3.3V 模块),必须加电平转换芯片(如 CH340),否则会烧引脚!
  • 我的踩坑记录:第一次接的时候把 TX/RX 直连了,结果数据完全收不到,后来用万用表测了引脚才发现接反了,交叉后立刻通了。

2. 核心原理:从字节传输到数据包解析

串口的基本单位是“字节”,但实际项目中需要传输完整的“数据包”(比如控制指令、传感器数据),这就需要用 状态机 处理数据帧。
以我做过的“串口控制 LED”为例,数据包格式是 @指令\r\n(文本包)或 FF 数据1 数据2 数据3 数据4 FE(HEX 包),状态机逻辑如下:

  • 状态 0:等待包头(比如 @FF),收到非包头数据则重新等待;
  • 状态 1:接收数据段,计数到指定长度后进入下一个状态;
  • 状态 2:等待包尾(比如 \r\nFE),收到正确包尾则置位“接收完成标志”,否则丢弃数据。

3. 代码实践:STM32 串口初始化与数据收发

核心代码分两部分:初始化(时钟、GPIO、USART 配置)和数据处理(发送/接收中断)。

(1)串口初始化(发送示例)
void Serial_Init(void) {
    // 1. 开启时钟(USART1 和 GPIOA)
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    
    // 2. 配置 GPIO:PA9 为复用推挽输出(TX)
    GPIO_InitTypeDef GPIO_InitStruct;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_9;
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // 3. 配置 USART:波特率 9600、8 位数据、1 位停止位、无校验
    USART_InitTypeDef USART_InitStruct;
    USART_InitStruct.USART_BaudRate = 9600;
    USART_InitStruct.USART_Mode = USART_Mode_Tx; // 仅发送模式
    USART_InitStruct.USART_WordLength = USART_WordLength_8b;
    USART_InitStruct.USART_StopBits = USART_StopBits_1;
    USART_InitStruct.USART_Parity = USART_Parity_No;
    USART_Init(USART1, &USART_InitStruct);
    
    // 4. 使能 USART
    USART_Cmd(USART1, ENABLE);
}
(2)中断接收与数据包解析(HEX 包示例)

接收需要开启中断,用状态机解析数据:

uint8_t Serial_RxPacket[4]; // 接收数据包缓冲区
uint8_t Serial_RxFlag;      // 接收完成标志

void USART1_IRQHandler(void) {
    static uint8_t RxState = 0;  // 状态机状态(静态变量保持状态)
    static uint8_t pRx = 0;      // 数据存储位置
    if (USART_GetITStatus(USART1, USART_IT_RXNE) == SET) {
        uint8_t RxData = USART_ReceiveData(USART1);
        
        switch (RxState) {
            case 0: // 等待包头 0xFF
                if (RxData == 0xFF) {
                    RxState = 1;
                    pRx = 0;
                }
                break;
            case 1: // 接收 4 个数据字节
                Serial_RxPacket[pRx++] = RxData;
                if (pRx >= 4) RxState = 2;
                break;
            case 2: // 等待包尾 0xFE
                if (RxData == 0xFE) {
                    RxState = 0;
                    Serial_RxFlag = 1; // 数据包接收完成
                }
                break;
        }
        USART_ClearITPendingBit(USART1, USART_IT_RXNE);
    }
}

4. 学习心得

  • 波特率必须一致!第一次用串口助手时波特率设成 115200,而代码里是 9600,结果收到一堆乱码,调一致后立刻正常。
  • 数据包解析一定要用状态机,否则容易出现“数据粘包”或“丢包”,比如文本包用 @\r\n 做边界,比裸传字节可靠多了。

二、I2C 通信:多设备“共享总线”的同步协议

I2C 是飞利浦(现恩智浦)推出的同步协议,最大特点是 两根线控制多个设备(SCL 时钟线、SDA 数据线),适合多传感器场景(比如同时接温湿度、陀螺仪)。

1. 硬件连接:两根线 + 上拉电阻

所有 I2C 设备的 SCL 连在一起,SDA 连在一起,并且 SCL 和 SDA 必须各接一个 4.7KΩ 上拉电阻(总线空闲时保持高电平)。

  • 从机地址:每个 I2C 设备有唯一地址,比如 MPU6050(陀螺仪)的地址是 0xD0(AD0 引脚接 GND 时),通过地址区分不同设备。
  • 我的踩坑记录:一开始忘了接上拉电阻,I2C 完全不通,用示波器看 SDA/SCL 一直是低电平,加上电阻后总线才正常。

2. 核心原理:软件模拟 vs 硬件 I2C

I2C 的实现有两种方式,各有优劣:

对比维度软件模拟 I2C硬件 I2C(STM32 内置)
灵活性高,可自定义时序(比如慢时钟)低,依赖芯片外设时序
代码复杂度需自己写起始/停止/应答逻辑调用库函数,逻辑更简洁
稳定性受 CPU 中断影响(可能时序不准)硬件自动生成时序,更稳定
关键时序(以“指定地址读”为例)
  1. 发送起始信号(SCL 高电平时,SDA 从高变低);
  2. 发送从机地址 + 写命令(比如 MPU6050 的 0xD0),等待从机应答;
  3. 发送要读取的寄存器地址(比如加速度计 X 轴地址 0x3B),等待应答;
  4. 发送重复起始信号;
  5. 发送从机地址 + 读命令(0xD1),等待应答;
  6. 读取数据,发送非应答(表示最后一个字节);
  7. 发送停止信号(SCL 高电平时,SDA 从低变高)。

3. 代码实践:MPU6050 数据读取(软件模拟 I2C)

以软件模拟 I2C 为例,核心是实现 I2C 的基础时序函数(起始、停止、发送字节、接收字节),再封装 MPU6050 的读写接口。

(1)I2C 基础时序函数
// 写 SCL 电平
void MyI2C_W_SCL(uint8_t BitValue) {
    GPIO_WriteBit(GPIOB, GPIO_Pin_10, (BitAction)BitValue);
    Delay_us(10); // 保证时序满足要求
}

// 写 SDA 电平
void MyI2C_W_SDA(uint8_t BitValue) {
    GPIO_WriteBit(GPIOB, GPIO_Pin_11, (BitAction)BitValue);
    Delay_us(10);
}

// 读 SDA 电平
uint8_t MyI2C_R_SDA(void) {
    uint8_t BitValue = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11);
    Delay_us(10);
    return BitValue;
}

// 起始信号
void MyI2C_Start(void) {
    MyI2C_W_SDA(1);
    MyI2C_W_SCL(1);
    MyI2C_W_SDA(0); // SCL 高时 SDA 拉低
    MyI2C_W_SCL(0); // 拉低 SCL 占用总线
}

// 停止信号
void MyI2C_Stop(void) {
    MyI2C_W_SDA(0);
    MyI2C_W_SCL(1);
    MyI2C_W_SDA(1); // SCL 高时 SDA 拉高
}

// 发送一个字节
void MyI2C_SendByte(uint8_t Byte) {
    uint8_t i;
    for (i = 0; i < 8; i++) {
        MyI2C_W_SDA(!!(Byte & (0x80 >> i))); // 从高位到低位发送
        MyI2C_W_SCL(1); // SCL 高时从机读数据
        MyI2C_W_SCL(0); // 拉低 SCL 准备下一位
    }
}

// 接收一个字节
uint8_t MyI2C_ReceiveByte(void) {
    uint8_t i, Byte = 0x00;
    MyI2C_W_SDA(1); // 释放 SDA,让从机发送数据
    for (i = 0; i < 8; i++) {
        MyI2C_W_SCL(1); // SCL 高时主机读数据
        if (MyI2C_R_SDA()) Byte |= (0x80 >> i); // 读取当前位
        MyI2C_W_SCL(0);
    }
    return Byte;
}
(2)MPU6050 数据读取
#define MPU6050_ADDR 0xD0

// 读 MPU6050 寄存器
uint8_t MPU6050_ReadReg(uint8_t RegAddr) {
    uint8_t Data;
    MyI2C_Start();
    MyI2C_SendByte(MPU6050_ADDR); // 写地址
    MyI2C_ReceiveAck(); // 等待应答
    MyI2C_SendByte(RegAddr); // 寄存器地址
    MyI2C_ReceiveAck();
    
    MyI2C_Start(); // 重复起始
    MyI2C_SendByte(MPU6050_ADDR | 0x01); // 读地址
    MyI2C_ReceiveAck();
    Data = MyI2C_ReceiveByte(); // 读数据
    MyI2C_SendAck(1); // 非应答
    MyI2C_Stop();
    return Data;
}

// 获取加速度计和陀螺仪数据
void MPU6050_GetData(int16_t *AX, int16_t *AY, int16_t *AZ, 
                     int16_t *GX, int16_t *GY, int16_t *GZ) {
    uint8_t DataH, DataL;
    // 加速度计 X 轴(16 位数据,高 8 位 + 低 8 位)
    DataH = MPU6050_ReadReg(0x3B);
    DataL = MPU6050_ReadReg(0x3C);
    *AX = (DataH << 8) | DataL;
    // 其他轴类似...
}

4. 学习心得

  • 应答信号(ACK)是 I2C 的“灵魂”,从机收到数据后会拉低 SDA 表示应答,主机必须等待应答再继续,否则会丢数据。
  • 硬件 I2C 虽然方便,但要注意“事件等待”,比如 STM32 的 I2C 需要等待 EV5(起始信号发送完成)、EV6(地址发送完成)等事件,否则时序会错乱。

三、SPI 通信:高速“全双工”的同步协议

SPI 是高速同步协议,支持全双工(同时收发),靠 4 根线 实现(SS 片选、SCL 时钟、MOSI 主机发从机收、MISO 从机发主机收),适合高速数据传输(比如存储芯片、显示屏)。

1. 硬件连接:一根 SS 对应一个从机

SPI 是“一主多从”架构,所有设备的 SCL、MOSI、MISO 连在一起,每个从机有独立的 SS 引脚(主机通过拉低某个 SS 选中对应从机)。

  • 引脚配置:主机的 MOSI/MISO/SCL 为推挽输出,从机的为浮空/上拉输入;SS 为推挽输出(主机)/输入(从机)。
  • 模式:SPI 有 4 种模式(CPOL 和 CPHA 组合),最常用的是模式 0(CPOL=0,CPHA=0)——空闲时 SCL 低,第一个边沿(上升沿)采样数据。

2. 核心原理:同步时钟下的“字节交换”

SPI 的核心是“时钟同步”,主机生成 SCL 时钟,在时钟的特定边沿(比如上升沿)传输数据,同时完成“发送”和“接收”(全双工)。
以 W25Q64(SPI 闪存芯片)为例,关键操作有:

  • 读 ID:发送“读 ID 指令”(0x9F),然后接收厂商 ID(MID)和设备 ID(DID);
  • 扇区擦除:SPI 闪存只能“从 1 改 0”,所以写入前必须擦除(擦除后全为 1),最小擦除单位是扇区(4KB);
  • 页编程:一次最多写一页(256 字节),超过页尾会覆盖开头数据。

3. 代码实践:W25Q64 数据读写(软件模拟 SPI)

(1)SPI 基础时序函数(模式 0)
// 写 SS 电平(选中/取消从机)
void MySPI_W_SS(uint8_t BitValue) {
    GPIO_WriteBit(GPIOA, GPIO_Pin_4, (BitAction)BitValue);
}

// 写 SCK 电平
void MySPI_W_SCK(uint8_t BitValue) {
    GPIO_WriteBit(GPIOA, GPIO_Pin_5, (BitAction)BitValue);
}

// 写 MOSI 电平
void MySPI_W_MOSI(uint8_t BitValue) {
    GPIO_WriteBit(GPIOA, GPIO_Pin_7, (BitAction)BitValue);
}

// 读 MISO 电平
uint8_t MySPI_R_MISO(void) {
    return GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_6);
}

// 交换一个字节(同时发送和接收)
uint8_t MySPI_SwapByte(uint8_t ByteSend) {
    uint8_t i, ByteRecv = 0x00;
    for (i = 0; i < 8; i++) {
        // 发送当前位(高位优先)
        MySPI_W_MOSI(!!(ByteSend & (0x80 >> i)));
        MySPI_W_SCK(1); // 上升沿,从机采样 MOSI,主机采样 MISO
        if (MySPI_R_MISO()) ByteRecv |= (0x80 >> i); // 接收当前位
        MySPI_W_SCK(0); // 拉低 SCK 准备下一位
    }
    return ByteRecv;
}
(2)W25Q64 扇区擦除与页编程
// 扇区擦除(4KB)
void W25Q64_SectorErase(uint32_t Addr) {
    W25Q64_WriteEnable(); // 写使能(必须先执行)
    MySPI_Start();
    MySPI_SwapByte(0x20); // 扇区擦除指令
    // 发送 24 位地址(Addr >> 16 是高 8 位)
    MySPI_SwapByte(Addr >> 16);
    MySPI_SwapByte(Addr >> 8);
    MySPI_SwapByte(Addr);
    MySPI_Stop();
    W25Q64_WaitBusy(); // 等待擦除完成(闪存忙时不响应指令)
}

// 页编程(最多 256 字节)
void W25Q64_PageProgram(uint32_t Addr, uint8_t *Data, uint16_t Count) {
    W25Q64_WriteEnable();
    MySPI_Start();
    MySPI_SwapByte(0x02); // 页编程指令
    MySPI_SwapByte(Addr >> 16);
    MySPI_SwapByte(Addr >> 8);
    MySPI_SwapByte(Addr);
    for (uint16_t i = 0; i < Count; i++) {
        MySPI_SwapByte(Data[i]); // 发送数据
    }
    MySPI_Stop();
    W25Q64_WaitBusy();
}

4. 学习心得

  • 写使能!SPI 闪存的所有写入/擦除操作前必须发送“写使能指令”(0x06),否则操作无效,我第一次忘了加,结果数据写不进去,查手册才发现这个关键点。
  • SS 引脚必须控制好:同一时间只能选中一个从机(拉低对应 SS),否则多个从机同时发送数据会导致总线冲突。

四、三大协议对比与总结

学习完三个协议后,我整理了一张对比表,方便后续项目选型:

协议引脚数通信方式速率拓扑结构适用场景
串口2~3异步低(≤115200bps)点对点调试日志、简单指令传输(如蓝牙模块)
I2C2同步中(≤400kbps)一主多从多传感器(温湿度、陀螺仪)
SPI4同步高(≤几十Mbps)一主多从高速存储(闪存)、显示屏

我的学习建议

  1. 先从串口入手:串口逻辑最简单,适合理解“发送/接收”的基本流程,熟悉中断和状态机后再学其他协议。
  2. 多做硬件实践:协议的坑很多在硬件连接(比如上拉电阻、引脚交叉),光看理论没用,一定要搭电路测试。
  3. 对比学习:比如 I2C 和 SPI 都是同步协议,但 I2C 用两根线、SPI 用四根线,I2C 有应答、SPI 靠 SS 选从机,对比后更容易记牢。

后续计划深入学习协议的高级特性,比如串口的 DMA 传输、I2C 的时钟拉伸、SPI 的 DMA 高速传输,让数据交互更高效。嵌入式通信协议是基础,学好这些“语言”,才能让不同设备顺畅“对话”~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值