计算机网络总结(三)——socket编程

socket编程

1.套接字和文件描述符

(1)网络编程就是编写程序是两台联网的计算机相互交换数据。操作系统会提供名为套接字(socket)的部件,套接字是网络数据传输的软件设备。学习 socket,也就是学习计算机之间如何通信,并编写出实用的程序。

socket编程,是站在传输层的基础上,所以可以使用 TCP/UDP 协议,但是不能干「访问网页」这样的事情,因为访问网页所需要的 http 协议位于应用层

(2)UNIX/Linux中的socket

①为了表示和区分已经打开的文件,UNIX/Linux 会给每个文件分配一个 ID,这个 ID 就是一个整数,被称为文件描述符(File Descriptor)。例如:

  • 通常用 0 来表示标准输入文件(stdin),它对应的硬件设备就是键盘;

  • 通常用 1 来表示标准输出文件(stdout),它对应的硬件设备就是显示器。

②网络连接也是一个文件描述符,我们可以通过socket()函数来创建一个网络连接,或者说打开一个网络文件, socket() 的返回值就是文件描述符。有了文件描述符,我们就可以使用普通的文件操作函数来传输数据了,例如:

  • 用 read() 读取从远程计算机传来的数据;

  • 用 write() 向远程计算机写入数据。

(3)Windows中的socket

Windows 也有类似“文件描述符”的概念,但通常被称为“文件句柄”。

与 UNIX/Linux 不同的是,Windows 会区分 socket 和文件,Windows 就把 socket 当作一个网络连接来对待,因此需要调用专门针对 socket 而设计的数据传输函数,针对普通文件的输入输出函数就无效了。

 

2. socket编程基础

2.1 C-S模式

在TCP/IP网络编程中,两个程序之间通信模式通常是客户端/服务端模式(client/server)。工作流程如下:

2.2 socket常用函数

(1)socket()

socket()函数用来创建套接字,定义如下:

#include<sys/socket.h>
int socket(int af, int type, int protocol);
/*
 * af表示地址族(Address Family),也就是 IP 地址类型,常用的有 AF_INET(IPv4 地址) 和 AF_INET6(IPv6 地址)
 * type表示数据传输方式/套接字类型,常用的有 SOCK_STREAM(流格式套接字/面向连接的套接字) 和 SOCK_DGRAM(数据报套接字/无连接的套接字)
 * protocol 表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议
 */
​
//使用示例
int tcp_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

(2)bind()

bind()函数用来将套接字与特定的 IP 地址和端口绑定起来,定义如下:

#include<sys/socket.h>
int bind(int sock, struct sockaddr *addr, socklen_t addrlen);
/*
 * sock表示socket文件描述符
 * addr表示 sockaddr 结构体变量的指针
 * addrlen表示 addr 变量的大小,可由 sizeof() 计算得出
 */

使用示例

//创建套接字
int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
​
//创建sockaddr_in结构体变量
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));  //每个字节都用0填充
serv_addr.sin_family = AF_INET;  //使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");  //具体的IP地址
serv_addr.sin_port = htons(1234);  //端口
​
//将套接字和IP、端口绑定
bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

sockaddr 是一种通用的结构体,可以用来保存多种类型的IP地址和端口号,而IPv4 地址的结构体通常使用sockaddr_in保存,如下所示:

struct sockaddr_in
{
    sa_family_t     sin_family;    //地址族(Address Family),也就是地址类型
    uint16_t        sin_port;      //16位的端口号
    struct in_addr  sin_addr;      //32位IP地址
    char            sin_zero[8];   //不使用,一般用0填充
};

(3)connect()

connect()函数用来建立连接,定义如下:

#include<sys/socket.h>
int connect(int sock, struct sockaddr *addr, socklen_t addrlen); 
/*
 * sock表示socket文件描述符
 * addr表示 sockaddr 结构体变量的指针
 * addrlen表示 addr 变量的大小,可由 sizeof() 计算得出
 */

