从零入门计算机网络系列(五):UDP套接字编程实战——从核心API到客户端与服务器通信
本文从UDP Socket的基础出发一路往下走。先把socket、sockaddr_in这些最基础的概念讲清楚再把字节序、recvfrom/sendto这对收发搭档逐一拆开。接着看服务端怎么bind客户端为什么是隐式bindINADDR_ANY又到底意味着什么。最后把IP地址转换和线程安全的API一并收尾。目录一、UDP套接字编程基础从创建套接字到接收数据1.1 Socket编程基础与常用头文件1.2 创建套接字socket函数详解1.2.1 参数解析与常用常量1.2.2 返回值与Linux“一切皆文件”的设计思想1.3 IPv4地址结构sockaddr_in详解1.3.1 sockaddr_in的结构成员与含义1.4 IP地址转换与字节序处理1.4.1 点分十进制地址与网络字节序的转换1.5 接收数据recvfrom函数详解1.5.1 参数含义与返回值二、UDP客户端与服务器从初始化到数据收发2.1 服务器初始化地址配置与端口绑定2.1.1 结构体清零bzero与explicit_bzero2.1.2 填充sockaddr_in与网络字节序转换2.1.3 绑定本地地址bind系统调用2.2 UDP数据收发机制从接收到响应2.2.1 数据接收recvfrom与阻塞I/O2.2.2 数据发送sendto函数2.3 客户端通信服务器地址与端口分配2.3.1 服务器地址的硬编码与配置文件管理2.3.2 客户端的隐式绑定端口由谁分配三、深入理解bind客户端策略与服务端需求3.1 UDP客户端是否需要显式调用bind3.2 为什么UDP服务端通常需要显式绑定端口四、UDP服务器进阶地址绑定、业务解耦与转换接口4.1 服务器地址绑定中的常见问题与INADDR_ANY原理4.1.1 常见的地址绑定误区4.1.2 使用INADDR_ANY监听本机的多个IPv4地址4.2 软件分层设计实现UDP通信与业务逻辑解耦4.2.1 使用回调函数处理业务逻辑4.2.2 回调函数模式实现4.3 IP地址转换函数的演进与线程安全性4.3.1 旧版地址转换API及其局限4.3.2 现代地址转换APIinet_pton与inet_ntop五、完整源码与运行测试5.1 InetAddr.hppIP地址封装类5.2 UdpServer.hppUDP服务器类5.3 UdpServer.cc服务端主程序5.4 UdpClient.cc客户端主程序5.5 Makefile项目编译规则5.6 编译运行与通信测试一、UDP套接字编程基础从创建套接字到接收数据网络编程的核心就是借助操作系统提供的套接字Socket接口让进程之间、主机之间能够交换数据。传输层协议里UDP用户数据报协议是个无连接、不可靠、面向数据报的协议。1.1 Socket编程基础与常用头文件在Linux网络编程中搞套接字开发通常绕不开这四个基础头文件#include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.hsys/types.h和sys/socket.h定下了套接字编程的核心数据类型和系统调用函数比如socket()、recvfrom()、sendto()这些。netinet/in.h和arpa/inet.h负责网络地址结构体比如struct sockaddr_in还有IP地址与字节序之间的转换函数。1.2 创建套接字socket函数详解网络通信的第一步是创建套接字。Linux里靠socket()这个系统调用来搞定int socket(int domain, int type, int protocol);1.2.1 参数解析与常用常量domain协议族/地址族指定通信的领域说白了就是决定这次是本地进程间通信还是走网络通信。AF_UNIX/AF_LOCAL用于Unix本地进程间通信。AF_INET用于IPv4互联网协议。AF_INET6用于IPv6互联网协议。type套接字类型指定通信的数据传输服务类型。SOCK_STREAM提供顺序、可靠、双向、基于连接的字节流通信也就是TCP。SOCK_DGRAM支持数据报通信无连接、不可靠、消息有固定最大长度也就是UDP。protocol协议类型通常填0。系统会根据domain和type自动推导出默认协议比如AF_INET配SOCK_DGRAM默认就是UDP协议。1.2.2 返回值与Linux“一切皆文件”的设计思想成功返回一个新的文件描述符File DescriptorFD。失败返回-1并设置errno 错误码。套接字在Linux里被抽象成一个文件描述符这恰恰体现了“一切皆文件”的设计哲学。网络设备的读写和普通文件的读写在内核层面有着高度统一的操作接口。打哪儿读、往哪儿写底层都是同一套东西。套接字披着网络的外衣骨子里还是个文件。1.3 IPv4地址结构sockaddr_in详解为了兼容五花八门的协议族地址格式Linux设计了一个通用结构体struct sockaddr。但真到了IPv4网络通信的实战里用的却是专门的struct sockaddr_in。1.3.1 sockaddr_in的结构成员与含义struct sockaddr_in的内部组织长这样struct sockaddr_in { __SOCKADDR_COMMON (sin_); /* 16 位地址类型 / 协议家族如 AF_INET */ in_port_t sin_port; /* 16 位端口号网络字节序 */ struct in_addr sin_addr; /* 32 位 IP 地址网络字节序 */ unsigned char sin_zero[8]; /* 填充项用于与 struct sockaddr 保持大小一致 */ };结构体成员逐个拆地址类型sin_family通过内核宏__SOCKADDR_COMMON(sin_)展开后就是sa_family_t sin_family用来指定协议族比如AF_INET。那个##是C语言预处理器的运算符专门负责把符号拼接合并。端口号sin_port16位无符号整数表示通信进程的端口号。注意它得是网络字节序。IP 地址sin_addr内部装着一个32位无符号整数表示IPv4地址。同样网络字节序。填充物sin_zero8个字节的填充。为什么要它为了让整个struct sockaddr_in的大小跟通用的struct sockaddr严丝合缝地对上sockaddr那边是14字节地址数据加2字节类型正好16字节。大小一致两者才能安全地互相强转。1.4 IP地址转换与字节序处理在计算机内部和网络传输里IP地址的表示形式完全是两码事。用户习惯点分十进制字符串比如192.168.1.1可网络底层传输和结构体存储要的是32位无符号整数uint32_t而且必须守网络字节序的规矩也就是大端。1.4.1 点分十进制地址与网络字节序的转换IP字符串转4字节网络字节序用inet_addr这个API一步到位把点分十进制字符串变成网络字节序的32位整数in_addr_t inet_addr(const char *cp);转换原理拆开看三步第一步把字符串192.168.1.1按 . 切开分成四个整型部分192、168、1、1。第二步每个部分占一个字节char四块拼起来组成一个32位整数。第三步按网络传输协议的要求把数值调整成网络字节序也就是大端序。反向转换的原理反过来要把收到的32位网络字节序IP变回可读字符串思路也不复杂用指针强转把四个字节一个个取出来再按点分十进制的格式拼回去。一个字符串一个整数中间隔着一道字节序的转换。来来回回网络通信的地址识别就靠这套转换在两端对齐。1.5 接收数据recvfrom函数详解网络服务器通常得7×24小时连轴转所以UDP服务器的核心逻辑往往就是一个死循环不停地监听、收包、处理。只要服务不停这个圈就一直转。Linux给无连接的数据包接收准备了一个专门的函数recvfrom。ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);1.5.1 参数含义与返回值sockfdsocket()返回的那个套接字文件描述符告诉函数从哪个套接字上收数据。buf用来存放接收数据的内存缓冲区指针。收来的数据就往这儿放。len缓冲区的最大可用字节数。别让数据溢出来。flags接收控制标志位通常填0表示阻塞式接收。没数据来它就老老实实等着。src_addr输出型参数。填一个struct sockaddr_in强转后的指针进去函数收完数据会把发送方客户端的IP地址和端口号写到这里。谁发的一查便知。addrlen输入输出型参数。传进去时是src_addr结构体的大小也就是sizeof(struct sockaddr_in)函数返回时它变成内核实际写入的地址信息结构体大小。返回值成功时返回实际接收到的数据字节数如果连接关闭或出错返回-1并设置errno。recvfrom一收数据到手发送方的地址也顺带揣兜里了。UDP服务器靠它才能在收到消息之后知道该给谁回话。二、UDP客户端与服务器从初始化到数据收发上文我们把UDP套接字的基础概念、套接字创建和数据结构捋了一遍。接下来就正式进入UDP服务器的初始化绑定bind、数据收发recvfrom 与 sendto以及客户端请求机制背后的底层逻辑。2.1 服务器初始化地址配置与端口绑定要创建一个UDP服务器第一步就是完成套接字初始化然后把特定的IP和端口信息绑定Bind到内核里。地址不绑上客户端就找不到你。2.1.1 结构体清零bzero与explicit_bzero在填充struct sockaddr_in之前通常得先把结构体占用的内存清零免得残留的脏数据混进去把通信搅乱。Linux环境下可以用下面这两个函数#include strings.h void bzero(void *s, size_t n); void explicit_bzero(void *s, size_t n);bzero把指定内存空间的前n个字节全部清零也就是填成\0。explicit_bzero功能跟bzero一样但它能防止编译器把那些“看起来没用”的内存清零操作优化掉。处理敏感数据、清完就不该留痕的场景用它更稳妥。服务器初始化阶段先把套接字地址结构体清零struct sockaddr_in local; bzero(local, sizeof(local));当然用memset来设置也一样效果完全相同。2.1.2 填充sockaddr_in与网络字节序转换服务器端得明确告诉内核三件事监听哪个协议族、用哪个端口号、绑哪个IP地址。可主机和网络的字节序未必一致大端小端各说各话。所以主机数据必须先转成网络字节序大端才能出门。端口号转换用htons(port)把主机字节序的16位整数转成网络字节序。IP 地址转换用inet_addr(ip_str.c_str())把点分十进制字符串转成网络字节序的32位整数。local.sin_family AF_INET; local.sin_port htons(_port); local.sin_addr.s_addr inet_addr(_ip.c_str());三行填完地址结构体的“灵魂”就齐了。协议族、端口、IP各就各位。2.1.3 绑定本地地址bind系统调用地址结构体填好了还得把它跟套接字绑在一起。这一步靠bind()系统调用int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);它做的事情很直接把创建好的文件描述符_sockfd跟本地网络地址IP 端口强关联起来。从此这个套接字就认准了这个地址。代码实现与校验逻辑int n bind(_sockfd, (struct sockaddr*)local, sizeof(local)); if (n 0) { LOG(LogLevel::FATAL) bind error; exit(2); }注意一个细节bind的第二个参数要求是通用套接字指针类型struct sockaddr*。所以传入struct sockaddr_in结构体地址时得做一次强制类型转换。这其实就是C语言版的“指针多态”子类型指针转成父类型指针函数拿到通用外壳后根据内部的sin_family字段再决定按哪套规则解读。传的时候是sockaddr_in用的时候还是sockaddr_in中间那层sockaddr*只是个通用入口。2.2 UDP数据收发机制从接收到响应UDP套接字文件描述符sockfd既能读也能写。读就是收数据写就是发数据。这一读一写就决定了UDP通信是全双工Full-Duplex的。2.2.1 数据接收recvfrom与阻塞I/O服务端7×24小时连轴转死循环里不停歇靠的就是recvfrom抓取客户端发来的数据。ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);阻塞IO的行为特征当flags设为0时recvfrom就是阻塞式IOBlocking IO。客户端没发数据那服务端进程就在这儿挂着一直等。跟C语言里scanf等键盘输入的感觉一模一样没输入它就不往下走。直到网络缓冲区里来了数据它才醒过来。多对一通信怎么知道数据是谁发的客户端和服务器通常是多对一的关系。一个服务器对应一堆客户端。服务器处理数据时不光要拿到客户端发来的“文本数据”存在buf里还得搞清楚这数据是谁发的客户端的IP和端口一个都不能少。src_addr输出型参数。内核收数据的时候会把客户端的 struct sockaddr_in 地址信息塞进这个结构体。addrlen输入输出型参数。传进去时表示传入结构体的大小也就是 sizeof(struct sockaddr_in)函数返回时内核把它改成实际写入的结构体大小。2.2.2 数据发送sendto函数服务器要给客户端回消息或者客户端要给服务器发数据都用sendto这个系统调用ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);参数详解sockfd通信用的套接字文件描述符。buf/len用户级缓冲区指针以及需要发送的数据长度。flags发送控制标志通常填0。dest_addr/addrlen目的端的套接字地址信息由struct sockaddr_in转换而来及其结构体大小明确指定数据发给谁。返回值成功时返回实际发送的字节数出错时返回-1并设置errno。不过有一点得说清楚UDP是无连接的。sendto成功调用只意味着数据被交给了操作系统的网络栈去发送并不保证对端一定能收到。发出去就算交差了。至于路上会不会丢能不能到UDP本身不闻不问。2.3 客户端通信服务器地址与端口分配客户端要访问UDP服务端首先得拿到服务端的IP地址和端口号。这是敲门砖没有它连门都找不到。2.3.1 服务器地址的硬编码与配置文件管理客户端怎么知道服务端的IP和端口实际开发中客户端和服务端程序通常出自同一家公司或者同一个开发团队。服务端的IP地址和默认端口号一般会直接硬编码在客户端代码里也可以通过域名解析DNS或者从配置文件里动态加载。怎么方便怎么来关键是把地址先定下来。2.3.2 客户端的隐式绑定端口由谁分配服务端必须显式调用bind()把固定端口绑死不然客户端找不到它。可客户端不一样通常不需要显式bind()。原因有两点第一如果客户端显式绑定固定端口很可能跟用户机器上其他正在运行的程序撞端口导致客户端启动失败。用户机器上跑着什么谁也说不准硬绑一个端口等于给自己埋雷。第二客户端在第一次调用sendto()发送数据包时操作系统内核会自动、随机地给它分配一个唯一的空闲端口号顺带完成隐式绑定。不用客户端操心内核全包了。所以服务端绑定是为了“被找到”客户端不绑定是为了“不撞车”。一个求稳一个求活。三、深入理解bind客户端策略与服务端需求前文把 UDP 的基础 API 和核心通信流程讲完了。接下来有几个关于套接字绑定的经典疑问得好好掰扯掰扯客户端到底需不需要 bind为什么客户端不能显式 bind服务器又为什么必须显式 bind3.1 UDP客户端是否需要显式调用bind结论先说客户端需要bind但绝对不需要“显式手动bind”。为什么需要bind任何网络通信在传输层都得明确标识发送方和接收方。IP地址负责在网络中定位主机端口号负责在主机上唯一标识一个通信进程。客户端发数据包的时候必须带上“源IP 源端口”不然服务器处理完请求压根不知道往哪儿回话。为什么由操作系统隐式完成这里面有两层考虑。第一操作系统不信任用户。如果允许客户端代码显式写死端口号操作系统没法保证这个端口在用户机器上没被别的程序占着。端口冲突一旦发生一个端口只能被一个进程bind客户端程序当场启动失败。用户机器上跑着什么谁也说不准硬绑一个端口等于给自己埋雷。第二随机端口机制更省心。客户端第一次调用sendto()发消息时内核会自动获取本机IP再从当前空闲的动态端口池里随机挑一个没被占用的端口分配给这个套接字顺手完成bind操作。端口值对客户端来说无关紧要。具体占哪个端口不重要只要在这次通信过程中这个端口是唯一的就行。客户端在乎的是“能发出去、能收回来”端口号是几无所谓。3.2 为什么UDP服务端通常需要显式绑定端口跟客户端正好相反服务器端必须显式调用bind()指定固定且众所周知的IP和端口号。服务器的对外服务属性。服务器是为不特定的众多客户端提供服务的。客户端想主动发起请求前提是它得在发送之前就知道服务器的IP和端口号。地址不明确客户端连门都找不到。众所周知且不可轻易改变。服务器的IP地址和端口号必须是固定的。HTTP的80、HTTPS的443都是众所周知的固定服务端口。自定义服务也一样端口一旦定下来就不能随便改。要是服务器每次重启都由操作系统随机分配端口客户端就彻底懵了它不知道服务器跑到哪个端口上去了连接根本建不起来。后果很严重。如果服务器随意变更端口或者压根没做显式 bind短时间内所有依赖这个端口的客户端都会掉线服务直接中断。一个绑定两种命运。客户端求活服务端求稳。核心就一句话服务端要“被找到”客户端要“不撞车”。四、UDP服务器进阶地址绑定、业务解耦与转换接口实际开发里服务器端的IP绑定策略藏着不少坑。接下来我们深入分析云服务器环境下的IP绑定约束、INADDR_ANY的底层逻辑、应用层软件的分层设计以及地址转换函数的线程安全演进。4.1 服务器地址绑定中的常见问题与INADDR_ANY原理实际部署UDP服务器时直接指定具体的IP地址不管是公网IP还是某个具体的内网IP往往会撞上各种绑定错误或者访问限制。┌───────────────────────────┐ │ 云服务器 (主机) │ │ │ [客户端访问] ─── │ 网卡1: 本地环回 127.0.0.1 │ │ 网卡2: 内网 IP (私网) │ │ (公网 IP 挂载在外部网关) │ └─────────────┬─────────────┘ │ 推荐使用 INADDR_ANY (0.0.0.0) 监听发送到本机所有网卡的数据4.1.1 常见的地址绑定误区直接bind公网IP——失败。在云厂商阿里云、腾讯云这些提供的云服务器上物理网卡或虚拟网卡上配置的通常是内网 IP私网IP。公网IP是通过NAT网络地址转换映射到云服务器上的它并没有直接配置到云服务器的本地网卡设备上。所以在代码里显式bind公网IP系统会直接报错甩你一句bind error。bind 127.0.0.1或特定内网IP——局限性太大。如果bind了127.0.0.1本地环回地址数据包只能在操作系统内部转圈仅允许同一台机器上的客户端访问。外界跨网络根本进不来。如果bind了具体的内网IP那客户端用127.0.0.1发数据收不到反过来也一样。显式绑定某个特定地址之后客户端就必须且只能通过这个地址才能成功访问。一条路走到黑别的门都关了。4.1.2 使用INADDR_ANY监听本机的多个IPv4地址要解决上面这些麻烦服务器初始化sockaddr_in结构体的时候就别绑定具体的静态IP地址了改用INADDR_ANY// 在宏定义中INADDR_ANY 即为 0x00000000全 0 地址 #define INADDR_ANY ((in_addr_t) 0x00000000) // 服务器端初始化代码 local.sin_addr.s_addr INADDR_ANY; // 自动转换为网络字节序的全 0INADDR_ANY 的优势与底层原理多网卡监听。一台服务器可能挂载多块物理网卡或虚拟网卡比如拥有内网IP、环回地址IP等。指定INADDR_ANY之后操作系统内核会监听所有到达这台主机、且目标端口匹配的UDP数据包不管它是从哪块网卡进来的。跨网络访问通畅。无论是通过内网IP、公网IP的NAT映射还是通过本地环回地址127.0.0.1访问这个端口服务器都能正常接收并响应。来者不拒条条大路通罗马。4.2 软件分层设计实现UDP通信与业务逻辑解耦写高可靠的服务器程序网络通信逻辑收发数据和上层业务处理计算、数据库交互等应该解耦Decoupling。4.2.1 使用回调函数处理业务逻辑服务器的核心工作循环分三步接收数据通过recvfrom获取客户端数据包以及客户端的sockaddr_in地址对端信息。业务处理把接收到的原始数据传给上层回调函数或者业务逻辑模块去处理。响应发送通过sendto把上层处理完毕的结果发回给客户端。4.2.2 回调函数模式实现UDP服务器在类定义里维护一个函数对象比如std::functionstd::string(const std::string)把数据处理的具体逻辑交给外部上层传入#include functional #include string // 定义业务处理函数类型 using func_t std::functionstd::string(const std::string); class UdpServer { public: UdpServer(uint16_t port, func_t func) : _port(port), _func(func), _isrunning(false) {} void Start() { _isrunning true; char buffer[1024]; while (_isrunning) { struct sockaddr_in peer; socklen_t len sizeof(peer); // 1. 接收消息 ssize_t s recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr*)peer, len); if (s 0) { buffer[s] 0; // 手动补充字符串结尾符 std::string request buffer; // 2. 软件分层交给上层业务回调处理 std::string response _func(request); // 3. 发送响应结果给客户端 sendto(_sockfd, response.c_str(), response.size(), 0, (struct sockaddr*)peer, len); } } } private: int _sockfd; uint16_t _port; bool _isrunning; func_t _func; // 上层回调函数 };4.3 IP地址转换函数的演进与线程安全性处理IP地址的字符串格式点分十进制如192.168.1.1和网络字节序二进制整数in_addr_t之间的转换时Linux C API经历了一轮从传统旧API到现代化POSIX API的演进。[字符串 IP 192.168.1.1] ──inet_pton / inet_addr── [网络字节序 32 位整数][字符串 IP 192.168.1.1] ──inet_ntop / inet_ntoa── [网络字节序 32 位整数]4.3.1 旧版地址转换API及其局限①inet_addr功能把点分十进制字符串转成in_addr_t。局限只认IPv4碰上广播地址255.255.255.255它返回-1即INADDR_NONE这就有了歧义——到底是地址无效还是真就是个广播地址分不清错误处理也难做。② inet_ntoa与线程安全隐患功能把4字节网络字节序IP转成点分十进制字符串。致命缺陷线程不安全inet_ntoa内部用了一个静态局部缓冲区static buffer来存转换后的字符串。多线程并发环境下如果多个线程同时调inet_ntoa后面的线程会把前面线程缓存在静态区里的IP字符串覆盖掉引发严重的并发数据竞争。4.3.2 现代地址转换APIinet_pton与inet_ntop现代网络编程推荐用inet_ptonPresentation to Network和inet_ntopNetwork to Presentation。API接口声明#include arpa/inet.h // 字符串 IP - 二进制网络字节序 IP int inet_pton(int af, const char *src, void *dst); // 二进制网络字节序 IP - 字符串 IP const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);两大核心优势支持协议族第一个参数af可以显式指定AF_INETIPv4或AF_INET6IPv6扩展性拉满。绝对线程安全inet_ntop要求调用者显式传入一个由调用方维护的内存缓冲区指针比如栈上分配的char ipbuffer[64]。它不依赖内部静态缓冲区每个线程在自己的栈帧空间里操作线程安全隐患从根上被消除。封装现代 IP 地址转换类示例#include arpa/inet.h #include string #include cstring class InetAddr { public: // 从 sockaddr_in 结构提取 IP 和端口 InetAddr(const struct sockaddr_in addr) : _addr(addr) { _port ntohs(_addr.sin_port); // 显式在栈上创建缓冲区使用线程安全的 inet_ntop char ipbuffer[64]; inet_ntop(AF_INET, _addr.sin_addr, ipbuffer, sizeof(ipbuffer)); _ip ipbuffer; } // 从 IP 和 Port 构建 sockaddr_in 结构 InetAddr(const std::string ip, uint16_t port) : _ip(ip), _port(port) { memset(_addr, 0, sizeof(_addr)); _addr.sin_family AF_INET; _addr.sin_port htons(_port); // 使用线程安全的 inet_pton inet_pton(AF_INET, _ip.c_str(), _addr.sin_addr); } std::string Ip() const { return _ip; } uint16_t Port() const { return _port; } struct sockaddr_in GetAddr() const { return _addr; } private: struct sockaddr_in _addr; std::string _ip; uint16_t _port; };用inet_pton和inet_ntop搭配栈缓冲区或者封装类既完美支持IPv4/IPv6扩展又能确保多线程高并发应用下的绝对安全。旧API那点坑到此就算彻底绕过去了。五、完整源码与运行测试下面把整套UDP服务器与客户端的完整代码整理出来包含IP地址封装类、UDP服务器类、服务端主程序、客户端主程序以及Makefile。代码可直接编译运行。5.1 InetAddr.hppIP地址封装类#pragma once #include arpa/inet.h #include string #include cstring #include sys/socket.h #include netinet/in.h namespace NetModule { class InetAddr { public: // 从 sockaddr_in 结构提取 IP 和端口用于服务端接收客户端地址 InetAddr(const struct sockaddr_in addr) : _addr(addr) { _port ntohs(_addr.sin_port); // 显式在栈上创建缓冲区使用线程安全的 inet_ntop char ipbuffer[64]; inet_ntop(AF_INET, _addr.sin_addr, ipbuffer, sizeof(ipbuffer)); _ip ipbuffer; } // 从 IP 和 Port 构建 sockaddr_in 结构用于客户端指定服务端地址 InetAddr(const std::string ip, uint16_t port) : _ip(ip), _port(port) { memset(_addr, 0, sizeof(_addr)); _addr.sin_family AF_INET; _addr.sin_port htons(_port); // 使用线程安全的 inet_pton inet_pton(AF_INET, _ip.c_str(), _addr.sin_addr); } std::string Ip() const { return _ip; } uint16_t Port() const { return _port; } struct sockaddr_in GetAddr() const { return _addr; } ~InetAddr() {} private: struct sockaddr_in _addr; std::string _ip; uint16_t _port; }; }5.2 UdpServer.hppUDP服务器类#pragma once #include iostream #include string #include functional #include cstring #include cstdlib #include unistd.h #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include InetAddr.hpp namespace NetModule { // 业务处理函数类型输入请求字符串返回响应字符串 using func_t std::functionstd::string(const std::string ); const uint16_t DEFAULT_PORT 8080; const int BACKLOG 10; class UdpServer { public: UdpServer(uint16_t port DEFAULT_PORT, func_t func nullptr) : _port(port), _func(func), _isrunning(false), _sockfd(-1) { } // 初始化创建套接字 绑定地址 void Init() { // 1. 创建 UDP 套接字 _sockfd socket(AF_INET, SOCK_DGRAM, 0); if (_sockfd 0) { std::cerr socket error std::endl; exit(1); } std::cout socket create success, sockfd: _sockfd std::endl; // 2. 填充本地地址结构体 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; // 监听所有网卡 // 3. 绑定地址 int n bind(_sockfd, (struct sockaddr *)local, sizeof(local)); if (n 0) { std::cerr bind error std::endl; exit(2); } std::cout bind success, port: _port std::endl; } // 启动服务器死循环接收 处理 响应 void Start() { _isrunning true; char buffer[1024]; while (_isrunning) { struct sockaddr_in peer; socklen_t len sizeof(peer); // 1. 接收消息 ssize_t s recvfrom(_sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)peer, len); if (s 0) { std::cerr recvfrom error std::endl; continue; } buffer[s] 0; // 手动补充字符串结尾符 std::string request buffer; // 构造对端地址对象方便打印和后续使用 InetAddr peerAddr(peer); std::cout [ peerAddr.Ip() : peerAddr.Port() ]# request std::endl; // 2. 软件分层交给上层业务回调处理 std::string response; if (_func) { response _func(request); } else { response Server received: request; } // 3. 发送响应结果给客户端 sendto(_sockfd, response.c_str(), response.size(), 0, (struct sockaddr *)peer, len); } } ~UdpServer() { if (_sockfd 0) { close(_sockfd); } } private: int _sockfd; uint16_t _port; bool _isrunning; func_t _func; // 上层回调函数 }; }5.3 UdpServer.cc服务端主程序#include UdpServer.hpp #include memory // 业务处理回调把收到的字符串转成大写再返回 std::string Handler(const std::string request) { std::string response request; for (auto ch : response) { if (ch a ch z) { ch - 32; } } return response; } // 用法./udp_server [端口号] int main(int argc, char *argv[]) { uint16_t port NetModule::DEFAULT_PORT; if (argc 2) { port static_castuint16_t(std::stoi(argv[1])); } std::unique_ptrNetModule::UdpServer server std::make_uniqueNetModule::UdpServer(port, Handler); server-Init(); server-Start(); return 0; }5.4 UdpClient.cc客户端主程序#include iostream #include string #include cstring #include cstdlib #include unistd.h #include sys/types.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h // 用法./udp_client [服务端IP] [服务端端口] int main(int argc, char *argv[]) { if (argc ! 3) { std::cerr Usage: argv[0] server_ip server_port std::endl; return 1; } std::string server_ip argv[1]; uint16_t server_port static_castuint16_t(std::stoi(argv[2])); // 1. 创建 UDP 套接字客户端不需要显式 bind由内核隐式完成 int sockfd socket(AF_INET, SOCK_DGRAM, 0); if (sockfd 0) { std::cerr socket error std::endl; return 2; } // 2. 填充服务端地址结构体 struct sockaddr_in server; memset(server, 0, sizeof(server)); server.sin_family AF_INET; server.sin_port htons(server_port); inet_pton(AF_INET, server_ip.c_str(), server.sin_addr); // 3. 循环发送 接收 while (true) { std::string line; std::cout Please Enter# ; std::getline(std::cin, line); if (line quit || line exit) { break; } // 发送数据给服务端 sendto(sockfd, line.c_str(), line.size(), 0, (struct sockaddr *)server, sizeof(server)); // 接收服务端响应 char buffer[1024]; struct sockaddr_in peer; socklen_t len sizeof(peer); ssize_t s recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0, (struct sockaddr *)peer, len); if (s 0) { buffer[s] 0; std::cout Server Response# buffer std::endl; } } close(sockfd); return 0; }5.5 Makefile项目编译规则.PHONY: all all: udp_server udp_client udp_server: UdpServer.cc UdpServer.hpp InetAddr.hpp g -o $ $ -stdc17 udp_client: UdpClient.cc g -o $ $ -stdc17 .PHONY: clean clean: rm -f udp_server udp_client5.6 编译运行与通信测试编译make先启动服务端默认端口8080./udp_server或者指定端口./udp_server 9090再启动客户端指定服务端IP和端口./udp_client 127.0.0.1 8080客户端输入内容服务端会收到并打印出来同时把字符串转成大写返回给客户端。输入quit或exit退出客户端。如果这篇文章对你有帮助别忘了点赞、收藏、关注三连支持。你的每一次反馈都是我继续硬核输出的最大动力。我们下篇见。