TCP套接字编程核心API浅析

TCP套接字编程核心API浅析

引言:学习了计算机网络基础协议,是不是跃跃欲试,那开始计算机网络编程之旅吧。本篇笔记介绍了TCP客户端与服务器通信使用的底层API,并分析了其与TCP三次握手,四次挥手的关联,希望对大家有所帮助。



一、TCP编程核心API

  在了解具体的API之前,我们需要对TCP服务器/客户端通信有一个整体认识,参考下图1,当理解了listen与accept函数,或许你也会认为这个经典的图片有一处错误。

这里写图片描述
图1、面向连接的socket的工作流程

  服务器端流程包括socket、bind、listen、accept、read、write、close。客户端流程包括socket、connect、write、read、close。下面我们来揭开这些API的面纱。

  如果将TCP通信比作两个人打电话的话,那么服务器端执行的动作依次为:买手机(socket)、插电话卡(bind)、开机处于监听状态(listen)、接通(accept)、听别人需求(recv)、讲自己观点(send)、挂断电话(close)。客户端执行的动作依次为:买手机(socket)、绑卡及拨号(connect)、讲自己需求(send)、听取对方观点(recv)、结束通话(close)。因为服务器是被动建立连接,所以要有一个众所周知的IP及端口,故有bind操作。


1.1、socket()

1)、函数概述:
  socket()函数创建一个通信的端点,返回一个以后可使用的套接字描述符。

这里写图片描述
图2、socket函数

2)、参数及返回值

  • domain:指明通信使用的协议簇,它是图3中的某个常值。常用的为AF_INET,即网络层使用Ipv4协议。

    这里写图片描述
    图3、socket函数domain参数

  • type: 指明套接字类型,它是图4中某个常值,常用的为SOCK_STREAM,流式套接字,即传输层采用TCP协议。SOCK_DGRAM,数据报套接字,即传输层采用UDP协议。

    这里写图片描述
    图4、socket函数type参数

  • protocal: 指明这个socket采用的协议,可填充常值包括IPPROTO_TCP、IPPROTO_UDP、IPPROTO_STCP,分别指明传输层采用的协议为TCP、UDP、SCTP。一般情况下protocal项可填0,系统会选择与domain、type符合的默认值。更详细内容可参考man手册。

  • 返回值: socket函数成功时,会返回一个小的非负整数值,我们称其为套接字描述符(socket descriptor),简称sockfd。失败返回-1。可能失败的原因可概括为参数错误、系统资源不足。

3)、使用实例:

//第一步、先socket打开文件描述符
int sockfd = -1, ret = -1;
struct sockaddr_in seraddr = {0};
struct sockaddr_in cliaddr = {0};
socklen_t  len = 0; 
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == sockfd)
{
    perror("socket error.");
    return -1;
}
printf("sockfd = %d.\n", sockfd);

友情提示:对于sockaddr_in这个结构体不理解的同学,可以移步至Socket编程中常见的数据结构及转换函数,补补功课。


1.2、bind()

1)、函数概述:
  bind函数把一个本地协议地址赋予一个套接字,协议地址32位的Ipv4或128位的Ipv6,与16位的TCP或者UDP的组合。

这里写图片描述
图5、bind函数

2)、参数及返回值:

  • sockfd: 由socket函数返回的套接字描述符。
  • const struct sockaddr *addr: 由const就知道这是一个输入型参数啦,其是一个指向sockaddr这个结构体的指针。
  • addrlen:这个参数是该地址结构的长度,我们在使用时设置为sizeof(struct sockaddr)即可。
  • 返回值:绑定成功返回0,绑定失败返回-1,其失败的原因可能是可能是地址受保护、sockfd不是合法描述符等。

3)、使用实例:

//第二步、bind绑定当前电脑的IP 端口与sockfd
seraddr.sin_family = AF_INET;          //设置地址族为Ipv4
seraddr.sin_port = htons(MYPORT);      //设置地址的端口号信息
                                       //htons主机与网络字节转换
seraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS); //设置结构体
ret = bind(sockfd, (const struct sockaddr*)&seraddr, sizeof(seraddr));
//长度判断是Ipv4还是Ipv6
if (ret != 0)
{
    perror("bind error!");
    return -1;
}
printf("bind success!\n");

