Qt6 实现 TCP/UDP/组播 透明中继抓包工具 | 流量录制回放 | 工业设备调试

0 阅读10分钟

一个透明 TCP/UDP/组播 中继调试工具:不打断生产链路的抓包 + 录制回放

面向工业自动化 / 嵌入式运维工程师。全文基于开源项目 DeviceForge(Qt 6 + C++17,MIT)里 NetRelayTool 模块的真实实现。支持 TCP/UDP 透明代理 + 组播(Multicast)零影响抓收与回灌。

请添加图片描述

一个现场常见的尴尬

调设备的人多半遇到过这个场景:上位机(HMI、组态软件、自研客户端)连着一台 PLC 或嵌入式终端,跑得好好的,突然某个指令不生效、或者偶发丢数据。你想看看两边到底在收发什么——

  • 上 Wireshark?需要网卡混杂模式或交换机镜像口,现场环境常常没有条件;抓到的还是裸包,要自己剥协议头。
  • 在上位机或设备上加日志?改代码、重新烧录、重启——打断了正在复现的现场,问题可能就不复现了。
  • 更麻烦的是:等你好不容易抓到一次异常数据,想反复重放给设备去定位,却没有手段。

我想要的其实很简单:一个能串在中间、原样转发、顺便把流量录下来、之后还能按原始节奏重放的东西。市面上开源的、带图形界面、还支持 TCP/UDP + 录制回放的中继调试工具意外地少,于是自己写了一个。

核心思路:透明中继代理

不改任何一端的代码,只在数据链路中间插一个"透明代理":

数据生产者(上位机/客户端) ──①原始数据──▶ 工具(监听) ──②原封不动──▶ 真实消费者(PLC/设备)
数据生产者               ◀──④转发─────  工具        ◀──③响应/回控制── 真实消费者
                                         │
                                         ├──▶ 实时 Hex+ASCII 视图
                                         └──▶ 录制成 .nrec 文件 ──▶ 之后按原始时序回放

用户只需要把生产者原来连的 PLC:502 改成连 工具:502,工具再去连真实的 PLC:502。中间的字节一个不改地双向透传,生产逻辑照常跑,工具只是"顺路"把流量抄一份。

TCP:每个连接配一对 socket

TCP 是有连接的,所以对每个进来的客户端连接,工具都建立一条到上游的配对连接,两个方向各自 readyRead → write。核心就这么点代码(真实实现,QTcpServer + QTcpSocket):

// 客户端有数据可读 → 原样转发到上游,同时旁路抓取
void NetRelayBackend::onTcpClientReadyRead(QTcpSocket* client)
{
    TcpPair* pair = m_pairByClient.value(client, nullptr);
    if (!pair) return;

    QByteArray data = client->readAll();
    if (data.isEmpty()) return;

    pair->session.bytesUp += data.size();
    // 旁路:实时视图 + 录制(不改动 data 本身)
    if (m_dataCb) m_dataCb(RelayDirection::Upstream, pair->session.clientAddr, pair->sessionId, data);
    recordData(RelayDirection::Upstream, pair->sessionId, data);

    // 转发
    if (pair->upstreamConnected && pair->upstream) {
        pair->upstream->write(data);
    } else {
        pair->pending.append(data);  // 上游尚未连上,先缓冲(有上限保护)
    }
}

有个容易忽略的坑:上游连接是异步的。客户端可能在 upstream 还没 connected 时就发来数据,所以要先缓冲到 pending,等 connected 信号来了再补发。而且缓冲必须有上限——否则上游若长时间连不上,一个猛发的客户端能把内存吃光。这里设了 1MB 上限,超了就丢最早的数据并告警。

反方向(上游→客户端)对称处理,方向标记为 Downstream。全部 socket I/O 都在 Qt 主线程事件循环里信号驱动,没有手动线程和锁——这也是我选 Qt 而非裸 socket + 线程池的原因:事件驱动模型让"一进一出两个方向 + N 个并发连接"的状态机简单得多

UDP:无连接,靠源地址认会话

UDP 没有连接概念,转发回程是难点:上游把响应发回来时,工具怎么知道该转给哪个客户端?做法是按客户端源地址 (ip, port) 建会话表,每个会话持有一个面向上游的 QUdpSocket,回程数据从这个 socket 收到后,查会话表路由回对应客户端。会话还带空闲超时(5 分钟没流量就回收),避免长时间运行会话表无限膨胀。

组播:额外订阅者,零影响抓雷达

组播和单播 UDP 的中继逻辑完全不同。组播是"订阅制"——数据源往组播组地址(如 239.1.2.3:5000)推数据,所有加入该组的接收者都能收到。关键洞察是:工具只是作为一个"额外的订阅者"加入组播组,对数据源和现有消费者完全无感知——你在调的雷达继续发,显控继续收,工具在旁边悄无声息地抄一份落盘。

