单片机通信协议详解:UART、I2C、SPI、CAN一次讲透
2026/9/16 12:03:16 网站建设 项目流程

做单片机开发,很多教程都会带你点亮 LED、跑通流水灯、看懂按键中断,再往前就断掉了。原因不是后面的知识有多难,而是从“控制引脚”到“读取一个传感器”,中间横着一道必须迈过去的坎:通信协议。严格说,这句话糙理不糙:学单片机而不去理解通信协议,项目一接外设就很容易卡在原地。本文就把 UART、I2C、SPI、CAN 这几种最常用的通信协议讲透,从概念到代码,从选型到排错,帮你在真实项目里真正用起来。

先给一个明确判断:点灯、按键、数码管解决的是“怎么输出和读取高低电平”的问题,而通信协议解决的是“怎么把一串有意义的数据传给对方,并让对方准确理解”的问题。这就好比两个人说话,GPIO 是嗓门,通信协议是语法。你有一副好嗓门,但不说同一种语言,对方依然听不懂。很多初学者学通信协议时容易背时序、记代码,越学越发散。实际上掌握四个关键词就够了:电平、时钟、时序、主从关系。理解这四件事,任何协议的数据手册拿到手基本都能看明白。

这篇文章会从工程角度看四种主流协议分别解决什么问题、适合什么场景、新手最容易在哪里翻车,并给出可以直接参考的代码示例和排查清单。不管你现在用的是 51、STM32 还是国产 ARM 内核单片机,只要理解了协议本身的机制,换芯片只是换引脚和寄存器名。

1. 单片机开发的分水岭:从“点灯”到“让外设说话”

很多人学单片机时有一种错觉:能写出跑马灯和电子时钟,就算是入门了。但这类例程本质上都是 GPIO 的“活”,无非是轮流把某个引脚拉高或拉低。真正进入产品开发后你会发现,传感器、显示屏、存储芯片、通信模组,没有几个外设是靠 GPIO 电平能驱动起来的。你需要在指定引脚上按照对方手册要求的时间顺序,发送一串 0 和 1,并且能接收对方回传的数据。

这里的核心困难有三点。第一,数据不是一位一位随便发的,而是要组成帧,比如一个字节、一帧报文、一个带校验的数据包。第二,发送双方必须约定好节奏,什么时候算开始,什么时候算结束,每个 bit 持续多久。第三,一个总线上可能挂着多个设备,你需要能够指定“我现在想和谁说话”。这些规则整合到一起,就是一个通信协议。它把底层物理电平差异屏蔽掉,让不同厂商的芯片和模块之间可以交换信息。

为什么说这是分水岭?因为只要越过这条线,你的开发对象就从“一块单片机”变成了“一个系统”。你会开始读外设芯片的数据手册,会关注设备地址、寄存器地址、时钟频率、数据格式,会理解为什么有些器件要接上拉电阻,为什么有些通信线不能太长。具备这套思维方式后,再去看蓝牙、Wi-Fi、Modbus、CANopen 这类复杂协议,也会顺手很多。

不少人的误区是:以为“会用库函数调HAL_I2C_Mem_Write()”就算学会了通信。库函数当然能帮你省去很多麻烦,但它不会替你做协议判断。现实中你肯定会遇到“换一颗传感器就调不通”“逻辑分析仪抓到的波形看不懂”“为什么偶尔成功偶尔失败”这类问题。这时候只有真正理解时序和帧格式,才能建立排查方向。

2. 通信协议到底是什么:先建立四个关键词

通信协议的本质,是通信双方共同遵守的一套约定。它明确回答了四个问题:数据用什么电平表示;数据怎么按时钟节拍传输;一次传输从哪里开始到哪里结束;总线上多个设备时,谁有权限发起通信。

把这四个问题落到具体概念上,就是下面四个关键词。

2.1 电平与串行传输

单片机引脚输出了高电平或低电平,代表逻辑 1 或逻辑 0。我们说的“发送一个字节 0xA5”,其实就是依次发出 10100101 这 8 个 bit。当一条数据线上每次只传一个 bit,就是串行通信。

大部分板级通信协议都是串行的,因为 MCU 引脚资源有限,用 8 根线并行传 8 个 bit 虽然快,但极其浪费引脚。记住这一点,你就明白为什么协议设计者都在想尽办法减少信号线的数量。

