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。太小了容易溢出,太大了浪费内存。这个平衡点,得根据你的数据量来调。

下面这张图展示了中断收发的完整流程:

UART中断收发流程图 应用层调用发送 写入发送缓冲区 使能发送中断 中断逐字节发送 硬件接收数据 触发接收中断 存入环形缓冲区 主循环处理数据 CPU可执行其他任务

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这些同步接口会轻松很多。代码写多了你会发现,很多通信协议的核心思想都是相通的。