ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32H743上FreeModbus TCP从站移植与Cache避坑

STM32H743上FreeModbus TCP从站移植与Cache避坑

简介:面向STM32H743平台嵌入式网络开发者的FreeRTOS、lwIP与FreeModbus-TCP/IP整合移植工程,基于正点原子阿波罗开发板,将lwIP Socket TCP客户端实验与FreeModbus TCP移植例程融合为可编译运行的参考方案,适合学习实时任务调度、TCP通信及Modbus TCP协议落地的中高级读者。压缩包共1040个文件、约57.73MB,以375个头文件、306个C源文件为核心,涵盖HAL外设驱动、lwIP协议栈、FreeRTOS内核与FreeModbus实现;并含编译中间文件、Keil工程、分散加载脚本及map、axf、hex等产物,便于对照构建与烧录验证。工程按硬件驱动、协议栈与应用任务分层组织,配置开关和接口实现较易定位,对工业以太网数据采集与远程控制场景有参考价值。已有1407人学习下载。读者可据此理解协议栈目录组织与裁剪配置,掌握网络接口接入、Socket通信和Modbus TCP报文处理,并借助工程产物复现实验、排查链接与内存配置问题。

1. 从一块 ping 得通却读不到寄存器的板子说起

某实验室有一套 STM32H743 的控制板,网口灯正常,电脑 ping 得通,Wireshark 里能看到 502 端口的 Modbus TCP 请求,但从站就是不回数据。换板、换网线、换 PHY 配置,折腾了两天,最后把 D-Cache 关掉,一切正常。这件事挺反直觉:STM32H743 上跑 FreeRTOS + LwIP + FreeModbus,卡人的往往不是协议栈本身,而是 H7 的 Cache、DMA 描述符和 RTOS 优先级这三者之间的配合。这份工程把四个组件拼成一个能跑的 Modbus TCP 从站,主频 480MHz,对外暴露保持寄存器和输入寄存器,适合做工业网关、数据采集器,或者第一次在 H7 上移植网络协议栈的开发者对照复现。下面按工程落地顺序拆,配置和代码都能直接拿去改。

2. 架构与底层地基:H743 的时钟、MPU/Cache 与 ETH 初始化

这套组合的分工很明确:FreeRTOS 管任务调度,LwIP 管 TCP/IP 协议栈,FreeModbus 管 Modbus TCP 应用层,STM32H743 的 ETH 外设加外部 PHY 管物理收发。先把任务和内存摆对,再谈协议栈移植,否则后面每一个现象都会变成玄学。

2.1 任务怎么切:从站轮询、协议栈内核与网卡接收

我一般切三个任务,而不是把 LwIP 和 Modbus 塞进一个 while 循环。FreeModbus 的 TCP 端口层依赖eMBPoll()周期性取报文,LwIP 的tcpip_thread负责处理协议栈消息,网卡接收可以挂在 ETH 中断里发信号量,也可以单独开一个低优先级任务。任务划分如下:

任务名优先级栈大小(字)职责
ethernetif_input6512等 ETH 中断信号量,把 DMA 缓冲区搬成 pbuf,投递给tcpip_thread
tcpip_thread51024LwIP 内核线程,处理 ARP、IP、TCP 状态机
modbus_poll4768调用eMBPoll(),处理 502 端口请求并更新寄存器
defaultTask1256板级指示灯、看门狗喂狗

优先级数字越大越高,这是 FreeRTOS 的惯例。ethernetif_input必须高于tcpip_thread,否则高包速率下接收信号量会丢;tcpip_thread要高于modbus_poll,否则 Modbus 任务忙等时会拖慢协议栈。栈大小别照抄,tcpip_thread里会调pbuf_alloc和netconn相关函数,1024 字是 H7 上比较稳的起点,内存紧张可以压到 768 字再跑压力测试。

任务之间的数据流不复杂:ETH 中断收到帧,释放二值信号量;ethernetif_input取到信号量后调用ethernetif_input()把帧交给 LwIP;tcpip_thread处理后,FreeModbus 的 netconn 收到数据,eMBPoll()解析功能码,回调函数读写寄存器数组,最后netconn_write把响应发回去。这条链路里任何一环阻塞,表现都是“ping 通但 Modbus 超时”,所以任务优先级和栈要一开始就定好。

2.2 内存怎么摆:DTCM、AXI SRAM 与 DMA 描述符

H743 的内存域比 F4 复杂,搬错段是最常见的翻车点。芯片内部大致是这样:

