在嵌入式开发中,通信协议是设备间数据交互的“语言”。最近系统学习了串口(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\n或FE),收到正确包尾则置位“接收完成标志”,否则丢弃数据。
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 中断影响(可能时序不准) | 硬件自动生成时序,更稳定 |
关键时序(以“指定地址读”为例)
- 发送起始信号(SCL 高电平时,SDA 从高变低);
- 发送从机地址 + 写命令(比如 MPU6050 的 0xD0),等待从机应答;
- 发送要读取的寄存器地址(比如加速度计 X 轴地址 0x3B),等待应答;
- 发送重复起始信号;
- 发送从机地址 + 读命令(0xD1),等待应答;
- 读取数据,发送非应答(表示最后一个字节);
- 发送停止信号(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) | 点对点 | 调试日志、简单指令传输(如蓝牙模块) |
| I2C | 2 | 同步 | 中(≤400kbps) | 一主多从 | 多传感器(温湿度、陀螺仪) |
| SPI | 4 | 同步 | 高(≤几十Mbps) | 一主多从 | 高速存储(闪存)、显示屏 |
我的学习建议
- 先从串口入手:串口逻辑最简单,适合理解“发送/接收”的基本流程,熟悉中断和状态机后再学其他协议。
- 多做硬件实践:协议的坑很多在硬件连接(比如上拉电阻、引脚交叉),光看理论没用,一定要搭电路测试。
- 对比学习:比如 I2C 和 SPI 都是同步协议,但 I2C 用两根线、SPI 用四根线,I2C 有应答、SPI 靠 SS 选从机,对比后更容易记牢。
后续计划深入学习协议的高级特性,比如串口的 DMA 传输、I2C 的时钟拉伸、SPI 的 DMA 高速传输,让数据交互更高效。嵌入式通信协议是基础,学好这些“语言”,才能让不同设备顺畅“对话”~
4754

被折叠的 条评论
为什么被折叠?



