Socket编程核心概念详解:IP、端口、TCP/UDP与网络字节序
本文深入解析了Socket编程的四大核心基石:IP地址负责在网络中唯一标识一台设备;端口号则在该设备上标识具体的应用程序进程。TCP和UDP是两种主要的通信协议,前者提供可靠、面向连接的传输,后者则提供高效、无连接的传输。最后,网络字节序(大端序)是不同系统间数据通信时必须遵守的统一格式规则,确保了数据的正确解读。掌握这四者,是理解和编写任何网络应用程序的基础。
一、Socket编程预备
1. 理解源IP地址和目的IP地址
我们说过IP地址是用来标识主机的唯一性的,但是这里我们要思考一个问题:数据传输到主机是目的吗?当然不是的,两台主机之间通信是没有任何意义的,数据是要给人看的。
但是人是怎么看到数据的?通过特定的进程看到数据的。比如:人要聊天就可以通过QQ或者微信,浏览网页信息就可以通过浏览器。
换句话说,进程是人在系统中的代表,只要把数据给了进程,人就相当于拿到了数据。所以,数据传输到主机不是目的,而是手段。到达主机内部,再交给主机内的进程才是目的。
所以网络通信的本质是进程之间在通信。
那么,新的问题又来了,系统内会有许多进程,数据到达目标主机后,怎么转发给目标进程?
这就要说到下一个话题上了:端口号。
2. 端口号
2.1 认识端口号
端口号(port)是传输层协议的内容。
. 端口号是一个2字节,16位的整数。
. 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理。
.`` IP地址+端口号能够标识网络上的某一台主机上的某一个进程。
IP地址标识主机的唯一性,端口号标识进程的唯一性,所以IP地址+端口号就能够标识某台主机上的某一个进程了。
. 一个端口号只能被一个进程占用。
大家有没有疑问呢,在系统里,我们也有可以标识进程唯一性的pid,在网络这里为什么还要有端口号呢?
没错,其实不用端口号标识进程的唯一性也是可以的,使用pid就行。但是不想这样做,为什么呢?举个例子大家就明白了。
大家都有身份证吧!身份证也可以用来标识一个人的唯一性吧,为什么在公司里,你还要有编号呢?如果你的身份证信息发生了改变,那么公司就要重新写入你的信息来标识你的唯一性,那这岂不是太麻烦了。所以,这样做的目的本质就是为了解耦。身份证信息的改变不会影响你在公司里编号标识你的唯一性。
现在,就明白为什么要有端口号了吧!那么,一个端口号只能标识一个进程,那一个进程可不可以有多个端口号呢?
答案是可以的。这就好比在学校里你也会有自己的学号,用学号来标识你的唯一性,虽然在学校里,一个学生只有一个学号,但是从理论上来说,一个学生可不可以有多个学号呢?是可以的。
我们要的是用学号来唯一性的标识学生,只要能够达到这个目的就可以。同理,一个进程也可以有多个端口号,我们要的是用端口号来唯一性的标识进程,达到这个目的就可以。
不知道大家有没有新的疑问呢?我们该如何理解,通过端口号找到目标进程的过程?
通过端口号找到进程,其实就是通过端口号找到PCB。在OS里维护着一张哈希表,里面存的就是端口号与PCB的映射关系,我们可以通过哈希值进行取模运算(哈希下标),找到对应的进程。
哈希值就是端口号经过哈希函数计算得到的一个结果。
2.2 端口号的范围划分
0-1023:知名端口号,HTTP,FTP,SSH等这些广为使用的应用层协议,它们使用的端口号都是固定的。
1024-65535(2^16 - 1):操作系统动态分配的端口号,客户端程序的端口号,就是由操作系统从这个范围分配的。
这就好比现实生活中有些电话号码也是特定的,比如:110,119,120这些电话号码就相当于知名端口号,已经“名花有主”了。
要想使用这些知名端口号,也不是不可以,如果知名端口号没有被使用,可以通过提高权限(sudo)来使用这些端口号。
2.3 理解Socket
综上,IP地址用来标识网络上唯一的一台主机,port用来标识该主机上唯一的一个网络进程。
IP+Port就能表示互联网中唯一的一个进程。
通信的时候,本质是两个互联网进程代表人来进行通信,{srclp,srcPort,dstlp,dstPort}这样的4元组就能标识互联网中唯二的两个进程。
我们把ip+port叫做套接字socket。
3. 认识TCP、UDP协议
我们知道传输层是属于内核的,那么我们要通过网络协议栈进行通信,就必须调用传输层提供的系统调用,来进行网络通信。
认识TCP(Transmission Control Protocol传输控制协议)协议
. 传输层协议
. 有连接
. 可靠传输
. 面向字节流
认识UDP(User Datagram Protocol用户数据报协议)协议
. 传输层协议
. 无连接
. 不可靠传输
. 面向数据报
这里我们对于TCP、UDP做简单了解。可以看到TCP属于有连接,UDP属于无连接,那么什么是有连接,无连接呢?
举一个简单的例子来理解,比如人们在打电话时,首先第一句不是说明自己打电话的来意,而是喂喂喂这样的话语,表明大家在打招呼。
我们把这种叫做有连接,反之叫做无连接,就像你老板给你发送邮件时,不会问你在不在,他直接就把邮件给你发送过来了。在网络中,有一个专业术语来形容它,叫做握手。
那什么又叫做可靠传输,不可靠传输呢?
比如数据在发送过程中会出现丢包,网络拥塞,发送过快…等一系列的问题,TCP会自动进行重新发送,减缓发送速率,这叫做可靠传输。这些操作对于用户而言都是透明的。
不可靠传输就是数据在发送过程中出现丢包等问题,UDP是不会管的。就比如你在银行取钱的时候,都是当面点清,离柜概不负责。
在这里,我们不要把可靠传输,不可靠传输当做TCP,UDP的优缺点去看,应该当做特点去看待。TCP可靠传输就意味着OS要做更多的事情去保证数据在发送过程中不会出现问题,而UDP不可靠传输,OS就会少做一些事情。对于这两点,带来优点的同时也必然会伴随着缺点出现。
而面向字节流和面向数据报我们先简单理解。面向字节流在管道中我们提到过,可以把它理解成自来水,你想接多少就接多少,面向数据报就相当于你收到的快递,要么收到一个,要么收到多个,不会收到一个半,两个半的快递。具体在后面会讲解。
4. 网络字节序
假设现在有两台主机A,B之间需要通信,我们知道多字节数据在存储时有大小端之分,如果A主机是大端,B主机是小端,发送的数据是0x1234abcd,TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。A主机作为发送方,按照协议规定发送过去的数据存储应该是0x1234abcd,再根据B主机是小端模式(假设未进行序列转化)提取出来的数据不就是0xcdab3412,那不就拿到相反的数据了吗!
所以数据在应用层方面需要我们手动去处理,发送到网络的过程当中,TCP/IP协议会帮助我们进行大小端字节序的转化,这是属于OS的部分,它会帮我们去处理这些工作。
而之所以网络选择大端字节序,是因为发送方式更符合人类的习惯,这叫做以人为本。
为使网络程序具有可移植性,使同样的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);
5. Socket编程接口
5.1 Socket接口的参数细节
// 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
// 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address,socklen_t address_len);
// 开始监听 socket (TCP, 服务器)
int listen(int socket, int backlog);
// 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address,socklen_t* address_len);
// 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
可以看到,在socket编程接口中,都有一个共同的参数struct sockaddr,接下来我们就要了解这个结构体了。
在网络通信中是有不同场景的,所以也是需要有不同的解决方案的。比如:1.unix域间socket 2.网络socket 3.原始socket。如果我们所有的场景都要去实现一份接口,那么就需要多套相似的接口,这太麻烦了。
所以,用一套抽象的socket API就可以完成所有的功能需求。
不同的网络协议的地址格式并不相同。

