不同CPU中,4字节整数1在内存空间的存储方式是不同的。4字节整数1可用2进制表示如下:
00000000 00000000 00000000 00000001
有些CPU以上面的顺序存储到内存,另外一些CPU则以倒序存储,如下所示:
00000001 00000000 00000000 00000000
若不考虑这些就收发数据会发生问题,因为保存顺序的不同意味着对接收数据的解析顺序也不同。
大端序和小端序
CPU向内存保存数据的方式有两种:
- 大端序(Big Endian):高位字节存放到低位地址(高位字节在前)。
- 小端序(Little Endian):高位字节存放到高位地址(低位字节在前)。
仅凭描述很难解释清楚,不妨来看一个实例。假设在 0x20 号开始的地址中保存4字节 int 型数据 0x12345678,大端序CPU保存方式如下图所示:
图1:整数 0x12345678 的大端序字节表示
对于大端序,最高位字节 0x12 存放到低位地址,最低位字节 0x78 存放到高位地址。小端序的保存方式如下图所示:
图2:整数 0x12345678 的小端序字节表示
不同CPU保存和解析数据的方式不同(主流的Intel系列CPU为小端序),小端序系统和大端序系统通信时会发生数据解析错误。因此在发送数据前,要将数据转换为统一的格式——网络字节序(Network Byte Order)。网络字节序统一为大端序。
主机A先把数据转换成大端序再进行网络传输,主机B收到数据后先转换为自己的格式再解析。
网络字节序转换函数
在《使用bind()和connect()函数》一节中讲解了 sockaddr_in 结构体,其中就用到了网络字节序转换函数,如下所示:
- //创建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); //端口号
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类型数据从网络字节序转换为主机字节序。
通常,以s
为后缀的函数中,s
代表2个字节short,因此用于端口号转换;以l
为后缀的函数中,l
代表4个字节的long,因此用于IP地址转换。
举例说明上述函数的调用过程:
- #include <stdio.h>
- #include <stdlib.h>
- #include <WinSock2.h>
- #pragma comment(lib, "ws2_32.lib")
- int main(){
- unsigned short host_port = 0x1234, net_port;
- unsigned long host_addr = 0x12345678, 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: %#lx\n", host_addr);
- printf("Network ordered address: %#lx\n", net_addr);
- system("pause");
- return 0;
- }
运行结果:
Host ordered port: 0x1234
Network ordered port: 0x3412
Host ordered address: 0x12345678
Network ordered address: 0x78563412
另外需要说明的是,sockaddr_in 中保存IP地址的成员为32位整数,而我们熟悉的是点分十进制表示法,例如 127.0.0.1,它是一个字符串,因此为了分配IP地址,需要将字符串转换为4字节整数。
inet_addr() 函数可以完成这种转换。inet_addr() 除了将字符串转换为32位整数,同时还进行网络字节序转换。请看下面的代码:
- #include <stdio.h>
- #include <stdlib.h>
- #include <WinSock2.h>
- #pragma comment(lib, "ws2_32.lib")
- int main(){
- char *addr1 = "1.2.3.4";
- char *addr2 = "1.2.3.256";
- unsigned long conv_addr = inet_addr(addr1);
- if(conv_addr == INADDR_NONE){
- puts("Error occured!");
- }else{
- printf("Network ordered integer addr: %#lx\n", conv_addr);
- }
- conv_addr = inet_addr(addr2);
- if(conv_addr == INADDR_NONE){
- puts("Error occured!");
- }else{
- printf("Network ordered integer addr: %#lx\n", conv_addr);
- }
- system("pause");
- return 0;
- }
运行结果:
Network ordered integer addr: 0x4030201
Error occured!
从运行结果可以看出,inet_addr() 不仅可以把IP地址转换为32位整数,还可以检测无效IP地址。
注意:为 sockaddr_in 成员赋值时需要显式地将主机字节序转换为网络字节序,而通过 write()/send() 发送数据时TCP协议会自动转换为网络字节序,不需要再调用相应的函数。
在socket中使用域名
客户端中直接使用IP地址会有很大的弊端,一旦IP地址变化(IP地址会经常变动),客户端软件就会出现错误。
而使用域名会方便很多,注册后的域名只要每年续费就永远属于自己的,更换IP地址时修改域名解析即可,不会影响软件的正常使用。
关于域名注册、域名解析、host 文件、DNS 服务器等本节并未详细讲解,请读者自行脑补。本节重点讲解如何使用域名。
通过域名获取IP地址
域名仅仅是IP地址的一个助记符,目的是方便记忆,通过域名并不能找到目标计算机,通信之前必须要将域名转换成IP地址。
gethostbyname() 函数可以完成这种转换,它的原型为:
- struct hostent *gethostbyname(const char *hostname);
hostname 为主机名,也就是域名。使用该函数时,只要传递域名字符串,就会返回域名对应的IP地址。返回的地址信息会装入 hostent 结构体,该结构体的定义如下:
- struct hostent{
- char *h_name; //official name
- char **h_aliases; //alias list
- int h_addrtype; //host address type
- int h_length; //address lenght
- char **h_addr_list; //address list
- }
从该结构体可以看出,不只返回IP地址,还会附带其他信息,各位读者只需关注最后一个成员 h_addr_list。下面是对各成员的说明:
- h_name:官方域名(Official domain name)。官方域名代表某一主页,但实际上一些著名公司的域名并未用官方域名注册。
- h_aliases:别名,可以通过多个域名访问同一主机。同一IP地址可以绑定多个域名,因此除了当前域名还可以指定其他域名。
- h_addrtype:gethostbyname() 不仅支持 IPv4,还支持 IPv6,可以通过此成员获取IP地址的地址族(地址类型)信息,IPv4 对应 AF_INET,IPv6 对应 AF_INET6。
- h_length:保存IP地址长度。IPv4 的长度为4个字节,IPv6 的长度为16个字节。
- h_addr_list:这是最重要的成员。通过该成员以整数形式保存域名对应的IP地址。对于用户较多的服务器,可能会分配多个IP地址给同一域名,利用多个服务器进行均衡负载。
hostent 结构体变量的组成如下图所示:
下面的代码主要演示 gethostbyname() 的应用,并说明 hostent 结构体的特性:
纯文本复制
- #include <stdio.h>
- #include <stdlib.h>
- #include <WinSock2.h>
- #pragma comment(lib, "ws2_32.lib")
- int main(){
- WSADATA wsaData;
- WSAStartup( MAKEWORD(2, 2), &wsaData);
- struct hostent *host = gethostbyname("www.baidu.com");
- if(!host){
- puts("Get IP address error!");
- system("pause");
- exit(0);
- }
- //别名
- for(int i=0; host->h_aliases[i]; i++){
- printf("Aliases %d: %s\n", i+1, host->h_aliases[i]);
- }
- //地址类型
- printf("Address type: %s\n", (host->h_addrtype==AF_INET) ? "AF_INET": "AF_INET6");
- //IP地址
- for(int i=0; host->h_addr_list[i]; i++){
- printf("IP addr %d: %s\n", i+1, inet_ntoa( *(struct in_addr*)host->h_addr_list[i] ) );
- }
- system("pause");
- return 0;
- }
运行结果:
Aliases 1: www.baidu.com
Address type: AF_INET
IP addr 1: 61.135.169.121
IP addr 2: 61.135.169.125
理解UDP套接字
TCP 是面向连接的传输协议,建立连接时要经过三次握手,断开连接时要经过四次握手,中间传输数据时也要回复ACK包确认,多种机制保证了数据能够正确到达,不会丢失或出错。
UDP 是非连接的传输协议,没有建立连接和断开连接的过程,它只是简单地把数据丢到网络中,也不需要ACK包确认。
UDP 传输数据就好像我们邮寄包裹,邮寄前需要填好寄件人和收件人地址,之后送到快递公司即可,但包裹是否正确送达、是否损坏我们无法得知,也无法保证。UDP 协议也是如此,它只管把数据包发送到网络,然后就不管了,如果数据丢失或损坏,发送端是无法知道的,当然也不会重发。
既然如此,TCP应该是更加优质的传输协议吧?
如果只考虑可靠性,TCP的确比UDP好。但UDP在结构上比TCP更加简洁,不会发送ACK的应答消息,也不会给数据包分配Seq序号,所以UDP的传输效率有时会比TCP高出很多,编程中实现UDP也比TCP简单。
UDP 的可靠性虽然比不上TCP,但也不会像想象中那么频繁地发生数据损毁,在更加重视传输效率而非可靠性的情况下,UDP是一种很好的选择。比如视频通信或音频通信,就非常适合采用UDP协议;通信时数据必须高效传输才不会产生“卡顿”现象,用户体验才更加流畅,如果丢失几个数据包,视频画面可能会出现“雪花”,音频可能会夹带一些杂音,这些都是无妨的。
与UDP相比,TCP的生命在于流控制,这保证了数据传输的正确性。
最后需要说明的是:TCP的速度无法超越UDP,但在收发某些类型的数据时有可能接近UDP。例如,每次交换的数据量越大,TCP 的传输速率就越接近于 UDP。
基于UDP的服务器端和客户端
前面的文章中我们给出了几个TCP的例子,对于UDP而言,只要能理解前面的内容,实现并非难事。
UDP中的服务器端和客户端没有连接
UDP不像TCP,无需在连接状态下交换数据,因此基于UDP的服务器端和客户端也无需经过连接过程。也就是说,不必调用 listen() 和 accept() 函数。UDP中只有创建套接字的过程和数据交换的过程。
UDP服务器端和客户端均只需1个套接字
TCP中,套接字是一对一的关系。如要向10个客户端提供服务,那么除了负责监听的套接字外,还需要创建10套接字。但在UDP中,不管是服务器端还是客户端都只需要1个套接字。之前解释UDP原理的时候举了邮寄包裹的例子,负责邮寄包裹的快递公司可以比喻为UDP套接字,只要有1个快递公司,就可以通过它向任意地址邮寄包裹。同样,只需1个UDP套接字就可以向任意主机传送数据。
基于UDP的接收和发送函数
创建好TCP套接字后,传输数据时无需再添加地址信息,因为TCP套接字将保持与对方套接字的连接。换言之,TCP套接字知道目标地址信息。但UDP套接字不会保持连接状态,每次传输数据都要添加目标地址信息,这相当于在邮寄包裹前填写收件人地址。
发送数据使用 sendto() 函数:
- ssize_t sendto(int sock, void *buf, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen); //Linux
- 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 的地址值结构体变量的长度。
UDP 发送函数 sendto() 与TCP发送函数 write()/send() 的最大区别在于,sendto() 函数需要向他传递目标地址信息。
接收数据使用 recvfrom() 函数:
- ssize_t recvfrom(int sock, void *buf, size_t nbytes, int flags, struct sockadr *from, socklen_t *addrlen); //Linux
- int recvfrom(SOCKET sock, char *buf, int nbytes, int flags, const struct sockaddr *from, int *addrlen); //Windows
由于UDP数据的发送端不不定,所以 recvfrom() 函数定义为可接收发送端信息的形式,具体参数如下:
- sock:用于接收UDP数据的套接字;
- buf:保存接收数据的缓冲区地址;
- nbytes:可接收的最大字节数(不能超过buf缓冲区的大小);
- flags:可选项参数,若没有可传递0;
- from:存有发送端地址信息的sockaddr结构体变量的地址;
- addrlen:保存参数 from 的结构体变量长度的变量地址值。
基于UDP的回声服务器端/客户端
下面结合之前的内容实现回声客户端。需要注意的是,UDP不同于TCP,不存在请求连接和受理过程,因此在某种意义上无法明确区分服务器端和客户端,只是因为其提供服务而称为服务器端,希望各位读者不要误解。
下面给出Windows下的代码,Linux与此类似,不再赘述。
服务器端 server.cpp:
- #include <stdio.h>
- #include <winsock2.h>
- #pragma comment (lib, "ws2_32.lib") //加载 ws2_32.dll
- #define BUF_SIZE 100
- int main(){
- WSADATA wsaData;
- WSAStartup( MAKEWORD(2, 2), &wsaData);
- //创建套接字
- SOCKET sock = socket(AF_INET, SOCK_DGRAM, 0);
- //绑定套接字
- sockaddr_in servAddr;
- memset(&servAddr, 0, sizeof(servAddr)); //每个字节都用0填充
- servAddr.sin_family = PF_INET; //使用IPv4地址
- servAddr.sin_addr.s_addr = htonl(INADDR_ANY); //自动获取IP地址
- servAddr.sin_port = htons(1234); //端口
- bind(sock, (SOCKADDR*)&servAddr, sizeof(SOCKADDR));
- //接收客户端请求
- SOCKADDR clntAddr; //客户端地址信息
- int nSize = sizeof(SOCKADDR);
- char buffer[BUF_SIZE]; //缓冲区
- while(1){
- int strLen = recvfrom(sock, buffer, BUF_SIZE, 0, &clntAddr, &nSize);
- sendto(sock, buffer, strLen, 0, &clntAddr, nSize);
- }
- closesocket(sock);
- WSACleanup();
- return 0;
- }
代码说明:
1) 第12行代码在创建套接字时,向 socket() 第二个参数传递 SOCK_DGRAM,以指明使用UDP协议。
2) 第18行代码中使用htonl(INADDR_ANY)
来自动获取IP地址。
利用常数 INADDR_ANY 自动获取IP地址有一个明显的好处,就是当软件安装到其他服务器或者服务器IP地址改变时,不用再更改源码重新编译,也不用在启动软件时手动输入。而且,如果一台计算机中已分配多个IP地址(例如路由器),那么只要端口号一致,就可以从不同的IP地址接收数据。所以,服务器中优先考虑使用INADDR_ANY;而客户端中除非带有一部分服务器功能,否则不会采用。
客户端 client.cpp:
- #include <stdio.h>
- #include <WinSock2.h>
- #pragma comment(lib, "ws2_32.lib") //加载 ws2_32.dll
- #define BUF_SIZE 100
- int main(){
- //初始化DLL
- WSADATA wsaData;
- WSAStartup(MAKEWORD(2, 2), &wsaData);
- //创建套接字
- SOCKET sock = socket(PF_INET, SOCK_DGRAM, 0);
- //服务器地址信息
- sockaddr_in servAddr;
- memset(&servAddr, 0, sizeof(servAddr)); //每个字节都用0填充
- servAddr.sin_family = PF_INET;
- servAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
- servAddr.sin_port = htons(1234);
- //不断获取用户输入并发送给服务器,然后接受服务器数据
- sockaddr fromAddr;
- int addrLen = sizeof(fromAddr);
- while(1){
- char buffer[BUF_SIZE] = {0};
- printf("Input a string: ");
- gets(buffer);
- sendto(sock, buffer, strlen(buffer), 0, (struct sockaddr*)&servAddr, sizeof(servAddr));
- int strLen = recvfrom(sock, buffer, BUF_SIZE, 0, &fromAddr, &addrLen);
- buffer[strLen] = 0;
- printf("Message form server: %s\n", buffer);
- }
- closesocket(sock);
- WSACleanup();
- return 0;
- }
先运行 server,再运行 client,client 输出结果为:
Input a string: C语言中文网
Message form server: C语言中文网
Input a string: c.biancheng.net Founded in 2012
Message form server: c.biancheng.net Founded in 2012
Input a string:
从代码中可以看出,server.cpp 中没有使用 listen() 函数,client.cpp 中也没有使用 connect() 函数,因为 UDP 不需要连接。