在上一篇文章中,我们对计算机网络的基本概念进行了简单梳理,包括 TCP/IP 协议栈、IP 与端口号的作用等内容。现在我们基于 Linux 网络编程,使用 C/C++ 的 socket API,实现一个简单的 UDP 群聊程序,以加深对 UDP 通信模型的理解。

整体设计思路

一句话概括:

Server 负责“收消息 + 记住客户端 + 转发消息”,Client 负责“发消息 + 收广播”。

Server 的核心职责

  • socket(AF_INET, SOCK_DGRAM):创建 UDP 套接字

  • bind:占住一个众所周知的端口,等客户端来找

  • recvfrom

    • 接收数据

    • 顺带“感知”客户端的 IP 和端口

  • ip + port 作为 key,维护一个在线用户表

  • 每收到一条消息,就 sendto 给所有用户 → 群聊效果


Client 的核心职责

  • bind,交给 OS 随机分配端口

  • 一个线程:

    • 从标准输入读数据

    • sendto 发给 server

  • 一个线程:

    • recvfrom 等 server 的广播消息

UDP服务端

class Server
{
public:
    // 构造函数:保存 server IP 和端口号
    Server(std::string server_ip, uint16_t server_port)
        : server_ip_(server_ip), server_port_(server_port)
    {
    }

    // 初始化 UDP socket 并完成 bind
    void init()
    {
        // 1. 创建 UDP socket
        // AF_INET  : IPv4
        // SOCK_DGRAM : UDP(无连接、面向报文)
        sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
        if (sockfd_ < 0)
        {
            printf("socket fail!\n");
            exit(1);
        }
        printf("socket successful! socket:%d\n", sockfd_);

        // 2. 填充本地地址结构
        struct sockaddr_in local;
        bzero(&local, sizeof(local));   // 清零,防止脏数据
        local.sin_family = AF_INET;
        local.sin_port = htons(server_port_); // 主机字节序 → 网络字节序
        // 0.0.0.0 表示监听本机所有网卡
        local.sin_addr.s_addr = inet_addr(server_ip_.c_str());

        socklen_t address_len = sizeof(local);

        // 3. 绑定端口
        // UDP server 必须 bind,客户端才能找到
        if (bind(sockfd_, (struct sockaddr *)&local, address_len) < 0)
        {
            printf("bind fail!!!\n");
            exit(2);
        }
        printf("bind successful!!!\n");
    }

    // 广播函数:将某个客户端的消息发送给所有在线用户
    void broadcast(std::string info, std::string client_ip, uint16_t client_port)
    {
        // 拼接广播消息格式:[ ip : port ] message
        std::string message = "[ ";
        message += client_ip;
        message += " : ";
        message += std::to_string(client_port);
        message += " ] ";
        message += info;

        // 遍历用户表,向每一个客户端发送消息
        for (auto user : users)
        {
            sendto(sockfd_,
                   message.c_str(),
                   message.size(),
                   0,
                   (struct sockaddr *)&user.second,
                   sizeof(user.second));
        }
    }

    // server 主循环
    void run()
    {
        while (1)
        {
            char buffer[1024];   // 接收客户端数据
            char key[256];       // 用于标识客户端的 key(ip-port)

            memset(buffer, 0, sizeof(buffer));
            memset(key, 0, sizeof(key));

            struct sockaddr_in client;
            bzero(&client, sizeof client);
            socklen_t client_len = sizeof(client);

            // recvfrom:UDP 核心接口
            // 接收数据的同时获取客户端的 IP 和端口
            int n = recvfrom(sockfd_,
                             buffer,
                             sizeof(buffer) - 1,
                             0,
                             (sockaddr *)&client,
                             &client_len);

            // 提取客户端 IP 和端口
            // inet_ntoa 将网络字节序 IP 转成字符串(非线程安全,但这里是单线程)
            std::string client_ip = inet_ntoa(client.sin_addr);
            uint16_t client_port = ntohs(client.sin_port);

            // 使用 ip + port 作为客户端的唯一标识
            snprintf(key, sizeof key, "%s-%d", client_ip.c_str(), client_port);

            // 判断该客户端是否是第一次出现
            auto is_exist = users.find(key);
            if (is_exist == users.end())
            {
                // UDP 是无连接的,通过 recvfrom 动态“感知”客户端
                users.insert(std::make_pair(key, client));
                printf("new user add\n");
            }

            // recvfrom 返回的是接收到的字节数
            if (n > 0)
            {
                // UDP 不会自动补 '\0',必须手动处理
                buffer[n] = 0;

                std::string info = buffer;

                // 将该客户端的消息广播给所有在线用户
                broadcast(info, client_ip, client_port);
            }

        }
    }