struct sockaddr_in就是网络socket,struct sockaddr_un就是unix域间socket,它们之间的区别在于网络socket是用于跨主机通信的,而unix域间socket是用来本地通信的。
可以看到这3个结构体的前面部分都有一个16位的地址类型,到时候就可以通过这个来进一步判断底层应该使用哪一个结构来进行通信了。
5.2 Socket接口详解
距离实现socket编程只差最后一步了,我们了解一下socket编程接口的作用。
. socket


domain表示协议族或者域,可以从下面的这些选项中选择。
AF_UNIX和AF_LOCAL都表示本地通信,而AF_INET表示网络通信。

type表示类型,从下面的这些选项中选择,SOCK_STREAM表示字节流,代表TCP协议,SOCK_DGRAM表示数据报,代表UDP协议, SOCK_RAW代表原始套接字,允许绕过传输层,到达网络层。

protocol设置为0就行,代表依据前两个参数自动选择合适的协议。
socket函数成功了返回一个文件描述符,失败了返回-1,并且设置错误码,默认创建出来的套接字是阻塞式的。
. bind

sockfd就是我们通过socket函数获取到的文件描述符,addr是一个结构体类型,代表着通信的类型,将来把IP+端口号填入到结构体里,再传入到该函数中即可,addrlen就是要传入结构体的大小,是一个输出型参数。

