Unix网络编程(1):套接字编程简介
1. 传输层
TCP
建立一个连接需要三次握手:
- 服务器准备接受外部连接,通常通过调用 $socket$ 、$bind$ 、$listen$ 这 $3$ 个函数完成,称为被动打开 ( $passive$ $open$ );
- 客户端通过调用 $connect$ 发起主动打开 ( $active$ $open$ ),即发送一个
SYN
报文; - 服务器确认 (
ACK
) 客户端的SYN
,同时也发送一个SYN
; - 客户端确认服务器的
SYN
。
TCP
终止一个连接需要四次挥手:
- 某个进程调用 $close$ ,称为主动关闭 ( $active$ $close$ ),该端发送一个
FIN
报文; - 对端接受
FIN
,执行被动关闭 ( $passive$ $close$ )。这个FIN
由TCP
确认,接收也作为一个文件结束符 ( $end-of-file$ , $EOF$ ) 传递给接收端应用程序 ( 放在已排队等候该应用进程接收的任何其他数据之后 ); - 一段时间后,接收到
EOF
的进程调用 $close$ 关闭套接字,并发送一个FIN
报文; - 接收到
FIN
的原端TCP
确认。
TCP
状态转换图可参考TCP、UDP和DNS简介。
SCTP
建立一个连接的过程为:
- 服务器准备接受外部连接,通常通过调用 $socket$ 、$bind$ 、$listen$ 这 $3$ 个函数完成,称为被动打开;
- 客户端通过调用 $connect$ 或者发送一个隐式打开该关联的消息进行主动打开,从而发送一个
INIT
消息,告知服务器客户端的IP
地址清单、初始序列号、分组起始标记、客户端请求的外出流数目以及客户端能支持的外出流的数目; - 服务器以一个
INIT
ACK
消息确认,其中含有服务器的IP
地址清单、初始序列号、起始标记、请求的外出流数目、支持的外出流数目和一个状态cookie
,包含服务器用于确认本次连接有效所需的所有状态; - 客户端以一个
COOKIE
ECHO
消息回射状态cookie
; - 服务器以一个
COOKIE
ACK
消息确认cookie
正确,于是连接建立。
SCTP
不像TCP
那样允许”半关闭“的连接,而是当某一端关闭时,另一端必须停止发送新数据,在发送完所有队列中的数据后关闭。
一个TCP
连接的套接字对 ( $socket$ $pair$ ) 是一个定义该连接的两个端点的四元组:本地IP
地址、本地TCP
端口号、外地IP
地址、外地TCP
端口号。在两个端点均非多宿这一最简单情形下,SCTP
和TCP
所用的四元组套接字对一致。然而在某个关联的任何一个端点为多宿的情形下,同一个关联可能需要多个四元组标识 ( IP
地址各不相同,但端口号一致 )。标识每个端点的两个值 ( IP
地址和端口号 ) 通常称为一个套接字。
并发服务器中主服务器循环通过派生一个子进程来处理每个新连接。假设服务器在端口 $21$ 上被动打开,等待请求,此时我们可使用 $\{$ $*:21$ , $*:*$ $\}$ 指出服务器的套接字对,其中外地IP
地址和外地端口都没有指定,记为 $*.*$ ,我们称为监听套接字 ( $listening$ $socket$ )。当服务器接受到一个客户连接时,会 $fork$ 一个子进程,并将连接交由子进程处理,这时产生的套接字称为 已连接套接字 ( $connected$ $socket$ )。
2. 套接字编程
2.1 套接字地址结构
2.1.1 IPv4
套接字地址结构
IPv4
套接字地址结构通常也称为“网际套接字地址结构”,命名为 $sockaddr_-in$ ,定义在 <$netinet/in.h$> 头文件中。
struct in_addr {
in_addr_t s_addr; // 32位IPv4地址,以网络字节序存储
};
typedef unsigned short int sa_family_t;
struct sockaddr_in {
uint8_t sin_len; // 结构体长度
sa_family_t sin_family; // AF_INET
in_port_t sin_port; // 16位端口号,网络字节序存储
struct in_addr sin_addr; // 32位IPv4地址
char sin_zero[8]; // 未使用
};
- 长度 $sin_-len$ 是为增加对
OSI
协议的支持而添加的,不是所有厂家都支持,而且POSIX
协议也并不要求; - 即使有长度字段,也无需设置和进行检查,除非涉及路由套接字;
POSIX
规范只需要 $sin_-family$ 、$sin_-addr$ 和 $sin_-port$ 这三个字段,除此之外,额外字段也是可以接受的;IPv4
地址和TCP
或UDP
端口总是以网络字节序存储;- $sin_-zero$ 字段不被使用,但在大多数定义中都存在;
- 按照惯例,在使用前应当将整个结构置 $0$ 。
2.1.2 通用套接字地址结构
当作为一个参数传递进任何套接字函数时,套接字地址结构总是以指针形式传递。然而这样的指针作为参数之一的任何套接字函数必须处理来自所支持的任何协议族的套接字地址结构。在ANSI C
之前,采取的办法是定义一个通用的套接字地址结构 $sockaddr$ ,定义在 <$sys/socket.h$> 头文件中。
struct sockaddr {
uint8_t sa_len;
sa_family_t sa_family; // 地址族:AF_xxx
char sa_data[14]; // 协议地址
于是套接字函数被定义为指向通用套接字地址结构的一个指针作为参数之一,比如:
int bind(int, struct sockaddr *, socklen_t);
所以在对这些函数进行调用前需要进行类型转换,否则会产生警告信息。因此,从应用程序开发人员的角度来看,通用套接字地址结构的唯一用途就是用作强制类型转换。
2.1.3 IPv6
套接字地址结构
IPv6
套接字地址结构定义在 <$netinet/in.h$> 头文件中。
struct in6_addr {
uint8_t s6_addr[16]; // 128位IPv6地址,以网络字节序存储
};
#define SIN6_LEN // 用于编译时测试
struct sockaddr_in6 {
uint8_t sin6_len; // 结构体长度
sa_family_t sin6_family; // AF_INET6
in_port_t sin6_port; // 传输层端口,以网络字节序存储
uint32_t sin6_flowinfo; // 流信息,未定义
struct in6_addr sin6_addr; // IPv6地址
uint32_t sin6_scope_id; // 范围接口集
};
- 如果系统支持套接字地址结构中长度字段,那么 $SIN6_-LEN$ 常数必须定义;
- $sin6_-flowinfo$ 字段分成两个部分,低 $20$ 位作为流标识 ( $flow$ $label$ ),高 $12$ 位保留;
- 对于范围地址 ( $scoped$ $address$ ),$sin6_-scope_-id$ 字段标识范围。
2.1.4 新的通用套接字地址结构
作为IPv6
套接字API
的一部分而定义的新的通用套接字地址结构克服了现有结构的一些缺点,足以容纳系统所支持的任何套接字地址结构,定义在 <$netinet/in.h$> 头文件中。
struct sockaddr_storage {
uint8_t ss_len; // 结构体长度
sa_family_t ss_family; // 地址族:AF_xxx
};
$sockaddr_-storage$ 类型提供的通用套接字地址结构相比 $sockaddr$ 存在以下两点差别:
- 如果系统支持的任何套接字地址结构有对齐需求,那么 $sockaddr_-storage$ 能够满足严苛的对齐需求;
- $sockaddr_-storage$ 足够大,能够容纳系统支持的任何套接字地址结构。
除了 $ss_-family_-t$ 和 $ss_-len$ 之外,$sockaddr_-storage$ 结构中的其他字段对于用户来说是透明的,必须转换成或者复制到对应的地址结构中才能访问。
2.2 值-结果参数
当往一个套接字函数传递套接字结构时,除了结构体指针外,还需要传递结构长度,传递方式取决于是从进程到内核,还是从内核到进程。从进程到和内核传递套接字地址结构的函数有 $3$ 个:$bind$ 、$connect$ 和 $sendto$ ,这些函数其中一个参数是套接字结构体指针,另一个参数是结构体的整型大小;从内核到进程传递套接字地址结构的函数有 $4$ 个:$accept$ 、$recvfrom$ 、$getsockname$ 和 $getpeername$ ,这些函数的其中一个参数是套接字结构体指针,另一个是结构体整型大小指针。于是在这两种传递方式中,结构体大小既作为值进行传递,也作为结果进行传递,称为值-结果 ( $value-result$ ) 参数。
2.3 字节排序函数
系统中存储数据的方式可以分为将字节低位存储在起始位置的小端 ( $little-endian$ ) 序和将字节高位存储在起始地址的 大端 ( $big-endian$ ) 序,这两种字节序之间没有标准可循,并且都有系统使用。我们把某个给定系统所用的字节序称为主机字节序 ( $host$ $byte$ $order$ )。网络协议必须指定一个网络字节序 ( $network$ $byte$ $order$ ),即大端序。从理论上来说,具体实现可以按照主机字节序存储套接字地址结构中的各个字段,等到需要再进行转换。然而,由于历史原因和POSIX
协议规定,套接字地址结构中的某些字段必须按照网络字节序进行维护。用于主机字节序和网络字节序之间相互转换的函数主要有以下 $4$ 个:
#include <netinet/in.h>
// 返回网络字节序值
uint16_t htons(uint16_t host16bitvalue);
uint32_t htonl(uint32_t host32bitvalue);
// 返回主机字节序值
uint16_t ntohs(uint16_t net16bitvalue);
uint32_t ntohl(uint32_T net32bitvalue);
2.4 字节操纵函数
操纵多字节字段的函数有两组。
第一组名字以 $b$ 开头,现今几乎所有支持套接字函数的系统都有提供。
#include <strings.h>
void bzero(void *dest, size_t nbytes);
void bcopy(const void *src, void *dest, size_t nbytes);
int bcmp(const void *ptr1, const void *ptr2, size_t nbytes);
第二组名字以 $mem$ 开头,起源自ANSI C
标准,支持ANSI C
标准函数库的系统都有提供。
#include <string.h>
void *memset(void *dest, int c, size_t len);
void *memcpy(void *dest, const void *src, size_t nbytes);
int memcmp(const void *ptr1, const void *ptr2, size_t nbytes);
$memset$ 将目标字符串指定数目字节数的值置为 $c$ 。$memcpy$ 类似于 $bcopy$ ,不过两个指针参数顺序相反,当处理区域重叠时,前者行为未定义,后者能正确处理。
2.5 地址转换函数
地址转换函数用于ASCII
字符串与网络字节序二进制值之间转换网络地址。
#include <arpa/inet.h>
// 字符串有效返回1,否则返回0
int inet_aton(const char *strptr, struct in_addr *addrptr);
// 字符串有效则返回32位二进制网络字节序IPv4地址,否则为INADDR_NONE
in_addr_t inet_addr(const char *strptr);
// 返回一个指向点分十进制字符串的指针
char *inet_ntoa(struct in_addr inaddr);
$inet_-aton$ 将 $strptr$ 转为 $32$ 位的网络字节序二进制值,存储在 $addrptr$ 中,成功返回 $1$ ,失败返回 $0$ 。$inet_-addr$ 进行相同转换,但是存在一个问题,当出错时,会返回 $INADDR_-NONE$ ,后者通常是 $32$ 位的以 $1$ 填充的值,这意味着有限广播地址 ( $255$.$255$.$255$.$255$ ) 不能由该函数处理,所以该函数如今已被废弃,应改用 $inet_-aton$ 。$inet_-ntoa$ 函数将一个 $32$ 位网络字节序二进制IPv4
地址转换为相应的点分十进制字符串,该函数是不可重入的。
#include <arpa/inet.h>
// 字符串有效返回1,否则返回0
int inet_pton(int family, const char *strptr, void *addrptr);
// 返回一个指向结果的指针
const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);
这两个函数是随IPv6
出现的,既适用于IPv4
地址,也适用于IPv6
地址。$family$ 参数可以是 $AF_-INET$ ,也可以是 $AF_-INET6$ ,如果是不支持的地址族,则会返回错误,并将 $errno$ 置为 $EAFNOSUPPORT$ 。$inet_-pton$ 会转换 $strptr$ 指针指向的字符串,存储在 $addrptr$ 中,成功返回 $1$ ,失败返回 $0$ 。$inet_-pton$ 则进行相反转换,从 $addptr$ 转换到 $strptr$ ,$len$ 标识目的存储单元大小。在 <$netinet/in.h$> 头文件中存在以下两个定义:
#define INET_ADDRSTRLEN 16
#define INET6_ADDRSTRLEN 46
帮助指定大小。如果 $len$ 太小,无法容纳结果,那么就会返回 $NULL$ ,并将 $errno$ 置为 $ENOSPC$ 。
2.6 I/O
函数
字节流套接字上的 $read$ 和 $write$ 函数所表现的行为不同于通常的文件I/O
,其输入或输出的字节数可能比请求数量少,原因在于内核中用于套接字的缓冲区可能达到了极限,此时需要再次调用 $read$ 或 $write$ 函数,以输入或输出剩余字节。为了预防这种情况,我们需要改写下原来的I/O
函数。
#include "unp.h"
ssize_t readn(int fd, void *vptr, size_t n) {
size_t nleft = n;
ssize_t nread;
char *ptr = vptr;
while (nleft > 0) {
if ((nread = read(fd, ptr, nleft)) < 0) {
if (errno == EINTR)
nread = 0;
else
return -1;
} else if (nread == 0)
break;
nleft -= nread;
ptr += nread;
}
return n - nleft;
}
ssize_t writen(int fd, const void *vptr, size_t n) {
size_t nleft = n;
ssize_t nwritten;
const char *ptr = vptr;
while (nleft > 0) {
if ((nwritten = write(fd, ptr, nleft)) <= 0) {
if (nwriten < 0 && errno == EINTR)
nwritten = 0;
else
return -1;
}
nleft -= nwritten;
ptr += nwritten;
}
return n;
}
$EINTR$ 表示系统调用被一个捕获的信号中断,如果发生该错误则继续进行读写操作。