端口及IP均需要转换为网络字节序,为什么协议簇sin_family不需要转换?

  因为sin_addr和sin_port是从IP和UDP协议层取出来的数据,网络层和运输层是和网络相关的,所以他们必须是网络字节顺序。然而sin_family域只是内核用来判断struct sockaddr 存储的是什么类型的数据,sockaddr_in还是sockaddr_in6,并且sin_family不会被发送到网络上,所以可以使用主机字节顺序存储。


1.3、listen()

1)、函数概述:
  listen函数进由TCP服务器调用,它完成两件事情。当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的客户套接字。listen函数把一个未连接的套接字转换成一个被动套接字,指示内核应接受指向该套接字的连接请求。

这里写图片描述
图6、listen函数

  根据TCP的状态转换图,调用listen导致套接字从CLOSED状态转换到LISTEN状态。

这里写图片描述
图7、listen调用导致服务器由关闭至监听状态

2)、参数及返回值:

  • sockfd: 由socket函数返回的套接字描述符。
  • backlog: backlog规定了由未处理连接构成的队列可能增长到的最大长度(详情见后面内容)。
  • 返回值:若listen()调用成功,即服务器由关闭变为监听LISTEN状态,返回0,否则失败返回-1。
    这里写图片描述
    图8、listen参数及返回值

3)、内核为连接维护的两个队列
  为了理解listen中的backlog参数,我们必须认识内核为任何一个给定的监听套接字维护两个队列,即未完成队列与已完成队列。

  • 未完成连接队列: SYN请求连接的报文已有某个客户发送到达服务器,而服务器正在等待完成相应的TCP三次握手过程,这些套接字处于SYN_RCVD状态。
  • 乙完成连接队列:每个已经完成TCP三次握手的客户,这些套接字处于ESTABLISHED状态。
    这里写图片描述
    图9、内核为给定监听套接字维护的两个队列

  每当在未完成队列中创建一项时,来自监听套接字的参数就复制到即将建立的连接中(构成TCP五元组)。连接的创建机制是自动完成的,无需服务器进程插手。图10展示了用这两个队列建立连接时所交换的分组。

这里写图片描述
图10、TCP三次握手和监听套接字的两个队列

  当来自客户的SYN到达时,TCP在其未完成连接的队列中创建一个新项,然后发出服务器的SYN响应,其中捎带对客户SYN的ACK。这一项一直保留在未完成连接的队列中,知道三次握手的第三个分节(客户对服务器SYN的ACK)到达或超时为止(源自Berkeley的实现为这些未完成连接的项设置的超时值为75s)。如果三次握手正常完成,该项就从未完成队列移到已完成队列的队尾。当进程调用accept时,已完成连接队列中的队头项将返回给进程,或者该队列为空,那么进程将被投入睡眠,直到TCP在该队列中放入一项才唤醒它。

4)、backlog值的具体含义:

  backlog的含义从未有过正式的定义,4.2BSD手册页面宣传它的定义是由未处理连接构成的队列可能增长到的最大长度,然而该定义并未解释未处理连接是处于SYN_RCVD状态的连接,还是尚未由进程接受的处于ESTABLISHED状态的连接,亦或两者皆可。
  也有文章解释在linux下,backlog制定的是complete queue的大小,而incomplete queue的大小可以由系统系统管理在/proc/sys/net/ipv4/tcp_max_syn_backlog下进行统一配置。可参考:How TCP backlog works in Linux

这里写图片描述
图11、当前系统处于半连接状态的最大值

5)、backlog值的设定:
  不要将backlog设置为0,如果不想让任何客户连接到服务器的监听套接字上,那就关掉该套接字。在三次握手正常情况下,未完成连接队列中的任何一项在其中存留的时间就是一个RTT,现在繁忙的HTTP服务器必须制定一个大的backlog值。

6)、半连接队列满时,对于新连接请求的处理方式:
  当一个客户SYN到达时,若这些队列是满的,TCP就忽略该分节,也就是不发送RST。这么做是因为:这种情况是暂时的,客户TCP将重发SYN,期望不久就能在这些队列中找到可用的空间。要是服务器的TCP立即响应一个RST,客户的connect调用就会立即返回一个错误,强制应用进程处理这种情况,而不是让TCP的正常重传机制来处理。另外,客户无法区分响应SYN的RST究竟意味着,“该端口没有服务器在监听”,还是意味着“该端口有服务器在监听,不过它队列满了”

7)、使用实例:

//第三步:listen监听端口,进入监听状态,非阻塞
    ret = listen(sockfd, BACKLOG);
    if (ret != 0)
    {
        perror("listen error!");
        return -1;
    }
    printf("listen sucess!\n");

