☰
W5500实现MQTT与Modbus主从同跑的嵌入式实战指南
2026/10/12 4:46:27 网站建设 项目流程

最近我在做一款工业边缘采集设备的固件,遇到一个很有意思的组合需求:设备既要把采集到的数据通过以太网上报到 MQTT 云平台,又要在本地的 Modbus 总线里同时充当主站和从站,而且网络层坚决不能用静态 IP,必须自动获取。拿到这个需求我第一反应是:协议本身都不难,难的是让它们在同一个 MCU 里和谐共处。尤其当接入层用了 W5500 这颗网卡芯片之后,很多细节从"跑通 Demo"到"稳定运行"之间,藏着不少文档里不会写的坑。

这篇文章就完整记录下来我在模拟项目X里用 W5500 实现 MQTT 稳定连接、DHCP 自动获取 IP、函数级错误返回,以及 freemodbus 主从同跑的整个过程。如果你也在做类似的数据采集网关、工业边缘盒子,或者是想搞懂多协议栈如何在单片机上合理共存,这篇应该能让你少走不少弯路。

1. 一机双栈的架构决策:MQTT 和 Modbus 为什么能挤进同一颗 MCU

1.1 需求拆解:上云、本地总线、成本三者的平衡

先把这个项目的需求掰开看。设备放在现场,需要做三件事:第一,把温湿度、压力等传感器数据周期上报到云端 MQTT Broker;第二,作为 Modbus 从站,让上位机或者触摸屏能够读写它的寄存器;第三,作为 Modbus 主站,去轮询现场其他从设备的寄存器。

很多工程师第一反应是,主站和从站能不能同时存在?答案是能。Modbus 协议本身没有规定一个设备不能同时做两个角色,只是链路层上需要处理好收发时序。而 MQTT 和 Modbus 一个走以太网,一个走串口或者 Modbus TCP,本质上互不干扰,真正需要操心的是 MCU 资源分配和任务调度。

硬件选型上,我最终用了带 W5500 的模块,主控是一颗主频不算太高的 Cortex-M 系列 MCU。为什么不用 MCU 内置 MAC + 外部 PHY?一方面是成本,另一方面是稳定性。W5500 最大优点就是内嵌了 TCP/IP 协议栈,TCP、UDP、ICMP、ARP 这些全在芯片里处理,MCU 只需要通过 SPI 读写 socket 缓冲。这意味着即使你的 MCU 没有网络协议栈移植经验,也能很快做出带网络功能的产品,而且 TCP 连接的数据收发不占用 MCU 的 CPU 时间切片。

1.2 W5500 在协议栈里的位置:它到底帮你干了什么活

很多人一听"W5500 内嵌协议栈",就以为 MQTT 和 Modbus TCP 都能直接跑,其实不是。W5500 帮你搞定的是传输层和网络层,包括 TCP 的三次握手、四次挥手、重传、ARP、IP 分片这些底层杂事。但 MQTT 属于应用层,报文封装、QoS 控制、心跳保活还得 MCU 自己处理。

做个不恰当的类比:W5500 就像一条高速公路,它把车(TCP 数据流)安全地从 A 送到 B,至于车上装的是水果还是钢材,它不管。MQTT 和 Modbus TCP 就是车上的货物,得你自己装车、卸货。

所以 W5500 + MCU 这套架构里,MCU 的软件任务大致分为三层:

  • 驱动层:通过 SPI 配置 W5500 寄存器、分配 socket 缓冲区、处理中断事件
  • 协议层:DHCP 客户端、MQTT 客户端、Modbus 协议栈
  • 应用层:传感器采集、数据上报逻辑、主站轮询规划

这三个层次之间怎么组织,直接决定了后面所有问题的排查难度。我的做法是严格分层,每一层只暴露带返回值的接口,禁止跨层调用。这也是后面"所有函数带返回值"这套设计能够顺利实施的前提。

1.3 整体软件框架和两个协议“栈”的共存方式

这个项目里实际存在两套协议栈,一套是面向以太网的 TCP/IP + MQTT,另一套是针对串口的 Modbus RTU 主从。等等,这里其实还有个更复杂的选型:Modbus 到底走串口还是走 TCP?

