🔥 星光编译者 · 个人主页
📚 学习专栏: 《C/C++ 成长笔记》 · 《Linux 实践手册》 · 《数据结构与算法》
🌄 向云端飞扬,编译属于自己的代码星河。
☕ 写在开篇
你好,这里是 星光编译者。
这里记录我在 C/C++、Linux、数据结构与算法 学习中遇到的真实问题、亲手验证过的代码,以及那些容易被忽略的实现细节。
比起简单罗列结论,我更愿意从问题出发,把一个知识点的来由讲清楚,把"为什么会这样"和"应该怎样解决"说明白,让每一次踩坑都沉淀成可以复用的经验。
如果这篇记录能帮你少绕一点路,或让某个模糊的地方忽然变得清晰,那么这次分享便有了意义。愿我们在一次次阅读、编译与调试中稳步向前,慢慢搭起属于自己的技术世界。

🔥 本文定位:从"一个进程怎样把消息发给另一个进程"出发,串起 UDP Socket 的地址模型、核心接口和回显通信流程。
💡 学习目标 :理解
socket / bind / sendto / recvfrom;区分本地地址与目标地址;掌握网络字节序与地址转换;能独立搭建回显服务器和客户端。📌 实现范围:Linux、IPv4、C++17、阻塞式 UDP。代码使用少量异常处理和 RAII 管理资源,重点仍是 Socket 调用本身。
课程 PDF 一共有 29 页,依次包含回显服务器、网络字典、简单聊天室以及补充接口说明。为了保持一篇文章的阅读节奏,这次拆成上下两篇:上篇先把通信闭环跑通,下篇再讨论业务封装、字典与聊天室。
系列衔接 :下篇:网络字典、聊天室与多线程收发。上篇对应 PDF 第 1~6 页,并提前整合第 26~28 页的地址转换内容。
文章目录
- 一、从本地进程通信走向网络通信
- [二、Socket 到底是什么](#二、Socket 到底是什么)
- [三、先看懂 UDP 通信的整体流程](#三、先看懂 UDP 通信的整体流程)
- 四、地址结构与网络字节序
- [五、服务器为什么要 bind](#五、服务器为什么要 bind)
- [六、sendto 与 recvfrom 的参数怎么读](#六、sendto 与 recvfrom 的参数怎么读)
- [七、UDP 的消息边界与返回值陷阱](#七、UDP 的消息边界与返回值陷阱)
- 八、完整的回显服务器与客户端
- 九、编译运行与验证方法
- 十、常见错误与排查顺序
- 总结
一、从本地进程通信走向网络通信
学过管道、共享内存和消息队列后,我们已经知道:两个进程可以通过操作系统提供的机制交换数据。但这些机制通常服务于同一台机器上的进程。
现在把问题扩大一点:客户端运行在一台电脑上,服务器运行在另一台 Linux 主机上。两者没有共同的进程地址空间,也不能直接共享同一个普通管道。消息必须经过各自的操作系统和网络设备,才能到达另一台机器上的目标进程。
一次网络通信,至少要解决三个问题:
- 去哪里:用 IP 地址描述网络层面的目标地址;
- 交给哪个服务:用端口号参与标识目标通信端点;
- 按什么方式交换数据:选择 UDP、TCP 等传输方式。

1.1 IP、端口与进程不是一一对应
在入门阶段,可以先把 IP 理解为"找到目标主机的线索",把端口理解为"找到目标服务入口的线索"。但不要把它们机械地理解成永久的一一对应关系。
一台主机可能有多个 IP 地址,一个进程可以拥有多个 Socket、绑定多个端口;通过端口复用等机制,多个进程也可能共同提供一个服务。操作系统处理数据时,还需要结合协议、地址、端口和 Socket 状态等信息。
因此,比"端口就是进程编号"更准确的说法是:端口参与标识传输层通信端点,操作系统再把数据交给相应的 Socket。
1.2 为什么同一个端口可以同时提供 UDP 和 TCP 服务
因为 UDP 与 TCP 属于不同的传输协议。例如 UDP 的 8888 和 TCP 的 8888,并不是同一个传输层入口。真正讨论冲突时,需要把协议也考虑进来。
这也是为什么服务配置中通常不只写"开放 8888 端口",还需要明确是开放 8888/udp 还是 8888/tcp。我们的示例全部使用 UDP。
1.3 UDP 能提供什么
UDP 提供数据报通信:应用把一条消息作为一个数据报发出去,接收端按数据报读取。它没有 TCP 那样的建连握手,也不负责保证每条消息一定送达、按发送顺序到达或只到达一次。Linux UDP 手册
这一点直接决定了我们后面代码的边界:回显和字典适合演示请求与响应,但"客户端超时"不能直接解释成"服务器一定没有处理"。如果响应丢了,服务器可能已经完成处理。
二、Socket 到底是什么
Socket,也叫套接字,是应用访问网络通信能力的一种接口与内核对象。应用程序不必自己逐字节构造 IP 数据包,而是通过 Socket 接口提出"创建通信端点、绑定地址、发送消息、接收消息"等请求。
在 Linux 中,创建成功的 Socket 通过一个文件描述符访问:
cpp
int fd = socket(AF_INET, SOCK_DGRAM, 0);
if (fd < 0) {
// 创建失败,需要检查 errno。
}

2.1 三个参数分别在选什么
| 参数 | 本文取值 | 含义 |
|---|---|---|
domain |
AF_INET |
使用 IPv4 地址族 |
type |
SOCK_DGRAM |
使用数据报 Socket |
protocol |
0 |
让系统选择该组合的默认协议,此处为 UDP |
如果后面学习 TCP,常见组合会变为 AF_INET + SOCK_STREAM + 0。地址族决定采用哪一类地址,Socket 类型决定通信语义,协议参数进一步指定实现协议。
2.2 文件描述符只是入口
fd 是一个整数,不是 IP 地址、端口号,也不是数据报本身。它是当前进程访问某个内核资源的入口。
可以把调用关系理解为:
text
应用保存 fd
→ 调用 sendto(fd, ...)
→ 内核找到对应 Socket
→ 根据目标地址和路由完成发送
创建成功只代表获得了一个通信资源。此时我们还没有为服务器确定服务端口,也没有发送任何数据。
2.3 为什么不能写 if (fd <= 0)
失败返回的是 -1,而 0 也是合法文件描述符。如果进程原本关闭了标准输入,系统完全可能把 0 分配给新创建的 Socket。
因此应检查 fd < 0,关闭时检查 fd >= 0。不要把标准输入"通常使用 0"的习惯,误当成"所有新资源都不可能使用 0"的规则。
2.4 Socket 用完也要关闭
课程代码中有些析构函数为空。对学习接口而言这不妨碍理解流程,但整理成可复用代码时,需要把资源生命周期补齐。
我们采用 RAII:对象创建时获得 Socket,对象析构时调用 close。同时禁用复制,防止两个对象保存同一个 fd,随后各自关闭一次。dup 等共享描述符机制属于另一层设计,本文不展开。
三、先看懂 UDP 通信的整体流程
理解每个接口之前,先把完整的请求与响应画出来。

服务器的基本流程是:
text
socket → bind → recvfrom → 处理消息 → sendto → 继续 recvfrom
客户端的基本流程是:
text
socket → 准备服务器地址 → sendto → recvfrom → 显示响应
3.1 为什么 UDP 服务器没有 accept
本文采用未连接的 UDP Socket。服务器收到的每个数据报,都可能来自不同客户端。recvfrom 会把本次消息的源地址一起交给应用,应用再决定把响应发给谁。
它不需要像典型 TCP 服务器那样,通过 listen / accept 为每个连接建立独立的通信 Socket。一个 UDP Socket 就可以接收多个客户端发来的消息。
3.2 客户端和服务器的区别在哪里
两边都创建 UDP Socket,都能发送和接收。区别主要来自程序角色与约定:服务器先占用一个稳定的服务端口,等待请求;客户端知道这个服务入口,主动发起请求。
所以,"服务器只能收、客户端只能发"是错误的。回显程序本身就要求两端都具备收发能力。
3.3 UDP 可以调用 connect 吗
可以。UDP 的 connect 可以设置默认对端,之后使用 send / recv,但它不会建立 TCP 式可靠连接,也不会给 UDP 增加可靠交付能力。本文使用 sendto / recvfrom,让每次通信的目标地址和源地址都更直观。Linux UDP 手册
四、地址结构与网络字节序
在命令行里,我们可以输入 127.0.0.1 8888;系统调用需要的是具有规定内存布局的地址结构。IPv4 编程中主要使用 sockaddr_in。
cpp
sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(8888);
int result = inet_pton(AF_INET, "127.0.0.1", &server.sin_addr);
4.1 为什么先写 {}
sockaddr_in server{}; 会对结构体进行值初始化,避免留下未初始化字段。课程中的 bzero 或 memset 也能完成清零,但在 C++ 示例中,花括号更直接。
结构体中的三个重点字段如下:
| 字段 | 表示什么 | 填写方式 |
|---|---|---|
sin_family |
地址族 | AF_INET |
sin_port |
16 位端口 | 主机序数值经过 htons |
sin_addr |
IPv4 二进制地址 | inet_pton 转换结果 |
填好结构体,只是准备好了应用内存中的参数。真正把本地地址配置到 Socket 上,要调用 bind;作为目标地址,则把它交给 sendto。
4.2 sockaddr 与 sockaddr_in 的关系
Socket 接口要支持多种地址族,因此参数经常使用通用的 sockaddr*。IPv4 使用 sockaddr_in,IPv6 使用 sockaddr_in6。
cpp
sendto(fd, data, size, 0,
reinterpret_cast<const sockaddr*>(&server), sizeof(server));
这里的转换没有把 IPv4 地址变成另一种网络地址,也没有进行字节序转换。它只是让 IPv4 结构体的地址满足通用接口的参数类型要求。地址族和长度告诉内核应该怎样解释这段数据。
4.3 网络字节序解决什么问题
假设一个 16 位整数是 0x1234。不同机器在内存中可能先放 0x12,也可能先放 0x34。如果发送端和接收端各自按自己的习惯解释同样的两个字节,就可能得到不同数值。
网络中的相关多字节整数使用约定的字节顺序,也就是大端序。于是我们在边界处进行转换:

| 函数 | 方向 | 常见用途 |
|---|---|---|
htons |
host → network,16 位 | 写入端口 |
ntohs |
network → host,16 位 | 打印收到的端口 |
htonl |
host → network,32 位 | 写入主机序的 32 位数值 |
ntohl |
network → host,32 位 | 读取网络序的 32 位数值 |
h 表示 host,n 表示 network,s 与 l 对应这组接口处理的宽度。即使某台机器本身就是大端,也照样使用这些函数;接口会完成正确的转换,应用不需要猜测运行平台。
4.4 IP 转换与整数转换不要混在一起
inet_pton 的职责是把可读的地址文本转成网络格式的二进制地址。htons 的职责是把端口这样的整数从主机序变成网络序。
cpp
server.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &server.sin_addr);
不要再对 inet_pton 的结果额外调用一次 htonl,否则在小端平台上可能把已经正确的地址反过来。
4.5 inet_pton 的返回值不是普通布尔值
cpp
int result = inet_pton(AF_INET, text.c_str(), &address.sin_addr);
if (result == 1) {
// 转换成功。
} else if (result == 0) {
// 文本不是合法的该地址族地址。
} else {
// 不支持该地址族等错误,检查 errno。
}
不能把所有非零值都当成成功,因为 -1 也是非零。POSIX 地址转换说明
还要注意:inet_pton 不负责 DNS 解析。传入 example.com 不会得到域名对应的 IP。本文命令行要求填写数字形式的 IPv4 地址,域名解析留待后续课程。
4.6 为什么这里选 inet_ntop
打印地址时,我们使用调用方提供的缓冲区:
cpp
char ip[INET_ADDRSTRLEN]{};
if (inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip)) == nullptr) {
// 转换失败。
}
INET_ADDRSTRLEN 包含 IPv4 文本和结尾空字符需要的空间。返回值成功时指向我们自己的缓冲区,失败时为 nullptr。inet_ntop 手册
PDF 还讨论了 inet_ntoa。它返回库内部缓冲区的指针,不需要调用方 free;后续调用可能覆盖之前的内容,因此不能把两次返回的指针当成两个独立字符串保存。现代 Linux 手册将相关接口标为 MT-Safe locale,所以也不能简单写成"所有 Linux 实现中它都不支持多线程";安全属性需要结合具体实现理解。inet 手册
工程上更清晰的选择仍然是 inet_ntop:结果存在哪块内存、何时失效,由调用方明确控制。不同线程各用自己的局部缓冲区,也更容易理解。
五、服务器为什么要 bind
创建 Socket 后,服务器还需要让客户端知道"应该往哪个服务入口发送消息"。bind 的作用是给 Socket 关联一个本地地址。
cpp
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(8888);
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (::bind(fd, reinterpret_cast<const sockaddr*>(&local), sizeof(local)) < 0) {
// 绑定失败。
}
5.1 本地地址和目标地址必须分清
这是初学网络编程最容易绕晕的一点:
bind里的地址回答:当前 Socket 在本机使用哪个地址与端口;sendto里的地址回答:当前这条消息要发给哪个对端;recvfrom输出的地址回答:当前收到的消息是谁发来的。
客户端应该把服务器地址交给 sendto,而不是尝试把服务器 IP 绑定到自己的 Socket 上。

5.2 INADDR_ANY 表示什么
INADDR_ANY 通常对应 0.0.0.0。作为 IPv4 的绑定地址,它表示通配本地接口地址,省去指定某一个本地 IP 的步骤。Linux IP 手册
它不是"允许任何人通过认证"的开关,也不是"服务器的可访问公网 IP"。数据能否从外部到达,还受到路由、防火墙、云安全组和网络映射等条件影响。
客户端发送时仍要使用具体的可达目标地址。本机测试用 127.0.0.1;局域网测试使用服务器的局域网地址;跨公网测试使用相应的可达入口。
5.3 云服务器为何可能不能绑定公网 IP
在某些云网络中,外部看到的公网 IP 由平台通过映射提供,它未必直接配置在虚拟机的网络接口上。此时应用试图绑定这个公网 IP,可能因为它不是本机地址而失败。
所以应先查看主机实际拥有的地址,再决定绑定策略。教学示例使用 INADDR_ANY 便于在多种环境中运行;如果只希望服务能在本机访问,则应显式绑定 127.0.0.1。实际部署也可以按业务要求绑定某个本地接口地址。
这比"云服务器永远不能绑定公网 IP"更准确:关键在于该 IP 是否实际属于本机接口以及系统配置。
5.4 客户端要不要显式 bind
对于本文回显客户端,通常不需要显式调用 bind。未绑定的 UDP Socket 在首次发送时,由系统分配可用的本地临时端口。Linux UDP 手册
客户端也需要一个可以接收响应的本地端点,只是这个选择交给内核完成。服务器读取请求的源地址后,就知道响应应该发到哪里。
所谓"临时端口",不是每发一条消息就换一个端口。只要客户端继续使用同一个 Socket,它通常会继续使用已分配的端口。
5.5 什么情况下客户端会主动绑定
当协议要求固定客户端端口、希望限制本地接口,或需要在第一次发送之前启动独立接收流程时,都可以显式绑定。
下篇的聊天室客户端使用:
cpp
socket.BindAny(0);
绑定端口 0 表示让系统选择可用端口。这仍然是客户端自己的本地端口,与服务器的 8888 没有冲突关系。
5.6 如何观察内核分配的端口
调用 getsockname 可以读取 Socket 的本地地址。我们的客户端在发送后打印它,让"隐式绑定"不再只是文字结论。
如果 Socket 绑定通配地址,查询结果中的 IP 可能仍显示 0.0.0.0;实际出站数据报的源地址由路由等因素决定,不能直接把这个通配地址当成线上源 IP。
六、sendto 与 recvfrom 的参数怎么读
这两个函数看起来参数较多,但把它们拆成"资源、数据、地址"三组就很好理解。
6.1 sendto:指定一条消息和它的目的地
cpp
ssize_t n = sendto(
fd, // 使用哪个 Socket
message.data(), // 消息从哪里开始
message.size(), // 发送多少字节
0, // 本次发送标志
reinterpret_cast<const sockaddr*>(&server),
sizeof(server) // 目标地址结构的大小
);
返回值类型是 ssize_t,因为它既要表示成功时的字节数,也要表示失败时的 -1。不要一开始就把它放进无符号的 size_t 中,否则 -1 可能变成一个很大的正数。
发送成功只说明这次调用被本地系统接受,不等于已经收到远端应用的确认。如果业务需要确认,需要设计对应的响应或确认消息。
6.2 为什么不用 strlen
字符串对象已经记录了字节长度。strlen 扫描到第一个 \0 就停止,不能表达中间含有零字节的完整数据。
例如三个字节 A、\0、B,使用 strlen 只能得到 1;使用 std::string 的 size() 可以得到 3。这里按实际长度发送,既适用于普通文本,也保留了二进制消息的能力。
6.3 recvfrom:读消息,同时得到发送者
cpp
char buffer[4096];
sockaddr_in peer{};
socklen_t length = sizeof(peer);
ssize_t n = recvfrom(
fd, buffer, sizeof(buffer), 0,
reinterpret_cast<sockaddr*>(&peer), &length
);
与 sendto 不同,peer 是输出位置。调用前我们不必知道是谁发来消息,调用后它包含这次数据报的源地址。

6.4 length 为什么必须初始化
length 是"值---结果"参数:调用前,它表示应用提供的地址缓冲区有多大;调用后,它表示实际地址长度。应使用 socklen_t,并在每次调用前初始化。recvfrom 手册
cpp
for (;;) {
sockaddr_in peer{};
socklen_t length = sizeof(peer);
// recvfrom(..., &length)
}
这个长度描述的是地址结构,不是消息内容。消息长度由函数返回值给出。混淆两者,容易把"地址有 16 字节"误写成"只接收 16 字节消息"。
6.5 没有消息时会怎样
本文使用阻塞 Socket。没有消息到达时,recvfrom 默认会等待,程序停在这一行,并不代表 CPU 一直忙于循环检查。
客户端可以设置 SO_RCVTIMEO,让等待有时间上限。示例设置三秒接收超时,超时通常表现为 -1,并设置 EAGAIN 或 EWOULDBLOCK。底层封装把这种情况转换成 false,便于业务层区别"没有等到消息"和"发生其他错误"。
七、UDP 的消息边界与返回值陷阱
Socket 接口能够调用成功,只是第一步。要写对程序,还必须理解返回值与数据报边界。

7.1 一个接收调用读取一个数据报
假设客户端连续发送 hello 和 world 两个数据报。在数据报到达的前提下,接收端按两条消息读取;不会因为两个包在时间上接近,就自动把它们拼成一个 helloworld。
但这不保证第二个包一定在第一个包之后到达。保留消息边界 与保证消息顺序是两件不同的事。
7.2 缓冲区太小,不能下次再读剩余部分
如果一个数据报有 2000 字节,而本次只提供 1024 字节缓冲区,超过的部分不会像普通文件那样留着等下次继续读取。应用必须预先约定消息上限,或者检查截断情况。
本文 Linux 封装在 recvfrom 中使用 MSG_TRUNC,使返回值能够反映原始数据报长度;如果它大于缓冲区大小,就丢弃本条消息,避免对截断后的内容进行处理。这个具体用法是 Linux 语义,不应直接假定所有平台都相同。recvfrom 手册
尤其注意:使用这种方式后,返回值可能大于缓冲区容量。必须先比较长度,再构造字符串,否则会读到缓冲区之外。
7.3 UDP 接收返回 0,可能是正常消息
课程中的一些示例只在 n > 0 时处理消息。这会忽略合法的零长度 UDP 数据报。
在 UDP 程序里,我们应区分:
| 结果 | 应该怎么理解 |
|---|---|
n > 0 |
收到非空数据报 |
n == 0 |
可以表示收到空数据报 |
n < 0 |
检查错误码 |
不要把 TCP 的"有序关闭后读到 0"直接套到 UDP 上。UDP 没有对应的连接关闭通知。recv 手册
我们的封装返回 true 表示成功收到一条数据报,空不空由 message.size() 表示。这样状态和数据内容就不会混在一起。
7.4 没有结尾零字符也能安全保存文本
课程常见写法是预留一个字节,然后写 buffer[n] = '\0'。这适用于随后按 C 字符串使用缓冲区的情况。
我们采用另一种方式:
cpp
message.assign(buffer, static_cast<std::size_t>(n));
它根据长度拷贝内容,不要求输入缓冲区以 \0 结束,也能正确保存内部包含零字节的数据。
7.5 大缓冲区不代表适合发大 UDP 包
本文接收缓冲区为 4096 字节,交互客户端限制输入为 1024 字节。这是演示程序的应用约定,并不是 UDP 的协议最大长度。
真实网络的路径 MTU 可能更小。消息过大会增加分片风险,或让发送因 EMSGSIZE 失败。业务应依据实际网络条件控制包大小,不能因为接收数组开得很大,就忽视链路限制。
7.6 超时之后为什么不直接自动重发
回显、字典查询通常可以重试,但后续业务可能是"扣款""提交任务"等有副作用操作。响应丢失时,客户端重新发送可能导致重复处理。
因此这里让客户端报告"交付状态未知",不自动重发。可靠请求协议需要请求编号、超时策略和去重机制。先明确 UDP 的语义,再决定业务怎样补足它。
八、完整的回显服务器与客户端
前面的接口说明解决了"为什么这样写"。这一节把它们组织成完整项目,方便在 Linux 上直接编译。
text
code/
├── udp.hpp # 地址、端口与 Socket 资源封装
├── echo_server.cpp # 原样回显
├── udp_client.cpp # 交互式请求/响应客户端
├── Makefile
└── README.md
代码不依赖 PDF 中省略的 Log.hpp、Thread.hpp 等文件。错误由异常携带到主函数,主函数统一打印并返回非零状态;这只是为了让网络主线更集中,不要求读者先搭建完整日志系统。
8.1 地址和资源封装:udp.hpp
Endpoint 保存一个 IPv4 端点,负责文本转换、端口转换和地址比较;UdpSocket 负责创建、绑定、收发与关闭。
cpp
#pragma once
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#include <cerrno>
#include <cstdint>
#include <stdexcept>
#include <string>
#include <system_error>
inline constexpr std::size_t kMaxDatagram = 4096;
[[noreturn]] inline void SystemError(const char* operation) {
throw std::system_error(errno, std::generic_category(), operation);
}
inline uint16_t ParsePort(const std::string& text) {
if (text.empty() || text.find_first_not_of("0123456789") != std::string::npos)
throw std::invalid_argument("port must contain decimal digits");
const auto value = std::stoul(text);
if (value == 0 || value > 65535)
throw std::out_of_range("port must be in 1..65535");
return static_cast<uint16_t>(value);
}
class Endpoint {
public:
Endpoint() = default;
explicit Endpoint(const sockaddr_in& address) : address_(address) {}
Endpoint(const std::string& ip, uint16_t port) {
address_.sin_family = AF_INET;
address_.sin_port = htons(port);
const int result = inet_pton(AF_INET, ip.c_str(), &address_.sin_addr);
if (result == 0) throw std::invalid_argument("invalid IPv4 address");
if (result < 0) SystemError("inet_pton");
}
const sockaddr_in& Address() const { return address_; }
uint16_t Port() const { return ntohs(address_.sin_port); }
std::string Text() const {
char ip[INET_ADDRSTRLEN]{};
if (!inet_ntop(AF_INET, &address_.sin_addr, ip, sizeof(ip)))
SystemError("inet_ntop");
return std::string(ip) + ":" + std::to_string(Port());
}
bool operator==(const Endpoint& other) const {
return address_.sin_family == other.address_.sin_family &&
address_.sin_port == other.address_.sin_port &&
address_.sin_addr.s_addr == other.address_.sin_addr.s_addr;
}
private:
sockaddr_in address_{};
};
class UdpSocket {
public:
UdpSocket() : fd_(socket(AF_INET, SOCK_DGRAM, 0)) {
if (fd_ < 0) SystemError("socket");
}
~UdpSocket() { if (fd_ >= 0) ::close(fd_); }
UdpSocket(const UdpSocket&) = delete;
UdpSocket& operator=(const UdpSocket&) = delete;
void BindAny(uint16_t port) {
sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_port = htons(port);
local.sin_addr.s_addr = htonl(INADDR_ANY);
if (::bind(fd_, reinterpret_cast<const sockaddr*>(&local), sizeof(local)) < 0)
SystemError("bind");
}
Endpoint Local() const {
sockaddr_in local{};
socklen_t length = sizeof(local);
if (getsockname(fd_, reinterpret_cast<sockaddr*>(&local), &length) < 0)
SystemError("getsockname");
return Endpoint(local);
}
void ReceiveTimeout(int milliseconds) {
if (milliseconds < 0) throw std::invalid_argument("negative timeout");
timeval timeout{};
timeout.tv_sec = milliseconds / 1000;
timeout.tv_usec = (milliseconds % 1000) * 1000;
if (setsockopt(fd_, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)) < 0)
SystemError("setsockopt");
}
void SendTo(const std::string& message, const Endpoint& peer) const {
if (message.size() > kMaxDatagram)
throw std::length_error("datagram exceeds 4096-byte demo limit");
ssize_t n;
do {
n = sendto(fd_, message.data(), message.size(), 0,
reinterpret_cast<const sockaddr*>(&peer.Address()), sizeof(sockaddr_in));
} while (n < 0 && errno == EINTR);
if (n < 0) SystemError("sendto");
if (static_cast<std::size_t>(n) != message.size())
throw std::runtime_error("incomplete datagram send");
}
// false means receive timeout; an empty datagram still returns true.
bool Receive(std::string& message, Endpoint& peer) const {
char buffer[kMaxDatagram];
sockaddr_in address{};
ssize_t n;
do {
socklen_t length = sizeof(address);
n = recvfrom(fd_, buffer, sizeof(buffer), MSG_TRUNC,
reinterpret_cast<sockaddr*>(&address), &length);
} while (n < 0 && errno == EINTR);
if (n < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) return false;
SystemError("recvfrom");
}
if (static_cast<std::size_t>(n) > sizeof(buffer))
throw std::length_error("oversized datagram discarded");
message.assign(buffer, static_cast<std::size_t>(n));
peer = Endpoint(address);
return true;
}
private:
int fd_;
};
这份头文件有五个值得单独强调的细节:
ParsePort在转成uint16_t之前检查范围,防止70000被截成另一个数值;- 析构函数关闭有效描述符,复制操作被禁用;
SendTo用显式字节长度,合法空消息的发送结果0也能通过检查;Receive先处理EINTR和超时,再检查截断,最后才复制缓冲区;ReceiveTimeout(0)对应不启用接收超时,非零值用于客户端等待边界。
本文不为 close 在 EINTR 后盲目重试,也不在析构函数里抛异常。关闭资源有自己的平台语义,不能简单照搬发送、接收的重试方式。
8.2 服务端:echo_server.cpp
服务器只做三件事:绑定端口、接收请求、把原始内容发回原始来源。
cpp
#include "udp.hpp"
#include <iostream>
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "Usage: " << argv[0] << " <port>\n";
return 1;
}
try {
UdpSocket socket;
socket.BindAny(ParsePort(argv[1]));
std::cout << "echo listening at " << socket.Local().Text() << std::endl;
for (;;) {
std::string request;
Endpoint peer;
try {
if (!socket.Receive(request, peer)) continue;
} catch (const std::length_error& error) {
std::cerr << error.what() << '\n';
continue;
}
std::cout << '[' << peer.Text() << "] bytes=" << request.size()
<< " request=" << request << std::endl;
socket.SendTo(request, peer);
}
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 2;
}
}
peer 每一轮都会更新。因此,不管这条消息来自客户端 A 还是 B,都能够回给本次发送者。这里不维护"连接表",也不在第一次接收后永久记住某个客户端。
接收到超过应用缓冲区的数据报时,服务器丢弃这条消息并继续接收。其他异常会终止这个教学程序;生产服务通常还需要按错误类别区分是否继续、退避或退出。
8.3 客户端:udp_client.cpp
客户端以"一行输入对应一次请求"的方式工作。先发送,再等待服务器响应。
cpp
#include "udp.hpp"
#include <iostream>
int main(int argc, char* argv[]) {
if (argc != 3) {
std::cerr << "Usage: " << argv[0] << " <server-ip> <port>\n";
return 1;
}
try {
Endpoint server(argv[1], ParsePort(argv[2]));
UdpSocket socket;
socket.ReceiveTimeout(3000);
std::string request;
while (std::cout << "request (/quit exits)> " && std::getline(std::cin, request)) {
if (request == "/quit") break;
if (request.size() > 1024) {
std::cerr << "request exceeds 1024 bytes\n";
continue;
}
socket.SendTo(request, server);
std::cout << "local endpoint: " << socket.Local().Text() << '\n';
std::string response;
Endpoint peer;
bool received = false;
while (socket.Receive(response, peer)) {
if (peer == server) { received = true; break; }
}
if (received) std::cout << "response [" << response.size() << "]: " << response << '\n';
else std::cout << "receive timeout; delivery is unknown\n";
}
} catch (const std::exception& error) {
std::cerr << error.what() << '\n';
return 2;
}
}
它有意保留了几个便于观察的行为:
- 第一条消息发出后打印本地端点,观察系统分配的端口;
- 空行也会发出一个零长度数据报;
/quit是客户端本地退出命令,不会发送到回显服务器;- 只接受来自配置的服务器端点的响应;
- 三秒内没有等到响应时报告超时,并允许继续输入。
来源比较可以过滤普通的无关数据报,但它不是身份认证。本文是受控实验中的教学客户端,不提供面向公网的可信通信协议。
还有一个协议层面的局限:如果上一条请求的响应迟到,它可能被下一次等待读到。这个客户端没有请求编号,因此只适合按顺序进行简单实验;增加请求 ID 和总等待截止时间,是进一步完善请求/响应协议的方向。
九、编译运行与验证方法
9.1 在 Linux 中编译
进入本篇文章的 code 文件夹:
bash
make
也可以直接使用 g++:
bash
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 echo_server.cpp -o echo_server
g++ -std=c++17 -Wall -Wextra -Wpedantic -O2 udp_client.cpp -o udp_client
这里是 Linux/POSIX 代码,不能把它直接当成 Windows Winsock 程序编译。Windows 访问 Linux 的加餐 PDF 属于后续独立文章,本篇不提前混入它的内容。
9.2 两个终端跑通回显
终端 A:
bash
./echo_server 8888
终端 B:
bash
./udp_client 127.0.0.1 8888
输入 hello,客户端会显示长度为 5 的响应。输入 你好,UDP,客户端应收到同样的文本。这里的长度是字节数,不是汉字数量,UTF-8 中文通常占用多个字节。
再输入一个空行,响应长度应为 0,但客户端不会退出。这正好验证前面讲到的空数据报语义。