内存区域起始地址大小能否被 ETH DMA 访问典型用途
ITCM0x0000000064KB否中断向量、热代码
DTCM0x20000000128KB否主栈、频繁访问的变量
AXI SRAM0x24000000512KB是FreeRTOS 堆、LwIP 内存池
SRAM10x30000000128KB是ETH 描述符、收发缓冲区
SRAM20x30020000128KB是可做 LwIP 补充堆
SRAM30x3004000032KB是以太网专用缓冲
SRAM40x3800000064KB是低功耗域,一般不用

关键结论只有一句:ETH 的 DMA 描述符和收发缓冲区不能放 DTCM,必须放 SRAM1/SRAM2/SRAM3 或 AXI SRAM。我一般把描述符固定到 SRAM1 开头,链接脚本里单开一段:

/* STM32H743 链接脚本片段:把以太网 DMA 段放到 D2 域 SRAM1 */ MEMORY { DTCMRAM (xrw) : ORIGIN = 0x20000000, LENGTH = 128K AXI_RAM (xrw) : ORIGIN = 0x24000000, LENGTH = 512K D2_SRAM1 (xrw) : ORIGIN = 0x30000000, LENGTH = 128K } SECTIONS { .RxDecripSection (NOLOAD) : { . = ALIGN(32); *(.RxDecripSection) } > D2_SRAM1 .TxDecripSection (NOLOAD) : { . = ALIGN(32); *(.TxDecripSection) } > D2_SRAM1 .RxArraySection (NOLOAD) : { . = ALIGN(32); *(.RxArraySection) } > D2_SRAM1 }

这段的作用是告诉链接器:带RxDecripSection、TxDecripSection、RxArraySection属性的变量别放进默认 RAM,统一落到0x30000000开始的 SRAM1。ETH 初始化结构体里的RxDesc和TxDesc要用__attribute__((section(".RxDecripSection")))修饰,否则 CubeMX 生成的代码默认把描述符放在 AXI SRAM,开了 Cache 之后就会出现“描述符被 CPU 写进 cache、DMA 读到旧值”的问题。

NOLOAD表示这些段不参与启动时的数据拷贝,省启动时间。对齐 32 字节是因为 Cortex-M7 的 Cache line 是 32 字节,描述符跨 cache line 会让 clean/invalidate 变得很难受。FreeRTOS 的heap_4我放在 AXI SRAM,LwIP 的mem_init也用 AXI SRAM,两者用链接脚本切分开,不要互相踩。

2.3 时钟与 MPU:480MHz 下先把 Cache 关对

H743 的 ETH 时钟来源要配对,常见做法是 HSE 25MHz 进 PLL1,CPU 跑 480MHz,PLL1Q 输出 200MHz 给 ETH 内核,RMII 的 50MHz REF_CLK 由外部 PHY 提供。CubeMX 里RCC_PERIPHCLK_ETH选 PLL1Q,分频系数按200MHz / 50MHz算,别把 ETH 时钟超到 200MHz 以上,否则 PHY 侧会间歇性丢包。

时钟配完,紧接着配 MPU。H7 默认开启 D-Cache 和 I-Cache,而以太网 DMA 访问内存不经过 D-Cache,所以描述符区和缓冲区要么配成 non-cacheable,要么在每次收发前后手动 clean/invalidate。我倾向于把 D2 域 SRAM1 整段配成 non-cacheable,省去后面调试 cache 一致性的麻烦:

/* 把 SRAM1 配成 non-cacheable,给 ETH DMA 描述符和缓冲区使用 */ static void MPU_Config(void) { MPU_Region_InitTypeDef init = {0}; HAL_MPU_Disable(); /* Region 0: 0x30000000 起 128KB,Non-cacheable, Non-shareable */ init.Enable = MPU_REGION_ENABLE; init.Number = MPU_REGION_NUMBER0; init.BaseAddress = 0x30000000U; init.Size = MPU_REGION_SIZE_128KB; init.SubRegionDisable = 0x00; init.TypeExtField = MPU_TEX_LEVEL1; /* TEX=1, C=0, B=0 -> Normal, non-cacheable */ init.AccessPermission = MPU_REGION_FULL_ACCESS; init.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE; init.IsShareable = MPU_ACCESS_NOT_SHAREABLE; init.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; init.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE; HAL_MPU_ConfigRegion(&init); /* Region 1: AXI SRAM 保持 cacheable,给 CPU 跑代码和堆 */ init.Number = MPU_REGION_NUMBER1; init.BaseAddress = 0x24000000U; init.Size = MPU_REGION_SIZE_512KB; init.TypeExtField = MPU_TEX_LEVEL0; init.IsCacheable = MPU_ACCESS_CACHEABLE; init.IsBufferable = MPU_ACCESS_BUFFERABLE; HAL_MPU_ConfigRegion(&init); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); } /* main 里这样调用,顺序不能反 */ MPU_Config(); SCB_EnableICache(); SCB_EnableDCache();

