简介这是一份基于Qt框架重写的网络调试助手完整源码面向嵌入式开发、网络通信调试及Qt学习人群可解决TCP/UDP多模式联调、数据收发与设备模拟等实际需求。压缩包共35个文件约263KB包含8个cpp与8个h源文件、4个ui界面文件、2个pri工程模块、2个qm多语言文件以及qrc、rc、ico、pro等工程配置资源另附send.txt、device.txt等示例数据与readme说明目录结构规整便于二次开发与移植。功能上覆盖TCP服务端、TCP客户端、UDP服务端、UDP客户端四种模式支持16进制与ASCII收发、定时自动发送、配置持久化、常用数据下拉加载、设备模拟自动回复、单连接或全部发送、多客户端并发并采用单线程实现。目前已有7837人学习下载适合作为网络调试工具原型或Qt网络编程参考案例帮助读者快速理解多模式通信架构与配置化设计思路。1. 网络调试助手为什么我最终用 Qt 重写了三遍串口屏、PLC、自研板卡混在同一条调试链路上时手边没一个趁手的网络调试助手排查效率会低到让人怀疑人生。市面上的工具要么只做 TCP 客户端要么 UDP 收发包时界面卡成幻灯片更别提同时开 TCP 服务端和 UDP 服务端做回环测试。我前后用 Qt 写了三版第一版单线程收发数据量一上来直接假死第二版把 socket 塞进moveToThread结果信号槽跨线程传QByteArray时忘了注册元类型编译报了一长串dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwid...的路径错误折腾半天才发现是构建套件没配对。第三版才把 TCP 客户端、TCP 服务端、UDP 服务端三套逻辑拆干净用QAbstractTableModel管收发记录十万行数据滚动不卡。这篇笔记就把这套终极版的落地路径拆开讲从选型理由到每个 socket 的创建参数再到多线程收发的坑新手能照着跑通熟手能直接拿去改自己的调试工具。2. 三套通信链路怎么拆TCP 客户端、TCP 服务端、UDP 服务端的选型与骨架2.1 为什么不用 QTcpSocket 一把梭而是三套独立封装很多人写网络调试助手习惯把QTcpSocket、QTcpServer、QUdpSocket全塞进主窗口类里信号槽连一堆初期看着省事后期加个「十六进制显示」或「定时发送」就得动全身。我的做法是抽一个NetWorker基类只暴露open()、close()、send(QByteArray)、readyRead信号四个接口然后TcpClientWorker、TcpServerWorker、UdpWorker各自继承。这样主窗口只跟基类指针打交道切换模式时不用改 UI 逻辑。选型上TCP 客户端用QTcpSocket是唯一解但要注意connectToHost之后不能立刻write必须等connected信号。TCP 服务端用QTcpServer监听每个新连接通过nextPendingConnection()拿到独立的QTcpSocket这里有个血泪经验如果不在incomingConnection里把 socket 的父对象设成 server客户端断开后 socket 对象不会自动回收跑一晚上内存能涨几百兆。UDP 服务端用QUdpSocket绑定端口时记得加QAbstractSocket::ShareAddress和ReuseAddressHint否则同一台机器上开两个调试助手会抢端口。提示Qt 5.15 之后QAbstractSocket::error信号被标记废弃改用errorOccurred如果你从旧版本迁移编译会报错但不会中断运行时收不到错误提示排查半天找不到原因。2.2 最小可运行骨架三个 Worker 的创建与信号连接下面这段代码是主窗口里初始化三套链路的骨架我把它放在MainWindow::initWorkers()里每个 Worker 都moveToThread到独立线程避免 socket 阻塞 UI。// mainwindow.cpp void MainWindow::initWorkers() { // TCP 客户端独立线程避免 connectToHost 阻塞界面 m_tcpClient new TcpClientWorker; m_tcpClientThread new QThread(this); m_tcpClient-moveToThread(m_tcpClientThread); connect(m_tcpClientThread, QThread::finished, m_tcpClient, QObject::deleteLater); connect(m_tcpClient, NetWorker::readyRead, this, MainWindow::onTcpClientData); connect(m_tcpClient, NetWorker::errorOccurred, this, MainWindow::onNetError); m_tcpClientThread-start(); // TCP 服务端监听端口每个客户端连接单独处理 m_tcpServer new TcpServerWorker; m_tcpServerThread new QThread(this); m_tcpServer-moveToThread(m_tcpServerThread); connect(m_tcpServer, NetWorker::readyRead, this, MainWindow::onTcpServerData); connect(m_tcpServer, NetWorker::errorOccurred, this, MainWindow::onNetError); m_tcpServerThread-start(); // UDP 服务端绑定本地端口收发广播/单播 m_udpWorker new UdpWorker; m_udpWorkerThread new QThread(this); m_udpWorker-moveToThread(m_udpWorkerThread); connect(m_udpWorker, NetWorker::readyRead, this, MainWindow::onUdpData); connect(m_udpWorker, NetWorker::errorOccurred, this, MainWindow::onNetError); m_udpWorkerThread-start(); }逻辑说明每个 Worker 都moveToThread到独立QThread这样QTcpSocket的waitForReadyRead或connectToHost不会卡住主线程。参数上QThread::start()默认优先级QThread::InheritPriority调试工具不需要改。信号连接里readyRead统一发QByteArray主窗口收到后追加到模型不直接操作 UI 控件。注意moveToThread之后Worker 里的QTimer也必须在该线程内创建否则定时发送会报QObject::startTimer: Timers cannot be started from another thread。我一般把定时器放在 Worker 的open()槽函数里 new父对象设成 Worker 自身。2.3 收发数据模型用 QAbstractTableModel 替代 QTableWidget热词里「qt 表格大数据卡顿优化 tablewiget 到 qtableview 自定义 model」是真实痛点。我第一版用QTableWidget每收一包insertRow一次收包频率到 1000 包/秒时界面直接无响应。后来换成QAbstractTableModelQTableView只维护一个QVectorPacketRecorddata()里按索引返回beginInsertRows/endInsertRows批量提交十万行滚动丝滑。// packetmodel.h class PacketModel : public QAbstractTableModel { Q_OBJECT public: enum Column { TimeCol, DirectionCol, DataCol, LengthCol, ColumnCount }; int rowCount(const QModelIndex parent QModelIndex()) const override { return parent.isValid() ? 0 : m_records.size(); } int columnCount(const QModelIndex parent QModelIndex()) const override { return parent.isValid() ? 0 : ColumnCount; } QVariant data(const QModelIndex index, int role) const override { if (!index.isValid() || role ! Qt::DisplayRole) return {}; const auto rec m_records.at(index.row()); switch (index.column()) { case TimeCol: return rec.timestamp.toString(hh:mm:ss.zzz); case DirectionCol: return rec.isSend ? TX : RX; case DataCol: return rec.displayText; // 已按 HEX/ASCII 格式化 case LengthCol: return rec.raw.size(); } return {}; } void appendRecord(const PacketRecord rec) { beginInsertRows(QModelIndex(), m_records.size(), m_records.size()); m_records.append(rec); endInsertRows(); // 超过 50000 行时裁剪头部防止内存无限增长 if (m_records.size() 50000) { beginRemoveRows(QModelIndex(), 0, 999); m_records.remove(0, 1000); endRemoveRows(); } } private: QVectorPacketRecord m_records; };参数说明appendRecord里每次只插入一行Qt 的 view 会自动只刷新可见区域所以性能瓶颈不在插入而在data()的调用频率。displayText在收包时就格式化好不要在data()里做toHex()转换否则滚动时 CPU 飙高。裁剪阈值 50000 行是我实测 8GB 内存机器上的安全值再大滚动条会变短但内存占用线性增长。3. 把三套链路跑起来TCP 客户端连接、服务端监听、UDP 绑定的具体命令与参数3.1 TCP 客户端connectToHost 的四个必调参数与超时处理TCP 客户端的核心是connectToHost但很多人只传地址和端口结果连接失败时界面卡死。我一般会显式设置QAbstractSocket::LowDelayOption和KeepAliveOption前者禁用 Nagle 算法调试小包时延迟从 40ms 降到 1ms 以内后者让系统定期发心跳防止中间设备断链。// tcpclientworker.cpp void TcpClientWorker::open(const QString host, quint16 port) { if (m_socket) close(); m_socket new QTcpSocket(this); m_socket-setSocketOption(QAbstractSocket::LowDelayOption, 1); m_socket-setSocketOption(QAbstractSocket::KeepAliveOption, 1); connect(m_socket, QTcpSocket::connected, this, [this]() { emit connectedChanged(true); }); connect(m_socket, QTcpSocket::readyRead, this, TcpClientWorker::onReadyRead); connect(m_socket, QTcpSocket::errorOccurred, this, [this](QAbstractSocket::SocketError err) { emit errorOccurred(m_socket-errorString()); emit connectedChanged(false); }); m_socket-connectToHost(host, port); // 5 秒超时避免一直转圈 QTimer::singleShot(5000, this, [this]() { if (m_socket m_socket-state() ! QAbstractSocket::ConnectedState) { m_socket-abort(); emit errorOccurred(连接超时); } }); }逻辑说明LowDelayOption设为 1 后write的数据会立即发出不会攒到 MSS 再发。KeepAliveOption在 Windows 上默认 2 小时才发第一个探测包调试场景下建议配合setSocketOption(QAbstractSocket::KeepAliveOption, 1)并在系统层调短但 Qt 没暴露更细的参数所以实际心跳还是靠应用层定时发空包。超时用QTimer::singleShot而不是waitForConnected因为后者会阻塞事件循环在 Worker 线程里虽然不卡 UI但会卡住该线程的其他信号。提示如果连接的是本机127.0.0.1connectToHost几乎瞬间返回超时定时器不会触发连外网设备时 5 秒是经验值超过 5 秒还没连上多半是路由或防火墙问题继续等没意义。3.2 TCP 服务端监听端口、处理多连接与广播发送TCP 服务端用QTcpServer::listen参数里QHostAddress::Any表示监听所有网卡端口传 0 表示系统随机分配。实际调试时我一般固定端口比如 8080方便对端配置。// tcpserverworker.cpp void TcpServerWorker::open(quint16 port) { if (m_server) close(); m_server new QTcpServer(this); connect(m_server, QTcpServer::newConnection, this, TcpServerWorker::onNewConnection); if (!m_server-listen(QHostAddress::Any, port)) { emit errorOccurred(m_server-errorString()); return; } emit listeningChanged(true, m_server-serverPort()); } void TcpServerWorker::onNewConnection() { while (m_server-hasPendingConnections()) { QTcpSocket *client m_server-nextPendingConnection(); client-setSocketOption(QAbstractSocket::LowDelayOption, 1); m_clients.append(client); connect(client, QTcpSocket::readyRead, this, [this, client]() { emit readyRead(client-readAll(), client-peerAddress().toString(), client-peerPort()); }); connect(client, QTcpSocket::disconnected, this, [this, client]() { m_clients.removeAll(client); client-deleteLater(); }); emit clientCountChanged(m_clients.size()); } } void TcpServerWorker::sendToAll(const QByteArray data) { for (QTcpSocket *client : m_clients) { if (client-state() QAbstractSocket::ConnectedState) { client-write(data); } } }参数说明nextPendingConnection返回的 socket 父对象默认是QTcpServer所以deleteLater后会被 server 回收不用手动 delete。m_clients用QListQTcpSocket*存活跃连接disconnected信号里先removeAll再deleteLater顺序反了会导致野指针。广播发送时逐个write如果客户端数量超过 50 个建议改成QTimer分批发送避免单次事件循环里写太多数据导致界面卡顿。3.3 UDP 服务端绑定参数、收发模式与广播地址UDP 服务端用QUdpSocket::bind关键参数是ShareAddress和ReuseAddressHint前者允许多个进程绑定同一端口后者让系统在端口被占用时尽量复用。// udpworker.cpp void UdpWorker::open(quint16 localPort) { if (m_socket) close(); m_socket new QUdpSocket(this); bool ok m_socket-bind(QHostAddress::AnyIPv4, localPort, QAbstractSocket::ShareAddress | QAbstractSocket::ReuseAddressHint); if (!ok) { emit errorOccurred(m_socket-errorString()); return; } connect(m_socket, QUdpSocket::readyRead, this, UdpWorker::onReadyRead); emit listeningChanged(true, localPort); } void UdpWorker::onReadyRead() { while (m_socket-hasPendingDatagrams()) { QByteArray buf; buf.resize(m_socket-pendingDatagramSize()); QHostAddress sender; quint16 senderPort; m_socket-readDatagram(buf.data(), buf.size(), sender, senderPort); emit readyRead(buf, sender.toString(), senderPort); } } void UdpWorker::send(const QByteArray data, const QString host, quint16 port) { m_socket-writeDatagram(data, QHostAddress(host), port); }逻辑说明pendingDatagramSize()返回下一个数据报的大小必须按这个大小分配缓冲区否则readDatagram会截断。ShareAddress在 Windows 上需要管理员权限才能生效普通用户绑定时如果端口被占用会直接失败这时可以换端口或提示用户。广播发送时目标地址填255.255.255.255但很多路由器默认禁止广播调试时优先用单播。注意UDP 的writeDatagram返回值是实际发送的字节数如果返回 -1 表示发送失败常见原因是目标地址不可达或缓冲区满。调试时不要忽略这个返回值我一般会在发送后检查m_socket-error()。4. 避坑与排查多线程收发、中文编码、大数据量下的五个翻车现场4.1 现象程序跑几分钟后界面卡死CPU 占用 100%原因readyRead信号里直接append到QTableWidget每收一包触发一次 UI 重绘收包频率高时事件循环被重绘占满。解决换成QAbstractTableModel并且收包时不要逐条beginInsertRows而是攒 50 条或 100ms 批量提交一次。我现在的做法是 Worker 里收包后发信号到主窗口主窗口用一个QTimer每 100ms 从队列取一批数据插入模型界面立刻流畅。4.2 现象TCP 服务端客户端断开后再次连接收不到数据原因disconnected信号里只removeAll没有deleteLater旧 socket 对象还在nextPendingConnection返回新对象时旧对象仍占用文件描述符系统达到上限后新连接无法建立。解决disconnected里先client-deleteLater()再从m_clients移除。如果客户端异常断开拔网线disconnected可能不触发需要加QTimer定期检查state()。4.3 现象发送中文时对端收到乱码原因Qt 的QString::toUtf8()默认 UTF-8但很多调试工具对端按 GBK 解析。解决在发送前让用户选择编码QString::toLocal8Bit()在中文 Windows 上是 GBKtoUtf8()是 UTF-8。我一般默认 UTF-8并在界面上加一个编码下拉框切换时重新编码待发送文本。4.4 现象UDP 绑定端口失败提示 Address already in use原因没有加ShareAddress或者加了但系统不支持。解决先尝试ShareAddress | ReuseAddressHint如果还失败用netstat -ano | findstr 端口号找到占用进程杀掉或换端口。Linux 上ReuseAddressHint对应SO_REUSEADDR行为略有差异绑定前最好先close()旧 socket。4.5 现象编译报错dependent ..\..\..\..\..\..\qt\5.15.2\msvc2019_64\include\qtwid...原因Qt Creator 的构建套件路径变了或者项目文件里INCLUDEPATH写死了绝对路径。解决检查Kits里 Qt 版本和编译器是否匹配删除.pro.user重新配置。如果用了Q_OBJECT宏但没跑qmake也会报类似错误执行qmake再make即可。5. 进阶技巧用 QDataStream 做协议解析与收发日志的后悔药5.1 自定义协议帧QDataStream 的字节序与版本控制调试助手经常要对接自定义二进制协议比如帧头0xAA55 长度 命令字 数据 CRC。用QDataStream可以省去手动拼字节但要注意setByteOrder和setVersion。// protocol.cpp QByteArray buildFrame(quint8 cmd, const QByteArray payload) { QByteArray frame; QDataStream ds(frame, QIODevice::WriteOnly); ds.setByteOrder(QDataStream::LittleEndian); // 与设备端一致 ds.setVersion(QDataStream::Qt_5_15); // 固定版本避免跨 Qt 版本解析差异 ds quint16(0xAA55); // 帧头 ds quint16(payload.size() 3); // 长度命令字 1 字节 数据 CRC 2 字节 ds cmd; ds.writeRawData(payload.constData(), payload.size()); quint16 crc crc16(frame.mid(2)); // 从长度字段开始算 ds crc; return frame; }参数说明setByteOrder必须和嵌入式端一致常见的是小端。setVersion固定为Qt_5_15这样即使以后升级 Qt 6解析旧日志时行为不变。writeRawData不添加长度前缀适合已经手动管理长度的场景。CRC 计算范围从长度字段开始避免帧头参与校验。5.2 收发日志的后悔药自动保存与回放调试时经常遇到「刚才那包数据没存想重发一次」的情况。我的做法是每收一包就追加到QVectorPacketRecord同时用QFile以二进制格式写日志格式就是QDataStream序列化的PacketRecord。回放时读日志文件按时间戳逐条send。// logmanager.cpp void LogManager::append(const PacketRecord rec) { QFile file(m_logPath); if (file.open(QIODevice::Append)) { QDataStream ds(file); ds.setVersion(QDataStream::Qt_5_15); ds rec.timestamp rec.isSend rec.raw rec.displayText; file.close(); } } QVectorPacketRecord LogManager::load(const QString path) { QVectorPacketRecord records; QFile file(path); if (!file.open(QIODevice::ReadOnly)) return records; QDataStream ds(file); ds.setVersion(QDataStream::Qt_5_15); while (!ds.atEnd()) { PacketRecord rec; ds rec.timestamp rec.isSend rec.raw rec.displayText; records.append(rec); } return records; }逻辑说明日志文件用追加模式每条记录独立序列化即使程序崩溃已写入的记录仍可读。displayText存格式化后的文本回放时直接显示不用重新按 HEX/ASCII 转换。回放时注意时间戳间隔如果原始间隔是 10ms回放时用QTimer按相同间隔发送否则对端可能处理不过来。提示日志文件超过 100MB 后打开会变慢我一般按天分文件文件名用yyyyMMdd.log启动时自动加载当天文件。5.3 一个具体技巧用 QSignalSpy 做自动化回归测试网络调试助手改多了容易引入回归 bug比如「TCP 服务端广播发送」被误改成只发第一个客户端。我写了一个简单的测试用例用QSignalSpy监听readyRead信号模拟两个客户端连接后广播断言两个客户端都收到数据。// test_tcpserver.cpp void TestTcpServer::testBroadcast() { TcpServerWorker server; server.open(0); // 随机端口 quint16 port server.serverPort(); QTcpSocket client1, client2; client1.connectToHost(127.0.0.1, port); client2.connectToHost(127.0.0.1, port); QVERIFY(client1.waitForConnected(1000)); QVERIFY(client2.waitForConnected(1000)); QSignalSpy spy1(client1, QTcpSocket::readyRead); QSignalSpy spy2(client2, QTcpSocket::readyRead); server.sendToAll(hello); QVERIFY(spy1.wait(1000)); QVERIFY(spy2.wait(1000)); QCOMPARE(client1.readAll(), QByteArray(hello)); QCOMPARE(client2.readAll(), QByteArray(hello)); }这个测试跑在QTest框架里每次改完TcpServerWorker先跑一遍确认广播逻辑没退化。参数上waitForConnected超时 1000ms 对本地回环足够QSignalSpy::wait也是 1000ms超过说明信号没发出来。我一般把这类测试放在tests/目录用qmake的SUBDIRS模板跟主工程一起构建。写到这里这套终极版我前后迭代了三个月最大的教训是不要在主线程里碰任何 socket 的阻塞调用哪怕只是waitForReadyRead(10)在收包频率高时也会让界面掉帧。另一个习惯是每加一个功能就先写个QSignalSpy测试跑通了再合进主分支省得后面改一处崩三处。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?