5.1 UART 串口:寄存器级收发 前四章的程序都"哑巴":跑没跑、跑到哪,全靠猜。这一节给芯片装上嘴和耳朵——UART 串口。它是嵌入式工程师用得最频繁的接口,没有之一:调试打印靠它、对接上位机靠它、很多模组(蓝牙、GPS、4G)的指令通道也是它。本节从帧格式讲到波特率寄存器的手算,再到轮询收发的完整寄存器代码,最后把 printf 重定向接上——从这一节起,你的程序正式学会说话。 帧格式:没有时钟线,靠约定同步 UART 是异步通信:收发两根线(TX 出、RX 进),没有共享时钟,同步全靠双方事先约定的波特率(每秒的位数)。
前四章的程序都"哑巴":跑没跑、跑到哪,全靠猜。这一节给芯片装上嘴和耳朵——UART 串口。它是嵌入式工程师用得最频繁的接口,没有之一:调试打印靠它、对接上位机靠它、很多模组(蓝牙、GPS、4G)的指令通道也是它。本节从帧格式讲到波特率寄存器的手算,再到轮询收发的完整寄存器代码,最后把 printf 重定向接上——从这一节起,你的程序正式学会说话。
UART 是异步通信:收发两根线(TX 出、RX 进),没有共享时钟,同步全靠双方事先约定的波特率(每秒的位数)。数据按帧打包,一帧的标准结构:
空闲态高电平 | 起始位(0) | D0 D1 D2 D3 D4 D5 D6 D7 | 校验位(可选) | 停止位(1,1.5或2位) 例:发送 0x55(二进制 01010101),9600 波特,无校验,1 位停止位: 线序(按时间从左到右):起始0 → 1,0,1,0,1,0,1,0 → 停止1 每 bit 时长 = 1/9600 ≈ 104.2 微秒,一帧共 10 bit,耗时约 1.04 毫秒
接收方的策略很聪明:检测到起始位的下降沿后,不等一位结束而是在第 7、8、9 个采样点(位宽的正中间偏后)连续采三次、取多数,既对齐了位中心,又免疫边沿毛刺。这就是为什么波特率偏差不能太大——两边各差 2%,一帧 10 位累计偏移 20%,采样点就滑进邻位了。工程经验:两端时钟偏差控制在 3% 以内,所以 2.3 节说过 HSI 内部 RC 振荡器(±1%)能凑合跑串口但不敢保证长帧稳定,正经产品一律用晶振。

