echo 'narcissus'

Trends in Cloud Computing

Proxy源代码分析--谈谈如何学习Linux网络编程

Linux是一个可靠性非常高的操作系统,但是所有用过Linux的朋友都会感觉到,Linux和Windows这样的傻瓜操作系统(这里丝毫没有贬低Windows的意思,相反这应该是Windows的优点)相比,后者无疑在易操作性上更胜一筹。但是为什么又有那么多的爱好者钟情于Linux呢,当然自由是最吸引人的一点,另外Linux强大的功能也是一个非常重要的原因,尤其是Linux强大的网络功能更是引人注目。放眼今天的WAP业务、银行网络业务和曾经红透半边天的电子商务,都越来越倚重基于Linux的解决方案。因此Linux网络编程是非常重要的,而且当我们一接触到Linux网络编程,我们就会发现这是一件非常有意思的事情,因为以前一些关于网络通信概念似是而非的地方,在这一段段代码面前马上就豁然开朗了。在刚开始学习编程的时候总是让人感觉有点理不清头绪,不过只要多读几段代码,很快我们就能体会到其中的乐趣了。下面我就从一段Proxy源代码开始,谈谈如何进行Linux网络编程。

首先声明,这段源代码不是我编写的,让我们感谢这位名叫Carl Harris的大虾,是他编写了这段代码并将其散播到网上供大家学习讨论。这段代码虽然只是描述了最简单的proxy操作,但它的确是经典,它不仅清晰地 描述了客户机/服务器系统的概念,而且几乎包括了Linux网络编程的方方面面,非常适合Linux网络编程的初学者学习。

这段Proxy程序的用法是这样的,我们可以使用这个proxy登录其它主机的服务端口。假如编译后生成了名为Proxy的可执行文件,那么命令及其参数的描述为:
./Proxy <proxy_port> <remote_host> <service_port>
其中参数proxy_port是指由我们指定的代理服务器端口。参数remote_host是指我们希望连接的远程主机的主机名,IP地址也同样有效。这个主机名在网络上应该是唯一的,如果您不确定的话,可以在远程主机上使用uname -n命令查看一下。参数service_port是远程主机可提供的服务名,也可直接键入服务对应的端口号。这个命令的相应操作是将代理服务器的proxy_port端口绑定到remote_host的service_port端口。然后我们就可以通过代理服务器的proxy_port端口访问remote_host了。例如一台计算机,网络主机名是legends,IP地址为10.10.8.221,如果在我的计算机上执行:
#./proxy 8000 legends telnet
那么我们就可以通过下面这条命令访问legends的telnet端口。
-----------------------------------------------------------------
[root@lee /root]#telnet legends 8000
Trying 10.10.8.221...
Connected to legends(10.10.8.221).
Escape character is '^]'

Red Hat Linux release 6.2(Zoot)
Kernel 2.2.14-5.0 on an i686
Login:
-----------------------------------------------------------------
上面的绑定操作也可以使用下面的命令:
[root@lee /root]#./proxy 8000 10.10.8.221 23
23是telnet服务的标准端口号,其它服务的对应端口号我们可以在/etc/services中查看。

下面我就从这段代码出发谈谈我对Linux网络编程的一些粗浅的认识,不对的地方还请各位大虾多多批评指正。 

#include <stdio.h> 
#include 
<ctype.h> 
#include 
<errno.h> 
#include 
<signal.h> 
#include 
<sys/types.h> 
#include 
<sys/socket.h> 
#include 
<sys/file.h> 
#include 
<sys/ioctl.h> 
#include 
<sys/wait.h> 
#include 
<sys/types.h> 
#include 
<netdb.h> 
#define TCP_PROTO   "tcp" 
int proxy_port;    /* port to listen for proxy connections on */ 
struct sockaddr_in hostaddr;   /* host addr assembled from gethostbyname() */ 
extern int errno;   /* defined by libc.a */ 
extern char *sys_myerrlist[]; 
void parse_args (int argc, char **argv); 
void daemonize (int servfd); 
void do_proxy (int usersockfd); 
void reap_status (void); 
void errorout (char *msg);
/*This is my modification. 
I'll tell you why we must do this later
*/

typedef 
void Signal(int);
/****************************************************************
function:    main 
description:   Main level driver. After daemonizing the process, a socket is opened to listen for connections on the proxy port, connections are accepted and children are spawned to handle each new connection. 
arguments:    argc,argv you know what those are. 
return value:  none. 
calls:      parse_args, do_proxy. 
globals:     reads proxy_port. 
***************************************************************
*/

main (argc,argv) 
int argc; 
char **argv; 

