UDP协议详解

一、UDP协议到底是什么?

咱们得先明确UDP的“性格”——它是TCP/IP协议族里“轻量级”的存在,核心特点就两个:无连接面向数据报

什么是无连接?比如你给朋友发微信消息,不用先打电话确认“你在不在”,直接发就行;UDP也是如此,客户端和服务器通信前,不用像TCP那样三次握手建立连接,直接发数据报就好。这就导致UDP的开销很小,速度快,但也有缺点:数据报可能丢包、乱序,服务器也不会确认“收到了”——不过对于实时性要求高的场景(比如语音通话、视频弹幕、设备心跳包),这些缺点可以接受,毕竟快比绝对可靠更重要。

那面向数据报又是什么意思?你可以把数据报理解成“一个完整的包裹”,每个包裹里都有完整的目标地址(IP+端口),服务器收到后要么完整处理,要么直接丢弃,不会像TCP那样把数据拆成片段再拼接。比如你发“Hello UDP”,服务器收到的就是完整的这串字符串,不会少一个字母。

搞懂这两个核心特性,后面的开发逻辑就顺理成章了——因为UDP无连接,所以服务器不用“等连接”,绑定地址后直接收发数据;因为面向数据报,所以收发要用专门的函数(recvfrom/sendto),而不是read/write。

二、UDP服务器编码

UDP服务器的开发流程其实特别固定,本质就两步:创建套接字绑定地址。但别小看这两步,尤其是“绑定”,里面藏着很多实战中会踩的坑。

2.1 第一步:创建套接字

套接字(Socket)是操作系统提供的网络通信接口,你可以把它理解成“网线的接口”——没有它,程序就没法和网络交互。创建UDP套接字的代码,不管是Linux还是Windows,核心参数都一样:

// Linux下创建UDP套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
// Windows下类似,只是类型和初始化不同,后面会讲跨平台

这里要解释三个参数:

  • AF_INET:表示用IPv4协议,这是目前最常用的,如果你用IPv6,就改成AF_INET6
  • SOCK_DGRAM:表示用UDP协议(数据报协议),如果是TCP,就改成SOCK_STREAM
  • 第三个参数填0:表示让系统自动选择对应的协议(UDP只有一种,所以填0就行)。

创建后要检查sockfd是否小于0——如果小于0,说明创建失败(比如权限不够、系统资源不足),这时候得打印错误信息并退出。

2.2 第二步:绑定地址——告诉系统“我要收哪个IP和端口的消息”

创建好套接字后,程序还不知道“要监听哪个IP、哪个端口”,这时候就需要“绑定”(bind)操作。绑定的本质,是把用户层定义的“IP+端口”信息,传给操作系统内核,让内核把这个信息和刚才创建的套接字关联起来——以后内核收到发往这个IP和端口的数据报,就会交给这个套接字对应的程序。

2.2.1 先理解“地址结构体”:要传什么给内核?

绑定前,得先定义一个“地址结构体”(sockaddr_in),把IP和端口填进去。这里有个关键知识点:结构体只能整体初始化,不能整体赋值。比如:

// 正确:定义时整体初始化(C语言风格)
struct sockaddr_in local_addr = {
    .sin_family = AF_INET,          // 必须和socket的AF_INET一致
    .sin_port = htons(8080),       // 端口号,要转成网络字节序
    .sin_addr.s_addr = htonl(INADDR_ANY)  // IP地址,INADDR_ANY就是0.0.0.0
};

// 错误:定义后整体赋值,编译器会报错
local_addr = {AF_INET, htons(8080), htonl(INADDR_ANY)};

这里又涉及两个重要概念:

  • 网络字节序:不同电脑的字节序可能不一样(比如x86是小端,网络协议规定用大端),所以端口和IP必须用htons(主机到网络短整型)、htonl(主机到网络长整型)转成大端,否则不同机器通信会乱码;
  • sin_addr.s_addr:存储IP地址的字段,INADDR_ANY是系统定义的宏,对应0.0.0.0,后面会详细讲它的作用。
2.2.2 绑定的核心坑:为什么云服务器不能绑公网IP?用0.0.0.0就对了

