目录
- 一、前言
- 二、串口封装的必要性
- [三、UART 面向对象的结构体封装思路](#三、UART 面向对象的结构体封装思路)
- [四、CubeMX 新增串口 DMA 通道配置](#四、CubeMX 新增串口 DMA 通道配置)
- 五、串口回调函数与功能函数完善
- 六、信号量优化串口发送机制
- [七、UART 封装文件实现与调用](#七、UART 封装文件实现与调用)
- 八、应用层任务函数适配
- 九、总结
- 十、结尾
一、前言
在吃透串口底层的收发逻辑与各类函数运用后,我们在多串口的项目开发中,会逐渐面临一个核心问题:串口相关的收发函数会随串口数量增多而冗余,串口功能的切换与修改成本大幅增加。就像此前的开发中,我们固定串口 2 为发送、串口 4 为接收,若想调换二者的收发角色,需要修改大量的串口相关函数调用。基于此,对 UART 串口进行面向对象的结构体封装就显得尤为必要,封装后能极大简化多串口的切换与管理,让代码的复用性和可维护性大幅提升,本次依旧延续 RS485 双串口通信 + LCD 实时显示的功能,完成串口的封装与底层逻辑优化。
二、串口封装的必要性
我们以实际开发中的需求为例,就能直观感受到封装的价值:
现有需求:将原本基于串口 2 的收发代码,直接修改为串口 4 的对应操作。
未封装前,我们需要逐行修改所有串口相关函数:
c
uart2_init(115200, 'N', 8, 1);
char *str = "Hello_Embed";
uart2_sendp(str, strlen(str), 100);
每更换一次串口,就要修改所有的串口函数名,涉及多串口任务时,这类修改繁琐且极易出错。而通过面向对象的思想,将串口的所有操作封装进结构体,就能通过指针直接切换串口,无需大面积修改代码,这也是嵌入式开发中优化代码结构的核心思路。
三、UART 面向对象的结构体封装思路
面向对象封装的核心,是将串口设备的名称 与该串口对应的初始化、发送、接收操作函数,全部整合到一个结构体中,让每个串口都成为一个独立的「设备对象」,通过结构体指针即可调用对应串口的所有功能。
首先定义串口设备的核心结构体,将串口的操作函数通过函数指针的形式封装进去:
c
struct UART_Device{
char *name,
int (*Init)(struct UART_Device *pDev, int baud, char parity, int data_bit, int stop_bit)
itn (*RecvByte)(struct UART_Device *pDev, uint8_t *data, int timeout);
};
接着为项目中用到的串口 2、串口 4,分别构造对应的结构体实例,绑定各自的底层操作函数:
c
struct UART_Device g_uart2_dev = {"uart2", uart2_init, uart2_send, uart2_recvbyte};
struct UART_Device g_uart4_dev = {"uart4", uart4_init, uart4_send, uart4_recvbyte};
封装完成后,只需通过结构体指针即可调用串口功能,切换串口仅需修改指针指向的对象:
c
struct UART_Device *pDev = &g_uart2_dev;
pDev->Init(pDev, 115200, 'N', 8, 1);
char *str = "Hello_Embed";
pDev->Send(pDev, str, strlen(str), 100);
通过这种方式,多串口的切换与修改步骤被大幅缩减,代码的耦合度也显著降低。本次我们还会完善串口的功能,在原有串口 2 发送、串口 4 接收的基础上,新增串口 2 接收、串口 4 发送的双向通信能力,实现双串口的全功能收发。
四、CubeMX 新增串口 DMA 通道配置
本次的 CubeMX 基础配置与此前保持一致,仅需新增两路 DMA 通道,以适配双串口的全功能收发需求:新增通道 2 作为串口 2 的接收 DMA 通道、通道 3 作为串口 4 的发送 DMA 通道,具体配置截图如下:


五、串口回调函数与功能函数完善
完成配置后,在usart.c文件中完善对应的功能代码,核心是新增串口 2 接收、串口 4 发送的相关函数,所有新增逻辑均仿照已有的串口函数编写即可,保证代码逻辑的一致性。其中核心改动点,是在 DMA 接收完成回调函数与 IDLE 空闲中断回调函数中,加入对串口 2 的判断分支,实现双串口的中断接收处理,完整代码如下:
c
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart == &huart2)
{
/* write queue : g_uart4_rx_buf 100 bytes ==> queue */
for (int i = 0; i < 100; i++)
{
xQueueSendFromISR(g_Uart2_Rx_Queue, (const void *)&g_uart2_rx_buf[i], NULL);
}
/* re-start DMA+IDLE rx */
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, g_uart2_rx_buf, 100);
}
if (huart == &huart4)
{
/* write queue : g_uart4_rx_buf 100 bytes ==> queue */
for (int i = 0; i < 100; i++)
{
xQueueSendFromISR(g_Uart4_Rx_Queue, (const void *)&g_uart4_rx_buf[i], NULL);
}
/* re-start DMA+IDLE rx */
HAL_UARTEx_ReceiveToIdle_DMA(&huart4, g_uart4_rx_buf, 100);
}
}
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size)
{
if (huart == &huart2)
{
/* write queue : g_uart4_rx_buf Size bytes ==> queue */
for (int i = 0; i < Size; i++)
{
xQueueSendFromISR(g_Uart2_Rx_Queue, (const void *)&g_uart2_rx_buf[i], NULL);
}
/* re-start DMA+IDLE rx */
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, g_uart2_rx_buf, 100);
}
if (huart == &huart4)
{
/* write queue : g_uart4_rx_buf Size bytes ==> queue */
for (int i = 0; i < Size; i++)
{
xQueueSendFromISR(g_Uart4_Rx_Queue, (const void *)&g_uart4_rx_buf[i], NULL);
}
/* re-start DMA+IDLE rx */
HAL_UARTEx_ReceiveToIdle_DMA(&huart4, g_uart4_rx_buf, 100);
}
}
同时我们也可以对原有的数据获取函数做功能优化,提升容错性,这是未优化的串口 4 数据获取函数:
c
// 串口4读队列获取数据函数
int UART4_GetData(uint8_t *pData)
{
xQueueReceive(g_Uart4_Rx_Queue, pData, portMAX_DELAY);
return 0;
};
优化后新增超时时间参数 与失败处理机制,能灵活适配不同的业务场景,优化后的串口 2 数据获取函数如下:
c
int UART2_GetData(struct UART_Device *pdev, uint8_t *pData, int timeout)
{
if (pdPASS == xQueueReceive(g_Uart2_Rx_Queue, pData, timeout))
return 0;
else
return -1;
}
六、信号量优化串口发送机制
本次新增了串口 4 的发送功能,因此需要单独封装通用的串口发送函数。此前的发送逻辑是在任务中直接实现,且原有的发送完成等待逻辑存在一个明显的缺陷:会丢失「数据在 1ms 内发送完成」的情况,原判断逻辑如下:
c
while(g_uart4_rx_cplt == 0 && timeout)
{
vTaskDelay(1);
timeout--;
}
针对该问题,这里推荐使用二进制信号量作为串口发送完成的标识,替代原有的标志位判断,能实现更灵敏、更精准的发送完成检测。
补充:关于信号量的详细知识点,可参考笔者 FreeRTOS 系列笔记的对应章节;另外,FreeRTOS 中的互斥锁(mutex)看似适配该场景,但绝对不能在中断中使用 mutex 。原因是:当其他任务持有的互斥量被中断中调用
xSemaphoreGive释放时,互斥锁的优先级继承机制会受到阻碍,最终导致系统运行异常。
因此本次选用二进制信号量作为替代方案,优化步骤如下:
1. 定义信号量与队列句柄
c
static SemaphoreHandle_t g_Uart2_Tx_Semaphore;
static QueueHandle_t g_Uart2_Rx_Queue;
static SemaphoreHandle_t g_Uart4_Tx_Semaphore;
static QueueHandle_t g_Uart4_Rx_Queue;
2. 在串口启动函数中创建二进制信号量
c
int UART2_Rx_Start(struct UART_Device *pDev, int baud, char parity, int data_bit, int stop_bit)
{
if (!g_Uart2_Rx_Queue)
{
g_Uart2_Rx_Queue = xQueueCreate(200, 1);
g_Uart2_Tx_Semaphore = xSemaphoreCreateBinary( );
HAL_UARTEx_ReceiveToIdle_DMA(&huart2, g_uart2_rx_buf, 100);
}
return 0;
}
int UART4_Rx_Start(struct UART_Device *pDev, int baud, char parity, int data_bit, int stop_bit)
{
if (!g_Uart4_Rx_Queue)
{
g_Uart4_Rx_Queue = xQueueCreate(200, 1);
g_Uart4_Tx_Semaphore = xSemaphoreCreateBinary( );
HAL_UARTEx_ReceiveToIdle_DMA(&huart4, g_uart4_rx_buf, 100);
}
return 0;
}
3. 编写基于信号量的串口发送函数
c
int UART2_Send(struct UART_Device *pDev, uint8_t *datas, uint32_t len, int timeout)
{
HAL_UART_Transmit_DMA(&huart2, datas, len);
if (pdTRUE == xSemaphoreTake(g_Uart2_Tx_Semaphore, timeout))
return 0;
else
return -1;
}
int UART4_Send(struct UART_Device *pDev, uint8_t *datas, uint32_t len, int timeout)
{
HAL_UART_Transmit_DMA(&huart4, datas, len);
if (pdTRUE == xSemaphoreTake(g_Uart4_Tx_Semaphore, timeout))
return 0;
else
return -1;
}
4. 修改发送完成回调函数,释放信号量
c
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart == &huart2)
{
xSemaphoreGiveFromISR(g_UART2_TX_Semaphore, NULL);
}
if (huart == &huart4)
{
xSemaphoreGiveFromISR(g_UART4_TX_Semaphore, NULL);
}
}
对比此前的发送完成回调函数,能清晰看到优化点:
c
void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart)
{
if(huart == &huart2)
{
g_uart2_tx_cplt = 1;
}
}
本次优化不仅移除了冗余的标志位,还新增了双串口的发送支持,发送流程也变为:开启 DMA 发送 → 等待信号量 → 发送完成释放信号量,原有的发送等待函数被彻底优化,响应更灵敏,也彻底解决了数据丢失的问题。
七、UART 封装文件实现与调用
完成底层函数的优化后,我们正式完成 UART 串口的面向对象封装,通过新建.h与.c文件,实现封装逻辑与解耦,让应用层无需关心底层实现,仅需调用封装接口即可。
封装头文件 uart_device.h
c
//.h
#ifndef __UART_DEVICE_H
#define __UART_DEVICE_H
#include <stdint.h>
struct UART_Device {
char *name;
int (*Init)( struct UART_Device *pDev, int baud, char parity, int data_bit, int stop_bit);
int (*Send)( struct UART_Device *pDev, uint8_t *datas, uint32_t len, int timeout);
int (*RecvByte)( struct UART_Device *pDev, uint8_t *data, int timeout);
};
struct UART_Device *GetUARTDevice(char *name);
#endif
封装源文件 uart_device.c
c
//.c
#include <string.h>
#include "uart_device.h"
extern struct UART_Device g_uart2_dev;
extern struct UART_Device g_uart4_dev;
static struct UART_Device *g_uart_devices[] = {&g_uart2_dev, &g_uart4_dev};
struct UART_Device *GetUARTDevice(char *name)
{
int i = 0;
for (i = 0; i < sizeof(g_uart_devices)/sizeof(g_uart_devices[0]); i++)
{
if (!strcmp(name, g_uart_devices[i]->name))
return g_uart_devices[i];
}
return NULL;
}
最后在usart.c中创建对应的结构体实例,绑定所有的底层操作函数,完成最终的封装映射:
c
#include "uart_device.h"
struct UART_Device g_uart2_dev = {"uart2", UART2_Rx_Start, UART2_Send, UART2_GetData};
struct UART_Device g_uart4_dev = {"uart4", UART4_Rx_Start, UART4_Send, UART4_GetData};
八、应用层任务函数适配
封装完成后,在app_freertos.c的应用层任务中,直接调用封装后的串口接口即可实现原有功能,代码逻辑更简洁,串口切换也变得极其简单,核心任务代码如下:
c
#include "uart_device.h"
static void CH1_UART2_TxTaskFunction(void *pvParameters)
{
uint8_t c = 1;
struct UART_Device *pdev = GetUARTDevice("uart2");
pdev->Init(pdev, 115200, 'N', 8, 1);
while(1)
{
pdev->Send(pdev, &c, 1, 100);
vTaskDelay(1000);
c++;
}
};
static void CH2_UART4_RxTaskFunction(void *pvParameters)
{
uint8_t c = 0;
int cnt = 0;
char buf[100];
int err;
UART4_Rx_Start();
struct UART_Device *pdev = GetUARTDevice("uart4");
pdev->Init(pdev, 115200, 'N', 8, 1);
while(1)
{
err = pdev->RecvByte(pdev, &c, 200);
if(err == 0)
{
sprintf(buf, "Recv Data : 0x%02x, Cnt : %d", c, cnt++);
Draw_String(0, 0, buf, 0x0000ff00, 0);
}
}
};
上述代码实现的依旧是串口 2 发送、串口 4 接收的功能,运行现象与此前完全一致。而回到最初的问题:当需要切换串口时,仅需修改GetUARTDevice()函数中的串口名称即可,比如GetUARTDevice("uart4"),无需修改任何其他逻辑,这就是封装的核心价值。
九、总结
- 多串口开发中,面向对象封装能大幅降低串口切换成本,提升代码复用性与可维护性;
- 串口封装核心是结构体整合设备名与操作函数,通过函数指针实现功能调用;
- 二进制信号量完美替代标志位,解决串口发送数据丢失问题,mutex 不可用在中断上下文;
- 双串口的 DMA+IDLE 中断回调需增加串口判断分支,实现全功能收发;
- 封装后应用层与底层解耦,串口切换仅需修改设备名称,逻辑极简。
十、结尾
本次笔记完成了串口底层逻辑的完善与面向对象封装,这是嵌入式开发中从「实现功能」到「优化代码」的重要进阶,封装的思路虽有一定难度,但吃透后能极大提升项目开发效率。串口作为嵌入式的核心外设,其优化与封装逻辑也适用于其他外设,是必备的进阶能力。感谢各位的阅读,深耕技术细节,打磨代码功底,才能稳步提升开发能力,持续关注本系列,一起解锁更多嵌入式实战优化技巧!