[关闭]
@FunC 2018-05-15T19:18:32.000000Z 字数 7366 阅读 2018

UNP CH03, CH04

unix


CH03 套接字编程简介

套接字地址结构

IPv4套接字地址结构

sockaddr_in

  1. struct in_addr {
  2. in_addr_t s_addr; /* 32位 IPv4 地址*/
  3. };
  4. struct sockaddr_in {
  5. uint8_t sin_len; /* 结构的长度(16字节) */
  6. sa_family_t sin_family; /* 协议族, AF_INET */
  7. in_port_t sin_port; /* 16位的TCP/UDP端口号 */
  8. struct in_addr sin_addr;
  9. char sin_zero[8]; /* unused */
  10. };

IPv6套接字地址结构

sockaddr_in6

  1. struct in6_addr {
  2. unit8_t s6_addr[16]; /* 128位(8 x 16) IPv6 地址*/
  3. };
  4. #define SIN6_LEN /* require for compile-time test */
  5. struct sockaddr_in6 {
  6. uint8_t sin6_len; /* 结构的长度(28字节) */
  7. sa_family_t sin6_family; /* 协议族, AF_INET6 */
  8. in_port_t sin6_port; /* 16位的TCP/UDP端口号 */
  9. unit32_t sin6_flowinfo /* 流信息,未定义 */
  10. struct in6_addr sin6_addr;
  11. unit32_t sin6_scope_id; /* 对于具备范围的地址,标识范围(scope) */
  12. };

通用套接字地址结构

sockaddr

  1. struct sockaddr {
  2. uint8_t sa_len;
  3. sa_family_t sa_family; /* 协议族 */
  4. char sa_data[14]; /* 协议相关的地址 */
  5. };

因为套接字函数需要用到套接字地址的指针,但是不同协议的套接字地址结构类型不同,而通用套接字地址结构便是用于进行强制类型转换(因为当时还没有ANSI C,没有 void*)

POSIX数据类型

套接字地址结构比较

值-结果参数

使用套接字函数时,其参数通常是一个结构的指针以及结构的长度。传递的方向不同,传递的方式也不同:
1. 从进程向内核传递,传递结构的大小即可(int,实际是socklen_t)

  1. struct sockaddr_in serv;
  2. connect(sockfd, (SA *) &serv, sizeof(serv));

因为结构大小参数的作用是用于告诉内核到底要从进程复制多少数据进来(只读)

  1. 从内核向进程传递,传递结构大小的指针(int *, 实际是 socklen_t *)
  1. struct sockaddr_in cli
  2. socklen_t len;
  3. len = sizeof(cli);
  4. getpeername(unixfd, (SA *) &cli, &len

核心需要结构的大小,目的是避免越界。同时还要修改结构的大小(可写),以告知进程该结构中存储了所少信息。

字节排序函数

对于一个16位证书,它由两个字节组成。内存中存储两个字节的方法有两种:小端和大端

系统使用的字节序称为主机字节序(host byte order)
网络协议必须指定一个网络字节序(network byte order)
这两种字节序之间的转换使用以下4个函数:

  1. #include <netinet/in.h>
  2. unit16_t htons(unit16_t host16bitvalue);
  3. unit32_t htonl(unit32_t host32bitvalue);
  4. /* 均返回:网络字节序的值 */
  5. unit16_t ntohs(unit16_t net16bitvalue);
  6. unit32_t ntohl(unit32_t net32bitvalue);
  7. /* 均返回:主机字节序的值 */

其中h代表host,n代表network,s代表short,l代表long

字节操纵函数

这些函数不对数据作解释,也不假设数据是以空字符结束的C字符串。
名字以b(表示字节)开头的一组:

  1. #include <strings.h>
  2. void bzero(void *dest, size_t nbytes); /* 目标字节串中指定数目字节置0 */
  3. void bcopy(const void *src, void *dest, size_t nbytes);
  4. /* 将指定数目的字节从源字节串移到目标字节串 */
  5. int bcmp(const void *ptrl, const void *ptr2, size_t nbytes);
  6. /* 返回:若相等则为0, 否则非0 */

inet_aton, inet_addr和inet_ntoa函数

inet_pton和inet_ntop函数

函数名中的p和n分别代表表达(presentation)数值(numeric)

  1. #include <arpa/inet.h>
  2. int inet_pton(int family, const char *strptr, void *addrptr);
  3. /* 返回:若成功则为1,若输入格式无效则为0,若出错为1 */
  4. const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len)
  5. /* 返回:若成功则为指向结果的指针,若出错则为NULL */

地址转换函数小结

sock_ntop和相关函数

inet_ntop尽管能适用于IPv4和IPv6,但它要求调用者传递一个指向某个二进制地址的指针。然而该地址通常包含在一个套接字地址结构中,因此,它要求调用者必须知道这个结构的格式和地址族,导致我们的代码与协议相关。
为了解决问题,我们自行编写一个名为sock_ntop函数,以指向某个套接字地址结构的指针为参数,查看结构内部,然后调用适当的函数来返回该地址的表达格式。

reads, writen 和 readline函数