你可能会遇到这样的问题:在云服务器上写代码,想绑定公网IP(比如120.xx.xx.xx),结果绑定失败,错误码是99(Address not available)。这不是代码错了,而是云服务器的特性——云服务器的公网IP是虚拟化的,不是主机实际的网卡IP,所以不能直接绑定

那该怎么办?用INADDR_ANY(也就是0.0.0.0)做“任意地址绑定”。它的作用是:让服务器接收发往“本机所有IP”的数据报。比如你的云服务器有两个内网IP(172.17.0.2和172.17.0.3),绑0.0.0.0后,发往这两个IP的8080端口的报文,服务器都能收到;如果只绑172.17.0.2,那发往172.17.0.3的报文就收不到了。

所以实战中,服务器的IP绑定几乎都是用0.0.0.0,除非你明确只需要监听某个特定IP。

2.2.3 绑定函数的调用:注意类型强转

绑定的函数是bind,参数要注意类型强转——因为bind的第二个参数要求是sockaddr*类型,而我们定义的是sockaddr_in*,所以需要强转:

// 绑定操作
socklen_t addr_len = sizeof(local_addr);
int ret = bind(sockfd, (struct sockaddr*)&local_addr, addr_len);
if (ret < 0) {
    perror("bind error");  // 打印错误原因
    close(sockfd);         // 失败要关闭套接字
    exit(1);
}

绑定失败的常见原因:

  • 端口被占用:比如8080已经被其他程序占用,这时候换个端口就行;
  • 绑定公网IP(云服务器):前面说过,改成0.0.0.0;
  • 用了1024以下的端口(普通用户):比如想绑80端口,需要root权限,普通用户会报Permission denied。

三、IP与端口

聊完绑定,必须单独说IP和端口的规范——这是网络编程的“交通规则”,不遵守就会出问题。

3.1 端口号:1024是道“分水岭”

端口号是16位的整数,范围是0~65535,但不是所有端口都能随便用:

  • 0~1023:系统保留端口:这些端口是预留给系统服务的,比如HTTP用80、HTTPS用443、SSH用22、MySQL用3306。普通用户(非root)不能绑定这些端口,强行绑定会报“权限不足”;
  • **102465535:普通用户可用端口**:但建议用800065535的端口,因为1024~8000之间可能有其他程序常用的端口(比如Tomcat用8080、Nginx用8080),容易冲突。

比如你想绑80端口,普通用户启动程序会报错,用sudo(Linux)或管理员权限(Windows)启动就能成功——但实战中不建议这么做,除非你开发的是HTTP这类标准服务。

另外,一个端口只能被一个进程绑定。比如你启动了一个UDP服务器绑8080,再启动另一个绑8080的服务器,第二个就会失败,错误码是98(Address already in use)。

3.2 客户端与服务器的端口差异

你可能会问:服务器必须绑定固定端口,那客户端要不要绑定?答案是:客户端必须有端口,但不用显式绑定

为什么?

  • 服务器要固定端口:因为客户端需要知道“往哪个端口发消息”。比如你用浏览器访问百度,必须知道百度的HTTP服务在80端口;如果服务器端口随机,客户端就找不到它了;
  • 客户端不用显式绑定:如果客户端显式绑端口,多个客户端(比如两个抖音APP)可能会绑同一个端口,导致后启动的客户端失败。操作系统会在客户端首次调用sendto发送数据时,自动分配一个临时端口(通常是1024以上),这样就不会冲突。

举个例子:你用手机发微信消息,微信客户端不会自己绑端口,而是系统分配一个临时端口(比如56789),消息发往微信服务器的固定端口(比如8080);服务器收到消息后,会通过客户端的临时端口把回复发回来——客户端的端口是什么,你不用关心,系统会处理。

四、UDP通信实战:收发消息与回显

绑定成功后,UDP服务器就可以开始收发消息了。因为UDP无连接,所以不用“监听”(listen)和“接受连接”(accept),直接循环收发就行。

4.1 核心函数:recvfrom与sendto

UDP收发消息用的是recvfromsendto,而不是TCP的readwrite——因为UDP需要知道“数据从哪来”和“数据往哪发”。

4.1.1 收消息:recvfrom——知道“谁发的消息”

