☰
STM32H743+FreeRTOS+LWIP+FreeModbus实现Modbus TCP从站寄存器远程读写
2026/10/17 3:13:34 网站建设 项目流程

简介:面向正点原子STM32H743阿波罗开发板的嵌入式开发者,这份资源提供了FreeRTOS + LwIP + FreeModbus TCP/IP的完整移植工程。工程以《lwIP例程11 lwIP_SOCKET_TCP客户端实验》为网络基础,结合《基于STM32的ModBus实现(二)移植FreeMODBUSTCP》实现Modbus TCP服务,整合了RTOS任务调度、网络协议栈和Modbus寄存器读写功能,可直接在开发板上验证远程通信与寄存器操作。压缩包共1040个文件,以C源码(约306个.c)和头文件(约375个.h)为主,还包含Keil工程文件、编译生成的hex/axf固件、map映射表、汇编启动文件及readme说明等,整体大小约57.73MB。目前已有1392人学习下载,适合有STM32基础、正在学习RTOS与网络协议栈的工程师。借助该工程可以快速搭建开发环境,参照源码理解FreeRTOS如何与LwIP、FreeModbus协同工作,也可直接烧录固件进行功能测试,有效缩短协议栈移植周期。

1. stm32 网络协议实战:把 H743 做成能远程读写寄存器的 Modbus TCP 从站

这套源码包解决的痛点非常明确:在 STM32H743 上,既要有实时操作系统调度任务,又要通过网口收发 TCP/IP 数据,还得把数据接进工业现场最常见的数据采集模型。拿到手不是普通的点灯工程,而是 FreeRTOS + LWIP + FreeModbus 三者已经整合好的完整网络协议栈框架。适合做设备联网、远程运维、数据采集网关的嵌入式开发者。你不需要从零去啃 LWIP 的移植文档,也不需要自己设计 Modbus 报文解析,照着工程把寄存器回调填上,就能用 Modbus Poll 这样的上位机工具,通过网络读写单片机里的变量,再往外扩温度、电压、开关状态一类真实数据都行。

2. 架构拆解:H743 的 ETH 外设、FreeRTOS、LWIP 与 FreeModbus 之间如何组织

2.1 为什么选 STM32H743 作为网络协议从站方案

STM32H743 是 Cortex-M7,主频能到 400MHz,Modbus TCP 这种小包业务连 20% 负载都跑不到。芯片内部带一路 10/100M 以太网 MAC,外接一颗 PHY 物理层芯片就能出网口。MAC 与 PHY 之间用 RMII 接口连接,只要 7 根数据线,比 MII 少了一半引脚;代价是 RMII 的参考时钟必须是 50MHz,而且收发共用。这个 50MHz 从哪来很关键:可以由板载 50MHz 有源晶振直接供给 PHY,也可以由 STM32 的 MCO2 引脚输出。拿到开发板后的第一件事,就是用示波器或者万用表确认 REF_CLK 是板上自产还是 MCU 外输,否则后面网络怎么调都闷着不通。

再一个容易被忽略的是 H743 的内存拓扑。Cortex-M7 有自己的 ITCM 和 DTCM,通过 TCM 接口访问,速度最快,但以太网 DMA 访问不到这两块。ETH DMA 能访问的是 AXI SRAM、SRAM1/2/3 和 SDRAM。所以网卡 DMA 描述符和收发缓冲绝不能放到 TCM 里,一旦放错,表现就是代码在 TCM 里跑得飞快,网络却一收包就死机。源码包里把 DMA 描述符单独放了一个段,目的就是不让它落到 TCM。H743 的双 Bank Flash 本来是为了无缝升级设计的,如果你的上位机以后要做远程固件更新,这套网络架构也能直接挂 bootloader,这是它比 F1/F4 更适合作联网网关的原因之一。

2.2 三个开源组件各自扮演的角色

先看 FreeRTOS。它在这个工程里主要做任务调度和资源隔离。LWIP 的 tcpip_thread 要持续响应网络事件,FreeModbus 的应用逻辑也要轮询状态机,再加上你自己的采集任务、显示任务,裸机 while(1) 里写调度很容易被某个阻塞调用拖死。FreeRTOS 把这些任务切成独立的时间片,并通过信号量、队列在任务之间传递事件。有个容易踩的细节:网络中断回调里不能直接调用 LWIP 的阻塞 API,正确做法是发信号量或者用tcpip_input把数据包丢给 tcpip_thread 去处理,这也是 FreeRTOS 在这里最大的价值。

