《WINDOWS 环境下32位汇编语言程序设计》第16章 WinSock接口和网络编程(1)
当今的时代是网络时代,网络给生活带来的影响超过了以往的任何事物,不管我们是用浏览器上网,是在打网络游戏,还是用MSN、QQ等即时通信软件和朋友聊天,网络的另一端实际上都是对应的网络应用程序在提供服务。
大多数的网络应用程序分为两部分:客户端和服务器端。以浏览网页为例,IE浏览器是客户端,Web服务器是服务器端;对于网络游戏,安装在我们的计算机上的游戏界面是客户端,而游戏服务商的服务器上运行的程序是服务器端。本章将通过一些例子来说明如何用Win32汇编来编写网络应用程序的服务器端和客户端,由于篇幅有限,本章仅涉及如何使用WinSock接口编写网络应用程序,除此之外,重点介绍TCP网络应用程序架构的设计思想。
虽然不了解网络原理,但是并不妨碍写网络程序,可是了解了网络原理无疑会对正确设计程序架构带来很大的帮助,如果读者没有网络方面的基础知识,请首先花少量时间了解下面这些概念,这方面的内容可以参考网络上很常见的CCNA认证课程教材中的基础部分。
● 什么是TCP/IP协议?
● TCP协议和UDP协议的特点是什么?
● IP地址,地址掩码的含义是什么?
● 网络架构模型——开放系统互联模型(OSI模型)的含义,该模型有哪些层次?平时常用的一些协议如HTTP,FTP,TCP,UDP和ICMP等协议分别位于哪个层次?
● TCP栈的含义,数据在TCP栈中传递时是如何封装和解包的?
● 如果希望进一步了解细节,如各种协议的具体功能说明,数据包封装的详细定义等,可以阅读下面的资料:
● RFC文档——TCP/IP的技术标准是公开的,其技术标准都是以RFC(Request for Comment)文档的形式出版的,读者可以很方便地从因特网中获取它们,获得任意一份RFC文档的最简单的方法是访问官方站点:
http://www.rfc-editor.org/rfc.html
● W.Richard Stevens的遗作:3卷中译本的《TCP/IP详解》,它们是《卷1:协议》、《卷2:实现》和《卷3:TCP事务协议、HTTP、NNTP和UNIX域协议》,这3本书是网络编程资料中当之无愧的经典之作,书中详细地介绍了网络传输协议的实现细节,但内容并不涉及网络应用程序的编写。
16.1 Windows Socket接口简介
Windows Socket接口是Windows下网络编程的接口,在介绍Windows Socket接口之前,首先要简单介绍一下TCP/IP协议和描述网络系统架构的OSI模型,以及TCP/IP模型。
一般来说,网络系统的架构可以用开放系统互联模型(OSI模型)来描述,OSI模型分层的思想类似于Windows等操作系统的分层,在Windows下,应用程序位于最高层,应用程序通过API调用位于中间层次的系统子程序,系统子程序再调用驱动程序,驱动程序最终操作计算机的硬件,各层次之间的隔离有利于层次间的分工协作,只要每个层次都严格遵守边界协定,那么它对于其他层次来说就可以看成是一个“黑匣子”,结果就是开发人员能够致力于本层次的开发和提高,而不必担心能否和其他层次合作。与之类似,OSI模型的体系结构分为7层,其中网络应用程序位于最高层,通过多个层次最终控制网络硬件所在的物理层来收发数据包。
TCP/IP是Transmission Control Protocol/Internet Protocol(传输控制协议/网际协议)的缩写,它最初是在20世纪70年代初期由美国国防部出资为ARPA(美国高级研究项目局)开发的,经过了多年以后,以TCP/IP协议为基础构建的ARPA网逐步演变成了今天的Internet。TCP/IP网络的架构可以用TCP/IP模型来描述,这个模型和OSI模型极为相似,但是它将层次的划分减少到了4层,其每一层在功能上和OSI模型的一层或多层相对应,两种模型从工作原理上看并没有本质的区别。
图16.1描述了OSI模型和TCP/IP模型各层次之间的对应关系,并例举了部分在各层次上工作的网络协议,读者可以在其中看到很多熟悉的名词,如Telnet,HTTP,TCP和UDP等。
TCP/IP协议的核心协议运行于传输层和Internet层上,主要包括TCP,UDP和IP协议,其中TCP协议和UDP协议是以IP协议为基础而封装的,这两种协议提供了不同方式的数据通信服务。在后面的16.2.3节中,会对两种协议的区别做详细的介绍。
如果说IP协议是道路,那么下一层网络访问层的各种协议就相当于不同的铺路材料,而上一层的TCP和UDP协议就相当于路上跑的不同类型的车辆;再上层应用层的各种协议就相当于车上运送的丰富多彩的货物,它们都以TCP和UDP协议为载体来完成。比如,HTTP协议使用TCP协议传输网页,POP3协议使用TCP协议传输邮件,而DNS协议使用UDP协议来传输域名和IP地址的翻译信息。
Microsoft为Win32环境下的网络编程提供了Windows Sockets接口(简称WinSock接口),正如Windows下的各种接口都是以API的形式出现的一样(如GDI),WinSock也是以一组API的方式提供的,从1991年推出1.0版开始,经过不断地完善后,WinSock接口现在已成为Windows下网络编程的标准。
由于网络协议最早是在UNIX操作系统上实现的,所以从可移植性考虑,WinSock接口以BSD UNIX操作系统中流行的Socket接口为范例定义,它包含了UNIX Sockets接口中一系列的同名函数,但是Windows系统的运行方式和UNIX系统有显著的不同,为了与Windows系统相适应,WinSock接口在移植这些函数的同时对它们进行了改造,并针对Windows的消息驱动机制定义了一部分新的函数,所以WinSock接口函数实际上是UNIX Sockets接口函数的超集。
如图16.1所示,WinSock接口提供的函数位于TCP/IP模型中的传输层和Internet层上面,也就是说,我们可以利用接口函数编写使用TCP和UDP协议的程序,也可以编写直接使用IP协议的程序,如使用ICMP协议完成Ping的功能,但WinSock接口并没有对应用层上的各种协议提供支持,所以应用程序无法通过WinSock接口提供的函数来实现HTTP、FTP与Telnet等协议,应用程序必须用另外的接口或者自己编程来实现这些高层协议。
图16.1 OSI模型、TCP/IP模型的结构和WinSock接口的关系
与GDI等接口类似,WinSock接口也是通过几个动态链接库来提供的,这些动态链接库从版本上分有1.1版本和2.0版本两种,从位数分可以分为16位版本和32位版本,如图16.2所示,WinSock接口函数的代码主要包括在WS2_32.dll库文件中,这个库文件提供了对2.0版本WinSock接口的支持。在早期的Windows系统中,16位和32位的1.1版本的文件名分别是WinSock.dll和WSock32.dll,为了给使用这些库文件的程序提供兼容性支持,系统中仍然存在这两个文件,只不过现在这两个文件中也是间接调用了WS2_32.dll文件而已。
图16.2 WinSock接口使用的动态链接库
为了使用WinSock接口,需要在源程序中包含对应的inc和lib文件,如果使用2.0版本,在源程序中必须包括WS2_32.inc和WS2_32.lib文件,如果要使用的是1.1版本的WinSock函数,那么既可以使用上面两个文件,也可以使用WSock32.inc和WSock32.lib文件。
在源文件中包含了对应的inc文件和lib文件后,在使用其他WinSock函数之前,必须首先使用WSAStartup函数来装入并初始化动态链接库,否则对其他任何WinSock函数的调用都不会成功,WSAStartup函数只需要在程序开始的时候调用一次:
invoke WSAStartup,wVersionRequested,lpWSAData
.if eax;无法初始化WinSock库
.endif
wVersionRequested是一个16位的参数,用来指定动态链接库将支持哪个版本的WinSock函数,其中的低8位指定主版本号,高8位用来指定副版本号,假如要使用1.1版的,可以在这里使用0101h,假如需要使用2.0版本函数,则可以将参数指定为0002h。
lpWSAData参数指向一个WSADATA结构,用来返回动态链接库的详细信息,结构的定义为:
WSADATA STRUCTwVersion WORD ? ;库文件建议应用程序使用的版本wHighVersion WORD ? ;库文件支持的最高WinSock版本szDescription BYTE WSADESCRIPTION_LEN + 1 dup (?);库描述字符串szSystemStatus BYTE WSASYS_STATUS_LEN + 1 dup (?) ;系统状态字符串iMaxSockets WORD ? ;同时支持的最大套接字数量iMaxUdpDg WORD ? ;2.0版中已废弃的字段lpVendorInfo DWORD ? ;2.0版中已废弃的字段
WSADATA ENDS
szDescription字段中返回的字符串一般是“WinSock 2.0”之类的库描述串,szSystemStatus字段中返回的是类似于“Running”一类的运行状态字符串。如果库装入成功,函数将返回0,否则将返回下面的出错代码:
● WSASYSNOTREADY——网络子系统未准备好。
● WSAVERNOTSUPPORTED——不支持指定的版本。
● WSAEINPROGRESS——另一个阻塞方式的WinSock 1.1操作正在进行中。
● WSAEPROCLIM——WinSock接口已经到达了所支持的最大任务数。
● WSAEFAULT——输入参数lpWSAData指定的指针无效。
不需要再使用WinSock函数时,比如,在程序退出前,必须使用WSACleanup函数将库释放:
invoke WSACleanup
WSACleanup函数没有输入参数,它将释放动态链接库并自动释放所有被创建的套接字等资源。如果函数执行成功将返回0,否则将返回SOCKET_ERROR。
WinSock函数中有些参数是16位的,如WSAStartup函数中的wVersionRequested参数,但是在传递这些参数时,并不是堆栈中压入一个字(word),而是将它扩展到32位的双字(dword)以后再压入这个双字,所以在源程序的invoke语句中并不需要特殊的处理。
16.2 Windows Socket接口的使用
本节将介绍一些常用的WinSock函数,但是在正式开始介绍前,读者需要了解和这些函数密切相关的IP地址、端口和网络字节顺序等的重要概念。
16.2.1 IP地址的转换
1.什么是IP地址和端口
要在计算机之间使用TCP/IP协议进行通信,这些计算机必须能够互相寻址,TCP/IP协议使用IP地址寻址方式,所以,对于一个IP网络来说,网络上的每个设备必须具有唯一的IP地址,否则将无法正确地进行寻址,同理,要让整个Internet范围内的计算机都能够互相通信,Internet上的所有设备也必须具有唯一的IP地址。
图16.3 IP地址的两种表示格式
IP地址是一个32位的二进制数,为了用人们熟悉的十进制数表示,通常将它分为4个8位的二进制数,每个8位二进制数被转换成十进制,中间用小数点隔开,就得到了IP地址的十进制字符串格式,图16.3中的IP地址11000000101010000000000101100100,以十进制方式表示就是192.168.1.100。
既然计算机是以IP地址来辨别的,如果一台计算机上运行了多个服务器程序怎么办?比如,既运行了Web服务,又运行了FTP服务,这两个服务都使用同样的IP地址,那么数据到达这个IP地址后,计算机如何分辨数据发往哪个服务的呢?这就涉及协议复用的问题,如果协议不能复用的话,那么它就只能为一个进程服务。
为此,TCP和UDP协议中有端口的概念,使用这两种协议通信的时候,IP地址和端口号结合起来才能唯一确定一个目标地址,这样这两种协议就提供了同时为不同进程提供通信的能力,在上面的例子中,Web服务使用80号端口,FTP服务使用21号端口,客户端指定和21号端口连接,系统就知道是和FTP服务进行通信。TCP/IP模型其他层次的其他协议中并没有端口的概念,如ICMP协议等,这些协议就只能寻址到IP地址为止。
端口号用一个16位的整数来表示,所以从理论上讲,可以同时有65536个服务使用同一IP地址进行通信,由于传输层的TCP协议和UDP协议是两个完全独立的模块,两者的工作是互不相干的,所以TCP和UDP各自的端口号也相互独立,一个进程使用TCP协议的某个端口号并不影响另一进程使用UDP协议的同名端口号,但是同一协议的同一端口号无法被两个进程同时使用。
大部分应用层上的协议都定义了自己使用的默认端口号(但这并不意味着必须强制使用这个端口号),另外,一些服务程序(如SQL Server,Oracle数据库与Windows的终端服务等)也使用固定的端口号。表16.1中列出了一些常用协议和服务程序使用的端口号,详尽的已分配端口号列表可以参考RFC 1700文档。
表16.1 常用协议和应用程序使用的默认端口号
由于在使用TCP和UDP协议进行通信时,必须同时指定IP地址和端口号才能完整地标识一个通信地址,所以在编程中通常将这两个参数一起定义在一个sockaddr_in结构中来使用,sockaddr_in结构是WinSock接口中最常用的结构之一,它的定义是:
sockaddr_in STRUCTsin_family WORD ? ;地址格式sin_port WORD ? ;端口号(使用网络字节顺序)sin_addr in_addr <> ;IP地址(使用网络字节顺序)sin_zero BYTE 8 dup (?) ;空字节
sockaddr_in ENDS
结构中的sin_family字段用来指定地址格式,在不同操作系统下,取值可以指定为AF_UNSPEC,AF_UNIX或AF_OSI等不同的值,但是在WinSock中只能使用AF_INET(也可以使用PF_INET,在Windows.inc中PF_INET被定义为与AF_INET等效)。sin_port字段和sin_addr字段则分别指定端口号和IP地址,其中sin_addr字段是个in_addr结构,这个结构实际上被定义为一个dword。
sockaddr_in结构看起来没什么特别的,但是数据放入sin_port字段和sin_addr字段时却必须经过特殊处理,因为系统要求这两个字段的数据是按照网络字节顺序排列的。
2.网络字节顺序
什么是网络字节顺序呢?这首先要从CPU对字节顺序的处理方式谈起。
CPU对字节顺序的处理方式有两种:大尾方式(big Endian)和小尾方式(little Endian)。在大尾方式中,数据的高字节被放置在连续存储区的首位,比如一个32位的十六进制数12345678h在内存中的存放方式是12h,34h,56h,78h,同样,IP地址192.168.0.1在内存中的存放方式是192,168,0,1;而在小尾方式中,数据的低字节被放置在连续存储区的首位,上面的数据在内存中的存放方式变为78h,56h,34h,12h及1,0,168,192。Intel 80x86系列处理器和DEC VAX处理器使用的是小尾方式(所以我们常常看到内存中的多字节数是倒过来放置的),而Motorola的680x0和其他大部分的RISC芯片都使用大尾方式。
大尾和小尾方式各有好处,不同的处理器采用不同的方式本身无可厚非,但是要在它们之间进行通信的话就必须选定其中一种方式当做标准,否则会造成混淆,比如,某个采用Intel CPU的计算机要向某个采用Motorola CPU的计算机的0100h号端口发送数据,它按照自己的字节处理顺序在TCP数据包首部填入代表端口号的数据00h,01h(小尾方式下的0100h),而接收方收到后却按照自己的方式理解为0001h端口,那就成问题了。
TCP/IP协议是一组开放的协议,它被设计用来在不同的计算机平台之间进行通信,所以在协议的实现细节中不能包含与特定平台相关的内容,凡是与平台相关的都需要转换为规定的格式,其中最主要的就是对字节顺序的处理。
TCP/IP协议统一规定使用大尾方式传输数据(也称Internet顺序),非常遗憾的是,这与Intel 80x86系列处理器使用的方式不同,所以在80x86平台下的WinSock编程中,当使用需要在协议中使用的参数时,必须首先将它们转换为Internet顺序。所以在填写sockaddr_in结构的sin_port字段和sin_addr字段时,必须首先进行转换。比如使用端口号9999,转换成十六进制是270Fh,那么放入sin_port字段的数值就应该是转换以后的0F27h,这样放到内存中后的排列顺序就刚好是27h,0Fh。
读者可能会问:为什么sin_family字段就不需要转换呢?这是因为IP地址和端口参数最终是被封装到IP数据包中通过网络传输到另一台计算机上去的,因此它们必须是网络字节顺序。但是sin_family字段是用来告诉WinSocket接口,结构中包含的地址类型是什么,并不需要发送到网络上,所以使用本机字节顺序。这就是前面说的“需要在协议中使用的参数”才需要转换的原因。
3.字节顺序转换函数
为了方便进行字节顺序转换,WinSock接口提供了一系列的函数。
htons函数完成的功能是“Host to Network Short”,即将16位的以当前主机字节顺序排列的数据转换成按网络顺序排列的数据:
invoke htons,hostshort
mov netshort,ax
函数的输入值是按主机字节顺序排列的16位数据(当然调用时需要扩展到32位以便当做参数输入),返回值的低16位是转换后的按网络字节顺序排列的数据。
htonl函数完成的功能是“Host to Network Long”,即将32位的以当前主机字节顺序排列的数据转换成按网络顺序排列的数据:
invoke htonl,hostlong
mov netlong,eax
ntohs函数完成的功能是“Network to Host Short”,即将16位的按网络顺序排列的数据转换成以当前主机字节顺序排列的数据(输入参数同样需要首先被扩展到32位):
invoke ntohs,netshort
mov hostshort,ax
ntohl函数则完成“Network to Host Long”功能,即将32位的按网络顺序排列的数据转换成以当前主机字节顺序排列的数据:
invoke ntohl,netlong
mov hostlong,eax
细心的读者可能已经发现,函数名实际上是n和h的组合(分别代表network和host),中间加上一个“to”,并且分l和s(long和short)后缀而已。这些函数用于转换IP地址时,由于IP地址是32位的,需要使用long版本,转换端口时,由于端口是16位的,使用的是short版本。
一般来说,当涉及当前主机字节顺序和网络顺序数据的转换时,不管当前主机使用的字节顺序是否和网络顺序相同,最好都进行一次转换,而且转换时必须使用这些转换函数而不是自定义的函数,这样程序可以在不同的主机上进行移植。
比如,现在使用的是Intel 80x86平台,它的主机字节顺序和网络字节顺序是不一样的,如果使用自定义的函数将字节顺序反过来,万一Windows移植到和网络字节顺序一致的硬件平台上,转换的结果就不对了。反之,在运行于Motorola平台上的UNIX系统中,CPU字节顺序和网络字节顺序是相同的,调用这些转换函数的话,函数的返回值和输入值不会有什么不同,但程序因此取消了字节顺序转换这一步骤的话,那么程序移植到运行于Intel平台上的Linux系统中时就会出错,所以不管主机的字节顺序是否和网络字节顺序相同,应该总是使用转换函数进行字节顺序转换。
4.其他一些转换函数
除了字节顺序转换函数,WinSock接口还提供了其他一些常用的转换函数,比如,将32位的IP地址和“aa.bb.cc.dd”类型的十进制IP地址字符串互相转换,这些函数可以为我们带来很多方便。
inet_addr函数和inet_ntoa可以在IP地址和字符串之间进行转换。
net_addr函数将一个由小数点分隔的十进制IP地址字符串转换成由32位二进制数表示的IP地址(网络字节顺序):
invoke inet_addr,lpString
.if eax != INADDR_NONEmov dwIP,eax
.endif
lpString参数指向“aa.bb.cc.dd”类型的IP地址字符串。如果转换成功,函数将返回已经按网络字节顺序排列的32位IP地址,否则返回INADDR_NONE。inet_ntoa则是inet_addr函数的逆函数,它将一个网络字节顺序的32位IP地址转换成字符串:
invoke inet_ntoa,in
.if eaxmov lpsz,eax
.endif
参数in是需要转换的32位IP地址,如果转换失败函数返回NULL,转换成功的话函数返回一个指针,指向转换后的IP地址字符串。这个字符串位于WinSock接口的内部缓冲区中,这是一个静态缓冲区,也就是说每次调用都使用同一个缓冲区,所以需要再次调用该函数前,必须将字符串拷贝到程序自己定义的缓冲区中,否则根据前面保存的指针来访问,得到的总是最后一次调用的结果。
一般来说,使用这些转换函数填写sockaddr_in结构的方式如下:
invoke inet_addr, addr szIpAddress ; szIpAddress = 'aa.bb.cc.dd'
.if eax == INADDR_NONEjmp Error
.endif
mov @stSin.sin_addr,eax
mov @stSin.sin_family,AF_INET
invoke htons,12345 ;假设端口号是12345
mov @stSin.sin_port,ax
例子中的@stSin定义成sockaddr_in结构,使用的端口号是12345,请注意inet_addr函数返回的整数类型的IP地址已经是网络字节顺序的,所以不需要调用htonl函数进行转换就可直接放入sin_addr字段,而端口号12345必须先使用htons函数进行转换后才能放入sin_port字段。
16.2.2 套接字
了解了IP地址、网络字节顺序等概念后,我们还无法开始写网络程序,还需要来了解一个新的概念。大家都知道,网络应用程序一般也被称为“socket程序”,UNIX和Windows操作系统中的网络编程接口都被称为socket接口,那么什么是socket呢?
1.什么是套接字(socket)
为了使用WinSock接口进行通信,首先必须建立一个用来通信的对象,这个对象就称为套接字(socket),套接字的定义是“通信的一端”,在通信的另一端必定有另一个套接字与之相对应以便互相传递数据。仅从编程的角度来看,套接字就是一个句柄而已,但socket这个称呼比句柄更贴切,因为socket的英文含义是插座、插孔或者接口,表达的含义就是通信时两端的对象必须一一对应接在一起才能工作。
套接字的种类有很多种,最主要的两种是流套接字(stream socket)和数据报套接字(datagram socket)。由于流套接字使用传输层的TCP协议进行通信,所以它具有TCP协议所拥有的各种特征,比如,它是面向连接的、稳定的,以及数据包是顺序发送的;而数据报套接字使用UDP协议进行通信,所以它的特征来自于UDP协议,如数据包可能丢失,可能重复,以及可能不按顺序到达等(一般将这两种套接字更直观地称为TCP套接字和UDP套接字,本书后面的内容也使用这种直观的称呼方式)。
另外,也存在其他一些不常用的套接字类型,如原始套接字(raw socket)、可靠信息分递套接字(rdm socket)和连续小分包套接字(seqpacket socket)等,其中值得一提的是原始套接字,由于使用这种套接字可以直接在Internet层上处理IP数据包的首部,所以可以用它来实现各种特殊的功能,如伪造发送者地址等。
使用WinSock接口进行通信的时候,第一个步骤就是建立一个套接字,由于套接字在创建时就需要指定种类,所以一旦套接字被创建,那么使用该套接字进行通信时使用的协议也就已经基本确定了。读者不可能创建一个TCP套接字后再希望通过它发送UDP数据包。
有一部分介绍WinSock的书籍中讲到:“套接字的种类有两种——流套接字和数据报套接字”,这种说法是不确切的,因为正如上面介绍的,实际上还存在其他类型的套接字。
2.套接字的创建和关闭
要开始通信之前,必须首先创建一个套接字,创建套接字使用socket函数:
invoke socket, af, type, protocol
.if eax != INVALID_SOCKETmov hSocket, eax
.endif
函数的第一个参数af用来指定套接字使用的地址格式。这个参数和sockaddr_in结构中sin_family字段的定义是一样的,唯一可以使用的值是AF_INET。
第二个参数type用来指定套接字的类型。正如前面介绍的,套接字有流套接字、数据报套接字和原始套接字等,下面是最常用的几种套接字类型定义:
● SOCK_STREAM——流套接字,使用TCP协议提供有连接的和可靠的传输。
● SOCK_DGRAM——数据报套接字,使用UDP协议提供无连接的和不可靠的传输。
● SOCK_RAW——原始套接字,WinSock接口并不使用某种特定的协议去封装它,而是由程序自行处理数据包,以及协议首部。
protocol参数配合type参数使用,用来指定使用的协议类型,当type参数指定为SOCK_STREAM或者SOCK_DGRAM的时候,系统已经明确使用TCP和UDP协议来工作,所以这时这个参数并没有什么意义,可以指定为0,但是当type参数指定为SOCK_RAW类型的时候,使用protocol参数可以更详细地指定原始套接字的工作方式。
当type参数指定为SOCK_RAW类型时,可以将protocol参数指定为以下的数值:
● IPPROTO_IP,IPPROTO_ICMP,IPPROTO_TCP和IPPROTO_UDP——分别指定使用IP,ICMP,TCP和UDP协议,这时系统会自动为数据加上IP首部,并且将IP首部中的上层协议字段设置为指定的这些协议名称。但是使用这个套接字接收数据时,系统却不会将IP首部自动去除,需要程序自行分析处理(如果在以后将套接字的属性设置上IP_HDRINCL选项的话,那么发送时系统将不自动添加IP首部,这时需要自己封装数据包)。
● IPPROTO_RAW——系统将数据包直接送到网络访问层发送,程序需要自己添加IP首部,以及其他协议首部,并且需要自己计算和填充协议首部中的校验和字段。当使用IPPROTO_RAW协议类型的原始套接字时,这个套接字只能用来发送数据包而无法接收数据包。这是因为所有的IP包都是先递交给系统核心,然后再传输到用户程序,当发送这样一个原始数据包的时候,核心并不知道,也没有这个数据被发送或者连接建立的记录,因此当远端主机回应的时候,系统核心就把这些包给丢弃而不是送到应用程序中。
当套接字被成功创建的时候,函数将返回一个套接字句柄,否则函数将返回INVALID_SOCKET,这时可以继续调用WSAGetLastError函数获取更详细的出错信息。
当不再需要使用套接字的时候,需要使用closesocket函数将它关闭:
invoke closesocket, s
参数s就是创建套接字时返回的套接字句柄。
对于还处于连接中的TCP套接字来说,关闭了套接字,系统会自动断开对应的连接。
当出错的时候,大部分WinSock函数的返回值是INVALID_SOCKET或者SOCKET_ERROR,如果需要进一步的出错代码,必须马上调用WSAGetLastError来获取,在以后提及“出错代码”时,指的就是这样得到的出错代码而不是函数的返回值。但唯一的例外是WSAStartup,它出错时会直接返回出错代码,因为这时WinSock库尚未装入,WSAGetLastError函数还无法工作。
3.套接字的工作模式
WinSock套接字的使用分为两种模式:阻塞模式和非阻塞模式。阻塞模式也称为同步模式,在这种模式下,WinSock函数会等待操作完成后才返回。比如,使用recv函数接收数据时,如果函数被调用时没有数据可收,那么函数不会返回,直到收到一些数据为止;再比如使用send函数发送n字节的数据时,在全部n字节的数据发送完之前,函数不会返回,所以这种模式下,调用WinSock函数的线程有可能处于等待状态。
在BSD UNIX中,套接字是以阻塞模式执行的,这对以命令行方式执行的UNIX程序来说并不是问题,但在Windows系统中运行时,以阻塞模式工作的socket函数WinSock函数必须放在单独的线程中,如果放在主线程中工作,那么主线程可能被阻塞而导致界面无法响应。为了适应Windows下的消息驱动体系,WinSock接口可以让套接字工作在非阻塞模式下,非阻塞模式又称异步模式,在这种模式下,同样是前面所述的情况,recv或send函数执行后会立即返回,等有数据到达或者链路空闲可以继续发送数据时,WinSock接口会通过某种形式(如窗口消息)通知应用程序,显然这种方式比较适合于Windows下的消息驱动体系。
具体编程的时候,究竟采用哪种模式并没有定论,每种模式都有自己的优缺点,在后面的例子中,会有不同的例子对两种模式的工作方式进行对比。当一个套接字被创建的时候,它默认工作在阻塞模式下,设置成非阻塞模式的方法,以及在该模式下的消息驱动机制将在16.3.4节中做详细介绍。
16.2.3 网络应用程序的一般工作流程
到这里,我们离网络编程又近了一步,但是我们还是应该首先了解一下网络应用程序的常用架构,以及TCP、UDP协议的详细特征,并初步了解一下后面将要介绍的函数都会用在架构中的哪个部分,这样在遇到具体的函数时可以做到心中有数。
从程序的结构及用途来看,大多数的网络应用程序分为两部分:客户端和服务器端。从使用的传输协议上看,占绝对多数的应用程序使用TCP和UDP两种协议,只有少量程序使用其他协议(如大家熟悉的Ping程序使用ICMP协议)。虽然在应用层上的程序使用了例如HTTP,FTP,POP3以及DNS等很多种协议,但这些协议本质上还是对TCP和UDP协议的封装。
1.TCP协议的特征和TCP程序的工作流程
TCP协议是一种传输层上的协议,它提供一种面向连接的、可靠的字节流服务。
面向连接的含义是:两个TCP套接字在开始传输数据之前必须先建立一个连接,也就是说由一方首先向另一方发送请求信息,双方互相确认以后才能开始通信。这一过程与打电话很相似,一方先拨号振铃,等待对方摘机后才开始对话,如果对端没有程序响应,那就像没有人接电话一样,通信是无法开始的。另外,面向连接意味着TCP协议不能用于广播,即一方不能用一个套接字同时向多方发送数据。
字节流服务的含义是:TCP连接上传输的数据是流方式的,也即没有边界的,假设发送方分三次分别发送了100、150、200字节的数据包,接收方看到的是一段数据流,无法得知发送方是如何分割发送的,接收方既可以一次接收450字节来将全部数据收完,也可以一次接收45字节分十次将数据收完。
TCP通信是可靠的,它采用超时及重传机制来保证不丢失数据。当一个TCP发送一个数据包后,它将启动一个定时器,等待对端确认收到这个包,如果在指定的时间内没有得到确认,将重发这个数据包。而接收数据包的时候,它将发送一个确认,如果检测发现数据包的校验和有错,TCP协议丢弃这个数据包并且不发送确认,那么对端会因为超时而重新发送这个数据包。
数据包在传输的时候会通过多个路由器,不同的数据包到达终点前经过的路由器可能是不同的,因此所花的时间也是不同的,这就可能造成后发的数据先到的情况,TCP协议在TCP首部保存数据包序号,如果有必要,它将对收到的数据进行重新排序,并将收到的数据以正确的顺序交给应用层。
接收方收到的IP报文段也可能重复,原因之一是在发送和确认之间还有个时差,发送方可能因为接收方的确认信息还在路上就发生超时而重发数据,这时接收方收到的数据包就会发生重复,对于这种情况,接收方会丢弃重复的数据。
TCP协议还提供流量控制机制,发送方可以根据接收方应答的时间和速率适当调整自己的数据发送速度,这样可以防止速度较快的主机使速度较慢主机的接收缓冲区溢出。
TCP协议的工作过程也和打电话的过程很相似。当一方滔滔不绝地讲话时,需要另一方偶尔回复“是的”、“嗯”之类的短语来确认,如果有一段时间没有听到对方的简短确认,发言方就会停下来问一句“你在听吗”。当另一方没有听清楚某句话的时候,他会说:“你刚才说什么,我没有听清楚”,这样发言方会复述前面的句子。如果一方讲得太快,另一方会要求他适当放慢速度。TCP协议实现的机制就与此类似。
从TCP协议的特征可以看出通信双方的工作方式必然是不同的,这种工作方式的不同可以用客户机/服务器模型(Client/Server或C/S)来描述,通信的发起端被称为客户机(Client,也称为工作站端或客户端),通信的等待方被称为服务器(Server,也称为服务器端),图16.4显示了两者工作方式的不同,图中的括号内显示了各步骤使用的WinSock函数,这些函数的用法在后面会有更详细的介绍。
图16.4 TCP服务器端和客户端模型
就像打电话一样,A对B说:“某某时候Call我”,那么B给A打电话的过程就可以用这种客户端/服务器端模型来描述。为了等待B的电话,A必须在双方约定的某个特定的电话旁边等待,否则B将不知道如何Call他。由于“特定的电话”意味着服务器端的地址必须是特定的,所以服务器端的套接字必须首先使用bind函数绑定到指定的IP地址和端口上。因为“等待”意味着服务器必须随时监听客户端的连接动作,所以套接字绑定地址后必须使用listen函数进入监听状态。而对于B来说,他可以在任何时刻从任何电话给A打电话。由此可见,客户端使用的套接字并不需要绑定过程,让系统自动指定任意值并不影响它向服务器端发起连接。
客户端可以随时使用connect函数连接到服务器,服务器检测到这个连接后,需要使用accept函数接受这个连接,当服务器接受连接后,一个稳定的连接就建立了,双方就可以开始互相通过send和recv函数收发数据了,这时通信的两端并没有任何区别。
2.UDP协议的特征和UDP程序的工作流程
虽然TCP协议由于可靠、稳定的特征而被用在大部分的应用场合,但是UDP协议由于对系统资源的要求比较小,所以也经常用在一些对数据的可靠性要求不高却要求效率的场所,如实时音频、视频点播和实时网络游戏等。
UDP协议是一个无连接的,面向消息的,不可靠的传输层协议。
无连接的含义是:客户端在发送UDP数据包前不需要先与服务器端进行握手确认,不管服务器是否在线,是否已经准备接收UDP数据包,客户端都可以随时发送数据。由于UDP协议是无连接的,所以通过同一个UDP套接字可以向任何服务器地址发送数据,而不需要创建多个套接字。
面向消息的含义是:与TCP协议的数据流方式不同,UDP数据包是有边界保护的,假设发送方分三次分别发送了100、150、200字节的UDP数据包,接收方必须分三次接收这些数据包,各数据包之间的数据不会粘连。
UDP协议是不可靠的,它并不对数据的可靠性与有序性等进行控制,由于UDP协议不是面向连接的,所以无法确认对方是否在线,也无法确认对方的指定端口是否在监听中,它直接将数据包按照指定IP地址和端口发出去,如果对方不在线的话,数据就会丢失。
由于UDP协议也不提供应答和重传机制,所以程序并不知道数据是否到达。同样,UDP协议也没有为数据包标识序号,所以数据到达对端的顺序有可能是不对的,相同的数据也有可能多次到达。它属于一种“发出就不管”的协议。
如果说TCP协议像是打电话,两个人在通话中实时地控制通话的流程并保证内容的正确性,那么UDP协议就像是寄信,发信人虽然知道收信人的地址,但是他并不确定信件会不会安全抵达,也不知道收信人究竟有没有因为外出而收不到信。另外,如果发信人发了好几封信,这些信可能并没有按发信时的顺序到达。总之,在收到收信人的回信之前,发信人无法确定信件是否安全、无损和有序地到达。
但是,正是因为UDP协议不对数据的可靠性进行保证,不需要在校验、排序、应答和流控上消耗资源,所以UDP协议的效率很高。在实际应用中,如果不需要很高的可靠性,使用UDP协议就非常适合,比如,对于实时视频,如果因为途中丢了数据包导致中断了几分之一秒,最重要的不是将它补上而是保证下面播放的实时性,如果为了补上丢失的数据包而导致“停格”显然是不必要的。
如图16.5所示,UDP程序的客户端和服务器端的工作方式区别不大,在客户端,UDP套接字不必经过连接的过程就可以直接发送数据。在服务器端,UDP套接字无所谓是否进入“监听”状态,只要有数据发送到套接字对应的端口,它就可以被接收,所以类似TCP程序架构中的连接(connect)、监听(listen)和接受连接(accept)等动作都是不存在的。服务器端和客户端唯一的区别就是服务器端程序必须首先将套接字绑定到一个固定的端口,以便客户端能够向约定的端口发送数据。
图16.5 UDP服务器/客户端模型
UDP程序的流程相对简单,所以本章中不再详细举例对UDP的编程进行讲解。
16.2.4 监听、发起连接和接收连接
好了,到现在为止,读者已经了解了网络编程所需的各种基础知识,在下面的几节中,将详细介绍图16.4和16.5中提及的各种函数的详细用法。当然,本节的内容大部分是针对TCP套接字的,因为本节的主题是“监听、发起连接和接收连接”,而UDP套接字并不需要监听和连接操作,本节中仅有bind函数常用于操作UDP套接字。
1.TCP客户端——连接到服务器
使用TCP套接字进行通信时,客户端必须首先使用connect函数连接到服务器的指定端口,connect函数的用法是:
invoke connect,s,lpsockaddr,len
参数s是TCP套接字的句柄,lpsockaddr参数指向一个sockaddr_in结构,用来指定服务器端的地址和端口,len参数则指定sockaddr_in结构的长度。
当套接字工作在阻塞模式下的时候,如果成功地连接到了服务器,那么函数返回0,否则返回SOCKET_ERROR。如果需要知道连接失败的详细原因,可以马上调用WSAGetLastError来得到具体的出错代码。常见的错误有:当服务器端没有在指定端口监听时,出错代码是WSAECONNREFUSED;如果网络不通,或者服务器不在线,那么错误代码可能是WSAETIMEDOUT。
由于TCP连接的过程需要进行三次握手,也就是说应答的数据包需要在网络上传递三次,所以连接的过程视网络速度需要几十毫秒到几秒钟。当服务器端没有反应的时候,connect函数可能会在十几秒钟后才因超时而失败返回,如果要在这个过程中退出,可以在另外一个线程中使用closesocket关闭套接字。
当套接字工作在非阻塞模式下的时候,不管连接成功与否,connect函数会马上返回并返回SOCKET_ERROR,这时并不意味着连接失败,而是表示函数返回的时候连接尚未成功(请记住非阻塞模式要求无论操作是否成功,函数必须马上返回)。只有调用WSAGetLastError函数得到的出错代码不是WSAEWOULDBLOCK(表示出错原因是因为操作正在进行中)的话,才表示连接失败。
发起连接时,系统会自动为套接字选择一个空闲的端口,如果一定要用特定的端口连接到服务器端,可以在调用connect函数前先用后面介绍的bind函数将套接字绑定到指定的端口上。
2.TCP服务器端——在指定的IP地址和端口监听并接收连接
不管是TCP套接字还是UDP套接字,如果希望套接字在指定的IP地址和端口监听,必须首先将它绑定到该IP地址和端口上,使用bind函数可以进行绑定操作:
invoke bind,s,lpsockaddr,len
参数s指定套接字句柄,lpsockaddr参数指向定义了需要绑定的IP地址和端口的sockaddr_in结构,len参数指定sockaddr_in结构的长度。
sockaddr_in结构中的sin_port参数填写为需要进行监听的端口,sin_addr参数只要填写成INADDR_ANY(定义为0)就可以了,表示自动在本机的所有IP地址上进行监听,例如,计算机有3个网卡,配置了3个IP地址,那么套接字会自动在所有3个地址上进行监听。
如果只需要在其中的某一个IP地址进行监听,不希望在全部地址监听怎么办?例如,服务器有一块网卡是连接因特网的,另一块网卡是连接内部网的,现在为了安全起见需要仅仅对内网的地址进行监听。当然可以,这时将sin_addr字段填写成内网的IP地址即可,套接字会在指定的那个地址上进行监听。
绑定成功的话,bind函数返回0,否则返回SOCKET_ERROR。一般来说,绑定失败是因为端口已经被其他的程序占用了,这时的出错代码会是WSAEADDRINUSE,还有一种情况是套接字已经绑定过了,那么错误代码会是WSAEFAULT。
将套接字和指定的IP地址和端口绑定成功后,UDP套接字就马上可以用来接收数据包了。但是TCP套接字还需要用listen函数进行监听,并使用accept函数接收进入的连接后才能进行通信。
使用listen函数可以使TCP套接字进入监听状态:
invoke listen,s,backlog
参数s指定套接字句柄,backlog参数是监听队列中允许保持的尚未处理的最大连接数量。注意不要将backlog参数理解为最多有多少个客户端能够和服务器相连接,它的真正含义是:当套接字监听到有客户端的连接请求后,还需要调用accept函数来接受连接,连接才真正被建立。在调用accept函数之前,连接请求将暂时被保存在队列中,如果这时有另一个客户端也发起连接的话,这个连接也将被保留在队列中,backlog参数指的就是这个队列的最大长度。实际上,如果程序能够在足够短的时间内响应进入的连接,那么即使将backlog参数指定为1,仍然不会丢失任何连接请求。
如果listen函数执行成功,将返回0,这时套接字就处于等待连接进入的状态了。执行失败,将返回SOCKET_ERROR,有一种可能是还没有进行bind操作就去listen,这时的出错代码是WSAEINVAL。
当有客户端向监听中的TCP套接字发起连接后,必须对监听的套接字调用accept函数以后,连接才最后被确定:
invoke accept,s,lpsockaddr,lpaddrlen
.if eax != INVALID_SOCKETmov hNewSocket, eax
.endif
参数s指定监听中的套接字句柄,lpsockaddr指向一个缓冲区,函数会在这里返回一个sockaddr_in结构,结构中存放有连接请求方的IP地址和端口,lpaddrlen则指向一个双字变量,函数在这里放入返回到缓冲区中的结构长度。如果不需要得到对方的地址信息,可以将lpsockaddr和lpaddrlen参数都设置为NULL。
如果执行成功,函数将新建一个TCP套接字并返回它的句柄,这个新套接字才是和客户端相连接的,程序可以使用它来和客户端之间收发数据,原来在监听状态的套接字仍然保持监听状态,以便接受下一个连接的进入,如果函数执行失败,将返回INVALID_SOCKET。
在这里读者一定要分清监听套接字和新套接字之间的区别。假如用于监听的套接字是#1,那么前面的bind,listen和accept等函数都是对#1操作的,当accept函数返回套接字#2后,#2才是和客户端相连的,所以为了和客户端进行通信而使用的send和recv等函数都是针对#2的。如果连接被客户端断开或者服务器主动断开连接,那么需要对#2调用closesocket。只有服务器端程序想不再继续监听的时候,才需要对#1调用closesocket函数。
由于在没有客户端发起连接请求的时候,accept函数不会返回,所以监听和接收连接的循环一般是这样写的:
invoke listen,hListenSocket,5
.while TRUEinvoke accept,hListenSocket,NULL,0.break .if eax == INVALID_SOCKET;在这里创建一个新线程对新的套接字进行通信,以便马上能处理新连接...
.endw
在循环中,accept函数成功返回时就意味这一个新的连接产生,但是在循环中直接对新连接进行数据收发是不恰当的,这样就没法马上回到accept函数处,其他客户端的连接请求就无法被处理了,所以一般创建一个新的线程来负责和新连接的通信工作,而循环马上返回到accept处等待新的连接。新的套接字句柄可以通过lParam参数传递给线程过程。
如果套接字不需要再进行监听了,可以在另一个线程中用closesocket函数将它关闭,这样,accept函数会马上退出并返回INVALID_SOCKET,程序就可以退出循环。
在使用accept函数的时候,还有一个简单的技巧:用第二个参数得到包含客户端IP地址和端口的sockaddr_in结构后,如果程序希望对客户端IP地址进行认证,那就可以在accept后马上进行判断,如果检测到IP地址不合法,则立刻使用closesocket函数将accept函数产生的新套接字关闭,这样连接马上会被断开。
16.2.5 数据的收发
对于TCP连接来说,一旦连接已经建立(对客户端来说就是connect返回成功,对服务器端来说是accept函数返回了新连接的套接字句柄),那么连接的双方实际上是对等的,因为TCP连接是一个全双工的连接,任何一方都可以在任何时刻向对方发送数据。
1.使用TCP套接字收发数据
在使用TCP连接向对方发送数据包时,一般使用send函数:
invoke send,s,lpbuf,len,flags
s参数指定套接字句柄,lpbuf指向包含要发送数据的缓冲区,len参数指定发送的数据长度,flags参数指定选项,这个参数一般指定为0。
当函数返回时,如果发送失败,则返回值是SOCKET_ERROR,否则返回值是函数发送成功的字节数(WinSock接口为每个套接字分配一个发送缓冲区和一个接收缓冲区,用send函数发送数据时,数据并没有马上在网络上进行传递,而是首先放到WinSock接口模块的发送缓冲区中,接口会在合适的时候将数据发送出去。所以前面的“成功发送”指的是成功地放入发送缓冲区而已)。
函数在阻塞模式和非阻塞模式下的表现有些不同,下面以用send函数发送n字节的数据,而当前发送缓冲区的空闲空间是m字节的情况为例说明。
在阻塞模式下,当发送缓冲区足够空,也就是n≤m的情况下,这时函数会将数据全部放入发送缓冲区,然后马上返回;而缓冲区不够大,也就是n>m≥0的时候,函数会一直等待,直到全部数据放入缓冲区为止。在两种情况下,返回值都是发送的实际字节数n。
而在非阻塞模式下,当发送缓冲区足够空,也就是n≤m的情况下,这时函数也会将数据全部放入发送缓冲区,然后马上返回,这时返回值就是发送的实际字节数n;当缓冲区不够大,也就是n>m>0的时候,函数不会等待,而是直接将m字节的数据放入缓冲区后马上返回,这时返回值是发送的实际字节数m;当发送缓冲区满,也就是m=0的时候,函数也会马上返回,这时返回值是SOCKET_ERROR,并且如果用WSAGetLastError获取出错代码的话会得到WSAEWOULDBLOCK。
所以在函数发送成功(也就是返回值不是SOCKET_ERROR)时,阻塞模式下函数肯定是已经将全部n字节数据发送成功并返回n,但是期间可能会有个等待的过程。而在非阻塞模式下,函数肯定会马上返回,但函数实际发送的字节数可能会在1到n之间,不一定等于程序要求发送的字节数,所以程序以后必须对没有发送的部分进行重发。
而函数返回SOCKET_ERROR时,阻塞模式下肯定意味着连接已经因为各种情况而断开,而在非阻塞模式下,要继续用WSAGetLastError获取出错代码,如果得到的出错代码是WSAEWOULDBLOCK意味着缓冲区满(这时实际发送0字节),否则表示连接已经断开了。
在使用TCP连接接收数据包时,一般使用recv函数:
invoke recv,s,lpbuf,len,flags
s参数指定读取的套接字句柄,lpbuf指向一个用来返回数据的缓冲区,len参数指定缓冲区的大小,flags参数用来指定读取时的选项,它可以是MSG_PEEK和MSG_OOB的组合,MSG_PEEK表示返回数据后并不从缓冲区中清除数据。
当函数返回时,如果接收失败,则返回值是SOCKET_ERROR,否则返回值是函数实际接收的字节数(WinSock接口收到数据时,首先会将数据放入接收缓冲区中,用recv函数接收数据实际上是从缓冲区中取数据)。
同样,函数在阻塞模式和非阻塞模式下的表现有些不同,下面以用recv函数接收n字节的数据,而当前接收缓冲区中有m字节数据的情况为例说明。
在阻塞模式下,如果接收缓冲区为空,即m=0的情况下,则函数等待直到有数据到达为止;如果缓冲区中已经有 m 字节的数据(或者缓冲区为空时,函数等待,然后有 m字节的数据到达后),如果m≥n,则函数从缓冲区中取n字节的数据并返回;如果m<n,那么函数只取m字节的数据并马上返回。函数的返回值是实际接收到的字节数,这个值会在1到n之间。也就是说在不超过n字节的前提下,有多少数据到达函数即返回多少数据。
在非阻塞模式下,当接收缓冲区中已经有数据的情况下,recv的表现方式和阻塞模式相同,即函数会马上返回,并视缓冲区中数据的数量返回1到n字节之间的数据;但是在缓冲区为空,即m=0的情况下,函数不会等待,而是马上返回SOCKET_ERROR,这时得到的出错代码会是WSAEWOULDBLOCK。
所以在函数成功返回(也就是返回值不是SOCKET_ERROR)时,两种模式下函数都不一定得到请求接收的字节数,实际接收的字节数可能会在1到n之间。如果要收满一定数量的数据才能进行下一步操作的话,程序必须循环接收直到接收的总数据量达到指定值为止。
而函数返回SOCKET_ERROR时,阻塞模式下肯定意味着连接已经因为各种情况而断开,而在非阻塞模式下,要继续用WSAGetLastError获取出错代码,如果得到的出错代码是WSAEWOULDBLOCK意味着缓冲区空(这时实际接收0字节),否则表示连接已经断开了。
读者必须仔细体会两种模式下send和recv函数的不同表现,因为在具体的应用中,函数的不同表现足以影响程序的架构设计。
2.使用UDP套接字收发数据
UDP协议不是面向连接的,当UDP套接字被创建以后,就可以直接通过它向服务器端发送数据了,由于前面介绍的send函数没有目标地址参数,所以一般不用它来发送UDP数据报,取而代之的是sendto函数:
invoke sendto,s,lpbuf,len,flags,lpto,tolen
参数s指定用来发送数据的套接字句柄,参数lpbuf指向包含发送数据的缓冲区,参数len指定要发送的数据的长度,参数flags一般指定为0,参数lpto指向一个包含目标地址和端口号的sockaddr_in结构,参数tolen指定了这个结构的大小。
TCP套接字在连接成功后,就只能向连接的对方发送数据,不可能途中再换一个IP地址和端口重新进行连接,而UDP套接字不是面向连接的,使用同一个UDP套接字,每次调用sendto函数的时候指定不同的服务器端IP地址和端口,就可以向不同的服务器发送数据。
UDP数据包有最大尺寸SO_MAX_MSG_SIZE的限制,如果发送的数据包小于该尺寸,并且发送成功,那么函数将返回实际发送数据的字节数;如果数据包大小超过该尺寸,函数将返回失败,这时没有任何数据被发送,并且出错代码是WSAEMSGSIZE。
sendto函数在阻塞模式和非阻塞模式下也有所不同,阻塞模式下如果没有足够的发送缓冲区,函数将等待到缓冲区空出足够的大小为止;而非阻塞模式下,函数会马上返回SOCKET_ERROR,这时得到的出错代码会是WSAEWOULDBLOCK。由于UDP协议是面向消息的,数据包不会被割裂发送,所以不管哪种情况下,函数不会只发送部分数据。
用sendto发送UDP数据包时,发送方使用哪个端口呢?实际上,对一个UDP套接字首次调用sendto函数时,系统会自动分配一个空闲的端口,在这后面再调用sendto函数,系统将一直使用分配的这个端口。所以对于UDP套接字来说,如果在对它调用sendto函数之前没有用bind函数绑定到一个固定的IP地址或端口上,意味着这时它还没有被系统自动指派某个端口,就无法使用它来接收数据(还没有地址和端口如何接收数据呢?),直到第一次对它使用sendto函数来发送数据以后,系统才自动将它绑定到某个空闲的端口上,这时套接字才可用来接收数据。
接收UDP数据包一般使用recvfrom函数:
invoke recvfrom,s,lpbuf,len,flags,lpfrom,lpfromlen
参数s指定套接字句柄,参数lpbuf指向一个用来接收数据的缓冲区,参数len指定缓冲区的大小。参数flags为标志。这个函数不同于recv函数的是多出来的最后两个参数,参数lpfrom指向一个缓冲区,函数在这里返回一个包含数据发送方地址的sockaddr_in结构,参数lpfromlen指向一个双字变量,函数在这里返回前面的sockaddr_in结构的长度。
同样,由于UDP套接字不是面向连接的,所以使用同一个UDP套接字可以接收从任何客户端发送过来的UDP数据包,只要对方指定了正确的IP地址和端口。
程序可以从sockaddr_in结构中得到发送方的IP地址和端口,如果需要向发送方回复数据的话,可以根据这个地址和端口来回复。
UDP协议是面向消息的,当使用recvfrom函数接收时,如果指定的缓冲区尺寸小于接收缓冲区中UDP包的尺寸,那么缓冲区中将收到部分数据,多余的部分会丢失,这时函数将返回SOCKET_ERROR,具体的出错代码是WSAEMSGSIZE。如果缓冲区大于要接收的UDP包的尺寸,系统仅仅返回要接收的UDP数据包,而不会将后面到达的数据包的内容一并返回。这时函数的返回值是实际接收的数据大小。
函数在阻塞模式和非阻塞模式下也有所不同,阻塞模式下接收缓冲区如果没有数据到达,函数将等待到有数据包到达为止;而非阻塞模式下,函数会马上返回SOCKET_ERROR,这时得到的出错代码会是WSAEWOULDBLOCK。
虽然建议用send/recv函数进行TCP套接字的数据收发,用sendto/recvfrom进行UDP套接字的数据收发,但这并不是绝对的,读者也可以用sendto和recvfrom函数去操作TCP套接字,这时两个函数的表现方式和send/recv函数是相同的,唯一的区别是系统会忽略参数中指定的sockaddr_in结构,因为TCP连接的对方地址已经是固定的了。
反过来,UDP套接字也可以用send/recv函数进行收发,但是这时怎么知道要发到哪个服务器的哪个端口去呢?答案是:读者可以首先用connect函数将UDP套接字虚拟地“连接”到服务器的IP地址和端口上,当然这时系统不会真正地在物理层面进行连接,而是会将connect参数中的地址信息保存下来并和UDP套接字关联起来,这样,后面使用send/recv函数的时候,就可以使用关联的地址和端口,这种用法的坏处是无法用同一个UDP套接字向多个服务器发送数据了,要将关联的地址更换,唯一的办法是将套接字关闭并重新创建一个。
16.2.6 一个最简单的TCP服务端程序
好了,看了这么多WinSock函数的介绍,读者一定对写一个网络应用程序有了大致的了解,下面我们将用一个TCP服务器端程序的例子来巩固一下前面这些函数的用法,在例子中还会介绍其他一些函数的用法,如select函数等。
这是一个完成Echo功能的TCP服务器端程序,程序将在9999号端口上进行监听,读者可以用任何工具连接到这个端口(比如,用telnet,在本机上进行测试时,只要在命令行下运行telnet 127.0.0.1 9999即可),当客户端向例子服务器端程序发送任何数据时,服务器端程序会将收到的数据原封不动地发送回来,就好像数据被“Echo”回来。
当然,这个服务器端程序不会只为一个客户端程序服务,读者也可以打开多个客户端程序连接到服务器端,服务器端会很好地同时为这些客户端工作,并在界面上显示出当前究竟有多少个客户端在线。
例子程序的代码可以在光盘的Chapter16\TcpEcho目录中找到,其中包含一个资源文件和汇编源代码文件,资源文件中简单地定义了一个作为主界面的对话框,对话框中只有一个用于显示在线的客户端数量的文本框,在这里就不具体列出了,汇编源代码如下:
; TcpEcho.asm
; 网络服务端程序例子 —— 将收到的字符发回给客户端
; -------------------------------------------------------------------
; 使用 nmake 或下列命令进行编译和链接:
; ml /c /coff TcpEcho.asm
; rc TcpEcho.rc
; Link /subsystem:windows TcpEcho.obj TcpEcho.res
.386
.model flat,stdcall
option casemap:none ; include 数据
include c:/masm32/include/windows.inc
include c:/masm32/include/user32.inc
include c:/masm32/include/kernel32.inc
include c:/masm32/include/wsock32.inc
includelib c:/masm32/lib/user32.lib
includelib c:/masm32/lib/kernel32.lib
includelib c:/masm32/lib/wsock32.lib ; equ 数据
ICO_MAIN equ 1000
DLG_MAIN equ 2000
IDC_COUNT equ 2001
TCP_PORT equ 9999; 数据段
.data?
hInstance dword ?
hWinMain dword ?
hListenSocket dword ?
dwThreadCounter dword ?
dwFlag dword ?
F_STOP equ 0001h
.const
szErrBind byte '无法绑定到TCP端口9999,请检查是否有其它程序在使用!',0; 代码段
.code
; 通讯服务线程:每个客户端登录的连接将产生一个线程
_ServiceThread proc _hSocket local @stFdSet:fd_set, @stTimeval:timeval local @szBuffer[512]:byte inc dwThreadCounter invoke SetDlgItemInt, hWinMain, IDC_COUNT, dwThreadCounter, FALSE .while !(dwFlag & F_STOP)mov @stFdSet.fd_count, 1 push _hSocket pop @stFdSet.fd_array mov @stTimeval.tv_usec, 200*1000 ;200ms mov @stTimeval.tv_sec, 0invoke select, 0, addr @stFdSet, NULL, NULL, addr @stTimeval .break .if eax == SOCKET_ERROR .if eax invoke recv, _hSocket, addr @szBuffer, sizeof @szBuffer, 0 .break .if eax == SOCKET_ERROR .break .if !eax invoke send, _hSocket, addr @szBuffer, eax, 0 .break .if eax == SOCKET_ERROR .endif .endw invoke closesocket, _hSocket dec dwThreadCounter invoke SetDlgItemInt, hWinMain, IDC_COUNT, dwThreadCounter, FALSE ret
_ServiceThread endp ; 监听线程
_ListenThread proc _lParam local @stSin:sockaddr_in ;创建 socketinvoke socket, AF_INET, SOCK_STREAM, 0mov hListenSocket, eax invoke RtlZeroMemory, addr @stSin, sizeof @stSin invoke htons, TCP_PORT mov @stSin.sin_port, ax mov @stSin.sin_family, AF_INET mov @stSin.sin_addr, INADDR_ANY invoke bind, hListenSocket, addr @stSin, sizeof @stSin .if eax invoke MessageBox, hWinMain, addr szErrBind, \NULL, MB_OK or MB_ICONSTOP invoke ExitProcess, NULL .endif ;开始监听,等待连接进入并为每个连接创建一个线程invoke listen, hListenSocket, 5 .while TRUE invoke accept, hListenSocket, NULL, 0 .break .if eax == INVALID_SOCKET push ecx invoke CreateThread, NULL, 0, offset _ServiceThread, eax, NULL, esp pop ecx invoke CloseHandle, eax .endw invoke closesocket, hListenSocket ret
_ListenThread endp ; 主窗口程序
_ProcDlgMain proc uses ebx edi esi hWnd, wMsg, wParam, lParam local @stWsa:WSADATA mov eax, wMsg .if eax == WM_INITDIALOG push hWnd pop hWinMain invoke LoadIcon, hInstance, ICO_MAIN invoke SendMessage, hWnd, WM_SETICON, ICON_BIG, eax invoke WSAStartup, 101h, addr @stWsa push ecx invoke CreateThread, NULL, 0, offset _ListenThread, 0, NULL, esp pop ecx invoke CloseHandle, eax .elseif eax == WM_CLOSE invoke closesocket, hListenSocket or dwFlag, F_STOP .while dwThreadCounter .endw invoke WSACleanup invoke EndDialog, hWinMain, NULL .else mov eax, FALSE ret .endif mov eax, TRUE ret
_ProcDlgMain endp ; main函数
main proc invoke GetModuleHandle, NULL mov hInstance, eax invoke DialogBoxParam, eax, DLG_MAIN, NULL, offset _ProcDlgMain, 0 invoke ExitProcess, 0
main endp
end main
运行效果:
程序的工作流程大致如下:
首先,程序创建了一个对话框作为主界面,在对话框的WM_INITDIALOG消息中,调用WSAStartup加载WinSock库,然后创建一个线程来运行负责监听和接收连接的循环。
在调用CreateThread函数的时候,程序使用了一个小技巧,由于CreateThread函数的最后一个参数是lpThreadId,得到的ThreadId没有继续使用的价值,所以没有必要专门为它定义一个变量。程序用push ecx指令临时在堆栈中分配一个dword空间供其使用,调用时使用esp将该临时空间的地址传给函数即可,然后在函数返回的时候直接用一个pop ecx操作释放堆栈空间(这个用法和子程序在堆栈中为局部变量保留空间的用法很像,不是吗?)。
监听线程的线程过程是_ListenThread,程序在这里首先用socket函数创建一个TCP套接字,并将套接字绑定在9999端口上,后面的流程就是listen和调用accept的循环了。
每次有客户端发起连接时,accept函数返回一个新的套接字,这个套接字就是与客户端连接的套接字,程序将创建一个新的线程_ServiceThread来负责与客户端进行通信。所以,每次有新的连接进入,服务器端程序的进程中就会多一个线程,而关闭一个连接后,就会少一个线程,这一点读者可以从任务管理器中得到验证。accept产生的新套接字句柄通过线程过程的参数传递给_ServiceThread线程。
在_ServiceThread的开始和最后,程序在对话框中显示当前的在线客户端数量,在子程序的中间,是一个select函数→recv函数→send函数的循环,让我们先忽略掉select函数,来看看recv和send函数吧。
套接字创建的时候,默认是工作在阻塞模式下,程序中没有将它设置成非阻塞模式,所以请读者努力回忆一下,recv和send函数在阻塞模式下是怎么工作的。
想起来了吗?在阻塞模式下,recv函数在接收缓冲区中没有数据的时候会等待,直到有数据到达为止,虽然程序在参数中指定接收sizeof @szBuffer个字节,但是函数不一定等到收满sizeof @szBuffer字节后才返回,接收到的数据数量和客户端发送过来的数据量有关(会从1到sizeof @szBuffer之间),具体要以返回值为准。
然后就是用send函数把收到的数据发送回去,以完成Echo的功能,注意要发送的字节数就是前面recv函数的返回值,由于阻塞模式下send函数返回时肯定是将请求发送的数量全部发送完毕,所以在这个例子中程序不需要做进一步处理。但是读者应该知道,要是在非阻塞模式下完成同样的send操作,程序必须判断返回值,当返回值小于请求发送的字节数时,需要循环将剩余的字节全部发送出去。
循环中对recv和send函数的返回值进行了出错判断,一旦函数返回SOCKET_ERROR,表示连接已经中断,程序将退出循环,然后关闭套接字,线程终止。
好了,现在我们来看看select函数是做什么用的,读者请首先设想一下,程序中要是把select函数去掉,仅仅剩下recv和send函数的循环会怎样?那样的话,如果客户端没有发送数据的时候,程序就会等待在recv函数中,这时按下服务器端对话框中的关闭按钮的话,线程将无法检测到退出标志(dwFlag设置为F_STOP)并退出。如果非要退出的话,在对话框的WM_CLOSE消息中就必须关闭每个线程中的套接字句柄,这样recv函数才会出错退出,但这样的话程序要维护一个在线连接的列表,会复杂很多。
反过来,如果先用某种方法检测是否有数据到达,有的话才去recv,那么就不会有上述情况发生了,select函数就是这样用的,这个函数可以用来检测套接字的各种情况,以便程序根据检测结果做下一步操作。
select函数可以同时检测多个套接字,可以检测套接字是否可读、是否可写,以及是否有异常发生:
invoke select,nfds,lpreadfds,lpwritefds,lpexceptfds,lptimeout
参数nfds是为了和BSD UNIX Socket的兼容而设置的,WinSock接口下函数将这个参数忽略,lpreadfds,lpwritefds和lpexceptfds分别指向不同的fd_set结构,用来指定需要检测的套接字句柄。fd_set结构的定义如下:
fd_set STRUCTfd_count DWORD ? ;fd_array中存放的套接字句柄数量fd_array DWORD FD_SETSIZE dup(?) ;套接字句柄列表
fd_set ENDS
假如要检测#1和#2套接字是否可读(也就是接收缓冲区中已经有数据到达),那么可以在lpreadfds参数指向的fd_set结构的fd_array中放入它们的句柄并将fd_count设置为2。如果同时要检测#1、#3和#4套接字是否可写(也就是发送缓冲区是否有空),那么可以在lpwritefds指向的fd_set结构中的fd_array中放入它们的句柄并将fd_count设置为3。同样,lpexceptfds指向的fd_set结构存放的是要检测出错(如检测连接是否断掉)的套接字句柄列表。如果没有某种操作需要检测,那么可以将相应的指针设置为NULL。
当函数返回的时候,函数会将这些fd_set结构中就绪的套接字句柄保留,而将没有就绪的套接字句柄清零,这时扫描结构中的fd_array列表并对还存在的套接字句柄进行相应的操作即可。
select函数的lptimeout参数指向一个timeval结构,用来指定检测的超时时间,该结构定义如下:
timeval STRUCTtv_sec DWORD ? ;秒tv_usec DWORD ? ;微秒,注意不是毫秒!
timeval ENDS
lptimeout参数的用法有以下几种:
● 如果lptimeout参数为NULL,那么函数将永远等待下去,直到列表中有某个套接字就绪时才返回。
● 如果lptimeout指向了一个timeval结构,而且结构中的时间定义为0,那么不管有没有套接字就绪函数都会马上返回。
● 如果lptimeout指向了一个timeval结构,而且结构中的时间定义不为0,如果经过了timeval指定的时间后还没有套接字就绪,那么函数超时返回;如果在指定的时间有套接字就绪,那么函数马上返回。
当select函数因为超时而返回时,返回值是0;因为出错而返回时,返回值是SOCKET_ERROR;如果因为某个套接字就绪而返回,返回值是就绪套接字的数量。
在例子中,程序每隔200ms对套接字进行检测,由于要检测的套接字数量为1,所以返回值大于0的时候,肯定就是这个套接字有数据可以接收了,程序马上进行recv和send操作。如果返回值是0,表示过了200ms还没有数据可以接收,这时程序跳过recv和send操作,回到循环的开始去检测循环条件,当检测到退出标志被设置时,循环结束,套接字被关闭,然后线程终止。
当按下了对话框中的关闭按钮时,程序在WM_CLOSE消息中将监听的套接字句柄关闭,这样,监听线程中的accept函数会失败返回,监听线程终止;接下来程序设置退出标志,如果有客户端在线的话,对应的工作线程会检测到退出标志并终止,等所有工作线程终止后(检测线程计数为0),程序用WSACleanup释放WinSock库并退出。