9.3 用 Python 检查二进制数据
保持服务器运行,在第三个终端执行:
bash
python3 - <<'PY'
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(2)
for data in (b'hello', '你好,UDP'.encode(), b'', b'A\x00B'):
s.sendto(data, ('127.0.0.1', 8888))
result, peer = s.recvfrom(4096)
assert result == data, (data, result)
print('OK:', repr(result), peer)
s.close()
PY
这比只看终端输出更可靠:它逐字节比较结果,可以发现 strlen 导致的内部零字节截断,也可以确认空包确实有响应。
9.4 验证超长消息
向服务器发送 4097 字节,预期不会得到截断后的回显,而是由服务器丢弃。紧接着再发一条正常消息,预期可以继续处理。
python
s.sendto(b'x' * 4097, ('127.0.0.1', 8888))
# 本次没有业务响应;按设定的超时结束等待。
这个实验验证的是应用层长度防护,不是"UDP 不允许发送 4097 字节"。在实际网络中,发送是否成功还取决于路径等条件。
9.5 多客户端如何区分
同时打开两个客户端。服务器日志中可以看到相同回环 IP 搭配不同的源端口,响应仍各自回到对应客户端。
此时可以重新审视 recvfrom:它不仅给了消息内容,还给了服务器完成回复所需的"回邮地址"。
9.6 本篇验证范围
项目整理后在 Ubuntu 24.04 的 g++ 环境中编译,并通过回环通信检查普通文本、UTF-8 中文、空数据报、内部零字节、超长数据报丢弃后的继续服务,以及非法地址和端口输入。具体验证记录放在 code/验证记录.md。
这些结果证明示例在本地实验环境下的行为,不代表已经测试过公网丢包、NAT、云安全组或不同发行版的所有细节。
十、常见错误与排查顺序
"客户端没收到响应"并不能直接说明 recvfrom 写错了。先判断程序停在哪一步,再检查对应条件。

