16.1 网络基础知识
Windows的网络通信建立在TCP/IP协议的基础上,在开始Windows的网络编程之前,应该首先了解TCP/IP协议的相关知识并且了解其中重要协议的工作方式,这样对正确理解WinSock程序的结构大有好处。在这一节中,将对一些网络基础知识进行一次快速的复习,有经验的读者可以直接跳过这一节。
16.1.1
TCP/IP协议
TCP/IP是Transmission Control Protocol/Internet Protocal(传输控制协议/国际协议)的缩写,它最初是在20世纪70年代初期由美国国防部出资为美国高级研究项目局(简称ARPA)开发的,经过了多年以后,以TCP/IP协议为基础构建的ARPA网已经演变成了今天的Internet。
1. TCP/IP模型的结构
在网络方面,最著名的结构模型就是开放系统互联模型(OSI模型),如图16.1左图所示,OSI模型的体系结构分为7层,其中应用程序所在的最高层通过多个层次最终调用网络硬件所在的物理层。OSI模型的分层结构有利于各层次的分工协作,只要每个层次都严格遵守边界协定,那么它对于其他层次来说就可以看成是一个“黑匣子”,结果就是开发人员能够致力于本层次的开发和提高,而不必担心能否和其他层次合作。
TCP/IP协议采用和OSI模型类似的概念性模型,但是将层次的划分减少到了4层。TCP/IP协议的每一层在功能上和OSI模型的一层或多层相对应。如图16.1的中间所示,TCP/IP模型分为应用层、传输层、Internet层和网络访问层。
TCP/IP模型的最底层是网络访问层(也叫网络接口层),它负责向网络的传输介质发送数据包以及从其中接收数据包。TCP/IP协议被设计为可以在不同类型的网络上传输,它并不依赖于某种特定的网络硬件,也不依赖于某种特定的数据帧格式和传输介质,网络访问层的作用是将上层的协议和网络硬件隔离开来,它通过不同的驱动程序来支持各种类型的网络硬件和组网方式,包括Ethernet(以太网),Token Ring(令牌网),Frame Relay(帧中继)和ATM等。
图16.1
TCP/IP协议组模型的结构
Internet层位于网络访问层的上层,它主要负责数据包的寻址、包装和路由功能,运行于这一层上的协议主要有:
● IP协议(网际协议)——是一个路由协议,负责IP寻址、数据包的分裂和重组任务。
● ARP协议(地址解析协议)——负责网络接口层地址和Internet层地址之间的解析,比如将一台主机的IP地址解析成其网卡的MAC地址。
● ICMP协议(网际控制消息协议)——提供诊断功能,用于报告IP数据包传送中的错误信息,Ping程序就是用ICMP协议检测某台主机的典型例子。
● IGMP协议(Internet组管理协议)——负责IP多点传送组的管理。
传输层位于Internet层的上层,主要负责提供对话和信息包通信服务。传输层的核心协议是TCP协议和UDP协议:
● TCP协议——提供一对一有连接导向的通信服务,提供连接的确认,数据包发送/接收顺序的控制,及出错重传等机制,以保证数据在传输中的正确性。
● UDP协议(用户数据报协议)——提供一对一或者一对多的通信服务,它不提供连接确定,数据包顺序控制,及出错重传等机制,所以传输的数据有可能是不可靠的。
应用层是TCP/IP模型的最上层,它为应用程序访问其他层次提供服务,应用层中定义了很多利用下层协议来交换数据的应用型协议,比如下面列出的协议最终是利用下层的TCP协议来交换数据的:
● HTTP(超文本传输协议)——用来传送Web网页文件。
● FTP协议(文件传输协议)——用于交互式文件传送。
● SMTP协议(简单邮件传输协议)——用于邮件的发送。
● POP3协议(邮局协议)——用于接收邮件。
● Telnet协议(远程登录协议)——用于终端登录操作。
而其他一些协议则建立于UDP协议的基础上,如:
● DNS(域名系统)——用于将域名解析成IP地址。
● RIP协议(路由信息协议)——用于供路由器在IP网络上交换路由信息。
● SNMP协议(简单网络管理协议)——用于网络管理控制台和网络设备(路由器、智能集线器等)之间的通信,或者用来收集和交换网络管理信息。
应用程序可以遵循这些协议定义的规范与其他程序进行数据交换。另外,一些接口也在应用层上为应用程序提供服务,如本章中要介绍的WinSock接口是一个标准的用于Windows网络编程的应用程序接口,WinSock接口由Microsoft提供;而NetBIOS是一个工业标准,它提供诸如对话、数据包交换和名字解析等服务,这些接口都是通过调用传输层或者Internet层协议来完成的。
2. 常用协议和RFC文档
TCP/IP的技术标准是公开的,正是这样才使Internet发展成全世界最大的网络,但技术上的公开性并不意味着技术标准可以由任何人随意规定,事实上,Internet的技术标准是由4个组织来负责维护的,它们是:
● Internet协会(ISOC,Internet SOCiety)
● Internet体系结构委员会(IAB,Internet Architecture Board)
● Internet工程专门小组(IETF,Internet Engineering Task Force)
● Internet研究专门小组(IRIF,Internet Research Task Force)
所有由这些组织公布的技术标准都是以RFC(Request for Comment)文档的形式出版的,表16.1列出了一些RFC文档的编号,它们定义了一些常见的协议。
表16.1
一些常用协议的RFC文档编号
协议缩写
协议名称
对应的RFC文档
UDP
User Datagram Protocol(用户数据报协议)
RFC 768
IP
Internet Protocol(网际协议)
RFC 791
TCP
Transmission Control Protocol(传输控制协议)
RFC 793
SMTP
Simple Mail Transfer Protocol(简单邮件传输协议)
RFC 821
ARP
Ethernet Address Resolution Protocol(以太网地址解析协议)
RFC 826
Telnet
Telnet Protocol(远程登录协议)
RFC 854,RFC 855
RARP
Reverse Address Resolution Protocol(逆地址解析协议)
RFC 903
FTP
File Transfer Protocol(文件传输协议)
RFC 959
DNS
Domain Name System(域名系统)
RFC 1034,RFC 1035
SNMP
Simple Network Management Protocol(简单网络管理协议)
RFC 1157
DHCP
Dynamic Host Configuration Protocol(动态主机配置协议)
RFC 1541
PPP
The Point-to-Point Protocol(点对点协议)
RFC 1661
POP3
Post Office Protocol V3(邮局协议V3)
RFC 1939
HTTP1.0
Hypertext Transfer Protocol 1.0(超文本传输协议1.0版本)
RFC 1945
IGMP
Internet Group Management Protocol(Internet组管理协议)
RFC 2236
HTTP 1.1
Hypertext Transfer Protocol 1.1(超文本传输协议1.1版本)
RFC 2616,RFC 2617
RFC文档的原文是英文版的,可以很方便地从互联网中获取它们,获得任意一份RFC或者已发布的所有RFC的最简单的方法就是访问ISOC提供的站点:
http://www.
如果希望查阅中文版的RFC文档,可以访问China-Pub网站的中文RFC文档翻译计划,网址是:
http://www.
但是RFC文档中包含的仅是枯燥的技术规范及说明,并没有实现的具体步骤及方法示范。如果希望详细了解这些协议的方方面面,最权威的书籍莫过于W.Richard Stevens的遗作:3卷中译本的《TCP/IP详解》,它们是:
● 卷1:协议——TCP/IP协议指南,描述了属于每一层的各个协议以及它们如何在不同操作系统中运行。
● 卷2:实现——以约500个图例、15 000行实际操作的C代码来介绍TCP/IP协议是如何实现的。
● 卷3:TCP事务协议、HTTP、NNTP和UNIX域协议——主要内容包括TCP协议的扩展应用。
这3本书是网络编程资料中当之无愧的经典之作,惟一的遗憾是书的内容是以UNIX操作系统为背景的,但由于书中的例子代码是以标准C写的,所以对Windows的网络编程同样有很大的参考价值。
3. 数据包的封装
当应用层上的程序与其他计算机进行通信时,它所发送的数据包在TCP/IP模型的各个层次中逐渐下降,最后到达位于最底层的网络访问层中,网络访问层通过网络介质将数据包传送到目标主机后,数据包再逐渐上升到位于应用层的应用程序中,这个过程和数据在堆栈中的传送方式很相似,所以一般也把TCP/IP模型称为TCP/IP栈。
各种协议在实现的时候都需要互相交换信息,如IP协议需要交换地址信息,TCP协议需要交换控制信息,所以,不同的协议都会对要处理的数据进行适当的封装。
以通过HTTP协议访问一个网站为例,如图16.2所示,应用程序在用户数据的前面加上HTTP首部,组成一个数据包后准备通过TCP栈发送到目标网站,当数据包到达传输层的时候,TCP协议需要为数据包加上源端口和目标端口、数据包序号和应答字段等内容,以便实现连接应答与出错重传等TCP协议中的各种特征,这些数据当做TCP首部被加在原来数据包的前面,由此形成一个新的数据包后再交给Internet层的IP协议,这个被封装后的数据包就被称为TCP报文段(TCP Segment)。
图16.2
数据包的封装
IP协议负责寻址,它需要为TCP段加上目标IP地址,为了能让对方知道数据包是从哪里发过来的,也需要加上自己的IP地址,为了让目标主机的Internet层在处理数据包后能知道往传输层的哪个协议中送,还需要标出数据包是由传输层的哪个协议送下来的,这些数据形成一个IP首部后被附加在TCP报文段的前面,形成一个新的被称为IP数据报(IP Datagram)的新数据包。
现在IP数据报到了网络访问层的网卡驱动程序中,在这里驱动程序根据目标IP地址查出目标设备的MAC地址,在数据报的头部加上源MAC地址和目标MAC地址,同时为数据报加上一个尾部,所有这些数据形成一个数据帧(Frame)后被发送到网络介质上。
当数据帧到达目标主机后,网络访问层将以太网首部和尾部数据去除以恢复成IP数据报,并将IP数据报传递到上层的IP协议中;IP协议分析IP首部数据,并根据IP首部中的传输层协议类型将恢复的TCP报文段交到上层的TCP协议中;TCP协议再根据TCP首部中的数据判断数据包的序号,检测有无丢包,并根据情况决定是否要求发送方重传,当完成这些纠错任务后,将正确的数据交到应用层的应用程序中;这时的数据包中包含HTTP首部和用户数据,应用程序最后处理HTTP首部并得到正确的原始数据,一次数据包的传输就完成了。
当使用不同的协议时,各种协议将为数据包加上自己的首部,这些协议首部的数据定义各不相同,长度也各不相同,比如TCP首部的长度为20个字节,而UDP首部的长度只有8个字节。
正是由于每个层次的协议都对数据包进行封装,所以,在TCP/IP模型的不同层次进行编程的时候,数据的处理方法可能是完全不同的,比如实现一个Ping功能,如果在应用层上调用Icmp.dll中的相关函数,那么寥寥几句就可以完成,但是通过WinSock接口在Internet层上实现,那就要自己去处理ICMP协议首部和IP协议首部了。
16.1.2
一些重要概念
1. IP地址和子网掩码
要在计算机之间使用TCP/IP协议进行通信,这些计算机必须能够互相寻址,TCP/IP协议使用IP地址寻址方式,所以,对于一个IP网络来说,网络上的设备之间要进行通信的话,每个设备必须具有惟一的IP地址,否则将无法正确地进行寻址,同理,要让整个Internet范围内的计算机都能够互相通信,Internet上的所有设备也必须具有惟一的IP地址。
IP地址是一个32位的二进制数,为了用人们熟悉的10进制数表示,通常将它分为4个8位的二进制数,每个8位二进制数被转换成10进制,中间用小数点隔开,就得到了IP地址的10进制字符串格式,图16.3中的IP地址11000000 10101000 00000001 01100100,以10进制方式表示就是192.168.1.100。
图16.3
IP地址的两种表示格式
从理论上说,网络中的计算机之间要进行通信,只要有双方的IP地址就可以了,但是由于网络结构的关系,在实际使用中还需要将IP地址划分成网络号和结点号两部分。这是因为对一个小网络来说,所有的计算机都挂在同样的传输介质上,如果计算机A向计算机B发送IP数据报,A计算机的网卡将向传输介质发送一个封装有这个IP数据报的数据帧,数据帧头部的目标地址是B计算机的网卡MAC地址,实际上,这时网络上的任一台计算机都可以“听”到这个数据帧,只不过只有B计算机发现数据帧的目标MAC地址和自己符合而进行处理罢了。
如果让整个Internet的通信都以这种方式寻址,那么每台计算机发送的数据帧就要被传遍全世界才能保证被目标计算机“听”到,这显然是不现实的,所以一般来说要将整个网络划分成一个个小网络(子网),仅同一个子网中的计算机进行通信时才对目标MAC地址进行寻址,不同子网之间的IP数据报通过网关来转发,网关一般由路由器或者有路由功能的计算机来担任。
当计算机A向不属同一子网的计算机B发送数据时,它将以网关的MAC地址当做目标地址发送数据帧,由网关负责选择向其他最合适的子网转发。为了让计算机知道何时通过网关转发,必须能够分辨目标计算机是否在同一个子网上。
如何分辨目标计算机是否属于同一个子网呢,这就是子网掩码的作用。子网掩码用来将IP地址划分成两个部分:网络号和结点号。如果当前主机的IP地址和目标主机的IP地址的网络号相同,那么就说明它们同属一个子网。
假如需要在当前子网保留256个地址(结点),可以将IP地址中的最后8位视为结点号(因为8位二进制位可以表示256个不同的数),将剩下的24位视为网络号。为了划分网络号和结点号,可以使用一个高24位为1的32位二进制数去和IP地址进行and运算,这样得到的结果就是网络号,用这个数取反后和IP地址进行and运算,得到的就是结点号,用来进行and运算的数就是子网掩码。
以192.168.1.100地址为例,如图16.4所示,二进制IP地址11000000 10101000 00000001 01100100和子网掩码11111111 11111111 11111111 00000000进行and运算以后,得到网络号11000000 10101000 00000001,一般习惯在网络号后面用0补足32位并用10进制IP地址的形式来表达,这样就得到了网络号192.168.1.0。IP地址中余下的8位就是结点号。这个掩码用10进制IP地址的方式表示就是255.255.255.0。
图16.4
IP地址和子网掩码例一
再举一个例子,当子网里面的计算机不到16台的时候,可以只用4位来表示结点号。这时可以将掩码的全1数据位扩展到28位,如图16.5所示,前28位为1的子网掩码用IP地址方式表示就是255.255.255.240,因为最后8位11110000等于10进制数240。而网络号用0补足32位以后就是192.168.1.96,因为最后8位01100000等于10进制数96。当然,也可以在其他任何位置划分结点号和网络号,比如用16位来表示结点号的话,那么掩码就是255.255.0.0。
图16.5
IP地址和子网掩码例二
有了子网掩码以后,源计算机将目标计算机的IP地址和自身的子网掩码进行and运算,如果得到的网络号和自身所属的网络号不同的话,就意味着目标计算机不在同一个子网中。这时为数据帧设置目标主机的MAC地址是无法让它“听”到的,于是计算机将数据帧的目标MAC地址设置为网关的MAC地址,这样路由器将接收到这个数据帧,路由器一般用来连接多个子网,它以同样的方法判断目标计算机是否位于其中一个子网上,是的话则向这个子网以目标主机的MAC地址发送数据帧,否则根据路由表的设置将数据帧发送到另一个合适的路由器上,由此传递一直到找到目标主机为止。
2. 端口
曾经有人问过这样一个问题:如果开了两个浏览器窗口去浏览同一个网站的不同页面,那么这两个浏览器窗口的发送方IP地址和接收方的IP地址都是一样的,计算机如何分辨数据是属于哪个浏览器窗口的呢?这就涉及协议复用的问题,如果协议不能复用的话,那么它在同一时刻就只能为一个进程服务。
为此,TCP/IP协议提出了协议端口的概念,协议端口用于标识通信的进程,这样就可以让使用同一个IP地址的不同进程通过不同的端口号对IP地址进行复用,协议端口是在传输层的TCP和UDP协议中实现的,这样就使传输层提供了同时为不同进程提供通信的能力,在其他层次的其他协议中并没有端口的概念。
端口号用一个16位的整数来表示,所以从理论上讲,可以同时有65 536个进程使用同一IP地址进行通信,由于传输层的TCP协议和UDP协议是两个完全独立的模块,两者的工作是互不相干的,所以TCP和UDP各自的端口号也相互独立,一个进程使用TCP协议的某个端口号并不影响另一进程使用UDP协议的同名端口号,但是同一协议的同一端口号无法被两个进程同时使用。
大部分应用层上的协议都定义了自己使用的默认端口号(但这并不意味着必须使用这个端口号),另外,一些服务程序(如SQL Server,Oracle数据库与Windows的终端服务等)也使用固定的端口号。表16.2中列出了一些常用协议和服务程序使用的端口号,详尽的已分配端口号列表可以参考RFC 1700。
表16.2
常用协议和应用程序使用的默认端口号
协议或应用程序
TCP端口号
UDP端口号
FTP
21
Telnet
23
SMTP
25
协议或应用程序
TCP端口号
UDP端口号
HTTP
80
POP3
110
DNS查询
53
TFTP协议
69
NetBIOS名字服务
137
NetBIOS数据包服务
138
SQL Server数据库
139、1433
Oracle数据库
1521
3. 字节顺序
TCP/IP协议是一组开放的协议,它被设计用来在不同的计算机平台之间进行通信,所以在协议的实现细节中不能包括与特定平台相关的东西,凡是与平台相关的都需要转换为规定的格式,其中最主要的就是对字节顺序的处理。
不同CPU对字节顺序的处理方式是不一样的,当数据包在基于这些处理器的平台上传输时,这一点可能会引发一些问题。CPU对字节顺序的处理方式有两种:大尾方式(big Endian)和小尾方式(little Endian)。在大尾方式中,数据的高字节被放置在连续存储区的首位,比如一个32位的16进制数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(字节流按小尾方式排列),而接收方收到后却按照自己的方式理解为0001h端口,那就成问题了。
TCP/IP协议统一规定使用大尾方式传输数据(也称Internet顺序),非常遗憾的是,这与Intel 80x86系列处理器使用的方式不同,所以在80x86平台下的WinSock编程中,当使用IP地址和端口号等参数时,必须首先将它们转换为Internet顺序。
16.2 WinSock接口
16.2.1
WinSock接口简介
Microsft为Win32环境下的网络编程提供了Windows Sockets接口(简称WinSock接口),正如Windows下的各种接口都是以API的形式出现的一样(如GDI),WinSock也是以一组API的方式提供的,从1991年推出1.0版开始,经过不断地完善后,WinSock接口现在已成为Windows下网络编程的标准。
WinSock接口是以BSD UNIX操作系统中流行的Socket接口为范例定义的,它包含了 UNIX Sockets接口中一系列的同名函数,但是Windows系统的运行方式和UNIX系统有显著的不同,为了和Windows系统相适应,WinSock接口在移植这些函数的同时对它们进行了改造,并针对Windows的消息驱动机制定义了一部分新的函数,所以使用WinSock接口的程序和UNIX网络通信程序的结构还是有很多的不同点的。
WinSock接口允许应用程序通过它来利用下层的网络通信协议进行通信。如图16.6所示,通过WinSock接口可以在TCP/IP模型的传输层上利用TCP或UDP协议进行通信,也可以在Internet层上直接使用IP协议,以便处理ICMP等数据包。
图16.6
WinSock接口
但WinSock接口并没有对应用层上的各种协议提供支持,所以应用程序无法通过WinSock接口提供的函数来实现HTTP,FTP与Telnet等协议,WinSock接口也不对绑定到TCP/IP协议上的NetBIOS协议提供支持,应用程序必须用另外的接口或者自己编程来实现这些高层协议。
大部分使用WinSock接口的程序主要是利用TCP协议来进行数据流通信或者利用UDP协议进行数据报通信,但是程序也可以通过WinSock接口直接使用IP协议来实现某些特殊功能,如实现ICMP与IGMP等Internet层上面的协议,或者用来发送非正常的数据包,比如在2000年2月份,雅虎、亚马逊和CNN等著名网站受到D.o.S(Denial of Service,拒绝服务)攻击而陷入瘫痪,D.o.S攻击程序制造了高流量的无用数据或反复发送畸形数据,以此引发服务器程序错误而大量占用系统资源,从而使主机处于假死状态甚至死机,D.o.S攻击程序使用的非正常的数据包就可以通过WinSock接口用IP协议实现。
1. WinSock接口中的套接字
为了使用WinSock接口进行通信,必须首先建立一个用来通信的对象,这个对象就称为套接字(Socket),套接字的定义是“通信的一端”,在通信的另一端必定有另一个套接字与之相对应以互相传递数据。使用WinSock接口进行通信的时候,第一个步骤就是建立一个套接字。
套接字的种类有很多种,最主要的两种是流套接字(stream socket)和数据报套接字(datagram socket)。流套接字使用传输层的TCP协议进行通信,所以它具有TCP协议所拥有的各种特征,比如它是面向连接的、稳定的,及数据包是顺序发送的等;而数据报套接字使用UDP协议进行通信,所以它的特征同样来自于UDP协议,如数据包可能丢失,可能重复,及可能不按顺序到达等。
另外,也存在其他一些不常用的套接字类型,如原始套接字(raw socket)、可靠信息分递套接字(rdm socket)和连续小分包套接字(seqpacket socket)等,其中值得一提的是原始套接字,使用这种套接字可以直接在Internet层上处理IP数据包的首部,所以可以用它来实现各种特殊的功能,如伪造发送者地址等。
一个套接字必须能与使用它的进程一一对应,所以对于流套接字和数据报套接字来说,IP地址和端口号是两个必不可少的参数,而对于原始套接字来说,由于它是在Internet层上工作的,在这里还没有开始使用端口号进行复用,所以它的有效参数只有IP地址。
有一部分介绍WinSock的书籍中讲到:“套接字的种类有两种——流套接字和数据报套接字”,这种说法是不确切的,因为正如上面介绍的,实际上还存在其他类型的套接字。
2. 套接字的工作模式
WinSock套接字的使用分为两种模式:阻塞模式和非阻塞模式。阻塞模式也称为同步模式,在这种模式下,WinSock函数直到全部操作完成后才返回。比如要接收数据包时,必须等到对方将数据包发送过来为止,调用WinSock函数的线程在这期间是被挂起的,所以程序看起来好像是停止响应了。显然,要以这种方式执行WinSock函数的话,几乎每个函数的使用都会违反“1/10秒规则”,所以必须考虑在不同的线程中执行每个WinSock函数,这显然是比较麻烦的。
在BSD UNIX中,套接字是以阻塞模式执行的,这对以命令行方式执行的UNIX程序来说并不是问题,但阻塞模式不是很适合于Windows下的消息驱动体系,所以WinSock为所有的函数提供了非阻塞模式的版本,非阻塞模式又称异步模式,在这种模式下,一个函数执行后会立即返回,即使是操作还没有全部完成,但是当函数最终完成操作的时候,WinSock接口会通过某种形式(如窗口消息)通知应用程序,显然这种方式非常适合于Windows下的消息驱动体系。
一般来说,WinSock接口强烈推荐程序员使用非阻塞模式进行通信编程,仅在绝对有必要的情况下才采用阻塞方式进行通信编程,因为非阻塞模式的操作能够更好地在Windows环境下进行。
16.2.2
WinSock编程概述
使用WinSock接口编程的一般步骤是:
(1)装入并初始化WinSock动态链接库。
(2)创建一个套接字。
(3)指定这个套接字的工作方式:使用阻塞模式还是非阻塞模式。
(4)如果使用TCP协议,则将套接字连接到对端主机的套接字(不使用TCP协议则不要这一步)。
(5)如果需要手工指定套接字使用的IP地址和端口号,那么将套接字绑定到指定的地址和端口(如果由系统自动分配地址和端口则不需要这一步)。
(6)通过套接字收发数据。
(7)关闭套接字。
(8)释放WinSock动态链接库。
1. WinSock库的初始化和释放
当前可以使用的WinSock接口动态链接库从版本上看有1.1版本和2.0版本两种,从位数分可以分为16位版本和32位版本,如图16.7所示,WinSock接口函数的代码主要包括在WS2_32.dll库文件中,这个库文件提供了对2.0版本WinSock接口的支持。但是在早期的Windows中,16位和32位的1.1版本的文件名分别是WinSock.dll和WSock32.dll,为了给使用这些库文件的程序提供兼容性支持,系统中仍然存在这两个文件,只不过在这两个文件中也是间接调用了WS2_32.dll文件而已。
为了使用WinSock接口,需要在源程序中包含对应的inc和lib文件,如果使用2.0版本,在源程序中必须包括WS2_32.inc和WS2_32.lib文件,如果要使用的是1.1版本的WinSock函数,那么既可以使用上面两个文件,也可以使用WSock32.inc和WSock32.lib文件。
图16.7
WinSock接口使用的动态链接库
在源文件中包含了对应的inc文件和lib文件后,在使用其他WinSock函数之前,必须首先使用WSAStartup函数来装入并初始化动态链接库,否则对其他任何WinSock函数的调用都不会成功:
invoke
WSAStartup,wVersionRequested,lpWSAData
.if eax
;初始化库出现错误
.endif
wVersionRequested是一个16位的参数,用来指定动态链接库将支持哪个版本的WinSock函数,其中的低8位指定主版本号,高8位用来指定副版本号,假如要使用1.1版的,可以在这里使用0101h,假如需要使用2.0版本函数,则可以将参数指定为0002h。
lpWSAData参数指向一个WSADATA结构,用来返回动态链接库的详细信息,结构的定义为:
WSADATA STRUCT
Wversion
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语句中并不需要特殊的处理。
2. 套接字的创建和关闭
套接字是通信的一端,当装载WinSock库以后,在开始通信之前必须为通信进程建立一个套接字,如果在一个程序中同时使用多个通信进程(比如用TCP协议同时连接到几个不同的主机),那就必须为每个通信连接创建一个套接字,创建套接字使用socket函数:
invoke
socket,af,type,protocol
.if eax !=
INVALID_SOCKET
mov hSocket,eax
.endif
函数的第一个参数af用来指定套接字使用的地址格式。在不同操作系统下,这个参数可以指定为AF_UNSPEC,AF_UNIX或AF_OSI等不同的值,但是在WinSock中只能使用AF_INET(也可以使用PF_INET,在Windows.inc中PF_INET被定义为与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就是创建套接字时返回的套接字句柄。
当出错的时候,大部分WinSock函数的返回值是INVALID_SOCKET或者SOCKET_ERROR,如果需要进一步的出错代码,必须马上调用WSAGetLastError来获取,在以后说明“出错代码”时,指的就是这样得到的出错代码而不是出错函数的返回值。但惟一的例外是WSAStartup,它出错时会直接返回出错代码,因为这时WinSock库尚未装入,程序无法通过调用WSAGetLastError函数来获取出错代码。
3. 设置套接字的工作模式
当一个套接字被创建的时候,它默认工作在阻塞模式下,所以如果不需要改变它的工作模式,可以直接跳过“设置为非阻塞模式”这一步,但是因为Windows程序的工作特点,WinSock强烈建议程序员使用非阻塞模式。有两个函数可以用来改变一个套接字的模式:ioctlsocket函数和WSAAsyncSelect函数。
ioctlsocket函数从BSD UNIX Socket规范中延续过来,它的用法是:
invoke
ioctlsocket,s,cmd,argp
参数s指定需要被设置模式的套接字句柄,cmd为命令参数,argp是一个指针,指向一个被cmd命令使用的参数。当cmd被指定为FIONBIO时,函数被用来改变套接字模式,这时如果argp指向的变量值为1,那么套接字的工作模式被设置为非阻塞模式;如果变量中的值为0,那么套接字被设置为阻塞模式。如下面的代码将一个套接字设置为非阻塞模式:
.data
dwArg dd
1
.code
mov dwArg,1
invoke
ioctlsocket,hSocket,FIONBIO,addr dwArg
.if eax ==
SOCKET_ERROR
;发生错误
.endif
但是,这个函数用起来并不方便,因为用这种方法将套接字设置为非阻塞模式后,其结果就是对这个套接字的操作会马上返回,而在操作最终完成以后,函数并不会通过某种机制通知程序操作已经完成,还需要程序去不停地查询操作结果,所以建议使用WinSock接口的WSAAsyncSelect函数来设置工作模式。
WSAAsyncSelect函数是WinSock接口中的特有函数,它仅针对Windows的消息体系工作,所以BSD UNIX Socket中并没有这个函数。这个函数将套接字设置为非阻塞模式,并且打开操作完成后的通知机制,通知消息可以被绑定到某个窗口句柄中,这样程序就不必不停地去查询套接字的操作是否已经完成,而是在窗口过程中等收到通知消息后再进行处理。
WSAAsyncSelect函数的用法是:
invoke
WSAAsyncSelect,s,hWnd,wMsg,lEvent
s参数指定需要设置的套接字句柄,hWnd指定一个窗口句柄,套接字的通知消息将被发送到与其对应的窗口过程中。
通知消息的ID可以由程序自己定义,当不同的动作完成以后,不同的通知码将被包含在消息的参数中传递给指定的窗口过程,wMsg参数用来定义通知消息使用的ID,可以在WM_USER以上数值中任意选择一个用做ID,最后的参数lEvent指定哪些通知码需要发送,它可以被指定为几个通知码的组合,一般常用的通知码有下面这些:
● FD_READ——套接字收到对端发送过来的数据包,表明这时可以去读套接字。
● FD_WRITE——当短时间内向一个套接字发送太多数据造成缓冲区满以后,发送函数会返回出错信息,当缓冲区再次变空的时候,WinSock接口通过这个通知码通知应用程序,表示可以继续发送数据了。但是缓冲区未溢出的情况下数据被发送完毕的时候并不会发送这个通知码。
● FD_ACCEPT——监听中的套接字检测到有连接进入(适用于流套接字)。
● FD_CONNECT——如果用一个套接字去连接对方主机,当连接动作完成以后将收到这个通知码(适用于流套接字)。
● FD_CLOSE——检测到套接字对应的连接被关闭(适用于流套接字)。
在使用中并不需要指定全部这些通知码。例如,对于数据报套接字来说,它并不需要一个连接的过程,所以FD_CONNECT,FD_CLOSE和FD_ACCEPT等通知码是没有意义的,而在流套接字中,如果一个套接字不是用于监听,那么FD_ACCEPT也是没有意义的,所以要根据套接字的类型选用合适的通知码。
4. 将套接字绑定到IP地址和端口
对于一个流套接字和数据报套接字来说,它使用的IP地址和端口的组合在系统中必须是惟一的,这样WinSock内核才能将它收发的数据和其他套接字分辨开来。当创建一个流套接字和数据报套接字用于通信的时候,如果不去人工指定它使用哪个IP地址和哪个端口,那么系统自动将套接字的IP地址指定为本机的IP地址,端口指定为1 024 ~ 65 536之间的任意一个未使用的端口号(编号在1 024以下的端口被视为保留端口而不予自动分配)。
但是在某些情况下,需要人工指定IP地址或端口号,比如,当创建的套接字用于提供特定服务的时候,必须使用一个固定的端口号,如提供Web服务的套接字一般工作在80号端口,而提供FTP服务的套接字一般工作在21号端口,不然每次由系统随机指定端口号的话,其他计算机就不知道该与哪个端口连接。另外,当一台计算机上指定有多个IP地址,而我们希望套接字使用其中的一个IP地址而不是全部IP地址时(比如,用做虚拟主机服务的计算机往往指定多个IP地址,然后指派IP地址1的端口80提供网站A服务,IP地址2的端口80提供网站B服务等),这时就需要指定套接字只使用其中的一个IP地址。
使用bind函数可以为套接字指定IP地址和端口,这个过程称为绑定。函数用法如下:
invoke
bind,s,lpsockaddr,len
参数s指定套接字句柄,lpsockaddr参数是一个指向sockaddr_in结构的指针,len参数指定sockaddr_in结构的长度。sockaddr_in的定义是:
sockaddr_in STRUCT
sin_family
WORD
? ;地址格式
sin_port
WORD
? ;端口号(使用网络字节顺序)
sin_addr
in_addr <> ;IP地址(使用网络字节顺序)
sin_zero
BYTE 8 dup (?) ;空字节
sockaddr_in ENDS
结构中的sin_family 字段用来指定地址格式,这个字段和socket函数中af参数的含义是相同的,所以惟一可以使用的值就是AF_INET。sin_port字段和sin_addr字段分别指定套接字需要绑定的端口号和IP地址,放入这两个字段的数据的字节顺序必须是Internet顺序,由于字节顺序和Intel CPU的字节顺序刚好相反,所以必须首先经过转换,比如当端口号为9 999时,转换成16进制是270Fh,那么放入sin_port字段的数值就应该是转换以后的0F27h。
sockaddr_in 结构是WinSock编程中最常用的结构,凡是涉及通信地址的时候都会用到这个结构,比如使用connect发起连接和使用sendto发送数据时的目标地址都是使用这个结构指定的。
读者可能会问一个问题,结构中存在一个定义IP地址的sin_addr字段,这样的话岂不是必须先获取本机的IP地址后才能绑定,否则如何填写这个字段呢?实际上仅在当前计算机配置有多个IP地址,而程序又只想绑定到其中一个IP地址的时候才需要具体指定使用哪一个地址,否则可以使用预定义值ADDR_ANY,这样系统会自动使用当前主机配置的所有IP地址。
使用bind函数绑定IP地址和端口的时候,端口号可以指定为任意一个16位值,也可以使用小于1 024的值,惟一的限制就是不能去绑定已经在使用中的端口号。
如果绑定成功,函数返回0,否则函数返回SOCKET_ERROR,这时程序可以继续调用WSAGetLastError函数获取进一步的出错代码。一般来说,绑定错误是由下面几个原因引起的,对应的出错代码如下:
● WSAEADDRINUSE——指定的IP地址或端口已经在使用中。
● WSAEFAULT——指定的结构、地址和端口等数据无效。
● WSAEINVAL——套接字已经被绑定到某个地址,绑定只能进行一次,对一个已经绑定过的套接字再次调用bind函数就会出现这个错误。
5. 用于连接和收发数据的函数
当完成了前面的库初始化,创建套接字,设置套接字模式以及绑定工作后,就可以使用套接字收发数据了。流套接字和数据报套接字数据收发时适用的函数有所不同,一般对流套接字使用send和recv函数来收发数据,对数据报套接字使用sendto和recvfrom函数。对于流套接字来说,开始收发数据之前还需要有一个监听或连接的过程,这时还要用到listen,accept和connect函数。另外,WinSock接口还提供一些与主机名解析有关的函数,如gethostbyname和gethostname等。
所有这些函数将在下面的章节中逐一介绍。
6. 常用的转换函数
WinSock接口中也提供了一些常用的转换函数,比如将32位的IP地址和 “aa.bb.cc.dd”类型的10进制IP地址字符串互相转换,或者将IP地址和端口号等数据在不同的字节顺序之间进行转换,这些函数可以为我们带来很多方便。
inet_addr函数和inet_ntoa可以在IP地址和字符串之间进行转换。
inet_addr函数将一个由小数点分隔的10进制IP地址字符串转换成由32位二进制数表示的IP地址(网络字节顺序):
invoke
inet_addr,lpString
.if eax !=
INADDR_NONE
mov dwIP,eax
.endf
lpString参数指向“aa.bb.cc.dd”类型的IP地址字符串。如果转换成功,函数将返回已经按网络字节顺序排列的32位IP地址,否则返回INADDR_NONE。
inet_ntoa则是inet_addr函数的逆函数,它将一个网络字节顺序的32位IP地址转换成字符串:
invoke
inet_ntoa,in
.if eax
mov lpsz,eax
.endif
参数in是需要转换的32位IP地址,如果转换失败函数返回NULL,转换成功的话函数返回一个指针,指向转换后的IP地址字符串。这个字符串位于WinSock接口的内部缓冲区中,所以若以后需要继续使用的话,那么在调用下一个WinSock函数之前必须将它拷贝到程序自己定义的缓冲区中。
除了IP地址转换函数,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
一般来说,当涉及当前主机字节顺序和网络顺序数据的转换时,不管当前主机使用的字节顺序是否和网络顺序相同,最好都进行一次转换,而且转换时必须使用这些WinSock函数而不是自定义的函数,这样程序可以在不同的主机上进行移植。
比如,现在使用的是Intel 80x86平台,它的字节顺序和网络字节顺序是不一样的,如果使用自定义的函数将字节顺序反过来,万一Windows移植到和网络字节顺序一致的硬件平台上,转换的结果就不对了,但使用WinSock接口提供的转换函数肯定是正确的,所以永远使用WinSock接口提供的转换函数是有必要的。
反之,在运行于Motorola平台上的UNIX系统中,CPU字节顺序和网络字节顺序是相同的,如果程序因此取消了字节顺序转换这一步骤,那么程序移植到运行于Intel平台上的Linux系统中时就会出错,所以不管主机的字节顺序是否和网络字节顺序相同,应该总是使用转换函数进行字节顺序转换。
读者可以注意到Win32 API函数的名称一般由几个单词组成,每个单词的首字母是大写的,但是一部分WinSock函数如socket和ioctlsocket等的命名却是全部小写的,造成这种现象的原因是这些函数名称源于UNIX socket,而UNIX socket中的函数命名全部是小写的。读者也可以注意到:WinSock接口中由Windows系统扩展的函数使用的就是标准的Win32 API命名方式,如WSAStartup和WSAAsyncSelect等。从这里也可以看出哪些函数是WinSock接口特有的。
16.3 TCP协议编程
介绍了WinSock编程的一般流程和常用函数以后,现在来看如何使用流套接字传输数据。
16.3.1
TCP协议简介
1. TCP协议的特征
TCP协议是一种传输层上的协议,它提供一种面向连接的、可靠的字节流服务。
面向连接意味着两个使用TCP协议的应用程序在开始传输数据之前必须先建立一个连接。其含义就是由一方首先向另一方发送请求信息,等对方确认以后才能开始通信。这一过程与打电话很相似,一方先拨号振铃,等待对方摘机后才开始对话,如果对端没有程序响应,那就像没有人接电话一样,通信是无法开始的。另外,面向连接意味着TCP协议不能用于广播,即一方不能用一个套接字同时向多方发送数据。
TCP通信是可靠的,它采用超时以及重传机制来保证不丢失数据。当一个TCP发送一个数据包后,它将启动一个定时器,等待对端确认收到这个包,如果在指定的时间内没有得到确认,它将重发这个数据包。而接收数据包的时候,它将发送一个确认,如果检测发现数据包的校验和有错,TCP协议丢弃这个数据包并且不发送确认,那么对端会因为超时而重新发送这个数据包。
数据包在传输的时候会通过多个路由器,不同的数据包到达终点前经过的路由器可能是不同的,因此所花的时间也是不同的,这就可能造成后发的数据先到的情况,TCP协议在TCP首部保存数据包序号,如果有必要,它将对收到的数据进行重新排序,并将收到的数据以正确的顺序交给应用层。
接收方收到的IP报文段也可能重复,原因之一是在发送和确认之间还有个时差,发送方可能因为接收方的确认信息还在路上就发生超时而重发数据,这时接收方收到的数据包就会发生重复,对于这种情况,TCP的接收端会丢弃重复的数据。
TCP还提供流量控制机制,发送方可以根据接收方应答的时间和速率适当调整自己的数据发送速度,这样可以防止速度较快的主机使速度较慢主机的接收缓冲区溢出。
TCP协议的工作过程也和打电话的过程很相似。当一方滔滔不绝地讲话时,需要另一方偶尔回复“是的”、“嗯”之类的短语来确认,如果有一段时间没有听到对方的简短确认,发言方就会停下来问一句“你在听吗”。当另一方没有听清楚某句话的时候,他会说:“你刚才说什么,我没有听清楚”,这样发言方会复述前面的句子。如果一方讲得太快,另一方会要求他适当放慢速度。TCP协议实现的机制就与此类似。
2. TCP程序的客户机/服务器模型
从TCP协议的特征可以看出通信双方的工作方式必然是不同的,这种工作方式的不同可以用客户机/服务器模型(Client/Server或C/S)来描述,通信的发起端被称为客户机(Client,也称为工作站端或者客户端),通信的等待方被称为服务器(Server),图16.8显示了两者工作方式的不同,图中的括号内显示了各步骤使用的WinSock函数。
图16.8
TCP服务器和客户机模型
就像打电话一样,A对B说:“某某时候Call我”,那么B给A打电话的过程就可以用这种客户机/服务器模型来描述。为了等待B的电话,A必须在双方约定的某个特定的电话旁边等待,否则B将不知道如何Call他。“特定的电话”意味着服务器端的地址必须是特定的,所以服务器端的套接字必须首先使用bind函数绑定到指定的IP地址和端口上。“等待”意味着服务器必须随时监听客户端的连接动作,所以套接字绑定地址后必须使用listen函数进入监听状态。而对于B来说,他可以在任何时刻从任何电话给A打电话。由此可见,客户端使用的套接字并不需要绑定过程,让系统自动指定任意值并不影响它向服务器端发起连接。
客户端可以随时使用connect函数连接到服务器,服务器检测到这个连接后,需要使用accept函数接受这个连接,当服务器接受连接后,一个稳定的连接就建立了,双方就可以开始互相通过send和recv函数收发数据了。
下面用两个互相配合的例子—聊天室服务器和客户端例子来演示如何用WinSock接口来实现这个模型。当在一台计算机上运行聊天室服务器程序以后,网络上可以有多个聊天客户端程序同时连接到服务器,当任意一个客户端发出一句话后,服务器将它转发到所有在线的客户端,这样所有的客户端就可以通过服务器进行聊天。
16.3.2
TCP聊天室例子——客户端
TCP聊天室的例子代码放在所附光盘的Chapter15\Chat-TCP目录中,包括服务器端代码和客户端代码。编译链接后执行将产生如图16.9所示的界面,图中后面的窗口是服务器端的界面,对话框中的编辑框中将显示所有客户端的对话内容,对话框最下面显示当前有多少个客户端在线。
图中前面的窗口是客户端的界面,使用时必须首先输入服务器IP地址并进行连接,当连接成功后,在下面的输入框中输入聊天内容并单击“发送”键,就可以将它发送给服务器端程序,服务器端会将它转发给所有的在线客户端。
图16.9
TCP聊天室例子的运行界面
先来看相对比较简单的客户端程序,客户端代码由Client.asm和Client.rc文件组成。Client.rc文件定义了图16.9前端的窗口,内容如下:
#include
#define ICO_MAIN
1000
#define DLG_MAIN
2000
#define IDC_SERVER
2001
#define IDC_CONNECT
2002
#define IDC_INFO
2003
#define IDC_TEXT
2004
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
ICO_MAIN
icon
"Main.ico"
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN DIALOG 94, 81, 245, 155
STYLE DS_MODALFRAME | WS_POPUP | WS_VISIBLE | WS_CAPTION | WS_SYSMENU
CAPTION "TCP聊天-客户端"
FONT 9, "宋体"
{
LTEXT "服务器IP地址:", -1, 6, 7, 57, 8
EDITTEXT IDC_SERVER, 63, 5, 116, 12
PUSHBUTTON "连接(&C)", IDC_CONNECT, 186, 4, 56, 14
EDITTEXT IDC_INFO, 4, 22, 237, 110, ES_MULTILINE | ES_AUTOVSCROLL
| ES_AUTOHSCROLL | ES_READONLY | WS_BORDER | WS_VSCROLL | WS_TABSTOP
LTEXT "输入", -1, 6, 140, 19, 8
EDITTEXT IDC_TEXT, 28, 138, 150, 12, ES_AUTOHSCROLL | WS_DISABLED
| WS_BORDER | WS_TABSTOP
DEFPUSHBUTTON "发送(&S)", IDOK, 185, 137, 56, 14, BS_DEFPUSHBUTTON
| WS_DISABLED | WS_TABSTOP
}
客户端的汇编源代码Client.asm文件的内容如下:
.386
.model flat, stdcall
option casemap :none ; case sensitive
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Include 数据
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
include
windows.inc
include
user32.inc
includelib
user32.lib
include
kernel32.inc
includelib
kernel32.lib
include
wsock32.inc
includelib
wsock32.lib
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; equ 数据
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN
equ
2000
IDC_SERVER
equ
2001
IDC_CONNECT
equ
2002
IDC_INFO
equ
2003
IDC_TEXT
equ
2004
WM_SOCKET equ
WM_USER + 100
TCP_PORT
equ
9999
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 数据段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.data?
hWinMain
dd
?
hSocket
dd
?
hWinConnect
dd
?
hWinOK
dd
?
hWinText
dd
?
hWinServer
dd
?
hWinInfo
dd
?
szReadBuffer
db
32768 dup (?)
.const
szIP
db
~127.0.0.1~,0
szConnect db
~连接(&C)~,0
szDisConnect
db
~断开(&D)~,0
szErrIP db
~无效的服务器IP地址!~,0
szErrSocket db
~创建Socket错误!~,0
szErrConnect
db
~无法连接到服务器!~,0
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 代码段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.code
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 建立 TCP/IP 连接
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_Connect
proc
local @szBuffer[256]:byte
local @stSin:sockaddr_in
;********************************************************************
; 转换IP地址并建立socket
;********************************************************************
invoke
RtlZeroMemory,addr @stSin,sizeof @stSin
invoke
GetDlgItemText,hWinMain,IDC_SERVER,\
addr @szBuffer,sizeof @szBuffer
invoke
inet_addr,addr @szBuffer
.if
eax ==
INADDR_NONE
mov
ebx,offset szErrIP
jmp
_Error
.endif
mov
@stSin.sin_addr,eax
mov
@stSin.sin_family,AF_INET
invoke
htons,TCP_PORT
mov
@stSin.sin_port,ax
invoke
socket,AF_INET,SOCK_STREAM,0
.if
eax ==
INVALID_SOCKET
mov
ebx,offset szErrSocket
jmp
_Error
.endif
mov
hSocket,eax
;********************************************************************
; 连接到服务器
;********************************************************************
invoke
EnableWindow,hWinConnect,FALSE
invoke
EnableWindow,hWinServer,FALSE
invoke
WSAAsyncSelect,hSocket,hWinMain,WM_SOCKET,\
FD_CONNECT or FD_READ or FD_CLOSE or FD_WRITE
invoke
connect,hSocket,addr @stSin,sizeof @stSin
.if
eax ==
SOCKET_ERROR
invoke
WSAGetLastError
.if eax != WSAEWOULDBLOCK
call
_DisConnect
mov
ebx,offset szErrConnect
jmp _Error
.endif
.endif
ret
_Error:
invoke
MessageBox,hWinMain,ebx,NULL,\
MB_OK or MB_ICONWARNING
ret
_Connect
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 断开连接
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_DisConnect
proc
invoke
closesocket,hSocket
mov
hSocket,0
invoke
SetWindowText,hWinConnect,addr szConnect
invoke
EnableWindow,hWinServer,TRUE
invoke
EnableWindow,hWinConnect,TRUE
invoke
EnableWindow,hWinText,FALSE
invoke
EnableWindow,hWinOK,FALSE
ret
_DisConnect
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 接收TCP数据包
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_RecvData
proc
_hSocket
invoke
RtlZeroMemory,addr szReadBuffer,sizeof szReadBuffer
invoke
recv,_hSocket,addr szReadBuffer,\
sizeof szReadBuffer,NULL
.if
eax !=
SOCKET_ERROR
invoke
GetWindowTextLength,hWinInfo
invoke
SendMessage,hWinInfo,EM_SETSEL,eax,eax
invoke
SendMessage,hWinInfo,EM_REPLACESEL,\
FALSE,addr szReadBuffer
.endif
ret
_RecvData
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 发送输入的文字
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_SendData
proc
local @szBuffer[1024]:byte
invoke
GetDlgItemText,hWinMain,IDC_TEXT,\
addr @szBuffer,sizeof @szBuffer
invoke
lstrlen,addr @szBuffer
.if
eax
invoke
send,hSocket,addr @szBuffer,eax,0
.if
eax ==
SOCKET_ERROR
invoke
WSAGetLastError
.if
eax == WSAEWOULDBLOCK
invoke
EnableWindow,hWinOK,FALSE
.endif
.endif
.endif
invoke
SetDlgItemText,hWinMain,IDC_TEXT,NULL
ret
_SendData
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 主窗口程序
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_ProcDlgMain
proc
uses ebx edi esi hWnd,wMsg,wParam,lParam
local @stWsa:WSADATA
mov
eax,wMsg
.if
eax ==
WM_SOCKET
;********************************************************************
; 处理 Socket 消息
;********************************************************************
mov
eax,lParam
.if
ax == FD_READ
invoke
_RecvData,wParam
.elseif ax == FD_WRITE
invoke
EnableWindow,hWinOK,TRUE
.elseif ax == FD_CONNECT
shr
eax,16
.if
ax == NULL
invoke
SetWindowText,hWinConnect,\
addr szDisConnect
invoke
EnableWindow,hWinConnect,TRUE
invoke
EnableWindow,hWinServer,FALSE
invoke
EnableWindow,hWinText,TRUE
invoke
EnableWindow,hWinOK,TRUE
invoke
SetFocus,hWinText
.else
invoke
_DisConnect
invoke
MessageBox,hWinMain,\
offset szErrConnect,NULL,\
MB_OK or MB_ICONWARNING
.endif
.elseif ax == FD_CLOSE
call
_DisConnect
.endif
;********************************************************************
.elseif eax ==
WM_COMMAND
mov
eax,wParam
.if
ax == IDOK
invoke
_SendData
.elseif ax == IDC_CONNECT
.if
hSocket
invoke
_DisConnect
.else
call
_Connect
.endif
.endif
;********************************************************************
.elseif eax ==
WM_CLOSE
invoke
_DisConnect
invoke
WSACleanup
invoke
EndDialog,hWinMain,NULL
;********************************************************************
.elseif eax ==
WM_INITDIALOG
push
hWnd
pop
hWinMain
invoke
GetDlgItem,hWnd,IDC_SERVER
mov
hWinServer,eax
invoke
GetDlgItem,hWnd,IDOK
mov
hWinOK,eax
invoke
GetDlgItem,hWnd,IDC_TEXT
mov
hWinText,eax
invoke
GetDlgItem,hWnd,IDC_CONNECT
mov
hWinConnect,eax
invoke
GetDlgItem,hWnd,IDC_INFO
mov
hWinInfo,eax
invoke
SetWindowText,hWinServer,addr szIP
invoke
WSAStartup,101h,addr @stWsa
;********************************************************************
.else
mov
eax,FALSE
ret
.endif
mov
eax,TRUE
ret
_ProcDlgMain
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 程序开始
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
start:
invoke
GetModuleHandle,NULL
invoke
DialogBoxParam,eax,DLG_MAIN,NULL,offset_ProcDlg Main,0
invoke
ExitProcess,NULL
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
end
start
在图16.8的模型中,已经了解了客户端程序大致的工作方式,但是到具体实现的时候,各种语句应该如何和Windows的消息驱动体系配合呢,上述源代码的结构看上去似乎比较松散,但是从图16.10中就可以看出它的工作流程。
图16.10
TCP客户端程序的常见结构
请读者结合图16.10中的数字序号来分析源代码,初始化WinSock库的WSAStartup函数被安排在窗口的初始化消息中(图中的①),当程序需要退出时,程序调用WSACleanup函数卸载WinSock库(图中的⑧)。
当用户要求连接时(比如例子中用户输入了IP地址并按下了“连接”按钮,见图16.10中的②),程序使用socket函数创建套接字,套接字的类型必须是流套接字,因为只有它是使用TCP协议的:
invoke
socket,AF_INET,SOCK_STREAM,0
mov hSocket,eax
invoke
WSAAsyncSelect,hSocket,hWinMain,\
WM_SOCKET,FD_CONNECT or FD_READ or FD_CLOSE or FD_WRITE
套接字创建以后,应该使用WSAAsyncSelect函数将通知消息以自定义的ID(例子中使用WM_SOCKET消息,它被预先定义为WM_USER+100)绑定到窗口过程中,这样就可以在适当的时候收到系统的通知消息,当收到消息的时候,lParam的低16位包括了通知码,高16位为出错信息,wParam参数指定了发生消息的套接字句柄。对于用于客户端的流套接字来说,在使用WSAAsyncSelect函数时必须包含FD_CONNECT,FD_READ,FD_WRITE和FD_CLOSE通知码。
完成这些事情后,接下来就可以使用connect函数去连接服务器了。
1. 连接到服务器
connect函数将一个流套接字连接到指定IP地址的指定端口上,它仅适用于流套接字,因为数据报套接字不是面向连接的。connect函数的用法是:
invoke
connect,s,lpsockaddr,len
参数s指定用于连接的套接字句柄,lpsockaddr参数指向一个sockaddr_in结构,用来指定要连接到的服务器的IP地址和端口,这个结构在前面介绍的bind函数中已经使用过,len参数则指定sockaddr_in结构的长度。
当连接成功的时候,函数返回0,否则返回值是SOCKET_ERROR,但是当套接字工作在非阻塞模式时,函数返回的时候连接还没来得及完成,返回值肯定会是SOCKET_ERROR,但这并不意味着以后连接就不会成功,如果出错代码显示为WSAEWOULDBLOCK值,表示出错是因为操作尚未完成引起的,这时应该等待,否则才是真正的出错。
比如,例子程序中的连接代码如下:
invoke
connect,hSocket,addr @stSin,sizeof @stSin
.if
eax ==
SOCKET_ERROR
invoke
WSAGetLastError
.if eax != WSAEWOULDBLOCK
;真正的出错,关闭套接字并退出
.endif
.endif
那么,什么时候才知道连接的动作是否真正成功呢?那就要等待系统的FD_CONNECT通知消息了(如图16.10的③),当收到通知消息时,lParam参数的高16位包含了出错信息,如果检测到高16位等于0就表示连接成功,可以开始收发数据了(如图16.10的④);否则表示没有连接成功,程序应该关闭套接字并显示出错信息(如图16.10的⑤)。
FD_CONNECT通知的常见处理方法是:
mov
eax,wMsg
.if
eax ==
WM_SOCKET
mov
eax,lParam
.if
ax == FD_CONNECT
shr
eax,16
;取lParam的高16位
.if
!ax
;连接成功,可以进行收发数据了
.else
;连接失败,显示出错信息
;并且关闭套接字
.endif
.endif
.endif
在进行连接的时候请读者注意:由于TCP协议的连接过程需要3次握手,也就是说确认连接用的数据包需要一定的往返时间,当连接互联网上的主机时,连接的过程往往需要几秒的时间,如果要连接的主机没有在线,等待连接失败的时间可能更长(可能会达到十几秒以上),因为这时客户端将一直等待到超时为止。
所以程序在开始连接的时候暂时禁止“连接”按钮,防止用户在连接的过程中再次按下按钮,而这时连接尚未确立,收发数据的功能也是被禁止的。
如果连接成功,收发数据的功能将被允许,而“连接”按钮也被改为“断开”按钮;如果连接失败,那么“连接”按钮被重新允许,以便用户再一次发起连接动作。
2. 接收数据
当FD_CONNECT通知显示连接成功以后,就可以开始收发数据了,但是什么时候有数据可供接收,是不是需要随时去读套接字?答案是并不需要,如果有数据过来,WinSock接口会发送包含FD_READ通知码的通知消息,程序可以在这里读数据(如图16.10中的A所示)。
系统在以下情况下发送FD_READ通知码:
● 调用WSAAsyncSelect函数对套接字设置非阻塞模式(参数中包括FD_READ)时,接收缓冲区中已经有数据存在。
● 原先系统的接收缓冲区是空的,然后有新的数据进入。
● 程序使用recv或recvfrom函数读取流套接字时,由于读出缓冲区比较小引起数据没有一次性被读完,这时系统会再发送一个FD_READ通知,表示缓冲区中仍有数据存在。
有一点要注意的是,如果收到FD_READ通知码后,由于程序比较忙而没有去读取数据,这时系统又收到了新的数据的话,那么系统并不会再次发送通知,而是必须在调用了recv或recvfrom函数以后才有可能再次收到FD_READ通知。
虽然recv和recvfrom函数都可以用于读取套接字,也都可以用于读取不同类型的套接字,但读取流套接字一般使用recv函数:
invoke
recv,s,lpbuf,len,flags
s参数指定读取的套接字句柄,lpbuf指向一个用来返回数据的缓冲区,len参数指定缓冲区的大小,flags参数用来指定读取时的选项,它可以是MSG_PEEK和MSG_OOB的组合,MSG_PEEK表示返回数据后并不从缓冲区中清除数据。
如果读取成功函数将返回实际读取的字节数,否则函数返回SOCKET_ERROR,调用WSAGetLastError函数可以进一步获取出错代码。
如果读出缓冲区大于收到的数据包长度,函数会成功读出整个数据包。但是当缓冲区小于数据包长度时,recv函数对不同类型套接字的处理有所不同,对于流套接字来说这并不会返回错误,这时函数仅读取缓冲区大小的数据,多余的数据可以在下一次调用中继续读取;但是UDP数据包不能被分割,所以对于数据报套接字来说,函数仅读出缓冲区大小的数据,多余的数据将被丢弃,这时函数返回的是SOCKET_ERROR并且出错代码被指定为WSAEMSGSIZE。
如果缓冲区中没有数据却调用recv函数去读的话,对于阻塞模式的套接字,函数将一直等待到有数据到达为止,对于非阻塞的套接字,函数会马上返回SOCKET_ERROR,这时的错误代码是WSAEWOULDBLOCK。
3. 发送数据
发送流数据一般使用send函数:
invoke
send,s,lpbuf,len,flags
s参数指定套接字句柄,lpbuf指向包含要发送数据的缓冲区,len参数指定发送的数据长度,flags参数指定选项。
当函数返回时,如果没有错误发生,那么返回值是实际发送的字节数,这个字节数不一定等于len参数指定的长度,具体发送了多少字节取决于系统发送缓冲区中剩余空间的大小。如果系统的发送缓冲区有足够剩余空间,那么指定的全部数据将被发送,否则发送缓冲区有多少剩余空间发送的数据就是多少。程序应该检测返回值是否等于输入的len参数,如果小于len参数,必须将余下的数据在下一次调用中继续发送。
要注意的是,函数执行成功并不意味着数据已经到达对端计算机,而只意味着数据已经进入系统的发送缓冲区。
如果函数执行失败,返回值是SOCKET_ERROR,调用WSAGetLastError函数可以进一步获取出错代码,典型的出错代码是当缓冲区全满的时候返回的WSAEWOULDBLOCK代码。那么当缓冲区全满的时候程序该怎么办呢?是不是需要不停地尝试发送呢?
回答是:没有这个必要,程序可以等待FD_WRITE通知码。但是这个通知会在什么时候收到呢?从字面上看,似乎系统在完成上一次发送后应该用FD_WRITE通知程序可以继续发送了,但实际情况并不是如此,如果没有因为缓冲区满而发生WSAEWOULDBLOCK错误,不管使用多少次send函数系统都不会得到FD_WRITE通知,得到FD_WRITE通知的情况是:
● 用WSAAsyncSelect函数为已连接的套接字设置FD_WRITE事件时,如果发送缓冲区有空闲空间,那么系统发送FD_WRITE通知。
● 套接字刚连接成功时。
● 当调用send或sendto函数发送数据,系统返回WSAEWOULDBLOCK错误时,表示发送缓冲区已经满了,在这以后,一旦系统将部分数据成功送抵对方,空出了部分发送缓冲区后,就会马上发一个FD_WRITE通知。
从这里可以看出,程序可以设置一个标志和FD_WRITE通知配合起来控制流量,标志一开始被设置为“可以发送”状态,如果使用send函数发送数据时发现返回WSAEWOULDBLOCK错误时,程序暂停发送并将标志设置为“暂停发送”(如图16.10的B所示),否则无论调用多少次send函数,返回的错误肯定都会是WSAEWOULDBLOCK。在收到FD_WRITE通知后(如图16.10的C),程序可以将标志恢复为“可以发送”状态并继续发送数据。
为了简化代码,例子程序在调用send函数得到WSAEWOULDBLOCK错误后,仅将“发送”按钮灰化来阻止用户继续输入聊天语句,当收到FD_WRITE通知后再恢复“发送”按钮的状态,这样一来本次要发送的内容实际上是被丢弃的,并且例子代码没有处理实际发送字节数少于要求发送字节数的情况,如果要求所有的数据都不能被丢弃,那么程序中必须处理这些情况。
当使用send函数时,如果返回的已发送数据数量和输入参数len 指定的值不同,则未发送的剩余数据需要被再次发送。
4. 连接的关闭
有好几种情况可以将连接断开:在本地使用closesocket函数关闭套接字可以主动断开连接(如图16.10中的⑥),远程计算机也可以断开连接,当发生其他错误的时候连接也会被断开,如超时,连接被复位,及拨号连接被挂断等。
当连接不是因为使用closesocket函数主动关闭套接字而断开的话,系统会通过FD_CLOSE通知码来通知应用程序(如图16.10中的⑦),这时程序也应该通过closesocket函数将套接字关闭,因为这时套接字已经不能用来收发数据了。
当处理FD_CONNECT通知的时候,如果lParam参数的高16位不为0,则表示连接不成功,但处理FD_CLOSE通知的时候,lParam参数的高16位不等于0并不代表“关闭连接”发生错误,或者理解为连接没有被关闭。
实际情况是,只要收到了FD_CLOSE通知,那么连接肯定已经断开,lParam高16位的错误代码是用来指明连接断开的原因的:假如高16位等于0,表示连接是被对方正常断开的;如果是WSAECONNRESET,表示连接被复位;如果是WSAECONNABORTED,则表示断开原因是超时或其他错误。
16.3.3
TCP聊天室例子——服务器端
在所附光盘的Chapter15\Chat-TCP目录中的Server.asm和Server.rc是服务器端的源代码,其中的Server.rc资源脚本文件定义了图16.9中的服务器端界面,内容如下:
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
#include
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
#define ICO_MAIN
1000
#define DLG_MAIN
2000
#define IDC_INFO
2001
#define IDC_COUNT
2002
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
ICO_MAIN
icon
"Main.ico"
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN DIALOG 94, 84, 245, 154
STYLE DS_MODALFRAME | WS_POPUP | WS_VISIBLE | WS_CAPTION | WS_SYSMENU
CAPTION "TCP聊天-服务器端"
FONT 9, "宋体"
{
EDITTEXT IDC_INFO, 4, 3, 237, 133, ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL
| ES_READONLY | WS_BORDER | WS_VSCROLL | WS_TABSTOP
LTEXT "当前连线客户端数量:", -1, 5, 141, 81, 8
LTEXT "0", IDC_COUNT, 88, 141, 81, 8
}
汇编源代码Server.asm的内容如下:
.386
.model flat, stdcall
option casemap :none ; case sensitive
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Include 数据
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
include
windows.inc
include
user32.inc
includelib
user32.lib
include
kernel32.inc
includelib
kernel32.lib
include
wsock32.inc
includelib
wsock32.lib
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; equ 数据
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN
equ
2000
IDC_INFO
equ
2001
IDC_COUNT equ
2002
WM_SOCKET equ
WM_USER + 100
TCP_PORT
equ
9999
MAX_SOCKET
equ 100 ;聊天室最大容量
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 数据段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.data?
hWinMain
dd
?
hSocket
dd
?
dwCount
dd
?
szReadBuffer
db
32768 dup (?)
szBuffer
db
32768 dup (?)
stTable
dd
MAX_SOCKET dup (?)
.const
szErrBind db
~无法绑定到TCP端口9 999,请检查是否有其他程序在使用!~,0
szFormat
db
~【客户端#%08x】- %s~,0dh,0ah,0
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 代码段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.code
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 在客户端列表中加上一个 socket
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_AddClient
proc
_hSocket
invoke
WSAAsyncSelect,_hSocket,hWinMain,\
WM_SOCKET,FD_READ or FD_CLOSE
xor
ebx,ebx
mov
esi,offset stTable
.while
ebx < MAX_SOCKET
.if
! dword ptr [esi]
push
_hSocket
pop
[esi]
inc
dwCount
invoke
SetDlgItemInt,hWinMain,\
IDC_COUNT,dwCount,FALSE
ret
.endif
inc
ebx
add
esi,4
.endw
invoke
closesocket,_hSocket
ret
_AddClient
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 从客户端列表中去掉一个 socket
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_RemoveClient proc
_hSocket
xor
ebx,ebx
mov
esi,offset stTable
mov
edi,_hSocket
.while
ebx < MAX_SOCKET
.if
[esi] == edi
invoke
closesocket,[esi]
mov
dword ptr [esi],0
dec
dwCount
invoke
SetDlgItemInt,hWinMain,\
IDC_COUNT,dwCount,FALSE
ret
.endif
inc
ebx
add
esi,4
.endw
ret
_RemoveClient endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 处理接收到的TCP包
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_RecvData
proc
_hSocket
local @dwRecv
invoke
RtlZeroMemory,addr szReadBuffer,sizeof szReadBuffer
invoke
recv,_hSocket,addr szReadBuffer,\
sizeof szReadBuffer,NULL
.if
eax !=
SOCKET_ERROR
mov
@dwRecv,eax
invoke
wsprintf,addr szBuffer,addr szFormat,\
_hSocket,addr szReadBuffer
;********************************************************************
; 按照客户端列表逐一发送
;********************************************************************
invoke
GetDlgItem,hWinMain,IDC_INFO
mov
ebx,eax
invoke
GetWindowTextLength,ebx
invoke
SendMessage,ebx,EM_SETSEL,eax,eax
invoke
SendMessage,ebx,EM_REPLACESEL,\
FALSE,addr szBuffer
mov
esi,offset stTable
xor
ebx,ebx
.while
ebx < MAX_SOCKET
mov
edi,[esi]
.if
edi
invoke lstrlen,addr szBuffer
invoke send,edi,addr szBuffer,eax,0
.endif
add
esi,4
inc
ebx
.endw
.endif
ret
_RecvData
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 初始化 Socket,绑定到服务TCP端口并监听
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_Init
proc
local @stWsa:WSADATA
local @stSin:sockaddr_in
invoke
WSAStartup,101h,addr @stWsa
invoke
socket,AF_INET,SOCK_STREAM,0
mov
hSocket,eax
invoke
WSAAsyncSelect,hSocket,hWinMain,WM_SOCKET,FD_ACCEPT
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,hSocket,addr @stSin,sizeof @stSin
.if
eax ==
SOCKET_ERROR
invoke
MessageBox,hWinMain,addr szErrBind,NULL,\
MB_OK or MB_ICONWARNING
invoke
SendMessage,hWinMain,WM_CLOSE,0,0
.else
invoke
listen,hSocket,5
.endif
ret
_Init
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 主窗口程序
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_ProcDlgMain
proc
uses ebx edi esi hWnd,wMsg,wParam,lParam
mov
eax,wMsg
.if
eax ==
WM_SOCKET
mov
eax,lParam
.if
ax == FD_ACCEPT
invoke
accept,wParam,0,0
invoke
_AddClient,eax
.elseif ax == FD_READ
invoke
_RecvData,wParam
.elseif ax == FD_CLOSE
invoke
_RemoveClient,wParam
.endif
;********************************************************************
; 退出时关闭全部连接
;********************************************************************
.elseif eax ==
WM_CLOSE
invoke
closesocket,hSocket
xor
ebx,ebx
mov
esi,offset stTable
cld
.while
ebx < MAX_SOCKET
lodsd
.if
eax
invoke
closesocket,eax
.endif
inc
ebx
.endw
invoke
WSACleanup
invoke
EndDialog,hWinMain,NULL
;********************************************************************
.elseif eax ==
WM_INITDIALOG
push
hWnd
pop
hWinMain
call
_Init
;********************************************************************
.else
mov
eax,FALSE
ret
.endif
mov
eax,TRUE
ret
_ProcDlgMain
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 程序开始
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
start:
invoke
GetModuleHandle,NULL
invoke DialogBoxParam,eax,DLG_MAIN,NULL,offset _ProcDlgMain,0
invoke
ExitProcess,NULL
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
end
start
服务器端程序的结构如图16.11所示,对于WinSock库的装入和清除,何时去读取套接字,以及发送数据时的流量控制等方面,服务器端程序和工作站端程序的用法是相同的,所以WSAStartup,WSACleanup,recv和send等函数的使用方法和客户端程序大同小异。服务器端程序的特殊之处就在于如何监听和接受连接。
图16.11
TCP服务器端程序的常见结构
当服务器端程序准备在某个端口提供服务时(如图16.11中的②),程序首先使用socket函数创建一个用于监听的流套接字并将它设置为非阻塞模式,例子中对应的代码是这样的:
invoke
socket,AF_INET,SOCK_STREAM,0
mov
hSocket,eax
invoke
WSAAsyncSelect,hSocket,hWinMain,WM_SOCKET,FD_ACCEPT
... ...
invoke
bind,hSocket,addr @stSin,sizeof @stSin
由于用来监听的套接字以后并不会用来收发数据,也不会用它去主动连接到其他地方,所以不需要为它设置FD_CONNECT,FD_READ,FD_WRITE和FD_CLOSE等通知码,惟一需要设置的就是FD_ACCEPT通知码。然后,必须使用bind函数将套接字绑定到一个固定的端口上,例子程序中使用9 999号端口来当做服务端口。接下来,需要让套接字进入监听方式。
1. 监听进入的连接
使用listen可以让一个流套接字进入监听状态:
invoke
listen,s,backlog
参数s指定套接字句柄,backlog参数是监听队列中允许保持的尚未处理的最大连接数量。
注意不要将backlog参数理解为最多能和多少个客户端相连接,它的真正含义是:当套接字监听到有客户端连接进来后,还需要调用accept函数来接受这个连接后,连接才真正被建立。在调用accept函数之前,连接请求将暂时被保留在队列中,如果这时有另一个客户端也发起连接的话,这个连接也将被保留在队列中,backlog参数指的就是这个队列的长度。实际上,如果程序能够在足够短的时间内响应进入的连接,那么即使将backlog参数指定为1,仍然不会丢失任何连接请求。
如果listen函数执行失败将返回SOCKET_ERROR。执行成功函数返回0,这时套接字就处于等待连接进入的状态了(如图16.11中的③)。
2. 接受客户端的连接请求
当有客户端向监听的套接字发起连接后,必须对监听的套接字调用accept函数以后,连接才最后被确定:
invoke
accept,s,lpsockaddr,lpaddrlen
.if eax !=
INVALID_SOCKET
mov hNewSocket,eax
.endif
参数s指定监听中的套接字句柄,lpsockaddr指向一个缓冲区,函数会在这里返回一个sockaddr_in结构,结构中存放有连接请求方的IP地址和端口,lpaddrlen则指向一个双字变量,函数在这里放入返回到缓冲区中的结构长度。如果不需要得到对方的地址信息,可以将lpsockaddr和lpaddrlen参数都设置为NULL。
如果执行成功,函数将新建一个套接字,这个套接字和客户端相连接,程序可以使用它来和客户端之间收发数据,而原来在监听状态的套接字仍然保持监听状态,以便接受下一个连接的进入。
在这里读者一定要分清监听套接字和新套接字之间的区别。假如用于监听的套接字是#1,那么前面的bind,listen和accept等函数都是对#1操作的,当accept函数返回套接字#2后,#2才是和客户端相连的,所以为了和客户端进行通信而使用的send和recv等函数都是针对#2的(如图16.11中的A,B和C)。如果连接被客户端断开或者服务器主动断开连接,那么需要对#2调用closesocket(如图16.11中的⑥)。只有服务器端程序想不再继续监听的时候,才需要对#1调用closesocket函数(如图16.11中的⑤)。
那么什么时候去调用accept函数呢,其实当连接进入的时候,系统会发送FD_ACCEPT通知码,只要在收到FD_ACCEPT通知码后再去调用就是了。
使用accept函数返回的新套接字通信时,发送和接收数据的方法与在客户端中使用的方法是一样的,为了使用这些套接字,在使用它们之前同样需要使用WSAAsyncSelect函数来设置通知消息,由于这些套接字已经处于连接状态,并且仅用于收发数据,所以设置的时候仅包含FD_READ,FD_WRITE和FD_CLOSE就可以,FD_CONNECT等通知码就没有必要设置了。
3. 维护连接列表
服务器端程序和客户端程序的最大不同是它必须维护一个连接列表。由于监听套接字每次接受一个连接后都会产生一个新的套接字,这些套接字在连接关闭之前必须被保存在一个列表中,而且列表中的数据必须动态维护,因为随时可能有新连接进入,也随时可能有连接被关闭,所以每当连接关闭时,程序必须在列表中找出并清除对应的项目,而新连接进入时,程序需要在列表中找出一个空位置并填写新的项目。
在例子程序中,服务器程序预定义了一个stTable变量,其中包括100个双字,这样就可以存放100个套接字句柄(这也决定了聊天室的容量是100个客户端)。在每次调用accept函数以后,程序马上使用_AddClient子程序将新的套接字句柄添加到列表中,在这个子程序中,程序从头开始扫描列表,如果找出一个位置的数据为0的话,则将句柄存放在这个地方,然后程序将在线客户端计数值dwCount加1并显示在窗口中。如果全部列表都不为0的话将直接关闭连接(客满了),这样从客户端看来就是连接被马上断开了。
当某个客户端断开的时候,程序收到FD_CLOSE通知,在这里除了关闭断开的套接字句柄以外,还需要从列表中将它找出并清除,这样列表中的无效项目才会空出来供新的连接使用,这个功能在_RemoveClient子程序中实现。
当某个客户端发送聊天语句的时候,服务器端程序收到FD_READ通知,在这里调用_RecvData子程序,子程序中首先使用recv函数读取发过来的聊天语句,然后将它显示在窗口中。接下来程序在句柄列表中找出当前在线的每个套接字句柄并使用send函数将聊天语句转发给它们,这样每个在线客户端的窗口中就会显示出这句聊天语句。
虽然这个TCP聊天室还有很多可以改进的地方(如加上登录验证密码,有连接进入时显示欢迎语句并向其他在线客户端发送“XXX进入了聊天室”,加上仅在某两个客户端之间收发数据的“私人谈话”功能等),但是它已经清楚地表现出了基于TCP协议的客户机/服务器模型的结构和实现方法,读者可以在这基础上很方便地实现其他功能。
16.4 UDP协议编程
1. UDP协议的特征
虽然TCP协议由于可靠、稳定的特征而被用在大部分的应用场合,但是UDP由于对系统资源的要求比较小,所以也经常用在一些对数据的可靠性要求不高却要求效率的场所,如实时音频和视频点播等。
UDP协议是一个简单的面向数据报的传输层协议,它将进程需要发送的数据包加上UDP首部,封装成一个UDP数据报,然后传给Internet层的IP协议发送出去。UDP首部中只包括双方的端口号、数据包长度与校验和等很少的字段,所以UDP协议的效率是很高的。从这种意义上看,UDP协议很像“使用端口号进行复用的IP协议”。
UDP协议并不提供其他方式对数据的可靠性与有序性等进行控制,所以IP协议的传输特征几乎就是它的特征。UDP协议不是面向连接的,它在发送数据之前并不首先建立连接,所以无法确认对方是否在线,也无法确认对方的指定端口是否在监听中,它直接将数据报按照指定IP地址的指定端口发出去,如果对方不在线的话,数据就可能丢失。
UDP协议也不提供应答和重传机制,所以程序并不知道数据是否到达。同样,UDP协议也没有为数据包标识序号,所以数据到达对端的顺序有可能是不对的,相同的数据也有可能多次到达。它属于一种“发出就不管”的协议。
如果说TCP协议像是打电话,两个人在通话中实时控制通话的流程并保证内容的正确性,那么UDP协议就像是寄信,发信人虽然知道收信人的地址,但是他并不确定信件会不会安全抵达,也不知道收信人究竟有没有因为外出而收不到信。另外,如果发信人发了好几封信,这些信可能并没有按发信时的顺序到达。总之,在收到收信人的复信之前,发信人无法确定信件是否安全、无损和有序地到达。
但是在应用中,如果不需要很高的可靠性,使用UDP协议就非常适合,比如对于实时视频,如果因为途中丢了数据包导致中断了几分之一秒,最重要的不是将它补上而是保证下面播放的实时性,如果为了补上丢失的数据包而导致“停格”显然是不必要的。另外,像OICQ一类的软件使用的就是UDP协议,对于这些软件来说,丢失一两句聊天数据并不重要,重要的是减少服务器的开销以便为更多的客户端服务。
图16.12
UDP服务器/客户机模型
2. UDP程序的客户机/服务器模型
UDP协议通信双方的工作方式相对来说比较相似,对于一个UDP套接字来说,如果它已经被建立,那么不必经过连接的过程就可以直接发送数据。另外,UDP套接字无所谓是否进入“监听”状态,只要有数据发送到套接字对应的端口,它就可以被接收,所以类似TCP协议中的连接、监听和关闭等动作都是不存在的。
在UDP的通信双方中,首次通信的发起方必须知道对方的IP地址和UDP端口才可以发送数据。发起方的端口号不必固定,因为报文的IP首部和UDP首部中已经包括了自己的地址和端口号数据。接收方在以前不知道发送方地址的情况下也可以根据这个地址回复数据。
如图16.12所示,在UDP协议的服务器/客户机模型中,两端惟一的区别就是服务器端程序必须首先将套接字绑定到一个固定的端口,以便客户端能够向约定的端口发送数据。本节将使用一个UDP聊天室例子来说明如何在程序中使用UDP协议,这个例子实现了与前面的TCP聊天室几乎同样的功能。
16.4.1
UDP聊天室例子——客户端
UDP聊天室例子执行后的界面如图16.13所示,由于UDP协议并不需要连接的过程,所以和TCP聊天室的客户端相比,对话框中少了一个“连接”按钮,只要直接单击“发送”按钮,程序就按照上面输入的IP地址发送聊天语句。
图16.13
UDP聊天室例子的运行界面
UDP聊天室的例子代码在所附光盘的Chapter15\Chat-UDP目录中,其中的Client.asm和Client.rc文件是客户端的代码。资源文件Client.rc如下:
#include
#define ICO_MAIN
1000
#define DLG_MAIN
2000
#define IDC_SERVER
2001
#define IDC_INFO
2002
#define IDC_TEXT
2003
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
ICO_MAIN
icon
"Main.ico"
//>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN DIALOG 94, 83, 245, 153
STYLE DS_MODALFRAME | WS_POPUP | WS_VISIBLE | WS_CAPTION | WS_SYSMENU
CAPTION "UDP聊天-客户端"
FONT 9, "宋体"
{
LTEXT "服务器IP地址:", -1, 6, 6, 57, 8
EDITTEXT IDC_SERVER, 63, 4, 77, 12
EDITTEXT IDC_INFO, 4, 20, 237, 110, ES_MULTILINE | ES_AUTOVSCROLL
| ES_AUTOHSCROLL | ES_READONLY | WS_BORDER | WS_VSCROLL | WS_TABSTOP
LTEXT "输入", -1, 6, 138, 19, 8
EDITTEXT IDC_TEXT, 28, 136, 150, 12, ES_AUTOHSCROLL | WS_BORDER | WS_TABSTOP
DEFPUSHBUTTON "发送(&S)", IDOK, 185, 135, 56, 14
}
UDP聊天室客户端的汇编源代码Client.asm内容如下:
.386
.model flat, stdcall
option casemap :none ; case sensitive
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Include 数据
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
include
windows.inc
include
user32.inc
includelib
user32.lib
include
kernel32.inc
includelib
kernel32.lib
include
wsock32.inc
includelib
wsock32.lib
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; equ 数据
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN
equ
2000
IDC_SERVER
equ
2001
IDC_INFO
equ
2002
IDC_TEXT
equ
2003
WM_SOCKET equ
WM_USER + 100
UDP_PORT
equ
9999
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 数据段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.data?
hWinMain
dd
?
hSocket
dd
?
szReadBuffer
db
32768 dup (?)
.const
szIP
db
~127.0.0.1~,0
szErrIP db
~无效的服务器IP地址!~,0
dwReturn
dd
-1
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 代码段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.code
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 接收到UDP数据包时显示到edit控件中,然后回复一个 -1
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_RecvData
proc
_hSocket
local @dwRecv,@dwSize
local @stSin:sockaddr_in
mov
@dwSize,sizeof @stSin
invoke
RtlZeroMemory,addr szReadBuffer,sizeof szReadBuffer
invoke
RtlZeroMemory,addr @stSin,sizeof @stSin
invoke
recvfrom,_hSocket,addr szReadBuffer,\
sizeof szReadBuffer,\
0,addr @stSin,addr @dwSize
.if
eax !=
SOCKET_ERROR
invoke
sendto,hSocket,addr dwReturn,4,\
0,addr @stSin,sizeof sockaddr_in
.if
eax ==
SOCKET_ERROR
invoke
WSAGetLastError
.if
eax == WSAEWOULDBLOCK
invoke
GetDlgItem,hWinMain,IDOK
invoke
EnableWindow,eax,FALSE
.endif
.endif
invoke
GetDlgItem,hWinMain,IDC_INFO
mov
ebx,eax
invoke
GetWindowTextLength,ebx
invoke
SendMessage,ebx,EM_SETSEL,eax,eax
invoke
SendMessage,ebx,EM_REPLACESEL,\
FALSE,addr szReadBuffer
.endif
ret
_RecvData
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 将输入框中的文字发送出去
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_SendData
proc
local @szBuffer[1024]:byte
local @stSin:sockaddr_in
invoke
RtlZeroMemory,addr @stSin,sizeof @stSin
invoke
GetDlgItemText,hWinMain,IDC_SERVER,\
addr @szBuffer,sizeof @szBuffer
invoke
inet_addr,addr @szBuffer
.if
eax ==
INADDR_NONE
invoke
MessageBox,hWinMain,addr szErrIP,NULL,\
MB_OK or MB_ICONWARNING
jmp
@F
.endif
mov
@stSin.sin_addr,eax
mov
@stSin.sin_family,AF_INET
invoke
htons,UDP_PORT
mov
@stSin.sin_port,ax
invoke
GetDlgItemText,hWinMain,IDC_TEXT,\
addr @szBuffer,sizeof @szBuffer
invoke
lstrlen,addr @szBuffer
.if
eax
mov
ecx,eax
invoke
sendto,hSocket,addr @szBuffer,ecx,\
0,addr @stSin,sizeof sockaddr_in
.if
eax ==
SOCKET_ERROR
invoke
WSAGetLastError
.if
eax == WSAEWOULDBLOCK
invoke
GetDlgItem,hWinMain,IDOK
invoke
EnableWindow,eax,FALSE
.endif
.endif
.endif
@@:
invoke
SetDlgItemText,hWinMain,IDC_TEXT,NULL
ret
_SendData
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 初始化 Socket
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_Init
proc
local @stWsa:WSADATA
invoke
SetDlgItemText,hWinMain,IDC_SERVER,addr szIP
invoke
WSAStartup,101h,addr @stWsa
invoke
socket,AF_INET,SOCK_DGRAM,0
mov
hSocket,eax
invoke
WSAAsyncSelect,hSocket,hWinMain,WM_SOCKET,\
FD_READ or FD_WRITE
ret
_Init
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 主窗口程序
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_ProcDlgMain
proc
uses ebx edi esi hWnd,wMsg,wParam,lParam
mov
eax,wMsg
;********************************************************************
.if
eax ==
WM_SOCKET
mov
eax,lParam
.if
ax == FD_READ
invoke
_RecvData,wParam
.elseif ax == FD_WRITE
invoke
GetDlgItem,hWnd,IDOK
invoke
EnableWindow,eax,TRUE
.endif
;********************************************************************
.elseif eax ==
WM_COMMAND
mov
eax,wParam
.if
ax == IDOK
invoke
_SendData
.endif
;********************************************************************
.elseif eax ==
WM_CLOSE
invoke
closesocket,hSocket
invoke
WSACleanup
invoke
EndDialog,hWinMain,NULL
;********************************************************************
.elseif eax ==
WM_INITDIALOG
push
hWnd
pop
hWinMain
call
_Init
;********************************************************************
.else
mov
eax,FALSE
ret
.endif
mov
eax,TRUE
ret
_ProcDlgMain
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 程序开始
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
start:
invoke
GetModuleHandle,NULL
invoke DialogBoxParam,eax,DLG_MAIN,NULL,offset _ProcDlgMain,0
invoke
ExitProcess,NULL
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
end
start
通过UDP协议收发数据时使用数据报套接字。程序在一开始创建一个SOCK_DGRAM类型的套接字并将它设置为非阻塞模式,由于数据报套接字并没有连接和断开的过程,所以并不需要设置FD_CONNECT和FD_CLOSE等通知码,只需要设置FD_READ和FD_WRITE通知码就可以了。一般来说,UDP客户端程序的结构如图16.14所示,WinSock库装入、数据报套接字的创建和设置工作放在窗口的初始化消息中完成(如图16.14中的①)。
图16.14
UDP客户端程序的常见结构
1. 发送UDP数据报
当数据报套接字被创建以后,就可以直接通过它向服务器端发送数据,16.3节中介绍的send函数没有目标地址参数,所以并不适合用来发送无连接的、发送时直接指定目标地址的UDP数据报,一般使用sendto函数来发送UDP数据报:
invoke
sendto,s,lpbuf,len,flags,lpto,tolen
参数s指定用来发送数据的套接字句柄,lpbuf指向包含发送数据的缓冲区,len参数指定要发送的数据的长度,flags参数一般指定为0,参数lpto指向一个包含目标地址和端口号的sockaddr_in结构,tolen参数指定了这个结构的大小。由于使用sendto函数需要每次指定对方的地址,所以在例子中,程序每次在调用sendto函数之前首先获取对话框中输入的IP地址,填写到一个sockaddr_in结构中后再供sendto函数使用。
如果数据发送成功,函数将返回实际发送数据的字节数,否则函数返回SOCKET_ERROR,程序可以通过调用WSAGetLastError返回进一步的出错代码。
如果数据报过大,UDP协议不会自动将它分割成几个部分分别传送,对于这种情况,函数将执行失败并返回一个WSAEMSGSIZE出错代码,这时没有任何数据被发送,所以使用的时候要注意数据包最大不能超过预定义值SO_MAX_MSG_SIZE指定的大小。
当发送缓冲区满的时候,函数返回WSAEWOULDBLOCK错误,如图16.14的③,④所示,这时程序应该暂停发送直到收到FD_WRITE通知为止,这一点和使用TCP协议发送数据包时的处理方法是一样的。
对于数据报套接字来说,如果在对它调用sendto函数之前没有被bind函数绑定到一个固定的IP地址或端口上,那么这时它还没有被系统自动指派某个端口,这就意味着还无法使用它来接收数据(还没有地址和端口如何接收数据),直到第一次对它使用sendto函数来发送数据以后,系统才自动将它绑定到某个空闲的端口上,这时套接字才可用来接收数据。所以在图16.14中,必须首先有第②步的发送动作,才可能有第⑤步的接收动作,一个未经bind的数据报套接字在使用sendto函数之前是不可能接收到数据的。
当使用sendto函数时,如果返回的已发送数据数量和输入参数len 指定的值不同,则未发送的剩余数据需要被再次发送。
2. 接收UDP数据报
16.3节介绍的recv函数可以用来接收UDP数据包,但是recv函数接收数据时并不提供通信对端的地址,对于TCP协议来说这不是问题,因为TCP协议连接的时候已经确定了对方的IP地址,但UDP协议是无连接的,使用recv函数接收数据的话,程序将无法知道数据是从哪里发送过来的,这样也就无法将回复数据发回给发送方。
一般使用可以返回对端地址的recvfrom函数来接收UDP数据包:
invoke
recvfrom,s,lpbuf,len,flags,lpfrom,lpfromlen
参数s指定套接字句柄,lpbuf指向一个用来接收数据的缓冲区,len参数指定缓冲区的大小。flags参数为标志。这个函数不同于recv函数的是多出来的最后两个参数,lpfrom参数指向一个缓冲区,函数在这里返回一个包含数据发送方地址的sockaddr_in结构,lpfromlen指向一个双字变量,函数在这里返回前面的sockaddr_in结构的长度。
除了可以返回对端地址以外,函数的使用方法和返回值与recv函数是一样的。
在使用UDP协议进行通信的程序中,应当使用适当的方法来做一些简单的确认动作。读者可能会问:如果要确认那就使用TCP协议好了,为什么还要使用UDP协议呢?其实这里的确认指的是确认双方是否在线的简单动作,而不是去实现TCP协议所具有的所有特性。
比如,例子程序在收到FD_READ通知后,调用_RecvData子程序来接收服务器发送过来的聊天语句,然后向服务器发送一个4个字节的数值为–1的双字数据当做应答,这是因为UDP协议没有连接,服务器端在收到客户端发送的聊天语句时将IP地址和端口当做ID登记下来,然后当其他客户端发送聊天语句的时候根据登记表中的ID将语句转发给所有客户端。但是某个客户端退出的时候,服务器是收不到通知的,没有应答机制将使服务器的客户端列表中逐步充满无效的地址,从而影响新客户端的进入,而发送应答信号可以让服务器端将没有应答的客户端适时地从列表中清除。
那么为什么不在客户端退出的时候通知服务器端“我已退出”呢?这是因为这个通知信息本身也是不可靠的(可能丢失),另外,当客户端被非正常终止或者网络物理连接异常断开时将没有机会发送“我已退出”信息(这种情况对于TCP协议来说是可以被检测到的,服务器可以得到FD_CLOSE通知),所以在对每条聊天语句进行确认是最好的办法。
16.4.2
UDP聊天室例子——服务器端
这里是和前面的客户端程序配套的服务器端代码,资源脚本文件请参考TCP聊天服务器使用的Server.rc文件,它们的界面定义是相同的。汇编源代码Server.asm如下:
.386
.model flat, stdcall
option casemap :none ; case sensitive
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Include 数据
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
include
windows.inc
include
user32.inc
includelib
user32.lib
include
kernel32.inc
includelib
kernel32.lib
include
wsock32.inc
includelib
wsock32.lib
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; equ 数据
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN
equ
2000
IDC_INFO
equ
2001
IDC_COUNT
equ
2002
WM_SOCKET equ
WM_USER + 100
UDP_PORT
equ
9999
MAX_SOCKET
equ 100 ;聊天室最大容量
RETRY_TIMES
equ
5
;********************************************************************
CLIENT_ADDR
struct
dwClientIP
dd
?
.else
mov
eax,FALSE
ret
.endif
mov
eax,TRUE
ret
_ProcDlgMain
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 程序开始
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
start:
invoke
GetModuleHandle,NULL
invoke DialogBoxParam,eax,DLG_MAIN,NULL,offset _ProcDlgMain,0
invoke
ExitProcess,NULL
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
end
start
通过UDP协议收发数据时使用数据报套接字。程序在一开始创建一个SOCK_DGRAM类型的套接字并将它设置为非阻塞模式,由于数据报套接字并没有连接和断开的过程,所以并不需要设置FD_CONNECT和FD_CLOSE等通知码,只需要设置FD_READ和FD_WRITE通知码就可以了。一般来说,UDP客户端程序的结构如图16.14所示,WinSock库装入、数据报套接字的创建和设置工作放在窗口的初始化消息中完成(如图16.14中的①)。
图16.14
UDP客户端程序的常见结构
1. 发送UDP数据报
当数据报套接字被创建以后,就可以直接通过它向服务器端发送数据,16.3节中介绍的send函数没有目标地址参数,所以并不适合用来发送无连接的、发送时直接指定目标地址的UDP数据报,一般使用sendto函数来发送UDP数据报:
invoke
sendto,s,lpbuf,len,flags,lpto,tolen
参数s指定用来发送数据的套接字句柄,lpbuf指向包含发送数据的缓冲区,len参数指定要发送的数据的长度,flags参数一般指定为0,参数lpto指向一个包含目标地址和端口号的sockaddr_in结构,tolen参数指定了这个结构的大小。由于使用sendto函数需要每次指定对方的地址,所以在例子中,程序每次在调用sendto函数之前首先获取对话框中输入的IP地址,填写到一个sockaddr_in结构中后再供sendto函数使用。
如果数据发送成功,函数将返回实际发送数据的字节数,否则函数返回SOCKET_ERROR,程序可以通过调用WSAGetLastError返回进一步的出错代码。
如果数据报过大,UDP协议不会自动将它分割成几个部分分别传送,对于这种情况,函数将执行失败并返回一个WSAEMSGSIZE出错代码,这时没有任何数据被发送,所以使用的时候要注意数据包最大不能超过预定义值SO_MAX_MSG_SIZE指定的大小。
当发送缓冲区满的时候,函数返回WSAEWOULDBLOCK错误,如图16.14的③,④所示,这时程序应该暂停发送直到收到FD_WRITE通知为止,这一点和使用TCP协议发送数据包时的处理方法是一样的。
对于数据报套接字来说,如果在对它调用sendto函数之前没有被bind函数绑定到一个固定的IP地址或端口上,那么这时它还没有被系统自动指派某个端口,这就意味着还无法使用它来接收数据(还没有地址和端口如何接收数据),直到第一次对它使用sendto函数来发送数据以后,系统才自动将它绑定到某个空闲的端口上,这时套接字才可用来接收数据。所以在图16.14中,必须首先有第②步的发送动作,才可能有第⑤步的接收动作,一个未经bind的数据报套接字在使用sendto函数之前是不可能接收到数据的。
当使用sendto函数时,如果返回的已发送数据数量和输入参数len 指定的值不同,则未发送的剩余数据需要被再次发送。
2. 接收UDP数据报
16.3节介绍的recv函数可以用来接收UDP数据包,但是recv函数接收数据时并不提供通信对端的地址,对于TCP协议来说这不是问题,因为TCP协议连接的时候已经确定了对方的IP地址,但UDP协议是无连接的,使用recv函数接收数据的话,程序将无法知道数据是从哪里发送过来的,这样也就无法将回复数据发回给发送方。
一般使用可以返回对端地址的recvfrom函数来接收UDP数据包:
invoke
recvfrom,s,lpbuf,len,flags,lpfrom,lpfromlen
参数s指定套接字句柄,lpbuf指向一个用来接收数据的缓冲区,len参数指定缓冲区的大小。flags参数为标志。这个函数不同于recv函数的是多出来的最后两个参数,lpfrom参数指向一个缓冲区,函数在这里返回一个包含数据发送方地址的sockaddr_in结构,lpfromlen指向一个双字变量,函数在这里返回前面的sockaddr_in结构的长度。
除了可以返回对端地址以外,函数的使用方法和返回值与recv函数是一样的。
在使用UDP协议进行通信的程序中,应当使用适当的方法来做一些简单的确认动作。读者可能会问:如果要确认那就使用TCP协议好了,为什么还要使用UDP协议呢?其实这里的确认指的是确认双方是否在线的简单动作,而不是去实现TCP协议所具有的所有特性。
比如,例子程序在收到FD_READ通知后,调用_RecvData子程序来接收服务器发送过来的聊天语句,然后向服务器发送一个4个字节的数值为–1的双字数据当做应答,这是因为UDP协议没有连接,服务器端在收到客户端发送的聊天语句时将IP地址和端口当做ID登记下来,然后当其他客户端发送聊天语句的时候根据登记表中的ID将语句转发给所有客户端。但是某个客户端退出的时候,服务器是收不到通知的,没有应答机制将使服务器的客户端列表中逐步充满无效的地址,从而影响新客户端的进入,而发送应答信号可以让服务器端将没有应答的客户端适时地从列表中清除。
那么为什么不在客户端退出的时候通知服务器端“我已退出”呢?这是因为这个通知信息本身也是不可靠的(可能丢失),另外,当客户端被非正常终止或者网络物理连接异常断开时将没有机会发送“我已退出”信息(这种情况对于TCP协议来说是可以被检测到的,服务器可以得到FD_CLOSE通知),所以在对每条聊天语句进行确认是最好的办法。
16.4.2
UDP聊天室例子——服务器端
这里是和前面的客户端程序配套的服务器端代码,资源脚本文件请参考TCP聊天服务器使用的Server.rc文件,它们的界面定义是相同的。汇编源代码Server.asm如下:
.386
.model flat, stdcall
option casemap :none ; case sensitive
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Include 数据
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
include
windows.inc
include
user32.inc
includelib
user32.lib
include
kernel32.inc
includelib
kernel32.lib
include
wsock32.inc
includelib
wsock32.lib
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; equ 数据
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
DLG_MAIN
equ
2000
IDC_INFO
equ
2001
IDC_COUNT
equ
2002
WM_SOCKET equ
WM_USER + 100
UDP_PORT
equ
9999
MAX_SOCKET
equ 100 ;聊天室最大容量
RETRY_TIMES
equ
5
;********************************************************************
CLIENT_ADDR
struct
dwClientIP
dd
?
wClientPort
dw
?
dwID
dd
?
dwRetryTimes
dd
?
CLIENT_ADDR
ends
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 数据段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.data?
hWinMain
dd
?
hSocket
dd
?
dwID
dd
?
szReadBuffer
db
32768 dup (?)
szBuffer
db
32768 dup (?)
stTable
CLIENT_ADDR MAX_SOCKET dup (<>)
.const
szErrBind db
~无法绑定到UDP端口9 999,请检查是否有其他程序在使用!~,0
szFormat
db
~【客户端#%d】- %s~,0dh,0ah,0
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 代码段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.code
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 将有回复的客户端(表示还在聊天中)地址添加到列表中
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_AddSocket
proc
_lpSocket
;********************************************************************
; 查找地址是否存在,已存在则不必添加且重新设置应答计数器
;********************************************************************
mov
esi,offset stTable
mov edi,_lpSocket
assume
esi:ptr CLIENT_ADDR
assume
edi:ptr sockaddr_in
xor
ebx,ebx
.while
ebx < MAX_SOCKET
mov
ax,[edi].sin_port
mov
ecx,[edi].sin_addr
.if
(ax == [esi].wClientPort)\
&& (ecx == [esi].dwClientIP)
mov
[esi].dwRetryTimes,5
ret
.endif
add
esi,sizeof CLIENT_ADDR
inc
ebx
.endw
;********************************************************************
; 否则在客户端地址表中找一个空的表项,并添加当前地址
;********************************************************************
mov
esi,offset stTable
xor
ebx,ebx
.while
ebx < MAX_SOCKET
.if
! [esi].dwRetryTimes
push
esi
inc
dwID
push
dwID
pop
[esi].dwID
mov
[esi].dwRetryTimes,RETRY_TIMES
push
[edi].sin_addr
pop
[esi].dwClientIP
mov
ax,[edi].sin_port
mov
[esi].wClientPort,ax
pop
esi
ret
.endif
add
esi,sizeof CLIENT_ADDR
inc
ebx
.endw
xor
esi,esi
assume
esi:nothing
assume
edi:nothing
ret
_AddSocket
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 处理接收到的UDP包
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_RecvData
proc
_hSocket
local @dwRecv,@dwStructSize
local @stSin:sockaddr_in
local @dwCount
mov
@dwStructSize,sizeof sockaddr_in
invoke
RtlZeroMemory,addr szReadBuffer,sizeof szReadBuffer
invoke
RtlZeroMemory,addr @stSin,sizeof @stSin
invoke
recvfrom,_hSocket,addr szReadBuffer,sizeof szReadBuffer,\
0,addr @stSin,addr @dwStructSize
.if
eax !=
SOCKET_ERROR
mov
@dwRecv,eax
;********************************************************************
; 登记客户端ID,并转换字符串格式
;********************************************************************
invoke
_AddSocket,addr @stSin
.if
! esi
ret
.endif
assume
esi:ptr CLIENT_ADDR
invoke
wsprintf,addr szBuffer,addr szFormat,\
dword ptr [esi].dwID,addr szReadBuffer
;********************************************************************
; 如果不是回复字符 -1 的话,则按照客户端列表逐一发送
;********************************************************************
.if
dword ptr szReadBuffer != -1
invoke
GetDlgItem,hWinMain,IDC_INFO
mov
ebx,eax
invoke
GetWindowTextLength,ebx
invoke
SendMessage,ebx,EM_SETSEL,eax,eax
invoke
SendMessage,ebx,EM_REPLACESEL,\
FALSE,addr szBuffer
mov
esi,offset stTable
xor
ebx,ebx
mov
@dwCount,ebx
;********************************************************************
.while
ebx < MAX_SOCKET
.if
dword ptr [esi].dwRetryTimes
push
[esi].dwClientIP
pop
@stSin.sin_addr
mov
ax,[esi].wClientPort
mov
@stSin.sin_port,ax
invoke
lstrlen,addr szBuffer
mov
ecx,eax
invoke
sendto,hSocket,addr szBuffer,ecx,\
0,addr @stSin,sizeof sockaddr_in
dec
dword ptr [esi].dwRetryTimes
.if
dword ptr [esi].dwRetryTimes
inc
@dwCount
.else
invoke
RtlZeroMemory,esi,\
sizeof CLIENT_ADDR
.endif
.endif
add
esi,sizeof CLIENT_ADDR
inc
ebx
.endw
invoke
SetDlgItemInt,hWinMain,IDC_COUNT,@dwCount,FALSE
;********************************************************************
.endif
assume
esi:nothing
.endif
ret
_RecvData
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 初始化 Socket,绑定到服务UDP端口并监听
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_Init
proc
local @stWsa:WSADATA
local @stSin:sockaddr_in
invoke
WSAStartup,101h,addr @stWsa
invoke
socket,AF_INET,SOCK_DGRAM,0
mov
hSocket,eax
invoke
WSAAsyncSelect,hSocket,hWinMain,WM_SOCKET,FD_READ
invoke
RtlZeroMemory,addr @stSin,sizeof @stSin
invoke
htons,UDP_PORT
mov
@stSin.sin_port,ax
mov
@stSin.sin_family,AF_INET
mov
@stSin.sin_addr,INADDR_ANY
invoke
bind,hSocket,addr @stSin,sizeof @stSin
.if
eax ==
SOCKET_ERROR
invoke
MessageBox,hWinMain,addr szErrBind,NULL,\
MB_OK or MB_ICONWARNING
invoke
SendMessage,hWinMain,WM_CLOSE,0,0
.endif
ret
_Init
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 主窗口程序
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_ProcDlgMain
proc
uses ebx edi esi hWnd,wMsg,wParam,lParam
mov
eax,wMsg
.if
eax ==
WM_SOCKET
mov
eax,lParam
.if
ax == FD_READ
invoke
_RecvData,wParam
.endif
;********************************************************************
.elseif eax ==
WM_CLOSE
invoke
closesocket,hSocket
invoke
WSACleanup
invoke
EndDialog,hWinMain,NULL
;********************************************************************
.elseif eax ==
WM_INITDIALOG
push
hWnd
pop
hWinMain
call
_Init
;********************************************************************
.else
mov
eax,FALSE
ret
.endif
mov
eax,TRUE
ret
_ProcDlgMain
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 程序开始
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
start:
invoke
GetModuleHandle,NULL
invoke DialogBoxParam,eax,DLG_MAIN,NULL,offset _ProcDlgMain,0
invoke
ExitProcess,NULL
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
end
start
当看到这里的时候,读者对于FD_XXX之类的通知码的处理,以及收发数据函数的使用等一定不会陌生了,所以在这里不再对这些内容进行过多的解释。
和UDP聊天室客户端的代码相比,服务器端代码的主要差别有两点:首先是服务器端的数据报套接字必须使用bind函数绑定到一个固定的服务端口上,这样才能接收客户端发送的数据;第二就是必须维护一个客户端ID列表,以便向所有的客户端转发聊天语句。
如果和TCP聊天室的服务器端代码相比,其差别有三点:首先是UDP服务器从开始到结束始终只使用一个数据报套接字来接收所有客户端发过来的数据,也用它来向所有客户端转发数据,而TCP服务器使用一个套接字进行监听,并由accept函数返回很多和客户端相连的新套接字;第二点就是TCP服务器可以用accept函数得到的套接字句柄来惟一确定客户端,而UDP服务器只能通过recvfrom接收数据时得到的IP地址和端口地址来确定客户端;最后一点是对客户端退出的处理上,TCP服务器只要检测到和客户端连接的套接字发出FD_CLOSE通知,就可以将它从列表中删除,而UDP服务器必须规定客户端在收到数据后回发一个确认数据,如果多次得不到确认(次数可以定义),则将客户端从列表中删除,因为UDP数据包可能丢失,得不到一次确认并不意味着客户端就没有发出过确认信息。
根据这些不同点,让我们来看具体在源程序中是怎样处理的。
例子程序为客户端列表中的项目定义了一个CLIENT_ADDR结构,结构定义如下:
CLIENT_ADDR
struct
dwClientIP
dd
? ;客户端IP地址
wClientPort
dw
? ;客户端端口
dwID
dd
? ;序号
dwRetryTimes
dd
? ;重试次数
CLIENT_ADDR
ends
结构中包括客户端的IP地址和端口,这两个字段用于惟一确定客户端,dwID字段仅用做显示,dwRetryTimes用做客户端应答计数器,原始值为最大允许的未应答次数(例子中定义为5次),每次向客户端发送数据后,如果没有得到应答,那么这个计数器减1,如果计数器减到0则视为客户端离线而将它从列表中删除,但是一旦在减到0之前得到回应,那么计数器被恢复到最大的允许值。
每次收到FD_READ通知时,表明有聊天语句或应答信号从客户端发过来,程序调用_RecvData子程序来接收数据,收到数据以后,程序首先按照发送方的地址和端口扫描列表(在_AddSocket子程序中完成),如果这个地址已经在列表中存在,则将对应的应答计数器恢复到最大值,否则找出一个空列表项将它登记进去。
接下来,程序判断收到的数据是否是定义为应答信号的–1,如果不是应答信号,表明这是一个聊天语句,这时程序用一个循环按照列表中的地址向所有客户端转发这个语句,每次向一个客户端转发以后,程序将列表中这个客户端的应答计数器减1,当发现减到0的时候将它从列表中删除。
由于服务器端程序要等待一定的无应答次数以后才将一个客户端视为离线,所以当一个客户端程序关闭的时候,服务器端程序最下面的“当前连线客户端数量”中显示的数值并不会马上变化,直到经过好几句聊天语句以后这个数值才会减少。
16.5 ICMP协议编程
前面几节中已经讨论了以下内容:
● WinSock函数的基本使用方法。
● 非阻塞模式下网络通信程序的结构。
● 在应用程序层次上使用流套接字和数据报套接字进行通信。
● 网络通信中的客户机/服务器模型以及它们的实现。
● TCP协议和UDP协议的使用。
在这一节中将继续讨论下列内容:
● 阻塞模式下网络通信程序的结构。
● 在IP协议层次上使用原始套接字进行通信。
● 主机名到IP地址的解析。
● ICMP协议的使用。
为了说明这些内容,本节使用一个控制台界面的Ping程序来当做例子,控制台程序的结构类似于DOS方式下的顺序执行方式,非常适合用于阻塞型的通信程序。有关控制台的操作比较简单,所以本书前面的内容中并没有用独立的章节来介绍,在这里首先以一个小节的篇幅补一补课。
16.5.1
题外话:控制台程序
随着用户界面设计的发展,人机交互的方式同过去也有了很大的不同,主流的用户界面已经从DOS下的字符终端界面发展成图形的窗口界面,但是在一些特定的程序中,过去这种老式的界面还是有着很大的用途。实际上,Windows中也保留了这种字符终端形式的界面,使用这种界面的程序被称为控制台程序。
控制台界面最主要的用途是用于网络的远程维护中。进行远程维护时一般使用Telnet等工具登录到远程主机并在上面执行命令,如果执行的是图形界面的程序,这个界面是无法远程操作的,所以我们可以发现Windows中用于网络的命令大多数是控制台界面的,如Ping,Netstat,Tracert,Arp,Route,Ipconfig和Finger等,与此相比,很难想像类似于Office一类的软件会用在远程操作中。另外在Windows系统中,以前的16位DOS程序也采用控制台窗口的方式执行。
但是不要因为控制台程序和老的DOS程序在运行界面上的相似性就认为它们是DOS下的16位exe文件,实际上,它们是不折不扣的32位的PE程序,它们可以使用Win32 API函数,文件头中同样有导入表和导出表,可以在程序中建立多个线程执行,总之,除了界面操作上的不同外,它们可以使用Win32编程中的所有东西。进一步来说,如果一定要让它有一个窗口的话,也可以在其中使用CreateWindow函数来创建一个窗口,这样控制台程序可以在使用终端界面输入输出的同时使用窗口上的菜单来操作(但估计没有人会做这样的事情)。
下面是一个包含了控制台操作中常用代码的_Console.asm文件,这个文件会用在后面的Ping例子中:
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 控制台程序的公用子程序
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.data?
hStdIn
dd
? ;控制台输入句柄
hStdOut dd
? ;控制台输出句柄
.code
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 控制台 Ctrl+C 捕获例程
; 这个子程序和Ping例子的主程序配合使用,dwOption变量在Ping.asm中定义
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_CtrlHandler
proc
_dwCtrlType
pushad
mov
eax,_dwCtrlType
.if
eax ==
CTRL_C_EVENT || eax == CTRL_BREAK_EVENT
or
dwOption,F_ABORT
.endif
popad
mov
eax,TRUE
ret
_CtrlHandler
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 控制台初始化
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_ConsoleInit
proc
invoke
GetStdHandle,STD_INPUT_HANDLE
mov
hStdIn,eax
invoke
GetStdHandle,STD_OUTPUT_HANDLE
mov
hStdOut,eax
invoke
SetConsoleCtrlHandler,addr _CtrlHandler,TRUE
ret
_ConsoleInit
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 控制台输出子程序
; 注意: 用 WriteConsole 输出则执行时无法用 > 重定向到文件
; 因此用 WriteFile
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_ConsolePrint proc
_lpsz
local @dwCharWritten
pushad
invoke
lstrlen,_lpsz
lea
ecx,@dwCharWritten
invoke
WriteFile,hStdOut,_lpsz,eax,ecx,NULL
popad
ret
_ConsolePrint endp
1. 控制台的输入和输出句柄
和DOS程序的界面类似,控制台程序的输入输出都在一个终端类型的窗口中完成,这个终端窗口可以用一个句柄来引用,获取这个句柄可以使用GetStdHandle函数:
invoke
GetStdHandle,nStdHandle
.if eax != INVALID_HANDLE_VALUE
mov hStd,eax
.endif
虽然控制台程序的输入输出都在这个终端窗口中完成,但由于用于输入和输出的句柄是不同的,为了获取不同的句柄,nStdHandle参数需要指定为以下不同的取值:
● STD_INPUT_HANDLE——标准输入句柄。
● STD_OUTPUT_HANDLE——标准输出句柄。
● STD_ERROR_HANDLE——标准出错信息句柄。
函数执行成功将返回对应的句柄,否则将返回INVALID_HANDLE_VALUE值。在上面的控制台初始化子程序_ConsoleInit中,程序分别获取输入和输出句柄并保存到hStdIn和hStdOut变量中以供后用。
2. 控制台窗口的输入和输出
如果需要向控制台窗口输出文本的话,可以使用两种方法,第一种方法是使用专用的控制台输出函数WriteConsole:
invoke
WriteConsole,hConsoleOutput,lpBuffer,\
nNumberOfCharsToWrite,lpNumberOfCharsWritten,lpReserved
参数hConsoleOutput指定为前面获取的标准输出句柄,lpBuffer参数指向输出的内容,nNumberOfCharsToWrite参数为要输出的数据长度,lpNumberOfCharsWritten指向一个双字变量,用来返回实际显示的数据长度。
另一个方法就是将控制台句柄当做文件句柄来使用,使用WriteFile函数就可以将输出内容“写”到控制台窗口上,正如上面的_ConsolePrint子程序所演示的那样。使用这两种方法的区别是:WriteConsole函数不支持输出内容的重定向;而WriteFile函数支持重定向。使用命令行方式的管道操作符可将内容重定向到一个文件中,如下面的命令将Ping的结果存放到result.txt文件中:
ping www. > result.txt
但是,如果使用WriteConsole函数来输出,那么结果仍然会被显示在控制台窗口中,而不是被定向到result.txt文件中去。如果程序希望某些内容可以重定向,而某些内容不允许重定向,那么可以混合使用这两种方法。
从控制台窗口接收键盘输入可以用ReadConsole或ReadFile两种方式来完成,ReadConsole函数的使用方法是:
invoke
ReadConsole,hConsoleInput,lpBuffer,\
nNumberOfCharsToRead,lpNumberOfCharsRead,lpReserved
hConsoleInput参数为控制台的标准输入句柄,lpBuffer指向用来接收输入数据的缓冲区,nNumberOfCharsToRead参数指定要读取的数据长度,lpNumberOfCharsRead指向一个双字,函数在这里返回实际读取的字节数。
3. 截获Ctrl+Break
在控制台程序中往往需要截获Ctrl+Break(或Ctrl+C)的组合键来判断是否要中途退出,这在DOS时代的程序中靠截获Int 23h中断来实现,但在Win32中不能再使用这种方法。
Win32控制台程序使用SetConsoleCtrlHandler函数来将Ctrl+Break的处理代码重新定义到自己指定的子程序中:
invoke
SetConsoleCtrlHandler,HandlerRoutine,Add
HandlerRoutine参数指定处理Ctrl+Break按键的子程序地址,Add参数指定为TRUE的时候,系统将设置这个子程序为Ctrl+Break处理程序,如果指定为FALSE,系统将取消这个设置。HandlerRoutine参数也可以为NULL,这时当Add参数设置为TRUE,系统将忽略对Ctrl+Break的处理,设置为FALSE的话系统将恢复原来的处理方式。
上面例子中的_ConsoleInit子程序使用SetConsoleCtrlHandler函数将处理子程序设置到_CtrlHandler中。
Ctrl+Break的处理子程序必须按照规定的格式定义:
HandlerRoutine
proc
dwCtrlType
该子程序有一个输入参数dwCtrlType,系统调用该子程序的时候将使用这个参数指明发生事件的类型,事件类型可能是以下几种:
● CTRL_C_EVENT——收到Ctrl+C字符。
● CTRL_BREAK_EVENT——收到Ctrl+Break字符。
● CTRL_CLOSE_EVENT——用户关闭了控制台窗口(比如按下了控制台窗口上面的关闭按钮或在控制台窗口的菜单上选择了“关闭”等)。
● CTRL_LOGOFF_EVENT——当前用户注销。
● CTRL_SHUTDOWN_EVENT——系统准备关闭。
在前面的例子代码中,_CtrlHandler子程序仅处理CTRL_BREAK_EVENT事件以及CTRL_C_EVENT事件,并在检测到这两个事件的时候设置程序中定义的dwOption变量,以便在主程序的循环中能够通过检测这个变量而退出程序。
4. 控制台程序的编译和链接
在控制台程序中如何创建一个控制台窗口呢?实际上这个窗口不是由控制台程序而是由Windows操作系统自动创建的,程序中并不需要自己去创建。
控制台程序和非控制台程序的PE文件头中的标志是不同的,当系统执行一个文件时首先对文件进行检测,如果是控制台程序就为它建立一个控制台窗口,而对非控制台程序则不作处理。
程序的PE头中的控制台标志是由链接程序设置的,所以指定一个程序是控制台程序的操作是在Link命令中实现的,如Ping例子的makefile文件如下:
NAME = Ping
OBJS = $(NAME).obj
$(NAME).exe: $(OBJS)
Link /SUBSYSTEM:CONSOLE $(OBJS)
$(NAME).obj: $(NAME).asm
ml /c /coff $(NAME).asm
如果链接的时候将Link.exe的subsystem选项指定为console,那么可执行文件就会被链接成控制台程序。
一个有趣的测试就是将一个标准的窗口界面程序按照/subsystem:console选项链接为控制台程序,运行以后就会发现:程序原来图形界面的工作不会有任何异常,但是屏幕上同时会有一个控制台窗口出现,这个窗口就是Windows主动搭配给它的。当然由于程序不会往上面输出信息,这个窗口中自始至终不会有任何东西出现。这也说明了控制台程序中可以使用任何Win32编程中的特征。
16.5.2
ICMP协议
1. ICMP协议简介
ICMP协议是Internet Control Message Protocol(网际控制消息协议)的缩写,它是由RFC 792文件定义的。ICMP协议用于处理错误消息和控制消息,当一个IP数据报在网络上的传输发生错误时,错误发送点的主机或路由器往往通过一个ICMP消息来通知数据报的发送者。
ICMP错误消息在以下几种情况下发送:数据报不能到达目的地时;网关已经失去缓存功能;网关能够引导主机在更短路由上发送。虽然ICMP协议用于传递错误消息,但它的本意并不是由此让IP协议变得可靠,而是仅为了当网络出现问题的时候能够返回信息而已。
ICMP协议使用IP协议做底层支持,当一个程序发送ICMP数据包的时候,这个数据包最后是被封装进一个IP数据报中传递的,从这个意义上看,ICMP协议似乎应该和TCP和UDP等传输层协议处于同一个层次,但是实际上它被定义在Internet层上,这是因为ICMP协议是当做IP协议的附属协议定义的,它由IP模块来实现,而且更多的情况下是被IP模块自己用来传输错误消息,所以在图15.1的TCP/IP模型中,ICMP协议是在IP协议的一个角上。
ICMP协议是无连接的,而且并不使用端口进行复用,从编程角度来看,只要发送端完成ICMP报文的封装并指定一个IP地址发送出去,这个报文就可以像UDP包一样自己到达目标地址。
2. ICMP报文的类型
在MASM32软件包的Windows.inc文件中,ICMP首部被定义成icmp_hdr结构:
icmp_hdr STRUCT
icmp_type
BYTE
?
;类型
icmp_code
BYTE
?
;代码
icmp_cksum WORD
?
;校验和
icmp_id
WORD
?
icmp_seq WORD
?
icmp_data
BYTE
?
icmp_hdr ENDS
其中icmp_type和icmp_code字段用来指定这个ICMP报文的用途,icmp_cksum 字段为整个报文的校验和,接下来的几个字段icmp_id,icmp_seq和icmp_data的定义可能是不确定的,因为它们实际上属于报文内容的一部分,而不同用途的报文其内容的定义方式是不同的。
正如前面所述,ICMP报文用于传递错误消息和控制消息,错误消息是当发出的IP报文段在网络上出现某种错误的时候,由错误发生点的设备发回来的;而控制消息用于当前主机主动向其他设备发送一个ICMP查询消息,对方会根据这个消息送回相应的回复消息,如Ping程序发送的就是“请求回显”ICMP消息,对方主机收到后送回一个“回显应答”ICMP消息。
消息的用途是由ICMP首部的类型和代码组合定义的,详细的定义请参考表16.3,表中同时列出了消息所属的类型。
表16.3
ICMP报文类型
类
型
代
码
作
用
类
型
0
0
回显应答
查询
3
0
网络不可达
错误消息
3
1
主机不可达
错误消息
3
2
协议不可达
错误消息
3
3
端口不可达
错误消息
3
4
需要进行分片但设置了不分片位
错误消息
3
5
源站选路失败
错误消息
3
6
目标网络不认识
错误消息
3
7
目标主机不认识
错误消息
3
9
目标网络被强制禁止
错误消息
3
10
目标主机被强制禁止
错误消息
3
11
由于服务类型TO S ,网络不可达
错误消息
3
12
由于服务类型TO S ,主机不可达
错误消息
3
13
由于过滤,通信被强制禁止
错误消息
3
14
主机越权
错误消息
3
15
优先权中止生效
错误消息
4
0
源端被关闭
错误消息
5
0
对网络重定向
错误消息
5
1
对主机重定向
错误消息
5
2
对服务类型和网络重定向
错误消息
5
3
对服务类型和主机重定向
错误消息
8
0
请求回显
查询
9
0
路由器通告
查询
10
0
路由器请求
查询
11
0
传输期间生存时间为0
错误消息
类
型
代
码
作
用
类
型
11
1
在数据报组装期间生存时间为0
错误消息
12
0
坏的IP首部
错误消息
12
1
缺少必需的选项
错误消息
13
0
时间戳请求
查询
14
0
时间戳应答
查询
17
0
地址掩码请求
查询
18
0
地址掩码应答
查询
从表中可以看出,用于查询的ICMP消息是一一对应的,如向其他主机发送“请求回显”消息,得到的回应会是“回显应答”消息;而发送“时间戳请求”的话,得到的回应会是“时间戳应答”消息;“地址掩码应答”消息是和“地址掩码请求”消息对应的。Ping程序正是通过向其他主机发送“请求回显”消息,然后根据能否收到“回显应答”消息来判断目标主机是否在线的。
3. 校验和的计算
在发送ICMP报文的时候,必须由程序自己计算ICMP报文的校验和并将它填入ICMP首部的对应字段中,如果校验和错误的话,这个ICMP报文到达下一个路由器的时候就会被认为是在传输中发生错误而丢弃。下面是一个计算报文校验和的子程序,输入参数_lpsz指向要计算的数据,_dwSize参数指定数据的长度,子程序将返回计算完成的校验和,这个子程序存放在_CheckSum.asm中。子程序代码如下:
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 计算数据包的校验和
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_CalcCheckSum proc
_lpsz,_dwSize
local @dwSize
pushad
mov
ecx,_dwSize
shr
ecx,1
xor
ebx,ebx
mov
esi,_lpsz
;********************************************************************
; 数据包校验和为每 16 位累加
;********************************************************************
cld
@@:
lodsw
movzx eax,ax
add
ebx,eax
loop
@B
;********************************************************************
; 最后如果有单 8 位则继续累加
;********************************************************************
test
_dwSize,1
jz
@F
lodsb
movzx eax,al
add
ebx,eax
@@:
;********************************************************************
; 将高 16 位并入低 16 位后取反输出
;********************************************************************
mov
eax,ebx
and
eax,0ffffh
shr
ebx,16
add
eax,ebx
not
ax
mov
@dwSize,eax
popad
mov
eax,@dwSize
ret
_CalcCheckSum endp
正如程序中所示,校验和的计算方法是将数据以字为单位累加到一个双字中,如果数据的长度是奇数,那么最后一个字节将被扩展到字以后再进行累加。累加的结果是32位的,而ICMP首部的校验和字段是16位的,校验和最终被定义为将累加结果的高16位和低16位相加后取反。在计算校验和之前,ICMP首部中的校验和字段要被首先设置为0。
16.5.3
一个Ping程序例子
好了,有了前面这些介绍,现在终于可以转入正题了,让我们来看Ping程序的源代码,代码存放在所附光盘的Chapter15\Ping目录中,目录中存放有前面列出的_CheckSum.asm和_Console.asm文件。由于控制台程序要处理命令行参数,所以程序也使用了第13章例子代码中的_CmdLine.asm文件。主文件Ping.asm的内容如下:
.386
.model flat, stdcall
option casemap :none
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Include
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
include windows.inc
include kernel32.inc
includelib
kernel32.lib
include
user32.inc
includelib user32.lib
include
wsock32.inc
includelib wsock32.lib
PACKET_SIZE
equ
32
;包大小默认为 32 字节
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 数据段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.data?
szHostName db
100 dup (?)
szBuffer db
1024 dup (?)
szBigBuffer
db 65536 dup (?) ;接收 ICMP_REPLY 的大缓冲区
stWsa
WSADATA
<>
;********************************************************************
; 标志及命令行参数
;********************************************************************
dwOption dd
?
F_ABORT
equ
0001h ;按了 Ctrl+C 终止
.data
szHelp db
~Usage: ping hostname~,0dh,0ah,0ah
db
~example:~,0dh,0ah
db
~
ping 127.0.0.1~,0dh,0ah
db
~
ping www.,0dh,0ah,0
szErrHost
db
~Unknown host [%s]~,0dh,0ah,0
szErrSocket
db
~Socket error.~,0dh,0ah,0
szErrTimeout db
~Request timed out.~,0dh,0ah,0
szErrUnreach db
~Destination host unreachable.~,0dh,0ah,0
szHostOneIP
db
~The IP address of [%s] is %s~,0dh,0ah,0
szPingOneIP
db
~Ping %s with 32 bytes of data:~,0dh,0ah,0ah,0
szHostMoreIP db
~The host [%s] has %d IP addresses:~,0dh,0ah,0
szPingMoreIP db
0dh,0ah,~Ping first IP %s ~,
db ~with 32 bytes of data:~,0dh,0ah,0ah,0
szSpar db
~ / ~,0
szReply
db
~Reply from %s: bytes=%d time=%dms TTL=%d~,0dh,0ah,0
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; 代码段
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
.code
include
_CmdLine.asm
include
_Console.asm
include
_CheckSum.asm
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_HostnameToIP proc
_lpszHostName
local @szBuffer[256]:byte
local @dwIP
invoke
inet_addr,_lpszHostName
.if
eax !=
INADDR_NONE
;********************************************************************
; 输入的是IP地址
;********************************************************************
mov
@dwIP,eax
invoke
inet_ntoa,eax
invoke
wsprintf,addr szBuffer,addr szPingOneIP,eax
.else
;********************************************************************
; 输入的是主机名称
;********************************************************************
invoke
gethostbyname,_lpszHostName
.if
eax
xor edi,edi ;用edi做计数器
mov
eax,[eax+hostent.h_list]
.while
dword ptr [eax]
mov
ebx,[eax]
push
[ebx]
inc
edi
add
eax,4
.endw
pop
eax
mov
@dwIP,eax
invoke
inet_ntoa,eax
mov
ebx,eax
.if edi ==
1 ;主机对应一个IP地址
invoke
wsprintf,addr szBuffer,\
addr szHostOneIP,_lpszHostName,ebx
invoke
wsprintf,addr @szBuffer,\
addr szPingOneIP,ebx
.else ;主机对应多个IP地址
invoke
wsprintf,addr szBuffer,\
addr szHostMoreIP,_lpszHostName,edi
invoke
lstrcat,addr szBuffer,ebx
invoke
wsprintf,addr @szBuffer,\
addr szPingMoreIP,ebx
.while
edi > 1
invoke
lstrcat,addr szBuffer,\
addr szSpar
pop eax
invoke
inet_ntoa,eax
invoke
lstrcat,addr szBuffer,eax
dec
edi
.endw
.endif
invoke
lstrcat,addr szBuffer,addr @szBuffer
.else
invoke
wsprintf,addr szBuffer,\
addr szErrHost,addr szHostName
invoke
_ConsolePrint,addr szBuffer
xor
eax,eax
ret
.endif
.endif
invoke
_ConsolePrint,addr szBuffer
mov
eax,@dwIP
ret
_HostnameToIP endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Ping 主程序
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_Ping
proc
_dwIP
local @szBuffer[256]:byte
local @stDest:sockaddr_in
local @stFrom:sockaddr_in
local @hSocket,@dwSize
local @stFdSet:fd_set
local @stTimeval:timeval
local @dwID:word,@dwSeq:word
pushad
mov
@stDest.sin_port,0
mov
@stDest.sin_family,AF_INET
push
_dwIP
pop
@stDest.sin_addr
;********************************************************************
; 初始化一个 socket 发送 ICMP 的 RAW 数据
;********************************************************************
invoke
socket,AF_INET,SOCK_RAW,IPPROTO_ICMP
.if
eax ==
INVALID_SOCKET
invoke
_ConsolePrint,addr szErrSocket
jmp
_Ping_Ret
.endif
mov
@hSocket,eax
;********************************************************************
; 循环 Ping
;********************************************************************
xor
ebx,ebx
mov
@dwID,1
mov
@dwSeq,1
.while
TRUE
.break
.if (dwOption & F_ABORT) || (ebx >= 4)
inc
ebx
assume
esi:ptr icmp_hdr
mov
esi,offset szBigBuffer
invoke
RtlZeroMemory,esi,sizeof szBigBuffer
;********************************************************************
; 构造 Echo Request 数据包
;********************************************************************
mov
ax,@dwID
mov
[esi].icmp_id,ax
mov
ax,@dwSeq
mov
[esi].icmp_seq,ax
mov [esi].icmp_type,ICMP_ECHOREQ
invoke
GetTickCount
mov dword ptr [esi].icmp_data,eax ;将当前时间当做数据
mov
ecx,PACKET_SIZE
add
ecx,sizeof icmp_hdr-1
invoke
_CalcCheckSum,addr szBigBuffer,ecx
mov
[esi].icmp_cksum,ax
;********************************************************************
; 发送 Echo Request 数据包
;********************************************************************
invoke
sendto,@hSocket,addr szBigBuffer,ecx,\
0,addr @stDest,sizeof sockaddr_in
.if
eax == SOCKET_ERROR
invoke
_ConsolePrint,addr szErrUnreach
.continue
.endif
assume
esi:nothing
;********************************************************************
; 等待回复
;********************************************************************
@@:
mov
@stFdSet.fd_count,1
push
@hSocket
pop
@stFdSet.fd_array
local @szBuffer[256]:byte
local @dwIP
invoke
inet_addr,_lpszHostName
.if
eax !=
INADDR_NONE
;********************************************************************
; 输入的是IP地址
;********************************************************************
mov
@dwIP,eax
invoke
inet_ntoa,eax
invoke
wsprintf,addr szBuffer,addr szPingOneIP,eax
.else
;********************************************************************
; 输入的是主机名称
;********************************************************************
invoke
gethostbyname,_lpszHostName
.if
eax
xor edi,edi ;用edi做计数器
mov
eax,[eax+hostent.h_list]
.while
dword ptr [eax]
mov
ebx,[eax]
push
[ebx]
inc
edi
add
eax,4
.endw
pop
eax
mov
@dwIP,eax
invoke
inet_ntoa,eax
mov
ebx,eax
.if edi ==
1 ;主机对应一个IP地址
invoke
wsprintf,addr szBuffer,\
addr szHostOneIP,_lpszHostName,ebx
invoke
wsprintf,addr @szBuffer,\
addr szPingOneIP,ebx
.else ;主机对应多个IP地址
invoke
wsprintf,addr szBuffer,\
addr szHostMoreIP,_lpszHostName,edi
invoke
lstrcat,addr szBuffer,ebx
invoke
wsprintf,addr @szBuffer,\
addr szPingMoreIP,ebx
.while
edi > 1
invoke
lstrcat,addr szBuffer,\
addr szSpar
pop eax
invoke
inet_ntoa,eax
invoke
lstrcat,addr szBuffer,eax
dec
edi
.endw
.endif
invoke
lstrcat,addr szBuffer,addr @szBuffer
.else
invoke
wsprintf,addr szBuffer,\
addr szErrHost,addr szHostName
invoke
_ConsolePrint,addr szBuffer
xor
eax,eax
ret
.endif
.endif
invoke
_ConsolePrint,addr szBuffer
mov
eax,@dwIP
ret
_HostnameToIP endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
; Ping 主程序
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
_Ping
proc
_dwIP
local @szBuffer[256]:byte
local @stDest:sockaddr_in
local @stFrom:sockaddr_in
local @hSocket,@dwSize
local @stFdSet:fd_set
local @stTimeval:timeval
local @dwID:word,@dwSeq:word
pushad
mov
@stDest.sin_port,0
mov
@stDest.sin_family,AF_INET
push
_dwIP
pop
@stDest.sin_addr
;********************************************************************
; 初始化一个 socket 发送 ICMP 的 RAW 数据
;********************************************************************
invoke
socket,AF_INET,SOCK_RAW,IPPROTO_ICMP
.if
eax ==
INVALID_SOCKET
invoke
_ConsolePrint,addr szErrSocket
jmp
_Ping_Ret
.endif
mov
@hSocket,eax
;********************************************************************
; 循环 Ping
;********************************************************************
xor
ebx,ebx
mov
@dwID,1
mov
@dwSeq,1
.while
TRUE
.break
.if (dwOption & F_ABORT) || (ebx >= 4)
inc
ebx
assume
esi:ptr icmp_hdr
mov
esi,offset szBigBuffer
invoke
RtlZeroMemory,esi,sizeof szBigBuffer
;********************************************************************
; 构造 Echo Request 数据包
;********************************************************************
mov
ax,@dwID
mov
[esi].icmp_id,ax
mov
ax,@dwSeq
mov
[esi].icmp_seq,ax
mov [esi].icmp_type,ICMP_ECHOREQ
invoke
GetTickCount
mov dword ptr [esi].icmp_data,eax ;将当前时间当做数据
mov
ecx,PACKET_SIZE
add
ecx,sizeof icmp_hdr-1
invoke
_CalcCheckSum,addr szBigBuffer,ecx
mov
[esi].icmp_cksum,ax
;********************************************************************
; 发送 Echo Request 数据包
;********************************************************************
invoke
sendto,@hSocket,addr szBigBuffer,ecx,\
0,addr @stDest,sizeof sockaddr_in
.if
eax == SOCKET_ERROR
invoke
_ConsolePrint,addr szErrUnreach
.continue
.endif
assume
esi:nothing
;********************************************************************
; 等待回复
;********************************************************************
@@:
mov
@stFdSet.fd_count,1
push
@hSocket
pop
@stFdSet.fd_array
mov
@stTimeval.tv_sec,0
mov @stTimeval.tv_usec,1000000
;超时时间1秒
invoke
select,0,addr @stFdSet,NULL,NULL,addr @stTimeval
.if
eax == SOCKET_ERROR
invoke
_ConsolePrint,addr szErrSocket
.continue
.endif
.if
eax
;********************************************************************
; 接收返回数据包
;********************************************************************
mov
@dwSize,sizeof @stFrom
invoke
recvfrom,@hSocket,addr szBigBuffer,\
sizeof szBigBuffer,0,addr @stFrom,addr @dwSize
.if
eax == SOCKET_ERROR
invoke
_ConsolePrint,addr szErrSocket
.else
mov
eax,@stFrom.sin_addr
.if
eax !=
@stDest.sin_addr
jmp
@B
.endif
mov
bx,word ptr szBigBuffer+\
sizeof ip_hdr+icmp_hdr.icmp_id
mov
cx,word ptr szBigBuffer+\
sizeof ip_hdr+icmp_hdr.icmp_seq
.if
bx != @dwID || cx != @dwSeq
jmp
@B
.endif
;********************************************************************
; 如果返回的数据包是由目标主机所发的话,则显示时间和 TTL 数据
;********************************************************************
invoke
inet_ntoa,eax
.if
eax !=
NULL
invoke
lstrcpy,addr @szBuffer,eax
.endif
invoke
GetTickCount
sub
eax,dword ptr szBigBuffer+\
sizeof ip_hdr+icmp_hdr.icmp_data
movzx ecx,szBigBuffer + ip_hdr.ip_ttl
invoke
wsprintf,addr szBuffer,addr szReply,\
addr @szBuffer,PACKET_SIZE,eax,ecx
invoke
_ConsolePrint,addr szBuffer
.endif
.else
invoke
_ConsolePrint,addr szErrTimeout
.endif
inc
@dwID
inc
@dwSeq
invoke
Sleep,1000
.endw
invoke
closesocket,@hSocket
_Ping_Ret:
popad
ret
_Ping
endp
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
start:
call
_ConsoleInit
invoke
_argc
.if
eax ==
2
invoke
WSAStartup,101h,addr stWsa
.if
! eax
invoke
_argv,1,addr szHostName,\
sizeof szHostName
invoke
_HostnameToIP,addr szHostName
.if
eax
invoke
_Ping,eax
.endif
.endif
invoke
WSACleanup
.else
invoke
_ConsolePrint,addr szHelp
.endif
invoke
ExitProcess,NULL
;>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
end
start
程序首先调用_ConsoleInit子程序来获取控制台窗口的输入输出句柄,然后通过_CmdLine.asm中的_argc函数获取参数的数量,如果参数不等于2,说明用户没有输入参数或者输入了太多的参数,那么程序在显示帮助信息后直接退出。
如果输入了合适的参数,程序使用_argv子程序将参数取出并存放到szHostName变量中,然后用_HostnameToIP子程序将输入的主机名或者字符串格式的IP地址串转换成32位的IP地址,最后调用_Ping子程序来完成ICMP数据包的收发工作。
1. 主机名到IP地址的解析
Ping程序既可以将主机名当做参数输入,也可以输入IP地址字符串,比如在命令行中输入ping www.的时候,程序的运行结果是:
The host [www.] has 5 IP addresses:
202.106.185.203 / 202.106.185.198 / 202.106.185.196 / 202.106.185.205 /
202.106.185.204
Ping first IP 202.106.185.203 with 32 bytes of data:
Reply from 202.106.185.203: bytes=32 time=40ms TTL=244
Reply from 202.106.185.203: bytes=32 time=30ms TTL=244
Reply from 202.106.185.203: bytes=32 time=50ms TTL=244
Reply from 202.106.185.203: bytes=32 time=40ms TTL=244
而输入ping 202.106.185.204命令的时候,运行结果是:
Ping 202.106.185.204 with 32 bytes of data:
Reply from 202.106.185.204: bytes=32 time=40ms TTL=244
Reply from 202.106.185.204: bytes=32 time=40ms TTL=244
Reply from 202.106.185.204: bytes=32 time=30ms TTL=244
Reply from 202.106.185.204: bytes=32 time=40ms TTL=244
在第一个使用示范中,_HostnameToIP子程序将www.解析成了5个IP地址,那么如何对主机名进行解析呢?
在_HostnameToIP子程序的一开始,首先使用inet_addr函数尝试对输入的字符串进行转换,如果输入的是“aa.bb.cc.dd”类型的IP地址字符串,那么函数将返回正确的IP地址,如果返回值是INADDR_NONE的话,说明输入的不是合法的IP地址字符串,程序则将这个字符串当做主机名来进行解析。
将主机名解析到IP地址使用gethostbyname函数:
invoke
gethostbyname,lphostname
.if eax
;处理返回的数据
.endif
函数惟一的输入参数是需要解析的主机名字符串,如果解析失败将返回0,否则函数返回一个指针,指向位于WinSock接口内部缓冲区中的一个hostent结构中,这个结构的定义是:
hostent STRUCT
h_name
DWORD ? ;指针,指向和IP地址对应的主机名
h_alias DWORD ? ;指针,指向一个包含别名指针的列表
h_addr
WORD
? ;返回的IP地址类型
h_len WORD
? ;每个地址的长度
h_list
DWORD ? ;指向一个指针列表
hostent ENDS
目标主机的IP地址由h_list字段来返回,如图16.15所示,gethostbyname函数返回hostent结构指针,hostent结构中的h_list字段本身也是一个指针,它指向一个IP地址的指针列表,当主机名对应多个IP地址的时候,这个指针列表中就存在多个表项,最后一个表项总是NULL,用来指示列表的结束。真正的IP地址数据的存放位置由指针列表中的多个指针指出。
图16.15
从gethostbyname函数的返回值得到IP地址
所以对gethostbyname函数的返回值要经过多次指针的转换后才能得到IP地址数据,一般的处理代码如下(比较麻烦):
invoke
gethostbyname,addr szHostName
.if
eax
mov
eax,[eax + hostent.h_list] ;取h_list指针
.while
dword ptr [eax]
mov
ecx,[eax] ;取一个IP地址的指针
mov ecx,[ecx] ;用指针取出IP地址
;现在ecx中就是IP地址,可以进行处理了!
add
eax,4 ;指向下一个IP地址指针
.endw
.endif
这样得到的IP地址已经是网络字节顺序的,可以直接用在其他函数中。
gethostbyname函数是利用DNS来解析主机地址的,解析需要的时间可能是几秒的数量级,但函数的执行是阻塞方式的,也就是说解析完成之前函数不会返回。如果在窗口程序中使用这个函数的话,最好将它安排在另一个线程中执行,否则在解析的过程中窗口会一动不动地呆在屏幕上。
另外,使用gethostname函数可以获取本地计算机的主机名:
invoke
gethostname,lpbuffer,size
函数将在lpbuffer指定的缓冲区中返回主机名字符串,size参数指定缓冲区的大小。
2. 阻塞方式的工作流程
前面已经讲述过,当使用WSAAsyncSelect函数将一个套接字设置为非阻塞模式后,如果有某种动作发生,WinSock接口会用消息通知指定的窗口过程,程序不必费心地去随时检测套接字的状态。但是当套接字工作于阻塞模式的话就不行了,这时必须有一种方法来检测套接字的状态,以便确定能否对套接字进行某种操作,否则对套接字的操作会无休止地等待。
比如要读一个阻塞模式的套接字的时候,如果缓冲区中没有数据,那么recv函数就会等待直到有数据到达,如果对方永远不发送数据的话,那么函数就永远地等待下去,只有在预先知道缓冲区中已经有数据的情况下再去读才能避免这种情况。
使用select函数可以进行这种检测,select函数可以同时检测多个套接字是否可读,是否可写,或是否有错误发生,并且可以指定检测的超时时间,它的用法是:
invoke
select,nfds,lpreadfds,lpwritefds,lpexceptfds,lptimeout
nfds参数是为了和BSD Unix Socket的兼容而设置的,函数将这个参数忽略,lpreadfds,lpwritefds和lpexceptfds分别指向不同的fd_set结构,用来指定需要检测的套接字句柄。fd_set结构的定义如下:
fd_set STRUCT
fd_count
DWORD
?
;fd_array中存放的套接字句柄数量
fd_array
SOCKET 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 STRUCT
tv_sec DWORD
? ;s
tv_usec
DWORD
? ;us,注意不是ms!
timeval ENDS
lptimeout参数的用法有以下几种:
● 如果lptimeout参数为NULL,那么函数将永远等待下去,直到列表中有某个套接字就绪时才返回。
● 如果lptimeout指向了一个timeval结构,而且结构中的时间定义为0,那么不管有没有套接字就绪函数都会马上返回。
● 如果lptimeout指向了一个timeval结构,而且结构中的时间定义不为0,如果经过了timeval指定的时间后还没有套接字就绪,那么函数出错返回,如果在指定的时间有套接字就绪,那么函数马上返回。
下列几种情况可以让lpreadfds指定的套接字就绪:处于监听的套接字可以进行accept操作了,套接字有数据到达(可以进行读操作了),套接字的连接被关闭。下列情况可以让lpwritefds指定的套接字就绪:调用了connect函数的非阻塞套接字连接成功,套接字可以用来发送数据了。调用了connect函数的非阻塞套接字连接失败,可以让lpexceptfds指定的套接字就绪。
如果select函数因为超时而返回,返回值是0;因为失败而返回,返回值是SOCKET_ERROR;如果因为某个套接字就绪而返回,返回值是就绪套接字的数量。
一般来说,使用select构成的循环可以让所有阻塞方式的套接字协同工作,循环的结构如下:
.while
TRUE
;将可能用来接收数据的套接字填入readfds
;将可能用来发送数据、等待连接成功的套接字填入writefds
;将等待连接成功的套接字填入exceptfds
;规定超时时间
invoke
select,NULL,lpreadfds,lpwritefds,lpexceptfds,lptimeout
.if ! eax
.continue
.elseif eax ==
SOCKET_ERROR
;出错
.else
;扫描readfds并对留在其中的套接字句柄进行recv或accept操作
;扫描writefds并对留在其中的套接字句柄进行send操作
.endif
.endw
在例子程序Ping.asm中使用的正是这样的结构,读者可以对比源代码分析:
.while
TRUE
;构造ICMP数据包
;通过sendto函数发送“请求回显”ICMP数据包
;将套接字句柄填入readfds并将超时时间设置为1秒
invoke
select,0,lpreadfds,NULL,NULL,lptimeout
.if eax
;用recvfrom接收“回显应答”ICMP数据包
;显示Ping的时间、地址等各种信息
.else
;显示Ping超时
.endif
.endw
3. 用原始套接字收发ICMP报文
例子程序中创建了一个原始套接字用于收发ICMP报文:
invoke
socket,AF_INET,SOCK_RAW,IPPROTO_ICMP
在16.2.2小节中已经讲到,系统会为使用IPPROTO_ICMP 协议的SOCK_RAW类型的原始套接字添加IP首部,但是接收的时候会返回包括IP首部的数据包,所以例子程序在构造要发送的数据时只需要填写ICMP报文,而不必去填写IP首部。
例子程序使用下面的代码填写ICMP报文:
mov
ax,@dwID
mov
[esi].icmp_id,ax
mov
ax,@dwSeq
mov
[esi].icmp_seq,ax
mov [esi].icmp_type,ICMP_ECHOREQ
;构造 ICMP_ECHO_REQ 数据包
invoke
GetTickCount
mov dword ptr [esi].icmp_data,eax ;将当前时间当做数据
mov
ecx,PACKET_SIZE
add
ecx,sizeof icmp_hdr-1
invoke
_CalcCheckSum,addr szBigBuffer,ecx
mov
[esi].icmp_cksum,ax
@dwID和@dwSeq是当做序号使用的,每次发送一个数据包以后,这两个数值会加1,ICMP首部的类型字段被填入“请求回显”,在Windows.inc文件中已经将这个类型预定义为ICMP_ECHOREQ,icmp_code字段保持为0,在程序中就不必显式地去设置了。
为了能够计算出一个Ping动作所花的时间,程序使用GetTickCount函数来获取当前系统的时间值并将它当做报文内容放入icmp_data位置,由于对方主机在处理“请求回显”查询时会将报文内容原封不动地封装在“回显应答”报文中返回,所以到接收到报文的时候再次调用GetTickCount函数并将时间相减就可以得出报文来回在路上所花的时间了。
填写好报文数据后,程序调用_CalcCheckSum子程序计算整个报文的校验和并将结果填入ICMP首部的icmp_cksum字段中,最后通过sendto函数发往目标主机。如果sendto函数返回SOCKET_ERROR,表示ICMP报文无法发送到目标主机,这时程序将显示“Destination host unreachable.”错误。
如果ICMP报文被成功发送,那么程序使用select函数等待一秒,如果在一秒内没有数据可供接收则视为目标主机没有应答,这时将在控制台窗口中显示“Request timed out.”错误。
最理想的结果就是select告诉程序有数据可供接收,这说明可能是目标主机有应答了,这时程序使用recvfrom函数来接收数据,由于ICMP协议并不提供端口复用,所以接收的数据有时并不是接收方所要的,比如有一个Ping程序在探测主机A,而我们又开了另一个命令行窗口并执行Ping.exe去探测主机B,那么主机B返回的“回显应答”报文也会被第一个Ping程序收到,而这显然不是程序希望得到的数据包,为了防止这种情况的发生,程序在收到数据以后首先对IP地址进行验证,只有当数据包是目标主机发回来时才进行下一步动作,否则回到select函数的地方继续等待。
当验证了ICMP报文是来自目标主机以后,就可以对它进行解读了:
;带有IP首部的ICMP报文被读到szBigBuffer缓冲区中
invoke
GetTickCount
sub
eax,dword ptr szBigBuffer+sizeof ip_hdr+icmp_hdr.icmp_data
movzx ecx,szBigBuffer + ip_hdr.ip_ttl
invoke
wsprintf,addr szBuffer,addr szReply,\
addr @szBuffer,PACKET_SIZE,eax,ecx
例子程序取出报文数据中保存的发送时间并和当前时间相减,由此得到报文来回所花的时间,并且取出IP首部TTL字段的数值,将这两个数值连同IP地址显示在屏幕上,这样就得到了我们需要的结果:
Reply from 202.106.185.203: bytes=32 time=30ms TTL=244
代码中的ip_hdr是IP首部的结构定义:
ip_hdr STRUCT
ip_hlv BYTE ?
ip_tos BYTE ?
ip_len
WORD
?
ip_id
WORD ?
ip_off WORD ?
ip_ttl BYTE ? ;TTL数值
ip_p BYTE ?
ip_cksum WORD ? ;校验和
ip_src DWORD
? ;源地址
ip_dest
DWORD
? ;目标地址
ip_hdr ENDS
从TTL字段可以看出从本地主机到目标主机经过了多少个路由器。当一个IP数据包被发送时,IP首部的TTL字段往往被设置为2的n次方,如256、128或64等,每当数据包经过一个路由器后,TTL字段被减1,定义TTL字段的初衷是防止数据包在设置错误的几个路由器之间循环打转,如果出现这种情况,在一段时间以后TTL字段终究会减少到0,这时数据包就被丢弃。
当从接收到的IP首部取出TTL字段以后,用大于它的最接近的2的n次方去减它就可以得到经过的路由器数量,如上例中的TTL为244,表示本地主机到目标主机经过了12个路由器(256–244=12),如果TTL为120的话,那么经过的路由器数量不是256–120=136而是8个(128–120=8)。