Linux:基于 UDP Socket 的实战项目 --简单双向通信程序

一、项目背景

上一个项目我们实现了基于 UDP 的英译汉翻译服务器,核心是单方向的请求 - 响应 (客户端发单词,服务端回释义)。本次将实现第二个实战项目 ------简单 UDP 双向通信程序 ,实现客户端与服务端的双向实时通信 ,充分利用 UDP 的全双工特性(一个 Socket 描述符同时支持收发)

该项目同样采用 C/S 架构,基于 UDP 协议实现,核心功能是:客户端和服务端可互相发送任意文本消息,一方发送后,另一方能实时接收并打印,实现简单的 "一对一" 网络聊天

二、项目整体设计

1. 架构设计

  • 服务端:监听固定 UDP 端口,循环接收客户端的文本消息,实时打印并支持输入消息返回给客户端,实现 "双向应答"
  • 客户端 :连接服务端的 IP 和端口,通过多线程实现同时收发(一个线程负责读取用户输入并发送,一个线程负责接收服务端消息并打印),避免单线程下 "收消息阻塞发消息" 或 "发消息阻塞收消息"
  • 通信协议:UDP(无连接,全双工,实时性高,适合简单双向通信)

2. 核心设计亮点

客户端多线程设计

UDP Socket 是全双工的,但单线程下,若调用recvfrom()(阻塞)接收消息,此时用户输入的消息无法及时发送;若循环读取用户输入,又无法及时接收服务端的消息。因此客户端必须通过多线程解决该问题:

  • 发送线程 :单独负责读取用户从标准输入的文本消息,调用sendto()发送到服务端
  • 接收线程 :单独负责调用recvfrom()接收服务端的消息,实时打印到终端
  • 两个线程共享同一个 Socket 描述符,UDP 协议支持该操作,无资源竞争问题
服务端单线程循环收发

服务端采用单线程无限循环,交替执行 "接收消息" 和 "发送消息",适用于 "一对一" 通信场景:先接收客户端的消息并打印,再读取服务端的输入并发送给客户端,实现双向应答

3. 核心功能需求

  1. 服务端:
    • 绑定固定 UDP 端口,支持本地 / 远程客户端连接
    • 实时接收客户端的文本消息,打印消息和发送方的 IP + 端口
    • 支持服务端输入文本消息,发送给客户端
    • 处理网络异常(如客户端断开、发送 / 接收失败)
  2. 客户端:
    • 通过命令行指定服务端 IP 和端口
    • 多线程实现:发送线程读取用户输入,接收线程接收服务端消息
    • 实时打印服务端发送的消息
    • 支持输入指定指令(如quit)退出程序,退出时关闭 Socket 和线程
    • 处理网络异常(如服务端关闭、发送 / 接收失败)

三、核心技术点解析

1. UDP 全双工通信的本质

UDP Socket 的全双工特性 体现在:一个通过socket(AF_INET, SOCK_DGRAM, 0)创建的 Socket 描述符(fd),既可以调用sendto()发送数据,也可以调用recvfrom()接收数据,无需额外创建新的 Socket。

这是因为 UDP 是无连接的,Socket 描述符不与任何特定的客户端绑定,每次sendto()都可以指定不同的目标地址,每次recvfrom()都可以接收不同客户端的数据,且收发操作互不干扰。

2. 客户端多线程实现(C++11 线程库)

本次项目采用C++11 标准的 std::thread实现多线程,无需依赖第三方库(如 pthread),跨平台性更好。

核心要点:

  1. 发送线程和接收线程共享 Socket 描述符和服务端地址结构,通过值传递或全局变量实现(本次采用全局变量,简化代码);
  2. 线程退出:通过全局标志位 (volatile bool)控制线程循环,当用户输入quit时,设置标志位为 false,线程退出;
  3. 线程等待:主线程通过join()等待发送线程和接收线程执行完毕后,再关闭 Socket,避免资源泄漏。

3. 数据报边界处理

UDP 是面向数据报 的协议,数据报具有边界性 :服务端 / 客户端每次recvfrom()只能接收一个完整的数据报,若数据报大小超过缓冲区,超出部分会被丢弃。

因此本次项目做了两点处理:

  1. 设置足够大的接收缓冲区(如 4096 字节),满足普通文本消息的传输;
  2. 每次发送消息后,无需额外添加分隔符,recvfrom()的返回值即为实际接收的字节数,直接打印即可。

4. 网络异常处理

