简介:面向嵌入式网络通信开发者的 STM32H743 综合移植工程,基于正点原子阿波罗开发板,把 FreeRTOS 实时内核、LwIP 协议栈与 FreeModbus TCP/IP 从机整合进同一套可编译运行的 Keil 工程,解决裸机网络例程难以承载多任务 Modbus 通信的问题。工程以正点原子《lwIP_SOCKET_TCP 客户端实验》作为网络底层,并参照公开的 FreeMODBUSTCP 移植笔记完成协议对接,适合已熟悉 HAL 库与外设驱动、希望进阶工业以太网通信的学习者。压缩包共 1040 个文件、约 57.73MB,以 375 个 .h 头文件与 306 个 .c 源文件为主体,涵盖 HAL 外设驱动、LwIP 与 FreeModbus 协议源码及 FreeRTOS 内核 tasks.c;另有 .o、.d、.crf 等编译中间文件,以及 .uvprojx 工程、.hex、.axf、.map 输出和少量 readme 说明,打开即可复现调试。目前已有 1407 人学习下载。
1. 第一次在 STM32H743 上凑齐 FreeRTOS、LwIP、FreeModbus TCP,我卡在 D-Cache 上
做 STM32 网络协议这套东西,STM32H743 几乎是绕不开的一颗芯片:480MHz 主频、自带以太网 MAC、1MB SRAM,跑 FreeRTOS 加 LwIP 再加一个 Modbus TCP 从站,资源绰绰有余。我第一次移植时以为工作量全在协议栈源码上,结果真正卡住两天的是 L1 D-Cache 和 MPU。现象很统一:ping 通,502 端口也连得上,Modbus 一读保持寄存器全是 0,换板子、换网线、换上位机都一样。H7 跟 F4 不是一个路数,DMA 搬运的以太网帧和 CPU 看到的缓冲区之间隔着一层缓存一致性,缓存没配好,协议栈逻辑写得再对也白搭。
这篇记录的是我从空工程一路装到能用 Modbus Poll 稳定读数的完整过程,包含三个中间件的版本取舍、关键配置参数、任务划分,以及装坏之后怎么一步步救回来。适合手里已经有 H743 板子、卡在“网口能通但协议跑不起来”阶段的人;新手照着顺序走也能走通,熟手可以重点看参数取舍和排查那几章。
2. 环境清点与工程骨架:H743 上三件套先定版本再动手
移植这种事,最怕的不是写代码,是版本对不上。CubeMX 生成的 FreeRTOS 和 LwIP 是跟固件包绑定的,FreeModbus 又是一份独立源码包,三者对 lwIP API 的假设不一样。我一般先把版本表列出来,写在工程根目录的 README 里,后面出问题第一件事就是回头看这张表。
2.1 版本对照表与硬件前提
这一次用的组合如下,都是我当时实际跑通的版本,不是建议你非要用这个,而是让你知道版本差异大概在哪:
| 项目 | 版本 | 说明 |
|---|---|---|
| 芯片 | STM32H743ZIT6 | 2MB Flash、1MB RAM,片上 ETH MAC |
| 固件包 | STM32CubeH7 1.11.0 | 内含 HAL、LwIP 2.1.3、FreeRTOS 10.3.1 |
| 工程生成 | STM32CubeMX 6.x | 只用来生成时钟、ETH、FreeRTOS 骨架 |
| FreeRTOS | 10.3.1(CubeMX 自带) | 换 10.5.1 也能用,接口兼容 |
| LwIP | 2.1.3 | 中间件里勾选,NETCONN 模式 |
| FreeModbus | 1.6.0 | 独立源码包,只用 TCP 部分 |
| 编译器 | arm-none-eabi-gcc 10.3 或 MDK AC6 | 两个都试过,差异在链接脚本段名 |
| 调试手段 | 调试器下载 + 串口日志 + Wireshark | 串口日志比单步调试管用得多 |
提示:CubeH7 固件包里的 LwIP 版本跟你装的 CubeMX 版本绑定,换 CubeMX 大版本后 LwIP 的
lwipopts.h模板会变,不要直接拿旧文件覆盖。
硬件上还有两个前提必须确认:PHY 的地址和复位引脚,以及 ETH 参考时钟是 25MHz 还是 50MHz。这两项配错,MDIO 读出来全是 0xFFFF,看起来像 PHY 坏了,其实是 RMII 时钟没起来。
2.2 目录规划:把厂商代码和你自己的代码分开
我踩过的坑是:一开始把 FreeModbus 源码直接扔进Middlewares/,CubeMX 重新生成工程时被删了一半。后来固定成这种结构,生成的代码和你手写的东西物理隔离:
Project/ ├── Core/ # CubeMX 生成,只放初始化 ├── Drivers/ # HAL 与 CMSIS ├── Middlewares/ │ ├── Third_Party/FreeRTOS/ │ └── Third_Party/LwIP/ ├── App/ # 手写,CubeMX 不会碰 │ ├── modbus/ │ │ ├── freemodbus/ # 从源码包拷过来的核心 │ │ └── port/ # 我改的 porttcp.c、portevent.c │ ├── app_modbus.c │ └── app_regs.c └── README.md # 版本表写这里FreeModbus 源码包里,TCP 模式真正需要的是modbus/下的mb.c、mbtcp.c、mbfunc*.c这一组,加上port/下的porttcp.c和portevent.c。portserial.c和porttimer.c在 TCP 模式下不参与编译,但很多教程照着 RTU 的例子把这两个也加进去,结果链接阶段报一堆未定义符号。
2.3 CubeMX 里先把这四件事配好,再谈移植
打开 CubeMX 之后不要急着勾中间件,顺序错了后面会返工。我的顺序是时钟、ETH、FreeRTOS、时基。
时钟树按 HSE 25MHz、PLL 到 400MHz 或 480MHz 配,我这里用 400MHz,SYSCLK 400MHz、HCLK 200MHz,ETH 走 RMII,需要 50MHz 参考时钟,由 PLL2 或外部有源晶振提供。CubeMX 里 ETH 的 PHY 地址填 0,自动协商勾上,RX/TX 描述符数量先用默认的 4 个,后面不够再改。
FreeRTOS 在 Middlewares 里选 CMSIS_V2 或者 CMSIS_V1 都行,我习惯 V1,接口简单。关键是把USE_PREEMPTION打开、TOTAL_HEAP_SIZE先给 64KB,后面看xPortGetFreeHeapSize()再调。
时基这一项最容易被忽略。CubeMX 里SYS → Timebase Source默认是 SysTick,但 SysTick 要被 FreeRTOS 接管,HAL 的HAL_Delay()就会跟着 FreeRTOS 的调度走,在中断里调用会直接死锁。常见做法是把 HAL 时基改成 TIM6 或 TIM7,让两个定时器各管各的。
/* stm32h7xx_it.c 里 TIM6 中断的处理保持默认即可 */ void TIM6_DAC_IRQHandler(void) { HAL_TIM_IRQHandler(&htim6); }这段是 CubeMX 自动生成的,不用改。逻辑很直白:HAL 的 tick 由 TIM6 提供,FreeRTOS 的 tick 由 SysTick 提供,两边互不干扰。参数上 TIM6 的预分频和重装载值由 CubeMX 按时钟算出,只要保证 1ms 中断一次就行,我一般看一眼htim6.Init.Prescaler和Period乘积是不是等于当前 APB 时钟除以 1000。
配完这四件事,先别急着加 LwIP,编译下载一次,确认能跑进main的while(1),串口能打出第一行日志。这个“最小可运行骨架”是后面所有排查的基线,没有它,后面出的问题你分不清是协议栈的还是时钟的。
3. 移植 FreeRTOS 与接入 LwIP:先立任务再通网口
骨架有了,接下来是把 RTOS 和网络栈立起来。这两块一起讲,是因为 LwIP 在 H7 上必须跑在 FreeRTOS 线程里,两者的优先级和中断配置是耦合的,拆开讲反而容易配错。
3.1 FreeRTOSConfig.h 里必须改的几项
CubeMX 生成的FreeRTOSConfig.h大部分能用,但有几项跟 H7 的 Cortex-M7 中断模型直接相关,不改必翻车:
/* Cortex-M7 有 4 位优先级,共 16 级 */ #define configPRIO_BITS 4 #define configLIBRARY_LOWEST_INTERRUPT_PRIORITY 15 #define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5 /* 内核与系统调用可用的最高优先级,左移到高 4 位 */ #define configKERNEL_INTERRUPT_PRIORITY \ (configLIBRARY_LOWEST_INTERRUPT_PRIORITY << (8 - configPRIO_BITS)) #define configMAX_SYSCALL_INTERRUPT_PRIORITY \ (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS)) #define configTOTAL_HEAP_SIZE ((size_t)(96 * 1024)) #define configMAX_PRIORITIES 7 #define configUSE_TIME_SLICING 1 #define configCHECK_FOR_STACK_OVERFLOW 2逻辑说明:configMAX_SYSCALL_INTERRUPT_PRIORITY是分水岭,数值上比它小(优先级更高)的中断不允许调用任何FromISR结尾的 API,调了就是 hardfault。configCHECK_FOR_STACK_OVERFLOW设成 2 会在任务切换时检查栈底填充字,配合自己实现的vApplicationStackOverflowHook能第一时间抓到哪个任务栈溢出了。
堆大小 96KB 是我这边的经验值:LwIP 自己在lwipopts.h里还有一块独立内存池,FreeRTOS 堆主要给任务栈、队列和netconn结构体。任务栈我一般给 Modbus 任务 2KB、LwIP 线程 3KB、应用任务 4KB,加起来不到 10KB,剩下的留给动态分配。
3.2 中断优先级分组与 ETH 中断的落点
Cortex-M7 的中断优先级分组必须在HAL_Init()里设成 4 组,也就是全部 4 位都给抢占优先级,没有子优先级。CubeMX 默认就是这么配的,但如果你从别的工程拷了HAL_Init进来,一定要确认这一行在:
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4);然后 ETH 中断的优先级必须设置成数值大于等于 5,比如 6 或 7,这样它才能调用FromISR版本的 API。LwIP 在ethernetif.c里通过信号量通知接收线程,这个信号量的 give 操作就是在 ETH 中断里做的。
/* ethernetif.c 的接收线程,等信号量而不是轮询 */ static void ethernetif_input(void *argument) { struct pbuf *p; for (;;) { if (sys_arch_sem_wait(&s_xSemaphore, 0) == 0) { do { p = low_level_input(&gnetif); if (p == NULL) break; if (gnetif.input(p, &gnetif) != ERR_OK) { pbuf_free(p); } } while (1); } } }这里sys_arch_sem_wait的超时参数给 0 表示永久等待,只有中断给了信号量才醒。如果你发现网络吞吐上不去、CPU 占用却很高,八成是这里用了轮询加延时,改成信号量驱动立刻就好。
3.3 MPU 与 D-Cache:LwIP 的缓冲区放在哪
这是整篇文章里最关键的一节。H7 的 L1 D-Cache 默认是开的,main里调了SCB_EnableDCache()之后,CPU 访问内存先走 cache。但 ETH 的 DMA 是直接往物理内存写的,不经过 cache。结果就是:DMA 把帧写进接收缓冲,CPU 从 cache 里读到的是旧数据,或者 CPU 写好的发送缓冲还在 cache 里没刷到内存,DMA 发出去的是旧内容。
解决办法是在 MPU 里划一块 non-cacheable 区域,把 ETH 描述符和 LwIP 的收发缓冲都放进去。我一般放在 D2 SRAM,地址0x30000000开始,大小 32KB:
static void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct = {0}; HAL_MPU_Disable(); /* Region 0: 0x30000000 起 32KB,关闭 cache,共享,可缓冲 */ MPU_InitStruct.Enable = MPU_REGION_ENABLE; MPU_InitStruct.Number = MPU_REGION_NUMBER0; MPU_InitStruct.BaseAddress = 0x30000000; MPU_InitStruct.Size = MPU_REGION_SIZE_32KB; MPU_InitStruct.SubRegionDisable = 0x0; MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL1; MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS; MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE; MPU_InitStruct.IsShareable = MPU_ACCESS_SHAREABLE; MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsBufferable = MPU_ACCESS_BUFFERABLE; HAL_MPU_ConfigRegion(&MPU_InitStruct); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); }参数说明:TypeExtField用TEX_LEVEL1配合IsCacheable = NOT_CACHEABLE、IsBufferable = BUFFERABLE,得到的是“写通、不缓存”的属性,这是 DMA 缓冲区最常见的组合。IsShareable设成 SHAREABLE 是为了让 CPU 和 DMA 之间对这块内存的顺序有一致性保证。DisableExec关掉取指,防止这块区域被当成代码执行。
光有 MPU 还不够,链接脚本里必须把 ETH 描述符和接收池显式放进去。CubeMX 生成的.ld文件里已经有类似这样的段,但很多人换编译器或者自己改链接脚本时把它弄丢了:
.lwip_sec (NOLOAD) : { . = ABSOLUTE(0x30000000); *(.RxDecripSection) *(.TxDecripSection) *(.Rx_PoolSection) } >RAM_D2对应的 C 代码里,描述符数组要带 section 属性:
ETH_DMADescTypeDef DMARxDscrTab[ETH_RX_DESC_CNT] __attribute__((section(".RxDecripSection"))); ETH_DMADescTypeDef DMATxDscrTab[ETH_TX_DESC_CNT] __attribute__((section(".TxDecripSection"))); uint8_t Rx_Buff[ETH_RX_DESC_CNT][ETH_MAX_PACKET_SIZE] __attribute__((section(".Rx_PoolSection")));还有一步别漏:MPU 配置必须在SCB_EnableDCache()之前调用,否则已经缓存的数据不会被重新映射。main里的顺序是MPU_Config()→SCB_EnableICache()→SCB_EnableDCache()→ 外设初始化。
3.4 lwipopts.h 参数与描述符数量
lwipopts.h是 LwIP 的大脑,参数乱给会导致内存不够或者吞吐上不去。我这边跑 Modbus TCP 用不到太复杂的场景,参数给得比较保守:
#define MEM_SIZE (24 * 1024) /* LwIP 自己管理的堆 */ #define MEMP_NUM_PBUF 16 #define MEMP_NUM_TCP_PCB 8 #define MEMP_NUM_TCP_SEG 32 #define PBUF_POOL_SIZE 16 #define PBUF_POOL_BUFSIZE 1536 #define LWIP_NETCONN 1 /* FreeModbus 的 porttcp 依赖 */ #define LWIP_SOCKET 0 #define LWIP_TCP 1 #define LWIP_DHCP 0 /* 固定 IP,DHCP 调试时再开 */ #define TCP_MSS 1460 #define TCP_SND_BUF (4 * TCP_MSS) #define TCP_WND (4 * TCP_MSS) #define TCP_SND_QUEUELEN ((4 * TCP_SND_BUF) / TCP_MSS)MEM_SIZE是 LwIP 内部mem_malloc的池子,Modbus TCP 每帧最多 260 字节左右,24KB 足够十几个并发连接。PBUF_POOL_SIZE和 ETH 接收描述符数量要匹配,描述符给 8 个,池子至少 16 个,否则高流量下会丢帧。LWIP_NETCONN必须为 1,FreeModbus 的porttcp.c用的是 netconn 接口,关掉就编不过。
ETH 描述符在 CubeMX 生成的ethernetif.c顶部:
#define ETH_RX_DESC_CNT 8 #define ETH_TX_DESC_CNT 8这两个数不是越大越好。8 个描述符加上 8 个 1536 字节缓冲,占内存大约 12KB,加上 LwIP 池 24KB,D2 SRAM 的 32KB 区域刚好够。你要开到 16 个,那块 MPU 区域就得扩到 64KB,重新算链接脚本。
3.5 任务切分与优先级分配
三件套跑起来之后,系统里至少有三个任务在跑:LwIP 的接收线程(由tcpip_init创建)、Modbus 协议轮询任务、应用层任务。优先级我给成这样:
| 任务 | 优先级 | 栈大小 | 职责 |
|---|---|---|---|
| LwIP tcpip 线程 | 5 | 3KB | 收发帧、协议栈内部处理 |
| Modbus 轮询 | 4 | 2KB | 调用eMBPoll()处理请求 |
| 应用任务 | 2 | 4KB | 更新寄存器、串口日志 |
| 空闲任务 | 0 | 默认 | 系统自带 |
LwIP 线程优先级高于 Modbus 任务,保证网络中断来的帧能及时被协议栈收掉;Modbus 任务优先级高于应用任务,保证请求响应延迟稳定在毫秒级。eMBPoll()内部是阻塞等待事件,没有请求的时候这个任务基本不占 CPU,所以优先级给高一点不会饿死别人。
4. 移植 FreeModbus TCP:把从站挂到 LwIP 的 netconn 上
FreeModbus 的源码结构比较简单,核心是一个状态机加一组寄存器回调,跟具体网络栈的耦合全在port目录里。TCP 模式下,难的不是协议本身,是把porttcp.c里那套 socket 语义改成 LwIP netconn 语义。
4.1 源码裁剪与 mbconfig.h
从源码包拷过来之后,先改mbconfig.h,只留 TCP,把 RTU 和 ASCII 关掉:
#define MB_RTU_ENABLED 0 #define MB_ASCII_ENABLED 0 #define MB_TCP_ENABLED 1 #define MB_TCP_PORT 502 #define MB_FUNC_READ_HOLDING_ENABLED 1 #define MB_FUNC_WRITE_HOLDING_ENABLED 1 #define MB_FUNC_READ_INPUT_ENABLED 1 #define MB_FUNC_WRITE_MULTIPLE_ENABLED 1 #define MB_FUNC_READ_COILS_ENABLED 1 #define MB_FUNC_WRITE_COILS_ENABLED 1只开你实际用到的功能码。全开的话 Flash 占用会多出 10KB 左右,不是大事,但每个功能码都对应一个回调函数,没实现的回调容易在调试时被误触发。我一般先只开保持寄存器的读写,跑通之后再加线圈。
编译文件清单是这样的,加到工程里的时候按组分开,别一股脑塞进一个 group:
freemodbus/ ├── modbus/ │ ├── mb.c │ ├── mbtcp.c │ ├── mbfuncbits.c │ ├── mbfunccoils.c │ ├── mbfuncdisc.c │ ├── mbfuncholding.c │ ├── mbfuncinput.c │ └── mbutils.c └── port/ ├── porttcp.c └── portevent.cportserial.c和porttimer.c在 TCP 模式下一个符号都用不上,加进去反而要你补一堆空实现。portevent.c是必须的,它提供xMBPortEventPost和xMBPortEventGet,TCP 模式下用 FreeRTOS 的队列实现最省事。
4.2 porttcp.c 对接 LwIP netconn
这是整个移植里唯一需要真正写代码的文件。FreeModbus 1.6.0 自带的porttcp.c是基于 BSD socket 的,H7 上开 socket 层会多占内存,我改成 netconn:
#include "port.h" #include "mbconfig.h" #include "lwip/api.h" #include "lwip/opt.h" static struct netconn *xListenConn = NULL; static struct netconn *xClientConn = NULL; static struct netbuf *xRxNetBuf = NULL; BOOL xMBTCPPortInit(USHORT usTCPPort) { err_t xErr; xListenConn = netconn_new(NETCONN_TCP); if (xListenConn == NULL) { return FALSE; } /* 监听端口绑定到任意本地地址 */ xErr = netconn_bind(xListenConn, IP_ADDR_ANY, usTCPPort); if (xErr != ERR_OK) { netconn_delete(xListenConn); xListenConn = NULL; return FALSE; } /* 只允许一个客户端排队,Modbus 主站一般单连接 */ netconn_listen(xListenConn); return TRUE; }逻辑说明:netconn_new(NETCONN_TCP)创建一个 TCP 控制块,netconn_bind绑到 502 端口,netconn_listen进入监听状态。这里没有调netconn_accept,因为 FreeModbus 是轮询模型,接受连接的动作放在xMBTCPPortGetRequest里做,每次eMBPoll检查一次有没有新连接。
接收和发送这两个函数是核心:
BOOL xMBTCPPortGetRequest(UCHAR **ppucMBTCPFrame, USHORT *pusTCPLength) { if (xClientConn == NULL) { /* 没有客户端时,非阻塞地尝试接受一个连接 */ if (netconn_accept(xListenConn, &xClientConn) != ERR_OK) { return FALSE; } netconn_set_recvtimeout(xClientConn, 1); } if (xRxNetBuf != NULL) { netbuf_delete(xRxNetBuf); xRxNetBuf = NULL; } /* 阻塞等待一帧,超时 1ms,交给 FreeRTOS 调度 */ if (netconn_recv(xClientConn, &xRxNetBuf) != ERR_OK) { return FALSE; } netbuf_data(xRxNetBuf, (void **)ppucMBTCPFrame, pusTCPLength); return TRUE; } BOOL xMBTCPPortSendResponse(const UCHAR *pucMBTCPFrame, USHORT usTCPLength) { struct netbuf *xTxBuf; void *pData; if (xClientConn == NULL) { return FALSE; } xTxBuf = netbuf_new(); if (xTxBuf == NULL) { return FALSE; } pData = netbuf_alloc(xTxBuf, usTCPLength); if (pData == NULL) { netbuf_delete(xTxBuf); return FALSE; } MEMCPY(pData, pucMBTCPFrame, usTCPLength); if (netconn_send(xClientConn, xTxBuf) != ERR_OK) { netbuf_delete(xTxBuf); return FALSE; } netbuf_delete(xTxBuf); return TRUE; }netconn_set_recvtimeout设成 1ms 是刻意为之:Modbus 从站必须在主站超时之前响应,但eMBPoll又不能永久阻塞,否则应用任务没机会跑。1ms 的超时让这个任务每次醒来检查一下事件队列,代价是每秒 1000 次上下文切换,在 400MHz 的 H7 上几乎看不出来。
4.3 寄存器回调与从站参数
协议栈跑起来之后,真正跟业务打交道的是回调函数。保持寄存器的读写回调长这样:
eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { USHORT i; USHORT *pusRegs = app_get_holding_regs(); /* 指向应用层寄存器数组 */ if ((usAddress < 1) || (usAddress + usNRegs > APP_REG_NUM + 1)) { return MB_ENOREG; /* 地址越界,回异常码 02 */ } if (eMode == MB_REG_READ) { for (i = 0; i < usNRegs; i++) { /* Modbus 是大端,H7 是小端,高低字节要换 */ *pucRegBuffer++ = (UCHAR)(pusRegs[usAddress - 1 + i] >> 8); *pucRegBuffer++ = (UCHAR)(pusRegs[usAddress - 1 + i] & 0xFF); } } else { for (i = 0; i < usNRegs; i++) { pusRegs[usAddress - 1 + i] = (USHORT)(*pucRegBuffer++ << 8); pusRegs[usAddress - 1 + i] |= (USHORT)(*pucRegBuffer++); } } return MB_ENOERR; }usAddress是从 1 开始的,数组下标从 0 开始,所以统一减 1。字节序必须手动换,LwIP 的htons只处理协议头,寄存器数据是应用层的事。地址越界返回MB_ENOREG,FreeModbus 会自动组异常响应,主站那边看到的就是异常码 02,比直接不回包好排查得多。
从站地址在 TCP 模式下其实不参与寻址,但报文里还带着单元标识符,我给固定值 1,跟主站配置对上就行。初始化顺序是:
eMBTCPInit(MB_TCP_PORT); /* 内部会调 xMBTCPPortInit */ eMBEnable(); /* 状态机进入就绪 */ /* 之后在任务循环里不断调用 */ eMBPoll();eMBTCPInit返回MB_ENOERR才算绑定成功,返回MB_EPORTERR就是内存不够或者 netconn 创建失败。我一般在这里加一行串口打印,把返回值打出来,比单步调试快。
4.4 轮询周期与线程安全
eMBPoll()放在独立任务里跑,任务体的结构很简单:
static void vModbusTask(void *pvParameters) { for (;;) { eMBPoll(); /* 内部处理接收、解析、回包 */ vTaskDelay(pdMS_TO_TICKS(2)); } }延时给 2ms 是权衡:给 0 会让这个任务占满优先级 4 的 CPU 时间,应用任务只能等它主动让出;给 10ms 又会让主站的 100ms 超时余量变薄。2ms 到 5ms 之间都行,看你的主站扫描周期。
线程安全这块要说清楚:应用任务写寄存器、Modbus 任务读寄存器,两边会打架。最省事的做法是给寄存器数组加一把互斥锁,但 Modbus 回调在协议栈任务上下文里执行,加锁要考虑不能在中断里调用。更轻的做法是寄存器全部用uint16_t,单个 16 位读写在 Cortex-M7 上是原子的,只有多寄存器批量写的时候才需要临界区:
taskENTER_CRITICAL(); /* 批量更新寄存器 */ taskEXIT_CRITICAL();临界区里不要放耗时操作,超过几十微秒就会影响网络中断的响应。
5. 常见问题排查:跑通前踩过的五笔账
这一章记录的都是我自己踩过、并且能复现的问题。每一条按现象、原因、解决三段写,你对照着看,能省下不少时间。
5.1 ping 通、502 能连,Modbus 读出来全是 0
现象:电脑 ping 板子正常,用 Modbus Poll 连 502 端口提示连接成功,读保持寄存器返回全 0,换功能码也一样,从站不报异常。
原因:D-Cache 没关。CPU 读到的接收缓冲是缓存里的旧数据,协议栈解析出来的报文长度和内容都不对,但 LwIP 层不报错,所以连接看起来是好的。另一种可能是寄存器回调里的字节序没换,读出来是 0x0000 或者字节颠倒。
解决:先确认 MPU 区域配了 non-cacheable,再确认链接脚本把.RxDecripSection放进了那块区域。用调试器看DMARxDscrTab的地址是不是0x30000000附近,如果不是,说明链接脚本没生效。字节序的问题在回调里打印原始pucRegBuffer前四个字节就能看出来。
5.2 一收包就进 HardFault
现象:板子上电正常,主站一发请求,程序直接跳 HardFault,串口最后一行日志停在 ETH 中断里。
原因:ETH 中断的优先级数值小于 5,在中断里调用了FromISR版本的信号量释放函数。Cortex-M7 对这条规则执行得很严格,违反了直接升级成 HardFault,不会给你任何提示。
解决:在 CubeMX 的 NVIC 配置里把 ETH 中断优先级改成 6 或 7,重新生成。同时检查所有外设中断,凡是会调用 FreeRTOS API 的,优先级数值都必须大于等于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。
5.3 连接建立后几秒钟就断开
现象:Modbus Poll 连上之后能读一两次,然后提示连接超时,重新连接又能读一两次。
原因:xMBTCPPortGetRequest里的netconn_recv超时设得太短,或者eMBPoll的调用周期比主站超时还长。还有一种可能是netconn_accept之后没有正确保存客户端连接,每次轮询都重新 accept,把老连接挤掉了。
解决:接收超时给 1ms 到 5ms,eMBPoll的调用周期给 2ms 左右。accept 只在xClientConn == NULL的时候调一次,连接建立后不要重复调用。
5.4 跑几个小时就死机,重启后正常
现象:短时间测试一切正常,挂一晚上第二天看板子没响应,复位后又好了。
原因:内存泄漏。netconn_recv拿到的netbuf没有netbuf_delete,或者发送失败的分支里netbuf_new出来的结构体没释放。FreeModbus 的 TCP port 里这种分支特别多,每一条return FALSE前面都要检查有没有申请过资源。
解决:在xMBTCPPortGetRequest开头先释放上一次的xRxNetBuf,发送函数里每个失败分支都加netbuf_delete。跑之前把MEM_SIZE调大一点做对照,如果调大之后存活时间变长,基本可以确认是泄漏。用mem_malloc的统计接口定期打印剩余内存,能更早发现。
5.5 编译报 RAM 区域溢出,删代码也没用
现象:加了 LwIP 之后链接报region RAM overflowed by xxx bytes,把应用代码删掉一大半还是溢出。
原因:H7 的 RAM 分了好几块,DTCM、AXI SRAM、D2 SRAM 各自独立,链接脚本默认只把.data和.bss放在 DTCM 或者 AXI SRAM 里,而 LwIP 的缓冲被强制放到了 D2 SRAM,那块区域大小有限。你删应用代码释放的是 AXI SRAM,跟溢出的区域不是同一块。
解决:打开.map文件看是哪一段溢出了。如果是RAM_D2,就减小 ETH 描述符数量或者 LwIP 的MEM_SIZE;如果是RAM(AXI),就把大的常量数组挪到 Flash,或者把不常用的缓冲手动指定到 D3 SRAM。改链接脚本之前先备份,MEMORY段的长度改错会让程序连启动都启动不了。
6. 装完怎么验证:三板斧从链路层一路验到应用层
移植完成不等于能用,必须按层验证。我的习惯是从下往上打三层,任何一层不过就停在那里查,不要跳过去看上层,不然出了问题分不清是谁的锅。
第一板斧是链路层。板子上电,网口灯亮不亮,交换机端口有没有协商到 100M 全双工。用ifconfig或者路由器后台看有没有拿到 IP,我给的是静态地址192.168.1.100,掩码255.255.255.0。命令行里直接:
ping -c 20 192.168.1.10020 个包不能丢,延迟稳定在 1ms 以内。丢包或者延迟忽高忽低,说明描述符数量不够或者中断优先级配错了,回到第 3 章查。
第二板斧是传输层。用telnet 192.168.1.100 502或者nc -vz 192.168.1.100 502确认端口能连上。连上之后不要急着关,同时开 Wireshark 抓包,过滤器写tcp.port == 502,看三次握手是不是正常完成,有没有 RST。有 RST 通常是netconn的生命周期管理有问题,连接被意外释放了。
第三板斧是应用层。我一般用命令行工具mbpoll,比图形界面的 Modbus Poll 更适合写进脚本:
# -m tcp 走 Modbus TCP,-a 1 从站地址,-t 4 保持寄存器 # -r 1 起始地址,-c 10 读 10 个,-1 表示从地址 1 开始(不是 0) mbpoll -m tcp -a 1 -t 4 -r 1 -c 10 -1 192.168.1.100 -p 502参数说明:-t 4是保持寄存器,-t 3是输入寄存器,-t 1是线圈;-r和-c决定读哪一段;-1这个开关很多人漏掉,不加的话它按协议地址 0 开始算,读出来的数据整体偏移一位。读到的值应该跟你代码里初始化的数组一致,我一般初始化成0x0001到0x000A,读出来对不上就回头查字节序。
三层都过之后,还有一件事要做:连续跑。让mbpoll每隔 200ms 读一次,挂在那儿跑两小时,同时串口每隔 10 秒打印一次xPortGetFreeHeapSize()和mem_get_free()。两个数字都应该稳定在某个值附近小幅波动,如果持续下降,就是第 5.4 条说的内存泄漏,别等它挂。
还有个小技巧:把eMBPoll()的返回值忽略掉是常规做法,但调试阶段可以在里面加一行统计,记录收到多少帧、回包多少帧、异常多少帧,串口打出来。主站发了 1000 次请求,从站回了 998 次,差的 2 次是丢包还是超时,一看数字就知道往哪个方向查。
从那以后我每次移植这套组合,都会先按第 6 章这三层跑一遍再交给别人,哪怕时间再紧也不跳步。D-Cache 和中断优先级这两个坑,吃过一次就够了,第二次还栽在上面只能怪自己。希望帮到你。
本文还有配套的精品资源,点击获取