开篇介绍:
hello 大家,那么在上篇博客中,我们正式进入网络的世界,知道了网络的工作原理,了解到了协议这一重要的方式,那么毋庸置疑的是,我们肯定也要进行编程,而对于网络编程,其实依靠的是一个很关键的方式------socket套接字,同时,还有不同的协议进行帮助网络通信,那么那么,在本篇博客中,我们就来了解一下UDP socket编程基础。
在开始之前,先明确咱们的学习目标:
-
理解网络通信的本质的是什么?
-
搞懂字节序的概念,以及为什么网络传输必须用大端序?
-
掌握Socket的核心作用,以及socket()、bind()、recvfrom()、sendto()四个核心函数的用法;
-
学会sockaddr_in结构体的填充技巧,理解INADDR_ANY的核心作用;
-
区分服务端和客户端的实现差异(重点是bind的使用场景);
-
能独立编写完整的UDP服务端和客户端代码,并实现通信。
话不多说,咱们正式开始!
一、前置认知:网络通信的本质到底是什么?
答案很简单:网络通信的本质,就是跨主机的进程间通信(IPC)。
咱们举个最贴近生活的例子:你用QQ给朋友发消息,你的QQ是一个进程,你朋友的QQ也是一个进程。这两个进程不在同一台主机上,它们之间的数据传递,就需要通过网络来完成------这就是网络通信的核心逻辑。
再类比一下:同一台主机上的进程通信(比如用共享内存、管道),就像你和同桌传纸条,直接递过去就行;而跨主机的进程通信(网络通信),就像你给远方的朋友寄信,需要经过邮局、快递员等中间环节,才能送到对方手里。
这里的"邮局""快递员",对应的就是咱们的网络协议(比如TCP/IP协议族);而咱们接下来要学的Socket,就是"寄信人"和"邮局"之间的接口------你(进程)必须通过Socket这个"窗口",才能把"信件"(数据)交给"邮局"(网络协议栈),或者从"邮局"接收别人寄来的"信件"。
然后大家可能会有个问题,怎么定位到对方的那一个指定进程呢???
是,ip地址可以定位唯一的一个主机,可是那个主机的那一个进程,怎么定位呢?那么其实就是要依靠端口号!!!
其实这些问题的核心,都绕不开一个网络通信的底层逻辑:数据传输到主机只是"中途站",最终交付给主机内的具体进程才是"终点站"。
1.1 先搞懂前提:IP地址的"局限性"------只能定位主机,不能定位进程
在聊端口号之前,我们先回顾一下IP地址的作用。IP地址(比如IPv4的192.168.1.100)是网络层协议的核心,它的核心功能只有一个:在互联网中唯一标识一台主机。就像我们寄快递时的"收件人地址",能精准定位到某栋楼、某个单元------但问题来了,快递到了小区门口,怎么确定是交给这户人家的爸爸、妈妈,还是孩子?
网络通信也是一样的道理。我们用QQ和朋友聊天,本质是自己电脑上的QQ进程和朋友电脑上的QQ进程在传输数据。IP地址只能把数据送到朋友的电脑上,但朋友的电脑上可能同时运行着很多进程:浏览器在刷网页、迅雷在下载文件、微信在后台挂着......操作系统收到这些来自网络的数据后,根本不知道该把"QQ聊天数据"交给QQ进程,还是把"网页数据"交给浏览器进程。
这里就暴露了IP地址的局限性:IP地址只能解决"数据送到哪台主机"的问题,无法解决"数据交给主机内哪个进程"的问题。而端口号的出现,就是为了弥补这个"最后一公里"的缺口。
我们可以用一个生动的类比理解:
-
互联网 = 一个巨大的城市;
-
主机(电脑/手机)= 城市里的一栋栋房子;
-
IP地址 = 房子的详细地址(比如XX路XX号XX单元);
-
进程(QQ/浏览器/迅雷)= 房子里的每一个人;
-
端口号 = 每个人的"专属收件码"。
快递员(网络数据)根据"地址"(IP)找到房子(主机)后,再根据"专属收件码"(端口号),就能精准把快递(数据)交给对应的人(进程)。这就是端口号最核心的作用:在一台主机内,唯一标识一个需要接收网络数据的进程,告诉操作系统"这笔数据该交给谁处理"。
1.2 端口号的核心定义与本质:传输层的"进程身份证"
明确了端口号的诞生背景后,我们来看它的官方定义:端口号(Port)是传输层协议(TCP/UDP)的核心字段,是一个2字节(16位)的无符号整数,取值范围是0~65535。
这里有几个关键信息必须牢记,我们逐个拆解:
1.2.1 端口号的归属:传输层专属,只为"进程通信"服务
端口号不属于网络层(IP协议所在层),也不属于应用层(QQ、浏览器所在层),而是传输层的"专属字段"。为什么?因为传输层的核心职责就是"端到端的可靠传输"------这里的"端",不是指主机,而是指"主机上的进程"。
当应用层进程(比如QQ)需要发送数据时,会把数据交给传输层(比如UDP);传输层会给数据加上一个"头部",头部里就包含了"源端口号"和"目的端口号";然后再把数据交给网络层,网络层加上IP头部(包含源IP和目的IP)后,才会把数据发送到网络中。
简单说:IP地址负责"跨网络定位主机",端口号负责"主机内定位进程",两者结合才能在互联网中唯一标识一个"网络进程"。这个组合就是我们常说的"套接字地址"(Socket Address),格式为:IP地址:端口号(比如192.168.1.100:8080)。
核心结论:IP地址 + 端口号 = 唯一标识互联网中的某一台主机的某一个进程。这是网络通信的基础,没有这个组合,数据就无法精准投递。
1.2.2 端口号的核心规则:一个端口只能被一个进程占用
端口号的使用有一个铁律:在同一台主机上,一个端口号只能被一个进程绑定(占用);但一个进程可以绑定多个端口号。
举个例子:
-
如果你的电脑上,迅雷进程已经绑定了端口号6881,那么其他进程(比如QQ、浏览器)就不能再使用6881这个端口了,否则会报错(比如"端口被占用");
-
反之,一个进程可以同时监听多个端口。比如一台服务器上的nginx进程,既可以监听80端口(HTTP服务),也可以监听443端口(HTTPS服务),这样就能同时处理来自不同端口的网页请求。
为什么会有这样的规则?很简单:如果一个端口能被多个进程占用,当操作系统收到数据时,就会陷入"两难"------不知道该把数据交给哪个进程。而一个进程绑定多个端口,是为了支持多种服务(比如nginx同时提供HTTP和HTTPS服务),这在服务端程序中很常见。
1.3 端口号的范围划分:知名端口、动态端口,各司其职
前面提到,端口号的取值范围是0~65535(因为16位整数的最大值是2^16 - 1 = 65535)。根据使用场景的不同,这个范围又被划分成了两类:"知名端口号"和"动态端口号",各自有明确的分工。
1.3.1 知名端口号(0~1023):应用层协议的"固定门牌号"
知名端口号也叫"-well-known ports",是互联网官方(IANA)统一分配的端口号,对应着常用的应用层协议。它们的特点是"固定不变",目的是让客户端能快速找到服务端的对应服务。
比如我们平时用浏览器上网(HTTP协议),默认会连接服务端的80端口;用HTTPS加密上网时,默认连接443端口。这些端口号已经成为行业共识,几乎所有的服务端程序都会遵守这个约定。
下面是一些最常用的知名端口号:
| 端口号 | 对应协议 | 用途说明 |
|---|---|---|
| 21 | FTP | 文件传输协议,用于上传/下载文件 |
| 22 | SSH | 安全外壳协议,用于远程登录服务器 |
| 23 | Telnet | 远程登录协议(未加密,现在基本被SSH替代) |
| 80 | HTTP | 超文本传输协议,用于普通网页访问 |
| 443 | HTTPS | 加密的HTTP协议,用于安全网页访问(比如网购、支付) |
| 53 | DNS | 域名解析协议,用于把域名(比如www.baidu.com)转换成IP地址 |
注意:普通用户程序(非管理员权限)无法绑定知名端口号。比如你写一个UDP服务端程序,想绑定80端口,运行时会报错"权限不足",这是操作系统的安全限制,避免普通程序占用系统核心服务的端口。
1.3.2 动态端口号(1024~65535):客户端的"临时收件码"
动态端口号也叫"临时端口号"或"私有端口号",这个范围的端口号不会被固定分配给某个协议,而是由操作系统"动态分配"给客户端程序使用。
这里就有一个常问的问题:"客户端程序(比如QQ、浏览器)需要端口号吗?"答案是:需要,但客户端不需要手动绑定固定端口,而是由操作系统自动分配一个临时端口。
客户端(无论基于 UDP 还是 TCP 协议)都不需要显式调用 bind 函数,这是客户端端口使用的核心原则。
当客户端首次调用 sendto(UDP)或 connect(TCP)发送数据时,操作系统会自动完成 bind 操作:操作系统会先识别客户端的出口 IP(比如本机连接路由器分配的内网 IP 192.168.1.200),再从 1024~65535 的动态端口范围内,挑选一个当前未被占用的随机端口号(例如 45678),最后自动将 "出口 IP + 随机端口" 的组合绑定到客户端的套接字上。
服务端通过 recvfrom(UDP)或已建立的连接(TCP)获取到这个临时的客户端地址信息后,就能精准地将响应数据回传给客户端 ------ 对客户端而言,端口号是 45678 还是 56789 完全不重要,只要保证在本机内唯一即可满足通信需求。
服务端与客户端对端口的需求存在本质差异,这也是两者绑定方式不同的核心原因:
- 服务端必须使用固定端口(如 80、8080)且显式执行 bind 操作。因为客户端要与服务端通信,必须明确知道服务端的 IP 地址和固定端口号(就像访问网页要连接 80 端口一样),只有固定端口才能让客户端稳定找到服务端;
- 客户端仅需保证端口唯一,无需固定数值,由操作系统自动 bind 即可。服务端只需能通过临时地址回包,客户端端口是否固定对通信没有任何正向作用。
如果强行让客户端显式 bind 固定端口(比如 9090),会直接引发端口冲突问题:同一台主机上启动多个客户端进程时,第一个进程会成功绑定 9090 端口,后续所有客户端进程再尝试绑定该端口都会失败(系统提示 "端口已被占用")。这是因为多个客户端进程运行的是同一段显式绑定 9090 端口的代码,都会申请该端口,但端口的使用规则是 "一个端口只能被一个进程绑定",最终导致同一主机无法同时运行多个客户端实例。
这种显式绑定固定端口的做法没有任何实际收益,反而会限制客户端的使用灵活性,大幅增加端口冲突的风险,完全违背了客户端 "灵活接入" 的设计需求。因此,客户端无需手动指定端口,交由操作系统自动分配随机的空余端口,是避免冲突、保障多客户端实例正常运行的最优方式。
我们用浏览器访问百度的过程,就能清晰理解动态端口的作用:
-
你打开浏览器(客户端进程),操作系统会从1024~65535中选一个未被占用的端口(比如54321)分配给浏览器;
-
浏览器向百度服务器(目的IP:220.181.38.148,目的端口:80)发送请求数据,此时数据的源端口是54321,目的端口是80;
-
百度服务器处理完请求后,会把网页数据返回给你的电脑(源IP:220.181.38.148,源端口:80),目的IP是你的电脑IP,目的端口是54321;
-
你的操作系统收到数据后,根据目的端口54321,就能精准把数据交给浏览器进程;
-
当你关闭浏览器后,操作系统会释放54321这个端口,后续其他客户端程序可以使用这个端口。
这就是动态端口的核心作用:给客户端进程分配临时的"通信标识",避免客户端端口冲突(如果所有客户端都用固定端口,多个人同时用浏览器会报错)。
新手避坑:客户端程序千万不要手动绑定固定端口!很多新手写UDP客户端时,会模仿服务端调用bind()函数绑定一个端口(比如8080),这样做不仅没必要,还可能因为端口被占用导致程序启动失败。客户端只需让操作系统自动分配动态端口即可。
1.4 核心疑问:端口号和进程ID(PID)的区别,为什么不用PID?
很多同学学过系统编程后会有一个疑惑:"进程ID(PID)也是唯一标识一个进程的,为什么网络通信不用PID,非要额外搞一个端口号?"
首先,我们先明确两者的基本区别:
| 对比维度 | 端口号 | 进程ID(PID) |
|---|---|---|
| 归属层面 | 传输层(网络通信层面) | 操作系统内核(系统进程管理层面) |
| 作用范围 | 仅用于网络通信,标识"需要接收网络数据的进程" | 用于操作系统管理所有进程(包括非网络进程) |
| 稳定性 | 服务端端口固定(比如80、443),客户端动态分配 | 每次进程重启,PID都会变化(由操作系统重新分配) |
| 关联关系 | 一个端口只能绑定一个进程,一个进程可绑定多个端口 | 一个PID唯一对应一个进程,一个进程只有一个PID |
为什么不用PID来进行网络通信?核心原因有3个:
1.4.1 避免"系统进程管理"与"网络通信"强耦合
PID是操作系统内核用于管理进程的内部标识,不同操作系统的PID分配规则不同(比如Linux和Windows的PID生成逻辑不一样)。如果网络通信依赖PID,就会导致"网络协议"和"操作系统内核"强绑定------比如Windows上的PID是4位整数,Linux上的PID是5位整数,跨系统通信时会出现兼容问题。
而端口号是传输层协议的标准字段,与操作系统无关,不管是Linux、Windows还是Mac,都遵守"0~65535"的取值规则。这样就能实现"跨系统、跨平台"的网络通信,这是PID无法做到的。
1.4.2 PID的不稳定性:进程重启后PID会变化
PID是操作系统在进程启动时动态分配的,每次进程重启,PID都会变成一个新的数值。比如你电脑上的QQ进程,这次启动的PID是1234,下次重启后可能变成5678。如果网络通信依赖PID,那么每次服务端进程重启后,客户端都需要重新获取新的PID才能通信,这在实际应用中完全不可行。
而端口号可以固定不变。比如百度服务器的HTTP服务端口是80,即使服务器重启,只要服务程序重新绑定80端口,客户端依然可以通过80端口访问,完全不需要修改配置。
1.4.3 一个进程可能需要多个网络通信标识
前面提到,一个进程可以绑定多个端口号,支持多种网络服务。比如nginx进程可以同时绑定80(HTTP)和443(HTTPS)两个端口,分别处理不同的网页请求。但一个进程只有一个PID,无法区分不同的网络服务。
用一个生活例子就能理解:假设你开了一家店,既卖奶茶又卖咖啡(一个进程提供多种服务),你需要两个"窗口"(两个端口号)分别接待奶茶客户和咖啡客户;而PID就像是你的"营业执照号"(唯一标识店铺),无法区分不同的客户类型。
1.4.4 经典例子:用10086理解端口号与PID的关系
我们用大家熟悉的10086来类比:
-
10086客服中心 = 一台服务端主机;
-
客服中心的"话费查询专线""业务办理专线""投诉建议专线" = 不同的端口号(比如80、443、8080);
-
每个接线员 = 一个进程;
-
接线员的工号 = PID。
当你拨打10086(连接服务端主机)后,会根据你的需求转接到不同的专线(不同的端口号),而不是直接转接到某个工号(PID)。因为专线号码(端口号)是固定的(你知道打哪个专线查话费),而工号(PID)是动态的(接线员可能离职、调岗,工号会变化)。这和网络通信中用端口号而不用PID的逻辑完全一致。
1.5 实战核心:源端口号与目的端口号的作用
前面我们讲的都是单个端口号的作用,而实际网络通信中,传输层数据段(TCP报文段或UDP数据报)里会包含两个端口号:源端口号和目的端口号。这两个端口号共同描述了"数据是谁发的,要发给谁",是实现双向通信的关键。
1.5.1 源端口号与目的端口号的定义
-
源端口号:发送方进程的端口号,用于接收方回复数据时定位发送方进程;
-
目的端口号:接收方进程的端口号,用于发送方定位接收方进程。
我们还是用"浏览器访问百度"的例子,拆解这两个端口号的工作流程:
-
客户端(你的电脑):
-
进程:浏览器;
-
IP地址:192.168.1.100(内网IP);
-
源端口号:54321(操作系统动态分配的动态端口)。
-
-
服务端(百度服务器):
-
进程:HTTP服务进程;
-
IP地址:220.181.38.148(公网IP);
-
目的端口号:80(HTTP协议的知名端口)。
-
-
通信流程:
-
浏览器发送请求数据:数据头部包含"源IP:192.168.1.100、源端口:54321、目的IP:220.181.38.148、目的端口:80";
-
数据经过网络传输后,到达百度服务器;
-
百度服务器的操作系统根据"目的端口:80",把数据交给HTTP服务进程;
-
HTTP服务进程处理请求后,生成回复数据(网页内容);
-
百度服务器发送回复数据:数据头部包含"源IP:220.181.38.148、源端口:80、目的IP:192.168.1.100、目的端口:54321";
-
回复数据到达你的电脑后,操作系统根据"目的端口:54321",把数据交给浏览器进程;
-
浏览器解析数据,显示出百度首页。
-
从这个流程可以看出:源端口号和目的端口号是"双向的",发送方的源端口号会变成接收方的目的端口号,这样才能实现数据的往返通信。如果没有源端口号,服务端处理完请求后,根本不知道该把回复数据发给客户端的哪个进程。
搞懂这个本质,后面的知识点就好理解了。接下来,咱们先解决第一个核心问题:跨主机通信时,数据的"格式"必须统一,这就涉及到"字节序"的概念。
二、必懂基础:字节序与网络字节序转换
咱们先思考一个问题:不同主机的CPU,存储多字节数据的方式可能不一样。比如一个4字节的整数0x12345678,在有的主机上存储顺序是0x12 0x34 0x56 0x78,在有的主机上却是0x78 0x56 0x34 0x12。如果直接把数据发出去,对方主机肯定解析出错。
这就像你寄信时,有的地方习惯"先写收件人姓名,再写地址",有的地方习惯"先写地址,再写姓名"------如果没有统一的格式,邮局就没法准确投递。网络通信也是一样,必须有统一的数据存储格式,这个格式就是"网络字节序"。
2.1 什么是大端序和小端序?
字节序,就是多字节数据在内存中的存储顺序。主要分为两种:
2.1.1 大端序(Big-Endian)
定义:数据的高位字节存放在内存的低地址,低位字节存放在内存的高地址。
类比:就像咱们写数字"1234",从左到右依次是高位到低位(1是千位,4是个位),大端序的存储方式就和写数字的顺序一致。
例子:整数0x12345678(16进制),大端序的存储顺序是:
内存地址:0x00 0x01 0x02 0x03
存储内容:0x12 0x34 0x56 0x78
2.1.2 小端序(Little-Endian)
定义:数据的低位字节存放在内存的低地址,高位字节存放在内存的高地址。
类比:把"1234"倒过来写,从左到右是低位到高位(4是个位,1是千位)。
例子:同样是整数0x12345678,小端序的存储顺序是:
内存地址:0x00 0x01 0x02 0x03
存储内容:0x78 0x56 0x34 0x12
这一部分的内容其实在我们之前学习C语言的时候就有学习到了,不知道大家现在是否还记得吗~
2.1.3 为什么会有两种字节序?
这是由CPU的架构决定的:比如x86、x86_64架构的CPU(咱们常用的PC、服务器大多是这种),默认是小端序;而一些嵌入式CPU(比如ARM),可以通过配置切换大端序或小端序。
问题来了:不同主机的字节序可能不一样,跨主机通信时怎么办?
解决方案:TCP/IP协议族明确规定,网络数据流必须采用大端序。也就是说,不管发送方是大端机还是小端机,发送数据前都要把数据转换成大端序;接收方收到数据后,再根据自己的字节序,转换成主机字节序。
2.2 四个核心转换函数:htonl、htons、ntohl、ntohs
为了方便开发者进行字节序转换,系统提供了四个标准函数,定义在<arpa/inet.h>头文件中。这四个函数的名字很好记,咱们拆解一下:
简单说:"h到n"就是主机字节序转网络字节序,"n到h"就是网络字节序转主机字节序。
2.2.1 函数原型与作用
cpp
// 32位主机字节序 → 32位网络字节序(用于IPv4地址)
uint32_t htonl(uint32_t hostlong);
// 16位主机字节序 → 16位网络字节序(用于端口号)
uint16_t htons(uint16_t hostshort);
// 32位网络字节序 → 32位主机字节序(用于接收IPv4地址)
uint32_t ntohl(uint32_t netlong);
// 16位网络字节序 → 16位主机字节序(用于接收端口号)
uint16_t ntohs(uint16_t netshort);

