Skip to content

第32篇:UART 协议详解 —— 没有时钟线怎么同步

承接上一篇:我们知道了 UART 是什么、为什么学它、硬件怎么接。这一篇我们拆开协议本身,搞清楚"没有时钟线"到底意味着什么。


异步通信的核心挑战

如果你之前接触过 SPI 或 I2C,你会记得它们都有一根专门的时钟线。SPI 有 SCK,I2C 有 SCL。时钟线的作用很明确:发送方在时钟信号的一个边沿放数据,接收方在另一个边沿读数据。时钟就像乐队指挥的指挥棒——每一拍都有明确的时刻,所有人知道什么时候该做什么。

UART 没有指挥棒。TX 和 RX 两根线各自独立——TX 线上只有发送方发出的信号,RX 线上只有接收方收到的信号。没有共享的时钟跳变来告诉接收方"现在是一个新 bit 的开始"。那接收方怎么知道一个数据帧从哪开始、到哪结束、中间每一个 bit 的边界在哪里?

答案是:双方在通信开始之前就约定好一个速率,然后各自用自己的时钟按照这个速率来"数拍子"。这个约定的速率就是波特率(Baud Rate)。比如双方约定 115200 baud,意思是每秒传输 115200 个 bit,那么每一个 bit 的持续时间就是 1/115200 ≈ 8.68 微秒。发送方每隔 8.68 微秒在 TX 线上放一个新 bit,接收方每隔 8.68 微秒从 RX 线上采样一个 bit。如果双方的时钟足够精确,整个过程就能对齐。

这就是"异步"的含义——没有共享时钟,但通过预先约定的速率 + 各自的本地时钟来达到同步的效果。听起来不太靠谱?实际上效果非常好,原因在于接收方使用了一种叫"过采样"的技术来对齐采样时刻。


数据帧解剖

一个完整的 UART 数据帧由以下几个部分组成,我们从头到尾拆开来看:

空闲状态(Idle)

不传输数据时,TX 线保持高电平。这是 UART 的默认状态——线上没人说话,就是高电平。这一点很重要,因为它让接收方能够区分"没人说话"和"正在传输数据中的某个状态"。

起始位(Start Bit)

当发送方准备发送一个字节时,它首先把 TX 线拉低一个 bit 的时间。这个从高到低的跳变就是起始位。起始位是整个帧的锚点——接收方检测到这个下降沿,就知道"有数据来了",并以此为基准点开始采样后续的 bit。

起始位为什么固定是低电平?因为空闲状态是高电平。从高到低的跳变是一个明确的信号变化,接收方不可能和空闲状态混淆。如果空闲状态也是低电平,那起始位的低电平就和空闲状态无法区分了。

数据位(Data Bits)

起始位之后是真正的数据。UART 支持 7、8 或 9 个数据位,其中 8 位是最常见的配置(这也是为什么我们常说 UART 传输"一个字节")。数据从最低有效位(LSB)开始发送——先发 bit0,再发 bit1,依此类推。这意味着如果你发送值 0x41(字母 'A',二进制 01000001),线上实际出现的顺序是 1-0-0-0-0-0-1-0

8 位数据位能覆盖标准 ASCII 字符集(0-127)和扩展 ASCII(128-255)。9 位数据位通常用于多机通信协议中的地址/数据标记——第 9 位用来区分当前帧是地址还是数据。

校验位(Parity Bit)—— 可选

数据位之后可以选择性加一个校验位。校验位的作用是让整个帧(数据位 + 校验位)中"1"的个数满足一定的奇偶性要求。有三种选择:无校验(None,最常用)、偶校验(Even,1 的总数为偶数)、奇校验(Odd,1 的总数为奇数)。无校验时这一位直接省略,这也是绝大多数嵌入式项目中使用的配置。

校验位能检测一位错误,但代价是多传一个 bit,而且在噪声大的环境中单 bit 校验的检出率不够高。实际项目中要么不用校验(靠上层协议做 CRC),要么用 9 位数据位做特殊用途。

停止位(Stop Bit)

