UNIX 环境高级编程|进程控制(下)
GitHub: https://github.com/storagezhang
Emai: debugzhang@163.com
本文为《UNIX 环境高级编程》第 8 章学习笔记
对在 UNIX 环境中的高级编程而言,完整地了解 UNIX 的进程控制是非常重要的。
其中必须熟练掌握的只有几个函数——fork
、exec
系列、_exit
、wait
和 waitpid
。很多应用程序都使用这些简单的函数。fork
函数也给了我们一个了解竞争条件的机会。
本章说明了 system
函数和进程会计,这也使我们能进一步了解所有这些进程控制函数。
本章还说明了 exec
函数的另一种变体:解释器文件以及它们的工作方式。
对各种不同的用户 ID 和组 ID(实际、有效和保存的)的理解,对编写安全的设置用户 ID 程序是至关重要的。
8.10 函数 exec
当进程调用一种 exec
函数时,该进程执行的程序完全替换为新程序,而新程序则从其 main
函数开始执行。因为调用 exec
并不创建新进程,所以前后的进程 ID 并未改变。exec
只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。
有 7 种不同的 exec
函数可供使用,它们常常被统称为 exec
函数,我们可以使用这 7 个函数中的任一个。这些 exec
函数使得 UNIX 系统进程控制原语更加完善。用 fork
可以创建新进程,用 exec
可以初始执行新的程序。exit
函数和 wait
函数处理终止和等待终止。这些是我们需要的基本的进程控制原语。
#include <unistd.h>
int execl(const char *pathname, const char *arg0, ... /* (char *)0 */);
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0,
... /* (char *)0, char *const envp[] */);
int execve(const char *pathname, char *const argv[], char *const envp[]);
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */);
int execvp(const char *filename, char *const argv[]);
int fexecve(int fd, char *const argv[], cahr *const envp[]);
// 返回值:若成功,不返回;若出错,返回 -1
区别:
- 前 4 个函数取路劲名作为参数,后 2 个函数则取文件名作为参数,最后一个取文件描述符作为参数。
- 当指定
filename
作为参数时:- 如果
filename
中包含/
,就将其视为路径名; - 否则,按照 PATH 环境变量,在它所指定的各目录中搜寻可执行文件。
- PATH 变量包含了一张目录表(称为路径前缀),目录之间用冒号分隔。
- 如果
- 如果
execlp
或execvp
使用路劲前缀中的一个找到了一个可执行文件,但是该文件不是由连接器产生的机器可执行文件,就认为该文件是一个 shell 脚本,于是试着调用 /bin/sh,并以该filename
作为 shell 的输入。 fexecv
函数避免了寻找正确的可执行文件,而是依赖调用进程来完成这项工作。调用进程可以使用文件描述符验证所需要的文件并且无竞争地执行该文件。否则,拥有特权的恶意用户就可以在找到文件位置并且验证之后,但在调用进程执行该文件之前,替换可执行文件(或可执行文件的部分路径)。- 函数
execl
、execlp
和execle
要求将新程序的每个命令行参数都说明为一个单独的参数。这种参数表以空指针结尾。 - 函数
execv
、execvp
、execve
和fexecve
应先构造一个指向各参数的指针数组,然后将该数组地址作为这 4 个函数的参数。 - 以
e
结尾的 3 个函数(execle
、execve
和fexecve
)可以传递一个指向环境字符串指针数组的指针。其他 4 个函数则使用调用进程中的environ
变量为新程序复制现有的环境。
注意:
-
每个系统对参数表和环境表的总长度都有一个限制。
- 在 POSIX.1 系统中,此值至少是 4096 字节。
- 当使用 shell 的文件名扩充功能产生一个文件名列表时,可能会受到此值的限制。
- 为了摆脱对参数表长度的限制,我们可以使用
xargs(1)
命令,将长参数表断开成几部分。
-
在执行
exec
后,进程 ID 没有改变,但新进程从调用进程继承了的下列属性:- 进程 ID 和父进程 ID
- 实际用户 ID 和实际组 ID
- 附属组 ID
- 进程组 ID
- 会话 ID
- 控制终端
- 闹钟尚余留的时间
- 当前工作目录
- 根目录
- 文件模式创建屏蔽字
- 文件锁
- 进程信号屏蔽
- 未处理信号
- 资源限制
- 友好值
tms_utime
、tms_stime
、tms_cutime
以及tms_cstime
值
对打开文件的处理于每个描述符的执行时关闭(close-on-exec)标志值有关。
- 进程中每个打开描述符都有一个执行时关闭标志。若设置了此标志,则在执行
exec
时关闭该描述符;否则该描述符仍打开。除非特地用fcntl
设置了该执行时关闭标志,否则系统的默认操作是在exec
后仍保持这种描述符打开。
-
在
exec
前后实际用户 ID 和实际组 ID 保持不变,而有效 ID 是否改变则取决于所执行程序文件的设置用户 ID 位和设置组 ID 位是否设置。如果新程序的设置用户 ID 为已设置,则有效用户 ID 变成程序文件所有者的 ID;否则有效用户 ID 不变。对组 ID 的处理方式与此相同。
在很多 UNIX 实现中,这 7 个函数中只有 execve
是内核的系统调用。另外 6 个只是库函数,它们最终都要调用该系统调用。这 7 个函数之间的关系如下:
在这种安排中,库函数 execlp
和 execvp
使用 PATH 环境变量,查找第一个包含名为 filename
的可执行文件的路径名前缀。fexecve
库函数使用 /proc 把文件描述符参数转换成路径名,execve
用该路径名去执程序。
8.11 更改用户 ID 和更改组 ID
在 UNIX 系统中,特权已经访问孔子,是基于用户 ID 和组 ID 的。当程序需要增加特权,或需要访问当前并不允许访问的资源时,我们需要更换自己的用户 ID 或组 ID,使得新 ID 具有合适的特权或访问权限。与此类似,当程序需要降低其特权或阻止对某些资源的访问时,也需要更换用户 ID 或组 ID,新 ID 不具有相应特权或访问这些资源的能力。
一般而言,在设计应用时,我们总是试图使用最小特权模型。依照此模型,我们的程序应当只具有为完成给定任务所需的最小特权。这降低了由恶意用户试图哄骗我们的程序以未预料的方式使用特权造成的安全性风险。
可以用 setuid
函数设置实际用户 ID 和有效用户 ID,用 setgid
函数设置实际组 ID 和有效组 ID:
#include <unistd.h>
int setuid(uid_t uid);
int setgid(gid_t gid);
// 返回值:若成功,返回 0;若失败,返回 -1
关于更改用户 ID 的规则(关于用户 ID 所说明的一切都适用于组 ID):
- 若进程具有超级用户特权,则
setuid
函数将实际用户 ID、有效用户 ID 以及保存的设置用户 ID(saved set-user-ID)设置为uid
。 - 若进程没有超级用户特权,但是
uid
等于实际用户 ID 或保存的设置用户 ID,则setuid
只将有效用户 ID 设置为uid
,不更改实际用户 ID 和保存的设置用户 ID。 - 如果上面两个条件都不满足,则
errno
设置为EPERM
,并返回 -1。 - 在此假定
_POSIX_SAVED_IDE
为真,如果没有提供这种功能,则上面所说的关于保存的设置用户 ID 部分都无效。
关于内核所维护的 3 个用户 ID(实际用户、有效用户和保存的设置用户),还要注意:
- 只有超级用户进程可以更改实际用户 ID。
- 通常,实际用户 ID 是在用户登录时,由
login
程序设置的,而且绝不会改变它。 - 因为
login
是一个超级用户进程,当它调用setuid
时,设置所有 3 个用户 ID。
- 通常,实际用户 ID 是在用户登录时,由
- 仅当对程序文件设置了设置用户 ID 时,
exec
函数才设置有效用户 ID。如果设置用户 ID 位没有设置,exec
函数不会改变有效用户 ID,而将维持其现有值。- 任何时候都可以调用
setuid
将有效用户 ID 设置为实际用户 ID 或者保存的设置用户 ID。 - 调用
setuid
时,不能将有效用户 ID 设置为任一随机值,只能从实际用户 ID 或者保存的设置用户 ID 中取得。
- 任何时候都可以调用
- 保存的设置用户 ID 是由
exec
复制有效用户 ID 而得到的。- 如果设置了文件的设置用户 ID 位,则在
exec
根据文件的用户 ID 设置了进程的有效用户 ID 以后,这个副本就被保存起来了。
- 如果设置了文件的设置用户 ID 位,则在
下表总结了更改这 3 个用户 ID 的不同方法:
注意:getuid
和 geteuid
函数只能获得实际用户 ID 和有效用户 ID 的当前值,我们没有可移植的方法去获得保存的设置用户 ID 的当前值。
函数 seteuid
和 setegid
POSIX.1 包含了两个函数 seteuid
和 setegid
,它们类似于 setuid
和 setgid
,但只更改有效用户 ID 和有效组 ID :
#include <unistd.h>
int seteuid(uid_t uid);
int setegid(gid_t gid);
// 返回值:若成功,返回 0;若出错,返回 -1
- 一个非特权用户可将其有效用户 ID 设置为其实际用户 ID 或其保存的设置用户 ID。
- 一个特权用户用户可将有效用户 ID 设置为
uid
。
8.12 解释器文件
所有现今的 UNIX 系统都支持解释器文件。
- 这种文件是文本文件,其起始行的形式是:
#! pathname [optional-argument]
,在感叹号和pathname
之间的空格是可选的。
- 最常见的解释器文件以下列行开始:
#! /bin/sh
pathname
通常是绝对路径名,对它不进行什么特殊处理(不使用 PATH 进行路径搜索)。- 对这种文件的识别是由内核作为
exec
系统调用处理的一部分来完成的。内核使调用exec
函数的进程实际执行的并不是该解释器文件,而是在该解释器文件第一行中pathname
所指定的文件。 - 一定要将解释器文件(文本文件,它以
#!
开头)和解释器(有该解释器文件第一行中的pathaname
指定)区分开来。
解释器使用户得到效率方面的好处,其代价是内核的额外开销(因为识别解释器文件的是内核)。
由于下述理由,解释器文件是有用的:
- 有些程序是用某种语言写的脚本,解释器文件可将这一事实隐藏起来。
- 解释器脚本提升了效率。
- 解释器脚本使我们可以使用除 /bin/sh 以为的其他 shell 来编写 shell 脚本。
8.13 函数 system
ISO C 定义了 system
函数,用于在程序中执行一个命令字符串,但是其操作对系统的依赖性很强。POSIX.1 包括了 system
接口,它扩展了 ISO C 定义,描述了 system
在 POSIX.1 环境中的运行行为。
#include <stdlib.h>
int system(const char *cmdstring)
参数:
cmdstring
:命令字符串(在 shell 中执行)
返回值:
- 如果
cmdstring
是一个空指针,则仅当命令处理程序可用时,system
返回非 0 值。- 这一特征可以确定在一个给定的操作系统上是否支持
system
函数。 - 在 UNIX 中,
system
总是可用的。
- 这一特征可以确定在一个给定的操作系统上是否支持
system
在其实现中调用了fork
、exec
和waitpid
,因此有 3 中返回值:fork
失败或者waitpid
返回除EINTR
之外的出错,则system
返回 -1,并且设置errno
以指示错误类型。- 如果
exec
失败(表示不能执行 shell),则其返回值如同 shell 执行了exit(127)
一样。 - 否则所有 3 个函数(
fork
、exec
和waitpid
)都成功,那么system
的返回值是 shell 的终止状态,其格式已在waitpid
中说明。
使用 system
而不是直接使用 fork
和 exec
的优点是:
system
进行了所需的各种出错处理以及各种信号处理。
如果一个进程正以特殊的权限(设置用户 ID 或设置组 ID)运行,它又想生成另一个进程执行另一个程序,则它应当直接使用 fork
和 exec
,而且在 fork
之后、exec
之前要更改回普通权限。设置用户 ID 或设置组 ID 程序绝不应调用 system
函数。
8.14 进程会计
大多数 UNIX 系统提供一个选项以进行进程会计(process accounting)处理。启动该选项后,每当进程结束时内核就写一个会计记录。
典型的会计记录包含总量较小的二进制数据,一般包括命令名、所使用的 CPU 时间总量、用户 ID 和组 ID、启动时间等。
任一标准都没有对进程会计进行过说明。于是,所有实现都有令人厌烦的差别。
函数 acct
启用和禁用进程会计。唯一使用这一函数的是 accton(8)
命令。超级用户执行一个带路径名参数的 accton
命令启动会计处理。执行不带任何参数的 accton
命令则停止会计处理。
会议记录结构定义在头文件 <sys/acct.h> 中,虽然每种系统的实现各不相同,但会计记录样式基本如下:
typedef u_short comp_t; /* 3-bit base 8 exponent; 13-bit fraction */
struct acct {
char ac_flag; /* flag (see Figure 8.26) */
char ac_stat; /* termination status (signal & core flag only) */
/* (Solaris only) */
uid_t ac_uid; /* real user ID */
gid_t ac_gid; /* real group ID */
dev_t ac_tty; /* controlling terminal */
time_t ac_btime; /* starting calendar time */
comp_t ac_utime; /* user CPU time */
comp_t ac_stime; /* system CPU time */
comp_t ac_etime; /* elapsed time */
comp_t ac_mem; /* average memory usage */
comp_t ac_io; /* bytes transferred (by read and write) */
/* "blocks" on BSD systems */
comp_t ac_rw; /* blocks read or written */
/* (not present on BSD systems) */
char ac_comm[8]; /* command name: [8] for Solaris, */
/* [10] for Mac OS X, [16] for FreeBSD, and */
/* [17] for Linux */
};
在大多数的平台上,时间是以时钟滴答数记录的,但 FreeBSD 以微妙进行记录。ac_flag
成员记录了进程执行期间的某些事件。这些事件见下图:
会计记录所需的各个数据(各 CPU 时间、传输的字符数等)都由内核保存在进程表中,并在一个新进程被创建时初始化(如 fork
之后在子进程中)。进程终止时写一个会计记录,这产生两个后果:
- 我们不能获取永远不终止的进程的会计记录。
- 像 init 这样的进程在系统生命周期中一直在运行,并不产生会计记录。
- 这也同样适用于内核守护进程,它们通常不会终止。
- 在会计文件中记录的顺序对应于进程终止的顺序,而不是它们启动的顺序。
- 为了确定启动顺序,需要读全部会计文件,并按启动日历时间进行排序。
- 这不是一种很完善的方法,因为日历时间的单位是秒,在一个给定的秒钟可能启动了多个进程。而墙上时钟时间的单位是时钟滴答(通常每秒滴答数在 60~128)。
- 但是我们并不知道进程的终止时间,所知道的只是启动时间和终止顺序。这就意味着,即使墙上时钟时间比启动时间要精确得多,仍不能按照会计文件中的数据重构各进程的精确启动顺序。
会计记录对应于进程而不是程序。在 fork
之后,内核为子进程初始化一个记录,而不是在一个新程序被执行时初始化。虽然 exec
并不创建一个新的会计记录,但相应记录中的命令名改变了,AFORK
标志则被清除。这意味着,如果一个进程顺序执行了 3 个程序(A exec
B、B exec
C,最后是 C exit
),只会写一个会计记录。在该记录中的命令名对应于程序 C,但 CPU 时间是程序 A、B 和 C 之和。
8.15 用户标识
任一进程都可以得到其实际用户 ID 和有效用户 ID 及组 ID。但是,我们有时希望找到运行该程序用户的登录名。系统通常记录用户登录时使用的名字,用 getlogin
函数可以获取此登录名。
#include <unistd.h>
char *getlogin(void);
// 返回值:若成功,返回指向登录名字符串的指针;若出错,返回 NULL
如果调用此函数的进程没有连接到用户登录时所用的终端,则函数会失败。通常称这些进程为守护进程(daemon)。
给出了登录名,就可用 getpwnam
在口令文件中查找用户的相应记录,从而确定其登录 shell 等。
8.16 进程调度
UNIX 系统历史上对进程提供的只是基于调度优先级的粗粒度的控制。调度策略和调度优先级是由内核确定的。进程可以通过调整友好值选择以更低优先级运行(通过调整友好值降低它对 CPU 的占有,因此该进程是“友好的”)。只有特权进程允许提高调度权限。
Single UNIX Specification 中友好值的范围在 0~(2*NZERO)-1 之间,有些实现支持 0~2*NZERO。友好值越小,优先级越高:你越友好,你的调度优先级就越低。NZERO 是系统默认的友好值。
进程可以通过 nice
函数获取或更改它的友好值。使用这个函数,进程只能影响自己的友好值,不能影响任何其他进程的友好值。
#include <unistd.h>
int nice(int incr);
// 返回值:若成功,返回新的友好值 NZERO;若出错,返回 -1
参数:
incr
:调用进程的友好值增加量。- 如果
incr
太大,系统直接把它降到最大合法值,不给出提示。 - 如果
incr
太小,系统也会把它提高到最小合法值,不给出提示。
- 如果
由于 -1 是合法的成功返回值,在调用 nice
函数之前需要清除 errno
,在 nice
函数返回 -1 时,需要检查它的值。
- 如果
nice
调用成功,并且返回值为 -1,那么errno
仍然为 0。 - 如果
errno
不为 0,说明nice
调用失败。
getpriority
函数可以像 nice
函数那样用于获取进程的友好值,但是 getpriority
还可以获取一组相关进程的友好值。
#include <sys/resource.h>
int getpriority(int which, id_t who);
// 返回值:若成功,返回 -NZERO~NZERO-1 之间的友好值;若出错,返回 -1
参数:
which
:控制who
参数是如何解释的,可以取以下三个值之一:PRIO_PROCESS
:进程PRIO_PGRP
:进程组PRIO_USER
:用户 ID
who
:选择感兴趣的一个或多个进程。- 如果
who
为 0,表示调用进程、进程组或者用户。 - 当
which
设为PRIO_USER
并且who
为 0 时,使用调用进程的实际用户 ID。 - 如果
which
作用与多个进程,则返回所有作用进程中优先级最高的(最小的友好值)。
- 如果
setpriority
函数可用于为进程、进程组和属于特定用户 ID 的所有进程设置优先级。
#include <sys/resource.h>
int setpriority(int which, id_t who, int value);
// 返回值:若成功,返回 0;若出错,返回 -1
参数:
which
:与getpriority
函数中相同。who
:与getpriority
函数中相同。value
:增加到NZERO
上,然后变为新的友好值。
8.17 进程时间
任一进程都可调用 times
函数获得它自己以及已终止子进程的墙上时钟时间、用户 CPU 时间和系统 CPU 时间:
#include <sys/times.h>
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
函数返回墙上时钟时间作为其函数值。此值是相对于过去的某一时刻度量的,所以不能用其绝对值而必须使用其相对值。 - 该结构中两个针对子进程的字段包含了此进程用
wait
函数族已等待到的各子进程的值。 - 所有由此函数返回的
clock_t
值都用_SC_CLK_TCK
(由sysconf
函数返回的每秒时钟滴答数)转换成秒数。
- 点赞
- 收藏
- 关注作者
评论(0)