4. 通用HAL接口定义实战(UART篇)
好,我们直接进入正题。上一章我们聊了HAL层的设计哲学,这一章就来点实在的——用UART这个最常用的外设,把接口定义从头到尾走一遍。
为什么选UART?说白了,串口是嵌入式开发的“标配”。你调试要它,通信要它,甚至很多传感器也是通过它来交互。我这些年经手的项目,从8位MCU到Cortex-A系列,UART的HAL层几乎每个项目都要重写一遍。踩过的坑多了,自然就总结出一套相对通用的写法。
4.1 结构体设计:UART_Handle 与 UART_Config
先看两个核心结构体。我个人习惯把实例句柄和配置参数分开定义,这样职责更清晰。
/* UART配置参数结构体 */
typedef struct {
uint32_t BaudRate; /* 波特率 */
uint8_t DataBits; /* 数据位:5/6/7/8 */
uint8_t StopBits; /* 停止位:1/1.5/2 */
uint8_t Parity; /* 校验位:无/奇/偶 */
uint8_t FlowControl; /* 流控:无/硬件RTSCTS */
uint8_t Mode; /* 模式:TX/RX/TXRX */
} UART_Config;
/* UART实例句柄结构体 */
typedef struct {
UART_RegTypeDef *Instance; /* 寄存器基地址 */
UART_Config Config; /* 当前配置 */
uint8_t *pTxBuff; /* 发送缓冲区指针 */
uint8_t *pRxBuff; /* 接收缓冲区指针 */
uint16_t TxSize; /* 发送数据长度 */
uint16_t RxSize; /* 接收数据长度 */
volatile uint8_t TxBusy; /* 发送忙标志 */
volatile uint8_t RxBusy; /* 接收忙标志 */
uint8_t ErrorCode; /* 错误码 */
void *pParent; /* 父设备指针(可选) */
} UART_Handle;
这里有几个设计要点,我重点说一下:
- Instance指针:指向具体的寄存器基地址,这样同一个结构体可以适配不同UART外设。我在一个项目里同时用过UART0~UART5,就是靠这个指针来区分的。
- Busy标志用volatile:这个很关键。中断服务程序和主循环都会访问它,不加volatile的话,编译器优化可能让你调试到怀疑人生。
- pParent指针:这是留给上层驱动的“后门”。比如你要把UART挂到某个DMA设备下,这个指针就能派上用场。
我的经验:Config结构体里的参数最好用枚举,而不是直接写数字。比如DataBits用UART_DATABITS_8这样的宏定义,代码可读性会好很多。我曾经接手过一个项目,波特率直接写了个9600,结果后来改成115200时漏改了一处,排查了整整半天。
4.2 接口函数原型:Init / DeInit / Send / Receive
接口定义要遵循一个原则:参数尽量少,职责尽量单一。来看我常用的接口原型:
/* 初始化与反初始化 */
int32_t UART_Init(UART_Handle *handle, UART_Config *config);
int32_t UART_DeInit(UART_Handle *handle);
/* 阻塞式收发 */
int32_t UART_Send(UART_Handle *handle, uint8_t *pData, uint16_t size, uint32_t timeout);
int32_t UART_Receive(UART_Handle *handle, uint8_t *pData, uint16_t size, uint32_t timeout);
/* 中断式收发 */
int32_t UART_Send_IT(UART_Handle *handle, uint8_t *pData, uint16_t size);
int32_t UART_Receive_IT(UART_Handle *handle, uint8_t *pData, uint16_t size);
/* 中断回调注册 */
int32_t UART_RegisterCallback(UART_Handle *handle, UART_CallbackType type, void (*callback)(UART_Handle *));
返回值统一用int32_t,0表示成功,负数表示错误码。这样做的好处是上层代码可以统一处理错误,不用关心具体外设的细节。
设计原则:所有接口的第一个参数都是UART_Handle指针。这是面向对象思想在C语言里的体现——把“对象”显式传进去,而不是用全局变量。这样同一个驱动可以同时管理多个UART实例,互不干扰。
4.3 中断回调注册机制
回调注册这块,我见过很多种实现方式。最简单的就是固定函数名,比如UART0_IRQHandler里直接调用某个全局函数。但这样扩展性太差。
我推荐的做法是在Handle里放一个回调函数指针数组:
/* 回调类型枚举 */
typedef enum {
UART_CALLBACK_TX_COMPLETE, /* 发送完成 */
UART_CALLBACK_RX_COMPLETE, /* 接收完成 */
UART_CALLBACK_ERROR, /* 错误发生 */
UART_CALLBACK_ABORT, /* 中止完成 */
UART_CALLBACK_MAX
} UART_CallbackType;
/* 在Handle结构体中增加 */
typedef struct {
/* ... 前面定义的成员 ... */
void (*Callbacks[UART_CALLBACK_MAX])(UART_Handle *);
} UART_Handle;
注册函数实现起来也很简单:
int32_t UART_RegisterCallback(UART_Handle *handle, UART_CallbackType type,
void (*callback)(UART_Handle *))
{
if (handle == NULL || type >= UART_CALLBACK_MAX) {
return -1;
}
handle->Callbacks[type] = callback;
return 0;
}
然后在中断服务程序里这样调用:
void UART_IRQHandler(UART_Handle *handle)
{
/* 检查中断标志位 */
if (/* 发送完成 */) {
handle->TxBusy = 0;
if (handle->Callbacks[UART_CALLBACK_TX_COMPLETE]) {
handle->Callbacks[UART_CALLBACK_TX_COMPLETE](handle);
}
}
/* 类似处理接收和错误 */
}
我曾经踩过的坑:回调函数里千万不要做耗时操作,比如打印日志或者延时。中断上下文里时间很宝贵,回调应该只做“通知”工作,真正的数据处理放到主循环或者任务里。有一次我在回调里直接调了printf,结果串口输出乱码,查了半天才发现是中断嵌套把缓冲区搞乱了。
4.4 接口实现要点
Init函数的实现流程,我整理成了一张图:
Send和Receive的实现,核心就是操作数据寄存器。阻塞模式用轮询+超时,中断模式靠状态机驱动。这里我给出一个简化版的Send实现思路:
int32_t UART_Send(UART_Handle *handle, uint8_t *pData, uint16_t size, uint32_t timeout)
{
uint32_t tickstart = GetTick();
/* 参数检查 */
if (handle == NULL || pData == NULL || size == 0) return -1;
if (handle->TxBusy) return -2; /* 上次发送未完成 */
handle->TxBusy = 1;
while (size--) {
/* 等待发送寄存器空 */
while (!(handle->Instance->SR & UART_SR_TXE)) {
if ((GetTick() - tickstart) > timeout) {
handle->TxBusy = 0;
return -3; /* 超时 */
}
}
/* 发送一个字节 */
handle->Instance->DR = *pData++;
}
/* 等待发送完成 */
while (!(handle->Instance->SR & UART_SR_TC)) {
if ((GetTick() - tickstart) > timeout) {
handle->TxBusy = 0;
return -3;
}
}
handle->TxBusy = 0;
return 0;
}
关于超时处理:GetTick()函数要由平台层提供,返回毫秒级的时间戳。我在不同平台上用过SysTick、TIMER、甚至RTC来实现。关键是精度要够,而且不能阻塞中断。曾经有个项目用了HAL_Delay来实现超时,结果中断里一调用就死锁,教训深刻。
4.5 接口定义检查清单
最后,我整理了一份接口定义的检查清单,你在设计自己的HAL层时可以对照着看:
| 检查项 | 说明 | 优先级 |
|---|---|---|
| 参数检查 | 所有接口入口都要做NULL指针和参数范围检查 | 必须 |
| 重入保护 | Busy标志或互斥锁防止并发调用 | 必须 |
| 超时机制 | 阻塞接口必须有超时退出路径 | 必须 |
| 错误码定义 | 统一错误码,方便上层排查 | 建议 |
| 回调保护 | 调用回调前检查函数指针是否为NULL | 必须 |
| 中断安全 | 中断服务程序里不能调用阻塞接口 | 必须 |
| 低功耗适配 | Init/DeInit要考虑时钟门控和电源管理 | 建议 |