写在前面
这里先介绍下socket函数(Windows版本)的函数声明,后续内容均围绕该声明展开:
#include <winsock2.h>
//af: 指定该套接字的协议族
//type: 指定该套接字的数据传输方式
//protocol: 指定该套接字的最终协议
//返回值:失败返回INVALID_SOCKET,否则为成功
SOCKET socket(int af, int type, int protocol);
协议和协议族
协议:协议就是为了完成数据交换而定好的约定。
协议族: 多个相关协议的集合 。
红烧牛肉面和藤椒牛肉面都属于牛肉面的一种,与之类似,套接字通信中的协议也具有以下几类:
名称 | 协议族 |
---|---|
PF_INET | IPv4互联网协议族 |
PF_INET6 | IPv6互联网协议族 |
PF_LOCAL | 本地通信的UNIX协议族 |
PF_PACKET | 底层套接字协议族 |
PF_IPX | IPX Novell协议族 |
套接字中实际采用的最终协议信息是通过socket函数的第三个参数传递的。在第一个参数指定的协议族范围内通过第三个参数决定最终协议。
套接字类型
套接字类型指的是套接字的数据传输方式,通过socket函数的第二个参数传递,只有这样才能决定创建的套接字的数据传输方式。
已通过第一个参数传递了协议族信息,为什么还要决定数据传输方式?
问题就在于,决定了协议族并不能同时决定数据传输方式。换言之,socket函数第一个参数PF_INET协议族中也存在多种数据传输方式。
这里最常见的就是面向连接的TCP(SOCK_STREAM)和面向消息的UDP(SOCK_DGRAM)。
面向连接的套接字特性
**面向连接的套接字的特性如下:**可靠的、按序传递的、基于字节的面向连接的数据传输方式的套接字。
收发数据的套接字内部有缓冲(buffer),简言之就是字节数组。通过套接字传输的数据将保存到该数组。因此,收到数据并不意味着马上调用recv函数。只有不超过数组容量,则有可能在数据填充满缓冲后通过1次recv函数调用读取缓冲中的全部内容。当然也可以分多次recv调用读取。
也就是说,,在面向连接的套接字中,recv函数和send函数的调用次数并无太大意义。所以说面向连接的套接字并不存在数据边界。
缓冲区满了会发生什么?
首先调用recv函数从缓存区读取部分(或全部)数据,因此,缓冲并不总是满的。
但如果recv函数读取速度比接收数据慢,缓冲就有可能满。此时套接字将无法再接收数据,但即使这样也不会发生数据丢失,因为传输端套接字将停止传输。
也就是说,面向连接的套接字会根据接收端的状态传输数据,如果传输出错还会提供重传服务。因此,面向连接的套接字除特殊情况外不会发生数据丢失。
面向消息的套接字特性
面向消息的套接字特性如下:
①强调快速传输而非传输顺序
②传输的数据可能丢失也可能销毁
③传输的数据有数据边界
④限制每次传输的数据大小
即面向消息的套接字比面向连接的套接字具有更快的传输速度,但无法避免数据丢失或损毁。另外,每次传输的数据大小具有一定限制,并存在数据边界。存在数据边界意味着接收数据的次数应和传输次数相同。
面向消息的套接字特性总结如下:不可靠的、不按序传递的、以数据的高速传输为目的的套接字。
协议的最终选择
socket函数的第三个参数决定最终采用的协议。
前面已经通过socket函数的前两个参数传递了协议族信息和数据传输方式,这些信息还不足以决定采用的协议吗?为什么还需要传递第三个参数?
正如各位所想,传递前两个参数即可创建所需套接字。所以大部分情况下可以向第三个参数传递0,除非遇到以下这种情况:
同一协议族中存在多个数据传输方式相同的协议。
协议族相同、传输方式也相同,但协议不同。此时就需要通过第三个参数具体指定协议信息。
这里以PF_INET为例,PF_INET指IPv4网络协议族,SOCK_STREAM是面向连接的数据传输。满足这两个条件的只有IPPROTO_TCP,因此可以省略第三个参数创建面向连接的套接字:
int tcp_socket = socket(PF_INET, SOCK_STREAM, 0);
//或
int tcp_socket = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)
SOCK_DGRAM指的是面向消息的数据传输方式,满足上述条件的协议只有IPPROTO_UDP。因此可以通过以下方式创建面向消息的套接字:
int udp_socket = socket(PF_INET, SOCK_DGRAM, 0);
//或
int udp_socket = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP);
地址族和数据序列
IP地址
IP(Internet Protocol),即网络协议,是为了收发网络数据而分配给计算机的值。端口号并非赋予计算机的值,而是为了区分程序中创建的套接字而分配给套接字的序号。即IP地址是分配给计算机的值,端口号则是分配给计算机中各应用程序的套接字的值。
为使计算机连接到网络必须向其分配IP地址,IP地址分为两类:
IPv4(Internet Protocol version 4) 4字节地址族
IPv6(Internet Protocol version 6) 6字节地址族
二者主要的差别是在IP所用的字节数,目前通用的地址族是IPv4,IPv6是为了应对2010年前后IP地址耗尽的问题而提出的标准。
IPv4标准的4字节IP地址分为网络地址和主机地址,其中网络地址又分为A、B、C、D、E等类型。如图:
因此只需通过IP地址的第一个字节即可判断网络地址占用的字节数:
A类地址的首字节范围: 0 ~ 127
B类地址的首字节范围: 128 ~ 191
B类地址的首字节范围: 192 ~ 223
还可以这样表示:
A类地址第一个字节的首位以0开始, 即00000000
B类地址第一个字节的前2位以10开始, 即1000 0000, 128(十进制)
C类地址第一个字节的前3位以110开始, 1100 0000, 192(十进制)
网络地址 和 主机地址
网络地址(网络ID)是为区分网络而设置的一部分IP地址。假设向某一地址www.baidu.com传输数据,该公司内部构建了局域网,把所有计算机连接起来。因此首先应该向www.baidu.com网络传输数据,也就是说,并非一开始就浏览所有4字节IP地址,进而找到目标主机。而是仅浏览4字节IP地址的网络地址,先把数据传到www.baidu.com的网络。然后www.baidu.com网络(构成网络的路由器)接收到数据后,流程传输数据的主机地址(主机ID)并将数据传给目标计算机。
端口号
IP地址用于区分计算机,只要有IP地址就能向目标主机传输数据。但仅凭IP地址无法传输给最终的应用程序,因此需要端口号来对应套接字。
端口号就是在同一操作系统内为区分不同的套接字而设置的,因此无法将一个端口分配给不同的套接字。
端口号由16位构成,因此可分配的端口号范围是0 ~ 65535,其中0 ~ 1023这1024个端口是知名端口,一般分配给特定的应用程序,所以应该分配此范围之外的值。
虽然端口不可重复,但TCP套接字和UDP套接字不会共用端口,例:如果某TCP套接字使用9190端口,则其他TCP套接字就无法使用该端口,但UDP套接字可以使用。
总之,数据传输目标地址应同时包含IP地址和端口号,只有这样,数据才会被传输到最终的目的应用程序(应用程序套接字)。
地址信息的表示
应用程序中使用的IP地址和端口号以结构体的形式给出了定义,如下:
struct SOCKADDR_IN
{
sa_family_t sin_family; //地址族(Address Family), unsigned short.
uint16_t sin_port; //16位TCP/UDP端口号
struct in_addr sin_addr; //32位IP地址
char sin_zero[8]; //不使用
};
struct in_addr
{
in_addr_t s_addr; //32位IPv4地址,typedef unsigned int in_addr_t
};
成员sin_family:每种协议族适用的地址族均不同,例IPv4使用4字节地址族,IPv6使用6字节地址族。
成员sin_port:该成员保存16位端口号,它以网络字节顺序保存。
成员sin_addr:该成员保存32位IP地址信息,也以网络字节顺序保存。
成员sin_zero:无特殊含义,只是为使结构体SOCKADDR_IN的大小与SOCKADDR结构体(bind,connect,accept中的第二个参数类型, 记得显示的类型转换)大小保持一致而插入的成员。
SOCKADDR结构体定义如下:
struct SOCKADDR
{
sa_family_t sin_family; //地址族
char sa_data[14]; //地址信息
};
此结构体成员sa_data保存的地址信息中需包含IP地址和端口号,剩余部分应填充0,而这对于包含地址信息来讲非常麻烦,继而就有了新的更方便结构体SOCKADDR_IN。因此只需填充SOCKADDR_IN结构体,然后转换成SOCKADDR类型传递给相应函数即可。
网络字节顺序与主机字节顺序
不同CPU中,4字节整数型值1在内存空间中的保存方式是不同的,如下:
第一种保存方式: 00000000 00000000 00000000 00000001
第一种保存方式: 00000001 00000000 00000000 00000000
保存顺序的不同意味着对接收数据的解析顺序也不同,因此CPU的数据解析方式也分为2种:
大端序(Big Endian):高位字节存放到低位地址
小端序(Little Endian):高位字节存放到高位地址
例:在0x20号开始的地址中保存4字节int类型数0x12345678,两种保存方式如图:
如果两台保存方式不同的计算机进行套接字通信的时候,数据解析方式就会不一致,从而导致相关错误。因此,在通过网络传输数据时约定了一种统一方式,这种约定称为网络字节顺序(Network Byte Order),非常简单的统一为大端序。
因此,主机在想网络传输数据时应将以主机字节顺序的数据(即使该主机是以大端序保存的)转换成网络字节数据在进行网络传输,
这就是为什么要在填充SOCKADDR_IN结构体前将数据转换成网络字节顺序的原因了。
字节顺序的转换
常用的字节顺序转换的API函数:
unsigned short htons(unsigned short);
unsigned short ntohs(unsigned short);
unsigned long htonl(unsigned long);
unsigned long ntohl(unsigned long);
htons中的h代表主机(host)字节顺序。
htons中的n代表网络(network)字节顺序。
另外s表示short,l表示long,因此htons是 h、to、n、s的组合,可以解释为“把short类型主机字节顺序转换成网络字节顺序”,同理ntohs表示“把short型的网络字节顺序转换成主机字节顺序”。
网络地址的初始化
将字符串信息转换为网络字节顺序的整数型
SOCKADDR_IN中保存地址信息的成员是32位整数型的。意味着需将常见的IP地址(201.211.124.36)转换成4字节整数型数据,这要如何转。
好在有相应的函数帮助我们将字符串形式的IP地址转换成32位整数型数据,这些转换函数在转换类型的的同时还会自动进行网络字节顺序的转换。
in_addr_t inet_addr(const char* string);
/成功时返回32位大端序整型数据,失败时返回INADDR_NONE
int inet_aton(const char* string, struct in_addr* addr);
//string: 含有序转换的IP地址信息的字符串地址
//addr: 将保存转换结果的in_addr结构体变量的地址值
//返回值:成功时返回1(true),失败时返回0(false)
与之对应的将32位整数型转换成字符串形式的函数:
char* inet_ntoa(struct in_addr adr);
//成功时返回转换的字符串地址, 失败时返回-1
一般的网络地址初始化如下:
SOCKET srvSock;
struct SOCKADDR_IN addr;
memset(&addr, 0, sizeof(addr)); //初始化该结构体
char* srvIP = "211.217.168.13"; //服务器IP
char* srvPort = "9190"; //服务器端口
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(srvIP);
addr.sin_port = htons(atoi(srvPort));
bind(srvSock, (SOCKADDR*)&SOCKADDR_IN, sizeof(SOCKADDR));
INADDR_ANY
每次创建服务器都要输入IP地址会有些繁琐,因此可以使用INADDR_ANY常量分配服务器端的IP地址,即可自动获取运行服务器端的计算机的IP地址。
总结
通过socket函数声明展开了解协议族、数据传输方式以及最终协议的相关知识,此外还学习了IP的分类规则,知道IP和端口分别标识计算机和套接字,以及初始化时的地址的初始化相关的API说明。
为了统一数据传输时的解析,这里引出了主机字节顺序和网络字节顺序,知道数据统一使用网络字节顺序传输,并介绍了主机字节顺序和网络字节顺序相互转换的API接口。