3. UDP通信流程:服务端与客户端

聊完了UDP协议本身,咱们得动手写代码了。这一章我带你走一遍UDP通信的完整流程——说白了,就是服务端和客户端各自该干哪些事。

我记得刚学Socket那会儿,总觉得流程很绕。后来发现,其实就几个步骤,跟打电话差不多。你想想看,打电话是不是也得先有设备、拨号、说话、挂断?UDP的Socket通信也是这个道理。

核心要点:UDP通信流程分为服务端和客户端两条线。服务端先启动,绑定端口,然后收/发数据;客户端随时可以发数据给服务端。两者都通过Socket这个“管道”操作。

UDP通信流程概览 UDP通信流程概览 服务端流程 ① 创建Socket ② 绑定端口(bind) ③ 接收数据(recvfrom) ④ 发送数据(sendto) ⑤ 关闭Socket(close) 客户端流程 ① 创建Socket ② 发送数据(sendto) ③ 接收数据(recvfrom) ④ 关闭Socket(close) 数据报文 数据报文 服务端必须先启动并绑定端口,客户端才能找到它

3.1 服务端流程详解

服务端是UDP通信中的“守门员”。它得先上场,占好位置,然后等着对方把球踢过来。我个人习惯把服务端流程拆成五步,每一步都有它的道理。

3.1.1 创建Socket

第一步,调用 socket() 函数。这相当于你从运营商那里申请了一部电话机。在Linux下,代码长这样:

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(1);
}

AF_INET 表示IPv4,SOCK_DGRAM 就是UDP。第三个参数传0,系统会自动选UDP协议。嗯,这里要注意:返回值是文件描述符,后面所有操作都靠它。

3.1.2 绑定端口

有了Socket,你得告诉操作系统:“我要用这个端口”。这就是 bind() 的作用。我在项目中遇到过有人忘了绑定端口,结果客户端死活连不上——因为系统随机分配了一个端口,客户端根本不知道往哪发。

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
server_addr.sin_addr.s_addr = INADDR_ANY;

if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
    perror("bind failed");
    close(sockfd);
    exit(1);
}

htons() 把端口号转成网络字节序,INADDR_ANY 表示监听所有网卡。绑定成功后,这个端口就是服务端的“门牌号”了。

小技巧:端口号建议选1024以上的,避免跟系统服务冲突。我一般用8000-9000之间的端口,不容易撞车。

3.1.3 接收数据

绑定完端口,服务端就进入“等待模式”。用 recvfrom() 接收数据,它会阻塞直到有数据到达。你想想看,这就像你坐在电话旁等铃响。

char buffer[1024];
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);

int n = recvfrom(sockfd, buffer, sizeof(buffer), 0,
                 (struct sockaddr*)&client_addr, &addr_len);
if (n < 0) {
    perror("recvfrom error");
} else {
    buffer[n] = '\0';
    printf("收到来自 %s:%d 的消息: %s\n",
           inet_ntoa(client_addr.sin_addr),
           ntohs(client_addr.sin_port),
           buffer);
}

recvfrom() 不光能拿到数据,还能知道是谁发的——客户端的IP和端口都保存在 client_addr 里。这一点很重要,因为后面回复数据时要用到。

3.1.4 发送数据

收到数据后,服务端通常要回复。用 sendto() 把数据发回去。目标地址就是刚才收到的 client_addr。

const char *reply = "消息已收到";
int sent = sendto(sockfd, reply, strlen(reply), 0,
                  (struct sockaddr*)&client_addr, addr_len);
if (sent < 0) {
    perror("sendto error");
}

这里有个坑:sendto() 的最后一个参数是地址长度,必须跟 recvfrom() 返回的一致。我曾经传错了长度,结果数据发不出去,排查了半天才发现是这里的问题。

3.1.5 关闭Socket

通信结束,记得关掉Socket。用 close() 释放资源。

close(sockfd);

这一步看似简单,但忘了做的话,端口会一直被占用。下次再启动服务端时,bind() 就会报错“Address already in use”。

避坑指南:我曾经在开发机上跑服务端,Ctrl+C 终止程序后没关Socket,结果端口被占用了好几分钟。后来我习惯在程序里加 setsockopt() 设置 SO_REUSEADDR,这样端口能立即重用。

3.2 客户端流程详解

客户端比服务端简单。它不需要绑定端口,直接发数据就行。说白了,客户端就是个“主动方”,想发就发。

3.2.1 创建Socket

跟服务端一样,先创建Socket。代码几乎一模一样:

