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 接收分层的实现
接收比发送复杂一些,因为涉及中断处理和环形缓冲区。我常用的做法是:
- 硬件层:在中断里把收到的字节放入环形缓冲区
- 应用层:从环形缓冲区读取数据,提供阻塞或非阻塞接口
环形缓冲区的实现:
/* 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驱动分层的结构就清晰了。我画了一张图,你看一眼就明白:
调试的时候,有几个小技巧可以分享:
- 先确认硬件层通不通:直接用HAL_UART_Transmit发固定数据,用示波器或逻辑分析仪看TX引脚波形。波形不对,上层写得再好也没用。
- printf重定向后死机?检查一下堆栈大小。printf内部用了不少栈空间,默认的0x200可能不够,我一般设到0x400以上。
- 接收丢数据?大概率是中断处理太慢。确保HAL_UART_RxCpltCallback里只做最轻量级的操作——把数据塞进环形缓冲区就行,别在里面做协议解析。
核心要点:
- 分层的关键是接口清晰,上层不关心底层用中断还是DMA
- printf重定向本质就是重写_write()或__io_putchar()
- 接收用环形缓冲区+中断,是嵌入式里最经典、最稳定的方案
嗯,这一章的内容就这些。UART分层搞定了,后面做任务调度、做协议解析,你就能专心写业务逻辑,不用再跟寄存器较劲了。