TCP/IP网络编程之地址族与数据序列
分配IP地址和端口号
IP是Internet Protocol(网络协议)的简写,是为收发网络数据而分配给计算机的值。端口号并非赋予计算机的值,而是为区分程序中创建的套接字而分配给套接字的序号
网络地址(Internet Address)
为使计算机连接到网络并收发数据,必须向其分配IP地址。IP地址分为两类:
- IPv4(Internet Protocol version 4):4字节地址族
- IPv6(Internet Protocol version 6):16字节地址族
IPv4和IPv6的差别主要是IP地址所用的字节数,目前通用的地址族为IPv4,IPv6是为了应对2010年前后IP地址耗尽的问题而提出的标准,即便如此,现在还是主要使用IPv4,IPv6的普及需要更长的时间
IPv4标准的4字节IP地址分为网络地址和主机(计算机)地址,且分为A、B、C、D、E等类型,图1-1展示了IPv4地址族,一般不会使用已被预约的E类地址,故省略
图1-1 IPv4地址族
网络地址(网络ID)是为区分网络而设置的一部分IP地址。假设向WWW.SEMI.COM公司传输数据,该公司内部构建了局域网,把所有计算机连起来。因此,首先应向SEMI.COM网络传输数据,也就是说,并非一开始就浏览所有4字节IP地址,进而找到目标主机;而是仅浏览4字节IP地址的网络地址,先把数据送到SEMI.COM网络,SEMI.COM网络(构成网络的路由器)接收到数据后,浏览传输数据的主机地址(主机ID)并将数据传给目标主机。图1-2展示了数据传输过程
图1-2 基于IP地址的数据传输过程
某主机向203.211.172.103和203.211.217.202传输数据,其中203.211.172和203.211.217为该网络的网络地址。所以,“向相应网络传输数据”实际上是向构成网络的路由器(Route)或交换机(Switch)传递数据,由接收数据的路由器根据数据中的主机地址向目标主机传递数据
若想构建网络,需要一种物理设备完成外网与本网主机之间的数据交换,这种设备便是路由器或交换机。它们实际上也是一种计算机,只不过是为特殊目的而设计运行的,因此有了别名。所以,如果在我们使用的计算机上安装适当的软件,也可以将其作为交换机。另外,交换机比路由器功能要简单一些,但实际用途差别不大
网络地址分类与主机地址边界
只需通过IP地址的第一个字节即可判断网络地址占用的字节数,因为我们根据IP地址的边界区分网络地址,如下所示:
- A类地址的首字节范围:0~127
- B类地址的首字节范围:128~191
- C类地址的首字节范围:192~223
还有如下这种表述方式:
- A类地址的首位以0开始,0000 0000为0,0111 111为127,与上面的0~127对应
- B类地址的前2位以10开始,1000 0000为128,1011 1111为191,与上面的128~191对应
- C类地址的前3位以110开始,1100 0000为192,1101 1111为223,与上面的192~223对应
正因如此,通过套接字收发数据时,数据传到网络后即可轻松找到正确主机
用于区分套接字的端口号
IP用于区分计算机,只要有IP地址就能找到目标主机,但仅凭这些无法传输给目标主机中的应用程序,毕竟处理数据靠的还是目标主机中的程序。假设用户在上网的同时,一边欣赏视频,一边浏览网页,这里至少需要两个套接字,一个接收视频数据,一个接收网页数据,那么问题来了,怎么区分这两个套接字呢?或者说,怎么区分到达的数据是正在观看的视频,还是正在浏览的网页呢?这里就需要用到端口号了
计算机中一般配有NIC(Network Interface Card,网络接口卡)数据传输设备。通过NIC向计算机内部传输数据时会用到IP,操作系统负责把传递到内部的数据适配给套接字,这时就要利用端口号了。也就是说,通过NIC接收的数据内有端口号,操作系统正是参考此端口号把数据传输给相应端口的套接字,如图1-3所示
图1-3 数据分配过程
端口号就是同一操作系统内区分不同套接字而设置的,因此无法将一个端口号分配给不同套接字。另外,端口号由16位构成,可分配的端口号范围是0~65535。但0~1023是知名端口(Well-known PORT),一般分配给特定应用程序,所以应当分配此范围之外的端口。另外,虽然端口号不能重复,但TCP套接字和UDP套接字不会共用端口号,所以允许重复。例如:如果某TCP套接字使用8500号端口,则其他TCP套接字就无法使用该端口号,但UDP套接字可以使用
总之,数据传输目标地址同时包含IP地址和端口号,只有这样,数据才会被传输到最终的目的应用程序
地址信息的表示
应用程序中使用IP地址和端口号以结构体的形式给出了定义。这里将以IPv4为中心,围绕此结构体讨论目标地址的表示方法
1 2 3 4 5 6 7 |
|
该结构体中提到另一结构体in_addr定义如下,它用来存放32位IP地址
1 2 3 4 |
|
讲解以上两个结构体前观察一些数据类型。uint16_t、int_addr_t等类型可以参考POSIX(Portable Operating System Interface,可移植操作系统接口)。POSIX是为Unix系列操作系统设立的标准,它定义了一些其他数据类型,如表1-1
数据类型名称 | 数据类型说明 | 声明的头文件 |
int8_t | signed 8-bit int | sys/types.h |
uint8_t | unsigned 8-bit int(unsigned char) | |
int16_t | signed 16-bit int | |
uint16_t | unsigned 16-bit int(unsigned short) | |
int32_t | signed 32-bit int | |
uint32_t | unsigned 32-bit int(unsigned long) | |
sa_family_t | 地址族(address family) | sys/socket.h |
socklen_t | 长度(length of struct) | |
in_addr_t | IP地址,声明为uint32_t | netinet/in.h |
in_port_t | 端口号,声明为uint16_t |
从这些数据类型声明也可掌握之前结构体的含义,那为什么需要额外定义这些数据类型呢?这是考虑到扩展性的结果。如果使用int32_t类型的数据,就能保证在任何时候都占用4个字节,即时将来使用64位表示int类型也是如此
结构体sockaddr_in的成员分析
成员sin_family:
每种协议适用的地址族均不同。比如,IPv4使用4字节地址族,IPv6使用16字节地址族,可以参考表1-2保存的sin_family地址信息
地址族(Adddress 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,否则无法得到想要的结果,后续还会介绍sockaddr
从之前介绍的代码也可看出,sockaddr_in结构体变量地址将以如下方式传递给bind函数,后续还会介绍到bind函数,现在来看下下面参数传递和类型转换的代码
1 2 3 4 5 |
|
此处重要的是第二个参数的传递,实际上,bind函数的第二个参数期望得到sockaddr结构体变量地址值,包括地址族、端口号、IP地址等。从下列代码也可看出,直接向sockaddr结构体填充这些信息会带来麻烦
1 2 3 4 5 |
|
此结构体成员要求sa_data保存的信息需包含IP地址和端口号,剩余部分应填充0,这也是bind函数要求的。而这对于包含地址信息来讲非常麻烦,继而就有了新的结构体sockaddr_in。若按照之前的讲解填写sockaddr_in结构体,则将生成符合bind函数要求的字节流。最后转换为sockaddr型的结构体变量,再传递给bind函数即可
sockaddr_in是保存IPv4地址信息的结构体,那为何还需要通过sin_family单独指定地址族信息呢?这还是与sockaddr结构体有关,结构体sockaddr并非只为IPv4设计,这从保存地址信息的数组sa_data长度为14字节也可看出。因此,结构体sockaddr要求在sin_family中指定地址族信息,是为了与sockaddr保持一致,sockaddr_in结构体中也有地址族信息
网络字节序与地址变换
不同CPU中,4字节整数值1在内存空间的保存方式是不同的。4字节整数型值1可用二进制表示如下:
00000000 00000000 00000000 00000001
有些CPU以这种顺序保存到内存,另一些CPU则以倒序保存
00000001 00000000 00000000 00000000
若不考虑这些就收发数据则会发生问题,因为保存顺序的不同意味着对接收数据的解析顺序也不同
字节序(Order)与网络字节序
CPU向内存保存数据的方式有两种,这意味着CPU解析数据的方式也有两种:
- 大端序:高位字节存放到低位地址
- 小端序:高位字节存放到高位地址
用下面的例子进行说明,假设在0x20号开始的地址中保存4字节int类型数0x12345678,大端序CPU保存如图1-4所示:
图1-4 大端序字节表示
整数0x12345678,0x12是最高位字节,0x78是最低位字节。因此,大端序先保存最高位字节0x12(最高位字节0x12存放到低位地址),小端序保存方式如图1-5所示:
图1-5 小端序字节表示
先保存的是最低位字节0x78,从以上分析可以看出,每种CPU的数据保存方式均不同。因此,代表CPU数据保存方式的主机字节序(Host Byte Order)在不同CPU中也各不相同。目前主流的Intel系列CPU以小端序方式保存数据。那么,如果两台字节序不同的计算机之间交换数据,势必会出现这样的问题,大端序计算机传输数据0x1234时未考虑字节序问题,直接以0x12、0x34的顺序发送,结果接收端以小端序方式保存数据,因此小端序接收到的数据则变为0x3412,而非0x1234。正因如此,在通过网络传输数据时约定统一使用大端序传输数据
字节序转换
既然我们明白了在填充sockaddr_in结构体前将数据转换成网络字节序。接下来,我们就来了解一下关于转换字节序的函数:
- 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型数据从主机字节序转化为网络字节序”。而ntohs可以解释为“把short类型数据从网络字节序转化为主机字节序”。通常,以s作为后缀的函数中,s代表两个字节short,因此用于端口号转换,以l作为后缀的函数中,1代表4个字节,因此用于IP地址转换
下面通过示例说明以上函数的调用过程
endian_conv.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
- 第6、8行:各保存2个字节、4个字节的数据。当然,若运行的CPU不同,则保存的字节序也不同
- 第11、12行:变量host_port、host_addr中的数据转化为网络字节序。若运行环境为小端序CPU,则改变之后的字节序保存
编译并运行endian_conv.c
1 2 3 4 5 6 |
|
这就是小端序CPU中运行的结果。如果在大端序CPU中运行,则变量值不会改变
网络地址的初始化与分配
前面已讨论过网络字节序列,接下来介绍bind函数为代表的结构体的应用
将字符串信息转换为网络字节序的整型
sockaddr_in中保存地址信息的成员为32位整数型,因此,为了分配IP地址,需要将其表示为32位整数型数据。这对于只熟悉字符串信息的我们并非易事,对于IP的表示,我们熟悉点分十进制法,而非整数型数据表示法。幸运的是,有个函数会帮我们将字符串形式的IP转换为32位整数型数据
1 2 |
|
如果向该函数传递“211.214.107.99”的点分十进制格式的字符串,它会将其转换为32位整数型数据并返回。当然,该整数型值满足网络字节序。另外,该函数的返回值类型in_addr_t在内部声明为32位整数型。下面示例表示该函数的调用过程
inet_addr.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
- 第6行:一个字节能表示的最大整数为255,也就是说,它是错误的IP地址。利用该错误地址检验inet_addr函数的错误检测能力
- 第8、13行:通过运行结果验证第8行的函数能正常调用,而第13行的函数调用出现异常
编译并运行inet_addr.c
1 2 3 |
|
从运行结果上来看,inet_addr函数不仅可以把IP地址转换为32位整数型,而且可以检测无效的IP地址。另外,从输出结果可以验证确实转换为网络字节序
inet_aton函数与inet_addr函数在功能上完全相同,也将字符串形式IP地址转换为32位网络字节序整数并返回,只不过该函数利用了in_addr结构体
1 2 |
|
- string:含有需转换的IP地址信息的字符串地址
- addr:将保存转件结果的in_addr结构体变量的地址值
实际编程中若要调用inet_addr函数,需将转换后的IP地址信息代入sockaddr_in结构体中声明的in_addr结构体变量。而inet_aton函数则不需此过程,原因在于,若传递in_addr结构体变量地址值,函数会自动把结果填入该结构体变量。通过示例了解一下inet_aton函数调用过程
inet_aton.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
- 第9、10行:转换后的IP地址信息需保存到sockaddr_in的in_addr型变量才有意义。因此,inet_aton函数的第二个参数要求得到in_addr型的变量地址值。这就省去了手动保存IP地址信息的过程
编译并运行inet_aton.c
1 2 3 |
|
最后再介绍一个与inet_aton函数相反的函数,此函数可以把网络字节序整数型IP转换成我们熟悉的字符串形式
1 2 |
|
该函数将通过参数传入的整数型IP地址转换为字符串格式并返回。但调用时需小心 ,返回值类型为char指针,返回字符串地址意味着字符串已保存到内存空间了,但该函数未向程序员要求分配内存,而是在其函数内部申请内存并保存字符串。也就是说,调用完函数后,应立即将字符串复制到其他的内存空间。因为,若再次调用inet_ntoa函数,则有可能覆盖之前保存的字符串信息。