TypeExtField和IsCacheable、IsBufferable的组合决定内存属性:TEX=1, C=0, B=0就是 Normal memory 里的 non-cacheable,适合 DMA 区;AXI SRAM 用TEX=0, C=1, B=1走 write-back,CPU 访问快。Region 编号不要和 FreeRTOS 的vPortSetupTimerInterrupt抢,H7 的 HAL 默认只用前几个 region,留出 8 个以上给应用。

有一个容易忽略的细节:开了 D-Cache 之后,SCB_EnableDCache()之前最好先SCB_CleanInvalidateDCache(),否则上电时 cache 里的随机内容可能被写回内存。这句我一般在main里HAL_Init()之后立刻加。

2.4 ETH 与 PHY:RMII 接线和地址扫描

RMII 的引脚比 MII 少,但接错一根就 link down。H743 常见接法是 PA1 做 REF_CLK,PA2 做 MDIO,PA7 做 CRS_DV,PC1 做 MDC,PC4/PC5 做 RXD0/RXD1,PB11 做 TX_EN,PB12/PB13 做 TXD0/TXD1,PHY 复位脚单独用一个 GPIO。这些引脚在 CubeMX 里选ETH外设后会自动标出来,但 PHY 地址不会自动填,得看原理图上 PHYAD 引脚的电平。

LAN8720A 的 PHY 地址由 RXER/PHYAD0 引脚在上电时决定,悬空或下拉一般是地址 0,上拉是地址 1;DP83848 的地址由多个引脚组合,范围 0 到 31。写死一个地址之前,先扫一遍 MDIO 总线,读 PHY 的 ID 寄存器,能省掉半天“为什么 link 不起来”的排查:

/* 扫描 MDIO 总线 0~31,打印能读到的 PHY 地址和 ID */ void ETH_ScanPHY(ETH_HandleTypeDef *heth) { uint32_t id = 0; for (uint8_t addr = 0; addr < 32; addr++) { if (HAL_ETH_ReadPHYRegister(heth, addr, PHY_ID1_REG, &id) == HAL_OK) { if (id != 0xFFFF && id != 0x0000) { printf("PHY found at addr %u, ID1=0x%04lX\r\n", addr, id); } } HAL_Delay(2); /* MDIO 时序慢,给 PHY 一点时间 */ } }

PHY_ID1_REG一般是0x02,PHY_ID2是0x03,两个寄存器拼起来就是厂商和型号。扫到地址后,把heth.Init.PHYAddress改成对应值,再调用HAL_ETH_Init。如果扫描结果全是0xFFFF,先查 MDC 有没有输出、MDIO 有没有上拉电阻,再查 PHY 的复位脚是不是一直被拉低。这一步过了,netif_set_link_up才有意义。

3. LwIP 在 FreeRTOS 上跑起来:sys_arch 与 ethernetif 移植

LwIP 能不能在 FreeRTOS 上跑稳,取决于两个文件:sys_arch.c负责把信号量、邮箱、线程接到 FreeRTOS,ethernetif.c负责把 DMA 缓冲区搬成pbuf。这两个文件通了,协议栈就活了;不通,表现就是 ping 不通或者 ping 通了但 TCP 建链失败。

3.1 lwipopts.h 里必须先定的几个开关

LwIP 的配置项有上百个,移植阶段真正要动的是下面这些。我一般从lwipopts.h里逐项确认,而不是用默认值直接编:

/* lwipopts.h:FreeRTOS + netconn 模式的关键开关 */ #define NO_SYS 0 /* 使用操作系统模式 */ #define LWIP_NETCONN 1 /* FreeModbus 的 TCP 端口层依赖 netconn */ #define LWIP_SOCKET 0 /* 不用 BSD socket,省内存 */ #define LWIP_TCPIP_CORE_LOCKING 1 /* 允许应用层直接调用部分 API */ #define MEM_ALIGNMENT 4 #define MEM_SIZE (32 * 1024) /* LwIP 堆,Modbus 场景 32KB 够 */ #define MEMP_NUM_PBUF 16 #define PBUF_POOL_SIZE 32 /* 接收缓冲池,小于这个数大帧会丢 */ #define PBUF_POOL_BUFSIZE 1524 #define TCP_MSS 1460 #define TCP_SND_BUF (4 * TCP_MSS) #define TCP_WND (4 * TCP_MSS) #define LWIP_STATS 1 #define LWIP_STATS_DISPLAY 1

