网络编程基础API

socket地址API

主机字节序和网络字节序

网络字节序采用大端存储,即低位存储高地址,在网络传输时需要考虑是否进行字节序转换。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 hostlong);
unsigned short int ntohs(unsigned short int hostshort);

它们的意义恨明确,比如htonl表示 “host to network long”。

长整型用于转换 IP 地址,短整型用于转换端口号。

通用socket地址

#include <bits/socket.h>
struct sockaddr{
    sa_family_t sa_family; // 地址族 或 协议族
    chat sa_data[14]; // 用于存放 socket 地址
}

地址族或协议族的可选内容:

NeatReader-1641371620413

NeatReader-1641371766334

由表5-2可见,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)];
}

这个结构体空间足够大,而且字节对齐。

专用socket地址

  1. UNIX本地协议族或地址族。

    #include <sys/un.h>
    struct sockadd_un{
        sa_family_t sin_family; // 地址族 AF_UNIX
        char sun_path[108]; // 文件路径名
    }
  2. 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 地址
    }
  3. IPv6协议族

    struct sockaddr_in6{
        sa_family_t sin6_family; // AF_INET6
        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]; // IPv6地址
    }

    所有专用socket地址(以及sockaddr_storage)类型的变量在实际使用时都需要转化为通用socket地址类型sockaddr(强制转换即可),因为所有socket编程接口使用的地址参数的类型都是sockaddr。

IP地址转换函数

#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_aton函数完成和inet_addr同样的功能,但是将转化结果存储于参数inp指向的地址结构中。它成功时返回1,失败则返回0。

inet_ntoa函数将用网络字节序整数表示的IPv4地址转化为用点分十进制字符串表示的IPv4地址。但需要注意的是,该函数内部用一个静态变量存储转化结果,函数的返回值指向该静态内存,因此inet_ntoa是不可重入的。

下面这对更新的函数也能完成和前面3个函数同样的功能,并且它们同时适用于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(用点分十进制字符串表示的IPv4地址或用十六进制字符串表示的IPv6地址)转换成用网络字节序整数表示的IP地址,并把转换结果存储于dst指向的内存中。其中,af参数指定地址族,可以是AF_INET或者AF_INET6。inet_pton成功时返回1,失败则返回0并设置errno.

inet_ntop函数进行相反的转换,前三个参数的含义与inet_pton的参数相同,最后一个参数cnt指定目标存储单元的大小。下面的两个宏能帮助我们指定这个大小(分别用于IPv4和IPv6):

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

创建socket

#include <sys/types.h>
#include <sys/socket.h>

int socket(int domain,int type,int protocol);

domain参数告诉系统使用哪个底层协议族。对TCP/IP协议族而言,该参数应该设置为PF_INET(Protocol Family of Internet,用于IPv4)或PF_INET6(用于IPv6);对于UNIX本地域协议族而言,该参数应该设置为PF_UNIX。关于socket系统调用支持的所有协议族,请读者自己参考其man手册。

type参数指定服务类型。服务类型主要有SOCK_STREAM服务(流服务)和SOCK_UGRAM(数据报)服务。对TCP/IP协议族而言,其值取SOCK_STREAM表示传输层使用TCP协议,取SOCK_DGRAM表示传输层使用UDP协议。

protocol参数是在前两个参数构成的协议集合下,再选择一个具体的协议。不过这个值通常都是唯一的(前两个参数已经完全决定了它的值)。几乎在所有情况下,我们都应该把它设置为0,表示使用默认协议。

命名socket

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

bind将my_addr所指的socket地址分配给未命名的sockfd文件描述符,addrlen参数指出该socket地址的长度。

bind成功时返回0,失败则返回-1并设置errno。其中两种常见的errno是EACCES和EADDRINUSE,它们的含义分别是:

❑EACCES,被绑定的地址是受保护的地址,仅超级用户能够访问。比如普通用户将socket绑定到知名服务端口(端口号为0~1023)上时,bind将返回EACCES错误。

❑EADDRINUSE,被绑定的地址正在使用中。比如将socket绑定到一个处于TIME_WAIT状态的socket地址。

监听socket

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

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

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

完整连接最多有(backlog+1)个。

接受连接

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

ckfd参数是执行过listen系统调用的监听socket 。addr参数用来获取被接受连接的远端socket地址,该socket地址的长度由addrlen参数指出。accept成功时返回一个新的连接socket,该socket唯一地标识了被接受的这个连接,服务器可通过读写该socket来与被接受连接对应的客户端通信。accept失败时返回-1并设置errno。

accept只是从监听队列中取出连接,而不论连接处于何种状态,更不关心任何网络状况的变化。

发起连接

#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。一旦成功建立连接,sockfd就唯一地标识了这个连接,客户端就可以通过读写sockfd来与服务器通信。connect失败则返回-1并设置errno。

关闭连接

关闭文件描述符。

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

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

如果无论如何都要立即终止连接(而不是将socket的引用计数减1),可以使用如下的shutdown系统调用(相对于close来说,它是专门为网络编程设计的):

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

sockfd参数是待关闭的socket。howto参数决定了shutdown的行为,它可取表5-3中的某个值。

NeatReader-1641374475193

由此可见,shutdown能够分别关闭socket上的读或写,或者都关闭。而close在关闭连接时只能将socket上的读和写同时关闭。

shutdown成功时返回0,失败则返回-1并设置errno。

数据读写

TCP 数据读写