帧的最后是停止位,固定为高电平,持续 1 个或 2 个 bit 的时间(有些设备支持 1.5 个 bit)。停止位把 TX 线拉回到高电平——也就是空闲状态。它的作用有两个:一是让接收方确认"这个帧结束了",二是保证下一个帧的起始位能产生一个干净的高到低跳变(因为停止位把线拉回了高电平)。

一个完整的 8N1 帧(8 数据位、无校验、1 停止位)在时序上看起来是这样的:

text
空闲  起始位  D0  D1  D2  D3  D4  D5  D6  D7  停止位  空闲
HIGH   LOW   x   x   x   x   x   x   x   x   HIGH   HIGH
       |<--- 10 bits 总共 (1+8+1) --->|

发送一个字节需要传输 10 个 bit(1 起始位 + 8 数据位 + 1 停止位)。在 115200 baud 下,一个帧的传输时间是 10/115200 ≈ 86.8 微秒,有效数据速率是 11520 字节/秒。


波特率与过采样

波特率(Baud Rate)的定义是每秒传输的符号数。在 UART 中,一个符号就是一个 bit,所以波特率等于比特率。常见的波特率有 9600、19200、38400、57600、115200、230400、460800、921600。其中 115200 是嵌入式项目中最常见的默认值——速度快到足以满足大多数调试和通信需求,同时对时钟精度要求又不算太苛刻。

你可能好奇这些数字为什么不是整十或整百——9600、115200,而不是 10000、100000。原因在于这些数字和早期电信系统的时钟分频有关。9600 = 9600,115200 = 9600 x 12。历史上时钟源通常是 1.8432 MHz 或其倍数,除以相应的整数就能得到这些波特率。

过采样:接收方如何找到 bit 的中心

前面说过,接收方按照约定的波特率每隔一个 bit 时间采样一次。但问题是:接收方的时钟和发送方的时钟不可能完全一致。如果有轻微的偏差(比如发送方实际速率是 115201 baud,接收方是 115199 baud),随着 bit 数的增加,采样点会逐渐偏离中心,最终导致采样到错误的值。

解决方案是过采样。STM32 的 USART 接收器不是在每一个 bit 时间只采样一次,而是采样 16 次(16x 过采样)或 8 次(8x 过采样)。16x 过采样是默认模式,也是我们代码中使用的模式。

具体过程是这样的:接收方检测到起始位的下降沿后,以波特率的 16 倍频率采样。对于 115200 baud,采样频率是 115200 x 16 = 1,843,200 Hz。接收方在起始位的中间位置(第 8 个采样点)确认起始位有效,然后每隔 16 个采样点读取一次数据——也就是在每个 bit 的中心位置采样。即使两个设备的时钟有微小偏差,只要偏差在 2-3% 以内,16 个采样点的累积偏移不足以让采样点滑出当前 bit 的范围,通信依然可靠。

这就是为什么我们在 uart_config.hpp 中看到的过采样配置固定为 UART_OVERSAMPLING_16

cpp
// 来源: code/stm32f1-tutorials/3_uart_logger/device/uart/uart_driver.hpp
huart_.Init.OverSampling = UART_OVERSAMPLING_16;

波特率误差:为什么有时候"明明配置对了"还是乱码

理想情况下,接收方和发送方的波特率完全一致。但现实中,波特率是由系统时钟分频得到的,而分频只能取整数值。如果你的系统时钟是 64 MHz(我们代码中的配置),想产生 115200 baud:

text
BRR = 64,000,000 / 115200 = 555.555...

取整后 BRR = 556,实际波特率 = 64,000,000 / 556 = 115107.9,误差 = (115200 - 115107.9) / 115200 = 0.08%。这个误差在 UART 容忍范围(通常 2-3%)之内,通信没问题。

但如果你的波特率设得更高,比如 921600:

text
BRR = 64,000,000 / 921600 = 69.444...

取整后 BRR = 69,实际波特率 = 64,000,000 / 69 = 927536.2,误差 = (927536.2 - 921600) / 921600 = 0.64%。还是在容忍范围内,但已经比 115200 大了一个数量级。

我们的代码中有一个 consteval 函数在编译时检查这个误差,确保不超过千分之三十(3%):