1.4、accept()

1)、函数概述:
  accept函数由TCP服务器调用,用于从已完成连接队列头部返回下一个已完成连接,如果已完成连接队列为空,那么进程就投入睡眠(假定套接字为默认的阻塞方式)。如果accept成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户的TCP连接。理解accept还是需要仔细推敲它的参数及返回值。

这里写图片描述
图12、accept函数

2)、参数及返回值:

  • sockfd:正在listen()的一个套接字描述符。
  • struct sockaddr *addr:这个addr是一个输出型参数,是一个指向struct sockaddr结构的指针,里面存储着远程连接过来的计算机的信息,如IP地址和端口。如果对返回客户协议地址不感兴趣,可以把这里cliaddr和addrlen均设置为NULL。
  • addrlen:是一个本地的整形数值,把它传递给accept()前,它的值应该是sizeof(struct sockaddr); accept不会再addr中存储多于addrlen bytes大小的数据。如果accept()函数在addr中存储的数据量不足addrlen,返回时,该整数值即为内核存放在套接字地址结构内的确切字节数。

这里写图片描述
图13、accept参数含义

  • 返回值:
      如果accept返回成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户的TCP连接,这个称为已连接套接字描述符(connected socket),为非负整数。失败,返回值为-1.
      同样accept可以返回客户进程的协议地址(由addr指针所指)及该地址的实际大小(由addrlen指针所指)。如果我们对客户的协议地址不感兴趣,那么可以把addr及addrlen均设置为空指针NULL。

    要区分监听描述符与已连接描述符:初始socket建立,随后用于bind、listen、accept第一个参数的称为监听描述符(listening socket)。一个服务器通常只建立一个监听套接字,它在服务器生命周期内一直存在。内核为每个由服务进程接受的客户连接创建一个已连接套接字(完成TCP三次握手,且被accept处理),当服务器完成这个给定客户的服务时,相应的已连接套接字就关闭了。

  • 3)、使用实例:

//第四步:accept处理监听请求,阻塞等待客户端接入
accept_fd  = accept(sockfd, (struct sockaddr*)&cliaddr, &len);
if (-1 == ret)
{
    perror("accept error!\n");
    return -1;
}   
printf("accept sucess, accept_fd = %d\n", accept_fd);

1.5、connect()

1)、函数概述:
  TCP客户用connect来建立与TCP服务器的连接,客户在调用connect前不必非得调用bind函数,因为内核会确定源IP地址,并选择一个临时端口作为源端口。connect调用会触发TCP三次握手的过程。

这里写图片描述
图14、connect函数

2)、参数及返回值:

  • sockfd:套接字描述符,是客户端socket()函数返回的。
  • const struct sockaddr addr:显然这是一个输入型参数,如果写为 seraddr更加明显,即其是一个存储远程服务器的IP地址和端口信息的结构。
  • addrlen:应该是sizeof(struct sockaddr),即存储远程服务器IP及端口的结构大小。

  • 返回值:如果连接成功返回0,否则返回-1.

3)、一个值得商讨的问题:

这里写图片描述
图15、经典的错误

  或许在学习了listen、accept、connect大家也不认同connect的指向问题了,connect调用一定发生在accept之前,这样accept才可以取到完成三次握手的全连接socket。应该是listen将服务器由CLOSED状态转变为LISTEN状态,connect函数导致当前客户端套接字由CLOSED状态转变为SYN_SENT状态,,客户端第一个SYN分节到达,随后服务器发出三次握手的第二分节,状态由LISTEN变为SYN_RCVD,客户端完成第三次握手后,二者进入ESTABLISH状态。此时,才将该次连接由半连接队列,转移至全连接队列。accept才可以介入处理,我认为connect应该指向listen之后,accept之前。

4)、使用实例:

//客户端第二步、connect连接服务器
seraddr.sin_family = AF_INET;    //设置服务器的地址族为Ipv4
seraddr.sin_port = htons(SERVER_PORT);//设置服务器的端口
seraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS);//设置服务器的IP地址

ret = connect(sockfd, (const struct sockaddr*)&seraddr,sizeof(seraddr));
if(ret != 0)
{
    perror("connect error!");
    return -1;
}   
printf("connect sucess!\n");

1.6、send()

1)、函数概述:
  accept函数返回一个已建立连接的描述符,就可以进行数据的收发了,send()函数是通过连接的套接字进行发送的函数,如果无连接可以参考sendto()。

