应用层通过传输层进行数据通信时,TCP和UDP会遇到同时为多个应用程序进程提供并发服务的问题。为了区别不同的应用程序进程和连接,许多计算机操作系统为应用程序与TCP/IP协议交互提供了称为套接字(Socket)的接口,区分不同应用程序进程间的网络通信和连接。
现代CPU的累加器一次都能装载4字节,即一个整数。这4字节在内存中排列的顺序将影响它被累加器装载成的整数值。这就是字节序问题。字节序分为大端字节序和小端字节序。大端字节序是指一个整数的高位字节存储在内存的低地址处,低位字节存储在内存的高地址处。小端字节序则相反。
现代PC多采用小端字节序,因此小端字节序又被称为主机字节序。在两台使用不同字节序的主机之间直接传递数据时,接收端必然错误地解释之。解决办法就是:发送端总是要把发送的数据转化成大端字节序数据后再发送,而接收端知道接受的数据是大端字节序,可以根据自身的字节序决定是否对接收到的数据进行转换。因此大端字节序也称为网络字节序。
Linux提供了如下4个函数来完成主机字节序和网络字节序之间的转换:
#include
unsigned long int htonl( unsigned long int hostlong );
unsigned short int htons( unsigned short int hostshort );
unsigned long int ntonl( unsigned long int netlong );
unsigned short int ntohs( unsigned short int netshort );
socket网络编程接口中表示socket地址的是结构体sockaddr,其定义如下:
#include
struct sockaddr{
sa_family sa_family;
char sa_data[14];
}
sa_family成员是地址族类型(sa_family_t)的变量。地址族类型通常与协议族类型对应。常见的协议族(protocol family,也称domain)和对应的地址族如下表
sa_data成员用于存放socket地址值。
通用socket地址结构体很不好用,比如设置与获取IP地址和端口号就需要执行烦琐的位操作。所以Linux为各个协议族提供了专门的socket地址结构体。如IPv4:
struct sockaddr_in{
sa_family_t sin_family; //地址族:AF_INET
u_int16_t sin_port; //端口号,要用网络字节序表示
struct in_addr sin_addr; //IPv4地址结构体,见下面
};
struct in_addr{
u_int32_t s_addr; //IPv4地址,要用网络字节序表示
};
所有专用socket地址类型的变量在实际使用时都需要转化为通用socket地址类型,因为socket编程接口使用的地址参数类型都是sockaddr。
下面3个函数用于用点分十进制字符串表示的IPv4地址和用网络字节序整数表示的IPv4地址之间的转换:
#include
int_addr_t inet_addr( const char* strptr ); //点分十进制字符串转化成网络字节序整数。
int inet_aton( const char* cp, struct in_addr* inp ); //同上,将结果存储于参数inp指向的地址结构中。
int inet_pton( int af, const char* src, void* dst ); //同上,af指定地址族,结果存到dst中。
char* inet_ntoa( struct in_addr in ); //不同以上,函数内部用静态变量存储转化结果,不可重入。
下面的socket系统调用可创建一个socket:
#include
#include
int socket( int domain, int type, int protocol );
domain参数告诉系统使用哪个底层协议族。PF_INET用于IPv4。
type参数指定服务类型。对于TCP/IP协议族而言,取SOCK_STREAM表示传输层使用TCP协议。
protocol参数是在前两个参数构成的协议集合下,再选择一个具体的协议,一般置0。
创建socket时,我们给它指定了地址族,但是没有指定具体socket地址。将一个socket与socket地址绑定称为给socket命名。在服务器程序中,我们通常要命名socket,因为只有命名后客户端才知道如何连接它。客户端通常不需要命名。命名socket的系统调用是bind,定义如下:
#include
#include
int bind( int sockfd, const struct sockaddr* my_addr, socklen_t addrlen );
bind将my_addr所指的socket地址分配给未命名的sockfd文件描述符,addrlen参数指出该socket地址的长度。
socket被命名后,还不能马上接受客户连接,需要创建一个监听队列以存放待处理的客户连接:
#include
int listen( int sockfd, int backlog );
sockfd参数指定被监听的socket。backlog参数提示内核监听队列的最大长度。
下面的系统调用从listen监听队列中接受一个连接:
#include
#include
int accept( int sockfd, struct sockaddr *addr, socklen_t *addrlen );
sockfd参数是执行过listen系统调用的监听socket。addr参数用来获取被接受连接的远端socket地址,该地址长度有addrlen参数指出。返回一个新的连接的socket,该socket唯一地标识了被接受的这个连接,服务器可通过读写该socket来与被接受连接对应的客户端通信。
如果说服务器通过listen调用来被动接受连接,那么客户端需要通过如下系统调用来主动与服务器建立连接:
#include
#include
int connect( int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen );
sockfd参数由socket系统调用返回一个socket。serv_addr参数是服务器监听的socket地址,addrlen参数则指定这个地址的长度。一旦成功建立连接,sockfd就唯一地标识了这个连接,客户端就可以通过读写sockfd来与服务器通信。
关闭一个连接实际上就是关闭该连接对应的socket,这可以通过如下关闭普通文件描述符的系统调用来完成:
#include
int close( int fd );
fd参数就是待关闭的socket。不过,close系统调用并非总是立即关闭一个连接,而是将fd的引用计数减1.只有当fd引用计数为0时,才真正关闭连接。多进程程序中,一次fork系统调用默认将使父进程中打开的socket的引用计数加1,因此我们必须在父进程和子进程中都对该socket执行close调用才能将连接关闭。
如果无论如何都要立即终止连接,可以使用如下shutdown系统调用:
#include
int shutdown( int sockfd, int howto );
sockfd参数是待关闭的socket。
对文件的读写操作read和write同样适用于socket。但是socket编程接口提供了几个专用的系统调用,其中用于TCP流数据报读写的系统调用是:
#include
#include
ssize_t recv( int sockfd, void *buf, size_t len, int flags );
ssize_t send( int sockfd, const void * buf, size_t len, int flags );
recv读取sockfd上的数据,buf和len参数分别指定都缓冲区的位置和大小,flags参数通常设为0。
send往sockfd上写入数据,buf和len参数分别指定写缓冲区的位置和大小。
socket编程接口中用于UDP数据报读写的系统调用是:
#include
#include
ssize_t recvform( int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen );
ssize_t sendto( int sockfd, const void* buf, size_t len, int flags, const struct sockaddr* dest_addr, socklen_t addrlen );
recvfrom读取sockfd上的数据,buf和len参数分别指定缓冲区的位置和大小。因为UDP通信没有连接的概念,所以我们每次读取数据都需要获取发送端的socket地址,即参数src_addr所指的内容,addrlen参数则指定该地址的长度。
sendto往sockfd上写入数据,buf和len参数分别指定写缓冲区的位置和大小。dest参数指定接收端的socket地址,addrlen参数则指定该地址的长度。
socket编程接口还提供了一对通用的数据读写系统调用。它们不仅能用于TCP流数据也能用于UDP数据报:
#include
ssize_t recvmesg( int sockfd, struct msghdr* msg, int flags );
ssize_t sendmsg( int sockfd, struct msghdr* msg, int flags );
在Linux内核检测到TCP紧急标志时,将通知应用程序有带外数据需要接收。内核通知应用程序带外数据到达的两种常见方式是:I/O复用产生的异常事件和SIGURG信号。可以通过系统调用找到带外数据在数据流中的具体位置:
#include
int sockatmark( int sockfd );
sockatmark判断sockfd是否处于带外标记,即下一个被读取到的数据是否是带外数据。
有些情况,想知道一个连接socket的本段socket地址,以及远端的socket地址。可以使用如下函数:
#include
int getsockname( int sockfd, struct sockaddr* address, socklen_t* address_len );
int getpeername( int sockfd, struct sockaddr* address, socklen_t* address_len );
getsockname获取sockfd对应的本端socket地址,并将其存储于address参数指定的内存中,该socket地址的长度则存储于address_len参数指向的变量中。
getpeername获取sockfd对应的远端socket地址,其参数同上。
如果说fcntl系统调用是控制文件描述符属性的通用POSIX方法,那么下面两个系统调用则是专门用来读取和设置socket文件描述符属性的方法:
#include
int getsockopt( int sockfd, int level, int option_name, void* option_value, socklen_t* restrict option_len );
int setsockopt( int sockfd, int level, int option_name, const void* option_value, socklen_t option_len );
sockfd参数指定被操作的目标socket。level参数指定要操作哪个协议的选项(即属性),比如IPv4、IPv6、TCP等。option_name参数则指定选项的名字。option_value和option_len参数分别是被操作选项的值和长度。
服务器程序可以通过设置socket选项SO_REUSEADDR来强制使用被处于TIME_WAIT状态的连接占用的socket地址。
经过setsockopt的设置之后,及时sock处于TIME_WAIT状态,与之绑定的socket地址也可以立即被重用。
SO_RECVBUF和SO_SNDBUF选项分别表示TCP接收缓冲区和发送缓冲区的大小。
SO_RCVLOWAT和SO_SNDLOWAT选项分别表示TCP接收缓冲区和发送缓冲区的低水位标记。它们一般被I/O复用系统调用用来判断socket是否可读或可写。当TCP接收缓冲区中可读数据的总数大于其低水位标记时,I/O复用系统调用将通知应用程序可以从对应的socket上读取数据;当TCP发送缓冲区中的空闲空间大于其低水位标记时,I/O复用系统调用将通知应用程序可以往对应的socket上写入数据。
SO_LINGER选项用于控制close系统调用在关闭TCP连接时的行为。
socket地址的两个要素,即IP地址和端口号,都是用数值表示的。不便于记忆,也不便于扩展。在前面在章节中,我们用主机名来访问一台机器,避免直接使用其IP地址。同样,我们用服务名称来代替端口号。
gethostbyname函数根据主机名称获取主机完整信息,gethostbyaddr函数根据IP地址获取主机的完整信息。
#include
struct hosten* gethostbyname( const char* name );
strcut hosten* gethostbyaddr( const void* addr, size_t len, int type );
name参数指定目标主机的主机名,addr参数指定目标主机的IP地址,len参数指定addr所指IP地址长度,type参数指定addr所指IP地址类型,其合法取值包括AF_INET(IPv4)和AF_INET6(IPv6)。
这两个函数返回的都是hostent结构体类型的指针,hostent结构体定义如下:
#include
struct hostent{
char* h_name; // 主机名
char** h_aliases; // 主机别名列表,可能有多个
int h_addrtype; // 地址类型
int h_length; // 地址长度
char** h_addr_list; // 网络字节序列出的主机IP地址列表
};
getservbyname函数根据名称获取某个服务的完整信息,getservbyport函数根据端口号获取某个服务的完整信息。
#include
struct servent* getservbyname( const char* name, const char* proto );
struct servent* getservbyport( int port, const char* proto );
name参数指定目标服务的名字,port参数指定目标服务对应的端口号。proto参数指定服务类型,给它传递“tcp“表示获取流服务,给它传递"udp"表示获取数据报服务,给它传递NULL则表示获取所有类型的服务。
这两个函数返回的都是servent结构体类型的指针,结构体servent的定义如下:
#include
struct servent{
char* s_name; // 服务名称
char** s_aliases; // 服务的别名列表,可能有多个
int s_port; // 端口号
char* s_proto; // 服务类型,通常是tcp或者udp
};
以上四个函数均是不可重入的,即线程非安全的。
getaddrinfo函数既能通过主机名获得IP地址,也能通过服务名获得端口号。该函数的定义如下:
#include
int getaddrinfo( const char* hostname, const char* service, const struct addrinfo* hints, struct addrinfo** result );
hostname参数可以接受主机名,也可以接受字符串表示的IP地址。同样,service参数可以接受服务名,也可以接受字符串表示的十进制端口号。hints参数使应用程序给geraddrinfo的一个提示,以对getaddrinfo的输出进行更精确的控制。result参数指向一个链表,该链表用于存储geraddrinfo反馈的结果。
getaddrinfo反馈的每一条结果都是addrinfo结构体类型的对象,结构体addrinfo的定义如下:
struct addrinfo{
int ai_flags; //
int ai_family; // 地址族
int ai_socktype; // 服务类型
int ai-protocol; // 具体网络协议,与socket系统调用的第三个参数相同,通常设置为0
socklen_t ai_addrlen; // socket地址ai_addr的长度
char* ai_canonname; // 主机的别名
struct sockaddr* ai_addr; // 指向socket地址
struct addrinfo* ai_next; // 指向下一个sockinfo结构的对象
}
getnameinfo函数能通过socket地址同时获得以字符串表示的主机名和服务名。
#include
int getnameinfo( const struct sockaddr* sockaddr, socklen_t addrlen, char* host, socklen_t hostlen, char* serv, socklen_t servlen, int flags );
getnameinfo将返回的主机名存储在host参数指向的缓存中,将服务名存储在serv参数指向的缓存中,hostlen和servlen参数分别指定这两块缓存的长度。flags参数控制genameinfo的行为。