Socket API

1 socket 地址API

socket最开始的含义是一个IP地址和端口对(ip, port)。它唯一地表示了使用TCP通信的一方。

1.1 主机字节序和网络字节序

字节序分为大端序和小端序,大端字节序指一个整数的高位字节存储在内存的低地址位,低位字节存储在内存的高地址位;小端字节序相反。通过运行下面程序查看本机字节序。

image.png

如今多数主机都是小端字节序,又称主机字节序。而网络传输中使用大端字节序,又称网络字节序。所以在传输时首先需要将主机字节序转换为网络字节序,接收时需要将网络字节序转换为主机字节序。

Linux提供4个函数来完成主机字节序和网络字节序的转换:

#include <netinet/in.h>
unsigned long int htonl(unsigned long int hostlong);
unsigned short int htons(unsigned short int hostshort);
unsigned long int ntohl(unsigned long int netlong);
unsigned short int ntohs(unsigned short int netshort);

顾名思义,hton指host to network,后面的ls指数据类型。

1.2 通用socket地址

socket网络编程接口中表示socket地址的结构体是sockaddr

#include <bits/socket.h>
struct sockaddr {
    sa_family_t sa_family;
  char sa_data[14];
}

sa_family_t是地址族类型,通常与协议族类型对应。常见的协议族类型和对应的地址族类型如下表:

协议族 地址族 描述
PF_UNIX AF_UNIX UNIX本地域协议族
PF_INET AF_INET TCP/IPv4协议族
PF_INET6 AF_INET6 TCP/IPv6协议族

PF_*AF_*都定义在<bits/socket.h>中,且二者对应的值相等。

sa_data成员变量用于存放socket地址值,但不同的协议族的地址值具有不同的含义和长度。如PF_UNIX有长度可达108字节的文件路径名;PF_INET有16bit端口号,32bit的IPv4地址,共6字节;PF_INET6有16bit端口号,32bit流标识,128bit的IPv6地址,32bit范围ID,共26字节。所以14字节的sa_data无法容纳多数协议族的地址值,所以Linux定义以下新的通用socket地址结构体:

#include <bits/socket.h>
struct sockaddr_storage {
    sa_family_t sa_family;
  unsigned long int __ss_align;
  char __ss_padding[128-sizeof(__ss_align)];
}

这个结构体提供了足够大的空间存放地址值。__ss_align用于内存对齐。

1.3 专用socket地址

为了更方便使用,Linux为各个协议族提供了专门的socket地址结构体。

UNIX本地域协议族使用如下专用socket地址结构体:

#include <sys/un.h>
struct sockaddr_un {
  sa_family_t sin_family; //地址族:AF_UNIX
  char sun_path[108];  //文件路径名
}

TCP/IP协议族有socket_insocket_in6两个专用的socket地址结构体,分别用于IPv4和IPv6:

struct sockaddr_in {
  sa_family_t sin_family; //地址族:AF_UNIX
  u_int16_t sin_port; //端口号,网络字节序表示
  struct in_addr sin_addr; //IPv4地址结构体
}
struct in_addr {
  u_int32_t s_addr; //IPv4地址,网络字节序表示
}
struct socket_in6 {
  sa_family_t sin6_family; //地址族:AF_UNIX
  u_int16_t sin6_port; //端口号,网络字节序表示
  u_int32_t sin6_flowinfo; //流信息,应设置为0
  struct in6_addr sin6_addr; //IPv6地址结构体
  u_int32_t sin6_scope_id; // scope ID,尚处于试验阶段
}
struct in6_addr {
  unsigned char sa_addr[16];
}

所有专用的socket地址结构体在实际使用时都需要转化为socket地址类型sockaddr(强制转换即可),因为所有的socket编程接口使用的地址参数类型都是sockaddr

1.4 IP地址转化函数

人们更习惯用可读性较好的字符串来表示IP地址,如点分十进制表示IPv4地址,以及用十六进制字符串表示IPv6地址。编程中需要转换为二进制数才能使用,记录日志时又需要将二进制数表示的地址转化为可读性更好的字符串表示的地址。

下面3个函数可用于点分十进制字符串表示的IPv4地址和网络字节序整数表示的IPv4地址之间的转换:

#include <arpa/inet.h>
in_addr_t inet_addr(const char * strptr);
int inet_aton(const char * cp, struct in_addr* inp);
char* inet_ntoa(struct in_addr in);
  • inet_addr函数将点分十进制字符串表示的IPv4地址转化为网络字节序整数表示的IPv4地址,失败时返回INADDR_NONE
  • inet_atoninet_addr完成一样的功能,但将结果存储到inp指向的内存中,成功时返回1,失败时返回0。
  • inet_ntoa函数将网络字节序整数表示的IPv4地址转化为点分十进制字符串表示的IPv4地址。需要注意的是,该函数内部使用一个静态变量存储转化结果,函数返回值指向该静态变量,因此该函数是不可重入的。

以下更新的2个函数同样可以完成上述函数的功能,同时适用于IPv4和IPv6:

#include <arpa/inet.h>
int inet_pton(int af, const char* src, void* dst);
const char* inet_ntop(int af, const void* src, char* dst, socklen_t cnt);
  • inet_pton函数将用字符串表示的IP地址src转化为网络字节序整数表示的IP地址,并将结果存放在dst中指向的内存中。af指定地址族(AF_INET、AF_INET6)。成功时返回1,失败返回0。

  • inet_ntop函数进行相反的转换,afsrcdst含义相同,cnt用于指定目标存储单元的大小。下面两个宏可以指定大小,分别用于IPv4、IPv6:

    #include <netinet/in.h>
    #define INET_ADDRSTRLEN 16
    #define INET6_ADDRSTRLEN 46
    

    inet_ntop成功时返回目标存储单元地址,失败返回NULL,并设置errno

2 socket 基础API

对于UNIX/LINUX系的系统来说,所有东西都是文件,包括socket,它就是一个可读可写可控制可关闭的文件描述符。

2.1 创建socket

通过下面来创建一个socket:

#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

domain参数告诉系统使用哪个底层协议,对于TCP/IP协议族而言,该参数应该设置为PF_INET/PF_INET6,对于UNIX本地域协议族而言,该参数应该设置为PF_UNIX

type参数指定服务类型,服务类型主要有SOCK_STREAM(流服务)和SOCK_UGRAM(数据报服务)。对于TCP/IP协议族来说,分别表示传输层使用TCP/UDP协议。自Linux内核2.6.17后,还可以接受SOCK_NONBLOCKSOCK_CLOEXEC,分别表示将新创建的socket设为非阻塞的,以及用fork调用创建子进程时在子进程中关闭该socket。

protocol参数是在前两个参数构成的协议集合下,再选择一个具体的协议。不过一般前两个都确定了某个协议,所以常设置为0,表示使用默认协议。

socket函数成功时返回一个socket文件描述符,失败时返回-1,并设置errno

2.2 命名socket

创建socket时指定了地址族,但并未指定使用该地址族中哪个具体的socket地址。将一个socket与socket地址绑定称为给socket命名。在服务器程序中,通常要命名socket,这样客户端才知道如何连接它。客户端通常不需要命名,而是采用匿名方式,即使操作系统自动分配socket地址。

#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen);

bindmy_addr指向的socket地址分配给未命名的sockfd文件描述符,addrlen指出该socket地址的长度。

bind成功时返回0,失败时返回-1并设置errno。常见的两个errno是:

  • EACCES:被绑定的地址是受保护地址,仅超级用户能够访问。比如普通用户将socket绑定到知名服务端口(0~1023)上。
  • EADDRINUSE:被绑定的地址正在使用中,比如将一个地址绑定到一个处于TIME_WAIT状态的socket地址。

2.3 监听socket

socket被命名后,还不能马上接受客户连接,需要使用如下系统调用创建一个监听队列以存放待处理的客户连接:

#include <sys/socket.h>
int listen(int sockfd, int backlog);

sockfd指定别监听的socket,backlog提示内核监听队列的最大长度。监听队列的长度如果超过backlog,服务器将不受理新的连接,客户端也将收到ECONNREFUSED错误信息。在内核2.2版本之前,backlog参数指所有半连接(SYN_RCVD)和全连接(ESTABLISHED)的socket上限,但之后只表示全连接状态的socket的上限。处于半连接状态的socket的上限由/proc/sys/net/ipv4/tcp_max_syn_backlog内核参数指定。

listen成功时返回0,失败时返回-1并设置errno

2.4 接受连接

下面的系统调用从listen监听队列中接受一个连接:

#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);