增加对常见网络异常的处理,提高程序的健壮性:

  1. 检查socket()/bind()的返回值,失败时打印错误信息并退出;
  2. 检查sendto()/recvfrom()的返回值,失败时打印错误信息,继续循环而非退出;
  3. 处理 IP 地址格式错误、端口号非法等命令行参数异常;
  4. 客户端处理用户输入中断(如 Ctrl+C),优雅退出线程和 Socket。

四、完整代码实现

1. 服务端代码(udp_echo_server.cpp)

实现单线程循环收发,双向通信:

复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cerrno>
#include <cstdlib>

using namespace std;

// 全局常量
const uint16_t DEFAULT_PORT = 8888;
const int BUFF_SIZE = 4096;

// 用法提示
void Usage(const string& proc) {
    cout << "Usage: " << proc << " [port]" << endl;
    cout << "Example: " << proc << " 8888" << endl;
}

int main(int argc, char* argv[]) {
    // 解析命令行参数,指定监听端口
    uint16_t port = DEFAULT_PORT;
    if (argc == 2) {
        port = atoi(argv[1]);
        if (port < 1024 || port > 65535) {
            cerr << "端口号必须在1024-65535之间" << endl;
            Usage(argv[0]);
            return 1;
        }
    } else if (argc > 2) {
        Usage(argv[0]);
        return 1;
    }

    // 1. 创建UDP Socket
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        cerr << "创建Socket失败:" << strerror(errno) << endl;
        return 2;
    }

    // 2. 绑定IP和端口(INADDR_ANY绑定所有网卡)
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(port);
    local.sin_addr.s_addr = INADDR_ANY;
    if (bind(sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
        cerr << "绑定端口" << port << "失败:" << strerror(errno) << endl;
        close(sockfd);
        return 3;
    }

    cout << "UDP通信服务端启动成功,监听端口:" << port << endl;
    cout << "等待客户端连接...(输入quit退出)" << endl;

    char recv_buff[BUFF_SIZE] = {0};
    string send_msg;
    struct sockaddr_in peer; // 存储客户端地址信息
    socklen_t peer_len = sizeof(peer);
    bool is_running = true;

    while (is_running) {
        // 3. 接收客户端消息(阻塞)
        memset(recv_buff, 0, sizeof(recv_buff));
        ssize_t recv_len = recvfrom(sockfd, recv_buff, sizeof(recv_buff)-1, 0, (struct sockaddr*)&peer, &peer_len);
        if (recv_len < 0) {
            cerr << "接收消息失败:" << strerror(errno) << ",继续等待..." << endl;
            continue;
        }

        // 获取客户端IP和端口
        string peer_ip = inet_ntoa(peer.sin_addr);
        uint16_t peer_port = ntohs(peer.sin_port);

        // 客户端退出判断
        if (string(recv_buff) == "quit") {
            cout << "客户端[" << peer_ip << ":" << peer_port << "]已退出" << endl;
            continue;
        }

        // 打印客户端消息
        cout << "[" << peer_ip << ":" << peer_port << "]:" << recv_buff << endl;

        // 4. 服务端输入消息,发送给客户端
        cout << "我:";
        getline(cin, send_msg);
        if (send_msg == "quit") {
            cout << "服务端准备退出..." << endl;
            is_running = false;
            // 发送退出指令给客户端
            sendto(sockfd, send_msg.c_str(), send_msg.size(), 0, (struct sockaddr*)&peer, peer_len);
            continue;
        }
        if (send_msg.empty()) {
            cout << "消息不能为空,重新输入..." << endl;
            continue;
        }

        // 发送消息到客户端
        ssize_t send_len = sendto(sockfd, send_msg.c_str(), send_msg.size(), 0, (struct sockaddr*)&peer, peer_len);
        if (send_len < 0) {
            cerr << "发送消息失败:" << strerror(errno) << endl;
        }
    }

    // 5. 关闭Socket
    close(sockfd);
    cout << "服务端已退出,Socket已关闭" << endl;
    return 0;
}

2. 客户端代码(udp_echo_client.cpp)

多线程实现同时收发,双向通信:

复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cerrno>
#include <cstdlib>
#include <thread>
#include <atomic>

using namespace std;

// 全局变量:多线程共享
atomic<bool> g_running(true); // 线程运行标志位,原子变量保证线程安全
int g_sockfd = -1;            // 共享的Socket描述符
struct sockaddr_in g_server;  // 共享的服务端地址结构
const int BUFF_SIZE = 4096;   // 缓冲区大小