2.2 同步与异步

通信双方必须知道“每一个 bit 该在什么时刻被读取”,否则时钟漂移一点,数据就乱了。

异步通信没有单独的时钟线,双方提前约定一个“波特率”,比如 9600、115200,即每秒传输多少个符号。常见 UART 串口就是异步通信。同步通信则有专门的时钟线,发送端在时钟信号上打节拍,接收端跟着时钟边沿采样数据。I2C 的 SCL、SPI 的 SCK 都是时钟线。

简单说,同步协议比异步协议多了一根时钟线,但因此抗波特率偏差的能力更强,速度也能做更高。

2.3 主从关系与总线仲裁

I2C 和 SPI 都有明显的“主机/从机”关系。主机负责发起传输、产生时钟、决定和哪个从机通信;从机只能被动响应。UART 本来没有严格的“主从”概念,但在实际项目中,上位机或 MCU 通常承担发起通信的角色。

CAN 总线则更接近“多主”模型,多个节点都能主动发消息,依靠仲裁机制解决冲突。这个区别非常重要,它会直接影响你设计协议时的交互方式。

2.4 四种常见协议速览

协议信号线时钟通信方式典型速度范围常见用途
UARTTX、RX无,双方约定波特率异步全双工9600~几 Mbps调试日志、蓝牙/GPS/4G 模组
I2CSCL、SDA同步半双工100k~3.4Mbps传感器、EEPROM、OLED
SPISCLK、MOSI、MISO、CS同步全双工几十 MbpsFlash、LCD、SD 卡
CANCANH、CANL有(位同步)异步多主通常 125k~1Mbps汽车、工业总线、多节点

这张表不要求你背下来,但它能帮你建立第一印象:协议之间没有绝对的好坏,只有“适合”和“不适合”。

3. UART:最简单也最容易出错的“对话方式”

UART 是入门首选,几乎所有单片机都带串口外设。你可以用 USB 转 TTL 模块把单片机和电脑连起来,在串口助手里看到打印信息。很多模块,比如 GPS、蓝牙、4G DTU,也都用串口与 MCU 交互。

串口一帧数据从低电平起始位开始,然后按低位在前发送 5~8 个数据位,接着是可选校验位,最后是高电平停止位。大家常说的“8-N-1”就代表 8 个数据位、无校验、1 个停止位。接收端会把引脚上出现的电平变化按约定的波特率切成一个个时间片,在每个时间片中间采样,恢复出数据。

如果只凭感觉写代码,UART 最容易出问题的地方有两个:一是波特率不匹配,二是共地问题。波特率不匹配会使收到数据全是乱码;收发双方没有共地,信号电压参考点不一致,收不到或偶尔收到错误数据也很正常。

3.1 一个典型 51 单片机串口初始化示例

下面这段代码在 51 系列单片机中非常经典,它使用定时器 1 作为波特率发生器,产生 9600bps。如果你用 11.0592MHz 晶振,这个配置能稳定工作。换成其他频率晶振时,TH1/TL1 必须按公式重新计算,这也是新手最容易忽略的点。

// 文件:uart_demo.c // 使用51单片机,晶振11.0592MHz,波特率9600 #include <reg51.h> void UART1_Init(void) { // 串口方式1:8位数据、可变波特率,允许接收 SCON = 0x50; // 定时器1工作在方式2:8位自动重装 TMOD &= 0x0F; TMOD |= 0x20; // 11.0592MHz下,波特率9600的初值 TH1 = 0xFD; TL1 = 0xFD; TR1 = 1; // 启动定时器1 } void UART_SendByte(unsigned char dat) { SBUF = dat; // 写入发送缓冲区 while (TI == 0); // 等待发送完成标志 TI = 0; // 清标志,准备下一次发送 } void main(void) { unsigned char msg[] = "Hello UART"; unsigned char i; UART1_Init(); for (i = 0; i < sizeof(msg) - 1; i++) { UART_SendByte(msg[i]); } while (1) { } }

代码逻辑不复杂:初始化串口号,把要发送的字节写入SBUF,然后等待硬件发送完。TI是发送完成标志,清除后才能继续发下一个字节。这里需要说明,寄存器名是 51 标准名称,如果你用的是 STM32 或者其它芯片,换成 HAL 库或标准库的对应 API,底层的“等待发送完成”思路是一样的。

