在网络调试工具中,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 收发、自动发送测试等功能时会更方便。

0voice · GitHub

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