初探Socket网络编程

学C++之前,就想用C++写一个网络软件,到达目的地的路很多,但我选择了学C++来达到我的目的。虽然用VB或Delphi来写我的这个网络软件,会更快更便捷,不过我还是选择了C++。

走上C++之路,要想写一个Windows下的软件,要学的实在太多了。首先要学SDK,学会用API函数来写软件的界面,但学SDK要有C语言的基础,如果C语言基础不好,还得返回去恶补C语言基础。学了SDK,虽然能写Windows界面软件,但制作流程太复杂,想要省事儿还得学MFC……总之,这是一个漫长的过程。

用C++写网络程序,以前想都没想过,认为只用C++既不能写Windows界面,也没有网络控件可用。说来惭愧,小弟我接触程序都六七年了,但编程水平仍然停留菜鸟水平,对程序的认识也不是一清二楚。最近一段时间一直在学习C++和SDK,也一直在调整自己的学习计划。C++看了本《C++ Primer Plus》,对C++面向对象的诸多特性有一定的认识,但动手能力差。后来又学了几天SDK,其实我到是挺喜欢SDK的,看了几章《Windows 程序设计》,最大的难点是消息机制,虽然对消息机制的概念容易理解,但真要熟悉对消息的控制还需要大量的实践。

对SDK有了一定了解,又不想学MFC,而到了这一步,最多也就能用SDK画个界面,写些一般的应用,虽然对我来说仍然有些困难,但到目前至少了解了实现的方法。写界面,用VB或Delphi更容易,所以目前也没必要花时间在这上面。接下来就是怎样仅不用控件又不用MFC类库来实现网络编程,于是认识了socket。

什么是socket,它是一种实现方法,当然你也可以理解为是一种接口或是一种工具。就像我们从一个城市到达另一个城市,你可以选择坐火车去还是坐飞机去。进行网络编程有多种实现方法,但我只知道socket这一种方法,而这也是最普遍使用的一种方法。

有了实现方法,根据需求还会对这种方法进行改良,比如坐火车从一个城市到另一个城市,会对火车的速度进行改进,以便我们能更快的到达目的地。而socket从出现在现在,也有过一进改良,最初是socket1.1,现在有了socket2.1。我们可以使用socket1.1也可以使用socket2.1,当然,经过改良后的socket2.1比socket1.1有多更多的功能。

了解了socket的概念,那么怎样用socket来实现网络编程呢?socket具体的实现代码是放在动态链接库文件(DLL)里的,这个文件名称为wsock32.dll和WS2_32.dll,位于“windows\system32\”文件夹下,其中wsock32.dll是socket1.1的实现代码,WS2_32.dll是socket2.1的实现代码。这两个DLL文件提供了socket网络编程的接口,在编译器的winsock.h和winsock2.h中声明了这两个版本的接口函数,只要包含了其中一个头文件,就能调用DLL来进行socket网络编程。

或许因为某些文字的描述,导致上面这段话的解释有些不清不楚,但至少也能明白个大概。第一步是了解,那第二步就是实现了,怎么进行socket编程,这主要是熟悉winsock.h和winsock2.h头文件中声明的这些函数,比如socket、bind、listen、accept、send、recv这些函数,不过要弄清这些函数的作用还得有一定的网络知识,这些网络知识包括什么是TCP/IP协议、IPX/SPX协议和NetBIOS协议,还有HTTP、FTP、SMTP、POP3这些网络协议。

还好N多年前学过局域网和无盘网络,对IPX/SPX、NetBIOS和TCP/IP有一些认识,其中IPX/SPX协议在以前架设Novell无盘网络时就要用到这个协议,NetBIOS协议是进行局域网通信的,而TCP/IP是进行互联网通信的协议。

下面该说说正题了,怎样用socket来进行网络编程,就是怎么使用winsock.h和winsock2.h中的那些函数。本来前阵子也下了不少网络编程的书,每本书都有讲socket编程,但一打开书目录,看到的全都是一些陌生的技术术语,让我有了畏惧心理,一直没认真看过。今晚发现在孙鑫的《VC++深入详解》里有一章是讲socket网络编程的,所以就准备认真的学习一下,因为孙鑫的书是以通俗易懂闻名的。

一、七步实现服务器端程序的编写

第一步:选择socket版本,加载socket库 (使用WSAStartup函数)

第二步:创建套接字        (使用socket函数)

第三步:绑定IP和端口        (使用bind函数)

第四步:指定创建的套接字为监听模式        (使用listen函数)

