系统调用跟我学

系统调用跟我学(1)

什么是系统调用?

Linux内核中设置了一组用于实现各种系统功能的子程序,称为系统调用。用户可以通过系统调用命令在自己的应用程序中调用它们。从某种角度来看,系统调用和普通的函数调用非常相似。区别仅仅在于,系统调用由操作系统核心提供,运行于核心态;而普通的函数调用由函数库或用户自己提供,运行于用户态。二者在使用方式上也有相似之处,在下面将会提到。

随Linux核心还提供了一些C语言函数库,这些库对系统调用进行了一些包装和扩展,因为这些库函数与系统调用的关系非常紧密,所以习惯上把这些函数也称为系统调用。

回页首

Linux中共有多少个系统调用?

这个问题可不太好回答,就算让Linus Torvaldz本人也不见得一下子就能说清楚。

在2.4.4版内核中,狭义上的系统调用共有221个,你可以在<内核源码目录>/include/asm-i386/unistd.h中找到它们的原本,也可以通过命令"man 2 syscalls"察看它们的目录(manpages的版本一般比较老,可能有很多最新的调用都没有包含在内)。广义上的系统调用,也就是以库函数的形式实现的那些,它们的个数从来没有人统计过,这是一件吃力不讨好的活,新内核不断地在推出,每一个新内核中函数数目的变化根本就没有人在乎,至少连内核的修改者本人都不在乎,因为他们从来没有发布过一个此类的声明。

随本文一起有一份经过整理的列表,它不可能非常全面,但常见的系统调用基本都已经包含在内,那里面只有不多的一部分是你平时用得到的,本专栏将会有选择的对它们进行介绍。

回页首

为什么要用系统调用?

实际上,很多已经被我们习以为常的C语言标准函数,在Linux平台上的实现都是靠系统调用完成的,所以如果想对系统底层的原理作深入的了解,掌握各种系统调用是初步的要求。进一步,若想成为一名Linux下编程高手,也就是我们常说的Hacker,其标志之一也是能对各种系统调用有透彻的了解。

即使除去上面的原因,在平常的编程中你也会发现,在很多情况下,系统调用是实现你的想法的简洁有效的途径,所以有可能的话应该尽量多掌握一些系统调用,这会对你的程序设计过程带来意想不到的帮助。

回页首

系统调用是怎么工作的?

一般的,进程是不能访问内核的。它不能访问内核所占内存空间也不能调用内核函数。CPU硬件决定了这些(这就是为什么它被称作"保护模式")。系统调用是这些规则的一个例外。其原理是进程先用适当的值填充寄存器,然后调用一个特殊的指令,这个指令会跳到一个事先定义的内核中的一个位置(当然,这个位置是用户进程可读但是不可写的)。在Intel CPU中,这个由中断0x80实现。硬件知道一旦你跳到这个位置,你就不是在限制模式下运行的用户,而是作为操作系统的内核--所以你就可以为所欲为。

进程可以跳转到的内核位置叫做sysem_call。这个过程检查系统调用号,这个号码告诉内核进程请求哪种服务。然后,它查看系统调用表(sys_call_table)找到所调用的内核函数入口地址。接着,就调用函数,等返回后,做一些系统检查,最后返回到进程(或到其他进程,如果这个进程时间用尽)。如果你希望读这段代码,它在<内核源码目录>/kernel/entry.S,Entry(system_call)的下一行。

回页首

如何使用系统调用?

先来看一个例子:

#include<linux/unistd.h> /*定义宏_syscall1*/

#include<time.h>     /*定义类型time_t*/

_syscall1(time_t,time,time_t *,tloc)    /*宏,展开后得到time()函数的原型*/

main()

{

        time_t the_time;

        the_time=time((time_t *)0); /*调用time系统调用*/

        printf("The time is %ld\n",the_time);

}

系统调用time返回从格林尼治时间1970年1月1日0:00开始到现在的秒数。

这是最标准的系统调用的形式,宏_syscall1()展开来得到一个函数原型,稍后我会作详细解释。但事实上,如果把程序改成下面的样子,程序也可以运行得同样的结果。

#include<time.h>

main()

{

        time_t the_time;

        the_time=time((time_t *)0); /*调用time系统调用*/

        printf("The time is %ld\n",the_time);

}



这是因为在time.h中实际上已经用库函数的形式实现了time这个系统调用,替我们省掉了调用_syscall1宏展开得到函数原型这一步。

大多数系统调用都在各种C语言函数库中有所实现,所以在一般情况下,我们都可以像调用普通的库函数那样调用系统调用,只在极个别的情况下,我们才有机会用到_syscall*()这几个宏。

回页首

_syscall*()是什么?

在unistd.h里定义了7个宏,分别是

_syscall0(type,name)

_syscall1(type,name,type1,arg1)

_syscall2(type,name,type1,arg1,type2,arg2)

_syscall3(type,name,type1,arg1,type2,arg2,type3,arg3)

_syscall4(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4)

_syscall5(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,type5,arg5)

_syscall6(type,name,type1,arg1,type2,arg2,type3,arg3,type4,arg4,type5,arg5,type6,arg6)



它们看起来似乎不太像宏,但其实质和 
#define MAXSIZE 100 
里面的MAXSIZE没有任何区别。

它们的作用是形成相应的系统调用函数原型,供我们在程序中调用。我们很容易就能发现规律,_syscall后面的数字和typeN,argN的数目一样多。事实上,_syscall后面跟的数字指明了展开后形成函数的参数的个数,让我们看一个实例,就是刚刚用过的time系统调用:

_syscall1(time_t,time,time_t *,tloc)



展开后的情形是这样:

time_t   time(time_t *   tloc)

{

    long __res;

    __asm__ volatile("int $0x80" : "=a" (__res) : "0" (13),"b" ((long)(tloc)));

    do {

        if ((unsigned long)(__res) >= (unsigned long)(-125)) {

            errno = -(__res);

            __res  = -1;

        }

        return (time_t) (__res);

    } while (0) ;

}



可以看出,_syscall1(time_t,time,time_t*,tloc)展开成一个名为time的函数,原参数time_t就是函数的返回类型,原参数time_t *和tloc分别构成新函数的参数。事实上,程序中用到的time函数的原型就是它。

回页首

errno是什么?

为防止和正常的返回值混淆,系统调用并不直接返回错误码,而是将错误码放入一个名为errno的全局变量中。如果一个系统调用失败,你可以读出errno的值来确定问题所在。

errno不同数值所代表的错误消息定义在errno.h中,你也可以通过命令"man 3 errno"来察看它们。

需要注意的是,errno的值只在函数发生错误时设置,如果函数不发生错误,errno的值就无定义,并不会被置为0。另外,在处理errno前最好先把它的值存入另一个变量,因为在错误处理过程中,即使像printf()这样的函数出错时也会改变errno的值。

回页首

系统调用兼容性好吗?

很遗憾,答案是--不好。但这决不意味着你的程序会三天两头的导致系统崩溃,因为系统调用是Linux的内核提供的,所以它们工作起来非常稳定,对于此点无需丝毫怀疑,在绝大多数的情况下,系统调用要比你自己编写的代码可靠而高效的多。

但是,在Linux的各版本内核之间,系统调用的兼容性表现得并不像想象那么好,这是由Linux本身的性质决定的。Linux是一群程序设计高手利用业余时间开发出来的,他们中间的大部分人没有把Linux当成一个严肃的商业软件,(现在的情况有些不同了,随着Linux商业公司和以Linux为生的人的增长,不少人的脑筋发生了变化。)结果就是,如果新的方案在效率和兼容性上发生了矛盾,他们往往舍弃兼容性而追求效率,就这样,如果他们认为某个系统调用实现的比较糟糕,他们就会毫不犹豫的作出修改,有些时候甚至连接口也一起改掉了,更可怕的是,很多时候,他们对自己的修改连个招呼也不打,在任何文档里都找不到关于修改的提示。这样,每当新内核推出的时候,很可能都会悄悄的更新一些系统调用,用户编制的应用程序也会跟着出错。

说到这里,你是不是感觉前途一片昏暗呢?呵呵,不用太紧张,如前面所说,随着越来越多的人把Linux当成自己的饭碗,不兼容的情况也越来越罕见。从2.2版本以后的Linux内核已经非常稳定了,不过尽管如此,你还是有必要在每个新内核推出之后,对自己的应用程序进行兼容性测试,以防止意外的发生。

