Linux 网络套接字编程(二)从 0 到 1 实现 UDP 回声服务器,recvfrom,sendto

目录

设计逻辑

设计目的

代码结构

​编辑

[EchoServer.hpp 服务端头文件](#EchoServer.hpp 服务端头文件)

[1. 头文件防护与依赖引入](#1. 头文件防护与依赖引入)

[2. 私有成员变量](#2. 私有成员变量)

[IP 地址和端口号在 UDP 通信中的作用](#IP 地址和端口号在 UDP 通信中的作用)

设计改动

[3. 全局常量与枚举错误码](#3. 全局常量与枚举错误码)

[4. 构造函数与析构函数](#4. 构造函数与析构函数)

[5. Init () 服务端初始化函数](#5. Init () 服务端初始化函数)

重识Socket

[6. Start () 业务循环启动函数](#6. Start () 业务循环启动函数)

[recvfrom 系统调用](#recvfrom 系统调用)

[sendto 系统调用](#sendto 系统调用)

[EchoServerMain.cc 服务端入口文件](#EchoServerMain.cc 服务端入口文件)

[运行服务端 :](#运行服务端 :)

[EchoClient.cc 客户端文件 :](#EchoClient.cc 客户端文件 :)

客户端与服务端的核心异同

[1. 头文件与使用提示函数](#1. 头文件与使用提示函数)

[2. 主函数入口 & 启动参数](#2. 主函数入口 & 启动参数)

[3. 创建 socket](#3. 创建 socket)

[4. 构建服务端 socket 目标地址](#4. 构建服务端 socket 目标地址)

[5. 循环交互逻辑](#5. 循环交互逻辑)

[运行结果 :](#运行结果 :)

本地回环地址通信 (127.0.0.1)

[内网 IP 通信 (10.0.16.7)](#内网 IP 通信 (10.0.16.7))

[公网 IP 通信 (124.222.191.171)](#公网 IP 通信 (124.222.191.171))

[完整代码 :](#完整代码 :)

总结:

第一步:客户端准备发送

第二步:客户端内核处理发送

第三步:网络传输到服务端

[第四步:服务端内核接收 & 匹配监听端口](#第四步:服务端内核接收 & 匹配监听端口)

第五步:服务端内核把数据放进服务端Socket的缓冲区

第六步:服务端应用程序接收


在上一篇文章中,我们系统梳理了 Socket 网络编程的底层理论,包括socket()创建通信端点、bind()绑定网络地址、网络字节序转换等核心系统调用原理。但网络编程的核心在于落地实践,只有结合真实代码,才能真正理解接口设计的底层逻辑与使用边界。

本文我们将基于 C++ 实现一个经典的 UDP 回声服务器,通过构建一套完整的客户端 / 服务端(Server/Client) 通信模型,把理论知识转化为可运行的工程代码。

设计逻辑

简单来说,UDP 客户端和服务端通信,就像两个人互相发短信,客户端代表用户,服务端代表官方服务器,客户端向服务端发消息,服务端收到消息后再回给客户端。

我们这个程序的整个流程,用最直白的话讲就是这样:

设计目的

我们选择 UDP 回声服务器作为入门实战案例,核心有两点设计考量:

  1. 贴合 UDP 协议本质:UDP 是无连接、面向数据报的传输协议,无需三次握手建立连接,通信逻辑简单。适合回声服务器「收什么、回什么」的核心逻辑,能完全聚焦 UDP 原生通信流程,避开 TCP 复杂的连接管理、状态维护,让我们专注理解socket、bind、recvfrom、sendto四大核心接口的设计用途与调用时序。

  2. 构建标准化 C/S 通信模型:项目拆分服务端与客户端双模块,复刻网络编程最基础的架构范式,清晰区分服务端被动监听与客户端主动发起的角色差异,直观呈现无连接协议下两端的交互流程,为后续 TCP 编程、高并发网络框架学习打下基础。

代码结构

因为这个小项目是经典的 客户端 - 服务端 结构,所以我们就需要在客户端和服务端分别创建文件编写代码,又因为服务端往往比客户端更复杂点,所以在服务端我们就使用两个文件 (EchoServer.hpp + EchoServerMain.cc),在客户端中我们就只使用 (EchoClient.cc) 这一个文件

三个文件各自负责什么?

EchoServer.hpp 服务端头文件 :

  1. 这个文件是整个 UDP 服务端的 "核心定义仓库"。因为我们要把 UDP 服务端封装成一个类,为什么?

  2. 如果我们不写类,把所有代码都堆在 main 函数里全部混在一起。以后如果想改端口、改日志、复用服务端代码,就会发现代码乱成一团。而封装成 UdpServer 类之后,整个逻辑变得非常清晰:构造函数初始化,Init() 负责创建,Start() 负责跑整个代码逻辑,析构函数负责释放资源,对外来看,使用者只需要创建对象,调用 Init (),调用 Start (),不用关心底层 socket 怎么创建、bind 怎么失败、错误怎么打印。

  3. 这就是面向对象最核心的好处:**使用者和实现者解耦。**在以后的真实开发里好处更明显:一个类管理一个服务,实现轻松复用。

EchoServerMain.cc 服务端入口文件 :

这个文件是服务端的 "启动入口",也是主函数所在文件。它不写任何复杂的网络逻辑,只做三件事:(1). 引入头文件 #include "EchoServer.hpp" ; (2). 解析命令行参数,拿到用户传入的端口 ; (3). 创建 UdpServer 对象,调用 Init() 初始化、Start() 启动服务。简单说:头文件负责 "造零件、定规则",main 文件负责 "组装零件、启动程序",这就是 C++ 标准的声明与实现分离写法。

EchoClient.cc 客户端文件 :

客户端逻辑就相对简单,不需要复杂的类封装和接口拆分,所以我们直接用一个文件搞定全部功能。这个文件包含客户端的主函数、命令行参数校验、socket 创建、主动发送数据、接收服务端回显的完整流程,用户运行后直接就能和服务端通信。

Mkefile 编译文件 :

下面我们直接看服务端 EchoServer.hpp 的代码 :

EchoServer.hpp 服务端头文件

这个文件是整个 UDP 服务端的核心封装,我们在这里定义 UdpServer 类、错误枚举、依赖头文件,把网络初始化、端口绑定、循环收发的逻辑全部封装成类的成员方法,实现功能和入口解耦。

1. 头文件防护与依赖引入

作为 C++ 工程的标准规范,我们首先做两件事:

  1. 使用 #ifndef / #define / #endif 头文件保护,防止头文件被重复包含,避免编译冲突;

  2. 引入所有依赖的系统头文件、日志模块、命名空间,因为我们在打印时用到了前面学到的 Logger.hpp 日志类的方便我们进行打印,所以我们也要包含日志类头文件。

2. 私有成员变量

在讲类的功能之前,我们先明确这个类内部要存什么数据

_sockfd 本质是操作系统提供给我们的网络通信 "操作句柄"。我们后面创建 socket、绑定端口、收发数据、全靠这个文件描述符。把它设为私有,避免外部代码随意修改。

_port 是存储服务端的端口号。UDP 服务端必须先固定端口,客户端才能精准找到服务端。私有化后,端口只能通过构造函数初始化,运行中无法被修改。

IP 地址和端口号在 UDP 通信中的作用

  1. 在 UDP 这种无连接通信模型里,IP + 端口号 = 唯一的通信端点

  2. IP 地址负责定位主机,它的作用就是就是在互联网的茫茫主机里,精准找到目标服务器这台机器本身。客户端通过 IP,就能确定数据要发到哪一台服务器;服务端通过 IP 收到数据,然后进行处理。所以 IP 解决的是数据该发给哪台电脑。

  3. 端口号port负责定位进程,因为一台服务器上可能同时跑着几十上百个程序,它们都用同一个 IP,操作系统正式依靠端口号才能区分数据到底该交给哪个进程。我们的 UDP 服务端固定监听一个端口,比如 8080,就是告诉操作系统所有发到 8080 端口的数据,全部交给我这个进程处理;客户端必须严格对应这个端口号,不然就算 IP 对了,端口不对,数据也到不了我们的服务端。所以端口解决的问题是数据到了电脑后,该交给哪个程序。

设计改动

但是这里补充一点我在写代码时的设计改动:

  1. 在最开始的设计里,私有成员中我们额外加过一个用来保存 IP 地址的成员变量,本意是想让服务端绑定一个固定 IP。但在实际测试中发现写死某个具体 IP 的这种写法限制太大,为什么呢?

  2. 这里我们要先厘清一个容易混淆的关键点:IP 在宏观视角和微观视角下,对应对象是不一样的。宏观层面,我们说 IP 对应一台主机。在整个互联网环境中,IP 地址就是一台服务器的唯一标识,客户端通过目标 IP,就能跨越网络,找到对应的那台主机,完成主机级别的寻址。这是我们日常交流、网络通信里最常说的概念。

  3. 但落到我们代码这个微观层面( 比如说在 bind() 函数绑定操作时),IP 实际对应的是主机上的某一张网卡。 一台服务器主机可以配置多张网卡,每张网卡都拥有独立的 IP:本机回环网卡、内网网卡、公网网卡,它们都属于同一台主机,却是三个完全独立的网络入口。当我们在代码里写死绑定某一个具体 IP 时,本质是告诉操作系统:我这个进程,只能监听这一张网卡上收到的数据,其他网卡的数据,系统会直接丢弃,不会交给我们的程序。

  4. 搞懂了宏观 IP 和微观 IP 的区别,我们再对应看三种 IP 和「我们的电脑、云服务器、Linux 系统」的关系,就特别好理解了:

(1). 第一种是本机回环 IP : 127.0.0.1,它和外部网络没关系,只属于当前正在运行的操作系统自己。不管是Windows 电脑,还是 Linux 云服务器,系统都会自带这个 IP。它的作用就是系统内部自己和自己通信,只能本机自测,别的电脑、外网都访问不了。

(2). 第二种是内网 IP : 10.0.16.7,它是同一局域网里各个设备的专用地址。比如说家用电脑连 WiFi 拿到的内网 IP,只能和家里其他设备互相访问;云服务器的内网 IP,只能和同机房、同内网的其他服务器通信。简单说:内网 IP 只在 "同一个局域网" 里生效,出了这个小圈子就用不了。

(3). 第三种是公网 IP : 124.222.191.171,是互联网上唯一的地址。云服务器公网 IP,就是它在全网的身份标识。全世界任何地方的电脑、手机,只要联网,都能通过这个 IP 找到你的 Linux 服务器。它是云服务器和外界网络沟通的唯一通道。

  1. 明白了这一点,我们再回头看代码的设计改动,逻辑就完全通顺了:如果我们在代码里写死绑定某一个 IP,就等于给自己的服务设了限制:绑定回环 IP,就只能本机测试;绑定内网 IP,就只能局域网访问;绑定公网 IP,就只能外网连接。不管绑定哪一个,都会损失另外两种访问方式。

所以最终我们删掉了 IP 相关的成员变量,改用 INADDR_ANY。改成 INADDR_ANY 之后,我们的客户端可以根据自己所在的网络环境,自由选择使用本机回环 IP、内网 IP、公网 IP 中的任意一个来连接服务端,三种方式全部都能正常通信,服务端不用做额外修改。

  1. 这也是为什么私有成员里我们只保留端口、去掉 IP。端口是服务对外固定的入口标识,必须固定;而 IP 只是网络入口,服务端本来就该接收所有来源的请求,不需要锁死某一张网卡、某一个 IP。

3. 全局常量与枚举错误码

在正式定义服务端类之前,我们还需要统一全局常量与错误枚举,我们设置默认无效文件描述符default_fd = -1、默认监听端口default_port = 8888。

枚举错误码定义了可能出现的各类异常状态,包含SUCCESS正常状态、USAGE_ERR参数错误、SOCKET_ERR套接字创建失败、BIND_ERR端口绑定失败。

4. 构造函数与析构函数

构造函数 UdpServe 负责对象初始化,接收用户传入的端口,若用户未指定端口,则自动使用我们定义的默认端口 8888,同时将套接字文件描述符初始化为default_fd = -1,标记套接字尚未创建。

析构函数~UdpServer() 负责程序退出时的资源自动回收,核心执行 close(_sockfd) 操作。释放套接字文件描述符,完成操作系统资源的安全回收。

这里重点解释一下 fd(文件描述符) 的底层本质:在 Linux 操作系统里,一切皆文件。不管是普通文本文件、管道、设备、还是我们这里的网络 socket,操作系统都会统一把它们当成文件来管理。而 fd(文件描述符) 本质就是操作系统分配的一个整数编号。后续所有对网络的操作 ------ 创建 socket、bind 绑定、recvfrom 收数据、sendto 发数据、close 关闭,全靠这个整数编号告诉操作系统要操作的是哪一个网络对象。fd = -1 代表无效、未创建、未打开;fd ≥ 0 代表操作系统已经成功分配了一个网络通信对象,可以正常使用。一般情况下我们创建出来的 sockfd 是 3。

5. Init () 服务端初始化函数

首先在这个 Init() 初始化函数中包括了三个步骤 : 1. 创建 socket ; 2. 填充网络地址信息 ; 3. bind 绑内核 socket。

我们先看第一步创建 socket :

1. 上一篇文章中我们也讲过 socket() 是一个系统调用,作用是向内核申请创建一个网络专用的 socket 文件对象,成功后返回文件描述符 fd。

  1. 第一个参数 AF_INET 指定IPv4 地址协议族,代表我们使用经典的 IPv4 网络通信;

  2. 第二个参数 SOCK_DGRAM 指定UDP 无连接数据报套接字,代表我们采用 UDP 协议通信,特点是无连接、面向数据包、不保证可靠传输;

  3. 第三个参数 0 对应协议编号,当前场景下填 0,系统会自动匹配 UDP 协议。

  4. 调用 socket 函数后,内核会在内存中创建 socket 网络文件、分配 fd 并返回。我们判断返回值是否小于 0:小于 0 代表创建失败,打印致命日志并退出程序;大于等于 0 代表创建成功,拿到有效 fd,后续所有网络操作都将通过这个 fd 完成。

下来第二步就是填充网络地址信息,准备绑定参数:4

为什么我们要填充网络信息?

我们创建的 socket 文件,此时只是一个 "空白的网络对象",没有和任何 IP、端口关联。填充sockaddr_in 结构体,就是给这个 socket 准备好要绑定的地址配置,告诉内核后续要监听哪个端口、哪些 IP 来源的请求,为第三步 bind 绑定做参数准备。

sockaddr_in 是 Linux 系统专门为IPv4 协议设计的地址结构体,上一篇文章中我们也讲过,专门用来存放 IP + 端口信息。

bzero 是系统函数,作用是把整个结构体的内存全部清零。C++ 栈上创建的变量会带有随机脏数据,不清空会导致后续绑定异常。

再次指定协议族为 IPv4,必须和第一步 socket 创建时的AF_INET保持一致,协议不匹配会直接导致绑定失败。

htons() 是网络编程核心函数,全称host to network short。因为 CPU(主机)和网络传输使用的字节序相反:主机是小端序,网络强制要求大端序。我们必须通过htons,把用户传入的主机字节序端口,转换成网络能识别的字节序,否则端口会解析错误、客户端无法连接。

这就是我们前面反复优化的最佳实践。INADDR_ANY本质是一个宏,值为 0,代表监听本机所有网卡、所有 IP 地址。不管客户端通过 127.0.0.1 本机、内网 IP、公网 IP 访问,只要端口匹配,数据都会路由到这个 socket,兼容各种访问场景。

补充 : 这里的 local 结构体,是存放在用户栈内存中,只是我们临时准备的参数,此时还没有被写入内核,只有第三步 bind 调用后,地址信息才会和内核 socket 文件绑定。

第三步用 bind() 绑定,将地址信息注册到内核 socket :

  1. bind() 是系统调用,核心作用是把第二步准备好的 IP + 端口地址信息,注册到内核的 socket 文件对象中。完成绑定后,操作系统会建立**「端口 → socket 文件」** 的映射关系,告诉内核:所有发往这个端口、匹配对应 IP 规则的数据,就交给这个 socket 文件处理,这样客户端就能通过 IP + 端口找到我们的服务端。

  2. 第一个参数 _sockfd 就是我们第一步创建 socket 时拿到的文件描述符,指定我们要绑定哪个内核 socket 对象;第二个参数 (struct sockaddr *)&local 是地址参数。因为 bind 函数的参数设计要求传入通用地址结构体 sockaddr,所以我们需要把 IPv4 专用的 sockaddr_in 强制类型转换。通过父类指针找到子类对象;第三个参数 sizeof(local) 是地址结构体的大小。

  3. bind 函数返回值小于 0 代表绑定失败,最常见原因是端口被其他进程占用,我们打印日志并退出;返回值大于等于 0 代表绑定成功,此时 socket 正式和 IP + 端口完成关联,服务端具备了接收客户端请求的能力。

重识Socket

通过上面的内容,我们可以从两个完全不同的维度,对 Socket 做最清晰的总结:

  1. 网络通信层面 看,Socket 就是 IP + 端口号。它是一个逻辑意义上的通信端点,用来唯一标识网络中通信的双方。客户端和服务端依靠这个地址互相定位、建立对话,它是抽象的通信身份,决定了 "谁和谁在对话"。

  2. Linux 操作系统内核层面 看,Socket 是一个特殊的网络文件对象 。进程调用 socket () 系统调用,内核会在内存中创建一个专门用于网络通信的文件,并分配唯一的文件描述符 fd。这个文件对象里维护着网络状态、协议信息、以及用于收发数据的内核缓冲区。我们代码中所有的 bind、sendto、recvfrom 操作,本质上都是拿着 fd,对这个内核文件对象进行读写控制。

简单来说:IP + 端口是对外的通信身份,用来寻址;Socket 文件是对内的系统载体,用来收发数据。 服务端通过创建内核中的 Socket 文件,绑定上一个公开的 IP + 端口 地址,客户端凭借这个地址找到内核中的 Socket 对象,双方通过 Socket 文件自带的内核缓冲区完成数据交换,实现跨网络通信。

6. Start () 业务循环启动函数

前面我们通过 Init() 完成了服务端的初始化,相当于搭好了架子。而 Start() 就是服务端真正开始干活、持续对外提供回声服务的核心业务函数。我们顺着代码执行顺序, 把 Start() 函数拆解成 4 个核心步骤 :

第一步开启while()循环,持续监听客户端请求 :

为什么用 while(true) 死循环?

UDP 服务端的核心特性是被动等待客户端连接,它不知道客户端什么时候发消息、会有多少个客户端发消息。如果没有循环,服务端只会接收一次客户端数据,回复完程序就结束了;开启while()死循环后,服务端就会一直保持运行状态,循环阻塞等待,实现持久服务。
char inbuffer[1024] 是我们在用户态栈内存定义的接收缓冲区,用来临时存放从客户端读取到的数据。1024 是自定义的缓冲区大小,决定了单次最多能接收多少字节的数据。
我们知道 socket() 创建好之后内核会自动为这个 socket 网络文件分配两块专属缓冲区:内核接收缓冲区和内核发送缓冲区,用来暂存数据。而我们代码里又定义了 inbuffer用户缓冲区,这两者不会冲突吗?

答案是不冲突,它们是分工明确、接力传递的关系。我们通过 socket 创建网络文件时,Linux 内核会自动为这个 socket 分配两块专属缓冲区:内核接收缓冲区和内核发送缓冲区,用来暂存网络数据包。客户端通过 sendto 发来的数据,会先经过网卡,直接进入服务端 socket 对应的内核接收缓冲区 排队缓存。当服务端调用 recvfrom 接收数据时,本质就是操作系统把内核缓冲区里暂存好的数据,拷贝到我们在用户栈上自定义的 inbuffer 缓冲区中,之后我们的业务代码才能对数据进行读取、打印和处理二者各司其职,数据是从内核缓冲区到用户缓冲区单向拷贝的关系,不存在任何矛盾。

第二步定义客户端地址结构体,调用 recvfrom 阻塞接收数据 :

这是 Start() 函数的核心,我们将这一步再拆成两部:peer 结构体的作用、recvfrom 系统调用全解析。

为什么要定义 struct sockaddr_in peer?

我们在前面 Init() 函数里的定义的 local 结构体,存的是服务端自己的 IP + 端口;而这里的 peer 结构体,则是用来存放客户端的 IP + 端口信息。因为 UDP 是无连接协议,客户端每次发数据都可能来自不同的地址;所以我们必须通过 peer 拿到客户端的网络端点信息,才能知道 "谁在给我发消息",后续才能精准把回声数据原路发回去。len = sizeof(peer) 提前计算好结构体大小,作为参数传给内核。

recvfrom 系统调用

recvfrom 是 UDP 专用的接收数据系统调用,核心作用就是阻塞等待客户端数据,同时获取客户端的网络地址,6 个参数逐个拆解:

  1. 第一个参数 _sockfd 是我们第一步创建的 socket 文件描述符,告诉内核要从这个网络文件 socket 的接收缓冲区里读取数据;
  2. 第二个参数 inbuffer 是我们定义的用户态接收缓冲区,数据从内核缓冲区拷贝出来后,最终存到这个数组里;
  3. 第三个参数 sizeof(inbuffer) - 1 是最大读取字节数。减 1 是安全规范,预留 1 个字节位置给字符串结束符 \0,避免缓冲区溢出;
  4. 第四个参数 0 是读取标志位,填 0 代表默认阻塞模式------ 如果内核接收缓冲区里没有数据,程序会卡在这一行,休眠等待,直到有客户端发数据过来才会继续执行;
  5. 第五个参数 (struct sockaddr *)&peer 是客户端地址输出参数。内核收到数据后,会自动把数据包里客户端的 IP + 端口地址写入这个结构体,方便后续把数据原路发回去;
  6. 第六个参数 &len 是地址长度输入输出参数,传入结构体大小,内核使用后更新实际写入的长度。

返回值 n 接收成功时 n 表示是实际读取到的客户端数据字节数;n < 0 表示失败。

第三步解析客户端地址 + 处理业务数据 :

首先我们要将网络字节序转主机字节序,因为内核从网络里拿到的客户端 IP、端口,都是网络大端序,CPU 无法直接识别,所以需要转换。

ntohs() 函数表示 network to host short,把客户端端口从网络大端序,转成主机小端序,方便我们打印显示;

inet_ntoa() 表示把 32 位二进制 IP 地址,转换成我们能看懂的点分十进制字符串(如 127.0.0.1、192.168.x.x)。

这行代码把客户端的 IP 和端口号,拼接成一个我们能直接看懂的字符串格式,方便日志打印和调试。client_ip 就是前面我们用 inet_ntoa(peer.sin_addr) 转换得到的字符串 IP,比如 "127.0.0.1"。std::to_string(client_port) 把 ntohs 转换后的端口号整数,比如 54321,转换成字符串 "54321"。按照拼接格式:用 [IP:端口] 的形式,比如 [127.0.0.1:54321],再加上 # 作为分隔符,和后面的消息体区分开。这么做的好处就是日志打印的时候,一眼就能看出这条消息来自哪个客户端;

手动补字符串结束符。因为网络数据是纯字节流,没有结束标记,必须手动加 \0,否则打印字符串时会出现乱码;

第一行代码是把我们前面拼接好的 client_address(也就是 [客户端IP:端口])和收到的原始消息 inbuffer,一起打印到日志里。后两行代码先定义一个固定前缀字符串 "server echo# ";把客户端发来的 inbuffer 内容拼接在后面;最终生成的 echo_string 就是:"server echo# "。实现我们的 "回声服务器" 功能:客户端发什么,服务端原样返回,并且加上服务端的标记前缀。

步骤四调用 sendto 原路回复数据给客户端

sendto 系统调用

sendto 是 UDP 专用的发送数据系统调用,核心作用是把处理好的回声数据,原路发回给对应的客户端,它也是 6 个参数,和 recvfrom() 的 6 个参数一一对应,但是逻辑恰恰相反:

  1. 第一个参数 _sockfd 同样使用我们的 socket 文件描述符,通过它操作内核网络文件 socket 的发送缓冲区;
  2. 第二个参数 const void *buf 是要发送的数据起始地址,这里我们填 echo_string.c_str() 表示把 C++ string 转成 C 语言 const char* 格式,因为 echo_string 已经 += 了 inbuffer[];
  3. 第三个参数 size_t len 表示要发送的数据实际字节长度;
  4. 第四个参数 0 表示发送标志位,填 0 代表默认模式;
  5. 第五个参数 (struct sockaddr *)&peer 是目标客户端地址。这就是我们前面解析 peer 结构体的意义 ------ 告诉内核,这条数据要发给哪个客户端的 IP + 端口;
  6. 第六个参数 len 是客户端地址结构体的长度。

recvfrom 本质是:从 socket 内核接收缓冲区 → 拷贝数据到用户态缓冲区;

sendto 本质是:从用户态缓冲区 → 拷贝数据到 socket 内核发送缓冲区 → 内核异步通过网卡发送到网络。

EchoServerMain.cc 服务端入口文件

接下来就是我们 UDP 回声服务器的程序入口文件,也是整个服务端项目的入口。前面我们拆解了 UdpServer 类的头文件、成员方法、底层通信逻辑,而这个入口文件的作用就是校验启动参数、创建服务端对象、调用初始化与业务循环,一键启动整个 UDP 服务。我们顺着代码顺序,分 4 个步骤逐行拆解,同时讲清它和客户端的关联关系。

第一步是工具函数定义 ------ 程序使用提示:

Usage 是一个静态工具函数,专门用来打印程序的正确启动格式。参数 process 用来接收程序自身名称(比如 ./server_udp);

输出格式为 ./server_udp + local_port,也就是告诉使用者:在 ./server_udp 启动服务时,也必须在后面传入一个端口号参数 port;否则参数数量就不对,会自动触发提示。

第二部就是主函数的入口 :

argc 是命令行参数个数,程序自身名称算第 1 个参数;argv[] 是命令行参数数组,argv[0] 存程序名,argv[1] 存用户传入的端口号。

我们的服务端启动后,必须传入 1 个端口参数,所以要求 argc == 2;如果参数数量不对就会调用 Usage 打印使用提示,直接以 USAGE_ERR 错误码退出程序;这一步是程序启动的安全校验,避免后续因为端口参数缺失导致初始化失败。

第三步就是环境配置 + 端口参数解析 :

ENABLE_CONSOLE_LOG_STRATEGY() 是启用控制台日志输出。

argv[1] 是用户命令行传入的端口字符串(比如 8080);std::stoi(argv[1]) 表示将字符串端口 argv[1] 转为整数,赋值给 uint16_t 类型的 server_port;这里的端口,就是服务端对外提供服务的唯一标识,后续客户端必须指定这个端口,才能和服务端建立 UDP 通信。

最后一步创建服务端对象 + 启动服务闭环:

首先我们创建智能指针对象,采用 C++14 智能指针管理 UdpServer 对象,自动管理内存,程序退出时自动析构,避免内存泄漏;创建对象时,传入我们解析好的 server_port 端口号,端口信息直接传入服务端类,和前面头文件的构造函数形成闭环。

随后调用 Init () 初始化,调用我们之前详细拆解的初始化函数:创建 socket 内核文件、绑定 INADDR_ANY 全网卡监听、完成操作系统端口注册;执行完这一步:服务端已经在操作系统中注册完成,端口处于监听状态,客户端此时已经可以发起连接请求。

最后调用 Start () 启动业务循环,调用我们的核心业务函数:开启死循环,阻塞等待客户端数据、接收解析客户端地址、封装回声数据、sendto 原路回复;执行完这一步:服务端进入服务状态,持续处理所有客户端的 UDP 请求,直到程序手动终止。

运行服务端 :

到目前为止,我们对整个服务端的代码编写就完成了,下来我们可以运行一下服务端的程序 :

当我们执行命令 ./server_udp 8080 后,终端就输出了两行日志 :

第一行日志对应 socket() 调用成功,sockfd: 3 说明内核成功为我们创建了 socket 文件对象,并分配了文件描述符 3。

第二行日志对应 bind() 调用成功,port: 8080 说明我们绑定的 8080 端口注册成功。结合我们使用的 INADDR_ANY,服务端已经开始监听本机所有网卡上、目标端口为 8080 的 UDP 数据包。

输出完这两行日志后,光标停留在下一行,程序没有退出,也没有新日志输出,这正是我们期望的状态:服务端已经成功创建 socket、绑定端口,进入了 Start() 函数里的 while(true) 死循环。此时 recvfrom() 正在内核中阻塞等待,服务端已经准备好接收任何客户端发来的消息。

EchoClient.cc 客户端文件 :

前面我们完整拆解了 UDP 服务端的全链路逻辑,服务端是被动监听、等待客户端连接 的角色;而客户端和服务端正好相反,是主动发起通信、定向给指定服务端发数据的角色。下面我们就来看看客户端的代码:

客户端与服务端的核心异同

✅ 相同点 : 客户端与服务端的代码核心完全一致,通信接口完全一致

  1. 都需要通过 socket() 创建内核网络文件、获取 fd 文件描述符,依赖 Linux 内核缓冲区完成数据收发;

  2. 都使用sendto()发送数据、recvfrom()接收数据,遵循 UDP 无连接通信标准。

❌ 核心不同点(最关键)

角色定位不同 :

  1. 服务端是被动方,必须bind()绑定固定端口 + INADDR_ANY全网卡监听,让客户端能精准找到自己;

  2. 而客户端是主动方,不需要手动 bind 绑定端口,首次调用 sendto() 时,操作系统会自动分配随机临时端口,由 OS 管理端口唯一性,避免端口冲突。

地址逻辑不同 :

  1. 服务端需要绑定自己的地址,等待所有客户端主动找过来;

  2. 而客户端需要预先配置服务端的 IP + 端口,主动定向访问指定服务端。

循环逻辑不同 :

  1. 服务端是死循环while(true)阻塞监听,持续等待任意客户端请求;

  2. 客户端是死循环用于持续获取用户输入、主动发起单次通信,每次循环完成一次 "发送 - 接收" 的请求应答。

客户端的执行逻辑可以分为 5 个核心步骤,下面我们逐一介绍:

1. 头文件与使用提示函数

  1. 头文件和服务端完全一致,包含网络编程必备的 socket.h、inet.h、in.h,提供 socket 创建、IP / 端口转换、地址结构体定义的系统接口;

  2. Usage 工具函数:打印客户端正确启动格式。客户端启动必须传入两个参数:服务端 IP、服务端端口,例如 ./client_udp 127.0.0.1 8080。参数缺失会直接提示用法并退出,和服务端参数校验逻辑对应,但校验的参数含义完全不同。

2. 主函数入口 & 启动参数

  1. argv[0] 表示客户端程序名;

  2. argv[1] 表示服务端 IP(如127.0.0.1、公网 IP);

  3. argv[2] 表示服务端端口(必须和服务端 bind 绑定的端口完全一致,如 8080);

  4. 当 argc != 3 就会直接退出,因为客户端必须明确知道服务端的 IP 和端口,这是主动通信的前提;

  5. 下来就将命令行传入的字符串 IP、端口,转为程序可用的 std::string 和 uint16_t 类型,为后续做准备。
    为什么客户端传入的是服务端的 IP 和服务端的端口号?

因为客户端是主动找人的一方,它必须知道对方在哪,不然不知道数据该发给谁。客户端启动传入服务端 IP 和端口,本质是为了给操作系统指明通信目标。UDP 是无连接通信,客户端每次发送数据包都必须明确指定接收方地址,因此必须预先知道服务端固定的监听 IP 与端口;而客户端自身的地址由操作系统自动分配随机临时端口,无需手动配置。

3. 创建 socket

  1. 客户端创建 socket 逻辑和服务端一致:AF_INET 表示 IPv4 协议;SOCK_DGRAM 表示 UDP 无连接数据报;核心作用就是客户端同样向 Linux 内核申请创建一个 socket 网络文件,拿到唯一 fd。后续所有的操作,都通过这个 fd 操作客户端 socket 的内核收发缓冲区。

  2. 需要注意的是 : 服务端和客户端用的不是同一个 socket,二者完全独立。服务端调用 socket () 创建一个内核网络文件,绑定固定端口被动监听;客户端同样调用 socket () 创建另一个全新的内核网络文件,由操作系统分配随机端口主动通信。二者是操作系统中两个完全独立的文件对象,只是依靠「IP + 端口」的寻址规则通过网络交换数据,不存在任何共用关系。

3. 并且客户端和服务端关键区别:客户端创建 socket 后,不执行 bind 绑定。为什么?

因为 bind 的本质相当于主动告诉操作系统:我要占用这个 IP + 端口,别人都不能用。所以服务端必须 bind:因为它要当固定服务点,必须提前占好一个固定端口,让所有人都能找到它;而客户端则不需要用 bind,因为客户端只是临时访问者,不需要固定身份,操作系统会在底层随机挑一个随机的空闲端口;自动把客户端本机 IP + 这个随机端口绑定给客户端的 socket;后续所有收发数据,都用这个自动分配的临时端口。

这就相当于我们刷抖音,抖音服务器(服务端)必须 bind。因为抖音要给全国几亿用户提供服务,它必须固定一个不变 IP + 端口。用来接收所有用户的视频请求。而我们的手机(客户端)不用 bind。当我们刷抖音时,只需要打开 APP,操作系统会自动给手机分配一个临时端口,用完就回收。

4. 构建服务端 socket 目标地址

struct sockaddr_in server 用来存放服务端的 IP + 端口,作为客户端发送数据的目标地址;服务端构建的是自己的监听地址;客户端构建的是对方的目标地址;

清空地址结构体脏数据,和服务端规范一致;

保持 IPv4 协议一致;

端口主机序转网络序,必须和服务端端口完全一致;

将十进制的 IP(如 127.0.0.1),转为 32 位网络二进制 IP,用于网络传输寻址;

客户端后续每次 sendto 发送数据,都会指定这个 server 结构体,告诉操作系统:这条 UDP 数据包,要发往这个 IP + 端口的服务端。

5. 循环交互逻辑

(1). 获取用户输入

每次循环主动获取用户输入的字符串,作为要发送给服务端的数据。

(2). sendto 主动发送数据

  • sockfd 是客户端自己的 socket fd;
  • message.c_str() 是要发送的用户数据;
  • (struct sockaddr*)&server 是目标服务端地址(我们前面提前构建好的 IP + 端口);

客户端没有手动调用 bind(),首次调用 sendto() 时,Linux 操作系统会自动为客户端 socket 分配一个随机临时端口;完成客户端 fd、随机端口、本机 IP 的内核绑定;后续所有收发,都用这个自动分配的随机端口。服务端必须手动 bind 固定端口,客户端不需要,这是 UDP 客户端开发的核心规范。

(3). recvfrom 接收服务端回声

这里的 temp 结构体,用来接收服务端的 IP + 端口;客户端的 recvfrom 是非阻塞等待:只有发送完数据后,才会等待服务端的回声;服务端的recvfrom是永久阻塞等待,随时接收所有客户端数据;数据接收逻辑和服务端完全一致:从客户端 socket 的内核接收缓冲区,拷贝数据到用户态inbuffer。

(4). 打印回声结果

将服务端返回的回声数据打印到终端,完成一次 "用户输入→客户端发送→服务端回声→客户端输出" 的完整交互闭环。

运行结果 :

下面我们就直接展示运行结果,展示 UDP 回声服务器在三种不同访问场景下的实际表现

本地回环地址通信 (127.0.0.1)

运行 ./client_udp 127.0.0.1 8080 命令后,在客户端:输入 "中午好" :

回车后立刻收到服务端回复 "server echo# 中午好" :

服务端日志打印出客户端来源为 [127.0.0.1:43677]。同时将 "中午好" 返回打印,注意这里的端口号 43677 是客户端自动分配的临时端口。

这是本机内部通信。客户端和服务端都在同一台主机上。而 127.0.0.1 是回环地址,数据不经过真实网卡,直接在内核里绕一圈就回到了客户端。

内网 IP 通信 (10.0.16.7)

运行 ./client_udp 10.0.16.7 8080 后在客户端:输入 "吃了吗" :

回车后立刻收到服务端回复 "server echo# 吃了吗" :

服务端日志打印出客户端来源为 [10.0.16.7:33657]。这里的 IP 变成了内网 IP。

这是同一局域网内的机器通信。客户端明确指定了服务端的内网 IP。数据经过了真实的网卡协议栈。客户端拿到的源 IP 是它发出数据包时的本机网卡 IP。这证明我们的服务端代码不是死绑某一个 IP,而是监听了所有网卡(归功于 INADDR_ANY),所以无论客户端从哪个网卡(内网、外网)发来数据,服务端都能收到。

公网 IP 通信 (124.222.191.171)

运行 ./client_udp 124.222.191.171 8080 后客户端输入 "今天过得怎么样" :

回车后立刻收到服务端回复 "server echo# 今天过得怎么样" :

服务端日志打印出客户端来源为 [124.222.191.171:44555]。源 IP 变成了公网 IP。

这是跨网络、跨公网的通信。客户端通过公网 IP 找到了服务端。客户端发送数据时,内核自动分配一个临时公网端口(如 44555)。数据通过网络路由,到达了拥有该公网 IP 的机器。服务端收到数据,并通过数据包里的公网 IP + 临时端口,确认了客户端身份,从而能准确回信。这也是最接近真实生产环境的效果。服务端代码支持公网访问,只要公网 IP 映射正确,服务端就能提供服务。

完整代码 :

EchoServer.hpp

cpp 复制代码
#ifndef __ECHOSERVER_HPP
#define __ECHOSERVER_HPP

#include <iostream>
#include <string>
#include <cstdlib>
#include <strings.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "Logger.hpp"

using namespace NS_LOG_MODULE;
const static int default_fd = -1;
const static int default_port = 8888;
enum
{
    SUCCESS = 0,
    USAGE_ERR,
    SOCKET_ERR,
    BIND_ERR,
};

class UdpServer
{
public:
    // UdpServer(const std::string &ip, uint16_t port = default_port)
    UdpServer(uint16_t port = default_port)
        : _port(port),
          _sockfd(default_fd)
    {
    }
    ~UdpServer()
    {
        close(_sockfd);
    }
    void Init()
    {
        // 第一步: 创建socket, 本质: 打开网卡 --- 系统特性
        _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        if (_sockfd < 0)
        {
            LOG(LogLevel::FATAL) << "create socket error";
            exit(SOCKET_ERR);
        }
        LOG(LogLevel::INFO) << "create socket success, sockfd: " << _sockfd;

        // 第二步: 填充网络信息, 有没有IP和端口信息设置到内核中??设置到你刚刚打开的网络socket对应的文件内部?
        struct sockaddr_in local; // struct sockaddr_in 数据类型,local 用户栈上的!!!,并没有设置到内核
        bzero(&local, sizeof(local));
        local.sin_family = AF_INET;
        local.sin_port = htons(_port);                  // h->n
    
        local.sin_addr.s_addr = INADDR_ANY; // 最佳实践:任意IP地址bind

        // 第三步:bind socket 信息
        int n = bind(_sockfd, (struct sockaddr *)&local, sizeof(local));
        if (n < 0)
        {
            LOG(LogLevel::FATAL) << "bind socket error";
            exit(BIND_ERR);
        }
        LOG(LogLevel::INFO) << "bind socket success"<< ", port: " << _port;
    }
    void Start()
    {
        // 传递的是字符串,echo server
        char inbuffer[1024];
        while (true)
        {
            struct sockaddr_in peer;
            socklen_t len = sizeof(peer);
            // 1. 用户发来的数据
            // 2. 用户的socket信息
            ssize_t n = recvfrom(_sockfd, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len);
            if (n > 0)
            {
                // 1. 网络字节序 → 主机字节序,解析客户端IP和端口
                uint16_t client_port = ntohs(peer.sin_port);
                std::string client_ip = inet_ntoa(peer.sin_addr); // 4字节IP-> ntoh -> 字符串
                std::string client_address = "[" + client_ip + ":" + std::to_string(client_port) + "]# ";
                // 2. 给接收数据加字符串结束符
                inbuffer[n] = '\0';
                // 3. 日志打印 + 封装服务端要原路返回的数据
                LOG(LogLevel::DEBUG) << client_address << inbuffer;
                std::string echo_string = "server echo# ";
                echo_string += inbuffer;
                // h to n
                sendto(_sockfd, echo_string.c_str(), echo_string.size(), 0, (struct sockaddr *)&peer, len);
            }
            else
            {
                LOG(LogLevel::ERROR) << "recvfrom error";
            }
        }
    }

private:
    int _sockfd;
    uint16_t _port;  // 用户设置好的,server port必须是固定的!
    // std::string _ip; // "192.168.2.2"
};

#endif

EchoServerMain.cc

cpp 复制代码
#include "EchoServer.hpp"
#include <memory>

static void Usage(const std::string &process)
{
    std::cerr << "Usage:\n\t";
    std::cerr << process << " local_port" << std::endl;
}

// ./server_udp port
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        Usage(argv[0]);
        exit(USAGE_ERR);
    }
    ENABLE_CONSOLE_LOG_STRATEGY();

    uint16_t server_port = std::stoi(argv[1]);

    std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(server_port);
    usvr->Init();
    usvr->Start();

    return 0;
}

EchoClient.cc

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
        
static void Usage(const std::string &proc)
{
    std::cout << "Usage:\n\t";
    std::cout << proc << " server_ip server_port" << std::endl;
}

// 我怎么知道server对方的IP和端口啊, 类似IP+Port 是被内置到client的!!!
// ./client_udp server_ip server_port
int main(int argc, char *argv[])
{
    if(argc != 3)
    {
        Usage(argv[0]);
        exit(1);
    }
    std::string server_ip = argv[1];
    uint16_t server_port = std::stoi(argv[2]);


    // 1. 创建socket
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if(sockfd < 0)
    {
        std::cerr << "socket error" << std::endl;
        exit(2);
    }

    // 2. 构建server端socket信息 
    // client 需要有自己的IP和Port信息吗? 需要的!
    // 需要显示的bind自己的ip和端口吗?不要显示bind!!!
    // 1. 为什么不让client显示bind?client bind port 出现冲突!client port只需要具有唯一性即可,具体是几,不重要。
    // 2. 如何设置自己的IP和端口呢?client 一般会采用随机端口的方式!由OS自主选择!
    // udp client 首次发送数据的时候,OS底层会隐式自动帮你进行获取随机端口,然后bind + Port + IP
    struct sockaddr_in server;
    memset(&server, 0, sizeof(0));
    server.sin_family = AF_INET;
    server.sin_port = htons(server_port); 
    server.sin_addr.s_addr = inet_addr(server_ip.c_str());

    while(true)
    {
        std::string message;
        // 1. 获取用户输入
        std::cout << "Please Enter# ";
        std::getline(std::cin, message);

        // 2. clinet 发送数据给 server,首次发送即自动bind
        ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, sizeof(server));
        if(n > 0)
        {
            // recvfrom
            char inbuffer[1024] = {0};
            struct sockaddr_in temp;
            socklen_t len = sizeof(temp);
            ssize_t m = recvfrom(sockfd, inbuffer, sizeof(inbuffer)-1, 0, (struct sockaddr*)&temp, &len);
            if(m > 0)
            {
                inbuffer[m] = 0;
                std::cout << inbuffer << std::endl;
            }
        }
    }


    return 0;
}

总结:

  1. 服务端之所以必须绑定 8080 端口,是因为服务端作为被访问方,需要提供一个固定的网络入口,这样客户端才能找到它。因此客户端在访问时,必须指定 8080 端口,就是为了通过这个端口精准定位到服务端程序。

  2. 而客户端在第一次发送数据之前,并没有自己的端口。当客户端第一次调用 sendto 发送数据时,操作系统会自动为客户端分配一个随机的临时端口。这个临时端口会被操作系统记录在对应的结构体中,并随着数据包一起发送到服务端。

  3. 服务端收到客户端数据后,就能从数据包中解析出客户端的 IP + 临时端口,并在回复时把这个地址填进 sendto 的目标地址里。这样服务端就能准确把数据回发给客户端,实现双方的双向通信。

  4. 而 IP 的作用,就是区分不同的网络路径。比如通过本地回环 127.0.0.1 、内网 IP、或者公网 IP 进行访问。不管是哪一种 IP,最终都是为了找到那台运行着服务端程序的机器,再通过 8080 端口找到具体的服务端进程。

谢谢大家的观看!

相关推荐
小宋加油啊2 小时前
服务器双卡5090 配置深度学习环境
运维·服务器·深度学习
Cyber4K2 小时前
【DevOps专项】GitLab 与 Jenkins 介绍及部署持续集成环境
运维·ci/cd·gitlab·jenkins·devops
与遨游于天地2 小时前
HTTP的历史由来
网络·网络协议·http
不败公爵2 小时前
finsh_thread_entry这个线程是自动启动的
java·linux·服务器
实心儿儿2 小时前
Linux —— 基础IO — 文件描述符 + 重定向
linux·运维·服务器
计算机安禾2 小时前
【Linux从入门到精通】第14篇:Linux引导流程浅析——从按下电源到登录界面
linux·服务器·人工智能·面试·知识图谱
YaBingSec2 小时前
玄机靶场-第三届-长城杯-初赛-SnakeBackdoor WP
java·运维·笔记·tomcat·ssh
雕刻刀2 小时前
服务器模拟断网
linux·服务器·前端
斯维赤2 小时前
Python学习超简单第八弹:网络编程
网络·python·学习