3.2 串口收发应用的注意事项

发送只是第一步。很多场景下,MCU 需要接收上位机下发的命令,再执行对应动作。接收数据建议用中断,而不是在主循环里死等,否则 CPU 会被长时间占用。串口中断里每次只接收一个字节,存到环形缓冲区,主程序再从缓冲区解析完整帧。

新手最常犯的错是“一次只发一个字节”时,看着没问题,一旦连续发一组完整报文就开始丢帧。原因多半是发送频率过高,程序还没来得及等发送完成标志,就写入了下一个字节。解决办法就是发送前一定要确认上一个字符已经发送完毕,或者用发送完成中断来驱动。

4. I2C:两根线串起一堆外设,但时序有讲究

I2C 只使用两根线:SCL 时钟线和 SDA 数据线。同一总线上可以挂多个设备,每个设备有唯一地址。MCU 作为主机发起通信,先发送起始条件,然后发送从机地址和读写标志。地址匹配到的从机回 ACK,之后按寄存器地址或直接读写数据,最后由主机发送停止条件结束通信。

很多常见外设都用 I2C:OLED 屏幕、EEPROM、温度传感器、触摸芯片、气压计等。它的优点是引脚省、接线方便;缺点是在高速率下对线路电容敏感,线太长容易通信不稳。

很多人第一次接触 I2C 被时序图吓到,其实时序总结起来就是两句话:时钟线 SCL 为高电平时,SDA 的电平不能随意变化;SCL 为高电平时,SDA 从高变低代表起始,SDA 从低变高代表停止。数据传输过程里,SDA 必须在 SCL 为低时变化,在 SCL 为高时保持稳定,接收方在 SCL 上升沿采样。

4.1 软件模拟 I2C 核心时序

下面这段代码用普通 GPIO 模拟 I2C,不依赖芯片自带硬件 I2C,很适合用来理解时序。实际使用时,把I2C_SCL_HIGH()这类宏替换成自己板子的引脚操作即可。

// 文件:soft_i2c_demo.c // 软件模拟I2C主机,帮助理解时序 #include <stdint.h> // 这些宏需要根据实际平台改为对应的GPIO操作函数 #define I2C_SCL_HIGH() SCL_GPIO_HIGH() #define I2C_SCL_LOW() SCL_GPIO_LOW() #define I2C_SDA_HIGH() SDA_GPIO_HIGH() #define I2C_SDA_LOW() SDA_GPIO_LOW() #define I2C_SDA_READ() SDA_GPIO_READ() static void I2C_Delay(void) { // 适当延时,用于控制SCL频率 // 具体延时时长由主频和目标SCL频率共同决定 } static void I2C_Start(void) { I2C_SDA_HIGH(); I2C_SCL_HIGH(); I2C_Delay(); // SCL为高时,SDA由高变低:起始条件 I2C_SDA_LOW(); I2C_Delay(); I2C_SCL_LOW(); I2C_Delay(); } static void I2C_Stop(void) { I2C_SDA_LOW(); I2C_SCL_HIGH(); I2C_Delay(); // SCL为高时,SDA由低变高:停止条件 I2C_SDA_HIGH(); I2C_Delay(); } static uint8_t I2C_WriteByte(uint8_t dat) { uint8_t i; uint8_t ack; for (i = 0; i < 8; i++) { // SCL为低时改变SDA,保证数据稳定准备 if (dat & 0x80) { I2C_SDA_HIGH(); } else { I2C_SDA_LOW(); } dat <<= 1; // 拉高SCL,从机在上升沿采样 I2C_SCL_HIGH(); I2C_Delay(); I2C_SCL_LOW(); I2C_Delay(); } // 释放SDA,等待从机ACK应答 I2C_SDA_HIGH(); I2C_SCL_HIGH(); I2C_Delay(); // 从机应答时会把SDA拉低,因此读到0表示收到ACK ack = (I2C_SDA_READ() == 0) ? 1 : 0; I2C_SCL_LOW(); I2C_Delay(); return ack; } static uint8_t I2C_ReadByte(uint8_t hostAck) { uint8_t i; uint8_t dat = 0; // 释放SDA,让从机可以驱动数据线 I2C_SDA_HIGH(); for (i = 0; i < 8; i++) { dat <<= 1; // 从机在SCL为低时改数据,主机在SCL为高时读取 I2C_SCL_HIGH(); I2C_Delay(); if (I2C_SDA_READ()) { dat |= 0x01; } I2C_SCL_LOW(); I2C_Delay(); } // 读取最后一个字节前,主机应发送NAK;还想继续读则发ACK if (hostAck) { I2C_SDA_LOW(); } else { I2C_SDA_HIGH(); } I2C_SCL_HIGH(); I2C_Delay(); I2C_SCL_LOW(); I2C_SDA_HIGH(); I2C_Delay(); return dat; }