| 现象 | 优先检查 | 原因与处理 |
|---|---|---|
socket 失败 |
errno、资源限制 |
先看错误信息,不能继续使用无效 fd |
bind 报地址不可用 |
地址是否属于本机 | 不要把对端 IP 或映射公网 IP 直接当本地地址 |
bind 报地址已占用 |
是否已有同类服务 | 停止旧服务,或选择其他端口 |
| 本机成功,跨机器失败 | 目标地址、UDP 规则、路由 | 127.0.0.1 永远指向当前这台机器 |
| 地址正确但端口错乱 | htons / ntohs |
转换应发生在主机序与网络序边界 |
| 消息中途被截断 | 收包容量、strlen |
区分数据报截断与 C 字符串截断 |
| 空行后程序异常 | 是否用了 n <= 0 |
零长度 UDP 消息也是合法数据报 |
| 查询一直等待 | 是否设置超时 | UDP 不保证远端一定回复 |
10.1 先缩小到回环实验
优先用同机 127.0.0.1 验证程序,再扩大到局域网,最后测试公网。这样每一步新增的变量更少,排查更容易定位。
不要一开始同时改端口、改 IP、开防火墙、改代码。如果一口气改了四个地方,即使问题暂时消失,也很难知道真正原因。
10.2 地址检查要看三个层次
- 命令行里的服务器 IP、端口是否正确;
- 进程是否实际创建并绑定了对应 UDP Socket;
- 网络路径是否允许数据报双向到达。
本篇重点解决前两个层次。具体网络命令与跨 Windows 验证,会在相应加餐文章中展开。
10.3 不要为了调通就默认打开端口复用
SO_REUSEADDR / SO_REUSEPORT 有特定的复用语义。入门示例没有开启它们,是为了让重复绑定明确报错,也让"谁占用了这个端口"更容易观察。
遇到地址占用时,先找到现有服务,再决定是否确实需要复用。复用不是所有 bind 错误的通用补丁。
总结
UDP Socket 编程可以沿着一条主线理解:
text
确定通信角色与地址
→ socket 创建资源
→ 服务器 bind 稳定入口
→ 客户端 sendto 发请求
→ 服务器 recvfrom 得到数据和来源
→ 处理后 sendto 回给来源
→ 客户端 recvfrom 接收响应
→ 关闭资源
真正需要记住的不是函数参数数量,而是每个参数在回答什么问题:fd 选择通信资源,缓冲区与长度说明传输内容,地址说明本地入口、目标端点或消息来源。
地址结构中的端口需要网络字节序,地址文本转换必须检查返回值;UDP 成功收包可以得到零字节,数据报被截断后也不能像文件一样继续读取剩余部分。理解了这些边界,代码才不会停留在"输入 hello 看起来能用"的程度。
上篇用回显完成了最小通信闭环。接下来,把"收到什么就返回什么"替换成真正的业务逻辑,再从一个响应扩展为多个客户端的消息转发,就进入下篇的网络字典与聊天室。