socket学习

socket学习

起因是老师的作业

什么是 socket?

socket 的原意是“插座”,在计算机通信领域,socket 被翻译为“套接字”,它是计算机之间进行通信的一种约定或一种方式。通过 socket 这种约定,一台计算机可以接收其他计算机的数据,也可以向其他计算机发送数据。

socket 的典型应用就是 Web 服务器和浏览器:浏览器获取用户输入的 URL,向服务器发起请求,服务器分析接收到的 URL,将对应的网页内容返回给浏览器,浏览器再经过解析和渲染,就将文字、图片、视频等元素呈现给用户。

学习 socket,也就是学习计算机之间如何通信,并编写出实用的程序

一个开始

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

  • 用 read() 读取从远程计算机传来的数据;
  • 用 write() 向远程计算机写入数据。

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

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

套接字

流格式套接字(SOCK_STREAM)

流格式套接字(Stream Sockets)也叫“面向连接的套接字”,在代码中使用 SOCK_STREAM 表示。

SOCK_STREAM 是一种可靠的、双向的通信数据流,数据可以准确无误地到达另一台计算机,如果损坏或丢失,可以重新发送。

流格式套接字有自己的纠错机制,在此我们就不讨论了。

SOCK_STREAM 有以下几个特征:

  • 数据在传输过程中不会消失;
  • 数据是按照顺序传输的;
  • 数据的发送和接收不是同步的(有的教程也称“不存在数据边界”)。

可以将 SOCK_STREAM 比喻成一条传送带,只要传送带本身没有问题(不会断网),就能保证数据不丢失;同时,较晚传送的数据不会先到达,较早传送的数据不会晚到达,这就保证了数据是按照顺序传递的。

数据报格式套接字(SOCK_DGRAM)

数据报格式套接字(Datagram Sockets)也叫“无连接的套接字”,在代码中使用 SOCK_DGRAM 表示。

计算机只管传输数据,不作数据校验,如果数据在传输中损坏,或者没有到达另一台计算机,是没有办法补救的。也就是说,数据错了就错了,无法重传。

因为数据报套接字所做的校验工作少,所以在传输效率方面比流格式套接字要高。

可以将 SOCK_DGRAM 比喻成高速移动的摩托车快递,它有以下特征:

  • 强调快速传输而非传输顺序;
  • 传输的数据可能丢失也可能损毁;
  • 限制每次传输的数据大小;
  • 数据的发送和接收是同步的(有的教程也称“存在数据边界”)

TCP

服务端

WSAStartup()

调用 WSAStartup() 函数进行初始化,以指明 WinSock 规范的版本,它的原型为:

1
int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);

wVersionRequested 为 WinSock 规范的版本号,低字节为主版本号,高字节为副版本号(修正版本号);lpWSAData 为指向 WSAData 结构体的指针。

image-20211003220519393

makeword是将两个byte型合并成一个word型,一个在高8位(b),一个在低8位(a)

如果成功,WSAStartup函数返回0。否则,返回下面列表显示的错误码之一

错误码解释
WSASYSNOTREADY网络通信中下层的网络子系统没准备好
WSAVERNOTSUPPORTEDSocket实现提供版本和socket需要的版本不符
WSAEINPROGRESS一个阻塞的Socket操作正在进行
WSAEPROCLIMSocket的实现超过Socket支持的任务数限制
WSAEFAULTlpWSAData参数不是一个合法的指针

WSADATA与SOCKET

WSAStartup() 函数执行成功后,会将与 ws2_32.dll 有关的信息写入 WSAData 结构体变量

image-20211003221025248

WinSock 编程的第一步就是加载 ws2_32.dll,然后调用 WSAStartup() 函数进行初始化,并指明要使用的版本号。

socket()

Windows 下使用 socket() 函数来创建套接字,原型为:

1
SOCKET socket(int af, int type, int protocol);

af 为地址族(Address Family),也就是 IP 地址类型,常用的有 AF_INET 和 AF_INET6。AF 是“Address Family”的简写,INET是“Inetnet”的简写。AF_INET 表示 IPv4 地址,例如 127.0.0.1;AF_INET6 表示 IPv6 地址,例如 1030::C9B4:FF12:48AA:1A2B。

记住127.0.0.1,它是一个特殊IP地址,表示本机地址

type 为数据传输方式,常用的有 SOCK_STREAM 和 SOCK_DGRAM

protocol 表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议

Windows 不把套接字作为普通文件对待,而是返回 SOCKET 类型的句柄

image-20211003222457345

可以看到,这里是SOCK_STREAM面向连接的,说明是TCP协议

bind() 函数

bind() 函数的原型为:

1
2
int bind(int sock, struct sockaddr *addr, socklen_t addrlen);  //Linux
int bind(SOCKET sock, const struct sockaddr *addr, int addrlen); //Windows

sock 为 socket 文件描述符,addr 为 sockaddr 结构体变量的指针,addrlen 为 addr 变量的大小,可由 sizeof() 计算得出.

