上篇文章我们准备了好几个工程模块——日志、通用错误码、不可拷贝基类。当时你可能会想:这些零散的组件到底要拼成什么?答案今天就揭晓。我们要用它们在 Linux 上写最基础、也最能讲清楚"网络编程到底在做什么"的一类程序——基于 UDP 协议的通信程序。

UDP(User Datagram Protocol,用户数据报协议)是 TCP/IP 协议栈里和 TCP 并肩的另一位主角。它最大的特点就是"简单直接、不啰嗦":你想发数据,一包一包地发出去,对方想收就收,中间没有 TCP 那样的三次握手、也没有连接维护、更没有复杂的可靠传输保证。打个比方,TCP 像寄挂号信,要回执、能追踪、可以拆包重组;UDP 更像是往窗外扔纸飞机,扔出去就完事,对面接没接到不是你能控制的。今天我们就用 UDP 写一个"回显服务器"(服务器把你发来的话原样发回去)、一个"网络字典服务器"(英译汉),并在这过程中把 socket 编程最核心的几块基石——地址结构、bind、字节序、sendto/recvfrom——彻底讲透。

你应该有的知识准备

这篇依赖的知识比较集中,先看几样底子够不够:

  • 进程与文件描述符:在 Linux 里,"打开的文件"会得到一个非负的整数编号,叫文件描述符(file descriptor,缩写 fd)。网络套接字在 Linux 里也是以"文件"的形式存在的(Unix 哲学"一切皆文件"),所以它同样有自己的 fd。理解这一点,你就能理解为什么 socket 相关的接口长得那么像 read/write。
  • IP 与端口:IP 地址(如 127.0.0.1)用来标识网络中哪一台主机,端口号(如 8888)用来标识这台主机上的哪一个进程。IP + 端口组合起来,就能唯一地定位到"某台机器上的某个进程"。
  • C/C++ 指针与结构体:一会儿的地址结构和类型转换会用到。只要你会把结构体地址强转成另一种结构体地址,就够用了。
  • 大端与小端(字节序)**:这是本文一个绕不开的点,我会在正文里用代码现场演示,你不需要提前会。

如果上面几条还有印象模糊的,那恰好说明这一篇对你价值很大。都清楚的话,我们直接开始。

套接字(socket)到底是什么

先把这个名字的意义掰扯清楚,因为它今天会被反复提起。套接字(socket,读法不同资料略有差异,常译为"套接字"或"套接口")可以理解成"网络通信的管道口"。它是操作系统提供给我们这些应用程序用来进行网络通信的一套接口和一个对象:你能把一个"套接字对象"创建出来,往里面写数据就是"发数据",从里面读数据就是"收数据",再配合一个地址,就能和指定远方的主机/进程对上话。

为什么叫"管道口"?因为 socket 本身并不是"连接"也不是"数据",它更像是一扇门、一个出入口。数据从你这边的"门"出去,穿过网络,从对方那边的"门"进来。在 Linux 里,这个"门"创建好之后,我们手里拿到的是它的一个文件描述符(fd),后续所有操作——绑定、收发——都是拿着这个 fd 去做的。所以你会发现 socket 编程的第一行代码几乎永远是"创建 socket,得到一个整数 fd"。

套接字有很多"种类",用不同的参数可以创建出不同用途的套接字。今天我们只需要其中的一种 UDP 套接字,它由下面这对参数决定。

AF_INET 和 SOCK_DGRAM:决定套接字种类的两个参数

创建套接字的函数长这样:

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

它的返回值要么是"一个合法的套接字 fd",要么是"负数"(表示出错)。我们关心的两个参数是 domain 和 type,它们共同决定了这个套接字是哪一类。

domain(也写作 family,译为"协议族/地址族") 决定的是"用哪一家的网络地址体系"。其中最常用的是:

  • AF_INET:IPv4 网络,地址是 4 字节的 IPv4 地址,就是我们常见的 192.168.1.1 这种;
  • AF_INET6:IPv6 网络;
  • AF_UNIX:本机进程间通信用的 Unix 域套接字。

注意 AF_INET 前面的 AF 是"Address Family",地址族。还有一个同义词 PF_INET(Protocol Family,协议族),绝大多数平台下它们取值相同,混写也行。今天我们写 UDP over IPv4,用的就是 AF_INET。

type(类型) 决定的是"数据以什么样的方式在网络上流动"。常遇到的有两个:

  • SOCK_STREAM:字节流套接字,对应 TCP。像水流一样,数据按字节一个接一个地流过去,讲究可靠、有序、面向连接;
  • SOCK_DGRAM:数据报套接字,对应 UDP。像投递一个个独立的"包裹"(数据报 datagram),每个包裹自带完整边界,但不可靠、无序、不面向连接。

所以 socket(AF_INET, SOCK_DGRAM, 0) 这句话的完整含义就是:创建一个"基于 IPv4 地址、以数据报方式工作"的 UDP 套接字。第三个参数 protocol 传 0 就代表让系统根据前两个参数自动选择协议(这里就是 UDP),绝大多数情况写 0。

这里有个值得记牢的点:SOCK_DGRAM 是"面向无连接"的协奏曲。它的"无连接"(connectionless)意味着:发数据前不需要像 TCP 那样先建连,每一包数据都独立传达,对方也不用一直"挂着"等你这一个连接。这一点会在后面 sendto 那一节体现得特别明显——你每次发送都要单独告诉内核"我要发给谁"。

地址结构 sockaddr 与 sockaddr_in

有了套接字这一个"门",还得告诉这个门"我们要跟哪扇门通信"——这就是地址。Linux 的 socket 编程里,地址并不是一串简单的 IP 字符串,而是被封装成一个又一个结构体。

名称上这里有两个很容易混、但必须分清的结构体:

老的万能结构体 sockaddr

sockaddr(Socket Address,也常在文档里被翻译成"套接字地址")是一个偏"通用/笼统"的地址结构体。它的定义大致是这个样子(阴影里的字段我们暂时不用深究):

struct sockaddr {
    sa_family_t sa_family;   // 地址族:AF_INET 等
    char        sa_data[14]; // 14 字节的"地址数据区",具体含义随协议而变
};

它的问题很明显:sa_data[14] 是一个"什么都装得下、但说不清具体装什么"的缓冲区。IPv4 地址要装的信息(端口、IP、地址族……)和 IPv6 的、Unix 域的信息完全不同,都硬塞进这 14 个字节,用起来既别扭又危险。所以在实际编程里,我们几乎从不直接操作 sockaddr,而是用它作为所有地址结构体的"通用接口"。

专用的 sockaddr_in——IPv4 专用

sockaddr_in(in = internet,指 Internet 协议也就是 IPv4 专用地址结构)才是我们真正填充数据的结构体:

#include <netinet/in.h>
struct sockaddr_in {
    sa_family_t    sin_family;    // 地址族:填 AF_INET
    in_port_t      sin_port;      // 16 位端口号(网络字节序)
    struct in_addr sin_addr;      // 32 位 IPv4 地址(网络字节序)
    char           sin_zero[8];   // 填充字节,保持与 sockaddr 同大小,习惯清零
};

其中 sin_addr 的类型 struct in_addr 内部其实就只有一个成员 in_addr_t s_addr;,也就是一个无符号 32 位整数,用来存储那个 4 字节的 IPv4 地址(同样以网络字节序存放,字节序这个点马上就讲)。

好,这里就冒出了第一个新手最容易晕的设计:明明平时我们只操作 sockaddr_in,可 bind、sendto、recvfrom 这些函数的参数却都声明成 struct sockaddr *(通用地址指针),这怎么办?

答案就是强制类型转换。因为 sockaddr_in 在内存布局上刻意和 sockaddr 对齐(sin_family 对应 sa_family,后面 14 字节由 sin_port+sin_addr+sin_zero 占满),所以"一个指向 sockaddr_in 的指针"可以被安全地当作"指向 sockaddr 的指针"用。于是你会在无数代码里看到这种如影随形的转换:

