4、UDP数据发送与接收:sendto()与recvfrom()系统调用

好,咱们今天聊聊UDP最核心的两个操作——发数据和收数据。说白了,就是 sendto() 和 recvfrom() 这两个系统调用。你想想看,TCP那边有 connect()、listen()、accept() 一套流程,UDP这边就简单多了,两个函数搞定一切。

我个人习惯把UDP的收发比作「寄明信片」。你写好内容,贴上地址,扔进邮筒就行。不需要提前跟对方打招呼,也不需要确认对方收到了没有。这就是UDP的哲学——简单、直接、不拖泥带水。

4.1 sendto():把数据扔出去

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:你创建好的UDP套接字描述符
  • buf:要发送的数据缓冲区
  • len:数据长度(字节)
  • flags:标志位,一般填0就行。想玩点花的可以填 MSG_DONTWAIT 或 MSG_NOSIGNAL
  • dest_addr:目标地址结构体(IP + 端口)
  • addrlen:地址结构体长度

返回值是实际发送的字节数,出错返回 -1。

我的小经验: 很多人以为 sendto() 一定会把 len 个字节全发出去。其实不一定!虽然UDP数据报理论上不会像TCP那样分多次发送,但如果发送缓冲区满了,或者遇到信号中断,返回值可能小于 len。我建议你写个循环检查返回值,别偷懒。

4.2 recvfrom():等着收明信片

有发就有收。recvfrom() 是接收端的主力:

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

注意最后两个参数是指针。调用前 addrlen 要初始化为 sizeof(struct sockaddr_in),调用后里面会填上发送端的地址信息。这样你就知道是谁发来的数据。

返回值是接收到的字节数。如果返回0,说明对端关闭了连接——等等,UDP哪有连接?嗯,这里返回0其实不太常见,一般表示收到了一个空数据报。

我曾经踩过的坑: 有一次我写UDP接收程序,recvfrom() 一直返回 -1,errno 是 EAGAIN。查了半天才发现,我把套接字设成了非阻塞模式,但没检查返回值就硬等。非阻塞模式下没数据就直接返回,不会阻塞。这个后面会细说。

4.3 无连接特性:UDP的灵魂

UDP最鲜明的特点就是「无连接」。什么意思?

  • 发送前不需要握手,直接扔数据
  • 接收端不需要维护连接状态表
  • 一个套接字可以跟任意多个对端通信
  • 内核不保存任何关于「对方是谁」的信息

我刚开始学网络编程时,总觉得这有点「不靠谱」。后来做视频流传输项目才明白,无连接恰恰是UDP的优势。你想啊,视频通话里每一帧都是独立的,丢了就丢了,下一帧补上。如果每发一帧都要先建立连接,那延迟得有多大?

下面这张图能帮你理解UDP无连接的工作方式:

UDP 无连接通信模型 发送端 A sendto() 接收端 B recvfrom() 接收端 C recvfrom() 接收端 D recvfrom() 数据报 1 数据报 2 数据报 3 说明:UDP 无连接特性允许一个发送端向多个接收端发送数据报, 每个数据报独立传输,互不影响。接收端之间没有关联,也不需要事先建立连接。

4.4 数据报边界保持:UDP的「信封」

这一点特别重要,也是UDP和TCP最大的区别之一。

TCP是流式协议,没有边界。你发两次 send(),对方可能一次 recv() 就全收走了,也可能分三次收。你根本不知道哪段数据对应哪次发送。

UDP不一样。它严格保持数据报边界。你发一次 sendto() 送出去的数据,对方必须用一次 recvfrom() 完整接收。不多不少,刚刚好。

举个例子:

操作 发送端 接收端
第一次 sendto("Hello", 5) recvfrom() → 收到 "Hello" (5字节)
第二次 sendto("World", 5) recvfrom() → 收到 "World" (5字节)
第三次 sendto("!", 1) recvfrom() → 收到 "!" (1字节)

看到没?三次发送,三次接收,每次的数据长度完全对应。不会出现TCP那种「粘包」问题。

核心要点: UDP的接收缓冲区如果小于数据报长度,recvfrom() 会返回 -1,并设置 errno 为 EMSGSIZE。数据报剩余部分会被丢弃。所以接收缓冲区一定要够大!我一般设 65535 字节,这是UDP数据报的最大理论值。

4.5 阻塞与非阻塞模式:等还是不等?

默认情况下,套接字是阻塞模式。什么意思?

  • 阻塞 recvfrom():如果没有数据可读,调用会一直等,直到有数据到达
  • 阻塞 sendto():如果发送缓冲区满了,调用会阻塞直到有空间

但有些场景下,你不想让程序卡在那里。比如一个游戏服务器,既要处理网络数据,又要更新游戏逻辑。这时候就需要非阻塞模式。

设置非阻塞有两种方式:

// 方法一:创建时指定
int sockfd = socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0);

// 方法二:运行时设置
int flags = fcntl(sockfd, F_GETFL, 0);
fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

非阻塞模式下,recvfrom() 如果没有数据,立即返回 -1,errno 设为 EAGAIN 或 EWOULDBLOCK。你的程序可以去做别的事,过一会儿再来检查。

我做过一个实时监控系统,用非阻塞UDP收数据。主循环每10毫秒轮询一次套接字,有数据就处理,没数据就继续跑其他逻辑。这样既保证了实时性,又不浪费CPU。

注意: 非阻塞模式下的轮询如果太频繁,会白白消耗CPU。我见过有人写了个死循环 while(1) { recvfrom(...); },结果CPU直接飙到100%。正确的做法是配合 select()、poll() 或 epoll() 使用,让内核告诉你什么时候有数据。

4.6 实战:一个简单的UDP收发示例

光说不练假把式。咱们写个最简单的UDP回射服务器和客户端:

发送端(客户端):

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

int main() {
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("socket");
        return 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);
    inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);

    const char *msg = "Hello, UDP!";
    int ret = sendto(sockfd, msg, strlen(msg), 0,
                     (struct sockaddr *)&server_addr, sizeof(server_addr));
    if (ret < 0) {
        perror("sendto");
    } else {
        printf("发送了 %d 字节\n", ret);
    }

    close(sockfd);
    return 0;
}

接收端(服务器):

#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>

int main() {
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        perror("socket");
        return 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");
        return 1;
    }

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

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

    close(sockfd);
    return 0;
}

这个例子虽然简单,但涵盖了UDP收发的全部核心流程。你编译运行一下,先开服务器,再开客户端,就能看到效果。

我的建议: 初学者可以先跑通这个例子,然后试着改成非阻塞模式,看看 recvfrom() 在没有数据时返回什么。再试试发一个超过接收缓冲区大小的数据报,看看 EMSGSIZE 错误。自己动手踩一遍坑,比看十遍书都管用。

好了,UDP的收发机制就聊到这儿。记住四个关键词:sendto、recvfrom、无连接、边界保持。理解了这些,UDP编程你就掌握了七八成。剩下的就是多写多练,把坑都踩一遍,自然就熟练了。