LWIP 是协议栈本体,提供 IP、ICMP、TCP、UDP 以及链路层封装。它内部有两种 API:raw API 和 netconn API。raw API 基于回调,内存占用小,但逻辑散;netconn API 更像 socket,每个连接占用一个线程,代码可读性好。FreeModbus 的 TCP 移植口内部基于 netconn API 实现,所以这个工程天然依靠多线程环境运行。LWIP 并不关心你用的是哪颗 MCU,它对外留了一个struct netif接口层,要求你实现发包、接收、初始化三个底层函数,具体到 H743 上就是操作 STM32 的 ETH MAC 和外接 PHY。

FreeModbus 是从站协议栈,它把 Modbus 报文解析、功能码分发、异常回应都包好了,留给你的是一组寄存器回调函数。RTU 模式要配置串口波特率、校验位;TCP 模式则完全交给 LWIP 传输。这里有个常被误解的点:Modbus TCP 帧里没有 CRC 字段,抓包时别按 RTU 思路去找校验码。MBAP 头里的事务标识符、协议标识符、长度是重点,后面的数据部分和 RTU 的 PDU 基本一致,所以你的寄存器回调在两种模式里可以复用同一套逻辑。

2.3 源码包目录结构与关键文件

一个标准的工程目录大致长这样:

stm32h743_freertos_lwip_modbus/ ├─ Core/ │ ├─ Inc/ │ └─ Src/ │ ├─ main.c │ ├─ freertos.c │ └─ stm32h7xx_it.c ├─ LWIP/ │ ├─ lwip-2.0.3/ │ │ ├─ src/core/ │ │ ├─ src/netif/ethernetif.c │ │ └─ src/include/ │ └─ app/lwip.c ├─ FreeModbus/ │ ├─ modbus/ │ └─ port/porttcp.c ├─ Drivers/STM32H7xx_HAL_Driver/ └─ MDK-ARM/

不同源码包里的 LWIP 版本可能不同,目录名会有差异,但职责划分是一样的。真正需要你动手改的文件集中在三处:LWIP 的 ethernetif.c、FreeModbus 的 porttcp.c、以及业务层的寄存器回调文件。下面把关键文件的作用列出来,方便你拿到包以后按图索骥:

关键文件职责
lwip.c完成 netif 注册、静态 IP 或 DHCP、tcpip_thread 初始化
ethernetif.cH743 网卡底层的 DMA 收发、中断回调、pbuf 转换
porttcp.cFreeModbus 的 TCP 端口实现,基于 LWIP netconn API
main.c / freertos.cRTOS 任务创建、信号量队列声明、外设初始化
modbus_regs.c用户寄存器数组与 eMB*CB 四个回调函数实现

这部分架构心里有底后,下一步就是真刀真枪把它跑起来,看 LWIP 到底怎么和硬件握手。

3. 移植落地:从 CubeMX 生成工程到 Modbus Poll 读到寄存器

3.1 CubeMX 里的关键配置

如果你拿到的是编译好的完整工程,可以跳过 CubeMX,但换板卡时多半要重新配置引脚。CubeMX 里选 STM32H743ZIT6,打开 ETH 外设,模式选 RMII。ETH 参数里 PHY 地址默认是 0,但板载 PHY 可能是 LAN8720 或 DP83848,地址不一定一样,必须以 PHY 手册为准。MAC 地址填一组不冲突的,习惯上我用 00:80:E1:00:00:01。时钟树方面:HSE 用板载 25MHz,AHB 配到 400MHz,APB1、APB2 不超过 100MHz。RMII 的 50MHz 参考时钟,如果来自板载晶振,ETH 配置里选外部时钟;如果由 MCO2 生成,则要配好 MCO2 的分频器和选择器。这一步出错的典型表现是HAL_ETH_Init卡死在 PHY 通信超时,因为 PHY 根本没收到时钟,MDIO 总线上没有回应。