// (struct sockaddr *)&local  —— 把 sockaddr_in 的地址强转成 sockaddr 的地址
int ret = bind(sockfd, (struct sockaddr *)&local, sizeof(local));

一句话记住:sockaddr_in 是"真正装数据"的(IPv4 专用),sockaddr 是"函数形参通用门面";要传地址时用一个强转把它们接起来。 这也是为什么所有 socket 接口里,地址参数写 struct sockaddr *,代码里却都在操作 struct sockaddr_in。

既然 sin_port 和 sin_addr 都反复强调"网络字节序",那字节序到底是个啥?这是本篇第二个绕不开的关卡,我们用代码当场演示清楚。

字节序与 htons / htonl / ntohs / ntohl

所谓字节序(byte order),说的是"一个多字节数值,在内存里是高地址存高位还是低地址存高位"。它分两种:

  • 大端序(big-endian):高位字节存低地址,也就是"高位在前"。人类阅读数字的习惯(先读最高位)就是大端。
  • 小端序(little-endian):低位字节存低地址,也就是"低位在前"。x86 系列的 PC 是典型的小端。

一台机器内部用什么序都可以,关键是网络传输必须统一。为了让全世界的机器互相都能读懂彼此的端口号、IP 这些多字节数,TCP/IP 协议规定:网络上传输的多字节整数一律使用"网络字节序",而网络字节序就是大端序。这就是 hton 这一族函数存在的意义——在"本机主机序"和"网络字节序"之间做转换(都是通过移位完成的纯数值转换,不是老老实实的网络收发)。

先动手确认一下你的机器是什么字节序,写这个几行的程序:

// byteorder.c —— 判断本机是大端还是小端
#include <stdio.h>
 
int main()
{
    unsigned short x = 0x0102;      // 一个 2 字节的数值,高字节 0x01,低字节 0x02
    unsigned char *p = (unsigned char *)&x;  // 把内存当字节数组看
    // p[0] 是低地址处的字节。小端序:低地址存低位,p[0] 应是 0x02
    if (p[0] == 0x02)
        printf("本机是小端 (little-endian)\n");   // x86 机器基本都会走这里
    else
        printf("本机是大端 (big-endian)\n");
    return 0;
}

编译运行一下(gcc -o byteorder byteorder.c && ./byteorder),多数机器会打印"本机是小端"。可网络是大端啊——所以当我们要把本机(小端)的端口号填进 sockaddr_in 送去网络,就必须先把主机序转成网络序。这正是那一族函数出现的时机。它们四个是一对两两对应的转换:

  • htons(port):Host To Network Short,"主机序转网络序,16 位(2 字节)",用来转端口号;
  • htonl(addr):Host To Network Long,"主机序转网络序,32 位(4 字节)",用来转 IP 的 4 字节值;
  • ntohs(x):Network To Host Short,"网络序转主机序,16 位",服务器收到对端端口后打印前要用它转回本机序;
  • ntohl(x):Network To Host Long,"网络序转主机序,32 位"。

记忆口诀非常好记:h = host(主机),to = 到,n = network(网络);s = short(16 位),l = long(32 位)。htons 就是"主机到网络、转 short";ntohs 反过来,是"网络到主机、转 short"。

为什么端口用 s(16 位)、IP 用 l(32 位)?因为端口号占 16 位(范围 0~65535),IPv4 地址占 32 位。一一对应,别记反了。下面这段就演示了它们怎么配合结构体里的字段:

// 结构化地看:怎么正确填充一个 IPv4 地址
#include <arpa/inet.h>
#include <netinet/in.h>
 
uint16_t port = 8888;            // 本机人读的端口(主机序)
local.sin_family = AF_INET;      // 地址族,它不是多字节整数,不需要换序
local.sin_port   = htons(port);  // 端口转网络序,因为是16位,用 htons
local.sin_addr.s_addr = htonl(INADDR_ANY); // IP 转网络序,32 位,用 htonl

这里有个极容易踩的坑要提前打预防针:地址族字段 sin_family 不需要也不能做字节序转换。因为 AF_INET 这个值主要在本机内核里用,不进网络报文,转了反而错。真正需要换序的只有 sin_port(16 位)和 sin_addr.s_addr(32 位)。你只要记住"凡是会被送进网络报文的多字节数字,通信双方都要按同一规格(网络序)存放",逻辑就通了——端口和 IP 会被写进 UDP 报文头,所以必须网络序;地址族不走网络,所以不用管。

我还想让你提前建立一个正确的直觉:字节序转换本身只是数值的"位重排",它发生在你的机器上,跟你发几万公里没关系。htons(8888) 在你内存里做一次位交换,把 8888 变成"网络序的 8888",然后整个塞进结构体、写进报文,对方收包后再用 ntohs 变回它本机的序。两边各转各的,映射关系才不乱。

回环地址、网卡地址与 INADDR_ANY

学会填端口了,还得会填 IP。这个看似简单的地方,其实藏着"服务器该 bind 到哪个地址"的一整套讲究。先把几个概念摆清楚——它们是本节的三个主角。

回环地址(loopback address)127.0.0.1。所谓"回环",就是数据不真正走出本机网卡,而是"绕了一圈回到自己"的地址。它专门用来在本机上测试网络程序——发往 127.0.0.1 的数据包只在操作系统的回环接口(常叫 lo)里打个转就被自己收回来了,不碰任何真实网卡、也不出局域网。它的好处是:随时随地可以测,不需要联网,别人也访问不到你的测试服务。坏处恰恰是:只有本机能连。

网卡地址(也叫物理地址/主机在某块网卡上配置的 IP)。当你的机器配了真实网卡(比如 eth0,IP 是 192.168.1.100),别人通过局域网来访问你,用的就是这块网卡的地址。它和回环地址最本质的区别在于"数据能不能出这台机器":回环地址出不了本机,网卡地址才能让别的机器连进来。

INADDR_ANY,从字面看是"任何一个地址",它的值就是 0x00000000(也就是 0)。当服务器在 bind 时把 IP 填成它,含义是:"我不挑,绑定本机所有本地地址,凡是发到这个端口的数据,不管是从哪个网卡进来的,我都收。"

那到底该 bind 哪个?这里有一条源课件里着重强调、也特别实战的结论:

写服务器时,推荐直接 bind INADDR_ANY(0),而不要去显式 bind 一个明确的 IP。

为什么?我给你讲三条理由,你就懂了。

第一,绑死一个 IP 会"漏接"。假设你的服务器显式 bind 了 192.168.1.100,那么只有发往 192.168.1.100:8888 的包才会被递给它的套接字;要是有用户从别的网卡、或者直接走回环 127.0.0.1:8888 来访问你,就会失败。可很多服务器可能就是"多网卡的",显式绑一个等于把其他入口全挡掉了。用 INADDR_ANY 就省心,系统帮你把活都接了。

第二,云服务器不能(也不该)直接 bind 公网 IP。这是个真实世界极其常见的坑:很多云主机上,公网 IP 并不直接配置在本机网卡上,而是通过 NAT(网络地址转换,公网 IP 和私网 IP 的一种映射转发机制)映射进来的。这种情况下你 bind 那个"看起来属于你"的公网 IP,内核在你的网卡上根本找不到它,bind 会直接失败。所以云厂商和教材都反复强调:写服务器代码别 bind 那个云上的公网 IP,老老实实用 INADDR_ANY。

第三,语义上最稳。INADDR_ANY 是"我不在乎从哪个地址进来,来者不拒",对绝大多数服务器业务来说这才是朴素而正确的需求。

那客户端呢?客户端要发的目标 IP 是要清清楚楚填的(要发给谁嘛),但客户端自己的 IP 往往也交给内核随机绑——这个点在后面的客户端章节会重点展开。现在你只需要记得:服务端 bind INADDR_ANY 是默认正确姿势;客户端一般不 bind、由内核自动选地址和端口。

地址转换函数:字符串 IP 和 in_addr 之间的桥梁

光有结构体还不行,因为 IP 在我们眼里是 "192.168.1.100" 这种字符串,而结构体里的 sin_addr.s_addr 是个 32 位整数。这俩之间需要一个"翻译官"——这就是地址转换函数的职责。它们负责在"点分十进制字符串"和"网络序 in_addr/int"之间来回倒腾。我先给结论,再给可以跑的代码。

