Linux UDP 服务端 实战思路 C++ 套接字 源码包含客户端与服务端 游戏服务端开发基础

文章目录

完整源码请进传送门gitee 仓库
注:内有惊喜,UDP+JavaScript+html的多用户猜数字游戏(BullsAndCows)


UDP是什么?为什么游戏服务端首选UDP?

在聊代码之前,我们先搞懂一个核心问题:UDP到底是什么?为什么做游戏服务端开发,首先要学UDP?

UDP(用户数据报协议)是TCP/IP协议簇中面向无连接的传输层协议,和我们更熟悉的TCP相比,它的核心特性可以总结为4个关键词:

  • 无连接:不用像TCP那样先握手建立连接,客户端想发数据直接发,服务端想收数据直接收,省去了连接建立/断开的开销;
  • 不可靠:UDP不保证数据一定能送到,也不保证数据按顺序到达,甚至可能出现数据重复------但游戏开发中,我们可以通过上层逻辑(比如重传关键数据包、序列号校验)弥补;
  • 低延迟:正是因为"不可靠",UDP省去了TCP的重传、拥塞控制、顺序保证等机制,数据传输延迟极低,这对"实时性要求极高"的游戏(比如MOBA、FPS、多人联机竞技)来说,是核心优势;
  • 面向数据报:UDP以"数据包"为单位收发数据,发一个包就是一个完整的单元,不会像TCP那样粘包,处理起来更简单。

举个游戏里的例子:玩家移动的坐标数据,哪怕丢了1-2帧,只需要用最新的坐标覆盖就行,延迟100ms比丢几个包更影响体验------这就是为什么几乎所有大型游戏的服务端,核心通信都是基于UDP实现的。

今天我们就从最基础的UDP通信开始,用C++实现Linux下的UDP服务端和客户端------这是游戏服务端网络编程的第一步,也是最核心的基础。

开始前的准备:UDP编程核心接口速览

在动手写代码前,我们先熟悉Linux下C++实现UDP通信的核心系统接口------这些接口是网络编程的基础,也是游戏服务端开发中高频使用的"工具"。

一、核心网络通信接口

1. int socket(int domain, int type, int protocol);

  • 函数作用 :创建套接字(网络通信管道),是所有网络编程的第一步。
  • 输入参数
    • AF_INET:指定IPv4协议族;
    • SOCK_DGRAM:指定UDP类型(TCP用SOCK_STREAM);
    • 0:默认协议(UDP/TCP专属协议)。
  • 返回值:成功返回套接字描述符(非负整数),失败返回-1。

2. addr系列结构体:描述网络地址(sockaddr_in);

  • 函数作用:存储IP、端口、协议族等网络地址信息,服务端/客户端均需使用。
  • 结构体定义
cpp 复制代码
struct sockaddr_in {
       sa_family_t    sin_family; // 协议族,固定填AF_INET
       in_port_t      sin_port;   // 端口号(需用网络字节序)
       struct in_addr sin_addr;   // IP地址结构体
};

// IP地址结构体
struct in_addr {
       uint32_t       s_addr;     // IP地址(需用网络字节序)
};
  • 关键说明 :使用时需强转为通用的struct sockaddr*类型。

3. in_addr_t inet_addr(const char *cp);

  • 函数作用:字符串IP转二进制,将字符串格式IP(如192.168.1.100)转为网络编程所需的二进制数值,这个已经废弃我们只是了解。
  • 输入参数:字符串格式的IPv4地址。
  • 返回值 :成功返回二进制IP(网络字节序),失败返回INADDR_NONE(通常是0xFFFFFFFF)。

4. int inet_pton(int af, const char *src, void *dst);

  • 函数作用:更通用的IP转换函数,支持IPv4/IPv6的IP转换,且能校验IP合法性。
  • 输入参数
    • AF_INET/AF_INET6:指定IP协议版本;
    • 字符串格式IP;
    • 存储二进制IP的地址(输出参数)。
  • 返回值:1(成功)、0(IP格式非法)、-1(调用出错)。

5. uint16_t htons(uint16_t hostshort);

  • 函数作用:主机序转网络序(端口),将主机字节序的端口号转为网络字节序,跨机器通信必用。
  • 输入参数:主机字节序的16位端口号。
  • 返回值:网络字节序的16位端口号。