recvfrom的作用是从套接字接收数据,并获取发送方(客户端)的地址信息:

char buf[1024] = {0};  // 接收缓冲区
struct sockaddr_in client_addr;  // 存储客户端地址
socklen_t client_addr_len = sizeof(client_addr);

// 接收消息:成功返回收到的字节数,失败返回-1
ssize_t recv_len = recvfrom(
    sockfd,          // 套接字
    buf,             // 接收缓冲区
    sizeof(buf)-1,   // 缓冲区大小(留1个字节存'\0',当字符串处理)
    0,               // 标志位,0表示阻塞(没消息就等)
    (struct sockaddr*)&client_addr,  // 输出参数:客户端地址
    &client_addr_len                  // 输入输出参数:地址长度
);

if (recv_len < 0) {
    perror("recvfrom error");
    continue;  // 收消息失败,继续等下一条
}
// 把缓冲区当成字符串处理,加'\0'
buf[recv_len] = '\0';

这里的关键是客户端地址(client_addr) ——通过它,我们能拿到客户端的IP和端口:

  • IP地址:client_addr.sin_addr.s_addr,需要用inet_ntoa转成字符串(比如从网络字节序的192.168.1.10,转成"192.168.1.10");
  • 端口号:client_addr.sin_port,需要用ntohs转成主机字节序(比如从网络字节序的56789,转成主机的56789)。
4.1.2 发消息:sendto——知道“发给谁”

收到客户端消息后,如果要回复(比如回显功能),就用sendto,把消息发回给客户端的地址:

// 回显功能:拼接前缀后发回
char echo_buf[1024] = {0};
snprintf(echo_buf, sizeof(echo_buf), "Server Echo: %s", buf);

// 发送消息:成功返回发送的字节数,失败返回-1
ssize_t send_len = sendto(
    sockfd,                  // 套接字
    echo_buf,                // 要发送的数据
    strlen(echo_buf),        // 数据长度
    0,                       // 标志位,0表示阻塞
    (struct sockaddr*)&client_addr,  // 目标地址(客户端地址)
    client_addr_len                  // 地址长度
);

if (send_len < 0) {
    perror("sendto error");
    continue;
}

这里要注意:sendto的目标地址,必须是recvfrom拿到的客户端地址——这样才能精准地把消息发回给对应的客户端。

4.2 客户端实现:比服务器更简单

客户端的逻辑比服务器简单,因为不用绑定地址——创建套接字后,直接发消息给服务器就行。

4.2.1 客户端核心流程
  1. 创建UDP套接字(和服务器一样);
  2. 定义服务器的地址结构体(填服务器的IP和端口);
  3. 循环读取用户输入,用sendto发给服务器;
  4. recvfrom接收服务器的回复,打印出来。

代码示例(Linux下):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main(int argc, char* argv[]) {
    // 检查命令行参数:需要传入服务器IP和端口
    if (argc != 3) {
        printf("Usage: %s <server_ip> <server_port>\n", argv[0]);
        exit(1);
    }
    const char* server_ip = argv[1];
    uint16_t server_port = atoi(argv[2]);

    // 1. 创建UDP套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("socket error");
        exit(1);
    }

    // 2. 定义服务器地址
    struct sockaddr_in server_addr = {0};
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(server_port);
    // 把字符串IP转成网络字节序
    if (inet_pton(AF_INET, server_ip, &server_addr.sin_addr) <= 0) {
        perror("invalid server ip");
        close(sockfd);
        exit(1);
    }
    socklen_t server_addr_len = sizeof(server_addr);

    // 3. 循环收发消息
    char buf[1024] = {0};
    while (1) {
        // 读取用户输入
        printf("Please enter message: ");
        fgets(buf, sizeof(buf)-1, stdin);
        // 去掉fgets读入的换行符
        buf[strcspn(buf, "\n")] = '\0';

        // 发送消息给服务器
        sendto(sockfd, buf, strlen(buf), 0, 
               (struct sockaddr*)&server_addr, server_addr_len);

        // 接收服务器回复
        char recv_buf[1024] = {0};
        ssize_t recv_len = recvfrom(sockfd, recv_buf, sizeof(recv_buf)-1, 0,
                                    NULL, NULL);  // 客户端不用关心回复的地址,填NULL
        if (recv_len < 0) {
            perror("recvfrom error");
            continue;
        }
        recv_buf[recv_len] = '\0';
        printf("Server reply: %s\n", recv_buf);
    }

    // 4. 关闭套接字(循环不会退出,实际中需要信号处理)
    close(sockfd);
    return 0;
}
4.2.2 客户端的命令行参数:怎么知道服务器地址?

