【Linux网络】Linux 网络编程入门:UDP Socket 编程(上)

🎬 个人主页艾莉丝努力练剑
专栏传送门 :《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 地址结构的"伪多态"设计

🗡博主在这里放了一只小狗,大家看完了摸摸小狗放松一下吧!🗡 ૮₍ ˶ ˊ ᴥ ˋ˶₎ა

相关推荐
代码中介商7 小时前
Linux多线程编程完全指南:线程同步、互斥锁与生产者消费者模型
linux·运维·服务器
计算机安禾7 小时前
【Linux从入门到精通】第43篇:I/O调度算法与磁盘性能优化
linux·算法·性能优化
X54先生(人文科技)7 小时前
《元创力》纪实录·桥段薪火三纪
网络·人工智能·开源·ai写作·零知识证明
(Charon)7 小时前
【C++/Qt】Qt 实现 POP3/IMAP 邮件测试工具:连接邮箱服务器、登录与读取邮件
服务器·开发语言·c++
计算机安禾7 小时前
【Linux从入门到精通】第44篇:Linux网络协议栈与TCP参数调优
linux·网络协议·tcp/ip
rleS IONS7 小时前
Linux系统离线部署MySQL详细教程(带每步骤图文教程)
linux·mysql·adb
学不会pwn不改名7 小时前
【ArchLinux】如何制服国产免驱网卡
linux·运维·网络
一只小bit7 小时前
Docker 存储卷:本地文件与容器内部文件建立绑定关系
运维·docker·容器
可视化运维管理爱好者7 小时前
rg完整中文操作指南
linux·运维·服务器·ai