image-20211003222759995

oldSocket是刚才socket()函数返回的socket

sockaddr_in 结构体

先看一下 sockaddr_in 结构体,它的成员变量如下:

1
2
3
4
5
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填充
};

sin_family 和 socket() 的第一个参数的含义相同,取值也要保持一致

sin_prot 为端口号。uint16_t 的长度为两个字节,理论上端口号的取值范围为 0~65536,但 0~1023 的端口一般由系统分配给特定的服务程序,例如 Web 服务的端口号为 80,FTP 服务的端口号为 21,所以我们的程序要尽量在 1024~65536 之间分配端口号。

端口号需要用 htons() 函数转换

sin_addr 是 struct in_addr 结构体类型的变量

sin_zero[8] 是多余的8个字节,没有用,一般使用 memset() 函数填充为 0

in_addr 结构体

sockaddr_in 的第3个成员是 in_addr 类型的结构体,该结构体只包含一个成员,如下所示:

1
struct in_addr{    in_addr_t  s_addr;  //32位的IP地址};

in_addr_t 在头文件 中定义,等价于 unsigned long,长度为4个字节。也就是说,s_addr 是一个整数,而IP地址是一个字符串,所以需要 inet_addr() 函数进行转换,例如:

1
2
unsigned long ip = inet_addr("127.0.0.1");
printf("%ld\n", ip);

image-20211003223419184

设置sockaddr_in 注意,又将其强制转换为了一个通用的socket_addr

image-20211003224306508

image-20211003224327739

sockaddr 和 sockaddr_in 的长度相同,都是16字节,只是将IP地址和端口号合并到一起,用一个成员 sa_data 表示。要想给 sa_data 赋值,必须同时指明IP地址和端口号,例如”127.0.0.1:80“,遗憾的是,没有相关函数将这个字符串转换成需要的形式,也就很难给 sockaddr 类型的变量赋值,所以使用 sockaddr_in 来代替。这两个结构体的长度相同,强制转换类型时不会丢失字节,也没有多余的字节。

image-20211004102955888

htons的功能:将一个无符号短整型的主机数值转换为网络 字节顺序,即大尾顺序(big-endian)

参数u_short hostshort:16位 无符号整数

返回值:TCP/IP网络 字节顺序.

The Windows Sockets inet_addr function converts a string containing an (Ipv4) Internet Protocol dotted address into a proper address for the IN_ADDR structure.

sockaddr 是一种通用的结构体,可以用来保存多种类型的IP地址和端口号,而 sockaddr_in 是专门用来保存 IPv4 地址的结构体

使用 bind() 绑定套接字后,还需要使用 listen() 函数让套接字进入被动监听状态,再调用 accept() 函数,就可以随时响应客户端的请求了。

listen() 函数

通过 listen() 函数可以让套接字进入被动监听状态,它的原型为:

1
2
int listen(int sock, int backlog);  //Linux
int listen(SOCKET sock, int backlog); //Windows

sock 为需要进入监听状态的套接字,backlog 为请求队列的最大长度。

所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。

image-20211003224816157

请求队列

当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)。

缓冲区的长度(能存放多少个客户端请求)可以通过 listen() 函数的 backlog 参数指定,但究竟为多少并没有什么标准,可以根据你的需求来定,并发量小的话可以是10或者20。

如果将 backlog 的值设置为 SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。

accept() 函数

当套接字处于监听状态时,可以通过 accept() 函数来接收客户端请求。它的原型为:

1
2
int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);  //Linux
SOCKET accept(SOCKET sock, struct sockaddr *addr, int *addrlen); //Windows

它的参数与 listen() 和 connect() 是相同的:sock 为服务器端套接字,addr 为 sockaddr_in 结构体变量,addrlen 为参数 addr 的长度,可由 sizeof() 求得。

accept() 返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 sock 是服务器端的套接字,大家注意区分。后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。

image-20211003225553648

accept() 返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 oldSocket 是服务器端的套接字,

注意区分。后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。

listen() 只是让套接字进入监听状态,并没有真正接收客户端请求,listen() 后面的代码会继续执行,直到遇到 accept()。accept() 会阻塞程序执行(后面代码不能被执行),直到有新的请求到来

Windows下数据的接收和发送

要在一个循环中监听事件

image-20211003230747958

Windows 区分普通文件和套接字,并定义了专门的接收和发送的函数。

从服务器端发送数据使用 send() 函数,它的原型为:

1
int send(SOCKET sock, const char *buf, int len, int flags);

sock 为要发送数据的套接字,buf 为要发送的数据的缓冲区地址,len 为要发送的数据的字节数,flags 为发送数据时的选项。

返回值和前三个参数不再赘述,最后的 flags 参数一般设置为 0 或 NULL。

在客户端接收数据使用 recv() 函数,它的原型为:

1
int recv(SOCKET sock, char *buf, int len, int flags);

客户端下线,返回0,释放客户端socket