常用且推荐的是 POSIX 标准的 inet_pton 与 inet_ntop:

  • inet_pton(AF_INET, "192.168.1.100", &addr):把字符串 IP parse(解析)成二进制 network(网络序)的 in_addr。pton 可以拆成 "p(resentation,字符串表达) to n(umeric,数值)" 来记。成功返回 1,失败返回 0;
  • inet_ntop(AF_INET, &addr, buf, sizeof(buf)):把网络序的 in_addr numeric 转成 presentation 字符串,写到调用者提供的缓冲区 buf 里。成功返回 buf,失败返回 NULL。

这俩函数的妙处之一在于:只要把第一参数改成 AF_INET6,它们还能处理 IPv6 的 16 字节地址——所以它们的接口里地址参数用的是 const void * 这种"泛型指针"(源课件里那句"形参是 void* addrptr",就是这个意思)。

另外还有一组"老古董"但你会经常撞见的函数 inet_addr 和 inet_ntoa:

  • inet_addr("192.168.1.100"):直接返回网络序的 32 位整数(s_addr)。缺点是不做错误检查(无效 IP 返回 INADDR_NONE 也就是 0xFFFFFFFF),而且不支持 IPv6,属于老式 API,教程里常见,新代码建议用 inet_pton 替代;
  • inet_ntoa(addr):把网络序的 in_addr 转成字符串返回,返回的 char* 指向一块静态存储区。它和 inet_ptop 最大的不同是结果由函数内部管理、不是线程安全——这个"静态区覆盖"的坑我们单开一节专门讲。

把两组都贴出来给你做对照:

// inet_demo.c —— 演示 pton/ntop 与老式 addr/ntoa 的用法
#include <stdio.h>
#include <arpa/inet.h>
 
int main()
{
    // ---------- 推荐用法:inet_pton / inet_ntop ----------
    char ip[] = "192.168.1.100";      // 我们人读的点分十进制
    struct in_addr addr;              // 用来存转换后的 32 位网络序地址
    // 字符串 -> 网络序 in_addr(返回1成功,0表示格式非法)
    if (inet_pton(AF_INET, ip, &addr) == 1)
    {
        // 看看转出来是什么(按无符号整数打印, 注意它是网络序的)
        printf("pton 成功, s_addr = %u\n", (unsigned)addr.s_addr);
 
        char buf[INET_ADDRSTRLEN];    // 装转换结果的缓冲区, IPv4 最大 16 字节
        // 网络序 in_addr -> 字符串(成功返回 buf, 失败返回 NULL)
        if (inet_ntop(AF_INET, &addr, buf, sizeof(buf)) != NULL)
            printf("ntop 转回字符串 = %s\n", buf);
    }
    else
    {
        printf("inet_pton: IP 格式非法\n");
    }
 
    // ---------- 老式写法:inet_addr / inet_ntoa ----------
    struct in_addr old;
    old.s_addr = inet_addr("8.8.8.8");  // 直接把字符串转成网络序 s_addr
    printf("inet_addr -> %s\n", inet_ntoa(old));  // inet_ntoa 再转回字符串打印
 
    return 0;
}

这里有一个必须反复强调的运行心理预期:请你别被 inet_ntoa 的返回值"骗"了。它返回的是一个指向内部静态存储区的 char*——也就是说,这个函数结果不是给我们预留的独立空间,而是它自己内部一块反复复用的地方。后果就是:你连续调用 inet_ntoa 两次,第二次的结果会把第一次的覆盖掉。我们到"常见坑"那一节会亲眼验证这个覆盖现象,并给出应对之法(一次调用后马上拷贝成字符串,或多线程下改用 inet_ntop)。

socket():创建那扇"门"

理论铺垫完了,开工写代码。一切的起点都是创建套接字:

// 步骤1:创建套接字,本质是让内核帮我们准备好一个"网络通信对象"
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)          // 返回负数意味着创建失败
{
    perror("socket");    // 打印出错原因(errno 对应的描述)并带上前缀
    exit(1);
}

AF_INET 表示 IPv4 地址族,SOCK_DGRAM 表示数据报(UDP),0 表示协议由系统按前两个参数自动选定。它在 Linux 下的本质,就是内核为这个程序在一个叫"套接字结构"的内核对象上登记了一笔,然后返回给我们一个整数文件描述符 sockfd。之后的一切操作都以它为把手。

在进入完整代码之前,我们先把第二个重点函数 bind 讲透,因为它身上背着服务端编程最重要的一道关卡。

bind():把套接字和"地址+端口"绑定到一起

很多人第一次看 bind 会产生一个疑问:为什么 socket() 出来之后还要 bind?socket 创建好了不就能通信了吗?

答案是:socket() 只给了你一个"口",但这个口还"没地址、没户口"。内核里这个套接字还没有和任何"本地 IP + 端口"关联起来。比如对服务器来说,客户端得知道"去 192.168.1.100:8888 找我",可如果这个套接字没绑定 8888 这个端口,别人发到 8888 的数据根本到不了你这个套接字手里。bind 就是做这件事的:把本地地址和端口正式"钉"在这个套接字上。

函数原型与调用方式:

#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd:socket() 返回的那个 fd;
  • addr:指向一个 sockaddr 的指针。注意形参类型是 struct sockaddr*,而我们真正填的是 sockaddr_in,所以要强转;
  • addrlen:传入这个地址结构体的实际大小,一般就是 sizeof(local)。

调用 bind 之前,我们得先把 sockaddr_in 结构体填完整(地址族、端口、IP 三个字段),并且——这一刻先在心里敲个警钟——填完结构体绝不等于"设置到内核里了"。结构体只是我们在用户自己内存里摆好的"申请书",真正的登记动作是接下来那一次 bind() 系统调用。这两者是两回事,很多人以为"我填好 local,系统就该知道了",其实内核啥也不知道,直到 bind() 真正执行。这一点源课件特意加了注释强调,值得你刻进脑子。

bind 的返回值也很有讲究:成功返回 0,失败返回 -1(并把错误码放进全局 errno)。这一步失败最常见的原因,就是"端口被占用"(EADDRINUSE)——某个进程已经占着 8888 端口了,你再 bind 就冲突。这个"端口占用绑定失败"的坑是网络编程被问爆的第一大坑,我们会专门开一节演练它。

来看一个标准的服务端绑定片段(先绑定,完整服务器在下文):

// 步骤2:填地址结构 + bind —— 服务端必须显式 bind
struct sockaddr_in local;
bzero(&local, sizeof(local));        // 先把结构体全部清零, 避免残留垃圾位(bzero 等价于 memset 归 0)
local.sin_family = AF_INET;          // 地址族: IPv4
local.sin_port = htons(port);        // 端口: 转成网络字节序(16 位用 htons)
local.sin_addr.s_addr = INADDR_ANY;  // IP: 绑定任意地址(0), 来者不拒
// 上面三行只是把"申请书"填好, 内核此时还一无所知……
 
int n = bind(sockfd, (struct sockaddr *)&local, sizeof(local));
if (n != 0)                          // 返回非 0 即失败
{
    std::cerr << "bind error: " << strerror(errno) << std::endl;
    exit(2);
}

写到这里,一个核心结论呼之欲出,也是本节的精髓:绑不 bind、绑到哪,取决于"你这程序是服务端还是客户端"。服务端必须显式 bind 一个众所周知的端口——因为客户端要主动"投递"到你这里,你没有一个固定端口,别人往哪儿投?这个点我先埋下,客户端那一节会做真正的正反对照。

完整实现 V1:Echo 回显服务器

所有概念都到齐了,我们来写第一个真正可运行的 UDP 程序。这一节的代码会逐行注释、可直接复制编译。我们做一个最经典的"回显服务器"(Echo Server):客户端发一句话过来,服务器收下、打印是谁发的,再把这句话原样回给客户端。

下面是服务端单文件完整代码。为了让你能独立跑起来,我把日志这些配套模块用标准输出和 perror 简化内联了,逻辑和课堂上完全一致:

// udp_echo_server.cc —— UDP 回显服务器(单文件,可编译运行)
#include <iostream>
#include <cstring>       // strerror/memset
#include <unistd.h>      // close
#include <sys/socket.h>  // socket/bind/recvfrom/sendto
#include <netinet/in.h>  // sockaddr_in
#include <arpa/inet.h>   // inet_ntoa 等地址函数
 
int main()
{
    // (1) 创建 UDP 套接字:IPv4 + 数据报
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        perror("socket");        // 打印具体错误
        return 1;
    }
 
    // (2) 填本地地址并显式 bind
    struct sockaddr_in local;
    bzero(&local, sizeof(local));        // 清零结构体
    local.sin_family = AF_INET;          // IPv4
    local.sin_port = htons(8888);        // 端口 8888 -> 网络序
    local.sin_addr.s_addr = INADDR_ANY;  // 绑定任意本地地址
    if (bind(sockfd, (struct sockaddr *)&local, sizeof(local)) != 0)
    {
        std::cerr << "bind error: " << strerror(errno) << std::endl;
        close(sockfd);
        return 2;
    }
    std::cout << "[server] bind ok, listening on port 8888..." << std::endl;
 
    // (3) 服务器主循环:永不退出,一直收/回
    char buffer[1024];               // 接收缓冲区
    for (;;)
    {
        struct sockaddr_in peer;     // 用来记录"谁是发消息的人"
        socklen_t len = sizeof(peer);  // 赋初值! 注意下面会讲为什么不能乱写
 
        // 收数据,同时把发送方地址放进 peer
        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                             (struct sockaddr *)&peer, &len);
        if (n > 0)
        {
            buffer[n] = 0;                       // 末尾补字符串终止符
            // 打印对方是谁:IP 与端口都要从网络序转回来
            std::cout << "[" << inet_ntoa(peer.sin_addr) << ":"
                      << ntohs(peer.sin_port) << "]# " << buffer << std::endl;
 
            // 原样回给"刚才发给我的人"(目的地就是 peer)
            sendto(sockfd, buffer, strlen(buffer), 0,
                   (struct sockaddr *)&peer, len);
        }
        else if (n < 0)
        {
            perror("recvfrom");
            break;
        }
    }
    close(sockfd);       // 关闭套接字
    return 0;
}

recvfrom 和 sendto 这两个"收发重头戏",我们在它们各自的章节里详细解剖。这里先剧透三个明天你会反复念叨细节,方便你先对上号:

  • recvfrom 的最后两个参数(&peer, &len)是输出参数:调用前 len 要先赋成 sizeof(peer)(源课件特意标注"不能乱写"),调用后 peer 里装的就是"是谁给我发的"、len 被更新成实际写入的地址长度;
  • sendto 的第五、六参数是我们要发给谁的目标地址;
  • 服务端这一个 sockfd 既能收又能发——它同时充当了"读的门"和"写的门"。

对应的客户端也贴出来。客户端这边有个极其重要的隐藏知识点("端口要不要 bind"),我把它放在代码之后专门谈:

// udp_echo_client.cc —— UDP 回显客户端(单文件,可编译运行)
#include <iostream>
#include <string>
#include <cstring>       // memset
#include <unistd.h>      // close
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
 
int main(int argc, char *argv[])
{
    // 用法: ./udp_echo_client 127.0.0.1 8888
    if (argc != 3)
    {
        std::cout << "Usage: " << argv[0] << " <server_ip> <server_port>\n";
        return 1;
    }
    std::string serverip = argv[1];      // 服务端 IP(从命令行拿)
    int serverport = atoi(argv[2]);      // 服务端端口
 
    // (1) 创建 UDP socket。注意: 这里不显式 bind, 让内核自动处理
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0)
    {
        perror("socket");
        return 2;
    }
 
    // (2) 填"要发给谁"的服务端地址
    struct sockaddr_in server;
    memset(&server, 0, sizeof(server));
    server.sin_family = AF_INET;
    server.sin_port = htons(serverport);                    // 目标端口(网络序)
    server.sin_addr.s_addr = inet_addr(serverip.c_str());   // 目标 IP
 
    // (3) 收发的循环
    char buffer[1024];
    std::string line;
    while (true)
    {
        std::cout << "Please Enter# ";
        std::getline(std::cin, line);        // 读一行输入
        if (line.empty())                   // 空行就重新输入
            continue;
 
        // 发: 目的地是 server(服务端)
        sendto(sockfd, line.c_str(), line.size(), 0,
               (struct sockaddr *)&server, sizeof(server));
 
        // 收: 等服务器把原话传回来(也把来源地址接进 from 结构体)
        struct sockaddr_in from;
        socklen_t len = sizeof(from);
        ssize_t m = recvfrom(sockfd, buffer, sizeof(buffer) - 1,
                             0, (struct sockaddr *)&from, &len);
        if (m > 0)
        {
            buffer[m] = 0;
            std::cout << "server echo# " << buffer << std::endl;
        }
        else
        {
            break;   // 出错就结束
        }
    }
    close(sockfd);
    return 0;
}

编译运行(开两个终端,一个跑服务端一个跑客户端):

# ① 编译两个程序
g++ -std=c++11 -o udp_echo_server udp_echo_server.cc
g++ -std=c++11 -o udp_echo_client udp_echo_client.cc
 
# ② 终端 A: 先启动服务端
./udp_echo_server
 
# ③ 终端 B: 再启动客户端, 连接回环地址
./udp_echo_client 127.0.0.1 8888
# 然后在客户端输入任意一句话, 服务器会把它原样回显
 
# ④ 你也可以用系统自带工具当"客户端"来测服务端
echo "hello udp" >/dev/udp/127.0.0.1/8888

你也可以试着把客户端的 127.0.0.1 换成你本机的网卡地址(ip addr 能看到,比如 192.168.1.100),再用另一台局域网机器连过来——你会发现服务器照样收得到。这正好验证了前面那句:INADDR_ANY 绑定的服务器,从哪个地址进来的包都收。而如果服务器把 INADDR_ANY 改成 127.0.0.1 再 bind,那么另一台机器就再也连不上了——因为数据进不了回环口。

客户端到底要不要 bind?——"端口是否绑定"的彻底辨析

这是 UDP 编程里被问得最多、也最值得掰开的一个问题。我们先给结论,再解释,最后用一个对照实验锤实它。

结论:客户端其实也一定会 bind——但它"不需要显式 bind",内核会在你第一次发送数据时自动为它选一个本机 IP 和一个随机端口完成 bind。

为什么服务端必须显式 bind,客户端却可以偷懒?答案是"职责和数量"。

  • 服务端的端口必须众所周知、不能轻易变。客户端要主动"寄包裹"到 服务端IP:服务端Port,所以服务端得固定一个大家都知道的端口号(比如 8888),这个端口是它面对世界的"门牌号",绝不能随手换。所以服务端一定要显式 bind 回固定的知名端口。
  • 客户端只要能把消息发出去、并能收到回音就够了。给客户端的"门牌号"叫什么、是多少,其实无所谓——只要系统帮忙分配一个不冲突的、能用于收发的临时端口就行。而且,客户端往往成千上万、串行并行,如果每个客户端代码都去写死一个端口,反而互相冲突。所以最省事、最不相互打架的做法,就是让内核在客户端第一次发数据时,随机分配一个本地端口(这类"用完就不用了"的临时端口,术语叫 ephemeral port,临时端口)并自动 bind 上去。

一句话总结这个对称关系,请记住:

服务端要"众所周知",所以必须显式 bind 固定端口;客户端要"随意且海量",所以交给内核自动随机 bind——但客户端并不因此就不用 bind,它是被内核悄悄 bind 掉了。

那怎么验证"客户端确实被自动 bind 了"?两类办法:

办法一:看看客户端到底占用哪个端口。 服务端在 recvfrom 后打印的 peer.sin_port(我们代码里已经打了 ntohs(peer.sin_port))——这个就是客户端自动 bind 出来的随机端口。你跑一次回显服务器,注意服务端那行 [127.0.0.1:xxxxx]# ...,里面的 xxxxx 就是客户端这次随机绑的端口。每次重启客户端它都可能不同,完美印证了"随机分配"。

