在Linux中,有一句非常流行的话:Linux中一切皆文件。确实如此,在Linux中各种设备都可以通过文件的方式来操作,外设的文件通常称为设备文件。而Linux中的网络通信也是通过操作网络文件描述符来实现的。
在之前的博客《运输层简介》中咱们知道:互联网中的设备与设备间进行必须要知道双方的IP地址以及端口号。IP地址可以找到通信的主机,而端口号指出真正通信的进程。这就是常说的socket通信,socket = < IP > : < port>。在Linux中网络通信也是需要这些信息的,在Linux系统中称之为套接字地址结构。在Linux中套接字地址结构通常以sockaddr开头,下面介绍几种常用的socket地址结构。
struct sockaddr {
sa_family_t sa_family;/*网络通信协议的域,和socket的第一个参数一致;常用PF_INET(AF_INET)*/
char sa_data[14];
}
struct sockaddr_in {
u8 sin_len;/*固定长度16*/
u8 sin_family; /*协议的domain*/
u16 sin_port;/*通信使用的端口号*/
struct in_addr sin_addr;/*通信使用的IP地址*/
char sin_zero[8];/*保留字段,为0*/
}
struct sockaddr_un {
sa_family_t sun_family;/*domain*/
char sun_path[UNIX_PATH_MAX];/*108的长度,保存路径*/
}
typedef unsigned short sa_family_t;
struct in_addr{
u32 s_addr;
}
其中struct sockaddr是通用的套接字地址结构,它可以在不同协议族之间进行强制转换。struct sockaddr 和struct sockaddr_in的大小是相同的,下图为对应的关系。struct sockaddr_in为以太网地址结构,而struct sockaddr_un则是Unix域的协议族地址结构,其主要使用在同一台主机上的进程之间的通信,其速度会比使用以太网结构的通信快上一倍。
构建网络框架API
这里主要介绍下构建网络框架中的各个函数的功能,具体的测试代码就不贴了,网上一大堆。
创建网络插口函数socket()
在Linux中一切皆文件,即可以通过读写文件的操作方式来操作设备。网络通信也是如此的,其中socket()函数就是在内核中创建一个sock描述,并且和一个文件描述符进行关联。后续操作此文件描述符即可以控制此网络接口socket。socket()函数成功会返回一个套接字文件描述符。
int socket(int domain, int type, int protocol);
socket原型如上,其中domain用于设置网络通信的域,函数socket根据这个参数选择通信协议的族。以太网中应该使用PF_INET这个域(AF_INET和其值是一样的)。下面列举了domain的的值,常用的使用了红色标记。
- PF_UNIX:主要用于Unix域通信,即本地通信。使用Unix通信时速度会比其他的API速度快上一倍。
- PF_INET :IPV4通信,绝大部分情况下都是使用这个域。
- PF_NETLINK: 主要用于netlink通信,即用户空间和内核之间的通信。
- PF_PACKET : 主要用于直接访问网卡MAC的数据,即直接操作网卡上的帧。
而type则是用于设置套接字通信的类型(即协议),如常见的控制传输协议TCP的类型为SOCK_STREAM、用户数据包协议UDP的类型为SOCK_DGRAM、以及原始套接字的类型为SOCK_RAM。下图是其可选的值
注意:并不是所有的协议族domain都实现了这些协议类型。
而socket函数的第三个参数protocol用于指定某个协议的特定类型,即对type的拓展。而很多协议往往只有一种特定类型,所以此时仅能设置为0。但是像SOCK_RAM和SOCK_PACKET这样的协议,就需要设置这个参数来选择协议特定的类型。
创建tcp套接字的时候使用socket(PF_INET,SOCK_STREAM,0),而创建UDP的时候是使用socket(PF_INET,SOCK_DGRAM,0)。socket创建的流程如下。
用户空间调用socket时会调用内核中的sys_socket()。其主要是创建内核socket结构(和应用层中的不一致),分配资源,如队列(接收、发送、异常)、根据参数给ops、type等复制。同时还会将内核socket和文件描述符进行绑定。最后把文件描述符返回给应用层。这样就可以通过文件描述符来查找到对应的内核socket结构,即可以通过操作文件来实现对网络通信的操作。
note:套接字文件描述符从形式上与通用文件描述符没有区别,判断一个文件描述符是否是一个套接字描述符可以通过:调用函数fstat()获得文件描述符的模式,然后将模式的S_IFMT部分与标识符S_IFSOCK比较。这样就可以知道一个文件描述符是否为套接字描述符。可以用以下的代码来实现。
int issockettype(int fd)
{
struct stat st;
int err = fstat(fd,&st);
if(err<0){return -1;}
if((st.st_mode & S_IFMF) == S_IFSOCK){
return 1;
}else{
return 0;
}
}
bind()用于绑定地址结构
成功建立套接字后,通过文件描述符可以找到内核socket,可以得到协议的参数以及对应的操作函数。但是此时socket文件描述符和网络中的IP以及端口号之类的并没有联系起来。咱们可以通过bind()使文件描述符和网络地址结构进行绑定,绑定后socket文件描述符就和网络地址结构中的IP、端口、类型等关联起来了。bind函数只用于服务器端的服务器网络接口的绑定,其它地方可以不使用。函数原型如下
int bind(int sockfd, struct sockaddr*my_addr,socket_len addrlen)
bind()函数主要用于把网络文件描述符和网络地址结构进行绑定,这样就可以使用sockfd来监听网络上的状态了。bind常用于服务器端,因为服务器开机后就一直运行着服务程序,不知道什么时候会有客户端的连接与通信(处理被动端)。所以在TCP中bind后会有accept()函数用于建立连接;而UDP中会使用recvfrom()函数来接收外面的数据。假如没有bind网络地址,服务器并不知道用监听那个IP、port的动静。所以无法进行通信。
listen()、accept()监听本地端口(在TCP通信中使用)
在C/S架构中,服务器上的服务程序运行之后需要一直监听客户端发送的建立连接请求。收到请求,建立连接之后双方才可以进行通信。由于服务器上资源有限,且服务器同一时间仅能处理一个客户连接,当多客户端的连接请求同时到来的时候,服务器并不能同时处理过来,所以需要将不能处理的客户端连接请求放到等待队列中。队列的长度由listen()函数来指定,受操作系统和硬件资源的影响,队列长度不能无限大。当超过了最大长度时,内核使用最大长度。当队列长度已满时,客户端发来的请求便会丢失。客户端connect()函数会返回ECONNREFUSED的错误。listen的流程和上述bind()流程类型,先根据sockfd描述符找到对应的内核socket,然后调用socket->ops中的listen函数。
accept()函数则是在建立连接后,内核创建一个新的socket用于记录客户端的信息。函数成功后返回客户端的文件描述符,以及通过参数能获取到客户端的IP、端口、类型等信息。和客户端通信时需要使用新连接的客户端套接字描述符,其流程如下:
连接目标网络connect()函数
在客户端建立套接字后,不需要进行地址绑定等操作。可以直接向服务器发起建立连接请求。连接服务器的函数为connect,和服务器建立连接需要指定服务器的地址结构以及客户端的文件描述符。连接建立成功后,就可以使用这个socket描述符和服务器进行通信。当服务器上面的连接建立请求队列已满时,此函数会返回ECONNREFUSED类型的错误码。函数的调用流程如下:
从上述可知后续的一系列函数(如bind、listen、accept、connect等)都依赖socket()创建的文件描述符sockfd,通过文件描述符可以查到到对应的内核socket。而内核中的socket中的ops(操作函数)会根据socket()参数来指定特定的协议操作方法。
close函数
当通信结束后可以使用close()函数来关闭sockfd。close()会释放内核中对应的sock资源以及文件描述符。在网络通信中也可以使用shutdowndown函数来关闭通信,其中可以SHUT_RD(切断读)、SHUT_WR(切断写)、SHUT_RDWR(读写都关闭,和close等价)。
网络字节序以及IP地址转换处理
由于在网络传输的数据和本地的数据之间可能存在字节序的对应问题,所以在网络编程中需要处理好字节序问题。下面将介绍一些字节序相关的函数以及对字节序进行简单的简绍。
字节序是由于CPU和OS对多字节变量的内存存储顺序不同而产生的,分为大端和小端存储。小端字节序,在表示变量的内存地址的起始地址存放低字节;而大端字节序,在表示变量的内存地址的起始地址存放高字节。
由于主机的千差万别,主机的字节序不能做到统一,但是对于网络上传输的变量,它们的值必须有一个统一的表示方法。网络字节序是指多字节变量在网络传输时的表示方法,网络字节序采用大端字节序的表示方法。在Linux下其主要函数如下:
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
函数传入的变量为需要转换的变量,返回值为转换后的数值;函数的命名规则为==“字节序”to“字节序”“变量类型”==,h表示host即主机,n表示network即网络字节序,l表示long型变量;s表示short型变量。在程序设计的时候需要调用字节序转换函数将主机的字节序转换为网络字节序。网络中的端口、IP等都需要先装换后在赋值给相应的地址结构体。
在计算机中只能识别二进制的数据,像IP地址这样32位长度的数据记忆与书写特别不方便。人们为了方便记忆会把IP地址8位8位分隔开,这就是常说的点分十进制,如192.168.1.123。这样比较容易记忆。在Linux中也有相应的函数用来在字符串IP地址和二进制地址之间进行转换。Linux中常用函数如下:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
in_addr_t inet_network(const char *cp);
char *inet_ntoa(struct in_addr in);
struct in_addr inet_makeaddr(in_addr_t net, in_addr_t host);
in_addr_t inet_lnaof(struct in_addr in);
in_addr_t inet_netof(struct in_addr in);
struct in_addr{
unsigned long int s_addr;/*IP地址*/
}
const char *inet_ntop(int af, const void *src,
char *dst, socklen_t size);
int inet_pton(int af, const char *src, void *dst);
- inet_aton()函数将cp中存储的点分十进制字符串类型的IP地址,转换为二进制的IP地址保存在inp中。
- inet_addr()将cp中存储的点分十进制字符串类型的IP地址转换为二进制的IP地址,IP地址是以网络字节序表达的
- inet_network()将cp中存储的点分十进制字符串类型的IP地址转换为二进制的IP地址,IP地址是以网络字节序表达的。其中cp可以有a.b.c.d形式也可以是a.b.c形式或者是a.b形式。
- inet_ntoa()函数则和inet_aton()转换相反,把二进制的IP地址转换为点分十进制的4段式字符串IP地址。此内存区域为静态的,所以多次转换保存的是最后一次的数据。
- inet_makeaddr()函数将主机字节序的网络地址和主机地址合并成一个网络字节序的IP地址
- inet_lnaof()函数返回IP地址的主机部分。
- inet_netof()函数则是返回IP地址的网络部分。
IP地址与域名之间的转换
在实际的使用中,经常会使用主机的域名,而很少会使用其IP地址。毕竟域名记忆更为方便。如www.baidu.com、www.google.com这样的域名会比其点分十进制的IP地址记忆方便多了。但是socket编程中的API都是基于IP地址,所以需要在主机域名和IP地址之间进行转换。这就是DNS(domain name system)服务,在主机域名和IP地址之间担任翻译工作。
其中gethostbyname和gethostbyaddr函数都可以获取主机的信息。gethostbyname函数通过主机的名称获得主机的信息,而gethostbyaddr函数通过IP地址获得主机的信息。
#include <netdb.h>
extern int h_errno;
struct hostent *gethostbyname(const char *name);
#include <sys/socket.h> /* for AF_INET */
struct hostent *gethostbyaddr(const void *addr,
socklen_t len, int type);
两个函数都能返回主机的一些信息,其结构如上图所示。h_name是主机的官方名称,如www.baidu.com,其中h_length是IP地址的长度,对于IPv4来说为4,即4个字节,在h_addr_list中保存的主机IP地址链表中,每个长度都是h_length,链表结尾是一个NULL指针。其关系如下图:
协议名称处理函数
为了方便操作,Linux提供了一组用于查询协议的值及名称的函数。下面简单介绍下相关的函数以及使用方法和注意事项。其中Linux中的操作函数有如下,这些函数是对文件/etc/protocols中的记录进行操作。
#include <netdb.h>
struct protoent *getprotoent(void);/*从协议文件中读取一行*/
struct protoent *getprotobyname(const char *name);/*从协议文件中找到匹配项*/
struct protoent *getprotobynumber(int proto);/*按照协议类型的值获取匹配项*/
void setprotoent(int stayopen);/*设置协议文件打开状态*/
void endprotoent(void);/*关闭协议文件*/
/etc/protocols文件中的内容如下图,记录了协议的名称、值和别名等值。在Linux中定义了struct protoent结构体用来描述这些信息。
p_name为指向协议名称的指针,p_aliases是指向别名列表的指针,协议的别名是一个字符串,p_proto是协议的值。
读取/etc/protocols文件中的信息之前需要事先打开此文件,调用setprotoent()函数,参数为1时为永远打开。操作完之后调用endprotoent()进行关闭。
其中getprotobyname()函数用来获取指定协议名称的信息,在访问IP原始套接字数据时会使用上,由于创建IP原始套接字需要指定protocol,这些值咱们平时也不会记忆,所以可以使用此函数获取。
TCP/UDP通信流程
在网络中通信模式主要有C/S、B/S、P2P等模式,其中C/S使用居多。下面对C/S通信模式进行讲解。而运输层又可以分为TCP通信和UDP通信两种。下面分别讲述下TCP和UDP通信框架的构建。
TCP通信
TCP通信流程比较简单,这里就不介绍了。
UDP通信
由于UDP是面向无连接、不可靠的通信协议,所以其编程架构和TCP还是有很大区别的。UDP通信不需要建立连接,所以不需要connect()、listen()、accept()等函数。其编程流程如下:
其中UDP创建套接字文件描述符,使用的函数还是socket(),当其中的参数和TCP通信存在很大的区别。type使用的是SOCK_DGRAM。侦听端口的绑定和TCP是一样的,把地址结构中的成员填充然后进行绑定。交互使用的是sendto、recvfrom等函数。UDP通信协议没有建立连接的过程,所以使用recvfrom时可以接收到不同发送方发送过来的数据,可以通过参数来获取发送发的地址结构信息(IP、port、类型等)。
recvfrom的原型如下,其中src_addr是用于保存发送方的地址结构信息。其函数流程如下:
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);
- 查找文件描述符对应的内核socket
- 建立一个消息结构,将用户空间的地址缓冲区指针和数据缓冲区指针打包到消息结构中
- 在套接字文件描述符中对应的数据链中查找对应的数据,并将数据复制到消息中
- 销毁数据链中的数据,将数据复制到应用层空间,并减少文件描述符的引用计数
内核空间使用消息结构msghdr用来存放所有的数据结构
其中msg_name和msg_namelen用来存放发送方的地址相关的信息,而其中消息存放在msg_iov中,base是用户空间传入的接收数据缓冲区地址,len为用户传入的接收缓冲区长度。
而在UDP的发送数据基本上使用sendto函数,原型如下。由于UDP不建立连接,所以需要把目的方的地址结构信息填充到参数dest_addr中。假如在发送中socket没有绑定本地的IP地址和port,在网络协议栈中会自动进行填充,如图:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
UDP协议不保证传输的服务质量,所以在传输中可能会出现丢包、乱序、流量控制、外出网络接口等问题。对于报文丢失、报文乱序可以借鉴TCP的方法。对UDP中的报文进行序号标记,在接收到的时候UDP报后,返回一个确认告诉发送方已经接收到了,在规定的时间内没有接收到确认信息就认为是报文丢失,重传;而报文乱序则可根据报文中的序号来恢复。
在UDP通信中使用sendto和recvfrom能很方便的和各个主机进行通信,在使用这两个函数的时候可以指定/获取对方的地址结构信息。而在UDP中也可以使用connect函数,使用后会将套接字描述符和网络地址结构进行绑定(绑定的是对方的)。绑定后不能使用sendto和recvfrom了,只能使用read/write或者是send/recv函数。在UDP协议中使用connect()函数作用仅仅表示确定了另一方的地址,而bind函数仅仅绑定了本地进行接收的地址和端口。