资讯动态

QT上位机开发:TCP、UDP、串口三合一通讯工具源码解析

发布时间:2026/10/21 3:45:23 来源:尧图企业网站定制
简介这份源代码资源面向QT开发初学者与嵌入式、物联网方向的工程师聚焦TCP、UDP与串口三种通信方式的集成实现帮助读者理解跨平台通信程序的搭建思路。压缩包共67个文件约2.11MB以cpp源文件、h头文件、o编译产物和ui界面文件为主另含pro工程文件、Makefile及可执行程序覆盖从源码到构建的完整结构。资源围绕QTcpSocket、QUdpSocket与QSerialPort三类核心类展开包含服务端与客户端示例、串口参数配置、事件处理与错误检测等模块可作为学习网络与本地设备通信的实践起点。目前已有434人学习下载适合希望快速掌握QT通信编程、对照源码理解连接建立与数据收发流程的开发者参考。1. 一个上位机项目把 TCP、UDP、串口三条链路塞进同一个 QT 界面很多做工控上位机的开发者都遇到过这种局面设备端有的走网口 TCP有的走 UDP 广播上报还有一堆老设备只认串口。如果每接一种设备就重写一个测试工具光是维护窗口和日志格式就能把人拖垮。这份「基于 QT 的 TCP UDP 串口通讯源代码」解决的正是这个问题——它把三种通讯方式统一到一个 QT 桌面程序里用同一套界面完成连接配置、数据收发和状态显示。适合谁适合正在做设备调试工具、产测上位机、协议联调助手的开发者尤其是需要快速验证「设备到底有没有在发数据」这类场景。拿到源码后你可以直接编译运行也可以把某个通讯模块单独抠出来塞进自己的项目里。2. 技术选型与目录结构QT 的通讯能力到底怎么组织2.1 为什么是 QT 而不是别的 GUI 框架这个项目选 QT 作为宿主核心原因是 QT 把网络和串口都做成了官方模块不需要再引入第三方库。TCP 和 UDP 由QTcpSocket、QUdpSocket承载串口由QSerialPort承载三者都继承自QIODevice这意味着它们的读写接口高度一致——read()、write()、readyRead()信号几乎可以互换使用。对于上位机来说这个统一抽象非常关键界面层不需要关心底层是网口还是串口只需要面向「一个能读能写的设备对象」编程。另一个现实原因是跨平台。QT 的QSerialPort在 Windows、Linux、macOS 上都能用编译出来的程序换个平台重新构建即可不用为每个系统单独写串口枚举逻辑。常见做法是在.pro文件里显式声明依赖模块避免链接阶段报找不到符号QT core gui QT network # QTcpSocket / QUdpSocket 所在模块 QT serialport # QSerialPort / QSerialPortInfo 所在模块 greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET CommTool TEMPLATE app这里network和serialport是两个必须手动加的模块QT 默认只带 core 和 gui。如果编译时报undefined reference to QSerialPort九成是漏了QT serialport。greaterThan(QT_MAJOR_VERSION, 4)这行是为了兼容 QT4 和 QT5 的 widgets 模块差异QT5 之后 widgets 从 gui 里独立出来了。2.2 目录结构长什么样这类通讯工具项目的目录通常不会太深核心逻辑集中在少数几个文件里。一个合理的组织方式是这样CommTool/ ├── CommTool.pro # 工程文件声明 QT 模块和源文件 ├── main.cpp # 程序入口启动主窗口 ├── mainwindow.h/.cpp # 主界面连接配置、收发区、状态栏 ├── mainwindow.ui # 界面布局文件QT Designer 生成 ├── tcpclient.h/.cpp # TCP 客户端封装 ├── tcpserver.h/.cpp # TCP 服务端封装可选 ├── udpworker.h/.cpp # UDP 收发封装 ├── serialworker.h/.cpp # 串口收发封装 └── protocolparser.h/.cpp # 数据帧解析可选按协议定制把每种通讯方式拆成独立的 worker 类而不是全塞进MainWindow是这类项目能不能二次开发的分水岭。如果所有 socket 都定义在MainWindow里界面一复杂就会变成几千行的巨型类改一处牵动全身。拆成 worker 后每个类只负责「连接、断开、发数据、收数据、抛信号」界面层只做信号槽连接和显示。2.3 信号槽是这套架构的粘合剂QT 的信号槽机制在这里承担了「通讯层与界面层解耦」的角色。worker 类收到数据后不直接操作界面控件而是发一个信号由MainWindow决定怎么显示。这样 worker 类可以脱离界面单独测试也方便替换成别的显示方式。// serialworker.h class SerialWorker : public QObject { Q_OBJECT public: explicit SerialWorker(QObject *parent nullptr); bool open(const QString portName, int baudRate); // 打开串口 void close(); // 关闭串口 void send(const QByteArray data); // 发送数据 signals: void dataReceived(const QByteArray data); // 收到数据时抛出 void errorOccurred(const QString msg); // 出错时抛出 private slots: void onReadyRead(); // 绑定 readyRead 信号 private: QSerialPort *m_port; };Q_OBJECT宏是信号槽的前提漏了它编译能过但信号连不上这是新手最容易翻车的地方。dataReceived和errorOccurred两个信号把「正常数据」和「异常信息」分开界面层可以分别处理——数据进文本框错误进状态栏或日志区。onReadyRead作为私有槽绑定到QSerialPort::readyRead信号串口一有数据到达就触发。3. 三条通讯链路的实现TCP、UDP、串口各自怎么落地3.1 TCP 客户端连接、收发、断线重连TCP 是有连接的可靠传输适合需要确认送达的场景比如下发配置、读取设备参数。QTcpSocket的使用流程是「connectToHost → 等 connected 信号 → write → readyRead 收数据」。这里有个容易忽略的点connectToHost是异步的调用后不会立刻连上必须等connected信号才能发数据。// tcpclient.cpp 核心片段 void TcpClient::connectToServer(const QString host, quint16 port) { m_socket-connectToHost(host, port); // 异步发起连接 } // 在构造函数里绑定信号 connect(m_socket, QTcpSocket::connected, this, [this]() { emit statusChanged(TCP 已连接); }); connect(m_socket, QTcpSocket::readyRead, this, [this]() { QByteArray data m_socket-readAll(); // 一次性读走缓冲区 emit dataReceived(data); }); connect(m_socket, QTcpSocket::disconnected, this, [this]() { emit statusChanged(TCP 已断开); }); connect(m_socket, QOverloadQAbstractSocket::SocketError::of(QTcpSocket::error), this, [this](QAbstractSocket::SocketError) { emit errorOccurred(m_socket-errorString()); });readAll()会把当前接收缓冲区里的数据全部读走但 TCP 是流式协议一次readyRead不代表收到一个完整的数据帧。如果设备协议是定长帧或带帧头帧尾必须在dataReceived之后再做粘包处理不能假设「一次读到的就是一条完整消息」。这是 TCP 通讯里最经典的坑后面避坑章节会展开。QOverload那行是因为QTcpSocket::error在 QT5 里有重载直接取地址会有歧义用QOverload明确指定签名。QT6 里error信号改名成了errorOccurred迁移时要注意。3.2 UDP无连接广播与单播UDP 不建立连接发出去就不管了适合设备主动上报、广播发现这类场景。QUdpSocket的用法和 TCP 差别很大收数据要先用bind占住本地端口然后等readyRead发数据用writeDatagram每次都要指定目标地址和端口。// udpworker.cpp 核心片段 bool UdpWorker::start(quint16 localPort) { // 绑定本地端口ShareAddress 允许多个程序绑同一端口调试时有用 return m_socket-bind(QHostAddress::AnyIPv4, localPort, QUdpSocket::ShareAddress); } void UdpWorker::sendTo(const QByteArray data, const QString host, quint16 port) { // 每次发送都带目标地址这是 UDP 和 TCP 最大的区别 m_socket-writeDatagram(data, QHostAddress(host), port); } void UdpWorker::onReadyRead() { while (m_socket-hasPendingDatagrams()) { QByteArray buf; buf.resize(int(m_socket-pendingDatagramSize())); QHostAddress sender; quint16 senderPort 0; // 读一条报文同时拿到发送方地址和端口 m_socket-readDatagram(buf.data(), buf.size(), sender, senderPort); emit datagramReceived(buf, sender.toString(), senderPort); } }hasPendingDatagrams配合while循环是必须的因为一次readyRead可能对应多条报文只读一条会丢数据。pendingDatagramSize返回下一条报文的字节数用它来 resize 缓冲区避免定长缓冲区截断长报文。readDatagram的第三、四个参数是输出参数把发送方地址和端口带回来——UDP 场景下这个信息很重要因为同一个 socket 可能收到多个设备的数据得靠来源地址区分。ShareAddress这个标志值得单独说默认情况下一个端口只能被一个 socket 绑定加了ShareAddress后多个进程可以绑同一端口。调试时经常需要同时开官方工具和自己的程序抓同一份广播这个标志能省不少事。3.3 串口枚举、打开、参数配置串口是三种方式里参数最多的波特率、数据位、停止位、校验位、流控任何一项和设备对不上都收不到正确数据。QSerialPortInfo用来枚举当前系统上可用的串口QSerialPort用来实际操作。// serialworker.cpp 核心片段 bool SerialWorker::open(const QString portName, int baudRate) { m_port-setPortName(portName); m_port-setBaudRate(baudRate); // 常用 9600/115200 m_port-setDataBits(QSerialPort::Data8); // 数据位 8 m_port-setStopBits(QSerialPort::OneStop); // 停止位 1 m_port-setParity(QSerialPort::NoParity); // 无校验 m_port-setFlowControl(QSerialPort::NoFlowControl); // 无流控 if (!m_port-open(QIODevice::ReadWrite)) { emit errorOccurred(m_port-errorString()); return false; } return true; } // 枚举可用串口填充下拉框 QStringList SerialWorker::availablePorts() { QStringList list; for (const QSerialPortInfo info : QSerialPortInfo::availablePorts()) { // 带上描述信息方便区分多个同型号设备 list info.portName() ( info.description() ); } return list; }setBaudRate传的是整数但界面上通常给的是下拉选项需要做一层映射。availablePorts返回的portName在 Windows 上是COM3这种在 Linux 上是ttyUSB0这种跨平台时不要硬编码。info.description()能给出设备描述插了多个同型号 USB 转串口时靠描述信息才能分清哪个是哪个。打开串口前一定要检查是否已被占用。串口是独占资源被别的程序打开后再打开会失败errorString()会返回「Permission denied」或类似信息。调试时如果打不开先确认没有别的串口工具还连着。3.4 三种方式统一到一个界面界面层要做的是把三个 worker 的信号都接到同一套显示逻辑上同时根据当前选中的通讯方式启用/禁用对应的配置控件。常见做法是用QStackedWidget或QGroupBox分组切换时只显示当前方式的参数区。// mainwindow.cpp 里统一接收数据 connect(m_serial, SerialWorker::dataReceived, this, MainWindow::appendData); connect(m_tcp, TcpClient::dataReceived, this, MainWindow::appendData); connect(m_udp, UdpWorker::datagramReceived, this, [this](const QByteArray d, const QString ip, quint16 port) { // UDP 多带来源信息拼进显示内容 appendData(QString([%1:%2] ).arg(ip).arg(port).toUtf8() d); });三个信号最终都汇到appendData显示格式统一。UDP 因为要标注来源单独用 lambda 包了一层。这种「worker 抛信号、界面统一消费」的结构后续要加 MQTT 或 WebSocket只需要再写一个 worker 类界面改动很小。4. 避坑与排查通讯调试里最容易翻车的几个点4.1 现象TCP 收到的数据总是「粘」在一起或断成两截原因TCP 是字节流协议没有消息边界。发送方调两次write接收方可能一次readAll全读到也可能分两次读到。这不是 bug是 TCP 的固有特性。解决在应用层定义帧格式。常见做法是「帧头 长度 数据 校验」接收端维护一个缓冲区每次readyRead把新数据追加进去然后循环检查缓冲区里是否有完整帧有就取出处理没有就等下次。不要试图用readAll的次数来判断消息条数。4.2 现象串口能打开但收不到任何数据原因参数不匹配。波特率、数据位、停止位、校验位任意一项和设备不一致收到的就是乱码或干脆没反应。另一个可能是 RX/TX 接反了。解决先确认设备手册上的串口参数逐项核对。如果参数确定无误还是没数据用示波器或逻辑分析仪看 TX 线上有没有波形。软件层面可以在readyRead里打印实际读到的字节数确认是「没触发信号」还是「触发了但读到空」。4.3 现象UDP 广播收不到单播正常原因绑定时用了QHostAddress::LocalHost或某个具体网卡地址导致广播包进不来。广播包的目标地址是广播地址绑定到具体地址的 socket 收不到。解决绑定用QHostAddress::AnyIPv4让 socket 监听所有网卡。如果机器有多张网卡还要确认广播是从哪张网卡出去的必要时用setMulticastInterface或指定发送网卡。4.4 现象程序退出时崩溃或报「QSocketNotifier: Socket notifiers cannot be enabled from another thread」原因socket 对象在 A 线程创建却在 B 线程里读写。QT 的网络对象有线程亲和性跨线程操作会出问题。解决要么把所有通讯操作都放在主线程数据量不大时够用要么把 worker 类moveToThread到独立线程并且所有对 socket 的调用都通过信号槽跨线程投递不要直接调方法。串口和 UDP 同理。4.5 现象编译报错找不到 QSerialPort原因.pro里漏了QT serialport或者 QT 安装时没勾选 serialport 模块。解决先检查.pro确认模块声明齐全。如果声明了还报错打开 QT 维护工具确认 serialport 组件已安装。Linux 下还可能需要把当前用户加入dialout组才能访问串口设备。5. 二次开发把通讯模块抠出来复用的几个技巧拿到这份源码后最有价值的二次开发方向不是改界面而是把 worker 类抽成独立模块接到自己的项目里。我一般会做三件事。第一件是给 worker 类加一个统一的抽象接口。三种通讯方式虽然底层不同但对外都可以归纳成「打开、关闭、发送、收到数据、报错」五个动作。定义一个基类让三个 worker 都继承它界面层就可以面向基类编程切换通讯方式时不用改调用代码class ICommWorker : public QObject { Q_OBJECT public: virtual bool open(const QVariantMap config) 0; // 配置用 map 传灵活 virtual void close() 0; virtual void send(const QByteArray data) 0; signals: void dataReceived(const QByteArray data); void errorOccurred(const QString msg); void statusChanged(const QString status); };QVariantMap作为配置载体TCP 传 host/port串口传 portName/baudRateUDP 传 localPort各 worker 自己解析自己需要的字段。这样加新通讯方式时界面层的配置区只需要多一组控件逻辑代码几乎不动。第二件是加一个环形缓冲或日志落盘。调试现场经常需要「跑一晚上看设备有没有异常上报」界面文本框存不下那么多数据。我通常会在appendData里加一个判断超过 N 行就删最旧的同时把原始数据按时间戳写到文件。写文件要注意别在主线程做用QFile配合定时 flush或者干脆丢给独立线程。第三件是给发送区加「定时发送」和「十六进制发送」。这两个功能在设备联调时使用频率极高。定时发送用QTimer间隔可配十六进制发送要把界面上的AA 55 01这种字符串转成QByteArray注意处理空格和非法字符QByteArray MainWindow::hexStringToBytes(const QString hex) { QByteArray out; QString clean hex; clean.remove(QRegularExpression([^0-9A-Fa-f])); // 去掉空格和分隔符 if (clean.size() % 2 ! 0) return out; // 奇数个字符不合法 for (int i 0; i clean.size(); i 2) { bool ok false; out.append(char(clean.mid(i, 2).toUInt(ok, 16))); if (!ok) return QByteArray(); // 有非法字符就整体放弃 } return out; }这个函数看着简单但边界条件不少奇数长度、非法字符、空串都要处理。我见过有人直接hex.toLatin1()就发出去结果设备收到的是 ASCII 字符A而不是字节0xAA排查半天。验证改造是否成功我习惯用一个笨办法拿一个 USB 转串口模块把 TX 和 RX 短接自己发自己收。串口能回环成功说明打开、发送、接收、显示这条链路是通的。TCP 和 UDP 则用nc或另一台机器上的调试工具对发。这套自测流程走一遍比直接接设备调试省时间——设备那边变量太多分不清是工具的问题还是设备的问题。从那以后我每次改完通讯模块都强制走一遍「回环自测 十六进制收发 定时发送」这三步确认基础链路没问题再接真实设备。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