☰
SK主控+LWIP的Modbus TCP/IP移植实战与调试全记录
2026/10/8 10:21:26 网站建设 项目流程

前阵子把一个老项目从“串口Modbus+网关”的架构换成“LAN直连+modbus-tcpip”,主角是SK系列主控,协议栈选了lwip。整个过程比想象中绕:lwip移植本身不算难,真正花时间的是底层LAN驱动的适配、PHY的坑,以及把modbus-tcpip的报文流和lwip的连接管理对齐。这篇把从选型、移植、联调到老化测试的过程完整捋一遍,涉及lwip的裁剪细节、PHY调试的几个经典现场、freemodbus往lwip上落的接法,还有我实际踩过的几类故障的排查链路。做类似项目,特别是想把原有Modbus设备平滑切到以太网通道的朋友,可以直接拿来当参照。

1. 先从SK+LAN这个组合说起:选型逻辑和总体架构

1.1 为什么是LAN直连,而不是继续堆串口

之前设备用串口Modbus RTU跑,稳是稳,但痛点很明显:波特率卡死在115200,一包数据哪怕只有几十个字节,轮询一圈下来要好几秒;现场要接上位机,还得专门加一个串口转以太网网关,多一个设备就多一层故障源。这次客户要求上位机直接通过以太网读设备数据,而且希望去掉中间网关,我第一反应就是给SK系列主控扩一个以太网口,直接把Modbus协议跑在TCP/IP上。

LAN方案相比串口的优势不光是速度。以太网是全双工,上位机可以随时发起连接,设备端不需要像RTU那样靠地址轮询区分主从;多个上位机、触摸屏、数据平台可以同时挂在同一台设备上,这在老架构里基本做不到。还有一个实际好处是距离——串口超过几十米就要加中继,以太网用普通交换机随便拉个一百米轻轻松松,现场布线灵活得多。

1.2 硬件资源盘点与软件分层

SK系列这颗主控本身带MAC控制器,所以硬件上加一颗PHY芯片就能把网口跑起来。PHY我选了带工业级温度范围的10/100M自适应芯片,RMII接口,只需要MIIO/MDIO两根管理线加四根数据线,加上50MHz参考时钟,引脚占用非常少。变压器用带网络变压器的RJ45座子,板上做了ESD防护和共模电感,这些对工业现场很重要,省了后面返工的麻烦。

软件上分成四层:最底下是PHY驱动和MAC描述符管理,往上是lwip协议栈的适配层,再往上是modbus-tcpip协议处理,最顶上才是业务逻辑。中间用RTOS做线程调度。为什么这么分?因为lwip本身不关心你的业务是什么,它只管把TCP数据流可靠地送上来;而modbus-tcpip关心的是报文格式和寄存器读写规则。把这两层拆开,好处是任何一层出了问题都能单独验证——PHY层用回环测试,lwip层用ping,modbus层用上位机读写寄存器,哪一环过不去一目了然。

2. lwip移植最花时间的不是协议栈,而是适配层

2.1 版本选择和lwipopts.h的裁剪策略

lwip我选了2.1.2。相比老掉牙的1.4.1,2.x版本在内存管理、TCP性能、keepalive支持上都好太多。1.4.1那套还要自己折腾很多兼容代码,2.1.2基本拿来就能用,社区资料也多,出问题搜起来方便。

移植lwip第一步就是裁剪配置,文件叫lwipopts.h。这个文件决定了协议栈占多少RAM、开哪些功能,是整个移植里最需要认真对待的东西。

#define NO_SYS 0 #define LWIP_SOCKET 1 #define LWIP_NETCONN 1 #define LWIP_TCP 1 #define LWIP_TCP_KEEPALIVE 1 #define MEM_SIZE (60 * 1024) #define MEMP_NUM_PBUF 16 #define PBUF_POOL_SIZE 24 #define MEMP_NUM_TCP_SEG 32 #define MEMP_NUM_NETBUF 16 #define MEMP_NUM_NETCONN 8 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (8 * TCP_MSS)