我最后的选择是:Modbus 从站走 Modbus TCP,主站走串口 RTU。为什么这么配?因为从站要服务的是现场上位机和触摸屏,它们通常是通过交换机走以太网访问设备,用 Modbus TCP 最自然。而主站要轮询的是一些老旧的传感器和执行器,它们普遍只支持 RS485 接口的 Modbus RTU,所以主站必须走串口。

这样一来,以太网侧跑的是 MQTT 和 Modbus TCP 两个应用协议,串口侧跑的是 Modbus RTU 主站,架构上一目了然。W5500 的 8 个 socket 正好满足:一个给 MQTT,一个给 Modbus TCP,剩下的可以留给固件升级、调试通道等扩展功能。

2. 驱动层先打好地基:SPI 配置、socket 资源划分与复位时序

2.1 SPI 速率、中断引脚和 Buffer 分配的三项关键配置

W5500 是通过 SPI 与 MCU 通信的,几百 KB 到上 MB 的数据都要从这里过。SPI 时钟我一开始图快,直接拉满到几十兆赫兹,结果发现偶尔通信异常。后来降到十几兆赫兹,稳定性立刻上来了。这个教训其实很典型——W5500 的 SPI 从机模式受 PCB 走线、引线长度、电平转换芯片等多方面影响,速率不是越高越好,而是在保证稳定的前提下尽量高。

一个容易忽略的地方是中断引脚。我用的是 MCU 的 EXTI 外部中断引脚,而不是轮询 W5500 的中断寄存器。当 W5500 收到数据、TCP 连接建立或断开时,INT 引脚会拉低,MCU 在中断回调里置一个事件标志位,主循环再处理。这样做的意义在于,你在做 Modbus 主站轮询这种阻塞性操作时,不至于漏掉 MQTT 的收包事件。

socket 缓冲区分配也是一个需要注意的细节。W5500 一共有 16KB 发送 + 16KB 接收缓冲,8 个 socket 默认各分 2KB + 2KB。对 MQTT 来说,2KB 的接收缓冲够用,但如果你的 MQTT 订阅消息体很大,或者 QoS1/QoS2 的 PUBLISH 报文比较大,就要给对应 socket 多分配一点。我实际把 0 号 socket(MQTT)设成 4KB 收 + 2KB 发,1 号 socket(Modbus TCP)设成 2KB 收 + 2KB 发,其余 socket 收收 1KB 或直接禁用。分配不当的后果就是接收大数据包时直接溢出丢包,看起来像"网络不稳定"。

2.2 复位时序和寄存器初始化:别跳过的 200ms 等待

W5500 的复位时序看起来简单,但其实有讲究。RST 引脚拉低至少 500us,然后再拉高,这一点大家基本都能做到。容易犯的错误是拉高后立刻就开始 SPI 配置寄存器,这时候芯片内部还在初始化,寄存器访问超时或者读到垃圾数据。

我的做法是:

static uint8_t w5500_hw_init(void) { uint8_t ret = APP_ERR_NONE; W5500_RST_LO(); delay_ms(1); W5500_RST_HI(); delay_ms(200); // 等芯片内部初始化完成,这个时间别省 set_CS(1); // 片选默认拉高 if (wizchip_init(NULL, NULL) < 0) { ret = APP_ERR_W5500_SPI; } return ret; }

200ms 是我实测下来比较稳妥的值,虽然 W5500 数据手册上标称的典型时间没那么长,但在批量生产中,环境温度、晶振偏差都会影响实际时间,留点余量不会错。

2.3 多 socket 分配策略:为什么 MQTT 占 0 号、Modbus TCP 占 1 号

socket 编号本身没有特殊含义,但一旦定了分配策略,后面所有模块都要严格遵守。我把 MQTT 固定在 0 号,Modbus TCP 固定在 1 号,原因很简单:0 号 socket 的缓冲区我给了最大的 4KB,而 MQTT 是最可能收到大 payload 的协议;Modbus TCP 的报文通常不超过 260 字节,2KB 已经非常宽裕。

在驱动层我封装了一个简单的 socket 注册表,拿着 socket 号和用途字符串去申请,避免模块之间互相踩踏。比如 MQTT 模块启动时调用socket_register(0, "mqtt"),如果发现 0 号已经被其他模块占用,就直接报错。这种"带返回值的函数"设计在项目初期有点像是过度工程,但等到要调试复杂问题、排查某个 socket 莫名其妙被占用的时候,你才会明白这套约束值多少钱。

3. DHCP 自动获取 IP 的实现细节:轮询、租约续期和兜底策略

3.1 DHCP 状态机:别把 DHCP_run() 放在 while(1) 里裸跑

W5500 本身不内置 DHCP 客户端,WIZnet 的 ioLibrary 提供了 DHCP 的软件实现。很多人第一次用的时候,直接在初始化里调用一次 DHCP_run(),然后发现确实拿到了 IP,就开始干活了。这其实是个隐患——DHCP 的 IP 租约是会过期的,到期不续租,轻则网络断线,重则 IP 冲突。

正确的做法是把 DHCP 的轮询放进一个周期执行的任务里,让状态机持续跑。我用的 ioLibrary 里面 DHCP_run() 本身就是带状态的,调用它会根据当前状态决定是发送 DISCOVER、接收 OFFER、发送 REQUEST 还是续租。你要做的是保证它是周期性被调用的,而不是只在开机时调一次。

3.2 租约过半就要主动续期,别等到期再现抓

DHCP 租约时间一般由路由器或者 DHCP 服务器配置,常见的是一天或者两小时。客户端应该在租约过去 50% 的时候发起 RENEW,这是 DHCP 协议的标准行为,但很多移植例程里根本没实现这个逻辑。

我实际的处理策略是:拿着 ioLibrary 里的get_dhcp_lease_time(),拿到租约总时长后,在自己的软件定时器里记录一个"下次续约时间点"。当运行时间超过租约一半时,把 DHCP 状态机切到一个 Request 重新发送的状态,然后在每次 DHCP_run() 里完成续约。

这里有一个非常值得注意的细节:如果设备是 DHCP 拿到的 IP,发给服务器的报文里面源 IP 也必须是对的,否则服务器不会处理你的续租请求。W5500 的 socket 在续租时要基于当前 IP 来发 UDP 包,这就要求你在续租前不能把 socket 重新初始化,不然 IP 就丢了。别笑,我还真见过有人在续租逻辑里把 socket 重新 open 了一遍,导致客户端和服务器永远在鸡同鸭讲。

static void dhcp_task(void) { if (dhcp_lease_seconds > 0) { uint32_t renew_threshold = dhcp_lease_seconds / 2; if (running_seconds > renew_threshold && dhcp_renew_sent == 0) { dhcp_state = DHCP_STATE_REQUEST; dhcp_renew_sent = 1; } } DHCP_run(); if (get_dhcp_leased()) { if (dhcp_lease_seconds == 0) { dhcp_lease_seconds = get_dhcp_lease_time(); } } }

3.3 获取不到 IP 时的降级策略:静态 IP 兜底不能一刀切

DHCP 不是任何时候都好用的。现场如果没接路由器、网线没插好、交换机没开 DHCP 服务,设备就会一直卡在获取 IP 的循环里。这时候整机其他功能全部瘫痪显然不合理,所以我的实现里加了一个超时降级逻辑。

具体做法:DHCP 启动后,如果连续 30 秒没拿到 IP,就自动切换到预置的静态 IP 配置,比如 192.168.1.230/24,同时开启一个周期性的 DHCP 探测任务,每隔 60 秒尝试一次 DHCP 发现。一旦 DHCP 可用,就切换到 DHCP 模式并重置网络配置。这样既能满足自动获取 IP 的默认需求,又保证了现场网络的兜底可用性。

这里要特别小心一个坑:切换 IP 配置的时候,W5500 需要重新初始化所有 socket,否则已建立的 MQTT 和 Modbus TCP 连接还留在旧 IP 上,数据包全都不通了。为了安全,我在网络层状态机里定义了一个NET_STATE_DHCP_TO_STATIC的中间状态,先把所有 socket 关闭,再重新初始化 IP 和 socket,然后由 MQTT 模块自动重连。反过来从静态切到 DHCP 也一样,必须完整走一遍网络重置流程。

4. MQTT 稳定连接的关键:心跳、断线检测、重连与 LWT

4.1 MQTT 跑在 TCP 上,所以先要处理 TCP 断开的感知

MQTT 是基于 TCP 的,这意味着一旦 TCP 连接断了,MQTT 必然断开。所以做 MQTT 稳定连接,第一步其实是把 TCP 层的断线检测做好。

W5500 的 TCP socket 有三种方式可以感知断开:第一种是接收对端 FIN 包后 socket 状态变成 CLOSE_WAIT;第二种是发送数据时 socket 缓冲区写不进去,返回超时;第三种是 TCP keepalive 探测。W5500 内置的 TCP 协议栈支持 keepalive 参数,你可以开启它让芯片在空闲时发送探测包,但实际效果不如应用层的 MQTT 心跳灵敏。

我的判断依据是组合式:如果 socket 变成 CLOSE_WAIT,或者 0 号 socket 的接收中断超过一定时间没有触发且发送失败,就把这条连接标记为"需要重建"。这里有一个很多人踩过的坑:TCP 连接断开但 MCU 没有立刻感知,于是继续往 socket 里写数据,W5500 的 buffer 满了之后写操作会被卡住,严重时整个 MCU 主循环都被拖慢。

所以我在发送函数里给 W5500 的 write 也加上了超时,比如 500ms 没写完就直接放弃并返回超时错误码,而不是死等下去。

4.2 心跳间隔怎么定:PINGREQ 不是越频繁越好

MQTT 协议里有 keepalive 机制,客户端在指定时间内没有发送任何控制报文,就要发一个 PINGREQ 心跳包。这个间隔(keepalive)是在连接服务器的 CONNECT 报文里声明的,服务器会用它来判断客户端是否存活。

我见过不少项目把 keepalive 设成几秒钟甚至更短,理由是"这样能更快发现断线"。但实际上,频繁的心跳一方面增加服务器负载,另一方面在 NAT 网关或者运营商的连接表里会产生多余的映射条目,反而容易触发限速策略。我的建议是设在 30 秒到 60 秒之间,具体取决于现场网络环境。

判断心搏是否需要发送,我在主循环里用一个单调递增的 tick 来控制,不要依赖某个被阻塞任务里的延迟函数。

static uint8_t mqtt_keepalive_tick(mqtt_client_t *c) { uint32_t now = get_sys_tick_ms(); if ((now - c->last_send_ms) >= c->keepalive_ms) { if (mqtt_ping(c) != MQTT_OK) { return APP_ERR_MQTT_PING_FAIL; } c->last_send_ms = now; } return APP_ERR_NONE; }

4.3 重连机制:指数退避比固定间隔好用得多

MQTT 重连是个绕不开的话题。Wi-Fi 网络上偶尔抖动,服务器重启,跨交换机 VLAN 调整,都可能导致连接断开。我这里的做法是把"断开"当成一种常态来设计,而不是异常。只要 TCP 层报告断开,MQTT 模块就进入重连流程。

重连间隔我一开始用的是固定 5 秒,后来发现现场几百台设备一起上线的时候,会导致服务器在短时间收到大量 CONNECT 报文,触发服务器的防重放保护,反而连不上。改成指数退避之后,表现明显好了:第一次 3 秒,第二次 6 秒,第三次 12 秒,最大 60 秒封顶。连接成功后重置退避计数。

再配合一个细节:重连之后要把之前订阅的主题全部重新订阅一遍。这是一个很常见的坑,服务器在你断开的时候会清除会话状态(取决于 clean session 标志),如果不重新订阅,设备看起来"在线"了,但实际上收不到任何下发的控制指令。

4.4 LWT 遗愿消息:让云端知道你掉线了

LWT(Last Will and Testament)可能是我在这个项目里得到回报最大的一个功能。设备在 CONNECT 报文里声明一个遗嘱主题和遗嘱消息,如果连接异常断开(没有正常发 DISCONNECT),服务器会自动替设备发布一条遗嘱消息。这样云端后台就能实时感知哪些设备掉线了,而不是等到超时轮询才发现。

我实际配置的遗嘱主题是devices/{device_id}/status,遗嘱消息内容是一段 JSON,比如在线状态字段置 0,同时还能带上掉线时间戳。正常上线时发布一条在线状态为 1 的报文,异常掉线时服务器自动发布状态为 0 的遗嘱。

这里有个关键点:遗嘱消息是由服务器代发的,所以在 CONNECT 里要配置好遗嘱的 QoS,我一般用 QoS1 或 QoS2,避免遗嘱消息自己也丢失。如果你的场景里设备异常掉线后需要远程重启或者远程诊断,LWT 几乎是必须实现的机制。

5. 函数返回值不是摆设:一套可追溯的错误码设计带来的工程收益

5.1 错误码枚举与分层:状态码也是协议的一部分

"相关函数均带返回值"这个要求,说白了就是在设计层面强制所有接口函数都要返回一个明确的状态,而不是 void 加全局错误变量。很多嵌入式的老项目习惯用全局变量记录错误,比如g_last_error = 3;然后在别的地方读一下。这种写法在小型单线程程序里勉强能跑,但一旦模块多了,全局错误变量被覆盖、被篡改是迟早的事,排查问题的成本极高。

我的做法是给每个模块定义独立的错误码枚举,从 0 开始递增,留下扩展空间。比如:

typedef enum { APP_ERR_NONE = 0x00, APP_ERR_W5500_SPI = 0x11, APP_ERR_W5500_SOCKET = 0x12, APP_ERR_DHCP_RETRY = 0x21, APP_ERR_MQTT_CONNECT = 0x31, APP_ERR_MQTT_PUBLISH = 0x32, APP_ERR_MQTT_SUBSCRIBE = 0x33, APP_ERR_MB_RTU_TIMEOUT = 0x41, APP_ERR_MB_TCP_TX = 0x51, } app_err_t;

每一层的函数返回自己的错误码,上层拿到错误码后可以选择直接向上传递,也可以翻译成更语义化的错误码后再上报。这里要克制的是"错误码爆炸"——每一层都加前缀,会让错误码越来越长,最终失去可读性。我参照了常见处理方式,统一保留两位数字分段:0x0X 表示通用成功/失败,0x1X 表示驱动层错误,0x2X 表示网络层错误,0x3X 表示 MQTT 错误,0x4X 表示 Modbus 错误,0x5X 表示业务层错误。这样的好处是光看错误码大概就知道是哪一层出的问题。

5.2 调用链中如何传播和收敛:不让错误码在中间层丢失

光有错误码还不够,关键是每次函数调用都要处理返回值,而不是眼神瞟一下就当无事发生。实际写代码的时候,我会要求自己遵循这么几条约定:

  • 函数入口做好参数检查,非法参数直接返回APP_ERR_W5500_SPI之类的错误码,不进入主流程
  • 中间层拿到错误码后,如果当前这一层无法处理,就原样向上返回,最多加一层上下文信息
  • 业务层对错误码做最终决策:是重试、降级,还是进入错误处理分支
  • 禁止用(void)func();这种写法来吞掉返回值

这四条看起来简单,但真正做到需要坚持。我见过最多的反例是:初始化函数里某个子函数返回了错误,外层init_all()直接忽略了,结果后面所有数据都是错的,还要靠串口日志一行行翻才能找到根因。如果每一层都传递错误码,这个问题根本不会发生。

5.3 把错误码变成调试日志:现场排查不再大海捞针

错误码的真正价值不在于"报错",而在于把出错的位置和原因用最短的路径告诉调试者。我在这个项目里把错误码和一段可读的字符串做了映射,每次错误发生时,不仅记下错误码,还会附上发生时的 tick 时间戳以及一些关键上下文,比如当时的 socket 状态、DHCP 是否拿到 IP、当前任务在哪个状态机里。

现场调试的时候,如果设备频繁重连,我可以拉出日志看到类似这样的序列:

[12345] [ERR] mqtt connect fail, code=0x31, soc=0, dhcp=1 [12346] [ERR] mqtt reconnect backoff, next_try=6000ms

这一行日志就直接告诉我:DHCP 没问题,socket 是 0 号,MQTT 连接失败。接下来我只需要去查服务器地址、端口、用户名密码这些配置就行了,而不是毫无目的地翻代码。

有一个相关的习惯特别推荐:把返回值检查做成一个统一的宏,比如RET_CHECK(call),在 Debug 版本里失败时主动打印文件和行号,Release 版本里则只记录错误码。这样开发期足够啰嗦,生产期足够省资源,两全其美。

6. freemodbus 主从同跑的落地细节:协议栈扩展和时间片博弈

6.1 从站功能的移植:给 FreeModbus 换一个 TCP 通道

FreeModbus 官方实现里,默认的从站可以跑在 RTU 上,通过串口收发;demo 里也带了 Modbus TCP 的实现。我在这个项目里让从站走 Modbus TCP,把 FreeModbus 的 port 层从"串口收发"换成"W5500 socket 收发"。

具体而言,FreeModbus 的事件驱动机制是通过回调函数处理请求收发的。在 TCP 模式下,一个 TCP 连接就是一个 Modbus TCP Slave 上下文。我打开了一个 TCP server socket 监听 502 端口,每来一个连接就为它分配一个 Modbus 上下文。由于 W5500 只有 8 个 socket,我把 Modbus TCP 的监听 socket 和连接 socket 做了严格的限制,最多支持 2 个同时连接的客户端,避免 socket 耗尽导致 MQTT 拿不到通道。

FreeModbus 里eMBInit、eMBEnable、eMBPoll这几个核心函数,在移植时保持不变。eMBPoll需要在主循环里周期调用,它会检查接收标志位,调度请求处理。我做的工作主要是写了一个mb_tcp_port.c,把xMBPortSerialPutBuf这类底层收发接口的串口实现替换成向 W5500 socket 缓冲区写入/读取数据。只要理解了 FreeModbus 的分层结构,这个改动并非难事,难的是和 MQTT 的时间片分配。

6.2 主站轮询扩展:从站在同一个协议栈里怎么当主站

FreeModbus 原生是纯从站协议栈,要在同一台设备里做主站,需要额外实现主站轮询状态机。我这里没有把主站硬塞进 FreeModbus 的从站框架里,而是独立写了一个 Modbus 主站状态机,走的是串口 RTU 通道。

主站的工作本质上是这样:按预定的轮询表,定时向不同的从站地址发送请求报文,等待响应,解析数据,然后决定下一步动作。这个逻辑本身不复杂,但它和从站使用的是同一个串口(或者不同串口,在项目里我分配了独立串口),和 MQTT 共享 MCU 的时间片。

我在主站状态机里定义了几个状态:

  • MB_MASTER_IDLE:没有请求要发,进入等待
  • MB_MASTER_SEND:发送请求帧
  • MB_MASTER_WAIT_RESP:等待从站响应,有超时限制
  • MB_MASTER_PROCESS:解析响应,更新寄存器缓冲

关键点是MB_MASTER_WAIT_RESP是一个阻塞状态,需要在等待响应期间还能让 mqtt_task 跑起来。如果用while (等待串口收包) ;这种死等写法,MQTT 的心跳、W5500 的 socket 事件全部都会被卡住,整个设备看起来就像"每隔几百毫秒停顿一下"。这个问题的本质后面会细讲。

6.3 时间片调度:让 MQTT 和 Modbus 主站在一个 while(1) 里共存

这里是我觉得整个项目最值得分享的内容——多任务并发在裸机上的调度方案。

我最终的调度框架很朴素:一个 tick 中断提供时间基准,主循环里按一个小的时间片调度表轮询各个任务模块。每个任务模块都是"非阻塞 + 快速返回"的:

while (1) { wizchip_poll(); // 检查 W5500 中断事件和 socket 状态 dhcp_task(); // DHCP 状态机轮询 mqtt_task(); // MQTT 心跳/重连/收包处理 mb_slave_task(); // FreeModbus 从站轮询 mb_master_task(); // Modbus 主站状态机 app_main_task(10); // 业务应用任务,带最小执行周期控制 }

但是单一的 while(1) 轮询有个大问题:如果mb_master_task()在等待串口响应时耗时较长,其他任务就被卡住了。为此我给每个任务都设置了"执行窗口",让它在等待外部事件时主动让出 CPU,避免死等。

Modbus 主站的等待响应阶段,我不用死等,而是标记一个时间戳,然后立刻返回主循环。主循环下一次轮回来到mb_master_task()时,检查时间戳是否超时。没有超时且没有收到有效响应,就继续返回,让其他任务执行。只有超时才进入超时处理。这样,单个任务的最长阻塞时间被压缩到微秒级,MQTT 的心跳、W5500 的中断处理都能被及时响应。

为了更清晰,我放一段调度核心的伪代码:

void mb_master_task(void) { switch (state) { case MB_MASTER_SEND: uart_send_buffer(fd, req_buf, req_len); state = MB_MASTER_WAIT_RESP; wait_start_ms = now_ms(); break; case MB_MASTER_WAIT_RESP: if (mb_rtu_rx_done == 1) { state = MB_MASTER_PROCESS; break; } if (now_ms() - wait_start_ms > MB_MASTER_TIMEOUT_MS) { report_modbus_error(APP_ERR_MB_RTU_TIMEOUT); state = MB_MASTER_IDLE; } // 无论超时与否,都直接返回,不在这里死等 break; } }

这套"时间戳轮询 + 非阻塞状态机"的方案,比塞一个 RTOS 轻量得多,但对任务拆分的要求更高。它要求每个协议模块都能够被拆成一个一个的有限状态机,而不是一连串阻塞函数。这个思维方式对于做裸机嵌入式开发非常重要。

7. 联调验证的完整链路和踩坑复盘

7.1 测试环境的搭建:没有局域网就别谈稳定连接

做网络相关项目,测试环境的搭建直接影响排查效率。我用了一台交换机把设备、路由器、一台运行 MQTT Broker 和 Modbus 模拟软件的 PC 连在一起,W5500 的网口直连交换机。

这个拓扑的优点是:你可以同时用 Wireshark 抓到 MQTT、Modbus TCP 和 DHCP 的所有报文,随时定位是应用层的问题还是底层 TCP 的问题。如果现场只有一个路由器,抓包会很不方便,因为多设备之间的流量经过路由器后可能被 NAT 改写源端口,排查起来事倍功半。

我在 PC 上跑了两个仿真工具:一个模拟 Modbus 从站设备(用来给设备的主站去轮询),一个模拟 Modbus 主站客户端(用来读写设备的从站寄存器)。这样设备的两面都能验证。MQTT 方面,我用一个自建的 Broker,并订阅设备上报的所有主题和遗嘱主题,相当于做一个透明的观察者。

7.2 实测中最容易翻车的三个环节

整个联调过程中,我遇到最多的问题大致有三类,这里逐个复盘一下。

第一类是 DHCP 续租导致的短暂断网。现象是设备运行了十多个小时后,MQTT 突然掉线,然后自动恢复。起初怀疑服务器问题,后来抓包发现,DHCP 的 RENEW 过程里,设备发送的 REQUEST 报文在 W5500 里走 UDP socket,如果这个 socket 的源端口在续租时发生了变化,服务器可能不认。我最后的解决办法是把 DHCP 相关的 UDP socket 固定在一个专用 socket 上,并且不随意关闭,续租期间专用 socket 状态保持不变。

第二类是 Modbus 主站轮询和 MQTT 上报的互相干扰。现象是当主站轮询大量从站设备时,MQTT 数据上报延迟显著增加,有时甚至出现心跳超时。排查后确认是主站任务死等响应导致的。用非阻塞状态机替换掉死等代码之后,这个问题彻底消失。这再次验证了裸机上做多协议栈,本质上是做时间管理。

第三类是 TCP 连接半开。设备侧 MQTT 连接在物理断开(比如网线拔掉)后,W5500 的 TCP 状态并没有立刻变成 CLOSED,而是停留在 ESTABLISHED 或者 CLOSE_WAIT。这种情况下,只通过读 socket 状态来判断是否在线是不够的。必须配合应用层心跳和发送失败来综合判断。我在 MQTT 模块里加了"连续 N 次心跳无响应则强制重连"的逻辑,才算把这个坑填平。

7.3 现场跑了一段时间后的一些心里话

文章写到这,这个项目也基本告一段落。我不敢说这套方案是万能的,但它确实证明了在 MCU 资源有限的情况下,W5500 加协议分层设计能够支撑 MQTT、Modbus TCP 从站、Modbus RTU 主站三者同时运行,并且保持稳定。

如果你要从头做类似的项目,我个人最想强调的就是:先把"带返回值 + 分层 + 非阻塞状态机"这套地基打好,再去调协议细节。很多人一上来就埋头写 MQTT 连接代码,结果后面 DHCP 续租、Modbus 时间片、socket 资源冲突这些问题一个个爆出来,每天都在救火。这个项目里所有的坑,几乎都是通过日志里那一个个错误码反向定位出来的。也许听起来不够炫酷,但工程化就是这样,先保证可调试,再追求功能性。

最后再分享一个小技巧:给 W5500 的每个 socket 起一个可读的名字,在日志里输出 socket 分配情况,检查是否出现"MQTT 的 socket 被 Modbus 抢了"这种隐蔽问题。这个小功能我花了一个小时写,但调试的时候帮了我至少三天。如果你也在做类似的设备,非常建议加上。

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

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

立即咨询