1. 前言:为什么从 UDP 开始
TCP 服务器要处理三次握手、连接管理、粘包拆包,代码一上来就有一堆状态要维护。而 UDP 是无连接的,一个服务器只需要四步就能跑起来:
socket()创建套接字bind()绑定地址和端口recvfrom()收数据,同时拿到对端地址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 编程就没有太多隐藏的坑了。建议先把这个回显服务器跑通,再逐步加上超时、并发和缓冲区调优。