2.2.2 核心特点(重点!)
这四个函数有一个非常智能的特性:如果主机字节序和网络字节序一致(即主机是大端机),函数会直接返回原参数,不做任何转换;如果不一致(主机是小端机),函数会进行字节序反转。
也就是说,不管你的程序跑在大端机还是小端机上,直接调用这些函数就行,不用自己判断主机字节序------这就是标准库的好处。
2.2.3 适用场景(必须记牢!)
网络通信中,需要转换字节序的只有两个东西:
-
IP地址(32位):用htonl/ntohl转换;
-
端口号(16位):用htons/ntohs转换。
举个例子:服务端要绑定8080端口,就必须用htons(8080)把端口号转换成网络字节序;客户端收到服务端回复后,要获取服务端的端口号,就必须用ntohs把网络字节序转换成主机字节序,才能正常显示。
2.2.4 新手避坑:不要忘记转换!
这是新手最容易踩的坑之一:直接把端口号或IP地址填进结构体,不做字节序转换。比如:
cpp
// 错误示例:端口号未做字节序转换
server.sin_port = 8080;
// 正确示例:用htons转换为网络字节序
server.sin_port = htons(8080);
如果不做转换,在小端机上运行时,端口号会变成一个完全错误的值(比如8080转换成小端序后,可能变成0x20 0x1F,对应的十进制是7967),导致程序无法正常通信。
三、核心概念:Socket到底是什么?
搞懂了字节序,咱们就进入网络编程的核心------Socket(套接字)。很多新手对Socket的理解很模糊,觉得它是一个"神秘的东西"。其实一句话就能概括:Socket是应用程序与网络协议栈交互的接口。
咱们再用一个更形象的类比:
你要把东西(数据)送到公路上(网络),必须通过大门(Socket);别人从公路上给你送东西,也必须通过大门(Socket)------Socket就是应用程序和网络之间的"唯一通道"。