int clilen; 
int childpid; 
int sockfd, newsockfd; 
struct sockaddr_in servaddr, cliaddr; 
parse_args(argc,argv); 
/* prepare an address struct to listen for connections */ 
bzero((
char *&servaddr, sizeof(servaddr)); 
servaddr.sin_family 
= AF_INET; 
servaddr.sin_addr.s_addr 
= htonl(INADDR_ANY); 
servaddr.sin_port 
= proxy_port; 
/* get a socket... */ 
if ((sockfd = socket(AF_INET,SOCK_STREAM,0)) < 0
fputs(
"failed to create server socket ",stderr); 
exit(
1); 
}
 
/* ...and bind our address and port to it */ 
if (bind(sockfd,(struct sockaddr_in *&servaddr,sizeof(servaddr)) < 0
fputs(
"faild to bind server socket to specified port ",stderr); 
exit(
1); 
}
 
/* get ready to accept with at most 5 clients waiting to connect */ 
listen(sockfd,
5); 
/* turn ourselves into a daemon */ 
daemonize(sockfd); 
/* fall into a loop to accept new connections and spawn children */ 
while (1{
/* accept the next connection */ 
clilen 
= sizeof(cliaddr); 
newsockfd 
= accept(sockfd, (struct sockaddr_in *&cliaddr, &clilen); 
if (newsockfd < 0 && errno == EINTR) 
continue
/* a signal might interrupt our accept() call */ 
else if (newsockfd < 0
/* something quite amiss -- kill the server */ 
errorout(
"failed to accept connection");
/* fork a child to handle this connection */ 
if ((childpid = fork()) == 0
close(sockfd); 
do_proxy(newsockfd); 
exit(
0); 
}
 
/* if fork() failed, the connection is silently dropped -- oops! */ 
lose(newsockfd); 
}
 
}

 

上面就是Proxy源代码的主程序部分,也许您在网上也曾经看到过这段代码,不过细心的您会发现在上面这段代码中我修改了两个地方,都是在预编译部分。一个地方是在定义外部字符型指针数组时,我将原代码中的
extern char *sys_errlist[];
修改为
extern char *sys_myerrlist[];原因是在我的Linux环境下头文件"stdio.h"已经对sys_errlist[]进行了如下定义:
extern __const char *__const sys_errlist[];
也许Carl Harris在94年编写这段代码时系统还没有定义sys_errlist[],不过现在我们不修改一下的话,编译时系统就会告诉我们sys_errlist发生了定义冲突。
另外我添加了一个函数类型定义:
typedef void Sigfunc(int);
具体原因我将在后面向大家解释。

套接字和套接字地址结构定义

   这段主程序是一段典型的服务器程序。网络通讯最重要的就是套接字的使用,在程序的一开始就对套接字描述符sockfd和newsockfd进行了定义。 接下来定义客户机/服务器的套接字地址结构cliaddr和servaddr,存储客户机/服务器的有关通信信息。然后调用parse_args (argc,argv)函数处理命令参数。关于这个parse_args()函数我们待会儿再做介绍。

创建通信套接字

  下面就是建立一个服务器的详细过程。服务器程序的第一个操作是创建一个套接字。这是通过调用函数socket()来实现的。socket()函数的具体描述为:

#include <sys/types.h>
#include 
<sys/socket.h>
int socket(int domain, int type, int protocol);
参数domain指定套接字使用的协议族,AF_INET表示使用TCP/IP协议族,AF_UNIX表示使用Unix协议族,AF_ISO表示套接 字使用ISO协议族。type指定套接字类型,一般的面向连接通信类型(如TCP)设置为SOCK_STREAM,当套接字为数据报类型时,type应设 置为SOCK_DGRAM,如果是可以直接访问IP协议的原始套接字则type应设置为SOCK_RAW。参数protocol一般设置为"0",表示使 用默认协议。当socket()函数成功执行时,返回一个标志这个套接字的描述符,如果出错则返回"-1",并设置errno为相应的错误类型。

设置服务器套接字地址结构

   在通常情况下,首先要将描述服务器信息的套接字地址结构清零,然后在地址结构中填入相应的内容,准备接受客户机送来的连接建立请求。这个清零操作可以用多种字节处理函数来实现,例如bzero()、bcopy()、memset()、memcpy()等,以字母"b"开始的两个函数是和BSD系统兼容的,而后面两个是ANSI C提供的函数。这段代码中使用的bzero()其描述为:
void bzero(void *s, int n);
函数的具体操作是将参数s指定的内存的前n个字节清零。memset()同样也很常用,其描述为:
void *memset(void *s, int c, size_t n);
具体操作是将参数s指定的内存区域的前n个字节设置为参数c的内容。
下一步就是在已经清零的服务器套接字地址结构中填入相应的内容。Linux系统的套接字是一个通用的网络编程接口,它应该支持多种网络通信协议,每一种协议都使用专门为自己定义的套接字地址结构(例如TCP/IP网络的套接字地址结构就是struct sockaddr_in)。不过为了保持套接字函数调用参数的一致性,Linux系统还定义了一种通用的套接字地址结构:

#include <linux/socket.h>
struct sockaddr
{
unsigned 
short sa_family; /* address type */
char sa_data[14]; /* protocol address */
}

其中sa_family意指套接字使用的协议族地址类型,对于我们的TCP/IP网络,其值应该是AF_INET,sa_data中存储具体的协议地址,不同的协议族有不同的地址格式。这个通用的套接字地址结构一般不用做定义具体的实例,但是常用做套接字地址结构的强制类型转换,如我们经常可以看到这 样的用法:
bind(sockfd,(struct sockaddr *) &servaddr,sizeof(servaddr))
用于TCP/IP协议族的套接字地址结构是sockaddr_in,其定义为:

#include <linux/in.h>
struct in_addr
{
__u32 s_addr;
}
;
struct sochaddr_in
{
short int sin_family;
unsigned 
short int sin_port;
struct in_addr sin_addr;
/*This part has not been taken into use yet*/
unsigned char_ _ pad[_ _ SOCK_SIZE__
- sizeof(short int-sizeof(unsigned short int- sizeof(struct in_addr)];
}
;
#define sin_zero_ - pad
其中sin_zero成员并未使用,它是为了和通用套接字地址struct sockaddr兼容而特意引入的。在编程时,一般都通过bzero()或是memset()将其置零。其他成员的设置一般是这样的:
servaddr.sin_family = AF_INET;
表示套接字使用TCP/IP协议族。
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
设置服务器套接字的IP地址为特殊值INADDR_ANY,这表示服务器愿意接收来自任何网络设备接口的客户机连接。htonl()函数的意思是将主机顺序的字节转换成网络顺序的字节。
servaddr.sin_port = htons(PORT);
设置通信端口号,PORT应该是我们已经定义好的。在本例中servaddr.sin_port = proxy_port;这是表示端口号是函数的返回值proxy_port。
另外需要说明的一点是,在本例中,我们并没有看到在预编译部分中包含有<linux/socket.h>和< linux/in.h>这两个头文件,那是因为这两个头文件已经分别被包含在<sys/types.h>和< sys/types.h>中了,而且后面这两个头文件是与平台无关的,所以在网络通信中一般都使用这两个头文件。

服务器公开地址

  如果服务器要接受客户机的连接请求,那么它必须先要在整个网络上公开自己的地址。在设置了服务器的套接字地址结构之后,可以通过调用函数bind()绑定服务器的地址和套接字来完成公开地址的操作。函数bind()的详细描述为:

#include <sys/types.h>
#include 
<sys/socket.h>
int bind(int sockfd, struct sockaddr *addr, int addrlen);
参数sockfd是我们通过调用socket()创建的套接字描述符。参数addr是本机地址,参数addrlen是套接字地址结构的长度。函数执行成功时返回"0",否则返回"-1",并设置errno变量为EADDRINUAER。
如果是服务器调用bind()函数,如果设置了套接字的IP地址为某个本地IP地址,那么这表示服务器只接受来自于这个IP地址的特定主机发出的连接请求。不过一般情况下都是将IP地址设置为INADDR_ANY,以便接受所有网络设备接口送来的连接请求。
客户机一般是不会调用bind()函数的,因为客户机在连接时不用指定自己的套接字地址端口号,系统会自动为客户机选择一个未用端口号,并且用本地IP地址自动填充客户机套接字地址结构中的相应项。但是在某些特定的情况下客户机需要使用特定的端口号,例如Linux中的rlogin命令就要求使用保 留端口号,而系统是不能为客户机自动分配保留端口号的,这就需要调用bind()来绑定一个保留端口号了。不过在一些特殊的环境下,这样绑定特定端口号也 会带来一些负面影响,如在HTTP服务器进入TIME_WAIT状态后,客户机如果要求再次与服务器建立连接,则服务器会拒绝这一连接请求。如果客户机最 后进入TIME_WAIT状态,则马上再次执行bind()函数时会返回出错信息"-1",原因是系统会认为同时有两次连接绑定同一个端口。

转换Listening套接字

接下来,服务器需要将我们刚才与IP地址和端口号完成绑定的套接字转换成倾听listening套接字。只有服务器程序才需要执行这一步操作。我们通过调用函数listen()实现这一操作。listen()的详细描述为:

#include <sys/socket.h>
int listen(int sockfd, int backlog);
可以看出程序在处理这两种情况时操作是完全不同的,同样是accept()返回"-1",如果有errno == EINTR,那么系统将再次调用accept()接受连接请求,否则服务器进程将直接结束。

处理客户机请求

   当服务器与客户机建立连接以后,就可以处理客户机的请求了。一般情况下服务器程序都要创建一个子进程用于处理客户机请求;而父进程则继续监听,时刻准备接受其它客户机的连接请求。我们这段proxy程序也不例外。它通过调用fork()创建处理客户机请求的子进程。我想在linux/Unix编程中,fork()的重要性不用我再多说什么了,在大型的服务器程序中,一般都要在子进程里,根据客户机请求的不同而通过exec()系列函数调用不同的处理程序,这也是在学习linux/Unix编程中一个非常重要的地方。不过我们这个proxy程序旨在讲述一些linux网络编程的基本概念,因此在子程序部分就直接调用了一个完成proxy功能的函数do_proxy(),其实际参数newsockfd就是accept()返回的套接字描述符。另外值得注意的一点就是,因为子进程继承了所有父进程中可用的文件描述符,所以我们必须在子进程中关闭倾听套接字(代码中子进程部分的close (sockfd);),同时在父进程中关闭accept()返回的套接字描述符(例如代码中父进程部分的close(newsockfd);)。

◆函数parse_args()

此函数的定义是:void parse_args (int argc, char **argv);

/**************************************************************** 
function:    parse_args 
description:  parse the command line args. 
arguments:    argc,argv you know what these are. 
return value:  none. 
calls:      none. 
globals:     writes proxy_port, writes hostaddr. 
***************************************************************
*/
 
void parse_args (argc,argv) 
int argc; 
char **argv; 

int i; 
struct hostent *hostp; 
struct servent *servp; 
unsigned 
long inaddr; 
struct 
char proxy_port [16]; 
char isolated_host [64]; 
char service_name [32]; 
}
 pargs; 
if (argc < 4
printf(
"usage: %s <proxy-port> <host> <service-name|port-number> ", argv[0]); 
exit(
1); 
}
 
strcpy(pargs.proxy_port,argv[
1]); 
strcpy(pargs.isolated_host,argv[
2]); 
strcpy(pargs.service_name,argv[
3]); 
for (i = 0; i < strlen(pargs.proxy_port); i++
if (!isdigit(*(pargs.proxy_port + i))) 
break
if (i == strlen(pargs.proxy_port)) 
proxy_port 
= htons(atoi(pargs.proxy_port)); 
else 
printf(
"%s: invalid proxy port ",pargs.proxy_port); 
exit(
0); 
}
 
bzero(
&hostaddr,sizeof(hostaddr)); 
hostaddr.sin_family 
= AF_INET; 
if ((inaddr = inet_addr(pargs.isolated_host)) != INADDR_NONE) 
bcopy(
&inaddr,&hostaddr.sin_addr,sizeof(inaddr)); 
else if ((hostp = gethostbyname(pargs.isolated_host)) != NULL) 
bcopy(hostp
->h_addr,&hostaddr.sin_addr,hostp->h_length); 
else 
printf(
"%s: unknown host ",pargs.isolated_host); 
exit(
1); 
}
 
if ((servp = getservbyname(pargs.service_name,TCP_PROTO)) != NULL) 
hostaddr.sin_port 
= servp->s_port; 
else if (atoi(pargs.service_name) > 0
hostaddr.sin_port 
= htons(atoi(pargs.service_name)); 
else 
printf(
"%s: invalid/unknown service name or port number ", pargs.service_name); 
exit(
1); 
}
 
}
这个函数的作用是传递命令行参数。参数的传递是通过两个全局变量来实现的,这两个变量是int proxy_port和struct sockaddr_in hostaddr。分别用于传递等待连接请求的proxy端口和被绑定的主机网络信息。

检验命令行参数

   在进行了局部变量定义以后,函数首先要检测命令行参数是否符合程序的要求,即在命令后紧跟代理服务器端口、远程主机名和服务端口号,如果不满足上述要求,则代理服务器程序结束。如果满足上述要求,则将命令行的这三个参数存储进我们自定义的pargs结构之中。注意pargs结构的三个成员都是以字符形式存放命令行参数信息的,后面我们需要调用函数将这些参数信息都转换成为数字形式的。

传递参数

   接下来就要将命令行的三个参数变换成合适的形式赋值给全局变量proxy_port和hostaddr,以供其它函数调用。首先传送代理服务器端口pargs.proxy_port,在这里程序调用了一个系统函数isdigit()检验用户输入的端口号是否有效。isdigit()的具体描述为:

#include <ctype.h>
int isdigit(int c);
isdigit()函数用来检测参数"c"是否是数字1~9中间的一个,如果答案是肯定的,则返回非"0"值,反之,返回"0"。程序中采用了这样的方法来对用户的输入进行逐位检验:
if (!isdigit(*(pargs.proxy_port + i)))
break;
在将有效端口号传递给全局变量proxy_port之前,还要将其转换成为网络字节顺序。这是因为网络中存在着多个公司的不同设备,这些设备表示数据 的字节顺序是不同的。例如在内存地址0x1000处存储一个16位的整数FF11,不同公司的机器在内存中的存储方式也不相同,有的将FF置于内存指针的 起始位置0x1000,11置于0x1001,这称为big-endian顺序;有的却恰恰相反,即little-endian顺序。这种基于主机的数据 存储顺序就称为主机字节顺序(host byte order)。为了在不同类型的主机之间进行通信,网络协议就规定了一种统一的网络字节顺序,这种顺序被规定为little-endian顺序。所以数据的网络字节顺序和主机字节顺序有可能是不同的,因此在编写通信程序时一定要注意不同顺序之间的转换。所以,程序中一定要有例程中这样的语句:
proxy_port = htons(atoi(pargs.proxy_port));
函数htons()的作用就是将主机字节顺序转换为网络字节顺序。它的具体描述为:
#include <netinet/in.h>
unsigned 
short int htons(unsigned short int data);
与htons()相似的函数还有三个,它们分别是htonl()、ntohs()和ntohl(),都用于网络与主机字节顺序之间的转换。如果这几个名字比较容易混淆的话,我们可以这样记忆:函数名中的h代表host,n代表network,s代表unsigned short,l代表unsigned long。所以"hton"即为"host-to-network":变换主机字节为网络字节。接收数据的就要用到"ntoh"("network-to -host")函数了。
在我们的例程中,由于端口号一般情况下最多不会超过4位数字,所以选用unsigned short型的htons()即可。
注意在例程中htons()的参数是另一个函数atoi()的返回结果。atoi()函数的具体描述为:
#include <stdlib.h>
int atoi(const char *nptr);
它的作用是将字符指针nptr指向的字符串转换成相应的整数并将其作为结果返回。这个操作与函数调用strtol(nptr,(char **)NULL,10)的效果几乎完全相同,唯一的区别是atoi()没有出错返回信息。之所以要调用这个函数是因为,系统在读取命令行的时候将所有的参 数都作为字符串处理,所以我们必须将其转换为整数形式。
接下来,例程先将全局变量hostaddr的所有成员清零,然后将成员hostaddr.sin_family设置为TCP/IP协议族标志 AF_INET。下面就可将命令行的另外两个参数<remote_host>和<service_port>传递给全局变量 hostaddr的两个成员hostaddr.sin_port和hostaddr.sin_addr了。这里我们用到了两个局部变量struct hostent *hostp和struct servent *servp来传递参数信息。struct hostent的详细描述为:
struct hostent {
char *h_name;
char **h_aliases;
int h_addrtype;
int h_length;
char **h_addr_list;
}
;
#define h_addr h_addrlist[0];
hostent成员的含义是h_name代表主机在网络上的的正式名称,h_aliases是所有主机别名的列表,h_addrtype是指主机的地址类型,一般设置为TCP/IP协议族AF_INET,h_length是主机的地址长度,一般设置为4个字节。h_addr_list是主机的IP地址列表。
我们要用它来传递我们期望绑定的远程主机名或是IP地址。因为命令行中的主机名参数已经被存储进pargs.isolated_host,所以我们就调用inet_addr()函数对主机名或主机的IP地址进行二进制和字节顺序转换。inet_addr()函数的描述为:
#include <sys/socket.h>
#include 
<netinet/in.h>
#include 
<arpa/inet.h>
unsigned 
long int inet_addr(const char *cp);
inet_addr()的作用就是将参数cp指向的Internet主机地址从数字/点的形式转换成二进制形式并同时转换为网络字节顺序,并将转换结果直接返回。如果cp指向的IP地址不可用,则函数返回INADDR_NONE或"-1"。
虽然Carl Harris在编写这段程序时使用了这个inet_addr()函数,但是我还是建议大家在编写自己的程序时使用另外一个函数inet_aton()来完成这些功能。原因是inet_addr()在IP地址不可用时返回"-1",但我们想想,IP地址255.255.255.255绝对是一个有效地址,那么其二进制返回值也将是"-1",因此inet_addr()无法对这个IP地址进行处理。而函数inet_aton()则采用了一种更好的方法来返回出错信息,它的具体描述为:
#include <sys/socket.h>
#include 
<netinet/in.h>
#include 
<arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
函数执行成功时返回非零,转换结果存入指针inp指向的in_addr结构。这个结构定义我们在前面的文章里已经介绍过了。如果参数cp指向的IP地址不可用,则返回"0"。这就避免发生inet_addr()那样的问题。
如果说用户在命令行中键入的是远程主机的IP地址,那么只用inet_addr()就算完成任务了,但如果用户键入的是主机域名那该怎么办呢?所以我们在例程中可以看到这样的语句:
if ((inaddr = inet_addr(pargs.isolated_host)) != INADDR_NONE) 
bcopy(
&inaddr,&hostaddr.sin_addr,sizeof(inaddr)); 
else if ((hostp = gethostbyname(pargs.isolated_host)) != NULL) 
bcopy(hostp
->h_addr,&hostaddr.sin_addr,hostp->h_length); 
else 
printf(
"%s: unknown host ",pargs.isolated_host); 
exit(
1); 
}
其中gethostbyname()函数就是用来转换主机域名的。它的具体描述为:
#include <netdb.h>
struct hostent *gethostbyname(const char *hostname);
参数hostname指向我们需要转换的域名地址,函数直接返回转换结果,如果函数执行成功,则结果直接返回到一个指向hostent结构的指针中,否则返回空指针NULL。
例程就是这样调用inet_addr()和gethostbyname()将命令行参数中的主机域名或是主机IP地址传递给全局变量hostaddr的成员sin_addr以便代理执行函数do_proxy()调用。
下面是传递服务名或是服务端口号。这里要用到结构servent做传递中介,struct servent的详细描述为:
struct servent {
char *s_name;
char **s_aliases;
int s_port;
char *s_proto;
}
;
其各成员的含义是s_name为服务的正式名称,如ftp、http等,s_aliases是服务的别名列表,s_port是服务的端口号,例如在一般情况下ftp的端口号为21,http服务的端口号为80,注意此端口号应该存储为网络字节顺序,s_proto是应用协议的类型。
例程中使用getservbyname()函数转换命令行参数中的服务名,此函数的详细描述为:
#include <netdb.h>
struct servent * getservbyname(const char *servname, const char *protoname);
它的作用就是转换指针servname指向的服务名为相应的整数表示的端口号,参数protoname表示服务使用的协议,例程中protoname 参数的值为TCP_PROTO,这表示使用TCP协议。函数成功时就返回一个struct servent型的指针,其中的s_port成员就是我们关心的服务端口号。如果用户在命令中键入的是端口号而不是服务名,那么和处理代理端口信息一样, 使用下面的语句进行处理:
hostaddr.sin_port = htons(atoi(pargs.service_name));
到这里,命令行的参数已经全部被转换成为网络通信所要求的字节顺序和数字类型,并且存储在三个全局变量中,就等着do_proxy()函数来调用了。

◆daemonize()函数创建守护进程

   在对main()函数进行介绍的时候我就提到过,一般服务器程序在接收客户机连接请求之前,都要创建一个守护进程。守护进程是linux/Unix编程 中一个非常重要的概念,因为在创建一个守护进程的时候,我们要接触到子进程、进程组、会晤期、信号机制以及文件、目录、控制终端等多个概念,因此详细地讨论一下守护进程,对初学者学习进程间关系是非常有帮助的。下面就是例程中的daemonize()函数:

/**************************************************************** 
function:  daemonize 
description: detach the server process from the current context, creating a pristine, predictable environment in which it will execute.
arguments: servfd file descriptor in use by server. 
return value: none. 
calls:    none. 
globals:   none. 
***************************************************************
*/
 
void daemonize (servfd) 
int servfd; 

int childpid, fd, fdtablesize; 
/* ignore terminal I/O, stop signals */ 
signal(SIGTTOU,SIG_IGN); 
signal(SIGTTIN,SIG_IGN); 
signal(SIGTSTP,SIG_IGN); 
/* fork to put us in the background (whether or not the user 
specified '&' on the command line 
*/
 
if ((childpid = fork()) < 0
fputs(
"failed to fork first child ",stderr); 
exit(
1); 
}
 
else if (childpid > 0
exit(
0); /* terminate parent, continue in child */ 
/* dissociate from process group */ 
if (setpgrp(0,getpid())<0
fputs(
"failed to become process group leader ",stderr); 
exit(
1); 
}
 
/* lose controlling terminal */ 
if ((fd = open("/dev/tty",O_RDWR)) >= 0
ioctl(fd,TIOCNOTTY,NULL); 
close(fd); 
}
 
/* close any open file descriptors */ 
for (fd = 0, fdtablesize = getdtablesize(); fd < fdtablesize; fd++
if (fd != servfd) 
close(fd);
/* set working directory to allow filesystems to be unmounted */ 
chdir(
"/"); 
/* clear the inherited umask */ 
umask(
0); 
/* setup zombie prevention */ 
signal(SIGCLD,(Sigfunc 
*)reap_status); 
}
此函数的作用就是创建一个守护进程。在Linux系统中,如果要将一个普通进程转换成为守护进程,必须要执行下面的步骤:
1. 调用函数fork()创建子进程,然后父进程终止,保留子进程继续运行。之所以要让父进程终止是因为,当一个进程是以前台进程方式由shell启动时,在父进程终止之后子进程自动转为后台进程。另外,我们在下一步要创建一个新的会晤期,这就要求创建会晤期的进程不是一个进程组的组长进程。当父进程终止,子进程运行,这就保证了进程组的组ID与子进程的进程ID不会相等。
函数fork()的定义为:
#include <sys/types.h>
#include 
<unistd.h>
pid_t fork(
void);
该函数被调用一次,但是返回两次,这两次返回的区别是子进程的返回值为"0",而父进程的返回值为子进程的ID。如果出错则返回"-1"。

2. 保证进程不会获得任何控制终端。通常的做法是调用函数setsid()创建一个新的会晤期。setsid()的详细描述为:
#include <sys/types.h>
#include 
<unistd.h>
pid_t setsid(
void);
第一步的操作已经保证调用此函数的进程不是进程组的组长,那么此函数将创建一个新的会晤,其结果是:首先,此进程变成该会晤期的首进程 (session leader,系统默认会晤期的首进程是创建该会晤期的进程)。而且,此进程是该会晤期中的唯一进程。然后,此进程将成为一个新的进程组的组长进程,新进 程组的组ID就是该进程的进程ID。最后,保证此进程没有控制终端,即使在调用setsid()之前此进程拥有控制终端,在创建会晤期后这种联系也将被解除。如果调用该函数的进程为一个进程组的组长,那么函数将返回出错信息"-1"。
当然我们还有其他的办法让进程无法获得控制终端,就象例程中所做的那样,
if ((fd = open("/dev/tty",O_RDWR)) >= 0
ioctl(fd,TIOCNOTTY,NULL); 
close(fd); 
}
其中/dev/tty是一个流设备,也是我们的终端映射。调用close()函数将终端关闭。

3.信号处理。一般是要忽略掉某些信号。这里就涉及到信号的概念了。信号其实相当于软件中断,Linux/Unix下的信号机制提供了一种处理异步事件的方法,终端用户键入印发中断的键,或是系统异常发出信号,这都会通过信号处理机制终止一个或多个程序的运行。
不同情况下引发的信号是不同的。不过所有的信号都有自己的名字,所有的名字都是以"SIG"开头的,只是后面有所不同,我们可以通过这些名字了解到系统中到底发生了些什么事。

当信号出现时,我们可以要求系统进行以下三种操作:
◇忽略信号。大多数信号都是采取这种方式进行处理的,在例程中我们就可以见到这种用法。但值得注意的是有两个例外,那就是对SIGKILL和SIGSTOP信号不能做忽略处理。
◇捕捉信号。这是一种最为灵活的操作方式。这种处理方式的意思就是,当某种信号发生时,我们可以调用一个函数对这种情况进行相应的处理。最常见的情况 就是,如果捕捉到SIGCHID信号,则表示子进程已经终止,然后可在此信号的捕捉函数中调用waitpid()函数取得该子进程的进程ID以及它的终止态。在我们这段例程中,就有这种用法的一个实例。还有就是如果进程创建了临时文件,那么就要为进程终止信号SIGTERM编写一个信号捕捉函数来清除这些临时文件。
◇执行系统的默认动作。对绝大多数信号而言,系统的默认动作都是终止该进程。
在Linux下,信号有很多种,我在这里就不一一介绍了,如果想详细地对这些信号进行了解,可以查看头文件<sigal.h>,这些信号 都被定义为正整数,也就是它们的信号编号。在对信号进行处理时,必须要用到函数signal(),此函数的详细描述为:
#include <signal.h>
void (*signal (int signo, void (*func)(int)))(int);
其中参数signo为信号名,参数func的值根据我们的需要可以是以下几种情况:(1)常数SIG_DFL,表示执行系统的默认动作。(2)常数SIG_IGN,表示忽略信号。(3)收到信号后需要调用的处理函数的地址,此信号捕捉程序应该有一个整型参数但是没有返回值。signal()函数返回 一个函数指针,而该指针指向的函数应该无返回值(void),这个指针其实指向以前的信号捕捉程序。
下面回到我们的daemonize()函数上来。这个函数在创建守护进程时忽略了三个信号:
signal(SIGTTOU,SIG_IGN);
signal(SIGTTIN,SIG_IGN);
signal(SIGTSTP,SIG_IGN);
这三个信号的含义分别是:SIGTTOU表示后台进程写控制终端,SIGTTIN表示后台进程读控制终端,SIGTSTP表示终端挂起。

4.关闭不再需要的文件描述符,并为标准输入、标准输出和标准错误输出打开新的文件描述符(也可以继承父进程的标准输入、标准输出和标准错误输出文件描述符,这个操作是可选的)。在我们这段例程中,因为是代理服务器程序,而且是在执行了listen()函数之后执行这个daemonize()的,所以要保留已经转换成功的倾听套接字,所以我们可以见到这样的语句:
if (fd != servfd) 
close(fd);
5.调用函数chdir("/")将当前工作目录更改为根目录。这是为了保证我们的进程不使用任何目录。否则我们的守护进程将一直占用某个目录,这可能会造成超级用户不能卸载一个文件系统。

6.调用函数umask(0)将文件方式创建屏蔽字设置为"0"。这是因为由继承得来的文件创建方式屏蔽字可能会禁止某些许可权。例如我们的守护进程 需要创建一组可读可写的文件,而此守护进程从父进程那里继承来的文件创建方式屏蔽字却有可能屏蔽掉了这两种许可权,则新创建的一组文件其读或写操作就不能生效。因此要将文件方式创建屏蔽字设置为"0"。
在daemonize()函数的最后,我们可以看到这样的信号捕捉处理语句:
signal(SIGCLD,(Sigfunc *)reap_status);
这不是创建守护进程过程中必须的一步,它的作用是调用我们自定义的reap_status()函数来处理僵死进程。reap_status()在例程中的定义为:
/**************************************************************** 
function:    reap_status 
description:   handle a SIGCLD signal by reaping the exit status of the perished child, and discarding it. 
arguments:    none. 
return value:  none. 
calls:      none. 
globals:     none. 
***************************************************************
*/
 
void reap_status() 

int pid; 
union wait status; 
while ((pid = wait3(&status,WNOHANG,NULL)) > 0
/* loop while there are more dead children */ 
}
上面信号捕捉语句的原文为:
signal(SIGCLD, reap_status);
我们刚才说过,signal()函数的第二个参数一定要有有一个整型参数但是没有返回值。而reap_status()是没有参数的,所以原来的语句 在编译时无法通过。所以我在预编译部分加入了对Sigfunc()的类型定义,在这里用做对reap_status进行强制类型转换。而且在BSD系统中 通常都使用SIGCHLD信号来处理子进程终止的有关信息,SIGCLD是System V中定义的一个信号名,如果将SIGCLD信号的处理方式设定为捕捉,那么内核将马上检查系统中是否存在已经终止等待处理的子进程,如果有,则立即调用信号捕捉处理程序。
一般在信号捕捉处理程序中都要调用wait()、waitpid()、wait3()或是wait4()来返回子进程的终止状态。这些"等待"函数的 区别是,当要求函数"等待"的子进程还没有终止时,wait()将使其调用者阻塞;而在waitpid()的参数中可以设定使调用者不发生阻塞,wait ()函数不被设置为等待哪个具体的子进程,它等待调用者所有子进程中首先终止的那个,而在调用waitpid()时却必须在参数中设定被等待的子进程ID。而wait3()和wait4()的参数分别比wait()和waitpid()还要多一个"rusage"。例程中的reap_status() 就调用了函数wait3(),这个函数是BSD系统支持的,我们把它和wait4()的定义一起列出来:
include <sys/types.h>
#include 
<sys/wait.h>
#include 
<sys/time.h>
#include 
<sys/resource.h>
pid_t wait3(
int *statloc, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, 
int *statloc, int options, struct rusage *rusage);
其中指针statloc如果不为"NULL",那么它将指向返回的子进程终止状态。参数pid是我们指定的被等待的子进程的进程ID。参数options是我们的控制选择项,一般为WNOHANG或是WUNTRACED。例程中使用了选项WNOHANG,意即如果不能立即返回子进程的终止状 态(譬如由于子进程还未结束),那么等待函数不阻塞,此时返回"0"。WUNTRACED选项的意思是如果系统支持作业控制,如果要等待的子进程的状态已经暂停,而且其状态自从暂停以来还从未报告过,则返回其状态。参数rusage如果不为"NULL",则它将指向内核返回的由终止进程及其所有子进程使用的资源摘要,该摘要包括用户CPU时间总量、缺页次数、接 收到信号的次数等。

◆代理服务程序do_proxy()

  在例程main()函数快要结束时,我们看到,在服务器接受了客户机的连接请求后,将为其创建子进程,并在子进程中执行代理服务程序do_proxy()。

/**************************************************************** 
function:    do_proxy 
description:  does the actual work of virtually connecting a client to the telnet service on the isolated host.
arguments:   usersockfd socket to which the client is connected. return value: none.
calls:     none. 
globals:    reads hostaddr. 
***************************************************************
*/
 
void do_proxy (usersockfd) 
int usersockfd; 

int isosockfd; 
fd_set rdfdset;
int connstat;
int iolen; 
char buf[2048]; 
/* open a socket to connect to the isolated host */ 
if ((isosockfd = socket(AF_INET,SOCK_STREAM,0)) < 0
errorout(
"failed to create socket to host");
/* attempt a connection */ 
connstat 
= connect(isosockfd,(struct sockaddr *&hostaddr, sizeof(hostaddr)); 
switch (connstat) {
case 0
break
case ETIMEDOUT: 
case ECONNREFUSED: 
case ENETUNREACH: 
strcpy(buf,sys_myerrlist[errno]); 
strcat(buf,
" "); 
write(usersockfd,buf,strlen(buf)); 
close(usersockfd); 
exit(
1); 
/* die peacefully if we can't establish a connection */
break;
default
errorout(
"failed to connect to host"); 
}
 
/* now we're connected, serve fall into the data echo loop */ 
while (1
/* Select for readability on either of our two sockets */ 
FD_ZERO(
&rdfdset);
FD_SET(usersockfd,
&rdfdset);
FD_SET(isosockfd,
&rdfdset);
if (select(FD_SETSIZE,&rdfdset,NULL,NULL,NULL) < 0
errorout(
"select failed");
/* is the client sending data? */
if (FD_ISSET(usersockfd,&rdfdset)) 
if ((iolen = read(usersockfd,buf,sizeof(buf))) <= 0
break/* zero length means the client disconnected */ 
rite(isosockfd,buf,iolen); 
/* copy to host -- blocking semantics */ 
}
 
/* is the host sending data? */ 
if (FD_ISSET(isosockfd,&rdfdset)) 
f ((iolen 
= read(isosockfd,buf,sizeof(buf))) <= 0
break/* zero length means the host disconnected */ 
rite(usersockfd,buf,iolen); 
/* copy to client -- blocking semantics */ 
}
 
}

/* we're done with the sockets */ 
close(isosockfd); 
lose(usersockfd);
}
在我们这段代理服务器例程中,真正连接用户主机和远端主机的一段操作,就是由这个do_proxy()函数来完成的。回想一下我们一开始对这段proxy程序用法的介绍。先将我们的proxy与远端主机绑定,然后用户通过proxy的绑定端口与远端主机建立连接。而在main()函数中,我们的 proxy由一段服务器程序与用户主机建立了连接,而在这个do_proxy()函数中,proxy将与远端主机的相应服务端口(由用户在命令行参数中指定)建立连接,并负责传递用户主机和远端主机之间交换的数据。
由于要和远端主机建立连接,所以我们看到do_proxy()函数的前半部分实际上相当于一段标准的客户机程序。首先创建一个新的套接字描述符isosockfd,然后调用函数connect()与远端主机之间建立连接。函数connect()的定义为:
#include <sys/types.h>
#include 
<sys/socket.h>
int connect(int sockfd, struct sockaddr *servaddr, int addrlen);
参数sockfd是调用函数socket()返回的套接字描述符,参数servaddr指向远程服务器的套接字地址结构,参数addrlen指定这个 套接字地址结构的长度。函数connect()执行成功时返回"0",如果执行失败则返回"-1",并将全局变量errno设置为相应的错误类型。在例程 中的switch()函数调用中对以下三种出错类型进行了处理: ETIMEDOUT、ECONNREFUSED和ENETUNREACH。这三个出错类型的意思分别为:ETIMEDOUT代表超时,产生这种情况的原因有很多,最常见的是服务器忙,无法应答客户机的连接请求;ECONNREFUSED代表连接拒绝,即服务器端没有准备好的倾听套接字,或是没有对倾听套接字的状态进行监听;ENETUNREACH表示网络不可达。
在本例中,connect()函数的第二个参数servaddr是全局变量hostaddr,其中存储着函数parse_args()转换好的命令行 参数。如果连接建立失败,在例程中就调用我们自定义的函数errorout()输出信息"failed to connect to host"。errorout()函数的定义为:
/**************************************************************** 
function:  errorout 
description:   displays an error message on the console and kills the current process. 
arguments:  msg -- message to be displayed. 
return value: none -- does not return. 
calls:    none. 
globals:   none. 
***************************************************************
*/
 
void errorout (msg) 
char *msg; 

FILE 
*console;
console 
= fopen("/dev/console","a"); 
fprintf(console,
"proxyd: %s ",msg); 
fclose(console); 
exit(
1); 
}
do_proxy()函数的后半部分是通过proxy建立用户主机与远端主机之间的连接。我们既有proxy与用户主机连接的套接字 (do_proxy()函数的参数usersockfd),又有proxy与远端主机连接的套接字isosockfd,那么最简单直接的通信建立方式就是从一个套接字读,然后直接写到另一个套接字去。如:
int n;
char buf[2048];
while((n=read(usersockfd, buf, sizeof(buf))>0)
if(write(isosockfd, buf, n)!=n)
err_sys(
"write wrror ");
这种形式的阻塞I/O在单向数据传递的时候是非常有效的,但是在我们的proxy操作中是要求用户主机和远端主机双向通信的,这样就要求我们对两个套 接字描述符既能够读由能够写。如果还是采用这种方式的阻塞I/O的话,很有可能长时间阻塞在一个描述符上。因此例程在处理这个问题的时候调用了select()函数,这个函数允许我们执行I/O多路转接。其具体含义就是select()函数可以构造一个表,在这个表中包含了我们所有要用到的文件 描述符。然后我们可以调用一个函数,这个函数可以检测这些文件描述符的状态,当某个(我们指定的)文件描述符准备好进行I/O操作时,此函数就返回,告知进程哪个文件描述符已经可以执行I/O操作了。这样就避免了长时间的阻塞。
还有一个函数poll()可以实现I/O多路转接,由于在例程中调用的是select(),我们就只对select()进行一下比较详细的介绍。select()系列函数的详细描述为:
#include <sys/time.h>
#include 
<sys/types.h>
#include 
<unistd.h>
int select(int n, fd_set *readfds, fd_set *writefds, fd_est *exceptfds, struct timeval *timeout);
FD_CLR(
int fd, fd_set *set);
FD_ISSET(
int fd, fd_set *set);
FD_SET(
int fd, fd_set *set);
FD_ZERO(fd_set 
*set);
select()函数将创建一个我们所关心的文件描述符表,它的参数将在内核中为这些文件描述符设置我们所关心的条件,例如是否是可读、是否可写以及 是否异常,而且在参数中还可以设置我们希望等待的最大时间。在select()成功执行时,它将返回目前已经准备好的描述符数量,同时内核可以告诉我们各个描述符的状态信息。如果超时,则返回"0",如果出错,则函数返回"-1",并同时设置errno为相应的值。
select()的最后一个参数timeout将设置等待时间。其中结构timeval是在文件<bits/time.h>中定义的。
struct timeval
{
__time_t tv_sec; 
/* Seconds */
__time_t tv_usec; 
/* Microseconds */
}
;
参数timeout的设置有三种情况。象例程中这样timeout==NULL时,这表示用户希望永远等待,直到我们指定的文件描述符中的一个已准备 好,或者是捕捉到一个信号。如果是由于捕捉到信号而中断了这个无限期的等待过程的话,select()将返回"-1",同时设置errno的值为 EINTR。
如果timeout->tv_sec==0&&timeout->tv_usec==0,那么这表示完全不等待。Select()测试了所有指定文件描述符后立即返回。这是得到多个描述符状态而不阻塞select()函数的轮询方法。
如果timeout->tv_sec!=0||timeout->tv_usec!=0,那么这两个参数的值即为我们希望函数等待的时 间。其中tv_sec设置时间单位为秒,tv_usec设置时间单位为微秒。如果在超时的时候,在我们指定的所有文件描述符里面仍然没有任何一个准备好的 话,则select()将返回"0"。
中间三个参数的数据类型是fd_set,它的意思是文件描述符集,而readfds, writefds和exceptfds则分别是指向文件描述符集的指针,他们分别描述了我们所关心的可读、可写以及状态异常的各个文件描述符。之所以我们 称select()可以创建一个文件描述符"表",那个所谓的表就是由这三个参数指向的数据结构组成的。其具体结构如图1所示。其中在每个set_fd数 据类型中都为我们关心的所有文件描述符保留了一位。所以在监测文件描述符状态的时候,就在这些set_fd数据结构中查询相关的位。
第一个参数n用来说明到底需要遍历多少个描述符位。n的值一般是这样设置的,从我们关心的所有文件描述符中选出最大值再加1。例如我们设置的所有文件 描述符中最大的为6,那么将n设置为7,则系统在检测描述符状态的时候,就只用遍历前7位(fd0~fd6)的状态。不过如果不想这样麻烦的话,我们可以象例程中那样将n的值直接设置为FD_SETSIZE。这是系统中设定的最大文件描述符个数,不同的系统这个值也不相同,一般是256或是1024。这样 在检测描述符状态的时候,函数将遍历所有的描述符位。
在调用select()函数实现多路I/O转接时,首先我们要声明一个新的文件描述符集,就象例程中这样:
fd_set rdfdset;
然后调用FD_ZERO()清空此文件描述符集的所有位,以免下面检测描述符位的时候返回错误结果:
FD_ZERO(&rdfdset);
然后调用FD_SET()在文件描述符集中设置我们关心的位。在本例中,我们关心的就是分别与用户主机和远端主机连接的两个套接字描述符,所以执行这样的语句:
FD_SET(usersockfd,&rdfdset);
FD_SET(isosockfd,&rdfdset);
然后调用select()返回描述符状态,此时描述符状态被存储进描述符集,也就是set_fd数据结构中。在图1中我们看到所有的描述符位状态都是 "0",在select()返回后,例如fd0可读,则在readfds描述符集中fd0对应的位上将状态标志设置为"1",如果fd1可写,则 writefds描述符集中fd1对应的位上将状态标志设置为"1",状态异常的情况也也与此相同。在本例中,我们只关心两个套接字描述符是否可写,因此 执行这样的select()函数:
select(FD_SETSIZE,&rdfdset,NULL,NULL,NULL)
那么在select()返回后怎样检测set_fd数据结构中描述符位的状态呢?这就要调用函数FD_ISSET(),如果对应文件描述符的状态为"已准备好"(即描述符位为"1"),则FD_ISSET()返回"1",否则返回"0"。
if (FD_ISSET(usersockfd,&rdfdset)) 
if ((iolen = read(usersockfd,buf,sizeof(buf))) <= 0
break/* zero length means the host disconnected */
write(isosockfd,buf,iolen);
这一段代码就实现从套接字usersockfd(用户主机)到套接字isosockfd(远端主机)的无阻塞传输。而下一段代码实现反方向的无阻塞传输:
if (FD_ISSET(isosockfd,&rdfdset)) 
if ((iolen = read(isosockfd,buf,sizeof(buf))) <= 0
break/* zero length means the host disconnected */ 
write(usersockfd,buf,iolen);
这样就通过proxy实现了用户主机与远端主机之间的通信。
对这段proxy代码我只是写了一些自己的理解,大多数是一些函数的用法,这些都是linux网络编程中一些最基础的知识,如果有不对的地方,还请各位大虾批评指正。
阅读更多
个人分类: Linux系统开发
上一篇基于S3C2410平台的LCD for Linux 2.6 驱动移植
下一篇写好C语言的头文件
想对作者说点什么? 我来说一句

proxy源代码分析

2010年10月20日 134KB 下载

Proxy源代码分析

2010年05月30日 137KB 下载

如何学习Linux网络编程

2010年03月04日 126KB 下载

没有更多推荐了,返回首页

关闭
关闭