(4)listen()

listen() 函数让套接字进入被动监听状态,即当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求,listen()定义如下:

#include<sys/socket.h>
int listen(int sock, int backlog); 
/*
 * sock表示进入监听状态的socket
 * backlog 为请求队列的最大长度
 */

(5)accept()

当套接字处于监听状态时,可以通过 accept() 函数来接收客户端请求,accept() 会阻塞程序执行,直到有新的请求到来。accept() 定义如下:

#include<sys/socket.h>
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen); 
/*
 * sock表示socket文件描述符
 * addr表示 sockaddr 结构体变量的指针
 * addrlen表示 addr 变量的大小,可由 sizeof() 计算得出
 */

使用示例

int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
......
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_size = sizeof(clnt_addr);
int clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);
//accept()返回一个新的套接字来和客户端通信,clnt_addr保存了客户端的IP地址和端口号,而serv_sock是服务器端的套接字

(6)write()/read()

使用 write() 可以向套接字中写入数据,使用 read() 可以从套接字中读取数据,定义如下:

#include<sys/socket.h>
ssize_t write(int fd, const void *buf, size_t nbytes);
ssize_t read(int fd, void *buf, size_t nbytes);
/*
 * fd表示要写入或读取的文件的描述符
 * buf表示要写入或读取的数据的缓冲区地址
 * nbytes表示要写入或读取的数据的字节数
 */

 

3.字节序

3.1 大端序和小端序

(1)不同 CPU 中,4 字节整数 1 在内存空间的存储方式是不同的。4 字节整数 1 可用 2 进制表示如下:

00000000 00000000 00000000 00000001

(2)有些 CPU 以上面的顺序存储到内存,另外一些 CPU 则以倒序存储,如下所示:

00000001 00000000 00000000 00000000

若不考虑这些就收发数据会发生问题,因为保存顺序的不同意味着对接收数据的解析顺序也不同。

CPU 向内存保存数据的方式有两种:

大端序(Big Endian):高位字节存放到低位地址(高位字节在前)。

小端序(Little Endian):高位字节存放到高位地址(低位字节在前)。

假设在 0x20 号开始的地址中保存 4 字节 int 型数据 0x12345678,大端序 CPU 保存方式如下所示:

//大端序保存方式
0x20号  0x21号  0x22号  0x23号
 0x12   0x34    0x56    0x78
//小端序保存方式
0x20号  0x21号  0x22号  0x23号
 0x78   0x56    0x34    0x12

不同 CPU 保存和解析数据的方式不同(主流的 Intel 系列 CPU 为小端序),小端序系统和大端序系统通信时会发生数据解析错误。因此在发送数据前,要将数据转换为统一的格式——网络字节序(Network Byte Order)。网络字节序统一为大端序

主机 A 先把数据转换成大端序再进行网络传输,主机 B 收到数据后先转换为自己的格式再解析。小端序系统传输数据时应转化为大端序排列方式。

 

3.2 网络字节序转换函数

htons()是最常用的网络字节序转换函数, htons() 用来将当前主机字节序转换为网络字节序,其中h代表主机(host)字节序,n代表网络(network)字节序,s代表short,htons 是 h、to、n、s 的组合,可以理解为”将 short 型数据从当前主机字节序转换为网络字节序“。

常见的网络字节转换函数有:

  • htons():host to network short,将 short 类型数据从主机字节序转换为网络字节序。

  • ntohs():network to host short,将 short 类型数据从网络字节序转换为主机字节序。

  • htonl():host to network long,将 long 类型数据从主机字节序转换为网络字节序。

  • ntohl():network to host long,将 long 类型数据从网络字节序转换为主机字节序。

注意:为 sockaddr_in 成员赋值时需要显式地将主机字节序转换为网络字节序,而通过 write()/send() 发送数据时 TCP 协议会自动转换为网络字节序,不需要再调用相应的函数。

 

