3. UART驱动分层与printf重定向:实现UART的发送/接收分层,重定向printf到串口用于调试

做嵌入式开发,串口打印基本是每个人的标配。你想想看,没有显示屏、没有键盘,芯片里跑得对不对,全靠串口往外吐信息。我刚开始用STM32那会儿,也是直接往USART_DR寄存器里塞数据,后来项目大了才发现——这么搞,代码根本没法维护。

这一章,我们就来聊聊怎么把UART驱动拆成两层,再把printf挂上去。说白了,就是让调试信息想打就打,而且不影响你正经的业务逻辑。

3.1 为什么需要分层?

先看一个反面教材。很多新手写UART发送,直接在main里这么干:

// 反面教材:业务代码和驱动代码混在一起
while(1)
{
    HAL_UART_Transmit(&huart1, (uint8_t*)"Hello\r\n", 7, 100);
    HAL_Delay(1000);
    
    // 处理传感器数据
    sensor_value = read_sensor();
    // 又发一次
    HAL_UART_Transmit(&huart1, (uint8_t*)&sensor_value, 4, 100);
}

这种写法有什么问题?我列一下:

  • 耦合太紧:业务代码里到处都是HAL_UART_Transmit,换个串口号得改几十处
  • 没法统一管理:想加个换行、加个时间戳?每个调用处都得改
  • 调试和业务不分:正式发布时,想关掉调试打印?你得一行一行删

所以,分层是必须的。我个人的习惯是分成两层:

层级 职责 典型接口
硬件抽象层(HAL) 直接操作HAL库的UART句柄,处理中断、DMA uart_hw_send(), uart_hw_recv()
应用接口层(APP) 封装格式化输出、缓冲管理、调试开关 uart_printf(), uart_puts(), uart_getchar()

这样,上层业务代码只调用应用接口层,底层换串口、换DMA,上层完全不用动。

3.2 硬件抽象层实现

这一层其实很薄,就是对HAL库的UART函数做一层简单的封装。我一般会定义一个结构体来管理UART句柄:

/* uart_hw.h */
#ifndef __UART_HW_H__
#define __UART_HW_H__

#include "stm32f1xx_hal.h"

typedef struct {
    UART_HandleTypeDef *huart;   // HAL库的UART句柄
    uint8_t *tx_buf;             // 发送缓冲区
    uint16_t tx_size;            // 发送数据长度
    volatile uint8_t tx_busy;    // 发送忙标志
} uart_hw_t;

int32_t uart_hw_init(uart_hw_t *obj, UART_HandleTypeDef *huart);
int32_t uart_hw_send(uart_hw_t *obj, const uint8_t *data, uint16_t len);
int32_t uart_hw_recv(uart_hw_t *obj, uint8_t *data, uint16_t len);

#endif

实现的时候,我习惯用中断方式发送,这样不会阻塞CPU:

/* uart_hw.c */
int32_t uart_hw_send(uart_hw_t *obj, const uint8_t *data, uint16_t len)
{
    if(obj == NULL || data == NULL || len == 0)
        return -1;
    
    // 等待上一次发送完成
    while(obj->tx_busy);
    
    obj->tx_busy = 1;
    obj->tx_buf = (uint8_t*)data;
    obj->tx_size = len;
    
    // 使用中断方式发送
    HAL_UART_Transmit_IT(obj->huart, (uint8_t*)data, len);
    
    return 0;
}

// HAL库的发送完成回调
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
    // 找到对应的uart_hw_t对象,清除忙标志
    // 这里需要根据实际项目做对象映射
    uart_hw_t *obj = find_uart_obj(huart);
    if(obj)
    {
        obj->tx_busy = 0;
    }
}
我的经验:tx_busy标志一定要用volatile修饰。我之前在一个项目里忘了加,结果编译器优化后,while循环永远跳不出来——芯片直接卡死。排查了整整一下午才发现是这个问题。

3.3 应用接口层与printf重定向

应用接口层要做两件事:一是提供更友好的打印接口,二是把printf挂到串口上。

printf重定向的原理其实很简单——C库的printf最终会调用一个底层函数:_write()(ARMCC)或__io_putchar()(GCC)。我们只要重写这个函数,把数据送到UART就行了。

以GCC为例,重定向代码如下:

/* uart_app.c */
#include <stdio.h>
#include "uart_hw.h"

static uart_hw_t g_uart_debug;  // 调试串口对象

