第11章 UDP编程:通信流程、核心函数与回射服务器实现

聊到UDP编程,我脑子里第一个蹦出来的词就是“轻量级”。跟TCP那套复杂的握手挥手比起来,UDP简直像个直来直去的快递员——把包裹扔给你就走,签不签收它不管。很多初学者觉得UDP简单,不就是发个数据报嘛。但我在项目中踩过几次坑之后才发现,越简单的东西,越容易在细节上翻车。

11.1 UDP通信流程:无连接的“发完即走”

UDP的全称是User Datagram Protocol,用户数据报协议。它没有TCP那种“先打电话再说话”的流程。说白了,UDP就是直接往对端IP和端口上扔数据,对方收不收得到,它不关心。

通信流程大致如下:

  • 服务端:创建socket → bind绑定端口 → 循环recvfrom接收 → sendto回复
  • 客户端:创建socket → sendto发送 → recvfrom接收(可选)

你看,客户端连connect都不需要。我刚开始学的时候觉得这太爽了,少写好多代码。后来才发现,没有连接意味着没有重传、没有拥塞控制、没有保活机制。你发出去的数据报,可能半路就丢了,而你的程序还傻呵呵地以为对方收到了。

核心区别:TCP是面向连接的字节流,UDP是无连接的数据报。UDP每个sendto对应一个独立的数据报,接收方必须用足够大的缓冲区一次收完。

11.2 recvfrom()与sendto():UDP的灵魂函数

这两个函数是UDP编程的核心。我见过不少新手把参数填错,导致数据死活发不出去。咱们一个一个来看。

11.2.1 sendto():把数据扔出去

#include <sys/types.h>
#include <sys/socket.h>

ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

参数说明:

  • sockfd:socket描述符,就是那个int
  • buf:要发送的数据缓冲区
  • len:数据长度,注意不要超过UDP数据报大小限制(65507字节)
  • flags:一般填0,除非你要用MSG_DONTWAIT之类的特殊标志
  • dest_addr:目标地址结构体,里面填IP和端口
  • addrlen:地址结构体的大小

返回值是实际发送的字节数。如果返回-1,那就是出错了。嗯,这里要注意:sendto成功返回只代表数据成功交给了协议栈,不代表对端收到了。我曾经在调试一个UDP服务时,看到sendto返回了100字节,以为万事大吉,结果对端那边毛都没收到——因为中间路由器把包丢了。

11.2.2 recvfrom():蹲在端口上等数据

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

参数跟sendto基本对称:

  • sockfd:socket描述符
  • buf:接收缓冲区
  • len:缓冲区大小,建议至少65536字节
  • flags:一般填0
  • src_addr:输出参数,返回发送方的地址信息
  • addrlen:值-结果参数,调用前填sizeof,调用后变成实际地址长度

recvfrom会阻塞,直到有数据到达。如果你不想阻塞,可以用fcntl设置非阻塞模式,或者用select/poll/epoll来监控。

个人经验:我习惯在recvfrom之前把src_addr结构体清零,addrlen设为sizeof(struct sockaddr_in)。这样能避免一些奇怪的内存残留问题。别问我怎么知道的——调试了一下午才发现的。

11.3 UDP回射服务器实现:从理论到代码

回射服务器(echo server)是网络编程里的“Hello World”。客户端发什么,服务端原样返回什么。虽然简单,但能完整展示UDP通信的整个流程。

11.3.1 服务端代码

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

#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len = sizeof(client_addr);
    char buffer[BUFFER_SIZE];
    int recv_len;

    // 1. 创建socket
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    // 2. 绑定地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;  // 监听所有网卡
    server_addr.sin_port = htons(PORT);

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

    printf("UDP echo server listening on port %d...\n", PORT);

    // 3. 循环接收并回射
    while (1) {
        recv_len = recvfrom(sockfd, buffer, BUFFER_SIZE, 0,
                           (struct sockaddr *)&client_addr, &addr_len);
        if (recv_len < 0) {
            perror("recvfrom failed");
            continue;
        }

        buffer[recv_len] = '\0';  // 确保字符串结束
        printf("Received from %s:%d: %s\n",
               inet_ntoa(client_addr.sin_addr),
               ntohs(client_addr.sin_port),
               buffer);

        // 原样返回
        sendto(sockfd, buffer, recv_len, 0,
               (struct sockaddr *)&client_addr, addr_len);
    }

    close(sockfd);
    return 0;
}