办法二:用 netstat 看连接表。

# 服务端和客户端都在跑的时候,另开终端查看 UDP 相关端口占用
netstat -uan | grep 8888
# 输出里能看到:
# udp 0 0 0.0.0.0:8888 ...                  <- 服务端:固定 8888
# udp 0 0 127.0.0.1:12345 ... (空闲)         <- 客户端:随机的 12345

左边那个固定 8888 是服务端显式 bind 的结果;右边那个随机的本地端口,就是内核替客户端自动 bind 的例证。前后因果一目了然。

那么有没有"客户端必须显式 bind"的反例?有,但那是特殊场景——比如你需要固定一个源端口去和某些防火墙/对端谈"只认这个源端口"的协议;或者你希望客户端和服务端(fd)共用同一个端口做多播等。这类需求属于特例,你有需要再去显式 bind 也不迟。默认战线就是:服务端显式 bind,客户端不显式 bind。

sendto:把数据发给——你指定的那个地址

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(某些特殊需求如不阻塞才传别的位),我们永远传 0;
  • dest_addr / addrlen:对方(目标)的地址与大小——这就是 sendto 和普通 write 最大的不同。

为什么 sendto 一定要带上"目标地址"?这正体现了 UDP"无连接"的本质。UDP 的套接字上没有"我已连上谁"这种记录。你发出去的每一包数据都是独立的、无状态的一次投递,所以每一次 sendto 都必须单独告诉内核"我这包要发给谁"。今天发给 A,下一包可以发给 B,完全不用打招呼。这正是和 TCP 的 connect + write 那种"先建连再裸写"最大的分水岭。

如果你把数据发给 server 这个"我们填好的 sockaddr_in":

// 客户端: 把 message 发送到 server 这个目标的地址
ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0,
                   (struct sockaddr *)&server, sizeof(server));
if (n < 0)   // 发送失败
    perror("sendto");

围绕 sendto 有一个极其重要、也极其容易让新手开心过头又失望的坑——返回值并不能保证对方收到:

// —— 必须破除的幻觉 ——
// sendto 返回大于0, 只代表"这包数据已成功交给本机内核的发送队列",
// 绝不代表对方一定收到了!
ssize_t n = sendto(sockfd, msg, len, 0, &addr, sizeof(addr));
if (n > 0)
{
    // 这里 n 是"交给内核的字节数",仅此而已
}

要清醒看待这一点:UDP 是不可靠协议,推出去就不管了。sendto 成功只说明"数据走出了你的进程、进了系统网络栈的队列"。至于对面机器收没收、中间路由丢没丢、数据有没有损坏,UDP 一概不负责。所以如果业务上必须确认对端收到,你就得自己在应用层加"应答+重传"的机制(这正是 TCP 替我们做的事)。网络编程有一句著名的话送给你:"TCP 给你的,是你要自己造的;UDP 不给的,你爱要不要。" 在我的课程里,我通常把 TCP 和 UDP 的职责关系用一句话打捞起来——可靠、有序、连接这些"贵"的属性,默认都压在 TCP 这头;UDP 是纯粹的"能发出去就不错了"的省心派。

recvfrom:收数据,同时拿到了"谁发的"

收的那头是 recvfrom,它的原型和 sendto 几乎是一对镜像:

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);
  • sockfd:我们的套接字;
  • buf / len:接收缓冲区及容量;
  • flags:传 0;
  • src_addr / addrlen:这次和 sendto 的角色完全不同——它们在这里是"输出参数"。调用前 addrlen 要放 sizeof(结构体),调用后 src_addr 里会被内核写进"到底是谁给这包数据"(对方的 IP + 端口),addrlen 被更新成实际写入的地址字节数。

前面那个 len 的初值,源课件特别用一句"socklen_t len = sizeof(peer); // 不能乱写"来强调。为什么不能乱写?你想想这逻辑:内核拿到 &addrlen,需要知道"你给的这块 peer 缓冲到底有多大,我才好往里写、且不会越界"。如果你初值乱填(比如填 0 或填错了大小),内核要么不敢写字、要么写出去爆缓冲,行为就不可控了。所以给 peer 填多少空间,len 就必须先赋成同样的值,这是契约。

在局域网 UDP 服务器里,这个输出参数就是它的"身份识别"法宝——它让一个服务器能服务成百上千个客户端:

struct sockaddr_in peer;         // 输出参数: 用来装"谁发的"
socklen_t len = sizeof(peer);    // 重要: 初值 = peer 的大小
recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
         (struct sockaddr *)&peer, &len);
// 之后 peer 里就是发送方的地址; 把它转成可读形式打印
std::cout << inet_ntoa(peer.sin_addr) << ":" << ntohs(peer.sin_port) << std::endl;

为什么说它是"一打多"的钥匙?因为服务器拿到 peer 后,要想回复某个客户端,只需 sendto(sockfd, ..., (struct sockaddr *)&peer, len) 即可——"把回复发回刚才是谁发来的那个地址"。于是服务器完全不需要为每个客户端开个新连接、也不需要"配对记忆",每一次 recvfrom 都自带"证词"告诉你该回给谁。这就是 UDP 服务器天然支持多客户端、且代码极简的根本原因。对比 TCP 里你得为每个连接 accept 出新的 fd 并维护,UDP 这份"随发随走、包上自带对方地址"的潇洒,是它的一大魅力。

一个 socket 既能收又能发:UDP 的全双工

你可能已经注意到一个细节:我们的 Echo 服务器从头到尾只有一个 sockfd,它既 recvfrom 又 sendto。这在 UDP 里完全站得住脚——一个 UDP 套接字是"全双工"的:既可以读,也可以写,还可以同时读写。

"全双工"这个词来自通信术语,意思是"两个方向可以同时进行,互不干扰"。套接字内部有两套独立的队列:接收队列和发送队列。你 recvfrom 只认准读队列,sendto 只认准写队列,两边互不挤占。所以:

  • 在上面的 Echo 服务器里,同一个 sockfd 一会儿从 peer 收、一会儿往 peer 回,完全没问题;
  • 在客户端里,你甚至可以开一个线程专门 recvfrom、另一个线程专门 sendto,两个线程共用同一个 sockfd 同时收发——因为读写走的是不同方向的队列,不存在"多线程读写同一个 socket 会互相覆盖"的问题(源课件里的聊天室客户端正是这么干的)。

写一个"收发双线程同用一 sockfd"的最小示例,感受一下全双工:

// duplex_demo.cc —— 演示"一个 sockfd 双线程同时收发"(核心片段)
#include <iostream>
#include <string>
#include <thread>        // 用线程演示两个方向并发
// ... 创建 sockfd、填 server 地址的过程同前 ...
 
// 线程1: 专管"读" —— 一直 recvfrom 打印
// 线程2: 专管"写" —— 一直从键盘读并 sendto
// 两个线程指着同一个 sockfd, 因为读/写走不同队列, 不会互踩
std::thread reader([](int fd){
    char buf[1024];
    struct sockaddr_in t; socklen_t l = sizeof(t);
    while (recvfrom(fd, buf, sizeof(buf)-1, 0, (struct sockaddr*)&t, &l) > 0)
        std::cout << "\nrecv: " << buf << std::endl;
}, sockfd);
 
std::thread writer([&](int fd){
    std::string s;
    while (std::getline(std::cin, s))
        sendto(fd, s.c_str(), s.size(), 0, (struct sockaddr*)&server, sizeof(server));
}, sockfd);
 
reader.join();
writer.join();

这个"一个 fd、两个方向、双线程"的模型,就是后面聊天室客户端的基础。你要记住一个结论:UDP 套接字没有"连接"这层包袱,读和写天然解耦,一个 fd 通吃收和发。

进阶 V2:英译汉网络字典服务器

