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、串口出问题先查时钟树”的全部数学。
三个自测问题,能答上说明这三层你真爬过了:
- 没有时钟线,接收方靠什么知道每一位的边界?(答案在 L2:起始位下降沿每帧重对一次表,16 倍过采样、位中点采样、三选二投票。)
- 为什么空闲是高电平、起始位必须是低?(答案在 L2/L3:沉默 = 持续举着 1;”开始”用打破沉默表示。若起始也是高,连续 0x00 的数据线和空闲无法区分。)
- 串口收不定长数据,为什么单字节中断不理想,DMA+空闲中断的帧边界从哪来?(答案在 L2:每秒上万次中断只为搬一个字节;帧边界 = 线路回空闲超过一个字节时间,IDLE 标志给出。)
收尾地图
把整篇文章收进一张速查表:
| 你想干什么 | 怎么做 | 一句话理由 |
|---|---|---|
| 打调试日志 | printf 重定向 + 轮询 TXE | 最简单;记住每字节 87µs 的阻塞 |
| 偶尔收几个字节 | 轮询或 RXNE 中断 | 数据稀疏,不值得更大阵仗 |
| 收不定长消息 / 大块数据 | DMA 循环缓冲 + IDLE 中断 | CPU 零参与,帧边界由”线路空闲”给出 |
| 乱码 | 先查波特率,再查时钟树(RC 还是晶振) | 异步协议,时间错了数据必错 |
| 串口完全不出数据 | 查 MODER=复用 + AFR 选 AF7 | GPIO 篇的选线器,交接没做 |
| ISR 里要打印 | 写进缓冲,回头再打 | 中断篇的老规矩,账在本文 L1 算过 |
| 选波特率/选晶体 | 动手算 BRR 能否整除 | 整除零误差,凑数有偏差,别背要算 |
下一站,两条路任选。往定时器走:GPIO 篇里按键消抖欠的账、PWM 呼吸灯,都在那里,串口 printf 正好当你的示波器。往总线走:I2C 和 SPI–它们和 UART 的分野,正是 L3 里”要不要时钟线”那个被否决的方案一:加一根时钟线就是 SPI;时钟线留着、再给每台设备发个地址、全用开漏共享两根线,就是 I2C。你已经提前读懂了它们的出身。等三条路都走熟,回头看 《FreeRTOS 核心原理》 里那句”任务 = 永不返回的函数”,再想想 DMA+空闲中断的接收循环–你会发现嵌入式的一切优化,都是在回答同一个问题:CPU 的每一拍时间,该花在哪。