第五步:接受客户端请求        (使用accept函数)

第六步:发送/接受数据        (使用send/recv函数)

第七步:关闭套接字        (使用closesocket函数)

以上是一个服务器端程序的编写流程,步骤虽然简单,每一步的作用都好理解,但难的是熟悉这些函数的使用,这些函数里的参数设置搞得人头晕脑涨的,下面来一步一步的解释。

第一步:使用WSAStarup函数选择socket库

前面已经说到,socket有两个版本,所以我们需要选择一个使用的版本,WSAPStarup函数就是用来选择socket库版本的,其声明原型如下:

  1. int WSAStartup(
  2.   WORD wVersionRequested,
  3.   LPWSADATA lpWSAData
  4. );

第一个参数是选择要加载的版本,确定套接字库,第二个参数是指定要使用的套接字库,该函数会返回执行状态,成功则返回0。注意这句话中的“选择”和“指定”,这就好比我们在设置密码时要输入两次,第一次是设置密码,第二次是确认密码。在使用WSAStartup函数时,发现了一个问题,就是当为第一个参数胡乱设置一个版本时,仍然为返回0,即成功选择了套接字库。经过实践证明,当WSAStartup找不到选择要加载的版本时,会自动选择socket1.1。

第二步:使用socket函数创建套接字

为什么要创建套接字?这就好比问人为什么要吃饭一样。socket函数的声明原型如下:

  1. SOCKET socket(
  2.   int af,       
  3.   int type,     
  4.   int protocol  
  5. );

第一个参数是设置地址簇,啥是地址簇呀,我不知道,只知道对于TCP/IP的套接字,这里必须设置为AF_INET或PF_INET。

第二个参数是指定套接字类型,在socket1.1中有SOCK_STREAM和SOCK_DGRAM两种套接字类型,关于套接字类型的介绍可查看《Internet套接字的两种类型》一文,我的理解是设置套接字类型就是设置数据的传送方式,是以流格式传送还是打包后传送。其中流格式套接字只发送一次,保证数据的完整性,且按发送顺序接受,基于TCP协议;而数据报式套接字是打包发送,不保证数据完整性,可重复发送,直到接收方收到为止,基于UDP协议。

第三个参数是指定使用地址簇相关的协议,比如af参数设为AF_INET或PF_INET,就该指定为TCP/IP协议,不过我们可以将其值设置为0,让系统自动选择协议类型。如果需要指定协议时,这个值该怎么设置呢?可以用getprotobyname函数。

socket函数的使用示例代码如下:

  1. SOCKET socketServer;
  2. socketServer = socket(AF_INET, SOCK_STREAM, getprotobyname("tcp"));

 

这才刚接触socket的第二个函数,就搞得晕晕的了,涉及的知识点不少,比如地址簇、套接字类型等,很明显要学网络编程,还得学好网络方面的理论知识,不过现在用不着花大量时间去打基础了,只要会用一些常用函数,了解函数中各类参数的大概作用就行了。

第三步:使用bind函数绑定IP地址和端口

IP地址和端口的概念接触得比较多,IP地址是互联网上一台计算机的名称,而端口,我们知道HTTP服务默认用的是80端口,FTP服务用的是21端口。要进行通信,就必须指定IP地址和端口,就像要找到一个人必须要知道他的姓名和住址一样。说到网络服务,我们知道的就比较多了,Telnet服务默认使用23端口,DNS服务默认使用53端口,如果更多的了解常见的网络服务和使用的端口可看看以下几篇文章:

《网络服务常用网络端口列表之udp协议端口》

http://www.idc588.com.cn/article/403.html

《TCP/IP协议端口大全》

http://www.itasd.com/news/wljs/wlxy/2008/6/086301926394141.html

太多的服务,使用了太多的端口,可以在CMD命令提示符窗口中使用“netstat -an ”命令来看一下自己的电脑当前使用了多少端口。其中1024以下的端口是分配给预定义的服务,如HTTP/FTP之类的,我们编写网络程序时,要为程序指定1024以上的端口号。

现在来看看bind函数的声明原型:

  1. int bind(
  2.   SOCKET s,                          
  3.   const struct sockaddr FAR *name,   
  4.   int namelen                        
  5. );

第一个参数是选择要绑定的套接定。

第二个参数是一个结构体,在这个结构体中设置IP地址和端口,在这个参数中有一个“FAR”,这个好像是设置内存地址寻址方式,用于DOS和16位Windows,可以无视,还是详细说说sockaddr这个结构体吧,下面是sockaddr结构体的定义:

  1. struct sockaddr {
  2.     unsigned short sa_family; //地址簇
  3.     char           sa_data[14]; //分配内存块
  4. };