回页首

该如何学习使用Linux系统调用呢?

你可以用"man 2 系统调用名称"的命令来查看各条系统调用的介绍,但这首先要求你要有不错的英语基础,其次还得有一定的程序设计和系统编程的功底,man pages不会涉及太多的应用细节,因为它只是一个手册而非教程。如果manpages所提供的东西不能使你感到非常满意,那就跟我来吧,本专栏将向你展示Linux系统调用编程的无穷魅力。

虽然本专栏并非异常高深的技术文章,但是还对读者有两点小小的要求:1)读者必须有一定的C语言编程经验,本专栏不会在语言细节上过分纠缠;2)读者必须有一定的Linux使用经验,本专栏也不打算在Linux应用上大动干戈。举一个小小的测试标准,如果你能完全看懂本文从开头到这里所讲的东西,你就合格了。收拾好行囊,准备出发吧!

 

系统调用跟我学(2)

进程管理相关的系统调用之一

关于进程的一些必要知识

先看一下进程在大学课本里的标准定义:“进程是可并发执行的程序在一个数据集合上的运行过程。”这个定义非常严谨,而且难懂,如果你没有一下子理解这句话,就不妨看看笔者自己的并不严谨的解释。我们大家都知道,硬盘上的一个可执行文件经常被称作程序,在Linux系统中,当一个程序开始执行后,在开始执行到执行完毕退出这段时间里,它在内存中的部分就被称作一个进程。

当然,这个解释并不完善,但好处是容易理解,在以下的文章中,我们将会对进程作一些更全面的认识。

回页首

Linux进程简介

Linux是一个多任务的操作系统,也就是说,在同一个时间内,可以有多个进程同时执行。如果读者对计算机硬件体系有一定了解的话,会知道我们大家常用的单CPU计算机实际上在一个时间片断内只能执行一条指令,那么Linux是如何实现多进程同时执行的呢?原来Linux使用了一种称为“进程调度(process scheduling)”的手段,首先,为每个进程指派一定的运行时间,这个时间通常很短,短到以毫秒为单位,然后依照某种规则,从众多进程中挑选一个投入运行,其他的进程暂时等待,当正在运行的那个进程时间耗尽,或执行完毕退出,或因某种原因暂停,Linux就会重新进行调度,挑选下一个进程投入运行。因为每个进程占用的时间片都很短,在我们使用者的角度来看,就好像多个进程同时运行一样了。

在Linux中,每个进程在创建时都会被分配一个数据结构,称为进程控制块(Process Control Block,简称PCB)。PCB中包含了很多重要的信息,供系统调度和进程本身执行使用,其中最重要的莫过于进程ID(process ID)了,进程ID也被称作进程标识符,是一个非负的整数,在Linux操作系统中唯一地标志一个进程,在我们最常使用的I386架构(即PC使用的架构)上,一个非负的整数的变化范围是0-32767,这也是我们所有可能取到的进程ID。其实从进程ID的名字就可以看出,它就是进程的身份证号码,每个人的身份证号码都不会相同,每个进程的进程ID也不会相同。

一个或多个进程可以合起来构成一个进程组(processgroup),一个或多个进程组可以合起来构成一个会话(session)。这样我们就有了对进程进行批量操作的能力,比如通过向某个进程组发送信号来实现向该组中的每个进程发送信号。

最后,让我们通过ps命令亲眼看一看自己的系统中目前有多少进程在运行:

$ps -aux (以下是在我的计算机上的运行结果,你的结果很可能与这不同。)

USER       PID %CPU %MEM   VSZ  RSS TTY      STAT START   TIME COMMAND

root         1  0.1  0.4  1412  520 ?        S    May15   0:04 init [3]

root         2  0.0  0.0     0    0 ?        SW   May15   0:00 [keventd]

root         3  0.0  0.0     0    0 ?        SW   May15   0:00 [kapm-idled]

root         4  0.0  0.0     0    0 ?        SWN  May15   0:00 [ksoftirqd_CPU0]

root         5  0.0  0.0     0    0 ?        SW   May15   0:00 [kswapd]

root         6  0.0  0.0     0    0 ?        SW   May15   0:00 [kreclaimd]

root         7  0.0  0.0     0    0 ?        SW   May15   0:00 [bdflush]

root         8  0.0  0.0     0    0 ?        SW   May15   0:00 [kupdated]

root         9  0.0  0.0     0    0 ?        SW<  May15   0:00 [mdrecoveryd]

root        13  0.0  0.0     0    0 ?        SW   May15   0:00 [kjournald]

root       132  0.0  0.0     0    0 ?        SW   May15   0:00 [kjournald]

root       673  0.0  0.4  1472  592 ?        S    May15   0:00 syslogd -m 0

root       678  0.0  0.8  2084 1116 ?        S    May15   0:00 klogd -2

rpc        698  0.0  0.4  1552  588 ?        S    May15   0:00 portmap

rpcuser    726  0.0  0.6  1596  764 ?        S    May15   0:00 rpc.statd

root       839  0.0  0.4  1396  524 ?        S    May15   0:00 /usr/sbin/apmd -p

root       908  0.0  0.7  2264 1000 ?        S    May15   0:00 xinetd -stayalive

root       948  0.0  1.5  5296 1984 ?        S    May15   0:00 sendmail: accepti

root       967  0.0  0.3  1440  484 ?        S    May15   0:00 gpm -t ps/2 -m /d

wnn        987  0.0  2.7  4732 3440 ?        S    May15   0:00 /usr/bin/cserver

root      1005  0.0  0.5  1584  660 ?        S    May15   0:00 crond

wnn       1025  0.0  1.9  3720 2488 ?        S    May15   0:00 /usr/bin/tserver

xfs       1079  0.0  2.5  4592 3216 ?        S    May15   0:00 xfs -droppriv -da

daemon    1115  0.0  0.4  1444  568 ?        S    May15   0:00 /usr/sbin/atd

root      1130  0.0  0.3  1384  448 tty1     S    May15   0:00 /sbin/mingetty tt

root      1131  0.0  0.3  1384  448 tty2     S    May15   0:00 /sbin/mingetty tt

root      1132  0.0  0.3  1384  448 tty3     S    May15   0:00 /sbin/mingetty tt

root      1133  0.0  0.3  1384  448 tty4     S    May15   0:00 /sbin/mingetty tt

root      1134  0.0  0.3  1384  448 tty5     S    May15   0:00 /sbin/mingetty tt

root      1135  0.0  0.3  1384  448 tty6     S    May15   0:00 /sbin/mingetty tt

root      8769  0.0  0.6  1744  812 ?        S    00:08   0:00 in.telnetd: 192.1

root      8770  0.0  0.9  2336 1184 pts/0    S    00:08   0:00 login -- lei

lei       8771  0.1  0.9  2432 1264 pts/0    S    00:08   0:00 -bash

lei       8809  0.0  0.6  2764  808 pts/0    R    00:09   0:00 ps -aux



以上除标题外,每一行都代表一个进程。在各列中,PID一列代表了各进程的进程ID,COMMAND一列代表了进程的名称或在Shell中调用的命令行,对其他列的具体含义,我就不再作解释,有兴趣的读者可以去参考相关书籍。

回页首

getpid

在2.4.4版内核中,getpid是第20号系统调用,其在Linux函数库中的原型是:

         #include<sys/types.h> /* 提供类型pid_t的定义 */

         #include<unistd.h> /* 提供函数的定义 */

         pid_t getpid(void);



getpid的作用很简单,就是返回当前进程的进程ID,请大家看以下的例子:

/* getpid_test.c */

#include<unistd.h>

main()

{

         printf("The current process ID is %d\n",getpid());

}



细心的读者可能注意到了,这个程序的定义里并没有包含头文件sys/types.h,这是因为我们在程序中没有用到pid_t类型,pid_t类型即为进程ID的类型。事实上,在i386架构上(就是我们一般PC计算机的架构),pid_t类型是和int类型完全兼容的,我们可以用处理整形数的方法去处理pid_t类型的数据,比如,用"%d"把它打印出来。

编译并运行程序getpid_test.c:

$gcc getpid_test.c -o getpid_test

$./getpid_test

The current process ID is 1980

(你自己的运行结果很可能与这个数字不一样,这是很正常的。)



再运行一遍:

$./getpid_test

The current process ID is 1981



正如我们所见,尽管是同一个应用程序,每一次运行的时候,所分配的进程标识符都不相同。

回页首

fork

在2.4.4版内核中,fork是第2号系统调用,其在Linux函数库中的原型是:

         #include<sys/types.h> /* 提供类型pid_t的定义 */

         #include<unistd.h> /* 提供函数的定义 */

         pid_t fork(void);



只看fork的名字,可能难得有几个人可以猜到它是做什么用的。fork系统调用的作用是复制一个进程。当一个进程调用它,完成后就出现两个几乎一模一样的进程,我们也由此得到了一个新进程。据说fork的名字就是来源于这个与叉子的形状颇有几分相似的工作流程。

在Linux中,创造新进程的方法只有一个,就是我们正在介绍的fork。其他一些库函数,如system(),看起来似乎它们也能创建新的进程,如果能看一下它们的源码就会明白,它们实际上也在内部调用了fork。包括我们在命令行下运行应用程序,新的进程也是由shell调用fork制造出来的。fork有一些很有意思的特征,下面就让我们通过一个小程序来对它有更多的了解。

/* fork_test.c */

#include<sys/types.h>

#inlcude<unistd.h>

main()

{

         pid_t pid;

        

         /*此时仅有一个进程*/

         pid=fork();

         /*此时已经有两个进程在同时运行*/

         if(pid<0)

                 printf("error in fork!");

         else if(pid==0)

                 printf("I am the child process, my process ID is %d\n",getpid());

         else

                 printf("I am the parent process, my process ID is %d\n",getpid());

}



编译并运行:

$gcc fork_test.c -o fork_test

$./fork_test

I am the parent process, my process ID is 1991

I am the child process, my process ID is 1992



看这个程序的时候,头脑中必须首先了解一个概念:在语句pid=fork()之前,只有一个进程在执行这段代码,但在这条语句之后,就变成两个进程在执行了,这两个进程的代码部分完全相同,将要执行的下一条语句都是if(pid==0)……。

两个进程中,原先就存在的那个被称作“父进程”,新出现的那个被称作“子进程”。父子进程的区别除了进程标志符(process ID)不同外,变量pid的值也不相同,pid存放的是fork的返回值。fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:

1.       在父进程中,fork返回新创建子进程的进程ID;

2.       在子进程中,fork返回0;

3.       如果出现错误,fork返回一个负值;

fork出错可能有两种原因:(1)当前的进程数已经达到了系统规定的上限,这时errno的值被设置为EAGAIN。(2)系统内存不足,这时errno的值被设置为ENOMEM。(关于errno的意义,请参考本系列的第一篇文章。)

fork系统调用出错的可能性很小,而且如果出错,一般都为第一种错误。如果出现第二种错误,说明系统已经没有可分配的内存,正处于崩溃的边缘,这种情况对Linux来说是很罕见的。

说到这里,聪明的读者可能已经完全看懂剩下的代码了,如果pid小于0,说明出现了错误;pid==0,就说明fork返回了0,也就说明当前进程是子进程,就去执行printf("I am the child!"),否则(else),当前进程就是父进程,执行printf("I am theparent!")。完美主义者会觉得这很冗余,因为两个进程里都各有一条它们永远执行不到的语句。不必过于为此耿耿于怀,毕竟很多年以前,UNIX的鼻祖们在当时内存小得无法想象的计算机上就是这样写程序的,以我们如今的“海量”内存,完全可以把这几个字节的顾虑抛到九霄云外。

说到这里,可能有些读者还有疑问:如果fork后子进程和父进程几乎完全一样,而系统中产生新进程唯一的方法就是fork,那岂不是系统中所有的进程都要一模一样吗?那我们要执行新的应用程序时候怎么办呢?从对Linux系统的经验中,我们知道这种问题并不存在。至于采用了什么方法,我们把这个问题留到后面具体讨论。

回页首

exit

在2.4.4版内核中,exit是第1号调用,其在Linux函数库中的原型是:

         #include<stdlib.h>

         void exit(int status);



不像fork那么难理解,从exit的名字就能看出,这个系统调用是用来终止一个进程的。无论在程序中的什么位置,只要执行到exit系统调用,进程就会停止剩下的所有操作,清除包括PCB在内的各种数据结构,并终止本进程的运行。请看下面的程序:

/* exit_test1.c */

#include<stdlib.h>

main()

{

         printf("this process will exit!\n");

         exit(0);

         printf("never be displayed!\n");

}



编译后运行:

$gcc exit_test1.c -o exit_test1

$./exit_test1

this process will exit!



我们可以看到,程序并没有打印后面的"neverbe displayed!\n",因为在此之前,在执行到exit(0)时,进程就已经终止了。

exit系统调用带有一个整数类型的参数status,我们可以利用这个参数传递进程结束时的状态,比如说,该进程是正常结束的,还是出现某种意外而结束的,一般来说,0表示没有意外的正常结束;其他的数值表示出现了错误,进程非正常结束。我们在实际编程时,可以用wait系统调用接收子进程的返回值,从而针对不同的情况进行不同的处理。关于wait的详细情况,我们将在以后的篇幅中进行介绍。

回页首

exit和_exit

作为系统调用而言,_exit和exit是一对孪生兄弟,它们究竟相似到什么程度,我们可以从Linux的源码中找到答案:

#define __NR__exit __NR_exit /* 摘自文件include/asm-i386/unistd.h第334行 */

“__NR_”是在Linux的源码中为每个系统调用加上的前缀,请注意第一个exit前有2条下划线,第二个exit前只有1条下划线。

这时随便一个懂得C语言并且头脑清醒的人都会说,_exit和exit没有任何区别,但我们还要讲一下这两者之间的区别,这种区别主要体现在它们在函数库中的定义。_exit在Linux函数库中的原型是:

         #include<unistd.h>

         void _exit(int status);



和exit比较一下,exit()函数定义在stdlib.h中,而_exit()定义在unistd.h中,从名字上看,stdlib.h似乎比unistd.h高级一点,那么,它们之间到底有什么区别呢?让我们先来看流程图,通过下图,我们会对这两个系统调用的执行过程产生一个较为直观的认识。


 

从图中可以看出,_exit()函数的作用最为简单:直接使进程停止运行,清除其使用的内存空间,并销毁其在内核中的各种数据结构;exit()函数则在这些基础上作了一些包装,在执行退出之前加了若干道工序,也是因为这个原因,有些人认为exit已经不能算是纯粹的系统调用。

exit()函数与_exit()函数最大的区别就在于exit()函数在调用exit系统调用之前要检查文件的打开情况,把文件缓冲区中的内容写回文件,就是图中的“清理I/O缓冲”一项。

在Linux的标准函数库中,有一套称作“高级I/O”的函数,我们熟知的printf()、fopen()、fread()、fwrite()都在此列,它们也被称作“缓冲I/O(buffered I/O)”,其特征是对应每一个打开的文件,在内存中都有一片缓冲区,每次读文件时,会多读出若干条记录,这样下次读文件时就可以直接从内存的缓冲区中读取,每次写文件的时候,也仅仅是写入内存中的缓冲区,等满足了一定的条件(达到一定数量,或遇到特定字符,如换行符\n和文件结束符EOF),再将缓冲区中的内容一次性写入文件,这样就大大增加了文件读写的速度,但也为我们编程带来了一点点麻烦。如果有一些数据,我们认为已经写入了文件,实际上因为没有满足特定的条件,它们还只是保存在缓冲区内,这时我们用_exit()函数直接将进程关闭,缓冲区中的数据就会丢失,反之,如果想保证数据的完整性,就一定要使用exit()函数。

请看以下例程:

/* exit2.c */

#include<stdlib.h>

main()

{

         printf("output begin\n");

         printf("content in buffer");

         exit(0);

}



编译并运行:

$gcc exit2.c -o exit2

$./exit2

output begin

content in buffer

/* _exit1.c */

#include<unistd.h>

main()

{

         printf("output begin\n");

         printf("content in buffer");

         _exit(0);

}



编译并运行:

$gcc _exit1.c -o _exit1

$./_exit1

output begin



在Linux中,标准输入和标准输出都是作为文件处理的,虽然是一类特殊的文件,但从程序员的角度来看,它们和硬盘上存储数据的普通文件并没有任何区别。与所有其他文件一样,它们在打开后也有自己的缓冲区。

