从零到负一

Socket编程基础1 - 套接字和TCP/IP入门

2020/04/16

这篇文章主要是用于复习UNP的第4章,以及之前章节关于TCP/IP的基本知识。这部分内容主要包括TCP/IP握手协议、socket的结构、如何建立socket连接等。这部分是后面学习的基础,因此必须要牢牢的掌握。

IPV4套接字地址结构

1
2
3
4
5
6
7
8
9
10
11
#define __SOCK_SIZE__  16           /* sizeof(struct sockaddr) */
struct sockaddr_in {
__kernel_sa_family_t sin_family; /* Address family */
__be16 sin_port; /* Port number */
struct in_addr sin_addr; /* Internet address */


/* Pad to size of `struct sockaddr'. */
unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
sizeof(unsigned short int) - sizeof(struct in_addr)];
};

sin_familyunsigned short(16bit)类型, sin_port也是unsigned short(16bit)类型,sin_addr结构体中只含有一个unsigned int(32bit)的变量,因此sin_addr实际长度也和unsigned int(32bit)一样。

一般套接字必须包含address family, port numberaddress,长度大于等于16字节。这里定义的套接字占16字节,其中最后的__pad部分必须置零。

有些函数,由于历史原因,并没有使用sockaddr_in作为参数的类型,而是使用sockaddr作为参数类型(比如connect函数):

1
2
3
4
struct sockaddr {
sa_family_t sa_family; /* address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};

我们可以将sockaddr_in类型强制转换成sockaddr类型,sin_port, sin_addr__pad就成了sa_data

字节排序和字符转换

不同硬件可能使用不同的字节排序(也就是我们说的大端和小端格式),但network传输数据时是按照大端的格式进行传输的。因此我们在收发数据时,需要使用以下函数对数据进行转换:

1
2
3
4
5
// 下面函数返回主机/网络字节的值
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

上面函数中h指的是本地主机,n指的是network,s是short, l是long。因此htonl就是将本地主机数据转换成network传输的数据类型,函数返回转换后的数据(如该数据小于1个字节,我们可以不用转换)。

除了数据需要转换,我们还需要对192.168.0.1这样的字符串地址和network传输的地址进行转换,转换函数使用:

1
2
3
4
// 成功返回1,其他情况返回0或-1
int inet_pton(int af, const char *src, void *dst);
// 成功返回指向结果的指针
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

上面函数中p指的是表达式,n指的是数字。inet_pton常用于设置sockaddr_in结构体中的sin_addr

TCP 3次握手和4次挥手

这里我简单总结下TCP的连接和断开。TCP在进行连接时,客户端和服务器端需要通过进行3次握手来建立连接;TCP在断开时,客户端和服务器端需要通过进行4次挥手来断开连接。

3次握手

3次握手基本流程

图1. Socket 3次握手示意图

这是一个简化版的3次握手示意图。服务器端首先调用socket, bind, listenaccept,然后被accept阻塞;客户端在服务器端调用accept后调用connect去主动打开服务器端的socket;至于客户端的socket,可以在accept前或后被调用。

  1. 服务器端被accept阻塞;
  2. 客户端调用connect然后被阻塞;同时发送SYN J 给服务器端;
  3. 服务器端收到SYNJ后,发送SYN KACK J+1(ACK的序列号是客户端SYN序列号加1)给客户端;
  4. 客户端收到ACK J+1后,知道连接成功,发送ACK K+1给服务器端,并从connect函数中返回;
  5. 服务器端收到ACK K+1后,知道客户端已经成功连接,从accept函数中返回。

上面就是TCP 3次握手的简化过程,这里并没有包括一些出错的情况:比如服务器端/客户端没有响应,连接到错误的服务器,网络不可到达等等。

为什么要3次握手?

只有进行至少3次握手,客户端和服务器端才能知道对方都准备好了。我们可以简单理解成,3次握手后,客户端和服务器端都知道对方可以读、可以写,然后就可以进行数据传输了。下面简单解释下:

比如第2次握手,服务器端已经知道自己可以接收数据(同时也知道客户端可以发送数据),但它不知道自己可不可以发送数据(也不知道客户端能不能接收数据),因此,服务器端发送ACK J+1给客户。客户端在收到ACK J+1前也不知道自己能不能发送/接收数据,当收到ACK J+1后,它知道自己可以发送/接收数据了。接下来的第3次握手后,服务器端也知道自己可以发送/接收数据了。

4次挥手

图2. TCP 4次挥手的示意图
#### 4次挥手的基本流程

当客户端断开连接(可能是直接调用close/shutdown,也可能是线程被关掉)时,4次挥手就发生了。

  1. 客户端发送FIN M给服务器端;
  2. 服务器端收到FIN M后,发送ACK M+1给客户端;
  3. 在之后某个时间,服务器端调用close函数,同时发送FIN N给客户端;
  4. 客户端收到FIN N后,回复一个ACK N+1,然后客户端就断开了连接。

这里的流程和3次握手很像,都是发送一个信号,然后回复一个信号,回复信号的序列号是接收信号的序列号加1。

为什么要4次挥手?

4次挥手和3次握手相比,多出了一步,就是服务器端发送FIN N给客户端,之所以要多出这步,是因为服务器端不会在接收到客户端关闭的信号后,立即关闭socket,而是会等待一段时间再调用close函数,因此我们需要多一次挥手。

3次握手和4次挥手的状态转移图

图3. TCP 3次握手和4次挥手的流程图

这个是完整的3次握手、4次挥手流程图,这个流程中添加了客户端和服务器端的状态。下图是一个完整的TCP状态转移图:

图4. TCP状态转移图

这个图比较复杂,和图3一起看比较好。TIME_WAIT是一个不太容易理解的状态,这里我不做深入研究了。

Socket编程API

最简单的socket编程只需要几个API即可,我这里总结一下服务器端和客户端分别需要什么样的API。

服务器端

图5是一个简单的服务器-客户端socket编程中服务器端的示意图,一共用到了4个socket编程专用的函数。它们分别是socket(), bind(), listen(), accept()

图5. 服务器端socket通信
  1. socket()函数用于生成一个socket描述符,根据实际需要,我们可以让socket()生成适合不同网络的socket描述符。比如生成IPV4, Stream并且是TCP的socket描述符和IPV4, 报文并且是UDP的socket描述符:

    1
    2
    int sock_udp = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
    int sock_tcp = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
  2. 有了socket描述符后,我们就需要将IP, 端口等的信息绑定到socket描述符上。我们通过调用bind()函数来实现这个功能。bind()用于绑定自己的接受端IP和端口,一般只绑定服务器端,不绑定客户端。服务器端如果有多个IP地址(比如多个网卡),那么可以指定某个网卡的IP接收数据;我们也可以用INADDR_ANY作为IP地址,内核会在连接时自动选择一个IP地址用于连接。要使用bind(),我们首先需要一个已经写好IP和端口的sockaddr_in和它的地址,然后需要服务器端用于监听的socket描述符号,下面是bind()的函数原型。

    1
    int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  3. 绑定IP和端口后,服务器端需要调用listen()对socket描述符进行监听。我们这里要设置一个最大连接数,这个最大连接数包括已经完成连接的和未完成连接的socket。已完成的连接是指服务器端已经accept()的连接,之前的连接都是未完成的连接。

  4. 接下来就是连接的最后一步了,调用accept()函数来接受客户端发送的连接。accept()函数默认情况下会阻塞进程,直到有连接到达。该函数原型如下:

    1
    int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);

    我们需要提供监听socket描述符、用于存放客户端IP, 端口信息的空间的地址以及该空间的大小。这个函数对于每个成功的接受,都会生成一个新的socket描述符,之后和客户端进行通信都是通过这个新生成的socket描述符。

客户端

客户端和服务器端类似,图6是客户端socket通信的示意图。我们可以看到,和服务器端相比,少了bind(), listen()accept(),但多了connect()

图6. 客户端socket通信

connect()函数原型如下:

1
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

这里sockfd是客户端socket()返回的socket描述符,sockaddr存放的是服务器端的IP和端口,addrlensockaddr存放数据的大小,这些数据都会复制到内核中的buffer中。

这种传地址、数据长度给函数(再给内核)的方法也叫“值-结果”,在Linux网络编程中,使用这种方式的函数还有很多,下面是写入内核的示意图:

图7. 值-结果参数示意图

Socket多进程基础

fork和exec函数

这部分属于多进程编程的范畴,fork()简单来说就是父进程复制一个进程作为子进程,这个子进程和父进程属于不同的内存空间,但他们的内容是一样的。现在Linux中并不是直接复制,而是只读的部分还是在一起,但要写的部分放在自己的内存空间。这么做主要是为了减少内存空间和时间的开销。

exec()函数用于覆盖父进程复制的空间,也就是用子进程要运行的代码来替代父进程的代码。在socket编程中,服务器端往往不用额外的代码,只需要和父进程一样的代码,因此可以不用exec()

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
for ( ; ; ) {
clilen = sizeof(cliaddr);
if ((connfd = accept(listenfd, (SA *) &cliaddr, &clilen)) < 0) {
if (errno == EINTR)
continue; /* back to for() */
else
err_sys("accept error");
}

