文章

UART 串口:两根线上的时间约定(以 Cortex-M4 为例)

点灯、按键、中断都调通了,你接上串口,想用打印看看程序里到底发生了什么。结果终端上蹦出来的不是 Hello,而是满屏乱码:xÿÿxÿ。你换了数据线、换了终端软件、重启了板子,乱码纹丝不动。更诡异的是:把波特率从 115200 改成 115202(就差 2),乱码了;改回 115200,好了。

问题不在数据,在时间。串口只有两根线–一根发、一根收–没有时钟线。接收方怎么知道每一位从哪开始、到哪结束?全靠一个约定:双方用一样的速度发和收。波特率差一点,每一位的边界就错一点,错到一半,1 就读成了 0。文首那个”差 2 就乱码”的现象,和”没有时钟线怎么对表”是同一个问题。

这篇文章以应用最广的 32 位内核之一 Cortex-M4(以 STM32F4 为例)为背景,分三层把 UART 拆开:先让收发都跑起来,再看那套”没有时钟线的对表术”,最后想明白–为什么两根线、一个时间约定,能撑起从 1960 年代电传机到今天调试口的整整一个甲子。顺带把 《Cortex-M4 与 MCU 开发》 里讲过的三种”等”法(轮询、中断、DMA)最后一种–DMA–在这里收尾。

L1:先让它跑起来–发一句话,再把它原样收回来

你在第一层。目标:用最小代码让发送和接收都跑起来,对串口配置长什么样有个地图感。

硬件准备:一个几块钱的 USB 转 TTL 串口模块,PA9(板子的 TX)接模块的 RX,PA10(板子的 RX)接模块的 TX–注意是交叉的,你发我收才叫对话;再共一根 GND(不共地,两边各说各的,必乱码)。STM32F407 的 USART1 挂在 APB2 上,时钟 84MHz,引脚是 PA9/PA10:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#define RCC_AHB1ENR (*(volatile unsigned int*)0x40023830)
#define RCC_APB2ENR (*(volatile unsigned int*)0x40023844)
#define GPIOA_MODER (*(volatile unsigned int*)0x40020000)
#define GPIOA_AFRL  (*(volatile unsigned int*)0x40020020)
#define GPIOA_AFRH  (*(volatile unsigned int*)0x40020024)
#define USART1_SR   (*(volatile unsigned int*)0x40011000)
#define USART1_DR   (*(volatile unsigned int*)0x40011004)
#define USART1_BRR  (*(volatile unsigned int*)0x40011008)
#define USART1_CR1  (*(volatile unsigned int*)0x4001100C)

void uart_putc(char c)
{
    while (!(USART1_SR & (1u << 7)))   /* 等 TXE:发送寄存器空了没 */
        ;
    USART1_DR = (unsigned char)c;      /* 空了,装进去,硬件自动发 */
}

char uart_getc(void)
{
    while (!(USART1_SR & (1u << 5)))   /* 等 RXNE:收到字节了没 */
        ;
    return (char)USART1_DR;            /* 读走它,硬件自动清标志 */
}

int main(void)
{
    const char *msg = "Hello, UART!\r\n";

    RCC_AHB1ENR |= 1u << 0;            /* 打开 GPIOA 的时钟 */
    RCC_APB2ENR |= 1u << 4;            /* 打开 USART1 的时钟 */

    GPIOA_MODER |= 2u << 18;           /* PA9  -> 复用(10) */
    GPIOA_MODER |= 2u << 20;           /* PA10 -> 复用(10) */
    GPIOA_AFRL  |= 7u << 8;            /* PA9  -> AF7 = USART1(AFRL 管 PA0–PA7) */
    GPIOA_AFRH  |= 7u << 8;            /* PA10 -> AF7 = USART1(PA8–PA15 归 AFRH) */

    USART1_BRR = 84000000 / 115200;    /* = 729,波特率的全部秘密 */
    USART1_CR1 = (1u << 13) | (1u << 3) | (1u << 2);  /* UE | TE | RE */

    while (*msg)
        uart_putc(*msg++);

    while (1)                          /* 回显:收到什么发回什么 */
        uart_putc(uart_getc());
}

