文章目录
1、引言
2、进程标识
- 每个进程都用一个唯一的非负整数标识,即为进程id:pid。进程ID是可以复用的,当一个进程终止时,其进程ID就可以用来标识其他进程。
- 系统中有一些专用进程:
- 进程ID为0的是调度进程,也称交换进程(swapper),它并不执行任何磁盘上的程序,它是内核中的系统进程;
- 进程ID为1的是init进程,此进程负责在自举内核后启动一个UNIX系统,init进程绝不会终止,它是一个以超级用户特权运行的普通用户进程;init通常读取与系统有关的初始化文件(
/etc/rc*
文件或/etc/inittab
文件,以及在/etc/init.d
中的文件)并将系统引导到一个状态(如多用户)。此外,init是所有孤儿进程的父进程。 - 进程ID为2的是页守护进程,负责支持虚拟存储器系统的分页操作。
- 通过以下函数获取一个进程的pid等id信息:
注意:这些函数都没有出错返回。pid_t getpid(void); // 调用进程pid pid_t getppid(void); // 调用进程的父进程pid uid_t getuid(void); // 调用进程的实际用户id uid_t geteuid(void); // 调用进程的有效用户id gid_t getgid(void); // 调用进程的实际组id gid_t getegid(void); //调用进程的有效组id
3、函数fork
-
一个现有进程可以通过调用fork复刻一个新进程
pid_t fork(void);
- fork函数调用一次返回两次。对于子进程返回值是0;对于父进程返回值是子进程pid。子进程只能有一个父进程,通过getppid获得父进程pid。
- 子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本,子进程获得父进程的数据空间、堆和栈的副本。注意这是子进程所拥有的副本,父子进程并不共享这些存储空间部分。父进程和子进程共享正文段.text。
- 由于fork之后进程跟着exec,所以现在很多fork实现并不执行父进程数据段、堆和栈的完全副本,而是使用写时复制(copy-on-write)。这些区域由父子进程共享,并且内核将它们的访问权限修改为只读。如果父子进程中的一个试图修改这些区域,则内核只为修改区域的那块内存制作副本。
-
实例:演示了一个fork函数,可以看出子进程对变量所做的改变并不影响父进程中该变量的值
#include "apue.h" int globvar = 6; /* external variable in initialized data */ char buf[] = "a write to stdout\n"; int main(void) { int var; /* automatic variable on the stack */ pid_t pid; var = 88; /* 注意sizeof和strlen的区别: 1)strlen需要进行一次系统调用,它计算的是不包含终止null字节的字符串长度。 2)sizeof计算包含null字节的字符串长度,sizeof是在编译时计算缓冲区长度,也就是说它不需要进行系统调用。 */ if (write(STDOUT_FILENO, buf, sizeof(buf)-1) != sizeof(buf)-1) /*write函数是不带缓冲的,其数据写道标准输出一次*/ err_sys("write error"); /* printf属于标准I/O库,是带缓冲的。如果标准输出连接到终端设备,则它是行缓冲的,否则它是全缓冲的。 这一点会影响到冲洗数据的时机。 */ printf("before fork\n"); /* we don't flush stdout */ if ((pid = fork()) < 0) { err_sys("fork error"); } else if (pid == 0) { /* fork函数调用一次返回两次,若返回的是0,则为子进程 */ globvar++; /* 子进程对数据进行更改,通过命令行可以发现子进程的变量值改变了,而父进程没有 */ var++; } else { /* fork函数调用一次返回两次,若返回的是子进程的PID,结果>0,为父进程 */ sleep(2);/* 父进程是自己睡眠2s,目的是让子进程先执行,但并不能保证2s是足够的 */ } printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globvar, var); exit(0); }
命令行输出结果:
lh@LH_LINUX:~/桌面/apue.3e/proc$ ./fork1 a write to stdout before fork pid = 3638, glob = 7, var = 89 pid = 3637, glob = 6, var = 88 lh@LH_LINUX:~/桌面/apue.3e/proc$ ./fork1 > result.txt lh@LH_LINUX:~/桌面/apue.3e/proc$ cat result.txt a write to stdout before fork pid = 3650, glob = 7, var = 89 before fork pid = 3649, glob = 6, var = 88
- fork之后是父进程先执行还是子进程先执行是不确定的,取决于内核的调度算法。如果要求父进程和子进程之间相互同步,则要求某种形式的进程间通信。
- 根据命令行输出结果我们可以发现:
- 当以交互方式运行程序时,标准输出连接至终端设备,此时为行缓冲,标准输出缓冲区由换行符
\n
冲洗,最后只得到printf
输出的行一次。 - 当将标准输出重定向到一个文件时,此时为全缓冲,当调用
fork
后,该行数据仍在缓冲区,然后在将父进程数据空间复制到子进程中时,该缓冲区数据也被复制到子进程中,此时父进程和子进程各自有了带该行内容的缓冲区。后面的语句printf("pid = %ld, glob = %d, var = %d\n", (long)getpid(), globvar,var);
将数据追加到已有的缓冲区中。当每个进程终止时(exit()会执行标准I/O清理程序),其缓冲区的内容都被写到相应文件中。
- 当以交互方式运行程序时,标准输出连接至终端设备,此时为行缓冲,标准输出缓冲区由换行符
-
文件共享
- 父进程的所有打开文件描述符都被复制到子进程中,就好像执行了
dup
函数。父进程和子进程每个相同的打开描述符共享一个文件表项。如下图所示。
- 可以看出,fork之后的父子进程每个相同的打开描述符共享一个文件表项,因此也共享相同的文件偏移量(读写指针)。
- 除了打开文件之外,父进程的很多其他属性也由子进程继承
- 实际用户ID、实际组ID、有效用户ID、有效组ID
- 附属组ID
- 进程组ID
- 会话ID
- 控制终端
- 设置用户ID标志和设置组ID标志
- 当前工作目录
- 根目录
- 文件模式创建屏蔽字
- 信号屏蔽和安排
- 对任一打开文件描述符的执行时关闭标志(close-on-exec)
- 环境
- 连接的共享存储段
- 存储映像
- 资源限制
- 父子进程的区别
- fork返回值不同
- 进程ID不同
- 子进程的
tms_utime
、tms_stime
、tms_cutime
、tms_ustime
值设置为0 - 子进程不继承父进程设置的文件锁。
- 子进程的未处理闹钟被清除。
- 子进程的未处理信号集设置为空集。
- 父进程的所有打开文件描述符都被复制到子进程中,就好像执行了
-
fork失败原因
- 系统中已经有了太多的进程
- 该实际用户ID的进程总数超过了系统限制
-
fork常用以下方法
- 一个父进程希望复刻自己,使父进程和子进程同时执行不同的代码段。在网络服务器中较为常见:父进程等待客户端的服务请求,请求到达时fork使子进程处理此请求,父进程则继续等待下一个服务请求。
- 一个进程要执行一个不同的程序,shell常使用这种方式。子进程从fork返回后立即调用exec。有些操作系统将fork之后立刻exec组合成一个操作:spawn,但是UNIX将这两个操作分开,因为子进程可以在fork和exec之间更改自己的属性。
4、函数vfork(不推荐使用)
- vfork函数调用方式和fork相同,但语义不同。现在不应该使用这个函数
- vfork用于创建一个新进程,而该新进程的目的就是exec一个新程序。但是它不会将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或exit)。不过在子进程exec或exit之前,它在父进程的空间中运行。这种方式提高了效率,但是如果子进程修改了数据(子进程修改数据会影响到父进程,因为共享存储空间)、进行函数调用、或者没有调用exec或exit就返回可能会带来未知结果。
- 并且vfork保证子进程先运行,在子进程调用exec或exit之后父进程才可能被调度运行。即子进程调用这两个函数中的任意一个时,父进程会恢复运行。(如果在调用这两个函数之前子进程依赖于父进程的进一步操作,会导致死锁)。
- 实例:vfork函数的使用
命令行输出int main(int argc, char* argv[]) { int num = 1; if(vfork() == 0) { // 子进程 cout << "子进程执行" << endl; num ++; cout << "子进程终止" << endl; _exit(0); } else { // 父进程 cout << "父进程执行" << endl; cout << "num : " << num << endl; cout << "父进程终止" << endl; } }
可以看出子进程修改变量值影响了父进程中该变量,这是因为父子进程共享存储空间。$ ./a.out > 子进程执行 > 子进程终止 > 父进程执行 > num : 2 > 父进程终止
5、函数exit
-
进程有5种正常终止和3种异常终止。其中正常终止:
- main函数内执行return语句,等效于调用exit
- 调用exit函数,此操作调用终止处理程序(atexit登记的函数),关闭所有标准I/O流,然后调用_exit
- 调用_exit或_Exit。其操作提供一种无需运行终止处理程序或信号处理程序而终止的方法。(exit是标准C库中的一个函数,_exit是一个系统调用)
- 进程的最后一个线程在启动例程中执行return语句。但是,该线程返回值不用做进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态0返回
- 进程的最后一个线程调用pthread_exit函数。此时进程终止状态总是0,这与传给pthread_exit参数无关
-
3种异常终止:
- 调用abort。它产生SIGABRT,这是下一种异常终止的特例
- 当进程接到某种信号时。信号可由进程自身(如调用abort函数)、其他进程或内核产生。
- 最后一个线程对“取消”请求做出响应。默认情况下“取消”以延迟方式发生:一个线程要求取消另一个线程,若干时间之后,目标线程终止。
-
不管进程是以何种方式终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开标识符,释放它所使用的存储器等。
-
希望子进程能够通知其父进程它是如何终止的。可以通过将退出状态作为参数传递给三个终止函数(exit、_exit、_Exit)。如果是异常终止,则不再由这三个函数的参数决定其终止状态,而是内核(不是进程本身)产生一个表明其异常终止原因的终止状态。 在任何情况下,父进程都能够通过wait或waitpid取得其终止状态。
-
退出状态和终止状态区别:
- 退出状态是传递给三个终止函数的参数,或main的返回值。
- 在调用_exit时,内核将退出状态转换成终止状态。
-
如果父进程在子进程之前终止,那么这些子进程的父进程会改变为init进程:当一个进程终止时,内核逐个检查所有活动进程,以判断他是否是正要终止进程的子进程,如果是,则该进程的父进程ID改为1,保证了每个进程都有一个父进程。
-
内核为每个终止子进程保存了一定量的信息,父进程调用wait或waitpid时可以得到这些信息(至少包括进程ID、该进程的终止状态、该进程使用的CPU时间总量)。一个已经终止,但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息并释放它仍占用的资源)的进程被称为僵尸进程。 即没有被父进程wait的终止子进程都是僵尸进程。
-
一个进程在终止时会关闭所有文件描述符,释放在用户空间分配的内存,但它的PCB还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个等信息。这个进程的父进程可以调用wait或waitpid获取这些信息,然后彻底清除掉这个进程。
-
被init进程收养的进程终止不会变成僵尸进程:只要有一个进程终止,init进程就会调用一个wait函数取得其终止状态,防止了系统中塞满僵尸进程。
6、函数wait和waitpid
-
当一个进程终止(无论正常异常),内核会向其父进程发送SIGCHLD信号(对这种信号的系统默认动作是忽略它)
-
调用
wait
或waitpid
会发生- 如果其所有子进程都还在运行,则函数阻塞
- 如果一个子进程已经终止,正等待父进程获取其终止状态,则取得该子进程终止状态立即返回。
- 如果它没有任何子进程,立即出错返回
- 如果进程由于接到
SIGCHLD
信号而调用wait
,那么期望wait
会立即返回。但是如果在随机事件调用wait
,则进程可能会阻塞。
pid_t wait(int *status); pid_t waitpid(pid_t pid, int *status, int options); //成功返回子进程PID,出错返回0、-1
-
wait
和waitpid
区别- 在一个子进程终止前,
wait
使其调用者阻塞,而waitpid
可以通过选项使调用者不阻塞。 waitpid
可以通过选项控制它所等待的进程是哪个或哪些
- 在一个子进程终止前,
-
这两个函数的参数
status
如果不为空,则会将终止进程的终止状态保存在其中。 -
注意,这个
status
保存的终止状态是多个信息的集合。其中某些位表示退出状态(正常返回),有些位表示信号编号(异常返回),有一位指示是否产生core
文件等。可以通过以下宏查看该返回的整形状态字。宏 说明 WIFEXITED(status) 若为正常终止,则为真。这种情况下可以通过WEXITSTATUS(status)宏获取子进程传给exit或_exit参数的低8位 WIFSIGNALED(status) 若为异常终止(收到一个不捕捉的信号),则为真。可以通过WTERMSIG(status)获取使子进程终止的信号编号。另外有些实现定义了WCOREDUMP(status)宏,返回是否产生终止进程的core文件 WIFSTOPPED(status) 若为当前暂停子进程的返回状态则为真,这种情况下可以通过WSTOPSIG(status)获取使子进程暂停的信号编号 WIFCONTINUED(status) 若在作业控制暂停后已经继续的子进程返回了状态,则为真 -
实例:函数
pr_exit
使用上表的宏打印进程状态的说明。void pr_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", WTERMSIG(status), #ifdef WCOREDUMP WCOREDUMP(status) ? " (core file generated)" : ""); #else ""); #endif else if(WIFSTOPPED(status)) printf("child stopped, signal number = %d\n", WSTOPSIG(status)); }
-
实例:调用
pr_exit
函数,演示终止状态的各种值#include "apue.h" #include <sys/wait.h> int main(void) { pid_t pid; int status; if ((pid = fork()) < 0) err_sys("fork error"); else if (pid == 0) /* child */ exit(7); if (wait(&status) != pid) /* wait for child */ err_sys("wait error"); pr_exit(status); /* and print its status */ if ((pid = fork()) < 0) err_sys("fork error"); else if (pid == 0) /* child */ abort(); /* generates SIGABRT */ if (wait(&status) != pid) /* wait for child */ err_sys("wait error"); pr_exit(status); /* and print its status */ if ((pid = fork()) < 0) err_sys("fork error"); else if (pid == 0) /* child */ status /= 0; /* divide by 0 generates SIGFPE */ if (wait(&status) != pid) /* wait for child */ err_sys("wait error"); pr_exit(status); /* and print its status */ exit(0); }
在命令行中执行得到:
lh@LH_LINUX:~/桌面/apue.3e/proc$ ./wait1 normal termination, exit status = 7 abnormal termination, signal number = 6 (core file generated) abnormal termination, signal number = 8 (core file generated)
可以通过查看
<signal.h>
头文件验证SIGABRT
的值为6
,SIGFPE
的值为8
-
任何一个子进程终止都会导致
wait
函数返回,但是有时候我们希望获取指定子进程的终止状态。waitpid
通过其pid
参数完成此功能:pid==-1
:等待任一子进程,waitpid=wait
pid>0
:等待进程id
为pid
的子进程pid==0
:等待组id
等于调用进程组id的任一子进程pid<-1
:等待组id
等于pid
绝对值的任一子进程
-
参数
options
能进一步控制waitpid
的操作,它是0
或者以下宏的按位或WCONTINUED
:若支持作业控制,那么由pid指定的任一子进程在停止后已经继续,但其状态尚未报告,则返回其状态WNOHANG
:若子进程还未终止,则waitpid不阻塞返回0WUNTRACED
:若支持作业控制,而由pid指定的任一子进程已处于停止状态,并且其状态自停止以来还未报告过,则返回其状态。
#include "apue.h" #include <sys/wait.h> int main(void) { pid_t pid; if ((pid = fork()) < 0) { err_sys("fork error"); } else if (pid == 0) { /* first child */ if ((pid = fork()) < 0) err_sys("fork error"); else if (pid > 0) exit(0); /* parent from second fork == first child */ /* * We're the second child; our parent becomes init as soon * as our real parent calls exit() in the statement above. * Here's where we'd continue executing, knowing that when * we're done, init will reap our status. */ sleep(2);//调用sleep保证在打印父进程ID时第一个子进程已经终止 /*第二个子进程的父进程原本是第一个子进程,但是第一个子进程已经终止,所以该进程已经变成了孤儿进程,被init进程收养,其ID为1*/ printf("second child, parent pid = %ld\n", (long)getppid()); exit(0); } if (waitpid(pid, NULL, 0) != pid) /* wait for first child */ err_sys("waitpid error"); /* * We're the parent (the original process); we continue executing, * knowing that we're not the parent of the second child. */ exit(0); }
在命令行中执行得到:
lh@LH_LINUX:~/桌面/apue.3e/proc$ ./fork2 lh@LH_LINUX:~/桌面/apue.3e/proc$ second child, parent pid = 1584
7、waitid函数
- 此函数类似于
waitpid
,但是提供了更多的灵活性int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
waitid
也允许等待指定的子进程,但是它使用两个单独的参数表示要等待的子进程类型(不像waitpid
函数只用一个参数表示)。其中idtype
指定子进程类型,id
则指定id
数值- 其中
idtype
参数值:P_PID
:等待指定进程,id指定要等待的子进程pidP_PGID
:等待特定进程组的任一子进程,id指定进程组idP_ALL
:等待任一子进程,忽略id
options
参数是以下标志按位或运算,其中必须包括WCONTINUED
/WEXITED
/WSTOPPED
之一WCONTINUED
:若支持作业控制,那么由pid指定的任一子进程在停止后已经继续,但其状态尚未报告,则返回其状态WEXITED
:等待已经退出的进程WNOHANG
:若子进程还未终止,则waitpid不阻塞立即返回WNOWAIT
:不破坏子进程退出状态,该子进程可由后续的wait、waitid或waitpid调用取得WSTOPPED
:若支持作业控制,该子进程已处于停止状态,并且其状态自停止以来还未报告过,则返回其状态。
infop
参数指向siginfo
结构体,该结构包含了造成子进程状态改变有关信号的详细信息。
- 其中
8、函数wait3和wait4
- 也可以通过这两个函数获取子进程终止状态。它们提供的功能比
wait
、waitpid
、waitid
多一个。这两个函数的一个参数允许内核返回终止进程及其所有子进程使用资源概况(shell
就是用wait4
函数获取终止子进程信息)。
些使用资源包括用户pid_t wait3(int *wstatus, int options, struct rusage *rusage); pid_t wait4(pid_t pid, int *wstatus, int options, struct rusage *rusage);
CPU
时间总量、系统CPU
时间总量、缺页次数、接收到信号的次数等。
9、竞争条件
-
当多个进程都企图对共享数据进行处理,而最后的处理结果又取决于进程运行的顺序时,我们认为发生了竞态条件。
-
如果一个进程希望等待一个子进程终止,则它必须调用wait函数中的一个。如果一个进程要等待其父进程终止,则可以使用下列形式循环:
while(getppid() != 1) { //若父进程为init进程则说明原本父进程终止了 sleep(1); }
这种形式的循环为轮询,它浪费了CPU时间,因为调用进程每隔
1s
被唤醒然后进行条件测试。 -
为了避免竞争条件和轮询,多个进程之间可以通过信号的方式解决。或者使用各种形式的进程间通信(IPC)。
-
实例1:产生了竞争条件的例子
#include "apue.h" 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); }
命令行输出:
lh@LH_LINUX:~/桌面/apue.3e/proc$ ./tellwait1 output from parent output from child lh@LH_LINUX:~/桌面/apue.3e/proc$ ./tellwait1 output from poaurtepnutt from child lh@LH_LINUX:~/桌面/apue.3e/proc$ ./tellwait1 outputo furtopmu tp afrreomn tc hild
可以看到,这里尝试了多3次,后面两次可以很明显地看出产生了竞争,当然竞争的结果是不确定,也可能在输出结果上无法看出父进程输出与子进程输出产生了竞争。
-
实例2:排除了竞争条件的例子
#include "apue.h" static void charatatime(char *); int main(void) { pid_t pid; TELL_WAIT(); if ((pid = fork()) < 0) { err_sys("fork error"); /* 这不就是生产者-消费者模型么,下面演示的是父进程由于子进程优先的情况,当然也可模拟倒转过来的情况 */ } else if (pid == 0) { WAIT_PARENT(); /* parent goes first */ charatatime("output from child\n"); } else { charatatime("output from parent\n"); TELL_CHILD(pid); /* child gose second */ } 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); }
运行该程序可以保证得到预期的输出,并且两个进程的输出不再交叉混合。
10、exec函数
- 当使用fork创建新的子进程后,如果要执行其他程序则需要调用exec函数。exec将该进程执行的程序完全替换为新程序,并且从main函数开始执行。因为exec并不创建新进程,所以前后的pid不变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆和栈。
exec
是一个函数集合,包括了七个函数/*这7个函数只有execve是内核的系统调用,另外6个只是库函数*/ int execl(const char *path, const char *arg, .../* (char *) NULL */); int execv(const char *path, char *const argv[]); int execle(const char *path, const char *arg, .../*, (char *) NULL, char * const envp[] */); int execve(const char *path, char *const argv[], char *const envp[]); int execlp(const char *file, const char *arg, .../* (char *) NULL */); int execvp(const char *file, char *const argv[]); int fexecve(int fd, char *const argv[], char *const envp[]); //这七个函数出错返回-1,成功不返回
- 这些函数区别:
- 前四个函数取路径名为参数,后两个函数则取文件名作为参数,最后一个取文件描述符为参数。
- 当指定路径名
path
为参数时,替换执行的程序为路径所表示的程序文件 - 当指定文件名
file
作为参数时,若file中带有/
,则将其视为路径名(不在PATH
中寻找);若仅仅是文件名,则按PATH
环境变量在它所指定的各目录中搜索可执行文件。- 注意,如果要运行
/…/a/b/x.out
可执行文件,并且PATH=/…/a/
,当输入x.out
无法在PATH
中找到该文件(因为/…/a/
中没有x.out
文件,也不会递归查找/…/a/
的子目录下的文件);输入b/x.out
则根本不尝试在PATH
中查找,而是以当前工作目录作为相对路径的起始路径,在该相对路径下查找x.out
文件。这也是为什么file
参数中带有/
不会在PATH
中寻找而是直接视为路径名的原因。
- 注意,如果要运行
- 这一点也可以从函数名看出,带
p
的函数名都是使用文件名作为参数,并在PATH
中寻找该文件的。 - 当使用
PATH
路径前缀中找到了一个可执行文件,但是该文件不是由连接编辑器产生的机器可执行文件,则认为该文件是一个shell
脚本,于是试着调用/bin/sh
,并以该文件名作为shell
的输入。
- 当指定路径名
- 第二个区别是与参数表的传递有关,从函数名可以看出来。
l
表示列表list
,v
表示矢量vector
。- 使用
l
的exec
函数要求将每个命令行参数用作一个单独的参数,这些参数以NULL
结尾 - 使用
v
的exec
函数则应先构造一个指向各参数的指针数组,然后将这个指针数组地址用作函数参数。
- 使用
- 第三个区别于向新程序传递环境表有关。
- 以
e
结尾的exec
函数可以传递一个指向环境字符串指针数组的指针(即新程序的环境表设置为该参数指定的环境表) - 没有
e
的则使用调用进程中的environ
变量为新程序复制现有环境,因此新程序与调用进程有相同环境表
- 以
- 前四个函数取路径名为参数,后两个函数则取文件名作为参数,最后一个取文件描述符为参数。
- 一些补充
PATH
变量包含了一张目录表(称为路径前缀),目录之间用冒号(:)分隔。例如,下列name=value
环境字符串指定在4个目录进行搜索。(注意回忆全局变量environ
和PATH
变量的关系)
最后的后缀PATH=/bin:/usr/bin:/usr/local/bin:.
.
表示当前目录。(零长前缀也表示当前目录。在value
的开始处可以用:
表示,在和那个中间则要用::
表示,在行尾以:
表示)- 在使用
ISO C
原型之前,对execl
、execle
和execlp
三个函数表示命令行参数的一般方法是:
这种语法显示地说明了最后一个命令行参数为空指针。如果用常量char *pathname,char *arg0,...,char *argn,(char*)0
0
来表示一个空指针,则必须将它转换为一个指针;否则它将被解释为整形参数。
- 执行exec后,进程ID没有改变。新程序从调用进程继承了下列属性
- 进程pid和父进程pid
- 实际用户ID和实际组ID
- 附属组ID
- 进程组ID
- 会话ID
- 控制终端
- 闹钟尚余留的时间
- 当前工作目录
- 根目录
- 文件模式创建屏蔽字
- 文件锁
- 进程信号屏蔽
- 未处理信号
- 资源限制
- 友好值
- tms_utime、tms_stime、tms_cutime、tms_cstime
- 对打开文件的处理和文件描述符标志
close-on-exec
有关。若设置了该标志,则在执行exec
时关闭该描述符,否则该描述符仍打开。除非调用fcntl
设置了该标志,否则系统默认操作是exec
后仍然保持这种描述符打开。 exec
会关闭打开的目录流(即通过opendir
函数打开的目录文件),这种操作其实是opendir
自己实现的,opendir
会调用fcntl
设置打开的目录文件的文件描述符标志位close-on-exec
。- 有效ID是否改变取决于所执行的新程序文件的设置用户ID位和设置组ID位。如果新程序的设置用户ID位已设置,则有效用户ID变成新程序文件的所有者ID,否则有效用户ID继承不变。对组ID的处理方式相同。
- 在很多UNIX实现中,这7个函数只有execve是内核的系统调用,另外6个只是库函数,它们最终都会调用该系统调用。这7个函数之间的关系如图所示。(特别重要!!!会这张图才证明理解透彻了!!!)
fexecve
会把文件描述符参数转换成路径名,然后用execve用该路径名去执行程序。- 通过表格对照区分七个函数的区别
- 实例:演示exec函数,这里使用了
execle
和execlp
函数#include "apue.h" #include <sys/wait.h> char *env_init[] = { "USER=unknown", "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", "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 */ /* 要求文件名、参数表和特定的环境,该函数能够调用是因为目录/home/sar/bin是当前路径前缀之一 */ if (execlp("echoall", "echoall", "only 1 arg", (char *)0) < 0) err_sys("execlp error"); } exit(0); }
-
注意,我们将第一个参数(新程序中的
argv[0]
)设置为路径名的文件名分量。某些shell
习惯将此参数设置为完全的路径名,当然这只是惯例而已,不是必须要这样。当login
命令执行shell的时候,login在argv[0]之前加一个/
作为前缀,这向shell指明它是作为登录shell被调用的。登录shell将执行启动配置文件(start-up profile)
命令,而非登录shell不会执行这些命令。 -
下面是上面程序需要执行的
echoall
程序#include "apue.h" int main(int argc, char *argv[]) { int i; char **ptr; extern char **environ; for (i = 0; i < argc; i++) /* echo all command-line args */ printf("argv[%d]: %s\n", i, argv[i]); for (ptr = environ; *ptr != 0; ptr++) /* and all env strings */ printf("%s\n", *ptr); exit(0); }
-
执行实例程序可以得到
$ ./a.out argv[0]:echoall argv[1]:myarg1 argv[2]:MY ARG2 USER=unknown PATH=/tmp argv[0]:echoall $ argv[1]:only 1 arg SHELL=/bin/bash ...还有很多没有列举 HOME=/home/sar
-
补充A:shell运行程序流程
- shell运行程序流程入下
- 读取用户由键盘输入的命令行。
- 分析命令,以命令名作为文件名,并将其它参数改造为系统调用execve()内部处理所要求的形式。
- 终端进程调用
fork()
建立一个子进程。 - 终端进程本身用系统调用
wait4()
来等待子进程完成(如果是后台命令,则不等待)。当子进程运行时调用execve(),子进程根据文件名(即命令名)到目录中查找有关文件(这是命令解释程序构成的文件),将它调入内存,执行这个程序(解释这条命令)。 - 如果命令末尾有
&
号(后台命令符号),则终端进程不用系统调用wait4( )等待,立即发提示符,让用户输入下一个命令,转⑴
。如果命令末尾没有&
号,则终端进程要一直等待,当子进程(即运行命令的进程)完成处理后终止,向父进程(终端进程)报告,此时终端进程醒来,在做必要的判别等工作后,终端进程发提示符,让用户输入新的命令,重复上述处理过程。
- shell如何启动?
- shell是用户和Linux内核之间的接口程序,为用户提供使用操作系统的接口。它接收用户命令,然后调用相应的应用程序
- shell在你成功地登录进入系统后启动,并始终作为你与系统内核的交互手段直至你退出系统
11、更改用户ID和更改组ID
-
什么是有效用户id、实际用户id、设置用户id和保存的设置用户id?
- 实际用户id(RUID,进程特有的概念): 在开机时,你输入的账号的对应id就是实际用户uid,说白了就是登录号,站在用户的角度上看。
- 有效用户id(EUID,进程特有的概念): 一般和RUID相同,站在操作系统的角度上看,用于给操作系统判断某个进程是否拥有操作某个文件的权限。
- 设置用户id(SUID,set user id,文件特有的概念): 站在文件的角度上看,文件权限有r-w-x-s-t类型,其中的s表示SUID是打开状态(即可用状态)。如果一个文件的权限位里面没有s,则表示SUID是关闭状态(即不可用状态)。注意:SUID只能用于可执行文件,其作用是修改EUID(有效用户id)
- 保存的设置用户id(SSUID,saved SUID,《Unix高级环境编程 第3版》的8.11节里面定义的,进程特有的概念),也有博客称为保留的设置用户id: EUID(有效用户id)的备份,既然是备份,则应该是恢复的作用。SSUID由linux的
exec()
函数保存,可以把SSUID理解为exec()
函数里面的一个局部变量。exec()
的执行逻辑大概如下(自己猜测的,为了好理解SSUID的作用):// 这里假设 "可执行文件"为/etc/passwd, 该文件的权限位-rwsr-xr-x,文件的拥有者是root void exec("可执行文件") { SSUID = EID; 进程的EID = 可执行文件用户(即root用户); 执行"可执行文件"; //此时调用本函数的进程已经拥有root权限了 EID = SSUID; }
- 文件特有的概念
- FUID(file uid): 文件的拥有者
- FGID(file gid): 文件的拥有者所在组的组id
- 设置用户id(SUID,set user id,文件特有的概念): 站在文件的角度上看,文件权限有
r-w-x-s-t
类型,其中的s
表示SUID
是打开状态(即可用状态)。如果一个文件的权限位里面没有s
,则表示SUID
是关闭状态(即不可用状态)。注意:SUID
只能用于可执行文件,其作用是修改EUID
(有效用户id)
- 总结:
RUID
、EUID
、SSUID
都是针对进程的,即站在进程的角度,即进程特有的概念。而SUID
则是针对文件的,即站在文件的角度,或者说是SUID是可执行文件特有的概念!为什么要修改进程的EUID?目的是想让进程在某一时刻能够拥有特殊的权限。
-
我们总是使用最小特权模型:我们的程序应当只具有为完成给定任务所需的最小特权。
-
可以通过setuid和setgid函数设置进程相关的用户ID和组ID
int setuid(uid_t uid); int setgid(gid_t gid);
- 若进程具有超级用户特权,则
setuid
函数将实际用户id
、有效用户id
、保存的设置用户id
设置为uid
。 - 若进程没有超级用户权限,但是
uid
等于实际用户id
或保存的设置用户id
,则setuid
只将有效用户设为uid
,不更改实际用户id和保存的设置用户id
- 如果以上条件不满足则出错返回
-1
并设置errno
- 若进程具有超级用户特权,则
-
下图是更改3个用户ID的不同方法,同样适用于组
ID
-
也可以通过
seteuid
和setegid
,它们类似于setuid
和setgid
,但是只更改有效用户ID和有效组ID。int seteuid(uid_t euid); int setegid(gid_t egid);
- 如果是特权用户,则可以将有效用户id设置为参数uid
- 如果不是特权用户,但是参数uid等于实际用户id或保存的设置用户id,则可以将有效用户id设置为参数uid。
-
以上的操作都适用于组ID。但是附属组ID不受这些函数的影响。
12、解释器文件
- 解释器文件是文本文件,其起始行的形式是
比如#! pathname [可选项]
#! /bin/sh
pathname
通常是绝对路径名,对它不进行特殊处理(不适用PATH搜索)- 内核使调用
exec
函数的进程实际执行的并不是该解释器文件,而是在该解释器文件第一行中pathname
所指定的文件(即解释器)。要将解释器文件(以#!开头的文件)和解释器(pathname表示的文件)区分开 - 当用
exec
函数执行一个解释器文件时,实际执行的是解释器文件第一行所指定的解释器。并将第一行中的可选项和exec
函数中的命令行参数作为该解释器的命令行参数。但是注意,对于exec
函数中的第一个命令行参数,会改变成exec
函数中的第一个参数pathname
,该参数告诉了解释器要解释的文件是哪一个。如以下解释器文件/a/b/c/test
在一个进程中调用exec函数#! /home/bin/echoarg -f ...
那么传递给解释器execl("/a/b/c/test","test","arg1","arg2",(char*)0);
echoarg
的命令行参数分别是/home/bin/echoarg
(解释器的pathname)、-f
(解释器的可选参数)、/a/b/c/test
(excel中的pathname,注意不是argv[0])、arg1
(excel中的argv[1])、arg2
(excel中的argv[2])。相当于在shell中输入:
然后解释器来解释该文件,由于/home/bin/echoarg -f /a/b/c/test arg1 arg2
#
是注释字符,因此解释器忽略该文件第一行。之后逐行解释文件内容。
- 使用解释器文件的优点
- 有些程序是用某种语言写的脚本,解释器文件可将这一事实隐藏起来
- 解释器文件效率较好
- 解释器文件使我们可以使用除了
/bin/sh
以外的其他shell
来写shell
脚本。当exec
函数找到一个非机器可执行的可执行文件时,总是调用/bin/sh
来解释执行该文件。但是解释器文件可以根据第一行内容来指定想要的解释器,不一定是/bin/sh
了。
13、函数system
- 可以在进程中通过
system
函数执行命令字符串。但是依赖于操作系统,有些操作系统可能不支持system
执行命令字符串。
如果int system(const char *command);
command
是一个空指针,那么当该函数可用时返回非0
值。可以通过这种方式测试一个给定操作系统是否支持system
函数。UNIX
中system
总是可用 - system函数的实现本质上调用了fork、exec、waitpid:
-
fork失败或waitpid返回除EINTR之外的错误,返回-1
- EINTR:如果进程在一个慢系统调用(slow system call,如waitpid)中阻塞时,当捕获到某个信号且相应信号处理函数返回时,这个系统调用被中断,调用返回错误,设置errno为EINTR
-
exec失败(即不能执行shell命令),则其返回值如同shell执行了_exit(127)
-
如果三个函数都成功,那么system返回值是shell的终止状态。shell的终止状态是其执行最后一条命令的退出状态。这种情况下和获取子进程的退出状态一样。(可以用wait函数那一节中的几个宏来判断子进程退出状态如
WIFEXITED
、WIFSIGNALED
)。
-
- 实例:system函数的一种实现(注意它对信号没有处理)
#include <sys/wait.h> #include <errno.h> #include <unistd.h> 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
命令。例如,可以使用<
和>
对输出和输出进行重定向。 - 实例:对上述
system
函数进行测试(注意:pr_exit
函数在之前的小节有定义)
命令行得到#include "apue.h" #include <sys/wait.h> 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); }
lh@LH_LINUX:~/桌面/apue.3e/proc$ ./systest1 2023年 07月 14日 星期五 22:29:21 CST normal termination, exit status = 0 sh: 1: nosuchcommand: not found normal termination, exit status = 127 lh tty7 2023-07-14 21:59 (:0) normal termination, exit status = 44
- 使用
system
函数而不是直接使用fork
和exec
的原因是:system
进行了所需的各种出错处理以及各种信号处理 - 一个安全漏洞:
- 注意,不能在设置用户ID程序中调用system函数。设置组ID程序同理。 即一个文件有设置用户
ID
位,那么当执行此文件时,该进程中不能调用system
函数。因为由于该文件有设置用户ID
位,因此该进程的有效用户ID
将会被设置为文件所有者ID
。但是如果在该进程中又system了其他命令,那么子进程有可能继承父进程的有效用户ID
。 - 当一个进程生成另一个进程时,一定要保证其最小权限原则,防止权限被不断地传递复制。
- 因此如果一个进程正在以特殊的权限运行(由于设置用户ID位或设置组ID位造成),它又想生成另一个进程执行另一个程序,则它不能使用system而应该使用fork和exec,并且在fork之后、exec之前更改回普通权限,防止特殊权限在exec之后传递下去。
- 注意,不能在设置用户ID程序中调用system函数。设置组ID程序同理。 即一个文件有设置用户
补充B:sysconf函数
- 我们可以通过函数参数,来获取系统配置参数值。比如获取cpu核数,页大小,最大页数,cpu个数等。
如果成功返回指定参数值,如果出错,那么函数返回long sysconf(int name);
-1
,并适当地设置errno
。当没有错误发生时,-1
也是一个合法的返回值。因此,程序要检查错误,应该在调用sysconf()
之前将errno
设置为0
,然后如果返回-1
,则检验errno
判断是否出错。 - 一些典型的参数值
- _SC_HOST_NAME_MAX: 主机名的最大长度,不包括终止空字节,由 gethostname(2) 返回。
- _SC_OPEN_MAX:进程可以随时打开的最大文件数
- _SC_PAGESIZE:页的大小(以字节为单位)
- _SC_NGROUPS_MAX:附属组 ID 的最大数量。
- 等等
int main(int argc, char* argv[]) { cout << "主机名最大长度:" << sysconf(_SC_HOST_NAME_MAX) << endl; cout << "页的大小:" << sysconf(_SC_PAGE_SIZE) << endl; } /*输出 主机名最大长度:64 页的大小:4096 */
- 注意:有时候可以不用
sysconf
函数获取系统配置参数值,而是直接获取其值。通常sysconf
参数去掉开头的_SC_
剩下的部分就是它要查询的系统参数值。如_SC_HOST_NAME_MAX
就是指的HOST_NAME_MAX
系统配置参数。但是有些时候这些系统配置参数名并没有定义,因此可以使用以下方法:#ifdef HOST_NAME_MAX cout << "主机名最大长度:" << HOST_NAME_MAX << endl ; #elif defined(_SC_HOST_NAME_MAX) cout << "主机名最大长度:" << sysconf(_SC_HOST_NAME_MAX) << endl; #endif
14、进程会计
- 启动进程会计后,每当进程结束时内核就会写一个会计记录。该记录包括命令名、所使用的CPU时间总量,用户ID和组ID,启动时间等。其本质是
accton
命令调用acct
函数来完成的。 - 启动和关闭进程会计:
- 超级用户执行一个带路径名参数的
accton
命令会启用进程会计,内核会将会计记录写到指定的文件中。如accton outfile
- 超级用户执行不带任何参数的
accton
命令则停止进程会计,如accton
- 超级用户执行一个带路径名参数的
- 会计记录文件中的每个记录项结构基本如下:
typedef uint16_t comp_t; struct acct { char ac_flag; /* Flags. */ uint16_t ac_uid; /* Real user ID. */ uint16_t ac_gid; /* Real group ID. */ uint16_t ac_tty; /* Controlling terminal. */ uint32_t ac_btime; /* Beginning time. */ comp_t ac_utime; /* User time. */ comp_t ac_stime; /* System time. */ comp_t ac_etime; /* Elapsed time. */ comp_t ac_mem; /* Average memory usage. */ comp_t ac_io; /* Chars transferred. */ comp_t ac_rw; /* Blocks read or written. */ comp_t ac_minflt; /* Minor pagefaults. */ comp_t ac_majflt; /* Major pagefaults. */ comp_t ac_swaps; /* Number of swaps. */ uint32_t ac_exitcode; /* Process exitcode. */ char ac_comm[ACCT_COMM+1]; /* Command name. */ char ac_pad[10]; /* Padding bytes. */ };
- 其中ac_flag成员记录了进程执行期间的某些事件:可以将ac_flag成员与下面常量按位与进行判断时是否发生这些事件
- 什么是核心转储?
核心转储core dump
- 什么是核心转储?
- 会计记录所需要的各个数据都由内核保存在PCB中,并在一个新进程被创建时初始化一个记录(如fork后在子进程中)。进程终止时写一个会计记录
- 我们无法获取永远不终止的进程的会计记录。如init进程这种一直运行的进程,一些内核守护进程的会计记录也无法获得。
- 在会计文件中记录的是顺序是进程终止的顺序,而不是它们启动顺序。只能通过记录项中的启动日历时间来确定进程启动顺序。
- 会计记录对应的是进程而不是程序。因此
fork
之后内核为子进程初始化一个记录,而不是在exec
一个新程序时执行初始化。虽然exec
并不创建一个新的会计记录,但相应记录中的命令名变了,AFORK
标志清除。比如一个进程中顺序执行了三个程序(A exec B,B exec C,然后C exit),那么只会写一个会计记录,该记录中命令名对应程序C,但是CPU时间是程序A/B/C之和。 - 会计记录时间中的进程时间都是以时钟滴答数量计算的。因此如果要获取进程时间值(秒数)需要用时钟滴答数除以每秒钟时钟滴答数
sysconf(_SC_CLK_TCK)
。见第一章内容
15、用户标识
-
可通过getlogin获取运行该进程的用户登录时用的登录名
char *getlogin(void);
16、进程调度
- UNIX对进程提供的只是基于调度优先级的粗粒度的控制。调度策略和调度优先级由内核确定。进程可以通过增加nice友好值选择以更低优先级运行(通过增加nice值来降低它对CPU的占有,因此该进程是友好的)。只有特权进程允许提高调度权限(即降低nice值)
int nice(int inc); // 成功返回新的友好值
- 参数inc是该进程的友好值增加量,不是绝对量。
- nice值越小优先级越高,nice值越大优先级越小。友好值的最大值NZERO,可以通过
sysconf(_SC_NZERO)
获取NZERO
值。Linux中nice值的有效范围从-20
到19
(即NZERO
值为20
),而且默认值是0
- 进程只能通过
nice
函数影响它自己和子进程的nice
值(即子进程会继承父进程友好值)
- 可以通过
getpriority
/setpriority
函数获取/设置指定进程或一组进程的nice值int getpriority(int which, id_t who); int setpriority(int which, id_t who, int prio);
getpriority
:可用来取得进程、进程组和指定用户的进程的nice值which
参数PRIO_PROCESS
表示进程PRIO_PGRP
表示进程组PRIO_USER
表示用户ID
who
参数:0
:当which为PRIO_PROCESS时表示调用进程;当which为PRIO_PGRP时表示调用进程所在进程组;当which为PRIO_USER时表示调用进程实际用户ID的进程。- 如果是非0值,则为指定的pid进程,进程组id中的所有进程,用户id的所有进程
- 如果which参数作用于多个进程,则函数返回所有作用进程中优先级最高的(最小nice值)
setpriority
:- 可以为进程、进程组、属于特定用户ID的所有进程设置优先级。前两个参数与
getpriority
相同,prio
增加到nice
值上。
- 可以为进程、进程组、属于特定用户ID的所有进程设置优先级。前两个参数与
17、进程时间
- 可以通过times函数获取调用进程和已终止子进程的时钟时间(墙上时钟时间)、用户CPU时间和系统CPU时间(相关概念见1.10节)。
clock_t times(struct tms *buf); struct tms { clock_t tms_utime; /* user time */ clock_t tms_stime; /* system time */ clock_t tms_cutime; /* user time of children */ clock_t tms_cstime; /* system time of children */ };
- 该函数会将墙上时钟时间作为函数返回值返回,并将用户CPU时间、系统CPU时间保存在参数指向的结构体中。
- 注意
times
函数获取到的墙上时钟值是相对于过去某一时刻度量的,所以不能使用其绝对值而要使用相对值。例如调用times
保存其返回值,过一段时间后再调用times
,从新返回的值中减去以前返回的值,此差值就是墙上时钟时间。用户CPU
和系统CPU
时间值则是相对于进程开始时开始计量的。 - 需要注意,这个函数获取的时间值都是
clock_t
类型,可以从第一章知道,clock_t
返回的是时钟滴答数。因此如果要知道具体时间(秒数),需要用该类型数值除以每秒时钟滴答数sysconf(_SC_CLK_TCK)
。 tms
结构体中对子进程的字段包含了此进程用wait
函数族已经等待到的各子进程的相关值。
- 实例:计算墙上时钟时间、父进程和子进程的用于CPU时间和系统CPU时间
命令行输出:#include "apue.h" #include <sys/times.h> 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) /* execute and time the "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("times error"); if ((status = system(cmd)) < 0) /* execute command */ err_sys("system() error"); if ((end = times(&tmsend)) == -1) /* ending values */ 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) /* fetch clock ticks per second first time */ 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_stime - tmsstart->tms_stime) / (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时间过少,第三个程序中运行了一个处理时间足够长的命令来表明所有的CPU时间都出现在子进程中。需要注意的是:shell和明林正是在子进程中执行的。lh@LH_LINUX:~/桌面/apue.3e/proc$ ./times1 "sleep 5" "date" "man bash > /dev/null" command: sleep 5 real: 5.00 user: 0.00 sys: 0.00 child user: 0.00 child sys: 0.00 normal termination, exit status = 0 command: date 2023年 07月 15日 星期六 12:03:09 CST real: 0.00 user: 0.00 sys: 0.00 child user: 0.00 child sys: 0.00 normal termination, exit status = 0 command: man bash > /dev/null <standard input>:2295: warning [p 19, 1.2i]: cannot adjust line real: 0.34 user: 0.00 sys: 0.00 child user: 0.07 child sys: 0.20 normal termination, exit status = 0