int32_t uart_app_init(UART_HandleTypeDef *huart)
{
    return uart_hw_init(&g_uart_debug, huart);
}

// 重定向printf到串口
int __io_putchar(int ch)
{
    // 发送一个字节
    uart_hw_send(&g_uart_debug, (uint8_t*)&ch, 1);
    return ch;
}

// 应用层打印接口
int uart_printf(const char *fmt, ...)
{
    char buf[256];
    va_list args;
    int len;
    
    va_start(args, fmt);
    len = vsnprintf(buf, sizeof(buf), fmt, args);
    va_end(args);
    
    if(len > 0)
    {
        uart_hw_send(&g_uart_debug, (uint8_t*)buf, len);
    }
    
    return len;
}
注意:重定向printf后,如果程序中使用了浮点数格式化(如%f),链接时可能需要添加-u _printf_float标志,否则会链接失败。这是GCC的一个坑,我踩过。

3.4 接收分层的实现

接收比发送复杂一些,因为涉及中断处理和环形缓冲区。我常用的做法是:

  1. 硬件层:在中断里把收到的字节放入环形缓冲区
  2. 应用层:从环形缓冲区读取数据,提供阻塞或非阻塞接口

环形缓冲区的实现:

/* ringbuf.h */
typedef struct {
    uint8_t *buffer;
    uint16_t head;
    uint16_t tail;
    uint16_t size;
} ringbuf_t;

int32_t ringbuf_init(ringbuf_t *rb, uint8_t *buf, uint16_t size);
int32_t ringbuf_put(ringbuf_t *rb, uint8_t data);
int32_t ringbuf_get(ringbuf_t *rb, uint8_t *data);
uint16_t ringbuf_available(ringbuf_t *rb);

然后在UART接收中断中:

// 在HAL_UART_RxCpltCallback中调用
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    uart_hw_t *obj = find_uart_obj(huart);
    if(obj)
    {
        // 把收到的字节放入环形缓冲区
        ringbuf_put(&obj->rx_ringbuf, obj->rx_byte);
        // 启动下一次接收
        HAL_UART_Receive_IT(huart, &obj->rx_byte, 1);
    }
}

应用层就可以这样读数据:

// 非阻塞读取,有数据返回0,无数据返回-1
int32_t uart_getchar_nonblock(uint8_t *ch)
{
    return ringbuf_get(&g_uart_debug.rx_ringbuf, ch);
}

// 阻塞读取,直到收到数据
uint8_t uart_getchar_block(void)
{
    uint8_t ch;
    while(ringbuf_get(&g_uart_debug.rx_ringbuf, &ch) != 0);
    return ch;
}

3.5 整体架构与调试技巧

把上面这些串起来,整个UART驱动分层的结构就清晰了。我画了一张图,你看一眼就明白:

UART驱动分层架构图 应用层 main() / 任务函数 应用接口层(APP) uart_printf() / uart_getchar() / printf重定向 硬件抽象层(HAL) uart_hw_send() / uart_hw_recv() / 环形缓冲区 STM32 HAL库 HAL_UART_Transmit_IT() / HAL_UART_Receive_IT() 数据流向:应用层 → APP接口层 → HAL抽象层 → STM32 HAL库 → 硬件寄存器 中断流向:硬件中断 → STM32 HAL库回调 → HAL抽象层(环形缓冲区)→ APP接口层 → 应用层

调试的时候,有几个小技巧可以分享:

  • 先确认硬件层通不通:直接用HAL_UART_Transmit发固定数据,用示波器或逻辑分析仪看TX引脚波形。波形不对,上层写得再好也没用。
  • printf重定向后死机?检查一下堆栈大小。printf内部用了不少栈空间,默认的0x200可能不够,我一般设到0x400以上。
  • 接收丢数据?大概率是中断处理太慢。确保HAL_UART_RxCpltCallback里只做最轻量级的操作——把数据塞进环形缓冲区就行,别在里面做协议解析。

核心要点:

  • 分层的关键是接口清晰,上层不关心底层用中断还是DMA
  • printf重定向本质就是重写_write()或__io_putchar()
  • 接收用环形缓冲区+中断,是嵌入式里最经典、最稳定的方案

嗯,这一章的内容就这些。UART分层搞定了,后面做任务调度、做协议解析,你就能专心写业务逻辑,不用再跟寄存器较劲了。