这个实现去掉了很多平台相关的细节,但把 I2C 最关键的五件事都体现出来了:起始、停止、写字节、读字节、ACK 应答。真正往芯片上移植时,你只需要保证I2C_Delay()的时间能让 SCL 频率落在目标从机允许范围内,大多数器件的标准模式上限是 100kHz,快速模式是 400kHz。

4.2 操作一个 I2C 从机时的完整调用流程

比如要写一颗 EEPROM 的内部寄存器,典型流程是:发送起始条件,发送设备地址 + 写标志,发送寄存器地址,发送要写入的数据,发送停止条件。读流程则是在发送寄存器地址后,重新发送起始条件,再发送设备地址 + 读标志。

这里有一个 99% 新手会踩的坑:手册上写的设备地址通常是 7 位地址,比如0x50,而发送到总线上的字节需要将地址左移一位,最低位表示读/写。如果直接拿 7 位地址当字节发,扫描总线永远找不到设备。这不是芯片坏了,而是对地址字节结构的理解不对。

5. SPI:速度优先,适合大批量数据交换

SPI 通常有四根线:SCLK 时钟、MOSI 主机输出从机输入、MISO 主机输入从机输出、CS 片选。主机要跟哪个从机通信,就把对应从机的 CS 引脚拉低,通信结束后再拉高。相比 I2C 依靠地址寻址,SPI 是依靠片选信号“点名”,这让协议本身简单很多,速度也可以做到很高。

SPI 的时序难点在“模式”上。模式由时钟极性 CPOL 和时钟相位 CPHA 共同决定,常见芯片多数支持模式 0 或模式 3。模式 0 表示空闲时时钟为低、第一个边沿采样;模式 3 表示空闲时时钟为高、第二个边沿采样。如果你的 SPI 读回来的数据全是 0xFF 或者是错位的,先检查的往往不是接线,而是主从机的 SPI 模式是否一致。

5.1 软件模拟 SPI 读写一个字节

// 文件:soft_spi_demo.c // 软件模拟SPI主机,模式0:空闲时钟低,上升沿采样 #include <stdint.h> #define SPI_CS_LOW() CS_GPIO_LOW() #define SPI_CS_HIGH() CS_GPIO_HIGH() #define SPI_SCLK_HIGH() SCLK_GPIO_HIGH() #define SPI_SCLK_LOW() SCLK_GPIO_LOW() #define SPI_MOSI_HIGH() MOSI_GPIO_HIGH() #define SPI_MOSI_LOW() MOSI_GPIO_LOW() #define SPI_MISO_READ() MISO_GPIO_READ() static void SPI_Delay(void) { // 空延时,控制SPI时钟频率 } uint8_t SPI_ExchangeByte(uint8_t txData) { uint8_t i; uint8_t rxData = 0; for (i = 0; i < 8; i++) { // 先准备发送数据 if (txData & 0x80) { SPI_MOSI_HIGH(); } else { SPI_MOSI_LOW(); } txData <<= 1; // 拉高时钟,主机在上升沿读取MISO SPI_SCLK_HIGH(); SPI_Delay(); rxData <<= 1; if (SPI_MISO_READ()) { rxData |= 0x01; } // 拉低时钟,准备下一位 SPI_SCLK_LOW(); SPI_Delay(); } return rxData; }

调用方式很直接:先把从机的 CS 拉低,然后连续调用SPI_ExchangeByte()发送命令和读取数据,结束后把 CS 拉高。注意读操作常常是“假写真读”:想读取从机某个寄存器时,主机仍然要发出一字节命令,命令本身往往包含寄存器地址和读标志。每一次交换过程中,主机会同时收到从机返回的一字节数据。

