Unix网络编程(1):套接字编程简介

1. 传输层

        TCP建立一个连接需要三次握手:

  1. 服务器准备接受外部连接,通常通过调用 $socket$ 、$bind$ 、$listen$ 这 $3$ 个函数完成,称为被动打开 ( $passive$ $open$ );
  2. 客户端通过调用 $connect$ 发起主动打开 ( $active$ $open$ ),即发送一个SYN报文;
  3. 服务器确认 ( ACK ) 客户端的SYN,同时也发送一个SYN
  4. 客户端确认服务器的SYN

        TCP终止一个连接需要四次挥手:

  1. 某个进程调用 $close$ ,称为主动关闭 ( $active$ $close$ ),该端发送一个FIN报文;
  2. 对端接受FIN,执行被动关闭 ( $passive$ $close$ )。这个FINTCP确认,接收也作为一个文件结束符 ( $end-of-file$ , $EOF$ ) 传递给接收端应用程序 ( 放在已排队等候该应用进程接收的任何其他数据之后 );
  3. 一段时间后,接收到EOF的进程调用 $close$ 关闭套接字,并发送一个FIN报文;
  4. 接收到FIN的原端TCP确认。

        TCP状态转换图可参考TCP、UDP和DNS简介
        SCTP建立一个连接的过程为:

  1. 服务器准备接受外部连接,通常通过调用 $socket$ 、$bind$ 、$listen$ 这 $3$ 个函数完成,称为被动打开;
  2. 客户端通过调用 $connect$ 或者发送一个隐式打开该关联的消息进行主动打开,从而发送一个INIT消息,告知服务器客户端的IP地址清单、初始序列号、分组起始标记、客户端请求的外出流数目以及客户端能支持的外出流的数目;
  3. 服务器以一个INIT ACK消息确认,其中含有服务器的IP地址清单、初始序列号、起始标记、请求的外出流数目、支持的外出流数目和一个状态cookie,包含服务器用于确认本次连接有效所需的所有状态;
  4. 客户端以一个COOKIE ECHO消息回射状态cookie
  5. 服务器以一个COOKIE ACK消息确认cookie正确,于是连接建立。

        SCTP不像TCP那样允许”半关闭“的连接,而是当某一端关闭时,另一端必须停止发送新数据,在发送完所有队列中的数据后关闭。
        一个TCP连接的套接字对 ( $socket$ $pair$ ) 是一个定义该连接的两个端点的四元组:本地IP地址、本地TCP端口号、外地IP地址、外地TCP端口号。在两个端点均非多宿这一最简单情形下,SCTPTCP所用的四元组套接字对一致。然而在某个关联的任何一个端点为多宿的情形下,同一个关联可能需要多个四元组标识 ( 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];  // 未使用
};

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;  // 范围接口集
};

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$ 存在以下两点差别:

  1. 如果系统支持的任何套接字地址结构有对齐需求,那么 $sockaddr_-storage$ 能够满足严苛的对齐需求;
  2. $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$ 表示系统调用被一个捕获的信号中断,如果发生该错误则继续进行读写操作。

Unix网络编程(1):套接字编程简介