目录
源IP地址和目的IP地址
源MAC地址和目的MAC地址
源端口号和目的端口号
端口号 VS 进程ID
TCP协议和UDP协议
网络字节序
字符串IP和整数IP相互转换
查看当前网络的状态
socket编程接口
socket常见API
创建套接字
绑定端口号
发送数据
接收数据
sockaddr结构
源IP地址和目的IP地址
一、定义
- 源 IP 地址(Source IP Address):它是发送数据的设备(如计算机、服务器等)在网络中的唯一标识。就像是发送信件时的寄件人地址,在网络通信中,它用于标识数据的来源。
- 目的 IP 地址(Destination IP Address):这是接收数据的设备在网络中的唯一标识,类似于收件人地址。它告诉网络基础设施,数据最终要传送到哪里。
二、在网络通信中的作用
- 数据传输导向:在互联网这样庞大的网络环境中,数据通过多个网络节点(如路由器、交换机等)进行传输。
- 源 IP 地址和目的 IP 地址就像指南针一样,引导数据包从源设备通过复杂的网络路径最终到达目的设备。
- 例如,当你在浏览器中访问一个网站时,你的计算机(源设备)的源 IP 地址和网站服务器(目的设备)的目的 IP 地址就确定了数据传输的起始点和终点。
- 路由决策依据:路由器是网络中的关键设备,用于将数据包从一个网络转发到另一个网络。
- 路由器根据数据包的目的 IP 地址来决定将其转发到哪个下一个网络节点。
- 它会查询路由表,路由表中记录了目的 IP 地址的网络范围以及对应的转发接口。
- 例如,如果一个数据包的目的 IP 地址属于某个特定的外部网络,路由器就会将其从连接外部网络的接口转发出去。
三、举例说明
- 假设你在自己的家中通过 Wi - Fi 连接的笔记本电脑访问一家在线购物网站。
- 你的笔记本电脑(假设其 IP 地址是 192.168.1.10)就是源设备,192.168.1.10 是源 IP 地址。
- 在线购物网站的服务器(假设其 IP 地址是 104.27.189.99)是目的设备,104.27.189.99 是目的 IP 地址。
- 当你在浏览器中输入网站的网址并发送请求时,数据从你的笔记本电脑(源)出发。
- 这个请求数据包包含了源 IP 地址(192.168.1.10)和目的 IP 地址(104.27.189.99)。
- 数据包首先到达你家中的无线路由器,路由器根据目的 IP 地址判断这个数据包需要发送到互联网服务提供商(ISP)的网络,然后经过一系列的网络设备(其他路由器等)的转发,最终到达购物网站的服务器(目的)。
- 在整个过程中,源 IP 地址和目的 IP 地址始终保持不变,确保数据能够准确地发送和接收。
源MAC地址和目的MAC地址
- 源 MAC 地址(Source MAC Address):它是发送数据的网络设备(如计算机网卡、网络交换机端口等)在局域网(LAN)中的物理地址。MAC 地址也被称为硬件地址,是由网络设备制造商固化在设备中的一个 48 位二进制数,通常表示为 12 位的十六进制数,例如 “00 - 1B - 44 - 11 - 3A - 0C”。这个地址在全球范围内是唯一的,用于在局域网环境中标识数据的发送端。
- 目的 MAC 地址(Destination MAC Address):它是接收数据的网络设备在局域网中的物理地址,同样是一个 48 位的二进制数,用于在局域网中标识数据的目标接收端。
有些数据的传输都是跨局域网的,数据在传输过程中会经过若干个路由器,最终才能到达对端主机。
源MAC地址和目的MAC地址是包含在链路层的报头当中的,而MAC地址实际只在当前局域网内有效,因此当数据跨网络到达另一个局域网时,其源MAC地址和目的MAC地址就需要发生变化,因此当数据达到路由器时,路由器会将该数据当中链路层的报头去掉,然后再重新封装一个报头,此时该数据的源MAC地址和目的MAC地址就发生了变化。
因此数据在传输的过程中是有两套地址:
- 一套是源IP地址和目的IP地址,这两个地址在数据传输过程中基本是不会发生变化的(存在一些特殊情况,比如在数据传输过程中使用NET技术,其源IP地址会发生变化,但至少目的IP地址是不会变化的)。
- 另一套就是源MAC地址和目的MAC地址,这两个地址是一直在发生变化的,因为在数据传输的过程中路由器不断在进行解包和重新封装。
源端口号和目的端口号
两台主机之间通信的目的不仅仅是为了将数据发送给对端主机,而是为了访问对端主机上的某个服务(某个进程)。比如我们在用百度搜索引擎进行搜索时,不仅仅是想将我们的请求发送给对端服务器,而是想访问对端服务器上部署的百度相关的搜索服务。
其实,这种在网络中通信的本质:就是进程间通信,只是在不同的机器上进程间通信 (跨网络的进程间通信),我们把这种通信称做 套接字通信(socket通信)
socket通信本质上就是两个进程之间在进行通信,只不过这里是跨网络的进程间通信。比如逛京东和刷抖音的动作,实际就是手机上的京东进程和抖音进程在和对端服务器主机上的京东服务进程和抖音服务进程之间在进行通信。
因此进程间通信的方式除了管道、消息队列、信号量、共享内存等方式外,还有套接字,只不过前者是不跨网络的,而后者是跨网络的。
那我们是如何去实现我们的 socket通信 的呢?
- 先把数据能够到达对方的机器
- 再在机器中能找到指定的进程,同一台机器也可能会同时存在多个正在进行跨网络通信的进程,因此当数据到达对端主机后,必须要通过某种方法找到该主机上对应的服务进程 !
端口号(port)的作用实际就是标识一台主机上的一个进程:
- 端口号是传输层协议的内容
- 端口号是一个2字节16位的整数
- 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理
- 一个端口号只能被一个进程占用
当数据在传输层进行封装时,就会添加上对应源端口号和目的端口号的信息。这时通过源IP地址+源端口号就能够在网络上唯一标识发送数据的进程,通过目的IP地址+目的端口号就能够在网络上唯一标识接收数据的进程,此时就实现了跨网络的进程间通信。
注意:
- 因为端口号是属于某台主机的,所以端口号可以在两台不同的主机当中重复,但是在同一台主机上进行网络通信的进程的端口号不能重复。
- 所以,一个进程可以绑定多个端口号,但是一个端口号不能被多个进程同时绑定。
端口号 VS 进程ID
端口号(port)和 进程ID(PID)的作用都是标识一台主机上唯一的一个进程,那在进行socket通信时为什么不直接用 进程ID 呢?
- 进程ID(PID)是用来标识系统内所有进程的唯一性的,它是属于系统级的概念
- 端口号(PORT)是用来标识需要对外进行网络数据请求的进程的唯一性的,它是属于网络的概念
- 一台机器上可能会有大量的进程,但并不是所有的进程都要进行网络通信,可能有很大一部分的进程是不需要进行网络通信的本地进程,此时PID虽然也可以标识这些网络进程的唯一性,但在该场景下就不太合适了
- 进程管理 和 网络 解耦,因为不同平台的 进程ID 的分配可能有所不同,可能会出现不兼容的问题
底层如何通过端口号(PORT)找到对应进程的?
机器底层采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系,当底层拿到端口号时就可以直接执行对应的哈希算法,然后就能够找到该端口号对应的进程。
TCP协议和UDP协议
网络协议栈是贯穿整个体系结构的,在应用层、操作系统层和驱动层各有一部分。当我们使用系统调用接口实现网络数据通信时,面对的协议层就是传输层,而传输层最典型的两种协议就是TCP协议和UDP协议
TCP协议
TCP协议叫做传输控制协议(Transmission Control Protocol),TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。
TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输。其次,TCP协议是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP协议都有对应的解决方法。
UDP协议
UDP协议叫做用户数据报协议(User Datagram Protocol),UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。
使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议本身是不知道的。
既然UDP协议是不可靠的,那为什么还要有UDP协议的存在?
TCP协议可靠是需要我们做更多的工作的,TCP协议虽然是一种可靠的传输协议,但这一定意味着TCP协议在底层需要做更多的工作,因此TCP协议底层的实现是比较复杂的,我们不能只看到TCP协议面向连接可靠这一个特点,我们也要能看到TCP协议对应的缺点。
UDP协议虽然是一种不可靠的传输协议,但这一定意味着UDP协议在底层不需要做过多的工作,因此UDP协议底层的实现一定比TCP协议要简单,UDP协议虽然不可靠,但是它能够快速的将数据发送给对方,虽然在数据在传输的过程中可能会出错。
编写网络通信代码时具体采用TCP协议还是UDP协议,完全取决于上层的应用场景。如果应用场景严格要求数据在传输过程中的可靠性,此时我们就必须采用TCP协议,如果应用场景允许数据在传输出现少量丢包,那么我们肯定优先选择UDP协议,因为UDP协议足够简单。
注意: 一些优秀的网站在设计网络通信算法时,会同时采用TCP协议和UDP协议,当网络流畅时就使用UDP协议进行数据传输,而当网速不好时就使用TCP协议进行数据传输,此时就可以动态的调整后台数据通信的算法。
网络字节序
计算机在存储数据时是有大小端的概念的:
- 大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
- 小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。
如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的
但是如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是相反的。
例如,现在两台主机之间在进行网络通信,其中发送端是小端机,而接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的,就会出现想要发送的数据是相反的。
因此TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据。
- 如果发送端是小端,需要先将数据转成大端,然后再发送到网络当中
- 如果发送端是大端,则可以直接进行发送
- 如果接收端是小端,需要先将接收到数据转成小端后再进行数据识别
- 如果接收端是大端,则可以直接进行数据识别
为什么网络字节序采用的是大端?而不是小端?
网络字节序采用的是大端,而主机字节序一般采用的是小端,那为什么网络字节序不采用小端呢?如果网络字节序采用小端的话,发送端和接收端在发生和接收数据时就不用进行大小端的转换了。
说法一: TCP在Unix时代就有了,以前Unix机器都是大端机,因此网络字节序也就采用的是大端,但之后人们发现用小端能简化硬件设计,所以现在主流的都是小端机,但协议已经不好改了。
说法二: 大端序更符合现代人的读写习惯。
网络字节序与主机字节序之间的转换
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,系统提供了四个函数,可以通过调用以下库函数实现网络字节序和主机字节序之间的转换。
#include <arpa/inet.h>
// 将一个 32 位的主机字节序整数转换为网络字节序
uint32_t htonl(uint32_t hostlong);
// 将一个 16 位的主机字节序整数转换为网络字节序
uint16_t htons(uint16_t hostshort);
// 将一个 32 位的网络字节序整数转换为主机字节序
uint32_t ntohl(uint32_t netlong);
// 将一个 16 位的网络字节序整数转换为主机字节序
uint16_t ntohs(uint16_t netshort);
- 函数名当中的h表示主机字节序(host),n表示网络字节序(network),l表示32位长整数,s表示16位短整数
- 例如htonl表示将32位长整数从主机字节序转换为网络字节序
- 如果主机是小端字节序,则这些函数将参数做相应的大小端转换然后返回
- 如果主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回
字符串IP和整数IP相互转换
IP地址的表现形式有两种:
- 字符串IP:类似于192.168.1.1这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址
- 整数IP:IP地址在进行网络传输时所用的形式,用一个32位的整数来表示IP地址
192.168.1.1
为例,192
的二进制是11000000
,168
的二进制是10101000
,1
的二进制是00000001
,连接起来得到11000000101010000000000100000001
,转换为十进制就是3232235777
字符串IP和整数IP的相互转换函数
// 字符串IP 转换成 整数IP
in_addr_t inet_addr(const char* cp);
// 整数IP转换成字符串IP
char* inet_ntoa(struct in_addr in);
查看当前网络的状态
netstat 命令
netstat
常用选项说明:
- -n:直接使用IP地址,而不通过域名服务器。
- -l:显示监控中的服务器的Socket。
- -t:显示TCP传输协议的连线状况。
- -u:显示UDP传输协议的连线状况。
- -p:显示正在使用Socket的程序识别码和程序名称
netstat命令显示的信息中:
- Proto 表示协议的类型
- Recv-Q 表示网络接收队列
- Send-Q 表示网络发送队列
- Local Address 表示本地地址
- Foreign Address 表示外部地址
- State 表示当前的状态
- PID 表示该进程的进程ID
- Program name 表示该进程的程序名称。
- 其中Foreign Address写成0.0.0.0:*表示任意IP地址、任意的端口号的程序都可以访问当前进程。
socket编程接口
socket常见API
头文件
#include <sys/types.h>
#include <sys/socket.h>
创建套接字:(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);
发送数据:
// 用于面向连接的套接字,将数据发送到已连接的对等方
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// 用于无连接的套接字或未连接的面向连接套接字,将数据发送到指定地址
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
接收数据:
// 用于面向连接的套接字,从已连接的对等方接收数据
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
// 用于无连接的套接字或未连接的面向连接套接字,接收数据并获取发送方地址
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
关闭套接字:
#include <unistd.h>
// 用于关闭套接字,释放资源
int close(int fd);
#include <sys/socket.h>
// 可以更灵活地关闭套接字的某些功能,如只关闭发送或接收功能
int shutdown(int sockfd, int how);
创建套接字
创建套接字(socket)的函数,如下:
int socket(int domain, int type, int protocol);
参数:
- domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于struct sockaddr结构的前16个位
- 如果是本地通信就设置为AF_UNIX
- 如果是网络通信就设置为AF_INET(IPv4)或 AF_INET6(IPv6)。
- type:创建套接字时所需的服务类型。其中最常见的服务类型是SOCK_STREAM和SOCK_DGRAM
- 如果是基于UDP 的网络通信,我们采用的就是SOCK_DGRAM,叫做用户数据报服务,
- 如果是基于TCP 的网络通信,我们采用的就是SOCK_STREAM,叫做流式套接字,提供的是流式服务。
- protocol:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。
返回值:
- 套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。
socket函数属于什么类型的接口?
网络协议栈是分层的,按照TCP/IP四层模型来说,自顶向下依次是应用层、传输层、网络层和数据链路层。
我们现在所写的代码都叫做用户级代码,也就是说我们是在应用层编写代码,因此我们调用的实际是下三层的接口,而传输层和网络层都是在操作系统内完成的,也就意味着我们在应用层调用的接口都叫做系统调用接口
socket函数是被谁调用的?
socket这个函数是被程序调用的,但并不是被程序在编码上直接调用的,而是程序编码形成的可执行程序运行起来变成进程,当这个进程被CPU调度执行到socket函数时,然后才会执行创建套接字的代码,也就是说socket函数是被进程所调用的
socket函数底层如何实现的
socket函数是被进程所调用的,而每一个进程在系统层面上都有一个进程地址空间PCB(task_struct)、文件描述符表(files_struct)以及对应打开的各种文件。
而文件描述符表里面包含了一个数组fd_array,其中数组中的0、1、2下标依次对应的就是标准输入、标准输出以及标准错误。
当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户。
每一个struct file结构体中包含的就是对应打开文件各种信息,比如文件的属性信息、操作方法以及文件缓冲区等
- 文件对应的属性在内核当中是由struct inode结构体来维护的
- 文件对应的操作方法实际就是一堆的函数指针(比如read*)在内核当中就是由struct file_operations结构体来维护的
- 文件缓冲区对于打开的普通文件来说对应的一般是磁盘,但对于现在打开的“网络文件”来说,这里的文件缓冲区对应的就是网卡
对于一般的普通文件来说,当用户通过文件描述符将数据写到文件缓冲区,然后再把数据刷到磁盘上就完成了数据的写入操作。
而对于现在socket函数打开的“网络文件”来说,当用户将数据写到文件缓冲区后,操作系统会定期将数据刷到网卡里面,而网卡则是负责数据发送的,因此数据最终就发送到了网络当中。
绑定端口号
绑定的函数叫做bind,该函数的函数原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
- sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符
- addr:网络相关的属性信息,包括协议家族、IP地址、端口号等
- addrlen:传入的addr结构体的长度
返回值:
- 绑定成功返回0,绑定失败返回-1,同时错误码会被设置
在绑定时需要将网络相关的属性信息(包括协议家族、IP地址、端口号等)填充到一个结构体当中,然后将该结构体作为bind函数的第二个参数进行传入,这实际就是struct sockaddr_in
结构体
struct sockaddr_in结构体
当中的成员如下:
- sin_family:表示协议家族
- 如果是本地通信就设置为 AF_UNIX
- 如果是网络通信就设置为 AF_INET(IPv4)或 AF_INET6(IPv6)
- sin_port:表示端口号,是一个16位的整数
- sin_addr:表示IP地址,是一个32位的整数
- sin_addr结构体中就一个成员(s_addr),该成员就是一个32位的整数,IP地址实际就是存储在这个整数当中的
发送数据
send
用于面向连接的套接字,将数据发送到已连接的对等方sendto
用于无连接的套接字或未连接的面向连接套接字,将数据发送到指定地址
// 用于面向连接的套接字,将数据发送到已连接的对等方
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// 用于无连接的套接字或未连接的面向连接套接字,将数据发送到指定地址
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
:控制发送操作的标志,如MSG_DONTWAIT
(不阻塞)等。dest_addr
(仅sendto
):指向接收方地址的指针。addrlen
(仅sendto
):接收方地址结构体的长度。
返回值:
- 成功时:返回实际发送的字节数。这个数字可能小于
len
,表示只发送了部分数据。这通常发生在以下情况:- 发送缓冲区已满,无法一次性发送全部数据,需要多次调用
send
函数将剩余数据发送完。
- 发送缓冲区已满,无法一次性发送全部数据,需要多次调用
- 失败时:返回 -1
接收数据
recv
用于面向连接的套接字,从已连接的对等方接收数据recvfrom
用于无连接的套接字或未连接的面向连接套接字,接收数据并获取发送方地址
// 用于面向连接的套接字,从已连接的对等方接收数据
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
// 用于无连接的套接字或未连接的面向连接套接字,接收数据并获取发送方地址
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
参数:
sockfd
:套接字描述符。buf
:指向接收数据的缓冲区的指针。len
:缓冲区的长度。flags
:控制接收操作的标志,如MSG_DONTWAIT
(不阻塞)等。src_addr
(仅recvfrom
):指向发送方地址的指针。addrlen
(仅recvfrom
):指向发送方地址结构体长度的指针。
返回值:
- 成功时:返回接收到的字节数,如果连接已经正常关闭,返回值为 0,表示对方已关闭连接。
- 失败时:返回 -1
sockaddr结构
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体:
- sockaddr_in 结构体是用于跨网络通信的
- sockaddr_un 结构体是用于本地通信的
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockeaddr结构体,该结构体与sockaddr_in和sockaddr_un的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族
注意: 实际我们在进行网络通信时,定义的还是sockaddr_in这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为sockaddr* 了。
为什么没有用 void* 代替 struct sockaddr* 类型?
我们可以将这些函数的 struct sockaddr* 参数类型改为 void*,此时在函数内部也可以直接指定提取头部的16个比特位进行识别,最终也能够判断是需要进行网络通信还是本地通信,那为什么还要设计出sockaddr这样的结构呢?
实际在设计这一套网络接口的时候C语言还不支持void*,于是就设计出了sockaddr这样的解决方案。并且在C语言支持了void*之后也没有将它改回来,因为这些接口是系统接口,系统接口是所有上层软件接口的基石,系统接口是不能轻易更改的,否则引发的后果是不可想的,这也就是为什么现在依旧保留sockaddr结构的原因。