烧进去,终端设成 115200-8-N-1(这个黑话下面 L2 解释),就能看到 Hello,敲一个字符回一个字符。先挑几个”看着眼生”的地方钉一下:

  • GPIOA_MODER |= 2u << 18 和 GPIOA_AFRL |= 7u << 8:上一篇 《GPIO:寄存器控制的开关阵》 里说”调串口前记得 MODER=10 + AFR 选对号”,就是这两行–把 PA9 从 GPIO 手里交接给 USART1,AF7 是 USART1 在选线器上的编号(查数据手册的引脚复用表)。”串口为什么死活不出数据”的头号嫌疑,至今仍是这一步没做。
  • USART1_BRR = 84000000 / 115200:波特率寄存器。一个除法就配好了时钟,为什么这么简单就能”约定时间”–L2 专门讲。
  • USART1_CR1 的三个位:UE(整个串口的总开关)、TE(发送使能)、RE(接收使能)。三扇闸,各管一段。
  • TXE / RXNE:状态寄存器 SR 里的两个标志位,”发送寄存器空”“接收寄存器非空”。这两行 while 死等,就是 《Cortex-M4 与 MCU 开发》 里三种”等”法的第一种–轮询。它在这儿够用,但在收数据这件事上不是最优解,L2 末尾会把它升级两次。
  • \r\n:老电传机留下的习惯–\n 是”换到下一行”,\r 是”光标回到行首”,终端两个都要才肯另起一行。只发 \n 结果对不齐行首,是每个人都会踩一次的坑。

到这里你已经能让两根线说话了。下一步几乎是所有人的共同愿望:用 printf。做法是重定向–告诉库”输出一个字符”时走串口:

1
2
3
4
5
6
7
8
#include <stdio.h>

int fputc(int ch, FILE *f)          /* MDK/ARMCLANG 的钩子 */
{
    uart_putc(ch);
    return ch;
}
/* GCC + newlib 则是实现 _write(),同理 */

从此 printf("x=%d\n", x) 直达终端,调试体验起飞。但先泼半盆冷水:115200 波特率下,发一个字节(10 位)要 86.8 微秒,一行 50 字符的日志就是 4 毫秒的死等。在中断里 printf 为什么常常把板子等死–《中断》 开头那个”加一行 printf 板子就死”的故事,串口这一层的账,现在算清了一半。

你已经会用了,但欠着一屁股解释:什么叫 8-N-1?没有时钟线,接收方凭什么能对上每一位?BRR 里那个除法,为什么除出来就是”时间”?

L2:懂原理–没有时钟线的对表术

到这一层,问题变了:不再问”怎么写”,而是问”底下发生了什么”。

一帧的样子:起始、数据、停止

先翻译黑话。115200-8-N-1 的意思是:波特率 115200、8 个数据位、N 无校验位、1 个停止位。串口不按”字节”发,按帧发,一帧的解剖图(从左往右是时间):

段宽度电平作用
空闲任意长高线上没人说话时,持续是 1
起始位1 位低“注意,我要说话了”
数据位8 位(可配 7–9)高=1 低=0低位在前(LSB first)
校验位0 或 1 位–奇偶校验,现代基本不用
停止位1 位(可配 0.5/1/2)高“我说完了”,线路回到空闲

注意两个反直觉的地方。第一,空闲时线上不是”安静”的,而是持续的高电平–串口的”沉默”是有人一直举着 1,不是没人。第二,起始位是低电平:说话的信号不是”出现什么”,而是”打破沉默”–线上从 1 跳到 0 的那一下,就是喊”开始了”。

拿字符 A(ASCII 0x41 = 0100 0001)画一条真实的波形。低字节在前发送,一位接一位是 b0, b1, …, b7:

1
2
3
4
5
空闲   起始  b0   b1   b2   b3   b4   b5   b6   b7   停止   空闲
─────┐     ┌──────────────────────────────┐     ┌────┐──────
     │     │                              │     │    │
     └─────┘                              └─────┘    └
 1    0     1    0    0    0    0    0    1    0    1      1

每一位的宽度不是”某个电平”,而是时间:115200 波特率下每位 8.68 微秒。接收方要做的,就是在正确的时刻去量线上的电平。这就引出了真正的问题:它怎么知道”正确的时刻”是哪一刻?

对表术:起始沿 + 16 倍过采样 + 中点采样

答案是三招组合。

第一招:起始沿重同步。 接收方平时悠闲地以 16 倍波特率(约每 0.54 微秒一次)盯着线看。看到线上从高跳到低,它立刻精神了:”起始位开始了,从现在起对表。”注意这个设计的妙处:每一帧都重新对一次表,所以两边的时钟只需要在一帧(约 10 位)之内不漂太多就行,跑上几个小时也不会累积误差。

第二招:中点采样。 对表之后,接收方数 8 个采样周期(半个位宽的 8/16),来到起始位的正中间,确认它还是低;然后每数 16 个采样周期采一次,正好落在每个数据位的正中间。为什么要采中点不采边沿?因为边沿处电平正在翻转、噪声最大;中点是这个位”最稳定”的时刻。好比隔着火车车窗看窗外风景,别在两节车厢交接处看,找窗户正中间看。

1
2
3
4
采样序号  1  2  3  4  5  6  7  8  9  10 ... 16 | 17 ... 32 | ...
                          ●  ●  ●              |     ●      |
起始沿后第 7、8、9 个采样点三选二投票 → 这一位的值;
之后每 16 个采样点量一次 → 逐位取到 b0…b7 → 停止位必须是 1,否则"帧错误"(FE 置位)

第三招:三选二投票。 第 7、8、9 三个采样点读到的值,举手表决,两个一样算数。单个采样点被毛刺打歪一下,投票能纠回来。这是最朴素也是最便宜的容错。

三招合起来,”没有时钟线”的恐惧就解除了:每一帧用起始沿对一次表,每一位取中点、多数表决。代价是双方的时钟得足够准–一帧 10 位,漂移超过半个位宽就错位,所以两边波特率的总偏差要压在百分之几以内。这顺手解释了一串老现象:为什么波特率配错必乱码(对表对了,节奏错了);为什么”串口乱码先查时钟树”(用了内部 RC 振荡器(±1% 左右)而没开 PLL 校准,就贴着出错的边缘);为什么文首差 2 的 115202 能用而有些数不能(L2 下一节)。

BRR:一个除法,怎么变成”时间”

USART 内部把 84MHz 的系统时钟分频成波特率的 16 倍(那 16 个采样点就是它),所以:

1
USART1_BRR = 84000000 / 115200;   /* = 729(0x2D9) */

BRR 里存的就是分频比。729 不是整数分频比的巧合版–它实际表示”分频 45 又 9/16”(BRR 高 12 位是整数部分、低 4 位是小数部分,729 = 45×16+9),硬件按 1/16 的粒度凑出最接近的波特率。84MHz ÷ (45+9/16) 再 ÷ 16 = 115226,偏差 0.02%,远在容差内。

但不是每个波特率都这么幸运。84MHz 配 9600:84000000/9600 = 8750,整除,零误差;配 115200:除不尽,凑 729,差 0.02%;要是换个时钟频率(比如 72MHz 的 F1 系列)配 115200:72e6/115200 = 625,也整除。同一个波特率,换颗芯片可能精确可能近似,全看时钟和它配不配–这就是老工程师换板子先查 BRR 能不能整除的原因,也是动手环节要你亲手算的东西。

收数据的三种等法:轮询 → 中断 → DMA + 空闲