6. uint16_t ntohs(uint16_t netshort);

  • 函数作用:网络序转主机序(端口),将网络字节序的端口号转回主机字节序,方便打印/处理。
  • 输入参数:网络字节序的16位端口号。
  • 返回值:主机字节序的16位端口号。

7. ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);

  • 函数作用UDP接收数据(核心),接收UDP数据,同时获取发送方的网络地址(struct sockaddr)。
  • 输入参数
    • 套接字描述符;
    • 接收数据的缓冲区;
    • 缓冲区大小;
    • 标志位(默认填0);
    • 存储发送方地址的结构体(输出参数);
    • 发送方地址结构体的长度(输入输出参数)。
  • 返回值:成功返回接收的字节数,失败返回-1。

8. ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);

  • 函数作用UDP发送数据(核心),指定目标地址,发送UDP数据。
  • 输入参数
    • 套接字描述符;
    • 要发送的数据;
    • 数据长度;
    • 标志位(默认填0);
    • 目标地址结构体;
    • 目标地址结构体的长度。
  • 返回值:成功返回发送的字节数,失败返回-1。

代码实现

服务端

为了让我们的 UDP 服务端符合解耦逻辑,同时实现资源自动管理、提升代码复用性,我们采用「先封装类、再调用」的思路来实现服务端逻辑 ------ 这也是游戏服务端开发中最常用的代码组织方式。

第一步:搭建 UdpServer 类的基础框架

一个完整的服务端生命周期包含「初始化→运行→销毁」三个核心阶段,我们先搭建出类的基础骨架,对应这三个阶段预留核心函数:

cpp 复制代码
struct UdpServer{
	UdpServer()
	{}
	~UdpServer()
	{}
};

第二步:补充核心业务函数

服务端需要先完成初始化(比如创建套接字、绑定端口),才能进入循环收发数据的运行状态。因此我们为类补充两个核心成员函数:

  • InitServer():负责服务端的初始化工作(创建套接字、绑定 IP / 端口等核心准备操作);
  • Start():负责服务端的运行逻辑(循环接收客户端数据、处理并回复)。
cpp 复制代码
struct UdpServer{
	UdpServer()
	{}
	
	void InitServer()
	{}

	void Start()
	{}

	~UdpServer()
	{}
};

第三步:套接字创建

咱们写 UDP 服务端,第一步就是 "造一个能通信的管道"------ 也就是创建套接字(socket),这是所有网络通信的 "地基",地基塌了后面啥都白搭。

所以咱们的 UdpServer 类,第一个私有成员变量就是 int _sockfd;(套接字描述符),而且我们将其初始化成 -1 明确此时是未创建套接字的状态:

cpp 复制代码
struct UdpServer{
	UdpServer()
		: sockfd(-1)
	{}
	
	void InitServer()
	{
		_sockfd = socket(AF_INET,SOCK_DGRAM,0);
        if(_sockfd < 0)
        {
            std::cerr << "Socket Fall!" << std::endl;
            EXIT(1);
        }
	}

	void Start()
	{}

	~UdpServer()
	{}
private:
	int _sockfd;
};

第四步:地址封装

咱们刚搞定了套接字的创建(这是通信的"管道"),接下来要解决第二个核心问题------网络地址的处理

Linux下的UDP编程里,地址操作全是繁琐的细节:比如IP要在"字符串<->二进制"之间转换、端口要做"主机序<->网络序"转换,还要处理sockaddr_in这种结构体的强转......这些操作如果散落在代码里,不仅冗余,还容易出错(比如忘转字节序、IP转换写错参数)。

所以我们的思路是:把所有和地址相关的操作,单独封装成一个Addr ------把"脏活累活"都藏在类里,外部只需要调用简单的接口(比如Ip()Port()),不用再关心底层的转换逻辑。

先看Addr类的核心设计思路

这个类的核心目标是:对外提供简洁的地址操作接口,对内封装所有复杂的转换细节。我们先拆解它的核心逻辑:

** 1. 核心成员变量:存储地址的"原始数据"和"友好数据"**

