系统调用错误处理
由于之后会用到大量的系统调用函数,我们需要做好错误处理,以便于查找问题。Uinx系统中的系统级函数遇到错误时会返回-1,并设置全局整数变量errno
来表示错误类型。这个时候我们可以通过strerror()
函数来返回和errno值相关联的错误。我们以处理fork()
的错误为例:
1 | if((pid = fork()) < 0){ |
我们可以进一步的封装这个错误:
1 | void unix_error(char *msg){ |
通过错误处理包装函数,我们可以进一步的优化代码。对于错误处理包装函数,有一个约定成俗的规矩,对于基本函数foo
,我们定义一个具有相同参数的包装函数Foo
。包装函数调用基本函数,检查错误,如果有问题就终止。比如下面对fork
的包装:
1 | pid_t Fork(){ |
我们之后的包装函数也会按照相同的处理模式,来进行编写。
进程调用
Unix提供了大量从C程序中操作进程的系统调用,我们来详细了解他们:
获取进程ID
每个进程都有一个唯一的正数非零进程PID
1 |
|
这两个函数返回的类型为pid_t
,在Linux中它们被types.h
定义为int
创建和终止进程
我们可以认为进程总是处于下面三种状态:
- 运行 进程要么在CPU上执行,要么在等待被执行且最终会被调度
- 停止
进程的执行被挂起,且不会被调度。当收到
SIGSTOP SIGTSTP SIGTTIN SIGTTOU
信号时,进程就保持停止,直到它收到一个SIGCONT
信号,这个时候,进程在次开始运行 - 终止 进程永远地停止了。三种原因:1)收到终止进程信号,2)从主程序返回,3)调用exit()函数
下面我们了解进程的创建和终止过程:
1 |
|
1 |
|
新创建的子进程会得到父进程用户级虚拟地址空间相同的一份副本,包括代码、数据、用户栈、堆、共享库。子进程也会得到与父进程任何打开文件描述符相同的副本,这意味着当父进程调用fork
时,子进程可以读取父进程中打开的所有文件。父子进程最大的区别就在于他们的PID不同
fork函数被调用一次,却会返回两次,这是因为调用之后创建了一个新的进程。然而,在两个进程中的返回值会有所不同,因此我们根据fork()
的返回值来判断父子进程
我们可以用下面这个程序来展示一个进程的创建:
1 | int main(){ |
我们可以看到执行的结果:
1 | ylin@Ylin:~/Program/test$ ./a.out |
实际的运行过程我们可以简化成流程图:

对于整个过程我们可以从中注意到一些关键点:
- 调用一次,返回两次 fork函数被父进程调用一次,但是却返回两次——一次返回到父进程,一次返回到子进程。对于多个fork函数的情况我们之后会涉及
- 并发执行 父子进程都是并发运行的独立进程。内核可能以任意方式交替执行它们的逻辑控制流的指令。因此我们不能对不同进程中指令的交替执行做出假设。不存在执行的先后关系
- 相同但是独立的空间 通过观察局部变量x,我们可以看出,父子进程对x所作的改变都是独立的。说明它们之间的空间是独立的。根据数值可以判断,x的值是相同的。因此我们说父子进程的空间相同且独立。
- 共享文件 printf将输出输入到stdout文件中,结果表明在子进程中的stdout也是打开的。子进程继承了这个文件,所以说父子进程中的文件描述符也是相同的
理解了这些我们就可以理解更复杂的情况,我们可以通过流程图来更好的分析复杂的嵌套情况:
1 | int main(){ |

回收子进程
当一个进程被终止时,内核不会立即将其清除。而是进程会处于一个终止的状态下,直到它的父进程将其回收。当父进程将终止的子进程回收时,内核将子进程的推出状态传递给了父进程,然后抛弃终止的进程。直到现在,这个进程才不存在了。对于处于终止状态,但没有被回收的进程,我们称之为僵死进程。
如果一个父进程终止了,内核会安排init进程作为它们的孤儿进程的养父。init进程的PID为1,是在系统启动时就由内核创建的,它不会终止,是所有内核的祖先,如果父进程没有回收它的僵死子进程就终止了。内核会安排init进程去回收它们。因为即使僵死子进程没有运行,也会消耗系统的内存资源
进程可以通过调用waitpid
函数来等待它的子进程终止或停止。
1 |
|
waitpid函数比较复杂,我们需要仔细讨论一下。
默认情况下(options=0),waitpid挂起调用进程的执行,直到它的等待集合中的一个子进程终止。如果等待集合中的一个进程在刚调用的时候就已经终止了,那么waitpid就立即返回。在这两种情况中,waitpid会返回导致waitpid返回的已终止进程的PID。此时,已终止的子进程会被回收,内核会清理它的痕迹。
这个过程非常的抽象,我们需要深入去理解waitpid:
判定等待集合的成员
等待集合的成员由参数pid确定:
- 如果pid>0,那么等待集合就是一个单独的子进程,它的进程ID等于pid
- 如果pid=-1,那么等待集合就是由父进程所有的子进程组成的
修改默认行为
可以通过修改options
为各个常量从而实现修改默认行为:
WNOHANG
如果等待集合中的任何子进程都没有终止,那么就立即返回。而默认的行为是挂起调用进程,直到有子进程终止。默认行为是等待的,会阻塞之后的操作。如果想要在等待子进程终止的同时,想要进行别的工作,我们就可以启用这个选项
WUNTRACED
挂起调用进程的执行,直到等待集合中的一个进程变成已终止或者被停止。返回的PID为导致返回的已终止或者被停止的子进程的PID。默认是返回导致返回的已终止的子进程。如果想要检查已终止和被停止的子进程时,可以启用这个选项。
WCONTINUED
挂起调用进程的执行,直到等待集合中一个正在运行的进程变成已终止或等待集合中一个被停止的进程收到
SIGCONT
信号重新开始执行
这些常量可以通过”|“来连接,从而更改行为
检查已回收子进程的退出状态
status是statusp指向的值,如果这个值不为NULL。waitpid就会在status中放上关于导致返回的子进程的状态信息。我们可以通过<wait.h>中定义的宏来解释status参数:
- WIFEXITED() 如果子进程通过exit或return正常终止则返回真
- WEXITSTATUS() 返回一个正常终止的子进程的退出状态。只有WIFEXITED()返回为真时,才有这个状态
- WIFSIGNALED() 如果子进程是因为一个未被捕获的信号终止的,那么返回真
- WTERMSIG() 返回导致子进程终止的信号的编号。只有WIFSIGNALED()返回为真时,才有这个状态
- WIFSTOPPED() 如果子进程是停止的,就返回真
- WSTOPSIG() 返回引起子进程停止的信号的编号。只有WITSTOPPED()返回为真时,才有这个状态
- WIFCONTINUED() 如果子进程收到SIGCONT信号重新启动,则返回真
错误条件
如果调用进程没有子进程,则返回-1,设置errno=ECHILD
如果waitpid被信号中断,返回-1,设置errno=EINTR
wait函数
wait()
是waitpid()
的简化版
1 |
|
wait(&status)
等价于waitpid(-1,&status,0)
让进程休眠
sleep函数可以将一个进程挂起指定的时间
1 |
|
如果请求的时间到了,就返回0;否则返回还要休眠的秒数,这种情况是因为sleep可能会被信号中断而过早返回。
另一个函数是puase,该函数让进程休眠,直到收到信号。
1 |
|
加载并运行程序
execve函数用于在当前进程的上下文中加载并运行一个新的程序。
1 |
|
execve函数加载并运行可执行目标文件filename,且待参数列表argv和环境变量列表envp。只有出现错误时,execve才会返回到调用程序。正常情况下调用一次不返回。
在execve加载了filename之哦胡。它会调用启动代码__libc_start_main
。启动代码设置栈,并将控制传递给新程序的主函数:
1 | int main(int argc,char* argv[],char*envp[]); |
当main开始执行时,用户栈的组织结构如下:

我们从高地址往下看,先是存放了参数和环境的字符串。然后是以NULL结尾的指针数组,其中每个指针都指向栈中的一个字符串。其中全局变量environ指向这些指针中的第一个envp[0]。在栈的顶部是系统启动函数__libc_start_main
的栈帧。
main的三个参数:
- argc 给出argv[]数组中非空指针的数量
- argv 指向argv[]数组中的第一个条目
- envp 指向envp[]数组中的第一个条目
同时LInux还提供了几个函数用来操作环境数组:
1 |
|
- getenv函数在环境数组中搜索字符串“name=value”。如果找到了,就返回指向value的指针。
- unsetenv函数查找字符串”name=value”,并删除
- setenv函数找到环境变量”name=value”后,会用新的value替换;否则则创建一个”name=new_value”的环境变量。overwrite用来控制是否覆盖已存在的同名环境变量,0则不覆盖。
使用fork和execve
我们写一个shell。shell打印一个命令行提示符,我们在stdin上输入命令行,然后对这个命令执行。于是我们可以搭建出一个简单的框架:
1 |
|
我们首先需要解析命令行参数,我们以空格作为分隔符,同时返回一个参数列表argv
。如果命令的参数以&
结尾,我们就把这个程序放在后台运行。
1 | int parseline(char* buf,char** argv){ |
解析好命令参数后,我们也需要判断第一个参数是否为程序名,或者是shell的内置函数。如果是内置函数我们就执行该函数,并返回1;如果不是就返回0。
1 | int builtin_command(char **argv){ |
最后我们就可以写出我们的执行函数了,如果builtin_comand
返回0,我们就需要创建一个新的子进程并加载程序运行。然后根据是否后台运行的需求,使用waitpid进行对前台程序的等待。当作业结束后,再进行一次迭代。我们的Shell就粗略的完成了。但是现在有一个问题,我们的shell不能回收已经结束的子进程,我们会在之后加以改进。程序的完整代码如下:
1 | // csapp.h |