【Linux 网络编程】UDP Socket 编程(上):从地址与字节序到完整回显服务器

🔥 星光编译者 · 个人主页

📚 学习专栏: 《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 页的地址转换内容。


文章目录


一、从本地进程通信走向网络通信

学过管道、共享内存和消息队列后,我们已经知道:两个进程可以通过操作系统提供的机制交换数据。但这些机制通常服务于同一台机器上的进程。

现在把问题扩大一点:客户端运行在一台电脑上,服务器运行在另一台 Linux 主机上。两者没有共同的进程地址空间,也不能直接共享同一个普通管道。消息必须经过各自的操作系统和网络设备,才能到达另一台机器上的目标进程。

一次网络通信,至少要解决三个问题:

  1. 去哪里:用 IP 地址描述网络层面的目标地址;
  2. 交给哪个服务:用端口号参与标识目标通信端点;
  3. 按什么方式交换数据:选择 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_;
};

这份头文件有五个值得单独强调的细节:

  1. ParsePort 在转成 uint16_t 之前检查范围,防止 70000 被截成另一个数值;
  2. 析构函数关闭有效描述符,复制操作被禁用;
  3. SendTo 用显式字节长度,合法空消息的发送结果 0 也能通过检查;
  4. Receive 先处理 EINTR 和超时,再检查截断,最后才复制缓冲区;
  5. 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 地址检查要看三个层次

  1. 命令行里的服务器 IP、端口是否正确;
  2. 进程是否实际创建并绑定了对应 UDP Socket;
  3. 网络路径是否允许数据报双向到达。

本篇重点解决前两个层次。具体网络命令与跨 Windows 验证,会在相应加餐文章中展开。

10.3 不要为了调通就默认打开端口复用

SO_REUSEADDR / SO_REUSEPORT 有特定的复用语义。入门示例没有开启它们,是为了让重复绑定明确报错,也让"谁占用了这个端口"更容易观察。

遇到地址占用时,先找到现有服务,再决定是否确实需要复用。复用不是所有 bind 错误的通用补丁。


总结

UDP Socket 编程可以沿着一条主线理解:

text 复制代码
确定通信角色与地址
    → socket 创建资源
    → 服务器 bind 稳定入口
    → 客户端 sendto 发请求
    → 服务器 recvfrom 得到数据和来源
    → 处理后 sendto 回给来源
    → 客户端 recvfrom 接收响应
    → 关闭资源

真正需要记住的不是函数参数数量,而是每个参数在回答什么问题:fd 选择通信资源,缓冲区与长度说明传输内容,地址说明本地入口、目标端点或消息来源。

地址结构中的端口需要网络字节序,地址文本转换必须检查返回值;UDP 成功收包可以得到零字节,数据报被截断后也不能像文件一样继续读取剩余部分。理解了这些边界,代码才不会停留在"输入 hello 看起来能用"的程度。

上篇用回显完成了最小通信闭环。接下来,把"收到什么就返回什么"替换成真正的业务逻辑,再从一个响应扩展为多个客户端的消息转发,就进入下篇的网络字典与聊天室。

相关推荐
企业解惑小能手-域智盾系统1 小时前
手机偷拍电脑屏幕怎么防范?光学泄密攻防实战与落地选型
网络·智能手机·域智盾
灯澜忆梦1 小时前
【Linux】#7 | vi/vim 编辑器
linux·编辑器·vim
凌云若寒1 小时前
BarTender官网下载打印机驱动
运维·服务器·前端·网络·产品经理
遇印记1 小时前
网络安全,数据链路层,物理层
运维·服务器·网络·windows·学习
重生之小比特1 小时前
【Linux系统】08 进程切换
linux·运维·服务器
kyle~1 小时前
Linux --- 内存管理内核子系统
linux·运维·服务器
Raas1001 小时前
MAI Gateway(魔芋企业级AI网关)能力解析:AI网关能做调用审计吗?AI网关核心功能详解
大数据·网络·人工智能·gateway·ai网关·mai gateway
讯捷蓝达1 小时前
戴尔 R750XS服务器不开机, 主板 Pfault fail-safe 电压异常、CPU1 VCCIO PG 电压超出范围维修
运维·服务器
昌原的儿子LEO1 小时前
嵌入式 Linux I2C 驱动开发核心知识总结:设备树与 platform 架构全解析
linux·驱动开发·架构