请读者结合前面的叙述,思考一下为什么这两个程序会得出不同的结果。相信如果您理解了我前面所讲的内容,会很容易的得出结论。

 

 

系统调用跟我学(3)

进程管理相关的系统调用之二

 

 

1.7背景

在前面的文章中,我们已经了解了父进程和子进程的概念,并已经掌握了系统调用exit的用法,但可能很少有人意识到,在一个进程调用了exit之后,该进程并非马上就消失掉,而是留下一个称为僵尸进程(Zombie)的数据结构。在Linux进程的5种状态中,僵尸进程是非常特殊的一种,它已经放弃了几乎所有内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置,记载该进程的退出状态等信息供其他进程收集,除此之外,僵尸进程不再占有任何内存空间。从这点来看,僵尸进程虽然有一个很酷的名字,但它的影响力远远抵不上那些真正的僵尸兄弟,真正的僵尸总能令人感到恐怖,而僵尸进程却除了留下一些供人凭吊的信息,对系统毫无作用。

也许读者们还对这个新概念比较好奇,那就让我们来看一眼Linux里的僵尸进程究竟长什么样子。

当一个进程已退出,但其父进程还没有调用系统调用wait(稍后介绍)对其进行收集之前的这段时间里,它会一直保持僵尸状态,利用这个特点,我们来写一个简单的小程序:

/* zombie.c */

#include <sys/types.h>

#include <unistd.h>

main()

{

         pid_t pid;

        

         pid=fork();

         if(pid<0)        /* 如果出错 */

                 printf("error occurred!\n");

         else if(pid==0) /* 如果是子进程 */

                 exit(0);

         else             /* 如果是父进程 */

                 sleep(60);       /* 休眠60秒,这段时间里,父进程什么也干不了 */

                 wait(NULL);      /* 收集僵尸进程 */

}



sleep的作用是让进程休眠指定的秒数,在这60秒内,子进程已经退出,而父进程正忙着睡觉,不可能对它进行收集,这样,我们就能保持子进程60秒的僵尸状态。

编译这个程序:

$ cc zombie.c -o zombie



后台运行程序,以使我们能够执行下一条命令

$ ./zombie &

[1] 1577



列一下系统内的进程

$ ps -ax

         ...  ...

 1177 pts/0    S      0:00 -bash

 1577 pts/0    S      0:00 ./zombie

 1578 pts/0    Z      0:00 [zombie <defunct>]

 1579 pts/0    R      0:00 ps -ax



看到中间的"Z"了吗?那就是僵尸进程的标志,它表示1578号进程现在就是一个僵尸进程。

我们已经学习了系统调用exit,它的作用是使进程退出,但也仅仅限于将一个正常的进程变成一个僵尸进程,并不能将其完全销毁。僵尸进程虽然对其他进程几乎没有什么影响,不占用CPU时间,消耗的内存也几乎可以忽略不计,但有它在那里呆着,还是让人觉得心里很不舒服。而且Linux系统中进程数目是有限制的,在一些特殊的情况下,如果存在太多的僵尸进程,也会影响到新进程的产生。那么,我们该如何来消灭这些僵尸进程呢?

先来了解一下僵尸进程的来由,我们知道,Linux和UNIX总有着剪不断理还乱的亲缘关系,僵尸进程的概念也是从UNIX上继承来的,而UNIX的先驱们设计这个东西并非是因为闲来无聊想烦烦其他的程序员。僵尸进程中保存着很多对程序员和系统管理员非常重要的信息,首先,这个进程是怎么死亡的?是正常退出呢,还是出现了错误,还是被其它进程强迫退出的?其次,这个进程占用的总系统CPU时间和总用户CPU时间分别是多少?发生页错误的数目和收到信号的数目。这些信息都被存储在僵尸进程中,试想如果没有僵尸进程,进程一退出,所有与之相关的信息都立刻归于无形,而此时程序员或系统管理员需要用到,就只好干瞪眼了。

那么,我们如何收集这些信息,并终结这些僵尸进程呢?就要靠我们下面要讲到的waitpid调用和wait调用。这两者的作用都是收集僵尸进程留下的信息,同时使这个进程彻底消失。下面就对这两个调用分别作详细介绍。

回页首

1.8wait

1.8.1 简介

wait的函数原型是:

                 #include <sys/types.h> /* 提供类型pid_t的定义 */

         #include <sys/wait.h>

         pid_t wait(int *status)

        



进程一旦调用了wait,就立即阻塞自己,由wait自动分析是否当前进程的某个子进程已经退出,如果让它找到了这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。

参数status用来保存被收集进程退出时的一些状态,它是一个指向int类型的指针。但如果我们对这个子进程是如何死掉的毫不在意,只想把这个僵尸进程消灭掉,(事实上绝大多数情况下,我们都会这样想),我们就可以设定这个参数为NULL,就象下面这样:

                 pid = wait(NULL);

        



如果成功,wait会返回被收集的子进程的进程ID,如果调用进程没有子进程,调用就会失败,此时wait返回-1,同时errno被置为ECHILD。

1.8.2 实战

下面就让我们用一个例子来实战应用一下wait调用,程序中用到了系统调用fork,如果你对此不大熟悉或已经忘记了,请参考上一篇文章《进程管理相关的系统调用(一)》。

/* wait1.c */

#include <sys/types.h>

#include <sys/wait.h>

#include <unistd.h>

#include <stdlib.h>

main()

{

         pid_t pc,pr;

         pc=fork();

         if(pc<0)                  /* 如果出错 */

                 printf("error ocurred!\n");

         else if(pc==0){           /* 如果是子进程 */

                 printf("This is child process with pid of %d\n",getpid());

                 sleep(10);       /* 睡眠10秒钟 */

         }

         else{                     /* 如果是父进程 */

                 pr=wait(NULL);   /* 在这里等待 */

                 printf("I catched a child process with pid of %d\n"),pr);

         }               

         exit(0);

}



编译并运行:

$ cc wait1.c -o wait1

$ ./wait1

This is child process with pid of 1508

I catched a child process with pid of 1508



可以明显注意到,在第2行结果打印出来前有10秒钟的等待时间,这就是我们设定的让子进程睡眠的时间,只有子进程从睡眠中苏醒过来,它才能正常退出,也就才能被父进程捕捉到。其实这里我们不管设定子进程睡眠的时间有多长,父进程都会一直等待下去,读者如果有兴趣的话,可以试着自己修改一下这个数值,看看会出现怎样的结果。

1.8.3 参数status

如果参数status的值不是NULL,wait就会把子进程退出时的状态取出并存入其中,这是一个整数值(int),指出了子进程是正常退出还是被非正常结束的(一个进程也可以被其他进程用信号结束,我们将在以后的文章中介绍),以及正常结束时的返回值,或被哪一个信号结束的等信息。由于这些信息被存放在一个整数的不同二进制位中,所以用常规的方法读取会非常麻烦,人们就设计了一套专门的宏(macro)来完成这项工作,下面我们来学习一下其中最常用的两个:

1,WIFEXITED(status) 这个宏用来指出子进程是否为正常退出的,如果是,它会返回一个非零值。

(请注意,虽然名字一样,这里的参数status并不同于wait唯一的参数--指向整数的指针status,而是那个指针所指向的整数,切记不要搞混了。)

2,WEXITSTATUS(status) 当WIFEXITED返回非零值时,我们可以用这个宏来提取子进程的返回值,如果子进程调用exit(5)退出,WEXITSTATUS(status)就会返回5;如果子进程调用exit(7),WEXITSTATUS(status)就会返回7。请注意,如果进程不是正常退出的,也就是说,WIFEXITED返回0,这个值就毫无意义。

下面通过例子来实战一下我们刚刚学到的内容:

/* wait2.c */

#include <sys/types.h>

#include <sys/wait.h>

#include <unistd.h>

main()

{

         int status;

         pid_t pc,pr;

         pc=fork();

         if(pc<0) /* 如果出错 */

                 printf("error ocurred!\n");

         else if(pc==0){  /* 子进程 */

                 printf("This is child process with pid of %d.\n",getpid());

                 exit(3); /* 子进程返回3 */

         }

         else{            /* 父进程 */

                 pr=wait(&status);

                 if(WIFEXITED(status)){    /* 如果WIFEXITED返回非零值 */

                          printf("the child process %d exit normally.\n",pr);

                          printf("the return code is %d.\n",WEXITSTATUS(status));

                 }else                     /* 如果WIFEXITED返回零 */

                          printf("the child process %d exit abnormally.\n",pr);

         }

}



