第2章 网络协议和网络编程例程
计算机网络是计算机技术和通信技术相结合的产物。从物理结构上看,计算机网络是在协议控制下,由计算机主机、设备终端、数据传输设备和通信控制处理设备组成的系统集合。在这里,人们不可避免地要提到“协议”(Protocol)一词,本部分将首先对几个与扫描有关的主流网络协议加以介绍,然后以软件开发的角度来说明如何使用这些协议,也就是这些协议的接口函数。考虑到TCP/IP编程协议贯穿于全书,所以此处详细列出其函数及用法,而NetBIOS编程接口、Win Inet编程接口、命名管道和邮槽等编程接口则因为有具体的扫描器与之对应,所以其详细介绍在相应的章节中。
Windows图形化编程的优点是程序界面看上去很直观,便于用户使用。但缺点是由于Windows本身是面向消息触发机制的,且图形界面各元素操作没有强制的顺序性,因而所编出来的程序,不能像Linux或DOS下的非图形界面一样,具有逻辑思路的连续性。在后续各章节的编程中,虽然所调用的函数不同、使用的技术各异,但毕竟同属于扫描器的大范畴,所以仍然会涉及大量相似的界面和相似的代码,如果在后续章节中,每一处都进行解释和说明,则会因多次重复而显得累赘;如果有选择地进行解释和说明,则又会使让有选择性阅读的读者因没看到解释而不知所云。所以,本章专门针对网络扫描器编程中,重复出现、共用的部分进行统一的说明。需要说明的是这里所提到的VC++的编程技巧和共同的例程,并不是针对VC++的通用技巧,而只是针对后续章节中,扫描器常用的一些共同的技巧和例程进行说明。
开发中,在不涉及版权和侵害他人利益的前进下,能够使用别人已做好的程序或工具有时也不失为一个好的办法。这样做可以节约自己的开发时间,达到事半功倍的效果,虽然本书很少使用这种方法,但仍然将其列为编程技巧之一。
2.1 常用的网络编程
早期的通信技术受当时各种情况的约束,很难考虑到未来的发展,因此各种技术之间,从设计之初就存在很大的差异。在实际运用中,计算机网络既要考虑计算机本身的特点,如计算机的设计架构、操作系统各不相同,又要考虑通信技术本身的特点,如通信介质有光纤、双绞线、无线等方式,要想消除这些差异,最好的办法就是事先约定一些协议,然后通信的所有方都按照这些协议来处理,最终实现数据的透明化通信。
计算机与计算机之间通信是指在不同系统实体之间的通信,这里的实体是指能发送或接收信息的终端、应用软件、通信进程等,这些实体之间通信需要遵守一些规则和约定,比如使用哪种编码格式?如何识别目的地址和名称,传送过程中出错了怎么办?两台通信的计算机处理速度不一样怎么办?总而言之,这里通信双方需要遵守的一组规则和约定就是协议。
全部协议有上千个,它们之间的约定、格式几乎各不相同,不过,我们这里只介绍其中最主要,也是目前非常流行的协议:TCP/IP协议族、NetBIOS协议,以及基于TCP/IP但又进行了扩展的几个高层协议。从编程角度上看,网络编程也正是针对这些具体的协议,按照协议的约定,完成相应的通信功能。
2.1.1 TCP/IP协议编程
20世纪90年代初,由Microsoft联合了几家公司共同制定了一套Windows下的网络编程接口,这个接口就是Windows Socket API。这套接口是通过DLL(Dynamic Link Library,动态链接库)实现的。所有的Windows Socket规范的实现都支持TCP/IP协议,即支持流接口和数据报接口。这种套接口在大量的可视化编程中都得到了使用,像Visual C++、Visual Basic、Delphi、Power Builder等程序设计语言都对其进行了封装,它们主要是利用一个叫Winsock.dll的系统动态链接库文件实现的。
TCP/IP协议是目前网络中使用最广泛的协议,Socket称为“套接口”,最早出现在Berkeley Unix中,最初只支持TCP/IP协议族和Unix协议,现在它已支持很多协议,是最重要的网络编程接口,特别是在Unix中,几乎所有的网络应用程序都是用Socket API来实现的,但本书很多例子都是在Windows下利用Windows Socket编写的,况且Unix下Socket和Windows下Socket在格式上相差不太大,所以本书采用Windows Socket进行说明。
Windows Sockets API是在Microsoft Windows下采用TCP和UDP应用程序的一个标准接口。Windows Sockets允许应用程序在主机上捆绑特定端口和IP地址,向用户提供面向连接(Connection-oriented)及无连接(Connectionless)两种服务,即流和数据报服务。
TCP/IP(Transmission Control Protocol/Internet Protocol)是传输层控制协议/互联网协议的缩写,当初是为美国国防部研究计划局(DARPA)设计的,目的在于能让各种计算机都可以在一个共同的网络环境中运行,如图2.1所示。
图2.1 TCP/IP协议(左半部)和OSI对照(右半部)
如今的TCP/IP已成为一个完整的网络体系结构。其中包括了工具性协议、管理性协议及应用性协议。由于它的出现较OSI(Open System Interconnect,开放式系统互联)参考模型要早,所以并不符合OSI/RM标准,大致来说,TCP相当于OSI的传输层,IP相当于其中的网络层。经过了几十年的实践考验,它已成为事实上的国际标准和工业标准。
2.1.1.1几个重要的概念
1. 端口(Port)和套接口
无需多言,端口正是我们要扫描的对象,具有“开”和“关”两种状态,利用它的开或关状态就可以初步判断一台主机是否提供了某种服务。和端口所在主机的IP地址结合起来,所形成的一个二元组(IP地址,端口地址)就组成了一个套接口。一个五元组(本地IP、本地端口、使用协议、远程IP、远程端口)组成了一个通信过程。一个IPv4的基本数据结构主要有in_addr和sockaddr_in两个,前者表示32位的IP地址,后者是通用的套接口地址结构,它们的结构如下:
struct in_addr { in_addr_t s_addr; }; struct sockaddr_in { short sin_family; u_short sin_port; struct in_addr sin_addr; char sin_zero[8]; };
IPv6目前还没有广泛使用,所以这里就不再说明了。
2. 地址表示顺序
不同的系统在内存存储多字节数据的方式有所不同,而网络传输中,数据存储顺序不一定和系统存储顺序一样,因此为保证系统正确性和可移植性,需要利用系统的转换函数进行转换。以IPv4的地址为例,一个IP地址的四个字节“192.168.1.100”,在PC架构的计算机中,数据的表示是低位优先(Little Endian),由前至后是100、1、168、192;而在网络Socket协议所表示的网络传输中,则是高位优先(BigEndian),由前至后是192、168、1、100,这需要在处理时通过函数转化。
3. 服务器和客户机
服务器不一定非和一台物理主机相对应,一台主机可以对应多个服务器,多台主机也可用软件捆绑后安装成一台服务器,服务器的主要识别标志就是一台主机运行了哪些服务器软件。客户机是指发出服务请求的计算机,客户机可以是一台物理主机对应,也可以是一台逻辑上的主机,比如在某主机上打开客户端访问服务器端,则该主机既是服务器,也是客户机。
4. 面向连接和面向非连接
面向连接(即TCP)的通信的双方,发起连接的为客户端,接收连接的一方称为服务器端。双方的通信一般分三步:建立连接、数据传送、释放连接。在传送过程中数据按顺序传送,很像电路交换,因此又称为“虚电路(VC,Virtual Circuit)服务”,这是一种可靠服务,但建立连接和释放连接的开销很大。面向非连接(即UDP)的通信中,没有客户端和服务器端之分,或者称为互为客户端和服务器端。双方中的任何一方都可以随时向对方发送数据或接收对方的数据。这是一种不可靠的服务,主要体现在数据传送过程中,报文有可能出现丢失、重复或与发送顺序不一致的现象,因而需要高层协议或程序自行解决,但其优点是灵活方便,效率很高。
需要说明的是,在现实中接触的很多工程师,每提及面向非连接的通信都嗤之以鼻,似乎面向非连接一无是处,在数据传输过程中优先级很低,经常会丢包,故这里以开发和实践的角度对二者进行如下比较:
❑面向连接和面向非连接只是电信专家与计算机专家就网络传送数据的方法进行争论后的一个折中结果。前者认为应该把传输的可靠保证放在传输上,后者认为应该把传输的可靠保证放在计算机端进行处理。
❑二者的关系很像是挂号信和普通信的关系。通信成功与否不取决于采用哪种方式,而取决于二者之间的链路,通常情况下,如果面向非连接的通信到达不了的数据,面向连接的通信也到达不了。只是面向连接的情况下,函数会明确告诉发送成功,但对方未接到;而面向非连接的情况下,函数只是告诉发送成功,不会告诉对方是否接到。
❑面向连接中的“可靠”源于协议内部不停地传递链路是可靠的信息,因此虽然用户感觉不到这些数据的传输,但实际上这些数据始终存在。而面向非连接则不需要维护这些息,因此面向连接的系统开销很大。比如两台电脑之间连接了一个小时未通信,在面向连接的方式下,这两台电脑之间会不停地发送确认信息以确定链路是否连通;而面向非连接的方式则没有这些维护信息。
❑如果传输层的更高层协议愿意,面向非连接的情况下,完全可以通过高层的应用模拟出面向连接的效果。例如:接到数据包后给一个确认的回复;或在通信期间定时发送检测链路可靠的信息。
综上所述,如果发送的数据量不大,并且想确保数据收到,或者在对方没收的情况下可以查知,或者在通信期间想随时知道网络是否已断,则可以采用面向连接的方式。如果发送的数据具有周期性、数据量较大(比如视频流等),即使中间某一个数据包未收到,也不影响整个数据的作用,或很快可以通过下一个周期得到相同的数据或随后的数据(例如ping程序,即使某一次数据不正常也不影响整个ping的过程),则使用面向非连接方式更合适。还有,广播或组播的方式只能使用面向非连接的方式。
2.1.1.2 Windows Socket结构
1. sockaddr结构
在Socket的函数中,有几个函数都使用了sockaddr结构,该结构用于保存一个IP地址,其结构如下:
struct sockaddr { unsigned short sa_family; char sa_data[14]; };
结构成员如下:
❑sa_family:协议族,一般是AF_INET。
❑sa_data:数据,14是其最大的长度。
sockaddr结构通常只是为了在各操作系统中保持兼容而使用,平时很少有人直接使用该结构的格式,而是使用一个与之兼容的sockaddr_in结构,其结构如下:
struct sockaddr_in { short sin_family; unsigned short sin_port; struct in_addr sin_addr; char sin_zero[8]; };
该结构表示一个TCP/IP通信中,某个主机IP地址和端口的完整表示,其结构成员如下:
❑sin_family:协议族,一般是AF_INET。
❑sin_port:端口地址。需要注意的是该端口地址在内存中的保存格式是低位优先,而网络上表示的是高位优先,因此在赋值的时候,应该调用htons函数做一下转换。
❑sin_addr:一个存储IP的结构。
❑sin_zero:为了和sockaddr结构保持同样长度而补足的8个字节,一般情况下,用户应该全部填成0。
其中真正存储IP结构的sin_addr变量又是一个结构,该结构如下:
struct in_addr { union { struct{ unsigned char s_b1,s_b2,s_b3, s_b4; } S_un_b; struct{ unsigned short s_w1,s_w2; } S_un_w; unsigned long S_addr; } S_un; };
注意,上面格式中,S_un_b、S_un_w和S_addr是一个联合体结构,也就是说,三者的地址是重合的,给其中任何一个变量或结构赋值,都会直接影响其余值。其中S_addr是一个ULONG型的变量,如果赋值的时候,使用的是字符串类型,需要通过调用inet_addr函数将字符串类型转换成网络存储格式的ULONG型。有的服务器有多个网卡,此时会有多个IP地址,或是一个网卡配置多个IP地址,而当前的程序并不想只绑定某一个IP地址,这时可以设置S_addr为htonl(INADDR_ANY)。
2. hostent结构
hostent结构的定义为:
struct hostent { char FAR * h_name; char FAR * FAR *h_aliases; short h_addrtype; short h_length; char FAR * FAR *h_addr_list; };
hostent结构用于存储给定主机的信息,例如主机名、IP地址等属性。
结构成员如下:
❑h_name:主机名。
❑h_aliases:主机的别名。
❑h_addrtype:地址的类型。
❑h_length:每个地址的长度,以字节为单位。
❑h_addr_list:地址列表。该地址列表中,每一个地址是以网络存储顺序保存的IP地址的ULONG表示格式。该双重指针其实可以看成一个指针数组,其中h_addr_list[0]表示第一个IP地址,如果有多个IP地址,则h_addr_list[1]表示第二个IP地址,依次类推。其中h_addr_list[0]可以用宏h_addr来表示。
3. servent结构
struct servent { char FAR * s_name; char FAR * FAR * s_aliases; short s_port; char FAR * s_proto; };
servent结构用于保存或返回给定服务名的名称和服务数。成员函数如下:
❑s_name:服务名。
❑s_aliases:服务的别名。
❑s_port:服务的端口。端口号以网络顺序存储。
❑s_proto:协议的名称。
2.1.1.3 Windows socket转换类函数
1. htons函数
u_short htons( u_short hostshort );
htons函数将计算机存储的USHORT格式转换为网络存储的USHORT格式。该函数名可以理解为:Host TO Net unsigned Short。
返回值:返回一个网络顺序的USHORT格式整数。
参数:hostshort,一个16位的以计算机存储格式的USHORT整数。
2. ntohs函数
u_short ntohs( u_short netshort );
ntohs函数将网络存储的USHORT格式转换为计算机存储的USHORT格式。该函数名可以理解为:Net TO Host unsigned Short。
返回值:返回一个网络顺序的USHORT格式整数。
参数:netshort,一个16位的以网络存储格式的USHORT整数。
3. htonl函数
u_long htonl( u_long hostlong );
htonl函数将计算机存储的ULONG格式转换为网络存储ULONG格式。该函数名可以理解为:Host TO Net unsigned Long。
返回值:返回一个网络顺序的ULONG格式整数。
参数:hostlong,一个32位的以计算机存储格式的ULONG整数。
4. ntohl函数
u_long ntohl( u_long netlong );
ntohl函数将网络存储的ULONG格式转换为计算机存储的ULONG格式。该函数名可以理解为:Net TO Host unsigned Long。
返回值:返回一个网络顺序的ULONG格式整数。
参数:netlong,一个32位的以网络存储格式的ULONG整数。
5. inet_ntoa函数
char FAR * inet_ntoa( struct in_addr in );
inet_ntoa函数将由in_addr结构所表示的网络地址,转换成由字符串表示的IP地址。在本书中的扫描器,有大量的IP字符串和ULONG之间的互换操作,由于该函数还要使用一个in_addr结构,所以比较麻烦,并且出错时,不能正确指出出错位置。因此本书中大部分的转换采用的是自编的一个函数,详细内容可参见2.2.5节“IP格式的互换”。
返回值:如果没有错误,则返回一个字符串型的IPv4地址串(形如“a.b.c.d”);否则返回NULL。
参数:in_addr,是一个in_addr类型的主机地址结构。该结构在本节前面内容中已详细说明了。
6. inet_addr函数
unsigned long inet_addr( const char FAR *cp );
inet_addr函数将字符串组成的IP地址串转换成一个ULONG的整数,该整数可用于in_addr结构中,是按网络格式存储的。在本书中的扫描器,有大量的IP字符串和ULONG之间的互换操作,由于该函数还要使用一个in_addr结构,所以比较麻烦,并且出错时,不能正确指出出错位置。因此本书中大部分的转换采用的是自编的一个函数,详细内容可参见2.2.5节“IP格式的互换”。
返回值:如果没有错误,则返回一个ULONG型整数;否则返回INADDR_NONE,此时例如IP字符串的某一项值大于255或为负值。
参数:cp,字符串指针,指向一个IP字符串。
7. gethostbyname函数
struct hostent FAR *gethostbyname( const char FAR *name );
gethostbyname函数根据主机名读取主机的信息(主要是IP地址)。
返回值:如果调用成功,则返回一个指向hostent结构的指针,hostent结构的定义参见前面小节;否则返回NULL。可以通过调用WSAGetLastError获得对错误信息的进一步描述:
参数:name:指向一个以NULL结尾的,表示主机名的字符串。
8. gethostbyaddr函数
struct HOSTENT FAR * gethostbyaddr( const char FAR *addr, int len, int type );
gethostbyaddr函数通过网络地址读取主机信息。
返回值:如果调用成功,则返回一个指向hostent结构的指针;否则返回NULL。可以通过调用WSAGetLastError获得对错误信息的进一步描述,错误信息与上节基本相同,有一个不同的错误码:
参数如下:
❑addr:指向一个网络字节顺序的IP地址。
❑len:地址的长度。
❑type:协议类型,通常是AF_INET。
9. gethostname函数
int gethostname( char FAR *name, int namelen );
gethostname函数读取本地主机的主机名。
返回值:如果调用成功,则返回0;否则返回SOCKET_ERROR。
可以通过调用WSAGetLastError获得对错误信息的进一步描述,参见前两节,有一个不同的错误码:
参数如下:
❑name:指向一个以NULL结尾的,表示主机名的字符串。
❑namelen:name的长度,以字节为单位。
10. getservbyname函数
struct servent FAR * getservbyname( const char FAR *name, const char FAR *proto );
getservbyname函数根据服务名和协议读取服务信息。
返回值:如果调用成功,则返回servent结构,该结构的定义参见前面结构说明;否则返回NULL。
可以通过调用WSAGetLastError获得对错误信息的进一步描述参见前面第7小节。
参数如下:
❑name:指向一个以NULL结尾的、表示服务名的字符串。
❑proto:指向一个以NULL结尾的协议字符串,如果是NULL,则返回第一个服务。
11. getservbyport函数
struct servent FAR * getservbyport( int port, const char FAR *proto );
getservbyport函数根据端口和协议读取服务信息。
返回值:如果调用成功,则返回servent结构,该结构的定义参见前面结构说明;否则返回NULL。
可以通过调用WSAGetLastError获得对错误的进一步描述,参见前面第7小节。
参数如下:
❑port:要查询的端口值。
❑proto:指向一个以NULL结尾的协议字符串,如果是NULL,则返回第一个服务。
2.1.1.4 Windows Socket通信类函数
需要说明的是,这些函数至少是1.1版本,如果要使用这些函数,需要在文件前面包含头文件,以及静态链接库:
#include <Winsock2.h> #program comment(lib,"Ws2_32.lib")
1. WSAStartup函数
int WSAStartup( WORD wVersionRequested, LPWSADATA lpWSAData );
WSAStartup函数首先查询当前操作系统是否支持所要求的版本号,完成对Windows Sockets2的初始化工作。要使用Socket2通信,必须首先使用该函数,因此该函数应在逻辑上,处于Socket2所有函数中的第一位。
返回值:函数如果调用成功,则返回0;否则返回如下之一的错误码:
此时由于Socket机制还没有完全建立,所以还不能通过WSAGetLastError函数获得错误的详细信息。只有该函数返回成功了,之后的操作才能使用WSAGetLastError函数。
参数如下:
❑wVersionRequested:一个WORD类型的变量,该变量可以看成两个由BYTE拼成,其中高位字节表示要使用功能的最低副版本号(修订版本号),低位字节表示的是要使用功能的最低主版本号。
❑lpWSAData:指向一个WSADATA结构的指针,该指针列出了当前Windows Sockets的各项实现细节。
2. WSACleanup函数
int WSACleanup (void);
WSACleanup函数完成与socket库绑定的解除,并释放socket库所占用的系统资源。该函数应该作为某次socket操作的最后一个函数,否则之后任何socket操作都会导致出错。
返回值:如果调用正确,则返回0;否则会返回SOCKET_ERROR。可以通过WSAGetLastError函数读取对错误信息的进一步描述,通常的错误码有:
3. socket函数
SOCKET socket( int af, int type, int protocol );
socket函数创建一个socket套接字。该函数相对简单,但却非常重要,因为这个函数是后面各种操作的基础。还有一点必须要提出的是,该协议中type字段将直接决定后面所建立的连接是面向连接的(TCP连接),还是面向非连接的(UDP连接),这将直接影响后续所有该socket的操作。
返回值:如果创建成功,则返回一个socket套接字(可以认为是一个句柄);否则返回INVALID_SOCKET。如果想知道详细错误信息,可以通过调用WSAGetLastError函数获得进一步的解释:
参数如下:
❑af:网络通信协议族,一般情况下用AF_INET,表示选择IPv4协议。
❑type:指明协议的采用连接类型,在Windows Sockets 1.1中只支持以下几种:
Windows Sockets 2及以后,还支持很多种类型,可以通过WSAEnumProtocols函数读取。
❑protocol:指定要用的协议。如果第二个参数type不是SOCK_RAW,则此参数一般是0,表示采用默认协议。如果type是SOCK_RAW,则此参数就可以指定相应的协议,比如IPPROTO_IP表示采用IP协议,IPPROTO_ICMP表示采用ICMP协议。
4. closesocket函数
int closesocket( SOCKET s );
closesocket关闭之前打开的socket套接字。在进行关闭之前,一般要通过shutdown函数通知对方自己要关闭套接字。
返回值:如果关闭成功,则返回0;否则返回SOCKET_ERROR。如果想知道详细错误信息,可以通过调用WSAGetLastError函数获得进一步的解释。常见的错误码参见第3小节,还有不同的错误码如下:
参数:s,之前打开的socket套接字。
5. setsockopt函数
int setsockopt( SOCKET s, int level, int optname, const char FAR *optval, int optlen );
setsockopt函数设置一个socket的参数选项。通常情况下,默认的选项就够用,但扫描器本身的特点是,几乎每一个程序都需要修改其默认的选项,所以该函数也是socket中一个重要的函数。在调用顺序上,如果setsockopt函数在bind函数之前,则设置的项会直到bind函数时才有效。即使setsockopt功能成功,bind函数也会因为setsockopt函数过早调用而失败。
与setsockopt作用相反的一个函数是getsockopt,getsockopt函数的功能是读取一个socket的参数选项,由于其函数参数项数目和各项的意义与setsockopt一样,所以此处不再重复。
返回值:如果调用成功,则返回0;否则返回SOCKET_ERROR。可以通过调用WSAGetLastError获得对错误信息进一步的解释。常见的错误码有:
参数如下:
❑s:由socket函数创建的socket套接字。
❑level:设置选项所定义的级别,当前支持的级别主要有SOL_SOCKET,IPPROTO_TCP和IPPROTO_IP,分别对应于应用层、传输层(TCP)和网络层(IP)的设置。
❑optname:要设置的选项。这些选项参数一次只能设置一个,所以要同时设置两个或两个以上参数时,需要多次重复调用setsockopt函数,并在每次调用时设置一个参数,如果连续重复设置同一个参数,则以最后一次的设置为有效设置。这些选项参数有:
❑optval:一个指向选项值的指针,具体的值由optname决定。
❑optlen:一个指向选项值长度的指针。
6. select函数
int select( int nfds, fd_set FAR *readfds, fd_set FAR *writefds, fd_set FAR *exceptfds, const struct timeval FAR *timeout );
select函数的作用是监视阻塞状态下端口的状态,如当前是否有数据到达,从而进入读端口的状态。需要说明的是select函数在Windows下和在Linux下使用方法有一定差别。select函数最重要的作用是通过readfds参数判断当前socket是否有数据到达,如果到达则转入读状态,否则继续空转或处理其他事务。本书较少采用这一功能来判断是否要数据到达,而是采用阻塞读取,直至超时后退出阻塞的方式。考虑到本书只有少数地方使用到此函数,所以在此只稍做介绍。
返回值:如果调用成功,则返回所监视的端口处于“准备”(ready)状态的socket句柄个数,并且将这些句柄保存在一个fd_set结构中;如果超时,则返回0;否则返回SOCKET_ERROR。可以通过WSAGetLastError函数获得对错误信息的进一步解释,错误码参见第5小节,不同的错误码如下:
参数如下:
❑nfds:在很多Linux版本下,该参数用于表示监视的句柄个数;在Windows下,该参数被忽略,系统会自动选择合适的数。
❑readfds:指向要监视的可读句柄集合。
❑writefds:指向要监视的可写句柄集合。
❑exceptfds: 指向要监视的异常句柄集合。
❑timeout:最大的超时值,该值指向一个TIMEVAL结构,如果设置为NULL,则表示进入该函数阻塞状态,直到读到结果或超时。
7. bind函数
int bind( SOCKET s, const struct sockaddr FAR *name, int namelen );
bind函数可以将一个本地的地址与socket套接字进行绑定。一旦绑定成功,则此后该socket的操作将与该地址有关。该函数既可用于面向连接的TCP通信中,也可以用于面向非连接的UDP通信中。
返回值:如果调用成功,则返回0;否则返回SOCKET_ERROR。可以通过WSAGetLastError函数获得对错误信息进一步的解释。
参数如下:
❑s:由socket函数创建的socket套接字。
❑name:分配给该sockdet套接字的一个地址SOCKADDR结构。
❑namelen:SOCKADDR结构的长度。
8. listen函数
int listen( SOCKET s, int backlog );
listen函数使用socket状态监听状态,并等待其他socket的连接。该函数仅用于面向连接的TCP通信中,UDP通信是不需要listen函数的。
返回值:如果调用成功,则返回0;否则返回SOCKET_ERROR。可以通过调用WSAGetLastError获得对错误信息的进一步描述。常见的错误码参见第5小节,不同的错误码如下:
参数如下:
❑s:指向一个由socket函数创建的socket套接字。
❑backlog:能连接的最大客户端数。如果设置成SOMAXCONN,则服务提供者尽可能地创建最大的值。
9. accept函数
SOCKET accept( SOCKET s, struct sockaddr FAR *addr, int FAR *addrlen );
accept函数允许和接收一个远端的连接,该函数仅用于面向连接的TCP通信中,并且只用于服务器端,用于接收客户端通过connect函数发来的连接申请;面向非连接的UDP通信是不需要处理此函数的。
调用成功后,该函数将会处于阻塞状态,直到有远端的连接,才会返回。从外表上看,程序很像是死掉了,因此除非程序本身没有要求,否则一般建议将此函数放入线程中使用,以避免整个程序像“僵死”一样。
该函数看似简单,其实比较复杂,也是多线程处理效果的关键,首先调用此函数之前,应该已成功地调用了listen函数。然后在调用该函数时,如果调用成功,则返回一个新的socket,所以如果后面服务端的处理很简单,可以在当前线程中用这个新创建的socket进行处理,俗称“短连接”;如果处理很复杂,并且仍在当前线程中处理,则会影响到accept函数对其他线程通过connect进行连接,此时就需要再创建一个线程,由新建的线程,并使用返回的一个socket专门处理此次连接后的各项操作,俗称“长连接”。
返回值:如果调用成功,则返回接收远端通过connect连接后,新创建的一个socket;否则返回INVALID_SOCKET,并且可以通过WSAGetLastError函数获得对错误信息的进一步描述。
需要说明的是,新创建的socket与原有socket具有相同的属性,因此不必再初始化。
常见的错误码参见第5小节,不同的错误码如下:
参数如下:
❑s:指向一个由socket函数创建的socket套接字。
❑addr:连接一个sockaddr结构的指针,该指针中保存着远端socket的一些信息。如果该值设置成NULL,则表示用户对谁建立的连接并不感兴趣,而只处理内容。其中的sockaddr结构详见前面结构描述。
❑addrlen:sockaddr结构的长度,调用之后,函数会返回实际需要的长度。如果该值设置成NULL,则表示用户对谁建立的连接并不感兴趣,而只处理内容。
10. connect函数
int connect( SOCKET s, const struct sockaddr FAR *name, int namelen );
connect函数以客户端的身份与远端主机建立连接。在扫描器的应用中,connect是一种简单而有效的连接方式,连接成功,则可以认为对方的端口是打开的。该函数仅用于面向连接的TCP通信中,并且只用于服务器端,用来接收客户端通过connect函数发来的连接申请;面向非连接的UDP通过是不需要处理此函数的。
返回值:如果调用成功,则返回0;否则返回SOCKET_ERROR。并且可以通过WSAGetLastError函数获得对错误信息的进一步描述。常见的错误码有:
参数如下:
❑s:指向一个由socket函数创建的socket套接字。
❑name:指向一个sockaddr的结构指针,该结构中保存了要连接的远端主机的IP地址和端口。其中的sockaddr结构详见前面结构描述。
❑addrlen:sockaddr结构的长度,调用之后,函数会返回实际需要的长度。如果该值设置成NULL,则表示用户对谁建立的连接并不感兴趣,而只处理内容。
11. send函数
int send( SOCKET s, const char FAR *buf, int len, int flags );
send函数发送数据到已建立连接的socket上,该函数既可以用于服务器端,也可以用于客户端,但双方都必须是采用TCP连接。
返回值:如果调用成功,则返回实际发送的字节数;否则返回SOCKET_ERROR。并且可以通过WSAGetLastError函数获得对错误信息的进一步描述。常见的错误码参见第10小节,不同的错误码如下:
参数如下:
❑s:指向一个由socket函数创建的socket套接字。
❑buff:指向一个发送缓冲区的指针。
❑len:buff缓冲区的长度,以字节为单位。
❑flags:发送的方法,一般置0。
12. recv函数
int recv( SOCKET s, char FAR *buf, int len, int flags );
recv函数用于接收从已建立连接的socket上的数据,该函数既可以用于服务器端,也可以用于客户端,但双方都必须是采用TCP连接。参数与上节大同小异。
返回值:如果调用成功,则返回实际接收到的字节数;否则返回SOCKET_ERROR,并且可以通过WSAGetLastError函数获得对错误信息的进一步描述。常见的错误码参见第10小节,不同的错误码如下:
13. shutdown函数
int shutdown( SOCKET s, int how );
shutdown函数禁止当前的发送或接收。对该函数关注的不多,所以可以看到很多程序在关闭socket的时候,在收发完成后,直接就调用closesocket函数了,这样做有的时候会使对方仍处于连接中,而已方已断开。
返回值:如果调用成功,则返回0;否则返回SOCKET_ERROR,并且可以通过WSAGetLastError函数获得对错误的进一步描述。常见的错误码参见第5小节。
参数如下:
❑s:要停止发送或接收的socket。
❑how:要关闭的类型,其中how的取值可以是:
14. sendto函数
int sendto( SOCKET s, const char FAR *buf, int len, int flags, const struct sockaddr FAR *to, int tolen );
sendto函数发送数据报到远端的主机指定的端口上。该函数只能用于面向非连接的通信中。
返回值:如果调用成功,则返回实际发送的字节数;否则返回SOCKET_ERROR,并且可以通过WSAGetLastError函数获得对错误信息的进一步描述。常见的错误码参见第10小节,不同的错误码如下:
参数如下:
❑s:指向一个由socket函数创建的socket套接字。
❑buf:指向一个接收缓冲区的指针。
❑len:buff缓冲区的长度,以字节为单位。
❑flags:发送的方法,一般置0。
❑to:要发送的目标主机IP和端口,该指针指向一个sockaddr结构,该结构的详细说明在前面结构说明中。
❑tolen:sockaddr结构的长度。
15. recvfrom函数
int recvfrom( SOCKET s, char FAR * buf, int len, int flags, struct sockaddr FAR *from, int FAR *fromlen );
recvfrom函数接收远端发过来的数据报。该函数只能用于面向非连接的通信中。
返回值:如果调用成功,则返回实际接收的字节数;否则返回SOCKET_ERROR,并且可以通过WSAGetLastError函数获得对错误信息的进一步描述。常见的错误码参见第10小节,不同的错误码如下:错误码解释WSAEINVAL参数无效,关闭的项只有“发”、“收”、“双向”三种可能。WSAEISCONN当前的socket是面向连接的TCP通信。WSAENETRESET在处理过程中,连接中断了。这一般可能是由于对方关闭了连接或中间的链路断开了。WSAEOPNOTSUPP监听的选项不支持。WSAEMSGSIZE要发送的信息过大,超出了上限。默认情况下,最大包的上限为WSAEMSGSIZE。WSAECONNRESET连接被远端主机复位,如果对方是个UDP服务端口,远端会回复一个“端口无法到达”(Port Unreachable)的ICMP数据包,通过该ICMP包可以进行UDP端口的扫描。socket不能再使用,建议关掉重建。
参数如下:
❑s:指向一个由socket函数创建的socket套接字。
❑buf:指向一个发送缓冲区的指针。
❑len:buff缓冲区的长度,以字节为单位。
❑flags:接收的方法,一般置0。
❑from:向本身发送数据包的源主机IP和端口,该指针指向一个sockaddr结构,该结构的详细说明在前面结构说明中。
❑fromlen:sockaddr结构的长度。
2.1.1.5原始套接字
上述Socket函数介绍中,提到一个原始套接字(Raw Socket),如果不使用原始套接字,则无论是发送和接收,系统都会自动处理IP包头、TCP/UDP包头的数据,这时用户只需要关心发送和接收的数据本身即可。这种自动处理虽然方便,但也使系统失去了灵活性。而当使用原始套接字时,如果发送数据,系统会将要发送的数据包的前面若干字节数据IP头、TCP/UDP头;如果接收数据,系统会将接收到的数据包前面加上数据IP头、TCP/UDP头。
在所有扫描实例中,绝大多数都是扫描器主动发起探测,但也有少部分扫描器是坐等接收信息,然后对被动接收的数据进行分析,从而得出结论。而这些应用在后面会广泛使用,故在这些专门列出。
该功能只能用于Windows 2000/XP及以后的版本中。在Windows 95/98/Me/NT中无法使用,如果确实要使用,只能使用“钩子”(hook)技术,直接从网卡驱动程序中进行截取。
1. 原始套接字的发送
原始套接字的发送很简单,但实际编写却很麻烦,这主要是因为需自己填充IP头和TCP头的数据内容,并分别计算IP头和TCP头的校验和。由于不再使用Socket提供的IP和TCP头,所以需要通过setsockopt函数告诉系统使用自己定义的IP和TCP头,并且虽然所填的是面向连接的TCP头,仍然要使用UDP所专用sendto函数,而不是使用send函数,如图2.2所示。
图2.2 原始套接字的发送流程图
2. 原始套接字的接收
原始套接字的接收相对复杂,步骤较多,但通常情况下,只要按如图2.3所示的步骤操作即可,每个步骤只有一两行语句,不像“原始套接字的发送”中的填充IP和TCP头那样需要很多行。其中的WSAIoctl函数的SIO_RCVALL参数表示接收经过本机网卡的所有数据包。
图2.3 原始套接字的接收流程图
2.1.2 NetBIOS/NetBEUI协议编程
“网络基本输入/输出系统”(Network Basic Input/Output System,NetBIOS)是一种标准的应用程序编程接口(Application Programming Interface,API),于1983年由Sytek公司专为IBM开发的网络通信编程接口。1985年,IBM在NetBIOS的基础上进行了扩展,创制了NetBIOS扩展用户接口(NetBIOS Extended User Interface,NetBEUI),它同NetBIOS接口集成后,共同构成了一套完整的协议。该协议不是一个能应用于广域网的协议,但考虑到其后来广泛的应用,所以很多厂商都在TCP/IP或IPX/SPX协议之上集成了NetBIOS编程接口。在Windows系列版本中几乎全部都支持NetBIOS接口。
NetBEUI是非路由协议,这种缺乏路由和网络层寻址功能,既是优点,也是缺点。优点在于其格式简单,因为不需要附加的网络地址和网络层数据包,所以数据传输效率高;缺点是这样的设计使其只能适用于局域网。
早期的Windows 98/NT系统中,NetBEUI是作为一个单独的协议,可以被用户选择安装,来实现局域网的数据通信的,之后的操作系统则直接将其集成到了操作系统中,不再单独供用户安装。
2.1.2.1 NetBIOS开发简介
NetBIOS协议既可以是一个面向连接的数据包服务,也可以是面向非连接的对话服务。如前所述,早期的NetBIOS只适用于局域网中,本身不存在路由功能,并且总节点数据有限,因此,现在没有多大的应用市场。但其设计中还是有很多可取处,为了在企业网Intranet和互联网Internet中也能使用NetBIOS,人们开发了NBT(NetBIOS over TCP),该接口是将NetBIOS运行在TCP协议上,将NetBIOS的协议数据包内容作为TCP/IP协议的数据,通过TCP/IP协议提供的路由功能,送到目标主机后,再将TCP/IP协议中的“数据”还原成NetBIOS协议内容,然后再使其在目标主机上得到应用。这种“协议A-协议B-协议A”的模式称为“隧道技术”(Tunnelling Technology)。
在NetBIOS的开发中,主要涉及如下几个概念。
1. 名字注册与注销
当一台安装有NetBIOS协议的主机申请加入某一网络时,首先通过广播寻找NetBIOS名称服务器(NetBIOS Name Server),并同时向该服务器注册自己的“NetBIOS名称”。该名称通常是一个16个字节组成的字符串,一般来说,一个主机有多少服务或多少属性,就要同时注册多少个NetBIOS名称,在Windows操作系统中,该名称通常是主机名或工作组名。(在命令行,输入“nbtstat -n”可以显示本机所有注册的NetBIOS名称,第4章的4.1节“NetBIOS协议的使用”中,对NetBIOS名称有较详细的介绍。)
当主机要退出网络时,会同样通过广播向NetBIOS名称服务器发送一个名称注销申请,名称服务器接到注销申请后将自己维护的名称列表中相应项进行清除。
在一个网络中,同时每台主机只能有唯一的注册名称,当出现重名时,采用“先入为主”的原则,谁先注册谁先使用,后者在前者注册后再注册就会失败,此时后者只能改名,或等前者注销后再使用。
2. 名称解析
当一个NetBIOS主机想和另一个NetBIOS主机通信时,首要的问题就是找到对方(也包含知道对方是否在网中),并通知对方要进行通信。实现这一功能可以通过广播的方式在网内通知所有主机,然后通过回复获知,也可以通过向NetBIOS名称服务器提出查询请求来实现。
对于网络扫描器,更关注的则是由NetBIOS扩展到NBT之后的变化,这些变化主要体现在后者结合了TCP/IP的很多概念,在NBT中,NetBIOS名称解释服务采用UDP端口137。与此同时,为了保持和其他操作系统NetBIOS兼容,Windows同时也提供了一个文件(Windows XP默认安装下,文件是C:\WINDOWS\system32\drivers\etc\lmhost.sam),在该文件中,用户可以直接定义已知的NetBIOS所对应的IP。(详细介绍参见第4章的4.1节“NetBIOS协议的使用”。)
3. NetBIOS数据包
NetBIOS数据包服务提供无连接的、非顺序的、不可靠的数据包传送。因而数据包既可以直接向另一个NetBIOS主机发送,也可以通过广播方式传送所有主机。通过广播方式发送的数据包,各主机都可以接到,但只有是自己的数据包才处理,由此可见,该方式不仅占用了大量的带宽,也在安全上大打折扣。在NBT中,NetBIOS数据包服务采用UDP端口138。
4. NetBIOS对话
NetBIOS对话服务提供连接导向的、顺序的、可靠的NetBIOS信息传送。在NBT中,NetBIOS对话采用TCP连接,提供对话创建,保持和终止状态。对话服务允许在两个方向上采用TCP端口139进行并发的数据传输。
2.1.2.2 NetBIOS的调用
处在不同编程层次,对NetBIOS的调用函数名是不相同的,但无论采用哪个层次使用NetBIOS,调用的结果都是一样的,根据调用的层次不同,可以把NetBIOS的调用分成从低到高的三个级别:汇编级、Windows API函数级和封装后的Windows API调用,虽然从结果上这三个级别上的调用都是殊途同归,但很明显的一个事实上,级别越低,调用效率越高,越容易对低层次进行设置、修改,满足各种特殊要求;调用级别越高,调用效率就越低,使用的参数限制越多,但使用更透明,即使是初学者也可以很快学会使用,但无法使用一些非常规的调用或对低层次进行设置、修改。
无论应用程序在哪个层次中调用,最终都只使用一个Netbios函数(注意大小写,不是NetBIOS),一套协议中所有功能都只用一个函数实现,这在诸多协议中是很少见的。该函数的原型是:
UCHAR Netbios( PNCB pncb );
Netbios函数解析和执行由网络控制块(NCB,Network Control Block)所指定的命令。虽然系统在执行此函数的时候,会根据其中命令结构成员的需要查找与该命令相关的结构成员值,但其余与该命令不相关的结构成员也建议都设为0。该函数需要平台的支持,所以要使用此函数有代码前面需要包含头文件Nb30.h,并且链接静态链接库文件Netapi32.lib。
返回值:如果NCB结构中,各成员的值互相矛盾,或不是合理的值,则返回NRC_BADNCB。如果NCB结构成员中,成员ncb_length的值不正确,或成员ncb_buffer无效,则返回NRC_BUFLEN。
在上述命令都是正确的前提下,如果是面向同步的命令,返回码是NCB结构中,ncb_retcode成员的值。如果是面向异步的命令,则要再看进一步的情况,如果异步命令已完成,则返回NCB结构中,ncb_retcode成员的值;如果异步命令正在进行中,则返回0。
参数pncb:指向一个NCB结构的指针。
在该函数中,用一个NCB结构来完成所有命令,该结构是一个64字节的缓冲区(64位的操作系统中是72个字节,本处不讨论),该结构的定义如下:
#define NCBNAMSZ 16 typedef struct _NCB { UCHAR ncb_command; UCHAR ncb_retcode; UCHAR ncb_lsn; UCHAR ncb_num; PUCHAR ncb_buffer; WORD ncb_length; UCHAR ncb_callname[NCBNAMSZ]; UCHAR ncb_name[NCBNAMSZ]; UCHAR ncb_rto; UCHAR ncb_sto; void (CALLBACK *ncb_post) (struct _NCB *); UCHAR ncb_lana_num; UCHAR ncb_cmd_cplt; UCHAR ncb_reserve[10]; HANDLE ncb_event; } NCB, *PNCB;
NCB结构作为网络控制块,包含命令的所有信息,但具体到每一个命令的时候,并不是每一个成员都会被用到,但即使这样,同样建议将其余不用的成员都设置为0,一个有效的办法就是在设置成员之前,先将整个结构都清0,然后再按需要设置相应成员的值。
NCB命令分为同步命令和异步命令,异步命令在调用结束后即返回,同步命令则需要等命令完成或超时后才返回。如果设定无超时,且该命令本身执行的很长,函数就会像“卡死”一样,这时可以通过复位(NCBRESET)、取消(NCBCANCEL)和断开(NCBUNLINK)等命令中断该状态,但这需要通过另外的线程来完成(因为当前进程已因同步而“卡死”),为了避免这种情况,建议将除了复位、取消和断开之外的所有命令都设置为异步操作。
结构成员如下:
❑ncb_command:命令码。指出该NCB所完成的功能,其余各成员是否使用,以及要设置成什么值则完全取决于该命令码。除了命令码之外,该值还可以通过操作符“|”与ASYNCH进行“逻辑或”操作,指定是否使用同步操作。可取的命令码主要有:
❑ncb_retcode:命令返回码,如果是同步命令,并且还没有结束,则该值被设为NRC_PENDING;否则返回如下值之一:
❑ncb_lsn:本地对话序号,通过NCBCALL命令可以获得本地对话序号。
❑ncb_num:本地网络名称的数量。通过NCBADDNAME或NCBADDGRNAME命令可以获得网络名称的数量。注意,这里的数量,而不是网络名称本身。并且NAME_NUMBER_1代表第一个网络名称。
❑ncb_buffer:指向一个缓冲区,当作发送功能(NCBSEND)时,该指向指向所发送的数据;当作接收功能(NCBRECV)或查询命令(NCBSSTAT)时,命令将读后的值放到这个指向所指向的缓冲区中。
❑ ncb_length:ncb_buffer的长度,以字节为单位。当作发送功能(NCBSEND)时,如果该长度不对,则返回NRC_BUFLEN错误;当作接收功能(NCBRECV)功能时,如果所设的长度不足以装下所返回的值,则系统会设定实际所需要的长度。
❑ncb_callname:指向远端名称,该名称最大长度为NCBNAMSZ。并且不能按普通字符串处理方式处理,详细格式说明可参见4.1节“NetBIOS协议的使用”中的说明。
❑ncb_name:指向本地名称,该名称最大长度为NCBNAMSZ。对其格式的解释参见结构成员ncb_callname的解释。
❑ncb_rto:设定接收超时值。该值以500毫秒为一个单位,实际的超时值为所设值乘以500,如果设为0,表示没有超时值,一直等到有可接收的数据。该命令主要应用于NCBRECV命令中。
❑ncb_sto:设定发送超时值。同ncb_rto一样,所设值乘以500毫秒作为超时值,0代表没有超时值,直到发送成功返回。该命令主要应用于NCBSEND和NCBCHAINSEND命令中。
❑ncb_post:同步命令完成时,要通知的线程地址。
❑ncb_lana_num:执行本次命令的网卡序号。
❑ncb_cmd_cplt:命令完成标志。该值与ncb_retcode member相似。
❑ncb_reserve:保留,必须为0。
❑ncb_event:指向一个事件句柄,当同步命令完成时,会复位该事件句柄。如果是异步命令(即命令码ncb_command没有与ASYNCH进行“逻辑或”操作),则必须设置为0。
通过对上述介绍,不难发现,表面看NetBIOS只有一个Netbios函数,但实际使用中,该命令的各项功能取决于其中各参数的不同组合,因此其复杂程序也不亚于其他协议。正是因为此,Windows在此基础上,通过更多的API包装了NetBIOS的Netbios函数,该些函数也因功能不同而不同,对于NetBIOS/NetBEUI编程在后续章节中,比较集中于第4章“NetBIOS扫描器的设计”,因此对于封装后的API函数,以及各函数的使用方法详见第4章内容。
2.1.3 Win Inet高层编程
鉴于TCP/IP协议族的重要性,前面详细介绍了其函数中最重要的几个,用这些函数加上高层协议组包理论上可以做任何基于TCP/IP协议的应用了。但在实践中,还是发现底层和较低层的操作模式相似,高层却要考虑各种细节。为此,API的提供者也向编程人员提供了高层的应用开发,在高层中,用户可以找到各种感兴趣的应用,此时的编程人员一般不需要考虑信息是在什么样的网络结构上运行了,而只需要将重点放在协议信息包的要求和传输命令格式上。
在这一层中,主要提供如下服务:
❑WWW(World Wide Web:万维网服务):这种服务占用端口80。这种服务的实现得益于一种由叫做“超链接”(Hyperlinks)的概念而转化的应用,使人们真正开始认识互联网,它打破了上网用户必须记一些命令的格式和顺序的限制,即使第一次上网,用户可以很方便地从世界上任何一个角落到达另外一个角落而几乎不需要记忆任何复杂的命令。短短几年,这种方式已成为上网的主要方式,甚至使一些初学上网的人认为上互联网就是上WWW网。
❑FTP(File Transfer Protocol:文件传输协议):这种服务占用端口21。文件传输协议使网上的文件从网络一端到另一端,而用户不需要考虑中间经过哪里,对方的操作系统是什么,网络是什么架构等细节。可以说,FTP是互联网上,最早的应用之一。虽然该应用只需要用户记住仅有的几个主要命令即可,但毕竟还是会把很多初学者拒之门外,所以虽然FTP目前仍然应用广泛,但很少把该应用作为一个标准的应用对外开放,而是将其内嵌于其他应用中使用。
❑Gopher(Gopher Protocol):早期的网络的使用,特别是WWW服务产生之前,几乎只是技术人员的专利,用户不仅要记住要一个个网址,还要记住所需要的资源的位置,并定期到一个个网址中查看所需要的资源是不是更新。为了解决这一问题,网络协议设计者设计了Gopher协议,该协议通过某种层次索引技术,将互联网上各服务器上提供的资源分类、汇总。用户需要通过菜单式地检索,就可以快速地定位到自己想要的资源,而不需要刻意记忆资源的位置和确切的名称。可以说Gopher是早期互联网资源检索的先驱。随着WWW技术出现,这种只有文字的检索技术和应用渐渐淡出互联网的舞台。
以上三种协议的编程,Windows的开发者分别提供了比Socket更高层的编程接口,统称为Win Inet高级编程。通过对这些接口API的调用,使程序员可以从底层Socket的接口中脱离出来,而只需要关心高层WWW、FTP、Gopher协议的具体内容即可,大大提高了开发效率。同样,也可以利用其提供的接口,做出简单明了的扫描器。
由于基于Win Inet的高层编程,相当于直接针对应用,所以将由这些API开发的程序统一归于第8章“基于应用的服务扫描器的设计”中,故这些接口的详细内容及扫描器编程方法详见第8章内容。
2.1.4 命名管道和邮槽高层编程
对于Socket编程,无论是基于面向连接的,还是面向非连接的,其实都有一个固定的编程框架。在此框架下,所需要做的只是高层上基于应用的开发,既然这样,理所当然地可以构想一种机制,能在“封装”(这里的封装不是面向对象的封装)了这个框架的基础上,只向用户提供基于高层协议的开发,而命名管道(Named Pipe)和邮槽(Mailslot)正是针对这一构想而设计的。
命名管道可以在同一台计算机的不同进程之间,或在跨越一个网络的不同计算机的不同进程之间进行有连接的可靠数据通信,命名管道其实就是对TCP协议的一种更高层的封装,这种封装可以使使用者不必关心TCP协议中的各项控制细节,而只需要知道主要的几个参数,就可以方便地在多台主机之间进行面向连接的可靠通信。
邮槽则是对UDP协议的一种更高层的封装(同样不是以面向对象的方式实现的),这种封装可以使使用者将消息传送或广播给一个或多个其他邮槽主机。由于邮槽是围绕一个广播通信体系设计的,所以不能实现可靠传输,只能应用在那种对数据传输的可靠性要求不高的地方。邮槽最大的缺点是,只允许从客户端到服务器建立一种不可靠的单向数据通信。
命名管道和邮槽都是一种高层应用,二者具有相似的操作模式,都采用UNC(Universal Naming Convention,通用命名规则)命名,即“\\<服务器>\<pipe|mailslot>\[路径名\]<资源名>”,都是由服务器方创建一个命名管道或邮槽名称,其他主机使用该名称。
需要说明的是,考虑到基于TCP/IP协议的互联网的应用遍布全书,所以此处称命名管道和邮槽是基于TCP/IP协议的,但实际上,这两种网络通信技术都在支持TCP或UDP的同时,还支持IPX等其他多种底层协议,并且,使用命名管道或邮槽编程甚至不需要知道其底层使用的是什么协议。
在扫描器的设计中,命名管道和邮槽的作用虽然应用领域不是十分广泛,但在某些特殊领域内又极具特效。在第9章“命名管道扫描器的设计”中,将详细介绍这种技术下,基于命名管道或邮槽的特殊扫描器。
本章随后的2.3节“嵌入外部程序”中会提到管道技术,本处的命名管道是其管道技术中的一个特例。
2.2 扫描器中公用编程示例
本书是一本以实践为主的书,各章节不仅仅是讲一些扫描原理,更重要的是要验证这一原理,所涉及的各种技术和技巧都以实例进行说明。但作为一个完整的程序,必然有一些共同的代码,同时,由于同为扫描器,所以又会有一些相似或相近的代码,这些代码如果全部原样照搬,则会使读者还要花时间定位要找的内容,如果只是附上关键代码,则一些相关的变量、控件由于没有被说明,但却出现在关键代码中,使读者不所所云。为了避免上述两种现象的出现,本书采用的方式是简要介绍程序的创建过程,并在创建说明中介绍所需要创建的变量和控件。然后在程序代码中,省略那些由VC++默认生成的代码。但经过这样处理后,同样存在原理不同,代码相似的现象,这种现象会导致很多代码需要重复地解释,同样为了避免这种情况,特将这些常用的代码单列于此,并详细介绍了这些代码的使用方法和意义,在内容中酌情进行解释。建议在看后续代码之前,先看一下本章的代码及解释。
需要说明的是:目前,Windows编程中,很多程序员使用匈牙利命名法作为其变量名的命名规范。匈牙利命名法是一种编程时的命名规范,基本原则是:变量名=属性+类型+对象描述,其中每一对象的名称都要求有明确含义,可以取对象名字全称或名字的一部分。本书尽量采用此命名法,但也对其进行了一些改进,如对于CString类的变量,采用的类型是“str”前缀。
这部分内容首先介绍面广泛使用的CTreeCtrl控件和CListCtrl控件以及编程风格上的约定,考虑到这两个控件是VC++默认控件,与扫描并没有直接关系,故其中的函数只是选用了几个扫描中用到的部分。一般情况下,程序中,少量的数据可以保存在INI文件中,大量的数据可以保存在数据库中,所以这里也对程序中,数据的保存和读取进行了说明。最后,对程序中重复使用的一些技巧进行了详细的介绍。
2.2.1 CTreeCtrl控件的应用
CTreeCtrl控件专门用于显示一些基于“树”状的数据结构。这种树状结构在自然界很普遍,一般具有传承性的自然事物,都具有这种特点。作为描述自然事物的程序,当然很多会用到这种结构,如图2.4所示。
图2.4 树状数据结构表现形式
在这种结构中,有一个或多个称为“根”的节点(在本例中,根节点只有一个,名称为“NetBIOS扫描[127.0.0.1]”)。在根节点上,可以有若干个分支节点(如“共享文件夹”和“用户名”)或叶子节点(如“主机名”和“当前时间”),各分支节点上还能有分支节点或叶子节点,所有分支的末端最终以叶子节点结束。
要创建这样的一个展示效果,首先需要在对话框中,放置一个CTreeCtrl树型控件,调整到合适的位置和大小,同时要声明一个CTreeCtrl的变量与此控件绑定:
CTreeCtrl m_ctlTreeResult;
默认的CTreeCtrl树型控件风格比较难看,改变其风格,可以右击控件,在弹出的菜单中选择“属性”菜单项,然后在弹出的“Tree控件属性”对话框的“风格”选项卡中选择“Has buttons”、“Has lines”、“Lines at root”三个选项,将展示效果改为如图2.4中所示的效果(其余各选项保持默认的不变)。
以此后的操作中,对m_ctlTreeResult变量的操作,就等于对界面进行操作。同时,有了这个变量,就可以定义其所有的根、分支或叶子节点了。在VC++里,所有的节点都是一个HTREEITEM的数据结构。
由于后面的程序主要是生成这样的一个树状结构,而不对其进行复杂的操作,所以这里只需要用到一个主要的函数InsertItem即可,该函数有如下几种调用方式:
1)HTREEITEM InsertItem( LPTVINSERTSTRUCT lpInsertStruct );
2)HTREEITEM InsertItem(UINT nMask, LPCTSTR lpszItem, int nImage, int nSelectedImage, UINT nState, UINT nStateMask, LPARAM lParam, HTREEITEM hParent, HTREEITEM hInsertAfter );
3)HTREEITEM InsertItem( LPCTSTR lpszItem, HTREEITEM hParent = TVI_ROOT, HTREEITEM hInsertAfter = TVI_LAST );
4)HTREEITEM InsertItem( LPCTSTR lpszItem, int nImage, int nSelectedImage, HTREEITEM hParent = TVI_ROOT, HTREEITEM hInsertAfter = TVI_LAST);
以上四个函数,功能一致,都可以增加一个节点到树状结构中。
返回值:无论哪一个调用,如果调用成功,返回的都是一个HTREEITEM变量(该变量实际就是一个句柄);如果调用失败,返回的是一个NULL。在平时使用中,如果要插入的项是叶子节点,可以不考虑返回值,如果是根节点或分支节点,则需要保存返回的值,因为下一步在该节点下面再加入新的节点时,要用到这个返回值。
考虑到这部分只是为了展示扫描参数或扫描结果,与扫描本身并没太大的联系,故此处不再详细介绍每一个参数的详细意义,如果用户对界面的要求不是很高,则只需要记住第3项调用即可。在第3项调用中,参数lpszItem即插入节点的描述字符串。hParent表示将当前哪个节点作为父节点,插入到该节点下面,默认的值是TVI_ROOT,表示直接插入到根节点下,否则就可以用前面调用InsertItem之后保存的返回值作为父节点,将当前节点插入到该节点的下面。hInsertAfter表示当要插入的父节点已有子节点时,当前插入的节点放置到什么位置,可取值分别为:
在上面例子中,各项的值是通过程序读取的,而要读的内容是后面要讲的,此处只是讲CTreeCtrl的用法,所以如果只是为了达到图2.4中的效果,则需要完成如下操作:
CString strItem;//用于生成各项的值。 strItem.Format("NetBIOS扫描[127.0.0.1]"); //将根节点插入,并记下该节点的位置到root变量中,以备后面再插入节点时使用 HTREEITEM root=m_ctlTreeResult.InsertItem(strItem); strItem.Format("主机名:localhost"); //插入“主机名”的叶子节点到root根节点上 m_ctlTreeResult.InsertItem(strItem,root); strItem.Format("共享文件夹"); //插入“共享文件夹”的分支节点到root根节点上 HTREEITEM curr=m_ctlTreeResult.InsertItem(strItem,root); for (i=0;i<iItemDir;i++) { strItem.Format("....");//依次生成各个共享的文件夹名 m_ctlTreeResult.InsertItem(strItem,curr); } strItem.Format("当前时间:XXXX");//当前时间 //插入“当前时间”的叶子节点到root根节点上 m_ctlTreeResult.InsertItem(strItem,root); strItem.Format("用户名"); //插入“用户名”的分支节点到root根节点上 HTREEITEM curr=m_ctlTreeResult.InsertItem(strItem,root); for (i=0;i<iItemDir;i++) { strItem.Format("....");//依次生成各个用户名 m_ctlTreeResult.InsertItem(strItem,curr); }
2.2.2 CListCtrl控件的应用
CListCtrl控件专门用于显示一些基于“关系”的数据结构。这种结构在自然界很普遍,一般用于具有同类型的自然事物,即所有事物都具有全部或大部分某一系列特点。作为描述自然事物的程序,当然很多会用到这种结构,如图2.5所示。
图2.5 CListCtrl例图
在这种结构中,以“行”为单位,每一行代表一个记录,每一列代表一个字段,所有在行中的事物,必须全部或部分具有列中的属性。
要创建这样的一个结构,首先需要在对话框中,放置一个CListCtrl列表控件,调整到合适的位置和大小,同时要声明一个CListCtrl的变量与此控件绑定。
CListCtrl m_ctlListResult;
与树型控件CTreeCtrl不同的是,CListCtrl的创建要分两步,第一步是设置控件的表头部分,第二步才是增、修、删其数据项。首先需要定义每个列的标题(即图2.5的表头部分)。其函数为:
❑int InsertColumn( int nCol, const LVCOLUMN* pColumn );
❑int InsertColumn(int nCol, LPCTSTR lpszColumnHeading, int nFormat =LVCFMT_LEFT, int nWidth = -1, int nSubItem = -1 );
上面函数,如果成功,则返回所在的列号;否则返回-1。调用成功后,一个具有表头的空列表就建成了。默认情况下,该表将来在添加了数据后,各数据之间没有分隔线,并且单击要选中的行,只有第一列的数据会被选中,为了增加一个分隔线以示美观,同时使单击某一行时,该行整行都会被选中,可以通过SetExtendedStyle函数在表头操作完成后即设计其风格属性。即
m_ctlListResult.SetExtendedStyle(LVS_EX_FULLROWSELECT| LVS_EX_GRIDLINES);
有了表头,并设置了其风格以后,就可以增加数据了。该操作可以使用以下各函数之一即可。
1) int InsertItem( const LVITEM* pItem ); 2) int InsertItem( int nItem, LPCTSTR lpszItem ); 3) int InsertItem( int nItem, LPCTSTR lpszItem, int nImage ); 4) int InsertItem( UINT nMask, int nItem, LPCTSTR lpszItem, UINT nState, UINT nStateMask, int nImage, LPARAM lParam );
如果增加完成,函数返回所添加数据的序号(该序号由0开始算起),否则返回-1。
由于后面的程序主要是生成这样的一个关系结构,而不对其进行复杂的操作,所以这里只需要用到一个主要的函数即可,并且上述各函数中,通常只需要使用第2个或第3个即可。
在图2.5所示的效果中,各项的值是通过程序读取的,而要读的内容是后面要讲的,此处只是讲CListCtrl的用法,所以如果只是为了达到图2.5中的例图,则需要完成如下操作:
/*预定义一些字段顺序的宏定义,之后如果想调整字段的顺序,只需要修改此宏定义的 顺序即可。注意:下面宏定义由0起,并且顺序号必须相连,不得间断。*/ #define LIST_RESULT_INDEX 0 #define LIST_RESULT_IP 1 #define LIST_RESULT_STATUS 2 #define LIST_RESULT_MAC 3 #define LIST_RESULT_NAME 4 #define LIST_RESULT_COMMENT 5 //创建表头部分 m_ctlListResult.InsertColumn(LIST_RESULT_INDEX,"序号",LVCFMT_LEFT,40); m_ctlListResult.InsertColumn(LIST_RESULT_IP,"IP",LVCFMT_LEFT,110); m_ctlListResult.InsertColumn(LIST_RESULT_STATUS,"状态",LVCFMT_LEFT,80); m_ctlListResult.InsertColumn(LIST_RESULT_MAC,"MAC",LVCFMT_LEFT,115); m_ctlListResult.InsertColumn(LIST_RESULT_NAME,"主机名",LVCFMT_LEFT,100); m_ctlListResult.InsertColumn(LIST_RESULT_COMMENT,"注释",LVCFMT_LEFT,100); //设定列表的风格是有网格线,并且在选择的时候是选中一行,而不是首字段 m_ctlListResult.SetExtendedStyle(LVS_EX_FULLROWSELECT| LVS_EX_GRIDLINES); CString strItem;//用于生成各项的值 for (int i=0;i<iNum;i++) { strItem.Format("%d",i+1); m_ctlListResult.InsertItem(i,strItem); strItem.Format("192.168.1.%d",i+1); m_ctlListResult.SetItemText(index,LIST_RESULT_IP,strStatus); m_ctlListResult.SetItemText(index,LIST_RESULT_STATUS,"扫描中..."); ......//其他部分写法雷同,故省略 }
2.2.3 INI文件的操作
程序开发的早期,数据库的发展还没有那么成熟,所以数据一般都保存在自己定义的文件中,这种自己定义的文件格式由于没有统一的约定,因而不能相互通用。为了解决这一问题,在早期的Windows程序设计中,定义了一种INI(Initial)文件格式,并且可以通过API直接对文件格式进行访问。
INI文件是一种纯文本保存格式,扩展名是“ini”,内容以“行”为单位,即每次操作的时候,可以对其中的一行进行操作。如下面一段,就是一个典型的INI文件格式:
[Init] BeginIP=192.168.0.1 EndIP=192.168.1.254 [List] Count=3 IP1=192.168.0.1 Port1=80 IP2=192.168.0.2 Port2=21 IP3=192.168.0.3 Port3=23
在上述INI文件格式中,所有内容都以行为单位。空行通常是为了增加可读性而人为设置的,空行的位置和多少并不影响INI文件的正常使用,在不考虑空行的情况下,每一行的内容格式主要包括以下两种类型:
❑[段名]:这种行称为段行,段行相当于文章中段的标题。由当前段名开始,到下一个段名之前的内容称为一段(segment)。各段的段名不能重复。
❑键名=字符串:这种行即是内容行,其中的字符串由等于号之后的第一个字符一直到回车前的全部内容。同一个段的键名不能重复,不同段的键名可以重复。
由此可见,该文件的操作是一个二维的,在程序设计中,段名、键名都是用户自己设定的,即具体使用多少个段名、每个段中使用多少个变量名都由用户自己设定。同时一个程序可以对多个互不相同的INI文件进行操作,对INI文件的操作,主要有包括“读”和“写”两个操作,每一个操作都要同时指定“INI文件名”、“段名”和“键名”,每次操作的时候在文件中显示为一个行,在逻辑上显示为一个由“段名.键名”组成二维值。
在讲这两个操作之前,需要先说明一下在使用过程中的一些约定。因为INI文件是纯文本文件,用户可以直接用记事本等编辑工具直接对其进行修改,因此会使数据的完整性、互异性、正确性遭到破坏。为此特约定如下:
❑在读写的时候,如果该文件不存在、段名不存在或者变量不存在,则在读、写操作的时候并不报错。如果是读操作,则直接按命令中约定进行操作;如果是写操作,则自动创建这样的文件,然后写入要写的数据。
❑如果段名出现重复,或同一段中的变量名出现重复,则读写操作时以第一个段的第一个变量名为准。
❑由于要读取的值由键名后的等号“=”之后开始,到回车之前的所有内容,所以如果要对键名进行注释,则不要把注释写到该行的后面,否则会作为内容被一起读取而导致出错。如果确实要注释,可以另起一行另用一个键名来注释。
如前所述,针对INI文件,主要只有读和写两个操作,考虑到实际使用中的需要,又将读取分为读字符串和读整数两种,共计三个API函数,下面分别介绍。
1. 读字符串
DWORD GetPrivateProfileString( LPCTSTR lpAppName, LPCTSTR lpKeyName, LPCTSTR lpDefault, LPTSTR lpReturnedString, DWORD nSize, LPCTSTR lpFileName);
2. 读整数
UINT GetPrivateProfileInt( LPCTSTR lpAppName, LPCTSTR lpKeyName, INT nDefault, LPCTSTR lpFileName);
3. 写字符串
BOOL WritePrivateProfileString( LPCTSTR lpAppName, LPCTSTR lpKeyName, LPCTSTR lpString, LPCTSTR lpFileName);
以上三个API函数的参数都类似,所以一并如下说明:
❑lpAppName:要读取或设置的段名。
❑lpKeyName:要读取或设置的键值。
❑lpDefault:当要读取一个字符串时,如果所读的INI文件不存在,或要读的段名不存在,或键名不存在时,则将该字符串作为默认字符串返回,所返回的字符串在lpReturnedString中,长度在nSze中。
❑lpReturnedString:在读取字符串的时候,所读取的字符串返回到该指针所指向的缓冲区中。
❑nSize:指名lpReturenedString缓冲区的长度,以字节为单。
❑nDefault:在读整数型数据的时候,如果所读的INI文件不存在,或段名不存在,或键值不存在的时候,则将该值作为默认值返回。
❑lpString:在写入文件时,要写入的字符串。
❑lpFileName:INI文件的路径名,该路径名可以是全路径,也可以是相对路径。
由于后面程序中,几乎每个程序都会使用到INI文件,并且各程序所用的风格一致,所以需要在这里统一进行说明。为了方便说明,假设文件的格式如前面例子所示。
首先,程序在运行的时候,通常需要从配置文件中读取一些参数作为系统中各变量的初始值,因此首先假设该文件已经存在,并且文件存在当前目录下,文件名为“Scaner.ini”,下面可以在程序初始化的时候,读取这些默认值:
/*由于文件名经常使用,所以可以先用一个宏定义定义要用的文件名。该文件可以放到任何使用以上函数的 前面。*/ #define INIFLIENAME ".\\Scaner.ini" #define MAXLISTCOUNT 1000 //假设从INI文件中读取的值保存到下面的变量中。 CString m_strBeginIP,m_strEndIP; //假设要读到的IP分别存于此变量 UINT m_uCount; //上面列表中,有效个数 CString m_strIP[MAXLISTCOUNT]; //保存列表中的IP UINT m_uPort[MAXLISTCOUNT] //保存端口值 //初始化,从INI文件中读取各变量名 char buff[MAX_PATH]; //定一个用于读取的缓冲区。 GetPrivateProfileString("Init","BeginIP","192.168.0.1",buff,MAX_PATH,INIFILENAME); m_strBeginIP.Format("%s",buff); //假设读到了BeginIP读到m_strBeginIP中。 GetPrivateProfileString("Init","EndIP","192.168.0.254",buff,MAX_PATH,INIFILENAME); m_strEndIP.Format("%s",buff); //假设读到了EndIP读到m_strEndIP中。 //读取列表 UINT m_uCount = GetPrivateProfileInt("List","Count",0,INIFILENAME);//读列表个数 if (m_uCount >= MAXLISTCOUNT) m_uCount = MAXLISTCOUNT; CString strItem; for (int i=0;i< m_uCount;i++) { strItem.Format("IP%d",i+1); GetPrivateProfileString("List",strItem,"",buff,MAX_PATH,INIFILENAME); m_strIP[i].Format("%s",buff); strItem.Format("Port%d",i+1); m_uPort[i]=GetPrivateProfileInt("List",strItem,0,INIFILENAME); if (m_strIP[i]=="" || m_uPort[i]==0) m_iCount=i; }
其次,要将当前数据保存到INI文件中,只需要按下面方式即可。
//程序退出时,将各变量写入到INI文件中 WritePrivateProfileString("Init","BeginIP",m_strBeginIP,INIFILENAME); WritePrivateProfileString("Init","EndIP",m_strEndIP,INIFILENAME); //写入列表 CString strItem,strValue; strValue.Format("%d",m_uCount);//列表个数 WritePrivateProfileString("List","Count",strValue,INIFILENAME); for (int i=0;i< m_uCount;i++) { strItem.Format("IP%d",i+1); WritePrivateProfileString("List",strItem,m_strIP[i],INIFILENAME); strItem.Format("Port%d",i+1); strValue.Format("%d",m_uPort[i]); WritePrivateProfileInt("List",strItem,strValue,INIFILENAME); }
与用户自己定义的文件格式相比,采用INI格式,并使用系统API进行读写操作时,用户不需要判断文件是否存在,不需要考虑指定的段号或变量名是否存在,而只需要读取即可,这种具有良好容错性的机制大大减少了程序运行中的异常出错等可能,程序执行中,也不会因为上述问题而导致异常,影响程序的正常运行。
但这种方式有时也会带来一些潜在的问题,因为当INI文件不存在或INI文件存在但要读的键名不存在时,函数会在读取不到时返回预设的默认值,因此用户通常不知道自己读取的数据是从INI文件里读取的数据,还是要读的数据是因键名不存在而返回的默认值。
综上所述,可以发现,INI文件只适合于数据量很小,并且不频繁读写的情况下。
2.2.4 数据库ADO的简单应用
在程序中,专业的数据存取当然还是使用数据库,因为数据库的使用,可以让程序与数据脱离,程序不必过多关注数据的增加、修改、删除、查询操作,而把这部分任务交由数据库本身来完成。使用VC++,有多种数据库的访问方式,当前主流的访问方式是采用ADO方式。下面对这种方式仅从操作的层面上进行说明。
要使用ADO访问数据库,主要需要分三步,第一步是进行一些必要的设置,即初始化操作;第二步是与数据库进行连接;第三步是通过SQL命令对数据库进行操作。其中第一步和第二步是一个完全固定的模式,几乎不需要任何改变,因此直接照搬即可;第三步则要根据具体的需要选择合适的位置和合适的语句,并且根据读到的格式,还要进行一些转换。下面分别介绍这三个步骤。
2.2.4.1 ADO初始化
在第一步初始化中,又需要固定的三个操作,这三个操作在VC++中几乎完全一样的,只需要原样照搬即可。这三个操作如下:
1)在头StdAfx.h的最后一行加入。
#import "c:\program files\common files\system\ado\msado15.dll" rename("EOF","adoEOF") Using namespace ADODB;
需要说明的是,加入这行,编译完成后,VC++会有一个警告:
warning C4146: unary minus operator applied to unsigned type, result still unsigned
这个警告可以不用理会,该警告也不会对程序的使用产生影响。
2)在BOOL CXXXXApp::InitInstance()的前部进行初始化。
CoInitialize(NULL);
CoInitialize函数可以完成对COM库的初始化,没有该初始化,程序编译仍然没有错误,但运行时会报连接出错。该函数在InitInstance中的位置没有明确的要求,但要尽量靠前,以便在使用前初始化。同时,系统建议使用CoInitializeEx函数代替。该函数的原型是:
HRESULT CoInitialize( LPVOID pvReserved //该值保留,必须为空 );
返回值:S_OK表示初始化成功,S_FALSE表示失败。
参数:pvReserved,保留系统使用,目前必须使用NULL。
3)在程序必要的位置声明如下变量:
_ConnectionPtr m_pConnection; _RecordsetPtr m_pRecordset;
其中,前一个命令pConnection用于表示与数据库连接的指针,后一个pRecordset表示某次读取时的记录的指针,后者要使用前者作为其参数,所以前者是进行一切操作的前提。
2.2.4.2建立数据库连接
要建立数据库的连接,以Access数据库DemoInject.mdb为例,可以使用下面的代码:
try { //创建Connection对象 HRESULT hr = m_pConnection.CreateInstance("ADODB.Connection"); if(SUCCEEDED(hr)) { //连接ACCESS数据库“DemoInject.mdb”。 hr=m_pConnection->Open("Provider=Microsoft.Jet.OLEDB.4.0;Data Source= DemoInject.mdb", "", "", adModeUnknown); } } catch(_com_error e)//捕捉异常 { CString errormessage; errormessage.Format("连接数据库失败,请确认配置正确后再运行!\r\n错误码:%s", e.ErrorMessage()); //显示错误信息 MessageBox(errormessage,"数据库出错提示",MB_OK|MB_ICONERROR); }
2.2.4.3进行数据读写
数据库连接成功后,就可以进行数据的读写,对数据的读写一般有两种方法:一种是直接用pRecordset读取记录,然后对每一个记录进行操作;还有一种是向连接pConnection发送SQL命令,然后将读到的数据记录返回给pRecordset指针。二者作用相同,可酌情使用。
前一种用法是直接用pRecordset读取记录:
pRecordset.CreateInstance("ADODB.Recordset"); CString strSQL="select * from UserList";//要执行的SQL命令。 pRecordset->Open(strSQL, pConnection,adOpenStatic,adLockOptimistic,adCmdText); for(int i=0;i< pRecordset->Fields->Count;i++) { //对数据的处理 m_strName=pRecordset->Fields->GetItem(_variant_t((long)i))->Name; ...... } pRecordset ->Close();
后一种用法是:
int iCount=0; CString strSQL="select * from UserList"; m_pRecordset=m_pConnection->Execute((_bstr_t) strSQL,NULL,adCmdText); while (!m_pRecordset->adoEOF) { //对数据的处理 m_strName=pRecordset->Fields->GetItem(_variant_t((long)i))->Name; ...... m_pRecordset->MoveNext(); iCount++; } m_pRecordset->Close();
对读到的记录中,字段的处理,要读的字段类型各异,取决于数据中设定的类型,同时某一字段的取值又存在多种可能,以字符串类型为例,有可能是空值、空字符串值("")、非空字符串等值,所以每次读取的时候都需要进行判断,为此,特将读取作成一个函数以增加可读性:
CString VarToStr(_variant_t var) {//将读出的值转化成CString值,该函数会将空值和空字符串值等同 CString strRet="";//先假设读到的是空串 if (var.vt!=VT_NULL) { var.ChangeType(VT_BSTR); strRet=var.bstrVal; } return strRet; } int VarToInt(_variant_t var) {//将读出的值转化成有符号整型int类型。读错和空值都用-1来表示 int iRet=-1; CString strTemp=VarToStr(var); if (strTemp != "") { iRet=atoi(strTemp); } return iRet; } UINT VarToUInt(_variant_t var) {//将读到的值转换成无符号整型UINT类型 UINT uRet=0; CString strTemp=VarToStr(var); if (strTemp != "") { uRet=atou(strTemp); } return uRet; } UINT atou(CString str) {//将字符串转变为UINT格式 UINT uRet=0; int num=str.GetLength(); UCHAR ch; for (int i=0;i<num;i++) { ch=str.GetAt(i); if (ch=='.') break; if (ch<'0' || ch>'9') { uRet=0; i=100; break; } uRet=uRet*10+ch-'0'; } return uRet; }
2.2.5 IP格式的互换
IP(Internet Protocol)地址是网络上每一个计算机的“身份证号”,每台计算机必须至少有一个IP地址才能被其他的计算机识别。目前,IP地址有IPv4(Internet Protocol Version 4)和IPv6(Internet Protocol Version 6)之分,其中IPv4由四个字节组成,而IPv6则由16个字节组成。下面以IPv4为例进行说明。
在文档描述中,一个IP地址通常描述为以点分隔的字符串,例如“192.168.1.100”,这种方式很方便于人识别,但在计算机处理中并不方便,原因在于这样表示的IP地址存在以下不足:
❑整个字符串的长度不定长,给传输、存储带来麻烦。
❑给编程时的程序判断带来麻烦,例如想知道某一个IP之后的一个IP是什么,再如某一个IP是不是在某两个IP所组成的IP网段里?
计算机所处理的IP地址是用一种UINT无符号整型表示的(当然,在使用中用int或DWORD也都可以),这样做可以避免字符串表示方式中难以处理的问题,但却具有可读性差的缺点,因此一个程序中,应该同时具有两种表现形式,又可以相互转换。
前面2.1.1.1节已经讲过数据存储的顺序在内存保存和在网络传输的区别。以“192.168.1.10”为例,在内存中存储,如果为字符串,则表示为:
在计算机内表示为:
在网络传输中,IP头中表示为:
要实现其中几种类型的转换,可以使用系统提供的API函数,也可以自己编写。使用API的优点是直接使用,缺点是不够灵活,有些API当输入出错时,会自动按默认数据处理,而自己编写的转换函数可以按自己要求处理。
要实现这种转换,先设定一个联合体(union),该联体合是组合了各种常用数据,用于这些数据之间的互换。下面是实现这种类型转换的结构和函数:
typedef union MultiByteStruct {//IP地址联合体 int iInt; //int有符号整型 float fFloat; //浮点数 UINT uInt; //无符号整数 ULONG uLong; //ULONG无符号整型 DWORD dwDword; //DWORD有符号整型 WORD wWord[2]; //WORD无符号整型数组 UCHAR ucByte[4]; //无符号字符数组 char cByte[4]; //字符数组 }UNIONIP,*PUNIONIP; //由无符号整数UINT转为CString类型 CString IPIntToStr(UINT IPInt) { CString IPStr; UNIONIP IP; IP.uInt=IPInt; IPStr.Format("%d.%d.%d.%d", IP.ucByte[0],IP.ucByte[1],IP.ucByte[2],IP.ucByte[3]);//低位优先 //IPStr.Format("%d.%d.%d.%d", IP.ucByte[3],IP.ucByte[2],IP.ucByte[1],IP.ucByte[0]);//高位优先 return IPStr; } CString CSSDPScanerDlg::IPDwordToStr(UINT IPDword) { UNIONIP IP; CString IPStr; IP.dwDword=IPDword; IPStr.Format("%d.%d.%d.%d",IP.ucByte[3],IP.ucByte[2],IP.ucByte[1],IP.ucByte[0]); return IPStr; } //将字符串类型转为UINT类型 UINT IPStrToInt(CString IPStr,BOOL bShowMsgBox) { UNIONIP IP; int i,j=0; IPStr.TrimLeft(" "); IPStr.TrimRight(" "); for (i=0;i<IPStr.GetLength();i++) { if (IPStr.GetAt(i) <'0' || IPStr.GetAt(i)>'9') if (IPStr.GetAt(i) == '.') j++; else { if (bShowMsgBox) AfxMessageBox("IP串中有非法字符,合适的字符为“0~9”和“.”。"); return 0; } } if (j!=3) { if (bShowMsgBox) AfxMessageBox("IP串格式不对。"); return 0; } i=0; IPStr+="."; CString temp; for (int m=0;m<4;m++) { temp=""; while (IPStr.GetAt(i) != '.') { temp+=IPStr.GetAt(i); i++; } i++; if (temp=="" || atoi(temp) > 0xFF) { if (bShowMsgBox) AfxMessageBox("IP串格式不对。"); return 0; } else IP.ucByte[m]=atoi(temp); } return IP.uInt; }
如果不要求精确到错误的位置,而只是进行转换,也可以使用inet_addr函数,该函数原型是:
unsigned long inet_addr( const char FAR *cp );
2.2.6 Windows操作系统类型的判断
目前支持的操作系统类型主要是Windows系列操作系统,严格地讲Windows 3.X及Windows NT 3.X系列是应用程序,而不是操作系统,因为这套系统的运行需要依附于其他的操作系统(以上二者都需要在DOS操作系统下才能运行)。直到Windows 95及Windows NT 4.0以后的Windows系列程序才称为操作系统。
有时,扫描器中所有函数是受操作系统版本限制的,或是操作系统版本不同、效果也不同,在后面各程序中,或多或少都有一些操作系统的限制,因此有必要在运行之前做一下操作系统类型的判断,代码如下所示:
enum Win32Type{Win32s,WinNT3,Win95,Win98,WinME,WinNT4,Win2000,WinXP}; static Win32Type g_Shell=IsShellType(); Win32Type IsShellType() { Win32Type ShellType; DWORD winVer; OSVERSIONINFO *osvi; //通过GetVersion和GetVersionEx函数读取操作系统的版本 //该函数返回一个DWORD类型,其中数据高位是主版本号,数据低位是次版本号。 winVer=GetVersion(); if(winVer<0x80000000)//最高位 {/*NT架构系列*/ ShellType=WinNT3; osvi= (OSVERSIONINFO *)malloc(sizeof(OSVERSIONINFO)); if (osvi!=NULL) { memset(osvi,0,sizeof(OSVERSIONINFO)); osvi->dwOSVersionInfoSize=sizeof(OSVERSIONINFO); GetVersionEx(osvi); //主版本等于4,表示是Windows NT4.0系列 if(osvi->dwMajorVersion==4L) ShellType=WinNT4; //主版本等于5,次版本是0,表示是Windows 2000系列 else if(osvi->dwMajorVersion==5L && osvi->dwMinorVersion==0L) ShellType=Win2000; //主版本等于5,次版本是1,表示是Windows XP系列 else if(osvi->dwMajorVersion==5L && osvi->dwMinorVersion==1L) ShellType=WinXP; free(osvi); } } else { if (LOBYTE(LOWORD(winVer))<4) ShellType=Win32s; else { ShellType=Win95; osvi= (OSVERSIONINFO *)malloc(sizeof(OSVERSIONINFO)); if (osvi!=NULL) { memset(osvi,0,sizeof(OSVERSIONINFO)); osvi->dwOSVersionInfoSize=sizeof(OSVERSIONINFO); GetVersionEx(osvi); //主版本等于4,次版本是10,表示是Windows 98系列 //主版本等于4,次版本是90,表示是Windows ME系列 if(osvi->dwMajorVersion==4L && osvi->dwMinorVersion==10L) ShellType=Win98; else { if(osvi->dwMajorVersion==4L && osvi->dwMinorVersion==90L) ShellType=WinME; } free(osvi); } } return ShellType; }
2.2.7 多线程的局限性和使用方式
因为扫描器本身的特点,所以本书绝大多数程序都是多线程的,但在这里,主要说明多线程的局限性,特别是在Windows下,多线程算法要注意下述各种问题:
❑运用多线程并不是绝对地能提高整个程序的性能。例如,对于同一个应用,如果可以拆分成若干个相同或相似的任务,而这些任务都是一些需要运行在“运行-等待-运行”模式的,则一些线程可以在其他线程处于“等待”状态的时候得到运行,使各线程的运行达到了统筹,进而使整个程序的运行得到了改善;而如果这些线程本身全部处于“运行”模式,则每一个线程的运行都占用了大量时间片,从整体上看,虽然各线程同样是得到了并行处理,但从实践上看,与这些线程所代表的任务一个个地运行,效果是相同的。
❑理论上看,Windows编程中,对一个程序所能再创建的线程数是没有太大的限制的,即一个程序可以创建任意多线程,而各线程又可以再创建任意多的线程。但实际上,默认情况下,一个进程可以占用2GB的逻辑空间,而一个线程在运行期要占用1MB的栈空间;按这个值算,则一个进程可以创建2GB/1MG=2048个线程,而实际中,进程自己也要占用堆栈空间,因此实际上能创建的线程数比这个值要小。
❑Windows系统内部的各个线程之间是通过消息机制完成的,即每一个线程本身有一个或多个消息队列,任何给该线程的消息都会按顺序放入消息队列中,线程按顺序依次处理,Windows本身是一个抢占式多任务的操作系统,如果一个进程长期获得不了CPU的时间片,就无法响应这些消息,最终消息溢出。
❑另外一个重要的影响效率的因素就是图形化的显示,因为每刷新一下界面,都要花费大量的系统资源,如果频繁地刷新界面,反而使界面来不及刷新,最终导致程序像死机一样。
综上所述,对于是否使用多线程,可以根据实际情况来选择,不过,就本书而言,由于涉及网络编程,由于每一个网络操作,都涉及数据在网上传输,其速度是远慢于本地的操作,所以多线程肯定会大大提高整体性能。
在VC++开发环境下,创建线程的方式有好多种,本书一般使用AfxBeginThread函数,该函数的原型是:
❑CWinThread* AfxBeginThread(AFX_THREADPROC pfnThreadProc, LPVOID pParam, int nPriority = THREAD_PRIORITY_NORMAL, UINT nStackSize = 0, DWORD dwCreateFlags = 0, LPSECURITY_ATTRIBUTES lpSecurityAttrs = NULL );
❑CWinThread* AfxBeginThread( CRuntimeClass* pThreadClass, int nPriority =THREAD_PRIORITY_NORMAL, UINT nStackSize = 0, DWORD dwCreateFlags = 0, LPSECURITY_ATTRIBUTES lpSecurityAttrs = NULL );
二者等价,都可以新创建一个指定类型的新线程,但前者创建的是一个工作线程(即不需要界面,不需要处理消息的简单线程),后者则是一个用户接口线程(既可以有自己的界面,又可以独立处理自己的消息)。
返回值:指向新线程的句柄。
参数如下:
❑pfnThreadProc:指向一个工作线程的名称(下例中的ProcessName)。该线程的名称必须在调用之前被声明,并且声明的格式为:
UINT ProcessName ( LPVOID pParam );
❑pThreadClass:从CWinThread继续的RUNTIME_CLASS类的对象指针。
❑pParam:给新创建线程传递的参数的指针。在具体使用中,指针指向的地址中数据的结构可以自己定义。
❑nPriority:线程的优先级,由于Windows是一个抢占式多任务操作系统,所以优先级决定了获得CPU执行的概率,如下所示:
❑nStackSize:指出堆栈的大小,以字节为单位。如果值为0,则表示线程堆栈的大小等同于创建该线程的进程或线程的堆栈大小。
❑dwCreateFlags:创建的一个扩充标志。该值可以是以下两值之一:
❑lpSecurityAttrs:安全属性指针,指向一个SECURITY_ATTRIBUTES结构。如果该指针为NULL,则该线程使用与创建进程或线程相同的安全组别。
本书中,将大量使用此函数用于创建多线程完成独立的任务,几乎涉及半数以上的程序。需要说明的有以下三点:
❑在线程创建完毕,线程即可进入运行状态。在线程中,可以通过AfxEndThread函数退出线程运行,但并不建议这样做,而是由线程自己退出。
❑根据经验,每次在调用AfxBeginThread创建线程之后,都应该调用一下Sleep函数,使线程有时间读取参数。这样做有以下两个原因:
• 每一线程的创建总是要花一些时间的,而如果AfxBeginThread函数的位置在调用AfxBeginThread函数的函数体最后,该母函数会因AfxBeginThread调用成功而返回,但此时线程有可能还没初始化成功,或虽然初始化完毕,但要接收的参数还没有接收完(因为这时AfxBeginThread函数的母函数已返回),最终导致崩溃性错误(显示内存不能为读)。
• 在创建多个线程时,通常是采用一个循环,在循环体内连续调用AfxBeginThread函数。如果各线程的参数不同,且AfxBeginThread函数之间没有间隔,则线程创建后,有时会出现读取参数的混乱(后创建的线程,在读参数的时候,参数已变成另一组新的值)。
❑在创建线程或监视程序运行状态时,有时需要快速地显示一些实时值,而这些值如果以控件的方式显示,控件会对快速的显示进行优化,而只在最后一步显示最后的结果,如果不要这种优化,则可以通过自己加上一段处理消息的代码实现。如下面的DoEvent函数:
void DoEvent(CString strTemp) { MSG msg; CWnd *pEdit=this->GetDlgItem(IDC_EDIT_Comment); pEdit->SetWindowText(strTemp); pEdit->UpdateWindow(); if(PeekMessage(&msg, NULL, 0, 0,PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } }
2.2.8 VC++下Windows Socket的使用
使用Microsoft Visual C++开发针对网络Socket的编程,一般有两种方法,一种是直接使用基于Socket的API(Application Programming Interface,应用程序编程接口),另一种是使用MFC(Microsoft Foundation Classes,微软基础类库)编程。二者各有利弊,前者使用较为麻烦,编程者不仅需要记住某项应用中所使用的API函数,还需要知道这些API的调用顺序、各个具有传递作用的参数等项。后者则以类的方式出现,通过封装,可以隐藏掉很多的细节,从而使其编程方法相对简单很多。
通常的网络Socket编程,直接使用基于MFC的编程就可以完成。但考虑到本书的特殊性,其中相当一部分程序更偏重于较底层的编程,常常会用到一些不常用的API函数和一些API的非常规用法,这时,MFC并不能完全胜任,就不得不用到基于API的方式。
2.2.8.1 CSocket的应用
Microsoft Visual C++的MFC针对网络编程提供了两个类,一个是CAsyncSocket类,另一个是CSocket类,后者对前者进行了继承和扩充,所以可以认为CSocket类更简单、更强大,但隐藏了很多底层的细节;CAsyncSocket类更接近于一些底层的控制。
要使用CSocket类,只需在标准程序生成的向导中,选择“Windows Sockets”选项即可。例如,向导采用的是基于对话框的,如图2.6所示,需要在向导中选择“Windows Sockets”。如果向导采用的是基于单文档(Single document)或基于多文档(Multiple documents),如图2.7所示,需要在向导中选择“Windows Sockets”。
图2.6 基于对话框的向导,选择CSocket的步骤
图2.7 基于单文档或多文档的向导,选择CSocket的步骤
如果用户在使用向导生成程序的时候,忘了选择“Windows Sockets”,同样可以通过手工添加代码完成。要完成这些功能,只需要在两个文件中添加代码。首先,需要在“StdAfx.h”的最后添加代码:
#include <afxsock.h> //标准CSocket类使用的头文件。
其次,还需要在××××.App(××××是工程名)文件的BOOL InitInstance()函数的最前面添加代码:
if (!AfxSocketInit()) { AfxMessageBox("Socket init error."); return FALSE; }
2.2.8.2 Socket2的应用
通常的网络Socket编程,直接使用CSocket就可以了,但是作为一些偏底层的编程,常常会用到一些不常用的API函数和一些API的非常规用法,这时,就要用到Socket2。使用Socket2就不像Socket1.1那么简单,不仅要在适当的位置加入头文件,还需要考虑到合适的静态链接库(win32 static LIBrary)。同时,要完全使用Socket2中的各项功能,有时仅仅安装了Microsoft Visual C++6.0 (SP6),即使是使用了“完全安装”,仍然会缺少一些头文件和库文件,这是因为其中很多头文件和库文件在“Microsoft Platform SDK”中,因此还需要安装“Microsoft Platform SDK”。
使用Socket2,需要在合适的头文件中,加入如下语句:
#include <winsock2.h>
有时,除了需要必要的头文件之外,还需要一些静态链接库,则这时有两种方式加载静态链接库,例如要使用头文件ws2tcpip.h和静态链接库ws2_32.lib文件,则首先在合适的位置加入头文件代码:
#include <ws2tcpip.h>
然后,一种方式是上面头文件之后加入如下语句:
#pragma comment(lib, "ws2_32.lib")
另一种方式是在Microsoft Visual C++的编译环境中加入,具体操作方式为,单击VC的“Project/Setting”菜单项,然后在弹出的“Project Settings”对话框中,选择“Link”选项卡,在“Object/library modules”项中输入ws2_32.lib,如图2.8所示。
图2.8 通过配置包含静态链接库
然后在系统初始化的时候,对Socket2进行初始化,初始化的代码如下:
WORD wVersionRequested; WSADATA wsaData; wVersionRequested = MAKEWORD(2,0); int err=WSAStartup( wVersionRequested, &wsaData ); if (err != 0 ) { MessageBox("当前系统,不支持Socket2的运行,但当前程序必须使用Socket2或以上。"); return; }
2.2.8.3 CSocket和Socket2同时使用
在特殊情况下,要同时使用CSocket和Socket2,如果简单地将上述二者代码进行合并使用,会导致大量网络常量和结构重复定义。
要同时使用二者,并不难,只需要注意在包含头文件的时候,先包含winsock2.h,再包含afxsock.h。至于二者放的位置,可视情况而定,即可以放到Stdafx.h中,也可以放到使用二者的cpp或h文件中。如在用基于对话框的向导时,不要选择“Windows Sockets”,然后在创建好的工程中,打开主对话框源程序代码页,在cpp文件的最前端加上如下代码:
#include <winsock2.h> #include <afxsock>
然后在OnInitDialog函数体,较前的部位加上如下代码:
if (!AfxSocketInit()) { AfxMessageBox("Socket init error."); return FALSE; } WORD wVersionRequested; WSADATA wsaData; wVersionRequested = MAKEWORD(2,0); int err=WSAStartup( wVersionRequested, &wsaData ); if (err != 0 ) { MessageBox("当前系统,不支持Socket2的运行,但当前程序必须使用Socket2或以上。"); return; }
加上上述两部分代码,后面即可以同时使用CSocket和Socket2。
2.2.9 网卡的混杂模式
2.2.9.1混杂模式简介
要接收网上的数据包有几种方法,一种方法是通过特定的函数打开本地一个端口,并主动给远端指定的端口发送数据,然后分析其返回的数据包;另一种方法是打开本地一个端口,并处于“监听”状态,然后等待远端主机的主动连接。无论上述哪种方法,都要打开本地端口,并且只能接收远端发向这个端口的数据。如果本地端口不指定,则该方法无法使用,因为不可能打开所有端口进行监听。并且如果某个端口被某个程序占用,则另一个则无法再使用这个端口,因此这种方法并不可行。
解决这一问题的方式是将网卡设为“混杂模式”(promiscuous mode),与混杂模式相对应的是“常规模式”。无论在混杂模式下,还是在常规模式下,网卡都会接到很多的数据包,这些数据包有些是发给该网卡的,有些不是发给该网卡的。当处于“常规模式”,网卡会自动过滤那些不是给网卡自己的数据包,而只让那些发给本网卡的数据通过。而在“混杂模式”下,所有的数据都不经过过滤而直接发送给操作系统内核进行处理。由于“混杂模式”会使本已没用的数据再进行更高一级的处理,增加了系统的负担,最终还是被高一级协议过滤掉或扔掉(不一定是全部被过滤掉,而是大部分被当做无用数据而不予处理),因此网卡默认的状态都是常规模式。
“混杂模式”的状态下,网卡会将所有自己接到的数据包全部不经过滤地直接给上一层进行处理,虽然在上一层的某一层中,这些本应被过滤掉的数据仍将被过滤掉或扔掉。因此,只要能将程序设定为某一合适的层中,就可以监听这些所有经过网卡的数据包,但同时不会对上层同样使用其中某些数据包的程序造成影响。也就是说这里要完成的只是“监听”,而不会“截止”、或“中断”这些来自网络的数据包,如图2.9所示。在实现上述功能时,只需要使用RAW Socket即可。RAW Socket技术细节参见第3章中3.4.2.3小节“TCP SYN扫描”使用RAW Socket判断对方的握手操作,第6章中6.6节“编程实例:快速多IP的ICMP扫描器”用RAW Socket接收ICMP数据包,第11章中11.4节“明文密码嗅探”和第12章中12.4节“基于嗅探的端口扫描监测及DDOS服务监测”利用RAW Socket作嗅探通过过滤数据包进行数据包的内容分析。
在图2.9中,“监听模式”是负责监听的程序在不干扰原有通信的前提下,只对数据进行监测,本书的嗅探方法正是基于这个原理。这种技术从网络安全角度来看,危害极大,很难防范。如第11章11.4节“明文密码嗅探”可用于监听网络上传输的明文密码。
图2.9 网络中常见的几种监测模式
“转发模式”是通过拦截发送端的数据实现数据的中断,然后再通过转发,将数据转发给接收端。之所以要中转一下,一般有多种原因。如“发送端”与“接收端”无法直接通信,这种应用如代理服务器;再如“发送端”唯一,而“接收端”有多个,则转发端还需要有一个判断数据流向哪个“接收端”的功能,这种应用如交换机。这种技术从网络安全角度来看,通常用于IP欺骗。
“过滤模式”与“转发模式”很像,所不同的是过滤模式并不是无条件转发,而是在转发之前对要转发的数据进行过滤,符合一定条件的才转发,否则就不转发。这种应用会使“接收端”接收不到应有数量的数据。
“截止模式”很简单,就是无条件地拦截由“发送端”给“接收端”的数据。
2.2.9.2混杂模式的意义
参阅过很多的文章,在描述“混杂模式”的作用时,很多人将其最大的功能描述为能接收所有发送至该网卡的IP数据包,这种说法并没有错,特别是在早期的集线器时代。但到了交换机的时代,这种现象并不常见。图2.10描述了集线器与交换机工作模式的对比。
图2.10 集线器与交换机模式
在集线器模式下,源主机“主机2”要向目标主机“主机3”发送数据,当数据到达集线器后,集线器会向除了源主机“主机2”本身之外的所有主机发送数据,即除了源主机“主机2”之外的所有主机都可以收到“主机2”所发的数据。目标主机“主机3”判断到该数据是给自己的,就进行处理;其余主机判断到所发的数据是给“主机3”的,而不是给自己的,就不进行处理。该模式下,硬件设计简单、速度快,并且对主机的开机(新主机加入)和关机(当前在线主机的离开)都具有很好的适应性,只要发送的时候,目标主机在线,就可以马上进入工作状态。但缺点是该模式对冲突的解决是非常麻烦的,例如,当“主机2”向某一台主机发送数据的期间,“主机4”也再向另一台主机发送数据,则二者的数据会出现冲突,这种冲突会导致二主机所发的数据全部出错。解决办法就是二者各“等”一会儿再重发,但再重发并不能避免产生新的冲突。这种现象在频繁数据交换的时候,冲突的现象非常明显,会大大减低网络带宽,现在这种模式已被交换机模式所代替。
在交换机模式下,源主机“主机2”要向目标主机“主机3”发送数据,当数据到达交换机后,交换机会分析目标主机“主机3”所在的端口,然后只向该端口发送数据,这样,其余主机就接不到由源主机“主机2”所发出来的。这样,使得别的主机就不需要处理不属于自己的数据,从而减少了负荷,而且如果此前“主机5”要向“主机1”发送数据时,就不会出现冲突。在该模式下,由于有效地减少了“冲突”的可能,所以整个带宽的利用率大大提高,并且各个主机也减少了处理不属于自己数据包的工作。但这样做也有一些缺点,比如新主机的加入和当前在线主机的离开,通常都需要交换机花费一定时间来判断新的变化。当前绝大多数以太网所使用的都是交换机模式。
通过上面的分析,不难看出,当前以交换机作为交换设备的网络中,即使是将某一个网卡设置成“混杂模式”,除非使用一些特殊技术,网卡本身一般是接不到不是发送给自身的数据,所接到的只是给本身IP的数据和广播数据,但这时RAW Socket可以监听所有的数据了。这点与“常规模式”不同。
2.3 嵌入外部程序
在编程中,难免会遇到有时所要做的程序功能,别人早已做出来了,而自己目前却感觉无所下手。这时,在不涉及版权和使用权的前提下,直接调用对方的程序,然后读取其结果也不失为一个好的办法。当然,这种方法看似有效,其中还有很多细节要处理,比如大部分程序并不向外界提供调用的接口,即使提供了,也不会公开,这种程序本身就是一个封闭的系统,除了在显示器上将结果显示出来之外,根本就不提供对外接口,这时就需要一些编程技巧。
2.3.1 可执行外部程序的几个函数
要想执行一个命令或将某一个程序作为一个命令运行,有几种方法,下面分别说明。
2.3.1.1调用system函数
早在C语言的版本中,就提供了system函数,该函数可以调用操作系统的内部命令(嵌入在操作系统的命令)、外部命令(以可执行文件的方式存在的操作系统命令)和程序文件(以可执行文件的方式存在的非操作系统程序)。在VC++中,虽然不建议使用该函数,但该函数仍然得到了支持,该函数的原型是:
int system( const char *command );
system函数在执行时会自动打开一个DOS命令提示符的窗口,在窗口中执行该命令,并将命令结果显示在DOS窗口中。
返回值:如果command为NULL,如果系统有命令解释器,则返回一个非0值;如果没有命令解释器,则返回0,并且设置系统errno变量为ENOENT。如果command为非NULL,系统会返回命令解释器(在Windows 98以前的操作系统,命令解释器是command.com文件,Windows 2000/XP及以后的操作系统命令解释器为cmd.exe文件)的返回值,如果命令运行正常情况下,命令解释器会返回0;否则返回-1,并设置系统errno变量为下面的值:
系统变量errno是一个操作系统级的变量,表示的是此前最后一次系统调用时返回值,相当于在VC中使用的GetLastError函数。在VC的使用中,不需要声明此变量,可以直接使用。
参数:command是一个字符串指针,该指针指向的地址中可以保存任何一个合法的DOS命令串,该命令串的格式、个数等属性取决于该DOS命令本身的要求。
2.3.1.2调用WinExec函数
比起system函数,WinExec函数有了一定的进步,WinExec函数同样是将命令串在DOS窗口中执行,但用户可以选择该DOS窗口是否显示。同时该命令主要是完成对可执行文件的调用执行操作,要调用内部命令一般不用此命令。WinExec函数的原型如下:
UINT WinExec( LPCSTR lpCmdLine, UINT uCmdShow );
需要说明的是,该命令的执行时间取决于命令本身的执行时间,如果命令执行需要很长时间,则程序表面看上去会像“死掉”状态,为了避免这种情况,可以在其他线程中调用GetMessage函数使之停止。
返回值:如果调用成功,返回一个大于31的值。如果失败,则返回下列可能的值:
参数如下:
❑lpCmdLine:是一个字符串指针,该指针指向的地址中可以保存任何一个非NULL的合法的DOS命令串,该命令串的格式、个数等属性取决于该DOS命令本身的要求。如果文件名中没有包含路径,则该函数会按如下顺序找:
1)首先在调用本函数的母程序所在的目录中找;
2)在当前目录中找;
3)在Windows系统目录中找,可以调用GetSystemDirectory函数获得系统目录;
4)在Windows目录中找,可以调用GetWindowsDirectory函数获得Windows目录;
5)在系统PATH变量所指向的各个目录中找。
以上查找步骤中,如果找到,则执行,并不再继续下一步的查找。如果所有路径均未找到,则返回文件没找到的错误。
❑uCmdShow:DOS命令提示符窗口所显示方式。该值较多地依赖于所运行的命令本身对窗口的要求,并且受DOS窗口本身对显示方式的限制。一般来说主要有如下几种:
2.3.1.3调用ShellExecute函数和ShellExecuteEx函数
system函数和WinExec函数都是在早期16位操作系统中使用的,虽然在VC中都可以使用,但对于Windows 2000/XP及以后操作系统,最好使用ShellExecute函数。该函数的原型为:
HINSTANCE ShellExecute( HWND hwnd, LPCTSTR lpVerb, LPCTSTR lpFile, LPCTSTR lpParameters, LPCTSTR lpDirectory, INT nShowCmd );
该函数的作用主要是执行可执行文件,一般不宜用作内部命令的调用。
返回值:如果调用成功,返回一个大于32的值。如果失败,则返回下列可能的值:
参数如下:
❑hwnd:父窗口的句柄,如果调用程序想获得该函数执行后的结果,则可以将接收结果的线程句柄填入,如果不需要,则可以设置为NULL。
❑lpVerb:一个字符串指针,指向一个动作的字符串,可以为以下值之一:
❑lpFile:指定一个文件或目录串的指针。
❑lpParameters:如果lpFile中指向的是可执行文件,并且该文件有命令行参数,则可以通过该指针指向保存参数的缓冲区中。
❑lpDirectory:可执行文件所在的目录,如果是默认目录,则可以为NULL。
❑nShowCmd:窗口的显示方式,有如下方式:
ShellExecuteEx功能只有一个参数,该参数一个复杂的结构,这里不再详述。
2.3.1.4调用CreateProcess函数
和ShellExecute函数作用差不多的一个函数是CreateProcess函数,CreateProcess函数的原型是:
BOOL CreateProcess( LPCTSTR lpApplicationName, LPTSTR lpCommandLine, LPSECURITY_ATTRIBUTES lpProcessAttributes, LPSECURITY_ATTRIBUTES lpThreadAttributes, BOOL bInheritHandles, DWORD dwCreationFlags, LPVOID lpEnvironment, LPCTSTR lpCurrentDirectory, LPSTARTUPINFO lpStartupInfo, LPPROCESS_INFORMATION lpProcessInformation );
返回值:若函数调用成功,则返回值不为0;若函数调用失败,返回值为0。
参数如下:
❑lpApplicationName:指向一个以空结尾的串,它指定了要执行的模块。
❑lpCommandLine:指向一个以空结尾的串,该串定义了要执行的命令行。
❑lpProcessAttributes:指向一个SECURITY_ATTRIBUTES结构,该结构决定了返回的句柄是否可被子进程继承。
❑lpThreadAttributes:指向一个SECURITY_ATTRIBUTES结构,该结构决定了返回的句柄是否可被子进程继承。
❑bInheritHandles:表明新进程是否从调用进程继承句柄。
❑dwCreationFlags:定义控制优先类和进程创建的附加标志。
❑lpEnvironment:指向一个新进程的环境块。
❑lpCurrentDirectory:指向一个以空结尾的串,该串定义了子进程的当前驱动器和当前目录。
❑lpStartupInfo:指向一个STARTUPINFO结构,该结构定义了新进程的主窗口将如何显示。该结构详见后面说明。
❑lpProcessInformation:指向PROCESS_INFORMATION结构,该结构接受关于新进程的表示信息。
在上述参数中,参数lpStartupInfo是STARTUPINFO结构,该结构的描述如下:
typedef struct _STARTUPINFO { DWORD cb; //STARTUPINFO结构的大小,以字节为单位 LPTSTR lpReserved; //保留 LPTSTR lpDesktop; //桌面的名称 LPTSTR lpTitle; //DOS窗口的标题 DWORD dwX; //左上角X DWORD dwY; //左上角Y DWORD dwXSize; //宽度 DWORD dwYSize; //高度 DWORD dwXCountChars; //一般不用 DWORD dwYCountChars; //一般不用 DWORD dwFillAttribute; //填充颜色属性 DWORD dwFlags; //设定本结构中哪些参数是有效的 WORD wShowWindow; //新进程创建时窗口的显示状态,如SW_HIDE表示要显示 WORD cbReserved2; //保留 LPBYTE lpReserved2; //保留 HANDLE hStdInput; //标准输入设备的句柄 HANDLE hStdOutput; //标准输出设备的句柄 HANDLE hStdError; //标准错误输出的句柄 } STARTUPINFO, *LPSTARTUPINFO;
该结构可以用来设置控制台的标题、新窗口的初始大小和位置,及重定向标准输入和输出。新程序通常可以忽略多数这些数据项,如果选择那样做的话。可以规定该结构体中的标志,已表明要设置的数据段。有时,不想设置任何信息,也必须传递一个有效的指针给空结构。参数lpProcessInformation返回进程和线程句柄,还包括进程和线程ID。这些句柄拥有在参数lpProcessAttributes和lpThreadAttributes中规定的访问。
要注意,针对CreateProcess的一些参数对控制台应用程序是特定的,而其他参数则对各种应用程序有用。大多数情况下,并不一定要填入STARTUPINFO结构,但无论如何必须提供它。其返回值是布尔型的,而真正感兴趣的返回值发生于作为参数传送的结构中。CreateProcess返回该结构中的进程ID及其句柄,以及初始线程ID及其句柄。可以将ID发送到其他进程,或使用句柄来控制新进程。一个简单的处理办法就是调用GetStartupInfo函数,该函数只有一个参数,是一个指向STARTUPINFO结构的指针:
VOID GetStartupInfo(LPSTARTUPINFO lpStartupInfo);
该函数用来取得当前进程的StartupInfo结构,有了当前进程的默认值,对于子进程,只需要修改要改的项即可。
2.3.2 编程实例:使用重定向接收外部程序运行结果
上述的几种函数,都可以做到在程序中调用外部可执行文件,有些还可以简单地获得函数执行结束后,退出时返回码。这在用户不太注重界面的环境中是可以使用的,因为虽然是一个DOS命令提示符的窗口,同样给用户显示了整个可执行文件运行过程中产生的中间结果。但在大多数情况下,这种显示方式是令人不满意的,甚至是不行的。比如,要将中间过程的显示作为另一种输入或将整个过程的中间结果显示到用户指定的界面中。
要解决将中间过程中产生的显示信息显示到指定位置,或在产生过程中实时读取的问题,通常有两种解决方式,即“重定向”和“管道操作”。
2.3.2.1重定向技术
重定向,顾名思义,就是将原本设定的数据源重新设定为另一个数据源,或将原本要将数据输出到某个目的地,重新设定到另一个目的地。例如,早期的DOS操作系统是非图形的文本界面,在这个界面下,默认的输入设备是键盘,默认的输出设备是显示器。当用户想看一下当前目录下的文件列表,则他会通过默认的输入设备“键盘”输入“dir”命令,然后回车后,DOS操作系统会读取当前目录下的文件列表,并将该列表输出到默认的输出设备“显示器”上,但如果人为地改变默认的输出设备为打印机,则输入的结果不再在显示器上显示,而是直接打印出来。
可重定向的设备详见表2.1。
表2.1 可重定向设备列表
重定向的符号有两个,一个是重定向“输入设备”的,用半角“<”表示;一个是重定向“输出设备”的,用半角符号“>”表示。除此之外,如果重定向的设备选为文件名,则还可以用半角符号“>>”来表示,该符号表示追加。
如在命令提示符的状态下输入:“dir>a.txt”,dir命令的作用是列出当前目录下的文件列表输出到显示器上,但由于使用了重定向,并且将输出内容重定向到a.txt文件,所以此时可以看到dir命令执行结束后,当前目录下的文件列表被保存到了a.txt文件中。
同样,有些程序或命令在运行后需要用户输入一些参数进行交互性的操作,例如,DOS下的“time”命令可以在显示当前时间的同时,让用户输入新的时间以更改时间。如果用户只是想看当前时间,而不是修改当前时间,只需要再回车一下即可。对于这样的交互同样可以使用重定向,首先建立一个文本文件rt.txt,其文件的内容只有一个回车。然后在命令提示符的状态下输入“time<rt.txt”,默认情况下,该命令除了显示当前时间之外,还会显示一个时间修改的提示符等待新时间(用户的键盘输入),但由于采用了重定功能,所以此时系统不再等待键盘的输入,而改由从rt.txt文件中读到内容作为输入,由于rt.txt文件中是一个回车命令,则该回车命令将代替键盘的输入,从而用户不再需要回车,而直接正常结束了time命令本身。
重定向符号“>”与“>>”的区别在于,每次重定向时,如果重定向的文件存在,则前者会将内容清空,然后将重定向内容填入文件中;而后者则是在保存原有内容的前提下,将重定向的内容追加到文件的尾部。
重定向的输入和输出还可以同时使用。例如本地FTP服务器上有A.rar和B.doc两个文件(关于FTP服务器的设备和操作,详见7.3节“FTP服务扫描”),在命令行操作符(以下简称“DOS命令行”)下输入“ftp -A 127.0.0.1”即可匿名登录本地FTP服务器。登录成功后,如果在“FTP的命令行”(注意这时不再是DOS的命令行,而是FTP服务器的命令行)中输入“ls”命令,则会显示出A.rar文件和B.doc文件,再在FTP的命令行中输入“bye”命令可退出FTP的命令行,而返回到DOS的命令行。这里的“ls”命令和“bye”命令都是FTP服务器本身的命令,因此可以用重定向操作。比如建立一个文本文件“doscmd.txt”,并在该文本文件的内容录入为:
ls bye
在DOS命令行中输入“ftp -A 127.0.0.1<doscmd.txt>dosprompt.txt”,则DOS首先会执行“ftp -A 127.0.0.1”命令。在该命令执行中需要用户从键盘输入时,由于采用了输入重定向,系统会自动到“doscmd.txt”文件中查找输入的内容;同时对操作的结果本应输出到屏幕上,但由于采用了输出重定向到文件,所以输出的内容全部都写入到了dosprompt.txt文件中。
下面以实例说明如何使用重定向功能,该程序执行一个DOS命令,该命令可以是一个DOS的内部命令,也可以是一个可执行文件,无论是哪一种都将输出结果重定向到一个临时文件(文件名是:Redirect.txt)中,然后将该文件的内容显示出来。考虑到文件本身只是一个中间过程,所以在显示后将临时文件删除掉。
2.3.2.2程序主界面
根据上述分析,可以做出一个界面,如图2.11所示。
图2.11 重定向主界面
主界面根据分组的方式分为上下两部分,上部分“DOS命令或可执行文件”是要执行的DOS命令,下部分“命令执行结果”显示了命令的执行结果。在“DOS命令或可执行文件”中输入要执行的命令,单击“执行命令”按钮,系统就会执行所输入的命令,然后将结果显示到“命令执行结果”中。
2.3.2.3程序原理
既然重定向可以把一个命令的执行结果生成到一个磁盘文件中,那么在程序中显示结果,当然就是把磁盘文件显示出来就可以了。不过,这个看似简单的原理,在编程实现的时候还要有很多麻烦。比如要执行的命令有长有短,如dir命令通常1秒内就可以执行完,而ping一台主机则需要几秒,而程序并不是执行完命令后才返回的,而是调用后马上就返回了,因此程序在调用后,需要等待一定时间,再读取生成的文件。如果在命令还没结束之前就读重定向所生成的文件,则会报错或是读成了上一次命令时所保存的中间文件,为了避免后者,需要在每次显示完命令结果后,删掉临时生成的文件。
2.3.2.4程序代码
要实现上述功能,按照如下步骤创建和设定。
1. 创建MFC的程序框架,定义布局和变量
运行Microsoft Visual C++ 6.0创建一个基于对话框的程序,程序名为Redirect。然后按照主界面的方式布局和设定各控件格局,并依次设定如下变量:
2. 程序主要代码
该程序相对比较简单,除了上面的设置之外,其余只要处理单击“执行命令”按钮后的操作。整个函数主要完成三个步骤:首先,创建一个进程,该进程完成指定的DOS命令,同时将命令的执行结果重定向到一个指定的文件中;随后,打开文件将内容显示到指定的文本框中;最后,将生成的文件删除。
#define MAXREADBUFFLEN (60*1000) void CRedirectDlg::OnBUTTONRun() { //单击“执行命令”按钮后要执行的操作 UpdateData(TRUE); //根据用户输入的命令串,生成重定向命令 CString strCommand,strFilename="Redirect.txt"; m_strCommand.TrimRight(" "); if (m_strCommand=="") return; strCommand.Format("cmd.exe /c \"%s\">%s",m_strCommand,strFilename); //创建一个不要出现DOS窗口的、隐藏的命令执行线程 STARTUPINFO si; ZeroMemory(&si,sizeof(si)); si.cb=sizeof(STARTUPINFO); si.wShowWindow=SW_HIDE;//隐藏的窗口 si.dwFlags=STARTF_USESHOWWINDOW|STARTF_USESTDHANDLES; PROCESS_INFORMATION pi; BOOL res=CreateProcess(NULL,strCommand.GetBuffer(0),NULL,NULL,NULL, NORMAL_PRIORITY_CLASS| CREATE_NO_WINDOW,NULL,NULL,&si,&pi); if (!res) { MessageBox("创建线程出错。"); return; } //等待进程执行完毕 WaitForSingleObject(pi.hProcess,INFINITE); //如果打开文件失败,则有可能文件仍在使用中,可以多次读取 char buff[MAXREADBUFFLEN]={0}; BOOL bSuccess=FALSE; try { CFile file; if (file.Open(strFilename,CFile::modeReadWrite,NULL)) { file.Read((char *)buff,MAXREADBUFFLEN); file.Close(); bSuccess=TRUE; } } catch (CFileException e) { //e.m_cause; Sleep(1000); } //如果打开文件成功,则删除临时文件,并显示出结果 if (bSuccess) { DeleteFile(strFilename); m_strResult.Format("%s",(char *)buff); } else MessageBox("程序执行出错。","出错提示"); UpdateData(FALSE); }
2.3.3 编程实例:使用管道接收外部程序运行结果
2.3.3.1管道技术
管道(pipe)技术是将某一个设备的输出,通过“管道”作为另一个设备的输入。从原理上讲,重定向技术与管道技术很相似,前者将某一设备的输出由默认输出设备改为另一个指定的输出设备上;后者将一个设备的输出作为另一个设备的输入。(本章前面2.1.4节“命名管道和邮槽高层编程”中提到命名管道技术,是本处管道技术的一个特例。)
管道需要有一个输出端和一个输入端,同重定向一样,二者既可以是设备,也可以是文件,甚至是操作系统提供的内部命令。在DOS命令行中,管道的符号是半角“|”符号,例如在DOS命令提示符中,输入“dir”会将当前目录下的文件列表显示于屏幕上,但如果输入“dir|more”则表示将列表通过管道发送到more命令中,而more命令除了将内容显示在显示器上之外,还要将显示的内容进行分页,当显示的内容超过一页时,只显示当前页,当用户按下空格键后,显示下一页,如此往复,直到全部显示完。
创建匿名管道的函数为CreatePipe函数,如下所示:
BOOL CreatePipe( PHANDLE hReadPipe, //读句柄的指针 PHANDLE hWritePipe, //写句柄的指针 LPSECURITY_ATTRIBUTES lpPipeAttributes, //安全属性结构的指针 DWORD nSize //管道的大小 );
CreatePipe可以创建一个管道,并指定管道的长度,该管道创建完毕后,可以通过ReadFile和WriteFile等函数进行读写操作。在用ReadFile函数读的时候,只有当另一个进程或线程写满了管道,ReadFile函数才会返回真,并且返回读到的字节数;否则返回错误。在用WriteFile函数到管道中,如果管道缓冲区不满,则写操作不会结束;如果所有字节写完之前,管道已满,则WriteFile函数无法返回,直到另一个进程或线程使用ReadFile读取管道中的内容后,使空间可以使用后,WriteFile才能继续写入剩下的字节。
返回值:如果功能调用成功,则返回一个非0值;否则返回0,并可通过GetLastError函数读取进一步的错误解释。
参数如下:
❑hReadPipe:指向一个接收读句柄的指针。
❑hWritePipe:指向一个接收写句柄的指针。
❑lpPipeAttributes:指向一个SECURITY_ATTRIBUTES结构的指针。所指向的结构可以决定其子进程能从本身继承的权限,如果该指针为NULL,则表示子进程无法继承任何权限。
❑nSize:表示管道缓冲区长度,以字节为单。如果该值为0,则由系统决定其长度。
在该参数中,有一个SECURITY_ATTRIBUTE结构,该结构用于子进程的权限继承问题:
typedef struct _SECURITY_ATTRIBUTES { DWORD nLength; LPVOID lpSecurityDescriptor; BOOL bInheritHandle; } SECURITY_ATTRIBUTES, *PSECURITY_ATTRIBUTES;
结构成员如下:
❑nLength:表示以字节为单位的结构长度。在Windows NT/2000中,有些功能在使用本结构的时候,并不验证nLength值,所以应该程序在调用时准确地设置其长度。
❑lpSecurityDescriptor:指向一个安全描述的指针。如果指向的值为NULL,则表示使用默认的安全描述。在Windows 95/98中该项被忽略。
❑bInheritHandle:指出了安全描述的对象能否被新创建的子进程继承,如果为TRUE则表示可以继承;FALSE表示不能继承。
下面以实例说明如何使用管道功能,该程序执行一个DOS命令,该命令可以是一个DOS的内部命令,也可以是一个可执行文件,无论是哪一种都将输出结果通过管道输入到一个文本框中。
2.3.3.2程序主界面
根据上述分析,可以做一个界面,如图2.12所示。
图2.12 管道主界面
在图2.12中,输入dir命令后,单击“执行命令”按钮,系统就会执行所输入的命令,然后将执行命令的结果通过管道传送到另一线程中,后者把结果显示到“命令执行结果”窗口中。与图2.11的重定向相比,无论是该程序的界面,还是执行效果,二者是一样的,但二者所采用的技术不同。
2.3.3.3程序代码
按如下步骤创建和设定。
1. 创建MFC的程序框架,定义布局和变量
运行Microsoft Visual C++ 6.0创建一个基于对话框的程序,程序名为Pipe,然后按照主界面的方式布局和设定各控件格局,并依次设定如下变量:
2. 程序主要代码
该程序相对比较简单,除了上面的设置之外,其余只要处理单击“执行命令”按钮后的操作。与重定向不同的是,管道只需要两个步骤,首先是创建一个进程,该进程完成指定的DOS命令,随后就是将命令的执行结果通过管道直接显示到指定的文本框中:
void CPipeDlg::OnBUTTONRun() { //单击“执行命令”按钮后的操作 UpdateData(TRUE); //创建一个管道,用于接收命令执行结果 SECURITY_ATTRIBUTES sa; ZeroMemory(&sa,sizeof(sa)); sa.nLength=sizeof(SECURITY_ATTRIBUTES); sa.lpSecurityDescriptor=NULL; sa.bInheritHandle=TRUE; HANDLE hRead,hWrite; if (!CreatePipe(&hRead,&hWrite,&sa,0)) { MessageBox("创建管道出错。"); return; } //创建一个没有DOS命令框的、隐藏窗口的进程来执行用户输入的命令 STARTUPINFO si; ZeroMemory(&si,sizeof(si)); si.cb=sizeof(STARTUPINFO); GetStartupInfo(&si); si.hStdError=hWrite; //将错误也重定向到管道中 si.hStdOutput=hWrite; //将输出重定向到管道中 si.wShowWindow=SW_HIDE; //隐藏的窗口 si.dwFlags=STARTF_USESHOWWINDOW|STARTF_USESTDHANDLES; PROCESS_INFORMATION pi; CString strCommand; strCommand.Format("cmd.exe /c %s",m_strCommand); BOOL res=CreateProcess(NULL,strCommand.GetBuffer(0), NULL,NULL,TRUE,NULL,NULL,NULL,&si,&pi); if (!res) { MessageBox("创建线程出错。"); return; } CloseHandle(hWrite);//结束后,关闭管道写句柄,不再写入 //从管道中读取已写入的数据,并显示出来 CString strTemp; char cBuff[4096]={0}; DWORD dwRead=0; m_strResult=""; while (true) { if (!ReadFile(hRead,cBuff,4095,&dwRead,NULL)) break; cBuff[dwRead]='\0'; strTemp.Format("%s",cBuff); m_strResult+=strTemp; } UpdateData(FALSE); }