在工业现场摸爬滚打过的朋友应该都有体会:项目做到最后,最头疼的不是功能实现,而是"对接"。上位机要数据、组态软件要采集、MES系统要联网,而这些系统十有八九点名要 Modbus TCP。我之前接过一个改造项目,原有设备是基于串口的 Modbus RTU 协议,上位机在厂区中控室,距离一百多米,拉 RS485 线根本不可能,客户又不愿意改协议重写通信库,最后定下来的方案就是把设备升级成网口,做一台标准 Modbus TCP 从机。细想下来,用 STM32F407 自带 MAC + LAN8720A 这颗 PHY + LwIP 协议栈 + FreeModbus 移植,是最顺手、也最省钱的路子。
这篇内容就是围绕这套组合,把从硬件设计、CubeMX 配置、LwIP 网络链路验证,到 FreeModbus TCP 移植、寄存器映射、现场联调与排障的完整过程写成一篇可直接参考的实战指南。适合正在做工业设备联网、SCADA 数据采集、远程监控的同学,无论你是第一次碰以太网,还是已经在用 RTU 想升 TCP,看完都能少走不少弯路。
1. 为什么是 STM32F407 + LAN8720A + LwIP 这套组合
1.1 需求从哪里来:Modbus RTU 升级到 Modbus TCP
先说项目场景。设备原本的做法是 MCU 通过 UART 转 RS485,挂一条 9600 波特率的 Modbus RTU 总线,上位机用 USB 转 485 采集卡去轮询。变电站、车间这种环境,电磁干扰大,现场网线资源又比 485 线好规划得多,加上上位机那边一水儿的以太网口,用户自然想直接走 TCP/IP。这里有两个选择:一是买现成的串口服务器,把 RS485 转成网络,但设备本体还是走 485,中间多一个盒子就多一个故障点,而且延时和轮询效率都不理想;二是把网口做进设备里,让设备直接支持 Modbus TCP。
我选了第二方案。既然 MCU 本身有以太网 MAC,只是缺一颗 PHY,成本上比外挂 W5500 硬件协议栈更可控,而且代码层面用 LwIP + FreeModbus 都是开源方案,License 干净,改起来也自由。Modbus RTU 和 Modbus TCP 在"寄存器读写"这件事上本质是一回事,应用层数据模型完全复用,只是传输层从串口换成了 TCP,所以升级工作量比想象中小,核心难点其实在网络链路和协议栈移植上。
1.2 器件与协议栈的选型对比
主控选 STM32F407 没什么悬念。F407 内置 10/100M 以太网 MAC,支持 MII 和 RMII 两种接口模式,自带的 DMA 和描述符可以减轻 CPU 负担,跑 LwIP 这种轻量级协议栈绰绰有余。F1 系列没有 MAC,F4 里 F407 因为有 512KB~1MB Flash 和大容量 RAM,跑 LwIP 加协议栈不会太紧张,价格和供货也稳定,国内生态资料多,遇到问题随便一搜就有参考。
PHY 选 LAN8720A 同样是从成本和资料两个维度考虑的。LAN8720A 是低功耗的 10/100M 以太网 PHY,RMII 接口只需要 7 根信号线,比 MII 少了一大半 IO,这对于 MCU 引脚规划非常友好。市面上很多模块设计和开发板验证资料都基于这颗芯片,出问题也容易找对照。同类还有 DP83848、YT8512 等,但 LAN8720A 的资料量和"踩坑笔记"明显更多,新手友好度更高。
网络协议栈选 LwIP 是顺理成章的,STM32CubeMX 直接集成,生成代码就能跑,不需要自己手动移植。Modbus 协议处理选 FreeModbus,依然是开源的,它把 RTU、ASCII、TCP 三种模式的应用层都做得很干净,最关键的是寄存器回调机制写得很清楚,我们只需要填充业务数据,协议栈自己会构造报文、处理功能码、计算 CRC(TCP 模式甚至不用 CRC)。
1.3 这套组合里的成本账
算一笔硬件成本:STM32F407VGT6 批量几十元,LAN8720A 单颗十元以内,再加上网口变压器集成座子,整套以太网硬件成本大概在二十到三十元人民币左右。对比工业串口服务器动辄几百块,这个成本优势相当明显,而且设备直接具备联网能力,数据链路少一层中转,稳定性会更好。所以只要 MCU IO 足够、Flash 足够,自己做这个网口方案,长远看比外挂模块划算得多。
2. RMII 硬件设计:7 根信号线和 50MHz 时钟必须配对
2.1 RMII 接口信号一览
RMII,即 Reduced Media Independent Interface,把 MII 的 16 根信号线精简成 7 根,换来的是 PHY 和 MAC 之间必须有一个 50MHz 的参考时钟。STM32F407 的 RMII 引脚是固定映射,没办法随便换到别的引脚,所以原理图设计必须先打开数据手册确认引脚,再画网络。
常用连接关系如下:
| 功能 | MCU 引脚 | 连接 LAN8720A |
|---|---|---|
| ETH_RMII_REF_CLK | PA1 | REF_CLK(50MHz 输入) |
| ETH_RMII_CRS_DV | PA7 | CRS_DV |
| ETH_RMII_RXD0 | PC4 | RXD0 |
| ETH_RMII_RXD1 | PC5 | RXD1 |
| ETH_RMII_TX_EN | PB11 | TX_EN |
| ETH_RMII_TXD0 | PB12 | TXD0 |
| ETH_RMII_TXD1 | PB13 | TXD1 |
| ETH_MDC | PC1 | MDC |
| ETH_MDIO | PA2 | MDIO |
我不建议自己按记忆画原理图,一定要打开 STM32CubeMX,勾选 ETH 外设的 RMII 模式后,它会自动把可用引脚列出来,对着 CubeMX 生成的引脚分配去画原理图,这样最不容易错。这里特别提醒一下,TXD0 是 PB12、TXD1 是 PB13,这对引脚和 SPI2 的 SCK/MOSI 复用,如果板子上 SPI2 已经被占用,就得提前评估是否冲突。
2.2 时钟方案:外部晶振输出 50MHz 给 MCU 的常见接法
RMII 接口下 PHY 和 MAC 必须共享同一个 50MHz 参考时钟。工程上最常见的是 LAN8720A 外接 25MHz 无源晶振,PHY 内部 PLL 倍频后,把 50MHz 时钟从 REF_CLK 引脚输出给 STM32F407 的 PA1,也就是让 PHY 作为时钟源。
这个方案的好处是 STM32F407 侧只需要配置 ETH 的 RMII 模式,PA1 作为输入接收 PHY 送来的 50MHz 时钟,时钟源由外部 PHY 保证,时序稳定。另一套方案是 STM32 的 MCO2 引脚输出 50MHz 给 PHY,但这对时钟树配置和信号完整性的要求更高,还需要 MCU 主动产生时钟,新手更容易在时钟树配置上出错。我自己的项目两种都试过,推荐优先用 25MHz 晶振加 PHY 输出 50MHz 的方案,CubeMX 默认的 RMII 配置就能直接匹配,不用额外折腾 MCO。
2.3 PHY 地址、复位电路和网络变压器的细节
LAN8720A 的 PHY 地址由 PHYAD0 引脚的电平决定,默认内部下拉到 0,所以很多模块和开发板上的 PHY 地址是 0x00。如果在原理图上给 PHYAD0 加了一个上拉电阻,地址就变成 0x01,那 CubeMX 里的 PHY Address 必须填 1,否则 MDIO 读写返回全是 0xFF,无法读取到 PHY 芯片 ID。这个坑太常见了,我见过好几次工程里 PHY 地址配置不对,网口死活起不来的情况。
复位电路也值得说。LAN8720A 的 nRST 建议用单独的 RC 复位,比如 10kΩ 上拉到 3.3V、0.1μF 电容对地,保证上电后有足够的复位时间。不要直接把 MCU 的 NRST 信号拉过去,因为 PHY 和 MCU 的上电时序不一样,共用复位可能导致 PHY 还没准备好时,MAC 已经开始访问 MDIO 了。
网络变压器这里,LAN8720A 的 TXP/TXN、RXP/RXN 是差分信号,必须经过网络变压器再连接到 RJ45 座子。有两个做法:一是用分离式网络变压器加普通 RJ45,二是直接用带变压器的超五类 RJ45 集成座,比如 HR911105A。小批量打样建议直接用集成座,省事,PCB 面积也省。需要注意把中心抽头的端接电阻和电容按手册接好,否则网线插上后信号质量差,可能百兆能通但不稳定,甚至干脆协商不上。
2.4 硬件上容易翻车的三个点
第一,LAN8720A 的 3.3V 供电。这颗 PHY 虽然标称低功耗,但以太网变压器和 PHY 在工作时瞬态电流并不小,如果供电走线太细或者和 MCU 共用一条细的电源线,网络峰值会拉低电压,直接表现为网口时好时坏。我习惯在 PHY 电源引脚就近放一个 10μF 钽电容加 0.1μF 陶瓷电容,确保高频去耦。
第二,MDIO 的上拉电阻。MDC 和 MDIO 一般建议加上 2.2kΩ 到 4.7kΩ 上拉,MDIO 是开漏双向数据线,没有上拉会读不到数据。
第三,如果板子上 RJ45 是带屏蔽的金属座,外壳要和系统地连接,原则上单点接地,避免地环路引入共模干扰。工业现场机箱接地不良的话,网口变压器隔离能防浪涌,但屏蔽层处理不到位,雷雨天气容易损坏 PHY。
3. CubeMX 配置与工程搭建:把 LwIP 先跑起来
3.1 时钟树和 ETH 外设配置
打开 STM32CubeMX,型号选 STM32F407VG(或者你实际用的后缀)。先在 SYS 里把 Debug 设为 Serial Wire,不然烧录器第二次连接容易撞上 SWDIO 复用。然后是 RCC,外部高速晶振选择 Crystal/Ceramic Resonator。
ETH 外设配置这一步很关键。我们用的是 RMII 模式,PHY Address 按前面的硬件设计填 0。RMII 时钟由外部 PHY 提供,CubeMX 里如果选的是"External PHY"模式,它就知道 REF_CLK 是从 PA1 进来的,不会去生成 MCO。与此同时,RCC 时钟树里要保证 ETH 外设时钟正确,这个在 CubeMX 里一般不需要手改,它会自动把 APB2 的时钟配好,但你要确认时钟树里没有红色的错误提示,尤其是 MCO2 没有开启的情况下,ETH 的 RMII 时钟源要选对。
有一个地方容易犯晕:开启 ETH 外设后,如果时钟树报错说 ETH 相关时钟没配好,大概率是外部晶振没启用或者 APB 总线时钟分频有问题,按 F407 的典型主频 168MHz 配置即可,ETH 的时钟源来自 PLL 的输出,CubeMX 会自动安排好。
3.2 LwIP 参数建议:满足 Modbus TCP 的典型内存配置
在 Middleware 里勾选 LwIP 后,进入 LwIP 配置页面。这里面参数很多,我直接给一套经过验证的配置,适应大部分 Modbus TCP 从机应用:
- IP 版本:IPv4 即可,不必开 IPv6
- DHCP:关闭,使用静态 IP,默认 192.168.1.10,掩码 255.255.255.0,网关 192.168.1.1
- MEM_SIZE:1600(堆内存大小,单位字节)
- MEMP_NUM_PBUF:10
- PBUF_POOL_SIZE:8
- PBUF_POOL_BUFSIZE:1520
- TCP_MSS:1460
- TCP_WND:(4 * TCP_MSS),也就是 5840
- TCP_SND_BUF:(4 * TCP_MSS)
- TCP_LISTEN_BACKLOG:1(如果只接受一个客户端)
这些参数不是拍脑袋定的。LwIP 的所有内存都是从 MEM_SIZE 和内存池里分配的,Modbus TCP 一帧请求很简短,最常见的读保持寄存器请求就 12 个字节,响应也就几十个字节,数据链路层用 GMAC 描述符收发,所以 1600 字节的堆对 Modbus TCP 从站完全够用。如果你以后要扩展 HTTP 服务器、MQTT 或者其他大报文应用,再把 MEM_SIZE 和 PBUF_POOL_SIZE 往大调。内存够大的 F407 不用太抠,但要理解这些参数之间的依赖关系,尤其 TCP_WND 和 TCP_SND_BUF 要和 TCP_MSS 成比例,否则窗口小了,PC 端大包发送就会很慢。
3.3 生成工程后的第一件事:确认 PHY 通讯正常
生成代码并编译烧录后,先别急着调协议栈。第一个要确认的是 HAL_ETH_Init 是否成功,以及能不能读到 LAN8720A 的芯片 ID。STM32CubeMX 生成的 ETH 驱动里,MX_LWIP_Init() 会调用 HAL_ETH_Init,它内部会通过 MDIO 访问 PHY。打开调试器,在 HAL_ETH_Init 返回后看一下返回值,如果返回 HAL_OK,说明 MDIO 链路没问题,PHY 地址和硬件对得上。
另一个快速验证方式是读 PHY 寄存器,比如 LAN8720A 的 PHY ID 寄存器是 0x02 和 0x03,正确值应该分别是 0x0007 和 0xC0F1。如果你用 STM32CubeMonitor 或者直接调试器内存窗口读寄存器,读到这两个值,就说明 PHY 时钟和 MDIO 都正常了。这里如果读出来全 0xFFFF,先查 PHY 地址;如果读出来全 0x0000,多半是 MDIO 引脚没拉上拉或者硬件没上电。
4. 先验证 TCP 链路:三次握手、长连接和重连
4.1 ping 通只说明 PHY 层没问题
很多人在 LwIP 跑起来的标志就是"ping 通了"。确实,能 ping 通说明 ARP 能解析、IP 层能转发、PHY 链路是活的,这是网络通信的底线。但 ping 通离"Modbus TCP 能通信"还有一段距离,因为它只能证明 ICMP 回显工作正常,不能证明 TCP 端口监听、应用层协议解析、以及数据回传都正常。
所以我会把 LwIP 的验证分成两步走:第一步 ping 通,第二步用 TCP 工具直接建立连接收发数据。只有第二步也通过,才能放心地开始移植 FreeModbus。
4.2 用网络调试助手完成第一次 TCP 连接
PC 上打开任意一款网络调试助手,选 TCP Client,目标 IP 填设备的 192.168.1.10,端口随意,比如 8080,先做个临时测试端口。连接之前先保证 PC 网卡和开发板在同一网段,比如 PC 设 192.168.1.20,掩码 255.255.255.0。
点击连接后,如果 LwIP 初始化正常,PDU 层的三次握手会自动完成:客户端发 SYN,LwIP 回 SYN+ACK,客户端再回 ACK,TCP 连接建立。这时候你从调试助手发一串数据,比如 "hello",设备端如果写了回环测试逻辑,会把同样的数据原样发回来,或者你至少能在调试助手里看到连接状态变为已连接。这一步验证的是 TCP 传输层收发是否通畅,LwIP 的接收回调、DMA 描述符、以太网中断是否正常工作。
在这个阶段我习惯加一个最简单的 TCP 回环测试代码,收到什么就回什么:
err_t echo_recv_cb(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { if (p != NULL) { tcp_write(tpcb, p->payload, p->len, 1); tcp_output(tpcb); tcp_recved(tpcb, p->len); pbuf_free(p); } else { tcp_close(tpcb); } return ERR_OK; }这段代码虽然朴素,但能把"能不能收、能不能发"一次性验证完。我自己在调 LwIP 时,跑通这个回环后再做应用层移植,后面出问题就可以明确甩锅给应用层,排查范围会小很多。
4.3 长连接、短连接和掉线重连的处理策略
Modbus TCP 的典型工作方式是上位机作为 Modbus TCP 客户端,主动连接设备 502 端口,连上之后保持长连接,按固定周期轮询寄存器数据。这样省去了频繁建连的开销,上位机也方便管理设备状态。
但长连接会带来"半开连接"问题。比如上位机崩溃、拔掉网线、或者直接把程序关掉,TCP 连接并不会立刻通知设备端,设备端可能一直以为连接还活着。LwIP 里可以开启 TCP keepalive 机制,让协议栈在空闲一段时间后主动发探测报文,判断连接是否真的存活。CubeMX 的 LwIP 配置页里没有直接暴露 keepalive 开关,但可以在初始化代码里对 tcp_pcb 设置:
tcp_pcb->so_options |= SOF_KEEPALIVE; tcp_pcb->keep_idle = 30000; /* 30 秒空闲后开始探测 */ tcp_pcb->keep_intvl = 5000; /* 探测间隔 5 秒 */ tcp_pcb->keep_cnt = 3; /* 连续 3 次无响应则断开 */我在实际项目里测试过,这个参数对应上位机异常断电的场景非常有效,设备会在 40 秒左右判定连接失效,然后关闭这个连接释放资源,等上位机重新连上来。如果不设置 keepalive,设备端的连接资源会被慢慢耗尽,新客户端无法连接,只能重启设备才能恢复。
5. FreeModbus TCP 移植:把 Modbus 帧跑在 TCP 之上
5.1 FreeModbus 源码里哪些文件是给你改的
先整体看一下 FreeModbus 的源码结构。它的核心代码在根目录下:mb.c 是协议状态机主循环,functions/ 目录里是各个功能码的处理函数,比如读保持寄存器是 mbfuncholding.c,读线圈是 mbfunccoils.c。这些核心代码不需要修改。你真正要动手的是 port 目录,它是移植层,接口是固定的,需要根据不同的硬件平台实现。
TCP 模式里,portserial 和 porttimer 这两个文件可以不用,串口和定时器只服务 RTU/ASCII 模式。你需要实现的是 port.h、portevent.c 和 porttcp.c。
port.h 是移植层的公共头文件,主要定义数据类型和字节序。portevent.c 实现事件通知机制,FreeModbus 内部用事件来做状态机驱动,比如收到一帧数据后产生 EV_FRAME_RECEIVED 事件。porttcp.c 是最关键的,它要把 LwIP 的 TCP 收发能力封装成 FreeModbus 需要的接口。
5.2 mbconfig.h 配置要点
mbconfig.h 是 FreeModbus 的开关配置,决定编译哪些模式和数据模型。我常用的配置是:
#define MB_ASCII_ENABLED 0 #define MB_RTU_ENABLED 0 #define MB_TCP_ENABLED 1 #define MB_TCP_PORT 502 #define MB_FUNC_HANDLING_COILS 1 #define MB_FUNC_HANDLING_DISCRETE_INPUT 1 #define MB_FUNC_HANDLING_HOLDING_REGISTERS 1 #define MB_FUNC_HANDLING_INPUT_REGISTERS 1 #define MB_FUNC_READ_COILS_ENABLED 1 #define MB_FUNC_WRITE_COILS_ENABLED 1 #define MB_FUNC_READ_HOLDING_ENABLED 1 #define MB_FUNC_WRITE_HOLDING_ENABLED 1关键点是把 RTU 和 ASCII 关掉,只留 TCP,这样代码更精简,也避免编译器试图把串口相关代码也编进去。MB_TCP_PORT 就是标准 Modbus TCP 使用的 502 端口,这个不能改,否则上位机默认连不上。功能码根据项目需要打开,一般保持寄存器全功能码够用。
5.3 porttcp.c 核心逻辑与常见移植错误
porttcp.c 要做的事情很简单:初始化一个 TCP 服务器,监听 502 端口;接受客户端连接;收到 TCP 数据后,把数据交给 FreeModbus 协议栈处理;当协议栈产出响应报文时,通过 TCP 发送给客户端。
我用 LwIP 的 RAW API 来实现,因为它没有依赖操作系统,裸机跑或 RTOS 里都能用。初始化部分:
static struct tcp_pcb *mb_tcp_listen_pcb; void eMBTCPInit(void) { mb_tcp_listen_pcb = tcp_new(); tcp_bind(mb_tcp_listen_pcb, IP_ADDR_ANY, MB_TCP_PORT); mb_tcp_listen_pcb = tcp_listen(mb_tcp_listen_pcb); tcp_accept(mb_tcp_listen_pcb, mb_tcp_accept_cb); }注意,上面这段是把 bind 后的 pcb 又赋给了 listen_pcb,这是 LwIP 官方 demo 常用的一个偷懒写法,实际上 tcp_listen 返回的 pcb 是新的,原 pcb 被废弃。如果是正式工程,建议用两个变量分开保存,避免混淆。
accept 回调里要做的是给新连接注册接收、错误、轮询和发送完成回调:
static err_t mb_tcp_accept_cb(void *arg, struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, mb_tcp_recv_cb); tcp_err(newpcb, mb_tcp_err_cb); tcp_poll(newpcb, mb_tcp_poll_cb, 2); return ERR_OK; }收到数据时,需要注意 LwIP 的 pbuf 可能是链表的,要处理多段 pbuf 的情况:
static err_t mb_tcp_recv_cb(void *arg, struct tcp_pcb *tpcb, struct pbuf *p, err_t err) { if (p == NULL) { /* 对端关闭连接 */ tcp_close(tpcb); return ERR_OK; } if (p->tot_len > MB_TCP_RX_BUFSIZE) { pbuf_free(p); return ERR_MEM; } pbuf_copy_partial(p, mb_tcp_rx_buf, p->tot_len, 0); uint16_t len = p->tot_len; tcp_recved(tpcb, len); pbuf_free(p); /* 数据交给 FreeModbus 协议栈 */ xMBTCPRxBufStart = mb_tcp_rx_buf; xMBTCPRxBufEnd = mb_tcp_rx_buf + len; (void)xMBPortEventPost(EV_FRAME_RECEIVED); return ERR_OK; }这里最容易犯的错就是忘了调用tcp_recved。LwIP 的接收窗口是协议栈自己维护的,你收了多少数据,就要通过tcp_recved告诉协议栈窗口可以释放。如果不做这一步,窗口会逐渐变成 0,客户端还能发数据但服务端处理不过来,表现为连接还在,但数据收发越来越慢,最后卡死。
另一个容易踩的坑是对pbuf的所有权误解。tcp_recv_cb里拿到的pbuf所有权属于调用者(也就是你的回调),用完必须pbuf_free,否则会导致内存池耗尽。我在调试时就遇到过一段时间后设备彻底收不到任何数据,查到最后就是这里少释放了一格 pbuf。
FreeModbus 端最后要调用 eMBPoll 来驱动协议栈,它会从内部缓冲区里解析帧、查功能码表、调用寄存器回调、组装响应帧。响应完成后,FreeModbus 通过发送函数把数据发出去,这个发送函数通常在eMBTCPPoll或者发送回调里执行。我用的是在 poll 时检查是否有待发送数据,再调用 tcp_write 和 tcp_output。
5.4 主循环里的初始化顺序
main 函数里的初始化顺序有讲究。先是 HAL 初始化、系统时钟配置,然后是 MX_LWIP_Init() 把网络协议栈拉起来,然后调用eMBInit(MB_TCP, 0x01, MB_TCP_PORT, 0),注意从站地址参数在 TCP 模式下其实不参与寻址(TCP 模式下 Unit ID 是报文里的一个字节),但函数签名还得传,随便填个 1 就行。接着调用eMBEnable()使能协议栈。
主循环是这样:
while (1) { MX_LWIP_Process(); eMBPoll(); /* 业务逻辑 */ vApplicationTask(); }MX_LWIP_Process()是 LwIP 的周期处理函数,TCP 的超时重传、ARP 老化都在里面。FreeModbus 的eMBPoll()响应不能太慢,如果主循环里业务逻辑跑很久才调用一次,Modbus 响应延时就会超标。建议把业务逻辑拆到中断或子状态机里,保证 eMBPoll 的执行周期在几毫秒级别。
6. 寄存器映射与应用代码:把设备数据交给 Modbus 协议栈
6.1 保持寄存器回调的实现
FreeModbus 的工作方式是通过回调函数把协议栈和业务逻辑解耦。它内部解析完 Modbus 请求后,会调用你在eMBInit时注册的回调函数,让你去读写真正的设备数据。最常用的是保持寄存器回调eMBRegHoldingCB。
我的实现思路是声明一个全局的 uint16_t 数组,比如 100 个保持寄存器,然后在这个回调里做数组切片拷贝:
static uint16_t usRegHoldingBuf[100]; eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { uint16_t i; if (usAddress + usNRegs > 100) { return MB_ENOREG; } if (eMode == MB_REG_WRITE) { /* PC 端写寄存器:TCP 报文是大端,需要解析成小端存到本地 */ for (i = 0; i < usNRegs; i++) { usRegHoldingBuf[usAddress + i] = ((uint16_t)pucRegBuffer[2 * i] << 8) | (uint16_t)pucRegBuffer[2 * i + 1]; } } else { /* PC 端读寄存器:本地数据转成大端发出去 */ for (i = 0; i < usNRegs; i++) { pucRegBuffer[2 * i] = (uint8_t)(usRegHoldingBuf[usAddress + i] >> 8); pucRegBuffer[2 * i + 1] = (uint8_t)(usRegHoldingBuf[usAddress + i] & 0xFF); } } return MB_ENOERR; }注意usAddress是从 0 开始的,而 Modbus 协议里保持寄存器的地址范围通常写成 40001 开头,上位机软件的"地址偏移"处理各家并不完全一致。Modbus Poll 里会有一个"Address"设置,通常填 0 就对应协议地址 0。不同上位机可能会做 1 偏移,联调时要先确认一下,不然会出现读数整体偏移一位的问题。
6.2 字节序和数据对齐
Modbus TCP 报文里,16 位寄存器数据是大端传输,也就是高字节在前。STM32 是小端存储,所以本地内存里的 uint16_t 数组在赋值给pucRegBuffer时,需要手动把高字节和低字节调换,上面代码里写的就是这个逻辑。如果你直接把memcpy一片内存给协议栈,不处理字节序,上位机读到的数据会高低字节颠倒,比如读到 0x1234 变成 0x3412。
32 位浮点数就更要注意。工业现场经常要传温度、压力这种浮点,Modbus 协议本身没有规定 32 位浮点的字节序,不同厂家有不同约定,常见的有 AB CD 大端和 CD AB 小端两种。最稳妥的做法是在应用层定义一个地址分配表,约定哪些寄存器是 16 位整数、哪些是 32 位浮点,然后在回调里做显式转换:
typedef union { float f; uint32_t u32; uint16_t u16[2]; } Float32_t; /* 写入时读浮点 */ Float32_t value; value.u16[0] = usRegHoldingBuf[addr]; /* 高 16 位 */ value.u16[1] = usRegHoldingBuf[addr + 1]; /* 低 16 位 */ float temp = value.f;在上位机那边,Modbus Poll 可以配置寄存器显示格式为 Float AB CD 或 Float CD AB,联调时让上位机工程师试两种配置,看到的数值哪个合理就是用哪个约定,然后把这个约定写进通信协议文档里。
6.3 多寄存器读写与地址越界保护
跑批生产的时候,上位机会一次读几十个寄存器,一次写十几个寄存器,这在usNRegs大于 1 时触发。回调里usAddress + usNRegs越界检查不能省,一旦越界返回 MB_ENOREG,协议栈会回一个 Modbus 异常码给上位机,而不是发一堆乱数据,这对排查问题非常有帮助。
我建议在工程里把保持寄存器数组做成一个结构体,把设备参数按逻辑分组,比如设备状态、控制命令、温度模拟量、累计量、参数配置,每一组写入函数独立处理,避免回调里堆一大坨 if-else。这样以后加参数,只需要改结构体和回调映射表。
7. 联调与抓包:Modbus 报文怎么一步步验证
7.1 Modbus Poll 连接与读写测试
软件层面我推荐 Modbus Poll,它自带 Modbus TCP 客户端功能,界面直观。打开 Modbus Poll,选择 Connection 里的 Connect,Connector 选 TCP/IP,IP 填设备地址 192.168.1.10,Port 填 502,点击 OK。如果一切正常,窗口标题会显示 Connected。
然后设置要读的寄存器:Setup 里的 Read/Write Definition,Function 选 03 Read Holding Registers,Address 填 0,Quantity 填 10。点 OK 后,如果一切正常,表格里会出现 10 个寄存器的实时数值,Scan Rate 默认是 0 表示连续轮询,你可以改到 100ms 模拟工业现场轮询频率。
如果这个阶段出现"Connect OK 但数据区域一直无响应",问题大概率在 FreeModbus 侧。常见情况是eMBPoll()没在主循环里执行,或者eMBEnable()没有被调用。也可能是porttcp.c里接收到了数据但没有触发EV_FRAME_RECEIVED事件,协议栈根本不知道有数据进来。
7.2 Wireshark 抓一个标准的 Modbus TCP 帧
到这一步我强烈建议打开 Wireshark,网卡设成混杂模式,抓一下以太网包。先看三次握手:客户端发 SYN、服务端回 SYN+ACK、客户端回 ACK,一目了然。如果三次握手只有前两个包,说明 ACK 没回,可能是客户端网卡防火墙拦了,也可能设备端回包有问题。
连接建立后,立刻能看到 Modbus TCP 报文。一个典型的读保持寄存器请求是这样的:
| 字段 | 值 | 含义 |
|---|---|---|
| Transaction ID | 0x0001 | 事务标识,用于配对请求响应 |
| Protocol ID | 0x0000 | 协议标识,Modbus 固定为 0 |
| Length | 0x0006 | 后面字节数 |
| Unit ID | 0x01 | 从站地址 |
| Function Code | 0x03 | 读保持寄存器 |
| Start Address | 0x0000 | 起始寄存器地址 |
| Quantity | 0x000A | 读 10 个寄存器 |
响应报文里,Transaction ID 会原样返回,Length 是后面数据的字节数,功能码还是 0x03,然后是字节计数加寄存器数据。抓包的巨大价值在于,你一眼就能看出是谁来谁往、报文结构对不对。比如 Transaction ID 不匹配,说明协议栈的请求响应配对逻辑有问题;比如 Length 字段写错,上位机会直接丢弃这个响应。
7.3 实测中的故障排查表
我把自己在这套方案里遇到过的所有问题汇总成一张表,按排查顺序排列:
| 现象 | 排查点 | 处理方式 |
|---|---|---|
| 完全 ping 不通 | PHY 芯片 ID 能否读到 | 先查 MDIO 和 PHY 地址 |
| ping 通但 TCP 连不上 | 端口是否监听 | tcp_listen是否调用、eMBEnable是否执行 |
| IP 能 ping 通、TCP 能连但收不到数据 | 网口中断是否正常 | 检查 ETH 中断优先级,确认MX_LWIP_Process有没有周期运行 |
| 数据偶发丢包 | TCP 窗口耗尽 | 检查tcp_recved有没有调用 |
| 寄存器读出来全是 0 | 寄存器数组未初始化或回调未写入 | 在回调里打断点,确认eMBRegHoldingCB是否被触发 |
| 数值高低字节颠倒 | 字节序处理问题 | 检查回调里大端小端转换 |
| 客户端一断设备就卡 | TCP 错误回调未处理 | tcp_err里关闭 pcb 释放资源 |
| 多客户端同时连会乱 | 多连接共享状态机冲突 | 简单方案只保留一个有效连接 |
8. 长期运行与工程化细节:那些文档里不会写的坑
8.1 DHCP 还是静态 IP
Modbus TCP 从机在绝大多数场景下都用静态 IP。理由很直接:上位机要配置设备地址,如果每次上电 IP 都不一样,整个系统就没法管理了。并且 Modbus TCP 最常用的轮询配置就是按固定 IP 去连,设备的 IP 必须稳定可预期。
设备要支持修改 IP 怎么办?我建议做一个寄存器映射表,把 IP 的四个字节映射到保持寄存器里,再配合一个"保存并重启"的寄存器,这样上位机通过 Modbus 就可以在线改设备地址,不需要拆机下载程序。具体实现时,写 IP 寄存器后需要把参数存到 Flash(比如 STM32 内部 Flash 的最后一个扇区),然后调用 NVIC_SystemReset() 重启,重启后 LwIP 初始化时先读这个地址再设置 IP。这个功能在现场维护时实用性极高。
8.2 TCP 连接异常断开后怎么恢复
前面提到 keepalive,这是硬件在环长时间运行的关键。此外,还要处理"上位机主动断开但 pcb 没有正确释放"的情况。LwIP 的 err 回调里,我会记录当前活动连接的 pcb 指针,如果 err 触发,先把指针置空:
static void mb_tcp_err_cb(void *arg, err_t err) { if (active_pcb != NULL) { tcp_abort(active_pcb); active_pcb = NULL; } }如果不做这一步,下一次客户端连接时 accept 还是会来,但旧连接资源没释放,一段时间后内存耗尽。我还习惯在 accept 回调里做判断:如果当前已经有一个活动客户端,就把新的连接直接拒绝掉。工业现场一般就是上位机和设备一对一通信,不让第二个客户端接入,可以避免两个上位机同时写控制寄存器的冲突风险。
8.3 散热、电源稳定性和看门狗的配合
LAN8720A 在长时间工作后明显升温,这是正常的,但也要注意不要让 PHY 附近还有其他发热元件。PCB 上尽量给 PHY 留出一块铺铜区域帮助散热。周围的电解电容不要用 105℃ 以下品质的,工业环境温度普遍偏高,用料至少要用 105℃ 级别。
电源稳定性方面,设备外壳是金属且接交流地的话,网口的网络变压器隔离效果很好,但是 PHY 的 3.3V 电源建议用独立的 LDO 单独供给,或者至少用磁珠把 MCU 电源和 PHY 电源隔开,防止 MCU 侧高频噪声耦合进 PHY 电源。
看门狗的问题容易被人忽略。很多人在裸机程序里把MX_LWIP_Process()放在主循环里,但喂狗也在主循环。如果某个 Modbus 请求处理耗时过长,或者 PHY 挂死导致 LwIP 轮询卡住,看门狗可能也救不回来。我建议把以太网协议栈周期处理放到定时器中断里,或者用一个低优先级任务单独跑,看门狗喂狗在主循环里最靠前的位置执行,避免它们在同一个循环里互相拖累。
8.4 现场经验小结
写到最后想分享一个真实经历。我在实验室里测试这套 Modbus TCP 从机,稳定运行三天没问题,但一拿到现场,客户第二天就反馈设备偶发失联。现场排查发现,他们的交换机启用了 IGMP Snooping 和部分风暴抑制策略,设备偶尔广播报文字节数异常,被交换机直接丢弃。后来我在 LwIP 里把 TCP 超时重传次数调小、把 ARP 缓存条目数调大,又让现场关闭了对该端口的速率限制,设备再没丢过连接。
类似的坑还会有很多,但方向和排查路径基本是通的。如果这篇文章能让你少走我走过的这几步弯路,那就值了。做个工程化设备,网上那些"能 ping 通就算成功"的教程真的只能算第一步,后面的链路稳定性、内存管理、异常恢复,才是真正决定设备能不能在现场站得住的关键。