4. 移植环境搭建
UART 这个外设,说简单也简单,说复杂也复杂。简单是因为它本质上就是发数据和收数据,复杂在于不同芯片的 UART 寄存器差异太大了。我早年做项目时,光是移植 UART 驱动就折腾了两周——后来我下定决心,必须做一层抽象,把共性抽出来。
这一章,我们就来聊聊 UART 的 HAL 层该怎么设计。我会从接口定义讲起,然后分别讨论轮询模式和中断模式的实现思路。
4.1 标准UART接口定义
我个人习惯,先把接口定死,再考虑实现。接口就是契约,上层代码只认契约,不关心底层寄存器长什么样。
一个完整的 UART HAL 层,至少需要这几类操作:
- 初始化与去初始化:配置波特率、数据位、停止位、校验位
- 发送:单字节发送、多字节发送
- 接收:单字节接收、多字节接收
- 流控:硬件流控(RTS/CTS)使能
- 中断管理:使能/禁用中断、注册回调
下面是我在项目中常用的接口定义,你可以直接拿来用:
/* UART 配置结构体 */
typedef struct {
uint32_t baudrate; /* 波特率 */
uint8_t data_bits; /* 数据位:5/6/7/8 */
uint8_t stop_bits; /* 停止位:1/2 */
uint8_t parity; /* 校验位:0=无, 1=奇, 2=偶 */
uint8_t flow_ctrl; /* 流控:0=无, 1=硬件流控 */
} uart_config_t;
/* UART 句柄结构体 */
typedef struct {
uint8_t port; /* 端口号 */
uart_config_t config; /* 配置参数 */
void *priv; /* 私有数据指针 */
void (*rx_callback)(uint8_t data); /* 接收回调 */
void (*tx_callback)(void); /* 发送完成回调 */
} uart_handle_t;
/* HAL 层接口 */
int32_t uart_init(uart_handle_t *handle);
int32_t uart_deinit(uart_handle_t *handle);
int32_t uart_send_byte(uart_handle_t *handle, uint8_t data);
int32_t uart_send_buffer(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_receive_byte(uart_handle_t *handle, uint8_t *data);
int32_t uart_receive_buffer(uart_handle_t *handle, uint8_t *data, uint32_t len);
int32_t uart_set_flow_ctrl(uart_handle_t *handle, uint8_t enable);
int32_t uart_irq_enable(uart_handle_t *handle, uint8_t enable);
关键设计点:句柄中的 priv 指针指向底层寄存器基地址或设备结构体。这样上层代码只操作句柄,底层实现可以随意切换。
4.2 轮询模式实现
轮询模式,说白了就是 CPU 死等。发送时一直检查发送寄存器是否为空,接收时一直检查数据是否到达。这种方式在裸机或简单任务中很常见,但会浪费 CPU 资源。
我早期做的一个温控项目,就是用轮询方式跑 UART。当时波特率只有 9600,CPU 大部分时间都在空转。嗯,那时候还没意识到中断的重要性。
轮询发送的核心逻辑:
int32_t uart_send_byte(uart_handle_t *handle, uint8_t data) {
/* 等待发送寄存器为空 */
while (!(UART_REG(handle->port, SR) & TX_EMPTY));
/* 写入数据 */
UART_REG(handle->port, DR) = data;
return 0;
}
轮询接收的核心逻辑:
int32_t uart_receive_byte(uart_handle_t *handle, uint8_t *data) {
/* 等待接收数据到达 */
while (!(UART_REG(handle->port, SR) & RX_NOT_EMPTY));
/* 读取数据 */
*data = UART_REG(handle->port, DR);
return 0;
}
注意:轮询模式一定要加超时机制。我曾经遇到过硬件故障导致发送寄存器一直不空,程序就死锁在那里。加个超时判断,能避免很多麻烦。
4.3 中断模式实现
中断模式就优雅多了。CPU 不用空等,数据到了或者发送完成了,硬件会主动通知你。你想想看,这对系统整体性能的提升有多大。
中断模式的设计要点:
- 发送中断:发送寄存器为空时触发,在中断中填充下一个字节
- 接收中断:接收到数据时触发,在中断中读取并存入缓冲区
- 环形缓冲区:中断和主程序通过环形缓冲区交换数据,避免数据丢失
我曾经在一个多串口项目中,用中断模式配合 DMA,实现了 4 路 UART 同时收发,CPU 占用率不到 5%。要是用轮询,估计早就崩了。
中断服务函数示例:
void uart_irq_handler(uart_handle_t *handle) {
uint32_t status = UART_REG(handle->port, SR);
/* 接收中断 */
if (status & RX_NOT_EMPTY) {
uint8_t data = UART_REG(handle->port, DR);
/* 存入环形缓冲区 */
ringbuf_push(&handle->rx_buf, data);
/* 调用用户回调 */
if (handle->rx_callback) {
handle->rx_callback(data);
}
}
/* 发送中断 */
if (status & TX_EMPTY) {
uint8_t data;
if (ringbuf_pop(&handle->tx_buf, &data) == 0) {
UART_REG(handle->port, DR) = data;
} else {
/* 没有数据了,关闭发送中断 */
uart_irq_enable(handle, 0);
}
}
}
经验之谈:中断服务函数里尽量少做事,把数据丢进缓冲区就赶紧退出。耗时的处理放到主循环或任务中去做。这是嵌入式开发的黄金法则。
4.4 流控实现
硬件流控(RTS/CTS)在高速通信中很有用。它通过硬件信号线来控制数据流,防止接收方来不及处理导致数据丢失。
流控的 HAL 抽象其实很简单:
int32_t uart_set_flow_ctrl(uart_handle_t *handle, uint8_t enable) {
if (enable) {
/* 使能 RTS/CTS 功能 */
UART_REG(handle->port, CR) |= RTS_EN | CTS_EN;
} else {
/* 禁用流控 */
UART_REG(handle->port, CR) &= ~(RTS_EN | CTS_EN);
}
return 0;
}
底层实现时,要注意不同芯片的流控引脚映射。我记得有一次在 STM32 上移植,RTS 引脚默认是推挽输出,但实际需要开漏模式,折腾了半天才找到问题。
4.5 轮询 vs 中断:如何选择?
| 对比项 | 轮询模式 | 中断模式 |
|---|---|---|
| CPU 占用 | 高,持续占用 | 低,只在收发时占用 |
| 实时性 | 依赖轮询频率 | 硬件触发,响应快 |
| 实现复杂度 | 简单 | 中等,需要缓冲区管理 |
| 适用场景 | 低速率、简单任务 | 高速率、多任务、实时要求高 |
| 功耗 | 高 | 低,CPU 可以休眠 |
我个人建议:新产品直接上中断模式,别犹豫。轮询模式只适合调试阶段或者极低成本的方案。
4.6 UART HAL 层架构图
下面这张图展示了 UART HAL 层的整体架构,从上层应用到底层硬件,每一层各司其职:
从这张图可以看出,HAL 层处于中间位置,向上提供统一接口,向下屏蔽硬件差异。你只要把 MCAL 层按芯片重新实现一遍,应用代码完全不用动。
核心思想:接口稳定,实现可变。这就是 HAL 层的价值所在。
4.7 避坑指南
最后分享几个我踩过的坑:
- 波特率误差:不同芯片的时钟源不同,计算出的波特率可能有误差。超过 3% 就会丢数据。我建议在初始化时做一次误差检查。
- 中断优先级:多串口项目中,中断优先级设置不当会导致数据丢失。高波特率的串口优先级要设高一些。
- 环形缓冲区大小:缓冲区太小容易溢出,太大浪费内存。一般按波特率计算:缓冲区大小 > 波特率 / 8 / 中断频率。
- 流控信号极性:不同芯片的 RTS/CTS 极性可能相反。移植时一定要看数据手册。
嗯,UART 的 HAL 抽象就聊到这里。记住一点:接口设计要简洁,实现要灵活。这样你的代码才能经得起跨平台移植的考验。