5.2 SPI 与 I2C 怎么选

SPI 的优势是全双工、速度快、协议开销小,适合批量读写,比如 LCD 刷新、Flash 存储、SD 卡读写。I2C 的优势是硬件开销极省、多设备组网方便,但在高频率和长距离场景下不如 SPI 稳定。如果项目里要刷一屏一屏的图像数据,坚持用 I2C 会非常痛苦;如果只是读一个温度传感器,专门给 SPI 占用四根线又显得浪费。真实项目中经常是“传感器多一点用 I2C,数据量大一点用 SPI”,两者可以同时存在,互相补充。

6. 从板级走向设备级:CAN、USB 与更多扩展协议

UART、I2C、SPI 解决的是同一个 MCU 周边几个芯片之间的通信问题,线短、节点少、环境相对可控。一旦场景变成“几十米长的总线”“几十个节点互相通信”“车间、车内有强干扰”,就需要往设备级协议走。CAN 总线是其中最典型的代表。

CAN 使用两根差分线 CANH 和 CANL,通过差分电压传递信息,天然抗共模干扰,因此适合汽车和工业现场。它属于多主总线,任意节点都能主动发消息,不需要主机点名。日常你会看到 CAN 帧有标准帧和扩展帧的说法,标准帧用 11 位标识符区分消息优先等级,扩展帧把标识符扩展到 29 位。仲裁机制保证多个节点同时抢总线时,优先级高的帧不会被破坏。

很多工程师觉得 CAN 难,是因为它已经从“底层传输”看到了“上层协议”。MCU 的 CAN 外设帮你完成了帧收发、错误检测、仲裁等大量工作,你真正需要设计的是:报文 ID 怎么分配、数据字段放什么内容、不同节点的软件如何约定同一个报文周期。再往上走,还有 Modbus、CANopen、EtherCAT 这类应用层协议,它们进一步规定了寄存器建模、对象字典、实时同步的规则。

从学习角度来说,不建议初学者一上来就研究全部协议栈。你可以先把 UART 用熟,再用软件模拟 I2C/SPI 理解时序,最后在真正的多节点项目里引入 CAN。底层机制一旦建立,理解任何上层协议都只是时间问题。

7. 看到一颗新模块,怎么快速判断用哪种协议

做实际项目时常遇到这样的场景:买回来一个模块,只有一个数据手册链接,连示例程序都没有。面对这种情况,关键不是立刻去搜“XX模块例程”,而是自己判断它用的是什么通信接口。

先看引脚名。引脚带 TX、RX 的,八成是 UART 串口;带 SCL、SDA 的,八成是 I2C;带 SCLK/SCK、MOSI、MISO、CS 的,是 SPI;带 CANH、CANL 的是 CAN 总线;还有一类器件如 DHT11、DS18B20,单根数据线既传时钟又传数据,属于单总线协议,本质上是低速定制时序。

然后再看数据手册,通常会在 Features 或 Interface Type 段落里直接写明接口类型。如果手册里出现 “I2C slave address: 0x3C” 这种字样,说明它是 I2C 从机;如果出现 SPI Mode 0/3,说明它支持 SPI。接着确认供电电压,查看接线图。很多传感器模块集成在开发板上时已经有了电平转换,但独立模块与 MCU 电压不一致时,必须考虑电平匹配。

最后用最小示例验证。先不要写复杂业务逻辑,先做三件事:初始化和引脚复用配置;如果是 I2C,先扫描设备地址;如果是 SPI,先读取芯片 ID 寄存器并打印。能以固定值读回 ID,基本说明物理链路已经通了,再展开后续功能。用逻辑分析仪抓一次波形,对照手册时序图核对起始条件、停止条件、数据位顺序,这个习惯能大幅减少“靠猜”排查的时间。

下面这个选型表可以作为日常参考:

需求特征优先考虑协议
调试打印、和电脑通信、连接 GPS/蓝牙/4G 模组UART
一条总线上挂多个低速传感器或小容量存储I2C
高速大批量读写屏幕、Flash、SD 卡SPI
工业现场、车内通信、多节点长距离抗干扰CAN
单温度/湿度传感器,引脚紧张,不追求高速单总线或 I2C