cpp 复制代码
private:
    struct sockaddr_in _addr;  // 底层原始地址结构体(给系统调用用)
    uint16_t _port;            // 主机序的端口(给我们自己用)
    std::string _ip;           // 字符串格式的IP(给我们自己用)
  • _addr:是Linux系统识别的地址结构体,bind()/sendto()/recvfrom()这些系统函数都需要它;
  • _port/_ip:是我们能直接用的"友好格式"------端口是主机序(不用再转ntohs()),IP是字符串(不用再调inet_ntop())。

2. 构造函数:适配不同场景的地址初始化

我们设计了3个构造函数,覆盖服务端/客户端的所有地址使用场景:

cpp 复制代码
// 空构造:备用(比如先创建对象,后续再赋值)
Addr() {}

// 场景1:从已有的sockaddr_in结构体初始化(比如接收客户端数据时)
Addr(const struct sockaddr_in &addr) : _addr(addr) {
    GetPort();  // 把网络序端口转成主机序,存到_port
    GetIp();    // 把二进制IP转成字符串,存到_ip
}

// 场景2:服务端绑定端口时初始化(只传端口,绑定所有网卡)
Addr(const uint16_t& port) : _port(port), _ip("") {
    _addr.sin_family = AF_INET;                // 固定用IPv4
    _addr.sin_addr.s_addr = INADDR_ANY;        // 绑定所有网卡(0.0.0.0)
    _addr.sin_port = htons(_port);             // 端口转网络序
}
  • 服务端用:直接传端口(比如8080),类内部自动完成"端口转网络序+绑定所有网卡",不用手动写memsethtons
  • 处理客户端地址用:收到recvfrom()返回的peer地址结构体后,传给Addr类,自动把"网络序端口/二进制IP"转成我们能直接打印的格式。

为什么服务端要绑定 INADDR_ANY(0.0.0.0),而非具体的云服务器IP?

咱们在Addr类的服务端构造函数里写了_addr.sin_addr.s_addr = INADDR_ANY;,注释标了"绑定所有网卡",这是服务端开发的核心小技巧------尤其是部署在云服务器上时,选INADDR_ANY远比写具体IP(比如云服务器的公网IP/内网IP)更合理:

INADDR_ANY 是Linux系统定义的宏,本质是0.0.0.0(二进制全0),它的核心含义是:让套接字绑定到当前服务器的所有网络接口(网卡)上

一台云服务器(或普通服务器)通常不止一个网卡,比如:

  • 内网网卡:对应私网IP(比如172.17.0.2,用于服务器内网通信);
  • 公网网卡:对应公网IP(比如120.78.168.XX,用于外网客户端访问);
  • 回环网卡:对应127.0.0.1(用于服务器本地测试)。

如果绑定INADDR_ANY,客户端无论是通过「公网IP」「内网IP」还是「127.0.0.1」访问服务端的8080端口,都能成功连接;但如果绑定具体的IP(比如120.78.168.XX),只有通过这个公网IP访问才有效,用内网IP/127.0.0.1访问都会失败。

核心原因:为什么云服务器优先用 INADDR_ANY

云服务器的网络环境比本地电脑复杂:

  • 它有「内网IP」(同机房服务器通信用)、「公网IP」(外网客户端访问用),甚至可能有容器/虚拟机的虚拟IP;
  • 如果硬写具体的公网IP(比如120.78.168.XX),会出现两个问题:
    ✅ 问题1:内网其他服务(比如同服务器的测试程序、同机房的其他服务器)想通过内网IP访问这个UDP服务,会连不上;
    ✅ 问题2:如果云服务器的公网IP变更(虽然少见,但部分云厂商会调整),代码里的IP也要跟着改,重新编译部署,特别麻烦。
  • 而绑定INADDR_ANY:不管是内网、公网、本地回环的请求,只要访问服务器的8080端口,服务端都能接收,不用管具体是哪个IP的请求。

3. 私有工具函数:封装所有"繁琐转换"

类里的AddrIp()/PtonIp()/HtonsPort()/GetPort()/GetIp()都是"内部工具",把重复的转换逻辑封装起来:

  • HtonsPort():把主机序端口转网络序(给系统用);
  • GetPort():把网络序端口转主机序(给我们用);
  • GetIp():把二进制IP转字符串(比如把0x7F000001转成127.0.0.1);
  • PtonIp():升级版的IP转换(支持校验IP合法性,游戏服务端更推荐用这个)。