3.1 Socket的三大核心作用
Socket不仅是"通道",还有三个核心作用,缺一不可:
3.1.1 对接网络协议栈
操作系统的网络协议栈是内核的一部分,不直接对外开放。应用程序要调用TCP、UDP等协议的功能,必须通过Socket提供的函数(比如sendto、recvfrom)------这些函数会触发内核的协议栈逻辑,完成数据的封装、发送、接收、解封装等操作。
简单说:Socket是应用程序访问网络协议栈的"唯一窗口"。
3.1.2 标识通信端点
网络中两个进程通信,必须知道对方的"位置"。这个"位置"由三部分组成,称为"通信端点":
-
协议族(比如IPv4);
-
主机IP地址;
-
主机内的端口号。
Socket会关联这三部分信息,就像"收件人地址+邮编+收件人姓名"一样,确保数据能准确送到目标进程。
3.1.3 统一通信接口
不管你用的是TCP还是UDP,不管你是和本地进程通信还是和远程进程通信,都可以通过Socket的标准函数(socket()、bind()、sendto()等)实现------无需关注底层协议的复杂细节(比如TCP的三次握手、UDP的数据报封装)。
这就像你寄快递,不管寄的是文件还是包裹,不管寄到本地还是外地,都只需要找快递员(Socket函数),不用管快递员是骑自行车还是开货车(底层协议)。
3.2 如何创建Socket?------ socket()函数详解
创建Socket的函数很简单,原型如下:
cpp
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
3.2.1 返回值
成功:返回一个非负整数(Socket描述符,类似文件描述符);失败:返回-1,并设置errno(全局错误码)。
类比:socket()函数就像"申请大门",成功后会给你一把"钥匙"(Socket描述符),你用这把钥匙才能打开大门(操作Socket)。
3.2.2 三个核心参数
这三个参数决定了创建的Socket的类型和功能,咱们逐个解析:
参数1:domain(协议族)
指定Socket使用的网络协议族,常用值有三个:
| 参数值 | 含义 | 典型应用场景 |
|---|---|---|
| AF_INET | IPv4网络协议(最常用) | 互联网TCP/UDP通信(比如QQ、浏览器) |
| AF_INET6 | IPv6网络协议 | 下一代互联网通信(目前使用较少) |
| AF_UNIX | Unix域协议 | 同一主机内的进程间通信(类似管道、共享内存) |
咱们入门阶段,只需要关注AF_INET(IPv4)即可。
参数2:type(套接字类型)
指定Socket的通信方式,决定了数据传输的特性。常用值有两个,对应UDP和TCP:
| 参数值 | 含义 | 核心特性 | 对应协议 |
|---|---|---|---|
| SOCK_DGRAM | 数据报套接字 | 无连接、不可靠、无顺序、以数据报为单位传输 | UDP |
| SOCK_STREAM | 流式套接字 | 面向连接、可靠、有序、以字节流为单位传输 | TCP |
咱们这篇文章讲UDP,所以type参数固定填SOCK_DGRAM。这里重点说一下SOCK_DGRAM的特性(UDP的核心特性):
-
无连接:通信前不需要建立连接(比如TCP的三次握手),直接发送数据即可;
-
不可靠:不保证数据一定能到达,也不保证到达顺序(可能乱序、丢失、重复);
-
以数据报为单位:每次发送/接收都是一个完整的数据块,不能拆分或合并。
参数3:protocol(协议类型)
指定具体使用的传输协议。通常设为0,表示"根据domain和type自动选择默认协议":
-
domain=AF_INET + type=SOCK_DGRAM → 默认协议是UDP(IPPROTO_UDP);
-
domain=AF_INET + type=SOCK_STREAM → 默认协议是TCP(IPPROTO_TCP)。
只有在使用特殊协议(比如原始套接字)时,才需要显式指定具体的协议值(如IPPROTO_ICMP)。咱们入门阶段,直接填0就行。
3.2.3 创建UDP Socket的示例代码
cpp
#include <sys/socket.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
// 创建UDP Socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket create failed"); // 打印错误信息
exit(1); // 退出程序
}
printf("socket create success, sockfd = %d\n", sockfd);
close(sockfd); // 关闭Socket
return 0;
}
运行这段代码,会输出类似"socket create success, sockfd = 3"的信息(sockfd的值可能是3、4等,取决于系统当前的文件描述符分配情况)。
四、关键步骤:将Socket与主机/进程关联------bind()函数详解
创建Socket后,它还只是一个"孤立的大门",没有和任何主机、进程关联。要让它能接收或发送数据,必须把它和"本地IP地址 + 本地端口号"绑定起来------这个操作就是通过bind()函数完成的。
类比:bind()函数就像"给大门贴上门牌",门牌上写着"XX小区(本地IP)XX号(本地端口)",这样别人才能找到你家的大门。
4.1 bind()函数原型
cpp
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *address, socklen_t address_len);
4.1.1 返回值
成功:返回0;失败:返回-1,并设置errno。
4.1.2 三个核心参数
参数1:sockfd(Socket描述符)
就是咱们通过socket()函数创建的Socket描述符------指定要绑定的Socket。
参数2:address(网络地址结构体)
这是最核心、最容易出错的参数。它是一个通用的网络地址结构体指针,用于存储要绑定的"协议族 + 本地IP + 本地端口"信息。
这里有一个关键知识点:struct sockaddr是一个"通用地址结构体",兼容IPv4、IPv6、Unix域等多种协议。但实际开发中,咱们用的是具体协议的地址结构体(比如IPv4用struct sockaddr_in),然后强制转换为struct sockaddr*类型。
为什么要这么设计?因为Socket的创造者希望用同一套接口支持多种协议------不管你用的是IPv4还是IPv6,都可以调用bind()函数,只需要传入对应的地址结构体并强制转换即可。

