串口/网络透传实战:FreeRTOS 多任务架构与连接池设计

前言

实战项目复盘: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, 超时)

为什么要三层

  1. DMA + 空闲中断(ReceiveToIdle) :数据由 DMA 自动搬运,一帧收完才产生一次中断,而不是逐字节中断------CPU 占用极低;

  2. FreeRTOS 队列 :中断只负责把字节"塞"进队列(xQueueSendFromISR),任务负责"取"(xQueueReceive)------中断与任务彻底解耦,中断里只做最小的事;

  3. 应用层阻塞读取:任务在队列上阻塞等待,没数据不占 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);

设计要点

  1. 连接池 + 互斥量:多任务(广播任务、各客户端任务)都会访问连接池,必须互斥保护------这是多任务编程最容易漏的地方;

  2. 发送失败即移除send <= 0 说明客户端断开,立即 close + 标记空位,防止死连接占着池子;

  3. 每客户端一个任务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 框架的三个关键机制

  1. 互斥锁 at_lock:AT 模块是单通道,命令必须一个一个来,防止两个任务同时发命令导致响应错乱;

  2. 信号量 at_resp_sem :发完命令就阻塞等信号量,W800 的响应解析线程收到"OK"后释放信号量------不用轮询等响应,省 CPU

  3. 超时机制:等响应有超时,模块没回就返回失败,不会永久卡死。

这就是"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 + 回调 别硬删任务,让它自己收尾

十、项目心得

作为一个项目,这套网关让我对"多任务系统怎么设计"有了真正的体感,几个印象最深的点:

  1. 任务划分是设计,不是编码:把透传拆成 6 个任务(每个方向一个、每个客户端一个),每个任务职责单一、互不阻塞。写代码反而成了最简单的一步,想清楚"谁负责什么"才是关键。

  2. 中断与任务要解耦:串口接收用 DMA + 空闲中断 + FreeRTOS 队列,中断只管"塞"、任务只管"取"------中断里绝不干重活,这是实时性不被拖垮的前提。

  3. 共享资源必须加锁:多客户端连接池被多个任务同时访问,互斥量没加好,广播时连接列表可能被并发修改,出现"往已关闭的 socket 发数据"。调试这类问题,让我真正理解了互斥量的价值。

  4. 任务退出要优雅:直接删除任务会留下没人释放的锁和资源,用 flag + 回调让任务自己收尾再删除,才符合产品级代码的要求。

相关推荐
Learn-Share_HY29 分钟前
[IT Network]如何配置反向路徑過濾器(rp_filter),以解決路由不對稱問題?
linux·嵌入式硬件·物联网·网络协议·tcp/ip·http·iot
新晨单片机设计2 小时前
S001A-基于STM32单片机超声波视力保护仪【Proteus仿真+Keil程序+原理图】
stm32·单片机·proteus
会周易的程序员2 小时前
aiDgeController 软PLC虚拟机stvm集成测试报告
c++·物联网·架构·集成测试·st·软plc·iec61131
星河单片机2 小时前
STM32驱动Y01-3IN1空气质量模块+OLED显示完整教程
stm32·单片机·嵌入式硬件·空气质量·甲醛检测·二氧化碳检测·tvoc检测
虎王物联2 小时前
ESP32 FreeRTOS多任务优先级翻转与互斥量保护实战
物联网·嵌入式·esp32·freertos
玩转单片机与嵌入式2 小时前
深入浅出TinyML 21:INT8量化改变了模型中的哪些数据?
stm32·深度学习·tinyml
别催小唐敲代码2 小时前
stm32_mpu6050_滤波教程
stm32·单片机·嵌入式硬件
K成长日志3 小时前
BLE不可连接状态--广播态
物联网·网络协议·蓝牙·低功耗·iot·ble
一条破秋裤3 小时前
STM32 学习笔记:GPIO 输出实验——LED 闪烁、流水灯与蜂鸣器
笔记·stm32·学习