客户端启动时,必须知道服务器的IP和端口——所以我们通过命令行参数传入,比如:

./udp_client 120.xx.xx.xx 8080

如果没传参数,就打印使用说明(Usage: ...),这是实战中常用的设计——总不能把服务器IP硬编码在客户端里,不然服务器IP变了,客户端就得重新编译。

五、代码解耦:让UDP服务器更灵活

前面写的回显服务器,消息处理逻辑(拼接“Server Echo: ”)和网络代码(recvfrom/sendto)混在一起了。如果想把处理逻辑改成“把消息转成大写”,就得改服务器的核心代码——这很麻烦,也容易出错。

这时候就需要解耦:把网络通信和业务逻辑分开,让服务器只负责“收发消息”,业务逻辑交给外部函数处理。

5.1 用std::function实现解耦

C++中的std::function可以封装函数(普通函数、lambda、成员函数),我们可以用它定义一个“消息处理函数”的类型,然后让服务器接收这个函数作为参数——这样想改业务逻辑时,只需要传不同的函数就行。

5.1.1 定义消息处理函数类型
#include <functional>
#include <string>

// 消息处理函数类型:输入是客户端消息,输出是处理后的消息
using MsgHandler = std::function<std::string(const std::string&)>;
5.1.2 服务器类的改造

把服务器封装成类,在构造函数中传入MsgHandler,收到消息后调用这个函数处理:

class UdpServer {
public:
    UdpServer(uint16_t port, MsgHandler handler) 
        : port_(port), handler_(handler) {
        // 初始化:创建套接字、绑定地址
        InitServer();
    }

    // 启动服务器,循环收发消息
    void Run() {
        char buf[1024] = {0};
        struct sockaddr_in client_addr;
        socklen_t client_addr_len = sizeof(client_addr);

        while (1) {
            // 收消息
            ssize_t recv_len = recvfrom(sockfd_, buf, sizeof(buf)-1, 0,
                                       (struct sockaddr*)&client_addr, &client_addr_len);
            if (recv_len < 0) {
                perror("recvfrom error");
                continue;
            }
            buf[recv_len] = '\0';
            std::string msg(buf);

            // 调用外部传入的处理函数
            std::string reply = handler_(msg);

            // 发消息
            sendto(sockfd_, reply.c_str(), reply.size(), 0,
                   (struct sockaddr*)&client_addr, client_addr_len);
        }
    }

private:
    void InitServer() {
        // 创建套接字
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (sockfd_ < 0) {
            perror("socket error");
            exit(1);
        }

        // 绑定地址
        struct sockaddr_in local_addr = {0};
        local_addr.sin_family = AF_INET;
        local_addr.sin_port = htons(port_);
        local_addr.sin_addr.s_addr = htonl(INADDR_ANY);

        if (bind(sockfd_, (struct sockaddr*)&local_addr, sizeof(local_addr)) < 0) {
            perror("bind error");
            close(sockfd_);
            exit(1);
        }
    }

private:
    int sockfd_;
    uint16_t port_;
    MsgHandler handler_;  // 消息处理函数
};
5.1.3 使用服务器:传入不同的处理函数

比如想实现“回显”和“大写转换”两个功能,只需要写两个处理函数:

// 回显处理函数
std::string EchoHandler(const std::string& msg) {
    return "Echo: " + msg;
}

// 大写转换处理函数
std::string UpperHandler(const std::string& msg) {
    std::string res = msg;
    for (char& c : res) {
        if (c >= 'a' && c <= 'z') {
            c -= 32;
        }
    }
    return "Upper: " + res;
}

int main() {
    // 1. 启动回显服务器
    // UdpServer server(8080, EchoHandler);
    // 2. 启动大写转换服务器
    UdpServer server(8080, UpperHandler);
    server.Run();
    return 0;
}