实现上有一个整个功能最要命的一行:bind(AnyIPv4, port, ShareAddress | ReuseAddressHint)。Windows 上 ShareAddress 被忽略,必须靠 ReuseAddressHint 才能让多个 socket 共享同一端口。如果只写 ShareAddress,工具会独占端口——现有消费者立刻收不到数据,直接毁掉"不影响现场"这个承诺。这个坑是在 spec 设计阶段查 Qt 文档和 Windows Socket API 行为才确认的,要是实现时才发现,整个功能的定位就崩了。

组播回放的设计也比较微妙:默认录制时文件头存了组播地址和端口,回灌时用 QUdpSocket::setMulticastInterface 指定网卡,按原始时序逐条 writeDatagram 回去。但回灌原组只能在真实源离线时用——否则录制流和真实数据混叠,消费者收到双份。这个不能硬阻止(无法可靠判断源是否在线),靠 UI 提示。

录制:一个够用就好的自定义二进制格式

抓包看是一回事,"能回放"是另一回事。回放要求录制格式必须带方向 + 相对时间戳,否则没法还原节奏。我没有直接用 pcap(它要造假的以太网/IP/TCP 头,为回放重建反而更重),而是定义了一个极简的 .nrec 格式:

文件头(32 字节): "NREC" magic + 版本 + 协议(TCP/UDP/Multicast) + 起始时间戳 + 保留
记录条目(变长):  方向 + 会话号 + 相对时间戳(ms) + 长度 + 原始字节

写入端就是顺序 append(QDataStream 小端):

// 文件头(组播时 protocol=2, reserved 区存 IPv4 组地址和端口)
m_stream->writeRawData(nrec::kMagic, 4);                  // "NREC"
*m_stream << quint16(nrec::kVersion);                     // 版本
quint8 protoByte = (proto == RelayProtocol::Tcp) ? 0
                 : (proto == RelayProtocol::Udp) ? 1 : 2; // 0=TCP,1=UDP,2=Multicast
*m_stream << protoByte;
*m_stream << qint64(startEpochMs);                        // 起始 epoch
// 组播时 reserved 区(16 字节)存 group_port(u16)+group_ipv4(u32);TCP/UDP 时为 0
// ... 之后每个数据块一条记录:方向 + 会话号 + ts_offset_ms + 长度 + payload

有两个细节值得说:

  1. 读取端要防御损坏文件.nrec 会被分享、从磁盘加载,属于不可信输入。读的时候校验 magic、版本,尤其是长度字段要和文件剩余字节比对再分配——否则一个构造出来的超大 length 会触发一次 GB 级内存分配直接把程序打挂。这个在写单元测试时特意覆盖了(坏 magic / 坏版本 / 超长 length / 截断文件都要被拒绝而非崩溃)。
  2. 文件权限。录制内容可能含明文凭证,落盘时设为仅所有者可读写,导出前二次确认。

回放:按原始时间间隔重放

回放的关键是"还原节奏":不是把数据一股脑灌出去,而是按录制时相邻记录的时间戳差值QTimer 逐条调度,尽量复现真实时序(也提供"尽快"模式做压测)。默认只重放"上行"记录到消费者——相当于没有真实生产者时,用录制流去驱动设备做回归测试

请添加图片描述

这里踩过一个真实的 bug:TCP 回放最后一条记录时,write() 后立刻 close(),导致最后一包还在发送缓冲里就被丢了。修复是完成前等 bytesWritten 把缓冲排空再收尾——异步 socket 的"写完≠发完"是个经典陷阱

中继与回放互斥

一个 Tool 内,中继和回放是互斥的(都占用同一套监听/连接资源),用一个 RelayMode{Idle, Relaying, Replaying} 状态机守着。这里有个隐蔽的并发窗口:UDP 上游地址若是主机名,要异步 DNS 解析,解析期间状态还没切到 Relaying,此时若发起回放就会和中继撞车。修复是解析开始就置位 + 加 isRunning() 守卫。异步操作的"中间态"最容易漏掉互斥判断,这类 bug 单测覆盖不到,靠代码审查才逮出来。

小结

整个工具的价值不在于多复杂,而在于把"不打断现场 + 双向透传 + 抓包 + 录制 + 回放"这条链路凑齐了。几个可复用的经验:

  • 透明代理用事件驱动(Qt signal/slot)比线程池简单可靠得多
  • UDP 中继的回程路由靠源地址会话表 + 空闲超时
  • 组播中继靠"零影响"定位——额外订阅者模式天然不破现有系统;端口共享的 ShareAddress|ReuseAddressHint 双 flag 是 Windows 唯一正确写法
  • 要回放就必须录方向 + 相对时间戳;自定义格式比 pcap 更省事
  • 加载不可信文件先校验长度上限,别信文件里写的数字
  • 异步 socket 的收尾要等 bytesWritten,别 write 完就 close

代码在 DeviceForge(MIT,src/tools/NetRelayTool/),带单元测试和完整设计文档。这是一个 Qt 6 + C++17 的工业设备批量运维平台,除了网络调试还有 FTP/FTPS 批量部署、Telnet 批量命令、Modbus 测试等模块。欢迎试用、提 Issue、拍砖。


如果这篇对你有用,项目点个 Star 是最大的鼓励。有工业现场的调试需求或 bug,欢迎在 GitHub Issues 交流。