这里要说明一下每个宏的用途:MEM_SIZE是lwip自己管理的堆内存大小,如果跑modbus这类小报文业务,60KB足够;TCP_SND_BUF是发送缓冲区大小,8个MSS意味着能缓冲约12KB的数据,上位机一次性写几十个寄存器也塞得下;MEMP_NUM_TCP_SEG决定协议栈同时能缓存多少个TCP分段,开小了高负载下会丢包,开大了占RAM。实际项目里这几个值是慢慢调出来的,后面老化测试时我会讲到怎么看它们够不够。

还有个关键配置是NO_SYS。我跑的是带RTOS的环境,所以设成0,这样lwip会自己创建一个tcpip_thread,所有网络协议处理都在这个线程里执行,业务线程通过socket API和它通信。如果没有RTOS就设成1,用raw API,那又是另一套写法,项目复杂度会高不少。

2.2 以太网驱动与DMA描述符配置

MAC驱动的核心是DMA描述符。SK片内MAC的DMA收发都要用描述符链表管理缓冲区,每个描述符指向一个数据缓冲区,收包时MAC硬件把数据写进缓冲区,置位描述符状态位;发包时软件把数据填进缓冲区,置位后交给硬件。这里有个硬件要求:描述符本身必须32字节对齐,缓冲区也要做对齐处理,不对齐的话DMA传输会出现不可名状的数据错乱。

描述符数量要留足,我配置了收发各16个。16个接收描述符意味着网卡硬件最多缓存16个包,如果应用线程处理不过来,新来的包会被硬件丢弃。一开始我只配了8个,上位机一次性下发大量数据时PC端抓包正常,但设备端就是收不全,后来把接收描述符加到16个才解决。

初始化以太网驱动的完整顺序大致是:使能MAC外设时钟,配置RMII引脚复用,复位PHY,读取PHY ID确认地址,然后初始化DMA描述符,配置MAC地址,最后使能接收和发送中断。这个顺序不能乱,尤其是PHY复位后要等一下,我遇到过复位后立刻去读PHY ID,读回来的全是0xFF,就是因为没等PHY稳定。

2.3 OS适配层:信号量、互斥锁和临界区

lwip要跑在RTOS上,必须实现它要求的几个OS抽象函数,在sys_arch.c里:sys_sem_new/sys_sem_signal/sys_arch_sem_wait,sys_mutex_new/sys_mutex_lock/sys_mutex_unlock,还有sys_mbox_new等。说白了就是把lwip需要的信号量、互斥锁、消息队列映射到RTOS的对应原语上。

信号量主要用于TCP/IP线程和驱动中断之间的同步,收包中断里释放信号量,tcpip_thread被唤醒去处理数据;互斥锁用于保护临界资源,比如寄存器备份区和modbus的事务状态;消息队列在socket API里大量使用,netconn层用mbox把数据从协议栈传递给socket层。这一层写不好,最典型的症状就是系统跑一段时间后死锁,或者中断里调了不能调的函数导致优先级翻转。

还有一个容易忽略的是sys_now函数。lwip的TCP超时重传、keepalive都依赖系统tick,sys_now要返回毫秒级时间戳。我用一个硬件定时器做时基,每1ms递增一次计数值,sys_now直接读这个值返回。千万不要用软件延时凑,TCP超时计算会乱套。

3. PHY与链路层调试:网口能link不代表收发正常

3.1 我踩过的“驱动正常但ping不通”的坑

第一次上电,PHY的link灯亮了,通过交换机也能看到设备,但是PC去ping就是不通。这种问题最坑,因为物理层看起来全好,你不知道该查哪一层。我的排查思路是用MDIO寄存器一点一点确认PHY状态。

首先是读PHY的BMCR(控制寄存器)确认是否处于自适应模式,速度协商出来是多少;再读BMSR(状态寄存器)确认link状态、自动协商完成位。确认PHY没问题后,问题就锁定到MAC或者lwip侧。