L1 的 uart_getc 用轮询收。能用,但按 《Cortex-M4 与 MCU 开发》 那张”等法”表的逻辑升级它一遍,正好把三种等法在同一个外设上走完全程:

升级一:中断。 使能 RXNE 中断(CR1 的 bit 5),每收到一个字节,硬件敲一次门,ISR 里读 DR。CPU 不再守着,115200 波特率下每秒约 1.1 万次中断,168MHz 的 Cortex-M4 扛得住,但每次进出中断十几个周期的开销,全是”为了搬一个字节”而花的。

升级二:DMA + 空闲中断。 让 DMA 这个专职搬运工接管:开一个足够大的缓冲区,DMA 设成循环模式,硬件每收到一个字节自动搬进缓冲区,CPU 全程不知情。可新问题来了–串口数据是”一帧一帧”不定长的(一条命令 20 字节、下一条 35 字节),DMA 只会埋头搬,怎么知道一条消息到头了?

答案在线路本身:一帧数据发完,线路回到空闲(持续高电平)。USART 有个专门的标志:IDLE 位–线路连续空闲超过一个字节的时间,它置 1(还可配成触发中断)。于是经典配方成型:

1
2
3
4
5
6
7
8
void USART1_IRQHandler(void)
{
    if (USART1_SR & (1u << 4)) {              /* IDLE:线路空闲了 */
        USART1_SR; USART1_DR;                /* 读 SR 再读 DR,清 IDLE 标志 */
        len = BUF_SIZE - DMA2_S5_NDTR;        /* USART1_RX 挂在 DMA2 的 Stream5;缓冲区大小 - 剩余计数 = 已收字节数 */
        /* 把 buf 里前 len 个字节交给上层,DMA 继续在后台搬 */
    }
}

一个字节的中断都不用 CPU 管,只在”一条消息说完了”时被打断一次。串口收一张图、收一包传感器数据,CPU 可以全程在算别的东西–这就是第一篇那张表里”大块数据搬运 → DMA”的落地现场。三种等法,至此在同一个外设上见齐了:轮询(最简单,CPU 全陪)、中断(字节粒度,CPU 陪得起)、DMA + 空闲(消息粒度,CPU 只看结果)。

到这里你已经懂原理了。但有个更根本的问题一直压着:两根线、一个时间约定–这么”寒酸”的方案,凭什么用了六十年还没被淘汰?

L3:想得透–为什么时间可以代替时钟

到这一层,问题变成:这些设计是理所当然的吗?换掉行不行?

本质:UART 到底是什么

去掉所有术语,UART 是这样一件事:

在没有共享时钟的两点之间,用”约定相同的比特时长”代替时钟线:发方按节拍摆电平,收方在每个起始位上重新对表、在每个位的正中间读数。时间本身,就是协议的一部分。

给同事的一句话:串口的全部可靠性都建立在”两边一样快”这个假设上–波特率就是速度契约,起始位是每帧一次的重新握手,停止位是下帧开始前的喘息。所以串口的故障几乎从不是”数据错了”,而是”时间错了”:乱码、丢字节、帧错误,九成往时钟树上查,比查任何逻辑都快。

三个”被否决的方案”

本质立住了,还得追问一层:为什么是这个方案,而不是显然的替代方案?

被否决的方案一:多加一根时钟线,做成同步传输。 收发双方共享时钟,根本不用对表,看起来彻底消灭了乱码。这条路确实存在–SPI 就是这么干的,而且确实快。但它败在 UART 的出生地上:UART 诞生于电话线和调制解调器时代,信号要穿过几公里的电话线才能到对端–数据可以慢悠悠地变形还能被救回来,时钟沿一旦在长线上变形,同步就全盘崩溃;而且那个年代每根线、每个连接器引脚都是真金白银。“把时钟藏进时间约定里”是花带宽和容差,换接线和距离–近处要快用 SPI,远处要稳用 UART,两条路各自赢了自己的那半个世纪。

