4. UART串口通信:从协议到实战
UART串口,做嵌入式开发的人没有不跟它打交道的。调试信息、传感器数据、固件升级……它就像嵌入式系统的“嗓子眼”。这一章,我们就把UART从协议到代码,彻底讲透。
本章核心脉络:协议原理 → 波特率计算 → 轮询收发 → 中断收发 → printf重定向。每一步我都会结合项目中的实际坑点来讲。
4.1 UART协议原理:其实没那么神秘
UART全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。说白了,就是两根线(TX、RX)来回传数据,没有时钟线,全靠双方约定好“节奏”。
这个节奏就是波特率。比如115200bps,意思是一秒传115200个比特。收发双方必须用同一个波特率,否则数据就乱了。我在项目中遇到过好几次,板子连上PC就是乱码,最后发现是板子晶振不准,导致实际波特率偏了那么一点点。
UART一帧数据的结构是这样的:
| 起始位 | 数据位(5~8位) | 校验位(可选) | 停止位(1/1.5/2位) |
|---|---|---|---|
| 1个低电平 | LSB先发 | 奇/偶/无 | 1个高电平 |
最常用的配置是8N1:8位数据、无校验、1位停止位。加上起始位,一帧就是10个比特。所以115200波特率下,实际每秒能传11520字节数据。
我的习惯:调试阶段一律用115200 8N1。这个配置兼容性最好,大部分串口工具和逻辑分析仪都默认支持。
4.2 波特率配置:算不对就等着乱码
ARM芯片的UART模块,通常有一个波特率发生器。它本质上是一个分频器。公式很简单:
波特率 = 时钟频率 / (16 × 分频系数)
以STM32F103为例,APB2时钟是72MHz,要得到115200波特率:
分频系数 = 72000000 / (16 × 115200) = 39.0625
取整39,实际波特率 = 72000000 / (16 × 39) = 115384。误差只有0.16%,完全没问题。
我曾经踩过的坑:用8MHz晶振的芯片,想跑921600波特率。算出来分频系数是5.42,取整后误差超过3%,结果就是丢包严重。后来换了16MHz晶振才搞定。记住,误差超过2%就容易出问题。
代码实现很简单,以ARM Cortex-M3为例:
void uart_set_baudrate(uint32_t periph_clock, uint32_t baudrate) {
uint32_t divisor = periph_clock / (16 * baudrate);
USART->BRR = divisor;
}
4.3 轮询模式收发:简单但占CPU
轮询模式,就是CPU死等。发数据时,等发送寄存器空;收数据时,等接收寄存器非空。代码很直白:
void uart_send_byte(uint8_t data) {
while (!(USART->SR & USART_SR_TXE)); // 等待发送缓冲区空
USART->DR = data;
}
uint8_t uart_recv_byte(void) {
while (!(USART->SR & USART_SR_RXNE)); // 等待接收数据
return (uint8_t)USART->DR;
}
这种模式适合简单的调试输出。但如果你在主循环里用轮询收数据,CPU就被锁死了。我早期做的一个项目,用轮询收GPS数据,结果按键响应变得奇慢无比。后来才改成中断。
适用场景:系统空闲时打印日志、初始化阶段的自检信息输出。不适合实时性要求高的数据接收。
4.4 中断模式收发:这才是正经做法
中断模式的核心思想:数据来了,CPU才去处理。平时CPU该干嘛干嘛。ARM Cortex-M的NVIC(嵌套向量中断控制器)让中断管理变得很优雅。
先看初始化:
void uart_init_interrupt(void) {
// 使能接收中断
USART->CR1 |= USART_CR1_RXNEIE;
// 配置NVIC
NVIC_EnableIRQ(USART1_IRQn);
NVIC_SetPriority(USART1_IRQn, 1);
}
中断服务函数:
void USART1_IRQHandler(void) {
if (USART->SR & USART_SR_RXNE) {
uint8_t data = USART->DR;
// 把数据放入环形缓冲区
ring_buffer_put(&rx_buffer, data);
}
}
这里有个关键点:中断里只做最轻量的事——把数据塞进缓冲区。真正的数据处理在主循环里完成。这样既不会丢数据,也不会阻塞中断太久。
我建议:环形缓冲区大小至少256字节。如果波特率超过115200,建议用512或1024。太小了容易溢出,太大了浪费内存。这个平衡点,得根据你的数据量来调。
下面这张图展示了中断收发的完整流程:
4.5 printf重定向:调试利器
printf是C标准库函数,默认输出到控制台。在嵌入式系统里,我们要把它重定向到UART。这样就能用printf来打印调试信息了。
以ARM GCC工具链为例,重定向很简单:
int _write(int file, char *ptr, int len) {
for (int i = 0; i < len; i++) {
uart_send_byte((uint8_t)*ptr++);
}
return len;
}
如果是Keil MDK,需要重写fputc:
int fputc(int ch, FILE *f) {
uart_send_byte((uint8_t)ch);
return ch;
}
注意:printf默认使用半主机模式(Semihosting),会通过JTAG/SWD输出。如果不关闭半主机,你的UART是收不到数据的。在Keil里,勾选“Use MicroLIB”就能自动关闭半主机。GCC的话,在链接脚本里加上--specs=nosys.specs。
重定向之后,你就可以这样用了:
printf("System initialized. Clock: %d MHz\r\n", SystemCoreClock / 1000000);
printf("Sensor value: %d, Temperature: %.2f°C\r\n", sensor_raw, temp);
我的经验:printf里尽量加\r\n,不要只用\n。很多串口工具只认\r\n才换行。另外,中断里千万别用printf,会递归调用导致死机。我见过有人把printf写在中断里,结果程序跑飞了查了半天。
4.6 实战要点总结
UART看似简单,但实际项目中容易出问题的点不少。我列个清单:
| 问题 | 现象 | 解决方法 |
|---|---|---|
| 波特率误差大 | 乱码、丢字节 | 用整数分频,误差控制在2%以内 |
| 中断里做耗时操作 | 系统卡顿、丢中断 | 中断只放数据,处理放主循环 |
| 环形缓冲区溢出 | 数据丢失 | 增大缓冲区,或提高处理速度 |
| printf半主机模式 | UART无输出 | 关闭半主机,使用MicroLIB或nosys.specs |
| TX和RX接反 | 完全无通信 | 交叉连接:TX接对方RX,RX接对方TX |
嗯,这一章的内容就到这里。UART是嵌入式开发的基础功,把它吃透了,后面学SPI、I2C这些同步接口会轻松很多。代码写多了你会发现,很多通信协议的核心思想都是相通的。