【Linux网络】UDP 服务器怎么写?从 socket、bind 到 recvfrom/sendto,手写完整回显服务器

1. 前言:为什么从 UDP 开始

TCP 服务器要处理三次握手、连接管理、粘包拆包,代码一上来就有一堆状态要维护。而 UDP 是无连接的,一个服务器只需要四步就能跑起来:

  1. socket() 创建套接字
  2. bind() 绑定地址和端口
  3. recvfrom() 收数据,同时拿到对端地址
  4. sendto() 按对端地址把数据发回去

这四步就是 UDP 服务器的全部骨架。本文从零手写一个完整的 UDP 回显服务器(Echo Server),把每个系统调用的参数、返回值、易错点都讲清楚,最后给出可以直接编译运行的完整代码。

2. UDP 与 TCP 的本质差异

在动手之前,先明确 UDP 的定位,这决定了代码的写法。

对比项 TCP UDP
连接 面向连接,需 connect/accept 无连接,直接收发
可靠性 可靠,重传、有序 不可靠,不保证到达与顺序
数据边界 字节流,存在粘包问题 数据报,一次收一个完整报文
收发接口 read/write、recv/send recvfrom/sendto
服务端模型 每连接一个 fd 一个 fd 服务所有客户端

关键点在于:UDP 服务器只有一个套接字,却要服务所有客户端 。因为无连接,服务器不保存任何客户端状态,每个数据报都自带来源地址,所以必须用 recvfrom 把来源地址取出来,再用 sendto 按这个地址回包。

3. 核心 API 逐个拆解

3.1 socket:创建套接字

c 复制代码
#include <sys/socket.h>

int socket(int domain, int type, int protocol);

UDP 服务器的典型调用:

c 复制代码
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
  • AF_INET:IPv4 协议族(IPv6 用 AF_INET6)。
  • SOCK_DGRAM:数据报套接字,即 UDP。
  • protocol:一般填 0,表示由前两个参数自动推导。

返回值是文件描述符,失败返回 -1 并设置 errno。

3.2 bind:绑定地址与端口

c 复制代码
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

服务器必须 bind,否则内核会随机分配端口,客户端根本不知道往哪发。先准备地址结构:

c 复制代码
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(8080);

if (bind(sockfd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
    perror("bind");
    return -1;
}

几个必须注意的点:

  • sin_port 必须用 htons 转成网络字节序,主机字节序直接填会绑到错误端口。
  • INADDR_ANY 表示监听本机所有网卡地址,适合服务器;只想监听某个网卡就填对应 IP。
  • bind 的第三个参数是地址结构长度,不是指针大小。

3.3 recvfrom:收数据并获取对端地址

c 复制代码
#include <sys/socket.h>

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

这是 UDP 服务器与 TCP 最大的不同:收数据的同时把发送方地址带出来。

c 复制代码
char buf[1024];
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);

ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0,
                     (struct sockaddr *)&cli_addr, &cli_len);
  • 返回值是实际收到的字节数,0 表示收到一个空数据报(合法),-1 表示出错。
  • cli_len 是值-结果参数 :传入时告诉内核缓冲区多大,返回时是内核实际写入的地址长度。每次调用前都要重新赋值为 sizeof(cli_addr),否则第二次调用可能失败。
  • 数据报超过 len 会被截断,多余部分直接丢弃,不会像 TCP 那样留在缓冲区里。

3.4 sendto:按对端地址回包

c 复制代码
#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);

把 recvfrom 拿到的 cli_addr 原样传回去即可:

c 复制代码
sendto(sockfd, buf, n, 0, (struct sockaddr *)&cli_addr, cli_len);

sendto 成功只代表数据交给了内核,不代表对方收到。UDP 没有确认机制,丢包、乱序都是正常现象。

4. 完整回显服务器实现

下面是一个可以直接编译运行的 UDP 回显服务器:收到什么就原样发回什么,并打印客户端 IP 和端口。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define PORT 8080
#define BUF_SIZE 1024

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

    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(PORT);

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

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

    char buf[BUF_SIZE];
    while (1) {
        struct sockaddr_in cli_addr;
        socklen_t cli_len = sizeof(cli_addr);

        ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0,
                             (struct sockaddr *)&cli_addr, &cli_len);
        if (n < 0) {
            if (errno == EINTR) {
                continue;  /* 被信号中断,重试 */
            }
            perror("recvfrom");
            break;
        }

        char ip[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &cli_addr.sin_addr, ip, sizeof(ip));
        printf("recv %zd bytes from %s:%d\n", n, ip, ntohs(cli_addr.sin_port));

        if (sendto(sockfd, buf, n, 0,
                   (struct sockaddr *)&cli_addr, cli_len) < 0) {
            perror("sendto");
            continue;
        }
    }

    close(sockfd);
    return 0;
}