被否决的方案二:砍掉起始位和停止位,帧头直接上是数据。 每帧 10 位里 2 位是”开销”,白扔 20% 的带宽,看起来很浪费。失败场景立刻就来:没有起始位,接收方不知道帧从哪开始–尤其是线上发连续的 0x00(数据位全 0)时,线路持续低电平,和”还没开始说话”在电气上无法区分;没有停止位,接收方采完最后一位立刻就要采下一帧的起始位,两边时钟的误差没有窗口恢复,一帧漂一点、帧帧往下传。起始位低、停止位高、空闲也是高,这套安排让”开始”和”结束”永远不可能被误认,还给了每帧一次的重同步点。那 20% 不是浪费,是买”自同步”的定金。

被否决的方案三:波特率焊死一种,出厂定死。 省掉 BRR、省掉配置、永远不会有配错波特率这回事。失败场景是整个世界的参差:1960 年代的电传机 110 波特,调制解调器时代 300、1200、9600,今天调试口 115200 起步、嵌入式板间通信奔着 3M 去–一个焊死的值,要么当年太超前,要么今天太落后。STM32 用一个 BRR 寄存器加一段小数分频逻辑,让同一颗芯片伺候所有时代的对端。用配置的复杂度,换跨越六十年的兼容性,这笔账怎么算都值。

一段顺手的历史

“波特率”(baud)这个名字,来自法国电报工程师 Émile Baudot–他 1870 年代设计的博多码(Baudot code)是电传机的字母表,五位一字符,UART 帧里”数据位”的概念就是从它一路传下来的。严格说 baud 是”每秒码元数”,UART 一个码元恰好一位,所以 baud 和 bit/s 数值相等;但在更复杂的调制(比如电话调制解调器)里一个码元驮好几位,两者就分家了–面试爱考,顺手记住。

UART 芯片化的祖师爷是 1970 年代的 8251/16550(IBM PC 上的串口卡),”串口乱码”从那时起就是程序员的老朋友。RS-232 电平(±12V)统治了机房的串口线三十年,今天开发板上的 3.3V TTL 串口是它的平民版。而 USB 时代它非但没死,反而成了嵌入式的第一调试口–上到卫星、下到几块钱的 WiFi 模块,几乎都留着一根 UART。原因本文已经回答了:两根线、一个时间约定,没有比这更便宜的对话方式。

一个反直觉收尾

最后戳破一个直觉:串口线上”什么都没发”的时候,不是没有信号,而是最强的信号–持续的高电平,一个明确的、双方公认的”我在听”。真正传递信息的动作反而是”打破它”:跳到低电平的那一下,才是一切的开始。更有甚者,如果发方强行把线拉低超过一整帧(起始位 + 数据位全 0 + 停止位也是 0),这在协议上不是数据,而是一个专门的信号–Break,从电传机时代传下来的”注意!”,至今仍在用来唤醒 bootloader、复位某些老设备。一条线上,沉默是约定,呐喊是协议,连”超长的低电平”都被赋予了含义–好的协议不浪费任何一种电气状态。

动手环节:在纸面上亲手算”时间”

这次的实验不需要板子也不需要浏览器,一张纸就够,算的正是本文的灵魂–时间。

实验一:画波形。 发送字符 A(0x41)的波形上面已经画过。现在先合上上文,自己画:H(0x48 = 0100 1000)在 115200 波特率下的波形长什么样?从空闲画到空闲,标出每一段是哪一位。

答案:H = 0x48,低位在前逐位是 b0=0, b1=0, b2=0, b3=1, b4=0, b5=0, b6=1, b7=0。波形:空闲高 → 起始低 → 低低低(b0–b2)→ 高(b3)→ 低低(b4–b5)→ 高(b6)→ 低(b7)→ 停止高。画对了,说明”帧”已经长在你脑子里了。顺手加餐:U(0x55)的波形是 0101 完美交替,每一 bit 都翻转–所以老工程师调试新链路爱先发一串 U,波形规整得一眼就能在示波器上认出来,还能顺手量出实际波特率。

