UART串口通信驱动:从原理到实战

UART串口,说白了就是嵌入式世界里最常用的"对讲机"。我做了这么多年项目,几乎每个板子都会留一个串口——调试、日志、协议交互,全靠它。今天咱们就把UART从原理到驱动实现,掰开揉碎了讲清楚。

UART协议原理:一帧数据怎么传?

UART全称是Universal Asynchronous Receiver/Transmitter,异步串行通信。什么叫异步?就是没有时钟线,靠双方约定好的波特率来同步。

一帧UART数据长这样:

起始位 数据位(5~9位) 校验位(可选) 停止位(1/1.5/2位)
1位低电平 LSB先发 奇/偶校验 高电平

举个例子,发送0x55(二进制01010101),波特率115200。空闲时TX线是高电平。先拉低1位时间(起始位),然后按位发数据:1→0→1→0→1→0→1→0,最后拉高1位时间(停止位)。

关键参数:波特率、数据位、停止位、校验位。收发双方必须完全一致,否则收到的全是乱码。我踩过这个坑——板子默认8N1,上位机配成了7E1,折腾了半小时才发现。

轮询模式:简单但占CPU

轮询模式最直接。CPU不断检查串口状态寄存器,有数据就读,能发送就写。

// 轮询发送一个字节
void UART_SendByte(uint8_t data) {
    // 等待发送缓冲区空
    while (!(USART1->SR & USART_SR_TXE));
    USART1->DR = data;
}

// 轮询接收一个字节
uint8_t UART_RecvByte(void) {
    // 等待接收数据就绪
    while (!(USART1->SR & USART_SR_RXNE));
    return (uint8_t)USART1->DR;
}

这种写法,我刚开始学STM32时天天用。简单、直观、好调试。但问题也很明显——while循环把CPU死死卡住了。你想想看,如果波特率9600,收一个字节就要等1ms左右,CPU啥也干不了。

轮询模式的适用场景:仅用于初始化阶段的硬件自检、低速率调试输出。生产代码里千万别这么写,除非你的MCU只干这一件事。

中断模式:让CPU干正事

中断模式才是正经做法。数据来了,硬件自动触发中断,CPU暂停当前任务去处理,处理完再回来。

配置步骤:

  1. 使能USART接收中断(RXNEIE)
  2. 编写中断服务函数
  3. 在中断里读DR寄存器,清标志位
// 中断服务函数
void USART1_IRQHandler(void) {
    if (USART1->SR & USART_SR_RXNE) {
        uint8_t data = USART1->DR;
        // 把数据放入环形缓冲区
        RingBuf_Put(&uart_rx_buf, data);
    }
}

这里有个关键点——中断服务函数要快进快出。我见过有人直接在中断里做协议解析、写Flash,结果系统响应一塌糊涂。正确的做法是:中断只负责把数据搬到缓冲区,解析工作交给主循环或任务。

我的习惯:每个UART配一个环形缓冲区,大小256字节起步。中断往里写,主循环往外读。这样既不会丢数据,也不会阻塞中断。

DMA传输:解放CPU的终极方案

DMA(Direct Memory Access)说白了就是硬件搬运工。CPU告诉DMA:"把串口收到的1000个字节搬到内存地址0x20001000",然后CPU就可以去干别的了。DMA搬完数据,发个中断通知CPU。

DMA传输的典型配置:

参数 配置值 说明
外设地址 USART1->DR 数据源/目标
内存地址 缓冲区首地址 数据存放位置
传输方向 外设到内存/内存到外设 收发不同通道
传输模式 循环/正常 循环模式适合连续接收
数据宽度 8位 与UART数据位一致
// DMA接收配置示例
void UART_DMA_Init(void) {
    // 使能DMA时钟
    RCC->AHBENR |= RCC_AHBENR_DMA1EN;
    
    // 配置DMA通道
    DMA1_Channel5->CPAR = (uint32_t)&USART1->DR;
    DMA1_Channel5->CMAR = (uint32_t)uart_dma_buf;
    DMA1_Channel5->CNDTR = 256;  // 传输256字节
    DMA1_Channel5->CCR = DMA_CCR_MINC    // 内存地址递增
                       | DMA_CCR_CIRC     // 循环模式
                       | DMA_CCR_TCIE;    // 传输完成中断
    DMA1_Channel5->CCR |= DMA_CCR_EN;    // 使能DMA
}

DMA传输的好处是零CPU占用。我做过一个项目,4路串口同时以921600波特率收发数据,CPU负载才15%左右。如果用中断模式,CPU直接飙到80%以上。

DMA的坑:DMA传输完成中断和UART空闲中断配合使用,才能准确判断一帧数据结束。我曾经只靠DMA传输完成中断来判帧,结果数据包长度不固定时,经常把两帧数据拼在一起。

串口打印调试工具实现

调试嵌入式系统,printf是最趁手的工具。但STM32的串口没有现成的printf,需要我们自己重定向。

标准做法是重写fputc函数:

// 重定向printf到串口
int fputc(int ch, FILE *f) {
    // 等待发送完成
    while (!(USART1->SR & USART_SR_TXE));
    USART1->DR = (ch & 0xFF);
    return ch;
}

// 使用示例
printf("System initialized, clock=%dMHz\r\n", SystemCoreClock / 1000000);

但printf有个问题——它默认是阻塞的。如果串口发送慢,printf会卡住整个系统。我建议在调试阶段用printf,正式发布时换成非阻塞的日志模块。

一个实用的调试工具应该包含:

  • 日志分级:ERROR、WARNING、INFO、DEBUG,可以动态开关
  • 时间戳:每个日志带上系统运行时间,方便定位问题
  • 非阻塞输出:用DMA或中断发送,不影响主逻辑
  • 彩色输出:不同级别用不同颜色,一眼看出问题
// 简易日志宏
#define LOG_ERROR(fmt, ...)  \
    do { \
        printf("[ERR][%lu] " fmt "\r\n", GetTick(), ##__VA_ARGS__); \
    } while(0)

#define LOG_INFO(fmt, ...)   \
    do { \
        printf("[INF][%lu] " fmt "\r\n", GetTick(), ##__VA_ARGS__); \
    } while(0)

我的调试三板斧:1) 串口打印关键变量;2) 用逻辑分析仪抓UART波形,确认时序;3) 在关键函数入口出口打印标记。这三招能解决90%的串口问题。

UART驱动知识体系

下面这张图总结了UART驱动的核心脉络:

UART串口驱动知识体系 UART协议原理 轮询模式 中断模式 DMA传输 轮询实现 while等待标志位 简单但阻塞CPU 中断实现 RXNE/TXE中断 环形缓冲区 DMA实现 零CPU占用 循环/正常模式 串口打印调试工具 printf重定向 日志分级 时间戳 非阻塞输出 选择哪种模式?低速调试用轮询,生产环境用中断,大数据量用DMA

从这张图可以看得很清楚:UART驱动从协议出发,衍生出三种实现模式,最终落地到调试工具。每种模式都有它的适用场景,没有银弹。

选型建议:

  • 调试阶段、数据量小(<100字节/秒)→ 轮询模式
  • 常规通信、数据量中等 → 中断模式 + 环形缓冲区
  • 高速通信(>1Mbps)、大数据量 → DMA + 空闲中断

嗯,UART串口驱动这块就讲到这里。记住一句话:串口是嵌入式工程师的"眼睛",把眼睛擦亮了,调试任何问题都事半功倍。