这些函数对外隐藏,外部代码完全不用关心"IP怎么转、端口怎么换",避免了重复写转换代码的麻烦。

4. 对外接口:极简调用,不用碰底层细节

类提供的公开接口全是"一键式"的,比如:

cpp 复制代码
struct sockaddr *NetAddr() { return (struct sockaddr*)&_addr; }  // 给系统函数传地址
std::string Ip() { return _ip; }                                // 获取字符串IP
uint16_t Port() { return _port; }                               // 获取主机序端口
socklen_t Size() { return sizeof(_addr); }                      // 获取地址结构体大小

为什么要单独封装Addr类?

  1. 解耦 :地址操作和UDP服务端逻辑彻底分开,UdpServer类只需要调用Addr的接口,不用关心地址怎么处理;
  2. 防错 :所有转换逻辑(比如htonsinet_pton)都封装在类里,避免手动写时漏写、写错;
  3. 复用 :后续写UDP客户端、甚至TCP程序时,这个Addr类可以直接用,不用重复写地址转换代码;
  4. 易读addr.Ip()addr.Port()比直接操作_addr.sin_addrntohs(_addr.sin_port)更直观,代码一眼就能看懂。

简单说:封装Addr类,就是把"需要记一堆系统函数、一堆转换规则"的繁琐操作,变成"调用几个简单接口"的轻松操作------这也是工程化代码的核心:把复杂留给自己,把简单留给调用者

第五步:bind() 将网络信息注入内核

咱们已经把套接字创建好了、地址也封装成Addr类了,现在到了UDP服务端的关键一步:调用bind()函数,把套接字和"IP+端口"绑定起来(本质是把网络信息注入内核)

而这一步,恰恰能让我们最直观地感受到"封装的好处"------先看对比,再拆解代码逻辑:

先感受:封装前后的"效率差"
bind()的核心作用是告诉内核:"把这个套接字(_sockfd)和指定的IP+端口绑定,后续所有发往这个端口的UDP数据,都交给这个套接字处理"。

如果不用我们封装的Addr类,写bind()要做一堆繁琐操作:

cpp 复制代码
// 未封装的写法:繁琐且易出错
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));  // 必须清空结构体,否则有脏数据
addr.sin_family = AF_INET;       // 固定写AF_INET,容易漏写
addr.sin_port = htons(8080);     // 必须转网络序,忘写就端口错误
addr.sin_addr.s_addr = INADDR_ANY; // 绑定所有网卡,新手容易写成具体IP
// 还要强转类型、手动传结构体大小,参数多容易错
bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));

这里每一步都有"踩坑点":忘写memset、漏转htons、强转类型写错......只要错一个,bind()就会失败,排查起来还得逐行看。

但用了我们封装的Addr类后,bind()直接简化成"一行核心代码":

cpp 复制代码
// 封装后的写法:一行搞定,零踩坑
Addr addr(8080);
bind(sockfd, addr.NetAddr(), addr.Size());

所有繁琐的初始化、转换、类型处理,都被Addr类"藏"在了内部,外部只需要调用简单的接口------这就是封装的魔力:把"需要记一堆规则、防一堆坑"的操作,变成"拿来就用"的简单调用

再拆解:UdpServer类里的bind()逻辑

我们把封装后的bind()整合到UdpServer类的InitServer()里,完整逻辑如下:

cpp 复制代码
// 定义默认端口:全项目统一,改起来只动这一处
const static uint16_t gdefaultport = 8080;

struct UdpServer{
public:
    // 构造函数:初始化套接字(初始-1)+ 初始化地址(传端口,默认8080)
    UdpServer(const uint16_t port = gdefaultport)
        : _sockfd(-1),  // 初始化为无效值,标记套接字未创建
          _addr(port)   // 调用Addr类构造函数,自动完成地址初始化
    {}

