一、分配给套接字的IP地址与端口号
IP是Internet Protocol(网络协议)的简写,是为收发网络数据而分配给计算机的值。端口号并非赋予计算机的值,而是为区分程序中创建的套接字而分配给套接字的序号。
1.1 网络地址
IP地址分为两类:
- IPv4:4字节地址族
- IPv6:16字节地址族
IPv4和IPv6的差别主要是表示IP地址所用的字节数,目前通用的地址族为IPv4,IPv6是为了应对2010年前后IP地址耗尽的问题而提出的标准。
IPv4标准的4字节IP地址分为网络地址而后主机(指计算机)地址,且分为A、B、C、D、E等类型。下图展示了IPv4地址族,一般不会使用已被预约了的E类地址,故忽略:
网络地址是为区分网络而设置的一部分IP地址。假设向WWW.SEMI.COM公司传输数据,该公司内部构建了局域网,把所有计算机连接起来。因此,首先应向SEMI.COM网络传输数据,也就是说,并非一开始就浏览所有4字节IP地址,进而找到目标主机;而是仅浏览4字节IP地址的网络地址,先把数据传到SEMI.COM的网络。SEMI.COM网络(构成网络的路由器)接收到数据后,浏览传输数据的主机地址(主机ID)并将数据传给目标计算机。下图展示了数据传输过程:
某主机向203.211.172.103和203.211.217.202传输数据,其中203.211.172和203.211.217为该网络的网络地址。所以,”向相应网络传输数据“实际上是向构成网络的路由器(router)或交换机(switch)传递数据,由接收数据的路由器根据数据中的主机地址向目标主机传递数据。
路由器和交换机
若想构建网络,需要一种物理设备完成外网与本网主机之间的数据交换,这种设备便是路由器或交换机。它们实际上也是一种计算机,只不过是为特殊目的而设计运行的,因此有了别名。所以,如果在我们使用的计算机上安装适当的软件,也可以将其用作交换机。另外,交换机比路由器功能要简单一些,而实际用途差别不大。
1.2 网络地址分类与主机地址边界
只需通过IP地址的第一个字节即可判断网络地址占用的字节数,因为我们根据IP地址的边界区分网络地址,如下所示:
- A类地址的首字节范围:0-127
- B类地址的首字节范围:128-191
- C类地址的首字节范围:192-223
还有如下的表述方式:
- A类地址的首位以0开始
- B类地址的前两位以10开始
- C类地址的前三位以110开始
1.3 用于区分套接字的端口号
IP用于区分计算机,只要有IP地址就能向目标主机传输数据,但仅凭这些无法传输给自最终的应用程序。假设我们看视频的同时打开了一个网页,那么至少需要一个接收视频数据的套接字和一个接收网页信息的套接字。问题在于如何区分两者。简言之,传输到计算机的网络数据是发给播放器,还是发给浏览器?
我们将问题概括描述,如果我们想要接收多台计算机发来的数据,则需要相应个个数的套接字,那么如何区分这些套接字呢?
计算机中一般配有NIC(Network Interface Card,网络接口卡)数据传输设备。通过NIC向计算机内部传输数据时会用到IP。操作系统负责把传递到内部的数据适当分配给套接字,这时就要利用端口号。也就是说,通过NIC接收的数据内有端口号,操作系统正式参考此端口号把数据传输给相应端口的套接字,如图3-3所示:
端口号就是在同一操作系统内为区分不同套接字而设置的,因此无法将1个端口号分配给不同套接字。另外,端口号由16位构成,可分配的端口号范围是0-65535。但0-1023是知名端口,一般分配给特定应用程序,所以应当分配此范围之外的值。另外,虽然端口号不能重复,但TCP套接字和UDP套接字不会共用端口号,所以允许重复。例如,如果某TCP套接字使用9190号端口,则其他TCP套接字就无法使用该端口号,但UDP套接字可以使用。
总之,数据传输目标地址应同时包含IP地址和端口号,只有这样,数据才会被传输到最终的目的应用程序中(应用程序套接字)。
二、地址信息的表示
2.1 表示IPv4的结构体
在填写地址信息时,我们需要了解如下的信息:
- 采用哪一种地址族?(基于IPv4的地址族)
- IP地址是什么?(211.204.214.76)
- 端口号是多少?(2048)
结构体定义为如下形态就可以回答上述问题,此结构体将作为地址信息传递给bind函数。
struct sockaddr_in{
sa_family_t sin_family; //地址族(Address Family)
unit16_t sin_port; //16位TCP/UDP端口号
struct in_addr sin_addr; //32位IP地址
char sin_zero[8]; //不使用
}
该结构体中涉及到的另一个结构体in_addr定义如下,它用于存放32位IP地址:
struct in_addr{
in_addr_t s_addr; //32位IPv4地址
}
在介绍以上两个结构体前需要先了解一些数据类型。uint16_t、in_addr_t等类型可以参考POSIX(Portable Operating System Interface,可移植操作系统接口)。POSIX是为UNIX系列操作系统设立的标准,它定义了一些其他数据类型,如下表所示:
额外定义这些数据类型是考虑到了扩展性的结果。如果使用int32_t类型的数据,就能保证在任何使用都占用4字节,即使将来用64位表示int类型也是如此。
2.2 结构体sockaddr_in的成员分析
成员sin_family
每种协议族适用的地址族均不同。比如,IPv4使用4字节地址族,IPv6使用16字节地址族。可以参考下表的sin_family地址信息:
地址族(Address Family) | 含义 |
---|---|
AF_INET | IPv4网络协议中使用的地址族 |
AF_INET6 | IPv6网络协议中使用的地址族 |
AF_LOCAL | 本地通信中采用的UNIX协议的地址族 |
注意,AF_LOCAL只是为了说明具有多种地址族而添加的,并不常见。
成员sin_port
该成员保存16位端口号,重点在于,它以网络字节序保存
成员sin_addr
该成员保存32位IP地址信息,且也以网络字节序保存。理解好该成员应同时观察结构体in_addr,可以看到in_addr声明为uint32_t,因此只需当作32位整数型即可。
成员sin_zero
无特殊含义。只是为使结构体sockaddr_in的大小与sockaddr结构体大小保持一致而插入的成员。必需填充为0,否则无法得到想要的结果。
bind函数参数传递
sockaddr_in结构体变量地址值将以如下方式传递给bind函数:
struct sockaddr_in serv_addr;
if(bind(serv_sock,(struct sockaddr *)&serv_addr,sizeof(serv_addr))==-1)
error_handling("bind() error");
这里重要的是第二个参数的传递。实际上,bind函数的第二个参数期望得到sockaddr结构体变量地址值,包括地址族、端口号、IP地址等。但从下面给出的sockaddr的定义中也可看出,直接向sockaddr结构体填充这些信息会带来麻烦。
struct sockaddr{
sa_family_t sin_family; //地址族(Address Family)
char sa_data[14]; //地址信息
}
此结构体成员sa_data保存的地址信息中包含IP地址和端口号,剩余部分填充0,这也是bind函数要求的。但这个结构体没有区分IP地址和端口号,因此使用起来比较麻烦,继而就有了新的结构体sockaddr_in。如果按照sockaddr_in的方式进行填充,那么可以生成符合bind函数要求的字节流。最后转换为sockaddr型的结构体变量,再传递给bind函数即可。
sin_family
sockaddr_in是保存IPv4地址信息的结构体,那为何还要通过sin_family单独指定地址信息族信息呢?这与之前讲过的sockaddr结构体有关。结构体sockaddr并非只为IPv4设计,这从保存地址信息的sa_data长度为14字节也可以看出。因此,结构体sockaddr要求在sin_family中指定地址族信息。为了与sockaddr保持一致,sockaddr_in结构体中也有地址族信息。
三、网络字节序与地址变换
3.1 字节序与网络字节序
CPU在内存中保存数据的方式有两种:
- 大端编址(Big Endian):高位字节存放到低位地址
- 小端编址(Little Endian):低位字节存放到低位地址
假设在0x20开始的地址中保存4字节int类型数0x12345678,大端和小端的对比如下:
因为部分机器采用大端编址而部分机器采用小端编址,因此在通过网络传输数据时需要约定统一方式,这种约定称为网络字节序(Network Byte Order)。实现也非常简单,将编址统一为大端编址。
3.2 字节序转换
帮助转换字节序的函数如下:
- unsigned short htons(unsigned short)
- unsigned short ntohs(unsigned short)
- unsigned long htonl(unsigned long)
- unsigned long ntohl(unsigned long)
其中,htons中的h代表主机(host)字节序,n代表网络(network)字节序。
另外,s指的是short,l指的是long(Linux中long类型占用4个字节,这很关键)。因此,htons是h、to、n、s的组合,也可以解释为“把short型数据从主机字节序转化为网络字节序”。
通常,以s作为后缀的函数中,s代表2个字节short,因此用于端口号转换;以l作为后缀的函数中,l代表4个字节,因此用于IP地址转换。
下面的函数示例说明了以上函数的调用过程:
#include<stdio.h>
#include<arpa/inet.h>
int main(int argc,char *argv[]){
unsigned short host_port=0x1234;
unsigned short net_port;
unsigned long host_addr=0x12345678;
unsigned long net_addr;
net_port=htons(host_port);
net_addr=htonl(host_addr);
printf("Host ordered port: %#x \n",host_port);
printf("Network ordered port: %#x \n",net_port);
printf("Host Ordered address: %#x \n",host_addr);
printf("Network ordered port: %#x \n",net_addr);
return 0;
}
运行结果:
这就是在小端编址CPU中运行的结果,如果在大端编址CPU中运行,那么变量值不会改变。
四、网络地址的初始化与分配
4.1 将字符串信息转换为网络字节序的整数型
sockaddr_in中保存地址信息的成员为32位整数型。因此,为了分配IP地址,需要将其表示为32位整数型数据。有一个函数会帮我们将字符串形式的IP地址转换成32位整数型数据。这个函数在转换类型的同时进行网络字节序转换:
#include<arpa/inet.h>
in_addr_t inet_addr(const char * string);
//成功时返回32位大端序整数型值,失败时返回INADDR_NONE
如果向该函数传递类似“211.214.107.99"的点分十进制格式的字符串,它会将其转换为32位整数型数据并返回。当然,该整数型值满足网络字节序。另外,该函数的返回值类型in_addr_t在内部声明为32位整数型,下列示例表示了该函数的调用过程:
从运行结果可以看出,inet_addr函数不仅可以把IP地址转成32位整数型,而且可以检测无效的IP地址。
inet_aton函数与inet_addr函数在功能上完全相同,也将字符串形式IP地址转换为32位网络字节序整数并返回。只不过该函数利用了in_addr结构体,且其使用频率更高。
#include<arpa/inet.h>
int inet_aton(const char *string, struct in_addr *addr);
//成功时返回1,失败时返回0
- string:含有需转换的IP地址信息的字符串地址值
- addr:将保存转换结果的in_addr结构体变量的地址值
实际编程中,如果要调用inet_addr函数,需将转换后的IP地址信息代入sockaddr_in结构体中声明的in_addr结构体变量。而inet_aton函数则不需此过程。原因在于,若传递in_addr结构体变量地址值,函数会自动把结果填入该结构体变量。通过如下的示例可以了解inet_aton函数调用过程:
4.2 网络地址初始化
现在介绍套接字创建过程中常见的网络地址信息初始化方式:
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)); //基于字符串的端口号初始化
上述memset的目的是将sockaddr_in结构体的成员sin_zero初始化为0。另外,最后一行代码调用的atoi函数把字符串类型的值转换成整数型。上述代码的功能就是利用字符串格式的IP地址和端口号初始化了sockaddr_in结构体变量。
4.3 客户端地址信息初始化
上面的网络地址信息初始化过程主要针对服务器端而非客户端。给套接字分配IP地址和端口号主要是为下面这件事做准备:
”请把进入IP 211.217.168.13、9190端口的数据传给我!“
反观客户端中连接请求如下:
”请连接到IP 211.217.168.13、9190端口!“
请求方法不同意味着调用的函数也不同。服务端的准备工作通过bind函数完成,而客户端则通过connect函数完成。因此,函数调用前需准备的地址值类型也不同。服务端声明sockaddr_in结构体变量,将其初始化为赋予服务器IP和套接字的端口号,然后调用bind函数;而客户端则声明sockaddr_in结构体,并初始化为要与之连接的服务端套接字的IP和端口号,然后调用connect函数。
4.3 INADDR_ANY
每次创建服务器端套接字都要输入IP地址有些繁琐,此时可如下初始化地址信息:
struct sockaddr_in addr;
char * serv_port="9190";
memset(&addr,0,sizeof(addr));
addr.sin_family=AF_INET;
addr.sin_addr.s_addr=htonl(INADDR_ANY);
addr.sin_port=htons(atoi(serv_port));
与之前方式最大的区别在于,利用常数INADDR_ANY分配服务器端的IP地址。若采用这种方式,则可自动获取运行服务器端的计算机IP地址,不必亲自输入。而且,若同一计算机中已分配多个IP地址(多宿主(Multi-homed)计算机,一般路由器属于这一类),则只要端口号一致,就可以从不同IP地址接收数据。因此,服务器端中优先考虑这种方式。而客户端中除非带有一部分服务器端功能,否则不会采用。
创建服务器端套接字需要IP地址的原因
初始化服务器端套接字时应分配所属计算机的IP地址,因为初始化时使用的IP地址非常明确,那为何还要进行IP初始化呢?如前所述,同一计算机中可以分配多个IP地址,实际IP地址的个数与计算机中安装的NIC的数量相等。即使是服务器端套接字,也需要决定应接收哪个IP传来的(哪个NIC传来的)数据。因此,服务器端套接字初始化过程中要求IP地址信息。另外,若只有一个NIC,则直接使用INADDR_ANY。
4.4.第一章的hello_server.c和hello_client.c运行过程
第一章中执行以下命令以运行相当于服务端的hello_server.c:
./hserver 9190
通过代码可知,向main函数传递的9190为端口号。通过此端口创建服务器端套接字并运行程序,但并未传递IP地址,因为可以通过INADDR_ANY指定IP地址。
执行下列命令以运行客户端的hello_client.c。与运行服务端的方式相比,最大的区别是传递了IP地址信息:
./hclient 127.0.0.1 9190
127.0.0.1是回送地址,指的是计算机自身IP。在第一章的示例中,服务器端和客户端在同一计算机中运行,因此,连接目标服务器端的地址为127.0.0.1。当然,若用实际IP地址代替此地址也能正常运转。如果服务器端和客户端分别在2台计算机中运行,则可以输入服务器端IP地址。
4.5 向套接字分配网络地址
讨论了sockaddr_in结构体初始化的方法后,接下来需要将初始化的地址信息分配给套接字。bind函数负责这项操作
#include<sys/socket.h>
int bind(int sockfd,struct sockaddr * myaddr, socklen_t addrlen);
- sockfd:要分配地址信息(IP地址和端口号)的套接字文件描述符
- myaddr:存有地址信息的结构体变量地址值
- addrlen:第二个结构体变量的长度
如果此函数调用成功,则将第二个参数指定的地址信息分配给第一个参数中的相应套接字。下面给出服务器端常见套接字初始化过程:
int serv_sock;
struct sockaddr_in serv_addr;
char * serv_port = "9190";
serv_sock = socket(PF_INET,SOCK_STREAM,0);
memset(&serv_addr,0,sizeof(serv_addr));
serv_addr.sin_family=AF_INET;
serv_addr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_addr.sin_port=htons(atoi(serv_port));
bind(serv_sock,(struct sockaddr *)&serv_addr,sizeof(serv_addr));
服务器端代码结构默认如上,省略了一些异常处理代码。