编译与运行:

bash 复制代码
gcc -Wall -o udp_echo_server udp_echo_server.c
./udp_echo_server

5. 客户端验证

用 nc 快速验证,注意 -u 表示 UDP:

bash 复制代码
nc -u 127.0.0.1 8080

输入任意内容回车,服务器会原样回显,同时服务端终端打印来源地址。

也可以写一个最小客户端,体会 sendto 与 recvfrom 在客户端侧的对称用法:

c 复制代码
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

int main(void) {
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);

    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(8080);
    inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);

    const char *msg = "hello udp";
    sendto(sockfd, msg, strlen(msg), 0,
           (struct sockaddr *)&serv_addr, sizeof(serv_addr));

    char buf[1024];
    struct sockaddr_in from;
    socklen_t from_len = sizeof(from);
    ssize_t n = recvfrom(sockfd, buf, sizeof(buf), 0,
                         (struct sockaddr *)&from, &from_len);
    if (n > 0) {
        buf[n] = '\0';
        printf("echo: %s\n", buf);
    }

    close(sockfd);
    return 0;
}

6. 常见坑与排查清单

  • 端口被占用 :bind 返回 EADDRINUSE。用 ss -ulnp | grep 8080 查占用进程。
  • 客户端收不到回包 :检查 sendto 用的是不是 recvfrom 返回的 cli_addr,而不是自己构造的地址。
  • 第二次 recvfrom 失败 :cli_len 没有在循环内重新初始化。
  • 中文乱码或截断 :buf 太小导致数据报被截断,UDP 截断是静默的,不会报错。
  • 权限问题:绑定 1024 以下端口需要 root 权限。
  • 防火墙拦截 :本机测试正常但跨机不通,先检查 iptables/firewalld 是否放行 UDP 端口。

7. 从回显服务器到生产可用

回显服务器只是起点,真实场景还需要考虑:

  • 并发处理 :单线程 recvfrom 串行处理,高并发下可用 epoll 监听 UDP fd,或起多个进程用 SO_REUSEPORT 分摊。
  • 超时控制 :用 setsockopt 设置 SO_RCVTIMEO,避免 recvfrom 永久阻塞。
  • 缓冲区调优 :SO_RCVBUF/SO_SNDBUF 默认值偏小,高吞吐场景需要调大。
  • 应用层可靠性:需要可靠传输就在 UDP 之上自己做序号、确认、重传,这正是 QUIC、KCP 的思路。

8. 小结

UDP 服务器的核心就是四个调用:socket 建套接字、bind 绑地址、recvfrom 收数据带出对端地址、sendto 按对端地址回包。理解了「一个 fd 服务所有客户端」这个模型,以及 cli_len 的值-结果语义,UDP 编程就没有太多隐藏的坑了。建议先把这个回显服务器跑通,再逐步加上超时、并发和缓冲区调优。

相关推荐
AI_Lab011 小时前
服务器管理工具要在服务器上装 Agent,安全吗?
服务器·安全·github
从琳开始9891 小时前
从Socket到UDP回显服务器:Java网络编程完整指南
java·网络·网络协议·udp·java-ee
静以修身zzz1 小时前
OpenStack对象存储-Swift、OpenStack编排管理-heat、OpenStack网络管理-neutron
linux·运维·服务器·架构·openstack·swift
2501_915921432 小时前
KXBuild CI 实操,Windows/Linux 服务器接入 Jenkins 打包 iOS IPA
服务器·windows·vscode·ios·ci/cd·个人开发·敏捷流程
Gauss松鼠会2 小时前
GaussDB 整体架构解析
运维·服务器·网络·数据库·sql·架构·gaussdb
sinovoip2 小时前
Banana Pi BPI-RT2 WiFi 6 路由器板采用 Realtek RTL8198D 双核 MIPS SoC。
网络·智能路由器
一技安身2 小时前
MySQL 8.4.13 LTS 通用二进制包(Linux免安装版)完整教程
linux·mysql·adb
迷途之人不知返2 小时前
【动态库与静态库】-3-静态链接与加载
linux
xiaoye-duck2 小时前
《C++ 项目:手写大模型接入 SDK》DeepSeek接口设计(上):初始化和全量响应
开发语言·网络·c++·语言模型