    void InitServer()
    {
        // 1. 创建套接字(之前讲过的核心步骤)
        _sockfd = socket(AF_INET,SOCK_DGRAM,0);
        if(_sockfd < 0)
        {
            std::cerr << "Socket Fall!" << std::endl;
            EXIT(1); // 创建失败直接退出,不往下走
        }

        // 2. 核心:调用bind()绑定套接字和地址
        // _addr.NetAddr():返回封装好的sockaddr*类型(不用手动强转)
        // _addr.Size():返回地址结构体大小(不用手动算sizeof)
        int n = bind(_sockfd, _addr.NetAddr(), _addr.Size());
        if(n < 0)
        {
            std::cerr << "Bind Fall!" << std::endl;
            EXIT(2); // 绑定失败直接退出,避免后续无效操作
        }
        std::cout << "Server All Ready!" << std::endl; // 绑定成功,服务端就绪
    }

    void Start()
    { } // 后续写运行逻辑

    ~UdpServer()
    { } // 后续写资源释放
private:
    int _sockfd;  // 套接字描述符(核心身份标识)
    Addr _addr;   // 封装后的地址对象(不用再碰原生结构体)
};

这里有几个关键细节要拎出来说:

  1. 构造函数的巧思UdpServer的构造函数接收端口参数(默认8080),并直接传给_addr(port)------这意味着创建UdpServer对象时,地址已经自动初始化好了(包括AF_INETINADDR_ANYhtons转换),不用在InitServer()里再写一行地址相关代码;
  2. bind()的参数简化_addr.NetAddr()帮我们完成了(struct sockaddr*)的强转,_addr.Size()帮我们封装了sizeof(addr),参数少了、类型对了,自然不容易错;
  3. 失败处理的必要性bind()返回值<0时直接退出(EXIT(2)),因为绑定失败后,服务端根本无法接收数据,继续运行也没有意义------这是"防御性编码",避免程序空跑、报莫名其妙的错。

为什么封装后代码更"工程化"?

  1. 可维护性拉满 :如果想改端口,只需要改gdefaultport,或者创建UdpServer时传新端口(比如UdpServer(9090)),不用在bind()附近找一堆代码改;如果想改地址逻辑(比如加IPv6支持),只需要改Addr类,UdpServer类完全不用动;
  2. 可读性拉满_addr.NetAddr()_addr.Size()(struct sockaddr*)&addrsizeof(addr)更直观,哪怕是新手看代码,也能一眼看懂"这是传地址、这是传地址大小";
  3. 复用性拉满 :后续写UDP客户端、甚至TCP服务端,这个Addr类都能直接用,bind()的调用方式也完全一样,不用重复造轮子。

简单说:bind()这一步的封装对比,把"封装的价值"从"抽象概念"变成了"看得见、摸得着的代码简化"------这也是游戏服务端开发的核心思路:把底层的繁琐逻辑封装成上层的简单接口,让开发聚焦业务,而非底层细节

到这里,UDP服务端的"地基"已经打牢了:套接字创建好了、地址绑定好了,接下来只需要在Start()里写"循环接收数据、处理数据、回复数据"的核心逻辑就行。

第六步:业务逻辑实现

现在服务端的"地基"(创建套接字、绑定地址)已经打牢,接下来要在Start()函数里实现核心业务逻辑------咱们先做一个最经典的"回声服务器"(客户端发什么,服务端就回什么),这是UDP服务端最基础也最易理解的业务模型,后续扩展成游戏服务端的"玩家指令处理""战斗数据同步"等逻辑,核心框架也完全通用。

先理清Start()的核心流程
Start()的核心是一个"死循环":服务端启动后,一直循环接收客户端的数据→解析数据→处理数据(这里是回声)→回复数据,直到主动停止服务。我们逐行拆解这段代码的逻辑:

cpp 复制代码
void Start()
{
    // 1. 标记服务端为"运行中"状态
    _isrunning = true;
    // 2. 核心循环:只要服务端在运行,就一直接收/回复数据
    while(_isrunning)
    {
        // 准备接收缓冲区:存储客户端发来的数据(1024字节足够日常测试)
        char buffer[1024];
        size_t bufferlen = sizeof(buffer);

        // 存储发送数据的客户端地址:UDP是无连接的,每次接收都要获取客户端地址(才能回复)
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);

        // 3. 核心:接收客户端数据(recvfrom是UDP的专属接收函数)
        ssize_t mark = recvfrom(
            _sockfd,                  // 服务端的套接字描述符
            buffer,                   // 接收数据的缓冲区
            bufferlen - 1,            // 缓冲区大小(留1位给'\0',避免字符串乱码)
            0,                        // 标志位:默认0(阻塞接收,没数据就等)
            (struct sockaddr *)&peer, // 输出参数:客户端的地址信息
            &len                      // 输入输出参数:地址结构体的长度
        );

        // 4. 处理接收到的有效数据
        if(mark > 0)
        {
            // 给数据加字符串结束符:把接收到的字节流转成可打印的字符串
            buffer[mark] = '\0';
            // 用我们封装的Addr类解析客户端地址:不用手动转IP/端口,一键获取
            Addr cli(peer);

            // 拼接客户端信息:IP+端口+发送的内容,方便日志打印
            std::string clientinfo = cli.Ip() + ":" + std::to_string(cli.Port()) + " # " + buffer;
            std::cout << " 接收到客户端信息 来自" << clientinfo << std::endl;

            // 5. 业务处理:极简的"回声逻辑"(客户端发什么,服务端加前缀返回)
            std::string echo = "echo # ";
            echo += buffer;

            // 6. 回复客户端:用sendto把处理后的结果发回去
            sendto(
                _sockfd,              // 服务端套接字描述符
                echo.c_str(),         // 要发送的回声数据
                echo.size(),          // 数据长度
                0,                    // 标志位:默认0
                cli.NetAddr(),        // 客户端地址(从Addr类直接拿,不用手动强转)
                cli.Size()            // 客户端地址结构体大小(Addr类封装)
            );
        }
    }
    // 循环退出后,标记服务端为"停止运行"(实际echo服务器不会走到这,除非_isrunning被修改)
    _isrunning = false;
}

核心细节拆解

  1. 为什么用while(_isrunning)循环?

    • _isrunning是服务端的"运行状态标记",用它控制循环而非while(true),是为了后续扩展"优雅停止服务"(比如加一个Stop()函数,把_isrunning设为false,循环就会退出,服务端正常关闭);
    • 游戏服务端也会用这种"状态标记+循环"的模式,比如检测到"服务器维护指令"时,把_isrunning设为false,先处理完当前客户端请求再退出,而非强制杀死进程。
  2. recvfrom()的关键:UDP无连接的体现

    • TCP是"连接式"的,建立连接后只需recv()就能收数据;但UDP是"无连接"的,服务端不知道谁会发数据,所以recvfrom()既要收数据,也要获取"谁发的(peer地址)"------这是UDP编程的核心特点;
    • bufferlen - 1留1位给'\0':因为客户端发来的是字节流,不加结束符的话,打印buffer时会出现乱码,这是新手最易踩的小坑。
  3. 封装的Addr类再次简化逻辑

    • 拿到peer(客户端地址结构体)后,只需Addr cli(peer),就能通过cli.Ip()cli.Port()直接获取"字符串格式的客户端IP""主机序的端口",不用手动调用inet_ntop()ntohs()
    • 回复数据时,cli.NetAddr()cli.Size()直接传给sendto(),不用手动强转struct sockaddr*、计算sizeof(peer),再次体现封装的价值。
  4. echo逻辑的扩展性

    • 咱们现在的业务是"加个echo前缀返回",但实际游戏服务端里,这里可以替换成任意逻辑:
      • 比如解析客户端发来的"玩家移动指令":buffer里是"move 100 200",就解析坐标并同步给其他玩家;
      • 比如解析"战斗指令":buffer里是"attack player1",就处理战斗逻辑并返回结果;
    • 核心框架不变:接收数据→解析数据→业务处理→回复数据,这是所有UDP服务端的通用逻辑。

补充:为什么是"回声服务器"?

回声服务器是学习UDP的"最佳入门案例":

  • 逻辑极简:没有复杂的业务逻辑,聚焦"UDP数据收发"的核心;
  • 易测试:客户端发任意内容,服务端都能回复,能快速验证服务端是否正常工作;
  • 框架通用:把echo逻辑换成游戏业务逻辑,就是一个基础的游戏UDP服务端雏形。