cpp
// 来源: code/stm32f1-tutorials/3_uart_logger/device/uart/uart_config.hpp
template <uint32_t APBClockHz, uint32_t BaudRate>
consteval bool is_baud_rate_valid() {
    uint32_t brr    = (APBClockHz + BaudRate / 2) / BaudRate;
    uint32_t actual = APBClockHz / brr;
    uint32_t error_permille =
        (actual > BaudRate) ? (actual - BaudRate) * 1000 / BaudRate
                            : (BaudRate - actual) * 1000 / BaudRate;
    return error_permille < 30;
}

这个函数在第 40 篇讲 C++ 模板驱动时会详细拆解。现在你只需要知道:编译器会在编译阶段替你检查"这个波特率在你的时钟频率下误差是否可接受"。如果不可接受,编译直接报错——而不是等你烧录到板子上之后才发现收到的全是乱码。


流控制

UART 还有一个可选机制叫流控制(Flow Control),用于防止接收方来不及处理数据而导致数据丢失。有两种方式:

硬件流控制使用 RTS(Request To Send)和 CTS(Clear To Send)两根额外的信号线。当接收方的缓冲区快满时,它拉高 RTS 信号告诉发送方"暂停发送";当缓冲区有空间了,再拉低 RTS 恢复发送。CTS 是反方向的——发送方检查 CTS 信号来决定是否继续发送。

软件流控制使用特殊的控制字符 XON(0x11)和 XOFF(0x13)来代替硬件信号线。接收方发 XOFF 让对方暂停,发 XON 让对方恢复。优点是不需要额外的线,缺点是这些控制字符不能出现在正常数据中。

我们的代码配置为无流控制(HwFlowControl::None),这是最简单的配置。对于 115200 baud 的调试通信,数据量通常不大,不需要流控制。在高速通信或数据量大的场景(比如通过 UART 传输固件镜像),你可能需要考虑启用硬件流控制。

cpp
// 来源: code/stm32f1-tutorials/3_uart_logger/device/uart/uart_config.hpp
enum class HwFlowControl : uint32_t {
    None   = UART_HWCONTROL_NONE,
    Rts    = UART_HWCONTROL_RTS,
    Cts    = UART_HWCONTROL_CTS,
    RtsCts = UART_HWCONTROL_RTS_CTS,
};

信号电平:TTL vs RS-232

最后一个需要搞清楚的概念是信号电平。

STM32F103 的 USART 引脚输出的是 TTL 电平:逻辑高 = 3.3V(接近 VDD),逻辑低 = 0V(接近 GND)。这个电平范围正好适合芯片间通信或通过 USB-TTL 适配器连接到 PC。

但 UART 历史上使用的是 RS-232 电平:逻辑高 = -3V 到 -15V,逻辑低 = +3V 到 +15V。RS-232 的电压范围远高于 TTL,所以传输距离更远、抗干扰能力更强。如果你的项目需要连接老式设备的 RS-232 串口(比如工控机或旧仪器),就需要在 STM32 和 RS-232 之间加一个电平转换芯片(比如 MAX232)。

我们使用的是 USB-TTL 适配器——适配器一端是 USB(连 PC),另一端是 TTL 电平的 TX/RX/GND(连 Blue Pill)。两边都是 TTL 电平,直接用杜邦线连就行,不需要电平转换。

接线方式再说一次,因为这个真的太容易搞反了:

text
USB-TTL 适配器      Blue Pill
  TX ────────────── PA10 (USART1 RX)
  RX ────────────── PA9  (USART1 TX)
  GND ───────────── GND

适配器的 TX 连到 Blue Pill 的 RX,适配器的 RX 连到 Blue Pill 的 TX。"我发你收,你发我收"——记住这个交叉关系,你就能避免 UART 调试中排名第一的接线错误。


小结

这一篇我们拆解了 UART 协议的完整机制:没有共享时钟,靠预约定波特率 + 过采样来同步;数据帧由起始位、数据位、可选校验位和停止位组成;波特率误差需要控制在 3% 以内;TTL 电平直接连 USB-TTL 适配器就能和 PC 通信。

下一篇我们转向硬件:STM32F103 上的 USART 外设具体长什么样、有哪些寄存器、时钟和 GPIO 复用功能怎么配置。搞清楚这些,下一篇我们就能动手写代码了。

基于 VitePress 构建