TCP是一个面向连接的传输层协议,提供高可靠性的字节流传输服务,主要用于一次传输要交换大量报文的情形。为了维护传输的可靠性,TCP增加了许多开销:例如确认、流量控制、计时器以及连接管理等。TCP协议的传输特点是:
- 端到端通信:TCP的连接是端到端的,这意外着一个TCP连接只支持两方通信,通常是客户端在一端,服务器端在另一端。
- 建立可靠连接:TCP要求客户端在与服务器交换数据之前,必须要先连接上服务器,这样就测试了网络的连通性。
- 可靠交付:一旦建立连接,TCP保证数据将按发送时的顺序交付,不会丢失,也不会重复,如果因为故障而不能可靠交付,发送方会得到通知。
- 双工传输:在任何时候,单个TCP连接都允许同时双向传输数据,因此客户端和服务器端可同时向对方发送数据。
- 流模式:TCP从发送方向接收方发送的数据,是没有报文边界的字节流。
2.1.1 套接字编程步骤
要使用流式套接字开发基于TCP协议的网络通信程序,需要分别制作服务器端程序和客户端程序,这两种程序调用WinSock函数的流程如图2-1所示。
图2-1 TCP通信程序的WinSock函数调用流程
总的来说,TCP服务器端程序编程的步骤如下:
1)加载WinSock动态链接库 (WSAStartup());
2)创建套接字(socket()),并将第2个参数设置为SOCK_STREAM;
3)绑定套接字(bind())到一个IP地址和端口上;
4)将套接字设置为监听模式等待连接请求(listen()),套接字监听就相当于手机待机,要等待客户端连接,必须提前处于监听状态,才能保证客户端任何时刻都能连接上;
5)请求到来后,接受连接请求,返回一个新的对应于此次连接的套接字(accept());
6)用返回的套接字和客户端进行通信(send()和recv());
7)返回,等待另一连接请求;
8)关闭套接字,关闭加载的套接字库(closesocket()/WSACleanup())。
TCP客户端程序编程的步骤如下:
1)加载WinSock动态链接库(WSAStartup());
2)创建套接字(socket()),第2个参数设置为SOCK_STREAM;
3)向服务器发送连接请求(connect());
4)和服务器端进行通信(send()/recv());
5)关闭套接字,关闭加载的套接字库(closesocket()/WSACleanup())。
其中,需要注意几点:
1) accept是等待接受连接函数,因此在TCP通信中,是服务器端先执行accept函数,客户端再接着执行connect函数。但connect函数会先于accept函数执行完毕。如果connect函数连接成功,则accept函数会返回一个新的套接字。
2) 在TCP通信中,任何一方都可以先发数据给对方,因此send()和recv()函数是没有先后顺序之分的。
3)服务器端程序需要先运行,客户端程序才能运行。因为只有服务器处于监听状态(listen)时,客户端才能成功发送连接(connect)请求。
2.1.2 套接字编程的准备工作
WinSock由两部分组成:开发组件和运行组件。开发组件主要是WinSock的头文件:winsock2.h,头文件包括了WinSock实现所定义的宏、常数值、结构体和函数调用接口原型。运行组件是指WinSock应用程序接口的动态链接库(DLL)和静态链接库(导入库)文件,WinSock各版本的头文件和链接库文件如表2-1所示。
表2-1 WinSock各版本的头文件和链接库
版本 |
头文件 |
静态链接库文件 |
动态链接库文件 |
WinSock 1 |
winsock.h |
winsock.lib |
winsock.dll |
WinSock 2 |
winsock2.h |
ws2_32.lib |
ws2_32.dll |
要在VC++编程中使用WinSock,必须做下面几步的准备工作。
1. 包含WinSock头文件
包含WinSock的头文件需要在程序文件首部使用编译预处理命令“#include”,将WinSock头文件包含进来。例如下面的预处理命令将把winsock2.h文件包含进来。
#include <winsock2.h>
提示:winsock2.h头文件与windows.h头文件存在相互包含关系,因此,如果在程序中已包含了windows.h文件,就不必再包含winsock2.h文件了。如果要包含winsock2.h文件,则一定要将#include <winsock2.h>写在#include<windows.h>之前。
2.链接WinSock导入库
链接WinSock导入库,有两种方法:
第一种是在程序中使用预处理命令“#pragma comment”。例如,程序要使用WinSock2时,可使用如下预处理命令:
#pragma comment (lib, "ws2_32.lib")
第二种方法是在VC6.0的“工程→设置”菜单中,选择“连接”选项卡,在如图2-2所示的对话框中的“对象/库模块”下输入“Ws2_32.lib”。如果是VS2010,则在项目属性页中的“配置属性→链接器→输入”的“附加依赖项”中直接添加导入库名字。
图2-2 在VC6.0中链接导入库
由于第二种方法在不同的系统中需要重新设置,而第一种方法方便代码共享,因此建议使用第一种方法链接WinSock库。
2.1.3 套接字编程中使用的函数
下面对套接字通信中使用的各个函数进行详细介绍。
1. WSAStartup()函数
应用程序运行时必须先载入WinSock动态链接库(ws2_32.dll)才能调用WinSock函数实现网络通信功能。加载动态链接库的方法是使用WSAStartup()函数,该函数原型如下:
int WSAStartup (
WORD wVersionRequested, //版本号
LPWSADATA lpWSAData //一个指向WSADATD结构体变量的指针
);
该函数返回值是一个整数,函数调用成功则返回0。
假如一个程序要使用2.2版本的Winsock,那么程序中可采用如下代码加载Winsock动态链接库:
WSADATD wsaData;
int err = WSAStartup(MAKEWORD( 2, 2 ), &wsaData );
if(err!=0){
cout<<"Winsock不能被初始化!"; //Winsock初始化错误处理代码
WSACleanup(); }
提示:MAKEWORD()是一个宏定义(注意不是函数),它的作用是把2个字节型数据合成一个WORD型(16位整型)数据。
2. socket()函数
在WinSock中,socket()函数用来创建套接字,其函数原型如下:
SOCKET socket (int af, int type, int protocol);
该函数有3个参数,各参数的含义如下:
- af:标识一个地址家族,在Windows中总是为AF_INET。
- type:表示套接字的类型,取值有3种:SOCK_STREAM表示流式套字;SOCK_DGRAM表示数据报套接字;SOCK_RAW表示原始套接字。
- protocol:用于指定套接字所用的特定协议,依赖于第2个参数type,对于TCP或UDP通信来说,该参数一般设为0,表示默认的协议;但对于原始套接字来说,该参数有很多不同的取值。
socket()函数的返回值数据类型是SOCKET,它是Winsock中专门定义的一种新的数据类型,表示套接字描述符,是一个无符号整型数。其定义为:
typedef u_int SOCKET;
3. bind()函数
socket()函数在创建套接字时并没有为创建的套接字分配地址,因此服务器端在创建了监听套接字之后,需要使用bind()函数将套接字绑定到一个已知的地址上,即为套接字指定协议名、本机IP地址和端口号。该函数原型为:
int bind(SOCKET s,struct sockaddr *name, int namelen);
该函数有3个参数,各个参数的含义如下:
- s:需要绑定到的套接字。
- name:是一个sockaddr结构指针,该结构中包含了要绑定的地址和端口号。
- namelen:是name缓冲区的长度。
如果函数执行成功,则返回值为0,否则为SOCKET_ERROR。
提示:
客户端的套接字一般不用绑定地址,当客户端程序调用connect()函数与服务器建立连接时,系统会自动为套接字选择一个IP地址和临时端口号,因此客户端很少使用bind()函数。
服务器端的监听套接字不绑定地址也不会出现明显错误,因为当服务器调用listen()函数时,系统也会为套接字分配IP地址和临时TCP端口号,不过由于临时端口号很难被客户端知晓从而导致客户端无法连接服务器,因此服务器端需要用bind()函数绑定地址。
4. listen()函数
listen()函数是只能由服务器端使用的函数,而且只适用于流式套接字,listen()函数用于将套接字设置为监听模式。该函数原型为:
int listen (SOCKET s, int backlog);
该函数有2个参数,各个参数的含义如下:
- s:套接字。
- backlog:表示等待连接的最大队列长度。例如,若设置backlog为4,当同时收到5个客户端连接请求时,则前4个客户端连接请求会放置在等待队列中,第5个客户端会得到错误信息。该参数值通常设置为常量SOMAXCONN,表示将连接等待队列的最大长度值设为一个最大的“合理”值,该值由底层开发者指定,在WinSock2中,该值为5。
提示:listen()函数中的backlog设置的是等待连接的客户端的最大个数,并不是服务器端能够同时连接的客户端数。TCP是一对一通信协议,因此一个服务器套接字在任何时候都只能连接一个客户端。
5. accpet()函数
accept()函数只适用于流式套接字,并且也是只能由服务器端使用的函数,其功能是接收指定的监听套接字上传入的一个连接请求,并尝试与请求方建立连接,连接建立成功后则返回为该连接创建的一个新套接字。该函数原型为:
SOCKET accept (SOCKET s, struct sockaddr *addr, int FAR* addrlen);
该函数有3个参数,各个参数的含义如下:
- s:是一个套接字,它应处于监听状态。
- addr:是一个sockaddr类型的结构指针,包含一组客户端的IP地址、端口号等信息。
- addrlen:指针类型,指向参数addr的长度。
accept()函数返回一个已建立连接的新的套接字的描述符(即已连接套接字的描述符),服务器与客户端的所有后续通信,都应使用该新的套接字(称为通信套接字)。而原来的监听套接字仍然处于监听状态,可以继续接受其他客户端的连接请求。
默认情况下,如果调用accept()函数时还没有客户端的连接请求到来,accept()函数将继续等待,进程将阻塞,直到客户端与服务器建立了连接之后才会返回。
6. connect()函数
connect()函数只能用在客户端,其功能是建立客户端与服务器之间的连接。客户端调用connect函数时发起主动连接,TCP协议开始三次握手过程,三次握手过程完成后,connect()函数返回。该函数的原型如下:
int connect (SOCKET s, const struct sockaddr *name, int namelen);
各个参数的含义如下:
- s:标识一个套接字。
- name:套接字s想要连接的主机地址和端口号。
- namelen:name缓冲区的长度。
connect函数用于发送一个连接请求。若成功则返回0,否则为SOCKET_ERROR。用户可以通过WSAGetLastError得到其错误描述。
7. send()函数
在连接建立成功后,就可以在已建立连接的套接字上发送和接收数据了。对于流式套接字,发送数据通常使用send()函数。注意send()函数发送成功仅表示已经将数据发送到了本机WinSock的缓冲区中,并不表示对方主机已成功接收。该函数的原型为:
int send (SOCKET s, const char *buf, int len, int flag s);
该函数有4个参数,各个参数的含义如下:
- s: 已建立连接的套接字标识符;
- buf :用来存放待发送数据的缓冲区,是该缓冲区地址的指针,如字符数组名;
- len:缓冲区buf中要发送数据的字节数,如strlen(str)+1;
- flags :用于控制数据发送的方式,通常取0,表示正常发送数据;如果取值为宏MSG_DONTROUT,则表示目标主机就在本地网络中,也就是与本机在同一个IP网段上,数据分组无须路由即可直接交付目的主机,如果传输协议的实现不支持该选项则忽略该标志;如果取值为宏MSG_OOB,则表示数据将按带外数据发送。
该函数的返回值是成功发送的字节数,注意该发送的字节数有可能小于参数len,如果连接已关闭则返回0,若发送错误,则返回SOCKET_ERROR。
8. recv()函数
recv()函数用来在已建立连接的流式套接字中接收数据,该函数实际上仅从本机的WinSock缓冲区中读取数据。该函数执行成功则返回实际从套接字s读入到buf中的字节数。连接终止则返回0;否则返回SOCKET_ERROR错误号,该函数的原型如下:
int recv (SOCKET s, char *buf, int len, int flags);
该函数有4个参数,各个参数的含义如下:
- s:已建立连接的套接字标识符;
- buf:是接收数据的缓冲区,是该缓冲区地址的指针;
- len:是buf的长度,如sizeof(buf)。
- flags:表示函数的调用方式,一般取值为0。
9. closesocket()函数
网络通信完成后,程序退出前应使用closesocket()函数关闭套接字以释放资源,此外,closesocket()还会发送数据包导致TCP通信的连接断开,该函数的原型如下:
int closesocket (SOCKET s );
该函数的参数为一个要被关闭的套接字,如果执行成功则返回0,否则返回SOCKET_ERROR。
10. WSACleanup()函数
应用程序在完成对WinSock动态链接库的使用后,需要注销与WinSock库的绑定,以释放WinSock库所占用的系统资源。WSACleanup()函数用来注销WinSock动态链接库。该函数的原型为:
int WSACleanup (void);
该函数无参数,执行成功后将返回0,否则返回SOCKET_ERROR。对应于应用程序中每一次对WSAStartup()调用,都应该有一个WSACleanup()的调用。
2.1.4 套接字建立连接与TCP三次握手
服务器端在调用listen()函数之后,内核会建立两个队列,SYN队列和ACCEPT队列,其中ACCPET队列的长度由backlog值指定。
TCP套接字建立连接的过程与TCP三次握手的关系如图2-3所示,步骤如下:
1)服务器端在调用accpet()函数之后,将阻塞,等待ACCEPT队列中有元素。
2)客户端在调用connect()函数之后,将开始发起SYN请求,请求与服务器建立连接,此时称为第一次握手。
3)服务器端在接受到SYN请求之后,把请求方放入SYN队列中,并给客户端回复一个确认帧ACK,此帧还会携带一个请求与客户端建立连接的请求标志,也就是SYN,这称为第二次握手。
4)客户端收到SYN+ACK帧后,connect()函数将返回,并发送确认建立连接帧ACK给服务器端,这称为第三次握手。
5)服务器端收到ACK帧后,会把请求方从SYN队列中移出,放至ACCEPT队列中,而accept函数也等到了自己的资源,从阻塞中唤醒,从ACCEPT队列中取出请求方,重新建立一个新的套接字,并返回。
这就是listen(),accept(),connect()三个函数的工作流程及原理。从这个过程可以看到,在connect函数中发生了两次握手。
图2-3 套接字建立连接与TCP三次握手的关系