☰
Linux下用Qt手写TCP client/server:网络协议编程避坑指南
2026/10/9 1:05:08 网站建设 项目流程

简介:一套面向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装 Qt6,qtbase5-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.cpp

Protocol.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_cast<quint32>(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_cast<const 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_cast<quint8>(buf.at(kHeaderSize)); *body = buf.mid(kHeaderSize + 1, len - 1); return true; } }

代码里的编码方式不是调 qToBigEndian,而是手动移位。为什么这么写?因为qToBigEndian在不同 Qt 版本上的重载长得不一样,有的参数是值,有的参数是字节指针,新手容易复制错。手动移位一眼能看懂,而且协议跨语言时也能照着实现。tryParse是后面收包循环的核心:它只做一次解析尝试,不负责缓冲区管理,能不能拆出完整包完全交给调用方判断。

2.3 CMake 配置与第一轮编译

CMakeLists.txt只需要关心两个 target:

cmake_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_cast<QTcpSocket*>(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,而是接管传入的 socket:

TcpConnection::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; QHash<qintptr, TcpConnection*> m_tcpConnections; QHash<qintptr, 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里,两端共享,解析规则不会出现“服务端按 type=1 处理,客户端却发了 type=2”这类问题。如果你连 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 use:server 快速重启的后悔药

现象:开发时改完代码重跑 server,listen失败,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_reuse=1打开快速复用,但这属于调参,不适合直接打进产品,不同内核行为有差异。最稳的办法还是协议层设计上减少频繁重连:长连接加心跳,比每请求一个新连接省心得多。

注意: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 trace=network,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再解出来,业务就能知道每个回包对应哪一条请求。客户端可以用QHash<quint32, 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,上线后要加消息关联,结果所有旧设备都要刷固件。后悔药一点都不好吃。现在我一律先设计消息头,哪怕暂时用不到。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询