if ((childpid = Fork()) == 0) { /* child process */
Close(listenfd); /* close listening socket */
// do something...
Close(connfd)
exit(0);
}
Close(connfd); /* parent closes connected socket */
}

这是一段简单的多进程socket服务器端的代码,我们可以看到子进程返回后,第一件事情就是关闭listenfd,之后父进程会关闭connfd。这里要注意,在关闭socket描述符时,并不是调用close()就结束该描述符对应的连接。每个描述符有一个counter,只有counter为0时,该连接才真正的关闭。这里经过fork()后,listenfd的counter变成了2,因此关闭listenfd后,该socket并没有结束通信。图8展示了这个过程:

图8. fork后的socket端口

fork()后,connfdlistenfd都有两个;当通信结束时,两个connfd都会被关闭,从而断开连接。而listenfd最终会保持一个,因此这个socket不会断开连接。

CATALOG
  1. 1. IPV4套接字地址结构
  2. 2. 字节排序和字符转换
  3. 3. TCP 3次握手和4次挥手
    1. 3.1. 3次握手
      1. 3.1.1. 3次握手基本流程
      2. 3.1.2. 为什么要3次握手?
    2. 3.2. 4次挥手
      1. 3.2.1. 为什么要4次挥手?
    3. 3.3. 3次握手和4次挥手的状态转移图
  4. 4. Socket编程API
    1. 4.1. 服务器端
    2. 4.2. 客户端
  5. 5. Socket多进程基础
    1. 5.1. fork和exec函数
    2. 5.2. 示例代码