8. 单片机通信协议常见问题与排查方法

通信问题不像编译报错那样直接,它的特征往往是“功能时好时坏”“数据偶尔对不上”,因此很多人只能重启重置。下面这张表总结了我在项目里最常见到的几种现象,以及对应的排查顺序。

问题现象可能原因排查方式解决方案
串口收到乱码波特率不匹配、晶振频率偏差检查串口助手和单片机配置统一波特率,核对晶振和定时器初值
串口完全无输出TX/RX 接反、没有共地万用表量电压,逻辑分析仪抓 TX 引脚调换接线,连接 GND
I2C 扫描不到设备缺少上拉电阻、SDA/SCL 接反确认上拉是否连接,用扫描例程反复扫描接 4.7k 左右上拉,交换两根线重试
I2C 能通信但数据错位设备地址未左移或寄存器地址错误打印发送的原始地址字节确认 7 位地址与 8 位字节之间的关系
SPI 读回全 0xFFCS 未拉低、MISO 接错、模式不一致用逻辑分析仪抓片选和数据确认片选信号,检查 SPI 模式
通信偶尔成功偶尔失败电源功率不足、接线过长、干扰降低速率,短接测试,观察波形边沿加强供电,缩短杜邦线,改善 PCB 布局
一接外设 MCU 就复位外设启动瞬间电流过大万用表测供电电压跌落增加电容,使用独立稳压

排查通信类问题有一个铁律:一次只改一个变量。不要同时换引脚、改模式、调速度,否则你根本不知道是哪一个调整生效了。先让通信恢复,再逐项优化,比一口气做多个改动要可靠得多。手里有逻辑分析仪会事半功倍,它能直观显示 SCL、SDA、MOSI、MISO 的电平时序,远比看调试日志更接近真相。

9. 从会用到用对:学习顺序和工程建议

如果已经看到这里,说明你并不满足于“抄别人的例程能跑就完事”。接下来的学习顺序,建议按照由浅入深、由调试到产品的路径走。

第一步,先用 UART 打通“单片机到电脑”的链路。不依赖开发板的默认示例,自己查手册配置一次波特率,用串口打印实时变量。这个阶段练熟以后,你会对 MCU 时钟频率、外设时钟、波特率产生器有更具体的感知。

第二步,用 I2C 驱动一个小型外设。推荐 EEPROM 或 OLED,因为这类外设功能简单,适合练习读地址、写寄存器、连续读写。真正跑通后,尝试用 I2C 扫描程序找出总线上所有设备,并观察不同设备地址的差别。

第三步,用 SPI 驱动 Flash 或 LCD。重点体验片选控制、连续读写、模式匹配,对比它与 I2C 在速度和时序上的差异。建议在这个阶段开始使用逻辑分析仪,把代码执行过程抓成波形。抓波形不是让你当“设备维修工”,而是通过“看见数据在线上流动”来加深对同步时序的理解。

第四步,再回到 UART,尝试自己设计一个简单通信帧。把一帧数据设计成“帧头 + 长度 + 命令 + 数据 + 校验”,然后在两个开发板之间双向通信。这个练习虽然简单,但它是理解 Modbus、XModem 等上层协议的基础。

工程开发层面,有几句经验值得单独强调。所有协议参数都要配置成宏,不推荐把波特率、I2C 地址、SPI 模式散落在代码各个角落。接收数据一定要做长度校验和数据校验,不能“收到帧头就信任整包内容”。当通信异常时,优先打印原始字节和十六进制内容,而不是只打印“通信失败”这样的结论性日志。只要条件允许,第一次调通新外设时不要直接上业务复杂的高速度,降低速度先把链路跑稳,再逐步提高速率。这会让排查范围小得多。

学习通信协议,最忌讳的是背代码和背函数名。代码是会随芯片平台变化的,但 I2C 起始条件、SPI 模式、UART 波特率这些底层机制是几十年来沉淀下来的通用知识。你真正要建立的,是“拿到任何一颗新芯片,都知道从手册里找哪些信息”的能力。把今天的内容结合自己手头的开发板练一遍,比连着看十个教程都更靠近工程师的目标。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询