这里写图片描述
图15、send()函数

2)、参数及返回值:

  • sockfd:代表客户端与远程服务器连接的套接字描述符,即在connect中使用的文件描述符。
  • buf:是一个指针,指向我们想发送信息的地址
  • len:想发送信息的长度
  • flags:发送标记,一般记为0,可以查看send的man手册,获取其他参数的详细值及各个参数代表的含义。

    这里写图片描述
    图16、send()函数flag参数

  • 返回值: send函数在成功调用后会返回它真正发送数据的长度,如果给send()参数中包含的数据长度远远大于send()一次所能发送的数据,则send()函数只发生它能发生的最大数据长度,因为它相信你会把剩下的数据再次调用它进行二次发送。如果包足够小,在1K以下,一般send()都会一次发送的。如果send函数发生错误,其返回值为-1,错误代码存储在全部变量error中。

3)、使用实例:

/*客户端一直向服务器发送数据*/
while(1)
{
     printf("请输入要发送的消息:\n");       
     scanf("%s", sendbuf);
     printf("您输入的信息为:%s", sendbuf);
     ret = send(sockfd, sendbuf, strlen(sendbuf),0);
     printf("成功发送了%d个字节\n", ret);       
 }

1.7、recv()

1)、函数概述:
  recv()与send()是最基本通过连接的套接字进行收发的函数,若无连接可以参考sendto()recvform()函数。

这里写图片描述
图17、recv函数

2)、参数及返回值:

  • sockfd:代表要读取数据的套接字描述符
  • buf:是一个指针,指向我们存储数据的内存缓存区域
  • len:缓存去的最大尺寸
  • flags:是recv()函数的一个标志,一般记为0,可以查看recv的man手册,获取其他参数的详细值及各个参数代表的含义。

    这里写图片描述
    图18、recv函数中flag参数

  • 返回值: recv()返回它所真正收到数据的长度,也就是存到buf中数据的长度,如果返回-1则代表发生了错误(如网络中断、对方关闭了套接字连接等),全部变量error里面存储了错误代码。

3)、使用实例:

/*客户端收取服务器发送的数据*/
while(1)
{
     ret = recv(sockfd, sendbuf, sizeof(sendbuf), 0);
     printf("server message: %s\n", sendbuf);
     memset(sendbuf, 0, sizeof(sendbuf));
}

1.8、close()

1)、函数概述:
  程序进行网络传输完毕后,需要关闭这个套接字描述符所表示的连接,实现这个功能非常简单,只需要使用标准的关闭文件的函数close()。

这里写图片描述
图19、close函数

2)、参数及返回值:

  • sockfd:建立连接的套接字描述符

  • 返回值:关闭成功返回0,失败返回-1。

3)、close与TCP四次挥手

TCP建立一个连接需要3个分节,其终止一个连接需要4个分节。

  • 1:当某个应用程序首先调用close,该段执行主动关闭(active close),该端的TCP于是发送一个FIN分节,表示数据发送完毕。
  • 2:接收到这个FIN的对端执行被动关闭(passive close),这个FIN由TCP确认,它的接收也作为一个文件结束符(end of file)传递给接收端应用进程,FIN的接收意味着接收端应用进程在相应的连接上再无额外数据可接收。
  • 3:一段时间后, 接到这个文件结束符的应用进程将调用close关闭它的套接字,这导致TCP也发送一个FIN。
  • 4:接收这个最终FIN原发送端TCP,即执行主动关闭的那一段,确认这个FIN。

这里写图片描述
图20、close与TCP的四次挥手

  无论是客户还是服务器,任何一端都可以执行主动关闭,通常情况下是客户执行主动关闭,但是在某些协议(如HTTP/1.0),却是由服务器主动执行关闭。


二、小结

  本文介绍了TCP客户端与服务器通信用的的API,包括服务器端socket、bind、listen、accept、recv、send、close。客户端socket、connect、send、recv、close。调用API是简单的事情,重点要理解API背后TCP的工作原理,如三次握手和四次挥手的过程。因为是初学者,可能有一些错误,感谢报错及建议。

参考内容:

1、linux man手册
2、《unix网络编程卷一》
3、《linux网络编程》
4、本文中其他一些链接

纠错与建议
邮箱:[email protected]


猜你喜欢

转载自blog.csdn.net/xd_hebuters/article/details/79684989