对文件的读写操作read和write同样适用于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上的数据,buf和len参数分别指定读缓冲区的位置和大小,flags参数的含义见后文,通常设置为0即可。recv成功时返回实际读取到的数据的长度,它可能小于我们期望的长度len。因此我们可能要多次调用recv,才能读取到完整的数据。recv可能返回0,这意味着通信对方已经关闭连接了。recv出错时返回-1并设置errno。

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

flags参数为数据收发提供了额外的控制,它可以取表5-4所示选项中的一个或几个的逻辑或。

NeatReader-1641375612074

值得一提的是,flags参数只对send和recv的当前调用生效,而后面我们将看到如何通过setsockopt系统调用永久性地修改socket的某些属性。

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,const void* buf,size_t len,int flags,struct sockaddr* dest_addr,socklen_t addrlen);

recvfrom读取sockfd上的数据,buf和len参数分别指定读缓冲区的位置和大小。因为UDP通信没有连接的概念,所以我们每次读取数据都需要获取发送端的socket地址,即参数src_addr所指的内容,addrlen参数则指定该地址的长度。

sendto往sockfd上写入数据,buf和len参数分别指定写缓冲区的位置和大小。dest_addr参数指定接收端的socket地址,addrlen参数则指定该地址的长度。

这两个系统调用的flags参数以及返回值的含义均与send/recv系统调用的flags参数及返回值相同。

值得一提的是,recvfrom/sendto系统调用也可以用于面向连接(STREAM)的socket的数据读写,只需要把最后两个参数都设置为NULL以忽略发送端/接收端的socket地址(因为我们已经和对方建立了连接,所以已经知道其socket地址了)。

带外标记

#include <sys/socket.h>
int sockatmark(int sockfd);

sockatmark判断sockfd是否处于带外标记,即下一个被读取到的数据是否是带外数据。如果是,sockatmark返回1,此时我们就可以利用带MSG_OOB标志的recv调用来接收带外数据。如果不是,则sockatmark返回0。

地址信息函数

在某些情况下,我们想知道一个连接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参数指定的内存中,该socket地址的长度则存储于address_len参数指向的变量中。如果实际socket地址的长度大于address所指内存区的大小,那么该socket地址将被截断。getsockname成功时返回0,失败返回-1并设置errno。

getpeername获取sockfd对应的远端socket地址,其参数及返回值的含义与getsockname的参数及返回值相同。

socket选项

#include <sys/socket.h>
int getsockopt(int sockfd,int level,int option_name,void* option_value,socklen_t* optlen)
int setsockopt(int sockfd, int level, int optname,const void *optval, socklen_t optlen);

sockfd参数指定被操作的目标socket。level参数指定要操作哪个协议的选项(即属性),比如IPv4、IPv6、TCP等。option_name参数则指定选项的名字。我们在表5-5中列举了socket通信中几个比较常用的socket选项。option_value和option_len参数分别是被操作选项的值和长度。不同的选项具有不同类型的值,如表5-5中“数据类型”一列所示。

NeatReader-1641376961612

getsockopt和setsockopt这两个函数成功时返回0,失败时返回-1并设置errno。

SO_RCVBUF和SO_SNDBUF选项分别表示TCP接收缓冲区和发送缓冲区的大小。不过,当我们用setsockopt来设置TCP的接收缓冲区和发送缓冲区的大小时,系统都会将其值加倍,并且不得小于某个最小值。TCP接收缓冲区的最小值是256字节,而发送缓冲区的最小值是2048字节(不过,不同的系统可能有不同的默认最小值)。系统这样做的目的,主要是确保一个TCP连接拥有足够的空闲缓冲区来处理拥塞(比如快速重传算法就期望TCP接收缓冲区能至少容纳4个大小为SMSS的TCP报文段)。此外,我们可以直接修改内核参数/proc/sys/net/ipv4/tcp_rmem和/proc/sys/net/ipv4/tcp_wmem来强制TCP接收缓冲区和发送缓冲区的大小没有最小值限制。

网络信息API

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);

name参数指定目标主机的主机名,addr参数指定目标主机的IP地址,len参数指定addr所指IP地址的长度,type参数指定addr所指IP地址的类型,其合法取值包括AF_INET(用于IPv4地址)和AF_INET6(用于IPv6地址)。

这两个函数返回的都是hostent结构体类型的指针,hostent结构体的定义如下:

#include <netdb.h>
struct hostent{
    char* h_name; // 主机名
    char** h_aliases; // 主机别名,可能有多个
    int h_addrtype; // 地址类型
    int h_length;// 地址长度
    char** h_addr_list; //按网络字节序列出的主机IP地址列表
};

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

#include <netdb.h>
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 <netdb.h>
struct servent
{
char* s_name;/*服务名称*/
char** s_aliases;/*服务的别名列表,可能有多个*/
int s_port;/*端口号*/
char* s_proto;/*服务类型,通常是tcp或者udp*/
};

需要指出的是,上面讨论的4个函数都是不可重入的,即非线程安全的。不过netdb.h头文件给出了它们的可重入版本。正如Linux下所有其他函数的可重入版本的命名规则那样,这些函数的函数名是在原函数名尾部加上_r(re-entrant)。

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

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

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

getnameinfo函数能通过socket地址同时获得以字符串表示的主机名(内部使用的是gethostbyaddr函数)和服务名(内部使用的是getservbyport函数)。它是否可重入取决于其内部调用的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);