sockfd是执行过listen系统调用的监听socket(执行过listen,处于LISTEN状态的socket)。addr参数用来获取被连接的远程socket地址,该socket地址的长度由addrlen参数指出。

accept成功时返回一个新的连接socket,该socket唯一地标识了被接受的这个连接,服务器可通过读写这个socket来与被连接对应的客户端通信。失败时返回-1并设置errno

2.5 发起连接

如果说服务器通过listen被动接受连接,那客户端需要通过以下系统调用来主动发起连接:

#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr* serv_addr, socklen_t addrlen);

sockfd参数由socket系统调用返回一个socket。serv_addr是服务器监听的socket地址,addrlen则指这个地址的长度。

connect成功时返回0,失败时返回-1并设置errno。一旦成功建立连接,sockfd就唯一标识了这个连接,客户端可以通过读写sockfd来与服务器通信。失败时两个常见的errno

  • ECONNREFUSED:目标端口不存在,连接被拒绝。
  • ETIMEDOUT:连接超时。

2.6 关闭连接

关闭一个连接实际上就是关闭该连接对应的socket,和关闭普通文件描述符一样:

#include <unistd.h>
int close(int fd);

fd是待关闭的socket,不过close系统调用并非总是立即关闭一个连接,而是将fd的引用计数减1。只有当fd的引用计数为0时,才真正关闭连接。多进程程序中,一次fork系统调用默认使父进程中打开的socket的引用计数加1,因此我们必须在父进程和子进程中都对该socket执行close调用才能将连接关闭。

如果要立即终止连接,可以使用shutdown系统调用:

#include <sys/socket.h>
int shutdown(int sockfd, int howto);

sockfd是待关闭的socket,howto参数决定了shutdown的行为,如下表:

可选值 含义
SHUT_RD 关闭sockfd上的读,并且该socket接收缓冲区中的数据都被丢弃
SHUT_WR 关闭sockfd上的写,发送缓冲区的数据会在真正关闭连接前全部发送出去,这种情况连接处于半关闭状态
SHUT_RDWR 同时关闭sockfd上的读和写

shutdown可以分别关闭读和写,close只能同时关闭。shutdown成功时返回0,失败时返回-1并设置errno

2.7 数据读写

对文件的读写操作readwrite同样适用于socket。但是socket编程接口提供了几个专用于socket读写的系统调用,它们增加了对数据读写的控制。

其中用于TCP流数据读写的系统调用是:

#include <sys/types.h>
#include <sys/socket.h>
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上的数据,buflen分别指定读缓冲区的位置和大小,flags通常设置为0。recv成功时返回实际读到的数据长度,它可能小于我们期望的长度len。因此我们可能要多次调用recv才能读到完整的数据。recv返回0意味着双方已经关闭连接。出错时返回-1并设置errno

sendsockfd上写数据,buflen分别指定写缓冲区的位置和大小,send成功时返回实际写入数据的长度,失败返回-1并设置errno

flags参数为数据收发提供额外的控制,取决以下一个或多个值的逻辑或:

选项名 含义 send recv
MSG_CONFIRM 指示数据链路层协议持续监听对方的回应,直到得到答复。它仅用于SOCK_DGRAM和SOCK_RAW类型的socket Y N
MSG_DONTROUTE 不查看路由表,直接将数据发给本地局域网内的主机。这表示发送者确切的知道目的主机就在本地网络上 Y N
MSG_DONTWAIT 对socket的此次操作将是非阻塞的 Y Y
MSG_MORE 告诉内核应用程序还有更多的数据要发送,内核将超时等待新数据写入TCP发送缓冲区后一并发送。这样可以防止TCP发送多个过小的报文段 Y N
MSG_WAITALL 读操作仅在读取到指定数量的字节后才返回 N Y
MSG_PEEK 窥探读缓存中的数据,此次读操作不会导致这些数据被清除 N Y
MSG_OOB 发送或接收紧急数据 Y Y
MSG_NOSIGNAL 往读端关闭的管道或socket连接中写数据时不引发SIGPIPE信号 Y N

用于UDP数据报读写的系统调用:

#include <sys/types.h>
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen);
ssize_t sendto(int sockfd, void* buf, size_t len, int flags, struct sockaddr* dest_addr, socklen_t addrlen);

recvfrom读取sockfd上面的数据,buflen分别指定读缓冲区的位置和大小,因为UDP通信没有连接的概念,所以我们每次读取数据都要获取发送端的socket地址。