掌握了收发全流程,我们来做个"有点实际业务"的东西——一个网路字典服务器:客户端发一个英文单词,服务器查表翻译成中文再返回。这正好演示如何在 recvfrom 拿到请求后,跑一段"业务函数",再把结果 sendto 回去(而不是像 Echo 那样原样返回)。

服务端核心就是"业务回调"怎么和 UDP 框架解耦。我们把"收到请求 -> 翻译 -> 回发"这个过程整理成可复用的形态。先说字典本身(用 unordered_map 在内存里存词条),再把它接进收发框架。完整可编译版本如下:

// udp_dict_server.cc —— 英译汉 UDP 字典服务器(单文件,可编译)
#include <iostream>
#include <string>
#include <fstream>        // 读词典文件
#include <unordered_map>
#include <unistd.h>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
 
static std::unordered_map<std::string, std::string> g_dict;   // 全局词表
 
// 从词典文件加载到 map,格式为一行一条 "english: 中文"
void LoadDict(const std::string &path)
{
    std::ifstream fin(path);
    std::string line;
    while (std::getline(fin, line))          // 逐行读
    {
        if (line.empty()) continue;          // 跳过空行
        auto pos = line.find(": ");          // 找分隔符
        if (pos == std::string::npos) continue;
        std::string key = line.substr(0, pos);          // 冒号左边是英文
        std::string val = line.substr(pos + 2);        // 右边是中文
        g_dict[key] = val;
    }
}
 
// 翻译函数:查不到就返回提示
std::string Translate(const std::string &key)
{
    auto it = g_dict.find(key);
    if (it == g_dict.end()) return "Unknown(未查到)";
    return it->second;
}
 
int main(int argc, char *argv[])
{
    if (argc != 2) { std::cout << "Usage: " << argv[0] << " <port>\n"; return 1; }
    int port = atoi(argv[1]);
    LoadDict("./dict.txt");                  // 提前把词典加载进内存
 
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);      // 建 UDP 套接字
    if (sockfd < 0) { perror("socket"); return 2; }
 
    struct sockaddr_in local;
    bzero(&local, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(port);
    local.sin_addr.s_addr = INADDR_ANY;
    if (bind(sockfd, (struct sockaddr *)&local, sizeof(local)) != 0)
    {
        std::cerr << "bind error: " << strerror(errno) << std::endl;
        close(sockfd);
        return 2;
    }
 
    // 收 -> 查 -> 回 的服务器主循环
    char buffer[1024];
    struct sockaddr_in peer;
    while (true)
    {
        socklen_t len = sizeof(peer);
        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                             (struct sockaddr *)&peer, &len);
        if (n < 0) { perror("recvfrom"); break; }
        buffer[n] = 0;                              // 补终止符, 当字符串用
 
        std::string result = Translate(buffer);     // 业务: 查字典
        std::cout << "[" << inet_ntoa(peer.sin_addr) << ":"
                  << ntohs(peer.sin_port) << "] " << buffer
                  << " -> " << result << std::endl;
 
        sendto(sockfd, result.c_str(), result.size(), 0,   // 回发结果
               (struct sockaddr *)&peer, len);
    }
    close(sockfd);
    return 0;
}

配套的词典文件 dict.txt(放在程序同目录,格式一行一条):

apple: 苹果
banana: 香蕉
cat: 猫
dog: 狗
book: 书
pen: 笔
happy: 快乐的
sad: 悲伤的
run: 跑
jump: 跳
teacher: 老师
student: 学生
car: 汽车
bus: 公交车
love: 爱
hate: 恨
hello: 你好
goodbye: 再见
summer: 夏天
winter: 冬天

编译运行,客户端可以随手测试(直接用 echo 大军 + 系统的 /dev/udp,或者跑我们之前的客户端):

g++ -std=c++11 -o udp_dict_server udp_dict_server.cc
./udp_dict_server 9999
# 另开终端:
echo "hello" >/dev/udp/127.0.0.1/9999   # 服务端应回: hello -> 你好

这个例子最想让你看清的,就是 UDP 服务器框架的"加业务"路径:recvfrom 那段循环框架基本不动,变化的只是"请求来了之后做什么"这一小步。正好引出下面这个小升华——把业务从框架里解耦出来的通用服务器。

从 Echo 到通用服务器:把"业务"抽象成回调

你对比 Echo 服务器和字典服务器会发现,它们 90% 的代码一模一样,只有"收到什么、返回什么"不同。这提示我们:可以把 UDP 服务器的骨架抽出来,把"如何处理请求、如何生成响应"留成一个可注入的函数(回调)。这样以后每写一个新业务,只需要换一个处理函数,而不用重复抄那套收发框架。源课件里用 C++ 的 std::function(既能接函数指针、仿函数、lambda 的通用可调用对象)来充当这个回调类型,大致长这样:

// 业务签名的统一形态:入参是请求,出参是响应
using Handler = std::function<void(const std::string& req, std::string* resp)>;
 
// 服务器骨架:接收 -> handler(req, &resp) -> 把 resp 回给请求方
void Start(int sockfd, Handler handler)
{
    char buffer[1024];
    struct sockaddr_in peer;
    for (;;)
    {
        socklen_t len = sizeof(peer);
        ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                             (struct sockaddr *)&peer, &len);
        if (n < 0) continue;                 // 收失败就继续等下一包
        std::string req(buffer, n);          // 请求(注意按实际字节 n, 而非 strlen)
        std::string resp;
        handler(req, &resp);                 // 交给业务函数去算响应
        sendto(sockfd, resp.data(), resp.size(), 0,
               (struct sockaddr*)&peer, len); // 回给刚才那个请求方
    }
}

这段代码特意用了 std::string req(buffer, n) 而非 req = buffer(std::string(buffer, n) 会精确取前 n 个字节,避免把缓冲区里没被写入的"历史垃圾"也带进去;而 req = buffer 由 strlen 截断、读到第一个 0 就停)。这个小细节,就是"二进制安全"和"字符串安全"的区别——网络数据不一定是文本,用长度来界定边界才是稳妥的做法。等你以后处理自定义协议(很可能不是文本)时就会体会到这个习惯的价值。

在封装版里,客户端也被包成了"写死目标 IP/端口"的小类:构造时传 ip, port,随后 SendTo(buf) 就固定发往那个地址、RecvFrom(buf) 收响应。业务使用方(比如 dict_client.cc)就不用再跟 sockaddr_in 纠缠,只管 while(cin>>word) { SendTo(word); RecvFrom(&result); } 即可。这是"把容易写错、容易忘的底层细节,装进一个专业类里"的工程化美德——你现在是为了对底层了如指掌而读源码,但等你熟练后,会更愿意复用封装而不是每回从零苦哈哈地填 sockaddr_in。

进阶 V3:一个简单的聊天室,思路与骨架

最后一个进阶是"聊天室"。它的价值不在代码复杂度,而在于路线(路由)的设计——如何把一条消息转发/群发给多个在线用户。我们已经知道,UDP 服务器 recvfrom 能拿到"谁发的"(peer),而只要把每个来过的 peer 地址存下来,就相当于给每个用户发了"会员牌"——下次收到某人消息,就把这条消息 sendto 给所有存过的地址,就实现了群聊。

于是"在线用户表"就是整个聊天室的核心数据结构。它本质上是一个"存 sockaddr_in(或封装后的 InetAddr)的集合(vector/set)"。源课件里的 Route(路由)模块,思路很清晰:

// 路由模块的核心思路(示意, 结合前文概念理解)
class Route
{
    std::vector<InetAddr> online_users;   // 在线用户表: 保存所有来过的人的地址
 
    void MessageRoute(int sockfd, const std::string& msg, InetAddr& peer)
    {
        // 新面孔 -> 视为上线, 登记进表
        if (!IsExist(peer)) AddUser(peer);
 
        // 把 消息+发送者标识 群发给每个在线用户
        std::string send = peer.StringAddr() + "# " + msg;   // 例: 127.0.0.1:8080# hello
        for (const auto& u : online_users)
            sendto(sockfd, send.c_str(), send.size(), 0,
                   (const struct sockaddr*)&(u.GetAddr()), sizeof(u.GetAddr()));
 
        // 如果发的是 "QUIT", 事后把他从在线表里摘掉(下线)
        if (msg == "QUIT") DeleteUser(peer);
    }
};

