ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux下用Qt手写TCP client/server:网络协议编程避坑指南

Linux下用Qt手写TCP client/server:网络协议编程避坑指南 简介一套面向Linux环境下Qt网络编程学习者的TCP通信实战示例围绕QTcpServer与QTcpSocket组件构建简易的客户端与服务器程序涵盖TCP/IP协议基础、socket套接字调用、Qt信号槽事件驱动以及网络异步编程等核心内容。压缩包内共27个文件其中包含9个C源码文件、5个头文件、2个工程文件、2个Makefile、1个UI界面设计以及编译生成的可执行程序整体仅265KB按client与server两个模块清晰划分便于对照工程结构理解代码组织与编译流程。已有232人学习适合具备基本C语法、希望深入掌握Qt网络模块或进行网络应用课程设计的开发者。通过该项目可完整掌握TCP服务端与客户端的搭建与交互流程理清监听、连接、收发数据、界面刷新以及断线处理等关键环节同时从源码中可学习到事件驱动编程、多线程条件下的线程安全、错误处理与资源管理等实用技巧为后续移植Web服务器、开发跨平台网络工具或进一步研究更复杂的协议栈奠定坚实基础。1. Linux 下用 Qt 手写 TCP client/server为什么网络协议编程的难点不在 API很多人拿到 client-and-server 这类 Qt 网络协议编程工程第一反应是去翻 QTcpSocket 帮助文档。在 Linux 上用 Qt 写一个 TCP client/server再让 server 挂一个最小的 webserver是这类工程最常见的打开方式。结果 demo 调通了一接真实设备就翻车粘包、半包、断线重连、TIME_WAIT每个问题都像玄学。这套方案的核心不是某个类而是 socket 生命周期和协议解析模型。Qt 的 Network 模块在 Linux 下把 TCP 通信封装得足够简单但正因为简单新手容易忽略底层内核协议栈的行为。这篇文章会把协议设计、收包缓存、异常断开这些坑拆开讲适合正在做嵌入式 Linux 上位机、或刚接触 Qt 网络编程的人。2. 环境准备与项目骨架在 Linux 上把 Qt Network 工程跑起来2.1 先选 Qt 版本Qt5 还是 Qt6我一般会用系统包管理器装 Qt。Ubuntu/Debian 上apt install qt6-base-dev装 Qt6qtbase5-dev装 Qt5。如果手头有现成的 linux 镜像或者要部署到嵌入式 linux 项目建议先在目标板子上确认 glibc 版本再决定 Qt 版本。QTcpServer 和 QTcpSocket 这两个类在 Qt5/Qt6 的 API 基本一致只有errorOccurred信号是两个版本的分水岭Qt 5.15 开始才有errorOccurred更早的版本用的是error(const QString)。如果编译时报no member named errorOccurred说明你的 Qt 版本低于 5.15。为什么要强调这个因为很多网上粘贴的代码都是在新版本下写的你拿到嵌入式 Linux 的 Qt 5.12 环境里一编就崩。这也是我用 CMake 而不是 qmake 的原因之一CMake 的find_package(Qt6 COMPONENTS Core Network)会在配置阶段就告诉你版本够不够而不是等到 link 阶段才报一堆晦涩错误。qmake 当然也能用但新项目我还是建议 CMake。2.2 最小工程结构client、server 和公共协议头我习惯把客户端和服务端放进同一个 CMake 工程共享一个common头文件。这样做的好处是两端对字段的定义不会悄悄漂移。结构大致是这样tcp-demo/ ├── CMakeLists.txt ├── common/ │ └── Protocol.h ├── server/ │ ├── Server.h │ ├── Server.cpp │ └── main.cpp └── client/ ├── Client.h ├── Client.cpp └── main.cppProtocol.h定义了最简单的帧格式前 4 字节是大端整数表示后续数据长度然后是 1 字节消息类型最后是消息体。长度字段管的是“包边界”类型字段管的是“业务分发”消息体就是你要传的业务数据可以是结构体、JSON甚至是序列化后的对象二进制。#pragma once #include QByteArray namespace proto { const int kHeaderSize 4; const int kMaxPayloadSize 1024 * 1024; inline QByteArray pack(quint8 type, const QByteArray body) { QByteArray frame; quint32 len 1 static_castquint32(body.size()); frame.reserve(kHeaderSize len); frame.append(char((len 24) 0xFF)); frame.append(char((len 16) 0xFF)); frame.append(char((len 8) 0xFF)); frame.append(char(len 0xFF)); frame.append(char(type)); frame.append(body); return frame; } inline bool tryParse(const QByteArray buf, quint8 *type, QByteArray *body) { if (buf.size() kHeaderSize) return false; const uchar *p reinterpret_castconst uchar *(buf.constData()); quint32 len (quint32(p[0]) 24) | (quint32(p[1]) 16) | (quint32(p[2]) 8) | p[3]; if (len 0 || len kMaxPayloadSize) return false; if (buf.size() kHeaderSize len) return false; *type static_castquint8(buf.at(kHeaderSize)); *body buf.mid(kHeaderSize 1, len - 1); return true; } }代码里的编码方式不是调 qToBigEndian而是手动移位。为什么这么写因为qToBigEndian在不同 Qt 版本上的重载长得不一样有的参数是值有的参数是字节指针新手容易复制错。手动移位一眼能看懂而且协议跨语言时也能照着实现。tryParse是后面收包循环的核心它只做一次解析尝试不负责缓冲区管理能不能拆出完整包完全交给调用方判断。2.3 CMake 配置与第一轮编译CMakeLists.txt只需要关心两个 targetcmake_minimum_required(VERSION 3.16) project(tcp_demo LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(Qt6 COMPONENTS Core Network REQUIRED) add_executable(server server/main.cpp server/Server.cpp ) target_link_libraries(server PRIVATE Qt6::Core Qt6::Network) add_executable(client client/main.cpp client/Client.cpp ) target_link_libraries(client PRIVATE Qt6::Core Qt6::Network)注意必须是 Qt6::Network只 link Qt6::Core 是不行的QTcpServer、QTcpSocket都在 Network 模块里。CMAKE_AUTOMOC一定要开因为 TcpConnection、Server 这些类里都有 Q_OBJECT不开的话 moc 文件不会生成链接期报的一堆undefined reference to vtable会让你怀疑人生。编译命令cmake -S . -B build cmake --build build -j$(nproc) ls build/server build/client如果没有图形界面在 qt creator 里打开命令行工具也可以直接用反正只是 console 程序。我这里故意不起 Widgets 界面就是为了让网络部分独立可测跑在 linux 镜像或者无头服务器上都行。Qt 的下载和安装有时确实让人头大apt 装完缺了哪个模块就再apt install哪个别自己编译整套 Qt时间成本太高。编译通过后先起 server再起 client./build/server 8899 8080 ./build/client 127.0.0.1 8899如果客户端能打印出connected和ack第一轮就通了。如果起 server 就报Address already in use八成是上一个进程还占着端口ss -ltnp | grep 8899看一眼就明。3. 用 QTcpServer 实现并发 TCP server从 newConnection 到每连接一个对象3.1 监听端口与信号槽选择的几个细节QTcpServer的用法看起来很简单listen(QHostAddress::Any, 8899)然后接住newConnection信号。这里有三处容易踩坑。第一QHostAddress::Any会监听所有 IPv4 地址如果只想本机调试用QHostAddress::LocalHost否则开发机上所有网卡都能连进来安全上不讲究。第二listen的返回值和errorString()一定要查大多数情况下失败是端口被占或权限不足后者的表现是Permission denied。第三nextPendingConnection()拿到的 socket 一旦不处理就是内存泄漏很多老教程在信号里直接拿一个临时变量又忘记 delete时间长了内存占用只涨不跌。我一般不在 Server 里直接操作每个 socket而是为每个连接新建一个TcpConnection对象。原因在 3.2 里说。Server 只负责监听、建连、回收连接里的粘包拆包和业务分发全部下沉到TcpConnection这样每个类的职责才能对得上“网络协议编程”这四个字。3.2 把每个连接封装成 TcpConnection连接生命周期管理如果你只有一个客户端直接在 Server 里connect(socket, QTcpSocket::readyRead, ...)没问题槽函数里用qobject_castQTcpSocket*(sender())也能勉强找到来源。可一旦客户端数量超过两三个代码就变成一坨if(sender()sockA)。所以我选择用TcpConnection把每个 socket 封装成一个独立对象所有信号槽都在自己内部解决。// TcpConnection.h #pragma once #include QObject #include QTcpSocket class TcpConnection : public QObject { Q_OBJECT public: explicit TcpConnection(QTcpSocket *socket, QObject *parent nullptr); signals: void closed(qintptr socketDescriptor); private slots: void onReadyRead(); void onDisconnected(); private: void handlePacket(quint8 type, const QByteArray body); void sendPacket(quint8 type, const QByteArray body); QTcpSocket *m_socket; QByteArray m_buffer; };构造函数里并不去建立新 socket而是接管传入的 socketTcpConnection::TcpConnection(QTcpSocket *socket, QObject *parent) : QObject(parent) , m_socket(socket) { m_socket-setParent(this); connect(m_socket, QTcpSocket::readyRead, this, TcpConnection::onReadyRead); connect(m_socket, QTcpSocket::disconnected, this, TcpConnection::onDisconnected); connect(m_socket, QTcpSocket::errorOccurred, this, [this](QAbstractSocket::SocketError) { m_socket-deleteLater(); }); }socket-setParent(this)这行是关键。nextPendingConnection()创建的 socket 所有权在调用方手里把 parent 设成 TcpConnection 后连接对象销毁或deleteLater时 socket 会跟着释放不用你手动 delete。很多人在这里踩坑写delete socket;然后 TcpConnection 用了一个悬空的 descriptor进程一收包就崩溃。onReadyRead是处理粘包的主战场。缓冲区每次只追加readAll()的数据然后用tryParse循环拆包。拆出一个完整帧就移除对应字节拆不出来就留在缓冲区等下一次信号。这个模型是 TCP 编程里最基础也最稳妥的一种void TcpConnection::onReadyRead() { m_buffer.append(m_socket-readAll()); quint8 type 0; QByteArray body; while (proto::tryParse(m_buffer, type, body)) { int frameSize proto::kHeaderSize 1 body.size(); m_buffer.remove(0, frameSize); handlePacket(type, body); } }每次 remove 的字节数要和tryParse里 length 字段的语义完全一致。我这里 length 包括 1 字节 type 和 body所以计算是 4 1 body.size()。如果协议里 length 只算 body这里就要写 4 1 body.size() 还是 4 body.size()很容易错。我的建议是协议头写清楚 length 包含什么代码里的长度常量也写成kHeaderSize 1 body.size()不要用魔法数字。提示不要把tryParse和缓冲区管理糅在一起。tryParse只负责从当前缓冲区里提一条完整帧缓冲区怎么存、怎么删是调用方的事。拆开以后两端都能复用这一个函数。handlePacket在这里先按 type 做业务分发void TcpConnection::handlePacket(quint8 type, const QByteArray body) { if (type 1) { qInfo().noquote() text message: body; sendPacket(2, ack: body); } else if (type 2) { qInfo().noquote() status request; sendPacket(2, online); } else { qWarning() unknown type type; m_socket-abort(); } }abort()与disconnectFromHost()的区别我放在第 5 章说。sendPacket则是把 type 和 body 打成帧再写出去void TcpConnection::sendPacket(quint8 type, const QByteArray body) { m_socket-write(proto::pack(type, body)); }有同学会问为什么不直接用QDataStream打包QDataStream会写入一个 4 字节的版本号前缀双方 Qt 版本不一致时解析出来就是错位的数据。自己定义裸字节格式看着土但跨语言、跨版本都不受框架影响。这也是网络协议编程最基本的思路协议属于业务层不属于 Qt。3.3 Server 主类与 WebServer一个进程监听两个端口Server 类需要同时监听两个端口8899 走上面的二进制协议8080 走 HTTP 协议让 server 兼任一个简单的 webserver。这样在同一台 Linux 机器上client 可以验证自定义 TCP 协议浏览器或 curl 可以验证 HTTP 协议两个方向都照顾到了。// Server.h #pragma once #include QObject #include QTcpServer #include QHash class TcpConnection; class HttpConnection; class Server : public QObject { Q_OBJECT public: explicit Server(quint16 tcpPort, quint16 httpPort, QObject *parent nullptr); private slots: void onTcpConnection(); void onHttpConnection(); private: QTcpServer *m_tcpServer; QTcpServer *m_httpServer; QHashqintptr, TcpConnection* m_tcpConnections; QHashqintptr, HttpConnection* m_httpConnections; };两个 server 各监听一个端口构造函数分别是Server::Server(quint16 tcpPort, quint16 httpPort, QObject *parent) : QObject(parent) , m_tcpServer(new QTcpServer(this)) , m_httpServer(new QTcpServer(this)) { if (!m_tcpServer-listen(QHostAddress::Any, tcpPort)) { qCritical() tcp listen error: m_tcpServer-errorString(); return; } if (!m_httpServer-listen(QHostAddress::Any, httpPort)) { qCritical() http listen error: m_httpServer-errorString(); return; } connect(m_tcpServer, QTcpServer::newConnection, this, Server::onTcpConnection); connect(m_httpServer, QTcpServer::newConnection, this, Server::onHttpConnection); } void Server::onTcpConnection() { while (m_tcpServer-hasPendingConnections()) { QTcpSocket *socket m_tcpServer-nextPendingConnection(); auto *conn new TcpConnection(socket, this); connect(conn, TcpConnection::closed, this, [this](qintptr descriptor) { m_tcpConnections.remove(descriptor); }); m_tcpConnections.insert(socket-socketDescriptor(), conn); qInfo() tcp client in: socket-peerAddress().toString(); } }hasPendingConnections()和nextPendingConnection()最好配合 while 循环因为一次newConnection信号可能对应多个已就绪连接。只取一个的话剩下的会留在内核监听队列里客户端以为连上了服务端却永远不处理。HTTP 端的HttpConnection不需要走二进制帧它按文本协议解析请求头。下面是最小 web server 的核心代码void HttpConnection::onReadyRead() { m_buffer.append(m_socket-readAll()); while (m_buffer.contains(\r\n\r\n)) { int headerEnd m_buffer.indexOf(\r\n\r\n); QByteArray head m_buffer.left(headerEnd); m_buffer.remove(0, headerEnd 4); QByteArray requestLine head.split(\n).value(0).trimmed(); qInfo().noquote() http request line: requestLine; QByteArray body; if (requestLine.startsWith(GET /)) { body R({status:ok,service:qt-webserver}); } else if (requestLine.startsWith(POST /)) { body R({method:post,status:accepted}); } else { body R({status:not_found}); } QByteArray response HTTP/1.1 200 OK\r\n Content-Type: application/json\r\n Content-Length: QByteArray::number(body.size()) \r\n Connection: close\r\n\r\n body; m_socket-write(response); m_socket-disconnectFromHost(); break; } }这只是能应付 curl 的最小 HTTP 响应严格说还有很多问题没有按Content-Length读取 POST body没有处理 Keep-Alive也不会区分 404 和 200。但它的核心要点是明确展现了 HTTP 协议和自定义二进制协议在解析模型上的差异HTTP 用\r\n\r\n当头部结束标志数据量小文本化自定义 TCP 用 4 字节长度前缀数据量大适合二进制结构体。选择哪种要看业务和浏览器通信就 HTTP和设备内部通信就按结构体打流。4. 用 QTcpSocket 实现 client连接、发送、接收与主动断开客户端看起来比服务端简单但connectToHost背后有一套状态机。很多人写死了一个调用顺序结果在设备掉电、路由不通的时候完全抓瞎。4.1 连接状态机与信号选择QTcpSocket的状态有UnconnectedState、HostLookupState、ConnectingState、ConnectedState、ClosingState和ConnectionlessState。只有ConnectedState下能正常收发数据。connectToHost是异步的你调用后立刻检查state() ConnectedState永远是 false必须等connected信号。我写的一个最小客户端// Client.h #pragma once #include QObject #include QTcpSocket class Client : public QObject { Q_OBJECT public: explicit Client(const QHostAddress addr, quint16 port, QObject *parent nullptr); private slots: void onConnected(); void onReadyRead(); void onDisconnected(); void onErrorOccurred(QAbstractSocket::SocketError error); private: QTcpSocket *m_socket; QByteArray m_buffer; };构造函数把要连接的地址和端口存下然后立刻发起连接Client::Client(const QHostAddress addr, quint16 port, QObject *parent) : QObject(parent) , m_socket(new QTcpSocket(this)) { connect(m_socket, QTcpSocket::connected, this, Client::onConnected); connect(m_socket, QTcpSocket::readyRead, this, Client::onReadyRead); connect(m_socket, QTcpSocket::disconnected, this, Client::onDisconnected); connect(m_socket, QTcpSocket::errorOccurred, this, Client::onErrorOccurred); m_socket-connectToHost(addr, port); }errorOccurred这个信号我在 Qt 5.15 和 Qt 6 里用得多。老项目用error(QAbstractSocket::SocketError)信号重载时会有一个 QString 参数两种写法在 connect 时都容易踩重载解析的坑。我的建议是统一用函数指针方式 connect不要用SIGNAL(...)宏老宏在编译期不做类型检查运行时连不上还会在控制台打一堆警告。连接成功后发送一个打招呼消息void Client::onConnected() { qInfo() connected to m_socket-peerAddress().toString(); m_socket-write(proto::pack(1, hello from qt client)); }这里有个常见误解write返回的qint64不是你这条消息已经发到对端的字节数而是写入了本地发送缓冲区的字节数。真正何时发出去由内核 TCP 协议栈决定。如果你写一条 1MB 的消息可能一次 write 返回全部 1MB也可能只写入一部分剩下的需要等bytesWritten信号再继续写。小消息不用担心大文件传输就必须做成写队列。4.2 发送缓冲区的背压write 返回值只是开始Qt 的 socket 是事件驱动的write调用拷贝数据到内核缓冲区或者 Qt 内部缓存一旦内核发送缓冲区满了继续 write 不会阻塞当前线程而是返回 0 或部分字节。你用循环把数据一次性write出去返回值和实际写入量不一定相等。处理大块数据的常见做法是维护一个QByteArray m_sendQueue每次要发数据先 append 到队列然后尝试 flush 队列。bytesWritten信号触发时继续从队列里取剩余部分。这里不展开完整实现但要记住如果业务里出现过“发送大帧卡死”或“对端接收不完整”先检查是不是把write等同于是同步发送了。我们这个小 demo 只发一条短消息所以直接write(proto::pack(...))没问题。但我在实际项目里的习惯是给sendPacket加一个计数器连续发送超过几十条时检查m_socket-bytesToWrite()超过阈值就打印告警。这个过程能提前发现背压问题而不是等到客户端卡死才查。4.3 接收解析和服务端同样的 tryParse 循环客户端的接收同样有粘包问题不能天真地以为readyRead一次就是一条完整消息。TCP 是字节流客户端可以复用一个m_buffer逻辑和服务端TcpConnection几乎一样void Client::onReadyRead() { m_buffer.append(m_socket-readAll()); quint8 type 0; QByteArray body; while (proto::tryParse(m_buffer, type, body)) { int frameSize proto::kHeaderSize 1 body.size(); m_buffer.remove(0, frameSize); qInfo().noquote() receive packet type type body body; } }为什么客户端和服务端各写一遍直接把tryParse放在公共Protocol.h里两端共享解析规则不会出现“服务端按 type1 处理客户端却发了 type2”这类问题。如果你连 Frame 的pack都共用粘包边界在发送端就被固定了。onDisconnected里要做的不是重连而是先记录状态void Client::onDisconnected() { qInfo() disconnected from server; QCoreApplication::quit(); } void Client::onErrorOccurred(QAbstractSocket::SocketError error) { qWarning() socket error error m_socket-errorString(); }这里我直接退出了程序真实场景往往要加断线重连。断线重连不要放在disconnected里立即connectToHost因为当时可能还处于ClosingState底层连接还没完全释放。正确做法是用QTimer::singleShot延迟 1 到 3 秒让状态机回到UnconnectedState再发起。我见过刚断就猛连导致资源耗尽、进程卡死在 DNS 查询的例子血泪经验。5. TCP 协议编程避坑台账粘包、SIGPIPE 与 Linux 下的端口复用排查5.1 粘包/半包不是协议栈的问题是接收方的解析模型问题现象客户端连续发两条消息服务端在readyRead里只触发了一次readAll()把两条数据一起拿回来或者一条 10KB 的消息被分裂成三四次readyRead到达。新手以为是自己调用顺序错了反复调整 sleep。原因TCP 是字节流不保证消息边界。内核把应用层发送的数据按 MSS 分片、按接收窗口合并readyRead只是告诉你“有新的字节可读”至于这些字节属于哪条业务消息它不管。Qt 的readAll()是在现有内核缓冲区里一次性读完如果对方两条消息紧挨着发送缓冲区里就是连续两条帧。解决用第 2 章定义的长度前缀帧在接收端维护一个QByteArray缓冲区每次readyRead追加然后循环tryParse。解析到完整帧才处理解析不到就返回等下一个readyRead。千万不要在readyRead里调用readLine去按行读取一个二进制协议二进制包里 0x0A 到处都是按行拆包必翻车。5.2 SIGPIPE 导致进程无声退出现象服务端对端已经断开但业务层还在write进程没有任何qWarning就直接退出如果是在 qt creator 里跑可能只看到 “The program has unexpectedly finished”连 core dump 都没有。原因Linux 下对一个已经收到 RST 的 socket 执行写操作内核会向进程发送SIGPIPE信号默认动作是终止进程。Qt 的isValid()和state()都救不了你因为状态更新可能还没追上 RST。解决在main()一开头忽略这个信号#include csignal int main(int argc, char *argv[]) { signal(SIGPIPE, SIG_IGN); QCoreApplication app(argc, argv); // 启动 Server / Client }之后写操作失败会体现为write返回 -1配合errorOccurred(RemoteHostClosedError)才能查到根因。如果是多线程环境pthread_sigmask也要处理但 Qt 的 socket 事件循环默认跑在主线程signal(SIGPIPE, SIG_IGN)在大多数场景就够用了。5.3 对端突然断开disconnected 和 readyRead 的顺序现象客户端拔网线或断电服务端没有立刻收到disconnected过一段时间readyRead触发readAll()返回空有时直接收到RemoteHostClosedError但disconnected信号还没到。原因断网分两种。优雅关闭正常 close 或 shutdown会发 FIN对端read()读到 0触发disconnected突发的物理断开没有 FIN要等 TCP 超时重传机制耗尽后才报错。这个超时可能几十秒取决于系统参数和 socket 是否有活动数据。解决不要在readyRead里假设readAll()一定非空。正确写法是先取readAll()如果为空至少不能继续拿它拼包。业务层的健康检查要自己做心跳服务端定期向客户端发 Ping客户端长时间没收到回包就主动重连。心跳间隔要根据业务容忍度设定我一般设 10 到 30 秒超时 3 次判定死亡。这是 TCP 编程里最常见的隐性翻车点日志里往往什么都没留下。5.4 TIME_WAIT 与 Address already in useserver 快速重启的后悔药现象开发时改完代码重跑 serverlisten失败errorString()显示 “Address already in use”。用ss -tn state time-wait能看到一堆处于 TIME_WAIT 的旧连接。原因主动关闭连接的一端通常是很短连接场景下的服务端在收到对端 ACK 后进入 TIME_WAIT维持 2 倍 MSL 时间约 60 秒。这个状态是为了防止旧连接的残留报文干扰新连接。服务端每次处理完请求就disconnectFromHost连接越多 TIME_WAIT 越多重启自然撞车。解决按优先级来。第一让客户端主动断开连接TIME_WAIT 由客户端承担服务端重启时端口就干净了。但这要看业务能不能改。第二服务端退出前先waitForDisconnected()把连接都关干净再退出。第三开发环境临时可以sysctl net.ipv4.tcp_tw_reuse1打开快速复用但这属于调参不适合直接打进产品不同内核行为有差异。最稳的办法还是协议层设计上减少频繁重连长连接加心跳比每请求一个新连接省心得多。注意QTcpServer 对SO_REUSEADDR没有直接暴露配置项出问题先从业务层关闭连接的方式找解决办法别一上来就改系统内核参数。5.5 排查三板斧ncat、tcpdump 和 strace现象代码逻辑看起来全对connected也触发了但服务端就是收不到完整帧或者不知道是发送没到还是接收解析出了问题。原因网络编程的 bug 往往在应用层之外可能是路由、防火墙、TCP 校验和、虚拟机网卡参数等。此时靠qDebug难以定位需要从协议栈层面看数据。解决先用 ncat 模拟对端把 Qt 程序踢开单独验证协议。ncat 127.0.0.1 8899连上后手动输入十六进制帧或者管道发文件。如果 ncat 能收发正确说明 Qt 代码的问题在信号槽或缓冲区管理。再用 tcpdump 确认数据是否真的到达网卡sudo tcpdump -i lo -X port 8899看到0x0000001c...之类的长度头说明发送路径正常问题在解析模型。如果 tcpdump 都看不到包问题在防火墙或路由先iptables -L -n检查。最后还可以用 strace 跟踪 libc 的recvfrom和sendto返回值能看出是阻塞、缓冲区满还是超时sudo strace -f -e tracenetwork,recvfrom,sendto ./build/server 8899 8080这三板斧下来基本能定位九成以上的网络问题。6. 把协议改造成双向序列号从 demo 到能用的网络协议前 5 章的帧格式能跑通 demo但要支撑真实业务还差一点没有序列号没有消息对应关系没有重传机制。我的习惯是在消息类型后面再加 4 字节消息 ID客户端发请求带自增 ID服务端回包同一个 ID。这样可以做请求响应匹配还能防重复。改进后的帧格式[length:4][type:1][msg_id:4][payload:N]tryParse里把msg_id再解出来业务就能知道每个回包对应哪一条请求。客户端可以用QHashquint32, QByteArray暂存待确认请求收到 ack 后移除超时的放进重发队列。要注意 msg_id 溢出一般用自增后绕回避免 0 和 0xFFFFFFFF 作为有效 id。压测时不要只开一个 client。常见做法是开几个终端同时启动for i in $(seq 1 10); do ./build/client 127.0.0.1 8899 done或者在 shell 里用seq 1 1000 | xargs -P 20 -I{} ./build/client 127.0.0.1 8899并发压一下。看服务端日志有没有粘包报错、有没有abort。真正的压力测试还要用iperf3或weighttp但验证帧解析逻辑上面这个 shell 已经够用。我给的小技巧是手写协议时在包结构里留一个保留字段方便后续协议版本升级而不用整个推翻解析逻辑。结构体打包也好、JSON 也好固定头加版本加载荷是值得一开始就定的习惯。别等上了设备才发现要加序列号那时所有已部署的客户端都要跟着改。我在项目里吃过这个亏第一版协议只有 length 和 type上线后要加消息关联结果所有旧设备都要刷固件。后悔药一点都不好吃。现在我一律先设计消息头哪怕暂时用不到。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进