STM32F407+LAN8720A+FreeModbus实现Modbus TCP从机(LwIP)

发布时间:2026/9/19 12:08:22
STM32F407+LAN8720A+FreeModbus实现Modbus TCP从机(LwIP) 说实话看到这个标题我就知道又是一位被客户追着要“网口远程监控”折磨的嵌入式兄弟。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请求先从网线进入LAN8720APHY把模拟信号转成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输入PA150MHz参考时钟RMII_MDIO双向PA2管理接口数据线RMII_MDC输出PC1管理接口时钟RMII_CRS_DV输入PA7载波侦听/数据有效RMII_RXD0输入PC4接收数据位0RMII_RXD1输入PC5接收数据位1RMII_TX_EN输出PB11发送使能RMII_TXD0输出PB12发送数据位0RMII_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外部晶振25MHzPLLM25PLLN336PLLP2得到SYSCLK168MHz这是F407能跑到的最高主频。USB的48MHz时钟可以让PLLQ7得到这个倒不一定用到但保持配置完整不会错。然后开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 High50MHz以上信号必须上下拉保持默认即可。3.2 LwIP参数配置说明CubeMX的Middleware里打开LwIP配置要点说一下参数值说明IP Address192.168.1.100静态IP便于调试Netmask255.255.255.0默认掩码即可Gateway192.168.1.1跨网段访问才需要DHCPDisabled从机建议静态IPMEM_SIZE1024*40LwIP堆内存太小影响连接数PBUF_POOL_SIZE16接收缓冲池10以下容易丢包TCP_MSS1460默认值即可LWIP_SOCKETEnabledFreeModbus 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版本它有专门的STM32F407HAL库的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是单元IDTCP模式用不到 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主机生成从机原样返回协议标识符2Modbus协议固定为0x0000长度2后续单元IDPDU的字节数单元标识符1从站地址TCP模式一般填1举个例子一条读3个保持寄存器的请求报文十六进制00 01 00 00 00 06 01 03 00 00 00 03前面00 01是事务ID00 00是协议ID00 06是后面6个字节的长度01是单元ID03是读保持寄存器功能码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_SEGModbus连接即断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地址和寄存器映射那样产品就更能打了。