Qt网络编程实战:QTcpServer与QTcpSocket实现局域网TCP通信
工试云启 考证服务中心整理

作为一个工作了十来年的C/Qt开发者我平时写工具类的小软件最常用到的通信方案就是TCP。前阵子接了个内部需求要在几台办公机器之间做一个简单的局域网消息收发工具不需要服务器中转也不想额外部署中间件。我直接用Qt自带的TcpSocket家族搭了一套客户端和服务端一天时间就把骨架跑通了。今天把这个实例完整拆一遍包括整体设计、关键代码、联调过程和几个典型的坑给正在折腾Qt网络编程的朋友一个可以直接“抄作业”的参考。这个项目用到的核心点其实不多服务端开一个QTcpServer去监听客户端用QTcpSocket去连接建立连接后在缓冲区里读写字节流。但要把这套流程真正弄明白还需要搞清楚信号槽的连接方式、数据粘包怎么处理、怎么保证消息不乱码、断开之后怎么安全回收资源。下面我按自己做这个Demo的顺序把每一步的思路和代码都展开说。1. 整体设计与思路拆解1.1 为什么选择Qt的TcpSocket而不是直接撸系统API如果你接触过Linux下的socket编程肯定知道要从socket()、bind()、listen()、accept()这一串函数写起后面还得手动处理非阻塞、EINTR错误、缓冲区管理一套流程走下来代码量不小而且容易在不同平台上踩不同的坑。用Qt就可以省掉这一大堆麻烦。Qt已经把TCP客户端和服务端封装成了两个类QTcpServer和QTcpSocket。底层在Windows上是winsock在Linux上是BSD socket在macOS上又是另一套但这些系统差异被Qt全部抹平了。我只需要关心业务逻辑什么时候监听、什么时候连接、收发什么数据。实测下来的体会是同样的功能用Qt写比用原生API写至少省一半代码而且可读性高很多项目后期维护也很舒服。另外Qt还提供信号槽机制。socket一旦有数据到达、连接建立、连接断开都会发射对应的信号我只需要把信号接到对应的槽函数上这比自己在循环里轮询read()要直观得多也不容易漏数据。1.2 TCP还是UDP——局域网通信该怎么选型这个项目标题里明确写了TCP通信协议。有些人可能会问局域网内不是UDP更省事吗这里需要做个区分如果你的业务场景是可靠消息传输比如聊天消息、文件内容、控制指令那就必须用TCP。TCP保证数据有序到达不会丢不会重复。如果业务场景是音视频流媒体、实时游戏状态同步、设备广播发现才更适合用UDP因为可以容忍少量丢包换取更低的延迟。我的这个项目是做局域网通信软件核心诉求是消息不丢、不乱序所以选TCP是合理的。有一点可以提一下Qt里的QUdpSocket实现起来也很简单但如果你的业务需要可靠传输直接用UDP去实现重传、排序、ACK这些机制会非常痛苦分分钟怀疑人生。能用TCP的地方尽量别自己造UDP的可靠性轮子。1.3 功能边界与架构选型开始动手之前我先明确了功能边界服务端启动监听接收多个客户端的连接并对每个客户端显示收发日志。客户端填写服务端IP和端口发起连接连接成功后发送消息并接收服务端的消息。协议方面设计一个简单的“消息包”分头部和正文避免粘包拆包时把消息读乱。架构上我采用了经典的C/S模型服务端负责监听和转发客户端负责主动连接。局域网内的通信场景一般不需要搞P2P穿透那种复杂逻辑一台机器当服务端另外几台当客户端就能满足需求。这里还隐含一个点QTcpServer在收到新连接时会为每个客户端动态创建一个新的QTcpSocket对象这个socket才是真正做读写通信的对象而QTcpServer自己那个socket只负责“接客”不负责“聊天”。2. 核心细节解析与实操要点2.1 QTcpServer与QTcpSocket的分工很多刚开始写Qt网络程序的人会搞混QTcpServer和QTcpSocket的分工。我打个比方QTcpServer就像前台接线员它只管监听电话线路。当有电话打进来它就分配一个专门的话务员去处理这通电话。QTcpSocket就是那个话务员每通电话对应一个独立的socket对象。所以代码里你会在服务端看到newConnection信号每次触发后用nextPendingConnection()获取这条新连接的socket。这个socket后续负责数据的收发而QTcpServer本身不传业务数据。2.2 关键信号与槽函数在实际开发里有四组信号一定需要处理QTcpServer::newConnection()新客户端连上来了。QTcpSocket::readyRead()socket缓冲区里有数据可读。QTcpSocket::connected()客户端成功建立连接。QTcpSocket::disconnected()连接断开了。我个人的习惯是服务端每accept一个新客户端就立刻把它加入一个列表比如QListQTcpSocket*并把readyRead、disconnected信号都连接好。这样后续要给所有客户端广播消息时只需要遍历这个列表就行。需要注意的是disconnected信号触发后socket一般就要准备删除了。这时不要直接用delete socket因为如果当前还在某个信号处理栈里直接删除可能引发野指针。更稳妥的方式是使用deleteLater()让Qt在事件循环适当的时候回收它。2.3 TCP粘包问题与自定义协议设计TCP是字节流协议它本身没有任何消息边界的概念。你调用send()发送的“消息”在网络层可能被拆成多个数据包到达也可能多个“消息”被合并成一个包到达。如果接收方不做协议解析直接把收到的字节转成字符串要么消息被截断要么多条消息拼在一起看起来就是乱码甚至错乱。我在这套Demo里设计了一个最简单的协议帧结构如下偏移量长度含义02字节包头魔数固定为0xAA55用来快速校验这是不是有效包22字节消息体长度len即正文部分有多少字节4len字节消息体用UTF-8编码的字符串这个协议本身非常简单但已经足够覆盖90%的轻量聊天/消息推送场景了。接收方在解析时会把收到的数据先暂存到缓冲区然后循环读取每次都判断缓冲区里够不够一个完整的包至少4字节头部约定的体长度够了就解析出来然后从缓冲区移除这部分数据继续解析下一条。2.4 QString与QByteArray的编码细节Qt的QString是Unicode字符串内部不是UTF-8字节流。当我需要把字符串发给对端时一定要转成QByteArray最推荐的是QString::toUtf8()。接收端用QString::fromUtf8()把字节数组解码回正常文本。这个坑特别常见有的人直接调socket-write(str.toStdString().c_str())或者用fromLocal8Bit在不同系统上会出现中文乱码。统一用UTF-8在客户端和服务端之间做字符串交换是最省心的。3. 实操过程与核心环节实现3.1 开发环境与工程配置我用的是Qt 6.x编译器用的MinGW 64bit。如果你用Qt 5.15或者用MSVC也完全不影响因为TCP这套API从Qt 4时代到今天基本没大变过。工程文件.pro里记得加一行QT core gui network如果是CMake工程在find_package之后加上网络组件的链接find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets Network) target_link_libraries(myapp PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets Qt6::Network)缺少network模块是新增题目里最容易忽略的一步。稍微漏掉这步编译就直接报找不到QTcpServer的头文件。3.2 服务端核心代码服务端界面我放了几个控件一个用来显示监听状态和消息日志的QTextEdit一个用来输入消息内容的QLineEdit一个启动监听的按钮一个发送按钮先看头文件的核心成员// serverwidget.h class ServerWidget : public QWidget { Q_OBJECT public: ServerWidget(QWidget *parent nullptr); ~ServerWidget(); private slots: void onStartListening(); void onSendMessage(); void onNewConnection(); void onReadyRead(); void onDisconnected(); private: QTcpServer *m_server; QListQTcpSocket * m_clients; QTextEdit *m_logEdit; QLineEdit *m_messageEdit; QPushButton *m_listenButton; QPushButton *m_sendButton; };关键的实现逻辑// serverwidget.cpp void ServerWidget::onStartListening() { m_server new QTcpServer(this); // 监听所有网卡上的8888端口 if (!m_server-listen(QHostAddress::Any, 8888)) { m_logEdit-append(QString(监听失败%1).arg(m_server-errorString())); return; } m_logEdit-append(QString(服务端已启动正在监听 0.0.0.0:8888 ...)); connect(m_server, QTcpServer::newConnection, this, ServerWidget::onNewConnection); } void ServerWidget::onNewConnection() { QTcpSocket *clientSocket m_server-nextPendingConnection(); if (!clientSocket) { return; } m_clients.append(clientSocket); connect(clientSocket, QTcpSocket::readyRead, this, ServerWidget::onReadyRead); connect(clientSocket, QTcpSocket::disconnected, this, ServerWidget::onDisconnected); m_logEdit-append(QString(新客户端连接%1:%2) .arg(clientSocket-peerAddress().toString()) .arg(clientSocket-peerPort())); }发送消息这里注意一下广播就意味着要把同一段数据写给每一个在线客户端void ServerWidget::onSendMessage() { QString msg m_messageEdit-text().trimmed(); if (msg.isEmpty()) { return; } // 组装协议帧魔数 长度 UTF-8正文 QByteArray body msg.toUtf8(); QByteArray packet; QDataStream stream(packet, QIODevice::WriteOnly); stream.setByteOrder(QDataStream::BigEndian); stream quint16(0xAA55) quint16(body.size()); packet.append(body); // 广播给所有客户端 for (QTcpSocket *client : m_clients) { if (client-state() QAbstractSocket::ConnectedState) { client-write(packet); } } m_logEdit-append(QString(我发%1).arg(msg)); }接收消息时要注意TCP数据可能会分多次到达不能用一次readAll()就当成完整消息。我这边会先把数据塞进一个缓冲m_buffer然后按协议拆包void ServerWidget::onReadyRead() { QTcpSocket *socket qobject_castQTcpSocket *(sender()); if (!socket) { return; } // 把socket中收到的所有字节追加到自己的缓冲 m_buffer.append(socket-readAll()); while (m_buffer.size() 4) { QDataStream stream(m_buffer); stream.setByteOrder(QDataStream::BigEndian); quint16 magic; quint16 len; stream magic len; if (magic ! 0xAA55) { // 没有魔数说明数据流错位了直接清空缓冲区等待重新对齐 m_buffer.clear(); return; } if (m_buffer.size() 4 len) { // 还不够一个完整消息继续等待 break; } QByteArray body m_buffer.mid(4, len); m_buffer.remove(0, 4 len); QString msg QString::fromUtf8(body); m_logEdit-append(QString(收到客户端消息%1).arg(msg)); } }服务端断开的处理void ServerWidget::onDisconnected() { QTcpSocket *socket qobject_castQTcpSocket *(sender()); if (!socket) { return; } m_clients.removeAll(socket); m_logEdit-append(QString(客户端断开%1:%2) .arg(socket-peerAddress().toString()) .arg(socket-peerPort())); socket-deleteLater(); }3.3 客户端核心代码客户端界面更简单一个IP输入框、一个端口输入框、一个连接按钮、一个消息输入框、一个发送按钮、一个日志显示框。成员变量核心就是QTcpSocket *m_socket。void ClientWidget::onConnect() { QString ip m_ipEdit-text().trimmed(); quint16 port m_portEdit-text().toUShort(); m_socket new QTcpSocket(this); connect(m_socket, QTcpSocket::connected, this, [this]() { m_logEdit-append(连接成功); }); connect(m_socket, QTcpSocket::readyRead, this, ClientWidget::onReadyRead); connect(m_socket, QTcpSocket::disconnected, this, [this]() { m_logEdit-append(连接断开); }); m_socket-connectToHost(ip, port); }这里有个体验细节connectToHost是异步的不要在调用后立刻往socket里写数据否则连接还没建立write的数据不会真正发出去。如果你希望连接成功后马上自动发一条上线通知可以在connected信号触发后再写。客户端接收数据同样要处理粘包逻辑代码和服务端基本一样不重复贴了。只是注意客户端界面上接收到的消息要带来源信息比如服务端IP.3.4 局域网联调的正确姿势写完两端代码我第一时间不是直接找另一台机器测试而是先在本机自测。客户端IP填的是127.0.0.1端口和服务端一致先把本机回路调通。如果本机就跑不通说明代码有问题这时候排查起来最快。本机通了之后才上局域网客户端IP改成服务端那台机器的局域网IP。比如服务端IP是192.168.1.100那客户端就填192.168.1.100。第一次上局域网联调最容易翻车的点是防火墙。Windows机器默认会拦截入站的未知端口连接解决方式有两种在服务端机器上给防火墙添加一条放行规则放行TCP 8888端口的入站连接。如果只是在临时测试可以直接把防火墙临时关掉调试完再打开。我个人的建议是加规则不要图省事关防火墙。关掉防火墙会让机器暴露在局域网各种扫描之下不安全。具体的规则添加步骤在Windows的“高级安全Windows Defender防火墙”里选择“入站规则”-“新建规则”-“端口”然后填上TCP端口8888选允许连接即可。联调时还可以用几条命令辅助验证服务端机器上执行netstat -ano | findstr 8888查看服务端有没有在监听8888端口。客户端机器上执行ping 服务端IP确认网络通不通。如果ping通但TCP连不上重点怀疑防火墙或服务端程序是否真的启动了。4. 常见问题与排查技巧实录4.1 客户端连不上服务端我先给一个速查表很多情况下对号入座就能解决现象可能原因检查方式一直连接中最后超时IP填错 / 不在同一网段ping服务端IP本机能连局域网内不行防火墙拦截添加放行8888端口规则服务端没反应服务端没执行listen()netstat -ano看端口状态连上秒断程序异常退出或socket被提前释放检查服务端日志有无报错我实际遇到过一个大坑服务端是用new QTcpServer创建的但没指定父对象也没有保存指针的成员变量函数执行完m_server这个局部变量析构监听也跟着关了。表面上看listen返回了true日志也打印正常但客户端怎么都连不上。这种情况用netstat一下子就能看出来端口根本没在监听。所以服务端这个QTcpServer对象一定要用成员变量持有或者把this作为它的父对象。4.2 中文乱码问题如果对端接收显示的是䏿–‡之类的乱码基本可以确定是编码不一致。发送端的字符串在转字节流的时候用了本地编码比如GBK接收端却用UTF-8去解码。我统一的做法是发送端QString::fromLocal8Bit(中文内容)或者直接确保输入框里的内容用text()拿到的就是QString转换时统一走toUtf8()。接收端QString::fromUtf8(rawBytes)。如果是从文件里读的文本先判断文件是什么编码。项目里一般建议尽量统一保存成UTF-8避免一堆编码转换的麻烦。4.3 粘包拆包的边界情况粘包问题在局域网里其实很常见尤其是你连续快速发送多条消息时。如果接收端不处理会出现多条消息拼成一条显示的情况。我自己用上面的while循环拆包时注意到一个关键细节缓冲区可能同时包含多条消息的一部分。比如缓冲区里有8个字节但前一条消息体长度是4后一条又来了4个字节这时候要在循环里把第一条完整取出后继续判断剩下的字节够不够第二条的头部。所以while不能写成if否则第二条消息会滞留在缓冲区直到下一条数据到达才被取出。另外还有一类极端情况如果网络异常导致字节流错位魔数校验不过。这时候最安全的方式是清空缓冲区让它重新对齐而不要尝试去逐字节找魔数否则很容易把自己绕晕。4.4 客户端退出时的崩溃问题这个坑很容易被忽略。客户端点击关闭窗口时如果socket还在连接状态析构函数里会直接销毁socket。由于断开过程是异步的如果这时候还有槽函数触发就可能访问到已释放内存。我推荐的做法是在客户端窗口关闭时先主动调用m_socket-disconnectFromHost()。如果它还没断开等待disconnected信号之后再执行后续清理。析构函数中用if (m_socket) m_socket-deleteLater();服务端同理关闭窗口时遍历所有客户端逐个disconnectFromHost并等disconnected信号后deleteLater。千万不要粗暴地delete socket我早期写代码时因为这种操作被莫名其妙的崩溃折磨过好几次。4.5 心跳与断线检测机制TCP本身没有固定的“对方是否活着”的实时通知。如果哪台客户端网络断了、电脑休眠了、或者程序被强杀服务端不一定立刻收到disconnected信号可能要等很久甚至完全不知道对方掉了线。解决这个问题的标准做法是心跳机制客户端每隔几秒发一个固定格式的心跳包比如消息体内容是PING。服务端收到PING后可以回复PONG或者只是简单更新该客户端最近活跃时间。服务端每隔一段时间扫描所有客户端如果某个客户端的最近活跃时间超过了阈值比如20秒就主动断开它的socket。心跳包和业务包的区分可以在协议头部里加类型字段。我这里是简单Demo就把PING直接当普通字符串消息包带过去服务端收到内容等于PING的包就直接忽略不算业务消息。如果是生产级项目建议在头部加一个Type字段把心跳、业务、连接通知这些类型区分开。5. 后续还可以这样扩展这套Demo跑通之后很容易在这个骨架上继续加东西在线用户列表服务端维护客户端列表后把在线编号和IP信息广播给所有客户端界面上用QListWidget展示。文件传输文件传输的核心问题不是“如何读文件”而是“如何把文件字节流和文件名、文件大小拼成一个完整的包”。TCP传输大文件时接收端不一次性读取全部字节而是边接收边写磁盘同时更新进度条。数据库消息记录在服务端把收到的消息追加到SQLite表里形成一个简单的局域网聊天系统。TLS加密如果通信内容敏感可以在TCP之上套一层QSslSocket。Qt对SSL的支持也做得比较完善直接改socket类型就能完成升级。我个人最推荐先去做在线用户列表和文件传输这两个扩展因为它们的实现正好能把今天这套“连接管理”和“协议拆包”的逻辑用到极致。把这几个功能练完Qt网络编程基本就算入门了。写这个Demo的过程中我最大的体会是网络编程的难点往往不在写代码本身而在把“字节流”和“消息”的边界想清楚。你只要把协议封包、拆包、缓冲区管理这几个核心点理解透了以后无论做聊天软件、文件传传输程序还是物联网网关思路都是通的。如果大家在自己动手实现时还有什么卡住的地方欢迎在评论区把现象和代码贴出来一起讨论。