    // 析构函数:关闭 socket
    ~Server()
    {
        if (sockfd_ > 0)
        {
            close(sockfd_);
        }
    }

private:
    std::string server_ip_;   // server 监听 IP
    uint16_t server_port_;    // server 监听端口
    int sockfd_;              // UDP socket 描述符

    // 在线用户表
    // key   : "ip-port"
    // value : 对应客户端的 sockaddr_in
    std::unordered_map<std::string, struct sockaddr_in> users;
};

int main()
{
    // 创建 UDP Server,监听 0.0.0.0:8080
    Server* scr = new Server("0.0.0.0", 8080);
    scr->init();
    scr->run();
}

UDP客户端

struct ThreadDate
{
    int sockfd;                 // UDP socket 描述符
    struct sockaddr_in server;  // server 地址信息
};

// 发送线程:从标准输入读取数据并发送给 server
void *send_msg(void *arg)
{
    ThreadDate *td = (ThreadDate *)arg;
    std::string message;

    while (1)
    {
        // 从终端读取一行输入
        std::getline(std::cin, message);

        // 使用 sendto 向 server 发送数据
        // UDP 是无连接的,每次发送都要指定对端地址
        sendto(td->sockfd,
               message.c_str(),
               message.size(),
               0,
               (sockaddr *)&(td->server),
               sizeof(td->server));
    }
    return nullptr;
}

// 接收线程:负责接收 server 广播回来的消息
void *recv_msg(void *arg)
{
    ThreadDate *td = (ThreadDate *)arg;
    char buffer[1024];

    while (1)
    {
        struct sockaddr_in temp;
        socklen_t len = sizeof(temp);

        // recvfrom:接收数据的同时获取发送方地址
        ssize_t s = recvfrom(td->sockfd,
                              buffer,
                              sizeof(buffer) - 1,
                              0,
                              (struct sockaddr *)&temp,
                              &len);
        if (s > 0)
        {
            // UDP 接收到的是原始字节流,需要手动补 '\0'
            buffer[s] = 0;

            // 打印接收到的消息
            std::cerr << buffer << std::endl;
        }
    }
    return nullptr;
}

int main(int argc, char *argv[])
{
    // 参数校验:需要指定 server IP 和端口
    if (argc != 3)
    {
        printf("\n\t usage : %s server_ip server_port\n", argv[0]);
        exit(1);
    }

    ThreadDate td;

    // 从命令行参数解析 server 信息
    std::string server_ip = argv[1];
    uint16_t server_port = atoi(argv[2]);

    struct sockaddr_in server;
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port);              // 主机序 → 网络序
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());

    td.server = server;

    // 创建 UDP socket
    // 客户端不需要 bind,由 OS 自动分配端口
    td.sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (td.sockfd < 0)
    {
        printf("socket fail!!!\n");
        exit(1);
    }
    printf("socket successful, sockfd : %d\n", td.sockfd);

    // 创建发送和接收两个线程
    // 一个线程负责输入并发送
    // 一个线程负责接收 server 广播消息
    pthread_t send, recv;
    pthread_create(&send, nullptr, send_msg, &td);
    pthread_create(&recv, nullptr, recv_msg, &td);

    // 等待线程结束
    pthread_join(send, nullptr);
    pthread_join(recv, nullptr);

    // 关闭 socket
    close(td.sockfd);
    return 0;
}

可以看到,这样一个简单的UDP的群聊功能就实现了。

但是其中还有许多细节需要我们注意,现在我们就来仔细深挖一下。