接着用示波器看RMII的TX_EN和TXD0,ping的时候应该能看到波形。如果没有波形,说明MAC没把数据发出来;有波形但PC收不到,多半是时钟相位问题或者MDIO配置有问题。我那次的最终原因很蠢——MAC地址全零。lwip里MAC地址全零,ARP就处理不了,PC的ping请求根本得不到回应。把MAC地址改成带随机部分的真实地址后,ping通了。

这个经历给我的教训是:链路调试别瞎猜,按物理层、MAC层、协议层一层层确认。每个环节都有对应的验证手段:PHY用MDIO寄存器看,MAC用示波器看RMII波形,协议层用wireshark看ARP和ICMP报文。

3.2 link状态检测的正确姿势

设备跑在工业现场,网线被人误拔、交换机断电是常事。modbus上位机需要能感知这些异常并恢复,所以link状态检测必须做对。

最省事的做法是开PHY的link状态变化中断,拉高PHY的INT引脚,在中断里更新网络状态。但PHY的中断存在毛刺,link状态经常在up/down之间抖动,直接拿来触发重连逻辑很容易崩。我最后的方案是:PHY中断只做一个置标志位的动作,由应用层每2秒轮询一次PHY的link状态寄存器,连续两次读到down才认为链路真断了。这个“连续确认”的延迟机制很有用,能滤掉绝大多数抖动干扰。

链路恢复后的处理也要注意。交换机端口有STP(生成树协议),插上网线后端口可能要等几十秒才放通数据。所以link恢复后不要立刻重连modbus,等交换机端口稳定,我一般延时10秒再主动发起重连,成功率明显提高。

3.3 中断处理与数据一致性

以太网中断处理有个原则:中断里只做最少的活,把能推迟的工作都交给协议栈线程。我的收包中断里做的事情只有三件:读取DMA状态判断是否收到完整包、释放信号量唤醒tcpip_thread、清中断标志。真正的报文处理全部在tcpip_thread里完成。这样做的目的是避免中断里调用lwip的API导致线程安全问题,也避免长时间关中断影响系统实时性。

另一个容易掉的坑是缓存一致性。如果主控带数据缓存,DMA写入的内存区域必须做一致性处理,否则会出现一种诡异现象:硬件明明收到了数据,CPU读出来却是旧数据。我在驱动里直接把收包缓冲区标记为DMA一致性内存,避免flush/invalidate操作不当。这个细节在裸机下不明显,上了RTOS之后问题才暴露出来,而且很难复现。

4. modbus-tcpip协议层落地:freemodbus与lwip的对接

4.1 从串口Modbus到Modbus TCP的差异

很多人以为modbus-tcpip就是在串口modbus外面套一层TCP,直接把RTU帧塞进去就行,这是误区。Modbus TCP用的是MBAP报文头(7字节),然后才是PDU(功能码+数据),和RTU的地址域+CRC完全不同。RTU帧有设备地址和CRC校验,TCP帧里这些都没有,靠TCP连接本身保证可靠性,靠Unit Identifier标识设备。

串口Modbus和Modbus TCP还有一个本质区别:串口是半双工共享总线,同一时间只能有一个主机发起请求,所以必须轮询;以太网是全双工点对点(交换机连接),每个客户端和设备之间是独立的TCP连接,多客户端可以同时请求,设备端要能并发处理。这个差异直接影响到协议栈实现和业务逻辑设计。

4.2 freemodbus在lwip上的接法

我选用freemodbus作为modbus协议栈,它在lwip上跑需要正确配置宏。

#define MB_TCP_ENABLED 1 #define MB_ASCII_ENABLED 0 #define MB_RTU_ENABLED 0 #define MB_FUNC_READ_HOLDING_ENABLED 1 #define MB_FUNC_WRITE_MULTIPLE_HOLDING_ENABLED 1

初始化代码大概这样:

eMBErrorCode eStatus = eMBInit(MB_TCP, 0x01, 0, 502); if (eStatus == MB_ENOERR) { eStatus = eMBEnable(); }

eMBInit的第一个参数传MB_TCP时,freemodbus内部会走TCP分支,绑定502端口。这里要注意:freemodbus的TCP实现依赖socket接口,lwipopts.h里LWIP_SOCKET必须设为1,否则编译都过不去。我一开始用默认配置忘了开socket,折腾了半天编译错误。

应用侧重点是注册寄存器回调。我实现eMBRegHoldingCB回调,在里面读写业务寄存器数组。这个回调会在收到03读保持寄存器、06写单个保持寄存器、16写多个保持寄存器等功能码时被调用。回调里要加临界区保护,因为多个TCP连接可能同时触发回调。我用一个互斥量包住寄存器数组的读写,简单可靠。

4.3 多客户端连接管理

freemodbus默认支持多客户端同时连接,但每个事务是串行处理的。这意味着如果上位机A发了一个读请求,同时上位机B也发了一个写请求,设备端会先处理完A再处理B。这对大多数场景够用了,但要注意一个问题:单个客户端长时间占用连接不关闭,会影响其他客户端的并发体验。所以TCP连接管理要加超时机制。

我在freemodbus的TCP层上做了两个增强:一是空闲超时断开——客户端连接超过30秒没有发送任何modbus帧,主动关闭该连接,释放资源;二是连接数限制——超过4个客户端时拒绝新连接。这两个策略让设备在长时间运行后不会因为连接泄漏而资源耗尽。这是从现场实际教训里总结的:有次设备跑了几天后上位机连不上,检查发现几十个TIME_WAIT状态的连接占满了资源,加了超时机制后彻底解决。

5. 联调阶段的问题排查手记:超时、断线、数据错位

5.1 请求超时的定位思路:从wireshark开始

上位机报超时,第一件事不是改代码,而是抓包。wireshark过滤器设成tcp.port == 502,然后看请求是否到达设备、设备是否回了响应、响应是否被上位机正确解析。三条路径任何一个环节断了,现象都是超时,但根因完全不一样。

我遇到过一种典型情况:上位机发请求,PC端抓包能看到SYN包重传,说明TCP连接根本没建立。检查设备端发现socket监听没有正常启动——freemodbus的TCP任务依赖tcpip_thread正常运行,我初始化时把eMBInit放在了tcpip_init之前,导致绑定失败。把初始化顺序理顺后就好了。

还有一种情况:TCP连接建立成功,请求也到了设备,就是没响应。这种多半是应用层卡住了。我在老代码里看到寄存器回调里面做了flash擦除操作,阻塞了几百毫秒,TCP层等不及就重传,重传又堆积,直接把接收缓冲挤爆。解决办法是把flash操作挪到独立线程,回调里只更新RAM中的寄存器镜像。

5.2 字节序和设备ID不一致的教科书级错误

modbus协议规定寄存器值是大端的,也就是高字节在前。MCU读到的U16是低字节在低地址,直接发送出去就会高低字节颠倒。我一开始手写报文处理时犯过这个错:上位机读到寄存器值为0x1234,实际应该是0x3412。排查时对比读写日志和数据表才发现,修复方式很简单,在发送前做一次字节交换。

uint16_t raw_value = register_buf[idx]; uint16_t be_value = (raw_value >> 8) | (raw_value << 8);

Unit Identifier是另一个坑。Modbus TCP的MBAP头里有Unit ID字段,但设备在TCP连接上不需要像RTU那样用地址区分轮询目标,很多上位机默认填1,而设备端回调里如果校验这个字段必须等于某个值,就会直接丢弃请求。我的做法是无论Unit ID是多少都接受并处理,保持兼容性。如果确实需要区分多台设备(通过网关级联),再单独处理这个字段。

5.3 周期性断线的根因:内存池耗尽

