简介:面向Qt网络编程学习者的Linux TCP通信示例资源包,聚焦Web服务器在Linux平台的移植思路与TCP/IP协议栈的工程落地。内容包括可运行的客户端与服务器工程,结合QTcpServer、QTcpSocket和信号槽事件驱动机制,演示监听端口、建立连接、收发数据的完整流程,适合具备基础C++/Qt知识、希望深入网络编程的开发者实践参考。压缩包为rar格式,共27个文件,主要包含9个cpp源文件、5个h头文件、2个pro与2个makefile构建配置,以及1个ui界面文件,另有若干o编译产物和编译好的client、server可执行文件,整体仅265KB,结构紧凑,便于直接查看和改读。内容还涉及异步通信、错误处理、资源管理及线程安全等常见问题,能帮助读者理解并发连接场景下Qt网络模块的实际运用。已有232人学习浏览,适合作为学习Qt TCP编程与Web服务器移植核心原理的入门示例。
1. 用 Qt 在 Linux 上跑通 TCP 客户端与服务端:这份 client-and-server 到底解决了什么
把client-and-server.rar在 Linux 上解开,里面通常是两个 Qt 工程:一个叫webserver的 TCP 服务端,一个叫client的 TCP 客户端。这是 Qt 网络协议编程最常见的起点,它解决的具体问题是:让两台 Linux 机器——或者一块嵌入式开发板和一个上位机——通过 TCP 协议收发数据。注意这里的webserver并不是跑在 80 端口上的 Web 服务器,它只是一个用QTcpServer监听自定义端口、按业务协议收发二进制或文本帧的网络服务端。适合读这篇文章的人,是准备做上位机、嵌入式 Linux 设备联调、工业现场自研协议通信的工程师。你不需要先懂 epoll,但需要理解 Qt 的事件循环和信号槽。
2. 为什么 Qt 适合做 TCP 网络编程:异步 IO、事件循环与核心类选型
2.1 从阻塞 socket 到事件驱动:Qt 替你做了什么
在 Linux 上用 C 写一个 TCP 服务端,经典套路是socket() + bind() + listen() + accept() + read()/write()。这里最大的分岔口是阻塞模式:accept()会卡住主线程,read()会卡在没有数据时。于是要用select()、poll(),进阶一点上epoll(),把 fd 交给内核去监听,等有事件再逐个处理。这一套写下来,代码量和调试点都不小,而且错误处理稍不到位就容易把进程拖死。
Qt 的QAbstractSocket把这层封装成了事件驱动模型。它内部仍然走 Linux 的 socket API,但把 select/poll 的监测逻辑合并进了 Qt 的事件循环。你不再写"我去读数据",而是等readyRead信号告诉你"内核里有可读数据了"。这种反转关系是 Qt 网络协议编程的核心:所有 IO 都不阻塞,状态变化通过信号通知,槽函数在事件循环线程里被执行。
事件循环的另一个作用是让信号槽跨线程调度成为可能。你在子线程里调用某个对象的信号,如果接收对象在另一个线程,Qt 会用队列连接把槽函数投递到接收对象所在线程的事件循环中执行。这比裸线程加锁处理网络缓冲区的写法要干净得多。代价是你必须清楚:某个 socket 事件的槽函数到底跑在哪个线程里,这直接影响后续对缓冲区、连接列表的访问是否需要加锁。
2.2 三个核心类:QTcpServer / QTcpSocket / QUdpSocket 的分工
在 Qt 网络模块里,做 TCP 离不开两个类:QTcpServer和QTcpSocket。QTcpServer只负责监听和接受连接,它自己不收发数据;有新客户端接入时发出newConnection信号,你在槽里调用nextPendingConnection()拿回一个QTcpSocket,后续收发都由这个 socket 完成。QTcpSocket是已建立 TCP 连接的双向通道,客户端用它发起连接,服务端用它和具体客户端通信。
QUdpSocket则是另一条路线:无连接、不保证有序、不保证不丢。选型时判断标准很简单——业务是否要求严格有序和可靠交付。像文件传输、指令应答、状态上报这类要确认的业务,用 TCP;像设备发现、实时状态广播、音视频这种能容忍丢包且要低延迟的策略,才考虑 UDP。很多工业现场会看到 Modbus TCP,它本身也是建立在 TCP 之上的应用协议,同样会遇到字节流边界问题,这一点我在第 6 章展开。
下表列出三个类在本标题项目里的角色:
| 类名 | 角色 | 典型场景 |
|---|---|---|
| QTcpServer | 服务端监听、接受连接 | 上位机、网关服务、设备接入 |
| QTcpSocket | 已建立连接的双向通道 | 客户端、服务端与某个客户端的会话 |
| QUdpSocket | 无连接数据报通信 | 设备广播、组播、低延迟传感流 |
2.3 线程模型边界:socket 不能跨线程乱动
QTcpSocket默认属于创建它的线程。一个常见错误是在自定义QThread的run()里创建 socket,连接建立后线程方法返回,线程退出,socket 连带销毁,连接瞬间断开。另一个常见做法是把这个 socket 对象直接丢给主线程用,信号槽虽然是队列连接,但 socket 内部的连接状态、读写缓冲的修改实际发生在它所属线程里,跨线程访问会有竞态。
我一般会遵循一条规则:socket 属于哪个线程,它的连接、读写、断开全在那个线程里处理。如果确实需要把网络层放到独立线程,正确做法是把整个网络对象(比如MyClient、MyServer)用moveToThread()挪到目标线程,并且给那个线程一个正在运行的事件循环。对大部分 client-server 场景,放在主线程就够了——QTcpServer不阻塞,QTcpSocket的读写都是异步的,不需要为"并发收包"单独开线程。
3. 服务端实现:用 QTcpServer 监听端口并管理多个客户端
3.1 工程骨架:.pro 里必须先加 network 模块
创建一个纯控制台 Qt 工程时,.pro文件是起点。QTcpServer和QTcpSocket都属于 network 模块,不加这个模块,#include <QTcpServer>根本找不到头文件,qmake 阶段就会报模块缺失。
QT += core network CONFIG += c++11 TARGET = webserver TEMPLATE = app SOURCES += main.cpp MyServer.cpp HEADERS += MyServer.h说明:Qt 6 里 core 模块默认启用,写不写都行;network 必须显式加上。TARGET = webserver只是编译产物命名,和第 1 章提到的服务端工程名保持一致。如果后续要加界面,再把 widgets 加进来,纯网络服务端通常不需要。
main.cpp只需要两件事:创建QCoreApplication启动事件循环,然后让MyServer开始监听。
#include <QCoreApplication> #include <QDebug> #include "MyServer.h" int main(int argc, char *argv[]) { QCoreApplication app(argc, argv); MyServer server; // 监听 0.0.0.0:9000,局域网内任意网卡都能连进来 if (!server.start(9000)) { qCritical() << "server listen failed"; return 1; } qDebug() << "webserver listening on port 9000"; return app.exec(); }注意这里用的是QCoreApplication,不是QApplication,因为不需要 GUI 事件循环。app.exec()会让进程挂起并持续派发事件,所有 TCP 连接建立、数据可读、断开通知都靠事件循环驱动。
3.2 最小服务端:监听本机所有网卡并接受连接
MyServer头文件定义一个继承QObject的类,成员只有一个QTcpServer指针和一个客户端 socket 列表。
#ifndef MYSERVER_H #define MYSERVER_H #include <QObject> #include <QTcpServer> #include <QTcpSocket> #include <QList> class MyServer : public QObject { Q_OBJECT public: explicit MyServer(QObject *parent = nullptr); bool start(quint16 port); private slots: void onNewConnection(); private: QTcpServer *m_server; QList<QTcpSocket *> m_clients; }; #endif // MYSERVER_H实现文件里,构造函数把newConnection信号绑定到槽函数。start()里调用listen(),这是服务端第一次和系统网络栈打交道的动作:
#include "MyServer.h" #include <QDebug> MyServer::MyServer(QObject *parent) : QObject(parent) , m_server(new QTcpServer(this)) { connect(m_server, &QTcpServer::newConnection, this, &MyServer::onNewConnection); } bool MyServer::start(quint16 port) { // QHostAddress::Any 表示绑定所有网卡,避免只回环可连 return m_server->listen(QHostAddress::Any, port); } void MyServer::onNewConnection() { // 用 while 收完所有 pending 连接,防止信号合并导致 accept 堆积 while (m_server->hasPendingConnections()) { QTcpSocket *socket = m_server->nextPendingConnection(); m_clients.append(socket); // 每个客户端绑定独立的读写处理 connect(socket, &QTcpSocket::readyRead, this, [this, socket]() { QByteArray data = socket->readAll(); qDebug() << "recv from" << socket->peerAddress().toString() << ":" << socket->peerPort() << "data:" << data; // 回显:方便用 telnet 或 nc 验证服务端是否还活着 socket->write("echo:" + data); socket->flush(); }); connect(socket, &QTcpSocket::disconnected, this, [this, socket]() { qDebug() << "client disconnected:" << socket->peerAddress().toString(); m_clients.removeOne(socket); // deleteLater 而不是 delete,避免事件循环里悬垂崩溃 socket->deleteLater(); }); qDebug() << "new client from" << socket->peerAddress().toString() << ":" << socket->peerPort(); } }这段代码里有三个值得注意的参数和习惯:
QHostAddress::Any是服务端最容易踩坑的地方。如果图方便写成QHostAddress::LocalHost,服务端只会监听回环地址 127.0.0.1,局域网内其他机器怎么都连不上,但本机测试一切正常。Any才会绑定所有可用网卡。
hasPendingConnections()配合nextPendingConnection()一定要用while循环。newConnection信号可能会因为事件循环合并而一次带出多个连接,只if一次会漏掉排在后面的 pending 连接,客户端表现出"连上了但服务端没反应"。
每个QTcpSocket的readyRead槽里我都捕获了socket指针,这个 lambda 会跟随 socket 的整个生命周期,必须以deleteLater()保证 socket 在事件循环空闲时才销毁,否则 lambda 里捕获的指针会变成悬垂指针,下一次信号触发直接让程序崩溃。
3.3 连接管理之外:还能调的几个参数
如果项目要从 demo 往生产走,服务端通常还要做两件事。一是限制最大挂起连接数,QTcpServer默认允许相当数量的连接排队,可以在listen()之前调用setMaxPendingConnections(256),防止连接风暴时资源被打满。二是给 socket 开 TCP 保活,让长时间不说话的死连接能被系统发现:
// 在拿到 socket 后开启 TCP keep-alive int keepAlive = 1; socket->setSocketOption(QAbstractSocket::KeepAliveOption, keepAlive);KeepAliveOption对应 Linux 层的SO_KEEPALIVE,默认探测间隔由系统参数决定,约两小时。需要更精细的探测间隔,就得直接调 Linux 的/proc/sys/net/ipv4/tcp_keepalive_time等参数,这属于 TCP 协议栈调优范围。对大多数上位机场景,先打开 keep-alive 即可,它能避免服务端一侧积累大量半开连接。
4. 客户端实现:connectToHost 异步连接与收发流程
4.1 异步连接:connectToHost 返回后连接还没建立
客户端工程结构和服务端类似,.pro里同样需要QT += core network,TARGET改成client。核心类从QTcpServer换成了QTcpSocket,它既是客户端发起连接的工具,也承担连接后的收发。
#ifndef MYCLIENT_H #define MYCLIENT_H #include <QObject> #include <QTcpSocket> class MyClient : public QObject { Q_OBJECT public: explicit MyClient(QObject *parent = nullptr); void connectToServer(const QString &host, quint16 port); void sendData(const QByteArray &data); private slots: void onConnected(); void onReadyRead(); void onDisconnected(); private: QTcpSocket *m_socket; }; #endif // MYCLIENT_H这个类最大的特点是connectToServer()是异步的:
#include "MyClient.h" #include <QDebug> MyClient::MyClient(QObject *parent) : QObject(parent) , m_socket(new QTcpSocket(this)) { connect(m_socket, &QTcpSocket::connected, this, &MyClient::onConnected); connect(m_socket, &QTcpSocket::readyRead, this, &MyClient::onReadyRead); connect(m_socket, &QTcpSocket::disconnected, this, &MyClient::onDisconnected); } void MyClient::connectToServer(const QString &host, quint16 port) { // 异步发起连接,立刻返回;结果靠 connected / errorOccurred 通知 m_socket->connectToHost(host, port); } void MyClient::onConnected() { qDebug() << "connected to server"; QByteArray hello = "hello webserver"; m_socket->write(hello); m_socket->flush(); } void MyClient::onReadyRead() { QByteArray data = m_socket->readAll(); qDebug() << "recv:" << data; } void MyClient::onDisconnected() { qDebug() << "disconnected from server"; }connectToHost()执行后立即返回,但 TCP 握手还在继续。许多人第一次写都在这翻车:connectToHost()后面紧接着write(),结果数据没送到服务端。原因不是write()失败,而是 socket 还没进入ConnectedState,写入的数据要么留在发送缓冲里等连接生效,要么在连接失败时被直接丢弃。正确做法是严格按照信号通知来:connected信号触发了再写业务数据。
如果确实需要同步等待连接结果,QTcpSocket也提供了waitForConnected(3000)。但这个方法只适合纯控制台程序或者后台线程,在 GUI 主线程里调用会阻塞事件循环,界面直接卡死。这个边界一定要写清楚:异步是 Qt 网络编程的默认姿势,wait 系列方法是给特殊场景用的后悔药,不是常规手段。
4.2 数据收发:write 与 readyRead 的配合
write()的返回值表示写进内部发送缓冲的字节数,不等于对端已经收到。真正把数据推向网络的是 flush,或者事件循环里的下一次写机会。在演示代码里我会在回显后主动调用flush(),因为要确认数据立刻发出。高吞吐业务里不建议每次都 flush,Nagle 算法本来就会合并小包,频繁 flush 反而会降低 TCP 协议栈的打包效率。
readyRead信号是接收侧的入口。它在内核缓冲区出现新数据时触发,每次触发调用一次readAll(),把当前缓冲区里所有可读字节取走。这里要记住:一次readyRead不保证对应一个完整的业务包,这是 TCP 字节流的天然属性。我在第 5 章会专门用一个条目讲粘包和半包。
发送端如果一次写入的数据很长——比如超过发送缓冲区大小——write()可能只接受一部分,剩余的要等内部bytesWritten信号继续写。实际业务中,文件传输或大帧协议要注意这个信号,普通指令级通信通常 4KB 以内的帧一次写完没问题。
4.3 断开之后怎么办:重连与状态检查
对 client-server 程序来说,服务端重启、网线波动、设备断电都会让连接断开。稳健的客户端必须重连。最简单的策略是定时器加状态检查:
#include <QTimer> void MyClient::onDisconnected() { qDebug() << "disconnected, schedule reconnect in 3s"; // 3 秒后重新连接;注意检查当前必须是未连接状态 QTimer::singleShot(3000, this, [=]() { if (m_socket->state() == QAbstractSocket::UnconnectedState) { m_socket->connectToHost("192.168.1.100", 9000); } }); }重连需要保存 host 和 port 作为成员变量,断线后从成员里取。还有一个细节:如果服务端没有启动,connectToHost会触发errorOccurred,但disconnected不会触发,重连调度永远不再执行。所以更稳的写法是重连定时器独立于disconnected信号,每次连接失败后自己也定时重试,直到成功为止。工业现场客户端一般会用指数退避,比如 3 秒、6 秒、12 秒逐步拉开重连间隔,避免服务端恢复瞬间被一堆客户端同时冲垮。
5. Qt 网络编程避坑:5 个高频翻车点与排查方法
5.1 服务端监听成功却收不到数据:QHostAddress::Any 与 LocalHost 的差别
现象:代码编译通过,listen()返回 true,但用局域网另一台机器连接超时;本机用telnet 127.0.0.1 9000却能连通。
原因:listen(QHostAddress::LocalHost, 9000)只会把 socket 绑定到回环地址 127.0.0.1。回环接口上的流量只有本机进程能访问,其他网卡收到的 TCP 连接请求根本没有进程在监听。这是服务端最常见的"假成功"。
解决:服务端统一使用QHostAddress::Any,必要时改用具体的网卡 IP(比如绑定到业务网卡的地址),再配合netstat -tlnp查看实际监听地址:
# 确认端口监听在 0.0.0.0 还是 127.0.0.1 netstat -tlnp | grep 9000 tcp 0 0 0.0.0.0:9000 0.0.0.0:* LISTEN 13245/webserver如果netstat里显示127.0.0.1:9000,那你绑定的就是LocalHost而不是Any。
5.2 connectToHost 返回后立刻 write,服务端一条数据都没收到
现象:客户端日志显示连接成功,write()返回值也正常,但服务端没有任何readyRead触发。
原因:connectToHost()是异步的,函数返回时 TCP 握手可能还在进行中。立即写入的数据此时进入 socket 内部的发送缓冲,如果连接随后失败,这些数据会随 socket 一起被丢弃;即使连接成功,数据也可能在槽函数执行顺序上先于connected触发,造成"连接还没完成就写数据"的竞态。
解决:把write()放到connected信号的槽里;如果要用同步方式,确认当前线程不是 GUI 主线程,再用waitForConnected(3000)。最简单的判断标准:代码里出现connectToHost(); write();连写,基本就是有问题的。
5.3 socket 提前 delete 导致 Qt 程序崩溃
现象:程序运行一段时间后突然崩溃,崩溃堆栈指向QTcpSocket的内部方法;或者关闭一个客户端连接时,整个服务端进程退出。
原因:这是 Qt 网络编程里常见的生命周期问题。QTcpServer::nextPendingConnection()返回的 socket 归你管理,但你如果在disconnected信号处理过程中直接delete socket;,同一轮事件循环里后续的 lambda 可能还会访问这个指针;或者删除后又从连接列表里取它做广播,访问了悬垂指针。
解决:统一使用socket->deleteLater()而不是delete socket;。deleteLater()会等到当前事件循环处理完这一批事件后才真正销毁对象,避免在信号处理链的中间炸掉。同时从m_clients容器中移除该指针的时机,要放在deleteLater()之前,避免列表里残留已经失效的地址。
5.4 readyRead 里 readAll() 读到的不是完整一包:TCP 粘包与半包
现象:客户端一次写了 100 字节,服务端第一次readyRead里readAll()却只有 60 字节;或者两次写入的 200 字节在接收端一次readAll()里全出现。
原因:TCP 是字节流协议,不保序,不保包边界。内核根据缓冲区和网卡驱动的情况决定每段可读数据的大小,上层应用定义的"包"只是自己的逻辑概念。以为"一次 write 对应一次 readAll"是几乎每个新手都会踩的坑。
解决:必须在应用层定义帧边界。行业里常见三种方案:固定长度帧、结束符分隔(如\r\n)、长度头 + 载荷。长度头方案最通用,第 6 章会直接给出可复用的解析代码。遇到数据不完整时先攒在缓冲区,凑够一整帧再处理,不要每来一次readyRead就当作一个消息。
5.5 Linux 下局域网连不上:防火墙与端口监听状态
现象:相同的代码在本机回环测试通过,放到两台物理机上就连接失败;客户端报Connection refused或者直接超时。
原因:Connection refused通常是目标端口没有进程在监听,或监听地址不是Any;超时则大概率是中间网络不通,或者防火墙直接丢弃了 SYN 包。很多 Linux 发行版默认防火墙放行回环流量,但拦截外部网卡对高位端口的访问。
解决:按顺序排查。先确认服务端进程真的在监听:
netstat -tlnp | grep 9000再确认客户端到服务端的 TCP 路径可达:
telnet 192.168.1.100 9000 # 或者一条命令直接看握手结果 nc -vz 192.168.1.100 9000如果 telnet 显示Connected,说明协议栈和端口都没问题,剩下的只看业务代码。如果卡住不动,用tcpdump -i any port 9000看 SYN 包有没有到目标机器。到了但没回包,就是防火墙拦截确认包,需要按发行版放行端口,常见的是firewall-cmd --add-port=9000/tcp(firewalld)或ufw allow 9000/tcp(ufw)。这里不看某个特定发行版,重点是先定位到"哪个环节丢包",而不是盲目改防火墙。
6. 进阶:用自定义帧协议解决粘包,把 client-and-server 做成可靠通信
6.1 帧协议设计:magic + length + payload
要彻底解决粘包和半包,我给这个 client-server 加一个简单的长度头协议。帧格式如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| MAGIC | 2 字节 | 固定0xA5A5,用于帧同步 |
| LENGTH | 2 字节 | PAYLOAD 长度,大端序,单位字节 |
| PAYLOAD | 0~65535 字节 | 业务数据 |
解析端维护每个 socket 自己的接收缓冲区,只有凑齐完整一帧时才交给业务逻辑。核心逻辑是这段:
// 每个 socket 对应一个 QByteArray m_buffers,可在外层用 QHash<QTcpSocket*, QByteArray> 保存 // readyRead 槽里执行: QByteArray &buf = m_buffers[socket]; buf.append(socket->readAll()); // 先把内核里的数据尽量收进应用缓冲区 forever { if (buf.size() < 4) break; // 帧头都没齐,继续等 // 大端序读取 magic 和 length quint16 magic = (quint8(buf[0]) << 8) | quint8(buf[1]); quint16 len = (quint8(buf[2]) << 8) | quint8(buf[3]); if (magic != 0xA5A5) { // 帧头不对,说明流错位了;丢掉一个字节重新找帧头 buf.remove(0, 1); continue; } if (buf.size() < 4 + len) break; // 半包:数据还没收齐,等下一次 readyRead QByteArray payload = buf.mid(4, len); buf.remove(0, 4 + len); // 消费完整一帧 qDebug() << "one complete frame:" << payload; // 这里再调用真正的业务处理函数,比如入库、下发、回执 }这段逻辑的要点在forever循环和break的配合:每次readyRead都可能带来完整帧、半包、甚至多帧,所以必须在循环里反复解析,直到缓冲区里的数据不足以构成下一个完整帧才退出。用memcpy直接按主机序读两个quint16在 x86 上是小端,跨平台到 ARM 或 PowerPC 会读反,所以统一手工按大端序拼字节,这属于网络协议编程的地基习惯。
6.2 验证方式:回环压测与双客户端并发
搭好协议解析后,验证分两步。第一步回环压测:客户端循环发送 1000 帧,服务端每收到完整一帧就打印一次,日志里应精确出现 1000 条记录,且每条 payload 完整无拼接。第二步双客户端并发:开两个client实例同时向服务端发数据,观察解析逻辑是否会串帧。只要缓冲区是每个 socket 独立维护的,两个连接的数据就不会互相污染;如果共用了一个QByteArray,就会出现 A 的 payload 里混进 B 的字节,这是实现时最容易被忽略的边界。
给这个帧协议发送端的写法也很简单:拼好 4 字节头部,再直接write即可。
// 发送端组装一帧 QByteArray payload = "hello"; QByteArray frame; frame.append(char(0xA5)); // MAGIC 高字节 frame.append(char(0xA5)); // MAGIC 低字节 frame.append(char((payload.size() >> 8) & 0xFF)); // LENGTH 高字节 frame.append(char(payload.size() & 0xFF)); // LENGTH 低字节 frame.append(payload); m_socket->write(frame); m_socket->flush();6.3 进阶之后的下一级台阶
帧协议之上,还可以继续加:请求序号、消息类型、CRC 校验。工业现场对接 PLC 或自研设备时,CRC 几乎不可省,因为电磁环境可能让链路层悄悄改坏字节。加校验后,接收端对不上 CRC 就直接丢弃该帧,同时记录一条错误日志,方便定位是线路问题还是协议分歧。做这一步时我有个教训:最早做帧解析时只处理了完整帧和半包,没处理帧头错位,结果某次设备主动发来一包异常数据后,后续所有数据全都解析错乱。后来补上"找不到 magic 就逐字节丢弃"的逻辑,程序才真正稳下来。这个逐字节丢弃的兜底逻辑看着笨,但在真实链路里价值很大,希望帮到你。
本文还有配套的精品资源,点击获取