这样一来,业务逻辑和网络代码完全分开了——想加新功能(比如过滤敏感词),只需要写新的处理函数,不用改服务器的核心代码,扩展性大大提升。

六、UDP的实际应用场景

学会了基础开发,咱们得知道UDP在实战中能做什么。下面三个场景,覆盖了大部分UDP的常用场景,也是面试中常问的。

6.1 场景一:远程命令执行(需做安全检查)

思路很简单:客户端发shell命令(比如ls -l),服务器收到后执行命令,把结果返回给客户端。但这里有个大问题:如果客户端发rm -rf /(删除服务器所有文件),后果不堪设想——所以必须做安全检查

6.1.1 安全检查:过滤危险命令

我们可以维护一个“违禁命令列表”,收到命令后遍历检查,如果包含违禁词,就拒绝执行:

#include <vector>
#include <string>

// 违禁命令列表:可以根据需求扩展
const std::vector<std::string> forbidden_cmds = {
    "rm", "shutdown", "reboot", "touch", "mkdir", "rmdir"
};

// 安全检查函数:返回true表示安全,false表示危险
bool IsCmdSafe(const std::string& cmd) {
    for (const auto& forbidden : forbidden_cmds) {
        // 检查命令是否包含违禁词
        if (cmd.find(forbidden) != std::string::npos) {
            return false;
        }
    }
    return true;
}
6.1.2 执行命令:用popen获取结果

popen是C标准库的函数,可以执行shell命令,并通过文件指针获取命令的输出结果:

std::string CmdHandler(const std::string& cmd) {
    // 1. 安全检查
    if (!IsCmdSafe(cmd)) {
        return "Error: Forbidden command!";
    }

    // 2. 执行命令,读取结果
    FILE* fp = popen(cmd.c_str(), "r");  // "r"表示读命令的输出
    if (fp == nullptr) {
        return "Error: Execute command failed!";
    }

    char buf[1024] = {0};
    std::string result;
    while (fgets(buf, sizeof(buf)-1, fp) != nullptr) {
        result += buf;
    }

    // 3. 关闭文件指针
    pclose(fp);
    return result.empty() ? "Command executed successfully (no output)" : result;
}

这样,客户端发ls -l,服务器就返回当前目录的文件列表;发rm test.txt,就返回“禁止命令”的提示——既实现了功能,又保证了安全。

6.2 场景二:跨平台通信(Windows客户端 ↔ Linux服务器)

UDP协议是跨平台的,所以Windows客户端和Linux服务器可以直接通信——关键是处理Windows和Linux的套接字差异。

6.2.1 Windows客户端的差异点

Windows的套接字需要额外初始化和清理,核心差异有三个:

  1. 头文件和库:需要包含winsock2.h,链接ws2_32.lib库;
  2. 初始化WSA:启动时要调用WSAStartup初始化 Winsock 库,退出时调用WSACleanup清理;
  3. 类型和函数:套接字类型是SOCKET(不是int),关闭套接字用closesocket(不是close),错误码用WSAGetLastError获取。

Windows客户端代码示例:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <winsock2.h>

// 链接ws2_32.lib库
#pragma comment(lib, "ws2_32.lib")