其中sa_data[14]是指定分配14个字节来存储IP地址和端口信息,刚开始看到这个参数时有些纳闷,要存储IP地址和端口信息,14个字节怎么够呢,后来才发现这里保存的信息是转换过的,不是原始信息。在这个结构体中你会发现没有设置IP地址和端口的结构体成员,这是我们需要使用sockaddr_in结构体来接受IP地址和端口的设置,然后再将sockaddr_in结构体转换为sockaddr结构体,这样在sockaddr结构体的sa_data[14]成员分配的内存块中就保存了IP地址和端口的数据。下面是sockaddr_in结构体的定义:

  1. struct sockaddr_in{
  2.     short            sin_family;
  3.     unsigned short      sin_port;
  4.     struct   in_addr      sin_addr;
  5.     char               sin_zero[8];
  6. };

在这个结构体中又有一个成员是结构体,in_addr结构体的定义如下:

  1. struct in_addr {
  2.   union {
  3.           struct { u_char s_b1,s_b2,s_b3,s_b4; }   S_un_b;
  4.           struct { u_short s_w1,s_w2; }            S_un_w;
  5.           u_long                                   S_addr;
  6.   } S_un;
  7. };

在in_addr结构体中,实际上是分别以unsigned char,unsigned short和unsigned long类型来保存IP地址信息,我们通常使用的都是保存在S_addr中的值。

对于bind函数第二个参数的设置,一口气用到了三个结构体,但最终在bind函数中设置的应该是一个sockaddr结构体。下面来个设置bind函数第二个参数的伪代码:

  1. SOCKADDR_IN addrSrv;
  2. addrSrv.sin_family = AF_INET;
  3. addrSrv.sin_port = 端口号;
  4. addrSrv.sin_addr.S_addr = IP地址;
  5. bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR));

之所以写成伪代码,是因为我们不能直接为addrSrv.sin_port和addrSrv.sin_addr.S_addr赋于类似“218.1.2.120”和“80”这样的字符串值或数值,因为sin_port是一个unsigned short类型,S_addr是一个unsigned long类型。除此之外,对于这两个值的设置还涉及到一个叫字节顺序的概念。

字节顺序是什么?以前从来没接触过这个概念,第一次接触是在SDK中,知道了高位字和低位字。对于高位字和低位字,我的理解是一个字节的数据分为低位字和高位字,即低位字+高位字等于一个字节的数据,再说得通俗一点,就像一个人的名字,分为姓和名,组合起来就是一个人的姓名,有了姓和名才是一个有意义的人名。但不同国家的人,姓名的表示方法也不同,中国人是姓在前,名在后,而外国人是名在前,姓在后。而在计算机中存储一个字节的数据使用不同的CPU也有不同的存储顺序,有的是高位字在前,低位字在后,有的是低位字在前,高位字在后,但对于基于Intel的CPU,是低位字在前,高位字在后。这就是所说的字节顺序。

由此可知,不同的计算机其字节顺序也不同,对于计算机字节顺序,我们称之为主机字节顺序;而网络数据在传输时,也要保存数据,这些数据的存储也有字节顺序,其顺序为高位字在前低位字在后,。所以我们在用网络传输数据时,需要首先将主机字节顺序转换为网络字节顺序。

好了,回到bind函数第二个参数的设置,我们现在需要设置sin_port和S_addr的值,就得分二个步骤,第一步是将如“218.1.2.120”和“80”这样的字符串或数值转换为sin_port和S_addr相应的数据类型,第二步是将转换后的数据类型中的数据从主机字节顺序转换为网络字节顺序,不过使用socket中的函数可以将这两个步骤合二为一。下面是几个字节顺序转换函数:

  1. u_long htonl(u_long hostlong) ;    //主机字节转换为网络字节,32比特整数
  2. u_short htons(u_short hostshort);    //主机字节转换为网络字节,16比特整数
  3. u_long ntohl(u_long netlong);    //网络字节转换为主机字节,32比特整数
  4. u_short ntohs(u_short netshort);    //网络字节转换为主机字节,16比特整数
  5. unsigned long inet_addr(const char FAR *cp);  //将字符串值转换为unsigned long的网络字节
  6. char FAR * inet_ntoa( struct in_addr in); //与inet_addr相反,转换为主机字节