字节流套接字上的read和write函数的表现和普通的文件I/O不同。输入或输出的字节数可能比请求的数量少,这是因为内核中用于套接字的缓冲区可能已经达到了极限,这是需要调用者再次调用read或write函数,以输入或输出剩余的字节。
下面给出相应的实现:
readn.c

  1. #include "unp.h"
  2. ssize_t /* Read "n" bytes from a descriptor. */
  3. readn(int fd, void *vptr, size_t n)
  4. {
  5. size_t nleft;
  6. ssize_t nread;
  7. char *ptr;
  8. ptr = vptr; /* 因为要知道指针前进的步数,需要将void指针变为char指针 */
  9. nleft = n;
  10. while (nleft > 0) {
  11. if ( (nread = read(fd, ptr, nleft)) < 0) {
  12. if (errno == EINTR)
  13. nread = 0; /* and call read() again */
  14. else
  15. return(-1);
  16. } else if (nread == 0)
  17. break; /* EOF */
  18. nleft -= nread;
  19. ptr += nread;
  20. }
  21. return(n - nleft); /* return >= 0 */
  22. }
  23. /* end readn */

writen.c

  1. #include "unp.h"
  2. ssize_t /* Write "n" bytes to a descriptor. */
  3. writen(int fd, const void *vptr, size_t n)
  4. {
  5. size_t nleft;
  6. ssize_t nwritten;
  7. const char *ptr;
  8. ptr = vptr;
  9. nleft = n;
  10. while (nleft > 0) {
  11. if ( (nwritten = write(fd, ptr, nleft)) <= 0) {
  12. if (nwritten < 0 && errno == EINTR)
  13. nwritten = 0; /* and call write() again */
  14. else
  15. return(-1); /* error */
  16. }
  17. nleft -= nwritten;
  18. ptr += nwritten;
  19. }
  20. return(n);
  21. }
  22. /* end writen */

至于readline函数,为了避免每读一个字节就调用一次read函数,同时又不使用stdio(因为其缓冲区状态不可见),我们需要自行实现缓冲区
readline.c

  1. #include "unp.h"
  2. static int read_cnt;
  3. static char *read_ptr;
  4. static char read_buf[MAXLINE];
  5. static ssize_t /* 包装后的read */
  6. my_read(int fd, char *ptr)
  7. {
  8. if (read_cnt <= 0) {
  9. again: /* 每次最多读MAXLINE个字符 */
  10. if ( (read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
  11. if (errno == EINTR)
  12. goto again;
  13. return(-1);
  14. } else if (read_cnt == 0)
  15. return(0);
  16. read_ptr = read_buf;
  17. }
  18. read_cnt--;
  19. *ptr = *read_ptr++;
  20. return(1);
  21. }
  22. ssize_t
  23. readline(int fd, void *vptr, size_t maxlen)
  24. {
  25. ssize_t n, rc;
  26. char c, *ptr;
  27. ptr = vptr;
  28. for (n = 1; n < maxlen; n++) {
  29. if ( (rc = my_read(fd, &c)) == 1) {
  30. *ptr++ = c;
  31. if (c == '\n')
  32. break; /* newline is stored, like fgets() */
  33. } else if (rc == 0) {
  34. *ptr = 0;
  35. return(n - 1); /* EOF, n - 1 bytes were read */
  36. } else
  37. return(-1); /* error, errno set by read() */
  38. }
  39. *ptr = 0; /* null terminate like fgets() */
  40. return(n);
  41. }
  42. ssize_t
  43. readlinebuf(void **vptrptr)
  44. {
  45. if (read_cnt)
  46. *vptrptr = read_ptr;
  47. return(read_cnt);
  48. }
  49. /* end readline */

CH04 基本TCP套接字编程

socket函数

为了执行网络I/O,一个进程必须做的第一件事就是调用socket函数,指定期望的通讯协议类型:

  1. #include <sys/socket.h>
  2. int socket(int family, int type, int protocol);
  3. /* 返回:若成功则为非负描述符, 否则为-1 */

其中family参数指明协议族,type参数指明套接字类型,protocol参数为某个协议类型常值(或设为0,选择所给定family和type组合的系统默认值)

family常值:

type常值:

protocol常值:

family 和 type 的组合:

connect 函数

TCP客户用connect函数来建立与TCP服务器的连接:

  1. #include <sys/socket.h>
  2. int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
  3. /* 返回:若成功则为0,出错为-1 */

其中的sockfd是socket函数返回的套接字描述符,另外两个参数则是上一章提到的(服务器的)套接字地址结构(必须含有服务器IP地址和端口号)及其大小。
对于TCP套接字,调用connect会触发TCP的三路握手,且仅在连接建立成功或出错时返回。
出错返回的情况:
1. 超时
2. 收到的响应是RST,表明服务器主机在我们指定的端口上没有进程在等待与之连接,是硬错误
3. 客户发出的SYN在中间某个路由上引发了“目的地不可达”ICMP错误,属于软错误

需要注意的是,connect失败后,对应的套接字不再可用,必须close当前的套接字描述符并重新调用socket。

bind函数

bind函数把一个本地协议地址赋予给一个套接字。对于网际协议,协议地址是32位的IPv4地址或128位的IPv6地址与16位TCP或UDP端口号的组合。

  1. #include <sys/socket.h>
  2. int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen);
  3. /* 返回:若成功则为0,出错为-1 */