实验二:BRR 整除题。 84MHz 的 USART1 要配 115200 和 9600 两种波特率。先预测:哪个能整除、哪个有误差?

答案:84000000/9600 = 8750,整除,零误差;84000000/115200 = 729.17,只能凑 729,实际 115226,+0.02%。反直觉的地方在于”常用的高速档反而不精确,低速档反而精确”——精度好不好,取决于时钟频率和波特率配不配,跟快慢无关。这也是真金白银的选择:老电路板上常见一颗 11.0592MHz 的”怪”晶体,就是专门为串口挑的–11059200/9600 = 1152、/115200 = 96,全整除,颗颗零误差。看,这题的正经做法是算,不是背——你刚才已经做了一遍。

实验三:误差预算。 两边波特率总偏差 3%,一帧 10 位,会漂多少个位宽?漂到多少必乱码?内部 RC 振荡器(±1%)对晶体(±0.005%)的链路能用吗?

答案:10 位 × 3% = 0.3 个位宽。采样点在位的正中,左右各能容忍半个位宽,所以 5% 是生死线(0.5 位);0.3 位虽在界内但已吃掉六成余量,工程上通常要求总偏差 < 2%。RC(±1%)对晶体:总偏差约 1%,漂 0.1 位,能用但不富裕;RC 对 RC,最坏 2%,漂 0.2 位,贴着工程红线–能用,别再叠加任何其它误差源。这就是”正式产品上晶振、省钱方案上 RC、串口出问题先查时钟树”的全部数学。

三个自测问题,能答上说明这三层你真爬过了:

  1. 没有时钟线,接收方靠什么知道每一位的边界?(答案在 L2:起始位下降沿每帧重对一次表,16 倍过采样、位中点采样、三选二投票。)
  2. 为什么空闲是高电平、起始位必须是低?(答案在 L2/L3:沉默 = 持续举着 1;”开始”用打破沉默表示。若起始也是高,连续 0x00 的数据线和空闲无法区分。)
  3. 串口收不定长数据,为什么单字节中断不理想,DMA+空闲中断的帧边界从哪来?(答案在 L2:每秒上万次中断只为搬一个字节;帧边界 = 线路回空闲超过一个字节时间,IDLE 标志给出。)

收尾地图

把整篇文章收进一张速查表:

你想干什么怎么做一句话理由
打调试日志printf 重定向 + 轮询 TXE最简单;记住每字节 87µs 的阻塞
偶尔收几个字节轮询或 RXNE 中断数据稀疏,不值得更大阵仗
收不定长消息 / 大块数据DMA 循环缓冲 + IDLE 中断CPU 零参与,帧边界由”线路空闲”给出
乱码先查波特率,再查时钟树(RC 还是晶振)异步协议,时间错了数据必错
串口完全不出数据查 MODER=复用 + AFR 选 AF7GPIO 篇的选线器,交接没做
ISR 里要打印写进缓冲,回头再打中断篇的老规矩,账在本文 L1 算过
选波特率/选晶体动手算 BRR 能否整除整除零误差,凑数有偏差,别背要算

下一站,两条路任选。往定时器走:GPIO 篇里按键消抖欠的账、PWM 呼吸灯,都在那里,串口 printf 正好当你的示波器。往总线走:I2C 和 SPI–它们和 UART 的分野,正是 L3 里”要不要时钟线”那个被否决的方案一:加一根时钟线就是 SPI;时钟线留着、再给每台设备发个地址、全用开漏共享两根线,就是 I2C。你已经提前读懂了它们的出身。等三条路都走熟,回头看 《FreeRTOS 核心原理》 里那句”任务 = 永不返回的函数”,再想想 DMA+空闲中断的接收循环–你会发现嵌入式的一切优化,都是在回答同一个问题:CPU 的每一拍时间,该花在哪。

本文由作者按照 CC BY 4.0 进行授权