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 编程的综合实战,敬请期待啦~~

相关推荐
DC升降压/LED驱动IC5 小时前
源芯片选型指南之 AP5193 DC-DC 宽电压 LED 降压恒流驱动器
stm32·单片机·嵌入式硬件·物联网·51单片机·proteus
蓝凌y5 小时前
51单片机之花样灯
c语言·单片机·嵌入式硬件·51单片机
UP_Continue5 小时前
Linux--UDP/TCP客户端与服务端模拟实现计算器原理
linux·tcp/ip·udp
爱喝纯牛奶的柠檬5 小时前
STM32驱动HC-SR04超声波测距模块
stm32·单片机·嵌入式硬件
FightingHg6 小时前
和claude、openclaw交互的一些杂七杂八记录
linux·运维·服务器
深念Y6 小时前
魅蓝Note5 Root + 改内核激活命名空间:让Docker跑在安卓上
android·linux·服务器·docker·容器·root·服务
李永奉6 小时前
杰理芯片SDK-更改芯片产品蓝牙名功能
单片机·嵌入式硬件·mcu·物联网·语音识别
新兴AI民工6 小时前
【Linux内核二十五】进程管理模块:CFS调度器pick_next_task_fair(一):pick_next_task_fair方法
linux·linux内核
我是一个对称矩阵6 小时前
分区安装Ubuntu系统
linux·运维·ubuntu
小捏哩6 小时前
死锁检测组件的设计
linux·网络·数据结构·c++·后端