网络编程学习3--使用socket格式建立连接

498 阅读12分钟

服务端

创建socket(socket函数)

int socket(int domain, int type, int protocol)

domain 就是指 PF_INET、PF_INET6 以及 PF_LOCAL 等,表示什么样的套接字.
type 可用的值是:

  • SOCK_STREAM: 表示的是字节流,对应 TCP;
  • SOCK_DGRAM: 表示的是数据报,对应 UDP;
  • SOCK_RAW: 表示的是原始套接字。 参数protocol是用来指定通信协议的,不过现在基本废弃了。因为协议已经通过前面两个参数指定完成了。所以protocol写成0即可。

Linux 中的一切都是文件,每个文件都有一个整数类型的文件描述符;socket 也是一个文件,也有文件描述符。使用 socket() 函数创建套接字以后,返回值就是一个 int 类型的文件描述符。
Windows 会区分 socket 和普通文件,它把 socket 当做一个网络连接来对待,调用 socket() 以后,返回值是 SOCKET 类型,用来表示一个套接字。

bind函数

如果想要创建的socket能够被使用,就需要将socket和socket地址绑定,这一步需要通过bind函数来实现。调用bind函数的方式如下:

int bind(int fd, const struct sockaddr* addr, socklen_t len)

第一个参数fd,是使用socket()函数创建的文件描述符。第二个参数是通用地址格式sockaddr* addr,不过需要注意的是,虽然这里接收的是通用地址格式,实际上传入的参数可能是IPv4、IPv6或者本地socket格式。第三个参数len,即addr结构的长度,可以设置成sizeof(struct sockaddr), bind函数会根据len字段判断传入的参数addr该怎么解析,它是一个可变的值。

对于使用者(调用bind)来说,每次需要将IPv4、IPv6或者本地socket格式转化为通用socket格式,下面是IPv4 socekt的例子:

struct sockaddr_in name;
bind (sock, (struct sockaddr*) &name, sizeof (name))

对于实现者(实现bind)来说,可根据该地址结构的前两个字节(地址族)判断出是哪种地址。为了处理长度可变的结构,需要读取函数里的第三个参数,也就是 len 字段,这样就可以对地址进行解析和判断了。
bind()函数的返回值为0时表示绑定成功,-1表示绑定失败。

在设置bind时,可以通过多种方式处理地址和端口。
设置IP地址时,如果将IP地址设置成本机IP地址,这相当告诉操作系统内核,仅仅对目标 IP 是本机 IP 地址的 IP 包进行处理。但是,有时候服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,如果只绑定一个具体的地址的话,那么只能监听所设置的ip地址所在网卡的端口,其他网卡无法监听端口,这一问题可以通过通配地址来解决。 通配地址 INADDR_ANY,转换过来就是0.0.0.0,泛指本机的意思,也就是表示本机的所有IP。

设置通配地址

对于 IPv4 的地址来说,使用 INADDR_ANY 来完成通配地址的设置;对于 IPv6 的地址来说,使用 IN6ADDR_ANY 来完成通配地址的设置。

struct sockaddr_in name;
name.sin_addr.s_addr = htonl (INADDR_ANY); /* IPV4通配地址 */

对于htonl()和htons()函数可见:juejin.cn/post/703628…

对于端口,如果把端口设置成 0,就相当于把端口的选择权交给操作系统内核来处理,操作系统内核会根据一定的算法选择一个空闲的端口,完成套接字的绑定。这在服务器端不常使用
一般来说,服务器端的程序一定要绑定到一个众所周知的端口上。服务器端的 IP 地址和端口数据,相当于打电话拨号时需要知道的对方号码,如果没有电话号码,就没有办法和对方建立连接。

eg:初始化IPv4 TCP socket:

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>

