Qt网络编程实战:Linux下构建TCP客户端与服务端

发布时间:2026/10/25 16:25:49
Qt网络编程实战:Linux下构建TCP客户端与服务端 简介面向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 c11 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; QListQTcpSocket * 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 networkTARGET改成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()因为要确认数据立刻发出。高吞吐业务里不建议每次都 flushNagle 算法本来就会合并小包频繁 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-port9000/tcpfirewalld或ufw allow 9000/tcpufw。这里不看某个特定发行版重点是先定位到哪个环节丢包而不是盲目改防火墙。6. 进阶用自定义帧协议解决粘包把 client-and-server 做成可靠通信6.1 帧协议设计magic length payload要彻底解决粘包和半包我给这个 client-server 加一个简单的长度头协议。帧格式如下字段长度说明MAGIC2 字节固定0xA5A5用于帧同步LENGTH2 字节PAYLOAD 长度大端序单位字节PAYLOAD0~65535 字节业务数据解析端维护每个 socket 自己的接收缓冲区只有凑齐完整一帧时才交给业务逻辑。核心逻辑是这段// 每个 socket 对应一个 QByteArray m_buffers可在外层用 QHashQTcpSocket*, 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 就逐字节丢弃的逻辑程序才真正稳下来。这个逐字节丢弃的兜底逻辑看着笨但在真实链路里价值很大希望帮到你。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询