编译并运行:

$ cc wait2.c -o wait2

$ ./wait2

This is child process with pid of 1538.

the child process 1538 exit normally.

the return code is 3.



父进程准确捕捉到了子进程的返回值3,并把它打印了出来。

当然,处理进程退出状态的宏并不止这两个,但它们当中的绝大部分在平时的编程中很少用到,就也不在这里浪费篇幅介绍了,有兴趣的读者可以自己参阅Linux man pages去了解它们的用法。

1.8.4 进程同步

有时候,父进程要求子进程的运算结果进行下一步的运算,或者子进程的功能是为父进程提供了下一步执行的先决条件(如:子进程建立文件,而父进程写入数据),此时父进程就必须在某一个位置停下来,等待子进程运行结束,而如果父进程不等待而直接执行下去的话,可以想见,会出现极大的混乱。这种情况称为进程之间的同步,更准确地说,这是进程同步的一种特例。进程同步就是要协调好2个以上的进程,使之以安排好地次序依次执行。解决进程同步问题有更通用的方法,我们将在以后介绍,但对于我们假设的这种情况,则完全可以用wait系统调用简单的予以解决。请看下面这段程序:

#include <sys/types.h>

#include <sys/wait.h>

main()

{

         pid_t pc, pr;

         int status;

        

         pc=fork();

        

         if(pc<0)

                 printf("Error occured on forking.\n");

         else if(pc==0){

                 /* 子进程的工作 */

                 exit(0);

         }else{

                 /* 父进程的工作 */

                 pr=wait(&status);

                 /* 利用子进程的结果 */

         }

}



这段程序只是个例子,不能真正拿来执行,但它却说明了一些问题,首先,当fork调用成功后,父子进程各做各的事情,但当父进程的工作告一段落,需要用到子进程的结果时,它就停下来调用wait,一直等到子进程运行结束,然后利用子进程的结果继续执行,这样就圆满地解决了我们提出的进程同步问题。

回页首

1.9waitpid

1.9.1 简介

waitpid系统调用在Linux函数库中的原型是:

                 #include <sys/types.h> /* 提供类型pid_t的定义 */

         #include <sys/wait.h>

         pid_t waitpid(pid_t pid,int *status,int options)

        



从本质上讲,系统调用waitpid和wait的作用是完全相同的,但waitpid多出了两个可由用户控制的参数pid和options,从而为我们编程提供了另一种更灵活的方式。下面我们就来详细介绍一下这两个参数:

pid

从参数的名字pid和类型pid_t中就可以看出,这里需要的是一个进程ID。但当pid取不同的值时,在这里有不同的意义。

1.       pid>0时,只等待进程ID等于pid的子进程,不管其它已经有多少子进程运行结束退出了,只要指定的子进程还没有结束,waitpid就会一直等下去。

2.       pid=-1时,等待任何一个子进程退出,没有任何限制,此时waitpid和wait的作用一模一样。

3.       pid=0时,等待同一个进程组中的任何子进程,如果子进程已经加入了别的进程组,waitpid不会对它做任何理睬。

4.       pid<-1时,等待一个指定进程组中的任何子进程,这个进程组的ID等于pid的绝对值。

options

options提供了一些额外的选项来控制waitpid,目前在Linux中只支持WNOHANG和WUNTRACED两个选项,这是两个常数,可以用"|"运算符把它们连接起来使用,比如:

ret=waitpid(-1,NULL,WNOHANG | WUNTRACED);



如果我们不想使用它们,也可以把options设为0,如:

ret=waitpid(-1,NULL,0);



如果使用了WNOHANG参数调用waitpid,即使没有子进程退出,它也会立即返回,不会像wait那样永远等下去。

而WUNTRACED参数,由于涉及到一些跟踪调试方面的知识,加之极少用到,这里就不多费笔墨了,有兴趣的读者可以自行查阅相关材料。

看到这里,聪明的读者可能已经看出端倪了--wait不就是经过包装的waitpid吗?没错,察看<内核源码目录>/include/unistd.h文件349-352行就会发现以下程序段:

static inline pid_t wait(int * wait_stat)

{

         return waitpid(-1,wait_stat,0);

}



1.9.2 返回值和错误

waitpid的返回值比wait稍微复杂一些,一共有3种情况:

1.       当正常返回的时候,waitpid返回收集到的子进程的进程ID;

2.       如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;

3.       如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;

当pid所指示的子进程不存在,或此进程存在,但不是调用进程的子进程,waitpid就会出错返回,这时errno被设置为ECHILD;

/* waitpid.c */

#include <sys/types.h>

#include <sys/wait.h>

#include <unistd.h>

main()

{

         pid_t pc, pr;

                

         pc=fork();

         if(pc<0)         /* 如果fork出错 */

                 printf("Error occured on forking.\n");

         else if(pc==0){           /* 如果是子进程 */

                 sleep(10);       /* 睡眠10秒 */

                 exit(0);

         }

         /* 如果是父进程 */

         do{

                 pr=waitpid(pc, NULL, WNOHANG);     /* 使用了WNOHANG参数,waitpid不会在这里等待 */

                 if(pr==0){                         /* 如果没有收集到子进程 */

                          printf("No child exited\n");

                          sleep(1);

                 }

         }while(pr==0);                              /* 没有收集到子进程,就回去继续尝试 */

         if(pr==pc)

                 printf("successfully get child %d\n", pr);

         else

                 printf("some error occured\n");

}



编译并运行:

$ cc waitpid.c -o waitpid

$ ./waitpid

No child exited

No child exited

No child exited

No child exited

No child exited

No child exited

No child exited

No child exited

No child exited

No child exited

successfully get child 1526



父进程经过10次失败的尝试之后,终于收集到了退出的子进程。

因为这只是一个例子程序,不便写得太复杂,所以我们就让父进程和子进程分别睡眠了10秒钟和1秒钟,代表它们分别作了10秒钟和1秒钟的工作。父子进程都有工作要做,父进程利用工作的简短间歇察看子进程的是否退出,如退出就收集它。

回页首

1.10exec

也许有不少读者从本系列文章一推出就开始读,一直到这里还有一个很大的疑惑:既然所有新进程都是由fork产生的,而且由fork产生的子进程和父进程几乎完全一样,那岂不是意味着系统中所有的进程都应该一模一样了吗?而且,就我们的常识来说,当我们执行一个程序的时候,新产生的进程的内容应就是程序的内容才对。是我们理解错了吗?显然不是,要解决这些疑惑,就必须提到我们下面要介绍的exec系统调用。

1.10.1 简介

说是exec系统调用,实际上在Linux中,并不存在一个exec()的函数形式,exec指的是一组函数,一共有6个,分别是:

#include <unistd.h>

int execl(const char *path, const char *arg, ...);

int execlp(const char *file, const char *arg, ...);

int execle(const char *path, const char *arg, ..., char *const envp[]);

int execv(const char *path, char *const argv[]);

int execvp(const char *file, char *const argv[]);

int execve(const char *path, char *const argv[], char *const envp[]);



其中只有execve是真正意义上的系统调用,其它都是在此基础上经过包装的库函数。

exec函数族的作用是根据指定的文件名找到可执行文件,并用它来取代调用进程的内容,换句话说,就是在调用进程内部执行一个可执行文件。这里的可执行文件既可以是二进制文件,也可以是任何Linux下可执行的脚本文件。

与一般情况不同,exec函数族的函数执行成功后不会返回,因为调用进程的实体,包括代码段,数据段和堆栈等都已经被新的内容取代,只留下进程ID等一些表面上的信息仍保持原样,颇有些神似"三十六计"中的"金蝉脱壳"。看上去还是旧的躯壳,却已经注入了新的灵魂。只有调用失败了,它们才会返回一个-1,从原程序的调用点接着往下执行。

现在我们应该明白了,Linux下是如何执行新程序的,每当有进程认为自己不能为系统和拥护做出任何贡献了,他就可以发挥最后一点余热,调用任何一个exec,让自己以新的面貌重生;或者,更普遍的情况是,如果一个进程想执行另一个程序,它就可以fork出一个新进程,然后调用任何一个exec,这样看起来就好像通过执行应用程序而产生了一个新进程一样。