参数于connect函数基本一致

如果客户或服务器在调用connect或listen时未曾调用bind函数,内核则为相应的套接字选择一个临时端口。

bind可以指定IP地址或端口:

对于IPv4,通配地址由常值INADDR_ANY指定,其值一般为0
对于IPv6,因为其存放在一个结构中;而C语言中,赋值语句右边无法表示常值结构,因此作出一些改写:

  1. struct sockaddr_in6 serv
  2. serv.sin6_addr = in6addr_any; /* wildcard */

系统预先分配in6addr_any变量并将其初始化为常值IN6ADDR_ANY_INIT。而头文件中含有in6addr_any的extern生命

因为头文件中定义的所有INADDR_常值都是按照主机字节序定义的,所以都应该对其使用htonl

Bind函数返回的常见错误时EADDRINUSE(地址已使用)

listen函数

listen函数仅由TCP服务器调用,它做两件事:
1. socket函数创建的套接字,默认是主动套接字,处于CLOSED状态,本应调用connect来发起连接。listen函数将主动套接字变为被动套接字,CLOSED状态转换到LISTEN状态,等待接受连接请求
2. 函数的第二个参数规定了内核应该为相应套接字排队的的最大连接个数(backlog)

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

本函数在调用socket和bind函数之后,调用accept函数之前调用。

关于backlog参数,内核为任何一个给定的监听套接字维护两个队列:
1. 未完成连接队列。队列中的套接字处于SYN_RCVD状态
2. 已完成队列。套接字以完成三次握手,处于ESTABLISHED状态

为了应对backlog值不足的问题,我们可以设定一个默认值,同时允许命令行选项或环境变量覆写该默认值。

accept函数

Accept函数由TCP服务器调用,用于从已完成连接队列队头返回下一个已完成连接。如果已完成连接队列为空,那么进程投入睡眠。

  1. #include <sys/socket.h>
  2. int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
  3. /* 返回:若成功则为非负描述符,若出错为-1 */

其中cliaddr和addrlen用来返回一连接的对端进程(客户)的协议地址。
如果accept成功,那么其返回值是由内核自动生成的一个全新的描述符,代表与所返回客户的TCP连接。
其中accept函数的第一个参数称为监听套接字描述符,返回值为已连接套接字描述符。
一个服务器通常仅仅创建一个监听套接字,该套接字在服务器的生命期内一直存在。

fork和exec函数

fork函数是Unix中派生新进程的唯一方法。

  1. #include <unistd.h>
  2. pid_t fork(void);
  3. /* 返回:在子进程中为0,在父进程中为子进程ID,若出错则为-1 */

不在子进程中返回父进程的id的原因,是因为父进程只有一个,且子进程可以通过getppid来取得父进程的id。而子进程有多个,父进程需要手动记录每个子进程的id,以方便后续操作。

fork函数的一个重要特性,就是附近父进程中调用fork之前打开的所有描述符在fork返回之后由子进程分享。

fork的两个典型用法:
1. 一个进程创建自身的副本,然后副本中执行其他任务(网络服务器的典型用法)
2. 一个进程想要执行另一个程序。因为创建新进程的唯一办法是调用fork,调用fork以后,在新进程中调用exec,把当前进程映像替换成新的程序文件(进程id不变),新程序从main函数开始执行。

并发服务器

上面说到,可以借助fork函数创建的子进程会共享描述符这一特性来编写并发服务器。
一般fork出子进程后,主进程会close相应的已连接套接字。但是这是连接并不会关闭,这是因为每个文件或套接字都有一个引用计数。在fork之后,已连接套接字的引用计数为2,主进程close只会把计数变为1,要等到子进程也close才会把已连接套接字的计数变为0,然后进行清理和资源释放。
图示:



close函数

close函数通常用于关闭套接字

  1. #include <unistd.h>
  2. int close(int sockfd);

关闭后的套接字描述符不能再由调用进程使用。

getsockname和getpeername函数

这两个函数或者返回与某个套接字关联的本地协议地址,或者返回与某个套接字关联的外地协议地址。

  1. #include <sys/socket.h>
  2. int getsockname(int sockfd, struct sockaddr *localaddr, socklen_t *addrlen);
  3. int getperrnname(int sockfd, struct sockaddr *peeraddr, socklen_t *addrlen);

需要这两个函数的情况,是因为有可能:
1. 没有调用bind,内核赋予连接IP地址和端口号
2. 以端口号0调用bind,内核去选择本地端口号
3. getsockname用于获取某个套接字的地址族
4. 对于以通配IP地址调用bind的TCP服务器上,一旦accept成功返回,getsockname就可以后去内核赋予的本地IP地址
5. 当一个服务器是由调用过accpt的某个进程通过调用exec执行程序时,它能够获取客户身份的唯一途径就是调用getpeername

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注