☰
SPI、I2C、UART通信协议选型指南:从原理到实战
2026/10/18 17:07:04 网站建设 项目流程

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)组合出四种模式:

模式CPOLCPHA采样沿移位沿
Mode 000上升沿下降沿
Mode 101下降沿上升沿
Mode 210下降沿上升沿
Mode 311上升沿下降沿

选错模式是SPI调试中最常见的问题。症状是读出来的数据整体偏移一位,或者全是0xFF/0x00。我的经验是:先查从机手册确认模式,再用逻辑分析仪抓波形对照,不要凭感觉试。

SPI没有应答机制,主机发了数据不知道从机收没收到。所以高速通信时要注意信号完整性:SCLK频率太高、走线太长、没有阻抗匹配,都会导致误码。一般PCB上SPI走线控制在10cm以内,超过的话降低时钟频率或者加串联电阻。

3. 实际项目中的选型逻辑与参数计算

3.1 一张表看懂三种协议的适用场景

维度UARTI2CSPI
线数2(TX/RX)2(SDA/SCL)4(SCLK/MOSI/MISO/CS)
拓扑点对点多主多从一主多从
寻址方式无7位/10位地址片选线
典型速率9.6k-115.2kbps100k-3.4Mbps1M-100Mbps
时钟异步同步同步
应答机制无有无
引脚开销低低高(每个从机一根CS)
适用距离板内/短距离板内板内

选型的核心逻辑是看数据量和设备数量。数据量小、设备少、引脚紧张,优先I2C;数据量大、实时性要求高,选SPI;两个设备之间简单通信,UART最省事。

3.2 速率与距离的取舍:什么时候该降速

很多人调通信不通就怀疑代码,其实信号完整性才是高频出问题的重灾区。我整理了一个经验对照表:

协议速率安全走线长度(普通FR4)超过后的措施
UART115.2kbps1-2米加驱动芯片
I2C100kHz30-50cm减小上拉电阻
I2C400kHz10-20cm减小上拉电阻+缩短走线
SPI1MHz20-30cm串联22Ω电阻
SPI10MHz5-10cm阻抗匹配+缩短走线
SPI50MHz<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%的问题:

  1. 电源和地:用万用表量从机供电是否正常,地是否共地。我遇到过好几次是忘了接从机的地,信号参考点都没有,怎么可能通。
  2. 引脚配置:TX/RX有没有配反?I2C的SDA/SCL有没有配成开漏?SPI的MISO有没有配成输入?这些在STM32的CubeMX里点错了,生成代码就是错的。
  3. 时钟使能:外设时钟开了没?GPIO时钟开了没?DMA时钟开了没?漏一个就全盘不动。
  4. 上拉电阻:I2C的SDA/SCL必须有上拉,很多新手以为芯片内部有,其实大多数没有。
  5. 片选信号:SPI的CS有没有拉低?有些从机要求CS在每次传输前有下降沿,不能一直拉低。

5.2 数据偶尔出错但大部分正常

这种“时好时坏”的问题最难查,通常是时序裕量不够。可能的原因:

  • 波特率误差接近2%的临界值,换晶振或调整分频系数
  • I2C上拉电阻偏大,上升沿太慢,减小电阻试试
  • SPI时钟太快,降低频率看是否改善
  • 电源纹波大,通信瞬间电流变化导致电平抖动,加去耦电容

我的经验是先用逻辑分析仪抓波形,看时钟和数据的关系是否满足从机手册的建立/保持时间要求。没有逻辑分析仪的话,用示波器看SCL和SDA的边沿,如果上升沿明显变缓,就是上拉电阻的问题。

5.3 多设备通信时的相互干扰

I2C总线上挂多个设备,一个设备出问题可能把整条总线拉死。比如某个设备死机后一直拉低SDA,主机发什么它都不理,其他设备也没法通信。解决方案是加I2C缓冲器或复用器,把设备分组隔离。或者用总线恢复机制:主机发送9个时钟脉冲,让从机把剩余的数据位移完,释放SDA线。

SPI多从机的话,注意MISO线的竞争。同一时刻只能有一个从机输出MISO,如果两个从机的CS同时有效,一个输出高一个输出低,就会短路。所以CS的控制逻辑必须严格,任何时候只能拉低一个。

5.4 常见问题速查表

现象可能原因排查方法
完全无数据电源/地/时钟/引脚配置万用表+示波器逐项检查
数据固定为0xFFMISO未接或从机未响应检查CS和从机供电
数据固定为0x00SDA/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。

我在实际项目中总结出一条原则:先确定数据量和实时性要求,再确定设备数量和引脚预算,最后才选协议。反过来先选协议再凑需求,往往会在后期发现各种不匹配。通信协议没有好坏之分,只有合不合适。把三种协议的底层逻辑吃透,遇到问题能快速定位,比背一百遍定义都有用。

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

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

立即咨询