首页 > 代码库 > TCP/IP网络编程系列之四(初级)

TCP/IP网络编程系列之四(初级)

TCP/IP网络编程系列之四-基于TCP的服务端/客户端

理解TCP和UDP

根据数据传输方式的不同,基于网络协议的套接字一般分为TCP和UDP套接字。因为TCP套接字是面向连接的,因此又称为基于流的套接字。在了解TCP之前,先了解一下TCP所属的TCP/IP协议栈。

如图所示,TCP/IP协议栈共分为4层,可以理解成数据收发分成了4个层次化过程。

链路层

它是物理链接领域标准化结果,也是最基本的领域,专门定义LAN、WAN、MAN等网络标准。若两台计算机通过网络进行数据交换,链路层就负责整个物理连接。

IP层

准备好物理连接后就要传输数据,首先要考录路径的选择问题,向目标主机传输数据选择哪条路径,就是由IP层负责处理的。IP本身是面向消息的、不可靠的协议。每次传输数据时会帮我们选择路径,但是并不一致,传输过程中出现路径错误,则选择其他路径。但是如果发生数据丢失或错误则无法解决,IP协议无法应对数据错误。

TCP/UDP层

IP层负责传输路径的选择,只需按照路径传输即可。TCP/UDP层以IP层提供的路径信息为基础完成实际的数据传输,故该层又称为传输层。UDP比TCP简单,先说一下TCP。TCP可以保证传输数据的可靠性。IP层只关注一个数据包的传输过程。因此,即使传输多个数据包,每个数据包也是由IP层实际传输的。也就是说,传输的顺序及传输本身不可靠的。若只利用IP层传输数据,则有可能导致后传输的数据包B比先传输的数据包A先到达目的地。也有可能在传输的过程中丢失A,反之如果添加TCP协议的话就不会出现这种情况。数据交换过程中可以确认对方已收到数据,并重传丢失的数据,那么IP层不保证数据传输,这类通信也是可靠的。

总之,TCP和UDP存在于IP层之上,决定主机之间的数据传输方式,TCP确认之后向不可靠的IP协议赋予可靠性。

应用层

套接字通信过程是自动处理的。选择数据传输路径、数据确认过程都被隐藏到套接字内部。总之,向各位提供的工具就是套接字,大家只需利用套接字编写程序即可。编写软件的过程中,需要根据程序特点决定服务端和客户端之间的数据传输规则,这便是应用层的协议。

实现基于TCP的服务端/客户端

TCP函数的调用顺序为下面所示:

socket()->bind()->listen()->accept()->read()/write()->close()   《=》 创建套接字->分配套接字地址->等待连接请求->允许连接->数据交换->断开连接

进入等待连接请求状态

我们调用的bind函数分配了地址,下面就要调用listen函数进入等待连接请求状态。只有调用了listen函数,客户端才能进入可发出连接请求的状态。这时的客户端才可以调用connect函数。(提前调用失败).

#inlcude <sys/socket.h>int listen(int sock,int backlog)成功时返回0,失败时返回-1sock:希望进入等待连接请求的套接字文件描述符,传递的描述符套接字参数成为服务端套接字(监听套接字)backlog:连接请求等待队列的长度,若为5,则队列长度为5,表示最多可以连接5个客户端连接。

"服务端等待连接请求状态"是指客户端请求连接时,受理连接前一直使请求状态处于等待状态。listen函数的第一个参数的用途,客户端连接请求本身也是从网络中接收到的一种数据,而想要接受就需要套接字。此任务就有服务端套接字完成。

受理客户端的连接请求

调用listen函数之后,若有新的连接请求,则应按顺序受理。受理请求意味着进入可接受数据的状态。

#include <sys/socket.h>int accept(int sock,struct sockaddr *addr,socklen_t *addrlen)成功时返回创建的套接字文件描述符,失败时返回-1sock:服务端套接字描述符addr:保存发起连接请求的客户端地址信息的变量地址值,调用函数后想传递来的地址变量参数填充客户端地址信息。addrlen:第二个参数addr参数长度,但是存有长度的变量地址。函数调用完成后,该变量即被填入客户端地址长度。

accept函数受理连接请求等待队列中待处理的客户端连接请求。函数调用成功时,accept函数内部将产生用于数据I/O的套接字,并返回文件描述符。需要强调的是,套接字是自动创建的,并自动与发起连接请求的客户端建立连接。

TCP客户端的默认函数调用顺序

socket()->connect()->read()/write()->close()  ==============  创建套接字->请求连接->交换数据->断开连接

服务端调用listen函数后创建连接请求等待队列,之后客户端即可请求连接。那么如何发起连接请求的呢?就是通过connect函数完成的。

#include <sys/socket.h>int connect(int sock,struct sockaddr *servaddr,socklen_t addrlen)sock:客户端套接字文件描述符servaddr:保存服务端地址信息的变量地址addelen:以字节为单位已传递给第二个结构体参数servaddr的地址变量长度。