还有一次最难查的问题——设备运行12小时左右,上位机开始间歇性超时,再过一段时间彻底连不上,设备本身没有死机(其他功能正常)。看网口状态link还是好的,但TCP连接全断。

我怀疑是某个线程卡死,或者内存泄漏。把lwip自带的统计信息打开后,发现mem_free的余量一直在下降,最终归零。导致内存耗尽的是TCP连接上了不关闭,每个连接占用的pcb和socket buffer都没有释放,慢慢地堆积。配合抓包工具发现上位机的连接断开后还有一个处于TIME_WAIT状态的连接残留,由于TCP_DEFAULT_TTL和MSL配置问题,TIME_WAIT要等很久才能回收。应对方案有两个:一是调整lwip的TCP_TIME_WAIT状态回收时间,二是应用层做空闲连接检测,超过30秒没有modbus请求就强制关闭。

这个案例说明一个道理:嵌入式网络设备跑几个小时甚至几天才出现的故障,大概率不是纯逻辑问题,而是资源管理问题。上线前一定要做长时间压力测试,并且在代码里保留内存统计接口,方便现场排查。

6. 稳定性和性能收尾:老化测试的完整配置

6.1 内存池参数的最终调优

老化测试暴露了内存问题后,我对lwipopts.h做了最终调整。内存参数不是越大越好,MCU的RAM总量就那么多,要在性能和资源之间平衡。我的最终配置经过72小时测试验证,稳定性和性能都过了:

参数初始值最终值备注
MEM_SIZE40KB60KB堆内存,留足余量
MEMP_NUM_TCP_SEG1632TCP分段缓存
PBUF_POOL_SIZE1624收包缓冲池
MEMP_NUM_NETCONN48支持更多并发连接
TCP_SND_BUF4*MSS8*MSS发送缓冲加大
接收描述符816硬件收包缓存

调整的依据不是拍脑袋:用上位机脚本以50ms周期连续读写寄存器,同时记录lwip统计的mem_free最小余量,只要余量不跌到0并且保持稳定增长后回落,就说明缓冲区够用。

6.2 看门狗策略和异常恢复

带网络协议的设备,看门狗不能简单喂狗了事。如果TCP线程死掉但主线程还在跑,喂狗就不会触发复位,设备看起来活着但网络实际已经瘫了。我设计了三级看门狗:主线程喂一级狗,modbus任务每500ms设置一个存活标志,主线程检查这个标志来喂二级狗,同时周期性检查网卡link状态,如果link down超过指定时间,触发一次网络栈完整重启——关中断、复位PHY、重新初始化DMA描述符、重新注册netif接口,整个过程约1秒,上位机在TCP层会感知到连接断开并自动重连。

网络栈重启逻辑要设计好,否则会变成无限重启。我加了一个上限:1小时内最多触发3次网络重启,超过就整机复位。这个机制让设备在持续网线抖动的情况下也能自恢复,而不是无限自杀。

6.3 老化测试的观测方法和结果

老化测试我跑了72小时,测试环境是设备接工业交换机,上位机用Modbus Poll加一个自写的Python脚本同时跑。脚本以500ms周期读保持寄存器,500ms周期写不同的寄存器值,再回读校验。同时每10秒ping一次设备,记录丢包率。

72小时结果:丢包率0%,modbus读写成功率100%,lwip内存余量稳定在16KB以上,没有出现一次死机或断连。这个状态下才算移植验收通过。还要测异常场景:拔网线10分钟再插回,设备在10秒内自动重连modbus,无需人工干预。

老化测试最大的价值是逼真地暴露了内存泄漏、连接泄漏这些在短时间功能测试里根本发现不了的问题。做完这一轮测试,我对这套SK+LAN+lwip+modbus-tcpip的方案才算真正有了信心。

这套组合后续如果换平台,大概率还会遇到新坑。但骨架和调试方法论是通用的:先物理层,再协议栈,再应用层,层层验证,用抓包说话,用统计指标说话。按照这个节奏走,任何以太网+modbus的项目都能稳稳落地。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询