【C++/Qt】Qt 实现 UDP 测试工具:客户端发送、服务器监听与消息收发
本文基于 Qt 的 QUdpSocket 介绍 UDP 客户端与服务器模块的搭建思路,重点讲解端口绑定、数据发送、数据接收、参数校验、错误处理和日志输出等关键流程。
在网络调试工具中,TCP、HTTP、WebSocket、MQTT 这些模块都有比较明显的“连接”概念,而 UDP 模块和它们不太一样。UDP 是无连接通信,不需要先建立连接,只要知道目标 IP 和目标端口,就可以直接发送数据。
本文结合我的 Qt 网络调试工具项目中的 UDP 模块进行整理,项目中 UDP 被拆成两个页面:
FormUDPClient // UDP 客户端模块,主要负责向指定 IP 和端口发送数据 FormUDPServer // UDP 服务器模块,主要负责绑定本地端口、接收数据和回复客户端整体思路不是写一个孤立 Demo,而是把 UDP 放进整个网络调试助手中,和 TCP、HTTP、WebSocket 等模块保持类似的界面风格:参数输入、启动监听、发送数据、接收数据显示、日志记录、错误提示和配置保存。
一、UDP 模块整体搭建思路
在我的项目中,UDP 模块分成了客户端和服务器两个类,而不是把所有功能都堆在一个窗口里。
这样拆分的好处是比较清楚:
FormUDPClient:负责“主动发送”
FormUDPServer:负责“绑定监听 + 接收数据 + 回复客户端”
UDP 和 TCP 最大的区别是:UDP 不需要 connectToHost(),也没有 newConnection() 这种连接管理流程。UDP 的核心主要就是两个函数:
bind() // 服务器绑定本地 IP 和端口,用来接收数据
writeDatagram() // 客户端或服务器向目标地址发送 UDP 数据报
在服务器端,需要准备一个 QUdpSocket 对象,并且记录服务器是否正在运行:
// UDP 服务器套接字,负责绑定端口、接收数据和发送回复
QUdpSocket *udpServerSocket;
// 标记当前 UDP 服务器是否已经启动监听
bool serverRunning;
如果服务器收到客户端发来的数据,还需要保存客户端的地址和端口,方便后面点击“发送”按钮时可以回复给最近一次通信的客户端:
// 保存最近一次发送数据过来的客户端地址
QHostAddress lastClientAddress;
// 保存最近一次发送数据过来的客户端端口
quint16 lastClientPort;
客户端这边相对简单,主要是准备一个 QUdpSocket,然后通过 writeDatagram() 直接发消息:
// UDP 客户端套接字,负责发送数据和接收服务器回包
QUdpSocket *udpClientSocket;
所以整个 UDP 模块可以理解成下面这个流程:
UDP Server:
创建 QUdpSocket
↓
读取界面 IP 和端口
↓
InputValidator 参数校验
↓
bind() 启动监听
↓
readyRead 信号触发接收函数
↓
readDatagram() 读取客户端数据
UDP Client:
创建 QUdpSocket
↓
读取目标 IP、目标端口、发送内容
↓
InputValidator 参数校验
↓
writeDatagram() 发送数据
↓
readyRead 接收服务器回复
也就是说,UDP 模块真正的核心不是代码有多复杂,而是把“界面输入、参数校验、套接字操作、日志显示、错误处理、配置保存”这些步骤按顺序串起来。
二、UDP Server:启动监听的流程
UDP 服务器模块的入口是“启动监听”按钮。点击按钮后,程序会先从界面中读取本地 IP 和端口,然后进行参数校验,校验通过后再调用 bind()。
这个地方要注意:UDP Server 的配置应该在监听成功之后再保存。因为用户输入了 IP 和端口,不代表这个端口一定能绑定成功。比如端口被占用、IP 不合法,都会导致监听失败。
核心逻辑可以整理成下面这样:
void FormUDPServer::startListen()
{
// 1. 从界面读取本地 IP 和端口
QString localIp = ui->lineEditLocalIp->text().trimmed();
QString localPortText = ui->lineEditLocalPort->text().trimmed();
// 2. 使用项目中的 InputValidator 做参数校验
// 这里的目的不是直接 bind,而是先保证用户输入格式正确
if (!InputValidator::isValidIp(localIp)) {
ErrorHandler::showError("本地 IP 地址格式不正确");
return;
}
if (!InputValidator::isValidPort(localPortText)) {
ErrorHandler::showError("本地端口格式不正确");
return;
}
// 3. 将端口从 QString 转成 quint16
quint16 localPort = localPortText.toUShort();
// 4. 根据界面输入创建监听地址
QHostAddress address(localIp);
// 5. 调用 bind() 启动 UDP 监听
bool ok = udpServerSocket->bind(address, localPort);
if (!ok) {
// 6. 监听失败时,交给 ErrorHandler 或日志区域显示错误信息
ErrorHandler::showError("UDP 服务器启动失败:" +
udpServerSocket->errorString());
return;
}
// 7. bind 成功后,说明服务器真正启动了
serverRunning = true;
// 8. 监听成功后再保存配置,避免保存无效参数
saveConfig();
// 9. 输出运行日志
appendLog(QString("UDP 服务器启动成功,监听地址:%1:%2")
.arg(localIp)
.arg(localPort));
}
这段流程里面最关键的是:
udpServerSocket->bind(address, localPort);
这一步相当于告诉系统:
我要在这个 IP 和这个端口上接收 UDP 数据。
如果这一步成功,那么别人向这个地址和端口发送 UDP 数据时,服务器就可以收到。
这里为什么要先用 InputValidator?
因为界面输入的数据都是字符串,比如 IP、端口都是用户手动输入的,如果不校验,直接传给 bind(),程序很容易出现错误。你的项目里把参数校验单独封装出来,这是比简单 Demo 更像完整项目的地方。
启动监听的逻辑可以概括成:
读取界面参数
↓
校验 IP 和端口
↓
调用 bind()
↓
判断是否启动成功
↓
成功后保存配置并输出日志
三、UDP Server:接收客户端数据并记录来源
UDP 服务器启动监听之后,并不是一直手动循环等待数据,而是依靠 Qt 的信号槽机制。
服务器套接字收到数据时,会触发 readyRead 信号:
connect(udpServerSocket, &QUdpSocket::readyRead,
this, &FormUDPServer::readData);
这行代码的意思是:
只要 udpServerSocket 收到 UDP 数据,
就自动进入 FormUDPServer::readData() 函数处理。
在接收函数里面,通常要用 while 循环读取。因为 UDP 数据是一个个数据报,有时候可能一次收到多个数据包,不能只读一次就结束。
void FormUDPServer::readData()
{
// 只要还有没读取完的数据报,就继续读取
while (udpServerSocket->hasPendingDatagrams()) {
// 1. 根据当前数据包大小创建缓冲区
QByteArray datagram;
datagram.resize(udpServerSocket->pendingDatagramSize());
// 2. 用来保存客户端的 IP 和端口
QHostAddress clientAddress;
quint16 clientPort;
// 3. 读取 UDP 数据,同时拿到发送方地址和端口
udpServerSocket->readDatagram(datagram.data(),
datagram.size(),
&clientAddress,
&clientPort);
// 4. 保存最近一次客户端信息,方便服务器后续回复
lastClientAddress = clientAddress;
lastClientPort = clientPort;
// 5. 将收到的字节数据转成字符串显示
QString msg = QString::fromUtf8(datagram);
// 6. 在日志窗口显示收到的数据
appendLog(QString("收到客户端 [%1:%2] 的数据:%3")
.arg(clientAddress.toString())
.arg(clientPort)
.arg(msg));
}
}
这个函数里最重要的是:
readDatagram(datagram.data(), datagram.size(), &clientAddress, &clientPort);
它不只是读取内容,还能拿到发送方的 IP 和端口。
这一点对 UDP 很关键。因为 UDP 没有连接关系,不像 TCP 那样每个客户端都有一个连接对象。UDP 收到数据时,必须通过 readDatagram() 自己获取对方地址。
这里保存:
lastClientAddress = clientAddress;
lastClientPort = clientPort;
是为了让服务器知道“最近是谁给我发了消息”。这样服务器点击发送按钮时,就可以把数据发回给这个客户端。
所以服务器接收数据的思路是:
readyRead 信号触发
↓
判断是否有待处理数据
↓
readDatagram() 读取数据
↓
记录客户端 IP 和端口
↓
显示到日志区
这个设计比较适合网络调试工具,因为日志里面能清楚看到数据来源,比如:
收到客户端 [192.168.1.10:50523] 的数据:hello udp
这样调试的时候就知道是哪台机器、哪个端口发来的数据。
四、UDP Client:发送数据与接收服务器回复
UDP 客户端模块的核心是发送数据。
客户端和 TCP Client 不一样,它不需要先连接服务器。点击发送按钮时,程序只需要读取目标 IP、目标端口和发送内容,然后调用 writeDatagram()。
在项目里,UDP Client 可以采用“懒创建”的方式:也就是第一次发送数据时,如果 udpClientSocket 还没有创建,就先创建它,并连接 readyRead 信号,方便后续接收服务器回复。
void FormUDPClient::sendData()
{
// 1. 如果客户端 socket 还没有创建,就先创建
if (udpClientSocket == nullptr) {
udpClientSocket = new QUdpSocket(this);
// 收到服务器回复时,进入 readData() 处理
connect(udpClientSocket, &QUdpSocket::readyRead,
this, &FormUDPClient::readData);
}
// 2. 从界面读取目标 IP、目标端口和发送内容
QString targetIp = ui->lineEditTargetIp->text().trimmed();
QString targetPortText = ui->lineEditTargetPort->text().trimmed();
QString sendText = ui->textEditSend->toPlainText();
// 3. 参数校验:IP、端口、内容都不能为空或格式错误
if (!InputValidator::isValidIp(targetIp)) {
ErrorHandler::showError("目标 IP 地址格式不正确");
return;
}
if (!InputValidator::isValidPort(targetPortText)) {
ErrorHandler::showError("目标端口格式不正确");
return;
}
if (sendText.isEmpty()) {
ErrorHandler::showError("发送内容不能为空");
return;
}
// 4. 转换端口和发送内容
quint16 targetPort = targetPortText.toUShort();
QByteArray data = sendText.toUtf8();
// 5. 发送 UDP 数据报
qint64 len = udpClientSocket->writeDatagram(data,
QHostAddress(targetIp),
targetPort);
// 6. 判断是否发送成功
if (len == -1) {
ErrorHandler::showError("UDP 数据发送失败:" +
udpClientSocket->errorString());
return;
}
// 7. 发送成功后保存配置并写日志
saveConfig();
appendLog(QString("发送到 [%1:%2]:%3")
.arg(targetIp)
.arg(targetPort)
.arg(sendText));
}
这段代码的核心是:
udpClientSocket->writeDatagram(data,
QHostAddress(targetIp),
targetPort);
它的含义很直接:
把 data 这段数据发送到 targetIp 的 targetPort 端口。
UDP 的发送逻辑比 TCP 简单很多,因为它不用维护连接状态。但是这里也有一个容易误解的点:
writeDatagram() 返回成功,只能说明数据从本机发出去了,
不能保证对方一定收到了。
这是 UDP 协议本身的特点。它更像“把消息发出去”,而不是像 TCP 那样先建立连接、再保证可靠传输。
如果服务器收到消息并回复,客户端同样通过 readyRead 接收:
void FormUDPClient::readData()
{
while (udpClientSocket->hasPendingDatagrams()) {
QByteArray datagram;
datagram.resize(udpClientSocket->pendingDatagramSize());
QHostAddress serverAddress;
quint16 serverPort;
// 读取服务器返回的数据
udpClientSocket->readDatagram(datagram.data(),
datagram.size(),
&serverAddress,
&serverPort);
QString msg = QString::fromUtf8(datagram);
appendLog(QString("收到服务器 [%1:%2] 回复:%3")
.arg(serverAddress.toString())
.arg(serverPort)
.arg(msg));
}
}
客户端的整体流程就是:
点击发送
↓
创建 QUdpSocket
↓
读取目标 IP、端口、内容
↓
参数校验
↓
writeDatagram() 发送
↓
readyRead 接收服务器回复
这样 UDP Client 和 UDP Server 就可以互相测试:客户端向服务器发送消息,服务器收到后显示来源,也可以再回复客户端。
五、配置保存、日志输出和错误处理
一个网络调试工具如果只是能发数据,其实还不够。真正用起来比较舒服的地方在于:配置能保存、错误能提示、通信过程能看见。
所以UDP 模块里除了 QUdpSocket,还结合了几个项目中通用的辅助模块:
InputValidator:负责校验 IP、端口、发送内容等参数
ErrorHandler:负责统一处理错误提示
QSettings:负责保存上一次使用的配置
日志窗口:负责显示启动、发送、接收、失败等通信状态
日志函数可以封装成统一形式:
void FormUDPServer::appendLog(const QString &msg)
{
// 获取当前时间,方便定位通信发生的时间点
QString time = QDateTime::currentDateTime()
.toString("yyyy-MM-dd hh:mm:ss");
// 追加到日志显示区域
ui->textEditLog->append(QString("[%1] %2").arg(time, msg));
}
这样不管是服务器启动成功、绑定失败、收到数据,还是客户端发送失败,都可以统一显示。
例如:
[2026-05-17 10:30:12] UDP 服务器启动成功,监听地址:127.0.0.1:8888
[2026-05-17 10:30:20] 收到客户端 [127.0.0.1:50523] 的数据:hello
[2026-05-17 10:30:25] 发送到 [127.0.0.1:8888]:test udp
配置保存建议按照“操作成功后保存”的思路处理。
UDP Server 这里,应该是:
bind() 成功
↓
serverRunning = true
↓
saveConfig()
因为只有绑定成功,才能说明这个 IP 和端口是有效的。
UDP Client 这里,应该是:
writeDatagram() 发送成功
↓
saveConfig()
因为发送成功后,说明这次目标 IP、目标端口和发送内容至少完成了一次有效提交。
关闭服务器监听时,可以这样处理:
void FormUDPServer::stopListen()
{
// 如果服务器没有运行,就不需要重复关闭
if (!serverRunning) {
appendLog("UDP 服务器当前未启动");
return;
}
// 关闭 UDP socket,释放端口
udpServerSocket->close();
// 修改运行状态
serverRunning = false;
appendLog("UDP 服务器已关闭监听");
}
这一部分虽然代码不复杂,但是很重要。因为网络调试工具经常会遇到端口占用、参数输入错误、目标主机不可达、重复启动监听等问题。如果没有错误处理和日志输出,用户只会感觉“没反应”。
所以 UDP 模块最终可以总结成一句话:
UDP 通信本身只需要 bind()、readDatagram()、writeDatagram(),
但作为一个完整的 Qt 网络调试工具,还需要参数校验、错误提示、日志输出和配置保存。
整个模块的搭建流程可以总结为:
FormUDPServer:
初始化 QUdpSocket
↓
启动监听时校验 IP 和端口
↓
bind() 成功后保存配置
↓
readyRead 触发 readDatagram()
↓
记录客户端地址和端口
↓
日志显示接收内容
FormUDPClient:
懒创建 QUdpSocket
↓
发送前校验目标 IP、端口、内容
↓
writeDatagram() 发送数据
↓
发送成功后保存配置
↓
readyRead 接收服务器回复
↓
日志显示通信过程
通过这个 UDP 模块,整个 NDATools 网络调试工具就不只是支持 TCP 这种面向连接的通信,也能支持 UDP 这种无连接的数据报通信。TCP 更适合强调连接管理和稳定传输,UDP 更适合轻量级、低延迟、广播或局域网消息测试。把 UDP Client 和 UDP Server 分开实现,也让项目结构更加清楚,后面继续扩展广播、Hex 收发、自动发送测试等功能时会更方便。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)