恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
SK主控+LWIP的Modbus TCP/IP移植实战与调试全记录
首页
资讯中心
/
SK主控+LWIP的Modbus TCP/IP移植实战与调试全记录
SK主控+LWIP的Modbus TCP/IP移植实战与调试全记录
发布时间:2026/10/8 10:21:40
前阵子把一个老项目从“串口Modbus网关”的架构换成“LAN直连modbus-tcpip”主角是SK系列主控协议栈选了lwip。整个过程比想象中绕lwip移植本身不算难真正花时间的是底层LAN驱动的适配、PHY的坑以及把modbus-tcpip的报文流和lwip的连接管理对齐。这篇把从选型、移植、联调到老化测试的过程完整捋一遍涉及lwip的裁剪细节、PHY调试的几个经典现场、freemodbus往lwip上落的接法还有我实际踩过的几类故障的排查链路。做类似项目特别是想把原有Modbus设备平滑切到以太网通道的朋友可以直接拿来当参照。1. 先从SKLAN这个组合说起选型逻辑和总体架构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层用pingmodbus层用上位机读写寄存器哪一环过不去一目了然。2. lwip移植最花时间的不是协议栈而是适配层2.1 版本选择和lwipopts.h的裁剪策略lwip我选了2.1.2。相比老掉牙的1.4.12.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_waitsys_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都依赖系统ticksys_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和TXD0ping的时候应该能看到波形。如果没有波形说明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无需人工干预。老化测试最大的价值是逼真地暴露了内存泄漏、连接泄漏这些在短时间功能测试里根本发现不了的问题。做完这一轮测试我对这套SKLANlwipmodbus-tcpip的方案才算真正有了信心。这套组合后续如果换平台大概率还会遇到新坑。但骨架和调试方法论是通用的先物理层再协议栈再应用层层层验证用抓包说话用统计指标说话。按照这个节奏走任何以太网modbus的项目都能稳稳落地。