前言
实战项目复盘:STM32H5 + W800 WiFi 模块 + FreeRTOS 实现串口-网络双向透传网关,支持 TCP/UDP Client/Server 多种模式。本文基于项目真实代码分析。
目录
[二、整体架构:6 个透传任务](#二、整体架构:6 个透传任务)
[三、核心设计 1:底层串口接收------DMA + 空闲中断 + FreeRTOS 队列](#三、核心设计 1:底层串口接收——DMA + 空闲中断 + FreeRTOS 队列)
[四、核心设计 2:串口读取与"分包"处理](#四、核心设计 2:串口读取与"分包"处理)
[五、核心设计 3:TCP Server 连接池(多客户端)](#五、核心设计 3:TCP Server 连接池(多客户端))
[accept 新客户端 → 加入连接池](#accept 新客户端 → 加入连接池)
[串口数据 → 广播给所有客户端](#串口数据 → 广播给所有客户端)
[六、核心设计 4:AT 命令框架(W800 WiFi 模块)](#六、核心设计 4:AT 命令框架(W800 WiFi 模块))
[七、核心设计 5:断线自动重连](#七、核心设计 5:断线自动重连)
[八、核心设计 6:任务的优雅退出](#八、核心设计 6:任务的优雅退出)
一、项目背景
工业物联网网关的典型需求:设备串口数据要能远程访问------现场设备只有串口,但运维人员不在现场,怎么办?把串口数据"透传"到网络,远程就能看。
项目硬件:STM32H5(主控)+ W800(WiFi 模块,走 AT 指令)+ FreeRTOS(多任务调度)。
cpp
[现场设备] ←串口→ [STM32H5网关] ←WiFi(AT)→ [网络/服务器]
网关要做的:串口收到的数据转发到网络,网络收到的数据转发到串口,双向透传。
二、整体架构:6 个透传任务
透传的本质是数据搬家:一边读,一边写。用 FreeRTOS 拆成 6 个任务,覆盖 3 种模式:
| 模式 | 串口→网络 | 网络→串口 |
|---|---|---|
| TCP Client | uart_to_net_task |
net_to_uart_task |
| TCP Server | uart_to_client_task(广播给所有客户端) |
client_to_uart_task(每客户端一个) |
| UDP Client | uart_to_net_utask |
net_to_uart_utask |
每个任务结构相同:循环读一边 → 写到另一边。以 TCP Client 的"网络→串口"为例:
cpp
void net_to_uart_task(void *param)
{
int socket = (int)param;
PUART_Device pUART = GetUARTDevice("uart2");
int iRecvLen;
uint8_t ucRecvBuf[100];
while(1)
{
/* 读网络数据 */
iRecvLen = recv(socket, ucRecvBuf, sizeof(ucRecvBuf), 0);
if(iRecvLen > 0)
{
/* 转发到串口 */
pUART->Send(pUART, ucRecvBuf, iRecvLen, 100);
}
else
{
/* 连接异常,退出任务 */
break;
}
}
vTaskDelete(NULL); /* 自杀:任务结束 */
}
recv 阻塞等数据 → 来了就转发 → 断了就退出任务------这就是透传任务的最小模型。
三、核心设计 1:底层串口接收------DMA + 空闲中断 + FreeRTOS 队列
先说最底层:串口数据是怎么从硬件进到任务的?项目的接收链路是 STM32 串口接收的高效标准做法------DMA + 空闲中断 + FreeRTOS 队列,三层解耦:
cpp
DMA 接收 (HAL_UARTEx_ReceiveToIdle_DMA)
↓ 空闲中断触发(一帧数据收完才中断一次)
中断回调: xQueueSendFromISR(rxQueue, 逐字节)
↓
FreeRTOS 队列 rxQueue(中断与任务解耦)
↓
应用层 UART_GetData: xQueueReceive(rxQueue, 超时)
为什么要三层:
-
DMA + 空闲中断(ReceiveToIdle) :数据由 DMA 自动搬运,一帧收完才产生一次中断,而不是逐字节中断------CPU 占用极低;
-
FreeRTOS 队列 :中断只负责把字节"塞"进队列(
xQueueSendFromISR),任务负责"取"(xQueueReceive)------中断与任务彻底解耦,中断里只做最小的事; -
应用层阻塞读取:任务在队列上阻塞等待,没数据不占 CPU。
cpp
/* 中断回调:DMA 收到一帧数据后,逐字节入队 */
void HAL_UARTEx_RxEventCallback(...)
{
for(i = 0; i < len; i++)
{
xQueueSendFromISR(pdata->rxQueue, &pdata->rx_buf[i], NULL);
}
HAL_UARTEx_ReceiveToIdle_DMA(pdata->huart, pdata->rx_buf, UART_RX_BUF_LEN);
}
/* 应用层读取:阻塞等队列(就是 RecvByte 的底层实现) */
int UART_GetData(PUART_Device pDev, uint8_t *pData, int timeout)
{
if (pdPASS == xQueueReceive(pdata->rxQueue, pData, timeout))
return 0;
else
return -1;
}
这就是"中断 + 队列 + 任务"的经典模型:中断塞、队列存、任务取,各干各的,互不阻塞。
四、核心设计 2:串口读取与"分包"处理
串口数据是一字节一字节来的,而且不定长。怎么知道"这一包数据收完了"?
项目在应用层采用的策略:超时攒批------从队列里连续读,直到 10ms 没新数据,认为一帧收完,一次性转发。
cpp
uint8_t ucRecvBuf[100];
int len = 0;
while(1)
{
/* 带 10ms 超时读单字节:有数据就继续读,超时就说明一包收完了 */
if(pUART->RecvByte(pUART, &data, 10) == 0)
{
if(len < sizeof(ucRecvBuf))
{
ucRecvBuf[len++] = data; /* 攒进缓冲 */
}
}
else
{
break; /* 串口空闲超时:一包数据攒齐了 */
}
}
if(len > 0)
{
send(socket, ucRecvBuf, len, 0); /* 整包转发到网络 */
len = 0;
}
为什么这么做:串口帧之间通常有间隙(协议栈在发完一帧后会有停顿),利用这个间隙------连续读字节,直到 10ms 没新数据,就认为"这一帧收完了",一次性转发。
⚠️ 这种方式有个局限:如果串口端连续高速发送(帧间无间隙),多个数据帧会被攒成一个大包转发------这就是粘包 的隐患。本项目透传场景不解析协议、直接整包转发,这是产品需求下的合理取舍:网关只负责"搬运",协议解析交给上位机。
五、核心设计 3:TCP Server 连接池(多客户端)
Server 模式要同时服务多个客户端。项目用固定数组连接池管理:
cpp
#define MAX_CLIENT_SOCKETS 5 // 最大 5 路客户端
int g_client_sockets[MAX_CLIENT_SOCKETS]; // 客户端连接列表
int g_client_count; // 当前连接数
SemaphoreHandle_t g_client_mutex; // 保护连接池
accept 新客户端 → 加入连接池
cpp
iSocketClient = accept(iSocketServer, ...);
if (-1 != iSocketClient)
{
/* 加锁保护连接池 */
xSemaphoreTake(g_client_mutex, portMAX_DELAY);
for(i = 0; i < MAX_CLIENT_SOCKETS; i++)
{
if(g_client_sockets[i] == -1) /* 找空位 */
{
g_client_sockets[i] = iSocketClient;
g_client_count++;
break;
}
}
xSemaphoreGive(g_client_mutex);
/* 为每个客户端单独开一个"网络→串口"任务 */
xTaskCreate(client_to_uart_task, "client_to_uart", 300,
(void *)iSocketClient, osPriorityNormal, NULL);
}
串口数据 → 广播给所有客户端
cpp
/* 拿到串口数据后,遍历连接池广播 */
xSemaphoreTake(g_client_mutex, portMAX_DELAY);
for(i = 0; i < MAX_CLIENT_SOCKETS; i++)
{
socket = g_client_sockets[i];
if(socket != -1)
{
if(send(socket, ucRecvBuf, len, 0) <= 0)
{
/* 发送失败:客户端断了,关闭并移除 */
close(socket);
g_client_sockets[i] = -1;
g_client_count--;
}
}
}
xSemaphoreGive(g_client_mutex);
设计要点:
-
连接池 + 互斥量:多任务(广播任务、各客户端任务)都会访问连接池,必须互斥保护------这是多任务编程最容易漏的地方;
-
发送失败即移除 :
send <= 0说明客户端断开,立即 close + 标记空位,防止死连接占着池子; -
每客户端一个任务 :
client_to_uart_task各自阻塞在recv上,互不干扰。
六、核心设计 4:AT 命令框架(W800 WiFi 模块)
W800 模块通过串口 AT 指令控制,项目封装了完整的 AT 框架:
cpp
int at_exec_cmd(PAT_Device pDev, int8_t *cmd, ...)
{
xSemaphoreTake(pDev->at_lock, portMAX_DELAY); /* 1. 串行化:AT 命令不能并发 */
at_reset_resp(pDev); /* 2. 清空上次响应 */
pUART->Send(pUART, (uint8_t *)cmd, strlen(cmd), timeout); /* 3. 发命令 */
/* 4. 等响应:信号量在解析线程收到 OK 后 Give */
if(xSemaphoreTake(pDev->at_resp_sem, timeout) == pdTRUE)
{
/* 校验响应状态 */
if(pDev->resp_status != AT_RESP_OK)
ret = -1;
}
else
{
ret = -1; /* 超时 */
}
xSemaphoreGive(pDev->at_lock);
return ret;
}
AT 框架的三个关键机制:
-
互斥锁
at_lock:AT 模块是单通道,命令必须一个一个来,防止两个任务同时发命令导致响应错乱; -
信号量
at_resp_sem:发完命令就阻塞等信号量,W800 的响应解析线程收到"OK"后释放信号量------不用轮询等响应,省 CPU; -
超时机制:等响应有超时,模块没回就返回失败,不会永久卡死。
这就是"AT 命令 + 信号量 + 互斥锁"的标准做法,socket API(socket/bind/listen/accept/send/recv)全部在 W800 层基于 AT 实现,对上层透传任务暴露的是标准 BSD socket 接口------上层代码和用真实网卡完全一样。
七、核心设计 5:断线自动重连
UDP 模式下,目标地址变化或连接异常时自动重连:
cpp
int w800_sendto(int sockfd, const void *buf, ...)
{
struct sockaddr_in *old_addr = &pDev->sockets[sockfd].remote;
struct sockaddr_in *new_addr = (struct sockaddr_in *)dest_addr;
/* UDP:目标地址变了(或没连过),就重新建立连接 */
if((pDev->sockets[sockfd].user_data == NULL) ||
old_addr->sin_addr.s_addr != new_addr->sin_addr.s_addr ||
old_addr->sin_port != new_addr->sin_port)
{
if(pDev->sockets[sockfd].user_data)
w800_close(sockfd); /* 先关旧的 */
if(w800_connect(sockfd, dest_addr, addrlen) != 0)
return -1; /* 重连失败 */
}
/* 然后正常发送 */
}
机制 :发送/接收前检查目标地址,变了就 close + 重新 connect------对上层透明,任务层无感知。TCP 模式则是靠 send/recv 返回失败来触发断开清理。
八、核心设计 6:任务的优雅退出
配置变更时(用户改了透传模式),旧任务要退出。项目用 flag + 回调注册:
cpp
static volatile int net_to_uart_flag = 0; /* 任务运行标志 */
void net_to_uart_exit(void) /* 回调:别人调它来让任务退出 */
{
net_to_uart_flag = 1;
}
/* 任务里每个循环都检查 flag */
while(1)
{
if(net_to_uart_flag)
break; /* 优雅退出:先收尾再自杀 */
...
}
net_to_uart_flag = 0;
vTaskDelete(NULL);
为什么不直接 vTaskDelete 别的任务 :直接删除任务不优雅------任务可能正占着互斥量/资源,直接删会导致锁没人释放、资源泄漏。用 flag 让任务自己退出,是嵌入式多任务的标准做法。
九、经验总结
| 设计点 | 做法 | 经验 |
|---|---|---|
| 任务划分 | 6 个透传任务,每个"读一边写一边" | 每个任务职责单一,好调试 |
| 分包处理 | RecvByte 超时攒批 | 利用帧间隙分帧,简单有效 |
| 多客户端 | 连接池 + 互斥量 + 广播 | 连接池要加锁,发送失败即清理 |
| AT 框架 | 互斥锁串行化 + 信号量等响应 | 单通道设备必须串行访问 |
| 断线处理 | 地址变化自动重连 / send失败清理 | 对上层透明 |
| 任务退出 | flag + 回调 | 别硬删任务,让它自己收尾 |
十、项目心得
作为一个项目,这套网关让我对"多任务系统怎么设计"有了真正的体感,几个印象最深的点:
-
任务划分是设计,不是编码:把透传拆成 6 个任务(每个方向一个、每个客户端一个),每个任务职责单一、互不阻塞。写代码反而成了最简单的一步,想清楚"谁负责什么"才是关键。
-
中断与任务要解耦:串口接收用 DMA + 空闲中断 + FreeRTOS 队列,中断只管"塞"、任务只管"取"------中断里绝不干重活,这是实时性不被拖垮的前提。
-
共享资源必须加锁:多客户端连接池被多个任务同时访问,互斥量没加好,广播时连接列表可能被并发修改,出现"往已关闭的 socket 发数据"。调试这类问题,让我真正理解了互斥量的价值。
-
任务退出要优雅:直接删除任务会留下没人释放的锁和资源,用 flag + 回调让任务自己收尾再删除,才符合产品级代码的要求。