几个动作各司其职,我把它们对应到聊天室语义:

  • IsExist(peer):判断这个地址是否已经在表里。"第一次给我发消息"就被视为"上线/登录"——这是 UDP 聊天室最绝妙的一点:不需要专门的注册请求,谁来第一条就自动登记;
  • AddUser(peer) / DeleteUser(peer):维护在线名单;收到 QUIT 就算离线;
  • MessageRoute(...):核心转发热逻辑——收到消息,先登记发送者,再把"谁说的+说了啥"广播给当前全部在线用户。

比较 InetAddr 时会发现它重载了 operator==(比较 IP 和端口是否都相等),这正是 IsExist 判断"是不是同一个人"的依据。

至于"要不要真正的线程/进程池",则取决于业务激烈程度。源课件演示了两种玩法:简单版就是单进程一个循环搞定(性能足够聊天室);进阶版用线程池把 MessageRoute 甩给一组工作线程并发处理(应对高并发转发热)。UDP 服务器天然无连接、状态都在应用层这份"在线表"里,所以线程并发处理的粒度也很自由——你想深入的话,后续专项文章的线程池章节会带你把这套并发骨架搭起来。今天你只要把这层"转发即查表广播"的思想记牢,它就是你写一切 UDP 群发/发布订阅的基础。

常见坑与边界:一网打尽

把所有晦涩角落集中晾一晾。下面这些坑,几乎每个 UDP 新手都踩过,我一个一个用实验或推理给你讲透。

坑1:整了半天,bind 报 "Address already in use"(EADDRINUSE)

这是端口绑定失败的头号原因。场景是这样的:你先前启动过的服务端进程还活着占着 8888,你又启动了第二个服务端去 bind 同一个 8888——于是第二个 bind 返回 -1,errno 为 EADDRINUSE(地址已被占用)。

遇到它的排查思路几乎是模板化的:

# 1. 看看谁占着这个端口
netstat -uan | grep 8888
#   或者更细的调查工具
lsof -i :8888
 
# 2. 如果确认是想释放它: 找出占用进程的 PID 并结束
#    (也可以由服务端配合 SO_REUSEADDR 缓解 TIME_WAIT 类问题, 但真正的 EADDRINUSE
#    通常就是"还有进程没退", 结束它即可)

但请注意,UDP 和 TCP 在"端口复用"上有个差异值得你记牢:TCP 因为要处理 4 元组的各种状态(尤其 TIME_WAIT),所以会有明显的"端口复用"问题;而 UDP 因为无连接、没有 TIME_WAIT 状态,一旦某进程退出,端口一般立刻就能被重新 bind。所以如果你在 UDP 服务端遇到 EADDRINUSE,九成九就是"有个还活着的进程霸着端口",把它找出来结束掉即可(lsof -i :8888 能直接告诉你 PID)。

坑2:inet_ntoa 的返回值会被覆盖(静态区)

inet_ntoa 返回的 char* 指向它自己内部的一块静态存储区,而非每次调用新分配的空间。这意味着:你连续调用两次 inet_ntoa,第二次的结果会覆盖掉第一次的。 用一个能看到"覆盖"的实验来验证:

// inet_ntoa_overwrite.cpp —— 演示返回值被覆盖的现象
#include <stdio.h>
#include <arpa/inet.h>
 
int main()
{
    struct in_addr a, b;
    a.s_addr = inet_addr("1.2.3.4");      // 构造两个不同的 IP
    b.s_addr = inet_addr("5.6.7.8");
 
    char *p1 = inet_ntoa(a);   // 第一次调用, p1 指向内部静态区里的 "1.2.3.4"
    char *p2 = inet_ntoa(b);   // 第二次调用, 覆盖同一静态区 -> 变成 "5.6.7.8"
    printf("p1 = %s\n", p1);   // 此时 p1 已经跟着变成 "5.6.7.8" 了!
    printf("p2 = %s\n", p2);   // "5.6.7.8"
    return 0;
}

运行结果很反直觉:明明先转的 p1,打印却是 5.6.7.8。根因就在于 p1、p2 其实是同一块静态内存的两个指针,第二次调用把它里面换成 5.6.7.8,p1 看到的自然跟着变了。

应对之法,一句话:要么在下次调用前立刻把结果拷贝成字符串,要么用线程安全的 inet_ntop。 前者用 std::string(p) 或 strdup 复制;后者让调用者自己提供缓冲区,天然规避"共享静态区导致互相覆盖"的隐患。这也是推荐入坑后一律用 inet_ntop 的原因。

延伸一下这个坑还有个多线程版本:inet_ntoa 不是线程安全的函数(源课件引用了 APUE 的明确结论)。因为"共享静态区",多个线程同时调用就可能互相踩踏,打印出对方的地址。不过有意思的是,在部分较新的 glibc 实现上它加了互斥锁,单一机器上可能测不出问题——但"这次没炸"不等于"永远安全",标准层面它就是不承诺线程安全。所以结论不变:多线程场景,用 inet_ntop + 自己的缓冲区,从根上消灭它。

(一个小抄:如果你想确认自己机器上 inet_ntoa 到底是否线程安全,可以照源课件那样开两个线程、各自 inet_ntoa 不同地址并反复打印,观察输出是否稳定,但这更多是"验证平台行为"的偏门兴趣,架不住它能跨平台炸——所以写代码时仍按"不可靠"对待。)

坑3:recvfrom 的 len 初值不能乱写

前面强调过:socklen_t len = sizeof(peer); 这句里的 sizeof(peer) 不是随手写的,它是一个输入-输出两用参数。调用前它告诉内核"我的 peer 缓冲区有多大",调用后它被踩成"实际写入了多少字节"。所以初值必须设成目标缓冲区的实际大小,否则内核要么不肯写buf(认为放不下)、要么玩火越界。这是 API 契约的一部分,不是可省可省的优化。凡是你自己填的"接收地址缓冲",len 初值必须等于它的大小,没有例外。

坑4:缓冲区多大才够?数据报 vs 截断

UDP 是"数据报"协议,一次 recvfrom 恰好取走一个完整的数据报(也就是完整的一包数据),不存在 TCP 那种"流式、可能一次没取完还要再取"的边界问题。但它有一个边界约束:如果你的接收缓冲区 buffer 不够大,装不下某个完整的数据报,那么超出缓冲区部分的字节会被内核直接丢弃。

换句话说,UDP 的可靠边界很"暴力":buffer 能装下 1023 字节,对面发来 2000 字节的包,recvfrom 只会给你 1023 字节,剩下的默默丢了——下一个 recvfrom 也不会再接到你以为的"剩余部分",因为数据报的边界已经断了。所以设计接收缓冲区时要结合业务估算最大的数据报长度。UDP 报文有长度上限(IPv4 下理论最大到 65507 字节左右的 payload),但一般聊天/字典这种业务用 1K~4K 就够,真要做大文件传输你得自己分片(或者干脆换个协议)。

这里再补一个和 TCP 的对比口吻帮你记忆:TCP 是"流",要处理粘包/半包边界;UDP 是"报",一个 recvfrom 恰是一个报文,简单粗暴,错就错在"装不下就丢"。

坑5:sendto 成功 ≠ 对方收到

这个坑前文已详述,这里只立一块碑就够:

sendto 返回的是"交给本机内核发送队列的字节数",它不携带任何"对端已收到"的信息。 UDP 不管对端在不在、收到没收到。要做确认,请在应用层自建"请求-应答"协议。

坑6:服务端 bind 具体 IP 会"只听一个门"

如果你把服务端 sin_addr.s_addr 从 INADDR_ANY 改成 inet_addr("192.168.1.100"),那么这个套接字只接收发往 192.168.1.100:端口 的数据;发往它其它网卡地址、或回环地址的同端口数据,它一概不理。多数情况下这不是你要的(你更希望"什么门都开着"),所以默认写 INADDR_ANY。反过来,客户端则是"必填且目标明确",不存在这种"听谁"的选择——它只负责发,不负责听。