客户端调用connect函数后,“服务端接收连接请求”或”发生断网等异常情况而中断请求“函数才会返回,完成函数调用。需要注意的是,所谓的"接收连接"并不意味着服务端调用accept函数,其实是服务端把连接请求记录到等待队列。因此accept函数返回后并不立即进行交换数据。

客户端实现过程中并未出现套接字地址分配,而是创建完套接字之后立即调用connect函数,客户端套接字是何时、何地如何分配的呢?何时?调用connect函数时候;何地?操作系统,更准确的说是在内核中;如何?IP用计算机的IP,端口随机分配的。

总结:

服务端创建套接字后连续调用bind、listen函数进入等待状态,客户端通过函数connect请求连接。需要注意的是,客户端只能等到服务器端调用listen函数后才能调用connect函数。同时清楚,客户端调用connect函数之前,服务端有可能率先调用accept函数。当然,此时的服务器端在调用accept函数时进入阻塞状态,直到客户端调用connect函数为止。

实现迭代服务器端/客户端

  回声客户端存在问题,每次调用read、write函数都会以字符串为单位执行实际的I/O操作。当然,每次调用write函数都会传递一个字符串,因此还算合理。但是客户端是基于TCP的,因此多次调用write函数传递的字符串有可能一次传递到服务端。此时客户端有可能从服务器端收到多个字符串,这不是我们希望看到的结果。还需要考虑服务端的情况,就是字符串太长,需要分两个数据包发送。服务器端希望通过调用1次write函数传输数据,但如果数据太大,操作系统就有可能把数据分成多个数据包发送到客户端。另外,在此过程中,客户端有可能尚未收到全部数据包时就调用了read函数。

服务端代码:

#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <arpa/inet.h>#include <sys/socket.h>void Error_Handling(char *message);int main(int argc,char *argv[]){    int serverSocket;    int clientSocket;    struct sockaddr_in serverAddr;    struct sockaddr_in clientAddr;    socklen_t clientSocketAddrSize;    char message []="Hello!";    if(argc != 2)    {        printf("Usage:%s <port>\n",argv[0]);        exit(1);    }    serverSocket = socket(PF_INET,SOCK_STREAM,0);    if(serverSocket == -1)    {        Error_Handling("socket() error");    }    memset(&serverAddr,0,sizeof(serverAddr));    serverAddr.sin_family=AF_INET;    serverAddr.sin_addr.s_addr=htonl(INADDR_ANY);    serverAddr.sin_port=htons(atoi(argv[1]));    if(bind(serverSocket,(struct sockaddr*) &serverAddr,sizeof(serverAddr))==-1)    {        Error_Handling("bind() error");    }    if(listen(serverSocket,5)==-1)    {        Error_Handling("listen() error");    }    clientSocketAddrSize = sizeof(clientAddr);    clientSocket = accept(serverSocket,(struct sockaddr*)&clientAddr,&clientSocketAddrSize);    if(clientSocket == -1)    {        Error_Handling("accept() error");    }    write(clientSocket,message,sizeof(message));    close(clientSocket);    close(serverSocket);    return 0;}void Error_Handling(char *message){    fputs(message,stderr);    fputc(\n,stderr);    exit(1);}
Server

客户端代码:

#include <stdio.h>#include <stdlib.h>#include <string.h>#include <unistd.h>#include <arpa/inet.h>#include <sys/socket.h>#define MAXSIZE 30void Error_Handling(char* message);int main(int argc,char* argv[]){    int sock;    struct  sockaddr_in serverAddr;    char message[MAXSIZE];    int str_len;    if(argc != 3)    {        printf("Usage : %s <IP> <Port> \n",argv[0]);        exit(1);    }    sock = socket(PF_INET,SOCK_STREAM,0);    if(sock == -1)    {        Error_Handling("sock() error");    }    memset(&serverAddr,0,sizeof(serverAddr));    serverAddr.sin_family=AF_INET;    serverAddr.sin_addr.s_addr=inet_addr(argv[1]);    serverAddr.sin_port=htons(atoi(argv[2]));    if(connect(sock,(struct sockaddr*)&serverAddr,sizeof(serverAddr))==-1)    {        Error_Handling("connect() error");    }    str_len = read(sock,message,sizeof(message)-1);    if(str_len == -1)    {        Error_Handling("read error");    }    printf("Message from server : %s \n",message);    close(sock);    return 0;}void Error_Handling(char *message){    fputs(message,stderr);    fputc(\n,stderr);    exit(1);}
Client

 

但是上述代码是可以运行,只不过你的数据小不会出现这种情况,单数数据量很大话就会有错误。

回声客户端的解决办法