重点:IPv4地址结构体struct sockaddr_in
struct sockaddr_in是IPv4专用的地址结构体,定义在<netinet/in.h>头文件中,结构如下:
cpp
struct sockaddr_in
{
sa_family_t sin_family; // 协议族(必须填AF_INET)
in_port_t sin_port; // 本地端口号(必须转网络字节序)
struct in_addr sin_addr; // 本地IP地址(必须转网络字节序)
unsigned char sin_zero[8]; // 填充字段,必须设为0
};
其中,struct in_addr是IPv4地址的结构体,里面只有一个成员:
cpp
struct in_addr
{
in_addr_t s_addr; // 32位IPv4地址(网络字节序)
};
咱们逐字段解析如何填充这个结构体:
-
sin_family:必须填AF_INET(表示IPv4协议),这是固定值,填错会绑定失败;
-
sin_port:填写要绑定的本地端口号(比如8080),但必须用htons()函数转换成网络字节序;
-
sin_addr.s_addr:填写要绑定的本地IP地址,用inet_pton函数转换网络字节序。常用值:INADDR_ANY(0.0.0.0),表示绑定本机所有可用的IP地址;
-
sin_zero8:填充字段,必须全部设为0(用bzero()函数初始化即可),否则可能导致绑定失败。
bzero()函数:快速初始化内存为0
填充struct sockaddr_in结构体前,建议先用bzero()函数把整个结构体初始化为0,避免未初始化的垃圾值导致错误。bzero()函数定义在<strings.h>头文件中,原型如下:
cpp
void bzero(void *s, size_t n);
示例:
cpp
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr)); // 初始化整个结构体为0
参数3:address_len(地址结构体长度)
指定address参数指向的地址结构体的实际长度(单位:字节),用于告诉系统地址结构体的大小,避免内存越界。
如果用的是struct sockaddr_in,直接填sizeof(struct sockaddr_in)即可。
4.2 关键知识点:INADDR_ANY的作用(服务端必懂)
前面提到,sin_addr.s_addr可以填INADDR_ANY(0.0.0.0),表示绑定本机所有可用的IP地址。这是服务端最常用的写法,一定要搞懂它的作用。
4.2.1 为什么服务端要绑定0.0.0.0?
一台服务器可能有多个网卡,每个网卡对应一个IP地址。比如:
-
网卡1:内网IP 192.168.1.100(用于局域网访问);
-
网卡2:公网IP 203.0.113.5(用于互联网访问)。
如果服务端只绑定192.168.1.100,那么只有局域网内的客户端能访问它;互联网上的客户端访问203.0.113.5时,会找不到这个服务端。
而绑定0.0.0.0后,服务端会监听本机所有网卡的IP地址------不管客户端是通过内网IP还是公网IP访问,都能收到请求。这就像快递站的"前台窗口",不管你从哪个门进来(哪个IP访问),都能接待你。
4.2.2 新手误区:0.0.0.0是客户端可访问的地址吗?
不是!0.0.0.0是服务端的"内部万能监听标识",对外是无效地址。客户端不能直接连接0.0.0.0,必须用服务端的"实际可访问IP"(比如内网IP 192.168.1.100、公网IP 203.0.113.5)才能建立连接。简单说,0.0.0.0是服务端的"监听暗号",告诉操作系统"所有进来的请求我都接",但客户端不知道这个暗号,只能通过真实IP找到服务端。
4.3 bind()函数实战示例(UDP服务端)
结合前面的知识点,我们写一个完整的UDP服务端绑定示例,包含Socket创建、地址结构体填充、bind()绑定三个核心步骤:
cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <strings.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
// 1. 创建UDP Socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket create failed");
exit(1);
}
// 2. 填充服务端地址结构体
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr)); // 初始化结构体为0
server_addr.sin_family = AF_INET; // 协议族:IPv4
server_addr.sin_port = htons(8080); // 绑定8080端口(转网络字节序)
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有IP地址
// 3. 调用bind()函数绑定Socket和地址
int ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (ret < 0) {
perror("bind failed");
close(sockfd);
exit(1);
}
printf("bind success: 0.0.0.0:%d\n", 8080);
close(sockfd);
return 0;
}
运行说明:这段代码编译后,用管理员权限运行(Linux/Mac下加sudo),会输出"UDP server bind success: 0.0.0.0:8080",表示绑定成功。如果8080端口已被占用,会输出"bind failed: Address already in use"。
4.4 bind()函数常见错误及解决方法
新手使用bind()时很容易遇到错误,这里总结3个最常见的错误场景及解决方案:
4.4.1 错误1:权限不足(Permission denied)
场景:普通用户运行程序,绑定80、21等知名端口(0~1023)。
原因:操作系统限制,普通用户无法绑定知名端口,需管理员权限。
解决方法:
-
Linux/Mac:用sudo命令运行程序(如sudo ./udp_server);
-
Windows:以"管理员身份"运行命令提示符,再执行程序;
-
替代方案:将端口改为1024以上的动态端口(如8080、9090),避免使用知名端口。
4.4.2 错误2:端口已被占用(Address already in use)
场景:要绑定的端口已被其他进程占用(比如之前运行的程序没关闭,或其他服务在用该端口)。
解决方法:
-
查看端口占用进程:Linux/Mac用命令
lsof -i :端口号(如lsof -i :8080),Windows用netstat -ano | findstr 端口号; -
关闭占用进程:Linux/Mac用
kill -9 进程ID,Windows在任务管理器中结束对应进程; -
更换端口:将程序中的绑定端口改为其他未被占用的端口(如8081)。
4.4.3 错误3:地址已在使用(Address already in use)(TIME_WAIT状态导致)
场景:程序刚关闭,立即重启绑定同一个端口,出现该错误。
原因:TCP/UDP连接关闭后,端口会进入TIME_WAIT状态(默认等待一段时间,确保数据完全传输),这段时间内端口无法被重新绑定。
解决方法:设置Socket选项SO_REUSEADDR,允许端口被重复使用。示例代码如下:
cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <strings.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket create failed");
exit(1);
}
// 设置SO_REUSEADDR选项,允许端口重复使用
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 后续填充地址结构体、绑定操作不变...
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
int ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (ret < 0) {
perror("bind failed");
close(sockfd);
exit(1);
}
printf("UDP server bind success: 0.0.0.0:8080\n");
close(sockfd);
return 0;
}
说明:setsockopt()函数用于设置Socket的属性,SO_REUSEADDR选项可以让操作系统忽略端口的TIME_WAIT状态,允许立即重新绑定。这在开发调试阶段非常实用。
4.5 客户端需要bind()吗?
答案:不需要手动bind()!
前面我们说过,客户端的端口由操作系统动态分配,无需手动绑定。如果客户端手动调用bind()绑定固定端口,会有两个问题:
-
端口冲突风险:如果该端口已被其他进程占用,客户端程序会启动失败;
-
毫无必要:客户端的核心需求是"发送请求给服务端",服务端通过客户端的源端口回复数据,而这个源端口由操作系统分配即可,无需固定。
示例:UDP客户端发送数据时,无需bind()操作,直接调用sendto()函数即可。操作系统会自动为客户端分配一个动态端口(1024~65535),作为源端口。
五、UDP核心操作:发送数据(sendto())与接收数据(recvfrom())
创建并绑定Socket后,UDP的核心操作就是"发送数据"和"接收数据"。由于UDP是无连接协议,发送数据时不需要先建立连接,接收数据时也不需要监听(区别于TCP的listen()和accept()),直接调用sendto()和recvfrom()即可。
5.1 发送数据:sendto()函数详解
sendto()函数专门用于无连接套接字(如UDP)发送数据,需要指定接收方的IP地址和端口号。
5.1.1 函数原型
cpp
#include <sys/socket.h>
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
5.1.2 参数解析
-
sockfd:Socket描述符(通过socket()创建的UDP Socket);
-
buf:存储要发送的数据的缓冲区地址;
-
len:要发送的数据长度(单位:字节);
-
flags:发送标志,通常设为0(默认阻塞发送);
-
dest_addr:接收方的网络地址结构体(包含接收方IP和端口);
-
addrlen:接收方地址结构体的长度(单位:字节)。
5.1.3 返回值
成功:返回实际发送的字节数;失败:返回-1,并设置errno。
5.1.4 UDP客户端发送数据示例
cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h> // 包含inet_addr()函数声明
#include <strings.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main() {
// 1. 创建UDP Socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket create failed");
exit(1);
}
// 2. 填充接收方(服务端)地址结构体
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET; // 协议族:IPv4
server_addr.sin_port = htons(8080); // 服务端端口:8080(转网络字节序)
// 服务端IP:将字符串IP转换为32位整数,再转网络字节序
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
// 3. 发送数据
const char *msg = "Hello, UDP Server!";
ssize_t send_len = sendto(sockfd, msg, strlen(msg), 0,
(struct sockaddr*)&server_addr, sizeof(server_addr));
if (send_len < 0) {
perror("sendto failed");
close(sockfd);
exit(1);
}
printf("Send success! Send len: %zd\n", send_len);
close(sockfd);
return 0;
}
关键说明:
-
inet_addr()函数:将字符串格式的IP地址(如"127.0.0.1")转换为32位整数格式,且自动转换为网络字节序,无需再调用htonl();
-
客户端未调用bind():操作系统会自动分配一个动态端口作为源端口,发送数据时会携带这个源端口;
-
127.0.0.1是本地回环地址,用于本机内的进程通信,适合调试。
5.2 接收数据:recvfrom()函数详解
recvfrom()函数用于无连接套接字接收数据,同时能获取发送方的IP地址和端口号(这对UDP服务端很重要,因为需要知道回复给谁)。
5.2.1 函数原型
cpp
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
5.2.2 参数解析
-
sockfd:Socket描述符(已绑定地址的UDP Socket);
-
buf:存储接收数据的缓冲区地址;
-
len:缓冲区的大小(单位:字节);
-
flags:接收标志,通常设为0(默认阻塞接收);
-
src_addr:用于存储发送方的网络地址结构体(输出参数,会填充发送方的IP和端口);
-
addrlen:输入输出参数,输入时指定src_addr的长度,输出时表示实际存储的地址长度。
5.2.3 返回值
成功:返回实际接收的字节数;失败:返回-1,并设置errno;返回0:表示对方关闭连接(UDP无连接,通常不会出现这种情况)。
5.2.4 UDP服务端接收数据示例
cpp
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <strings.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define BUF_SIZE 1024 // 缓冲区大小
int main() {
// 1. 创建UDP Socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd < 0) {
perror("socket create failed");
exit(1);
}
// 2. 绑定地址
struct sockaddr_in server_addr;
bzero(&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
int ret = bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (ret < 0) {
perror("bind failed");
close(sockfd);
exit(1);
}
printf("UDP server start: 0.0.0.0:8080\n");
// 3. 接收数据
char buf[BUF_SIZE] = {0}; // 接收缓冲区
struct sockaddr_in client_addr; // 存储客户端地址
socklen_t client_addr_len = sizeof(client_addr); // 客户端地址长度
// 阻塞接收数据(直到有数据到来)
ssize_t recv_len = recvfrom(sockfd, buf, BUF_SIZE - 1, 0,
(struct sockaddr*)&client_addr, &client_addr_len);
if (recv_len < 0) {
perror("recvfrom failed");
close(sockfd);
exit(1);
}
// 打印接收结果
printf("Receive from client: \n");
printf("Client IP: %s\n", inet_ntoa(client_addr.sin_addr)); // 转换IP为字符串
printf("Client Port: %d\n", ntohs(client_addr.sin_port)); // 转换端口为主机字节序
printf("Data: %s\n", buf);
close(sockfd);
return 0;
}
关键说明:
-
inet_ntoa()函数:将32位整数格式的IP地址(网络字节序)转换为字符串格式(如"127.0.0.1"),方便打印;
-
ntohs()函数:将客户端端口号从网络字节序转换为主机字节序,才能正确显示(客户端发送数据时,端口已转网络字节序);
-
阻塞特性:recvfrom()默认是阻塞的,即如果没有数据到来,程序会一直停在这一行,直到收到数据或出现错误。
5.3 UDP服务端与客户端完整通信实战
结合前面的知识点,我们实现一个完整的UDP通信案例:客户端发送一条消息给服务端,服务端接收后打印客户端信息和消息内容。
5.3.1 服务端头文件代码(UdpServer.hpp)
cpp
#pragma once
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/types.h>
#include <strings.h>
#include <functional>
#include "Log.hpp"
//ok,那么在本文件中,就来学习一下socket编程基础
//其实就是了解一些socket的常用函数,换句话来说,就是了解一下实现网络通信的一些接口
//那么在正式学习之前,我们必须要先明确一点,那么就是其实网络的通信的本质上就是实现进程通信
//只不过可能是不同主机(服务器等等)的进程间通信,比如你qq给另一个人的qq发信息,那么qq可是进程哦
//那么就是要知道这一点本质
//然后还有就是,我们要知道,协议规定网络中的数据都得是大端的
//这个之前C语言有讲过,大端的意思就是说数据的较低权重的字节在高地址,而较高权重的字节在低地址
//比如一个数字的二进制是0x123456,那么显而易见,我们知道的56等等,是占比较低的
//而在大端机器中,就是56放在比34等等要高的地址上,这就是大端
//那么小端就是和大端相反,即数据的较低权重的字节在低地址,而较高权重的字节在高地址
//这个还是比较好理解的,然后就是由于有的机器可能是大端,有的机器可能是小端
//而我们又知道,网络要求代码啊,结构啊什么之类的都得一样,那么你发送到网络的数据
//肯定也得是统一的格式,不可能有大端有小端吧,那么就很不方便
//•发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
//•接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
//•因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址.
//•TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节.
//那么意思就是说,如果当前发送主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接发送即可;
//而接收主机也是和上面一样的道理,如果当前接收主机是小端, 就需要先将数据转成大端; 否则就忽略, 直接接收即可;
// 主机字节序:不同 CPU(比如 x86 和 ARM)存储多字节数据(如 16/32 位整数)的顺序不同,
// 分为「大端序」(高位字节在前)和「小端序」(低位字节在前)。
// 比如整数0x12345678,小端 CPU 存为0x78 0x56 0x34 0x12,大端 CPU 存为0x12 0x34 0x56 0x78。
// 网络字节序:TCP/IP 协议规定,网络传输的数据必须统一用「大端序」,否则不同主机通信会解析出错误数据。
//那么为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,
//可以调用以下库函数做网络字节序和主机字节序的转换。
//#include <arpa/inet.h>
//uint32_t htonl(uint32_t hostlong);
//uint16_t htons(uint16_t hostshort);
//uint32_t ntohl(uint32_t netlong);
//uint16_t ntohs(uint16_t netshort);
//函数名 全称(便于记忆) 作用 适用场景
//htonl Host to Network Long(32 位) 将 32 位主机字节序整数 → 网络字节序 IPv4 地址(32 位)、32 位标识 / 参数等
//htons Host to Network Short(16 位) 将 16 位主机字节序整数 → 网络字节序 TCP/UDP 端口号(16 位)
//ntohl Network to Host Long(32 位) 将 32 位网络字节序整数 → 主机字节序 接收网络传来的 32 位整数(如对方 IP)
//ntohs Network to Host Short(16 位) 将 16 位网络字节序整数 → 主机字节序 接收网络传来的端口号
//•这些函数名很好记, h 表示 host , n 表示 network , l 表示 32 位长整数, s 表示 16 位短整数。
//•例如htonl 表示将32 位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
//•如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
//•如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
//那么这里要知道,我们的IP地址(标识唯一的主机)是32位整数,而端口号(标识唯一的进程)是16位整数哦,即短整数
//那么上面的这些函数就是能把IP地址或端口号转换为符合网络字节序的函数
//用了这些函数之后,不管你是大端主机还是小端主机,那么数据都会被转换为大端的,符合网络字节序
//OK,知道了上面之后,接下来我们就进入网络通信的具体解析
//那么首先,我们想要实现两个主机(进程)的网络通信,那么肯定得先开一个口子吧
//就像我们在同一个主机中使用共享内存实现进程间通信一样,我们得先创建共享内存,然后才能使用它巴拉巴拉
//那么网络通信也是一样的道理,你得先开个口子
//那么这个口子是什么呢?????不错,就是socket,也叫做套接字!!!
//那么它是什么嘞?有什么作用呢?
//创建套接字(Socket)的核心目的,是为应用程序提供一个与网络协议(如 TCP/IP)交互的 "接口"
//让应用能通过这个 "桥梁",实现跨设备的数据发送与接收,是网络通信的基础入口。
//具体来说,它的核心作用可拆解为 3 点:
//对接网络协议栈:操作系统的网络协议栈(负责 TCP、UDP、IP 等协议的底层实现)不直接对外开放,
//套接字就是协议栈提供给应用程序的 "唯一交互窗口",即开辟一个口子用来操作
//应用通过调用 socket 相关函数(如 sendto、recvfrom),才能触发底层协议的通信逻辑。
//标识通信端点:每个套接字会关联关键信息(如协议类型 SOCK_STREAM/UDP、本地 IP、本地端口),
//这些信息共同构成一个 "通信端点"。就像寄信需要 "收件人地址 + 邮编",
//网络中两台设备通信时,必须通过对方的套接字信息(IP + 端口 + 协议),才能准确定位目标应用并传递数据。
//那么这也就暗示我们要把创建好了后的socket和当前主机,使用它的进程联系起来
//是不是一下子就想起来了共享内存的将进程和共享内存挂上联系!!!
//统一通信接口:无论应用使用 TCP 还是 UDP,无论通信对象是本地设备还是远程服务器,
//都可以通过套接字的标准函数(如 socket()、bind()、connect())实现通信,
//无需关注底层协议的复杂细节(如 TCP 握手、UDP 数据报封装),降低了网络编程的复杂度。
//简单类比:如果把应用程序比作 "房子",网络协议栈比作 "公路系统",
//那么套接字就是 "房子的大门"------ 应用必须通过这扇门,才能把数据 "送" 到公路上,或从公路上 "接" 到数据。
//那么怎么创建socket呢???就是利用下面这个函数:
//创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
////////////////////////////////////////////////
//int socket(int domain, int type, int protocol);
////////////////////////////////////////////////
//返回值很简单,失败返回-1,成功则返回创建好了后的socket的标识符,后续我们挂上联系等等,都是利用这个返回值
//其实就是类似fock函数返回的子进程pid
//成功调用:返回一个非负的整数(套接字描述符)
//失败调用:返回 -1,并设置全局变量 errno 标识错误原因
//那么第一个参数是什么呢???
//很简单,就是你要是什么通信,因为网络通信除了支持不同主机的通信,它也支持同一个主机的通信啊!!!
//所以,如果我们想让创建的socket支持不同主机的通信,第一个参数就传入AF_INET参数
// 参数值 含义 典型应用场景
// AF_INET IPv4 网络协议(最常用) 互联网 TCP/UDP 通信
// AF_INET6 IPv6 网络协议(很瘦少用) 下一代互联网通信
// AF_UNIX 本地进程间通信(Unix 域) 同一主机内进程通信
//所以我们一般都是传入AF_INET给第一个参数就行
//然后是第二个参数,type(套接字类型)
//指定套接字的通信方式,决定了数据传输的特性(面向连接 / 无连接、可靠 / 不可靠)。
//常用值:
//参数值 含义 特性 对应协议
//SOCK_STREAM 流式套接字 面向连接、可靠、有序、字节流 TCP
//SOCK_DGRAM 数据报套接字 无连接、不可靠、无顺序、以数据报为单位 UDP
//SOCK_RAW 原始套接字 可直接访问底层网络协议(如 ICMP) 自定义协议
//那么由于我们这里目前学习的是udp,所以这第二个参数,我们就使用SOCK_DGRAM即可
//SOCK_DGRAM 是套接字(Socket)的核心类型之一,对应 UDP(用户数据报协议),
//本质是一种无连接、不可靠的 "数据报式" 通信接口。
//其核心特性可总结为 4 点:
//无连接:通信前无需像 TCP 那样建立连接,直接发送数据,双方无需维持会话状态。
//不可靠:不保证数据能送达、不保证送达顺序(可能乱序 / 丢失 / 重复),协议本身没有重传、确认机制。
//面向数据报:数据以 "独立数据包" 为单位传输,每个数据包都包含完整目标地址,接收方会一次性接收整个数据包(无法拆分)。
//低开销、高实时:因无需维护连接和可靠性,传输效率高、延迟低,适合语音通话、视频流、DNS 查询等对实时性要求高、可容忍少量数据丢失的场景。
//第三个参数就很简单了,我们一般直接传入0即可,解析如下:
//3. protocol(协议类型)
//指定具体使用的传输协议,通常设为 0,表示根据 domain 和 type 自动选择默认协议:
//AF_INET + SOCK_STREAM + 0 → 自动选择 IPPROTO_TCP(TCP 协议)
//AF_INET + SOCK_DGRAM + 0 → 自动选择 IPPROTO_UDP(UDP 协议)
//只有需要使用特殊协议时(如原始套接字),才需要显式指定具体的协议值(如 IPPROTO_ICMP)。
//所以,我们使用socket一般就是socket(AF_INET,SOCK_DGRAM,0)
//ok,知道了socket的使用之后,我们再来说说bind函数的使用
//那么它是干什么用的呢???其实和我们之前将进程和共享内存挂上联系很像
//bind函数就是用于将本主机ip和本进程端口号去和我们创建的socket建立联系!!!
//相当于就是让双方都知道彼此,并且都能联系彼此!!!
// bind()是TCP/IP 网络编程中用于 "绑定套接字与网络地址" 的核心函数,
// 作用是将创建好的套接字(socket)与特定的「IP 地址 + 端口号」关联起来,
// 让套接字后续能在这个地址上监听连接(服务端)或发送数据(客户端可选)。
//绑定端口号 (TCP/UDP, 服务器)
////////////////////////////////////////////////
//int bind(int socket, const struct sockaddr *address, socklen_t address_len);
////////////////////////////////////////////////
//返回值依旧很简单,失败返回-1,成功返回0
// 1. int socket
// 含义:套接字描述符,是之前通过 socket() 函数创建的返回值(一个非负整数,类似文件描述符)。
// 示例:比如 int sockfd = socket(AF_INET, SOCK_STREAM, 0); 创建的 sockfd 就是这个参数。
//那么就是要传入我们之前创建的套接字socket函数的返回值就行
// 2. const struct sockaddr *address
// 含义:通用网络地址结构指针,用于存储要绑定的「协议族 + IP + 端口」信息。
// 注意:struct sockaddr 是通用地址结构(兼容 IPv4、IPv6 等协议),
// 实际开发中会用具体协议的地址结构(如 IPv4 用 struct sockaddr_in,IPv6 用 struct sockaddr_in6),
// 再强制转换为 struct sockaddr*,这个在下面有解释为什么
// 以IPv4 的 struct sockaddr_in 为例(最常用),结构定义:
// struct sockaddr_in
// {
// sa_family_t sin_family; // 协议族:必须填 AF_INET(表示IPv4)
// in_port_t sin_port; // 端口号:必须转成网络字节序(用 htons())
// struct in_addr sin_addr; // IP地址:必须转成网络字节序(用 inet_addr() 或 htonl())
// unsigned char sin_zero[8]; // 填充字段,设为0即可
// };
//其中 struct in_addr 是IPv4地址的结构:
// struct in_addr
// {
// in_addr_t s_addr; // 32位IPv4地址(网络字节序)
// };
//对于这一个结构体还是必须精讲一下!!!
//那么我们知道,其实使用网络通信是有远程通信,也有本地通信,甚至还有原始通信(当然,很少使用)
//那么很显然,远程通信和本地通信肯定不一样,那么这就代表什么???
//代表这二者的系统接口啊、信息啊之类的,也会不一样!!!
//但是呢,socket的创造者,就是想用同一套接口!!!,怎么办
//所以,它就把两个(原始这里忽略,其实也有)通信方式的信息啊,即ip端口号等等,都存进结构体内
//然后呢,不同的通信方式对应不同的结构体,比如远程通信对应struct sockaddr_in,本地通信对应struct sockaddr_un
//但是上面也说了,创建者想要只用一套接口,所以,其实socket使用的结构体就是struct sockaddr这个结构体
//可是远程通信和本地通信终究是不一样的结构体啊!!!
//那么,就直接强制转换就好了!!!
//把struct sockaddr_in/struct sockaddr_un强制转换为struct sockaddr这个结构体就好了
//•socket API可以都用struct sockaddr *类型表示, 在使用的时候需要强制转化成sockaddr_in;
//这样的好处是程序的通用性, 可以接收IPv4, IPv6, 以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数;
//这一点很重要,需要知道
//OK,接下来我们就来详细解析一下struct sockaddr_in结构体!!!
//那么同样的,我们要知道,这个结构体其实就是存储IP地址,端口号等等的信息,所以,它是非常重要的
//所以,想想,socket能不能自己就知道自己是在哪个主机,哪个进程???,不可以!!!
//还有就是socket能不能自己找到要把信息发送到哪个IP的哪个进程?????????
//不知道,那怎么办???那就用户指定,所以,我们是需要往struct sockaddr_in结构体内填充信息的
//还有就是,我们是需要把我们自己发送信息到网络的主机IP和进程端口号也发送到网络的
//不然收到信息的主机的进程,怎么知道,是谁发的????????????
//所以,无论怎么样,我们都需要往struct sockaddr_in结构体内填充信息
//所以,我们就来看看这个结构体内的信息要怎么填,其实上面也有分析了
//首先是sin_family,这个是网络协议族,需要我们传入是什么类型的IP
//那么由于我们都是使用IPV4,所以我们统一填充AF_INET(和socket函数的第一个参数一样哦)
//然后就是sin_port,即进程端口号,代表是哪个进程,
//还有就是sin_addr中的s_addr,这个是结构体内的结构体内的变量,哈哈,即IP地址,代表是哪个主机
//那么我们知道ip地址是四分位,所以本质上是一个字符串,这一点需要注意哦,可不是整数
//那么这两个参数我们都需要注意,我们前面分析了,网络中的数据得是大端的
//可是有的主机是小端的怎么办,那么它的端口号和IP地址就都是小端的
//很简单,转换就好了,不管你是大端机器还是小端机器,都给我进行转换为大端数据就好了
//所以sin_port我们就需要调用上面所说的htons函数去将传入的端口号改为大端
//而s_addr就需要我们使用inet_addr(一般是使用该函数,利用该函数将字符串的IP地址转换为符合网络通信协议的IP地址)
//或者htonl函数去进行转换,这一点需要严格注意
//最后就是unsigned char sin_zero[8]; // 填充字段,设为0即可
//那么这个我们是在正式填充struct sockaddr_in结构体之前使用bzero函数或者memset函数去将该结构体全部成员都变为0搞定的
//memset函数大家不陌生了肯定,那么bzero更方便,
// 头文件:通常需要包含 <strings.h>
// 函数原型:
// void bzero(void *s, size_t n);
// 二、参数解析
// void *s:
// 指向要初始化的内存区域的指针(可以是结构体、数组、普通变量的地址等)。
// 注意:s不能为NULL,否则会触发内存访问错误(段错误)。
// size_t n:
// 要初始化为 0 的字节数(指定 s 指向的内存区域中,前n个字节会被设为 0)。
// 一般直接传入sizeof(s(即第一个参数,注意不是指针哦,本体))就行
// 三、核心功能
// 将 s 指向的内存区域的前n个字节全部设置为二进制 0,等价于 "批量给内存写 0"
//OK,在这里必须再补充一点,那就是,如果我们想让一个进程不止能接收一个ip地址发生的信息呢???
//其实这个在这里有点不太好理解,其实就是我们要知道一个点
//那就是进程只绑定一个 IP 地址时,核心结果是:该进程仅能通过绑定的这个 IP进行网络通信(接收请求、发送数据),
//主机上的其他 IP 无法访问该进程提供的服务,通信范围受限,但同时能提升安全性和通信针对性。
//若主机有多个 IP(如同时配置内网 IP 192.168.1.100、公网 IP 203.0.113.5),
//进程仅绑定其中一个(比如内网 IP),则:
//只有指向该绑定 IP 的请求能被进程处理(内网设备访问 192.168.1.100:端口 可连接);
//指向主机其他 IP 的请求会被内核拒绝(外网设备访问 203.0.113.5:端口 会超时 / 连接失败)
//说白了就是,如果我们一个进程绑定本主机一个IP地址的话,那么本主机的其他IP地址都会被别的主机进行连接,也就是发送数据
//那么这就导致说,如果我们是一个接受信息,比如服务端,那么仅我们绑定的IP会运行网络通信,而其他的都不行
//那么很明显这个并不高效,尤其是在服务端的时候。
//那么怎么样才能让一个进程可以本主机的多个IP地址进行网络通信呢???
//那么就需要我们对于struct sockarr_in 中的in_arr中的s_arr不是传入一个本主机IP地址
//而是传入0.0.0.0,即INADDR_ANY(宏定义,值为 0.0.0.0),记得同样要经过htonl函数的转换(如果使用INADDR_ANY就不用)哦,以防万一
//那么将socket绑定这个ip地址之后,那么该进程就可以通过本主机的所以IP地址进行网络通信
// 0.0.0.0 的真实作用(仅服务端内部有效)
// 服务端绑定 0.0.0.0,不是让客户端连这个地址 ------
// 而是告诉操作系统:"我要监听这台服务器上所有可用的网卡 IP"
// (比如服务器可能有多个网卡,对应公网 IP、局域网 IP 等)。
// 举个例子:若服务器的公网 IP 是 123.123.123.123、局域网 IP 是 192.168.1.100,
// 绑定 0.0.0.0 后,客户端无论是通过公网(连 123.123.123.123)还是局域网(连 192.168.1.100),都能访问到这个服务端。
// 0.0.0.0 相当于服务端的 "内部万能监听标识",对外是无效地址,客户端无法直接连接 0.0.0.0。
// 客户端如何锁定服务端?用 "服务端的实际可访问 IP"
// 客户端不需要知道服务端绑定的是 0.0.0.0,
// 只需要知道服务端的 "实际可访问 IP"(公网 IP 或局域网 IP)+ 服务端的固定端口,就能精准锁定:
// 若在公网场景(比如你用手机连微信服务器):
// 客户端用微信服务器的 公网 IP + 固定端口(如微信的通信端口)发起连接。
// 若在局域网场景(比如你电脑连公司内网服务器):
// 客户端用服务器的 局域网 IP + 固定端口(如公司服务器的 8080 端口)发起连接。
// 这些 "实际可访问 IP" 是服务端对外提供的地址,
// 客户端通过静态配置(如程序内置)、DNS 解析(如输入 www.weixin.com 解析出公网 IP)等方式获取,
// 与服务端内部绑定 0.0.0.0 无关。
//这一些都是重点,需要大家仔细学习!!!
// 3. socklen_t address_len
// 含义:address 指向的地址结构的实际长度(单位:字节),用于告诉系统地址结构的大小,避免内存越界。
// 示例:若用 struct sockaddr_in,则传 sizeof(struct sockaddr_in)。
//其实就是直接传sizeof(struct sockaddr_in)就行
//那么会了bind函数的使用之后(其实最重点的事struct sockaddr_in的信息填充)
//那么我们就可以在一个主机内的一个进程内创建socket,并将该socket和该主机(IP地址)的该进程(端口号)绑定起来
//那么其实仅仅用这些还不够,因为我们还没接收信息,发送信息
//socket和bind函数的使用只够我们创建socket和绑定
//那么发送信息和接收信息,本质上其实是两个进程干的事,
//这个我们在之前的进程间通信啊,生产者消费者模型(这个是线程)啊,都说了不下十遍了
//所以,其实这是需要两个进程干的,但是在本文件中,我们先了解socket函数和bind函数的使用以及struct sockaddr_in结构体就行了
//ok,那么在本文件中,我们就封装出一个属于客户端的类,这个类的功能就是创建服务端的socket,以及bind
//以及进行接收信息和发送相同的信息回去,那么这个就是一个简单的测试
//那么在这里我们就先需要知道一些前备知识,首当其冲的就是,其实网络通信是全双工的!!!
//那么我们之前说过,管道其实是半双工的,即每次只能写或者读,
//不能既有进程发送信息另一个进程,然后另一个进程接收信息或者发送信息给那一个进程
//那么全双工就是,两个进程既能同时写(发送信息)也能同时读(接收信息),
//全双工是一种网络通信模式,允许通信双方在同一时刻同时发送和接收数据,
//就像现实中的电话通话 ------ 你说话的同时也能听到对方说话,双向传输互不干扰。
//那么这个时候大家就有疑问了,那不就不能保证数据原子性了吗,那么确实是,但是这并没什么
//首先,对于TCP协议,它是不会发生数据丢失等等的,因为它有自己的操作进行保证,所以它是最牛波一的通信协议,全双工
//而对于UDP协议,它是允许有数据丢失的,毕竟你全双工效率那么高,损失一点也没什么,下面是详细解析:
/*********************************************************************
* Socket 通信中数据原子性的核心原理
* 核心结论:原子性由底层传输协议(TCP/UDP)保证,Socket 仅为通信接口
********************************************************************/
/* ===================== 1. 数据原子性的定义 ===================== */
// 网络通信中"数据原子性":一段完整数据要么被对方完整接收,要么完全不接收,
// 不会出现"只接收一部分"的情况(如发送"Hello World",不会只收到"Hello")。
/* ===================== 2. TCP 协议:天然保证原子性 ===================== */
// TCP 是面向连接的字节流协议,底层通过以下机制保障原子性:
// ① 分段与重组:超大数据(超过MTU,如1500字节)自动拆分为TCP段,带序号传输;
// 接收方按序号重组为完整数据后,才通过recv()交给应用层(未重组完则等待)。
// ② 确认应答(ACK):接收方收到TCP段后回复ACK,未收到则重发,确保无段丢失。
// ③ 流量/拥塞控制:避免因接收能力不足、网络拥堵导致数据丢失,间接保障完整性。
// 注意:TCP保证字节流完整,但recv()是"按需读取"(如发1000字节可能分两次读500字节),
// 需应用层处理"数据边界"(如先传长度再传数据),不属于TCP原子性问题。
/* ===================== 3. UDP 协议:不保证原子性 ===================== */
// UDP 是无连接的数据报协议,无任何原子性保障:
// ① 数据报(最大约64KB)丢失则接收方完全收不到;
// ② 数据报拆分后若有分片丢失,整个数据报无法重组,接收方也收不到;
// ③ 无重发、确认机制,丢失后无法补救。
// 适配场景:仅适合实时性要求高、能容忍少量丢失的场景(语音/视频弹幕);
// 若需原子性,需应用层自行实现(序号、确认重发),复杂度高。
/* ===================== 核心总结 ===================== */
// 1. TCP:底层自动保障原子性,无需开发者处理底层逻辑(像"快递+签收确认");
// 2. UDP:不保证原子性,需应用层自行实现(像"明信片投递,丢了不补");
// 3. Socket 仅为通信接口,不直接提供原子性保证,核心依赖传输协议。
//那么既然网络通信是全双工,所以,再想想,我们之前说,想要用命名管道实现两个进程的全双工,是不是就得创建两个命名管道!!!
//所以网络通信是全双工的话,那么就需要服务端和客户端都得有一个各自的socket套接字
//那么那么,你只这么说可不能让我信服!!!给我理由!!!
//为什么服务端和客户端都要创建socket,不应该共用一个,然后都通过这个socket进行消息的收发吗
/*********************************************************************
* 核心原理:服务端与客户端为何无法共用同一个Socket?
* 核心结论:Socket功能分工/通信原理决定二者必须独立,无法共用
********************************************************************/
/* ===================== 核心类比 ===================== */
// Socket ≈ 通信专用的"管道接口":
// - 服务端Socket = 快递站的"前台窗口(只收不发)"+"专属快递员(专送专收)"
// - 客户端Socket = 寄件的"客户(只主动找快递站,不等人找)"
// 客户没法和快递站前台共用一个"接口"------功能天生相反,根本没法混用。
/* ===================== 一、功能分工:Socket角色完全相反 ===================== */
// 服务端的Socket分两个"工种",客户端只有一个,角色冲突无法共用:
// 1. 服务端-监听Socket(前台窗口)
// ✅ 唯一工作:绑定固定端口(快递站公示电话),只监听客户端的连接请求,从不主动发起连接;
// ✅ 对应代码:bind() + listen(),仅负责"接请求",不收发任何实际数据;
// ✅ 类比:快递站前台只登记"谁要寄件",从不亲自送包裹。
// 2. 服务端-通信Socket(专属快递员)
// ✅ 诞生场景:监听Socket收到请求后,通过accept()新建的"专属接口";
// ✅ 唯一工作:只对接发起请求的这个客户端,一对一收发数据;
// ✅ 类比:前台派一个快递员专门服务你,这个快递员只跟你打交道。
// 3. 客户端Socket(寄件客户)
// ✅ 唯一工作:主动调用connect()连接服务端的"前台窗口"(监听Socket),
// 连接成功后只和对接自己的"快递员"(通信Socket)交互;
// ✅ 关键:客户端Socket从不"等别人连接",只会主动发起连接;
// ✅ 矛盾点:一个Socket没法同时"站着等别人"和"主动找别人",系统直接禁止此操作。
/* ===================== 二、通信原理:数据传输需要"两个独立端点" ===================== */
// 网络传数据 ≈ 寄快递:必须明确"寄件人(源端点)"和"收件人(目的端点)",
// 每个端点 = IP + 端口 + Socket(就像"地址+手机号+专属快递单")。
// 举例子:
// - 客户端端点:你的手机(IP)+ 138xxxx1234(临时端口)+ 你的Socket(快递单);
// - 服务端端点:快递站(IP)+ 400xxxx5678(固定端口)+ 通信Socket(快递员工号)。
// 若共用Socket,会导致"寄件人"和"收件人"的快递单重合,快递员根本分不清"谁给谁发",
// 最终数据要么发不出去(系统报错),要么发出去也会迷路丢失。
/* ===================== 关键补充 ===================== */
// ❌ 误区:服务端监听Socket和通信Socket用同一个端口 = 共用Socket?
// ✅ 正解:端口是"干活的场地",Socket是"干活的人"------前台和快递员在同一个快递站(同端口),
// 但却是两个不同的人(不同Socket),完全不冲突。
/* ===================== 核心总结 ===================== */
// 1. 功能层面:服务端Socket分"监听(等请求)"和"通信(传数据)",客户端只"主动连请求",角色相反无法共用;
// 2. 原理层面:数据传输需要"源/目的两个独立端点",共用Socket会导致端点重合,数据无法识别;
// 3. 简单类比:监听Socket=公司总机(只接电话),通信Socket=业务员分机,客户端Socket=你的手机,三者绝不能共用。
//这个确实不是很好理解,因为这点就和共享内存不一样,所以我们不能太单纯的理解
// 网络传数据 ≈ 寄快递:必须明确"寄件人(源端点)"和"收件人(目的端点)",
// 每个端点 = IP + 端口 + Socket(就像"地址+手机号+专属快递单")。
//这个是最重要的
//那么反正就是要知道,服务端和客户端都需要有自己的socket套接字,这才是实现全双工的根本
//还有就是虽然不能像共享内存那样子将两个进程都和共享内存挂上联系从而实现精确的一对一联系
//但是可不要忘记了,网络通信的本质是依靠IP加端口号进行唯一定位的,所以虽然服务端和客户端都有自己的socket
//但是依然可以精准定位接收/发送信息的主机的进程
//OK,那么这个知识暂时就了解到这里
//那么在这里我们主要是先实现UDP的网络通信,那么前面也说了UDP是比较不靠谱的
//UDP 没有 "连接" 的概念,每次发送 / 接收数据时,
//都需要明确指定「对方的 IP + 端口」(发送时)或「获取对方的 IP + 端口」(接收时)
//所以我们应该用什么函数去进行数据的收发呢???
//答案是:recvfrom和sendto函数
// recvfrom() 和 sendto() 不是 UDP 专属函数------ 它们是通用的 Socket I/O 函数,
// 理论上所有类型的套接字(包括 TCP、UDP)都可以调用;
// 但这两个函数是为无连接的 UDP 量身设计的,TCP 虽然能调用,但完全没必要(有更适配的函数),
// 因此实际开发中几乎只在 UDP 里使用。
// 接收数据(可获取发送方地址)
// ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
// struct sockaddr *src_addr, socklen_t *addrlen);
//那么返回值依旧是失败返回-1,成功:返回实际接收到的字节数
//第一个参数,即需要传入调用该函数的进程的创建的socket套接字,很简单的操作
//参数名 类型 核心含义 注意事项
//sockfd int 套接字描述符(socket() 创建的返回值) 必须是已创建 / 绑定(UDP)或已连接(TCP)的有效套接字
//buf void * 接收数据的缓冲区指针(存储收到的字节) 需提前分配内存(如 char buf[1024]),避免空指针
//len size_t 缓冲区的最大长度(字节) 建议留 1 字节给字符串结束符(\0),避免越界,传入sizeof(buf)-1即可
//flags int 接收标志(控制接收行为) 通常传 0(默认阻塞接收);常用值:MSG_DONTWAIT(非阻塞)、MSG_PEEK(预览数据不读取)
//src_addr struct sockaddr * 输出参数:存储发送方的地址信息(IP + 端口) UDP 必传,传sockaddr_in(记得强制转换哦)(需获取谁发的);TCP 传 NULL(无意义)
//addrlen socklen_t * 输入输出参数:
//输入:src_addr 结构体的长度(如 sizeof(struct sockaddr_in))
//输出:实际接收的地址长度 必须传指针(不能传值);UDP 需先初始化值(如 socklen_t len = sizeof(addr);)
//最后一个参数的输出属性我们基本不用,所以直接传入&len即可
//因为是从另一个进程接收数据,那么肯定得知道是哪个主机的哪个进程,而这些又是依靠ip和端口号
//再加上这些都是存储在struct sockaddr_in结构体中,所以就需要输出参数src_addr!!!
//需要简单注意一下
// 发送数据(需指定接收方地址)
// ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
// const struct sockaddr *dest_addr, socklen_t addrlen);
//那么返回值依旧是失败返回-1,成功:返回实际发送的字节数(UDP 中通常等于 len,TCP 可能小于 len,需循环发送);
//第一个参数,即需要传入调用该函数的进程的创建的socket套接字,很简单的操作
//参数名 类型 核心含义 注意事项
//sockfd int 套接字描述符(socket() 创建的返回值) 同 recvfrom,UDP 需已绑定(可选),TCP 需已连接
//buf const void * 要发送的数据缓冲区指针 数据需提前准备(如字符串、二进制数据),const 保证数据不被修改
//len size_t 要发送的字节数(如 strlen(buf)) 不能超过缓冲区实际长度,UDP 单次发送不宜超过 65507 字节(MTU 限制)
//flags int 发送标志(控制发送行为) 通常传 0(默认阻塞发送);常用值:MSG_DONTWAIT(非阻塞)、MSG_OOB(发送紧急数据)
//dest_addr const struct sockaddr * 输入参数:接收方的地址信息(IP + 端口) UDP 必传(需指定发给谁);TCP 传 NULL(无意义)
//addrlen socklen_t dest_addr 结构体的长度(如 sizeof(struct sockaddr_in)) 直接传值(无需指针),必须与地址结构体类型匹配
//因为是给另一个进程发送数据,那么肯定得知道是哪个主机的哪个进程,而这些又是依靠ip和端口号
//再加上这些都是存储在struct sockaddr_in结构体中,所以就需要输入参数dest_addr!!!
//其实理解了之后,还是比较简单的说实话!!!
//而且再想想,如果我们想要把从哪个ip的进程接收到的数据返回给那个进程,是不是就是可以利用recvfrom的那个输出型参数
//进行直接定位了!!!
// src_addr/dest_addr:分别用于获取发送方地址(recvfrom)、指定接收方地址(sendto);
// 这两个参数是为 UDP 设计的核心 ------UDP 无连接,必须每次明确地址;
// 而 TCP 建立连接后,内核已保存对方地址,这两个参数就成了 "多余项"。
//ok,下面哦我们就来实践一下,对于服务端,我们就封装个类出来
using func_t = std::function<std::string(const std::string&)>;
const int defaultsocketfd=-1;//默认socketfd
using namespace LogModule;
class UdpServer
{
public:
//构造函数
//是服务端,那么就是接收信息,那么也是一个进程,那么是不是应该我们得给它分配端口号呢???
//再想想,服务端是不是要出名,独一的,所以它应该就要有单独的指定的端口号
//这样子那些客户端才能定位到这个服务端,这一点需要注意
UdpServer(int port,func_t func)
:_port(port)
,_func(func)
,_socketfd(defaultsocketfd)
,_isrunning(false)//刚开始本服务端肯定没有运行
{
Use_Monitor_Log();//显示器上打印日志
}
//析构函数,啥也不用做,用默认的就行
~UdpServer()=default;
//创建服务端socket套接字并进行bind的函数
void InitUdpServer()
{
_socketfd=socket(AF_INET,SOCK_DGRAM,0);
if(_socketfd<0)
{
LOG(LogLevel::FATAL)<<"socket failed";
exit(2);
}
//创建struct sockaddr_in结构体,将本进程的ip啊(其实就是0.0.0.0)端口号啊传入
struct sockaddr_in sever;
bzero(&sever,sizeof(sever));
sever.sin_family=AF_INET;
sever.sin_addr.s_addr=htonl(INADDR_ANY);
sever.sin_port=htons(_port);
//进行bind绑定!!!
//服务端是需要bind绑定的,因为服务端必须要有一个确定的端口号,原因上面也说了
//是服务端,那么就是接收信息,那么也是一个进程,那么是不是应该我们得给它分配端口号呢???
//再想想,服务端是不是要出名,独一的,所以它应该就要有单独的指定的端口号
//这样子那些客户端才能定位到这个服务端,这一点需要注意
//不想服务端是不需要有什么单独的我们已知的端口号,因为它只是负责发送信息!!!
//所以它是不用显式bind的,在发送信息的时候,OS就会自动给它客户端进程随机端口号!!!
int ret_bind=bind(_socketfd,(struct sockaddr*)&sever,sizeof(sever));
if(ret_bind<0)
{
LOG(LogLevel::FATAL)<<"bind failed";
exit(2);
}
LOG(LogLevel::INFO)<<"bind success!!!";
}
//运行服务端网络通信的函数,其实就是调用recvform函数罢了,然后本文件为了测试会再简单的
//把接收到的信息发送回给本服务端进程发送信息的客户端进程
void StartUdpServer()
{
//代表开始运行本服务端,所以要把_isrunning进行修改
_isrunning=true;
while(_isrunning)//只要还在于运行,就一直运行下去,其实是个废话
{
//接收信息
char buff[1024]={0};//创建接收数据的字符串
struct sockaddr_in client;
socklen_t clientsize=sizeof(client);
bzero(&client,clientsize);
int ret_recvfrom=recvfrom(_socketfd,buff,sizeof(buff)-1,0,(struct sockaddr*)&client,&clientsize);
if(ret_recvfrom<0)
{
LOG(LogLevel::INFO)<<"sever recvfrom failed!!!";
}
else
{
//因为我们要把字符串最后一个字符设为0,而且我们刚开始也已经留出空间了
//再加上recvfrom函数返回值就是收到的字节数,所以直接操作就完事了
buff[ret_recvfrom]='\0';
}
//那么我们为了测试,也可以让服务端往向其发送信息的客户端发送信息
//那么怎么知道是哪个客户端向本服务端发送数据呢???
//不要忘记了recvfrom函数的输出型参数哦
int ret_sendto=sendto(_socketfd,buff,sizeof(buff),0,(struct sockaddr*)&client,clientsize);
if(ret_sendto<0)
{
LOG(LogLevel::INFO)<<"sever sendto failed!!!";
}
}
}
private:
int _socketfd;//socket套接字
int _port;//服务端进程端口号
func_t _func;//服务端处理任务的函数,回调函数
//int _ip;//本进程所在的主机ip地址,那么想想看,服务端要用指定本主机的哪个ip地址吗??
//不用,直接传入INADDR_ANY就行,因为是服务端,所以是肯定要接收一大堆客户端的访问,所以就要使用0.0.0.0
//这一点需要注意
bool _isrunning;//判断本服务端是否还运行,其实本质上是一直死循环待机,毕竟想拼多多啊之类的服务器,什么时候关过机呢!!
};
//至此一个简单的sever服务端就封装好了
服务端源文件代码:
cpp
#include "UdpServer.hpp"
#include <iostream>
#include <memory>
// 仅仅是用来进行测试的
std::string defaulthandler(const std::string &message)
{
std::string hello = "hello, ";
hello += message;
return hello;
}
// 命令行参数,因为我们会要求用户在调用服务端的时候就得传入服务端的端口号,
// 那么ip可以不用,因为我们使用0.0.0.0
// 所以就要求用户这么调用服务端进程:
int main(int argc, char *argv[])//二级指针哦
{
if (argc != 2)
{
std::cerr << "Usage: " << argv[0] << " port" << std::endl;
return 1;
}
// std::string ip = argv[1];
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<UdpServer> usvr = std::make_unique<UdpServer>(port, defaulthandler);
usvr->InitUdpServer();
usvr->StartUdpServer();
return 0;
}
//其实还是比较简单的
5.3.2 客户端代码(UdpClient.cc)
cpp
#include <iostream>
#include <string>
#include <cstring>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>
#include <sys/socket.h>
//客户端,其实就是向服务端发送信息的进程
//那么前面也说了,要客户端也要创建一个socket哦
//然后客户端必须要知道要发送信息的服务端的IP地址和端口号哦
//那么这就需要我们外界在调用该客户端进程的时候就一起传入
//所以,就又需要命令行参数了!!!
// ./udpclient server_ip server_port
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 = std::stoi(argv[2]);//将端口号进行转换,从字符串转换为整型
// 1. 创建socket
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd < 0)
{
std::cerr << "socket error" << std::endl;
return 2;
}
// 2. 本地的ip和端口是什么?要不要和上面的"文件"关联呢?
// 问题:client要不要bind?需要bind.
// client要不要显式的bind?不要!!首次发送消息,
// OS会自动给client进行bind,OS知道IP,端口号采用随机端口号的方式
// 为什么?一个端口号,只能被一个进程bind,为了避免client端口冲突
// client端的端口号是几,不重要,只要是唯一的就行!
//客户端(无论 UDP/TCP)不需要显式调用 bind,首次发送数据时操作系统(OS)会自动完成 bind:
//OS 会自动为客户端套接字绑定「本机出口 IP + 随机未被占用的端口号」;
//显式 bind 反而容易引发端口冲突,违背客户端 "灵活接入" 的设计需求。
//服务端 vs 客户端:端口需求的本质差异
//角色 端口需求 绑定方式 原因
//服务端(Server) 必须用固定端口(如 80、8080) 显式 bind 客户端需要 "找到" 服务端,必须知道服务端的固定 IP + 固定端口(比如访问网站要连 80 端口)
//客户端(Client) 只需要唯一端口(具体数值无关) OS 自动 bind 服务端只需 "回包" 给客户端的临时 IP + 端口,客户端端口不用固定,唯一即可
//OS 会做 3 件事:
//选一个未被占用的随机端口(比如 45678);
//选客户端的出口 IP(比如本机连路由器的 IP 192.168.1.200);
//自动将这个 "192.168.1.200:45678" 绑定到客户端套接字上。
//服务端通过recvfrom拿到客户端的 "192.168.1.200:45678",就能精准回包,
//客户端端口是 45678 还是 56789 完全不重要。
//如果客户端显式 bind 一个固定端口(比如 9090):
//同一台机器启动多个客户端进程时,第二个进程 bind 9090 会失败(端口被第一个进程占用);
//因为要是我们的客户端代码显式bind的话,即显式将客户端进程绑定到我们指定的端口号的话
//那么如果同一个主机下启动多个客户端进程呢???是不是本质上就是运行同一段代码
//那么这些客户端进程是不是会都去申请我们指定的端口号呢???
//可是第一个客户端进程已经绑定走了,那么后面的又怎么可能申请成功呢???
//所以这就导致无法同一个主机下启动多个客户端进程
//所以啊,我们不应该显式将客户端进程绑定到我们指定的端口号
//应该让OS自己随机挑选空余的端口号,这样子才不会发生上面所说的问题
//这一点就是最重要的原因,需要知道!!!
//客户端完全不需要固定端口,这种做法只会增加 "端口冲突" 的风险,没有任何收益。
//填写服务器信息
//客户端必须要知道要发送信息的服务端的IP地址和端口号哦
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.c_str());
while(true)//死循环
{
std::string input;
std::cout << "Please Enter# ";
std::getline(std::cin, input);
int n = sendto(sockfd, input.c_str(), input.size(), 0, (struct sockaddr*)&server, sizeof(server));
(void)n;
char buffer[1024];
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int m = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len);
if(m > 0)
{
buffer[m] = 0;
std::cout << buffer << std::endl;
}
//网络通信是全双工,所以可以同时接发信息
}
return 0;
}
5.3.3 编译与运行步骤(Linux/Mac环境)
-
编译服务端:
gcc UdpServer.cc -o UdpServer; -
编译客户端:
gcc UdpClient.cc -o UdpClient; -
打开第一个终端,运行服务端(需管理员权限,因绑定8080端口,部分系统可能不需要):
sudo ./UdpServer 8080, -
打开第二个终端,运行客户端:
./UdpClient 127.0.0.1 8080, -
此时第一个终端(服务端)输出:
New client connected:Client IP: 127.0.0.1Client Port: 54321(端口号可能不同,是操作系统动态分配的)Received data (39 bytes): Hi, UDP Server! This is a test message.Reply sent to client!。
至此,一个完整的UDP客户端-服务端通信案例就运行成功了!通过这个案例,你可以清晰地看到UDP通信的整个流程:客户端发送数据(携带动态端口)→ 服务端接收数据(获取客户端IP和端口)→ 服务端回复数据(基于客户端地址)→ 客户端接收回复。
5.4 UDP通信的注意事项
-
数据报边界问题:UDP以数据报为单位传输,发送方一次sendto()发送的数据,接收方必须一次recvfrom()接收完整,否则会丢失数据(或后续数据出现错误)。比如发送方发送100字节,接收方第一次recvfrom()只接收50字节,剩下的50字节会被丢弃;
-
数据丢失与乱序:UDP是不可靠协议,数据可能丢失、重复或乱序。如果需要可靠传输,需在应用层实现重传、确认、序号等机制(如TFTP协议就是基于UDP实现的可靠传输);
-
缓冲区大小:接收缓冲区的大小要足够容纳可能收到的最大数据报,避免数据被截断;
-
端口号转换:始终记得将端口号和IP地址在主机字节序和网络字节序之间转换,这是新手最容易踩的坑;
-
客户端动态端口:客户端不要手动bind()固定端口,交给操作系统动态分配即可。
六、总结:UDP Socket编程核心流程与知识点
6.1 UDP服务端核心流程
-
调用socket()创建UDP Socket(domain=AF_INET,type=SOCK_DGRAM,protocol=0);
-
(可选)设置SO_REUSEADDR选项,避免端口占用问题;
-
填充struct sockaddr_in地址结构体(协议族、端口号、IP地址);
-
调用bind()将Socket与地址结构体绑定;
-
调用recvfrom()阻塞接收客户端数据(获取客户端地址);
-
(可选)调用sendto()回复客户端数据;
-
通信结束,调用close()关闭Socket。
6.2 UDP客户端核心流程
-
调用socket()创建UDP Socket;
-
填充服务端的struct sockaddr_in地址结构体(服务端IP和端口);
-
调用sendto()向服务端发送数据(无需bind(),操作系统自动分配动态端口);
-
(可选)调用recvfrom()接收服务端的回复;
-
通信结束,调用close()关闭Socket。
6.3 核心知识点梳理
-
网络通信本质:跨主机进程间通信,依赖IP地址(定位主机)+ 端口号(定位进程);
-
端口号范围:0~65535,分为知名端口(0~1023)和动态端口(1024~65535);
-
字节序:网络字节序为大端序,需用htonl/htons/ntohl/ntohs函数转换IP和端口;
-
Socket:应用程序与网络协议栈的接口,核心作用是对接协议栈、标识通信端点、统一通信接口;
-
bind()函数:服务端必须绑定,客户端无需绑定;
-
sendto/recvfrom():UDP专用的发送和接收函数,需指定对方地址。
七、核心差异:UDP服务端与客户端实现对比
UDP服务端和客户端的核心目标不同(服务端需持续监听请求,客户端需主动发起通信),导致两者在实现流程、函数调用、参数配置上存在显著差异。很多新手混淆两者的实现逻辑,最终导致程序无法正常运行。下面从核心步骤、关键细节两方面做全面对比。
7.1 核心步骤差异对比(表格清晰呈现)
| 实现步骤 | UDP服务端 | UDP客户端 | 核心原因 |
|---|---|---|---|
| 1. 创建Socket | 调用socket(AF_INET, SOCK_DGRAM, 0),逻辑与客户端一致 | 调用socket(AF_INET, SOCK_DGRAM, 0),逻辑与服务端一致 | 两者均需通过Socket与网络协议栈交互,创建逻辑完全相同 |
| 2. 绑定地址(bind) | 必须调用,绑定固定端口(如8080)和INADDR_ANY | 通常不调用,由系统自动分配临时端口 | 服务端需固定端口供客户端定位;客户端无需固定端口,临时端口可避免冲突 |
| 3. 填充地址结构体 | 填充本地地址(sin_family=AF_INET,sin_port=固定端口,sin_addr=INADDR_ANY) | 填充服务端地址(sin_family=AF_INET,sin_port=服务端固定端口,sin_addr=服务端IP) | 服务端绑定本地地址用于监听;客户端需指定服务端地址才能发送数据 |
| 4. 数据收发逻辑 | 循环调用recvfrom阻塞等待数据,接收后通过sendto回复(复用客户端地址) | 先调用sendto向服务端发送数据,再调用recvfrom接收回复(可选) | 服务端是被动响应方,需持续监听;客户端是主动发起方,按需发送数据 |
| 5. 程序生命周期 | 长期运行(通常通过循环+信号处理退出) | 短期运行(发送/接收数据后即可退出,或按需重复通信) | 服务端需持续提供服务;客户端完成单次/多次通信后即可终止 |
7.2 关键差异点深度解析
7.2.1 bind函数:服务端"必用",客户端"禁用"
这是两者最核心的差异,前文虽提及但需进一步强调:
-
服务端必须bind:服务端的核心是"让客户端能找到自己",固定端口是前提(如HTTP服务固定80端口)。若不bind,Socket未关联固定端口,客户端无法定位服务端,自然无法发送数据。且服务端需绑定INADDR_ANY,确保所有网卡IP均可接收请求。
-
客户端禁止手动bind:客户端的核心是"向服务端发送数据",本地端口无需固定。系统会自动为客户端分配1024~65535之间的临时端口(发送数据时绑定),通信结束后释放。若手动bind固定端口,可能因端口被占用导致程序启动失败;且多客户端同时运行时会出现端口冲突。
客户端手动bind并非"不能运行",而是"完全没必要且风险极高"。实际开发中,客户端绝不要主动调用bind函数。
7.2.2 地址结构体:"本地"vs"远程"
两者填充地址结构体的目标完全相反:
-
服务端:填充本地地址(自己的IP和端口),用于bind绑定,告诉系统"监听这个地址的请求"。
-
客户端:填充服务端地址(远程IP和端口),用于sendto发送数据,告诉系统"把数据发给这个地址"。
例外情况:客户端接收服务端回复时,也可通过recvfrom获取服务端地址,但通常无需处理(客户端已明确服务端地址)。
7.2.3 程序流程:"循环监听"vs"单次/多次通信"
服务端需通过无限循环(while(1))持续调用recvfrom阻塞等待数据,确保能处理多个客户端的请求(UDP无连接,可同时接收多个客户端数据)。而客户端通常是"发送数据→接收回复→退出"的短期流程,无需循环(除非需持续通信,如即时聊天客户端)。
八、实战必备:UDP编程调试技巧与常见问题解决
新手编写UDP程序时,常遇到"程序运行无报错,但就是无法通信"的问题。下面分享实战中最常用的调试技巧和问题解决方案。
8.1 常用调试工具(Linux环境)
8.1.1 netstat:查看端口监听与连接状态
核心作用:检查服务端是否成功绑定端口、是否处于监听状态。
cpp
# 查看8080端口的监听状态(UDP服务端常用)
netstat -anp | grep 8080 | grep UDP
# 输出示例(成功监听):
udp 0 0 0.0.0.0:8080 0.0.0.0:* 12345/udp_server
参数说明:
-
-a:显示所有连接和监听端口;
-
-n:以数字形式显示IP和端口(不解析域名和服务名);
-
-p:显示占用端口的进程PID和名称(需root权限)。
若无输出:说明服务端未成功绑定8080端口,需检查bind函数调用是否错误(如端口被占用、字节序未转换)。
8.1.2 tcpdump/Wireshark:抓包分析数据传输
核心作用:确认数据是否发送/接收成功,定位"发送方问题"还是"接收方问题"。
cpp
# tcpdump抓包(监听本地回环接口,UDP端口8080)
tcpdump -i lo udp port 8080
# 输出示例(客户端发送数据,服务端接收):
12:34:56.789012 IP localhost.54321 > localhost.8080: UDP, length 10
12:34:56.789045 IP localhost.8080 > localhost.54321: UDP, length 16
说明:
-
第一行:客户端(端口54321)向服务端(端口8080)发送UDP数据,长度10字节;
-
第二行:服务端向客户端回复数据,长度16字节。
若只看到客户端发送数据,无服务端回复:检查服务端recvfrom是否调用成功、sendto是否执行;若未看到客户端发送数据:检查客户端sendto是否错误、服务端IP/端口是否填写正确。
Wireshark是图形化工具,操作更直观,支持Windows/Linux/Mac,适合新手使用(过滤规则:udp.port == 8080)。
8.1.3 perror与strerror:打印详细错误信息
所有系统调用(socket、bind、sendto、recvfrom)失败后,都会设置全局变量errno。仅用printf打印"失败"无法定位问题,必须用perror或strerror打印errno对应的错误描述:
cpp
#include <string.h> // 包含strerror声明
// 错误示例:仅打印提示
if (bind(sockfd, ...) < 0) {
printf("bind failed\n");
exit(1);
}
// 正确示例1:用perror打印
if (bind(sockfd, ...) < 0) {
perror("bind failed"); // 输出:bind failed: Address already in use(端口被占用)
exit(1);
}
// 正确示例2:用strerror打印(更灵活)
if (bind(sockfd, ...) < 0) {
printf("bind failed: %s\n", strerror(errno)); // 输出同上
exit(1);
}
8.2 常见错误及解决方案(新手必看)
8.2.1 错误1:bind failed: Address already in use(端口被占用)
原因:服务端要绑定的端口已被其他程序占用(如8080端口被其他UDP/TCP服务占用)。
解决方案:
-
更换端口:将服务端端口改为其他未占用端口(如8081),注意客户端也要同步修改;
-
杀死占用进程:用netstat -anp | grep 8080找到占用进程的PID,然后用kill -9 PID杀死进程(需root权限);
-
设置SO_REUSEADDR选项:在bind前设置Socket选项,允许端口复用(适合开发环境):
cpp
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
8.2.2 错误2:接收不到数据(程序无报错,但recvfrom一直阻塞)
常见原因及解决:
-
客户端IP/端口填写错误:检查客户端代码中服务端IP(如是否把127.0.0.1写成127.0.0.2)、端口号是否与服务端一致;
-
服务端未绑定正确地址:检查服务端是否绑定INADDR_ANY(而非具体IP,如仅绑定192.168.1.100,局域网外客户端无法访问);
-
防火墙拦截:Linux防火墙(如iptables)可能拦截UDP端口,关闭防火墙或开放对应端口:
cpp
# 关闭Linux防火墙(开发环境)
systemctl stop firewalld
# 或开放8080 UDP端口
firewall-cmd --add-port=8080/udp --permanent
firewall-cmd --reload
8.2.3 错误3:数据乱码/解析错误
原因:
-
缓冲区未清空:每次recvfrom前未用bzero清空缓冲区,残留上一次数据;
-
数据格式不统一:客户端与服务端约定的数据格式不一致(如客户端发字符串,服务端按整数解析);
-
未添加字符串结束符:接收字符串时,缓冲区未留1字节存'\0',导致打印时出现乱码。
解决方案:
-
接收前必清空缓冲区:bzero(buf, BUF_SIZE);
-
严格约定数据格式:如统一用字符串、固定长度的二进制数据;
-
接收时留1字节给'\0':recvfrom的len参数设为BUF_SIZE-1(如BUF_SIZE=1024,传入1023)。
8.2.4 错误4:端口号显示错误(如客户端端口显示为24903,实际是54321)
原因:未用ntohs转换端口号的字节序。客户端端口存储在sockaddr_in的sin_port中(网络字节序),直接打印会因主机字节序(小端)与网络字节序(大端)不一致导致错误。
解决方案:打印端口时必须用ntohs转换:
cpp
# 错误示例:直接打印
printf("客户端端口:%d\n", client_addr.sin_port);
# 正确示例:用ntohs转换
printf("客户端端口:%d\n", ntohs(client_addr.sin_port));
至此,我们对Linux:UDPsocket编程基础的解析便告一段落!!!
结语:
当你看到这里时,相信你已经完整走过了Linux UDP Socket编程基础的学习之旅。回望这段历程,从对"网络通信本质"的懵懂好奇,到对"端口号与IP地址"组合定位逻辑的豁然开朗;从对"字节序转换"的混淆不解,到能熟练运用htonl、htons等函数规避跨主机通信的字节序陷阱;从不知道Socket为何物,到能独立完成服务端与客户端的创建、绑定、数据收发全流程代码编写,甚至能精准排查端口冲突、数据乱码等常见问题------这每一步的跨越,都藏着你主动探索的汗水,也标志着你正式迈入了网络编程的大门,值得为自己的坚持与成长由衷鼓掌。
或许在学习过程中,你也曾有过不少困惑与挫败:第一次调用bind函数时,因忘记转换端口号字节序导致程序报错,对着"Address already in use"的提示束手无策;第一次编写客户端代码时,误模仿服务端手动bind固定端口,结果出现端口冲突无法启动;第一次调试通信流程时,程序无报错却始终接收不到数据,最后才发现是防火墙拦截了UDP数据包;第一次用netstat抓包时,看着陌生的端口状态与数据流向,半天摸不清问题所在......但正是这些看似棘手的"小插曲",让你逐渐摸清了UDP编程的核心规律,也让你在排查问题的过程中,加深了对网络协议底层逻辑的理解。毕竟,网络编程的魅力就在于此------它不仅需要扎实的理论基础,更需要极强的实践能力,每一个bug的解决,都是一次技术认知的升华。
回顾我们整个学习脉络,其实始终围绕着"如何实现跨主机进程间通信"这一核心问题展开。我们首先明确了网络通信的本质是跨主机的IPC,而要实现这一目标,就必须解决"如何定位目标进程""如何统一数据格式""如何通过接口与网络协议栈交互"这三大核心问题。于是,端口号作为"进程身份证"应运而生,与IP地址组合形成唯一的网络进程标识;字节序转换函数作为"格式转换器",确保不同主机间的数据能正确解析;Socket作为"应用与协议栈的桥梁",为我们提供了统一的通信接口。在此基础上,我们逐步掌握了socket函数创建套接字、bind函数绑定地址、sendto与recvfrom函数收发数据的完整流程,也清晰区分了服务端与客户端的实现差异------服务端需显式绑定固定端口以提供稳定服务,客户端则由系统自动分配动态端口以避免冲突,这种差异背后,是网络通信"供需双方"的核心需求不同。
而我们编写的UDP服务端与客户端实战案例,更是将这些零散的知识点串联成了完整的技术闭环。从服务端初始化套接字、绑定0.0.0.0地址监听所有网卡请求,到循环调用recvfrom接收客户端数据并通过sendto回复;从客户端创建套接字后直接向服务端发送数据,到接收服务端的响应信息,每一行代码都承载着我们对网络通信逻辑的理解。当你第一次成功运行服务端与客户端,在客户端输入信息后能实时收到服务端的回复时,那种跨越"主机边界"实现数据传输的成就感,或许就是驱动我们深入学习网络编程的最大动力。
可能有同学会觉得,UDP作为无连接、不可靠的协议,实用性不如TCP,为什么我们要先从UDP入手学习?其实,UDP的"简单性"恰恰是它成为网络编程入门最佳选择的关键。正因为它没有TCP复杂的三次握手、四次挥手、重传机制等,我们才能更专注于网络通信的核心基础------地址定位、字节序转换、Socket接口使用等。这些知识点是所有网络编程的共性基础,掌握了它们,再学习TCP编程时,你就能更清晰地理解TCP在UDP基础上增加的可靠性机制,学习曲线也会更加平缓。同时,UDP并非"无用武之地",在实时性要求高、能容忍少量数据丢失的场景中,UDP有着不可替代的优势,比如语音通话、视频直播、DNS查询等,这些日常场景背后,都离不开UDP协议的支撑。了解UDP的特性与编程逻辑,能让你更全面地理解网络通信的多样性。
在学习过程中,我们还介绍了netstat、tcpdump、Wireshark等调试工具的使用方法,这也是网络编程实战中不可或缺的技能。很多时候,程序代码看似无懈可击,但实际运行时却会出现各种问题,此时调试工具就是我们的"慧眼"------通过netstat可以查看端口监听状态,快速定位端口占用问题;通过tcpdump或Wireshark抓包,可以直观地看到数据的发送与接收过程,判断数据是否完整、地址是否正确。掌握这些工具的使用,能让你在后续的开发中少走很多弯路,也能提升你排查问题的效率与能力。
当然,UDP Socket编程基础的学习,仅仅是你网络编程之旅的起点。接下来,你还会接触到更复杂但更可靠的TCP编程,学习面向连接的通信逻辑、流式数据的处理方式,理解三次握手、四次挥手、滑动窗口、拥塞控制等核心机制;你会学习IO复用技术(select、poll、epoll),让你的服务端能同时处理多个客户端的请求,提升程序的并发能力;你会学习多线程、多进程编程,结合网络编程实现高并发服务;你还会深入学习应用层协议,比如HTTP、HTTPS,理解浏览器与服务器的通信过程,甚至能自己编写简单的Web服务器。这些知识会一步步构建起你的网络编程知识体系,让你从"能实现简单通信"的新手,成长为"能开发高并发、高可靠网络服务"的资深开发者。
在后续的学习中,希望你能始终保持初学者的好奇心与探索欲,不要害怕遇到问题。网络编程涉及的知识点繁多且复杂,遇到困惑、踩坑都是正常的,关键是要学会主动查阅资料、调试排查、总结经验。建议你多动手编写代码,将理论知识转化为实践能力,比如尝试修改我们本次的UDP实战案例,增加数据校验功能实现UDP的简单可靠性;尝试编写一个简单的UDP聊天程序,支持多客户端之间的消息转发;尝试使用调试工具分析不同场景下的UDP数据传输情况,加深对协议特性的理解。实践是检验真理的唯一标准,只有在不断的实践与总结中,你才能真正掌握网络编程的核心技能。
同时,也要提醒你,网络编程是一门注重底层逻辑的学科,学习过程中要多问"为什么"。比如,为什么端口号的取值范围是0~65535?为什么客户端不需要手动bind固定端口?为什么TCP是可靠的而UDP是不可靠的?这些问题的答案,都藏在协议的设计逻辑与操作系统的实现原理中。深入思考这些问题,能让你不仅"知其然",更"知其所以然",也能让你在面对复杂问题时,具备更清晰的分析思路。
最后,祝贺你顺利完成Linux UDP Socket编程基础的学习!这段学习经历,不仅为你打下了网络编程的基础,也培养了你解决复杂问题的能力与耐心。网络世界广阔无垠,还有无数的技术知识等待你去探索。愿你在未来的学习道路上,始终保持热爱与执着,不断突破自我,在网络编程的世界里绽放光彩。相信只要你坚持下去,终会成为自己想成为的技术强者,用代码构建更便捷、更高效的网络服务,书写属于自己的技术故事!