ETH DMA 描述符数量按常规设即可:

参数建议值说明
RX Descriptor 数量4越大越抗突发流量,但占用 RAM
TX Descriptor 数量4如果 TCP 吞吐量不够可加到 8
RX Buffer 大小1536要能容纳最大以太网帧 1518 字节

CubeMX 生成代码后,还需要手动定义 DMA 描述符数组和 DMA 收发缓冲区,它们必须 32 字节对齐:

#define ETH_RX_DESC_CNT 4 #define ETH_TX_DESC_CNT 4 #define ETH_RX_BUF_SIZE 1536 __attribute__((section(".eth_dma"), aligned(32))) ETH_RxDescTypeDef dma_rx_desc[ETH_RX_DESC_CNT]; __attribute__((section(".eth_dma"), aligned(32))) ETH_TxDescTypeDef dma_tx_desc[ETH_TX_DESC_CNT]; __attribute__((section(".eth_dma"), aligned(32))) uint8_t rx_buff[ETH_RX_DESC_CNT][ETH_RX_BUF_SIZE];

这段代码把描述符和缓冲放到了独立段.eth_dma,并强制 32 字节对齐。ETH DMA 描述符的地址要求低 5 位为 0,否则硬件可能直接报总线错误;section是把这组变量固定放到链接脚本指定的可 DMA 内存区域,我一般把这个段安排在 AXI SRAM 里。不熟悉链接脚本的话,也可以直接用ALIGN_32BYTES宏定义普通数组,再用 MPU 关闭这段内存的缓存。

接下来是 H743 特有的 MPU 配置,很多 F1/F4 转过来的开发者会在这里翻车。H743 的 D-Cache 如果开着,ETH DMA 往内存写数据时,CPU 读到的可能是缓存里的旧数据,于是接收到的帧长度、校验值都是乱的。最直接的办法是把 AXI SRAM 配成不缓存:

MPU_Region_InitTypeDef MPU_InitStruct = {0}; HAL_MPU_Disable(); MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress = 0x24000000; MPU_InitStruct.Size = MPU_REGION_SIZE_512KB; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.IsBufferable = MPU_REGION_NOT_BUFFERABLE; MPU_InitStruct.IsCacheable = MPU_REGION_NO_CACHE; MPU_InitStruct.IsShareable = MPU_REGION_NOT_SHAREABLE; HAL_MPU_ConfigRegion(&MPU_InitStruct); HAL_MPU_Enable(MPU_CONFIG_DEFAULT);

这里0x24000000是 AXI SRAM 的起始地址。整片配成不缓存,换来的是 DMA 和 CPU 之间的数据一致性,代价是这块内存的读取速度下降。如果你既想要缓存性能,又想要 DMA 安全性,就只把.eth_dma段所在的小区域设为 no-cache,其余保持 cacheable,这个进阶优化到后面的二次开发里再展开。

3.2 FreeRTOS 任务划分与栈空间分配

FreeRTOS 的配置文件里,下面几个宏决定了网络协议栈能不能跑稳:

#define configTOTAL_HEAP_SIZE (64 * 1024) #define configMINIMAL_STACK_SIZE 128 #define configUSE_TIMERS 1 #define configMAX_PRIORITIES 5

64KB 堆给任务栈、信号量、消息队列使用,实际够跑,但如果你还要在上面跑复杂的业务逻辑,建议直接加大到 128KB,H743 的 RAM 足够。configUSE_TIMERS置 1,因为 LWIP 的 DHCP 客户端依赖软件定时器。

任务规划一般分成这几类:

任务优先级栈大小职责
tcpip_thread31024 wordsLWIP 协议栈主循环
ModbusPollTask2512 words周期调用 eMBPoll 处理请求
UserTask1512 words业务采集、控制逻辑
idle_task0128 wordsFreeRTOS 空闲任务

优先级数字越大,任务优先级越高。经验是 tcpip_thread 要比 Modbus 任务高,否则大量网络中断时,Modbus 任务抢占了 CPU,协议栈处理不过来,TCP 窗口会越变越小。栈大小按 word 算,1024 words = 4KB,如果任务里用了 printf、snprintf 这类大栈函数,要酌情加大。跑一轮压力测试就能看出谁栈不够、谁优先级不对,不需要一开始就拼命调。

