3. UART串口驱动:从框图到实战

UART串口,说白了就是嵌入式世界的"通用翻译官"。不管你是调试打印、跟传感器聊天,还是和Wi-Fi模块传数据,十有八九都得靠它。我这些年经手的项目,几乎每个板子第一件事就是把串口调通——就像人要先学会说话一样。

这一节,咱们把USART从里到外拆开看。从寄存器怎么配,到轮询、中断、DMA三种收发模式怎么玩,最后再搞定printf重定向。嗯,内容不少,但都是硬通货。

3.1 USART框图与核心寄存器

先看整体结构。我习惯把USART框图分成三块:发送通路、接收通路、控制与状态。

USART框图核心结构 发送数据寄存器(TDR) 发送移位寄存器 TX 引脚 接收数据寄存器(RDR) 接收移位寄存器 RX 引脚 控制寄存器(CR1/CR2/CR3) 状态寄存器(SR/ISR) 波特率发生器(BRR) 关键寄存器速查: • SR(状态寄存器): bit5 RXNE(接收非空), bit6 TC(发送完成), bit7 TXE(发送空) • DR(数据寄存器): 9位有效数据,发送写、接收读 • BRR(波特率寄存器): 16位,USARTDIV = fCK / (16 * BaudRate)

这张图我建议你多看两眼。发送和接收是两条独立的通路,各有一个移位寄存器干活。控制寄存器决定怎么干,状态寄存器告诉你干完了没有。

核心寄存器速记口诀:

  • CR1 — 使能开关(UE, TE, RE),中断使能(RXNEIE, TCIE)
  • CR2 — 停止位长度,同步模式配置
  • CR3 — DMA使能(DMAT, DMAR),流控(RTS/CTS)
  • SR/ISR — 查状态:能不能发?有没有收到?
  • DR — 数据进出的唯一通道
  • BRR — 波特率 = 时钟 / (16 × USARTDIV)

3.2 轮询模式收发

轮询模式,说白了就是CPU死等。代码简单,但效率低。适合调试阶段或者低速通信。

发送一个字节的套路:

// 等待发送数据寄存器空
while (!(USART1->SR & USART_SR_TXE));
// 写入数据
USART1->DR = (data & 0xFF);

接收一个字节的套路:

// 等待接收数据寄存器非空
while (!(USART1->SR & USART_SR_RXNE));
// 读取数据
uint8_t data = USART1->DR & 0xFF;

我曾经踩过的坑:读DR寄存器时,RXNE位会自动清零。但如果你先读SR再读DR,中间插了别的操作,可能会丢数据。我的习惯是:读SR判断RXNE后,立即读DR,中间不要有任何其他代码。

轮询收发完整函数:

void uart_send_byte(USART_TypeDef *USARTx, uint8_t data)
{
    while (!(USARTx->SR & USART_SR_TXE));
    USARTx->DR = data;
}

uint8_t uart_recv_byte(USART_TypeDef *USARTx)
{
    while (!(USARTx->SR & USART_SR_RXNE));
    return (uint8_t)(USARTx->DR & 0xFF);
}

void uart_send_string(USART_TypeDef *USARTx, const char *str)
{
    while (*str)
    {
        uart_send_byte(USARTx, *str++);
    }
}

你想想看,如果CPU一边发数据一边还要处理其他任务,轮询模式就把CPU锁死了。这时候就该中断上场了。

3.3 中断模式收发

中断模式的核心思想:让CPU干正事,串口准备好了再通知你。

配置步骤其实就三步:

  1. 使能USART中断(RXNEIE或TCIE)
  2. 使能NVIC中的USART中断通道
  3. 编写中断服务函数

发送中断的典型写法:

// 发送一个字节(启动中断)
void uart_send_byte_it(USART_TypeDef *USARTx, uint8_t data)
{
    // 把数据暂存到全局变量
    g_tx_data = data;
    // 使能发送完成中断
    USARTx->CR1 |= USART_CR1_TCIE;
}

// 中断服务函数
void USART1_IRQHandler(void)
{
    if (USART1->SR & USART_SR_TC)
    {
        // 发送完成,关闭中断
        USART1->CR1 &= ~USART_CR1_TCIE;
        // 可以在这里设置标志位,通知主循环
        g_tx_done = 1;
    }
}

我的个人习惯:发送中断我一般用TC(发送完成)而不是TXE(发送空)。为什么?TXE只是说数据从DR移到了移位寄存器,但还没发完。TC才是真正发完了。如果你紧接着要关串口或切模式,用TC更安全。

接收中断更常用:

void USART1_IRQHandler(void)
{
    if (USART1->SR & USART_SR_RXNE)
    {
        uint8_t data = USART1->DR & 0xFF;
        // 把数据放入环形缓冲区
        ringbuf_put(&g_rx_ring, data);
    }
}