(注:完整的业务逻辑可参考源码中的猜数字游戏 Gitee,核心的recvfrom()+sendto()框架不变。)

  1. Start()的核心是"循环收发数据":recvfrom()收数据(带客户端地址)→ 处理数据 → sendto()回复数据,这是UDP服务端的通用骨架;
  2. 无连接特性:UDP必须通过recvfrom()获取客户端地址,才能精准回复数据;
  3. 封装价值:Addr类让地址处理全程简化,聚焦业务逻辑而非底层转换;
  4. 扩展性:echo逻辑可轻松替换为游戏服务端的"指令解析""数据同步"等核心业务,只需修改数据处理部分。

到这里,一个能跑通的UDP服务端就完整实现了------接下来只需在main()里创建UdpServer对象、调用InitServer()Start(),服务端就能启动并响应客户端请求了。

最后一步:释放套接字------服务端的"优雅收尾"

咱们把UDP服务端的核心逻辑都实现完了,最后要补上一个容易被忽略但至关重要的细节:在析构函数中释放套接字资源。这一步是保证服务端"优雅退出"的关键,尤其是游戏服务端这类需要7x24小时运行的程序,资源泄漏会直接导致服务崩溃或端口占用。

析构函数中安全关闭套接字

cpp 复制代码
~UdpServer()
{
    // 只有套接字描述符有效(> -1),才调用close()
    if(_sockfd > -1)
        ::close(_sockfd);
}

这段代码看似简单,却藏着两个核心设计思路,咱们拆开来聊:

1. 为什么要判断 _sockfd > -1

还记得我们把_sockfd初始化为-1(无效标记)吗?这个判断就是为了避免"无效操作":

  • 场景1:如果服务端初始化失败(比如socket()创建失败,_sockfd还是-1),此时调用::close(-1)会触发系统错误(EBADF,表示文件描述符无效),虽然不致命,但会产生无意义的错误日志,干扰问题排查;
  • 场景2:如果_sockfd已经被关闭过(比如手动调用close()),重复关闭也会触发EBADF错误;
  • 加了if(_sockfd > -1)后,只有套接字真正创建成功(_sockfd是有效值),才会执行关闭操作,彻底避免无效调用。

2. 为什么要用 ::close() 而非 close()

这里的::是C++的"全局作用域符",核心目的是明确调用系统的close()函数,而非类的成员函数(如果有的话)

  • 如果后续给UdpServer类新增了名为close()的成员函数(比如void close()),直接写close(_sockfd)会优先调用类内的成员函数,导致系统的close()没被调用,套接字资源泄漏;
  • ::后,强制调用全局的close()(Linux系统提供的关闭文件描述符的函数),确保套接字被正确释放------这是防御性编码的小细节,能避免命名冲突带来的坑。

到这里,一个完整、健壮、可扩展的UDP服务端就全部实现了:从套接字创建、地址封装、绑定内核,到业务逻辑处理,再到资源优雅释放,每一步都兼顾了"功能实现"和"工程化最佳实践",也是游戏服务端网络编程的核心基础。

相关推荐
神秘面具男032 小时前
Ansible Playbook 编写与运行
服务器·网络·ansible
TG:@yunlaoda360 云老大2 小时前
华为云国际站代理商OCR的多语种识别能力可以应用于哪些场景?
服务器·华为云·ocr
SMF19192 小时前
解决从物理机复制的文件无法粘贴到vm虚拟机centos系统中问题
linux·运维·centos
TG:@yunlaoda360 云老大2 小时前
华为云国际站代理商HiLens的技术优势体现在哪些方面?
服务器·数据库·华为云
同聘云2 小时前
阿里云国际站云服务器是虚拟技术吗?云服务器和虚拟技术的关系是什么?
服务器·安全·阿里云·云计算
QQ12154614682 小时前
Linux CentOS 7配置 Tomcat 系统服务
linux·centos·tomcat
游戏23人生2 小时前
c++ 语言教程——17面向对象设计模式(六)
开发语言·c++·设计模式
SMF19192 小时前
【FTP服务器】Linux(Centos)系统搭建FTP服务器(可根据账号独立配置每个账号的ftp地址)
linux·服务器·centos
superman超哥2 小时前
仓颉内存管理内功:栈与堆的分配策略深度解析
c语言·开发语言·c++·python·仓颉