// 用法提示
void Usage(const string& proc) {
    cout << "Usage: " << proc << " server_ip server_port" << endl;
    cout << "Example: " << proc << " 127.0.0.1 8888" << endl;
}

// 接收线程函数:循环接收服务端消息并打印
void RecvThread() {
    char recv_buff[BUFF_SIZE] = {0};
    struct sockaddr_in temp;
    socklen_t temp_len = sizeof(temp);
    while (g_running) {
        memset(recv_buff, 0, sizeof(recv_buff));
        // 接收服务端消息(阻塞)
        ssize_t recv_len = recvfrom(g_sockfd, recv_buff, sizeof(recv_buff)-1, 0, (struct sockaddr*)&temp, &temp_len);
        if (recv_len < 0) {
            if (g_running) { // 非主动退出时打印错误
                cerr << "\n接收消息失败:" << strerror(errno) << endl;
            }
            continue;
        }
        // 服务端退出判断
        if (string(recv_buff) == "quit") {
            cout << "\n服务端已退出,客户端将自动退出..." << endl;
            g_running = false;
            break;
        }
        // 打印服务端消息(换行避免覆盖输入提示)
        cout << "\n[服务端]:" << recv_buff << endl;
        cout << "我:";
        fflush(stdout); // 刷新输出缓冲区,保证提示正常显示
    }
}

// 发送线程函数:循环读取用户输入并发送到服务端
void SendThread() {
    string send_msg;
    while (g_running) {
        cout << "我:";
        getline(cin, send_msg);
        // 客户端主动退出
        if (send_msg == "quit") {
            cout << "客户端准备退出..." << endl;
            g_running = false;
            // 发送退出指令给服务端
            sendto(g_sockfd, send_msg.c_str(), send_msg.size(), 0, (struct sockaddr*)&g_server, sizeof(g_server));
            break;
        }
        if (send_msg.empty()) {
            cout << "消息不能为空,重新输入..." << endl;
            continue;
        }
        // 发送消息到服务端
        ssize_t send_len = sendto(g_sockfd, send_msg.c_str(), send_msg.size(), 0, (struct sockaddr*)&g_server, sizeof(g_server));
        if (send_len < 0) {
            cerr << "发送消息失败:" << strerror(errno) << endl;
            if (errno == ECONNREFUSED) { // 服务端关闭
                g_running = false;
                break;
            }
        }
    }
}

int main(int argc, char* argv[]) {
    // 校验命令行参数
    if (argc != 3) {
        Usage(argv[0]);
        return 1;
    }
    string server_ip = argv[1];
    uint16_t server_port = atoi(argv[2]);
    if (server_port < 1024 || server_port > 65535) {
        cerr << "端口号必须在1024-65535之间" << endl;
        return 1;
    }

    // 1. 创建UDP Socket
    g_sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (g_sockfd < 0) {
        cerr << "创建Socket失败:" << strerror(errno) << endl;
        return 2;
    }

    // 2. 填充服务端地址结构
    memset(&g_server, 0, sizeof(g_server));
    g_server.sin_family = AF_INET;
    g_server.sin_port = htons(server_port);
    g_server.sin_addr.s_addr = inet_addr(server_ip.c_str());
    if (g_server.sin_addr.s_addr == INADDR_NONE) {
        cerr << "服务端IP地址格式错误" << endl;
        close(g_sockfd);
        return 3;
    }

    cout << "UDP通信客户端启动成功,连接服务端:" << server_ip << ":" << server_port << endl;
    cout << "输入消息即可发送,输入quit退出..." << endl;

    // 3. 创建发送线程和接收线程
    thread send_thread(SendThread);
    thread recv_thread(RecvThread);

    // 4. 等待线程执行完毕
    send_thread.join();
    recv_thread.join();

    // 5. 关闭Socket
    close(g_sockfd);
    cout << "客户端已退出,Socket已关闭" << endl;
    return 0;
}

五、编译与运行

1. 编译代码

复制代码
# 编译服务端
g++ -o udp_echo_server udp_echo_server.cpp -std=c++11
# 编译客户端(多线程必须加-pthread)
g++ -o udp_echo_client udp_echo_client.cpp -std=c++11 -pthread

2. 启动服务端

复制代码
# 方式1:使用默认端口8888
./udp_echo_server
# 方式2:指定端口,如9999
./udp_echo_server 9999

服务端启动成功输出:UDP通信服务端启动成功,监听端口:8888 + 等待客户端连接...(输入quit退出)

