资讯动态

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

发布时间:2026/10/26 2:19:18 来源:尧图企业网站定制
简介面向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_threadtcpip_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()把帧交给 LwIPtcpip_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 SRAMLwIP 的mem_init也用 AXI SRAM两者用链接脚本切分开不要互相踩。2.3 时钟与 MPU480MHz 下先把 Cache 关对H743 的 ETH 时钟来源要配对常见做法是 HSE 25MHz 进 PLL1CPU 跑 480MHzPLL1Q 输出 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 起 128KBNon-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; /* TEX1, C0, B0 - 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的组合决定内存属性TEX1, C0, B0就是 Normal memory 里的 non-cacheable适合 DMA 区AXI SRAM 用TEX0, C1, B1走 write-backCPU 访问快。Region 编号不要和 FreeRTOS 的vPortSetupTimerInterrupt抢H7 的 HAL 默认只用前几个 region留出 8 个以上给应用。有一个容易忽略的细节开了 D-Cache 之后SCB_EnableDCache()之前最好先SCB_CleanInvalidateDCache()否则上电时 cache 里的随机内容可能被写回内存。这句我一般在main里HAL_Init()之后立刻加。2.4 ETH 与 PHYRMII 接线和地址扫描RMII 的引脚比 MII 少但接错一根就 link down。H743 常见接法是 PA1 做 REF_CLKPA2 做 MDIOPA7 做 CRS_DVPC1 做 MDCPC4/PC5 做 RXD0/RXD1PB11 做 TX_ENPB12/PB13 做 TXD0/TXD1PHY 复位脚单独用一个 GPIO。这些引脚在 CubeMX 里选ETH外设后会自动标出来但 PHY 地址不会自动填得看原理图上 PHYAD 引脚的电平。LAN8720A 的 PHY 地址由 RXER/PHYAD0 引脚在上电时决定悬空或下拉一般是地址 0上拉是地址 1DP83848 的地址由多个引脚组合范围 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, ID10x%04lX\r\n, addr, id); } } HAL_Delay(2); /* MDIO 时序慢给 PHY 一点时间 */ } }PHY_ID1_REG一般是0x02PHY_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负责把信号量、邮箱、线程接到 FreeRTOSethernetif.c负责把 DMA 缓冲区搬成pbuf。这两个文件通了协议栈就活了不通表现就是 ping 不通或者 ping 通了但 TCP 建链失败。3.1 lwipopts.h 里必须先定的几个开关LwIP 的配置项有上百个移植阶段真正要动的是下面这些。我一般从lwipopts.h里逐项确认而不是用默认值直接编/* lwipopts.hFreeRTOS 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 1NO_SYS0是前提等于 1 就没有tcpip_threadFreeModbus 的 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把信号量、邮箱、线程接到 FreeRTOSLwIP 的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_outputnetif-linkoutput指向low_level_outputnetif-mtu设 1500netif-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_ETHARPnetif_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置 1MB_RTU_ENABLED和MB_ASCII_ENABLED置 0MB_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.cFreeModbus 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/0x10eMBRegInputCB处理 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_tModbus 规定寄存器是大端。如果寄存器会被多个任务改比如采集任务写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 写描述符之后没有 cleanDMA 读到的是旧值或者接收缓冲区被 DMA 更新后 CPU 直接读读到了 cache 里的旧数据。H7 的 D-Cache 是 write-backCPU 写不会立刻进内存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 提到 64MEM_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本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