NO_SYS=0是前提,等于 1 就没有tcpip_thread,FreeModbus 的 netconn 也用不了。MEM_SIZE和PBUF_POOL_SIZE是一对:MEM_SIZE管协议栈自己的堆,PBUF_POOL_SIZE管网卡收包池,Modbus TCP 报文很短,但 ARP、ICMP 也会进来,32KB 加 32 个 pbuf 在 H743 上很宽裕。TCP_SND_BUF给 4 个 MSS,是因为 FreeModbus 一次响应最多 260 字节左右,留出余量给重传。

LWIP_STATS打开会占一点代码空间,但调试阶段很有用:串口里stats_display()能直接看到 pbuf 分配失败、内存耗尽、TCP 重传次数,比盲猜快得多。量产前再关掉。

3.2 sys_arch.c:把信号量、邮箱、线程接到 FreeRTOS

LwIP 的sys_arch.c是移植层里最机械也最容易写错的部分。信号量和邮箱的封装很直接,注意 FreeRTOS 的xQueueCreate第二参数是“队列项大小”,LwIP 的邮箱里存的是void *指针,所以传sizeof(void *):

/* sys_arch.c:邮箱、信号量、线程的 FreeRTOS 封装 */ err_t sys_mbox_new(sys_mbox_t *mbox, int size) { *mbox = xQueueCreate((UBaseType_t)size, sizeof(void *)); return (*mbox != NULL) ? ERR_OK : ERR_MEM; } void sys_mbox_post(sys_mbox_t *mbox, void *msg) { while (xQueueSend(*mbox, &msg, portMAX_DELAY) != pdPASS) { } } err_t sys_mbox_trypost(sys_mbox_t *mbox, void *msg) { return (xQueueSend(*mbox, &msg, 0) == pdPASS) ? ERR_OK : ERR_MEM; } u32_t sys_arch_mbox_fetch(sys_mbox_t *mbox, void **msg, u32_t timeout) { void *tmp; if (xQueueReceive(*mbox, &tmp, timeout ? pdMS_TO_TICKS(timeout) : portMAX_DELAY) != pdPASS) return SYS_ARCH_TIMEOUT; if (msg) *msg = tmp; return 0; } sys_thread_t sys_thread_new(const char *name, lwip_thread_fn thread, void *arg, int stacksize, int prio) { TaskHandle_t h = NULL; /* LwIP 的 stacksize 单位是字,FreeRTOS 的 xTaskCreate 也是字,可直接传 */ if (xTaskCreate(thread, name, (configSTACK_DEPTH_TYPE)stacksize, arg, (UBaseType_t)prio, &h) != pdPASS) return NULL; return (sys_thread_t)h; }

sys_mbox_post里用portMAX_DELAY死等是有意的:LwIP 内核消息不能丢,丢了之后 TCP 状态机会卡住。sys_arch_mbox_fetch的timeout单位是毫秒,转成 tick 要用pdMS_TO_TICKS,传 0 表示永久阻塞。sys_thread_new的栈单位在两边的定义一致,都是StackType_t也就是 32 位字,所以直接传就行,但调用方传的stacksize要按字来算,别按字节。

还有一个容易漏的:sys_arch_protect和sys_arch_unprotect。LwIP 用它做短临界区,FreeRTOS 下用taskENTER_CRITICAL和taskEXIT_CRITICAL包住,嵌套计数交给 FreeRTOS 自己管,不要自己再维护一层。

3.3 ethernetif.c:从 DMA 描述符到 pbuf 的搬运

ethernetif.c是网卡驱动和 LwIP 的桥。low_level_init里填 MAC 地址、开启 ETH 中断;low_level_output把 pbuf 链拷进发送缓冲区;low_level_input把收到的帧包成 pbuf。核心代码不长,但每一句都和 DMA 描述符的状态位有关:

/* ethernetif.c:发送一个 pbuf 链到 ETH DMA */ static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint8_t *buffer = (uint8_t *)ETH_TxBuff; /* 指向 SRAM1 里的发送缓冲 */ /* 把 pbuf 链拷贝到连续的 DMA 缓冲区,注意长度上限 */ for (q = p; q != NULL; q = q->next) { memcpy(buffer, q->payload, q->len); buffer += q->len; } /* 清理 D-Cache:如果缓冲区是 cacheable,必须把 CPU 写入刷到内存 */ SCB_CleanDCache_by_Addr((uint32_t *)ETH_TxBuff, p->tot_len); /* 把缓冲区挂到 DMA 发送描述符,触发发送 */ HAL_ETH_TransmitFrame(&heth, p->tot_len); return ERR_OK; } /* 接收:从 DMA 描述符取一帧,包成 pbuf 交给 LwIP */ static struct pbuf *low_level_input(struct netif *netif) { struct pbuf *p = NULL; uint32_t len = 0; uint8_t *payload = NULL; if (HAL_ETH_GetReceivedFrame_IT(&heth) != HAL_OK) return NULL; len = heth.RxFrameInfos.length; payload = (uint8_t *)heth.RxFrameInfos.buffer; /* 接收缓冲可能被 DMA 改过,先 invalidate 再读 */ SCB_InvalidateDCache_by_Addr((uint32_t *)payload, len); p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p != NULL) { pbuf_take(p, payload, len); } /* 释放描述符,让 DMA 继续收下一帧 */ HAL_ETH_ReleaseRxFrame(&heth, payload); return p; }

HAL_ETH_TransmitFrame和HAL_ETH_GetReceivedFrame_IT是 HAL 库对描述符环的封装,不同版本的 HAL 函数名略有差异,以手头的stm32h7xx_hal_eth.c为准。SCB_CleanDCache_by_Addr和SCB_InvalidateDCache_by_Addr只在缓冲区是 cacheable 时才需要;如果按第 2 章把 SRAM1 配成了 non-cacheable,这两句可以去掉,但留着也不亏,代价是几十个周期。pbuf_alloc用PBUF_POOL从池里拿,比PBUF_RAM快,适合网卡接收路径。

ethernetif_init里还要把netif->output指向etharp_output,netif->linkoutput指向low_level_output,netif->mtu设 1500,netif->flags置NETIF_FLAG_BROADCAST | NETIF_FLAG_ETHARP。这些是 LwIP 的约定,漏一个就会出现“能收不能发”或者“ARP 不响应”。

3.4 启动顺序:tcpip_init 与网卡添加

启动顺序错了,netif加进去也没人处理。我一般按下面这个次序写main:

/* main.c:网络初始化顺序 */ int main(void) { HAL_Init(); SystemClock_Config(); /* 480MHz + PLL1Q 200MHz */ MPU_Config(); /* SRAM1 non-cacheable */ SCB_EnableICache(); SCB_EnableDCache(); MX_GPIO_Init(); MX_ETH_Init(); /* 内部会调 HAL_ETH_Init */ /* 1. 先起 LwIP 内核线程 */ tcpip_init(NULL, NULL); /* 2. 再加网卡,ethernetif_init 在 netif_add 里被调用 */ IP4_ADDR(&ipaddr, 192, 168, 1, 100); IP4_ADDR(&netmask, 255, 255, 255, 0); IP4_ADDR(&gw, 192, 168, 1, 1); netif_add(&gnetif, &ipaddr, &netmask, &gw, NULL, &ethernetif_init, &tcpip_input); netif_set_default(&gnetif); netif_set_up(&gnetif); /* 3. 等 PHY link 起来再起 Modbus 任务 */ while (netif_is_link_up(&gnetif) == 0) { vTaskDelay(pdMS_TO_TICKS(100)); } xTaskCreate(modbus_poll_task, "modbus", 768, NULL, 4, NULL); vTaskStartScheduler(); for (;;) { } }

关键点是tcpip_init必须在netif_add之前,netif_set_up必须在netif_add之后,netif_is_link_up要等 PHY 协商完成。tcpip_input是 LwIP 自带的入口函数,它会往tcpip_thread的邮箱里投消息,所以ethernetif_input任务里拿到 pbuf 之后直接调tcpip_input(p, netif)就行,不用自己处理 ARP 和 IP。

如果用的是NETIF_FLAG_ETHARP,netif_add之后还要确保etharp_init被调用过,tcpip_init内部会做这件事。顺序反了,ARP 表是空的,ping 网关都不通。

4. FreeModbus TCP 从站:porttcp 与寄存器回调

