简介:stm32h743平台下的FreeRTOS+LWIP+FreeMODBUS TCP/IP移植资源包,面向需要在H7系列MCU上搭建实时网络通信能力的嵌入式开发者。以正点原子阿波罗开发板为硬件基础,整合了LWIP Socket TCP客户端例程与FreeMODBUS TCP移植代码,适合用于物联网设备接入、工业数据采集等场景,解决多任务环境下的协议栈协同与Modbus设备通信问题。压缩包共1040个文件、约57.73MB,文件构成以375个h头文件和306个c源文件为主体,覆盖HAL库驱动、TCP/IP协议栈、FreeRTOS任务管理及Modbus寄存器处理模块;另含Keil工程文件、编译中间产物(o/d/crf)与少量说明文档,可直接在MDK环境下打开对照调试。已有1392人学习下载,资源目录分级清晰,便于按模块检索。通过阅读核心任务与协议栈初始化源文件,可快速理清任务创建、LWIP初始化、Socket通信及Modbus寄存器映射的实现脉络,对从事STM32网络开发的初学者和进阶者均有直接参考价值。
1. 项目定位:H743上的Modbus TCP网关,到底解决了什么问题
之前给某产线设备做联网改造,一台老旧的温度采集柜还在用RS485轮询,上位机读一次全量数据要三百多毫秒,现场调试时看波形都是大步进。把那套逻辑搬到STM32H743上,用FreeRTOS做任务调度、LWIP跑TCP/IP协议栈、FreeModbus以TCP模式提供Modbus服务之后,轮询延迟压到了个位数毫秒。这个工程就是一套完整的移植样板:它把一颗H743变成了一个带网络接口的Modbus TCP从站设备,电脑上的调试工具填上IP和端口就能直接读写寄存器,适合做工业网关、设备联网、数据采集这类项目的开发者。拿到手能烧录跑通,也能照着换成自己的板子。这个工程最反直觉的地方在于,Modbus从串口搬到以太网之后,真正难的不是Modbus本身,而是协议栈和实时内核怎么在你的板子上协作。
2. 技术选型与整体架构:FreeRTOS、LWIP、FreeModbus 三层怎么搭
2.1 为什么是H743配这三件套
先看硬件底子。STM32H743 是 Cortex-M7 内核,主频能跑到 480MHz,带双精度浮点,Flash 2MB、RAM 1MB,内置了以太网 MAC 控制器,支持 RMII 接口,外部需要再接一颗 PHY 芯片。做 Modbus TCP 从站,本质上只需要把收到的 TCP 报文解析成功能码,再按照寄存器地址去读写内存变量,连实时控制都不需要,所以这套组合的核心矛盾不是算力,而是三块软件在内存和中断上怎么协作。H743 的 AXI SRAM 可以给 LWIP 做 DMA 描述符,DTCM 可以放任务栈,1MB 的 RAM 规划起来非常宽裕。
选 FreeRTOS 的理由很简单:生态大,网上资料多,中断里调用的 API 都有现成说明。LWIP 虽然叫轻量级协议栈,但它该有的功能都在,TCP、UDP、DHCP、ARP 全都支持。FreeModbus 则是把 Modbus 协议从串口 RTU 到 TCP 的差异封装好,应用层只需要填四个回调函数就行。
从工程结构上说,三块软件的关系是分层的:FreeRTOS 负责任务调度和资源互斥,LWIP 挂在 FreeRTOS 之上作为一个带独立线程的协议栈,FreeModbus 再以 LWIP 为传输层跑在数据链路之上。数据流是“网线 → H743 以太网 MAC → 硬件 DMA → LWIP 的 pbuf → FreeModbus 解析 → 用户寄存器”,最底层是寄存器读写回调,回调里操作的是工程里的全局变量。
2.2 工程目录结构与任务划分
拿到工程源码之后,先看目录,避免一上来就找不着文件。常见的组织方式大概是这样的:
project_root/ ├── Core/ // 时钟、GPIO、ETH、DMA 初始化 │ ├── Src/main.c │ ├── Src/eth.c │ └── Inc/ ├── Middlewares/ │ ├── FreeRTOS/ // 内核源码 + 配置文件 │ ├── LWIP/ // 协议栈源码 + 移植文件 │ └── FreeModbus/ // Modbus 从站协议栈 ├── App/ │ ├── modbus_regs.c // 寄存器回调实现 │ ├── tasks.c // 任务创建、初始化顺序 │ └── app_config.h └── Drivers/ // 芯片外设驱动main.c 里做的核心事情只有三件:初始化硬件时钟和 GPIO、创建任务、启动调度器。工程里通常建了四个任务:tcpip_thread 是 LWIP 自己的协议栈线程,优先级中等;modbus_task 负责周期调用 eMBPoll(),优先级要低于 tcpip_thread,否则容易抢了协议栈的处理节奏;另外还有一个 led_task 做状态指示,一个 idle 钩子统计 CPU 占用。任务划分的原则是:谁有实时要求谁用高优先级,Modbus 这类带 TCP 缓冲的协议任务反而不适合跑太快,给它留一个 10ms 到 20ms 的轮询周期就够了。
2.3 关键配置参数:内存、任务栈与 LWIP 裁剪
落到具体的配置项。先看 FreeRTOS 的 FreeRTOSConfig.h,几个关键宏:
#define configTOTAL_HEAP_SIZE ( 256 * 1024 ) #define configMINIMAL_STACK_SIZE ( 128 ) #define configUSE_MUTEXES 1 #define configUSE_RECURSIVE_MUTEXES 1堆大小给了 256KB,是考虑到 LWIP 的 pbuf 和线程栈都要从 FreeRTOS 的堆里分配。如果板子 RAM 没这么大,至少要给 128KB,再往下 LWIP 在重负载下会频繁申请失败。mutex 必须开,Modbus 回调里要保护寄存器数组被并发访问。
LWIP 的 lwipopts.h 里最影响行为的是这几个:
#define MEM_SIZE ( 64 * 1024 ) #define PBUF_POOL_SIZE 24 #define TCP_MSS 1460 #define TCP_WND ( 32 * TCP_MSS )PBUF_POOL_SIZE 默认 16 个,做从站通讯如果同时连多个上位机或者网页调试工具,建议加到 24。TCP_WND 决定了吞吐量,Modbus 报文通常就 100 多个字节,这个值不需要太大,32 个 MSS 已经覆盖绝大多数场景。协议栈默认是带 DHCP 的,做设备出厂调试时更建议静态 IP,把 LWIP_DHCP 关掉能省一点内存和开机时间。
3. 核心模块拆解:LWIP 与 FreeRTOS 的粘合层是怎么工作的
3.1 从硬件中断到协议栈线程
H743 的以太网 MAC 收到数据帧后,会通过 DMA 把数据搬到内存描述符里,然后触发一个以太网中断。这个中断在 FreeRTOS 里要优先把它翻译成“事件”,再唤醒 tcpip_thread 去处理,而不是直接在中断里跑协议栈。移植 LWIP 时最关键的两个粘合 API 是 sys_now() 和 sys_thread_new()。
sys_now() 给协议栈提供毫秒级时间戳,LWIP 的 TCP 超时重传全靠它。用 FreeRTOS 实现非常直接:
u32_t sys_now(void) { return (u32_t)xTaskGetTickCount(); }xTaskGetTickCount() 返回的是从启动到现在的 tick 数,在 tick rate 是 1000Hz 的配置下,它就是毫秒数。这里要注意 tick rate 不能低于 100,否则 TCP 定时器精度不够,容易出现超时误判。
sys_thread_new() 是 LWIP 创建线程的入口,底层就是调用 xTaskCreate():
sys_thread_t sys_thread_new(const char *name, lwip_thread_fn thread, void *arg, int stacksize, int prio) { TaskHandle_t task_handle = NULL; xTaskCreate(thread, name, stacksize, arg, prio, &task_handle); return (sys_thread_t)task_handle; }协议栈线程的栈大小给 2048 字(8KB)起步,LWIP 内部处理 TCP 报文时有大量函数嵌套调用,栈给小了会在诡异的位置溢出。
3.2 以太网中断里该做什么,不该做什么
以太网中断服务函数里,正确做法是从描述符里取走 pbuf,通过任务通知给 tcpip_thread 发通知。这类“中断发信号、线程收信号”的模式是 LWIP 在 FreeRTOS 上最常见的移植方案。
void ETH_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; // 通知 tcpip_thread 有新数据到达 vTaskNotifyGiveFromISR(xTcpipTaskHandle, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }任务通知比二值信号量更轻量,从 ISR 里发送开销极小。xTcpipTaskHandle 需要在 tcpip_thread 启动时保存下来。中断里千万不要去做协议解析或者任何带阻塞的操作,LWIP 的 etharp_input、tcpip_input 之类接口也不是在这里调用的,协议栈处理统一放到线程上下文里执行。
3.3 netif 注册与 Link 状态轮询
协议栈要工作,得先把网络接口注册进去。工程里一般会在 tcpip_thread 创建后调用 netif_add(),把 H743 的 MAC 和 IP 信息交给 LWIP,再把 netif 设置为默认接口和 link up。这个动作不能提前到 FreeRTOS 调度器启动之前,因为 netif_add() 内部会用到信号量。
Link 状态有两种维护方式:中断式和轮询式。H743 的 MAC 自带 PHY 状态寄存器,工程里更常用的是在周期任务里读 PHY 寄存器,拿到 link up 事件时调用 netif_set_link_up()。如果 PHY 的中断引脚没接,就得用周期任务每 500ms 读一次状态寄存器。我一般建议轮询兜底加中断触发一起用,省得人为操作网线时状态半天不刷新。
4. 核心模块实现:FreeModbus TCP 的寄存器读写与回调链
4.1 FreeModbus 的 TCP 启动流程
FreeModbus 在串口 RTU 和 TCP 之间做了抽象,TCP 模式下协议栈内部用 LWIP 的 socket 接口连接。启动流程需要先初始化事件回调,再设置 IP 端口,最后开启协议栈:
// 二选一:如果板子走RS485用RTU模式 // eMBInit(MB_RTU, 0x01, 0, 38400, MB_PAR_NONE); // 走以太网用TCP模式,监听502端口 eMBTCPInit(502); eMBEnable();严格说这里不是两个都调,而是根据编译宏选择 MB_RTU 还是 MB_TCP。工程里如果只需要 TCP,那就只调 eMBTCPInit() 和 eMBEnable()。eMBTCPInit() 内部会创建一个监听 socket,调用 eMBEnable() 之后 FreeModbus 开始接受连接请求。
端口号 502 是 Modbus 标准端口,如果现场端口被占用,这个值是可以改的,上位机那边也要跟着改。工程里建议做成宏定义,方便二次开发时切换。
4.2 轮询循环与四个回调
Modbus 从站是“请求-应答”模式的,FreeModbus 自己不做并发的 TCP 同时处理,而是要求应用层周期调用 eMBPoll() 来喂事件。任务里通常写成这样的循环:
for (;;) { eMBPoll(); vTaskDelay(pdMS_TO_TICKS(10)); }eMBPoll() 每调用一次,会检查有没有收到完整的 Modbus 帧。收到之后解析出功能码和地址,对应地调用下面几个回调函数。Holding Register 是 Modbus 里最常用的地址空间,大多数设备数据都放在这里:
eMBError_t eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { uint16_t *regs = app_regs.holding; uint16_t idx = usAddress - APP_HOLDING_START; if (eMode == MB_REG_WRITE) { for (USHORT i = 0; i < usNRegs; i++) { regs[idx + i] = (pucRegBuffer[i * 2] << 8) | pucRegBuffer[i * 2 + 1]; } } else { for (USHORT i = 0; i < usNRegs; i++) { pucRegBuffer[i * 2] = regs[idx + i] >> 8; pucRegBuffer[i * 2 + 1] = regs[idx + i] & 0xFF; } } return MB_ENOERR; }这个回调里做的事情就是大端字节序的装拆。Modbus 协议规定寄存器都是大端传输,高字节在前。很多新手在这里翻车:上位机写入的值和读回来的不一致,多半不是协议栈问题,而是字节序写反了。usAddress 是协议地址,一般从 0 或 1 开始,和数组下标之间要留一个偏移量,别直接用地址当下标,否则改地址范围时会越界。
4.3 多任务场景下的寄存器保护
FreeRTOS 工程里除了 modbus_task,可能还有采集任务在往同一个寄存器数组里写数据,传感器任务 10ms 更新一次温度,上位机通过 Modbus 去读,这就有并发读写问题。在寄存器个数不多的情况下,用临界区是最省事的做法:
portENTER_CRITICAL(); app_regs.holding[TEMP_REG] = sensor_value; portEXIT_CRITICAL();临界区会关中断,适合写单个寄存器这种纳秒级操作。如果一次要更新一整个数组,建议改成 FreeRTOS 的 mutex。注意 eMBRegHoldingCB 回调本身运行在 modbus_task 上下文里,如果它内部加了 mutex,那上报采集任务里也要拿同一把锁,不然锁等于没加。
4.4 多连接与异常处理
FreeModbus TCP 默认是支持多连接的,但这个支持有限:每个连接需要一个 socket,而 socket 数量受 LWIP 的 MEMP_NUM_NETCONN 配置限制。来看一个比较典型的翻车现场:电脑上开着 Modbus 调试工具,网页上也开着一个 TCP 调试助手,两边同时连上设备,结果有一边收发开始超时。原因是 LWIP 的 netconn 数量跑满了,FreeModbus 在 accept 新连接时没有资源可用,表现为连接半通不通。解决方法是把 MEMP_NUM_NETCONN 从默认的 4 调到 8,同时给 FreeModbus 的并发连接数做好上限控制。
异常处理上,Modbus 功能码在回调里返回一个错误码,比如 MB_MBE_INVALID_ADDRESS,协议栈会自动回一个异常响应帧,不需要应用层自己拼帧。
5. 避坑指南:移植过程常见的四个坑与排查思路
5.1 上电直接 HardFault,进调试器一看卡在 vAssertCalled
现象:烧录后一运行就进 HardFault,断点打在 HardFault_Handler 里,调用栈指向 vAssertCalled。
原因:最常见的是 FreeRTOS 的堆大小不够,或者某个任务栈溢出。还有一种非常隐蔽的情况是 LWIP 在调度器启动之前被初始化了,里面调用了 pvPortMalloc,此时内存还没就绪。
解决:先把 configTOTAL_HEAP_SIZE 调大,然后把所有任务的栈大小统一放大一倍试跑。再检查初始化顺序,确保先启动调度器,再在 tcpip_thread 里初始化 LWIP。如果还崩,在 FreeRTOS 里打开栈溢出检测钩子,跑一段时间看哪个任务触发的。
5.2 网线插上后 IP 能 ping 通,但 Modbus 工具连不上 502
现象:点灯正常,能 ping 通设备 IP,Modbus 调试器却提示连接失败或超时。
原因:这种一般不是协议栈的问题,而是 FreeModbus 没有正确启动。常见的是 eMBEnable() 里调用了 LWIP 的 API,但在调用前 netif 还没有 link up,socket bind 失败被静默忽略。
解决:把 eMBTCPInit() 和 eMBEnable() 的调用时机挪到 netif link up 之后。或者干脆把网络连接状态作为前置条件,确认 PHY 状态寄存器里的 link 位为 1 再启动 Modbus 任务。工程里建议加一个状态机,网络未就绪时 Modbus 任务直接阻塞等待。
5.3 上位机读写大量寄存器时偶发超时
现象:单点读写正常,上位机一次读 100 个寄存器时经常超时,重试又好了。
原因:LWIP 的 PBUF_POOL_SIZE 太小,多个 TCP segment 同时到达时 pbuf 池耗尽,报文直接被丢弃。这是并发吞量问题,不是协议栈死锁。
解决:把 PBUF_POOL_SIZE 从 16 改到 32,MEM_SIZE 对应调大。同时把 modbus_task 的轮询周期从 10ms 缩短到 5ms,让收发缓冲区尽快消费掉。
5.4 断电重启后要等几十秒才能 ping 通
现象:设备反复断电上电,第二次启动网络要等很久。
原因:上位机或交换机的 ARP 缓存没有过期,设备 IP 没变但 MAC 地址变了(比如从另一台板子备份了配置),ARP 请求一直被旧映射挡住。如果 DHCP 是自动获取的,也可能是在等租约超时。
解决:设备端改成静态 IP,上电就把 netif_set_up() 置好。现场部署时给设备固定 IP,不要依赖 DHCP,工业环境里 DHCP 服务器本来就未必可靠。
6. 二次开发改造:把写死的寄存器表变成参数化数据模型
工程的默认写法是把寄存器数组定义在 .c 文件顶部,加一个寄存器就改一次代码,跑起来没问题,但项目大了维护成本高。我习惯改成一张“寄存器映射表”,表里每一项定义地址、读写权限、类型、对应的变量指针,上位机读哪个地址就查表找到变量地址:
typedef struct { uint16_t addr; uint16_t *var_ptr; uint16_t flags; } modbus_map_item_t; const modbus_map_item_t holding_map[] = { { 0x0000, &sensor_temp, MB_REG_RW }, { 0x0001, &sensor_humi, MB_REG_RW }, { 0x0002, &motor_speed, MB_REG_RW }, };这样改表不动逻辑,加一个新传感器只需要在表里加一行,然后把变量声明好。对于生产环境,甚至可以把这个表放到 Flash 里,做成支持在线配置参数的重映射结构。
改完之后验证分三步走:第一步先用通用 Modbus 调试工具做功能验证,把所有改过的寄存器读写一遍;第二步用两个客户端同时连接设备,分别读写不同地址段,确认互不干扰;第三步做长时间稳定性测试,至少连续跑 24 小时,同时用一个计数器寄存器记录 Modbus 请求次数,确认没有漏帧。
从那以后,我每次改寄存器布局都会强制走一遍这个流程,表格、回调、上位机地址表三处同步更新,少了任何一处都会在现场被坑到。这个工程的移植思路本身很通用,换到其它带以太网的 MCU 上,骨干逻辑也是同一套,只是 PHY 驱动和中断优先级要按新芯片重新调一遍。希望帮到你。
本文还有配套的精品资源,点击获取