
🎬 个人主页 :艾莉丝努力练剑
❄专栏传送门 :《C语言》《数据结构与算法》《C/C++干货分享&学习过程记录》
《Linux操作系统编程详解》《笔试/面试常见算法:从基础到进阶》《Python干货分享》
⭐️为天地立心,为生民立命,为往圣继绝学,为万世开太平
🎬 艾莉丝的简介:

文章目录
- [1 ~> 为什么从 UDP 开始?](#1 ~> 为什么从 UDP 开始?)
- [2 ~> UDP Echo Server 整体结构设计](#2 ~> UDP Echo Server 整体结构设计)
-
- [2.1 为什么服务端要封装?](#2.1 为什么服务端要封装?)
- [2.2 Client 为什么不封装?](#2.2 Client 为什么不封装?)
- [3 ~> 第一个系统调用:socket()](#3 ~> 第一个系统调用:socket())
-
- [3.1 socket 的本质是什么?](#3.1 socket 的本质是什么?)
- [3.2 domain(协议族)详解](#3.2 domain(协议族)详解)
-
- [3.2.1 domain(协议族 / 地址族)](#3.2.1 domain(协议族 / 地址族))
- [3.2.2 domain 的真正意义(非常关键)](#3.2.2 domain 的真正意义(非常关键))
- [3.3 type(套接字类型)](#3.3 type(套接字类型))
-
- [3.3.1 UDP vs TCP 的本质区别](#3.3.1 UDP vs TCP 的本质区别)
- [3.4 protocol 为什么填 0?](#3.4 protocol 为什么填 0?)
-
- [3.4.1 protocol=0 的真实含义](#3.4.1 protocol=0 的真实含义)
- [3.5 socket 返回值的真实意义](#3.5 socket 返回值的真实意义)
-
- [3.5.1 为什么socket返回的是fd?](#3.5.1 为什么socket返回的是fd?)
- [3.5.2 第一个 socket 的 fd 通常是多少?](#3.5.2 第一个 socket 的 fd 通常是多少?)
- [3.6 创建 UDP Socket 示例代码](#3.6 创建 UDP Socket 示例代码)
- [4 ~> 第二个系统调用:bind()](#4 ~> 第二个系统调用:bind())
-
- [4.1 为什么必须 bind?](#4.1 为什么必须 bind?)
-
- [4.1.1 bind 的真实作用](#4.1.1 bind 的真实作用)
- [4.1.2 IP 与端口的真实分工](#4.1.2 IP 与端口的真实分工)
- [4.2 sockaddr_in 结构体详解](#4.2 sockaddr_in 结构体详解)
-
- [4.2.1 sin_family(地址族)](#4.2.1 sin_family(地址族))
- [4.2.2 sin_port(端口号)](#4.2.2 sin_port(端口号))
-
- [4.2.2.1 为什么必须 htons?](#4.2.2.1 为什么必须 htons?)
- [4.2.3 sin_addr(IP 地址)](#4.2.3 sin_addr(IP 地址))
- [4.2.4 sin_zero(填充字段)](#4.2.4 sin_zero(填充字段))
- [4.3 完整 bind 示例代码](#4.3 完整 bind 示例代码)
- [4.4 bind 的真正意义:把网络引入系统](#4.4 bind 的真正意义:把网络引入系统)
- [4.5 bind 的常见错误](#4.5 bind 的常见错误)
- [4.6 bind 任意地址:INADDR_ANY](#4.6 bind 任意地址:INADDR_ANY)
-
- [4.6.1 为什么推荐使用 INADDR_ANY?](#4.6.1 为什么推荐使用 INADDR_ANY?)
- [4.7 云服务器 bind 的特殊情况](#4.7 云服务器 bind 的特殊情况)
- [4.8 总结](#4.8 总结)
- [5 ~> UDP 接收数据:recvfrom()](#5 ~> UDP 接收数据:recvfrom())
-
- [5.1 为什么 UDP 必须使用 recvfrom?](#5.1 为什么 UDP 必须使用 recvfrom?)
- [5.2 recvfrom 参数完整解析](#5.2 recvfrom 参数完整解析)
-
- [5.2.1 第一组:收货基础(三个参数)](#5.2.1 第一组:收货基础(三个参数))
-
- [5.2.1.1 sockfd ------ 从哪个 socket 收?](#5.2.1.1 sockfd —— 从哪个 socket 收?)
- [5.2.1.2 buf ------ 数据放哪里?](#5.2.1.2 buf —— 数据放哪里?)
- [5.2.1.3 len ------ 缓冲区有多大?](#5.2.1.3 len —— 缓冲区有多大?)
- [5.2.2 第二组:接收方式(flags)](#5.2.2 第二组:接收方式(flags))
-
- [5.2.2.1 flags 常见扩展(了解)](#5.2.2.1 flags 常见扩展(了解))
- [5.2.3 第三组:最关键部分 ------ 谁发来的?](#5.2.3 第三组:最关键部分 —— 谁发来的?)
-
- [5.2.3.1 src_addr ------ 发件人地址(输出参数)](#5.2.3.1 src_addr —— 发件人地址(输出参数))
- [5.2.3.2 addrlen ------ 地址长度(输入输出参数)](#5.2.3.2 addrlen —— 地址长度(输入输出参数))
- [5.3 recvfrom返回值三种情况](#5.3 recvfrom返回值三种情况)
-
- [5.3.1 情况 1:n > 0(成功)](#5.3.1 情况 1:n > 0(成功))
- [5.3.2 情况 2:n == 0(极少见)](#5.3.2 情况 2:n == 0(极少见))
- [5.3.3 情况 3:n == -1(失败)](#5.3.3 情况 3:n == -1(失败))
- [5.4 recvfrom 工作全过程](#5.4 recvfrom 工作全过程)
- [5.5 标准 recvfrom 示例代码](#5.5 标准 recvfrom 示例代码)
- [5.6 recvfrom标准用法(addrlen 重点)](#5.6 recvfrom标准用法(addrlen 重点))
- [5.7 recvfrom 的工程意义](#5.7 recvfrom 的工程意义)
- [5.7 总结](#5.7 总结)
- [6 ~> UDP 发送数据:sendto()](#6 ~> UDP 发送数据:sendto())
-
- [6.0 准备工作](#6.0 准备工作)
- [6.1 为什么 UDP 必须使用 sendto?](#6.1 为什么 UDP 必须使用 sendto?)
- [6.2 sendto 参数](#6.2 sendto 参数)
-
- [6.2.1 第一组:发送的数据(三个参数)](#6.2.1 第一组:发送的数据(三个参数))
-
- [6.2.1.1 sockfd ------ 从哪个 socket 发?](#6.2.1.1 sockfd —— 从哪个 socket 发?)
- [6.2.1.2 buf ------ 要发送的数据](#6.2.1.2 buf —— 要发送的数据)
- [6.2.1.3 len ------ 要发送多少字节](#6.2.1.3 len —— 要发送多少字节)
- [6.2.2 第二组:发送方式(flags)](#6.2.2 第二组:发送方式(flags))
- [6.2.3 第三组:目标地址](#6.2.3 第三组:目标地址)
-
- [6.2.3.1 dest_addr ------ 发送给谁?](#6.2.3.1 dest_addr —— 发送给谁?)
- [6.2.3.2 addrlen ------ 地址长度](#6.2.3.2 addrlen —— 地址长度)
- [6.3 sendto 的返回值](#6.3 sendto 的返回值)
-
- [6.3.1 n > 0(成功)](#6.3.1 n > 0(成功))
- [6.3.2 n == -1(失败)](#6.3.2 n == -1(失败))
- [6.4 sendto 与 recvfrom 的镜像关系](#6.4 sendto 与 recvfrom 的镜像关系)
- [6.5 Echo Server 的完整闭环](#6.5 Echo Server 的完整闭环)
- [6.6 为什么 UDP 每次都要写地址?](#6.6 为什么 UDP 每次都要写地址?)
- [6.7 总结](#6.7 总结)
- [7 ~> netstat 调试 UDP 服务](#7 ~> netstat 调试 UDP 服务)
-
- [7.1 netstat -uap:调试 UDP 的黄金组合](#7.1 netstat -uap:调试 UDP 的黄金组合)
-
- [7.1.1 -u:只显示UDP](#7.1.1 -u:只显示UDP)
- [7.1.2 -a:显示所有 socket](#7.1.2 -a:显示所有 socket)
- [7.1.3 -p:显示进程信息](#7.1.3 -p:显示进程信息)
- [7.2 如何判断服务器是否真的启动?](#7.2 如何判断服务器是否真的启动?)
- [7.3 端口被占用怎么办?](#7.3 端口被占用怎么办?)
- [7.4 Recv-Q 的真正意义](#7.4 Recv-Q 的真正意义)
-
- [7.4.1 Recv-Q 不断增大意味着什么?](#7.4.1 Recv-Q 不断增大意味着什么?)
- [7.5 -n 参数:必须养成的习惯](#7.5 -n 参数:必须养成的习惯)
- [7.6 常见 netstat 组合](#7.6 常见 netstat 组合)
-
- [7.6.1 查看 UDP 监听](#7.6.1 查看 UDP 监听)
- [7.6.2 查某个端口](#7.6.2 查某个端口)
- [7.6.3 只看监听 socket](#7.6.3 只看监听 socket)
- [7.7 为什么现在更推荐 ss?](#7.7 为什么现在更推荐 ss?)
- [7.8 总结](#7.8 总结)
- [8 ~> 客户端设计:为什么通常不 bind?](#8 ~> 客户端设计:为什么通常不 bind?)
-
- [8.1 OS 客户端真的没有 bind 吗?](#8.1 OS 客户端真的没有 bind 吗?)
-
- [8.1.1 sendto() 会隐式触发 bind](#8.1.1 sendto() 会隐式触发 bind)
- [8.2 操作系统如何分配客户端端口?](#8.2 操作系统如何分配客户端端口?)
- [8.3 为什么客户端不能随便 bind 端口?](#8.3 为什么客户端不能随便 bind 端口?)
-
- [8.3.1 真实问题:端口冲突](#8.3.1 真实问题:端口冲突)
- [8.4 为什么服务器必须手动 bind?](#8.4 为什么服务器必须手动 bind?)
-
- [8.4.1 类比理解](#8.4.1 类比理解)
- [8.5 客户端必须关心什么?](#8.5 客户端必须关心什么?)
-
- [8.5.1 客户端需要准备的地址信息](#8.5.1 客户端需要准备的地址信息)
- [8.6 为什么客户端必须知道服务器地址?](#8.6 为什么客户端必须知道服务器地址?)
- [8.7 一个 socket 能访问多个服务器吗?](#8.7 一个 socket 能访问多个服务器吗?)
-
- [8.7.1 实际应用场景](#8.7.1 实际应用场景)
- [8.8 客户端与服务器通信顺序对比](#8.8 客户端与服务器通信顺序对比)
-
- [8.8.1 服务器顺序](#8.8.1 服务器顺序)
- [8.8.2 客户端顺序](#8.8.2 客户端顺序)
- [8.9 总结](#8.9 总结)
- [9 ~> IP 地址表示与字节序问题(大端序 vs 小端序)](#9 ~> IP 地址表示与字节序问题(大端序 vs 小端序))
-
- [9.0 准备工作](#9.0 准备工作)
- [9.1 点分十进制 IP 的本质是什么?](#9.1 点分十进制 IP 的本质是什么?)
- [9.2 为什么不能直接使用字符串 IP?](#9.2 为什么不能直接使用字符串 IP?)
- [9.3 inet_addr() 的真正作用](#9.3 inet_addr() 的真正作用)
- [9.4 什么是字节序(Endian)?](#9.4 什么是字节序(Endian)?)
-
- [9.4.1 大端序(网络字节序)](#9.4.1 大端序(网络字节序))
- [9.4.2 小端序(主机字节序)](#9.4.2 小端序(主机字节序))
- [9.5 为什么网络统一使用大端序?](#9.5 为什么网络统一使用大端序?)
- [9.6 htons / ntohs 的真正意义](#9.6 htons / ntohs 的真正意义)
-
- [9.6.1 htons()](#9.6.1 htons())
- [9.6.2 ntohs()](#9.6.2 ntohs())
- [9.7 为什么端口必须转换?](#9.7 为什么端口必须转换?)
- [9.8 inet_ntoa() 的真正作用](#9.8 inet_ntoa() 的真正作用)
- [9.9 IP 字符串 ↔ 4字节 IP 的双向转换](#9.9 IP 字符串 ↔ 4字节 IP 的双向转换)
- [9.10 大端与小端对 IP 的影响](#9.10 大端与小端对 IP 的影响)
- [9.11 内存地址顺序永远不变](#9.11 内存地址顺序永远不变)
- [9.12 总结](#9.12 总结)
- [10 ~> bind 任意地址 INADDR_ANY(重点)](#10 ~> bind 任意地址 INADDR_ANY(重点))
-
- [10.1 绑定具体 IP 会发生什么?](#10.1 绑定具体 IP 会发生什么?)
- [10.2 多网卡环境下的问题](#10.2 多网卡环境下的问题)
- [10.3 INADDR_ANY 的真正意义](#10.3 INADDR_ANY 的真正意义)
- [10.4 为什么生产环境强烈推荐 INADDR_ANY?](#10.4 为什么生产环境强烈推荐 INADDR_ANY?)
- [10.5 云服务器为什么不能 bind 公网 IP?](#10.5 云服务器为什么不能 bind 公网 IP?)
-
- [10.5.1 云服务器真实网络结构](#10.5.1 云服务器真实网络结构)
- [10.6 bind 0.0.0.0 是服务器最佳实践](#10.6 bind 0.0.0.0 是服务器最佳实践)
- [10.7 bind 任意地址的代码示例(标准写法)](#10.7 bind 任意地址的代码示例(标准写法))
- [10.8 总结](#10.8 总结)
- [11 ~> 构建 UDP 字典服务器](#11 ~> 构建 UDP 字典服务器)
- [12 ~> UDP 客户端实现](#12 ~> UDP 客户端实现)
- 结尾

1 ~> 为什么从 UDP 开始?
在系统编程(如进程、线程)学习中,通常是:先讲理论,再写代码。
但网络编程的学习方式恰好不同:先写代码,再反推原理。
原因很简单:
- 网络协议本身就是工程系统
- 很多细节只有在代码中才会真正暴露出来。
而 UDP 是所有协议里结构最简单的一种,它具备以下特点:
- 无连接
- 面向数据报
- 不保证可靠性
- API 简单
这让UDP成为理解Socket编程的最佳起点。
2 ~> UDP Echo Server 整体结构设计
在开始写代码之前,必须先明确一件事:
- 服务器不是一段代码,而是一个结构。
我们实现的第一个网络程序:UDP Echo Server
这个程序逻辑非常简单:
客户端发送消息
↓ 服务器接收消息
↓ 服务器原样返回
↓ 客户端收到响应
这是一个很经典的Echo(回显)模型。
2.1 为什么服务端要封装?
在工程设计中:服务器代码通常要封装。
而客户端可以先简单写。
原因是:
服务器往往要负责:
- socket 创建
- bind 绑定
- 接收数据
- 发送数据
- 日志处理
- 资源释放
如果全部写在 main() 里,很快就会失控。
因此我们通常设计成UdpServer类。
这个类的典型接口如下:
cpp
class UdpServer
{
public:
UdpServer(uint16_t port);
~UdpServer();
bool Init();
void Start();
private:
int sockfd;
};
本质都是:Socket + 事件循环。
2.2 Client 为什么不封装?
客户端通常逻辑简单:
发送 ~> 接收 ~> 退出
没有长期运行需求。
因此,初期客户端可以直接写在 main 中减少复杂度,更利于理解流程。
3 ~> 第一个系统调用:socket()
我会介绍下面这些内容:
- socket 本质是什么
- domain/type/protocol 真正意义
- 为什么 socket 返回的是文件描述符
这一部分如果理解透了,后面 bind / recvfrom / sendto 的理解会非常顺。
在真正开始网络通信之前,第一件必须做的事情就是:
- 创建一个 Socket(套接字)
在 Linux 网络编程中,几乎所有通信的起点都是这个系统调用:
cpp
int socket(int domain, int type, int protocol);
这是我们接触到的第一个真正的网络系统调用,也是所有后续操作的基础。
3.1 socket 的本质是什么?
把 socket 当成 "网络连接" 是一个误解。
更准确的理解是:socket 是内核为你创建的一种通信端点(communication endpoint)。
把它想象成:

当我调用:
cpp
int sockfd = socket(...);
实际上做了这些事情:
- 内核创建一个 socket 对象
- 为它分配资源(缓冲区等)
- 返回一个 文件描述符(fd)
这个 fd 的意义非常重要:socket 在 Linux 中,本质就是一个文件描述符。
这也是为什么这些系统调用能够作用到socket上面:
cpp
read()
write()
close()
这句话也是老生常谈的了:Linux 的哲学是一切皆文件(Everything is a file)。
3.2 domain(协议族)详解
来看函数原型:
cpp
int socket(int domain, int type, int protocol);
这里的三个参数都很关键。
3.2.1 domain(协议族 / 地址族)
domain 决定了你要使用哪一种通信方式。
直接翻译就是域。

最常见的两个:
cpp
AF_INET // IPv4 网络通信
AF_UNIX // 本地进程通信
除此之外还有:
cpp
AF_INET6 // IPv6
AF_BLUETOOTH // 蓝牙通信
不过在实际开发中:99% 的场景只用 AF_INET。
3.2.2 domain 的真正意义(非常关键)
把 AF_INET 理解成:"表示 IPv4"。
但它真正的意义是:选择协议栈分支。
当我调用:
cpp
socket(AF_INET, ...);
相当于告诉内核:内核,我要走 IPv4 网络协议栈。
如果是:
cpp
socket(AF_UNIX, ...);
则会走Unix Domain Socket 协议栈。
这就是一种非常典型的解耦设计!
你调用的 API 没变:
cpp
socket()
但通过 domain 参数,内核会进入完全不同的实现路径。
可以把 socket 想象成:
一个通用插座(socket的中文好像就是插座,老外真是的,取得什么名字)。

而 domain决定你插的是哪种电源。
bash
IPv4
IPv6
本地通信
蓝牙通信
全部可以通过同一接口实现。
这就是协议解耦的经典设计。
3.3 type(套接字类型)
第二个参数:
cpp
type
这个参数决定了通信方式是什么!
最常见的两个:
cpp
SOCK_STREAM // TCP
SOCK_DGRAM // UDP
我们现在学习的是:
cpp
SOCK_DGRAM
也就是 UDP(面向数据报,TCP是面向数据流,下面我会对比一下两者)。
3.3.1 UDP vs TCP 的本质区别
TCP:
bash
面向连接
可靠
有序
流式传输
UDP:
bash
无连接
不保证可靠
面向数据报
UDP 的最大特点是:
- 每次发送都是一个完整的数据报
不会拆成流。
这也是为什么UDP 不推荐使用:
cpp
read()
write()
而推荐使用:
cpp
recvfrom()
sendto()
3.4 protocol 为什么填 0?
第三个参数:
cpp
protocol
在绝大多数情况下protocol = 0。
3.4.1 protocol=0 的真实含义
当我写:
cpp
socket(AF_INET, SOCK_DGRAM, 0);
内核会自动推导:
bash
AF_INET + SOCK_DGRAM
→ UDP
如果是:
cpp
socket(AF_INET, SOCK_STREAM, 0);
则自动推导:
bash
TCP
因此:protocol = 0 表示自动选择默认协议
也可以显式写:IPPROTO_UDP,不过没有这个必要,直接设置为0就行。
3.5 socket 返回值的真实意义
返回值:
cpp
int sockfd
如果成功,返回一个文件描述符。
如果失败,返回-1。
并设置:
cpp
errno
3.5.1 为什么socket返回的是fd?
因为在 Linux 中:
cpp
socket = 文件描述符
这就意味着:
我可以:
cpp
close(sockfd);
关闭 socket。
也意味着,socket 的底层管理方式,与文件完全一致。
3.5.2 第一个 socket 的 fd 通常是多少?
通常是3。
因为:
bash
0 → stdin
1 → stdout
2 → stderr
前三个已经被占用。
所以,第一个 socket 一般是 fd = 3。
3.6 创建 UDP Socket 示例代码
这是标准 UDP socket 创建代码:
cpp
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
perror("socket");
exit(1);
}
执行成功后,我们就拥有了一个 UDP 通信端点。
但注意,此时 socket 还不能接收数据,因为它还没有:
bash
IP 地址
端口号
换句话说就是:你创建了一个"电话机",但还没有"电话号码"。
接下来要做的事情就是给 socket 绑定地址。
4 ~> 第二个系统调用:bind()
- 真正决定服务器是否能被访问的关键步骤:
bind()。
如果这里理解不透,后面很多问题(端口冲突、收不到包、云服务器访问失败)都会变成"玄学"。
这里我们会介绍:
bash
为什么服务器必须 bind
sockaddr_in 结构体完整解析
IP 和端口的真实意义
htons / inet_addr 为什么必须存在
- 下面我们正式开始。
在创建完 socket 之后:
cpp
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
我只是拥有了一个通信端点(socket)。
但此时它没有 IP,也没有端口。
换句话说:我有一部电话但没有电话号码。
这时必须执行第二个关键操作:
cpp
bind()
函数原型如下:
cpp
int bind(
int sockfd,
const struct sockaddr *addr,
socklen_t addrlen
);
它的作用非常明确:把 IP 地址和端口号绑定到 socket 上。
4.1 为什么必须 bind?
为什么客户端通常不 bind,而服务器必须 bind?
答案非常简单:
- 服务器是被访问的对象;客户端是主动访问的人。
4.1.1 bind 的真实作用
执行:
cpp
bind(sockfd, ...)
本质是在告诉内核:
以后发往某个 IP + 某个 Port 的数据,全部交给这个
socket
也就是说:
网络数据
↓ IP匹配
↓ 端口匹配
↓ socket 接收
↓ 进程处理
如果你不 bind,内核根本不知道应该把数据交给谁。
4.1.2 IP 与端口的真实分工
很多人只知道:
bash
IP + Port
但不知道它们的真实职责。
其实它们分别解决两个不同问题:
bash
IP → 找到主机
Port → 找到进程
可以这样理解:
IP= 大楼地址
Port= 房间号
Process= 房间里的人
如果没有端口:
信寄到大楼,但没人知道给谁。
所以:网络通信的本质其实是:进程通信,不是主机通信。
4.2 sockaddr_in 结构体详解
在调用 bind 之前,必须先准备:
cpp
sockaddr_in
它是IPv4 地址结构体。
定义如下:
cpp
struct sockaddr_in
{
sa_family_t sin_family;
uint16_t sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
艾莉丝接下来就逐个拆解。
4.2.1 sin_family(地址族)
cpp
sin_family = AF_INET;
表示使用 IPv4,这一点必须与:
cpp
socket(AF_INET, ...)
保持一致,否则 bind 会失败。
4.2.2 sin_port(端口号)
端口号:
cpp
uint16_t sin_port;
范围:0 ~ 65535。
但这里必须注意一个非常重要的问题:端口号必须转换成网络字节序。
也就是:
cpp
htons()
例如:
cpp
local.sin_port = htons(8080);
4.2.2.1 为什么必须 htons?
因为 主机字节序 ≠ 网络字节序。
大多数机器都是小端序(Little Endian)
而网络规定大端序(Big Endian)
如果不转换,端口号会错乱。例如:
bash
8080 → 0x1F90
小端存储:
90 1F
网络期望:
1F 90
所以必须:
cpp
htons()
它的含义:
cpp
Host To Network Short
4.2.3 sin_addr(IP 地址)
IP 地址:
cpp
struct in_addr sin_addr;
但这里又出现一个问题,我们平时写的是:
bash
192.168.1.1
这是字符串
但网络需要的是4字节整数,因此必须转换:
cpp
inet_addr()
例如:
cpp
local.sin_addr.s_addr = inet_addr("127.0.0.1");
这个函数会同时完成两件事情:
字符串IP → 4字节IP
主机序 → 网络序
4.2.4 sin_zero(填充字段)
cpp
char sin_zero[8];
它的作用是:
- 占位
- 对齐结构体
通常写:
cpp
memset(&local, 0, sizeof(local));
统一清零即可。
4.3 完整 bind 示例代码
标准 UDP bind 写法:
cpp
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8080);
local.sin_addr.s_addr = inet_addr("127.0.0.1");
int ret = bind(
sockfd,
(struct sockaddr*)&local,
sizeof(local)
);
if (ret < 0)
{
perror("bind");
exit(1);
}
执行成功后,这个 socket 就拥有了IP + Port。
也就意味着它正式进入网络世界。
4.4 bind 的真正意义:把网络引入系统
在调用 socket() 之后,只是系统概念。
调用 bind() 之后,网络真正介入
可以理解为:
socket() → 创建电话
bind() → 注册电话号码
只有注册之后,别人才能打给你。
4.5 bind 的常见错误
实际开发中,最常见错误是:
css
Address already in use
原因通常是端口被占用。
可以用下面的指令查看哪个进程占用了端口:
bash
netstat -uap | grep 8080
然后释放端口:
bash
kill -9 PID
这属于网络开发必须掌握的基本排查技能。
4.6 bind 任意地址:INADDR_ANY
在真实服务器开发中,最常见的写法其实不是:
cpp
inet_addr("127.0.0.1")
而是:INADDR_ANY。
写法:
cpp
local.sin_addr.s_addr = INADDR_ANY;
它代表0.0.0.0,含义是绑定本机所有 IP 地址。
4.6.1 为什么推荐使用 INADDR_ANY?
因为一台服务器可能有多个网卡、多个 IP。
例如:
bash
192.168.1.10
10.0.0.5
127.0.0.1
那么发往 10.0.0.5 的数据不会交给我;而如果绑定INADDR_ANY则表示所有发往该端口的数据全部接收。
这才是生产环境的推荐做法。
4.7 云服务器 bind 的特殊情况
尝试:
cpp
inet_addr("公网IP")
结果:
bash
bind 失败
EADDRNOTAVAIL
原因是公网 IP 并不在你的机器上!
真实结构是:
公网IP → 云网关 → 内网IP
中间发生了 NAT 转换。
因此正确写法永远是:
cpp
INADDR_ANY
这是云服务器标准实践。
4.8 总结

5 ~> UDP 接收数据:recvfrom()
这个是UDP的灵魂!
艾莉丝会介绍下面的内容:
- recvfrom 每个参数真实意义
- 为什么 UDP 必须 recvfrom
- src_addr 的真正价值
- 返回值三种情况
- 如何知道"是谁发来的数据"
如果说 socket() 是创建通信能力,bind() 是让别人能找到你,那么 recvfrom() 才是服务器真正开始"工作"的地方。
我们正式开始。
当 socket() 和 bind() 完成之后:
cpp
socket();
bind();
服务器终于拥有了:IP + Port。也就是说,别人现在可以向你发送数据了。
接下来服务器要做的事情只有一件:
等待客户端发来数据。
这一步就是通过recvfrom()完成的。
函数原型如下:
cpp
ssize_t recvfrom(
int sockfd,
void *buf,
size_t len,
int flags,
struct sockaddr *src_addr,
socklen_t *addrlen
);
这个函数是 UDP 编程中最重要的接口之一。
5.1 为什么 UDP 必须使用 recvfrom?
- 既然 socket 是文件描述符,为什么不能直接用
read()?
理论上可以这样:
cpp
read(sockfd, buf, len);
但在 UDP 中不推荐使用 read() 原因非常关键:UDP 是无连接协议!
这就意味着:每个数据包可能来自不同客户端。
如果你用 read(),你只能拿到数据,但不知道是谁发的------这在 UDP 中几乎是致命问题。
但是有了recvfrom就不一样了:
cpp
recvfrom()
不仅能读数据,还能告诉你是谁发来的数据,这才是它真正的价值。
5.2 recvfrom 参数完整解析
我们把参数分成三组来理解。
5.2.1 第一组:收货基础(三个参数)
cpp
int sockfd
void *buf
size_t len
这三个参数的含义非常直观,我们简单来看一下。
5.2.1.1 sockfd ------ 从哪个 socket 收?
cpp
int sockfd
表示从哪个 socket 接收数据。
内核通过这个 fd 找到对应的 socket。
可以理解为:收货窗口编号。
5.2.1.2 buf ------ 数据放哪里?
cpp
void *buf
表示接收缓冲区。
内核收到数据后,会把数据:内核缓冲区 → 拷贝 → 用户缓冲区
也就是:网络 → 内核 → 用户程序
典型写法:
cpp
char buffer[1024];
5.2.1.3 len ------ 缓冲区有多大?
cpp
size_t len
告诉内核最多可以放多少字节。
如果对方发的数据超过 len,多余数据会被丢弃,这点在 UDP 中尤其重要。
因为 UDP 是面向数据报的。
5.2.2 第二组:接收方式(flags)
cpp
int flags
通常写0,表示阻塞接收。
也就是:如果没有数据,程序会等待。
5.2.2.1 flags 常见扩展(了解)

5.2.3 第三组:最关键部分 ------ 谁发来的?
cpp
struct sockaddr *src_addr
socklen_t *addrlen
这两个参数是 recvfrom 的灵魂。
5.2.3.1 src_addr ------ 发件人地址(输出参数)
cpp
struct sockaddr *src_addr
调用之前这个结构是空的;调用之后,内核会帮你填上:
- 对方的 IP
- 对方的 Port
这就是UDP 能识别客户端的关键。
最佳实践:
cpp
struct sockaddr_in peer;
然后:
cpp
recvfrom(
sockfd,
buffer,
sizeof(buffer),
0,
(struct sockaddr*)&peer,
&len
);
执行完成后:
cpp
peer.sin_addr
peer.sin_port
就包含了客户端地址。
5.2.3.2 addrlen ------ 地址长度(输入输出参数)

5.3 recvfrom返回值三种情况
返回值:ssize_t n。
这个返回值的意义非常关键,必须严格判断。
5.3.1 情况 1:n > 0(成功)

5.3.2 情况 2:n == 0(极少见)

5.3.3 情况 3:n == -1(失败)

5.4 recvfrom 工作全过程
完整流程:
客户端
sendto()
↓ 网卡收到数据
↓ 内核协议栈处理
↓ 数据进入 socket 接收缓冲区
↓recvfrom()取出数据
↓ 复制到 buf
↓ 填充 src_addr
重点是 recvfrom 不仅给你数据,还给你"发件人信息"。
5.5 标准 recvfrom 示例代码
这是一个典型服务器接收代码:
cpp
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
ssize_t n = recvfrom(
sockfd,
buffer,
sizeof(buffer) - 1,
0,
(struct sockaddr*)&peer,
&len
);
if (n > 0)
{
buffer[n] = 0;
printf(
"收到来自 %s:%d 的消息: %s\n",
inet_ntoa(peer.sin_addr),
ntohs(peer.sin_port),
buffer
);
}
else
{
perror("recvfrom");
}
这里还有两个关键函数:
cpp
inet_ntoa()
ntohs()
它们的作用是:网络格式 → 人类可读格式。
否则你看到的只是二进制。
5.6 recvfrom标准用法(addrlen 重点)
cpp
#include <iostream>
#include <cstring>
#include <arpa/inet.h>
#include <unistd.h>
int main()
{
// 1. 创建 socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
perror("socket");
return 1;
}
// 2. 绑定地址
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8080);
local.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd,
(struct sockaddr*)&local,
sizeof(local)) < 0)
{
perror("bind");
return 2;
}
char buffer[1024];
while (true)
{
// 3. 定义客户端地址结构
struct sockaddr_in peer;
// 4. addrlen 必须初始化
socklen_t addrlen = sizeof(peer);
// 5. recvfrom 接收数据
ssize_t n = recvfrom(
sockfd,
buffer,
sizeof(buffer) - 1,
0,
(struct sockaddr*)&peer,
&addrlen // 重点:传地址长度变量地址
);
if (n > 0)
{
buffer[n] = 0;
std::cout
<< "from "
<< inet_ntoa(peer.sin_addr)
<< ":"
<< ntohs(peer.sin_port)
<< " -> "
<< buffer
<< std::endl;
}
}
close(sockfd);
return 0;
}
5.7 recvfrom 的工程意义

- 服务器不是复杂算法,而是稳定循环。
5.7 总结

6 ~> UDP 发送数据:sendto()
本部分会介绍:
- sendto 参数真实含义
- sendto 与 recvfrom 的镜像关系
- UDP 为什么每次都必须写目标地址
- 返回值真正代表什么
继续推进到 sendto() 。

6.0 准备工作

6.1 为什么 UDP 必须使用 sendto?

6.2 sendto 参数
我们同样把参数拆成三组理解:
- 货物
- 发送方式
- 目标地址
6.2.1 第一组:发送的数据(三个参数)
cpp
int sockfd
const void *buf
size_t len
6.2.1.1 sockfd ------ 从哪个 socket 发?

6.2.1.2 buf ------ 要发送的数据

6.2.1.3 len ------ 要发送多少字节

6.2.2 第二组:发送方式(flags)

6.2.3 第三组:目标地址
cpp
const struct sockaddr *dest_addr
socklen_t addrlen
这一组参数就是**sendto的灵魂**,因为 UDP 没有连接。
6.2.3.1 dest_addr ------ 发送给谁?
cpp
const struct sockaddr *dest_addr
表示目标 IP + Port,就例如:
cpp
struct sockaddr_in peer;
里面填:
cpp
peer.sin_family = AF_INET;
peer.sin_port = htons(8080);
peer.sin_addr.s_addr = inet_addr("127.0.0.1");
然后:
cpp
sendto(
sockfd,
buffer,
len,
0,
(struct sockaddr*)&peer,
sizeof(peer)
);
UDP 每次发送都必须带地址。
6.2.3.2 addrlen ------ 地址长度

6.3 sendto 的返回值
返回值:ssize_t n。
含义:实际发送的字节数。
6.3.1 n > 0(成功)

6.3.2 n == -1(失败)

6.4 sendto 与 recvfrom 的镜像关系

cpp
recvfrom ←→ sendto
这两个是一对完全对称的接口------这也是 Unix API 设计的一种美。
6.5 Echo Server 的完整闭环
现在我们可以完成:
接收 → 处理 → 返回
代码如下:
cpp
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
while (true)
{
ssize_t n = recvfrom(
sockfd,
buffer,
sizeof(buffer) - 1,
0,
(struct sockaddr*)&peer,
&len
);
if (n > 0)
{
buffer[n] = 0;
printf(
"收到来自 %s:%d 的消息: %s\n",
inet_ntoa(peer.sin_addr),
ntohs(peer.sin_port),
buffer
);
sendto(
sockfd,
buffer,
n,
0,
(struct sockaddr*)&peer,
len
);
}
}
这就是 UDP Echo Server 的最核心代码。
6.6 为什么 UDP 每次都要写地址?

6.7 总结

7 ~> netstat 调试 UDP 服务
netstat是一个网络调试工具。
现实开发中,你遇到的很多问题,并不是代码写错,而是:
- 服务到底有没有启动?端口是不是被占用?数据到底有没有到达?
我们正式开始:

7.1 netstat -uap:调试 UDP 的黄金组合
最常用的一条命令:
Bash
netstat -uap
这一条命令,基本可以解决 80% 的 UDP 调试问题。
7.1.1 -u:只显示UDP

7.1.2 -a:显示所有 socket

7.1.3 -p:显示进程信息

7.2 如何判断服务器是否真的启动?

7.3 端口被占用怎么办?

7.4 Recv-Q 的真正意义

7.4.1 Recv-Q 不断增大意味着什么?

7.5 -n 参数:必须养成的习惯

7.6 常见 netstat 组合
这里是工程级的、实际开发中最常用的几种组合。
7.6.1 查看 UDP 监听

7.6.2 查某个端口

7.6.3 只看监听 socket

7.7 为什么现在更推荐 ss?

7.8 总结

8 ~> 客户端设计:为什么通常不 bind?
这个部分,艾莉丝会介绍:

- 为什么服务器必须 bind,而客户端通常不需要 bind?
我们正式开始:

8.1 OS 客户端真的没有 bind 吗?

8.1.1 sendto() 会隐式触发 bind

却仍然能够进行通信。
8.2 操作系统如何分配客户端端口?

8.3 为什么客户端不能随便 bind 端口?

8.3.1 真实问题:端口冲突

8.4 为什么服务器必须手动 bind?

8.4.1 类比理解

8.5 客户端必须关心什么?

8.5.1 客户端需要准备的地址信息
cpp
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(8080);
server.sin_addr.s_addr = inet_addr("127.0.0.1");
这段代码的本质是:准备目标地址而不是不是本地地址。
8.6 为什么客户端必须知道服务器地址?

8.7 一个 socket 能访问多个服务器吗?

8.7.1 实际应用场景

8.8 客户端与服务器通信顺序对比
8.8.1 服务器顺序

8.8.2 客户端顺序

8.9 总结

9 ~> IP 地址表示与字节序问题(大端序 vs 小端序)
这部分,艾莉丝会介绍:

这个部分属于底层理解:

9.0 准备工作

9.1 点分十进制 IP 的本质是什么?

9.2 为什么不能直接使用字符串 IP?

9.3 inet_addr() 的真正作用

9.4 什么是字节序(Endian)?

9.4.1 大端序(网络字节序)

9.4.2 小端序(主机字节序)

9.5 为什么网络统一使用大端序?

9.6 htons / ntohs 的真正意义
这两个函数是网络编程中最常见的函数之一。
9.6.1 htons()

9.6.2 ntohs()

9.7 为什么端口必须转换?

9.8 inet_ntoa() 的真正作用

9.9 IP 字符串 ↔ 4字节 IP 的双向转换

9.10 大端与小端对 IP 的影响

不能自己乱处理。
9.11 内存地址顺序永远不变

9.12 总结
这个部分解决了"为什么必须做字节序转换?"的问题。

10 ~> bind 任意地址 INADDR_ANY(重点)

10.1 绑定具体 IP 会发生什么?

10.2 多网卡环境下的问题

10.3 INADDR_ANY 的真正意义

10.4 为什么生产环境强烈推荐 INADDR_ANY?

10.5 云服务器为什么不能 bind 公网 IP?

10.5.1 云服务器真实网络结构

10.6 bind 0.0.0.0 是服务器最佳实践

10.7 bind 任意地址的代码示例(标准写法)
推荐服务器写法:
cpp
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8080);
local.sin_addr.s_addr = INADDR_ANY;
int ret = bind(
sockfd,
(struct sockaddr*)&local,
sizeof(local)
);
if (ret < 0)
{
perror("bind");
exit(1);
}
这个版本可以直接部署到云服务器,不用修改。
10.8 总结

11 ~> 构建 UDP 字典服务器


我们正式开始:

字典


代码
dict.txt(字典数据)
txt
hello 你好
world 世界
apple 苹果
banana 香蕉
linux 操作系统
socket 套接字
udp 用户数据报协议
tcp 传输控制协议
服务器代码(server.cpp)
这是完整 UDP 字典服务器。
cpp
#include <iostream>
#include <unordered_map>
#include <string>
#include <fstream>
#include <cstring>
#include <arpa/inet.h>
#include <unistd.h>
#define PORT 8080
// 字典类
class Dictionary
{
public:
bool Load(const std::string& filename)
{
std::ifstream in(filename);
if (!in.is_open())
{
std::cerr << "open file failed\n";
return false;
}
std::string word;
std::string meaning;
while (in >> word >> meaning)
{
dict[word] = meaning;
}
std::cout
<< "load dictionary success, size="
<< dict.size()
<< std::endl;
return true;
}
std::string Query(const std::string& word)
{
auto it = dict.find(word);
if (it == dict.end())
{
return "Not Found";
}
return it->second;
}
private:
std::unordered_map<
std::string,
std::string
> dict;
};
int main()
{
// 1. 加载字典
Dictionary dict;
if (!dict.Load("dict.txt"))
{
return 1;
}
// 2. 创建 socket
int sockfd =
socket(AF_INET,
SOCK_DGRAM,
0);
if (sockfd < 0)
{
perror("socket");
return 2;
}
// 3. 绑定地址
struct sockaddr_in local;
memset(&local, 0,
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)
{
perror("bind");
return 3;
}
std::cout
<< "UDP Dictionary Server Start..."
<< std::endl;
char buffer[1024];
while (true)
{
struct sockaddr_in peer;
socklen_t len =
sizeof(peer);
// 4. 接收请求
ssize_t n =
recvfrom(
sockfd,
buffer,
sizeof(buffer) - 1,
0,
(struct sockaddr*)&peer,
&len
);
if (n > 0)
{
buffer[n] = 0;
std::string word =
buffer;
std::string result =
dict.Query(word);
std::cout
<< "query: "
<< word
<< " from "
<< inet_ntoa(peer.sin_addr)
<< ":"
<< ntohs(peer.sin_port)
<< std::endl;
// 5. 返回结果
sendto(
sockfd,
result.c_str(),
result.size(),
0,
(struct sockaddr*)&peer,
len
);
}
}
close(sockfd);
return 0;
}
客户端代码(client.cpp)
完整 UDP 字典客户端:
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <arpa/inet.h>
#include <unistd.h>
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 8080
int main()
{
// 1. 创建 socket
int sockfd =
socket(AF_INET,
SOCK_DGRAM,
0);
if (sockfd < 0)
{
perror("socket");
return 1;
}
// 2. 准备服务器地址
struct sockaddr_in server;
memset(&server, 0,
sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(SERVER_PORT);
server.sin_addr.s_addr =
inet_addr(SERVER_IP);
char buffer[1024];
while (true)
{
std::cout
<< "请输入单词(quit退出): ";
std::cin >> buffer;
if (strcmp(buffer, "quit") == 0)
{
break;
}
// 3. 发送请求
sendto(
sockfd,
buffer,
strlen(buffer),
0,
(struct sockaddr*)&server,
sizeof(server)
);
struct sockaddr_in peer;
socklen_t len =
sizeof(peer);
// 4. 接收响应
ssize_t n =
recvfrom(
sockfd,
buffer,
sizeof(buffer) - 1,
0,
(struct sockaddr*)&peer,
&len
);
if (n > 0)
{
buffer[n] = 0;
std::cout
<< "结果: "
<< buffer
<< std::endl;
}
}
close(sockfd);
return 0;
}
编译顺序等要求

总结

12 ~> UDP 客户端实现

下面是一份非常标准的 UDP 客户端实现:
cpp
#include <iostream>
#include <cstring>
#include <string>
#include <arpa/inet.h>
#include <unistd.h>
int main()
{
// 1. 创建 socket
int sockfd =
socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0)
{
perror("socket");
return 1;
}
// 2. 准备服务器地址
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(8080);
server.sin_addr.s_addr =
inet_addr("127.0.0.1");
char buffer[1024];
while (true)
{
// 3. 获取用户输入
std::cout << "请输入单词: ";
std::cin >> buffer;
// 4. 发送请求
sendto(
sockfd,
buffer,
strlen(buffer),
0,
(struct sockaddr*)&server,
sizeof(server)
);
// 5. 接收响应
struct sockaddr_in peer;
socklen_t len =
sizeof(peer);
ssize_t n =
recvfrom(
sockfd,
buffer,
sizeof(buffer) - 1,
0,
(struct sockaddr*)&peer,
&len
);
if (n > 0)
{
buffer[n] = 0;
std::cout
<< "服务器返回: "
<< buffer
<< std::endl;
}
}
close(sockfd);
return 0;
}
这是一个完整可运行的 UDP 客户端。

结尾
uu们,本文的内容到这里就全部结束了,艾莉丝在这里再次感谢您的阅读!
|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ### 艾莉丝努力练剑 C/C++ & Linux 底层探索者 | 一个正在努力练剑的技术博主 *** ** * ** *** 👀 【关注】 跟随我一起深耕技术领域,见证每一次成长。 ❤️ 【点赞】 让优质内容被更多人看见,让知识传递更有力量。 ⭐ 【收藏】 把核心知识点存好,在需要时随时查、随时用。 💬 【评论】 分享你的经验或疑问,评论区一起交流避坑! 不要忘记给博主"一键四连"哦! "今日练剑达成!"
"技术之路难免有困惑,但同行的人会让前进更有方向。" |
结语:希望对学习Linux相关内容的uu有所帮助,不要忘记给博主"一键四连"哦!
往期回顾:
【Linux网络】计算机网络入门:Socket编程预备,从字节序共识到 Socket 地址结构的"伪多态"设计
🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡 ૮₍ ˶ ˊ ᴥ ˋ˶₎ა
