3. Linux UDP 编程基础:从 socket 到非阻塞

好,咱们进入实战环节。UDP 编程,说白了就是让两个程序通过“无连接”的方式互相扔数据包。你想想看,没有三次握手,没有连接维护,效率确实高,但代价就是——丢包了你自己扛。

这一章,我会把 UDP 编程最核心的几个 API 掰开揉碎讲清楚。包括 socket()、bind()、sendto()、recvfrom(),以及非阻塞模式怎么设。每个函数我都会结合自己的踩坑经历来讲,希望能帮你少走弯路。

核心要点:UDP 编程的四个基本步骤——创建套接字、绑定地址、发送数据、接收数据。非阻塞模式是解决“卡死”问题的关键。

UDP 编程核心流程 socket() 创建套接字 bind() 绑定地址端口 sendto() 发送数据 recvfrom() 接收数据 非阻塞模式设置(可选) 注意:sendto() 和 recvfrom() 可以独立运行,不需要配对顺序

3.1 socket() 函数:一切通信的起点

socket() 是网络编程的入口。它的作用就是向内核申请一个套接字资源。说白了,就是拿一个文件描述符,后面所有的收发操作都靠它。

#include <sys/socket.h>
int socket(int domain, int type, int protocol);

三个参数,我一个个说:

  • domain:地址族。UDP 通信几乎只用 AF_INET(IPv4)或 AF_INET6(IPv6)。我个人习惯用 AF_INET,兼容性最好。
  • type:套接字类型。UDP 用 SOCK_DGRAM(数据报套接字)。别写成 SOCK_STREAM,那是 TCP 的。
  • protocol:协议。传 0 就行,内核会根据 domain 和 type 自动选择 UDP 协议。

小技巧:返回值判断要严谨。我见过不少新手直接用 if (sockfd < 0),其实应该用 if (sockfd == -1)。虽然效果一样,但后者更符合 POSIX 规范。

举个例子:

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

嗯,这里要注意:socket() 返回的 fd 默认是阻塞模式。后面我们会讲怎么改成非阻塞。

3.2 bind() 函数:给套接字一个身份

bind() 的作用是把套接字和一个本地地址绑定起来。对于 UDP 服务端来说,这步是必须的——你得告诉内核:“嘿,这个端口归我管了。”

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数说明:

参数 含义 常见取值
sockfd socket() 返回的文件描述符 必须有效
addr 指向 sockaddr_in 结构体的指针 需要强制类型转换
addrlen 地址结构体的大小 sizeof(struct sockaddr_in)

我在项目中遇到过一个问题:bind()EADDRINUSE 错误。这是因为端口被占用了。解决办法有两个:一是换端口,二是设置 SO_REUSEADDR 选项。

避坑指南:我曾经在调试一个 UDP 服务时,发现 bind() 总是失败。查了半天,原来是端口号小于 1024,需要 root 权限。记住:1024 以下的端口只有 root 才能绑定。

完整的绑定代码示例:

struct sockaddr_in servaddr;
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);  // 监听所有网卡
servaddr.sin_port = htons(8888);               // 端口号

if (bind(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)) == -1) {
    perror("bind failed");
    close(sockfd);
    exit(EXIT_FAILURE);
}

3.3 sendto() 与 recvfrom():UDP 的收发双雄

这两个函数是 UDP 通信的核心。和 TCP 不同,UDP 每次发送都要指定目标地址,每次接收都能拿到发送方的地址。

3.3.1 sendto():把数据扔出去

#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:套接字描述符
  • buf:要发送的数据缓冲区
  • len:数据长度(字节)
  • flags:标志位,一般传 0
  • dest_addr:目标地址结构体
  • addrlen:目标地址结构体大小

返回值:成功返回发送的字节数,失败返回 -1。

个人经验:UDP 的 sendto() 不会阻塞,因为数据直接扔给内核缓冲区就返回了。但要注意:如果缓冲区满了,会返回 EAGAIN 或 EWOULDBLOCK。我在高并发场景下遇到过这个问题,解决办法是增大套接字缓冲区。

3.3.2 recvfrom():等着收数据

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

和 sendto() 对称,但有两个关键区别:

  1. src_addr 是输出参数,会填上发送方的地址
  2. addrlen 是值-结果参数,调用前要初始化,调用后会被修改

举个例子,完整的收发流程:

// 发送端
char buffer[] = "Hello, UDP!";
struct sockaddr_in dest;
dest.sin_family = AF_INET;
dest.sin_port = htons(8888);
inet_pton(AF_INET, "192.168.1.100", &dest.sin_addr);

ssize_t sent = sendto(sockfd, buffer, strlen(buffer), 0,
                      (struct sockaddr *)&dest, sizeof(dest));
if (sent == -1) {
    perror("sendto failed");
}

// 接收端
char recv_buf[1024];
struct sockaddr_in sender;
socklen_t sender_len = sizeof(sender);

ssize_t received = recvfrom(sockfd, recv_buf, sizeof(recv_buf), 0,
                            (struct sockaddr *)&sender, &sender_len);
if (received > 0) {
    recv_buf[received] = '\0';
    printf("Received from %s:%d: %s\n",
           inet_ntoa(sender.sin_addr),
           ntohs(sender.sin_port),
           recv_buf);
}

3.4 非阻塞模式设置:别让程序卡死

默认情况下,recvfrom() 是阻塞的——如果没有数据,它会一直等下去。这在生产环境里是个大问题。你想想看,如果网络断了,程序就永远卡在那了。

设置非阻塞模式有两种方法:

方法一:fcntl() 设置 O_NONBLOCK

#include <fcntl.h>

int flags = fcntl(sockfd, F_GETFL, 0);
if (flags == -1) {
    perror("fcntl F_GETFL failed");
    return -1;
}

if (fcntl(sockfd, F_SETFL, flags | O_NONBLOCK) == -1) {
    perror("fcntl F_SETFL failed");
    return -1;
}

方法二:recvfrom() 的 MSG_DONTWAIT 标志

ssize_t n = recvfrom(sockfd, buf, len, MSG_DONTWAIT,
                     (struct sockaddr *)&src, &src_len);
if (n == -1 && errno == EAGAIN) {
    // 没有数据,不阻塞,直接返回
    printf("No data available\n");
}

避坑指南:我曾经在做一个实时音视频传输项目时,用了阻塞模式。结果有一次网络抖动,整个接收线程卡死了 30 秒,导致视频画面直接卡住。后来改成非阻塞 + 超时重试机制,问题才解决。记住:生产环境一定要用非阻塞模式。

两种方法的对比:

方法 优点 缺点 适用场景
fcntl() 设置 O_NONBLOCK 全局生效,所有操作都非阻塞 需要额外系统调用 长期运行的服务端
MSG_DONTWAIT 标志 只影响单次调用,灵活 每次都要传标志 临时需要非阻塞的场景

我的建议:服务端程序用 fcntl() 设置全局非阻塞,客户端按需使用 MSG_DONTWAIT。这样代码更清晰,也更容易维护。

好了,这一章的内容就到这里。UDP 编程的基础 API 其实不多,但每个细节都可能成为坑。记住:socket() 创建、bind() 绑定、sendto() 和 recvfrom() 收发、非阻塞模式保平安。把这四个点吃透,UDP 编程就算入门了。