那么设置S_addr和sin_port中的值该如何转换呢?IP地址得用字符串来表示,所以得用inet_addr函数;端口号是一个数值,而sin_port是一个unsigned short数据类型的值,所以得用htons函数来转换。这下bind函数我们就可以设置第二个参数的值了,看下面的代码:

  1. SOCKADDR_IN addrSrv;
  2. addrSrv.sin_family = AF_INET;
  3. addrSrv.sin_port = htons(6000);
  4. addrSrv.sin_addr.S_addr = inet_addr("127.0.0.1");
  5. bind(sockSrv, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR));

费了这么大的劲儿来解释bind函数的第二个参数,真累人。

bind函数的第三个参数是设置第二个参数中SOCKADDR结构体的大小。

第四步: 用listen函数设置套接字为监听模式

listen函数的作用就好理解了,通过监听模式可以监控bind绑定的IP地址和端口上是否有数据请求,该函数的声明原型如下:

  1. int listen(
  2.   SOCKET s,    
  3.   int backlog  
  4. );

第二个参数是设置同一端口上可同时连接的数目,即同时接受多少个请求。

第五步:用accept函数接受请求

当客户端想要发送一个字符串到服务器端,通过listen函数可监听到这个请求,如果要接收客户端发送过来的字符串,就要用accept函数来接受客户端的请求。accept函数的声明原型如下:

  1. SOCKET accept(
  2.   SOCKET s,
  3.   struct sockaddr FAR *addr,
  4.   int FAR *addrlen
  5. );

accept函数会返回一个对于此次连接的套接字类型,并将客户端的IP地址和端口信息存放到sockaddr结构体中。第三个参数是给sockaddr结构体分配一块内存,accept函数的使用如下:

  1. SOCKET sockConn;
  2. SOCKADDR_IN addrClient;
  3. int len;
  4. sockConn = accept(sockSrv, (SOCKADDR*)&addrClient, sizeof(SOCKADDR));

服务器端接受了客户端的请求,那么两者就建立了连接,即可以进行数据传输了。

第六步:用send/recv函数来发送和接收数据

其函数声明原型如下:

 

  1. int send(
  2.   SOCKET s,              
  3.   const char FAR *buf,  
  4.   int len,               
  5.   int flags              
  6. );
  7. int recv(
  8.   SOCKET s,       
  9.   char FAR *buf,  
  10.   int len,        
  11.   int flags       
  12. );

这两个函数的使用就勿须多说了,只是想解释一个小问题。在前面已经说到,通过网络传输数据,需要将主机字节转换为网络字节,那么使用send或recv函数来发送和接收数据是否也要进行字节顺序转换呢?答案是不。因为send/recv是socket的函数,会自动将主机字节转换为网络字节,照目前来看,需要转换的就是bind函数中的IP地址字符器和端口数值。

第七步:(1)用closesocket函数关闭套接字 (2)用WSACleanup函数结束socket库的使用

我们打开一个文件不用以后需要关闭这个文件,而使用了套接字和socket库后也要做个扫尾工作。

二、五步实现客户端程序的编写

第一步:选择socket版本,加载socket库 (使用WSAStartup函数)

第二步:创建套接字        (使用socket函数)

第三步:接受客户端请求        (使用accept函数)

第四步:发送/接受数据        (使用send/recv函数)

第五步:关闭套接字        (使用closesocket函数)

很明显,客户端程序的编写少了两个步骤,分别是用bind函数绑定IP地址和端口以及设置套接字的监听模式,为什么?要弄清这个问题,可以回头看看服务器端的accept函数,该函数的第二个参数是一个sockaddr结构体,这里面保存的就是客户端程序的IP地址和端口,服务器端在接受通信请求时就获取了客户端的IP地址和端口。

三、结束语

这是一个基于TCP的socket编程例子,弄清这个例子的使用我花了三个晚上,第一个晚上把书上的这部分内容看了一遍,有些犯晕,只了解了大致的步骤,但弄不清这些函数里面各个参数的作用和设置,记不到脑子里,于是把书上的代码敲了一遍。这段代码敲得比较慢,每到一个函数的使用时都回头看看书中对该函数的解释,这段代码敲下来后思路清晰些许,但记得还是不牢靠。第二个晚上和第三个晚上就是打字了,打了上面这么一大篇,又把整个过程理了一遍。看来这写读书笔记确实如人所说,很费时,不过结果是我现在可以把书扔了也能写出这个例子的代码。

posted @ 2008-10-27 01:45  冷寒生  阅读(284)  评论(0编辑  收藏  举报
IT知识库