3.3 32位整数型数据转换

in_addr_t inet_addr(const char* string);

该函数把字符串转换成网络字节序整数型IP地址,成功时返回32位大端序整数型值,失败时返回INADDR_NONE) int inet_aton(const char* string, struct in_addr* addr)

该函数与inet_addr函数在功能上完全相同,成功时返回1,失败时返回0) char* inet_ntoa(struct in_addr* addr);

该函数把网络字节序整数型IP地址转换成我们熟悉的字符串形式,成功时返回转换的字符串地址值,失败时返回-1)

 

3.4 网络地址初始化

struct sockaddr_in addr;              
char* serv_ip = "211.217.168.13"      //声明IP地址字符串  
char* serv_port = "9190";            //声明端口号字符串  
memset(&addr, 0, sizeof(addr));      //结构体变量addr的所有成员初始化为0  
addr.sin_family = AF_INET;           //指定地址族  
addr.sin_addr.s_addr = inet_addr(serv_ip);  //基于字符串的IP地址初始化  
addr.sin_port = htons(atoi(serv_port));     //基于字符串的端口号初始化

 

3.5 域名及网络地址

1.域名系统

DNS(Domain Name System,域名系统)是对IP地址和域名进行相互转换的系统,其核心是DNS服务器。域名是赋予服务器端的虚拟地址,而非实际地址,因此需要将虚拟地址转化为实际地址。

2.IP地址和域名之间的转换

使用以下函数可以通过传递字符串格式的域名获取IP地址

#include<netdb.h>
struct hostent* gethostbyname(const char * hostname);
//(成功时返回hostent结构体地址,失败时返回NULL指针)
//这个函数使用方便,只要传递域名字符串,就会返回域名对应的IP地址。只是返回时,地址信息装入hostent结构体。此结构体定义如下:
struct hostent
{
    char * h_name;         //存有官方域名
    char ** h_aliases;     //可以通过多个域名访问同一主页
    int h_addrtype;        //地址类型,IPv4或IPv6
    int h_length;          //地址长度
    char ** h_addr_list;   //地址列表(以整数形式保存域名对应的IP地址)
}

hostent 结构体变量的组成如下图所示:

利用IP地址获取域名

#include<netdb.h>
struct hostent* gethostbyaddr(const char * addr, socklen_t len,  int family);    
//  (成功时返回hostent结构体地址,失败时返回NULL指针)
//  addr  含有ip地址信息的in_addr结构体指针,为了同时传递IPv4地址之外的其他信息,该变量的类型声明为char指针。
//  len  向第一个参数传递的地址信息的字节数,IPv4时为4,IPv6时为16。
//  family  传递地址族信息,IPv4时为AF_INET,IPv6时为AF_INET6。

上述两个函数的调用的结果都是通过hostent结构体变量地址值传递的

 

4.TCP的连接和断开

4.1 TCP的连接

1.三次握手

TCP是面向连接的协议,所以使用TCP前必须先建立连接,而建立连接是通过三次握手来进行的

假设A为客户端,B为服务端,建立连接的过程如图所示:

流程解释如下:

最开始,A和B都处于 CLOSE 状态,然后 B 处于 LISTEN(监听)状态,等待客户的连接请求。

  • 【第一次握手】客户端会随机初始化序列号Seq(置于TCP首部的序列号字段),假设为x,并设置标志位SYN = 1,ACK = 0,接着客户端向服务端发送连接请求报文段(SYN报文),表示向服务器发起连接,之后客户端处于SYN_SENT状态。

  • 【第二次握手】服务端收到客户端的SYN报文后,也会随机初始化序列号Seq,假设为y,并把TCP首部的确认应答号ACK字段填入x+1,并设置标志位SYN = 1,ACK = 1,最后把该报文发给客户端,之后服务端处于SYN_RCVD状态。

  • 【第三次握手】客户端收到服务端的确认连接报文后,还要向服务端回应一个应答报文,把TCP首部的确认应答号ACK字段填入y+1,并设置标志位ACK = 1,最后把报文发送给服务端,这次报文可以携带客户到服务器的数据,最后客户端处于ESTABLISHED状态。服务端收到客户端的应答报文后,也进入ESTABLISHED状态。