FreeModbus 的 TCP 端口层比 RTU 简单,不用自己算 CRC,也不用管 3.5 字符间隔,但要把 netconn 的连接管理写对。官方源码里porttcp.c是个空壳,需要按 LwIP 的 API 补全五个函数。

4.1 源码裁剪与目录放法

FreeModbus 的源码不多,TCP 从站只需要这几个文件:

Middlewares/FreeModbus/ ├── modbus/ │ ├── mb.c /* 协议核心,eMBInit/eMBPoll/eMBEnable */ │ ├── mbfuncbits.c /* 线圈读写 0x01/0x05/0x0F */ │ ├── mbfunccoils.c │ ├── mbfuncdisc.c /* 离散输入 0x02 */ │ ├── mbfuncholding.c /* 保持寄存器 0x03/0x06/0x10 */ │ ├── mbfuncinput.c /* 输入寄存器 0x04 */ │ └── include/ ├── tcp/ │ ├── mbtcp.c /* TCP 帧解析 */ │ └── porttcp.c /* 需要自己补的移植层 */ └── include/

demo目录和rtu目录可以删掉,ascii也删掉,只留modbus加tcp。mbconfig.h里把MB_TCP_ENABLED置 1,MB_RTU_ENABLED和MB_ASCII_ENABLED置 0,MB_FUNC_OTHER_REP_SLAVEID_ENABLED看需求。寄存器数量由MB_HOLD_REG_START、MB_HOLD_REG_SIZE这类宏控制,不同版本的宏名有差异,编译前先看一眼mbconfig.h。

4.2 porttcp.c:用 netconn 接住 502 端口

FreeModbus 1.6 的 TCP 端口层用 netconn API,核心是“监听、接收、发送、关闭”四个动作。下面是我在 H743 上跑通的最小实现:

/* porttcp.c:FreeModbus TCP 端口层,基于 LwIP netconn */ static struct netconn *xNetConn = NULL; /* 监听连接 */ static struct netconn *xClientConn = NULL; /* 当前客户端连接 */ static struct netbuf *xRxBuf = NULL; BOOL xMBTCPPortInit(USHORT usTCPPort) { ip_addr_t addr; IP_ADDR4(&addr, 0, 0, 0, 0); /* 监听所有网卡 */ xNetConn = netconn_new(NETCONN_TCP); if (xNetConn == NULL) return FALSE; /* 绑定 502 端口,失败就回收资源 */ if (netconn_bind(xNetConn, &addr, usTCPPort) != ERR_OK) { netconn_delete(xNetConn); xNetConn = NULL; return FALSE; } if (netconn_listen(xNetConn) != ERR_OK) { netconn_delete(xNetConn); xNetConn = NULL; return FALSE; } /* 非阻塞 accept,交给 eMBPoll 轮询 */ netconn_set_nonblocking(xNetConn, 1); return TRUE; } /* 从客户端取一帧 Modbus TCP 报文,返回长度;无数据返回 0 */ USHORT xMBTCPPortGetRequest(UCHAR **ppucMBTCPFrame) { if (xClientConn == NULL) { /* 没有客户端时尝试 accept,不阻塞 */ if (netconn_accept(xNetConn, &xClientConn) != ERR_OK) return 0; netconn_set_nonblocking(xClientConn, 1); } if (netconn_recv(xClientConn, &xRxBuf) != ERR_OK) return 0; *ppucMBTCPFrame = (UCHAR *)xRxBuf->p->payload; return (USHORT)xRxBuf->p->len; } /* 发送响应,发完释放 netbuf */ BOOL xMBTCPPortSendResponse(const UCHAR *pucMBTCPFrame, USHORT usTCPLength) { err_t err; if (xClientConn == NULL) return FALSE; err = netconn_write(xClientConn, pucMBTCPFrame, usTCPLength, NETCONN_COPY); if (xRxBuf != NULL) { netbuf_delete(xRxBuf); xRxBuf = NULL; } return (err == ERR_OK) ? TRUE : FALSE; }

netconn_set_nonblocking是关键:eMBPoll是周期调用的,如果accept和recv阻塞,整个 Modbus 任务就卡死,看门狗都救不回来。非阻塞模式下recv返回ERR_WOULDBLOCK时直接返回 0,等下一轮再取。

netconn_write用NETCONN_COPY,让 LwIP 自己拷贝一份,避免调用返回后缓冲区被复用。Modbus 响应很短,这个拷贝开销可以忽略。