int main(int argc, char* argv[]) {
    if (argc != 3) {
        printf("Usage: %s <server_ip> <server_port>\n", argv[0]);
        return 1;
    }
    const char* server_ip = argv[1];
    uint16_t server_port = atoi(argv[2]);

    // 1. 初始化Winsock库
    WSADATA wsaData;
    if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
        printf("WSAStartup error: %d\n", WSAGetLastError());
        return 1;
    }

    // 2. 创建UDP套接字
    SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd == INVALID_SOCKET) {
        printf("socket error: %d\n", WSAGetLastError());
        WSACleanup();
        return 1;
    }

    // 3. 定义服务器地址
    struct sockaddr_in server_addr = {0};
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(server_port);
    // 字符串IP转网络字节序(Windows用inet_addr,Linux用inet_pton)
    server_addr.sin_addr.s_addr = inet_addr(server_ip);
    if (server_addr.sin_addr.s_addr == INADDR_NONE) {
        printf("invalid server ip\n");
        closesocket(sockfd);
        WSACleanup();
        return 1;
    }
    int server_addr_len = sizeof(server_addr);

    // 4. 循环收发消息
    char buf[1024] = {0};
    while (1) {
        printf("Please enter message: ");
        fgets(buf, sizeof(buf)-1, stdin);
        buf[strcspn(buf, "\n")] = '\0';

        // 发送消息
        int send_len = sendto(sockfd, buf, strlen(buf), 0,
                             (struct sockaddr*)&server_addr, server_addr_len);
        if (send_len == SOCKET_ERROR) {
            printf("sendto error: %d\n", WSAGetLastError());
            continue;
        }

        // 接收消息
        char recv_buf[1024] = {0};
        int recv_len = recvfrom(sockfd, recv_buf, sizeof(recv_buf)-1, 0,
                               NULL, NULL);
        if (recv_len == SOCKET_ERROR) {
            printf("recvfrom error: %d\n", WSAGetLastError());
            continue;
        }
        recv_buf[recv_len] = '\0';
        printf("Server reply: %s\n", recv_buf);
    }

    // 5. 清理
    closesocket(sockfd);
    WSACleanup();
    return 0;
}

编译时,Windows下用VS编译要确保链接了ws2_32.lib;Linux下用g++编译普通客户端即可——这样,Windows客户端就能和Linux服务器互通消息了。

6.3 场景三:简易聊天室(群聊功能)

聊天室的核心是“消息广播”——一个客户端发消息,服务器把消息转发给所有在线客户端。要实现这个功能,需要解决两个问题:管理在线用户多线程客户端

6.3.1 服务器:管理在线用户与广播消息
  1. 在线用户管理:用unordered_map存储在线用户,key是客户端的IP+端口(确保唯一),value是客户端的地址结构体;
  2. 消息广播:收到客户端消息后,先把客户端加入在线列表(如果不在),然后遍历所有在线用户,用sendto转发消息。

代码核心部分:

#include <unordered_map>
#include <string>
#include <arpa/inet.h>  // for inet_ntoa

// 在线用户列表:key是"IP:端口",value是客户端地址
std::unordered_map<std::string, struct sockaddr_in> online_users;

// 广播消息函数
void BroadcastMessage(int sockfd, const std::string& msg, 
                     const struct sockaddr_in& sender_addr) {
    // 1. 生成客户端的"IP:端口"标识
    char client_ip[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &sender_addr.sin_addr, client_ip, sizeof(client_ip));
    uint16_t client_port = ntohs(sender_addr.sin_port);
    std::string client_id = std::string(client_ip) + ":" + std::to_string(client_port);

    // 2. 把发送者加入在线列表(如果不在)
    if (online_users.find(client_id) == online_users.end()) {
        online_users[client_id] = sender_addr;
        printf("User %s online\n", client_id.c_str());
    }

    // 3. 拼接消息:加上发送者标识
    std::string broadcast_msg = "[" + client_id + "]: " + msg;

    // 4. 遍历在线用户,转发消息
    for (const auto& user : online_users) {
        const struct sockaddr_in& addr = user.second;
        sendto(sockfd, broadcast_msg.c_str(), broadcast_msg.size(), 0,
               (struct sockaddr*)&addr, sizeof(addr));
    }
}

// 服务器的消息处理函数
void ChatHandler(int sockfd) {
    char buf[1024] = {0};
    struct sockaddr_in client_addr;
    socklen_t client_addr_len = sizeof(client_addr);

    while (1) {
        // 收消息
        ssize_t recv_len = recvfrom(sockfd, buf, sizeof(buf)-1, 0,
                                   (struct sockaddr*)&client_addr, &client_addr_len);
        if (recv_len < 0) {
            perror("recvfrom error");
            continue;
        }
        buf[recv_len] = '\0';
        std::string msg(buf);

        // 广播消息
        BroadcastMessage(sockfd, msg, client_addr);
    }
}
6.3.2 客户端:多线程改造(边发边收)

单线程客户端有个问题:如果客户端在等用户输入(fgets阻塞),就没法接收其他客户端的消息——所以需要把客户端改成多线程,一个线程收消息,一个线程发消息。