int make_socket (uint16_t port)
{
  int sock;
  struct sockaddr_in name;


  /* 创建字节流类型的IPV4 socket. */
  sock = socket (PF_INET, SOCK_STREAM, 0);
  if (sock < 0)
    {
      perror ("socket"); // 类似C++中的cerr
      exit (EXIT_FAILURE);
    }


  /* 绑定到port和ip. */
  name.sin_family = AF_INET; /* IPV4 */
  name.sin_port = htons (port);  /* 指定端口 */
  name.sin_addr.s_addr = htonl (INADDR_ANY); /* 通配地址 */
  /* 把IPV4地址转换成通用地址格式,同时传递长度 */
  if (bind (sock, (struct sockaddr*) &name, sizeof (name)) < 0)
    {
      perror ("bind");
      exit (EXIT_FAILURE);
    }


  return sock;
}

listen函数

bind函数将socket和地址关联,而listen函数是让服务器真正处于可接听的状态。通过listen函数,可以创建一个监听队列存放待处理的客户连接。
前面初始化创建的socket可以认为是一个“主动的”socket,其目的是之后主动发起连接请求(调用connet函数)。通过listen函数,可以将“主动”的socket转换为“被动的”socket,操作系统内核就会知道这个socket是用来等待用户请求的,然后操作系统会做好相应的准备,比如完成连接队列。
listen的函数原型如下:

int listen (int socketfd, int backlog)

第一个参数socketfd是socket描述符,第二个参数backlog,在Linux表示已经完成(ESTABLISHED)且未accept的队列大小这个参数的大小决定了可以接收的并发数目(backlog规定了内核应为相应socket排队的最大连接个数。)。该参数越大,理论上并发数目也越大,但是参数过大也会占用过多系统资源,而在Linux中并不允许对这个参数进行改变。
内核为任何一个给定的监听套接字维护两个队列:

  1. 未完成连接队列,每个这样的SYN分节对应其中的一项:已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN_RCVD状态
  2. 已完成连接队列,每个已完成TCP三路握手过程的客户对应其中一项。这些套接字处于ESTABLISHED状态。 backlog参数决定了内核监听队列的最大长度监听队列的长度如果超过backlog,服务器将不受理新的客户连接
    在内核版本2.2之前的Linux中,backlog参数是指所有处于半连接状态(SYN_RCVD)和完全连接状态(ESTABLISHED)的socket上限。但自内核版本2.2之后,它只表示处于完全连接状态的socket的上限。处于半连接状态的socket的上限则由/proc/sys/net/ipv4/tcp_max_syn_backlog内核参数定义。backlog参数的典型值是5.

accept函数

当客户端的连接请求到达时,服务器端应答成功,连接建立,这个时候操作系统内核需要把这个事件通知到应用程序,并让应用程序感知到这个连接。在连接建立之后,accept函数可以看作是操作系统内核和应用程序之间的桥梁

通过accept函数可以从listen监听队列中接受一个连接,注意accept只是从监听队列中取出连接,而不论连接处于何种状态,更不关心任何网络状况的变化(如果监听队列中处于ESTABLISHED状态的连接对应的客户端出现网络异常(比如掉线),或者提前退出,此时通过accept函数接收该连接仍然能够正常返回)。

accept的原型:

int accept(int listensockfd, struct sockaddr* cliaddr, socklen_t* addrlen)

第一个参数listensockfd是socket描述符,这是前面通过bind,listen一系列操作而得到的socket。
第二个参数cliaddr传出参数,通过该指针可以获取客户端的地址(IP地址和端口号)
第三个参数addrlen传入-传出参数,传入sizeof(cliaddr)的大小,函数返回时返回真正接收到的地址结构体的大小。传入的sizeof(cliaddr)是调用者提供的缓冲区长度以避免缓冲区溢出,传出的客户端地址结构体的实际长度(该长度可能并没有占满调用者提供的缓冲区)。 如果给cliaddr参数传NULL,表示不关心客户端的地址。