官方porttcp.c里还有vMBTCPPortClose、vMBTCPPortDisable和xMBTCPPortIsConnected,按同样的方式补:关闭时netconn_close加netconn_delete,把指针置空,断开时重置状态机。漏写close的后果是客户端重连时端口被占,第二个连接进不来。

4.3 寄存器回调:把保持寄存器映射到结构体

FreeModbus 把应用层和协议层分开,寄存器读写通过回调完成。TCP 从站要补两个回调:eMBRegHoldingCB处理 0x03/0x06/0x10,eMBRegInputCB处理 0x04。我一般把寄存器映射到一个结构体,回调里按地址偏移取值:

/* 应用层寄存器映射:保持寄存器和输入寄存器各一组 */ typedef struct { uint16_t holding[32]; /* 0x0000 ~ 0x001F */ uint16_t input[16]; /* 0x0000 ~ 0x000F */ } AppRegs_t; static AppRegs_t g_regs; eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { /* Modbus 报文地址从 0 开始,越界直接返回非法地址 */ if (usAddress + usNRegs > 32) return MB_ENOREG; if (eMode == MB_REG_READ) { for (USHORT i = 0; i < usNRegs; i++) { uint16_t v = g_regs.holding[usAddress + i]; *pucRegBuffer++ = (UCHAR)(v >> 8); *pucRegBuffer++ = (UCHAR)(v & 0xFF); } } else { /* MB_REG_WRITE,对应 0x06 和 0x10 */ for (USHORT i = 0; i < usNRegs; i++) { uint16_t v = ((uint16_t)pucRegBuffer[0] << 8) | pucRegBuffer[1]; g_regs.holding[usAddress + i] = v; pucRegBuffer += 2; } } return MB_ENOERR; } eMBErrorCode eMBRegInputCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs) { if (usAddress + usNRegs > 16) return MB_ENOREG; for (USHORT i = 0; i < usNRegs; i++) { uint16_t v = g_regs.input[usAddress + i]; *pucRegBuffer++ = (UCHAR)(v >> 8); *pucRegBuffer++ = (UCHAR)(v & 0xFF); } return MB_ENOERR; }

usAddress是报文里的起始地址,从 0 开始,不是 1。很多人第一次写会把它当 1-based,结果读 0x0001 读到的是第二个寄存器。pucRegBuffer是协议层给的缓冲区,读的时候按大端写两个字节,写的时候按大端拼回uint16_t,Modbus 规定寄存器是大端。

如果寄存器会被多个任务改,比如采集任务写input、Modbus 任务读input,回调里要加互斥。FreeRTOS 下用taskENTER_CRITICAL包住拷贝循环,或者用一个SemaphoreHandle_t。Modbus 回调运行在modbus_poll任务上下文,不在中断里,用临界区就够。

4.4 主任务循环与异常恢复

eMBPoll不阻塞,要放在任务里周期调用。我一般给 10ms 周期,既能及时响应,又不会把 CPU 占满:

/* modbus_poll_task:初始化从站并周期轮询 */ static void modbus_poll_task(void *arg) { eMBErrorCode eStatus; /* TCP 模式初始化,从站地址在 TCP 下一般填 1,实际不参与寻址 */ eStatus = eMBInit(MB_TCP, 1, 0, 502, MB_PAR_NONE); if (eStatus != MB_ENOERR) { printf("eMBInit failed: %d\r\n", eStatus); vTaskDelete(NULL); } eMBEnable(); for (;;) { eStatus = eMBPoll(); if (eStatus != MB_ENOERR) { /* 协议层异常时复位,避免状态机卡死 */ eMBDisable(); vTaskDelay(pdMS_TO_TICKS(100)); eMBEnable(); } vTaskDelay(pdMS_TO_TICKS(10)); } }

eMBInit的第二个参数是从站地址,TCP 下协议层不靠它寻址,但eMBPoll里会用它校验单元标识符,填 1 或 0xFF 都行,看上位机怎么发。第三个参数是端口号,标准是 502;第四个参数是奇偶校验,TCP 下填MB_PAR_NONE。

异常恢复这段是血泪经验:现场电磁干扰强的时候,TCP 连接会断,eMBPoll返回错误,如果不复位状态机,后面所有请求都会被丢掉。加一个eMBDisable再eMBEnable的软复位,配合porttcp.c里的连接清理,能救回大部分假死。复位前最好打个日志,不然现场出了问题只能看黑匣子。

5. 避坑排查:五个让从站起不来的坑

这套组合的坑有个共同点:现象都像“网络不通”,但根因分散在 Cache、PHY、中断优先级、内存池和连接管理上。下面五条是调试时最常撞到的,每条按现象、原因、解决写,照着排查能省不少时间。