事实上第二种情况被应用得如此普遍,以至于Linux专门为其作了优化,我们已经知道,fork会将调用进程的所有内容原封不动的拷贝到新产生的子进程中去,这些拷贝的动作很消耗时间,而如果fork完之后我们马上就调用exec,这些辛辛苦苦拷贝来的东西又会被立刻抹掉,这看起来非常不划算,于是人们设计了一种"写时拷贝(copy-on-write)"技术,使得fork结束后并不立刻复制父进程的内容,而是到了真正实用的时候才复制,这样如果下一条语句是exec,它就不会白白作无用功了,也就提高了效率。

1.10.2 稍稍深入

上面6条函数看起来似乎很复杂,但实际上无论是作用还是用法都非常相似,只有很微小的差别。在学习它们之前,先来了解一下我们习以为常的main函数。

下面这个main函数的形式可能有些出乎我们的意料:

int main(int argc, char *argv[], char *envp[])



它可能与绝大多数教科书上描述的都不一样,但实际上,这才是main函数真正完整的形式。

参数argc指出了运行该程序时命令行参数的个数,数组argv存放了所有的命令行参数,数组envp存放了所有的环境变量。环境变量指的是一组值,从用户登录后就一直存在,很多应用程序需要依靠它来确定系统的一些细节,我们最常见的环境变量是PATH,它指出了应到哪里去搜索应用程序,如/bin;HOME也是比较常见的环境变量,它指出了我们在系统中的个人目录。环境变量一般以字符串"XXX=xxx"的形式存在,XXX表示变量名,xxx表示变量的值。

值得一提的是,argv数组和envp数组存放的都是指向字符串的指针,这两个数组都以一个NULL元素表示数组的结尾。

我们可以通过以下这个程序来观看传到argc、argv和envp里的都是什么东西:

/* main.c */

int main(int argc, char *argv[], char *envp[])

{

         printf("\n### ARGC ###\n%d\n", argc);

         printf("\n### ARGV ###\n");

         while(*argv)

                 printf("%s\n", *(argv++));

         printf("\n### ENVP ###\n");

         while(*envp)

                 printf("%s\n", *(envp++));

         return 0;

}



编译它:

$ cc main.c -o main



运行时,我们故意加几个没有任何作用的命令行参数:

$ ./main -xx 000

### ARGC ###

3

### ARGV ###

./main

-xx

000

### ENVP ###

PWD=/home/lei

REMOTEHOST=dt.laser.com

HOSTNAME=localhost.localdomain

QTDIR=/usr/lib/qt-2.3.1

LESSOPEN=|/usr/bin/lesspipe.sh %s

KDEDIR=/usr

USER=lei

LS_COLORS=

MACHTYPE=i386-redhat-linux-gnu

MAIL=/var/spool/mail/lei

INPUTRC=/etc/inputrc

LANG=en_US

LOGNAME=lei

SHLVL=1

SHELL=/bin/bash

HOSTTYPE=i386

OSTYPE=linux-gnu

HISTSIZE=1000

TERM=ansi

HOME=/home/lei

PATH=/usr/local/bin:/bin:/usr/bin:/usr/X11R6/bin:/home/lei/bin

_=./main



我们看到,程序将"./main"作为第1个命令行参数,所以我们一共有3个命令行参数。这可能与大家平时习惯的说法有些不同,小心不要搞错了。

现在回过头来看一下exec函数族,先把注意力集中在execve上:

int execve(const char *path, char *const argv[], char *const envp[]);



对比一下main函数的完整形式,看出问题了吗?是的,这两个函数里的argv和envp是完全一一对应的关系。execve第1个参数path是被执行应用程序的完整路径,第2个参数argv就是传给被执行应用程序的命令行参数,第3个参数envp是传给被执行应用程序的环境变量。

留心看一下这6个函数还可以发现,前3个函数都是以execl开头的,后3个都是以execv开头的,它们的区别在于,execv开头的函数是以"char *argv[]"这样的形式传递命令行参数,而execl开头的函数采用了我们更容易习惯的方式,把参数一个一个列出来,然后以一个NULL表示结束。这里的NULL的作用和argv数组里的NULL作用是一样的。

在全部6个函数中,只有execle和execve使用了char *envp[]传递环境变量,其它的4个函数都没有这个参数,这并不意味着它们不传递环境变量,这4个函数将把默认的环境变量不做任何修改地传给被执行的应用程序。而execle和execve会用指定的环境变量去替代默认的那些。

还有2个以p结尾的函数execlp和execvp,咋看起来,它们和execl与execv的差别很小,事实也确是如此,除execlp和execvp之外的4个函数都要求,它们的第1个参数path必须是一个完整的路径,如"/bin/ls";而execlp和execvp的第1个参数file可以简单到仅仅是一个文件名,如"ls",这两个函数可以自动到环境变量PATH制定的目录里去寻找。

1.10.3 实战

知识介绍得差不多了,接下来我们看看实际的应用:

/* exec.c */

#include <unistd.h>

main()

{

         char *envp[]={"PATH=/tmp",

                          "USER=lei",

                          "STATUS=testing",

                          NULL};

         char *argv_execv[]={"echo", "excuted by execv",     NULL};

         char *argv_execvp[]={"echo", "executed by execvp", NULL};

         char *argv_execve[]={"env", NULL};

         if(fork()==0)

                 if(execl("/bin/echo", "echo", "executed by execl", NULL)<0)

                          perror("Err on execl");

         if(fork()==0)

                 if(execlp("echo", "echo", "executed by execlp", NULL)<0)

                          perror("Err on execlp");

         if(fork()==0)

                 if(execle("/usr/bin/env", "env", NULL, envp)<0)

                          perror("Err on execle");

         if(fork()==0)

                 if(execv("/bin/echo", argv_execv)<0)

                          perror("Err on execv");

         if(fork()==0)

                 if(execvp("echo", argv_execvp)<0)

                          perror("Err on execvp");

         if(fork()==0)

                 if(execve("/usr/bin/env", argv_execve, envp)<0)

                          perror("Err on execve");

}



程序里调用了2个Linux常用的系统命令,echo和env。echo会把后面跟的命令行参数原封不动的打印出来,env用来列出所有环境变量。

由于各个子进程执行的顺序无法控制,所以有可能出现一个比较混乱的输出--各子进程打印的结果交杂在一起,而不是严格按照程序中列出的次序。

编译并运行:

$ cc exec.c -o exec

$ ./exec

executed by execl

PATH=/tmp

USER=lei

STATUS=testing

executed by execlp

excuted by execv

executed by execvp

PATH=/tmp

USER=lei

STATUS=testing



果然不出所料,execle输出的结果跑到了execlp前面。

大家在平时的编程中,如果用到了exec函数族,一定记得要加错误判断语句。因为与其他系统调用比起来,exec很容易受伤,被执行文件的位置,权限等很多因素都能导致该调用的失败。最常见的错误是:

1.       找不到文件或路径,此时errno被设置为ENOENT;

2.       数组argv和envp忘记用NULL结束,此时errno被设置为EFAULT;

3.       没有对要执行文件的运行权限,此时errno被设置为EACCES。

回页首

1.11进程的一生

下面就让我用一些形象的比喻,来对进程短暂的一生作一个小小的总结:

随着一句fork,一个新进程呱呱落地,但它这时只是老进程的一个克隆。

然后随着exec,新进程脱胎换骨,离家独立,开始了为人民服务的职业生涯。

人有生老病死,进程也一样,它可以是自然死亡,即运行到main函数的最后一个"}",从容地离我们而去;也可以是自杀,自杀有2种方式,一种是调用exit函数,一种是在main函数内使用return,无论哪一种方式,它都可以留下遗书,放在返回值里保留下来;它还甚至能可被谋杀,被其它进程通过另外一些方式结束他的生命。

进程死掉以后,会留下一具僵尸,wait和waitpid充当了殓尸工,把僵尸推去火化,使其最终归于无形。

这就是进程完整的一生。

回页首

1.12小结

本文重点介绍了系统调用wait、waitpid和exec函数族,对与进程管理相关的系统调用的介绍就在这里告一段落,在下一篇文章,也是与进程管理相关的系统调用的最后一篇文章中,我们会通过两个很酷的实际例子,来重温一下最近学过的知识。

 

 

