本文从UDP Socket的基础出发,一路往下走。
先把socket、sockaddr_in这些最基础的概念讲清楚,再把字节序、recvfrom/sendto这对收发搭档逐一拆开。接着看服务端怎么bind,客户端为什么是隐式bind,INADDR_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_bzero
2.1.2 填充sockaddr_in与网络字节序转换
2.1.3 绑定本地地址:bind系统调用
2.2 UDP数据收发机制:从接收到响应
2.2.1 数据接收:recvfrom与阻塞I/O
2.2.2 数据发送:sendto函数
2.3 客户端通信:服务器地址与端口分配
2.3.1 服务器地址的硬编码与配置文件管理
2.3.2 客户端的隐式绑定:端口由谁分配
三、深入理解bind:客户端策略与服务端需求
3.1 UDP客户端是否需要显式调用bind
3.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 现代地址转换API:inet_pton与inet_ntop
五、完整源码与运行测试
5.1 InetAddr.hpp:IP地址封装类
5.2 UdpServer.hpp:UDP服务器类
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.h>- sys/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 Descriptor,FD)。
- 失败:返回-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_port):16位无符号整数,表示通信进程的端口号。注意,它得是网络字节序。
IP 地址(sin_addr):内部装着一个32位无符号整数,表示IPv4地址。同样,网络字节序。
填充物(sin_zero):8个字节的填充。为什么要它?为了让整个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 参数含义与返回值
- sockfd:socket()返回的那个套接字文件描述符,告诉函数从哪个套接字上收数据。
- 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就是阻塞式IO(Blocking 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?服务器又为什么必须显式 bind?
3.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::function<std::string(const std::string&)>,把数据处理的具体逻辑交给外部上层传入:
#include <functional> #include <string> // 定义业务处理函数类型 using func_t = std::function<std::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 现代地址转换API:inet_pton与inet_ntop
现代网络编程,推荐用inet_pton(Presentation to Network)和inet_ntop(Network 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_INET(IPv4)或AF_INET6(IPv6),扩展性拉满。
绝对线程安全: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.hpp:IP地址封装类
#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.hpp:UDP服务器类
#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::function<std::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_cast<uint16_t>(std::stoi(argv[1])); } std::unique_ptr<NetModule::UdpServer> server = std::make_unique<NetModule::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_cast<uint16_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 $@ $< -std=c++17 udp_client: UdpClient.cc g++ -o $@ $< -std=c++17 .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退出客户端。
如果这篇文章对你有帮助,别忘了点赞、收藏、关注三连支持。你的每一次反馈,都是我继续硬核输出的最大动力。我们下篇见。