当客户端和服务端都处于ESTABLISHED状态,表示连接建立完成,客户端和服务端就可以互相发送数据了。

2.为什么是三次握手?

客户端和服务端通信前要进行连接,“三次握手”的作用就是双方都能明确自己和对方的收、发能力是正常的。

(1)为什么不是两次

1)第一次握手:客户端发送网络包,服务端收到了。这样服务端就能得出结论:客户端的发送能力、服务端的接收能力是正常的

2)第二次握手:服务端发包,客户端收到了。这样客户端就能得出结论:服务端的接收、发送能力,客户端的接收、发送能力是正常的

3)第三次握手:客户端发包,服务端收到了。这样服务端就能得出结论:客户端的接收、发送能力,服务端的发送、接收能力是正常的。

经历了上面的三次握手过程,客户端和服务端都确认了自己的接收、发送能力是正常的,之后就可以正常通信了。这个过程最少是需要三次握手,两次达不到让双方都得出自己、对方的接收、发送能力都正常的结论。

(2)为什么不是四次

三次握手就已经理论上最少可靠建立连接,所以不需要使用更多的通信次数。

3.初始序列号

客户端和服务端的初始序列号是不相同的,因为如果一个已经失效或者使用过的连接被重用了,但该连接旧的历史报文还残留在网络中,如果序列号相同,那么就无法分辨是不是历史报文,如果历史报文被接收了,会产生数据错乱。

 

4.2 TCP的断开

1.四次挥手

客户端和服务端都可以主动断开连接,而断开连接是通过四次挥手来进行的

假设A为客户端,B为服务端,断开连接的过程如图所示:

流程解释如下:

  • 【第一次挥手】客户端向服务端发送连接断开请求,此时会发送一个TCP首部FIN报文,并设置标志位 FIN = 1,并随机初始化序列号Seq,假设为x,之后客户端进入FIN_WAIT_1状态。

  • 【第二次挥手】服务端收到客户端发送的报文后,就向客户端发送ACK应答报文,把TCP首部的确认应答号ACK字段填入x+1,并设置标志位 ACK = 1,之后服务端进入CLOSED_WAIT状态。这时上层的应用程序会被告知另一端发起了关闭操作,通常这将引起应用程序发起自己的关闭操作。

  • 【第三次挥手】客户端收到服务端的ACK报文后,进入FIN_WAIT_2状态。服务端发起自己的 FIN 段,并设置标志位 FIN = 1,并随机初始化序列号Seq = y,并向客户端发送断开连接请求的FIN报文,之后进入LAST_ACK状态。

  • 【第四次挥手】客户端收到服务端的FIN报文后,回一个ACK报文,之后进入TIME_WAIT状态,该状态会持续2MSL时间(为了保证服务器能收到客户端的确认应答),若该时间段内没有服务器的重发请求的话,就进入CLOSED状态,当服务器收到确认应答后,也便进入CLOSED状态。

当客户端和服务端都处于CLOSED状态,表示断开连接成功。

2.为什么是四次挥手

1)关闭连接时,客户端向服务端发送FIN时,表示客户端不再发送数据了但是还能接收数据。

2)服务端收到客户端的FIN报文时,先回一个ACK应答报文,而服务端可能还有数据要处理和发送,等服务端不再发送数据时,才发送一个FIN报文给客户端表示同意关闭连接。

在建立连接时,服务端在 LISTEN 状态下,收到建立连接请求的 SYN 报文后,把 ACK 和 SYN 放在一个报文里发送给客户端。而断开连接时,服务端通常要完成数据的发送和处理,当收到对方的 FIN 报文时,仅仅表示对方不再发送数据了但是还能接收数据,己方是否现在关闭发送数据通道,需要上层应用来决定,所以ACK和FIN会分开发送。所以断开连接比三次握手多了一次。

