简介:这是一份基于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 包/秒时界面直接无响应。后来换成QAbstractTableModel+QTableView,只维护一个QVector<PacketRecord>,data()里按索引返回,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: QVector<PacketRecord> 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.1,connectToHost几乎瞬间返回,超时定时器不会触发;连外网设备时 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用QList<QTcpSocket*>存活跃连接,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 上是 GBK,toUtf8()是 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 收发日志的后悔药:自动保存与回放
调试时经常遇到「刚才那包数据没存,想重发一次」的情况。我的做法是每收一包就追加到QVector<PacketRecord>,同时用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(); } } QVector<PacketRecord> LogManager::load(const QString &path) { QVector<PacketRecord> 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测试,跑通了再合进主分支,省得后面改一处崩三处。希望帮到你。
本文还有配套的精品资源,点击获取