这里我强烈建议用环形缓冲区。我在项目里吃过亏——中断来得太快,主循环来不及处理,数据就丢了。环形缓冲区能很好地解耦中断和主循环的速度差异。

3.4 DMA模式收发

DMA模式,说白了就是让DMA控制器帮你搬数据,CPU彻底解放。适合大批量数据传输,比如用串口刷日志、传文件。

配置DMA发送的要点:

// 配置DMA发送通道
void uart_dma_tx_config(void)
{
    // 1. 使能DMA时钟
    RCC->AHB1ENR |= RCC_AHB1ENR_DMA1EN;
    
    // 2. 配置DMA通道
    DMA1_Stream3->CR = 0;  // 先复位
    DMA1_Stream3->PAR = (uint32_t)&USART1->DR;  // 外设地址
    DMA1_Stream3->M0AR = (uint32_t)g_tx_buf;    // 内存地址
    DMA1_Stream3->NDTR = TX_BUF_SIZE;           // 传输数量
    
    // 3. 配置方向:内存到外设
    DMA1_Stream3->CR |= DMA_SxCR_DIR_0;  // 内存到外设
    DMA1_Stream3->CR |= DMA_SxCR_MINC;   // 内存地址递增
    DMA1_Stream3->CR |= DMA_SxCR_TCIE;   // 传输完成中断
    
    // 4. 使能USART的DMA发送
    USART1->CR3 |= USART_CR3_DMAT;
}

启动一次DMA发送:

void uart_dma_send(uint8_t *data, uint16_t len)
{
    // 等待上一次传输完成
    while (DMA1_Stream3->CR & DMA_SxCR_EN);
    
    // 设置源地址和长度
    DMA1_Stream3->M0AR = (uint32_t)data;
    DMA1_Stream3->NDTR = len;
    
    // 启动DMA
    DMA1_Stream3->CR |= DMA_SxCR_EN;
}

我曾经遇到的坑:DMA传输完成后,USART可能还没把移位寄存器里的最后一个字节发完。如果你立刻关串口或进低功耗模式,最后一个字节就丢了。我的做法是:DMA传输完成中断里,再等一下USART的TC标志位,确认真的发完了再收工。

三种模式的对比:

模式 CPU占用 实时性 适合场景 代码复杂度
轮询 100%(阻塞) 差 调试、低速单次通信 低
中断 仅中断时占用 好 交互式通信、不定长数据 中
DMA 几乎为0 最好 批量数据、高速传输 高

3.5 printf重定向实现

printf重定向,说白了就是让C库的printf函数把数据吐到你的串口上。调试利器,没有之一。

在STM32上,标准做法是重写_write函数(ARMCC)或__io_putchar(GCC)。

ARMCC(Keil)环境:

// 在工程设置中勾选"Use MicroLIB"
int fputc(int ch, FILE *f)
{
    // 轮询发送一个字节
    while (!(USART1->SR & USART_SR_TXE));
    USART1->DR = (ch & 0xFF);
    return ch;
}

// 如果要用DMA方式重定向
int fputc(int ch, FILE *f)
{
    // 用DMA发送,需要自己实现队列
    uart_dma_putchar(ch);
    return ch;
}

GCC(STM32CubeIDE)环境:

int __io_putchar(int ch)
{
    while (!(USART1->SR & USART_SR_TXE));
    USART1->DR = (ch & 0xFF);
    return ch;
}

// 还需要实现_sys_write(新版本GCC)
int _write(int file, char *ptr, int len)
{
    for (int i = 0; i < len; i++)
    {
        __io_putchar(*ptr++);
    }
    return len;
}

我的经验:printf默认是阻塞的,如果你在中断里调用printf,可能会死锁。我一般会做一个非阻塞版printf:先把要打印的字符串格式化到缓冲区,然后通过DMA或中断异步发送。这样既不影响实时性,又能愉快地打日志。

非阻塞printf的简单实现思路:

// 用vsnprintf格式化到缓冲区
void debug_printf(const char *fmt, ...)
{
    va_list args;
    va_start(args, fmt);
    vsnprintf(g_debug_buf, DEBUG_BUF_SIZE, fmt, args);
    va_end(args);
    
    // 启动DMA发送(非阻塞)
    uart_dma_send((uint8_t *)g_debug_buf, strlen(g_debug_buf));
}

嗯,到这里UART串口驱动的四种模式就全了。从轮询到中断再到DMA,每一步都是性能和复杂度的权衡。实际项目中,我通常这样搭配:

  • 调试阶段:轮询printf,简单粗暴
  • 正常通信:中断接收 + 环形缓冲区,DMA发送
  • 大批量数据:DMA收发全开,配合双缓冲

串口驱动写好了,后面移植RTOS时,你就能体会到非阻塞通信的好处了——任务不会被串口阻塞,系统响应才够快。