3.TIME_WAIT

TCP 是面向连接的传输方式,必须保证数据能够正确到达目标机器,不能丢失或出错,而网络是不稳定的,随时可能会毁坏数据,所以机器A每次向机器B发送数据包后,都要求机器B确认,回传ACK包,告诉机器A我收到了,这样机器A才能知道数据传送成功了。

(1)TIME_WAIT的作用

客户端接收到服务器端的 FIN 报文后进入此状态,此时并不是直接进入 CLOSED 状态,还需要等待一个时间计时器设置的时间 2MSL,即TIME_WAIT状态(在Linux系统里2MSL默认是60秒,那么一个MSL也就是30秒,Linux系统停留在TIME_WAIT的时间固定为60秒)。需要TIME_WAIT的原因有:

1)确保最后一个确认报文能够到达。如果服务端没收到客户端发送来的确认报文,那么就会重新发送FIN报文,如果这时客户端完全关闭了连接,那么服务器无论如何也收不到ACK包了,所以客户端需要等待片刻、确认对方收到ACK包后才能进入CLOSED状态

2)等待一段时间是为了让本连接持续时间内所产生的所有报文都从网络中消失,使得下一个新的连接不会出现旧的连接请求报文,这也是TIME_WAIT设置为2MSL的原因。因为数据包在网络中是有生存时间的,超过这个时间还未到达目标主机就会被丢弃,并通知源主机。这称为报文最大生存时间(MSL,Maximum Segment Lifetime)。TIME_WAIT 要等待 2MSL 才会进入 CLOSED 状态。2MSL的时间是从客户端接收到FIN后发送ACK开始计时的,比如过去了MSL后,服务端仍然没有收到ACK,就会触发超时发送FIN报文,那么TIME_WAIT将重新计时。2MSL 是数据包往返的最大时间,如果 2MSL 后还未收到服务器重传的 FIN 包,就说明旧连接的所有报文都自然消亡了,即服务端已经断开了

注意:只有发起连接终止的一方会进入 TIME_WAIT 状态。

(2)TIME_WAIT的危害

过多的TIME_WAIT的危害主要有两种,第一个是内存资源占用,第二个是对端口资源的占用,一个TCP连接至少消耗一个本地端口,如果TIME_WAIT过多,会导致无法创建新连接。

 

4.3 断开连接函数

socket中断开连接的函数用close()/closesocket()函数,和shutdown()函数。 调用 close()/closesocket() 函数意味着完全断开连接,即不能发送数据也不能接收数据,如果 主机A发送完数据后,单方面调用 close()/closesocket() 断开连接,之后主机A、B都不能再接受对方传输的数据,有些特殊时刻,需要只断开一条数据传输通道,而保留另一条。这时需要调用shutdown()函数

Linux下shutdown()函数的原型为:

int shutdown(int sock, int howto); 
//sock 为需要断开的套接字,howto 为断开方式。

howto 取值如下:

  • SHUT_RD:断开输入流。套接字无法接收数据(即使输入缓冲区收到数据也被抹去),无法调用输入相关函数。

  • SHUT_WR:断开输出流。套接字无法发送数据,但如果输出缓冲区中还有未传输的数据,则将传递到目标主机。

  • SHUT_RDWR:同时断开 I/O 流。相当于分两次调用 shutdown(),其中一次以 SHUT_RD 为参数,另一次以 SHUT_WR 为参数。

 

参考:

  1. 《TCP-IP网络编程》 韩-尹圣雨
  2. 《图解网络》小林coding

  3. 《图解TCP/IP》

  4. https://zhuanlan.zhihu.com/p/108822858

 

posted @ 2021-11-25 21:00  烟消00云散  阅读(42)  评论(0编辑  收藏  举报