系统调用跟我学(4)

进程管理相关的系统调用之三

 

1.13 Shell

对Linux不是太陌生的读者都应该对Shell有一定的了解,就是这个程序在我们登陆后自动执行,打印出一个$符号,然后等待我们输入命令。Linux下最常用的Shell应用程序是Bash,绝大部分Linux发行版默认安装的都是它。下面我们也来亲手编写一个Shell程序,这个Shell远远不如Bash复杂,但也能满足我们一般的使用,下面,我们就开始。

首先,给这个Shell取一个名字,不妨就叫做Mini Shell。

Linux系统的命令分为内部命令和外部命令两种,内部命令由Shell程序实现,如cd、echo等,Linux的内部命令数量有限,而且绝大部分都很少用到。而每一个Linux外部命令都是一个单独的应用程序,我们非常熟悉的ls、cp等绝大多数命令都是外部命令,这些命令都以可执行文件的形式存在,绝大部分放在目录/bin和/sbin中。这样一来,我们编程的难度就可以大大下降了,我们只需要实现很有限的内部命令,对于其它的输入,统统当作应用程序来执行即可。

为了简单明了起见,Mini Shell只实现了2个内部命令: 
1、cd 用于切换目录,和我们熟悉的命令cd类似,除了没有那么多的附加功能。 
2、quit 用于退出Mini Shell。

下面是程序清单:

1:       /* mshell.c */

2:       #include <sys/types.h>

1:       #include <unistd.h>

3:       #include <sys/wait.h>

4:       #include <string.h>

5:       #include <errno.h>

6:       #include <stdio.h>

7:

9:       void do_cd(char *argv[]);

10:      void execute_new(char *argv[]);

11:

12:      main()

13:      {

14:              char *cmd=(void *)malloc(256*sizeof(char));

15:              char *cmd_arg[10];

16:              int cmdlen,i,j,tag;

17:     

18:              do{

19:                       /* 初始化cmd */

20:                       for(i=0;i<255;i++) cmd[i]='\0';

21:     

22:                       printf("-=Mini Shell=-*| ");

23:                       fgets(cmd,256,stdin);

24:     

25:                       cmdlen=strlen(cmd);

26:                       cmdlen--;

27:                       cmd[cmdlen]='\0';

28:     

29:                       /* 把命令行分解为指针数组cmd_arg */

30:                       for(i=0;i<10;i++) cmd_arg[i]=NULL;

31:                       i=0; j=0; tag=0;

32:                       while(i<cmdlen && j<10){

33:                               if(cmd[i]==' '){

34:                                        cmd[i]='\0';

35:                                        tag=0;

36:                               }else{

37:                                        if(tag==0)

38:                                                 cmd_arg[j++]=cmd+i;

39:                                        tag=1;

40:                               }

41:                               i++;

42:                       }

43:                      

44:                       /* 如果参数超过10个,就打印错误,并忽略当前输入 */

45:                       if(j>=10 && i<cmdlen){

46:                               printf("TOO MANY ARGUMENTS\n");

47:                               continue;

48:                       }

49:                      

50:                       /* 命令quit:退出Mini Shell */

51:                       if(strcmp(cmd_arg[0],"quit")==0)

52:                               break;

53:    

54:                       /* 命令cd */

55:                       if(strcmp(cmd_arg[0],"cd")==0){

56:                               do_cd(cmd_arg);

57:                               continue;

58:                       }

59:                      

60:                       /* 外部命令或应用程序 */

61:                       execute_new(cmd_arg);

62:              }while(1);

63:     }

64:    

65:     /* 实现cd的功能 */

66:     void do_cd(char *argv[])

67:     {

68:              if(argv[1]!=NULL){

69:                       if(chdir(argv[1])<0)

70:                               switch(errno){

71:                               case ENOENT:

72:                                        printf("DIRECTORY NOT FOUND\n");

73:                                        break;

74:                               case ENOTDIR:

75:                                        printf("NOT A DIRECTORY NAME\n");

76:                                        break;

77:                               case EACCES:

78:                                        printf("YOU DO NOT HAVE RIGHT TO ACCESS\n");

79:                                        break;

80:                               default:

81:                                        printf("SOME ERROR HAPPENED IN CHDIR\n");

82:                               }

83:              }

84:    

85:     }

86:    

87:     /* 执行外部命令或应用程序 */

88:     void execute_new(char *argv[])

89:     {

90:              pid_t pid;

91:    

92:              pid=fork();

93:              if(pid<0){

94:                       printf("SOME ERROR HAPPENED IN FORK\n");

95:                       exit(2);

96:              }else if(pid==0){

97:                       if(execvp(argv[0],argv)<0)

98:                               switch(errno){

99:                               case ENOENT:

100:                                       printf("COMMAND OR FILENAME NOT FOUND\n");

101:                                       break;

102:                              case EACCES:

103:                                       printf("YOU DO NOT HAVE RIGHT TO ACCESS\n");

104:                                       break;

105:                            default:

106:                                    printf("SOME ERROR HAPPENED IN EXEC\n");

107:                              }

108:                      exit(3);

109:             }else

110:                      wait(NULL);

111:    }       



这个程序稍稍有点长,我们来对它作一下详细的解释:

函数main:

14行:定义字符串cmd,用于接收用户输入的命令行。 
15行:定义指针数组cmd_arg,它的形式和作用都与我们熟悉的char *argv[]一样。

从以上2个定义可以看出Mini Shell对命令输入的2个限制:首先,用户输入的命令行必须在255个字符之内(除去字符串结束标志'\0');其次,命令行的参数个数不得超过10个(包括命令本身在内)。

18行:进入一个do-while循环,这个循环是本程序的主体部分,基本思想是"等待输入命令--处理已输入命令--等待输入命令"。

22行:打印输入提示信息。在Mini Shell中,你可以随意定自己喜欢的命令输入提示信息,本程序中使用了"-=MiniShell=-*| ",是不是有点像一个CS高手?如果不喜欢,你可以用任意的字符替换它。

23行:接收用户输入。

25-27行:fgets接受输入时,会将输入字符串时末尾的换行符("\n")一起接受,这是我们不需要的,所以要把它去掉。本程序中简单的用字符串结束标志'\0'覆盖了字符串cmd的最后一个字符来实现这个目的。

30行:初始化指针数组cmd_arg。

32-42行:对输入进行分析,将cmd中参数间的空格用'\0'填充,并把各参数的起始地址分别赋与cmd_arg数组。这样就把cmd分解成了cmd_arg,但分解后的各命令参数仍然使用着cmd的内存空间,所以在命令执行结束前不宜对cmd另外赋值。

45行:如果还未分析到输入字符串的末尾(i<cmdlen),而分析出的参数已经达到或超过了10个(j>=10),就认为输入的命令行超出了10个参数的限制,打印错误并重新接收命令。

51-52行:内部命令quit:字符串cmd_arg[0]就是命令本身,如果命令是quit,则退出循环,也就等于退出该程序。

55-58行:内部命令cd:调用函数do_cd()完成cd命令的动作。

61行:对于其它的外部命令和应用程序,调用函数execute_new()执行。

函数do_cd:

68行:仅仅考虑紧跟在命令后面的参数argv[1],而不再考虑其它的参数。如果这个参数存在,就把它作为要转换的目录。

69行:调用系统调用chdir切换当前目录,参见附录1。

70-82行:对chdir可能出现的错误进行处理。

函数execute_new:

92行:调用系统调用fork产生新的子进程。

93行:如果返回负值,说明fork调用出现错误。

96行:如果返回0,说明当前进程是子进程。

97行:调用execvp执行新的应用程序,并检测调用是否出错(返回负值)。这里使用execvp的原因是它可以自动在各默认目录里寻找目标应用程序的位置,而不必我们自己编程实现。

98-107行:对execvp可能出现的错误进程处理。

108行:如果execvp的执行出现错误,子进程在这里终止。表面上看起来,这个exit是接着97行的错误判断的下一行语句,而非if语句的一部分,似乎无论调用execvp成功与否都会接着执行exit。但事实上,如果execvp调用成功的话,这个进程将会被新的程序代码填充,因而根本不可能执行到这一行。反之,如果执行到了这一行,说明前面的execvp调用一定出现了错误。这样的效果和exit被包含在if语句中的效果是完全一样的。

109行:如果fork返回其它值,说明当前进程是父进程。