sendtosockfd上写数据,buflen分别指定写缓冲区的位置和大小,dest_addr指定接收端的socket地址。

flags参数和之前的一样。

值得一提的是,recvfromsendto也可以用于面向连接的socket数据读写,只要把socket地址参数设置为NULL,因为已经建立连接的话,就已经知道其socket地址了。

通用的数据读写

socket编程接口还提供一对通用的数据读写系统调用,不仅能用于TCP,也可以用于UDP:

#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr* msg, int flags);
ssize_t sendmsg(int sockfd, struct msghdr* msg, int flags);

struct msghdr {
  void* msg_name; //socket 地址
  socklen_t msg_namelen; // socket地址长度
  struct iovec* msg_iov; //分散的内存块
  int msg_iovlen; //分散的内存块的数量
  void* msg_control; //指向辅助数据的起始位置
  socklen_t msg_controllen; //辅助数据的大小
  int msg_flags; //复制函数中的flags参数,并在调用过程中更新
};

struct iovec {
  void* iov_base; //内存起始地址
  size_t iov_len; //内存长度
};

msg_name指向一个socket地址结构变量,它指定通信对方的socket地址。对于面向连接的TCP协议,该成员没有意义,必须设置为NULL。

iovec封装了一块内存的起始位置和长度,msg_iovlen指定这样的数据有多少个。对于recvmsg而言,数据将被读取被存放在msg_iovlen块分散的内存中,这些内存的起始位置和长度由msg_iov数组指定,这称为分散读。对于sendmsg而言,msg_iovlen块分散的内存中的数据将被一并发送,这称为集中写。

msg_controlmsg_controllen用于辅助数据的传送,例如使用它们实现进程间传递文件描述符。

msg_flags无须设定,它会复制recvmsg/sendmsg函数中的flags

2.8 地址信息函数

在某些情况下,我们想知道一个连接socket的本端socket地址,以及远端的socket地址:

#include <sys/socket.h>
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中,长度存储于address_len中。如果实际socket地址长度大于address所指内存大小,那么该socket地址将被截断。成功时返回0,失败时返回-1并设置errno

getpeername获取sockfd对应的远端socket地址,参数信息相同。

2.9 socket选项

如果说fcntl系统调用是控制文件描述符属性的通用POSIX方法,那么下面两个系统调用则是专门用来读取和设置socket文件描述符属性的方法:

#include <sys/socket.h>
int getsockopt(int sockfd, int level, int option_name, void* option_value, socklen_t* restric 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_valueoption_len分别是被操作选项的值和长度。不同选项具有不同类型的值。

getsockoptsetsockopt都是成功时返回0,失败时返回-1并设置errno

SO_REUSEADDR 选项

可以通过设置socket选项SO_REUSEADDR来强制使用被处于TIME_WAIT状态的连接占用的socket地址,例如:

int sock = socket(PF_INET, SOCK_STREAM, 0);
assert(sock > 0);
int reuse = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));

struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int ret = bind(sock, (struct sockaddr*)&address, sizeof(address));

经过setsockopt设置后,即使sock处于TIME_WAIT状态,与之绑定的socket地址也可以立即被重用。此外,我们也可以通过修改内核参数/proc/sys/net/ipv4/tcp_tw_recycle来快速回收被关闭的socket,从而使得TCP连接根本不进入TIME_WAIT状态,进而允许应用程序立即重用本地的socket地址。

SO_RCVBUF和SO_SNDBUF选项

SO_RCVBUFSO_SNDBUF选项分别表示TCP接受缓冲区和发送缓冲区的大小。不过,当我们用setsockopt来设置TCP的接收缓冲区和发送缓冲区的大小时,系统都会将其值加倍,并且不得小于某个最小值。TCP接受缓冲区的最小值是256字节,而发送缓冲区最小值是2048字节(不同系统可能不同)。此外,可以直接修改内核参数/proc/sys/net/ipv4/tcp_rmem/proc/sys/net/ipv4/tcp_wmem来强制TCP接收缓冲区和发送缓冲区的大小没有最小值限制。

SO_RCVLOWAT和SO_SNDLOWAT选项