为什么调用socket系统调用时使用的是SOCK_DGRAM,而不是SOCK_STREAM?

  • TCP:面向连接、可靠传输、面向字节流

  • UDP:无连接、不可靠传输、面向报文

这是因为UDP是面向数据报的传输层协议,所以选用SOCK_DGRAM,当我们需要进行TCP网络编程时,就需要使用SOCK_STREAM。

为什么 server 要 bind具体的端口号,而client 却不用?

首先客户端是需要bind端口号的,只不过不需要我们显示的进行bind,可以让操作系统随机选择即可,并且一个端口号只能被一个进程bind,不能被多个进程同时bind,这是因为端口号具有唯一标识主机进程的作用,如果一个端口被多个进程bind,对端发过来的消息,我们就不知道给哪一个进程发送了。所以一个端口号只能被一个进程bind。

现在假如我们将客户端也bind一个具体的端口号的话,那么就会很容易造成冲突,比如我们现在正在悠闲的躺在床上刷着抖音,而我们使用的抖音客户端假如bind的是1234这个端口号,现在到中午了,我们要开始觅食了,这个时候我们要打开美团进行点外卖了,但是美团的客户端也bind了1234这个端口号,这个时候一旦你要打开美团这个软件,不好意思,这个时候你已经打不开了,因为你正在刷抖音的客户端已经占用了1234这个端口号,所以美团想要打开是不可以的,所以对于客户端直接bind一个具体的端口号是不现实的,我们手机上面这么多软件,总不能让所有的这些软件的公司都协商一下,一人用一个吧,所以客户端不能直接bind一个具体的端口号,只能交给我们的操作系统,让我们操作系统随机为我们分配一个端口号就可以了,只要这个端口号唯一,我们就可以与远程的服务器进行通信,从而让我们再快乐刷抖音的同时,可以打开美团点外卖了。

这就是为什么server要bind具体的端口号,而client却不需要。

inet_addr("0.0.0.0") 是什么意思?

这个其实就是表示监听我这个主机中所有的网卡,只要是发给我这个主机的,统统接收,因为我们的主机不止一个网卡,会有许多的网卡,其它的不用多说,起码有线网卡和无线网卡就有两个,现在我们大多使用的都是无线网卡,还有像虚拟机中的虚拟网卡等等都会进行监听,所有的数据只要发给我这台主机,统统照单全收。还可以使用以下的方式:

local.sin_addr.s_addr = htonl(INADDR_ANY);

0.0.0.0 = INADDR_ANY,都是表示监听所有网卡,异曲同工之妙。

recvfrom 为什么要传 sockaddr_in*

这个就很简单了,sockaddr_in保存了发给我们数据的对端的IP和端口号,这样我们就知道这个数据是谁给我们发过来的,这样我们在处理完数据之后就可以通过这个IP和端口号再将我们想要回复的消息通过sendto系统调用接口给对方返回回去。

UDP 为什么不需要 connect

这是因为UDP是无连接,不可靠的传输层协议,是不存在连接状态的维护,并且sendto / recvfrom 每次都携带目标地址,同时UDP也不需要listenaccept,这些都是TCP 专用接口,我们再接下来的TCP网络编程中会见到这些接口的详细使用的,现在不需要着急。

server绑定的端口号不要选择[0,1024],尽量选择1024以上

这是因为在操作系统中,0~1023这区间内的端口号通常已经被系统服务或标准网络协议占用。

端口号 服务
22 SSH
21 FTP
23 Telnet
25 SMTP
53 DNS
80 HTTP
443 HTTPS

所以为了避免端口冲突,选择1024以上的端口,可以尽可能的减少冲突。

到这里,我们已经完成了一个基于 UDP 的简易群聊程序,并且从系统调用、协议语义以及操作系统角度,对其关键实现细节进行了深入分析。

UDP 网络编程的核心思想并不在于“接口有多复杂”,而在于对无连接模型的正确理解

  • 没有连接状态

  • 每个数据包自带源地址

  • 通信关系由数据“自然形成”

正是这种特性,使得 UDP 非常适合实时性要求高、允许少量丢包的场景,例如聊天室、直播、游戏同步等。这就是UDP Socket 群聊模型的简单实现。

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