SPI、I2C、UART:串口通信协议简介
2026-06-13
embedded、communication
在进行嵌入式开发时,我们常常需要让 MCU (microcontroller unit,即俗称的“单片机”) 与外围设备 (peripheral,外设) 交换信息,包括 LED 矩阵、LCD 显示屏等等。在人类社会中,信息的交换绝大部分是通过语言文字实现的;而在嵌入式领域,MCU 与外设之间的信息交换也有自己的“语言”,我们通常称之为通信协议 (communication protocol)。
最快速的信息交换是并行 (parallel),通过很多根线将数据的所有比特位全部同时传输,相当于修建一条十几个车道的高速公路,很多辆车可以同时行驶。但在嵌入式设计的领域,能够调用的硬件资源往往十分有限,从一个设备拉出十几根线仅仅为了传输某个特定的数据是相当不现实的。这种情况下,串行 (series) 就显得尤为必要,通过仅仅两三根线将数据的比特位在时间维度上依次发送,相当于开辟一条乡间小路,汽车必须一辆辆地依次通过。
虽然串行传输的速度往往比并行传输慢了好几倍,但是它极大地减少了面积和端口数,更加的经济。而且现在的硬件一般都能以兆赫兹级别的频率处理数据,需要多花费的时间在大部分的实际使用中都不会有太大的影响。
常用的串口通信协议有以下三种:
- SPI:Series Peripheral Interface,串行外围设备接口;
- I2C:Inter-Integrated Circuit,直译为“集成电路之间”;
- UART:Universal Asynchronous Receiver/Transmitter,通用异步收发器。
本文将涵盖这三种通信协议的硬件构造、数据传输以及各自的特殊机制。
The English translation of this post is provided here, only for reference.
一些基本概念
在接触协议的具体细节前,先来看传输中涉及的几个基本概念。
- 主机 (master) 和从机 (slave):发起通信的称为主机,其余的设备称为从机。绝大多数情况下,MCU 作为主机,外设作为从机。需要注意的是,从机也可以向主机发送数据;谁是主机、谁是从机是根据整个传输过程的发起来确定的。
- 时钟信号 (clock signal):由于数据串行传输,如何控制传输的“时机”就显得尤为重要。一种方法是将一个周期性的信号作为“节拍器”,主机和从机再根据这个“节拍器”来确定何时发数据,何时读数据。这个周期性的信号就被称为时钟信号。需要时钟信号的机制是同步 (synchronous) 的,反之就是异步 (asynchronous) 的。
- 边沿敏感 (edge-sensitive) 和电平敏感 (level-sensitive):所谓的“边沿”和“电平”以时钟信号为参照。
- 边沿是指时钟信号从低电平变为高电平或者由高电平变为低电平的变化过程,而“边沿敏感”指的就是数据是在某个特定的边沿 (上升沿 rising-edge/下降沿 falling-edge) 处被读出的;
- “电平敏感”指的则是数据是在时钟信号位于某个特定的电平 (高或低) 时被读出的。
边沿敏感 (上升沿) 和电平敏感 (高电平) 的信号示意。
💡 如果数据信号是边沿敏感的,需要保证其在时钟信号指定边沿前后的一小段时间内保持恒定,否则会形成建立/保持时间违例 (setup/hold time violation),容易导致系统进入亚稳态 (metastable state) 并影响功能的正确性。这是由硬件产生的限制,无法根除,且是高频数字 IC 设计的难点。
SPI:完整且灵活的通信协议
硬件构造
SPI 通信协议支持一个主机与多个从机之间通信,需要四种类型的连线。
| 线名 | 功能 | 描述 |
|---|---|---|
SCK |
时钟线 | 主机产生;极性和相位可选 |
CS |
片选线 | 决定主机和哪个从机通信;每个从机一根,低电平有效 |
MOSI |
主机输出,从机输入 | 传输数据;所有从机共用一根 |
MISO |
主机输入,从机输出 | 传输数据;所有从机共用一根 |
SPI 协议硬件构造示意。
SPI 协议的设计允许数据以极高的速率传输,例如 STM32G473RCT6 就支持最高 8.0 MBit/s 的速率 (每秒传输 800 万个比特位)。同时由于输入和输出线分离,SPI 协议可实现全双工 (full-duplex) 通信,即由主机向从机发送的数据和由从机向主机发送的数据能同时传输。
数据传输
开始与停止
SPI 的片选线 CS 是低电平有效的。因此,SPI 的数据传输由片选线 CS 被设为低电平开始,在片选线 CS 被设为高电平时结束。
传输过程
SPI 的数据信号是边沿敏感的。其时钟信号由时钟线 SCK 搭载,我们可以调整时钟极性 (clock polarity, CPOL) 和时钟相位 (clock phase, CPHA) 来满足不同的外设。
- 时钟极性 CPOL:时钟空闲 (idle) 时的电平,可取 0 或 1:
- 0 代表时钟空闲时位于低电平,
- 1 代表时钟空闲时位于高电平。
- 时钟相位 CPHA:数据从时钟信号开始的第几个边沿开始采样,可取 0 或 1:
- 0 代表使用从时钟信号开始的那个边沿,
- 1 代表使用从时钟信号开始的那个边沿的后一个边沿。
SPI 协议时钟极性、时钟相位示意。
在两根数据线 MOSI 和 MISO 上,数据都是最高位 (MSB) 先出,意味着数据的高位先传输,低位后传输。下图展示了一个 SPI 协议的传输示例。
SPI 协议传输示例:主机向从机发送数据,CPOL = 0,CPHA = 0。
具体实现
SPI 传输协议在 MCU 上的实现有软件和硬件两种。
- 软件 SPI 就是通过 MCU 上的通用输入输出 (GPIO) 端口来控制 SPI 协议的四种连线,再通过代码操作 GPIO 来传输数据。
- 硬件 SPI 就是借由 MCU 内置的 SPI 相关模块实现传输,代码层面只需要调用对应的库函数即可。例如,在 STM32G4 系列的 MCU 上配置好相应的引脚后,我们可以直接调用
HAL_SPI_Transmit和HAL_SPI_Receive这两个函数来借由 MCU 上的 SPI 模块实现发送与接收数据。
软件 SPI 通常比硬件 SPI 的传输速率会低一些。
💡 从更广泛的视角来看,解决同一个问题,通常软件实现都会比硬件实现的执行效率低。软件实现是间接的:所有代码最终都要翻译成机器码扔到一个处理器去执行,而处理器为了普适性一般不会为了某个具体的问题作优化。硬件实现则是直接的:将需求一步到位落地实现到数字逻辑中,设计出为了解决这个问题的专用的数字电路,因此可以优化到极致。
有现实的示例可供参考。在量化交易领域,计算速度至关重要:一个延迟了几毫秒的卖出操作就可能造成几千甚至上万元的损失。除了把计算程序的性能凹到极致,从业人士甚至还开始使用 FPGA (现场可编程门阵列) 来进行相关计算,因为 FPGA 的执行速度更快。FPGA 是更直接的硬件实现,它的内部是一些基本的逻辑元件,可以通过编程把它们连接起来,从而形成某个特定功能的数字电路。
以下是软件 SPI 的实例,代码基于 STM32G4 系列 MCU。
// CPOL = 0, CPHA = 0
void spi_transmit(const GPIO_TypeDef* CS_port, const uint16_t CS_pin, uint8_t data) {
HAL_GPIO_WritePin(SCK_port, SCK_pin, GPIO_PIN_RESET); // SCK low, idle
HAL_GPIO_WritePin(CS_port, CS_pin, GPIO_PIN_RESET); // CS low, start transmission
for (int i = 7; i >= 0; --i) { // MSB first
HAL_GPIO_WritePin(MOSI_port, MOSI_pin, (data & (1 << i)) ? GPIO_PIN_SET : GPIO_PIN_RESET);
HAL_GPIO_WritePin(SCK_port, SCK_pin, GPIO_PIN_SET); // SCK high, rising edge
HAL_GPIO_WritePin(SCK_port, SCK_pin, GPIO_PIN_RESET); // SCK back to low
}
HAL_GPIO_WritePin(CS_port, CS_pin, GPIO_PIN_SET); // CS high, end transmission
HAL_GPIO_WritePin(SCK_port, SCK_pin, GPIO_PIN_RESET); // SCK low, back to idle
}
I2C:一根数据线控制权的巧妙调度
我们可以发现,SPI 有一个缺点:连线的数量还是太多了,尤其是片选线,每个从机都需要有一根。有没有办法减少连线的数量呢?
有的兄弟,有的。请看 I2C。
硬件构造
I2C 通信协议支持多个主机和多个从机之间的通信。它只需要两根线。
| 线名 | 功能 | 描述 |
|---|---|---|
SCL |
时钟线 | 主机产生,从机可以根据自身处理情况延长低电平时间 |
SDA |
数据线 | 用于主机和从机之间双向传输数据 |
由于没有片选线,I2C 要求系统里的每个从机都要有一个独一无二的地址 (address) 以方便主机区分。
I2C 协议硬件线路连接示意。
我们看到,I2C 协议的时钟线 SCL 和数据线 SDA 都连接了一个上拉电阻 (pull-up resistor)。而在每个设备内,两根线各自都通过一个 NMOS 三极管接地。这与 I2C 产生电平的机制有关:
- 某个设备想输出低电平时,就让其 NMOS 三极管导通,将对应的线接地;
- 某个设备想输出高电平时,就断开其 NMOS 三极管释放对应的线,上拉电阻会把它拉到高电平。
因此,当空闲时,两根线都会处于高电平。
I2C 协议产生电平的机制。
💡 NMOS 三极管是 MOSFET (金属氧化物半导体场效应晶体管) 中的一种。NMOS 有三个端口,图中右上、右下的端口分别是漏极 (drain) 和源极 (source),左侧的端口是栅极 (gate)。在这个电路中,NMOS 可以看作是一个由栅极电平控制的开关:当栅极为低电平的时候,源极和漏极之间不导通,开关断开;当栅极为高电平的时候,源极和漏极之间导通,开关闭合。
这种连接还产生了一个线与门 (wired-AND) 的效应:当多个设备想要往同一根线上输出的时候,
- 只有这些设备都想要输出高电平的时候,这根线才会是高电平;
- 反之,只要有一个设备想输出低电平的时候,这根线就是低电平。
I2C 利用这一效应避免了短路 (VCC 直接接地),还实现了很多巧妙的机制,我们一会儿会讲到。
然而,由于 I2C 的高电平不是硬连接到 VCC 而是靠上拉电阻实现,电源就需要通过上拉电阻给整条线路的寄生电容 (parasitic capacitance) 充电。而上拉电阻的阻值一般不会太小,所以这个 RC 电路的时间常数比较大,因而从低电平到高电平的转换就会较为缓慢。这限制了 I2C 的传输速率,使其无法达到 SPI 的水平 (例如 STM32G473RCT6 的 I2C 最高传输速率只有 400 kBit/s,即每秒传输 40 万个比特位)。
此外,由于只有一根数据线,虽然主机可以向从机发送数据,从机也可以向主机发送数据,但是这两者不能同时进行。因此,I2C 只能实现半双工 (half-duplex)。
数据传输
开始与停止
I2C 的开始和停止比较特殊:
- 开始条件 (START condition):在
SCL为高电平时,SDA由高电平变为低电平; - 停止条件 (STOP condition):在
SCL为高电平时,SDA由低电平变为高电平。
除此之外,还存在一个重复开始条件 (repeated START condition),是在主机希望发起一个新的通信请求,但是不想发送停止条件从而释放两根线的控制权时使用的。
传输过程
I2C 的数据信号是电平敏感的。除了开始和停止条件之外,数据线 SDA 上的所有电平都是在时钟线 SCL 位于高电平时读取的。因此,在每个 SCL 的高电平期间,SDA 的电平应保持稳定,否则会被认为是开始或停止条件,导致执行错误。
在数据线 SDA 上,数据都是最高位先出的,且一般以字节为单位。I2C 还引入了确认 (acknowledge, ACK) 和不确认 (not acknowledge, NACK) 机制来让发送者知晓数据的接收情况:在每个字节发送完毕后,发送者会暂时释放数据线 SDA 一个时钟周期,接收者在这个时钟周期内会控制 SDA 的电平:
- 高电平表示不确认 (NACK),接收者无法或不愿再接收数据;
- 低电平表示确认 (ACK),接收者完整收到并处理了数据,告知发送者可进行接下来的操作。
需要注意的是,发送者不一定是主机,接收者也不一定是从机。从机也可以向主机发送数据。
💡 ACK 与 NACK 的电平高低是有讲究的:在一切正常的时候,接收者需要对数据线
SDA的电平进行主动的操作。如果接收者本身故障了完全没有反应,它就不会动SDA的电平;而SDA的电平空闲时是高,对应 NACK,发送者一看,就知晓自己发送的数据并没有被接收者接收到。
I2C 协议传输一个字节的过程示意 (接收者在最后发送 ACK)。
I2C 的数据传输往往还遵循固定的格式。不同从机可能对数据的格式有不同的要求,但是大多数都包含从机地址、从机内部的寄存器地址,还有要传输的数据本体。以下图片来源于 IS31FL3731 的手册,它是一个 LED 矩阵的驱动器,使用 I2C 与 MCU 之间进行通信。
IS31FL3731 规定的数据传输格式。图片取自 IS31FL3731 的手册。
特殊机制
当一个多主机的系统采用 I2C 协议进行通信的时候,如何有序、高效地调度线路的控制权,就成为了一个问题。这时,I2C 协议的“线与门”机制就可以发挥威力了。
时钟同步 (Clock Synchronization)
多个主机产生的时钟信号虽然可以设置为相同的频率,但是相位和占空比很难保证完全一致。如何确定一个稳定的时钟信号,保证数据线 SDA 在这个时钟信号为高电平时是恒定的呢?
我们想到,在所有主机各自产生的时钟信号均为高电平时,它们想要往数据线 SDA 上输出的数据都是恒定的,所以 SDA 的电平就一定是恒定的。因此,这个稳定的时钟信号就可以通过所有的时钟信号取与获得。这并不需要任何额外的器件,因为 SCL 天然具有线与门的特性!这就是时钟同步。
I2C 的时钟同步机制示意。
仲裁 (Arbitration)
时钟是同步了,但是多个主机会尝试在同一个时间点开始发送数据,产生控制权的争夺。I2C 通过仲裁来确定哪个主机获得最终的控制权。
仲裁的规则十分简单:我们没办法阻止主机在同一个时间点开始发送数据,那就让它们一边发送各自的数据一边盯着数据线 SDA 看,谁先往 SDA 上发送高电平但是看到低电平,谁输掉仲裁 (lose arbitration) 并退出。输掉仲裁的主机需要释放 SDA,等待观测到停止条件后才能重新尝试发送数据。这一仲裁机制是去中心化的,没有一个统一的上层单元指挥,全靠各个主机的“自觉”判定。
由于 SDA 的线与门特性,某个主机因输掉仲裁而退出时,不会对仍在场上发送数据的主机产生任何影响,因为它尝试发送的是高电平;而输掉仲裁前,它们发送的一定是相同的数据。因此仲裁的过程对于所有从机而言都是透明的,无法察觉。
I2C 的仲裁机制示意。
时钟拉伸 (Clock Stretching)
有时,从机处理主机发送来的数据需要一定时间。由于时钟信号的产生并不由从机控制,如果在处理完成前主机发送的新数据就到达了,或者从机忙于处理数据没能及时拉低数据线 SDA 来应答 ACK,很容易造成传输的混乱和效率低下。
但是从机也并非束手无策。从机可以通过主动拉低时钟线 SCL 来延伸某个时钟周期低电平的时间,从而完成数据的处理。由于 SCL 具有线与门的特性,只要从机将其拉低,即使主机产生的时钟信号想将其置高,它也仍然是低电平。主机在观测这种情况时,也需要相应地延长自己产生的时钟信号的低电平时间,直至从机完成数据处理释放 SCL。这就是时钟拉伸。
I2C 的时钟拉伸机制示意。
具体实现
I2C 协议在 MCU 上的实现也有两种,硬件 I2C 和软件 I2C。但是由于 I2C 的机制比较复杂,软件实现也会很繁琐且执行效率低下,因此绝大部分情境下 I2C 协议都是通过硬件来实现的。在 STM32G4 系列 MCU 上,配置好 I2C 模块对应的引脚之后,代码中可以直接调用 HAL_I2C_Master_Transmit 和 HAL_I2C_Master_Receive 函数来实现硬件 I2C 向从机传输和接受数据。
UART:去除时钟线,实现异步全双工
I2C 协议的线路数量虽然只有 2 根,但是它只有一根数据线 SDA,只能实现半双工,就像单线铁路:如果有两列不同方向的列车交汇,其中一列就需要停车等待另一列完全通过之后才能通过。有没有办法既保留 I2C 线路少的优势,同时又将其改造成“双线铁路”实现全双工呢?
我们自然而然地把目光投向 I2C 的另一根时钟线 SCL,它的存在意味着 I2C 协议是同步的。如果把它砍了换成另一根数据线,就能重新实现全双工,但传输变成了异步。这就是 UART。
硬件构造
UART 协议只支持两个设备之间的通信,且没有严格的主机从机之分,因为两台设备的构造完全一致。每个设备均有两个端口,
RX用来接收数据,TX用来发送数据。
每台设备的 RX 端口都与另一台设备的 TX 端口相连。这就实现了全双工,两台设备之间可以同时互相传输数据。
UART 协议硬件线路连接示意。
💡 一般来说,UART 应用在电脑主机与 MCU 之间的通信。在这种情况下,电脑主机通常被称为上位机 (host computer)。
数据传输
开始与停止
RX/TX 在空闲时为高电平。当 RX/TX 由高电平变为低电平时,传输开始;当数据传输完毕后,RX/TX 由低电平变为高电平,传输停止。开始和停止的条件在发生后也占一个数据位的时间,称为起始位 (START bit) 和停止位 (STOP bit)。
传输过程
由于没有统一的时钟来指挥,两台设备必须提前约定好很多东西,其中就包括波特率 (baud rate)。波特率是指每秒传输的符号数,单位 baud。在 UART 的语境下,波特率就等于每秒传输的比特位的数量,因为一个比特就由信号中的一个符号携带。UART 协议常用的波特率有 9600、19200 和 115200。
虽然波特率设为一致了,但是接收方看到的仍然只是一个光秃秃的数据信号。如果它一个周期只对这个数据信号采样一次,很容易出现严重的错误,因为没有参照点。UART 协议解决这个问题的方法是起始位同步 (START bit synchronization) 和过采样 (oversampling):接收方在观测到起始位之后才开始接收数据,同时需要以约定好的波特率的若干倍来对数据信号采样。这个倍数一般是 2 的幂,通常采用 8、16 或者 32。
以 16 倍过采样为例,每 16 个采样周期对应一个数据位。接收者的采样流程如下:
- 观测到
RX由高变低,开始采样; - 经过 8 个采样周期后,观察
RX是否仍为低,若是,将其作为起始位的中心点;若不是,则之前观测到的RX由高变低是噪声毛刺,停止采样。 - 每隔 16 个采样周期对
RX进行采样,作为一个新的数据位的中心点; - 在接收数据完毕之后停止采样。
此外,两台设备还需规定起始位和停止位之间传输多少比特位,在整个通信过程中都需要保持一致。我们还可以选择在一个数据传输完毕后添加一个奇偶校验位 (parity bit),表示传输的数据中应该有奇数个还是偶数个高电平,方便接收者检验。
UART 协议硬数据传输示意:16 倍过采样,3 个数据位,无校验位。
当然,就像对好的时钟经过一段时间后会不精确一样,两台设备各自的波特率也可能会有小幅的差异,引起误差累积。不过理论上这个误差的影响不是很大,因为
- 每次数据传输之前都有起始位同步,因而误差累积被限制在一次数据传输之内;
- 通过过采样并且选择合适的采样时间点,我们还保证每次采样都大致在数据信号的中心点,即使有小幅误差也很难读取到错误的电平。
实际应用中,UART 协议可以忍受两台设备之间的波特率误差在 4% 左右。
具体实现
UART 协议一般通过专门的硬件实现,代码中只需要调用对应的底层函数即可。
除提供的底层函数之外,还存在许多开源的 UART 库可供使用,能极大简便调试和开发。例如,SerialAccessor 就是一个通过 UART 实现上位机对 MCU 寄存器直接访问的串口调试框架。有时间的话,我也会详细解析一下这个框架的运行机制,包括 DMA 缓冲区等等。
总结
我们详细讲述了 SPI、I2C,以及 UART 串口通信协议的基本原理。在实际应用中,可根据它们的特点和实际需求灵活使用。
| 协议 | 时序类型 | 传输模式 | 主从机数量 | 端口数量 | 从机选择方式 | 传输速率 |
|---|---|---|---|---|---|---|
| SPI | 同步 | 全双工 | 一主多从 | 至少 3 | 片选线 CS |
很高 |
| I2C | 同步 | 半双工 | 多主多从 | 2 | 从机地址 | 较低 |
| UART | 异步 | 全双工 | 一对一 | 2 | - | 较低 |
参考资料
- Understanding the SPI Bus (SBOA621) - Texas Instruments
- Understanding the I2C Bus (SLVA704) - Texas Instruments
- Basics of I2C: Advanced Topics (TIPL6104) - Texas Instruments
- UART Protocol Overview - Texas Instruments
- IS31FL3731: Audio Modulated Matrix LED Driver - Lumissil Microsystems
- Description of STM32G4 HAL and low-layer drivers (UM2570) - ST Microelectronics