根据数据传输方式的不同,基于网络传输协议的套接字一般分为TCP套接字和UDP套接字。因为TCP是面向连接的,因此又称为基于流(stream)的套接字。
计算机网络是一个非常复杂的系统。目前全世界最大的计算机网络系统叫因特网(Internet),现在人们普遍称之为互联网,它是一个覆盖了全球绝大地区的互连网络,是由数量极大的各种计算机网络互连起来的。互联网具有两个主要基本特点,即连通性和共享。
为了设计出极其复杂的计算机网络,工程师们提出了分层的方法。“分层”的好处是可以将一个庞大而又复杂的问题,转化为若干较小的局部问题,而这些较小的局部问题就比较容易研究和处理了。
目前的计算机网络体系结构的划分有三种模型,分别是:TCP/IP 4层模型、TCP/IP 5层模型、OSI 7层模型。如下图所示:
《说明》实际互联网上使用的是 TCP/IP 4层协议体系结构,而 TCP/IP 5层协议体系结构一般只是教材为了介绍计算机网络原理而设计的。OSI 的7层协议体系结构,是国际标准化组织(ISO) 提出的一个计算机网络体系结构的全球性标准框架,即著名的开放系统互连基本参考模型 OSI/RM(Open Systems Interconnection Reference Model),简称为 OSI。OSI/RM 在1983年形成了开放系统互连基本参考模型的正式文件,即著名的 ISO 7498 国际标准,也就是所谓的7层协议的体系结构。
OSI 7层体系结构模型试图达到一种理想境界,即全球计算机网络都遵循这个统一的标准,因而全球的计算机将能够很方便地进行互连和交换数据。然而事与愿违,由于基于 TCP/IP 4层协议的网络体系结构的互联网已抢先在全球相当大的范围内成功地运行了,占领了市场,所以几乎没有什么网络设备厂商生产出符合 OSI 标准的商用产品。最终,OSI 只获得了一些理论研究成果,但在市场化方面则失败了。现今规模最大的、覆盖全球的、基于 TCP/IP 的互联网并未使用 ISO 标准。所以,我们学习计算机网络编程,也是基于 TCP/IP 协议栈体系结构为基础的。
为了便于学习和理解计算机网络的分层体系结构,我们以五层协议的体系结构为模型,该体系模型综合了 OSI 和 TCP/IP 的优点,是一种很好的折中办法。下面我们自上而下、简要地介绍一下各层的主要功能。
应用层是体系中的最高层,是为用户提供所需要的各种应用服务,例如:FTP、Telnet、DNS、SMTP等,它是用户与网络的接口。该层通过应用程序来完成用户的网络应用需求,如:文件传输、收发电子邮件等。
功能:通过应用进程间的交互来完成特定网络应用服务。
应用层协议(application layer protocol):不同的网络应用的应用进程之间,需要有不同的通信规则,这就需要有应用层协议。应用层协议定义的是应用进程间通信和交互的规则。这里的进程就是指主机中正在运行的应用程序。每一个应用层协议都是为了解决某一类应用问题,而问题的解决又必须通过位于不同主机中的多个应用进程之间的通信和协作来完成。应用进程之间的这种通信必须严格遵循规则。应用层的具体内容就是精确定义这些通信规则,即应用层协议。在互联网中的应用层协议有很多,如域名系统 DNS,支持万维网应用的 HTTP协议,支持电子邮件的 SMTP协议,等等。
数据传输单元:报文(message)
题外话:网络编程的大部分内容就是设计并实现应用层协议。
运输层也叫传输层,运输层的任务就是负责向两台主机中应用进程之间的通信提供通用的数据传输服务。应用进程利用该服务传送应用层报文。所谓“通用的”,是指并不针对某个特定的网络应用,而是多种应用可以使用同一个传输层服务。也就是说,运输层是为应用层服务的。
功能:为应用进程之间提供端到端的逻辑通信服务。之所以说是为上层的应用进程之间提供端到端的逻辑通信服务,是因为从运输层的角度看,通信的真正端点并不是主机而是主机中的进程。也就是说,端到端的通信是应用进程之间的通信。“逻辑通信”的意思是:从应用层来看,只要把应用层报文交给下面的运输层,运输层就可以把这个报文传送给对方的运输层(哪怕双方相距很远,例如几千公里),好像这种通信就是沿着水平方向直接传送数据给对端的。之所以有这样的错觉,是因为运输层向上面的应用层屏蔽了下层网络核心的通信细节,下层的通信过程对应用层来说是透明的,它使得应用进程看见的好像就是在两个运输层实体之间有一条端到端的逻辑通信信道。
TCP/UDP:这两个协议是运输层主要使用的协议。TCP:提供面向连接的、可靠的数据传输服务,其数据传输的单元是数据报(segment)。UDP:提供无连接的、尽最大努力交付的数据传输服务(不保证数据传输的可靠性),其数据传输的单元是用户数据报。
复用/分用:运输层有复用和分用功能。复用就是多个应用层进程可同时使用下面运输层的传输服务;分用是运输层把收到的信息可以分别交付给上面应用层中的不同进程。
网络层负责为分组交换网上的不同主机提供通信服务。在发送数据时,网络层把运输层产生的报文段或用户数据报封装成分组或数据报进行传送。在TCP/IP 体系中,由于网络层使用IP协议,因此分组也叫做IP数据报,或简称为数据报。
功能:为主机之间提供逻辑通信服务。具体来讲,就是解决数据传输过程中的路由选择问题,使源主机运输层传下来的分组,能够通过网络中的路由器找到目的主机。
数据传输单元:IP数据报(IP分组)
请注意:不要将运输层的“用户数据报 UDP” 和 网络层的“IP数据报” 弄混。此外,无论在哪一层传送的数据单元,都可笼统地用“分组”来表示。
IP(Internet Protocol)协议:它是用在网络层上的协议,也是 TCP/IP 协议族体系中最重要的协议之一,因此网络层也叫IP层。IP协议是一种面向消息的、不可靠传输的协议。与IP协议配套使用的还有三个协议:
- ARP(Address Resolution Protocol,地址解析协议)
- ICMG(Internet Control Message Protocol,网际控制报文协议)
- IGMP(Internet Group Management Protocol,网际组管理协议)
网络层协议除了上面提到的4种主要协议外,还包括众多的路由选择协议。
网络层通信特点:网络层向上只提供简单灵活、无连接的、尽最大努力交付的数据报服务。即,网络层不提供服务质量的承诺。也就是说,所传送的分组可能出错、丢失、重复和失序(即不按序到达终点),当然也不保证分组交付的时限。
路由器(Router):它是网络层中使用的中间设备,它的作用是将不同的网络互相连接起来。
数据链路层常简称为链路层。
功能:数据链路层是为网络层服务的,解决的两个相邻网络结点之间的数据传输问题。
数据传输单元:数据帧(Frame)
局域网,它虽然也是一个网络,但是我们一般不把局域网放在网络层中讨论,这是因为在网络层要讨论的问题是多个网络互连的问题,是讨论一个IP分组怎样从一个网络,通过路由器,转发到另一个网络。当我们研究的是在同一个局域网中,数据是怎样从一台主机传送到另一台主机,但并不经过路由器转发。从整个互联网来看,局域网仍属于数据链路层的范围。我们经常听到的以太网,就是属于一种局域网,它是一种总线型拓扑结构的局域网。
物理层,即在物理链路上进行数据传输,所传的数据就是二进制的比特 0 或 1。
数据传输单元:二进制比特
功能:解决怎样才能在连接各种计算机的传输媒体上传输数据比特流的问题,而不是指具体的传输媒体。可以将物理层的功能描述为确定计算机与传输媒体的接口有关的一些特性,包括:机械、电气、功能、过程特性。
【参考链接】
《计算机网络(第7版-谢希仁)》第1~6章
网络互联参考模型(详解)
网络层(也叫IP层)解决数据传输过程中的路径选择问题。而运输层是以IP层提供的路径信息为基础完成实际的数据传输,使用的传输协议主要是TCP 和 UDP协议。这两个是运输层很重要的协议,务必弄明白。
关于TCP协议和UDP协议的详细内容,请参见下面的参考链接。
TCP协议-TCP连接管理
UDP协议详解
问题:运输层在 TCP/IP 协议栈中的地位和作用?为什么运输层是必不可少的?运输层的通信和网络层的通信有什么重要的区别?
答:从通信和信息处理的角度看,运输层向它上面的应用层提供通信服务,它属于面向通信部分的最高层,同时也是用户功能中的最底层。当网络的边缘部分中的两个主机使用网络的核心部分的功能进行端到端的通信时,只有主机的协议栈才有运输层,而网络的核心部分中的路由器在转发IP数据报(IP分组)时都只用到下三层的功能。
从网络层来看,通信的两端是两个主机,IP数据报的首部明确地标志了这两个主机的IP地址。但“两个主机之间的通信”这种说法还不够清楚。这是因为,真正进行通信的实体是在主机中的进程,是两个主机中的进程在交换数据(即通信)。因此严格地讲,两个主机进行通信就是两个主机中的应用进程在互相通信。IP协议虽然能把分组送到目的主机,但是这个分组还停留在主机的网络层而没有交付主机中的应用进程。从运输层的角度看,通信的真正端点并不是主机而是主机中的进程。也就是说,端到端的通信是应用进程之间的通信(如下图1-1所示)。因此,运输层是必不可少的。
运输层的通信和网络层的通信有很大的区别。网络层提供主机之间的逻辑通信,而运输层则提供应用进程之间的逻辑通信。运输层还有复用、分用的功能,还要对收到的报文进行差错检测。
下图给出了TCP服务器默认的函数调用顺序,绝大部分TCP服务器都按照该顺序调用。
调用socket()函数创建TCP套接字,声明并初始化服务器端的网络地址信息结构体变量,调用bind()函数向套接字分配地址。这两个阶段前面的文章已经讲过,下面讲解之后的几个过程。
当我们调用bind()函数给套接字分配了网络地址后,接下来就要通过调用listen()函数进入等待连接请求状态。只有调用了listen()函数,客户端才能进入可发出连接请求的状态。换言之,这时客户端才能调用 connect() 函数(若提前调用将发生错误),这也是为什么我们要先执行服务器端程序,后执行客户端程序的原因。
#include
int listen(int sockfd, int backlog);
//参数说明
//sockfd: 希望进入等待连接请求状态的套接字文件描述符,传递的描述符套接字参数成为服务器端套接字(即监听套接字)
//backlog: 最大连接请求等待队列(Queue)的长度,若为5,则等待队列长度为5,表示最多可以让5个连接请求进入等待队列
//返回值: 成功时返回0,失败时返回-1
先解释一下等待连接请求状态和含义和连接请求等待队列。“服务器端处于等待连接请求状态” 是指,客户端请求建立TCP连接时,受理连接请求前一直使请求处于等待状态。下图给出了这个过程。
由上图2-3克制,作为listen()函数的第一个参数传递的文件描述符套接字的用途。客户端连接请求本身也是从网络中接收到的一种数据,而要想接收就需要套接字。此任务就由服务器端套接字完成。服务器端套接字是接收连接请求的一名门卫或一扇门。
客户端如果向服务器端询问:“请问我是否可以发起连接”?服务器端套接字就会亲切应答:“您好!当然可以,但系统正忙,请到等候室排号等待,准备好后会立即受理您的连接”。同时将连接请求请到等候室。调用listen()函数即可生成这种门卫(服务器端套接字),listen函数的第二个参数将决定了等候室的大小。等候室称为连接请求队列等待队列,准备好服务器端套接字和连接请求等待队列后,这种可接收连接请求的状态称为等待连接请求状态。
listen()函数的第二个参数值与服务器端的特性有关,像频繁接收连接请求的Web服务器端至少应为15。另外,连接请求等待队列的大小始终要根据实际使用情况而定。
调用listen()函数后,若有新的连接请求,则应按顺序受理。受理连接请求意味着进入可接收数据的状态。接收数据需要使用的部件当然是套接字了,可能有人也许会认为我们可以使用服务器端套接字,但是服务器端套接字是做门卫的。如果在与客户端的数据交换中使用门卫,那谁来守门呢?因此需要另外一个套接字,但没必要亲自创建。我们是使用 accept() 函数就会自动创建一个新的套接字,并连接到发起请求的客户端。
#include
#include
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
//参数说明
//sockfd: 套接字对应的文件描述符
//addr: 保存发起连接请求的客户端地址信息的变量地址值,调用函数后向传递来的地址变量实参填充客户端地址信息
//addrlen: 第2个参数addr结构体的长度,指向存有长度变量的地址。函数调用后,该变量即被填入客户端地址长度
//返回值: 成功时返回0,失败时返回-1
accept()函数受理连接请求等待队列中待处理的客户端连接请求。函数调用成功后,accept()函数内部将产生用于数据I/O的新套接字,并返回其文件描述符。需要强调的是,套接字是自动创建的,并自动与发起连接请求的客户端建立连接。下图展示了accept()函数调用过程。
上图2-4 展示了 “从连接请求等待队列中取出1个连接请求,创建一个新套接字并完成连接请求” 的过程。服务器端单独创建的套接字与客户端建立连接后进行数据交换。
客户端的函数调用顺序要比服务器端简单许多。因为创建套接字和请求连接就是客户端的全部内容。如下图所示。
与服务器端相比,区别就在于“请求连接”,它是创建客户端套接字后向服务器端发起的连接请求。服务器端调用listen()函数后创建连接请求等待队列,之后客户端即可发起请求连接。客户端发起请求连接时使用connect()函数来完成的。
#include
#include
int connect(int sockfd, const struct sockaddr *serv_addr, socklen_t addrlen);
//参数说明
//sockfd: 套接字对应的文件描述符
//serv_addr: 保存目标服务器端网络地址信息的结构体变量的地址值
//addrlen: 以字节为单位传递已传递给第2个结构体参数serv_addr的地址变量长度
//返回值: 成功时返回0,失败时返回-1
《函数说明》客户端调用 connect 函数后,发生以下情况之一才会返回(完成函数调用)
- 服务器端接收了连接请求。
- 发生断网等异常情况而中断连接请求。
需要注意的是,所谓“接收连接”并不意味着服务器端调用了accept函数,其实是服务器端把连接请求信息记录到等待队列。因此connect函数返回后,并不会立即进行数据交换。
《知识拓展》客户端套接字地址信息在哪?
实现服务器端程序必经过程就是给套接字分配IP地址和端口号。但客户端实现过程中并未出现套接字地址分配过程,而是创建套接字后立即调用connect()函数。难道客户端套接字无需分配IP地址和端口号?当然不是!网络数据交换必须分配IP地址和端口号。既然如此,那客户端套接字何时、何地、如何分配网络地址呢?
- 何时?调用connect()函数时。
- 何地?操作系统,更准确地说是是内核中。
- 如何?IP用客户端主机的IP,端口号由操作系统随机分配。
客户端的IP地址和端口号在调用connect()函数时由操作系统自动分配,无需调用标记的bind函数进行分配。当然,客户端也是可以使用bind()函数自己主动分配指定的IP地址和端口号的,但是在客户端程序一般都不会这么做的,因为不需要。
前面讲解了TCP服务器端/客户端的实现顺序,实际上二者并非相互独立,下图展示了服务器端/客户端之间的交互过程。
图2-6 的总体流程整理如下:服务器端创建套接字后连续调用bind、listen函数进入等待连接请求状态,客户端通过调用connect函数发起连接请求。需要注意的是,客户端只能等到服务器端调用listen函数后才能调用connect函数。同时要清楚,客户端调用connect函数前,服务器端有可能已率先调用accept函数。当然,此时服务器端在调用accept函数时会进入阻塞(blocking)状态,直到客户端调用connect函数为止。
接下来我们实现一个回声(echo)服务器端/客户端通信程序。顾名思义,服务器端将客户端传输过来的字符串数据原封不动地传回给客户端,就像回声一样。
之前讨论的“Hello world”服务器端程序处理完1个客户端连接请求后即退出,连接请求等待队列实际没有多大意义。但这并非我们想象的服务器端。设置好等待队列的大小后,应向所有客户端提供服务。如果向继续受理后续的客户端连接请求,应怎样扩展代码呢?最简单的办法就是插入循环语句反复调用accept()函数,其函数调用过程如下图所示:
从上图3-7可以看出,调用accept()函数后,紧接着调用I/O相关的read、write函数,然后调用close函数。这并非针对服务器端套接字,而是针对accept()函数调用时创建的新套接字。
调用close函数就意味着结束了针对某一客户端的服务。此时如果还想服务于其他客户端,就要重新调用accept()函数。我们不仅要问:“这算什么呀?又不是银行窗口,好歹也是一个服务器端,难道同一时刻只能服务于一个客户端吗?”
是的!同一时刻确实只能服务于一个客户端。当然,我们可以使用多进程或多线程的方式编写同时服务多个客户端的服务器端程序,目前我们这里暂不涉及。
我们首先整理一下程序的基本运行逻辑。
1、服务器端程序 echo_server.c
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 1024
void error_handling(char *message);
int main(int argc, char *argv[])
{
int serv_sock, clnt_sock;
char message[BUF_SIZE];
int str_len, i;
struct sockaddr_in serv_adr; //服务器端地址信息变量
struct sockaddr_in clnt_adr; //客户端地址信息变量
socklen_t clnt_adr_sz;
if(argc!=2) {
printf("Usage: %s \n", argv[0]);
exit(1);
}
serv_sock=socket(PF_INET, SOCK_STREAM, 0);
if(serv_sock==-1)
error_handling("socket() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=htonl(INADDR_ANY);
serv_adr.sin_port=htons(atoi(argv[1]));
if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1)
error_handling("bind() error");
if(listen(serv_sock, 5)==-1)
error_handling("listen() error");
clnt_adr_sz=sizeof(clnt_adr);
for(i=0; i<5; i++) //为处理5个客户端连接而添加的循环语句,共调用5次accept函数,依次向5个客户端提供服务
{
clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz); //受理客户端连接请求,并返回新的套接字文件描述符
if(clnt_sock==-1)
error_handling("accept() error");
else
printf("Connected client %d\n", i+1);
while((str_len=read(clnt_sock, message, BUF_SIZE))!=0) //接收客户端发来的消息
write(clnt_sock, message, str_len); //回送客户端发来的消息
close(clnt_sock); //关闭与客户端进行数据交互的套接字
}
close(serv_sock); //关闭服务器端的监听套接字
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译程序:gcc echo_server.c -o eserver
运行程序:./eserver 9190
Connected client 1
Connected client 2
Connected client 3
1、客户端程序 echo_client.c
#include
#include
#include
#include
#include
#include
#define BUF_SIZE 1024
void error_handling(char *message);
int main(int argc, char *argv[])
{
int sock;
char message[BUF_SIZE];
int str_len;
struct sockaddr_in serv_adr;
if(argc!=3) {
printf("Usage: %s \n", argv[0]);
exit(1);
}
sock=socket(PF_INET, SOCK_STREAM, 0); //创建客户端TCP套接字
if(sock==-1)
error_handling("socket() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family=AF_INET;
serv_adr.sin_addr.s_addr=inet_addr(argv[1]);
serv_adr.sin_port=htons(atoi(argv[2]));
if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1) //调用connect函数,向服务器端发起连接请求
error_handling("connect() error!");
else
puts("Connected...........");
while(1)
{
fputs("Input message(Q to quit): ", stdout); //标准输出
fgets(message, BUF_SIZE, stdin); //标准输入
if(!strcmp(message,"q\n") || !strcmp(message,"Q\n")) //如果输入字符q或Q,则退出循环体
break;
write(sock, message, strlen(message)); //向服务器端发送字符串消息
str_len=read(sock, message, BUF_SIZE-1); //接收来自服务器端的消息
message[str_len]= '\0'; //在字符数组尾部添加字符串结束符'\0'
printf("Message from server: %s", message); //输出接收到的消息字符串
}
close(sock); //关闭客户端套接字
return 0;
}
void error_handling(char *message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
编译程序:gcc echo_client.c -o eclient
运行程序:./eclient
Connected...........
Input message(Q to quit): Good morning
Message from server: Good morning
Input message(Q to quit): Hi
Message from server: Hi
Input message(Q to quit): Q
《程序说明》我们编写的回声服务器端/客户端程序是以字符串为单位传递数据。理解了这一点后再观察echo_client.c程序的下面两行代码:
write(sock, message, strlen(message));
str_len=read(sock, message, BUF_SIZE-1);
我们知道,TCP协议的特点之一就是基于字节流的,没有数据边界的,因此我们应该要意识到这两行代码是不太适合做字符串单位的回声的。
下面是 echo_client.c 程序的几行代码:
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE-1);
message[str_len] = '\0';
printf("Message from server: %s", message);
以上代码有个错误假设:“每次调用read、write函数时都会以字符串单位执行实际的I/O操作。”
当然,每次调用write函数都会传递1个字符串,因此这种假设在某种程度上也算合理。但是我们知道TCP协议的特点之一就是传输的数据不存在数据边界。因此,多次调用write函数传递的字符串有可能一次性传递到服务器端。此时客户端有可能从服务器端收到多个字符串,这不是我们希望看到的结果。还需要考虑服务器端的如下情况:
“字符串太长,需要分成2个TCP报文段发送!”
服务器端希望通过调用1次write函数传输数据,但如果数据太大,超过了TCP最大报文段长度MSS,操作系统就会把数据分成多个TCP报文段发送给客户端。另外,在此过程中,客户端有可能在尚未收到全部数据包时就调用了read函数。
所有这些问题都源自于TCP协议的数据传输特性。那该如何解决呢?我们将在下一篇博文中解释说明。
虽然我们编写的服务器端/客户端回声程序运行的结果是正确的。但是这只是运气好罢了!只是因为收发的数据量小,而且运行环境为同一台主机或相邻的两台主机,所以没有发生错误,可实际上仍存在发生错误的可能。
答:链路网是WAN(广域网)、MAN(城域网)、LAN(局域网)等网络标准相关的协议栈,是定义物理特性标准的层级。IP层是定义网络传输数据标准的层级。
二者的关系是:IP层数据报是在链路层的基础上传输数据的。链路层负责物理结点的连接,IP层负责为数据传输选择合适的传输路径。
答:将极其复杂的TCP/IP协议栈分层设计,可以简化协议的设计难度,将一个大问题划分成若干个小问题,再逐个攻破,将大幅提供设计效率。
更重要的原因的是,TCP/IP协议栈属于开放式系统,为了通过标准化操作设计开放式系统,所以采用分层设计的思想。按照不同层级要求,制定了统一的层级标准,从而使不同体系结构的计算机网络都能互联互通。这种标准化设计是TCP/IP蓬勃发张的重要原因。
答:服务器端调用了listen
函数后,客户端才可以调用connect
函数。
答:服务器端调用listen函数时创建了连接请求等待队列。它是用来临时存放客户端的连接请求信息的。accept函数调用后,服务器端套接字开始受理连接请求,它将从连接请求等待队列的队头依次取出客户端的连接请求信息,并与客户端建立连接。
答:因为客户端套接字是连接请求的发起方,并不需要主动监听自己的的网络地址信息,只需要将其告诉通信对端即可,因此没必要通过bind函数明确地分配地址信息。
客户端在调用connect函数时,操作系统会自动为客户端套接字分配IP地址和端口号,并与之绑定在一起。
《TCP-IP网络编程(尹圣雨)》第4章 - 基于TCP的服务器端/客户端(1)
《计算机网络(第7版-谢希仁)》第1-6章