11.3.2 客户端代码

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

#define SERVER_IP "127.0.0.1"
#define PORT 8888
#define BUFFER_SIZE 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr;
    socklen_t addr_len = sizeof(server_addr);
    char send_buf[BUFFER_SIZE];
    char recv_buf[BUFFER_SIZE];
    int recv_len;

    // 1. 创建socket
    sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("socket creation failed");
        exit(EXIT_FAILURE);
    }

    // 2. 配置服务器地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    inet_pton(AF_INET, SERVER_IP, &server_addr.sin_addr);

    // 3. 发送并接收回射数据
    while (1) {
        printf("Enter message: ");
        fgets(send_buf, BUFFER_SIZE, stdin);
        send_buf[strcspn(send_buf, "\n")] = '\0';  // 去掉换行符

        if (strcmp(send_buf, "exit") == 0) break;

        // 发送数据
        sendto(sockfd, send_buf, strlen(send_buf), 0,
               (struct sockaddr *)&server_addr, addr_len);

        // 接收回射数据
        recv_len = recvfrom(sockfd, recv_buf, BUFFER_SIZE, 0,
                           NULL, NULL);  // 不关心发送方地址
        if (recv_len < 0) {
            perror("recvfrom failed");
            continue;
        }

        recv_buf[recv_len] = '\0';
        printf("Echo from server: %s\n", recv_buf);
    }

    close(sockfd);
    return 0;
}

11.4 UDP通信流程图

下面这张图展示了UDP回射服务器的完整通信流程。我特意把服务端和客户端放在两侧,方便对比。

UDP回射服务器通信流程图 服务端 客户端 1. socket() 创建UDP套接字 2. bind() 绑定端口8888 3. recvfrom() 阻塞等待数据 4. sendto() 原样返回数据 5. 回到步骤3,继续等待 1. socket() 创建UDP套接字 2. sendto() 发送数据到服务端 3. recvfrom() 等待回射数据 4. 打印收到的回射数据 5. 回到步骤2,继续发送 sendto() sendto() 回射

11.5 避坑指南:UDP编程常见问题

写UDP程序,有几个坑我几乎每次都会遇到。分享出来,希望大家少走弯路。

坑一:数据报边界问题

UDP是消息边界保护的。你sendto一次,对端必须recvfrom一次。如果对端缓冲区太小,多余的数据会被丢弃。我曾经写过一个日志收集程序,客户端每次发500字节,服务端缓冲区只设了256字节——结果日志全丢了,排查了一天才发现。

坑二:字节序问题

端口号和IP地址在网络传输中要用网络字节序(大端)。我见过有人直接赋值端口号,没调用htons,结果服务端bind到端口8888,客户端却往端口0x22B8(小端解释)发数据。嗯,这种bug最难查。

坑三:UDP丢包无感知

UDP不保证可靠传输。如果你的应用场景要求数据必须到达,那就要在应用层自己实现确认和重传机制。我在做视频流传输时,丢个几帧还能接受,但如果是金融交易数据,丢一个包就是事故。

11.6 总结

UDP编程的核心就三个东西:socket、sendto、recvfrom。流程简单,但细节不少。我个人建议初学者先把回射服务器跑通,然后试着改一改——比如改成广播模式,或者加个简单的超时重传。只有亲手写过、调试过、踩过坑,才能真正理解UDP的脾气。

记住一句话:UDP是快,但快是有代价的。选择UDP,就意味着你要自己处理丢包、乱序、重复等问题。用好了,它是利器;用不好,它就是坑。


公众号:蓝海资料掘金营,微信deep3321