3.3 LWIP 底层三件套的实现要点

LWIP 的移植核心是三个底层函数:初始化、发包、收包。

static void low_level_init(struct netif *netif) { netif->hwaddr_len = 6; netif->hwaddr[0] = 0x00; netif->hwaddr[1] = 0x80; netif->hwaddr[2] = 0xE1; netif->hwaddr[3] = 0x00; netif->hwaddr[4] = 0x00; netif->hwaddr[5] = 0x01; netif->mtu = 1500; netif->flags = NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP | NETIF_FLAG_LINK_UP; dma_descr_init(); // 配置 DMA 描述符环 HAL_ETH_Start_IT(&heth); // 启动 MAC 并开启接收中断 }

netif->hwaddr是 MAC 地址,前三字节填厂商 OUI,后三字节自己定。mtu = 1500是标准以太网 MTU,Ping 包 1472 字节加上 28 字节首部正好在范围内。NETIF_FLAG_LINK_UP表示强制链路为已连接,调试阶段建议直接置 1,省得 PHY 状态没上报时 LWIP 拒绝发包。

static err_t low_level_output(struct netif *netif, struct pbuf *p) { uint8_t *tx_buf = (uint8_t *)TX_BUF_ADDR; struct pbuf *q; uint32_t len = 0; if (p->tot_len > ETH_TX_BUF_SIZE) { return ERR_MEM; } for (q = p; q != NULL; q = q->next) { memcpy(&tx_buf[len], q->payload, q->len); len += q->len; } HAL_ETH_TransmitFrame(&heth, tx_buf, len); return ERR_OK; }

这段发送逻辑把 pbuf 链拷贝成连续内存再交给 DMA。pbuf 可能是多个节点拼接的,每个节点分散在不同内存区,而 H743 的 DMA 描述符一次装载多个地址会更复杂,所以这里直接合并成一块连续 buffer。ETH_TX_BUF_SIZE是发送缓冲区的上限,如果p->tot_len超过它,说明有超大包需要处理,要么调大缓冲区,要么改用描述符汇聚模式。

static struct pbuf *low_level_input(struct netif *netif) { struct pbuf *p; uint32_t frame_len = 0; uint8_t *buf = NULL; if (HAL_ETH_GetRxDataBuffer(&heth, &buf) != HAL_OK) { return NULL; } HAL_ETH_GetRxDataLength(&heth, &frame_len); p = pbuf_alloc(PBUF_RAW, frame_len, PBUF_POOL); if (p != NULL) { pbuf_take(p, buf, frame_len); } HAL_ETH_ReleaseRxDataBuffer(&heth); return p; }

HAL_ETH_GetRxDataBuffer拿到 DMA 接收缓冲区的地址,GetRxDataLength拿到帧长,pbuf_alloc从 LWIP 内存池申请一块 pbuf,再pbuf_take把数据拷进去,最后释放 HAL 接收 buffer。如果 pbuf 分配失败,也必须释放 HAL buffer,否则 DMA 描述符环会被占满,后续帧全部被丢。不同 HAL 版本里函数名可能叫HAL_ETH_ReadData,但思路都是“拿 buffer、取长度、拷进 pbuf、释放 buffer”四步。

3.4 构建、下载与首次验证

编译工程时,用 MDK 就选 AC6 编译器,把 LWIP 源码目录、HAL 驱动目录、FreeModbus 目录都加进 Include Path。常见的编译错误集中在头文件路径上,比如lwipopts.h找不到、FreeRTOSConfig.h路径不对。如果用了 CMake,还要确认LWIP_POSIX_SOCKETS_IO_NAMES宏是 0,避免和 netconn API 的函数重名。

下载到板子后,用网线连接电脑,给电脑配置静态 IP 192.168.1.10,板子默认 IP 一般是 192.168.1.100,也可能在源码里改成别的。先ping 192.168.1.100,能通说明 ARP、ICMP 都正常,LWIP 已经跑起来了。接着打开 Modbus Poll,创建 TCP/IP 连接,填板子 IP,端口 502,协议选 Modbus TCP,从站号填 1。试着读一个保持寄存器地址,比如地址 0,对应上位机显示的 40001,能返回数据,整套链路就算打通了。

4. 核心链路拆解:一个 Modbus TCP 请求从网口到寄存器的完整旅程

网络通了之后,更重要的是知道一帧数据具体怎么流转,下面拆这条链路。

4.1 从 ISR 到 ethernetif_input 的帧分发

ETH 每收到一帧,会进入HAL_ETH_IRQHandler,再触发接收完成回调。常见做法是在中断回调里做一次 buffer 拷贝,然后通过tcpip_input把 pbuf 交给 LWIP 的 tcpip_thread 处理。

void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { struct pbuf *p = low_level_input(heth->Netif); if (p != NULL) { if (tcpip_input(p, heth->Netif) != ERR_OK) { pbuf_free(p); } } }

这个回调要求heth->Netif已经指向 LWIP 注册的 netif 结构体。tcpip_input会把 pbuf 通过内部邮箱发给 tcpip_thread,返回ERR_OK表示收下;如果返回非零,说明邮箱满或其他错误,必须立即pbuf_free释放,否则就是一次内存泄漏。中断回调里的操作要尽可能短,这里的重量级操作是low_level_input里的pbuf_alloc和memcpy,100M 下尚可接受。如果将来换千兆网口或要求更高吞吐,就必须改成零拷贝方案,直接把 DMA 描述符挂给 pbuf。

4.2 LWIP tcp_recv 与 FreeModbus 的事件队列

TCP 连接建立后,LWIP 会调用注册的tcp_recv。FreeModbus 的 porttcp.c 内部用 netconn API 创建了一个 TCP 服务器并监听 502 端口。简单示意如下:

static void tcp_server(void *arg) { struct netconn *conn, *newconn; conn = netconn_new(NETCONN_TCP); netconn_bind(conn, IP_ADDR_ANY, 502); netconn_listen(conn); while (1) { newconn = netconn_accept(conn); if (newconn != NULL) { // 新建线程或复用接收逻辑处理 newconn } } }

实际工程里,porttcp.c 会把 accept、recv 处理封装好,你不需要自己实现。我们要做的是在 FreeRTOS 环境下,调用eMBTCPInit(502)初始化 TCP 端口,再eMBEnable()使能从站,之后周期调用eMBPoll()。eMBPoll()内部会从事件队列里取事件,如果没有连接请求或被唤醒,它很快返回。RTOS 移植版里,事件队列往往用 FreeRTOS 的队列或二值信号量实现,接收线程解析完一帧请求就置位一次队列,让 Modbus 任务去处理。能 ping 通但连不上 502,多半是eMBTCPInit(502)没成功,或者eMBEnable()没被调用。

4.3 寄存器回调的地址映射与大小端处理

FreeModbus 对上层暴露四个回调:线圈、离散输入、保持寄存器、输入寄存器。主站发来什么功能码,协议栈就分发到对应回调。下面是最常用的保持寄存器回调:

uint16_t reg_holding[128]; eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { USHORT i; if ((usAddress + usNRegs) > 128) { return MB_ENOREG; } if (eMode == MB_REG_READ) { for (i = 0; i < usNRegs; i++) { pucRegBuffer[2 * i] = (reg_holding[usAddress + i] >> 8) & 0xFF; pucRegBuffer[2 * i + 1] = reg_holding[usAddress + i] & 0xFF; } } else { for (i = 0; i < usNRegs; i++) { reg_holding[usAddress + i] = (pucRegBuffer[2 * i] << 8) | pucRegBuffer[2 * i + 1]; } } return MB_ENOERR; }

这个函数维护了 128 个保持寄存器。usAddress是协议栈传来的地址偏移,从 0 开始,但上位机显示保持寄存器时从 40001 开始,所以偏移 0 对应上位机的 40001。代码里按 Modbus 大端序处理:读时高字节放前面,写时再把高字节拼回去。如果少了这一步,上位机写进 0x1234,读出来会变成 0x3412。MB_ENOREG表示地址越界,主机会收到异常码 02,这是调试时判断地址范围最直接的反馈。

4.4 FreeRTOS 堆、LWIP 内存池与 pbuf 生命周期

LWIP 的内存管理有多种模式,最常见的坑是以为 LWIP 会使用 FreeRTOS 的堆。实际上,LWIP 默认用自己的mem_malloc管理一片静态数组,和 FreeRTOS 的configTOTAL_HEAP_SIZE是两套体系。你想调网络内存,要改的是 lwipopts.h:

#define MEM_ALIGNMENT 4 #define MEM_SIZE (40 * 1024) #define PBUF_POOL_SIZE 20 #define PBUF_POOL_BUFSIZE 1536 #define LWIP_TCPIP_THREAD_STACKSIZE 1024

PBUF_POOL_SIZE是预分配 pbuf 池,每个 pbuf 对应一帧缓冲,调大能提升并发,代价是多吃内存。TCP 发送一个大块数据时,LWIP 会切成多个 TCP 段,每个段占用一个 pbuf,所以池子越小,TCP 窗口越小,吞吐量越低。PBUF_POOL_BUFSIZE是每个 pbuf 的容量,一般按 1536 字节设置。

pbuf 生命周期要特别注意:接收方向,tcpip_input收下 pbuf 后,要等 tcpip_thread 处理完才释放;发送方向,low_level_output返回后 HAL 层可能还在 DMA 发送,LWIP 不会立刻释放 pbuf。因此不要在回调里抢着释放 pbuf,也不要在 DMA 发送完成前复用它的内存。我习惯在low_level_output里先把数据搬进 DMA buffer,确认不再引用 pbuf 内容后再返回,这样上层在何时释放都不会出事。

5. 避坑指南:从 ping 不通到数据错位,移植最容易翻车的 5 个问题

这一章直接讲现场踩坑经验。正式开调之前,建议先排一下排查顺序:物理层(PHY 芯片是否工作)→ 链路层(DMA 收发包是否正常)→ 传输层(TCP 监听是否建立)→ 应用层(Modbus 回调是否正确)。下面每一条都按现象、原因、解决来写。

5.1 现象:HAL_ETH_Init 卡死,PHY 通信超时

这是整个移植里最常见的现象,而且最让人摸不着头脑。网线明明插着,板子供电正常,但初始化就过不去。

原因:RMII 参考时钟没起来,PHY 芯片根本没有工作时钟,MDIO 总线上读不到 PHY 的 ID 寄存器。特别常见的是开发板上有 50MHz 晶振,但 CubeMX 默认配置用了 MCO2 输出,两边都以为对方在给,结果 PHY 时钟悬空。

解决:先用示波器量 ETH_RMII_REF_CLK 引脚,频率稳定在 50MHz 才算正常。然后确认 PHY 地址,读 PHY ID 寄存器验证。如果时钟有、PHY 也复位了,还是卡初始化,就把 HAL_ETH_Init 里的超时时间放大,或者断开 PHY 的复位脚重新拉一次再做。调试网络最怕玄学,量到波形才有依据。

5.2 现象:能 ping 通,但 Modbus Poll 连接 502 超时

能 ping 通说明 LWIP 已经跑起来,问题出在 FreeModbus 服务器。这类现象不是协议栈坏了,而是应用层服务没起来。

原因:eMBTCPInit(502)没有被调用,或者eMBEnable()没有返回成功;还有一种更隐蔽的情况,eMBPoll()所在的 Modbus 任务优先级太低,一直被其他任务抢占,导致接收队列的事件永远得不到处理。

解决:在eMBTCPInit处加日志,返回值必须是MB_ENOERR。然后确认eMBEnable调用。最后检查 Modbus 任务是否真的在运行,最简单的方法是任务里翻转一个 GPIO,用示波器看。要是任务在跑还连不上,用 Wireshark 抓包看有没有 SYN 和 SYN-ACK,如果没有,说明 LWIP 的 TCP server 根本没有进入监听状态。

5.3 现象:运行几分钟后随机 HardFault

这类问题非常打击人:刚烧进去一切正常,跑着跑着突然死机,复位后又正常,像是抽签。

原因:D-Cache 开启后,ETH DMA 往内存写数据,CPU 的缓存里还是旧数据,接收到的长度字段和内容就变成随机的,程序最终走到非法地址。这是 H743 和 F1/F4 最大的区别,也是移植 H7 的必修课。

解决:要么在 MPU 里把 ETH DMA 所在内存区域配成 non-cacheable,要么在每次使用前手动执行SCB_InvalidateDCache_by_Addr失效缓存,在发送前执行SCB_CleanDCache_by_Addr清洗缓存。还有一种验证阶段常用的办法:直接在启动文件里关掉 D-Cache,先排除缓存问题,再谈性能。

5.4 现象:TCP 连接一段时间后无法创建新连接

设备运行几个小时,上位机突然连不上,重启板子又恢复。这种情况通常不是死机,而是 LWIP 的资源被耗尽。

原因:LWIP 里的每个 TCP 连接都会占用一个 TCP PCB,断开后进入 TIME_WAIT 状态,默认要等 2MSL 才会释放。如果上位机频繁断开重连,PCB 就会被 TIME_WAIT 连接占满,新连接自然无法建立。这是 LWIP 的经典问题,不是 H743 特有。

解决:在 lwipopts.h 里调小LWIP_TCP_TIME_WAIT_DURATION,比如把 2MSL 从 60000ms 改成 5000ms,释放速度会明显加快。同时看MEM_SIZE是否够,必要时把PBUF_POOL_SIZE从 20 加到 40,还要给 TCP 连接加个空闲超时,异常断开时能及时回收资源。

5.5 现象:寄存器写入后读回来的字节顺序不对

上位机写 0x0102,板子读出来是 0x0201,看着像把整形拆错位了。

原因:Modbus 协议规定数据大端传输,高字节在前;而 STM32 的 Cortex-M7 是小端 CPU,内存里低字节在前。回调里没有做字节序转换,数据就反了。

解决:在eMBRegHoldingCB里统一处理。读取时pucRegBuffer[2*i]放高字节、pucRegBuffer[2*i+1]放低字节;写入时再把两个字节拼回uint16_t。凡是涉及多字节的变量,温度、电压、计数值都要遵守这个规则。以后看到回调里<< 8,不要觉得是多余,那正是 Modbus 大端序的要求。

6. 二次开发思路:改端口、扩展功能码、把真实外设数据挂进寄存器

拿到工程后,你多半不会满足于读一个静态数组。第二开发最常见的是四件事。

改监听端口:把eMBTCPInit(502)里的 502 改成 1502 或自定义端口即可。注意上位机要和这个端口保持一致,Windows 防火墙记得放行。改从站地址:Modbus TCP 的 MBAP 头里也有从站地址位,虽然很多上位机忽略它,但严谨的做法是把eMBTCPInit后的从站地址设为固定值,方便点对点管理。

扩展自定义功能码:FreeModbus 默认支持 01、02、03、04、05、06、15、16 这些常规功能码。如果是特殊设备协议,可以在解析层增加分支,但更省事的做法是保留一个未使用的功能码做钩子。我的习惯是把 0x41 作为设备诊断功能码,上位机按特殊功能码发请求,板子返回固件版本、运行时长、错包计数。

挂真实传感器:在输入寄存器回调里读 ADC 或外存变量,格式和保持寄存器一样:

eMBErrorCode eMBRegInputCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs) { if (usAddress == 0) { // 把 adc_temperature 按大端写入 pucRegBuffer pucRegBuffer[0] = (uint8_t)(adc_temperature >> 8); pucRegBuffer[1] = (uint8_t)(adc_temperature & 0xFF); } return MB_ENOERR; }

性能调优方面,先把PBUF_POOL_SIZE从 20 提到 40,再把 tcpip_thread 栈设到 1536 words。如果数据量到了几百 KB/s,就可以考虑把low_level_input里的memcpy改成零拷贝,让 DMA 描述符直接指向 pbuf 的 payload,前提是这段内存必须 non-cacheable。调完一定用 Modbus Poll 连续读一万次以上,验证没有随机超时。

因为这个项目,我后来给自己立了个规矩:凡是改过网络相关代码,都必须先抓包留底,再烧进板子用 Modbus Poll 压测至少 30 分钟,确认没有一处随机异常才敢发布。嵌入式联网的玄学问题,大多是缓存一致性和资源泄漏造成的,反复验证比头脑里的代码推演更可靠。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询