STM32F407+LAN8720+LWIP+FreeModbus实现Modbus TCP从站移植全解析 简介面向STM32F407开发者的Modbus TCP移植工程包结合LAN8720网卡芯片与轻量级LWIP协议栈完成FreeModbus的TCP通信适配。适合工业控制、物联网设备等需要以太网数据交互的嵌入式项目也适合中级开发者学习协议栈移植思路。压缩包共546个文件约13.1MB核心包含C语言源码、头文件、编译中间文件、可烧写的HEX固件以及工程配置文件源码便于阅读和修改HEX可用于快速上板验证。目前已有5423人学习具备较好的参考价值。工程内已给出LWIP同FreeModbus的对接实现覆盖PHY地址配置、内存池调整、Socket通信和中断处理等关键环节同时附带了编译产物和目录结构可直接在现有工程基础上扩展功能码或加入安全策略显著降低从零开发的时间成本。1. 为什么是F407 LAN8720 LWIP FreeModbus 这套组合很多人在做设备联网时第一反应是直接用带网络协议栈的模块比如W5500或者ESP8266。这当然能跑但一旦你面对的是工业现场、需要长时间在线、要接入SCADA或者组态软件的设备Modbus TCP几乎是绕不开的协议而这套基于F407自带MAC加外置PHY的方案恰恰是成本、可控性和协议自由度之间最均衡的一条路。这也就是为什么这个标题值得你花时间它不是又一个Demo而是一条能直接落到产品上的主线。STM32F407内置了以太网MAC但物理层收发器PHY必须外挂LAN8720A是这颗主控最常见的搭档因为价格便宜、RMII接口引脚少、3.3V供电省事。LWIP负责把TCP/IP栈跑起来而FreeModbus则是在应用层提供Modbus TCP从站服务四者串起来就构成了一个完整的从站节点上位机通过网口读写你的寄存器。这套方案适合谁适合那些已经把F407用熟、想砍掉外置协议栈芯片、同时又不愿意被商业协议库绑定的开发者。这个移植过程说难不难说简单也容易翻车。硬件上就一个RMII接口软件上主要是LWIP底层网卡驱动的适配和FreeModbus里TCP端口的接入。真正让人头疼的往往是时钟配置、PHY复位时序、内存池大小这类看不见的细节。下面我从硬件连接一路讲到验证方法尽量把每一步的为什么和参数含义都交代清楚。2. 硬件是前提RMII接口、时钟和复位顺序2.1 四线RMII把引脚分配先固定住LAN8720A和F407之间走RMII模式只需要TXD0/TXD1、RXD0/RXD1、TX_EN、MDC/MDIO再加上一个50MHz的REF_CLK。很多人第一次照着别的板子抄原理图结果发现网络不通一半以上的原因出在引脚分配和时钟源上。我一般建议先固定一套标准引脚不要随便改。/* RMII接口引脚分配 - 以F407VGT6为例 */ #define RMII_TXD0 GPIO_Pin_12 // GPIOB #define RMII_TXD1 GPIO_Pin_13 // GPIOB #define RMII_TX_EN GPIO_Pin_11 // GPIOB #define RMII_RXD0 GPIO_Pin_4 // GPIOA #define RMII_RXD1 GPIO_Pin_5 // GPIOA #define RMII_CRS_DV GPIO_Pin_7 // GPIOA #define RMII_MDC GPIO_Pin_1 // GPIOC #define RMII_MDIO GPIO_Pin_2 // GPIOCGPIO的复用功能也不能搞错。TX那组在GPIOB上要复用为AF11RX那组在GPIOA上复用为AF11MDC/MDIO在GPIOC上复用也是AF11。如果某个引脚配置错了现象往往是link灯能亮但ping不通因为PHY的management接口正常数据通路却断了。2.2 50MHz时钟为什么说这是整个方案的命门LAN8720A的REF_CLK需要精确的50MHz。F407的MCO1引脚可以输出50MHz时钟但注意MCO输出的是系统时钟分频后的信号这意味着你的系统时钟必须是特定的倍数。一般做法是用HSE 8MHz晶振通过PLL倍频到168MHz然后MCO1配置为PLL的4分频也就是除以3.36这并不直接等于50MHz。更干净的做法是用一个外部50MHz有源晶振直接给LAN8720A的REF_CLKF407这边通过软件配置RMII时钟模式为外部源。这样两边都是干净的50MHz不再受系统时钟分频的约束。我遇到过用MCO出50MHz时系统一进低功耗模式网络就断的情况换外部晶振后问题消失本质是MCO的时钟路径在低功耗下被切断了。2.3 复位时序上电后必须做的事LAN8720A的NRST引脚低电平复位硬件上RC延时即可但软件上更可靠的做法是拉低至少1ms后再释放。问题是LAN8720A没有硬复位引脚只能用GPIO控制。另外它的PHY地址由RXER/PHYAD0引脚的电平决定接地就是地址0x00这也是最常见配置。/* PHY复位函数 - 软件复位时序 */ void LAN8720_Reset(void) { GPIO_ResetBits(GPIOG, GPIO_Pin_11); /* 拉低reset引脚 */ delay_ms(150); /* 保持低电平至少100ms */ GPIO_SetBits(GPIOG, GPIO_Pin_11); /* 释放复位 */ delay_ms(200); /* 等待PHY内部就绪 */ /* 写PHY的Basic Control Register触发软复位 */ ETH_WritePHYRegister(0x00, 0x8000); delay_ms(100); /* 等待软复位完成 */ }注意最后一个软复位步骤容易漏。硬复位只保证了上电时序LAN8720A在收到软复位命令后会重新加载内部配置。如果不做这一步网口可能link正常但协商出来的速率不对或者干脆auto-negotiation失败。我在调试时就遇到过能link上100M却ping不通的情况排查到最后是软复位没写PHY处于半初始化状态。3. 把LWIP跑起来驱动适配、内存管理和网卡初始化3.1 先解决底层ethernetif.c里到底要改什么LWIP的驱动层是围绕ethernetif.c展开的它相当于把F407的MAC外设包装成LWIP认识的结构体。标题里的成包能直接跑固然好但你要弄清楚它改了什么否则以后换个板子还是要自己折腾。最关键的是low_level_init函数。里面要设置MAC地址、配置F407的MAC和DMA、初始化PHY。这里面有个顺序问题先给PHY做复位和配置再初始化MAC控制器否则MAC寄存器读不到有效链路状态。/* low_level_init核心片段 */ static void low_level_init(struct netif *netif) { /* 1. 配置MAC地址 */ netif-hwaddr_len 6; netif-hwaddr[0] 0x00; netif-hwaddr[1] 0x80; netif-hwaddr[2] 0xE1; netif-hwaddr[3] 0x00; netif-hwaddr[4] 0x00; netif-hwaddr[5] 0x01; /* 2. 设置PHY地址为0 */ ETH_InitStructure.ETH_PhyAddress 0; /* 3. 指定RMII模式 */ ETH_InitStructure.ETH_Mode ETH_Mode_RMII; /* 4. 开启DMA收发环形缓冲区 */ ETH_InitStructure.ETH_RxBuffLen 1524; /* 5. 初始化以太网外设 */ ETH_Init(ETH_InitStructure); }MAC地址可以随便编一个本地管理的地址但别用全0否则部分交换机会直接丢弃这个端口的报文。RMII模式这个配置一定不能漏默认值是MII引脚都不对应初始化能过但收不到任何包。3.2 内存池与PCBLWIP性能的隐形天花板LWIP里有两个核心内存概念内存池memp和内存堆mem。TCP报文收发需要动态分配pbuf如果内存池配置太小高负载下直接丢包现象是ping小包通、ping大包不通或者长时间运行后断流。以F407搭配FreeModbus TCP的典型场景我一般这样配置lwipopts.h/* lwipopts.h关键参数 */ #define MEM_ALIGNMENT 4 #define MEM_SIZE (16 * 1024) /* 内存堆16KB */ #define MEMP_NUM_PBUF 16 #define MEMP_NUM_TCP_PCB 8 #define MEMP_NUM_TCP_SEG 16 #define PBUF_POOL_SIZE 16 #define PBUF_POOL_BUFSIZE 1524 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) /* 开启TCP和DHCP客户端便于联调 */ #define LWIP_DHCP 1 #define LWIP_DNS 1注意PBUF_POOL_BUFSIZE设成1524刚好装下一个标准以太网帧。如果你这里设小了收包时pbuf会被截断应用层拿到的是坏数据。TCP_WND决定了单连接接收窗口4倍MSS大约是5.8KB对Modbus这种小报文完全够用不用贪大内存总共就那么多。3.3 网卡初始化顺序先PHY后MAC是铁律初始化失败最常见的原因是对底层的调用顺序没理清。标准流程是PHY复位和识别→ MAC初始化→ 申请netif结构并注册→ 设置默认路由和网关→ 启动DHCP或配静态IP。/* 网卡启动代码 - tcpip_thread中调用 */ struct netif g_netif; void LwIP_Network_Init(void) { tcpip_init(NULL, NULL); /* 启动tcpip_thread */ netif_add(g_netif, ipaddr, netmask, gw, NULL, ethernetif_init, tcpip_input); netif_set_default(g_netif); /* 等PHY链路就绪后再up */ while (!ethernetif_link_check(g_netif)) { delay_ms(100); } netif_set_up(g_netif); }这个while循环等链路就绪是关键。如果不等DHCP可能在网线还没有协商完成时就发出了 discover 报文结果收不到offer回包最终拿不到地址。常见做法是循环检测PHY的link状态寄存器等它置位后再调用netif_set_up这样DHCP成功率几乎100%。4. 让Modbus TCP从站跑起来FreeModbus移植与寄存器映射4.1 FreeModbus TCP端口在干什么FreeModbus本身支持RTU、ASCII和TCP三种模式TCP模式下协议栈变成了TCP/IP而串口相关的底层全部被替换为socket收发。它的核心接口是eMBTCPInit和eMBEnable前者绑定IP和端口后者启动Modbus事件轮询。移植时要理解FreeModbus的单线程模型它把所有请求都放进队列然后由主循环调用eMBPoll来处理。不要把eMBPoll放在中断里否则耗时操作会卡断系统。/* main函数主循环中的Modbus轮询 */ int main(void) { /* 初始化硬件、LWIP等 */ eMBTCPInit(502); /* Modbus TCP默认端口 */ eMBEnable(); /* 使能从站服务 */ while (1) { eMBPoll(); /* 轮询Modbus事件 */ /* 其他任务 */ } }4.2 寄存器映射Coil、Input Register、Holding Register的读写入口FreeModbus通过回调函数把你的寄存器数组暴露给网络。四个关键回调是eMBRegHoldingCB、eMBRegInputCB、eMBRegCoilsCB和eMBRegDiscreteCB。以最常用的Holding Register为例/* Holding Register回调函数 */ eMBErrorCode eMBRegHoldingCB(UCHAR *pucRegBuffer, USHORT usAddress, USHORT usNRegs, eMBRegisterMode eMode) { USHORT usRegIndex; USHORT usRegCount usNRegs; /* 计算实际寄存器索引 */ usRegIndex (USHORT)(usAddress - REG_HOLDING_START); if (eMode MB_REG_WRITE) { /* 写操作把缓冲区数据写入寄存器数组 */ while (usRegCount 0) { usRegHolding[usRegIndex] *pucRegBuffer 8; pucRegBuffer; usRegHolding[usRegIndex] | *pucRegBuffer; pucRegBuffer; usRegIndex; usRegCount--; } } else { /* 读操作把寄存器数组写入缓冲区 */ while (usRegCount 0) { *pucRegBuffer (UCHAR)(usRegHolding[usRegIndex] 8); *pucRegBuffer (UCHAR)(usRegHolding[usRegIndex] 0xFF); usRegIndex; usRegCount--; } } return MB_ENOERR; }这段代码做了大小端转换。Modbus TCP协议里寄存器数据是大端而STM32是小端所以每个16位寄存器在发送时先把高字节放进缓冲区。有人偷懒直接memcpy结果上位机读到的数值高低字节颠倒数据量小时看不出数据量大时根本没法用。4.3 端口绑定与多连接502端口被占用怎么办FreeModbus TCP默认监听502端口但你的现场设备可能需要同时接入多台上位机。FreeModbus原生支持多连接它是通过select模型轮询多个socket连接数受LWIP的MEMP_NUM_TCP_PCB限制。如果你把MEMP_NUM_TCP_PCB设成8那最多同时接受约7个连接。现场接入了组态软件和触摸屏再加一个调试工具连接数很容易超过限制。此时新连接会被拒绝但已有连接不受影响。遇到这个问题优先加大MEMP_NUM_TCP_PCB比如改成12代价是多占约400字节内存对F407来说毫无压力。还有一点LWIP的PCB是有超时回收机制的如果上位机异常退出而没有发FIN包LWIP会在很久之后才回收该连接。所以不要频繁重连调试否则会把PCB占满。重启设备往往能立刻解决问题但这不是长久之计正确做法是检查上位机是否正常关闭连接。5. 避坑清单LAN8720和LWIP移植中的常见问题5.1 现象网线插上后link灯不亮新板子第一次上电最常见的就是这个。我排查过很多次原因大致有三类。第一类是REF_CLK没有输出用示波器量PHY的25引脚没有50MHz波形第二类是复位引脚被某个外设拉低PHY一直处于复位态第三类是LAN8720A的供电引脚没接滤波电容导致PHY内部LDO不稳定。解决方法是按顺序检查先量供电再量时钟最后查复位时序。很多人第一步就去翻代码其实硬件问题更常见。如果手头没有示波器可以用万用表量PHY的LED1和LED2引脚正常情况下上电后这两个引脚会拉高通过LED状态也能判断PHY是否正常工作。5.2 现象ping大包丢包ping小包正常这个问题指向DMA描述符的数量。以太网DMA需要足够多的描述符来缓存多个帧如果描述符太少硬件收到新帧但找不到空闲描述符时就直接丢弃该帧。小包情况下DMA处理速度快不容易触发大包或者连续ping时描述符耗尽开始丢包。检查ETH_DMADescTab数组的大小默认是TX描述符4个、RX描述符4个。通过ETH_InitStructure里的描述符数量参数把RX描述符加到6到8个。F407的DMA描述符每个占用32字节增加几个对内存影响很小。另外要注意RX描述符的DMA buffer要和ETH_RxBuffLen匹配这里设置成1524小于这个值也会丢包。5.3 现象连接502端口能通但读写寄存器超时连接能通说明TCP握手完成读写超时基本是应用层问题。最常见的是Modbus功能码没使能。FreeModbus里默认只开启了一部分功能码比如按住寄存器读写Coil读写可能默认关闭。检查mbconfig.h里的配置宏。/* mbconfig.h中需要确认的宏 */ #define MB_FUNC_HANDLING_SUPPORTED 1 #define MB_FUNC_READ_INPUT_REG_ENABLED 1 #define MB_FUNC_READ_HOLDING_REG_ENABLED 1 #define MB_FUNC_WRITE_MULTIPLE_REG_ENABLED 1 #define MB_FUNC_READ_COILS_ENABLED 1如果哪个功能码编译进去了但调用仍然失败那就要检查寄存器地址范围。FreeModbus的地址是以1为基准的比如Holding Register起始地址是0x0001但上位机发送的地址可能从0开始。这个偏差会在回调函数里计算索引时体现如果索引算错读写就落在了错误的寄存器上。5.4 现象长时间运行后Modbus响应越来越慢这是典型的LWIP内存泄漏。常见原因是pbuf没有释放。在ethernetif_input函数里每个接收到的帧都会封装成pbuf然后交给tcpip_input由协议栈内部释放。但如果你在low_level_input里手动修改了pbuf的引用计数或者错误地调用了pbuf_free就有可能导致泄漏。另一个小原因是LWIP的tcpip_thread线程栈设置太小当报文频率升高时栈溢出导致任务卡顿。检查tcpip_thread的栈大小一般给1024到2048字节比较保险。这种问题比较难定位我一般做法是跑压力测试连续收发12小时观察内存池的使用率变化。5.5 现象PHY的link状态一直是down排除硬件问题后最常见的是MDIO时序问题。F407的MDC时钟是根据AHB时钟分频得到的如果分频系数不对PHY的management接口通信会失败读回来的寄存器全为0xFFFF或0x0000。通过ETH_InitStructure里的ETH_MDCClockRange可以设置分频系数。当你的AHB时钟是168MHz时把这个参数配置为合适的分频值MDC大概是几百kHzPHY在手册中会给MDC的最大频率一般不超过2.5MHz。配置低了没问题配置高了可能通信不稳定。注意这里设置的是MAC读PHY时的时序如果同时还有其他驱动也在操作PHY保持一样的配置。6. 验证方法和进阶从ping通到Modbus Poll再到接口封装6.1 用最小命令确认底层ping和ARP先行移植完成后第一件事不是打开上位机软件而是先做网络层的验证。把开发板接到交换机板子设成静态IP电脑设成同网段先ping一下。ping通了说明MAC和PHY基本正常LWIP的ARP响应和ICMP响应都在工作。# 设置开发板IP为192.168.1.100在代码中配置 ping 192.168.1.100 -t如果ping不通抓包看ARP响应。电脑发ARP请求如果板子回了ARP但ping超时说明MAC层没问题问题在ICMP或者路由配置。如果ARP都没有响应基本可以确定问题出在PHY的收发链路上回头检查RMII引脚和DMA配置。6.2 用Modbus Poll做最终验证数据一次读对ping通了接下来用现成的Modbus主站工具验证寄存器读写这是整个移植从调试走向结束的标志。打开连接配置选择Modbus TCP填IP和端口502从站ID填1FreeModbus TCP模式下从站ID可以随意填服务器会忽略然后读取Holding Register。读取地址0x0000 到 0x0009 预期结果寄存器数据与代码中初始化的值一致如果读出来的值都是0先看usRegHolding数组有没有初始化。如果我前面代码里的usRegHolding没有赋初值读出来自然全是0。如果值是反的大小端问题检查回调函数里的字节转换。如果读不到先ping一下IP排除网络问题后再看502端口是否在监听可以通过开发板的串口打印确认。6.3 进阶把寄存器访问封装成设备属性调试通了之后接下来做的事值得认真考虑把Modbus的寄存器读写封装成与应用层解耦的接口不要直接在主逻辑里到处写usRegHolding[i]的裸操作。比如设备有温度和湿度两个量那就定义温度寄存器的宏、湿度寄存器的宏然后主逻辑不直接操作寄存器数组而是调用设备状态更新函数写入特定寄存器。这样做的收益是后续增加寄存器、换通讯协议的时候不需要改业务逻辑。因为Modbus只是个壳换成RTU或者MQTT只要接口不变业务代码完全不用动。在实际项目中我见过太多因为寄存器访问散落各处导致后面加一个功能码要改几十处调用的情况重构成统一接口后维护成本下降一大截。6.4 最后一步把LWIP的周期性处理挂进系统如果你不希望LWIP跑在裸机死循环里可以用一个周期任务比如每5ms调用一次ethernetif_check_link状态、每250ms调用一次dhcp_fine_tmr或tcp_tmr。把tcpip_thread和周期处理都放进RTOS里整个系统的实时性和稳定性会上一个台阶。/* FreeRTOS中周期性处理LWIP定时器 */ void LwIP_Periodic_Task(void *arg) { while (1) { dhcp_fine_tmr(); /* 每250ms调用 */ tcp_tmr(); /* 每250ms调用 */ ethernetif_check_link(g_netif); /* 检查网线状态 */ vTaskDelay(250); } }这个任务用250ms的周期就够TCP的超时重传、ARP老化都依赖这些定时器。如果你不调tcp_tmrTCP的重传机制会失效丢包后连接直接卡死这是我踩过的最深的坑之一当时排查了很久才意识到是定时器没跑。其实这套方案回头看真正的门槛不在代码量而在于对时序的理解PHY复位要等够时间、时钟要精确、DMA描述符要配够、LWIP定时器要周期性驱动。把这四件事刻进习惯里后面换MCU也能少走很多弯路。希望这篇能帮你在移植时少交点学费。本文还有配套的精品资源点击获取