简介:基于Qt框架的TCP服务器客户端通信示例,面向需要掌握网络编程或Qt通信开发的初学者与进阶者。资源完整实现支持多客户端同时连接、断线后自动重连、按字节数据解析与循环发送等功能,可用于学习QTcpServer、QTcpSocket、信号槽机制及自定义协议解析思路;服务端与客户端分属独立工程,代码注释清晰,便于理解连接管理与数据收发的完整流程。压缩包共16个文件,包含6个cpp源码、4个头文件、2个工程文件、2个界面ui文件及2个用户配置,整体约15KB,代码精简,便于快速阅读和二次修改。已有3800余人学习,适合作为课程设计、毕业设计或日常开发的参考。通过学习可快速搭建一个具备多客户端管理、重连逻辑与定时发送的TCP通信Demo,理解网络通信中连接维护与数据粘包处理的常见手段。 在 Qt 里写 TCP 通信,最容易被新手卡住的不是connect和send本身,而是“服务器到底怎么同时接住多个客户端”“某个客户端突然掉线了怎么知道”“收到的一堆字节流怎么按规矩切成一条条完整消息”。这三个问题不解决,写出来的 Demo 只能自己跟自己玩,一上真实场景就崩。这次我把一个实际能用的 TCP 服务器/客户端框架拆开讲,重点放在多客户端管理、断线重联和自定义报文解析上,代码可以直接抄去改。
1. 要解决的核心问题:不是“连通”,而是“稳住”
很多教程里的 TCP 示例长这样:服务器 accept 一个连接,然后while循环里read,客户端connect成功就write两句,完事。这种代码在局域网里跑通没问题,但一旦你要做的东西是“一台服务器服务好几个设备”“设备随时可能断网重来”“报文格式带长度字段”,这套写法就完全不够用。
我这次要搭的服务器端需要同时挂多个客户端,客户端断开后服务器要能感知并清理资源;客户端侧要能在网络闪断后自动重新连接,不能一断就死;两端交互的数据不是裸字符串,而是“固定包头 + 包体长度 + 校验”的结构化报文。这套组合起来,基本覆盖了大多数设备联网、上位机通信、工控数据采集的实际场景。
先说结论:Qt 里做 TCP,核心不是QTcpSocket和QTcpServer这两个类本身,而是事件循环驱动下的信号槽机制、readyRead触发的粘包拆包逻辑、以及连接状态机(connecting/connected/reconnecting)。把这三件事想明白,剩下的都是体力活。
2. 服务器端设计:每个连接一个实例,信号槽闭环管理
2.1 多客户端连接的本质:连接描述符不是变量,是对象
服务器要同时管多个客户端,最直观的做法是:来一个连接就new一个QTcpSocket,丢进一个QList或QHash里。nextPendingConnection()每次调用返回的都是一个新 socket 对象,这个对象只对应一个客户端。所以“多客户端”在 Qt 里的本质是“多个 socket 对象 + 一个容器来管理它们的生命周期”。
我习惯用QHash<QTcpSocket*, ClientInfo>,key 是 socket 指针,value 是自定义结构体,存客户端的 IP、端口、连接时间、最近一次心跳时间、设备编号(如果协议里有的话)。这样后续要“给指定设备发指令”或者“踢掉某个连接”时,直接按 key 查表,比遍历列表高效,语义也清晰。
2.2 断线检测的两个手段:disconnected 信号和心跳超时
QTcpSocket自带disconnected信号,客户端正常关闭连接时会触发。但真实环境里最常见的问题不是“对方正常 bye”,而是“网线被拔了”“设备断电了”“路由重启了”。这种异常断开,TCP 层可能要等很久才能发现(甚至根本发现不了),对应的表现就是服务器这边 socket 还挂在列表里,state()还是ConnectedState,但数据已经发不出去了。
所以要双保险:
- 依赖
disconnected信号做及时清理:收到信号就从哈希表移除,调用deleteLater()释放对象。 - 实现心跳机制兜底:客户端每隔固定时间(比如 5 秒)发一个心跳包,服务器记录每个 socket 最近一次收到数据的时间,用一个
QTimer每 1 秒检查一次,超过 15 秒没收到任何数据的连接直接abort()并清理。
这里有个容易踩的坑:abort()会立即断开而且不等待缓冲区的数据发完,但它会触发disconnected信号,所以清理逻辑要写在disconnected槽里,别在超时检查里重复写两遍。
2.3 拆包粘包处理:readyRead 不等于“一条完整消息”
readyRead信号的意思是“有新数据可读”,但读到的字节数跟协议里的“一条消息”没有任何关系。可能是半包(一条消息还没发完),也可能是粘包(好几条消息挤在一起到了)。处理方式很简单也很标准:先攒数据,再按协议格式切。
假设我的报文格式是:
[2字节 魔数 0xAA55] [2字节 命令字] [4字节 包体长度] [包体数据] [1字节 校验]那我在readyRead槽里做的事情就是:
- 把 socket 里能读到的所有字节 append 到一个
QByteArray buffer; - 检查
buffer.size()是否大于等于 8(包头长度); - 校验魔数是否匹配,取第 4~7 字节的包体长度,计算整包长度 = 8 + 包体长度 + 1;
- 如果
buffer.size() < 整包长度,说明半包,等下次readyRead再处理; - 如果
buffer.size() >= 整包长度,取出一个完整报文去解析,然后从 buffer 里移除这段,循环第 2~5 步。
这个循环很重要,因为一次readyRead里可能塞进了好几条报文。判断逻辑写清楚后,粘包问题自然消失。
3. 客户端设计:断线重联的状态机
3.1 断线重联不是“连一次就完事”,是“永远在尝试”
客户端比服务器端麻烦的地方在于:它需要对“断开”这件事做出反应,而且要在后台默默重连,不能把主界面卡死,也不能无限快速重连把系统资源耗光。
我用一个三状态的小状态机来管这件事:
STATE_IDLE:初始状态,还未发起连接;STATE_CONNECTING:正在连接(connectToHost已调用但还没成功);STATE_CONNECTED:已连接,正常收发数据。
重点在“断开后”的处理。disconnected信号到了之后,如果检查到不是主动关闭(维护一个manualClose_标志位),就启动一个QTimer,每隔 3 秒调用一次connectToHost。为什么延迟 3 秒而不是立刻重连?因为立刻重连大概率会失败——对端服务可能还没起来、网络栈还没释放。3 秒是个经验值,局域网设备 1~2 秒也行,跨公网建议 5 秒以上。
3.2 心跳包与“假连接”的识别
客户端还有个常见问题:connectToHost成功、connected信号也发了,但这个连接过一会儿就“哑”了——对端不主动发任何数据,网络中间链路其实已经断了。这时候如果不做任何处理,客户端会一直以为自己是连着的,直到真的调write才发现写不进去。
所以客户端也要发心跳,而且心跳要带应答检测:发完心跳后如果在规定时间内没收到任何数据(包括服务器的其他业务数据),就认为链路已死,主动abort()然后进入重连流程。
这里我踩过一个坑:如果服务器端有心跳超时自动断开机制,客户端的心跳间隔必须小于服务器的超时阈值,否则会出现“服务器把客户端踢了,客户端还不知道”的情况。比如服务器 15 秒没收到数据就断开,客户端心跳就得设成 5 秒一次,留足余量。
4. 代码结构与核心实现
下面给一个精简但可运行的实现骨架,两头代码都放出来。
4.1 服务器端完整逻辑(TcpServer + ClientSession)
服务器这边,我把每个客户端单独包装成一个ClientSession类(继承QObject),内部持有一个QTcpSocket*。这样做的好处是:每个连接的拆包缓冲区、心跳计时、业务数据都是独立的,不会互相污染。服务器类只负责管理QHash<QTcpSocket*, ClientSession*>。
// ClientSession.h class ClientSession : public QObject { Q_OBJECT public: explicit ClientSession(QTcpSocket* socket, QObject* parent = nullptr); QTcpSocket* socket() const { return socket_; } QString peerInfo() const; qint64 lastActiveTime() const { return lastActiveTime_; } void sendMessage(const QByteArray& data); signals: void disconnected(ClientSession* session); void messageReceived(ClientSession* session, const QByteArray& payload); private slots: void onReadyRead(); void onDisconnected(); void onErrorOccurred(QAbstractSocket::SocketError error); private: void tryParseBuffer(); bool parsePacket(const QByteArray& packet); void updateLastActive(); QTcpSocket* socket_; QByteArray buffer_; qint64 lastActiveTime_; quint32 expectedPacketSize_; };// ClientSession.cpp ClientSession::ClientSession(QTcpSocket* socket, QObject* parent) : QObject(parent) , socket_(socket) , lastActiveTime_(0) { connect(socket_, &QTcpSocket::readyRead, this, &ClientSession::onReadyRead); connect(socket_, &QTcpSocket::disconnected, this, &ClientSession::onDisconnected); connect(socket_, &QTcpSocket::errorOccurred, this, &ClientSession::onErrorOccurred); updateLastActive(); } void ClientSession::onReadyRead() { buffer_.append(socket_->readAll()); updateLastActive(); tryParseBuffer(); } void ClientSession::tryParseBuffer() { // 报文格式: [2字节魔数0xAA55][2字节命令字][4字节包体长度][包体][1字节校验] const int headerSize = 8; while (buffer_.size() >= headerSize) { const unsigned char* data = reinterpret_cast<const unsigned char*>(buffer_.constData()); if (data[0] != 0xAA || data[1] != 0x55) { // 魔数不匹配,可能是数据流错位,丢弃第一个字节继续找 buffer_.remove(0, 1); continue; } quint32 bodyLen = 0; memcpy(&bodyLen, buffer_.constData() + 4, 4); bodyLen = qFromLittleEndian(bodyLen); if (bodyLen > 1024 * 1024) // 过大的包体长度,认为是非法数据,清空缓冲区 { buffer_.clear(); return; } int totalLen = headerSize + static_cast<int>(bodyLen) + 1; if (buffer_.size() < totalLen) return; // 半包,等更多数据 QByteArray packet = buffer_.left(totalLen); buffer_.remove(0, totalLen); // 校验字节:这里简化成 包体所有字节异或 char check = 0; for (int i = 8; i < totalLen - 1; ++i) check ^= packet.at(i); if (check != packet.at(totalLen - 1)) continue; // 校验失败,丢弃这条 QByteArray payload = packet.mid(8, static_cast<int>(bodyLen)); emit messageReceived(this, payload); } }tryParseBuffer里用while而不是if,是一开始写的时候最容易忽略的点。第一次写的时候我用的是if,结果粘包时一条一条取出来,缓冲区里剩下的数据要等下一次readyRead才处理,延迟一会儿,在高速数据流下会越积越多,最后直接卡死。
服务器类TcpServer就简单了,它只负责newConnection分发和心跳检查:
void TcpServer::onNewConnection() { while (hasPendingConnections()) { QTcpSocket* socket = nextPendingConnection(); ClientSession* session = new ClientSession(socket, this); sessions_.insert(socket, session); connect(session, &ClientSession::disconnected, this, &TcpServer::onSessionDisconnected); connect(session, &ClientSession::messageReceived, this, &TcpServer::onMessageReceived); qDebug() << "客户端接入:" << session->peerInfo() << "当前连接数:" << sessions_.size(); } } void TcpServer::onSessionDisconnected(ClientSession* session) { sessions_.remove(session->socket()); session->deleteLater(); qDebug() << "客户端断开,剩余连接数:" << sessions_.size(); } void TcpServer::checkHeartbeat() { qint64 now = QDateTime::currentMSecsSinceEpoch(); for (auto it = sessions_.begin(); it != sessions_.end(); ) { ClientSession* session = it.value(); if (now - session->lastActiveTime() > 15000) { qDebug() << "心跳超时,强制断开:" << session->peerInfo(); session->socket()->abort(); // 会触发 disconnected 信号 it = sessions_.erase(it); } else { ++it; } } }注意onSessionDisconnected里我把session从哈希表移除后又调用了deleteLater(),因为disconnected信号是socket发出的,槽函数执行时socket对象还在,不能直接delete,否则会崩。用deleteLater()后,事件循环会在当前信号处理完之后安全释放对象。
4.2 客户端重连状态机实现
客户端的核心是一个ClientEngine类,封装了连接、重连、心跳。
void ClientEngine::start(const QString& host, quint16 port) { host_ = host; port_ = port; state_ = STATE_CONNECTING; manualClose_ = false; socket_ = new QTcpSocket(this); connect(socket_, &QTcpSocket::connected, this, &ClientEngine::onConnected); connect(socket_, &QTcpSocket::disconnected, this, &ClientEngine::onDisconnected); connect(socket_, &QTcpSocket::readyRead, this, &ClientEngine::onReadyRead); connect(socket_, &QTcpSocket::errorOccurred, this, &ClientEngine::onErrorOccurred); connectToServer(); } void ClientEngine::connectToServer() { if (state_ == STATE_CONNECTED) return; state_ = STATE_CONNECTING; socket_->connectToHost(host_, port_); } void ClientEngine::onConnected() { state_ = STATE_CONNECTED; emit statusChanged(true); qDebug() << "连接成功:" << host_ << port_; if (!reconnectTimer_) reconnectTimer_ = new QTimer(this); reconnectTimer_->stop(); // 启动心跳定时器 heartbeatTimer_->start(5000); } void ClientEngine::onDisconnected() { state_ = STATE_IDLE; heartbeatTimer_->stop(); if (manualClose_) { emit statusChanged(false); return; } emit statusChanged(false); qDebug() << "连接断开,3秒后重连..."; reconnectTimer_->start(3000); } void ClientEngine::onReconnectTimeout() { qDebug() << "正在重连..."; connectToServer(); }这里有个细节:reconnectTimer_是一次性定时器(setSingleShot(true)),还是重复定时器?我一开始用的是重复定时器,结果发现一旦connectToHost失败,Qt 内部会产生errorOccurred,然后某些情况下disconnected信号也会跟着触发,导致重连定时器被重复启动、多个重连流程同时跑。后来统一改成单次定时器,每次超时只触发一次connectToServer,如果失败,disconnected槽里会再次启动定时器,形成“失败—等待—再失败—再等待”的干净循环。
4.3 指定格式报文的发送与接收
发报文的封装逻辑两端一样,这里给一个通用函数:
QByteArray buildPacket(quint16 cmd, const QByteArray& payload) { QByteArray packet; QDataStream stream(&packet, QIODevice::WriteOnly); stream.setByteOrder(QDataStream::LittleEndian); stream << quint16(0xAA55); stream << cmd; stream << quint32(payload.size()); packet.append(payload); char check = 0; for (int i = 0; i < payload.size(); ++i) check ^= payload.at(i); packet.append(check); return packet; }接收端在ClientEngine::onReadyRead()里跟服务器端走一模一样的拆包逻辑——先攒buffer_,再按魔数、长度、校验切包。这个逻辑两处都有,为了避免重复,实际项目中可以抽出来放成一个静态工具类或一个PacketParser类,两端共用。我在主项目里就是这么干的,因为一旦协议升级,只改一处。
5. 实际联调中遇到的三个坑和对应的解法
5.1 坑一:粘包在“局域网 + 高频发送”场景下几乎必现
第一次联调时,客户端每 50 毫秒发一条 4 字节的心跳,服务器端readyRead里只做了一次readAll(),然后立刻当一条报文解析。结果就是:服务器偶尔收到一条 12 字节的数据,里面有 2~3 条心跳报文粘在一起,解析直接失败,校验不过被丢弃。现象是“服务器偶尔丢心跳,然后误判客户端掉线”。
解法就是上面写的tryParseBuffer()里的while循环,先判断长度够不够,不够就攒着,够了就切走,循环处理。
5.2 坑二:deleteLater和abort的触发顺序
服务器踢掉超时客户端时,我先调socket->abort(),然后立刻deleteLater(),结果在 Qt 5.15 版本上偶发崩溃。看堆栈发现是deleteLater在abort触发的disconnected信号处理链还没结束时就执行了,socket对象被释放,而信号处理函数还在引用它。
修法其实很微妙:onSessionDisconnected里不要调用socket->deleteLater(),改在槽函数的最后统一对session做deleteLater()。因为session的生命周期由服务器管理,session析构函数里再delete socket_,这样保证信号链先走完,再销毁对象。
5.3 坑三:connectToHost的超时时间不能依赖 Qt 默认值
Qt 里connectToHost默认的超时时间其实挺长的(在部分平台上可能超过 30 秒甚至更长),如果目标 IP 不可达(比如网线断了但 IP 没换),客户端会一直卡在CONNECTING状态不弹错。这会导致重连逻辑失效——因为disconnected信号根本没触发。
解决方法是自己在CONNECTING状态加一个 10 秒的定时器,如果 10 秒后还没收到connected信号,就主动abort()这次连接,强制触发disconnected,从而进入重连流程。
void ClientEngine::onConnectTimeout() { if (state_ == STATE_CONNECTING) { qDebug() << "连接超时,强制取消本次连接"; socket_->abort(); } }注意abort()之后,onDisconnected里会把reconnectTimer_重新启动,这样就自动衔接到下一轮重连。
6. 进阶:把连接池与业务处理解耦
最后聊一下演进思路。上面这套代码能跑,但如果你要在上面叠加“给指定设备下发指令”“按设备编号路由消息”“统计各连接收发字节数”,建议把ClientSession和实际业务处理再拆一层,引入一个MessageDispatcher。
我的做法是:TcpServer只负责连接生命周期和原始拆包,收到完整报文后发一个messageReceived(session, cmd, payload)信号。MessageDispatcher接收这个信号,通过cmd命令字分发到不同的业务槽函数(比如onHeartbeat、onStartCommand、onConfigSet)。ClientSession里不写任何业务逻辑,只保留一个sendPayload(cmd, data)的公共接口。这样后期加协议命令,只需要修改MessageDispatcher的映射表,不需要动网络层,维护成本直线下降。
另一个想提的点是日志。TCP 联调阶段的日志一定不要只打qDebug() << "received"这种没营养的信息。把每条报文的命令字、包体长度、来源IP、时间戳、是否校验通过打出来,用一个带文件输出的日志库(qInstallMessageHandler重定向到文件即可)记录,很多人排查半包粘包问题半天找不到头绪,其实就是缺了这几个字段的日志。
再补充一个小技巧:如果你在做多客户端联调时想在同一个局域网里快速造出“客户端频繁断开”的环境,可以在服务器里临时加一个调试接口,每收到第 N 条消息就abort()那个连接。这种故意制造故障的做法,比手动拔网线高效得多,而且能稳定复现问题。
Qt 里的 TCP 通信,说到底就是一个“事件驱动 + 状态管理”的问题。QAbstractSocket类已经把底层伯克利套接字封装得很友好了,剩下的难点全在应用层协议的处理和连接生命周期的维护上。把上面这套结构理顺,多客户端、断线重联、自定义报文解析,基本一次搞定。
本文还有配套的精品资源,点击获取