int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
    perror("socket creation failed");
    exit(1);
}

注意,客户端不需要 bind()。系统会自动给客户端分配一个临时端口,你只管发数据就行。

3.2.2 发送数据

客户端用 sendto() 把数据发给服务端。这里需要指定服务端的地址和端口。

struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8888);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

const char *msg = "Hello, UDP Server!";
int sent = sendto(sockfd, msg, strlen(msg), 0,
                  (struct sockaddr*)&server_addr, sizeof(server_addr));
if (sent < 0) {
    perror("sendto failed");
}

你想想看,这里客户端不需要先建立连接,直接就把数据扔出去了。UDP就是这么“任性”。

3.2.3 接收数据

如果服务端回复了,客户端可以用 recvfrom() 接收。这一步是可选的——有些场景下客户端只管发,不管收。

char buffer[1024];
struct sockaddr_in from_addr;
socklen_t from_len = sizeof(from_addr);

int n = recvfrom(sockfd, buffer, sizeof(buffer), 0,
                 (struct sockaddr*)&from_addr, &from_len);
if (n > 0) {
    buffer[n] = '\0';
    printf("服务端回复: %s\n", buffer);
}

嗯,这里要注意:recvfrom() 会阻塞,如果服务端没回复,程序会一直等。我建议用非阻塞模式或者设置超时,避免程序卡死。

3.2.4 关闭Socket

跟服务端一样,用 close() 收尾。

close(sockfd);

3.3 服务端 vs 客户端:流程对比

为了让你看得更清楚,我把两者的流程放在一起对比一下:

步骤 服务端 客户端
1 创建Socket 创建Socket
2 绑定端口(必须) 无需绑定(系统自动分配)
3 接收数据(recvfrom) 发送数据(sendto)
4 发送数据(sendto) 接收数据(recvfrom,可选)
5 关闭Socket 关闭Socket

看出来了吗?服务端多了一个 bind() 步骤,而且通常是先收后发。客户端则是先发后收,甚至可以不收。

3.4 完整示例:一个简单的UDP回声服务

光说不练假把式。我给你写一个完整的回声服务——客户端发什么,服务端就原样返回。这是我在项目中调试网络时最常用的“Hello World”程序。

服务端代码(echo_server.c):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) { perror("socket"); exit(1); }

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);
    server_addr.sin_addr.s_addr = INADDR_ANY;

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

    printf("UDP Echo Server 启动,监听端口 8888...\n");

    char buffer[1024];
    struct sockaddr_in client_addr;
    socklen_t addr_len = sizeof(client_addr);

    while (1) {
        int n = recvfrom(sockfd, buffer, sizeof(buffer), 0,
                         (struct sockaddr*)&client_addr, &addr_len);
        if (n < 0) { perror("recvfrom"); continue; }
        buffer[n] = '\0';
        printf("收到: %s\n", buffer);

        sendto(sockfd, buffer, n, 0,
               (struct sockaddr*)&client_addr, addr_len);
    }

    close(sockfd);
    return 0;
}

客户端代码(echo_client.c):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) { perror("socket"); exit(1); }

    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(8888);
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");

    char buffer[1024];
    while (1) {
        printf("请输入消息: ");
        fgets(buffer, sizeof(buffer), stdin);
        buffer[strcspn(buffer, "\n")] = '\0';

        sendto(sockfd, buffer, strlen(buffer), 0,
               (struct sockaddr*)&server_addr, sizeof(server_addr));

        int n = recvfrom(sockfd, buffer, sizeof(buffer), 0, NULL, NULL);
        if (n > 0) {
            buffer[n] = '\0';
            printf("回声: %s\n", buffer);
        }
    }

    close(sockfd);
    return 0;
}

运行方法:先在一个终端里编译运行服务端,再开另一个终端运行客户端。客户端输入什么,服务端就返回什么。试试看,你会对UDP通信有更直观的感受。

3.5 常见问题与避坑

最后,我总结几个新手容易踩的坑,都是我亲身经历过的:

  • 端口被占用:服务端退出后,端口不会立即释放。可以用 setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) 解决。
  • 数据丢失:UDP不保证送达。如果客户端发了数据但服务端没收到,检查防火墙和网络连通性。
  • 缓冲区溢出:recvfrom() 的缓冲区要足够大。我一般用4096字节,UDP数据报最大65507字节,但实际应用中很少超过1500字节。
  • 地址结构清零:每次使用 struct sockaddr_in 前,记得用 memset() 清零。否则残留数据可能导致绑定失败。