问题不在服务端而在客户端,但是只看代码也许不太好理解,因为I/O使用了相同的函数。两者都是在循环调用read和write函数。实际上之前的回声客户端将100%接收自己传输的数据,只不过接收数据的单位有问题。回声客户端传输的是字符串,而且通过调用write函数一次性发送的。之后还调用一次read函数,期待中接收自己传输的字符串。这就是问题所在,既然回声客户端会接收到所有字符串数据,是否只需等待一会,那等待多长时间?这个不合理。我们可以提前确定接收数据的大小。若之前传输了20字节长的字符串,则在接收时循环调用read函数读取20个字节即可。回声客户端可以提前知道接收的数据长度,但是我们应该意识到大多数情况下这不太可能,既然如此,若无法预知接收数据长度时应如何收发数据?那就需要应用层协议的定义,收发数据过程中定义好规则以表示数据的边界,或提前告知收发数据的大小。服务端/客户端实现过程中逐步定义这些规则的集合就是应用层协议。

TCP 原理

TCP套接字中的I/O缓冲

TCP套接字的数据收发无边界。服务端调用一次write函数传输40个字节的数据,客户端也有可能通过4次read函数调用每次读取10字节。那么有个问题,客户端可以缓慢的分批读取数据,那么剩余的数据都存放在哪?实际上,write函数调用后并非立即传输数据,read函数调用后也并非马上接收数据。write函数调用瞬间,数据将移至输出缓冲;read函数调用瞬间,从输入缓冲读取数据。调用writ函数时,数据将移到输出缓冲,在适当的时候传向对方的输入缓冲。这是对方将调用read函数冲输入缓冲读取数据。

I/O缓冲的特点:

  1.I/O缓冲在每个TCP套接字中单独存在

  2.I/O缓冲在创建套接字过程中自动生成

  3.即使关闭套接字也会继续传输输出缓冲中的遗留数据

  4.关闭套接字将丢失输入缓冲中的数据。

那么如果客户端的输入缓冲为30个字节,但是服务端传输了100个字节,那怎么办?这根本不会发生的。因为TCP会控制数据流,TCP中有滑动窗口协议(Sliding Window)。

与对方套接字的连接

TCP通信过程中会经过3次对话过程,因此该过程又称三次握手。套接字是以全双工方式工作的。也就是说,它可以双向传递数据。因此,收发数据前做些准备,首先,请求连接的主机A向主机B传递如下信息:

  [SYN] SEQ: 1000,ACK:-

该消息中,SEQ为1000,ACK为空。现在传递的数据包序号为1000,如果接收无误,请通知我向你传递1001号数据包。这是首次请求连接时使用的信息,又称SYN。SYN是Synchronization的简写,表示收发数据前传输的同步信息。接下来主机B向A传递如下信息:

  [SYN+ACK] SEQ:2000,ACK:1001

此时SEQ为2000,ACK为1001。代表现在传递数据包的序号为2000,如果接收无误,请通知我向你传递2001号数据包。而ACK1001的含义:刚才传输的SEQ为1000的数据包接收无误,现在请传递2001号数据包。对主机A首次传输的数据包的确认信息(ACK:1001)和为主机B传输数据做准备的同步消息(SEQ 2000)捆绑发送,因此,这种类型的消息又称SYN+ACK.

收发数据前向数据包分配序号,并向对方通报次序号,这都是为防止数据丢失所做的准备。通过向数据包分配序号并确认,可以在数据丢失时马上查看并重传丢失的数据包。因此,TCP可以保证可靠的数据传输。最后观察主机A向主机B传输的消息:

[ACK] SEQ:1001,ACK:2001

已正确收到传输的SEQ为2000的数据包,现在可以传输SEQ为2001的数据包。这样就传输了添加ACK 2001的ACK信息。至此,主机A和主机B确认了彼此均就绪。

与对方主机的数据交换

通过第一步的三次握手过程完成了数据交换的准备,下面是正式开始收发数据,比如主机A分两次(2个数据包)向主机B传递200字节的过程。首先,主机A通过1个数据包发送100个字节的数据,数据包的SEQ为1200.主机B为了确认这一点,向主机A发送ACK1301消息。此时的ACK号为1301,原因在于ACK号的增量为传输的数据字节数。ACK消息:

ACK号-》SEQ号+传递的字节数+1,与三次握手协议相同,最后加1是为了告知对方下次要传递的SEQ号。如果在传输的过程中出现错误,则重传数据,tcp套接字启动计时器以等待ACK应答。若相应计时器超时,则重传。

断开与套接字的连接

先由套接字A向套接字B传递断开连接的消息,套接字B发出确认收到的消息,然后向套接字A传递可以断开连接的消息,套接字A同样发出确认消息。数据包内的FIN表示断开连接,也就是说,双发各发送1次FIN消息后断开连接。此过程经历四个阶段,因此又称四次握手。

TCP/IP网络编程系列之四(初级)