这两个选项分别表示TCP接收缓冲区和发送缓冲区的低水位标记。它们一般被I/O复用系统调用用来判断socket是否可读可写。当TCP接收缓冲区可读数据的总数大于其低水位标记时,I/O复用系统调用将通知应用程序可以从对应的socket上读取数据;当TCP发送缓冲区中的空闲空间大于其低水位标记时,I/O复用系统调用将通知应用程序可以往对应的socket上写入数据。默认情况下,这两个标记都是1字节。

SO_LINGER选项

该选项用于控制close系统调用在关闭TCP连接时的行为。默认情况下,当我们使用close系统调用来关闭一个socket时,close将立即返回,TCP模块负责把该socket对应的TCP发送缓冲区中残余数据发送给对方。可以通过设置该选项,实现立即返回不发送残余数据,或者其它行为。

3 网络信息API

socket地址两个要素,ip地址和端口号,都是用数值表示的,不便于记忆,也不便于扩展。我们可以通过主机名来访问一台机器,从而避免使用IP地址。同样,我们也可以通过服务名称来代替端口号,比如下面两个命令完全相同:

telnet 127.0.0.1 80
telnet localhost www

gethostbyname和gethostbyaddr

gethostbyname函数通过主机名称获取主机完整信息,gethostbyaddr函数根据IP地址获取主机完整信息。gethostbyname通常现在etc/hosts配置文件中查找主机,如果没有找到,再去访问DNS服务器。

#include <netdb.h>
struct hostent* gethostbyname(const char* name);
struct hostent* gethostbyaddr(const void* addr, size_t len, int type);

struct hostent {
  char* h_name; //主机名
  char** h_aliases; //主机别名列表,可能有多个
  int h_addrtype; //地址类型
  char** h_addr_list; //按网络字节序列出的主机IP地址列表
}

type指定IP地址的类型,包括AF_INET / AF_INET6

getservbyname和getservbyport

前者是根据名称获取某个服务的完整信息,后者是根据端口号获取某个服务的完整信息。它们实际上都是通过读取/etc/services文件来获取服务的信息的。定义如下:

#include <netdb.h>
struct servent* getservbyname(const char* name, const char* proto);
struct servent* getservbyport(int port, const char* proto);

struct servent {
  char* s_name; //服务名称
  char** s_aliases; //服务的别名列表,可能有多个
  int s_port; //端口号
  char* s_proto; //服务类型 tcp/udp
}

name参数指定目标服务的名字,port参数指定目标服务对应的端口号。proto参数指定服务类型,给它传递tcp表示获取流服务,传递udp表示获取数据报服务,传递NULL表示获取所有类型服务。

getaddrinfo

既可以通过主机名获得IP地址(内部使用的是gethostbyname),也能通过服务名称获得端口号(内部使用的是getservbyname)。它是否可重入取决内部调用的gethostbynamegetservbyname是否是重入版本。函数定义如下:

#include <netdb.h>
int getaddrinfo(const char* hostname, const char* service, const struct addrinfo* hints, struct addrinfo** result);

struct addrinfo {
  int ai_flags; //
  int ai_family; //地址族
  int ai_socktype; //服务类型
  int ai_protocol; //
  socklen_t ai_addrlen; //socket地址...的长度
  char* ai_canonname; // 主机名别称
  struct sockaddr* ai_addr; //指向socket地址
  struct addrinfo* ai_next; //指向下一个addrinfo对象
}

hostname可以接收主机名,也可以接收字符串表示的IP地址。同样service可以接收服务名,也可以接收字符串表示的十进制端口号。hints参数是应用程序给getaddrinfo的一个提示,以对getaddrinfo的输出进行更精确的控制。hints参数可以被设置为NULL,表示允许getaddrinfo反馈任何可用结果。result指向一个存储反馈结果的链表。

getnameinfo

能够通过socket地址同时获得以字符串表示的主机名(内部使用gethostbyaddr)和服务名(内部使用getservbyport)。是否可重入取决内部使用的函数是否可重入。函数定义:

#include <netdb.h>
int getnameinfo(const struct sockaddr* sockaddr, socklen_t addrlen, char* host, socklen_t hostlen, char* serv, socklen_t servlen, int flags);

getnameinfo将返回的主机名存储在host指向的缓存中,将服务名存储在serv指向的缓存中,hostlenservlen分别指定这两个缓冲的长度。flags控制getnameinfo的行为。

getaddrinfogetnameinfo都是成功返回0,失败返回错误码。

results matching ""

    No results matching ""