USART 的波特率发生器就是一个专用分频器。STM32F103 的 USART1 挂在 APB2(72 MHz),USART2/3 挂在 APB1(36 MHz,2.3 节的"双倍规则"不适用于 USART,它不翻倍)。波特率与 BRR 寄存器的关系:
波特率 = f_clk / (16 × USARTDIV) USARTDIV = f_clk / (16 × 波特率),BRR 寄存器 = USARTDIV × 4(小数部分存低 4 位) 例:USART1 @72MHz 求 115200: USARTDIV = 72 MHz / (16 × 115200) = 39.0625 BRR = 39.0625 × 16 = 625 = 0x271 → 整数部分 39,小数 0.0625×16=1 → BRR = 0x271 实际波特率 = 72M / (16 × 39.0625) = 115200,误差 0%; 若求 9600:USARTDIV = 468.75,BRR = 0x1D7C,误差 0.02%,安全。
误差核算永远是必要动作:算完实际波特率,与目标差超过 3% 就换主频或换波特率。串口乱码的头号嫌疑犯就是波特率误差,其次是两端共地——排障先查这两样,再怀疑别的。
USART 的数据通路很简单:CPU 把字节写进数据寄存器 DR,硬件按帧移位发出;对端发来的帧被移位收进移位寄存器,收满一帧自动落到 DR。CPU 怎么知道"能不能写、有没有货"?看状态寄存器 SR 的两个标志位:
| 标志 | 含义 | 等它做什么 |
|---|---|---|
| TXE | 发送寄存器空(数据已转入移位器) | 写 DR 前等它,可以塞下一个字节 |
| TC | 整帧发送完成(含停止位移出) | 关串口/进低功耗前等它,确保线安静 |
| RXNE | 收到一个字节,DR 有货 | 读 DR 前等它 |
#include "stm32f10x.h" void usart1_init_115200(void) { RCC->APB2ENR |= RCC_APB2ENR_USART1EN | RCC_APB2ENR_IOPAEN; /* PA9=TX 复用推挽,PA10=RX 浮空输入 */ GPIOA->CRH &= ~(0xFFUL << 4); GPIOA->CRH |= (0xBUL << 4) /* PA9: CNF=10 MODE=11 复用推挽 */ | (0x4UL << 8); /* PA10: CNF=01 MODE=00 浮空输入 */ USART1->BRR = 0x271; /* 72 MHz 下 115200,见上文手算 */ USART1->CR1 |= USART_CR1_TE | USART_CR1_RE | USART_CR1_UE; /* 收发使能 + USART 开 */ } void uart_putc(char c) { while (!(USART1->SR & USART_SR_TXE)) { } /* 等发送寄存器空 */ USART1->DR = (uint8_t)c; } void uart_puts(const char *s) { while (*s) uart_putc(*s++); } char uart_getc(void) /* 阻塞读:仅演示,工程上改中断收 */ { while (!(USART1->SR & USART_SR_RXNE)) { } return (char)USART1->DR; } /* 经典回显:收到什么回什么——串口链路通了的第一声问候 */ int main(void) { usart1_init_115200(); uart_puts("uart link ok\r\n"); /* \r\n 是串口终端的通用换行 */ for (;;) { char c = uart_getc(); uart_putc(c); } }
等待标志的循环里不需要 volatile 修饰——SR 定义在 CMSIS 头文件里时已带 volatile。轮询版接收的短板也摆明着:字节间隔稍密就溢出丢字(RXNE 没及时读走,又来一帧,溢出标志 ORE 置位)。工程解法两条路:开 RXNE 中断逐字节收,或开 IDLE 空闲中断成包收——把 4.1 的中断套路搬过来即可,ISR 里同样只做"读 DR、存缓冲、清标志"三件事。
把标准库的 printf 接到串口,调试效率立刻上一个台阶——格式化输出变量、十进制十六进制随便看。Keil 的 MicroLIB 与 GCC 的 newlib 都通过一个底层函数重定向:
/* GCC + newlib:重写字符输出桩,printf 全家就都走串口了 */ #include <stdio.h> int __io_putchar(int ch) /* Keil MicroLIB 用 int fputc(int ch, FILE *f) */ { uart_putc((char)ch); return ch; } /* 用法:调试信息随手就来 */ // printf("duty=%d, ticks=%lu\r\n", (int)TIM3->CCR1, g_ticks_10ms);
两个注意点:其一,printf 带着几 KB 的格式化代码,Flash 紧张的工程换成精简的自写打印函数;其二,ISR 里别 printf(4.1 的纪律),要打印就置标志回主循环打。printf 的两个嵌入式特有注意点值得补记。浮点格式化(%f)会把 newlib 的体积再抬高一截,Flash 紧张时用"定点数打印"替代——把 3.14 存成 314、打印时手动补小数点,是资源受限场景的经典手法。另外,中断上下文调用 printf 会踩 newlib 内部锁(reent 结构),轻则死等重则死锁,第 6 章的队列方案(中断入队、任务打印)才是正解——这里先把习惯立住:printf 只出现在任务与主循环里。
至此"改代码 → 烧录 → 打印验证"的循环完全打通,这个循环的顺畅程度直接决定后面所有开发的速度。下一节把镜头转向芯片之外的器件:I2C 总线上的传感器,正等着把你测到的数据通过串口报告出去。