bind函数成功了返回0,失败了返回-1并且设置错误码。
在这里额外补充一个知识。

将s指向的空间前n个字节初始化为全0。
在这里我们研究一下struct sockaddr_in结构体。

它的里面有sin_port,代表的是端口号,类型是in_port_t类型的,其实就是一个2字节,16bit的整数。
sin_addr代表的就是IP地址,它的类型是struct in_addr,是一个结构体,该结构体里只有一个成员。

可以看到,s_addr的类型是in_addr_t,就是一个4字节,32bit的整数。

struct sockaddr_in结构体里还有一个宏,代表的就是sin_family,它的类型是一个无符号的短整数。

在这里向大家提出一个问题:多字节的数据在存储时不是会有大小端之分吗,而计算机通信不是会由于大小端的不一致而导致数据出现问题吗,所以网络规定了要以大端发送,不就是为了解决这个问题吗?那为什么在应用层还要手动的进行网络字节序列的转化呢?
网络只是规定了要用大端发送数据,但是并不会帮我们进行转化,它确实是为了解决这个问题的,但是它只是定了一个标准,所以我们需要在应用层手动转化,将转化后的数据发送给网络。
网络就相当于是一个快递员,它只负责一味地搬送数据。


这个函数是用来在网络套接字中读取数据的,同时获取对端的套接字(IP+端口号),接下来我们就来分析一下这个函数的参数。
sockfd代表文件描述符,buf代表缓冲区(用户自定义),len代表缓冲区的大小,flags标志位代表读取数据的控制接收行为,0代表默认行为,阻塞式套接字会一直等待数据,非阻塞式套接字会立即返回,src_addr代表源主机的套接字(IP地址+端口号),addrlen代表源主机套接字结构体的大小,输入时为结构体的大小,输出时为结构体的实际大小。

返回值代表读取到的字节数,发生错误返回-1。


这个函数和recvfrom是非常相像的,该函数是发送数据的,接下来我们来依次解析该函数的参数。
sockfd代表文件描述符,buf代表要发送数据的起始地址,len代表发送数据的大小,flags标志位设置为0,代表使用默认的发送行为,dest_addr代表目标主机的套接字,addrlen代表该结构体的大小。

成功了,返回发送数据的字节数,失败返回-1,设置错误码。
可以看到,recvfrom和sendto函数都会使用到同一个文件描述符sockfd,说明使用这两个函数来进行网络通信的协议就是全双工的,即可以读也可以写。
介绍一个命令
netstat [选项]//用于显示各种网络相关信息
-u//显示udp协议的相关连接
-t//显示tcp协议的相关连接
-a//显示所有的
-n//将能显示成数字的显示成数字
-p//显示进程相关信息
补充几个额外的知识。
127.0.0.1是用来进行本地环回通信的,数据不会通过网络发送到对端,而是在本地的协议栈里进行封装和解包(不会到达数据链路层)。

可以看到127.0.0.1和另一个IP地址(云服务器的IP地址,内网)都代表的是在本地进行通信,但是IP地址不同就无法实现通信。

理由同上,只不过这一次服务端的IP地址换成了云服务器的公网IP地址。那么导致这一问题的原因是什么呢?
是因为我们在服务端显示的bind了指定的套接字(这里指IP地址),所以网络通信是以IP地址为导向的,只有指定的IP地址才可以接收数据。因此,我们不建议这样做。