思考题/自测:带详解答案

(先自己写/想一遍,再对照详解,效果最好。)

Q1:服务端用 INADDR_ANY 绑定 8888,客户端连接 127.0.0.1:8888 和 192.168.1.100:8888 都能通吗?为什么?

详解:都能通。INADDR_ANY(值为 0)表示"绑定本机所有本地地址"。内核收到任何落到"本机某个 IP 地址 + 8888 端口"的 UDP 数据包,都会把它递给这个套接字。回环地址 127.0.0.1 是本机的"回环网口",网卡地址 192.168.1.100 是本机真实网口,两者都是"本机地址"之一,所以都在 INADDR_ANY 的接收范围内。反过来命题更显威力:若服务端 bind 成 inet_addr("192.168.1.100"),那只有发往 192.168.1.100:8888 的包能被接收,发往 127.0.0.1:8888 的包会被丢掉(因为 127.0.0.1 不是 192.168.1.100)。

Q2:客户端不显式 bind,那么它到底有没有端口?服务端怎么知道回给谁?

详解:客户端一定有端口。它的端口不是程序员写的,而是内核在客户端第一次调用 sendto 时自动随机分配的一个临时端口(同时自动完成 bind)。因为 sendto 时内核发现这个套接字还没有本地地址,就顺手给它 bind 了一个本地 IP 和一个未占用的随机端口。你自己的程序不用管它,但要知道"它确实存在"。服务端在 recvfrom 时通过输出参数 peer 拿到了发送方的 IP + 端口(也就是客户端那个自动分配的随机端口),所以要回给谁,就看 peer 里的地址即可。这也就是为什么 recvfrom 之后能用一个 sendto 精准回给别人——因为"谁发的"已经由内核替你记在 peer 里了。

Q3:sin_port 和 sin_addr.s_addr 为什么要 htons/htonl?不转会怎样?

详解:因为网络协议规定,网络上传送的多字节数字一律使用"大端(网络字节序)"。我们写程序的本机可能是小端(如 x86),所以填入结构体时要用 htons/htonl 把本机序转成网络序,收回来再用 ntohs/ntohl 转回。如果不转:在小端机器上,端口号的高低字节会颠倒地写进报文,接收方读到的端口就错了(比如 0x3412 被读成 0x1234);不过还有一层容易忽略的、更隐蔽的问题——同一台本机做收发(回环)时,你 sendto 不转、bind 不转,两边"颠倒得一致",可能碰巧能对上,看起来很"通",但一旦跨机器(一大端一小端),立刻错乱。所以别赌,永远正确转序才是唯一可靠姿势。

Q4:示例里服务端 recvfrom 成功后再 sendto 回的人是谁?指向哪个地址结构?

详解:回的是"这次发消息给我的那位",指向 peer。因为 recvfrom 的最后两个参数是"输出参数",调用后 peer 中装的就是发送方的 IP + 端口,len 是实际写入的地址长度。随后 sendto(sockfd, ..., (struct sockaddr *)&peer, len) 就是把数据最终发给 peer 所代表的那个远端。注意 len 在这里应该直接用 recvfrom 更新后的值(而不是重新 sizeof(peer)),虽然一般 sockaddr_in 大小不变,但严格遵循"谁给我的、我用谁的长度回"更稳。这正好呼应坑3"len 有时是要被覆盖更新的"。

Q5:一个 UDP socket 既能 recvfrom 又能 sendto,甚至两个线程同时收和发,这和 TCP 的 connect 有什么区别?

详解:UDP 套接字是全双工、无连接的。它有独立的接收队列和发送队列,recvfrom 只读接收队列、sendto 只写发送队列,两个方向互不干扰,所以一个 fd 天然同时支持收与发,也能被两个线程各管一头。而 UDP 本身没有"连接"状态,你不需要(也不需要维护)"我连上了谁"这种记录,每包都自带目标地址。TCP 则相反:必须先 connect 建立一条连接(内核记录"这条 socket 对端是谁"),之后收发都默认指那个对端,receive 也不存在"每次还要告诉我对方是谁"这种输出。这就是"面向连接 vs 无连接""每次带地址 vs 一次建连"的根本差异。

Q6:我 bind 8888 失败,报 EADDRINUSE。这时候该怎么做?为什么 UDP 重启后一般很快能重新 bind?

详解:EADDRINUSE 表示这个端口已被占用,最常见是"还有个进程活着占着 8888"。做法:先用 netstat -uan | grep 8888 或 lsof -i :8888 找出占用者(能直接看到 PID),确认是自己旧的服务器就把它的进程结束掉(kill),再重新 bind 通常就成功了。至于"UDP 怎么这么快能重绑":因为 UDP 无连接、没有 TCP 那种 TIME_WAIT 状态(TCP 有大量处于 TIME_WAIT 的残留四元组占用端口),所以只要占用进程退出,UDP 端口基本立刻释放、立即可重 bind;TCP 却可能因为 TIME_WAIT 要多等片刻。这是 TCP 与 UDP 在端口复用上比较明显的一个区别。

Q7:为什么 sendto 明明返回成功,对方却可能没收到?要让"确认收到"该怎么做?

详解:sendto 成功只代表这包数据已成功交给本机内核的发送队列,此后它要经网卡发上网络、可能要经过多跳路由器、最终被对端网络栈接收,这中间的任何一环(对端没开机、中间路由丢包、对端崩溃、数据在校验出错被丢)都可能让这包消失,UDP 一分力都不出。要确认,必须在应用层自建"应答机制":客户端发请求后阻塞等响应,服务器收到后回一条"我收到了",客户端等不到就重发(超时重传)——这正是 TCP 可靠传输的简化版。写 UDP 业务时,凡是"必须保证到达/有序/去重"的需求,都得自己把它做出来。

收尾

从一头雾水的"socket 到底是个啥",走到能写会跑的 Echo 服务器、字典服务器、甚至能理解聊天室转发的骨架,这条路我们走得基本完整了。今天必须焊进脑子里的骨架大致是这条:

  • **套接字(socket)**是"网络通信的口",socket(AF_INET, SOCK_DGRAM, 0) 开一扇 UDP 的门,得到一个 fd;
  • 目标/本地地址用 sockaddr_in 填(地址族 + 端口 + IP),传参时强转成 sockaddr;
  • 端口、IP 必须用 htons/htonl 转成网络字节序,收回来再用 ntohs/ntohl 转回——这是跨机器通信不失真的前提;
  • 公共 IP/IP 的选择,写了三兄弟的回环地址(本机通)、网卡地址(出本机)、INADDR_ANY(来者不拒),服务器默认绑 INADDR_ANY;
  • 服务端必须显式 bind 固定的知名端口,客户端不显式 bind(内核在首次 sendto 时自动随机 bind)——搞清楚这个对称,等于解开了一大半"端口绑定"的疑惑;
  • sendto 每次都要带"这次发给谁"(UDP 无连接)、recvfrom 会把"谁发的"通过输出参数交还给你——这一收一发的地址就是 UDP 服务器能一打多的根基;
  • 一个 UDP socket 全双工,读写天然双向,做聊天室那类应用才顺理成章。

这些概念单独看都是小锤子,拼在一起就是一把能凿开无数网络应用的斧头。UDP 的门道,归根到底就一句话:它可以做得简陋(不可靠不保证),但正因为简单,才更直白地逼你理解"地址、端口、收发、边界"这些网络世界的最小组成单位。 把地基打牢,下一篇我们走进 TCP 的连接与流的世界时,你会发现自己已经站在特别坚实的地面上——因为"地址怎么填、发往何处、回给何人"这套心智模型,你在 UDP 这里已经修炼到家了。

(小提示:让这套代码真正长进身体,最快的办法就是把 Echo 服务器和字典服务器各敲一遍,亲手把 recvfrom/sendto 跑出"我能回显"和"我能翻译"的那一刻——比看任何一遍讲解都值钱。踩到 EADDRINUSE 就回头看坑1,数据没到就装个 netstat 核对端口,每解决一个问题,你对 socket 的理解就深一分。)