说实话,看到这个标题我就知道,又是一位被客户追着要“网口远程监控”折磨的嵌入式兄弟。STM32F407 + LAN8720A + LwIP + FreeModbus这套组合,在工业设备联网、数据采集、远程运维场景里实在太经典了——MCU做从机,PC或触摸屏当主机,通过Modbus TCP协议读写寄存器,稳定、好调试、生态成熟。这篇文章我就按自己的实际开发流程,从硬件接线、CubeMX配置、FreeModbus TCP移植到联调抓包,一步步给你拆开讲清楚,顺便把那些只有踩过坑才懂的门道也一并交代。
1. 项目概述与整体思路
1.1 这个项目到底在做什么
先明确一下目标:我们要让一块STM32F407开发板,通过LAN8720A这颗百兆PHY芯片接入以太网,在LwIP协议栈之上跑一个FreeModbus TCP从机。最终的效果是:你在电脑上用Modbus Poll这类主机软件,填上开发板的IP地址(比如192.168.1.100)和端口502,就能直接读写F407内部的寄存器数据。对于工业现场来说,这就相当于给设备装了一个“远程数据窗口”,PLC、组态软件、上位机都能用统一的Modbus协议把设备接入系统。
很多人一听“Modbus TCP从机”就觉得复杂,其实拆开来看也就三层东西:物理层是以太网PHY,也就是LAN8720A负责收发网络信号;中间是LwIP这个轻量级TCP/IP协议栈,负责把TCP连接、IP报文、ARP、ICMP这些底层逻辑处理好;最上面才是Modbus TCP应用协议,它规定了数据怎么组织、功能码什么含义。而FreeModbus这个开源库,就是把最上层的Modbus协议处理逻辑做好了,我们只需要把它“粘”到LwIP上。
1.2 方案选型:为什么这么搭
这套方案能火这么多年,原因很务实。F407自带以太网MAC控制器(10/100M),硬件上就差一颗外置PHY,选LAN8720A主要是因为它便宜、功耗低、引脚少(RMII接口只需要10根线左右),而且市面上大量开发板都在用,参考资料多到你想踩坑都难。LwIP就更不用说了,专门为嵌入式系统设计的TCP/IP协议栈,内存占用小,F407这种资源完全跑得动。
FreeModbus选择TCP模式而不是RTU,核心考虑是:TCP面向连接、可靠传输,数据不容易丢;而且不用像RTU那样非得一个485总线把设备串起来,走交换机、走WiFi桥接都行,现场布线灵活得多。如果你只是想本地通信,RTU成本更低,但一旦涉及远程监控、多设备组网,TCP优势就非常明显了。这个项目里我们只做从机,所以重点是让FreeModbus在TCP端口502上监听并响应请求,不用处理主机侧的事务调度。
1.3 整体技术架构梳理
从软件层面看,代码可以分为三块:底层是HAL库驱动ETH外设和DMA,中间是LwIP协议栈,上层是FreeModbus协议栈和用户应用逻辑。这里要特别提一句,LwIP有两种接口模式:一种是带操作系统的,靠信号量和邮箱做进程间通信;另一种是裸机运行,靠周期调用sys_check_timeouts()和ethernetif_input()来处理协议栈事件。我们这篇讲的是裸机方案,也就是CubeMX直接生成不带RTOS的代码,所有网络处理都是轮询加中断的方式,代码逻辑更直观,也更容易排查问题。
从数据流来看,主机发来的Modbus TCP请求,先从网线进入LAN8720A,PHY把模拟信号转成RMII数字信号,然后F407的MAC通过DMA把数据搬到内存,LwIP解析TCP/IP协议头并把载荷交给上层,最后FreeModbus按照Modbus TCP报文格式解析功能码和数据,调用我们注册的寄存器读/写回调函数去操作实际的业务变量。回答数据再沿着这条链路原路返回。理解了这条链路,后面出问题排查就有方向了。
2. 硬件连接与设计要点
2.1 RMII接口接线:不是随便连的
F407的MAC和LAN8720A之间走的是RMII接口,相比MII接口,RMII把数据线从16根砍到7根,代价是时钟频率需要50MHz。标准RMII引脚分配如下:
| 信号 | 方向 | STM32F407引脚 | 说明 |
|---|---|---|---|
| RMII_REF_CLK | 输入 | PA1 | 50MHz参考时钟 |
| RMII_MDIO | 双向 | PA2 | 管理接口数据线 |
| RMII_MDC | 输出 | PC1 | 管理接口时钟 |
| RMII_CRS_DV | 输入 | PA7 | 载波侦听/数据有效 |
| RMII_RXD0 | 输入 | PC4 | 接收数据位0 |
| RMII_RXD1 | 输入 | PC5 | 接收数据位1 |
| RMII_TX_EN | 输出 | PB11 | 发送使能 |
| RMII_TXD0 | 输出 | PB12 | 发送数据位0 |
| RMII_TXD1 | 输出 | PB13 | 发送数据位1 |
注意,以上是F407的标准AF映射,也就是CubeMX里“默认”会给你分配的引脚。不同开发板由于走线原因,可能会把PHY芯片接到其他GPIO口上复用成RMII功能,比如有些板子用PG11、PG13、PG14等引脚。所以拿到一块板子,第一件事永远是:查原理图,确认PHY到底接在哪组引脚上。
2.2 50MHz时钟与PHY复位
RMII接口对时钟的要求就一句话:必须保证干净、准确的50MHz时钟,而且这个时钟要同时供给PHY和STM32F407的MAC。LAN8720A支持两种时钟模式:一种是从机模式,也就是外部给它喂50MHz时钟;另一种是主机模式,LAN8720A自己生成50MHz时钟,通过REF_CLK_OUT引脚输出给MCU。
我在项目里强烈建议用外部50MHz有源晶振方案:晶振输出直接接到LAN8720A的XI/CLKIN引脚,然后LAN8720A的REF_CLK_OUT引脚接F407的PA1。这样MCU不参与时钟生成,时序最可靠。有些板子试图用F407的MCO引脚去输出50MHz时钟给PHY,我劝你别折腾——F407的PLL配置要同时满足系统主频和USB时钟,MCO要精确输出50MHz在多数晶振配置下都很难算出一个整数倍频分频比。实测下来,外部有源晶振是最省心的做法。
再说PHY复位。LAN8720A的复位引脚很多板子直接接一个RC复位电路,上电自动复位,但这样有个问题——MCU无法控制PHY的复位时机。我更推荐用一个GPIO去控制PHY的NRST引脚,在代码里做“拉低→延时50ms→拉高→再延时200ms”的时序,确保PHY完全稳定后再去操作MDIO总线。有些时候你发现MDIO读回来的寄存器全是0xFFFF,十有八九是PHY还在复位状态中。
2.3 硬件设计注意事项
一个是PHY地址。LAN8720A的PHY地址是由RXER/PHYAD0引脚的电平决定的,默认是0。也就是说SMI总线上这个PHY的地址是0x00,在CubeMX里配置时必须填0,读PHY寄存器才能读到正确数据。
另一个是网络变压器的选型。RJ45座子如果自带变压器(比如HR911105A),直接连接即可;如果用的是不带变压器的RJ45座,必须外接网络变压器,否则信号过不去,而且很容易损坏PHY芯片。别问我怎么知道的,曾经有一次因为手头没有带变压器的座子,直接用网线怼上去,PHY温度高得能煎鸡蛋。
最后是LAN8720A的LED引脚,一般有两个:一个指示Link状态,一个指示收发活动。调试的时候特别有用,网线插上灯就亮、有数据就闪,一眼能判断物理链路通不通。如果网线插上灯不亮,先别急着查代码,检查网线、座子、焊接,硬件链路通不过,软件再怎么调也没用。
3. STM32CubeMX配置与工程搭建
3.1 ETH外设配置:一步一步来
时钟树我建议先把System Clock Mux配好:HSE外部晶振25MHz,PLLM=25,PLLN=336,PLLP=2,得到SYSCLK=168MHz,这是F407能跑到的最高主频。USB的48MHz时钟可以让PLLQ=7得到,这个倒不一定用到,但保持配置完整不会错。
然后开ETH外设,选RMII接口。在RMII参数里,特别要注意几个地方:
PHY Address必须填0,匹配LAN8720A的硬件地址。MAC Address随便写一个,注意别跟局域网其他设备冲突。PHY Clock Prescaler这里有个经验:F407的MDC时钟必须满足IEEE 802.3规定的2.5MHz以下。如果HCLK是168MHz,在CubeMX里它会自动给你算分频,我们不用手动改,但要确认生成后的ETH_MDCClockRange参数正确,否则MDIO通信不稳定,读PHY寄存器偶发错误。DMA Receive/Transmit缓存数量,CubeMX默认值就行,但LwIP的内存池参数后面要单独调。
IO口配置方面,CubeMX会自动把AF引脚映射到对应GPIO,我们只需要确认速度模式设为Very High(50MHz以上信号必须),上下拉保持默认即可。
3.2 LwIP参数配置说明
CubeMX的Middleware里打开LwIP,配置要点说一下:
| 参数 | 值 | 说明 |
|---|---|---|
| IP Address | 192.168.1.100 | 静态IP,便于调试 |
| Netmask | 255.255.255.0 | 默认掩码即可 |
| Gateway | 192.168.1.1 | 跨网段访问才需要 |
| DHCP | Disabled | 从机建议静态IP |
| MEM_SIZE | 1024*40 | LwIP堆内存,太小影响连接数 |
| PBUF_POOL_SIZE | 16 | 接收缓冲池,10以下容易丢包 |
| TCP_MSS | 1460 | 默认值即可 |
| LWIP_SOCKET | Enabled | FreeModbus TCP要用socket API |
这里重点说MEM_SIZE和PBUF_POOL_SIZE。LwIP的内存管理有点像“粮仓”:MEM_SIZE是总库存,PBUF_POOL是粮仓里最常用的那批固定规格口袋,TCP_MSS则是每辆运粮车的最大载重量。如果MEM_SIZE太小,TCP连接建立、TCP段重排这些内存分配就会失败,表现就是“能ping通但TCP连不上”或者“连接成功但一收发数据就断”。F407内存足够,这些参数放心开大。
3.3 FreeModbus源码导入
去GitHub上找armink维护的FreeModbus版本,它有专门的STM32F407+HAL库的TCP示例接口。下载后把src/目录下的所有.c文件(比如port.c、mbtcp.c、mb.c等)加入工程,再把port/目录下的port.h、port.c、porttcp.c也加进来。注意port.c里定义了串口和定时器的接口,TCP模式下用不到但编译需要,可以直接留空实现。
把mbconfig.h里几个关键宏改一下:
#define MB_TCP_ENABLED 1 #define MB_TCP_PORT_USED 502 #define MB_SLAVE_RTU_ENABLED 0 #define MB_FUNC_HANDLING_SUPPORTED 1 #define MB_REG_HOLDING_CNT 100 #define MB_REG_INPUT_CNT 100 #define MB_REG_COILS_CNT 16 #define MB_REG_DISCRETE_CNT 16尤其是MB_REG_xxx_CNT这些定义寄存器数量的宏,直接决定后面数组的大小,一定要按你的实际业务量来定。改太多也没关系,跑得动。
4. FreeModbus TCP移植核心实现
4.1 TCP模式移植的文件和接口
FreeModbus在TCP模式下的核心文件是mbtcp.c,它实现了Modbus TCP协议层,但底层的socket操作全部通过porttcp.c暴露的接口来适配。也就是说,移植的重心就是把porttcp.c里的socket调用换成LwIP风格的调用。
LwIP的socket API和标准BSD socket非常像,所以porttcp.c的改动其实不大。核心代码结构:
#include "lwip/sockets.h" #include "lwip/inet.h" static int xTCPListenSocket = -1; static int xTCPSocket = -1; // 初始化监听socket int xMBTCPPortInit(USHORT usTCPPort) { struct sockaddr_in addr; int rc, opt = 1; xTCPListenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if(xTCPListenSocket < 0) return -1; setsockopt(xTCPListenSocket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); addr.sin_family = AF_INET; addr.sin_port = htons(usTCPPort); addr.sin_addr.s_addr = INADDR_ANY; rc = bind(xTCPListenSocket, (struct sockaddr *)&addr, sizeof(addr)); if(rc < 0) { closesocket(xTCPListenSocket); return -1; } rc = listen(xTCPListenSocket, 4); if(rc < 0) { closesocket(xTCPListenSocket); return -1; } // 注册连接回调 vMBTCPPortSetCallback(&prvTCPCallback); return 0; }这里SO_REUSEADDR特别重要。有一次我把程序下到板子后要重新烧写复现,结果板子重启后TCP始终监听不了502端口,查了半天发现是TIME_WAIT状态没有回收端口。加上SO_REUSEADDR,这个问题就消失了。
prvTCPCallback是一个静态函数,它处理新连接建立的事件。当有主机连上来时,FreeModbus内部需要触发一个MB_EVENT_CONNECTED事件,让协议栈进入“已连接”状态。armink的demo里是这样的:
static void prvTCPCallback(void *pvData) { eMBTCPCallBack((UCHAR)pvData); }在mbtcp.c内部,eMBTCPCallBack会设置一个标志位,告诉eMBPoll“有连接了,开始准备收发数据”。
4.2 寄存器回调函数的实现
Modbus协议真正和业务打交道的地方,是那四个寄存器读写回调函数。这里以最常用的保持寄存器为例:
static USHORT usRegHoldingBuf[MB_REG_HOLDING_CNT]; eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { int iRegIndex = (int)usAddress - 1; if((usAddress >= 1) && (usAddress + usNRegs) <= MB_REG_HOLDING_CNT + 1) { if(eMode == MB_REG_WRITE) { // 主机写入:把小端序数据转成寄存器数组 for(int i = 0; i < usNRegs; i++) { usRegHoldingBuf[iRegIndex + i] = (pucRegBuffer[2 * i] << 8) | pucRegBuffer[2 * i + 1]; } } else { // 主机读取:把寄存器数组填到发送缓冲区 for(int i = 0; i < usNRegs; i++) { pucRegBuffer[2 * i] = usRegHoldingBuf[iRegIndex + i] >> 8; pucRegBuffer[2 * i + 1] = usRegHoldingBuf[iRegIndex + i] & 0xFF; } } return MB_ENOERR; } return MB_ENOREG; }这套写法是FreeModbus的标准回调结构,唯一要搞清楚的是:usAddress是从1开始计数的,而我们的数组下标从0开始,所以中间有个减1的操作。另外数据是大端模式(高位在前),这个和Modbus协议规定一致,别搞反了。
实际项目中,你要读写的寄存器往往不是一片数组,而是分布在代码各个模块里的全局变量。这时候一个常用套路是:在回调函数里按地址做映射,比如地址0x0001对应电机转速、0x0002对应设备温度。回调里先判地址,然后直接读写对应的变量。这样业务代码和Modbus协议就解耦了,想加个监控量只需要扩展映射表就行。
4.3 主循环调度与内存配合
FreeModbus的裸机运行方式,一句话概括就是“高频轮询”。在主循环里必须频繁调用eMBPoll(),它内部会检查是否有新的请求到来、是否需要发送响应、连接状态有没有变化。如果主循环里有个耗时很长的阻塞操作,Modbus响应就会超时,主机那边会报错。
框架结构:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_ETH_Init(); MX_LWIP_Init(); // 初始化Modbus TCP从机,最后一个0是单元ID,TCP模式用不到 eMBInit(MB_TCP, 0, 0, 0); eMBEnable(); while(1) { eMBPoll(); // FreeModbus轮询 sys_check_timeouts(); // LwIP超时处理(TCP重传、ARP老化等) // 你的业务代码 } }很多人移植完发现设备能ping通,但Modbus请求没响应,一个重要原因就是没在循环里调用sys_check_timeouts()。LwIP的TCP状态机有很多定时器要靠这个函数驱动,不调用的话TCP握手可能都完不成。CubeMX生成的LwIP代码里,MX_LWIP_Process()这个函数内部就包含了sys_check_timeouts(),所以直接调它更省事。
5. 联调测试与问题排查实录
5.1 PC端Modbus Poll联调流程
移植完成后先别急着写业务逻辑,第一步是验证协议栈通不通。PC端装一个Modbus Poll(网上有试用版),新建连接填上开发板IP和端口502,从站ID随便填,功能码选03(读保持寄存器)或04(读输入寄存器),地址从0开始,数量填10。然后点击“连接”,正常情况下几毫秒内就能看到寄存器数据刷出来。
如果连不上,先分几步排查:
- PC端ping开发板IP,能通说明LwIP协议栈起来了。
- 用Modbus Poll连接时看TCP端口,如果报“Connection refused”,说明FreeModbus没在监听或者还没初始化成功。
- 检查PHY的Link灯,不亮就是硬件问题。
- 打开调试器看程序有没有跑进
eMBPoll(),有时候初始化写到一半就HardFault了,程序根本没进主循环。
我调试的时候喜欢在板子上留一个“心跳寄存器”——用一个递增计数器,主循环每次HAL_GetTick()变化就把计数值写入保持寄存器。这样Modbus Poll读数据的时候,如果数值持续在增长,说明通信链路和协议栈都是通的,而且可以验证寄存器写入功能。别小看这个小技巧,它能帮你区分到底是通信问题还是业务问题。
5.2 Wireshark抓包分析Modbus TCP帧
当Modbus Poll看起来“能用但偶尔出错”的时候,就得请出Wireshark来抓包分析了。先设置抓包过滤条件,只保留跟开发板IP相关的流量:
ip.addr == 192.168.1.100连上开发板后,随便发一条读保持寄存器的请求,Wireshark里能看到完整的交互过程。首先是TCP三次握手:SYN、SYN-ACK、ACK,这一步出了问题通常就是端口不对或者服务端没监听。握手完成后才是Modbus TCP应用数据。
这里有个必须搞懂的报文格式。Modbus TCP的报文由MBAP头(7字节)加PDU组成,MBAP头包含:
| 字段 | 字节数 | 内容 |
|---|---|---|
| 事务标识符 | 2 | 主机生成,从机原样返回 |
| 协议标识符 | 2 | Modbus协议固定为0x0000 |
| 长度 | 2 | 后续单元ID+PDU的字节数 |
| 单元标识符 | 1 | 从站地址,TCP模式一般填1 |
举个例子,一条读3个保持寄存器的请求报文(十六进制):
00 01 00 00 00 06 01 03 00 00 00 03前面00 01是事务ID,00 00是协议ID,00 06是后面6个字节的长度,01是单元ID,03是读保持寄存器功能码,00 00是起始地址,00 03是寄存器数量。而对应的正常响应应该是:
00 01 00 00 00 09 01 03 06 00 2A 00 3C 00 1E这里的00 09是长度,包含了1字节单元ID、1字节功能码、1字节字节计数和6字节数据。用Wireshark看这些字段时,能很清楚地映照出我们代码里每一个字节的组织逻辑。如果响应报文的事务ID和请求对不上,那基本可以断定是FreeModbus内部的响应缓存区被覆盖了,检查端口处理的并发问题。
5.3 常见问题速查表
| 现象 | 常见原因 | 解决思路 |
|---|---|---|
| 网线插上PHY灯不亮 | 焊接、网线、变压器 | 检查硬件,确认RJ45芯线序 |
| 能ping通但TCP连不上 | LwIP内存不足 | 加大MEM_SIZE、MEMP_NUM_TCP_SEG |
| Modbus连接即断 | PHY复位时序不对 | 加长复位延时,检查电源稳定 |
| 读写寄存器超时 | 主循环阻塞 | 把耗时业务移出主循环或改成中断+状态机 |
| 响应报文乱序/丢失 | TCP_MSS或PBUF_POOL过小 | 调整LwIP参数,抓包确认 |
| 地址偏移1个 | usAddress从1开始 | 回调里下标减1 |
其中“能ping通但TCP连不上”这个坑我遇到不止一次。ping走的是ICMP协议,它不需要建立TCP连接,所以ping通只能说明ARP和IP层正常,不代表TCP状态机没问题。检查重点放在LwIP内存和sys_check_timeouts()是否被调用上。
5.4 避坑经验:关于TCP长连接和重连
这个项目做完后,我最想提醒大家的是TCP长连接的维护。Modbus TCP从机在工业现场往往需要长时间运行,而主机的连接可能会异常断开——网线松动、主机软件崩溃、电脑休眠,这些都要考虑到。FreeModbus默认的处理方式是:检测到socket错误后关闭连接,回到监听状态,等待新连接进来。
在实际代码里,我增加了一个连接超时机制:如果modbus_poll没看到新请求的时间超过30秒,就主动关闭当前socket,重新监听。这样可以避免一个“半死”的连接一直占着socket,让后续主机连不上。另外配合TCP的KeepAlive机制,也就是在LwIP里开启SO_KEEPALIVE套接字选项,系统会周期性地发送探测报文,一旦发现对端不可达就主动断开。这两个手段叠加起来,从机侧基本能做到“无人值守”。
还有一点,如果你在前面的开发中发现设备重启后要等几十秒才能重新连上Modbus,多半是TCP的TIME_WAIT状态在作怪。这就是我前面提SO_REUSEADDR的重要原因。把这个选项在监听socket上设置好,重启后端口能立刻复用,主机重连基本无感。
最后我想说,这套组合方案的技术栈其实非常稳定,网上资料也多,真正考验人的反而是那些“软硬件交界”的细节——时钟怎么给、PHY地址配什么、内存够不够、主循环忙不忙。把这些细节摸透了,换个PHY芯片、换块MCU,迁移起来也不会有太大障碍。我目前是把这一套做成了完整的从机模板,后续打算再扩展一个简单的HTTP配置页面,让用户能在浏览器里直接修改IP地址和寄存器映射,那样产品就更能打了。