110行:调用系统调用wait。wait在这里有两个作用:

1.       使父进程在此暂停,等待子进程执行完毕。这样,就可以等子进程的所有信息全部输出完毕后才打印命令提示符,等待下一条命令的输入,从而避免了命令提示符和应用程序输出混杂在一起的现象。

2.       收集子进程退出后留下的僵尸进程。可能有读者一直对这个问题存有疑问--"我们编程生成的子进程由我们自己设计的父进程负责收集,但我们手动执行的这个父进程由谁收集呢?"现在大家应该明白了,我们从命令行执行的所有进程最后都是由shell收集的。

关于Mini Shell的编译和运行,这里就不再敷述了,有兴趣的读者可以自行动手实验,或者对这个程序进行改进,使之更接近甚至超过我们正使用的Bash。

回页首

1.14daemon进程

1.14.1 了解daemon进程

这又是一个有趣的概念,daemon在英语中是"精灵"的意思,就像我们经常在迪斯尼动画里见到的那些,有些会飞,有些不会,经常围着动画片的主人公转来转去,啰里啰唆地提一些忠告,时不时倒霉地撞在柱子上,有时候还会想出一些小小的花招,把主人公从敌人手中救出来,正因如此,daemon有时也被译作"守护神"。所以,daemon进程在国内也有两种译法,有些人译作"精灵进程",有些人译作"守护进程",这两种称呼的出现频率都很高。

与真正的daemon相似,daemon进程也习惯于把自己隐藏在人们的视线之外,默默为系统做出贡献,有时人们也把它们称作"后台服务进程"。daemon进程的寿命很长,一般来说,从它们一被执行开始,直到整个系统关闭,它们才会退出。几乎所有的服务器程序,包括我们熟知的Apache和wu-FTP,都用daemon进程的形式实现。很多Linux下常见的命令如inetd和ftpd,末尾的字母d就是指daemon。

为什么一定要使用daemon进程呢?Linux中每一个系统与用户进行交流的界面称为终端(terminal),每一个从此终端开始运行的进程都会依附于这个终端,这个终端就称为这些进程的控制终端(Controlling terminal),当控制终端被关闭时,相应的进程都会被自动关闭。关于这点,读者可以用X-Window中的XTerm试验一下,(每一个XTerm就是一个打开的终端,)我们可以通过键入命令启动应用程序,比如:

$netscape

然后我们关闭XTerm窗口,刚刚启动的netscape窗口也会随之一同突然蒸发。但是daemon进程却能够突破这种限制,即使对应的终端关闭,它也能在系统中长久地存在下去,如果我们想让某个进程长命百岁,不因为用户或终端或其他的变化而受到影响,就必须把这个进程变成一个daemon进程。

1.14.2daemon进程的编程规则

如果想把自己的进程变成daemon进程,我们必须严格按照以下步骤进行:

1.       调用fork产生一个子进程,同时父进程退出。我们所有后续工作都在子进程中完成。这样做我们可以:

1.       如果我们是从命令行执行的该程序,这可以造成程序执行完毕的假象,shell会回去等待下一条命令;

2.       刚刚通过fork产生的新进程一定不会是一个进程组的组长,这为第2步的执行提供了前提保障。


这样做还会出现一种很有趣的现象:由于父进程已经先于子进程退出,会造成子进程没有父进程,变成一个孤儿进程(orphan)。每当系统发现一个孤儿进程,就会自动由1号进程收养它,这样,原先的子进程就会变成1号进程的子进程。 

2.       调用setsid系统调用。这是整个过程中最重要的一步。setsid的介绍见附录2,它的作用是创建一个新的会话(session),并自任该会话的组长(session leader)。如果调用进程是一个进程组的组长,调用就会失败,但这已经在第1步得到了保证。调用setsid有3个作用:

1.       让进程摆脱原会话的控制;

2.       让进程摆脱原进程组的控制;

3.       让进程摆脱原控制终端的控制;


总之,就是让调用进程完全独立出来,脱离所有其他进程的控制。 

3.       把当前工作目录切换到根目录。如果我们是在一个临时加载的文件系统上执行这个进程的,比如:/mnt/floppy/,该进程的当前工作目录就会是/mnt/floppy/。在整个进程运行期间该文件系统都无法被卸下(umount),而无论我们是否在使用这个文件系统,这会给我们带来很多不便。解决的方法是使用chdir系统调用把当前工作目录变为根目录,应该不会有人想把根目录卸下吧。关于chdir的用法,参见附录1。 
当然,在这一步里,如果有特殊的需要,我们也可以把当前工作目录换成其他的路径,比如/tmp。 

4.       将文件权限掩码设为0。这需要调用系统调用umask,参见附录3。每个进程都会从父进程那里继承一个文件权限掩码,当创建新文件时,这个掩码被用于设定文件的默认访问权限,屏蔽掉某些权限,如一般用户的写权限。当另一个进程用exec调用我们编写的daemon程序时,由于我们不知道那个进程的文件权限掩码是什么,这样在我们创建新文件时,就会带来一些麻烦。所以,我们应该重新设置文件权限掩码,我们可以设成任何我们想要的值,但一般情况下,大家都把它设为0,这样,它就不会屏蔽用户的任何操作。 
如果你的应用程序根本就不涉及创建新文件或是文件访问权限的设定,你也完全可以把文件权限掩码一脚踢开,跳过这一步。

5.       关闭所有不需要的文件。同文件权限掩码一样,我们的新进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不被我们的daemon进程读或写,但它们一样消耗系统资源,而且可能导致所在的文件系统无法卸下。需要指出的是,文件描述符为0、1和2的三个文件(文件描述符的概念将在下一章介绍),也就是我们常说的输入、输出和报错这三个文件也需要被关闭。很可能不少读者会对此感到奇怪,难道我们不需要输入输出吗?但事实是,在上面的第2步后,我们的daemon进程已经与所属的控制终端失去了联系,我们从终端输入的字符不可能达到daemon进程,daemon进程用常规的方法(如printf)输出的字符也不可能在我们的终端上显示出来。所以这三个文件已经失去了存在的价值,也应该被关闭。 

下面,就然我们亲眼看一个daemon进程的诞生:

1.14.3 一个daemon程序

/* daemon.c */

#include<unistd.h>

#include<sys/types.h>

#include <sys/stat.h>

#define MAXFILE 65535

main()

{

         pid_t pid;

         int i;

        pid=fork();

         if(pid<0){

                 printf("error in fork\n");

                 exit(1);

         }else if(pid>0)

                 /* 父进程退出 */

                 exit(0);

         /* 调用setsid */

        setsid();

         /* 切换当前目录 */

        chdir("/");

         /* 设置文件权限掩码 */

         umask(0);

         /* 关闭所有可能打开的不需要的文件 */

         for(i=0;i<MAXFILE;i++)

                 close(i);

         /*

            到现在为止,进程已经成为一个完全的daemon进程,

            你可以在这里添加任何你要daemon做的事情,如:

         */

         for(;;)

                 sleep(10);

}



编译和运行的任务就交给读者们自己完成。daemon进程不像其他进程一样有很抢眼的运行结果,基本上它只是毫不声张地做自己的事。你不可能看到任何东西,但可以用"ps -ajx"命令观察一下你的daemon进程的状态和一些参数。

回页首

1.15附录

1.15.1 系统调用chdir

              #include <unistd.h>

       int chdir(const char *path);

      



chdir的作用是改变当前工作目录。进程的当前工作目录一般是应用程序启动时的目录,一旦进程开始运行后,当前工作目录就会保持不变,除非调用chdir。chdir只有1个字符串参数,就是要转去的路径。例如:

chdir("/");



进程的当前路径就会变为根目录。

1.15.2 系统调用setsid

              #include <unistd.h>

       pid_t setsid(void);

      



一个会话(session)开始于用户登陆,终止于用户退出,在此期间该用户运行的所有进程都属于这个会话,除非进程调用setsid系统调用。

系统调用setsid不带任何参数,调用之后,调用进程就会成立一个新的会话,并自任该会话的组长。

1.15.3 系统调用umask

              #include <sys/types.h>

       #include <sys/stat.h>

       mode_t umask(mode_t mask);

      



系统调用umask可以设定一个文件权限掩码,用户可以用它来屏蔽某些权限,以防止误操作导致给予某些用户过高的权限。

 

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值