那么,应该如何解决呢?不建议 bind指定的IP,而是bind 任意的IP地址。
那么,什么叫做任意的IP地址呢?INADDR_ANY。
任意的IP仅限于本机,表示只要是发送到本机的数据都可以被接收,不再以IP地址为导向,而是以本机为导向,只要IP地址是本机的,都可以接收数据。一个主机可以有多个IP地址,但一个IP地址不可以有多台主机(公网环境下)。


可以看到,客户端使用不同的IP地址(都属于一台主机的IP地址)都是可以通信的。

云服务器的公网IP是不允许bind的,但是这与云服务器的类型有关。可以看到,虽然bind成功了,但是没有办法进行通信。

inet_ntoa函数是一个用于将网络字节序的IP地址转换为点分十进制字符串的函数。
跨网络通信

通过内网ip访问的话,这个数据包是会通过网卡发送到局域网中的,但是局域网中的路由器根据ip地址又会转发回来。进一步验证了client发送数据的时候,OS会自动随机分配一个端口号。

listen是用来监听套接字的,sockfd就是文件描述符,backlog是已完成连接队列的最大长度。

成功了,返回0,失败了返回-1并设置错误码。

accept函数是用来接收套接字连接的,sockfd是文件描述符,addr是用来接收源主机套接字的结构体,addrlen是套接字结构体的大小,输出时代表该结构体的实际大小。
没有新连接时,accept默认是阻塞的,有新连接,accept返回,获取新连接。

成功了返回一个文件描述符,失败了返回-1设置错误码。
看到这里大家有没有疑问呢?
accept函数第一个参数要求我们传递一个文件描述符,而它的返回值又是一个文件描述符,该怎么理解这两个文件描述符呢?
举一个例子来帮助大家理解。
今天你和你的朋友去了西湖旁边去玩,旁边有一个西湖鱼庄,鱼庄有一个服务员张三不断地在拉客,邀请客人吃饭,你和你的朋友被张三拉进去吃饭了,张三冲着后厨喊叫一声,这是李四过来询问顾客要点些什么菜,张三继续跑到外面拉客去了,王五又过来服务顾客了,张三继续拉客。
西湖鱼庄就是一个服务器,顾客就是一个个客户端,而张三就是套接字,用来获取新连接的,此时更准确的叫法应该是监听套接字,而accept函数返回的文件描述符就像是服务顾客,与顾客进行通信的,所以accept return fd是用来进行网络通信的。
虽然它们同样都叫文件描述符,但是它们的用处不同。

发起连接请求,sockfd是文件描述符,addr是目标主机的套接字结构体,addrlen是该结构体的大小。

成功了返回0,失败了返回-1设置错误码。并且connect函数在发起连接请求之后还会进行bind(在调用connect之前没有显示的bind,系统会自动bind)。

popen创建一个管道并启动一个子进程来执行shell命令,建立父子进程间的单向数据流。command要执行的shell命令字符串,type管道类型,"r,w"父进程从子进程读取或写入数据。

成功返回FILE指针,可用于标准I/O操作,失败返回NULL。
pclose关闭由popen创建的管道流,并等待子进程结束。

成功返回子进程的退出状态,失败返回-1。
关于地址转换函数的补充内容。
在地址转换时,会进行字符串转in_addr,也会in_addr转字符串,我们使用了两个接口函数。


现在,我们来分析一下这个函数。inet_ntoa返回的是一个char*字符串,那么这个字符串它在哪里存储呢?还记得FILE* fopen函数吗,我们说FILE是一个结构体,这个结构体对象存储在fopen函数里(动态申请),给我们返回的是这个对象的指针,同理,inet_ntoa返回的字符串也在该函数里,在函数内部申请了一块静态空间。

因此,这块空间的生命周期是贯穿整个程序,所以该函数每次被调用时,都会将新的IP地址字符串填充到这个唯一的,固定的静态数组中,并返回它的指针,因此这个函数它不是线程安全的,连续调用会覆盖之前的结果。
推荐使用这两个函数代替上面的函数。


更多推荐

所有评论(0)