8.1 引言
本章介绍UNIX的进程控制,包括创建新进程、执行程序和进程终止。还将说明进程属性的各种ID——实际、有效和保护的用户和组ID,以及它们如何受到进程控制原语的影响。本章还包括了解释器文件和system函数。本章最后讲述大多数UNIX系统所提供的进程会计机制。这种机制使我们能从另一个角度了解进程的控制功能。
8.2 进程标志符
每个进程都有要给非负整数表示唯一进程ID,虽然进程ID是唯一的,但是可以重用。当一个进程终止后,其进程ID就可以再次使用了。大多数UNIX系统实现延迟重用算法,使得赋予新建进程的ID不同于最近终止进程所使用的ID。这防止了将新进程误认为是使用同一ID的某个已终止的先前进程。
系统中有一些专用进程,但具体细节因实现而异。ID为0的进程通常是调度进程,常常被称为交换进程。该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程。进程ID 1 通常是init 进程,在自举过程结束时由内核调用。该进程的程序文件在UNIX的早期版本中是/etc/init,在较新版本中是 /sbin/init。此进程负责在自举内核后启动一个UNIX系统。init 通常读与系统有关的初始化文件,并将系统引导到一个状态(例如多用户)。init进程决不会终止。它是一个普通的用户进程(与交换进程不同,它不是内核中的系统进程),但是它以超级用户特权运行。本章稍后部分会说明 init 如何称为所有孤儿进程的父进程。
每个UNIX 系统实现都有它自己的一套提供操作系统服务的内核进程,例如,在某些UNIX的虚拟存储器实现中,进程ID 2 是页守护进程。此进程负责支持虚拟存储系统的分页操作。
除了进程 ID,每个进程还有一些其他的标识符。下列函数返回这些标识符。
pid_t getpid(void);pid_t getppid(void);
uid_t getuid(void); // 返回进程的实际用户IDuid_t geteuid(void); // 返回进程的有效用户ID gid_t getgid(void);gid_t getegid(void);
注意,这些函数都没有出错返回,在下一节中讨论 fork 函数时,将进一步讨论父进程 ID。
8.3 fork函数
一个现有进程可以调用 fork 函数创建一个新进程
pid_t fork(void);
由 fork 创建的新进程被称为子进程。fork函数被调用一次,但返回两次。子进程的返回值是0,父进程的返回值是新子进程的进程 ID。
子进程和父进程继续执行 fork 调用之后的指令,子进程是父进程的副本。例如,子进程获得父进程的数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父、子进程并不共享这些存储空间部分。父子进程共享正文段(见7.6节)
由于在 fork 之后经常跟随着 exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆完全复制。作为替代,使用写时复制技术。这些区域由父、子进程共享,而且内核将它们的访问权限改变为只读的。如果父、子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储器系统中的一“页”。
一般来说,在 fork 之后父子进程执行的现后顺序是不确定的。如果要求父子进程之间相互同步,则要求某种形式的进程间通信。
文件共享
fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中。父、子进程的每个相同的打开描述符共享一个文件表项(如图3-3)。
考虑下述情况,一个进程具有三个不同的打开文件,他们是标准输入、标准输出和标准出错。在从fork返回时,我们有了下图。
这种共享方式使父、子进程对同一个文件使用了一个文件偏移量。这样就可以实现父子进程交互写同一个文件。
如果父、子进程写到同一描述符文件,但又没有任何形式的同步,那么它们的输出就会相互 混合(假定所用的描述符是在fork之前开打的)。虽然这种情况是可能发生的,但这并不是常用的操作模式。
在fork之后处理文件描述符有两种常见的情况:
(1)父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行读、写操作的任一共享描述符的文件偏移量已执行了相应更新。
(2)父、子进程各自执行不同的程序段。在这种情况下,在fork之后,父、子进程各自关闭它们不需要使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种是网络服务进程中经常使用的。
除了打开文件之外,父进程的很多其他属性也由子进程继承,包括
(1)实际用户ID、实际组ID、有效用户ID、有效组ID
(2)附加组ID
(3)进程组ID
(4)会话ID
(5)控制终端
(6)设置用户ID标志和设置组ID标志
(7)当前工作目录
(8)根目录
(9)文件模式创建屏蔽字
(10)信号屏蔽和安排
(11)针对任一打开文件描述符的在执行时关闭标志。
(12)环境
(13)连接的共享存储段
(14)存储映射
(15)资源限制
父子进程之间的区别是:
(1)fork的返回值
(2)进程ID不同
(3)两个进程具有不同的父进程ID
(4)进程的 tms_utime、tms_stime、tms_cutime以及tms_ustime均被设置为0。
(5)父进程设置的文件锁不会被子进程继承。
(6)子进程的未处理闹钟(alarm)被清除
(7)子进程的未处理信号集设置为空集。
使fork失败的主要原因是进程太多了。
fork有两种用法:
(1)一个父进程希望复制自己,使父、子进程同时执行不同的代码段。这在网络服务进程中是常见的——父进程等待客户端的服务请求。当这种情况到达时,父进程调用 fork,使子进程处理此请求。父进程则继续等待下一个服务请求到达。
(2)一个进程要执行一个不同的程序。这对shell是常见的情况。在这种情况下,子进程从fork返回后立即调用 exec。
某些操作系统将(2)中的两个操作(fork、exec)组合成一个,并称其为spawn。UNIX将这两个操作分开,使得子进程在 fork 和 exec 之间可以更改自己的属性。例如 I/O 重定向、用户ID、信号安排等。在15章中有很多这方面的例子。
8.4 vfork函数
vfork函数的调用序列和返回值与fork相同,但两者的语义不同。
vfork被认为有瑕疵,应被弃用。
vfork用于创建一个新进程,而该新进程的目的是exec一个新程序。由于vfork不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec或exit,于是就不会访问该地址空间。相反,在子进程调用 exec 或 exit 之前,他在父进程的空间中运行。这种优化工作方式在某些UNIX的页式虚拟存储器实现中提高了效率。(这与上一节中提及的在fork之后跟随exec,并采用在写时复制技术相似,而且不复制比部分复制要更快一些。)
vfork和 fork之间的另一个区别是:vfork保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行。(如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁)。
int glob = 6;int main(void){ int var; pid_t pid; var = 88; printf("before vfork\n"); if ((pid = vfork()) < 0) { err_sys("vfork error"); } else if (pid == 0) { glob++; var++; _exit(0); } printf("pid = %d, glob = %d, var = %d\n", getpid(), glob, var); exit(0);}
子进程对变量 glob 和 var 做增1操作,结果改变了父进程中的变量值。因为子进程在父进程的地址空间中运行,所以这并不令人惊讶。但是其作用的确与 fork 不同。
注意,在程序清单中,调用了 _exit 而不是 exit。正如7.3节所述, _exit并不执行标准 I/O 缓冲的冲洗操作。如果调用的是exit而不是 _exit,则程序的输出是不确定的。它依赖标准 I/O 库的实现。
如果该实现也关闭标准 I/O 流,那么表示那么标准输出 FILE 对象的相关存储区将被清 0 。注意,父进程的 STDOUT_FILENO 仍旧有效,子进程得到的是父进程的文件描述符数组的副本。但由于没有缓冲区,所以父进程调用 printf 时不会产生任何输出。
8.5 exit函数
如7.3节所述,进程有下面5中正常终止方式:
(1)main函数内return,等效于调用 exit。
(2)调用 exit 函数。其操作包括调用各终止处理程序(终止处理程序在调用 atexit 函数时登记),然后关闭所有标准 I/O 流等。因为 ISO C 并不处理文件描述符、多进程(父、子进程)以及作业控制,所以这一定义对UNIX系统而言是不完整的。
(3)调用 _exit 或 _Exit 函数。其存在的目的是为进程提供一种无需运行终止处理程序或信号处理程序而终止的方法。在 UNIX 中, _Exit 和 _exit 是同义的,并不清洗标准 I/O 流。_exit函数有 exit 调用。
(4)进程最后一个线程在启动例程中执行返回语句。但是,该线程的返回值不会用作进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态0返回。
(5)进程的最后一个线程调用 pthread_exit 函数。如同前面一样,在这种情况下,进程终止状态总是0,这与传送给 pthread_exit 的参数无关。
三种异常终止方式如下
(1)调用 abort。它产生 SIGABORT 信号,这是下一种异常终止的一种特例。
(2)当进程接受到某些信号时。信号可由进程自身(例如调用abort函数)、其他进程或内核产生。例如,进程越出其他地址空间访问存储单元或者除以0,内核就会为该进程产生相应的信号。
(3)最后一个线程对“取消”请求做出响应。按系统默认,“取消”以延迟方式发生:一个线程要求取消另一个线程,一段时间后,目标线程终止。
不管进程如何终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开描述符,释放它所使用的存储器等。
对于上述任意一种情形,我们都希望终止进程能够通知其父进程它是如何终止的。对于三个终止函数(exit、_exit和_Exit),实现这一点的方法是,将其退出状态作为参数传给函数。在异常终止情况下,内核(不是进程本身)产生一个指示其异常终止原因的终止状态。在任意一种情况下,该终止进程的父进程都能用wait或waitpid函数取得其终止状态。
注意,这里使用“退出状态”(它是传向exit或 _exit 的参数,或 main 的返回值)和“终止状态”两个术语,以表示有所区别。在最后调用_exit时,内核将退出状态换成终止状态(回忆图7-1)。下一节中的表8-1说明父进程检查子进程终止状态的不同方法。如果子进程正常终止,则父进程可以获得 子进程的退出状态。
当子进程退出后,将其终止状态返回给父进程,但是如果父进程提前终止,那么init成为父进程。其操作过程大致如下:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否正要终止进程的子进程,如果是,则将该进程的父进程ID更改为1(init进程的ID)。这种处理方法保证了每个进程都有一个父进程。
另一个我们关系的情况是如果子进程在父进程之前终止,那么父进程又如何能在做相应及检查时得到子进程的终止状态呢?对此问题的回答是:内核为每个终止子进程保持了一定量的信息,所以当终止进程的父进程调用 wait 或 waitpid 时,可以得到这些信息。这些信息至少包括进程 ID、该进程的终止状态、以及该进程使用 CPU 时间总量。内核可以释放终止进程所使用的所有存储区,关闭其所有打开文件。在UNIX术语中,一个已经终止,但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息,释放它仍占用的资源)的进程被称为僵死进程(zombie)。ps(1)命令将僵死进程的状态打印为 Z。如果编写一个长期运行的程序,它调用 fork 产生了很多子进程,那么除非父进程等待取得子进程的终止状态,否则这些子进程终止后就会变成僵死进程。
最后一个要考虑的问题是:一个由Init进程领养的进程终止时会发生什么?它会不会变成一个僵死进程?答案是“否”,因为Init被编写成无论何时只要有一个子进程终止,init就会调用一个wait函数取得其终止状态。
8.6 wait和waitpid函数
当一个进程正常或异常终止时,内核就向其父进程发送 SIGCHILD 信号。父进程可以选择忽略,或者捕捉,对于这种信号的系统默认动作是忽略它。
现在需要知道的是调用 wait 或 waitpid的进程可能发生什么情况:
(1)如果其所有子进程都还在运行,则阻塞。
(2)如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
(3)如果他没有任何子进程,则立即出错返回
pid_t wait(int *statloc);pid_t waitpid(pid_t pid, int *statloc, int options); 两个函数的返回值:若成功则返回进程ID,0(见后面说明),若出错则返回-1
两个函数的区别如下:
(1)waitpid可以设置不阻塞。
(2)waitpid并不等的在其调用之后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。
对于 statloc 用于获得返回的状态,其中某些为表示退出状态(正常返回),其他位则指示信号编号(异常返回),有一个位指示是否产生一个core文件等。使用下列的宏来查看
void pre_exit(int status){ if (WIFEXITED(status)) printf("normal termination, exit status = %d\n", WEXITSTATUS(status)); else if (WIFSIGNALED(status)) printf("abnormal termination, signal number = %d%s\n", WTERMGIS(status)),#ifdef WCOREDUMP WCOREDUMP(status) ? " (core file generated)" : "");#else "");#endif else if (WIFSTOPPED(status)) printf("child stopped, signal number = %d\n", WSTOPSIG(status));}
对于 waitpid 的函数中 pid 参数的作用解释如下:
pid == -1 等待任一子进程。与 wait 等效。
pid > 0 等待其进程ID == pid 的子进程
pid == 0 等待其组ID等于调用进程组ID的任一子进程
pid < -1 等待其组ID等于pid绝对值的任一子进程。
对于 wait ,其唯一出错是调用进程没有子进程(函数调用被一个信号中断时,也可能返回另一种出错。第10章讨论)。但是对于 waitpid,如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程则都将出错。
options参数使我们能进一步控制waitpid的操作。此参数可以是0。或者按照下表常量位或运算的结果。
waitpid函数提供了wait函数没有提供的三个功能:
(1)waitpid可等待一个特定的进程,而wait则返回任一终止子进程的状态。
(2)waitpid提供了一个wait的非阻塞版本。
(3)waitpid支持作用控制(利用 WUNTRACED 和 WCONTINUED 选项)。
如果一个进程fork一个子进程,但不要它等待子进程终止,也不希望子进程处于僵死状态直到父进程终止,实现这一要求的技巧是调用 fork 两次。(本质上就是让 init 进程管理孙子进程)
// 调用 fork 两次以避免僵死进程int main(void){ pid_t pid; if ((pid = fork()) < 0) { err_sys("fork error"); } else if (pid == 0) { if ((pid = fork()) < 0) err_sys("fork error"); else if (pid > 0) exit(0); sleep(2); printf("second child, parent pid = %d\n", getppid()); exit(0); } if (waitpid(pid, NULL, 0) != pid) err_sys("waitpid error"); exit(0);}
8.9 竞态条件
当多个进程都企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序,则我们认为这发生了竞态条件。
在父、子进程的关系中,常常出现下述情况,在调用 fork 之后,父、子进程都有一些事情要做。例如,父进程可能要用子进程ID更新日志文件中的一个记录,而子进程则可能要为父进程创建一个文件,在本例中,要求每个进程在执行完它的一套初始化操作后要通知对方,并且在继续运行之前,要等待另一方完成其初始化操作。这种方案可以用代码描述如下:
TELL_WAIT(); // set thing up for TELL_XXX & WAIT_XXXif ((pid = fork()) < 0) { err_sys("fork error");} else if (pid == 0) { // child // child does whatever is necessary .. TELL_PARENT(getppid()); //tell parent we're done WAIT_PARENT(); // and wait for parent // and the child continues on its way ... exit(0);}// parent does whatever is necessary ...TELL_CHILD(pid); // tell child we're doneWAIT_CHILD(); // and wait for child// and the parent continues on its way...exit(0);
TELL_WAIT、TELL_PARENT、TELL_CHILD、WAIT_PARENT、WAIT_CHILD可以是宏,也可以是函数。
在后面几章会说明实现这些TELL和WAIT例程的不同方法:10.16节中说明使用信号的一种实现,程序清单15-3说明使用管道的一种实现。下面先看一个使用这5各例程的实例。
程序清单8-6输出两个字符串:一个由子进程输出,另一个由父进程输出。因为输出依赖内核使用这两个进程运行的顺序及每个进程运行的时间程度,所以该程序包含了一个竞争条件。
static void charatatime(char *);int main(void){ pid_t pid; if ((pid = fork()) < 0) { err_sys("fork error"); } else if (pid == 0) { charatatime("output from child\n"); } else { charatatime("output from parent\n"); } exit(0);}static void charatatime(char *str){ char *ptr; int c; setbuf(stdout, NULL); // set unbuffered for (ptr = str; (c = *ptr++) != 0;) putc(c, stdout);}
在程序中将标准输出设置为不带缓冲的,于是每个字符输出都需要调用一次write。本例的目的是使内核能尽可能地在两个进程间多次切换,以便演示竞态条件。结果如下 :
修改程序清单上面程序,使用 TELL 和WAIT 函数,于是有如下:
运行此程序则能够得到预期的输出。
8.10 exec函数
当进程调用 exec函数时,该进程的程序完全替换为新程序,而新程序则从其main函数开始执行。因为exec并不创建新进程,所以前后的进程ID并没有改变。exec只是用一个全新的程序替换了当前进程的正文、数据、堆和栈段。
有6种不同的exec函数可使用。
这些函数之间的第一个区别是前4个取路径名作为参数,后两个则取文件名作为参数。而当指定filename作为参数时:
(1)如果filename中包含/,则将其视为路径名。
(2)否则就按PATH环境变量,在它所指定的各目录中搜索可执行文件。
PATH变量包含了一张目录表(称为路径前缀),目录之间用冒号(:)分隔。例如,name=value环境字符串
PATH=/bin:/usr/bin:/usr/local/bin:.
指定在4各目录中进行搜索。最后的路径前缀表示当前目录。(零长前缀也表示当前目录。在value的开始处可用 : 表示,在行中间则要用 :: 表示,在行尾则以 : 表示。)
如果execlp或execvp使用路径前缀找到了一个可执行文件,但是该文件不是由连接编辑器产生的机器可执行文件,则认为该文件是一个shell脚本,于是试着调用/bin/sh,并以该filename作为shell的输入。
第二个区别与参数表的传递有关(l表示list,v表示矢量vector)。函数execl、execlp和execle要求将新程序的每个命令行参数都说明为一个单独的参数。这种参数表以空指针结尾。对于另外三个函数(execv、execvp和execve),则应先构造一个指向各参数的指针数组,然后将该数组地址作为这三个函数的参数。
execl、execle和execlp三个函数表示命令行参数的一般方法是:
char *arg0, char *arg1,..., char*argn, (char *)0
应当特别指出的是:在最后一个命令行参数之后跟了一个空指针,如果用常数0来表示一个空指针,则必须将他强制转换为一个字符指针,否则将他解释为整形参数。如果一个整形数的长度与char *的长度不同,那么exec函数的实际参数就将出错。
最后一个区别与新程序传递环境表相关。以e结尾的两个函数(execle和execve)可以传递一个指向环境字符串指针数组的指针。其他四个函数则使用调用进程中的environ变量为新程序复制现有环境(回忆7.9节,其中曾提及如果系统支持setenv和putenv这样的函数,则可更改当前环境和后面生成的子进程的环境,但不能影响父进程的环境)。通常,一个进程允许将其环境传播给其子进程,但有时也有 这种情况,即进程想要为子进程指定某一个确定的环境。例如,在初始化一个新登陆的shell时,login程序通常创建一个只定义少数几个变量的特殊环境,而在我们登陆时,可以通过shell启动文件,将其他变量加到环境中。
execle的参数是:
char *pathname, char *arg0, ..., char *argn, (char *)0, char *envp[]
从中可见,最后一个参数是指向环境字符串的各字符指针构成的数组的地址。而函数原型中,所有命令行参数、空指针和envp指针都用省略号(...)表示。
这6个exec函数的参数很难记忆。函数名中的字符会给我们一些帮助。字母p表示该函数取filename作为参数,并且用PATH环境变量寻找可执行文件。字母 l 表示该函数取一个参数表,它与字母 v 互斥。 v 表示该函数取一个 argv[] 矢量。最后,字母 e 表示该函数取 envp[] 数组,而不使用当前环境。下表显示了这 6 个函数之间的区别。
前面曾提及在执行 exec 后,进程 ID 没有改变。除此之外,执行新程序的进程还保持了原进程的以下特征:
(1)进程ID和父进程ID
(2)实际用户ID和实际组ID
(3)附加组ID
(4)进程组ID
(5)会话ID
(6)控制终端
(7)闹钟尚余留的时间
(8)当前工作目录
(9)根目录
(10)文件模式创建屏蔽字
(11)文件锁
(12)进程信号屏蔽
(13)未处理信号
(14)资源限制
(15)tms_utime、tms_stime、tms_cutime以及tms_cstime值。
对打开文件的处理与每个描述符的执行时关闭(close-on-exec)标志有关。见图3-1以及3.14节中对 FD_CLOEXEC 的说明,进程中每个打开描述符都有一个执行时关闭标志。若此标志设置,则在执行exec时关闭该描述符,否则该描述符仍然打开。除非特地用 fcntl 设置了该标志,否则系统的默认操作是在执行exec后仍保持这种描述符打开。
POSIX.1明确要求在执行exec时关闭打开的目录流(见4.21节中所述的opendir函数),这通常是由opendir函数实现的,它调用fcntl函数为对应于打开目录流的描述符设置执行时关闭标志。
注意,在执行exec前后实际用户ID和实际 组ID保持不变,而有效ID是否改变则取决于所执行程序文件的设置用户ID位和设置组ID位是否设置。如果新程序的设置用户ID位已设置,则有效用户ID变成程序文件所有者的ID,否则有效用户ID不变,对组ID的处理方式与此相同。
6个函数之间的关系如下图:
在这种安排中,库函数execlp和execvp使用PATH环境变量,查找第一个包含名为filename的可执行文件的路径名前缀。
char *env_init[] = { "USR=unknow", "PATH=/tmp", NULL};int main(void){ pid_t pid; if ((pid = fork()) < 0) { err_sys("fork error"); } else if (pid == 0) { /* specify pathname, specify environment */ if (execle("/home/sar/bin/echoall", "myarg1", "MY ARG2", (char *)0, env_init) < 0) err_sys("execle error"); } if (waitpid(pid, NULL, 0) < 0) err_sys("wait error"); if ((pid = fork()) < 0) { err_sys("fork error"); } else if (pid == 0) { /* specify filename, inherit environment */ if (execlp("echoall", "echoall", "only 1 arg", (char *)0) < 0) err_sys("execlp error"); } exit(0);}
// echoallint main(int argc, char *argv[]){ int i; char **ptr; extern char **environ; for (i = 0; i < argc; i++) printf("argv[%d]: %s\n", i, argv[i]); for (ptr = environ; *ptr != 0; ptr++) printf("%s\n", *ptr); exit(0);}
注意,shell提示符出现在第二个exec打印argv[0]之前。这是因为父进程并不等待该子进程结束。
8.11 更改用户ID和组ID
进程的用户ID和组ID绝对了其特权的大小。
一般而言,在设计应用程序时,我们总是试图使用最小特权模型。依照此模型,我们的程序应当只具有为完成给定任务所需的最小特权。这减少了安全性受到损害可能性,这种安全性损害是由于恶意用户试图哄骗我们程序以未预料的方式使用特权所造成的。
可以使用 setuid 函数设置实际用户ID和有效用户ID。与此类似,可以用setgid函数设置实际组ID和有效组ID
int setuid(uid_t uid);int setgid(gid_t gid);
8.12 解释器文件
如今UNIX系统都支持解释器文件,这种文件时文本文件,其起始行形式是:
#! pathname [optional-argument]
感叹号和pathname之间的空格是可选的。最常见的解释器文件以下列行开始:
#!/bin/sh
pathname通常是绝对路径,对它不进行什么特殊处理(即不使用PATH进行 路径搜索)。内核调用exec函数的进程实际执行的并不是该解释器文件,而是该解释器文件第一行中 pathname 所指定的文件。一定要将解释器文件(文本文件,它以 #! 开头)和解释器(由该解释器文件第一行中的pathname指定)区分开来。
让我们观察一个实例,从中可了解当被执行文件是解释器文件时,内核如何处理exec函数的参数及解释器文件第一行的可选参数。
int main(void){ pid_t pid; if ((pid = fork()) < 0) { err_sys("fork error"); } else if (pid == 0) { if (execl("/home/sar/bin/testinterp", "testinterp", "myarg1", "MY ARG2", (char *)0) < 0) err_sys("execl error"); } if (waitpid(pid, NULL, 0) < 0) err_sys("waitpid error"); exit(0);
程序echoarg(解释器)回送每一个命令行参数(他就是程序清单7-3).注意,这里 argv[0] 是该解释器的pathname,argv[1]是解释器文件中的可选参数,其余参数是pathname(/home/sar/bin/testinterp),以及程序清单8-10中调用 execl 的第二个和第三个参数(myarg1和 MY ARG2)。调用execl时的 argv[1] 和 argv[2] 已右移两个位置。注意,内核取 execl 调用中的 pathname 而非第一个参数 (testinterp),因为一般而言, pathname 包含了比第一个参数更多的信息。
8.13 system函数
在程序中执行一个命令字符串很方便。例如,假定要将时间和日期放到某个文件中,则可使用6.10节中说明的函数实现这一点。调用 time 得到当前日历时间,接着调用 localtime 将日历时间转换为年、月、日、时、分、秒、周日形式,然后调用 strftime 对上面的结果进行格式化处理,最后将结果写到文件中。但是用下面的 system 函数则更容易做到这一点。
int system(const char *cmdstring);
system("data > file");
如果 cmdstring 是一个空指针,则仅当命令处理程序可用时,system 返回值非 0 值,这一特征可以确定在一个给定操作系统上是否支持 system 函数。
因为 system 在其实现中调用了 fork、exec和 waitpid,因此有三种返回值:
(1)如果 fork 失败或者 waitpid 返回除 EINTR 之外的出错,则 system 返回 -1,而且 errno 中设置了错误类型值。
(2)如果 exec 失败(表示不能执行 shell),则其返回值如同 shell 执行了 exit(127)一样。
(3)否则所有三个函数(fork、exec和waitpid)都执行成功,并且 system 的返回值是 shell 的终止状态,其格式已在 waitpid 中说明。
如果 waitpid 由一个捕捉到的信号中断,则某些早期的 system 实现都返回错误类型值 EINTR,但是,因为没有可用的清理策略能让应用程序从这种错误类型中恢复,所以 POSIX 后来增加了下列要求:在这种情况下 system 不返回一个错误(10.5节将讨论被中断的系统调用)。
下面是 system 函数的一种实现。它没有对信号进行处理。10.18节中将修改此函数使其进行信号处理。
int system(const char *cmdstring) /* version without signal handling */{ pid_t pid; int status; if (cmdstring == NULL) return 1; /* always a command processor with UNIX */ if ((pid = fork()) < 0) { status = -1 /* probably out of processes */ } else if (pid == 0) { /* child */ execl("/bin/sh", "sh", "-c", cmdstring, (char *)0); _exit(127); /* execl error */ } else { /* parent */ while (waitpid(pid, &status, 0) < 0) { if (errno != EINTR) { status = -1; /* error other than EINTR from waitpid() */ break; } } } return status;}
shell 的 -c 选项告诉shell 程序取下一个命令参数(在这里是 cmdstring)作为命令输入(而不是从标准输入或从一个给定的文件中读命令)。shell对以 null 字符终止的命令字符串进行语法分析,将它们分成命令行参数。传递给 shell 的实际命令字符串可以包含任一有效 shell 命令。例如,可以用<和>重定向输入和输出。
如果不使用 shell 执行此命令,而是试图由我们自己去执行它,那么将相当困难。首先,我们必须用 execlp 而不是 execl,像 shell 那样使用 PATH 变量。我们必须将 null 结尾的命令字符串分成各个命令参数,以便调用 execlp。最后,我们也不能使用任何一个 shell 元字符。
注意,我们调用 _exit 而不是 exit。这是为了防止任一标准 I/O 缓冲区(这些缓冲区会在 fork 中由父进程复制到子进程)在子进程中被冲洗。
用程序清单8-13对system的这种版本进行了测试(pr_exit函数定义在程序清单8-3中)
int main(void){ int status; if ((status = system("date")) < 0) err_sys("system() error"); pr_exit(status); if ((status = system("nosuchcommand")) < 0) err_sys("system() error"); pr_exit(status); if ((status = system("who; exit 44")) < 0) err_sys("system() error"); pr_exit(status); exit(0);}
使用 system 而不是直接使用 fork 和 exec 的优点是:system 进行了所需的各种出错处理,以及各种信号(在10.18节中的 system 函数的下一个版本中)。
在UNIX 早期版本中,都没有 waitpid 函数,于是父进程用下列形式的语句等待子进程:
while ((lastpid = wait(&status) != pid && lastpid != -1));
如果调用 system 的进程在调用它之前已经生成它自己的子进程,那么将引起问题。因为上面的 while 语句一直循环执行,直到由 system 产生的子进程终止才停止,如果不是 pid 标识的任一子进程在 pid 子进程之前终止,则它们的进程 ID 和 终止状态都会被 while 语句丢弃。实际上,由于 wait不能等待一个指定的进程以及其他一些原因,POSIX 才定义了 waitpid 函数。如果不提供 waitpid 函数,popen 和 pclose 函数也会发生同样的问题。
设置用户 ID 程序
如果在一个设置用户 ID 程序中调用 system,那么发生什么呢?这是一个安全性方面的漏洞,绝不应当这样做。下面程序对其命令行参数调用 system 函数。
int main(int argc, char *argv[]){ int status; if (argc < 2) err_quit("command-line argument required"); if ((status = system(argv[1])) < 0) err_sys("system() error"); pr_exit(status); exit(0);}
将此程序编译称可执行文件 tsys。
程序清单8-15是另一个简单程序,它打印其实际和有效用户ID
int main(void){ printf("read uid = %d, effective uid = %d\n", getuid(), geteuid()); exit(0);}
将此程序编译成可执行文件 printuids。运行这两个程序,得到下列结果:
我们给予 tsys 程序的超级用户权限在 system 中执行了 fork 和 exec 之后仍会保持下来。
如果一个进程正以特殊权限(设置用户ID或设置组ID)运行,它又想生成另一个进程执行另一个程序,则它应当直接使用 fork 和 exec,而且在 fork 之后、exec 之前要改回到普通权限。设置用户ID或设置组ID程序决不应调用 system 函数。
8.14进程会计
8.15用户标识
8.16 进程时间
在 1.10 节中说明了我们可以测量的三种时间:墙上时钟时间、用户cpu时间和系统cpu时间。任一进程都可调用 times 函数以获得它自己及终止进程的上述值。
clock_t times(struct tms *buf); 返回值:若成功返回流逝的墙上时钟时间(单位:时钟滴答数),若出错则返回-1
此函数添写由 buf 指向的 tms 结构,该结构定义如下:
struct tms { clock_t tms_utime; /* user CPU time */ clock_t tms_stime; /* system CPU time */ clock_t tms_cutime; /* user CPU time, terminated children */ clock_t tms_cstime; /* system CPU time, terminated children */};
注意,此结构没有包含墙上时钟时间的任何测量值。作为替代,times函数返回墙上时钟时间作为其函数值。此值是相对于过去的某一时刻测量的,所以不能用其绝对值,而必须使用其相对值。例如,调用 times,保存其返回值。在以后某个时间再次调用 times,从新的返回值中减去以前的返回值,此差值就是墙上时钟时间(一个长期运行的进程可能会使墙上时钟时间溢出,当然这种可能性极小)
该结构中两个针对子进程的字段包含了此进程用 wait、waitid或waitpid已等待到的各个子进程的值。
所有由此函数返回的 clock_t 值都用 _SC_CLK_TCK 变换成秒数。
// 时间以及执行所有命令行参数static void pr_times(clock_t, struct tms *, struct tms *);static void do_cmd(char *);int main(int argc, char *argv[]){ int i; setbuf(stdout, NULL); for (i = 1; i < argc; i++) do_cmd(argv[i]); /* once for each command-line arg */ exit(0);}static void do_cmd(char *cmd){ struct tms tmsstart, tmsend; clock_t start, end; int status; printf("\ncommand: %s\n", cmd); if ((start = times(&tmsstart)) == -1) /* starting values */ err_sys("time error"); if ((status = system(cmd)) < 0) /* execute command */ err_sys("system() error"); if ((end = times(&tmsend)) == -1) err_sys("times error"); pr_times(end_start, &tmsstart, &tmsend); pr_exit(status);}static void pr_times(clock_t real, struct tms *tmsstart, struct tms *tmsend){ static long clktck = 0; if (clktck == 0) if ((clktck = sysconf(_SC_CLK_TCK)) < 0) err_sys("sysconf error"); printf(" real: %7.2f\n", real/(double)clktck); printf(" user: %7.2f\n", (tmsend->tms_utime - tmsstart->tms_utime)/(double)clktck); printf(" sys: %7.2f\n", (tmsend->tms_cutime - tmsstart->tms_sutime) / (double)clktck); printf(" child user: %7.2f\n", (tmsend->tms_cutime - tmsstart->tms_cutime)/ (double) clktck); printf(" child sys: %7.2f\n", (tmsend->tms_cstime - tmsstart->tms_cstime) / (double)clktck);}
运行程序,得到:
在这两个实例中,子进程中显示的所有CPU时间都是执行shell和命令的子进程所使用的CPU时间。