用结构体传递线程参数(套接字和服务器地址):

#include <thread>

// 线程参数结构体
struct ThreadData {
    int sockfd;
    struct sockaddr_in server_addr;
    int server_addr_len;
};

// 收消息线程函数
void RecvThread(void* arg) {
    ThreadData* data = (ThreadData*)arg;
    char recv_buf[1024] = {0};

    while (1) {
        ssize_t recv_len = recvfrom(data->sockfd, recv_buf, sizeof(recv_buf)-1, 0,
                                   NULL, NULL);
        if (recv_len < 0) {
            perror("recvfrom error");
            continue;
        }
        recv_buf[recv_len] = '\0';
        // 打印其他客户端的消息(换行分开)
        printf("\n%s\nPlease enter message: ", recv_buf);
        fflush(stdout);  // 刷新缓冲区,确保消息及时显示
    }
}

// 发消息线程函数(主线程也可以做,这里单独放出来清晰)
void SendThread(void* arg) {
    ThreadData* data = (ThreadData*)arg;
    char buf[1024] = {0};

    while (1) {
        printf("Please enter message: ");
        fgets(buf, sizeof(buf)-1, stdin);
        buf[strcspn(buf, "\n")] = '\0';

        sendto(data->sockfd, buf, strlen(buf), 0,
               (struct sockaddr*)&data->server_addr, data->server_addr_len);
    }
}

int main(int argc, char* argv[]) {
    // 省略参数检查、创建套接字、定义服务器地址的代码...

    // 准备线程参数
    ThreadData data;
    data.sockfd = sockfd;
    data.server_addr = server_addr;
    data.server_addr_len = server_addr_len;

    // 创建收消息线程
    std::thread recv_thread(RecvThread, &data);
    recv_thread.detach();  // 分离线程,不用等它结束

    // 主线程做发消息
    SendThread(&data);

    close(sockfd);
    return 0;
}

这样,客户端就能一边收消息(其他用户发的),一边发消息(自己输入的),不会互相阻塞——这才是聊天室该有的样子。

七、细节

最后,总结几个实战中常见的问题,下次遇到就能快速解决:

7.1 云服务器通信不通:检查安全组

本地(同一台机器)客户端和服务器能通信,但跨网络(比如你用家里的电脑连云服务器)就不通——90%是因为云服务器的安全组没开放对应的端口。

云服务商(阿里云、腾讯云)为了安全,默认会关闭大部分端口,你需要手动在控制台开放端口:

  1. 登录云服务器控制台,找到“安全组”;
  2. 新增一条入站规则:端口范围填你服务器的端口(比如8080),授权对象填0.0.0.0/0(允许所有IP访问,测试用,生产环境要限制IP);
  3. 保存规则后,再试跨网络通信,通常就能通了。

7.2 客户端收不到消息:检查端口是否被分配

客户端没显式绑定端口,首次sendto后,操作系统才会分配临时端口——如果sendto前调用recvfrom,会收不到消息吗?不会,但此时客户端还没端口,服务器没法回复消息。所以客户端的逻辑应该是“先发送,再接收”,或者确保首次收发是发送。

7.3 消息乱码:检查字节序转换

如果客户端和服务器在不同字节序的机器上(比如x86和ARM),端口或IP没转成网络字节序,就会导致消息乱码或通信失败。记住:所有端口和IP在传递前,都要转成网络字节序(htons/htonl),接收后转成主机字节序(ntohs/ntohl)。

八、复习总结

下次复习时,你可以按照这个逻辑串联:

  1. UDP特性:无连接、面向数据报→决定了服务器不用建连接,收发用recvfrom/sendto;
  2. 服务器流程:创建套接字(AF_INET+SOCK_DGRAM)→绑定地址(0.0.0.0+1024以上端口)→循环收发;
  3. IP与端口:0.0.0.0绑定所有IP,1024以下端口需root,客户端不用显式绑端口;
  4. 代码实现:recvfrom拿客户端地址,sendto发消息,用std::function解耦;
  5. 应用场景:远程命令(安全检查)、跨平台通信(Windows初始化WSA)、聊天室(在线用户+多线程客户端);
  6. 实战坑点:云服务器安全组、字节序转换、客户端多线程。
Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