当accept调用成功时,返回新的socket描述符,代表了与客户端的连接,失败时,返回-1.
这里一定要注意有两个套接字描述字,第一个是监听套接字描述字 listensockfd,它是作为输入参数存在的;第二个是返回的已连接套接字描述字

注意accept函数和connect函数最后一个参数的区别,accept函数最后一个参数是socklen_t*,是一个指针,所以应该传地址,connect函数最后一个参数是socklen_t,不是指针,可以用sizeof()求得。

为什么要分为两个socket描述字?

网络程序的一个重要特征就是并发处理,不可能一个应用程序运行之后只能服务一个客户,所以监听套接字一直都存在,它是要为成千上万的客户来服务的,直到这个监听套接字关闭;而一旦一个客户和服务器连接成功,完成了 TCP 三次握手,操作系统内核就为这个客户生成一个已连接套接字让应用服务器使用这个已连接套接字和客户进行通信处理。如果应用服务器完成了对这个客户的服务,比如一次网购下单,一次付款成功,那么关闭的就是已连接套接字,这样就完成了 TCP 连接的释放。请注意,这个时候释放的只是这一个客户连接,其它被服务的客户连接可能还存在。最重要的是,监听套接字一直都处于“监听”状态,等待新的客户请求到达并服务

客户端

创建socket

这一步和服务端一样

connect函数

客户端和服务端建立连接,是通过connect函数完成的。
connect函数原型:

int connect(int sockfd, const struct sockaddr* servaddr, socklen_t addrlen)

函数的第一个参数 sockfd 是连接套接字,通过前面讲述的 socket 函数创建。
第二个参数servaddr,是传入参数,指定服务器端地址信息,含IP地址和端口号。
第三个参数addrlen,是传入参数,传入服务器端地址结构体的大小,sizeof(servaddr) 返回值:成功返回0,失败返回-1.
客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于:bind的参数是自己的地址,而connect的参数是对方的地址

客户在调用函数 connect 前不必非得调用 bind 函数,因为如果需要的话,内核会确定源 IP 地址,并按照一定的算法选择一个临时端口作为源端口。(客户端可以通过bind指定使用固定端口连接,没有bind则会产生一个随机的端口来完成连接请求)
如果是TCP socket,通过调用connect函数会激发TCP的三次握手过程,并且仅在连接建立成功或出错时才返回,出错返回有以下几种情况:

  1. 三次握手无法建立,客户端发出的 SYN 包没有任何响应,于是返回 TIMEOUT 错误。这种情况比较常见的原因是对应的服务端 IP 写错
  2. 客户端收到了 RST(复位)回答,这时候客户端会立即返回 CONNECTION REFUSED 错误。这种情况比较常见于客户端发送连接请求时的请求端口写错,因为 RST 是 TCP 在发生错误时发送的一种 TCP 分节。产生 RST 的三个条件是:目的地为某端口的 SYN 到达,然而该端口上没有正在监听的服务器(如前所述);TCP 想取消一个已有连接;TCP 接收到一个根本不存在的连接上的分节。
  3. 客户发出的 SYN 包在网络上引起了"destination unreachable",即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通.

TCP三次握手

TCP三次握手解读.png 这里的网络编程模型采用阻塞式的,所谓阻塞式,就是调用发起后不会直接返回,由操作系统内核处理之后才会返回
服务器端通过 socket,bind 和 listen 完成了被动套接字的准备工作,被动的意思就是等着别人来连接,然后调用 accept,就会阻塞在这里,等待客户端的连接来临;客户端通过调用 socket 和 connect 函数之后,也会阻塞。接下来的事情是由操作系统内核完成的,更具体一点的说,是操作系统内核网络协议栈在工作。
具体过程:

  1. 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 j,客户端进入 SYNC_SENT 状态;
  2. 服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 j+1,表示对 SYN 包 j 的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 k,服务器端进入 SYNC_RCVD 状态;
  3. 客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 k+1;
  4. 应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。