3. 启动客户端

本地测试(同一机器):

复制代码
./udp_echo_client 127.0.0.1 8888

远程测试(服务端在阿里云服务器):

复制代码
./udp_echo_client 阿里云公网IP 8888

注意 :阿里云服务器需在防火墙中放行对应 UDP 端口(如 8888)

4. 运行效果

服务端:
复制代码
UDP通信服务端启动成功,监听端口:8888
等待客户端连接...(输入quit退出)
[127.0.0.1:41568]:你好,服务端!
我:你好,客户端!
[127.0.0.1:41568]:UDP双向通信测试
我:测试成功!
[127.0.0.1:41568]:quit
客户端[127.0.0.1:41568]已退出
我:quit
服务端准备退出...
服务端已退出,Socket已关闭
客户端:
复制代码
UDP通信客户端启动成功,连接服务端:127.0.0.1:8888
输入消息即可发送,输入quit退出...
我:你好,服务端!

[服务端]:你好,客户端!
我:UDP双向通信测试

[服务端]:测试成功!
我:quit
客户端准备退出...
客户端已退出,Socket已关闭

六、核心代码解析

1. 客户端原子变量 g_running

使用std::atomic<bool>而非普通bool作为线程运行标志位,原因是:原子变量保证多线程下的读写操作是原子的,避免数据竞争和内存可见性问题

若使用普通bool,发送线程修改g_running后,接收线程可能无法及时看到修改后的结果,导致线程无法正常退出;原子变量则可以保证修改后立即对其他线程可见。

2. 接收线程的 fflush (stdout)

接收线程打印服务端消息后,执行fflush(stdout)刷新输出缓冲区,目的是:保证 "我:" 的输入提示能正常显示在终端,避免被服务端消息覆盖

因为终端的输出是行缓冲的,若没有换行或手动刷新,输出内容可能会留在缓冲区中,无法及时显示。

3. 服务端的 peer_len 参数

recvfrom()peer_len输入输出参数 :入参是peer结构体的大小,出参是实际的客户端地址结构大小。首次调用时需初始化peer_len = sizeof(peer),后续循环可复用,无需重新初始化。

4. 退出指令的同步

客户端 / 服务端输入quit时,会先发送quit指令给对方,再设置g_running = false,保证对方能接收到退出指令,实现 "优雅退出",避免一方退出后另一方仍阻塞在接收状态。

本次项目基于 UDP Socket 实现了简单的双向通信程序:

  1. UDP Socket 的全双工特性,一个 Socket 描述符可同时支持收发操作
  2. 客户端多线程设计的核心思想,解决单线程下的阻塞问题,实现同时收发
  3. std::threadstd::atomic的使用,实现 C++11 多线程编程和线程安全
  4. UDP 面向数据报的边界处理和常见网络异常的处理方法
  5. C/S 架构下双向通信的核心流程,为后续实现群聊聊天室奠定基础

下一篇博客将实现本次项目的进阶版 ------UDP 群聊聊天室,支持多个客户端同时在线聊天,服务端实现消息广播,是 UDP Socket 编程的综合实战,敬请期待啦~~

相关推荐
用户2367829801688 小时前
Linux systemctl 服务管理命令:从 systemd 架构到实战技巧
linux
LIZHUOLONG19 小时前
linux 设备初始化
linux·运维·服务器
雪霁清寒9 小时前
麒麟V10用MobaXterm远程连接SSH偶尔卡顿的问题
linux·ssh
ylscode9 小时前
Linux CIFSwitch 内核新漏洞允许攻击者获得 root 权限
linux·运维·服务器
芯岭技术郦10 小时前
集成 2.4G 射频收发器、MCU 及丰富外设的XL2417D透传模组
单片机·嵌入式硬件
诸葛务农10 小时前
共沸脱水技术及其在光刻胶用PGMEA纯化中的应用(中)
linux·数据库·人工智能
lld95102710 小时前
(二)从验证到执行:策略实时运行全链路
linux·服务器·数据库
zlinear数据采集卡10 小时前
定时器电路深度解析:从经典555到STM32定时器,从ZLinear采集卡的工程化设计实战
stm32·单片机·嵌入式硬件·fpga开发·自动化
坤昱10 小时前
cfs调度类深入解刨——最新内核细节分析5
linux·分布式·cfs调度·eevdf调度·linux调度·linux技术·kernel最新版本内容
阿洛学长10 小时前
Kali Linux 虚拟机安装(VMware Workstation 17)
java·linux·服务器