5.1 ping 通但读寄存器超时

现象:电脑能 ping 通板子,Wireshark 抓包能看到 502 端口的请求,但从站不回响应,或者回的报文长度不对。

原因:ETH 的 DMA 描述符或收发缓冲区放在了 cacheable 区域,CPU 写描述符之后没有 clean,DMA 读到的是旧值;或者接收缓冲区被 DMA 更新后 CPU 直接读,读到了 cache 里的旧数据。H7 的 D-Cache 是 write-back,CPU 写不会立刻进内存,DMA 看不到。

解决:按第 2 章把描述符区配成 non-cacheable,或者在low_level_output里SCB_CleanDCache_by_Addr、low_level_input里SCB_InvalidateDCache_by_Addr。两个方案选一个,不要混着用。配完 MPU 记得SCB_EnableDCache()之前先SCB_CleanInvalidateDCache(),否则上电残留的 cache 内容会捣乱。

5.2 PHY 一直 link down

现象:netif_is_link_up一直返回 0,网口灯不亮,或者灯亮但HAL_ETH_ReadPHYRegister读不到 PHY ID。

原因:PHY 地址填错是最常见的,LAN8720A 的地址由 RXER/PHYAD0 引脚决定,原理图上拉和下拉对应 0 或 1;其次是 RMII 的 REF_CLK 没接对,H743 的 PA1 要接 PHY 输出的 50MHz,如果 PHY 只给 25MHz 晶振、没开 REF_CLK 输出,ETH 内核就没有时钟;再其次是 MDIO 上拉电阻缺失,总线读回来全是 1。

解决:用 2.4 节的扫描代码把 0 到 31 地址全读一遍,找到能返回非 0xFFFF 的地址;确认 PHY 的REF_CLK输出使能位被写过;万用表量 MDIO 在空闲时是不是高电平。地址对了还 link down,就查网线和水晶头,别笑,我见过一次是压线钳把第 3 对线压偏了。

5.3 硬 fault 在以太网中断里

现象:一收包就进 HardFault,调用栈停在ETH_IRQHandler或者xQueueGenericSendFromISR里,偶尔能跑几秒再挂。

原因:ETH 中断优先级高于 FreeRTOS 的configMAX_SYSCALL_INTERRUPT_PRIORITY,中断里调用了带FromISR的 API。Cortex-M7 的优先级数值越小越高,如果 ETH 中断配成 0 或 1,而configMAX_SYSCALL_INTERRUPT_PRIORITY是 5,那么xSemaphoreGiveFromISR里的configASSERT会直接触发,或者临界区被打断导致队列状态错乱。

解决:把ETH_IRQn的抢占优先级设成 5 或更低,HAL_NVIC_SetPriority(ETH_IRQn, 5, 0),和 FreeRTOS 的configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY保持一致。另外HAL_ETH_IRQHandler里如果调了HAL_ETH_GetReceivedFrame_IT,要确认它不会在中断里做长循环,收包动作尽量丢给ethernetif_input任务。

5.4 大帧丢包与内存耗尽

现象:小报文正常,一读几十个寄存器就超时;串口里mem_malloc返回 NULL,或者pbuf_alloc失败;stats_display()里PBUF_POOL的err计数一直涨。

原因:PBUF_POOL_SIZE太小,接收路径同时来了多个帧,池子空了就丢;MEM_SIZE太小,TCP 重传和 ARP 缓存把堆吃光;TCP_SND_BUF不够,响应发不出去。Modbus 单次最多读 125 个寄存器,响应 255 字节左右,但 TCP 分段、重传和 ACK 会额外占 pbuf。

解决:把PBUF_POOL_SIZE从 32 提到 64,MEM_SIZE从 32KB 提到 48KB,再开LWIP_STATS看memp和mem的峰值。H743 的 AXI SRAM 有 512KB,这点内存不算多。改完跑 24 小时压力测试,看stats_display()里的drop和err是否归零。

5.5 多客户端连接被拒

现象:第一个 Modbus 客户端连上正常,第二个客户端连不上,或者第一个断开后第二个要等很久才能连。

原因:官方porttcp.c的模型是单连接,netconn_accept只接一个客户端,xClientConn被占用时新连接排在 backlog 里;断开时如果没有netconn_close和netconn_delete,旧连接占着端口,新连接进不来。

解决:单客户端场景下,在 `xMBTCPPortGet

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

返回列表