执行失败,返回SOCKET_ERROR

image-20211003230553718

这里设置了客户端输入quit即退出循环

1
2
3
closesocket(newSocket);
closesocket(oldSocket);
WSACleanup();

关闭两个句柄

终止Winsock 2 DLL (Ws2_32.dll) 的使用.

客户端

socket() 函数用来创建套接字,确定套接字的各种属性,然后服务器端要用 bind() 函数将套接字与特定的IP地址和端口绑定起来,只有这样,流经该IP地址和端口的数据才能交给套接字处理;而客户端要用 connect() 函数建立连接。

connect() 函数

connect() 函数用来建立连接,它的原型为:

1
2
int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen);  //Linux
int connect(SOCKET sock, const struct sockaddr *serv_addr, int addrlen); //Windows

参数类型与上述相同

image-20211003231620650

之前同理也要用WSAStartup先初始化,然后使用sock()建立客户端套接字

image-20211003232120611

注意,客户端的代码只创建了客户端的套接字且与服务端连接

然后发送消息

send()

image-20211004100255294

从客户端发送数据使用 send() 函数,它的原型为:

1
int send(SOCKET sock, const char *buf, int len, int flags);

sock 为要发送数据的套接字,buf 为要发送的数据的缓冲区地址,len 为要发送的数据的字节数,flags 为发送数据时的选项。

UDP

TCP 是面向连接的传输协议,建立连接时要经过三次握手,断开连接时要经过四次握手,中间传输数据时也要回复ACK包确认,多种机制保证了数据能够正确到达,不会丢失或出错。

UDP 是非连接的传输协议,没有建立连接和断开连接的过程,它只是简单地把数据丢到网络中,也不需要ACK包确认。

UDP中的服务器端和客户端没有连接

UDP不像TCP,无需在连接状态下交换数据,因此基于UDP的服务器端和客户端也无需经过连接过程。也就是说,不必调用 listen() 和 accept() 函数。UDP中只有创建套接字的过程和数据交换的过程。

UDP服务器端和客户端均只需1个套接字

TCP中,套接字是一对一的关系。如要向10个客户端提供服务,那么除了负责监听的套接字外,还需要创建10套接字。但在UDP中,不管是服务器端还是客户端都只需要1个套接字。之前解释UDP原理的时候举了邮寄包裹的例子,负责邮寄包裹的快递公司可以比喻为UDP套接字,只要有1个快递公司,就可以通过它向任意地址邮寄包裹。同样,只需1个UDP套接字就可以向任意主机传送数据。

基于UDP的接收和发送函数

创建好TCP套接字后,传输数据时无需再添加地址信息,因为TCP套接字将保持与对方套接字的连接。换言之,TCP套接字知道目标地址信息。但UDP套接字不会保持连接状态,每次传输数据都要添加目标地址信息,这相当于在邮寄包裹前填写收件人地址。

udp与tcp协议不同,但创建套接字与绑定连接ip流程类似.

先初始化WSAStartup()

image-20211004094535914

再创建套接字,不过这里面向的是数据报连接

image-20211004094604239

再初始化sockadd_in,填入ipv4地址与端口,进行绑定

image-20211004094721033

绑定之后进行接收数据即可.

image-20211004100014595

接收数据使用 recvfrom() 函数:

1
int recvfrom(SOCKET sock, char *buf, int nbytes, int flags, const struct sockaddr *from, int *addrlen);  //Windows

recvfrom()

由于UDP数据的发送端不不定,所以 recvfrom() 函数定义为可接收发送端信息的形式,具体参数如下:

  • sock:用于接收UDP数据的套接字;
  • buf:保存接收数据的缓冲区地址;
  • nbytes:可接收的最大字节数(不能超过buf缓冲区的大小);
  • flags:可选项参数,若没有可传递0;
  • from:存有发送端地址信息的sockaddr结构体变量的地址;
  • addrlen:保存参数 from 的结构体变量长度的变量地址值。

sendto()

发送数据使用 sendto() 函数:

1
int sendto(SOCKET sock, const char *buf, int nbytes, int flags, const struct sockadr *to, int addrlen);  //Windows

Linux和Windows下的 sendto() 函数类似,下面是详细参数说明:

  • sock:用于传输UDP数据的套接字;
  • buf:保存待传输数据的缓冲区地址;
  • nbytes:带传输数据的长度(以字节计);
  • flags:可选项参数,若没有可传递0;
  • to:存有目标地址信息的 sockaddr 结构体变量的地址;
  • addrlen:传递给参数 to 的地址值结构体变量的长度。

image-20211004100619357

使用inet_ntoa与ntohs可以将数据转为字符串,整数

image-20211004102623675

实验结果

tcp通信

image-20211004100907639

image-20211004100913307

udp通信

image-20211004101456204

image-20211004101508985

下面是我跟其他ip地址交互的

客户端

img

服务端

image-20211004102014108

可以看到客户端的ip与端口

-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道