1. 三种通信协议到底在解决什么问题
嵌入式开发干久了,你会发现一个很有意思的现象:很多人能背出SPI有四根线、I2C有两根线、UART是异步的,但一到实际项目选型就犯难。我见过不少工作两三年的工程师,画板子的时候随手选一个,调试不通了再回头翻手册,浪费大量时间。问题的根源不在于记不住定义,而在于没有从“数据怎么在芯片之间流动”这个角度去理解它们。
SPI、UART、I2C本质上解决的是同一类问题:让两颗(或多颗)芯片通过有限的引脚可靠地交换数据。但它们的解题思路完全不同。UART像是两个人约好时间聊天,没有时钟线,全靠事先约定好的语速;I2C像是一条公交线路,多个站点共享两条线,每个站点有唯一地址;SPI则像是点对点的专用高速公路,速度快、线多、但只能一对一(或一对多通过片选切换)。
这篇文章适合谁看?如果你正在做单片机项目、画原理图、写驱动,或者面试时被问到通信协议选型,那这篇内容就是给你准备的。我会从实际项目出发,把三种协议的底层逻辑、参数计算、代码实现、踩坑经验全部拆开讲清楚。不堆砌手册上的定义,只讲你在实际工作中真正用得上的东西。
2. 三种通信协议的核心原理拆解
2.1 UART:没有时钟线,靠什么保证数据不错乱
UART的全称是通用异步收发器,关键词在“异步”两个字。它没有时钟线,发送方和接收方各自用自己的时钟源,靠预先约定的波特率来同步。你可以把它想象成两个人用摩尔斯电码通信,事先说好每个点持续0.1秒,那双方就都按这个节奏来。
那问题来了:如果发送方的时钟和接收方的时钟有偏差怎么办?这就是UART最核心的容错机制——起始位和停止位。每一帧数据以起始位(低电平)开头,以停止位(高电平)结尾。接收方在检测到起始位的下降沿时,会重新对齐自己的采样时钟,这样每次传输都能重新同步,累积误差不会超过一帧。
一帧UART数据的典型结构是这样的:
| 部分 | 位数 | 作用 |
|---|---|---|
| 起始位 | 1位 | 标志一帧数据的开始,低电平 |
| 数据位 | 5-9位 | 实际传输的数据,通常8位 |
| 校验位 | 0或1位 | 奇偶校验,可选 |
| 停止位 | 1或2位 | 标志一帧结束,高电平 |
波特率的选择有个经验公式:波特率误差要控制在2%以内,否则接收方采样点会偏移到数据位的边缘,导致误码。比如你用8MHz晶振产生9600波特率,分频系数是8000000/(16×9600)=52.08,取整52的话实际波特率是8000000/(16×52)=9615.38,误差0.16%,完全没问题。但如果用7.3728MHz晶振,分频系数是48,刚好整除,误差为0,这也是为什么很多老式单片机推荐用7.3728MHz或11.0592MHz晶振——就是为了让常用波特率能整除。
注意:UART的TTL电平和RS-232电平是两回事。单片机的TX/RX引脚输出的是TTL电平(0V/3.3V或5V),而传统串口用的是RS-232电平(正负12V左右)。直接对接会烧芯片,中间必须加电平转换芯片。
2.2 I2C:两根线挂多个设备,地址冲突怎么破
I2C最吸引人的地方就是两根线可以挂几十个设备。SDA是数据线,SCL是时钟线,所有设备都并联在这两条线上。每个设备有唯一的7位地址,主机发起通信时先发地址,匹配的设备才会响应。
但这里有个实际问题:I2C的SDA和SCL都是开漏输出,也就是说设备只能把线拉低,不能主动拉高。线要变高必须靠上拉电阻。这就引出了I2C最关键的硬件设计点——上拉电阻的选型。
上拉电阻太大,上升沿太慢,高速通信时波形还没恢复到高电平就被拉低了;上拉电阻太小,功耗大,而且灌电流可能超过器件的承受能力。计算公式是这样的:
上升时间 t_r ≈ 0.847 × R_p × C_bus
其中C_bus是总线电容,包括PCB走线电容和器件引脚电容,通常按每根线100pF估算。I2C标准模式(100kHz)要求上升时间小于1000ns,快速模式(400kHz)要求小于300ns。
假设C_bus=200pF,快速模式下:R_p < 300ns / (0.847 × 200pF) ≈ 1.77kΩ。所以4.7kΩ在快速模式下可能偏大,2.2kΩ或1.5kΩ更合适。但还要检查灌电流:3.3V / 1.5kΩ = 2.2mA,大多数I2C器件能承受3mA以上,没问题。
I2C还有一个容易忽略的细节:时钟拉伸。从设备如果处理不过来,可以把SCL线拉低,强制主机等待。这在读传感器数据时很常见,比如某些温湿度传感器转换一次需要几十毫秒,期间就会拉伸时钟。如果你的I2C驱动不支持时钟拉伸,通信就会失败。
2.3 SPI:四根线换来的高速与灵活
SPI是三种协议里最快的,没有地址帧开销,没有起始停止位,数据可以连续流式传输。它的四根线分别是:
- SCLK:时钟,由主机产生
- MOSI:主机输出从机输入
- MISO:主机输入从机输出
- CS:片选,每个从机一根,低电平有效
SPI的灵活性体现在模式配置上。时钟极性(CPOL)和时钟相位(CPHA)组合出四种模式:
| 模式 | CPOL | CPHA | 采样沿 | 移位沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 上升沿 | 下降沿 |
| Mode 1 | 0 | 1 | 下降沿 | 上升沿 |
| Mode 2 | 1 | 0 | 下降沿 | 上升沿 |
| Mode 3 | 1 | 1 | 上升沿 | 下降沿 |
选错模式是SPI调试中最常见的问题。症状是读出来的数据整体偏移一位,或者全是0xFF/0x00。我的经验是:先查从机手册确认模式,再用逻辑分析仪抓波形对照,不要凭感觉试。
SPI没有应答机制,主机发了数据不知道从机收没收到。所以高速通信时要注意信号完整性:SCLK频率太高、走线太长、没有阻抗匹配,都会导致误码。一般PCB上SPI走线控制在10cm以内,超过的话降低时钟频率或者加串联电阻。
3. 实际项目中的选型逻辑与参数计算
3.1 一张表看懂三种协议的适用场景
| 维度 | UART | I2C | SPI |
|---|---|---|---|
| 线数 | 2(TX/RX) | 2(SDA/SCL) | 4(SCLK/MOSI/MISO/CS) |
| 拓扑 | 点对点 | 多主多从 | 一主多从 |
| 寻址方式 | 无 | 7位/10位地址 | 片选线 |
| 典型速率 | 9.6k-115.2kbps | 100k-3.4Mbps | 1M-100Mbps |
| 时钟 | 异步 | 同步 | 同步 |
| 应答机制 | 无 | 有 | 无 |
| 引脚开销 | 低 | 低 | 高(每个从机一根CS) |
| 适用距离 | 板内/短距离 | 板内 | 板内 |
选型的核心逻辑是看数据量和设备数量。数据量小、设备少、引脚紧张,优先I2C;数据量大、实时性要求高,选SPI;两个设备之间简单通信,UART最省事。
3.2 速率与距离的取舍:什么时候该降速
很多人调通信不通就怀疑代码,其实信号完整性才是高频出问题的重灾区。我整理了一个经验对照表:
| 协议 | 速率 | 安全走线长度(普通FR4) | 超过后的措施 |
|---|---|---|---|
| UART | 115.2kbps | 1-2米 | 加驱动芯片 |
| I2C | 100kHz | 30-50cm | 减小上拉电阻 |
| I2C | 400kHz | 10-20cm | 减小上拉电阻+缩短走线 |
| SPI | 1MHz | 20-30cm | 串联22Ω电阻 |
| SPI | 10MHz | 5-10cm | 阻抗匹配+缩短走线 |
| SPI | 50MHz | <5cm | 需要专业SI设计 |
这个表不是绝对的,但能帮你快速判断问题方向。我曾经遇到一个项目,SPI时钟设到20MHz,走线15cm,数据随机出错。降到5MHz就稳定了,后来在时钟线上串了33Ω电阻,又试到10MHz也没问题。串联电阻的作用是抑制反射,成本几分钱,效果立竿见影。
3.3 多设备共存时的地址与片选管理
I2C的地址冲突是个经典问题。比如你用了两个同型号的温湿度传感器,出厂地址一样,怎么办?大多数这类传感器提供地址引脚,可以配置成不同的地址。如果只有两个可选地址,那就只能挂两个。再多的话需要I2C多路复用器,比如TCA9548A,一路变八路,每路可以挂相同地址的设备。
SPI的片选管理相对简单,每个从机一根CS线,主机拉低哪个就选哪个。但要注意:CS的默认状态必须配置为高电平,否则上电瞬间所有从机都被选中,MISO线会打架。我在STM32上就踩过这个坑,初始化时忘了把CS引脚设为高,结果两个SPI从机同时输出,读出来的数据全是乱的。
4. 代码层面的实现要点与调试方法
4.1 UART中断接收的缓冲区设计
UART接收最怕数据溢出。主循环处理慢,串口数据一直来,硬件缓冲区只有一两个字节,很快就丢了。解决方案是环形缓冲区+中断。
#define UART_BUF_SIZE 256 typedef struct { uint8_t buffer[UART_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buf_t; ring_buf_t uart_rx_buf; void UART_IRQHandler(void) { if (UART_GetITStatus(UART_IT_RXNE)) { uint8_t data = UART_ReceiveData(); uint16_t next = (uart_rx_buf.head + 1) % UART_BUF_SIZE; if (next != uart_rx_buf.tail) { uart_rx_buf.buffer[uart_rx_buf.head] = data; uart_rx_buf.head = next; } // 如果next == tail,说明缓冲区满了,丢弃数据 } } uint8_t uart_read_byte(uint8_t *data) { if (uart_rx_buf.head == uart_rx_buf.tail) { return 0; // 空 } *data = uart_rx_buf.buffer[uart_rx_buf.tail]; uart_rx_buf.tail = (uart_rx_buf.tail + 1) % UART_BUF_SIZE; return 1; }这个设计的精髓在于head和tail的追赶逻辑。head由中断更新,tail由主循环更新,两者相等表示空,head+1等于tail表示满。这样不需要加锁,中断和主循环各管各的指针,天然线程安全。
注意:head和tail必须用volatile修饰,否则编译器优化后可能把变量缓存到寄存器里,导致主循环读不到中断的更新。
4.2 I2C读写时序的软件模拟与硬件外设选择
I2C有两种实现方式:硬件外设和软件模拟。硬件外设省CPU,但不同芯片的I2C外设有各种奇怪的bug,比如某些型号在从机拉伸时钟时会卡死。软件模拟灵活,任何GPIO都能用,但占用CPU时间。
软件模拟I2C的核心是精确控制SDA和SCL的时序。以写一个字节为例:
void i2c_start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(4); SDA_LOW(); delay_us(4); SCL_LOW(); delay_us(4); } void i2c_write_byte(uint8_t byte) { for (int i = 0; i < 8; i++) { if (byte & 0x80) SDA_HIGH(); else SDA_LOW(); byte <<= 1; delay_us(2); SCL_HIGH(); delay_us(4); SCL_LOW(); delay_us(2); } // 第9个时钟读ACK SDA_INPUT(); SCL_HIGH(); delay_us(4); // 读SDA电平判断ACK SCL_LOW(); delay_us(2); SDA_OUTPUT(); }delay_us的具体值取决于你的I2C速率。100kHz的话,一个时钟周期10us,高电平5us低电平5us。但实际调试时,上升沿的时间由外部上拉电阻决定,软件延时只是保证低电平持续时间足够。如果上拉电阻太大,SCL还没升到高电平你就开始下一步了,从机根本认不到时钟。
4.3 SPI的DMA传输与片选控制
SPI高速传输必须用DMA,否则CPU一直在搬数据,什么都干不了。但DMA和片选配合有个坑:DMA传输完成中断里才能拉高CS,如果提前拉高,最后一个字节还没发完就被截断了。
void spi_dma_transfer(uint8_t *tx, uint8_t *rx, uint16_t len) { CS_LOW(); DMA_SetAddr(DMA_CH_TX, tx); DMA_SetAddr(DMA_CH_RX, rx); DMA_SetLen(DMA_CH_TX, len); DMA_SetLen(DMA_CH_RX, len); DMA_Enable(DMA_CH_TX); DMA_Enable(DMA_CH_RX); SPI_EnableDMA(SPI1, SPI_DMA_TX | SPI_DMA_RX); SPI_Enable(SPI1); } void DMA_TX_Complete_IRQHandler(void) { // 等待RX也完成 while (!DMA_GetFlag(DMA_CH_RX, DMA_FLAG_TC)); SPI_Disable(SPI1); CS_HIGH(); DMA_ClearFlag(DMA_CH_TX, DMA_FLAG_TC); DMA_ClearFlag(DMA_CH_RX, DMA_FLAG_TC); }这里的关键是等RX也完成再拉高CS。因为SPI是全双工,TX发完的时候RX可能还在收最后几个字节。只等TX完成就拉高CS,从机会认为通信结束,后面的数据就丢了。
5. 常见问题排查与避坑经验
5.1 通信完全无反应的排查顺序
遇到通信不通,按这个顺序查,能解决80%的问题:
- 电源和地:用万用表量从机供电是否正常,地是否共地。我遇到过好几次是忘了接从机的地,信号参考点都没有,怎么可能通。
- 引脚配置:TX/RX有没有配反?I2C的SDA/SCL有没有配成开漏?SPI的MISO有没有配成输入?这些在STM32的CubeMX里点错了,生成代码就是错的。
- 时钟使能:外设时钟开了没?GPIO时钟开了没?DMA时钟开了没?漏一个就全盘不动。
- 上拉电阻:I2C的SDA/SCL必须有上拉,很多新手以为芯片内部有,其实大多数没有。
- 片选信号:SPI的CS有没有拉低?有些从机要求CS在每次传输前有下降沿,不能一直拉低。
5.2 数据偶尔出错但大部分正常
这种“时好时坏”的问题最难查,通常是时序裕量不够。可能的原因:
- 波特率误差接近2%的临界值,换晶振或调整分频系数
- I2C上拉电阻偏大,上升沿太慢,减小电阻试试
- SPI时钟太快,降低频率看是否改善
- 电源纹波大,通信瞬间电流变化导致电平抖动,加去耦电容
我的经验是先用逻辑分析仪抓波形,看时钟和数据的关系是否满足从机手册的建立/保持时间要求。没有逻辑分析仪的话,用示波器看SCL和SDA的边沿,如果上升沿明显变缓,就是上拉电阻的问题。
5.3 多设备通信时的相互干扰
I2C总线上挂多个设备,一个设备出问题可能把整条总线拉死。比如某个设备死机后一直拉低SDA,主机发什么它都不理,其他设备也没法通信。解决方案是加I2C缓冲器或复用器,把设备分组隔离。或者用总线恢复机制:主机发送9个时钟脉冲,让从机把剩余的数据位移完,释放SDA线。
SPI多从机的话,注意MISO线的竞争。同一时刻只能有一个从机输出MISO,如果两个从机的CS同时有效,一个输出高一个输出低,就会短路。所以CS的控制逻辑必须严格,任何时候只能拉低一个。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无数据 | 电源/地/时钟/引脚配置 | 万用表+示波器逐项检查 |
| 数据固定为0xFF | MISO未接或从机未响应 | 检查CS和从机供电 |
| 数据固定为0x00 | SDA/SCL接反或上拉缺失 | 交换线序测试 |
| 数据整体偏移一位 | SPI模式选错 | 切换CPOL/CPHA四种组合 |
| 偶尔误码 | 时序裕量不足 | 降速或减小上拉电阻 |
| 多设备时好时坏 | 地址冲突或CS竞争 | 逐个设备单独测试 |
| 高速时出错 | 信号反射 | 串联电阻或缩短走线 |
6. 从项目实战中积累的选型与调试心得
6.1 什么时候优先选I2C而不是SPI
很多人觉得SPI快就无脑选SPI,其实不对。引脚资源紧张的时候,I2C的优势非常明显。比如你要挂一个EEPROM、一个温湿度传感器、一个OLED屏,用SPI需要3根共用线+3根CS=6根线,用I2C只需要2根线。PCB走线少、布线简单、成本低。
而且I2C有应答机制,主机发了数据从机必须回ACK,没回就说明通信失败,可以重试。SPI没有这个机制,发了就发了,从机收没收到主机不知道。对于可靠性要求高但速度要求不高的场景,I2C更合适。
6.2 UART在调试中的不可替代性
不管项目里用不用UART做正式通信,调试串口几乎是必备的。打印日志、查看变量、输出错误码,没有比UART更简单的了。我习惯在项目初期就把UART调试口配好,用printf重定向,后面调什么外设都方便。
但要注意:调试串口不要用太高的波特率。115200足够用了,再高的话如果USB转串口芯片质量一般,容易丢数据。而且调试信息本身不需要那么快,省下来的CPU时间干正事。
6.3 逻辑分析仪是调通信协议的最佳投资
我买过最值的调试工具就是逻辑分析仪,几百块钱的八通道版本,支持I2C、SPI、UART协议解码。抓一段波形,软件直接告诉你地址、数据、ACK位,比对着示波器数时钟脉冲效率高十倍。
比如I2C通信失败,逻辑分析仪一看:主机发了地址0x48,从机没回ACK。那就说明从机没响应,要么地址错了,要么从机没上电,要么从机死机了。方向立刻明确,不用瞎猜。
6.4 通信协议的未来:高速与低功耗的平衡
现在的新芯片越来越多地采用组合方案:比如用SPI做高速数据采集,用I2C做配置和状态读取,用UART做调试输出。没有一种协议能通吃所有场景,理解每种协议的优缺点,根据实际需求灵活组合,才是正确的做法。
另外,I3C作为I2C的升级版,在保持两根线的同时把速率提到了12.5Mbps,还支持带内中断。虽然目前普及度还不高,但以后可能会逐渐替代I2C在传感器领域的地位。如果你的项目周期长,选型时可以关注一下芯片是否支持I3C。
我在实际项目中总结出一条原则:先确定数据量和实时性要求,再确定设备数量和引脚预算,最后才选协议。反过来先选协议再凑需求,往往会在后期发现各种不匹配。通信协议没有好坏之分,只有合不合适。把三种协议的底层逻辑吃透,遇到问题能快速定位,比背一百遍定义都有用。