UART、IIC、SPI、CAN:嵌入式开发四大通信协议选型实战指南
1. 从零开始:嵌入式通信协议到底在聊什么?
刚入行那会儿,每次看芯片手册,看到UART、I2C、SPI、CAN这些缩写,头都大了。感觉每个都差不多,都是传数据的,为啥要搞这么多种?后来踩坑踩多了才明白,这就像我们出门,去楼下便利店和跨省出差,选择的交通工具肯定不一样。选错了协议,轻则项目进度拖慢,重则整个系统不稳定,半夜被叫起来“救火”都是常事。
简单来说,通信协议就是设备之间“说话”的规矩。你说中文,他说英文,那肯定没法沟通。在嵌入式世界里,UART、I2C、SPI、CAN就是四种最常用的“语言”。它们各有各的方言、语速和适用场合。你的项目是几个传感器小范围“窃窃私语”,还是一堆控制单元在嘈杂的工厂里“大声喊话”,直接决定了你该选谁。
我遇到过不少新手朋友,觉得SPI最快,就什么都想用SPI,结果IO口不够用了;或者觉得CAN太“高级”,汽车才用,结果在工业环境里用UART组网,被干扰得数据乱飞。这篇文章,我就结合自己这些年做过的项目,从“为什么选”和“怎么用”两个角度,带你把这四大协议捋清楚。咱们不空谈理论,就假设你现在要做一个多传感器数据采集与显示的工业控制模块,看看在实际开发中,该怎么做出最合适的选择。
2. 协议四剑客:一张表看懂核心差异
在深入每个协议之前,我们先从上帝视角快速对比一下。这张表是我自己总结的“选型速查手册”,每次新项目评估时都会先看一眼。
| 协议 | 通信类型 | 最少线数 | 速度范围 | 拓扑结构 | 核心特点 | 典型应用场景 |
|---|---|---|---|---|---|---|
| UART | 异步、全双工 | 2 (TX, RX) | 低-中 (通常到几Mbps) | 点对点 | 简单通用,需约定波特率,无时钟线,距离较远 | 芯片调试、GPS/蓝牙模块、设备间简单通信 |
| I2C | 同步、半双工 | 2 (SDA, SCL) | 低-中 (标准100kbps,快速400kbps,高速可达3.4Mbps) | 多主多从 | 引脚占用少,有地址寻址和应答机制,协议较复杂 | 挂载多个传感器、访问EEPROM、控制IO扩展芯片 |
| SPI | 同步、全双工 | 3+n (SCLK, MOSI, MISO, 每个从机需独立CS) | 高(可达几十甚至上百Mbps) | 一主多从 | 速度极快,协议简单,无应答,占用IO口多 | 驱动显示屏、读写Flash/SD卡、连接高速ADC |
| CAN | 异步、半双工 | 2 (CAN_H, CAN_L) | 中-高 (常见125kbps-1Mbps) | 多主对等 | 高可靠性、抗干扰强,距离远,有优先级和错误处理 | 汽车网络、工业总线、电梯控制、医疗设备 |
看表可能还有点抽象,我打个比方。UART就像两个人打电话,直接拨号连接,说清楚波特率(语速)就能聊,但只能一对一。I2C像一个小型会议,主持人(主机)按名单(设备地址)点名,点到谁谁发言(半双工),用两根线就能组织起多人会议。SPI则像主机和多个从机之间拉了一条高速数据流水线,主机通过片选信号(CS)点名让哪个从机接上流水线,然后数据可以同时收发(全双工),速度飞快。CAN最特别,它像一个去中心化的紧急广播网络,任何节点都能在空闲时喊话,而且用“喊话的紧急程度”(报文ID优先级)来决定谁先占用频道,天生抗干扰,适合复杂环境。
2.1 关键参数深度解读:速度、距离与拓扑
光看表还不够,我们得挖一挖几个关键参数背后的“坑”。
首先是速度。很多人以为SPI一定比I2C快,这没错,但前提是配置正确。SPI的时钟频率可以很高,但实际有效数据吞吐量受限于你的MCU处理能力、代码效率和从设备本身的速度。我实测过,用STM32的硬件SPI驱动一块SPI Flash,理论时钟能到几十MHz,但如果你的代码是模拟SPI或者中断处理不当,实际速度可能大打折扣。而I2C在快速模式(400kHz)下,对于很多传感器(如温湿度、气压)已经绰绰有余,它的优势在于用极少的线连接多个设备,速度换来了系统的简洁。
其次是通信距离。UART在TTL电平下(3.3V/5V),可靠传输距离通常只有一两米。如果你想传得更远,比如十米以上,就必须转换成RS-232或RS-485电平。RS-485配合双绞线,可以轻松做到千米级传输,并且支持多点总线(类似简化版CAN),这在很多工业采集场景中非常实用。而CAN本身采用差分信号,抗共模干扰能力极强,从物理层就为长距离、恶劣环境通信打下了基础。
最后是拓扑结构,这是选型时最容易忽略但至关重要的点。点对点(UART)最简单,但扩展性差。一主多从(SPI)扩展时需要为每个从机增加一根片选线,从机多了布线会变复杂。多主多从(I2C)布线简洁,但总线负载能力有限,从机太多或总线太长会导致信号失真。多主对等(CAN)网络最灵活,任何设备都能主动发起通信,并通过仲裁机制优雅地解决冲突,构建真正的分布式系统。
3. UART:嵌入式世界的“普通话”
如果说只能学一种通信协议,那一定是UART。它太基础、太通用了,堪称嵌入式开发的“普通话”。几乎所有的MCU都会自带至少一个UART模块,它的核心思想就两个字:简单。
3.1 工作原理与实战配置
UART是异步通信,也就是说通信双方没有共享的时钟线。那怎么保证你发的一个bit,我收的时候不会错位呢?靠的就是事先约定好的波特率。比如双方都约定9600的波特率,那么发送方就会以每秒9600比特的速率把数据位“吐”到TX线上,接收方则以同样的速率从RX线上“采样”数据。这就好比两个人约好每秒唱一个字,只要起唱时间对齐,就能把一首歌对完。
数据在线上不是一个bit一个bit散着传的,而是打包成“帧”。一帧数据包括:1个起始位(总是低电平)、5-9个数据位(通常是8位,就是我们常说的一个字节)、可选的奇偶校验位、以及1-2个停止位(高电平)。空闲时,总线保持高电平。起始位的那个下降沿,就是接收方开始工作的“发令枪”。
在代码里配置UART,主要就是设置这几个参数。以STM32的HAL库为例,通常需要初始化一个结构体:
UART_HandleTypeDef huart1; huart1.Instance = USART1; huart1.Init.BaudRate = 115200; // 波特率 huart1.Init.WordLength = UART_WORDLENGTH_8B; // 数据位8位 huart1.Init.StopBits = UART_STOPBITS_1; // 停止位1位 huart1.Init.Parity = UART_PARITY_NONE; // 无校验位 huart1.Init.Mode = UART_MODE_TX_RX; // 收发模式 huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; // 无硬件流控 HAL_UART_Init(&huart1);配置好后,发送和接收数据就非常简单了。发送可以用阻塞方式HAL_UART_Transmit(&huart1, pData, Size, Timeout),接收可以用中断方式HAL_UART_Receive_IT(&huart1, pData, Size),让数据到来时自动触发中断处理函数,不占用CPU时间。
3.2 典型应用与避坑指南
UART在项目中主要有三大用途:调试打印、模块通信、设备间直连。
1. 调试打印:这是UART最经典的用法。通过一个USB转TTL的小模块(比如CH340、CP2102),把MCU的UART引脚连接到电脑,就能在串口助手(如SecureCRT、Putty)上看到程序运行的日志、变量值,这是排查bug最直接的手段。我习惯在项目初期就留出一个UART专用于调试。
2. 与无线/定位模块通信:像ESP8266 WiFi模块、HC-05蓝牙模块、NEO-6M GPS模块,它们通常都提供UART接口。通信方式往往是“AT指令”模式。你需要严格按照模块手册的指令格式,通过UART发送字符串指令,然后等待并解析模块返回的字符串响应。这里常见的坑是缓冲区溢出和响应超时处理。一定要为接收数据设计一个环形缓冲区,并设置合理的超时机制,避免因为等待一个永不到来的响应而卡死程序。
3. 两个MCU间直接通信:只要将A的TX接B的RX,A的RX接B的TX,地线接好,并设置相同的波特率等参数,两者就能直接对话。这种方式简单可靠,适合两个设备之间的数据交换。但要注意,如果通信距离超过1米,TTL电平容易受干扰,此时应考虑转换为RS-485电平进行传输。
注意:UART通信的稳定性极度依赖波特率的一致性。如果双方晶振有误差,长时间通信可能会产生累积误差导致错位。在高速或长时间通信时,建议选择带硬件流控(RTS/CTS)的UART,或者在高波特率下使用更精准的时钟源。
4. I2C:连接多个传感器的“省线能手”
当你需要在一个MCU上挂载好几个传感器,比如同时监测温度、湿度、气压,而MCU的引脚又捉襟见肘时,I2C就是你的救星。它只用两根线——串行数据线SDA和串行时钟线SCL,就能组建一个小型网络,堪称“省线小能手”。
4.1 总线机制与寻址奥秘
I2C总线是一个多主多从的架构,但实际应用中,绝大多数情况是一个主机(MCU)带多个从机(传感器、存储器等)。总线上的所有设备都并联在这两根线上,靠设备地址来区分。
每个I2C从设备都有一个7位(常用)或10位的唯一地址。这个地址通常由设备型号和外部引脚电平共同决定。例如,常见的温湿度传感器SHT30,它的7位地址是0x44(二进制1000100),但通过改变芯片上一个ADDR引脚的电平,可以在0x44和0x45之间选择,这样你就能在同一条总线上挂两个SHT30了。
通信总是由主机发起。过程就像一场严谨的对话:
- 主机发起START信号:在SCL高电平期间,SDA产生一个下降沿。
- 主机发送地址帧:发送7位从机地址 + 1位读写方向位(0写,1读)。
- 从机应答:被寻址的从机,在第9个时钟周期将SDA线拉低,作为ACK信号。
- 数据传输:主机或从机开始发送8位数据,每发送完一个字节,接收方都要在第9个时钟周期发送ACK。
- 主机发起STOP信号:在SCL高电平期间,SDA产生一个上升沿,结束通信。
这个过程中,ACK信号至关重要。它相当于每次对话后的“收到,请继续”。如果从机没有应答(NACK),主机就知道可能地址错了或者从机忙,可以据此进行错误处理。
4.2 实战:驱动多个I2C设备
假设我们的工业控制模块需要连接一个SHT30温湿度传感器和一个AT24Cxx系列的EEPROM存储器。它们的I2C地址分别是0x44和0x50(假设A0-A2引脚接地)。接线非常简单:MCU的I2C引脚(例如PB6/PB7)分别上拉到3.3V(通常用4.7kΩ电阻),然后SDA和SCL分别连接到两个设备的对应引脚。
软件上,以STM32 HAL库为例,首先初始化I2C外设:
I2C_HandleTypeDef hi2c1; hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 400000; // 快速模式,400kHz hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; // 时钟占空比 hi2c1.Init.OwnAddress1 = 0; // 主机模式,自身地址可设为0 hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; // 7位地址模式 hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; HAL_I2C_Init(&hi2c1);读取SHT30的数据,通常需要先发送测量命令,再读取结果。这是一个典型的“写-读”过程:
// 1. 发送测量命令(写操作) uint8_t cmd[2] = {0x2C, 0x06}; // SHT30的触发测量命令 HAL_I2C_Master_Transmit(&hi2c1, 0x44 << 1, cmd, 2, HAL_MAX_DELAY); // 注意地址左移1位,最低位是读写位 // 等待传感器测量完成(数据手册要求约15ms) HAL_Delay(20); // 2. 读取6个字节的数据(读操作) uint8_t data[6]; HAL_I2C_Master_Receive(&hi2c1, (0x44 << 1) | 0x01, data, 6, HAL_MAX_DELAY); // 地址最低位置1,表示读 // 3. 解析温湿度值 uint16_t rawTemp = (data[0] << 8) | data[1]; uint16_t rawHumi = (data[3] << 8) | data[4]; float temperature = -45 + 175 * (rawTemp / 65535.0); float humidity = 100 * (rawHumi / 65535.0);而写入EEPROM则是一个“写-写”过程,需要先发送要写入的存储器内部地址,再发送数据。
提示:I2C总线对上升时间很敏感。如果总线上设备多、走线长,上拉电阻的阻值需要减小以提供更强的拉高能力,但会增加功耗。通常4.7kΩ-10kΩ是常用范围。如果通信不稳定,可以尝试降低时钟速度(比如降到100kHz),并使用逻辑分析仪抓取波形,查看ACK信号和时序是否符合规范。
5. SPI:追求极致速度的“数据快车道”
如果你需要刷一块高清TFT屏,或者高速记录数据到SD卡,那么SPI就是你需要的“数据快车道”。它的设计哲学就是简单粗暴快。没有复杂的地址帧和应答机制,主机完全掌控时钟,数据在时钟边沿被打入或打出,可以实现极高的传输速率。
5.1 四线制与工作模式
标准的SPI需要4根线:
- SCLK:串行时钟,由主机产生。
- MOSI:主机输出,从机输入。
- MISO:主机输入,从机输出。
- SS/CS:从机选择,低电平有效。每个从机都需要一根独立的片选线。
SPI是全双工的,这意味着数据可以同时收和发。在主机向从机发送一个bit的同时,也可能从从机那里接收一个bit。很多SPI设备利用这一特性,在主机发送命令字的同时,从机返回状态或数据。
SPI还有一个容易让人困惑的概念:时钟极性(CPOL)和时钟相位(CPHA),它们共同定义了四种工作模式。
- CPOL:时钟空闲时的电平。0表示空闲时为低电平,1表示空闲时为高电平。
- CPHA:数据采样的时刻。0表示在时钟的第一个边沿(上升沿或下降沿)采样,1表示在时钟的第二个边沿采样。
具体来说:
- 模式0 (CPOL=0, CPHA=0):时钟空闲低,数据在上升沿采样。最常用。
- 模式1 (CPOL=0, CPHA=1):时钟空闲低,数据在下降沿采样。
- 模式2 (CPOL=1, CPHA=0):时钟空闲高,数据在下降沿采样。
- 模式3 (CPOL=1, CPHA=1):时钟空闲高,数据在上升沿采样。
主从设备的模式必须完全一致才能通信!通常从设备(如Flash芯片、显示屏驱动IC)的手册会明确规定其SPI模式,主机需要据此配置。
5.2 实战:驱动SPI TFT屏幕与Flash
在我们的工业控制模块中,假设需要驱动一块240x320的SPI TFT屏来显示采集到的数据,并用一片SPI Flash存储历史记录。
驱动TFT屏:SPI屏的驱动芯片(如ILI9341)通常有一堆寄存器需要配置(初始化序列),然后就是不断地往显存里写像素数据。为了提高刷屏速度,有几点优化技巧:
- 使用硬件SPI和DMA:这是提升速度最有效的方法。将像素数据通过DMA搬运到SPI的数据寄存器,CPU几乎不干预。
- 优化传输格式:很多屏支持一次发送多个像素数据,而无需重复发送写命令和地址。可以设置屏为“内存连续写”模式。
- 仅刷新变化区域:如果只是部分数据更新,不要刷新整个屏幕,只更新对应的矩形区域。
一段简化的ILI9341初始化代码片段如下(使用STM32 HAL库及硬件SPI):
// 假设SPI已初始化,CS、DC(数据/命令选择)、RST为GPIO引脚 void TFT_SendCommand(uint8_t cmd) { HAL_GPIO_WritePin(TFT_DC_GPIO_Port, TFT_DC_Pin, GPIO_PIN_RESET); // DC拉低,表示命令 HAL_GPIO_WritePin(TFT_CS_GPIO_Port, TFT_CS_Pin, GPIO_PIN_RESET); // 片选使能 HAL_SPI_Transmit(&hspi1, &cmd, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(TFT_CS_GPIO_Port, TFT_CS_Pin, GPIO_PIN_SET); // 片选关闭 } void TFT_SendData(uint8_t data) { HAL_GPIO_WritePin(TFT_DC_GPIO_Port, TFT_DC_Pin, GPIO_PIN_SET); // DC拉高,表示数据 HAL_GPIO_WritePin(TFT_CS_GPIO_Port, TFT_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, &data, 1, HAL_MAX_DELAY); HAL_GPIO_WritePin(TFT_CS_GPIO_Port, TFT_CS_Pin, GPIO_PIN_SET); } // 发送初始化命令序列 TFT_SendCommand(0xCF); TFT_SendData(0x00); TFT_SendData(0xC1); TFT_SendData(0x30); // ... 更多初始化命令 TFT_SendCommand(0x11); // 退出睡眠 HAL_Delay(120); TFT_SendCommand(0x29); // 开启显示读写SPI Flash:Flash芯片(如W25Q64)的操作主要是读、写、擦除。写操作之前必须先擦除(擦除单位通常是扇区4KB),而且SPI Flash的写速度相对较慢。在存储数据时,需要设计好的磨损均衡和坏块管理策略(如果是文件系统如FATFS,会帮你处理一部分)。读写Flash的关键是仔细阅读数据手册中的时序图,特别是写使能、等待忙状态这些操作,必须严格遵循。
注意:SPI的片选信号CS,必须在一次完整的数据传输前后进行拉低和拉高操作。不能在传输过程中随意切换。当总线上有多个SPI从设备时,要确保同一时刻只有一个设备的CS是有效的,否则会造成总线冲突。对于速度要求极高的场景,注意PCB布局,SPI线应尽可能短,并远离高频噪声源。
6. CAN:复杂环境下的“可靠信使”
最后我们来到工业级选手——CAN总线。当你需要把几十个甚至上百个节点(传感器、执行器、控制器)连接到一个网络上,并且环境嘈杂、距离遥远、要求通信绝对可靠时,UART、I2C、SPI都力不从心,CAN就是为此而生的。它最初为汽车电子设计,现在广泛应用于工业自动化、船舶、医疗设备等领域。
6.1 差分信号与多主仲裁
CAN的物理层采用差分信号,即CAN_H和CAN_L两根线。它传输的不是绝对电压值,而是两根线之间的电压差。这种设计让CAN对共模干扰(比如电机产生的噪声同时叠加在两条线上)具有天生的免疫力,通信距离可以轻松达到千米级(在较低速率下)。
CAN网络是一个真正的多主对等网络。没有绝对的主机,任何节点都可以在总线空闲时主动发送消息。那如果两个节点同时发送怎么办?CAN用了一个非常巧妙的非破坏性仲裁机制。每个CAN报文都有一个唯一的标识符(ID),这个ID不仅代表消息内容,也决定了优先级——ID值越小,优先级越高。
在发送过程中,每个节点都在同时监听总线。它发送一个bit的同时也在读回总线上的电平。如果它发送了一个“隐性”位(逻辑1,差分电压为0),但读回来的是“显性”位(逻辑0,差分电压为正),它就意识到有更高优先级的报文在发送,于是立即退出发送转为接收,等待总线空闲后再重试。这个过程没有数据损坏,也没有冲突导致的延迟不确定性,确保了高优先级消息的实时性。
6.2 实战:构建一个简单的CAN网络
假设我们的工业控制模块需要作为CAN网络中的一个节点,接收来自多个传感器的数据,并向上位机转发。我们需要一个MCU(如STM32,带CAN控制器)和一个CAN收发器芯片(如TJA1050)。
硬件连接:MCU的CAN_Tx和CAN_Rx引脚连接到TJA1050的TXD和RXD。TJA1050的CANH和CANL引脚连接到双绞线总线。必须在总线两端(最远的两个节点处)各接一个120欧姆的终端电阻,这是消除信号反射、保证信号完整性的关键,很多人会忘记这一点。
软件配置:CAN的配置比前三种协议复杂,因为它涉及波特率设置、过滤器配置、工作模式等。CAN波特率不是随意设的,它由一系列时间参数决定:同步段、传播时间段、相位缓冲段1和2。通常可以使用在线CAN波特率计算器来辅助设置。例如,目标波特率500kbps,APB1时钟为36MHz,一个常见的配置是:预分频器=4,时间段1=13,时间段2=2。
CAN_HandleTypeDef hcan; CAN_FilterTypeDef sFilterConfig; hcan.Instance = CAN1; hcan.Init.Prescaler = 4; // 预分频 hcan.Init.Mode = CAN_MODE_NORMAL; // 正常工作模式 hcan.Init.SyncJumpWidth = CAN_SJW_1TQ; hcan.Init.TimeSeg1 = CAN_BS1_13TQ; // 时间段1 hcan.Init.TimeSeg2 = CAN_BS2_2TQ; // 时间段2 hcan.Init.TimeTriggeredMode = DISABLE; hcan.Init.AutoBusOff = DISABLE; hcan.Init.AutoWakeUp = DISABLE; hcan.Init.AutoRetransmission = ENABLE; // 自动重传 hcan.Init.ReceiveFifoLocked = DISABLE; hcan.Init.TransmitFifoPriority = DISABLE; if (HAL_CAN_Init(&hcan) != HAL_OK) { Error_Handler(); } // 配置过滤器:例如,只接收ID为0x123的标准数据帧 sFilterConfig.FilterBank = 0; sFilterConfig.FilterMode = CAN_FILTERMODE_IDMASK; sFilterConfig.FilterScale = CAN_FILTERSCALE_32BIT; sFilterConfig.FilterIdHigh = 0x123 << 5; // 标准ID左移5位 sFilterConfig.FilterIdLow = 0x0000; sFilterConfig.FilterMaskIdHigh = 0xFFFF; // 掩码,全匹配 sFilterConfig.FilterMaskIdLow = 0x0000; sFilterConfig.FilterFIFOAssignment = CAN_RX_FIFO0; sFilterConfig.FilterActivation = ENABLE; sFilterConfig.SlaveStartFilterBank = 14; if (HAL_CAN_ConfigFilter(&hcan, &sFilterConfig) != HAL_OK) { Error_Handler(); } // 启动CAN HAL_CAN_Start(&hcan); // 使能接收中断 HAL_CAN_ActivateNotification(&hcan, CAN_IT_RX_FIFO0_MSG_PENDING);发送与接收:配置好后,发送一个报文就是填充一个CAN_TxHeaderTypeDef结构体(包含ID、类型、数据长度等),然后调用HAL_CAN_AddTxMessage。接收则通常在中断回调函数HAL_CAN_RxFifo0MsgPendingCallback中处理,使用HAL_CAN_GetRxMessage获取报文。
在实际的工业控制模块中,你可能会定义一系列自定义的CAN ID,比如0x101代表温度数据,0x102代表压力数据。你的节点可以订阅(通过过滤器设置)关心的ID,也可以定期广播自己采集的数据。这种基于消息的、松耦合的通信方式,使得系统非常易于扩展和维护。
注意:CAN总线调试离不开专业的工具,如USB-CAN适配器(如PCAN, ZLG的USBCAN)。它们可以让你在电脑上监听总线上的所有报文,分析错误帧,这对于排查通信问题(如波特率不匹配、终端电阻缺失、节点故障导致持续发送错误帧拖死总线)至关重要。在布线时,一定要使用双绞线,并确保总线拓扑是线性的,不要有星型或树型分支,否则会破坏信号完整性。
