目录
预备知识
重点补充说明:网络协议中的下三层,主要解决的是数据安全可靠的送到远端机器。
理解源IP地址和目的IP地址
在我们日常生活中,只要我们的电子设备连上网后,都会有一个自己的IP地址,该IP地址的主要作用就是来标记自己设备在网络上的位置,当我们的一台主机向另一台主机上传输数据时,那么这时候就需要知道对端主机的IP地址,此IP地址我们就称为目的IP地址,而我们的主机IP地址就为源IP地址。同样当对端主机收到我们主机传来的数据后,想要对我们主机做出反应,同样也需要知道我们的IP地址,在返回的过程中我们的主机IP地址就变为了目的IP地址,而对端主机就变为了源IP地址。因此一个传输的数据当中应该涵盖其源IP地址和目的IP地址,目的IP地址表明该数据传输的目的地,源IP地址作为对端主机响应时的目的IP地址。
这就好比唐僧取经,在去西天的路上被人询问时,电视剧上总是说出,“贫僧从东土大唐而来,奉唐王御旨前往西天拜佛求经“。这里大唐就是源地址,西天就目的地址。
在数据进行传输之前,会先自顶向下贯穿网络协议栈完成数据的封装,其中在网络层封装的IP报头当中就涵盖了源IP地址和目的IP地址。而除了源IP地址和目的IP地址之外,还有源MAC地址和目的MAC地址的概念。
理解源MAC地址和目的MAC地址
大部分数据的传输都是跨局域网的,数据在传输过程中会经过若干个路由器,最终才能到达对端主机。
源MAC地址和目的MAC地址是包含在链路层的报头当中的,而这里所说的MAC地址其实只在当前所处的局域网内有效,因为当数据跨网络到达另一个局域网时,其源MAC地址和目的MAC地址就需要发生变化,当数据达到路由器时,路由器会将该数据当中链路层的报头去掉,然后再重新封装一个报头,此时该数据的源MAC地址和目的MAC地址就发生了变化。
就比如上图主机1向主机2发送数据时,我们模拟一下数据的源MAC地址和目的MAC地址的变化过程:
时间点 | 源MAC地址 | 目的MAC地址 |
---|---|---|
刚开始 | 主机1的MAC地址 | 路由器A的MAC地址 |
经过路由器A之后 | 路由器A的MAC地址 | 路由器B的MAC地址 |
经过路由器B之后 | 路由器B的MAC地址 | 路由器C的MAC地址 |
经过路由器C之后 | 路由器C的MAC地址 | 路由器D的MAC地址 |
经过路由器D之后 | 路由器D的MAC地址 | 主机2的MAC地址 |
所以在数据的传输过程中是存在两套地址的
- 一套是源IP地址和目的IP地址,这两个地址在数据传输过程中基本是不会发生变化的(存在一些特殊情况,比如在数据传输过程中使用NET技术,其源IP地址会发生变化,但至少目的IP地址是不会变化的)。
- 另一套是源MAC地址和目的MAC地址,这两个地址是一直在发生变化的,数据传输的过程中路由器会进行解包和重新封装修改源MAC地址与目的MAC地址。
有了IP地址,为什么还需要MAC地址?
在这个过程中,数据的传输貌似只需要IP地址就可以保证数据传输到对应的主机上,那为什么还需要MAC地址呢?
简单来说,IP地址和MAC地址就像寄快递时的收件人地址和身份证号,它们在不同的层面工作,各自扮演着不可替代的角色。
我们可以用一个生动的比喻来理解:
想象你要从北京(主机1)寄一个包裹(数据包)到上海(主机2)的一个朋友家里。
IP地址就像是信封上的地址:
上海市浦东新区XX路XX号
。这个地址是全局的、逻辑的,它告诉全世界的邮政系统(互联网)这个包裹最终要去哪个城市、哪个街道。IP地址负责的是“最终”的、端到端的通信。MAC地址就像是运输途中每一个中转站负责人的身份证号。包裹不会直接从北京飞到上海的朋友家,它需要先送到北京的集散中心(路由器A),再由北京的集散中心发往郑州的集散中心(路由器B),再到南京(路由器C),最后到达上海的集散中心(路由器D),由上海的快递员送货上门。
在每一个中转站,工作人员并不关心包裹的最终地址是哪条路(IP地址),他们只关心:“这个包裹下一个应该交给谁?” 他们会把包裹交给下一个集散中心派来的、有特定身份证(MAC地址)的司机。
北京站的工作人员(路由器A)看到目的IP是上海,于是他把包裹交给身份证为“豫A-XXXXX”(路由器B的MAC地址) 的、开往郑州的司机。
郑州站的工作人员(路由器B)收到包裹后,同样根据路由表,把它交给身份证为“苏A-XXXXX”(路由器C的MAC地址) 的、开往南京的司机。
如此反复,直到上海的快递员(路由器D)拿着自己身份证(路由器D的MAC地址),把包裹送到最终地址(主机2的MAC地址)的手中。
认识端口号
在一台主机上我们向另一台主机发送数据时,发送数据的单位肯定不是主机,而是主机内的单个进程,两台主机之间传输数据就类似于通信,通信就是以进程为单位。所以“主机之间通信”,实际上是一种简化的说法。真正的通信主体,是运行在主机之上的进程。数据交换的最终目的并非主机本身,而是主机上运行的特定应用程序实例。
在实际场景下,两台主机上会同时启动多个进程,需要进行网络通信的多个进程可能会同时与对端主机交互。因此当数据到达对端主机后,必须要通过某种方法找到该主机上对应的进程,然后将数据交给该进程处理。而当该进程处理完数据后还要对发送端进行响应,因此对端主机也需要知道,是发送端上的哪一个进程向它发送的数据请求。再这个过程中起重要作用的就是端口号,端口号是标记那些进行跨网络通信的进程进行标识。
需要注意的是:并非所有进程都需要分配端口号。只有那些需要通过网络(包括本地回环网络)进行通信的进程才需要端口号。
- 端口号(port)是传输层协议的内容
- 端口号是一个2字节16位的整数。
- 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理。
- 一个端口号只能被一个进程占用。
所以IP+端口号的组合,就确定了在互联网上通信中主机中进程的唯一性了。
同样端口号是隶属于某台主机的,所以端口号可以在两台不同的主机当中重复,但是在同一台主机上进行网络通信的进程的端口号不能重复。此外,一个进程可以绑定多个端口号,但是一个端口号不能被多个进程同时绑定。这就好比我们同时打开多个浏览器,进行使用,那么每个浏览器页面对应的端口号也是不同的。
底层如何通过port找到对应进程的?
操作系统内部采用哈希的方式建立 “端口-进程”对应表。
数据包一到,内核就查看它的端口号,然后就会采用哈希算法,立刻去这张表里映射出这个端口号归哪个进程管,最后把数据塞给这个进程。
就像快递站根据包裹上的房间号(端口),查一下住户登记表,然后把包裹送给对应的住户(进程)。
理解 "端口号" 和 "进程ID"
我们之前在学习系统编程的时候, 学习了 pid 表示唯一一个进程; 此处我们的端口号也是唯一表示一个进程. 那么这两者之间是怎样的关系?
用定义区别就是进程的pid是标识唯一一个进程,而端口号标识的是进行网络通信的进程的唯一编号。在数学上的集合标识就是进程ID包含于端口号。
但这又反应了一个问题,那在进行网络通信时为什么不直接用PID来代替端口号(port)呢?
这是因为:
- 一方面一台机器上可能会有大量的进程,但并不是所有的进程都要进行网络通信,可能有很大一部分的进程是不需要进行网络通信的本地进程,此时PID虽然也可以标识这些网络进程的唯一性,但在该场景下就不太合适了。
- 另一方面是为了将系统和网络功能进行解耦。
这就好比进程ID是我们的身份证,只要是合法公民都会有。而端口号就好比学生的学号,工人的工号。并不是每个中国人都是工人,也不是每个中国人都是学生,只是不同的时间段会有不同的身份,过了这个时间段该身份就失效了。所以在学校中用于标记学生的唯一性使用学号比较方便,在工作时用于标记工人的唯一性使用工号比较方便。
如何理解一个进程可以绑定多个端口号,但是一个端口号不能被多个进程绑定。
我们拿10086这个例子来解释
现在我们把整整个互联网比喻为中国移动全国网络,一台电脑:一个省的移动客服中心,IP 地址:这个省客服中心的大楼地址,端口号:大楼内部的总机号码,进程:客服中心里不同的部门或团队
一个端口号不能被多个进程绑定
“10086”这个号码,是全球知名的中国移动客服号(知名端口)。
这个号码必须被固定转接到一个指定的客服团队(进程A),比如“业务办理团队”。
规则:绝对不能同时把“10086”这个号码既转给“业务办理团队”(进程A),又转给“投诉处理团队”(进程B)。
后果:如果客户拨打10086,电话交换机会懵了,不知道到底该接进哪个团队的办公室,导致呼叫失败(冲突、端口占用错误)。
一个进程可以绑定多个端口号
“业务办理团队”(进程A) 非常强大,他们可以提供多种服务。因此,客户可以通过多个不同的号码找到他们:
拨打
10086
:办理普通业务。拨打
1008611
:查询话费(这相当于绑定了第二个端口)。拨打
10085
:办理套餐升级(这相当于绑定了第三个端口)。
所有这些号码最终都转接到了同一个团队(进程A)。这就是一个进程监听多个端口。
理解源端口号和目的端口号
理解了端口号,那么源端口号和目的端口号就非常好理解了,就是在描述:数据是谁发的,要发给谁。
为了方便理解,这里举一个简单的唐僧取经的例子。
在电视剧中,路人询问唐僧要去干什么的时候,他一般都会说:“贫僧从东土大唐而来,奉唐王御旨前往西天拜佛求经“。那么如果我们现在规定唐僧改为说:“贫僧从东土大唐中大唐小区1010号而来,奉唐王御旨前往西天藏经阁8080号拜佛求经“。那么这里的大唐是源IP地址,西天就是目的IP地址,那么这里的大唐小区1010号就为源端口号,而藏经阁8080号就是目的端口号。
认识TCP协议和UDP协议
网络协议栈是贯穿整个体系结构的,在应用层、操作系统层和驱动层各有一部分。当我们使用系统调用接口实现网络数据通信时,不得不面对的协议层就是传输层,而传输层最典型的两种协议就是TCP协议和UDP协议。
我们先对TCP与UDP先有一个直观的认识,后面再详谈一下其细节。
TCP协议
TCP协议又叫做传输控制协议,其英文名为:Transmission Control Protocol。其是一种面向连接的、可靠的、基于字节流的传输层通信协议。
TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输。其次TCP协议还是可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP协议就会采取相应的措施,然后进行弥补。
UDP协议
UDP协议叫做用户数据报协议(User Datagram Protocol),UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。
使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也表明UDP是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议是不会做任何反应的,甚至来说是不知道。
既然UDP协议是不可靠的,那为什么还要有UDP协议的存在?
UDP的“不可靠”并非它的缺点,而是它的设计特性。正是这种“不可靠”带来了简单、高效和低延迟的巨大优势,使得它在许多场景下比可靠的TCP协议更具不可替代性。
我们可以用一个比喻来理解:
TCP 就像寄送一封 挂号信。你需要排队、填单子、付更多钱。邮局会给你回执,如果信没送到,你会知道,并且可以重寄。过程可靠但繁琐、慢。
UDP 就像寄送一张 明信片。你扔进邮筒就不管了。它可能丢失、可能乱序到达,你也不知道对方收没收到。但过程极其简单、快捷、便宜。
为什么我们需要“明信片”(UDP)?因为很多应用场景中,速度比可靠性更重要,或者应用层自己就能处理可靠性问题。
所以UDP的不可靠也带来了很多的优点与方便,比如说:
- 因为不需要连接,所以速度极快,延迟极低,就比如说我们打游戏时如果突然一个丢包导致的延迟和重传可能会让玩家在关键时刻“卡住”,此时使用UDP可以等网络好时,立马接上游戏进度,继续玩,几乎不影响,但如果是TCP,那么还需要重新建立连接,等恢复时已经“game over”了。所以游戏宁愿接收一个“你已经死了”的新状态,也不愿等待重传“你刚才中了一枪”的旧状态。
因无需连接状态,所以操作系统无需维护连接状态,以至于服务器资源消耗小。
所以UDP适用一些即使丢包也无所谓的一些网络场景,比如说:实时视频通话,在线游戏,DNS查询,直播等。
而TCP则适用一些要保证数据的完整性的一些网络场景,比如说:视频,银行交易,文件下载等。
网络字节序
我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?
- 大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
- 小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。
如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。但如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的。
比方说,现在两台主机之间在进行网络通信,其中发送端是小端机,而接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存。
所以网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。
由于发送端与接收端分别采用小端(Little-Endian)和大端(Big-Endian)两种不同的字节存储顺序,即便相同的字节序列在不同的主机上也会被解释为不同的数值。
假设发送端欲发送一个32位整数值 0x11223344
。在小端模式下,该值在内存中的存储顺序为低地址到高地址依次是 0x44
、0x33
、0x22
、0x11
。发送端依照内存地址从低到高的顺序将该字节序列发出。
接收端按相同顺序将字节流存入接收缓冲区,得到的字节序列同样为 44 33 22 11
。然而,由于接收端采用大端模式,其在解释该数据时会认为低地址存放的是最高有效字节(MSB),因此会将此序列解读为 0x44332211
。
最终,接收端识别到的数据(0x44332211
)与发送端原本欲发送的数据(0x11223344
)完全不同。这种差异并非源于传输错误,而是由于双方对多字节数据的内存解释方式不同所导致的——这就是典型的大小端偏差引起的数据识别错误。
所以TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。不管这台主机是大端机还是小端机,都会按照这个TCP/IP规定的网络字节序来发送/接收数据。如果当前发送主机是小端,就需要先将数据转成大端。否则就忽略,直接发送即可。
其中,所有的大小端的转化工作是由操作系统来完成的,该操作属于通信细节,不过也有部分的信息需要我们自行进行处理,比如端口号和IP地址。
为什么网络字节序采用的是大端?而不是小端?
一方面
当时参与早期网络建设和标准制定的机器,如Sun Microsystems的工作站、IBM的大型机和Motorola的68000系列处理器,都采用的是大端序。
因此,对于这些平台的开发者来说,大端序是“自然”的字节序。将网络字节序定为大端序,符合当时主流开发环境的习惯,简化了编程和调试。
另一方面
大端序被称为“人类可读的”格式。
例如,IP地址
192.168.1.1
用十六进制表示是C0 A8 01 01
。在大端系统中,这个值在内存或网络包中的存储顺序就是
C0 A8 01 01
,从左到右,地址递增,和我们书写、阅读的顺序完全一致。在小端系统中,存储顺序则是
01 01 A8 C0
,顺序是反的。
这种一致性使得人工读取网络抓包数据(如Wireshark抓包)更加直观。工程师可以直接按照传输顺序读出字节,并正确地解读出协议字段的值,而无需在脑中先进行字节翻转。这在网络调试的早期非常重要。
但这也并不是说明小端不好。反而在事实上大多数现代CPU(如x86、x86-64架构)都是小端序。
小端序类型转换灵活,数学运算方便。
网络字节序与主机字节序之间的转换
为使网络程序具有可移植性,使同样的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);
- 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
- 例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回。
- 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
字符串IP VS 整数IP
IP地址的表达方式分为两种,分别是:
- 字符串IP:类似于
192.168.233.123
这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址。 - 整数IP:IP地址在进行网络传输时所用的形式,用一个32位的整数来表示IP地址。
我们最为了解的就是字符串IP,另一个不了解的就是整数IP。
再网络传播中,因为每一时刻都会有非常大量的数据在网络上传输,那么为了提高效率,就要想办法进行对每次的网络传输进行尽可能的优化,那么其中一种方法就是整数IP的设计。
在网络传播中,字符串的点分十进制IP的形式进行IP地址的传送,那么此时一个IP地址至少就需要15个字节,但实际并不需要耗费这么多字节。
IP地址实际可以划分为四个区域,其中每一个区域的取值都是0~255,而这个范围的数字只需要用8个比特位就能表示,因此我们实际只需要32个比特位就能够表示一个IP地址。其中32位的整数的每一个字节对应的就是IP地址中的某个区域,我们将IP地址的这种表示方法称之为整数IP,此时表示一个IP地址只需要4个字节。
所以整数IP的存在就大大提高了数据在网络中传输效率。
字符串IP和整数IP相互转换的方式
转化的方式有很多,其中经典的就是联合体。联合体内定义两个成员分别是位段A,与自定义结构体维护字符串IP。
由于联合体(union)的内存空间由其成员共享,我们可以通过以下方式设置和读取IP地址:
设置IP地址:
若要以整数形式设置IP,可直接将整数值赋给联合体的第一个成员。
若要以字符串形式设置IP,需先将字符串按点分十进制格式拆分为四个部分,将每个部分转换为对应的二进制数值,再依次赋给联合体中第二个成员的字段 p1、p2、p3 和 p4。
读取IP地址:
若要获取整数形式的IP,直接读取联合体的第一个成员即可。
若要获取字符串形式的IP,需依次读取联合体中第二个成员的字段 p1、p2、p3 和 p4,将每个部分转换为字符串后,以点分十进制格式拼接起来。
需要注意的是,在操作系统内部,实际是通过位段(bit-field)和枚举(enum)等机制完成字符串IP与整数IP之间的相互转换的。
所以在网络传播中,发送端在发数据前需要将字符串IP调用函数转化为整数IP。
在接收端,通常需要将收到的整数形式的IP地址再转换回字符串形式。
特别补充说明:在基于 IPv4 的 socket 网络编程中,
sockaddr_in
结构体中的成员struct in_addr sin_addr
用于表示 32 位的 IP 地址。然而,我们通常更习惯使用点分十进制的字符串形式来表示 IP 地址(例如"192.168.1.1"
)。
那么对于此二者的转换,也不需要我们自己去编写代码,其也提供了相应的函数。
inet_addr函数
将字符串IP转换成整数IP的函数叫做inet_addr,该函数的函数原型如下:
in_addr_t inet_addr(const char *cp);
该函数使用起来非常简单,我们只需传入待转换的字符串IP,该函数返回的就是转换后的整数IP。除此之外,inet_aton函数也可以将字符串IP转换成整数IP,不过该函数使用起来没有inet_addr简单。
inet_aton函数
int iner_aton(const char *strptr, struct in_addr *addrptr);
inet_ntoa函数
将整数IP转换成字符串IP的函数叫做inet_ntoa,该函数的函数原型如下:
char *inet_ntoa(struct in_addr in);
需要注意的是,传入inet_ntoa函数的参数类型是in_addr
,因此我们在传参时不需要选中in_addr
结构当中的32位的成员传入,直接传入in_addr
结构体即可。
socket编程接口
在英文中,“Socket”一词原意为“插座”。一个插座上通常设有不同规格的插孔,只需将匹配的插头插入对应插孔,即可建立连接并传输电流。
这个概念被巧妙地延伸至网络通信中。Socket(套接字) 正如一个通用的通信插座,为网络中的数据流提供了标准的连接接口。
socket 常见API
创建套接字:(TCP/UDP,客户端+服务器)
int socket(int domain, int type, int protocol);
绑定端口号:(TCP/UDP,服务器)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
监听套接字:(TCP,服务器)
int listen(int sockfd, int backlog);
接收请求:(TCP,服务器)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
建立连接:(TCP,客户端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockaddr结构
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及后面要讲的UNIX Domain Socket。然而,各种网络协议的地址格式并不相同。
sockaddr结构的出现
套接字编程不仅支持跨网络的进程间通信,还有支持本地的进程间通信(域间套接字编程)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地的就不需要,所以就有了
struct sockaddr_in和struct sockaddr_un这两种套接字编程。其中struct sockaddr_in是网络套接字编程,用于跨网络的进程间同行。而struct sockaddr_un域间套接字编程,用于本地间进程间的通信。
但在实际设计上,其实是设计了三个套接字编程,多的就是struct sockaddr,其称为原始套接字编程。其是为了让套接字编程的网络通信和本地通信能够使用同一套函数接口,该结构体与
struct sockaddr_in和struct sockaddr_un的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。
此时当我们在传递在传参时,就不用传入struct sockaddr_in和struct sockaddr_un这样的结构体,而统一传入struct sockaddr这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API内部就可以提取struct sockaddr的前16比特位来进行识别是网络通信还是本地通信。此时就使用struct sockaddr进行了统一,大大降低了编码难度。
注意: 实际我们在进行网络通信时,定义的还是struct sockaddr_in这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为struct sockaddr*罢了。
想必也注意到了为什么会有那么多的本地通信的方式,其实简单原因就是一家造一个,每家都有自己的产品。
还有一点就是为什么不使用void*,而是在单独设计一个struct sockaddr,然后强转为
struct sockaddr*,这是因为那时候C语言还不支持void*,到后来支持了又不好改了。底层代码已经使用struct sockaddr*好几年了,再改好多都要改,所以不如不改。
补充说明:
- IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型,16位端口号和32位IP地址。因为IPv6拥有128位的地址长度,是IPv4(32位)的4倍,s
truct sockaddr_in
这个结构体了。它会完全无法容纳IPv6的128位地址。Socket API引入了新的通用和专用结构体struct sockaddr_in6
。 - IPv4,IPv6地址类型分别定义为常数AF_INET,AF_INET6。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。
- socket API可以都用struct sockaddr *类型表示,在使用的时候需要强制转化成sockaddr_in;这样的好处是程序的通用性,可以接收IPv4,IPv6,以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数。
socket函数底层
网络协议栈采用分层结构,以TCP/IP四层模型为例,从顶至底依次为应用层、传输层、网络层与数据链路层。目前我们所编写的代码属于用户级代码,亦即处于应用层进行开发。因此,我们所调用的实际是下层(传输层、网络层与数据链路层)所提供的接口。其中,传输层与网络层的功能主要由操作系统内核实现,这意味着我们在应用层所调用的接口本质上都属于系统调用接口。
那么其底层是做了什么处理呢?
在学习操作系统中的文件系统时,我们了解到,每个进程在创建时,操作系统都会为其默认打开三个标准文件流:
标准输入(stdin),文件描述符为 0
标准输出(stdout),文件描述符为 1
标准错误(stderr),文件描述符为 2
在系统层面,每个进程都对应一个进程控制块(通常称为 PCB,在 Linux 中具体实现为 task_struct
结构体),其中包含一个管理打开文件的信息结构(如 files_struct
)。该结构中维护了一个文件描述符表,通常以数组形式实现(如 fd_array[]
)。文件描述符实际上就是这个数组的下标索引,而默认打开的上述三个文件流就分别对应于下标 0、1 和 2。
同样当我们调用 socket
函数创建套接字时,实质上相当于打开了一个“网络文件”。该操作在内核中会分配并初始化一个对应的 struct file
结构体,用于管理该套接字的文件状态和信息。该结构体会被链接到当前进程所维护的打开文件链表(或树)中,同时其地址会被存入进程文件描述符表 fd_array
数组中当前最小可用的位置。
假设当前最早可用的文件描述符是 3,那么内核会将新创建的 struct file
结构体的首地址填入 fd_array[3]
。此时,该数组下标为 3 的指针就指向了这个“网络文件”的内核结构体。最终,socket
函数将文件描述符 3 作为返回值返回给用户进程,此后用户便可通过该描述符对此套接字进行读写等操作。
其中socket函数的返回值就是上面所说的文件描述符,如果创建失败,那么就返回-1。
每个 struct file
结构体用于维护一个已打开文件的上下文信息,主要包括该文件的属性、操作方法以及相关的缓冲区等。
文件属性:由内核中的
struct inode
结构体负责维护,其中包含了文件的元数据,如权限、大小、时间戳等基础属性。操作方法:通过一个函数指针集合定义,具体由
struct file_operations
结构体实现。该结构体中包含了诸如read
、write
等针对文件的操作接口,用于实现对文件的各种读写和控制行为。文件缓冲区:用于暂存读写数据。对于普通文件,该缓冲区通常用于缓存与磁盘之间的数据;而对于使用
socket
创建的“网络文件”,其缓冲区则用于与网卡进行数据交互。
下一篇文章就用本篇文章的知识,简单的用代码实现一个简易版的UDP协议的网络程序。