来源:《基于UNIX/Linux的C系统编程》
在UNXI中,使用套接字socket主要是用来实现网络编程。网络程序通过系统调用来获取一个用于通信的文件描述符,即套接字,然后程序可以像操作普通文件一样对该描述符进行读写,进而实现网络之间的数据交流。
创建套接字
为了执行网络I/O,需要做的第一项工作就是调用socket函数来指定期望的通信协议类型,并能返回一个新创建的套接字描述符(类似于文件描述符)。socket函数原型定义如下:
#include <sys/socket.h>
#include <sys/types.h>
int socket(int family, int type, int protocol);
其中参数family指定套接字的域,type为指定套接字类型,protocol为指定套接字协议,若函数调用成功,则返回所指定的套接字描述符,否则返回-1。
1、套接字域
套接字域(domain)定义了网络协议族(family)及其套接字所支持的寻址方案。引入该域的目的主要是为了支持包括TCP/IP协议在内的大多数网络协议,从而为套接字成为通用网络编程工具创造条件。
常量 | 描 述 |
---|---|
PF_UNIX | UNIX主机内部的进程间通信 |
PF_INET | ARPA网际协议(IPv4协议) |
PF_INET6 | ARPA网际协议(IPv6协议) |
PF_ROUTE | 路由套接字 |
PF_ISO | 国际标准组织协议 |
PF_NS | Xerox网络协议 |
2、套接字类型
针对不同的通信需求,通常提供了3种不同的套接字类型:
- 流套接字(SOCK_STREAM):用于提供面向连接、可靠的数据传输服务。该服务将保证数据能够实现无差错、无重复发送,并按顺序接收。流套接字之所以能够实现可靠的数据服务,原因在于其使用了TCP。这类套接字中,传输数据之前必须在两个应用进程之间建立一条通信连接,这就确保了参与通信的两个应用进程都是活动并且响应的。当连接建立以后,应用进程只要通过套接字向TCP层发送数据流,而另一个应用进程便可以接收到相应的数据流,它们不需要知道传输层是如何对数据流进行处理。特别需要注意的是通信连接必须显示建立。该套接字类型适合传输大量的数据,但不支持广播和多播方式。
- 数据报套接字(SOCK_DGRAM):提供了一种无连接的服务,通信双方不需要建立任何显示连接,数据可以发送到指定的套接字,并且可以从指定的套接字中接收数据。该服务并不能保证数据传输的可靠性,数据由可能在传输过程中丢失或出现数据重复,且无法保证顺序地接收到数据。数据报套接字使用UDP进行数据的传输。由于数据包套接字不能保证数据传输的可靠性,对于有可能出现的数据丢失情况,需要在程序中做相应的处理。与数据报套接字相比,使用流套接字是一个更为可靠的方法,但对于某些应用,建立一个显示连接所导致的系统开销是令人难以接受的,并且数据包套接字支持广播和多播方式。
- 原始套接字(SOCK_RAW):与标准套接字(即前面两个)的区别在于原始套接字可以读写内核没有处理的IP数据包,而流套接字只能读取TCP的数据,数据包套接字只能读取UDP的数据。使用原始套接字则可以避开TCP/IP处理机制,被传送的数据包可以被直接传送给需要它的应用程序。因此,其主要在编写自定义底层协议的应用时使用,例如各种不同的TCP/IP使用程序(如ping和arp)都使用原始套接字实现,也可以用来实现数据包捕捉分析等。
3、套接字协议
对于给定的套接字域和类型,可能有一个或多个协议实现所需的操作。下表列出了一些常用的套接字协议。一般来说,套接字协议的取值为0,除非用在原始套接字上。
协议 | 描 述 |
---|---|
TCP | 用于字节流套接字的传输控制协议 |
UDP | 用于数据报套接字的用户数据报协议 |
ICMP | Internet控制信息协议 |
RAW | 手工创建IP数据包 |
值得注意的是,并非所有套接字域和类型的组合都是有效的,下表给出了一些有效组合和对应的真正协议。
类型 & 域 | PF_INET | PF_INET6 | PF_UNIX | PF_ROUTE |
---|---|---|---|---|
SOCK_STREAM | TCP | TCP | PF_UNSPEC | 不支持 |
SOCK_DGRAM | UDP | UDP | PF_UNSPEC | 不支持 |
SOCK_RAM | IP | IP | 不支持 | 支持 |
套接字寻址
一个进程为了能和另一个进程通信,彼此必须知道对方的标识,在网络中的进程间通信也同样如此。之前已经讨论过网络中的进程如何标识的问题,而这个标识就是所谓的套接字地址。每种协议族都定义了自己的套接字地址结构,这些结构的名字均以”sockaddr_”开头,并以对应每种协议族的唯一后缀结束。
1、IPv4套接字地址结构
该结构以”sockaddr_in”命名,其在头文件<netinet/in.h>中的定义如下:
struct in_addr { in_addr_t s_addr; // 32位的IPv4地址 }; struct sockaddr_in { uint8_t sin_len; // 地址结构的长度,值为16 sa_family_t sin_family; // 套接字类型为PF_INET in_port_t sin_port; // 16位的TCP或UDP端口号 struct in_addr sin_addr; // 32位的IPv4地址 char sin_zero[8]; // 还未使用 };
其中:
- in_addr_t :无符号32位整数类型,定义在<netinet/in.h>中;
- uint8_t :无符号8位整数类型,定义在<sys/types.h>中;
- sa_family_t:套接字地址结构的地址族,定义在<sys/socket.h>中;
- in_port_t :无符号16位整数类型,定义在<netinet/in.h>中;
- sin_zero :暂不使用,但总是将它设置为0。为了方便起见,在初始化时,一般将整个结构置为0,而不仅仅是将sin_zero置为0。
此外,UNIX还定义了一些具有特殊意义的IP地址,声明在<netinet/in.h>中:
- INADDR_LOOPBACK :表示本机地址,统一定义为127.0.0.1;
- INADDR_BROADCAST:广播地址,用来进行消息广播的特定地址;
- INADDR_ANY :通配名。
需要指出的是,套接字结构仅在给定的主机上使用,虽然结构中的某些成员(如IP地址、端口号)用在不同主机间的通信,但结构本身不参与通信。
2、IPv6套接字地址结构
该结构以”sockaddr_in6″命名,其在头文件<netinet/in.h>中的定义如下:
struct in6_addr { uint8_t s6_addr[16]; // 128位的IPv6地址,网络字节顺序存储 }; #define SIN6_LEN // 定义套接字地址结构中的长度成员 struct sockaddr_in6 { uint8_t sin6_len; // 地址结构的长度,值为24 sa_family_t sin6_family; // 套接字类型为PF_INET6 in_port_t sin6_port; // 16位的TCP或UDP端口号 uint32_t sin6_flowinfo; // 优先级和流量标记 struct in6_addr sin6_addr; // 128位的IPv6地址,网络字节顺序存储 };
3、通用套接字地址结构
当作为参数传递给任何一个套接字函数时,套接字地址结构总是通过指针来传递,但是通过指针来取得此参数的套接字函数必须处理来自所支持的任何协议族的套接字地址结构。为此,需要定义一个通过的套接字地址结构,其在头文件<sys/socket.h>中的定义如下:
struct sockaddr { uint8_t sa_len; // 地址结构的长度 sa_family_t sa_family; // 套接字类型 char sa_data[14]; // 与套接字类型对应的地址 };
使用结构sockaddr可以取得更好的移植性。为了同时支持IPv4和IPv6协议,编程中应该使用结构sockaddr,而避免使用结构sockaddr_in和结构sockaddr_in6。
4、地址转换函数
(1)字符串地址与二进制型地址
对于在ASCII字符串与网络字节序的二进制(此值存于套接字地址结构中)之间转换地址的函数,一般有两组:一组适用于IPv4地址的inet_aton、inet_addr和inet_ntoa函数;另一组能对IPv4和IPv6地址都能处理的inet_pton、inet_ntop函数。后一组函数原型如下:
#include <arpa/inet.h>
int inet_pton(int family, const char *strptr, void *addrptr);
const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);
上述函数中,p代表presentation(表达)格式,n代表numeric(数值)格式。地址表达格式通常是ASCII串,数值格式则是存在于套接字地址格式中的二进制。顾名思义,inet_pton函数是将地址从表达格式转换为数值格式,而inet_ntop函数刚好相反。若inet_pton函数调用成功,返回1;若返回0,表示输入不是有效的表达格式;若返回-1,则表示出错,并将错误代码写入errno。若inet_ntop函数调用成功, 返回指向结果的指针,否则返回NULL。
需要指出,inet_pton和inet_ntop函数在处理地址,要求调用者指明地址是in_addr或in6_addr结构。也就是说,为了使用inet_ntop函数,必须分别为IPv4和IPv6编写额外的代码段。
为IPv4编写如下的代码:
struct sockaddr_in addr;
inet_ntop(PF_INET, &addr.sin_addr, str, sizeof(str));
为IPv6编写如下代码:
struct sockaddr_in6 addr;
inet_ntop(PF_INET6, &addr.sin6_addr, str, sizeof(str));
这就是使得所编写的代码与地址协议族协议相关,因此,若要实现与协议无关的编程,提高程序的可移植性,就要避免使用这两个函数,而使用getnameinfo和getaddrinfo两个函数。
(2)主机名与IP地址
为方便记忆,计算机都有一个主机名,这样就不必记忆复杂的数点形式的IP地址。当前所有的操作系统中都有一个hosts文件用于记录主机名与IP地址的映射。而在系统编程中,UNIX通常使用hostent结构来描述与主机名相关的信息。其结构定义如下:
#include &amp;lt;netdb.h&amp;gt; struct hostent { char *h_name; // 主机的正式名称 char **h_aliases; // 主机的别名 int h_addrtype; // 主机的地址类型 PF_INET int h_length; // 主机的地址长度 对于IPv4是4字节32位 char **h_addr_list; // 主机的IP地址列表 }; #define h_addr h_addr_list[0] // 主机的第一个IP地址
同时UNIX系统还为该结构设置了相关的函数:
#include <netdb.h>
struct hostent *gethostbyname(char *name);
struct hostent *gethostbyaddr(void *addr, size_t length, int type);
其中gethostbyname函数可以将主机名转换为一个指向hostent结构的指针,通过给结构获取主机相关信息;gethostbyaddr函数可以将一个32位IP地址(二进制IP地址,如C0A80001)转换为一个指向hostent结构的指针。这两个函数调用失败则返回NULL。
(3)服务与端口地址
UNIX系统为处理服务信息,定义了servent结构用于描述服务相关信息。该结构定义如下:
#include &amp;lt;netdb.h&amp;gt; struct servent { char *s_name; // 服务的正式名称 char **s_aliases; // 服务的可选别名 int s_port; // 服务使用的端口号 char *s_proto; // 与该服务一起使用的协议名 };
同时UNIX系统也为该结构设置了相关的函数:
#include <netdb.h>
struct servent *getservbyname(char *name, char *proto);
struct servent *getservbyport(int port, char *proto);
其中getservbyname函数可以通过给定的服务名和协议名获得一个指向servent结构的指针,从而从该结构中获取相关服务信息;getservbyport函数则可以通过给定端口地址和协议名获取servent结构的指针。这两个函数调用失败则返回NULL。
例1:验证上述函数的可行性。
#include <stdio.h> #include <netdb.h> #include <sys/types.h> #include <sys/socket.h> #include <arpa/inet.h> #include <netinet/in.h> int main(int argc, char *argv[]) { struct hostent *host; struct servent *serv; if (argc != 3) return 1; if (strcmp(argv[1], "DNS") == 0) { if ((host = gethostbyname(argv[2])) == NULL) { printf("gethostbyname error!\n"); return 1; } printf("Host name: %s\n", host->h_name); printf("Host IP: %s\n", inet_ntoa(*((struct in_addr *)host->h_addr))); } else if (strcmp(argv[1], "SERV") == 0) { if ((serv = getservbyname(argv[2], "tcp")) == NULL) { printf("getservbyname error!\n"); return 2; } printf("Serv name: %s\n", serv->s_name); printf("Serv port: %d\n", ntohs(serv->s_port)); } return 0; }
套接字选项
在进行网络编程时,经常需要查看或者设置套接字的某些特性,例如设置地址复用、读写数据的超时时间、对读缓冲区的大小进行调整等操作。函数getsockopt用于获得套接字选项的设置情况,函数setsockopt用于设置套接字选项。其函数原型如下:
#include <sys/types.h>
#include <sys/socket.h>
int getsockopt(int s, int level, int optname, void *optval, socklen_t *optlen);
int setsockopt(int s, int level, int optname, const void *optval, socklen_t *optlen);
其中参数s为标识一个套接字的描述符;参数level是选项定义的层次,目前仅支持SOL_SOCKET(通用套接字选项)、IPPROTO_IP(IP选项)和IPPROTO_TCP(TCP选项);参数optname为需设置的选项;参数optval为指向存放选项值的缓冲区指针;参数optlen为optval缓冲区长度。在不同协议层上存在选项,但往往会出现在最上面的套接字层。当对套接字选项进行操作时,必须要给出选项所处的层和选项名称。设置选项将影响套接口的操作,诸如操作的阻塞与否、包的选径方式、带外数据的传送等。这两个函数若调用成功,返回0,否则返回-1。
若未进行setsockopt调用,则调用getsockopt函数将返回系统默认值。下表中所列常用套接字选项,其中”y”表示该选项被对应函数支持。
level | optname | getsocket | setsocket | 说 明 |
---|---|---|---|---|
SOL_SOCKET | SO_BROADCAST | y | y | 允许发送广播数据报 |
SO_DEBUG | y | y | 使能调试跟踪 | |
SO_DONTROUTE | y | y | 旁路路由表查询 | |
SO_ERROR | y | 获取待处理错误并消除 | ||
SO_KEEPALIVE | y | y | 周期性测试连接是否存活 | |
SO_LINGER | y | y | 若有数据待发送则延迟关闭 | |
SO_OOBINLINE | y | y | 让接收到的带外数据继续在线存放 | |
SO_RCVBUF | y | y | 接收缓冲区大小 | |
SO_SNDBUF | y | y | 发送缓冲区大小 | |
SO_RCVLOWAT | y | y | 接收缓冲区低潮限度 | |
SO_SNDLOWAT | y | y | 发送缓冲区低潮限度 | |
SO_RCVTIMEO | y | y | 接收超时 | |
SO_SNDTIMEO | y | y | 发送超时 | |
SO_REUSEADDR | y | y | 允许重用本地地址 | |
SO_REUSEPORT | y | y | 允许重用本地端口 | |
SO_TYPE | y | 取得套接口类型 | ||
SO_USELOOPBACK | y | y | 路由套接口取得所发送数据的拷贝 | |
IPPROTO_IP | IP_HDRINCL | y | y | IP头部包括数据 |
IP_OPTIONS | y | y | IP头部选项 | |
IP_RECVDSTADDR | y | y | 返回目的IP地址 | |
IP_RECVIF | y | y | 返回接收到的接口索引 | |
IP_TOS | y | y | 服务类型和优先权 | |
IP_TTL | y | y | 存活时间 | |
IP_MULTICAST_IF | y | y | 指定外出接口 | |
IP_MULTICAST_TTL | y | y | 指定外出TTL | |
IP_MULTICAST_LOOP | y | y | 指定是否回馈 | |
IP_ADD_MEMBERSHIP | y | 加入多播组 | ||
IP_DROP_MEMBERSHIP | y | 离开多播组 | ||
IPPROTO_TCP | TCP_KEEPALIVE | y | y | 控测对方是否存活前连接闲置秒数 |
TCP_MAXRT | y | y | TCP最大重传时间 | |
TCP_MAXSER | y | y | TCP最大分节大小 | |
TCP_NODELAY | y | y | 禁止Nagle算法 | |
TCP_STDURG | y | y | 紧急指针的解释 |
例2:某个服务器进程占用了TCP的80端口进行侦听,当再次在此端口侦听时,系统往往会因地址冲突而返回错误。这主要是因为某些非正常退出的服务器程序,操作系统可能需要占用端口一段时间才能允许其他进程使用,即使这个程序已经终止,内核仍然需要一段时间才能释放此端口,而通过设置套接字选项SO_REUSEADDR(允许重用本地地址)则可以解决该类问题。
#include <sys/types.h> #include <sys/socket.h> #include <errno.h> int initserver(int type, const struct sockaddr *addr, socklen_t alen, int qlen) { int fd, err; int reuse = 1; if ((fd = socket(addr->sa_family, type, 0)) < 0) return -1; if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(int)) < 0) { err = errno; goto errout; } if (bind(fd, addr, alen) < 0) { err = errno; goto errout; } if (type == SOCK_STREAM || type == SOCK_SEQPACKET) { if (listen(fd, qlen) < 0) { err = errno; goto errout; } } return fd; errout: close(fd); errno = err; return -1; }