系统启动时,操作系统将会分配和初始化一个异常表,将异常表的首地址放到 CPU 的异常表基址寄存器中。处理器触发异常时,会根据异常编号,从异常表上获取到处理程序的地址,并跳转到对应的处理程序:
异常类似于过程调用,但是有如下区别:
异常有如下类型:
异常号在 是 CPU 架构师定义的异常,对于任何的操作系统都是一样的。 是操作系统自己定义的中断或是陷阱等。
中断来自于外部 IO 设备。外部 IO 设备将异常号放到系统总线上,然后向处理器芯片上的一个引脚发送信号,来触发中断。
CPU 执行完当前指令后,如果发现中断引脚电压升高,则从系统总线上读取异常号,并调用对应的处理程序。
处理器提供了一个特殊的 syscall
指令,调用后处理器将跳到处理陷阱的处理程序中。
在 Linux 系统中会维护一个跳转表(和异常表不同,跳转表专门用于处理系统调用),根据寄存器中存储的系统调用编号,再跳转到对应的系统调用。
Linux 系统的系统调用的参数一律使用寄存器传递(不通过栈传递)。其中 %rax
寄存器要存储系统调用号,寄存器 %rdi
、%rsi
、%rdx
、%r10
、%r8
、%r9
包含最多 6 个参数,参数一到参数六依次存储在这些寄存器(注意这和正常函数调用的约定不同)。从函数调用返回时,%rax
中存储返回值。一般来说返回值为负数的话说明发生了异常,异常编号会存储在一个全局整数变量 errno
中,这个变量定义在 errno.h
中。
strerrno
函数可以获取与某个 errno
相关联的错误。我们可以封装一个错误报告函数:
c1234void unix_error(char *msg){
fprintf(stderr, "%s: %s\n", msg, strerror(errno));
exit(0);
}
我们最好给每个系统调用函数都包装一个错误处理形式,比如:
c12345pid_t Fork(){
pid_t pid;
if((pid = fork()) < 0) unix_error("Fork error");
return pid;
}
一些可能能被处理程序修正的错误,被称为故障。如果处理程序能够修正这个错误则可以返回到引起故障的指令,并重新执行。否则,处理程序则调用内核的 abort
程序,终止引起故障的程序。常见的故障如下:
一些错误是不可恢复的致命错误,通常是一些硬件错误,比如 DRAM 或是 SRAM 存储的数据被损坏,奇偶校验错误。CPU 不希望处理程序返回到原来的程序中,因此对应的处理程序被触发后应该立刻终止程序。通常触发异常号为 18,描述为“机器检查”的异常。
pid_t getpid(void);
unistd.h
中,其中 pid_t
在 sys/types.h
中,其实是 int
类型。pid_t getppid(void);
unistd.h
中。pid_t fork(void);
unistd.h
中。int execve(const char *filename, const char *argv[], const char *envp[]);
定义在 unistd.h
中。
在当前进程的上下文中加载并运行一个程序。注意,加载后当前进程的文件描述符,信号阻塞状态等仍然和之前一样。
argv
指向一个指针数组,这个数组里每个元素都指向一个参数的字符串。数组的末尾要以空指针结尾。按照管理,argv[0]
指向的字符串应该和 filename
一致。
envp
和 argv
类似,不同的是指向的字符串应该是形如 name=value
形式的键值对,作为环境变量。
argv
和 envp
对应于 main
函数的完整原型: int main(int argc, char *argv[], char *envp[]);
如果成功则不返回了。返回了说明发生错误,返回 -1。
void exit(int status);
stdlib.h
中。status
为退出状态来终止进程。pid_t waitpid(pid_t pid, int *statusp, int options);
sys/wait.h
中。options
为 0 时),waitpid
挂起当前进程,直到它的等待集合中的一个进程被终止,则返回。此时这个终止的进程的资源就被当前进程完全回收了。pid
来确定等待集合:
pid
> 0 ,那么等待集合就是一个单独的 pid 为 pid
的子进程。pid
= -1,那么等待集合就是全部的子进程。options
可选如下参数:
WNOHANG
:如果等待集合中任何子进程都没被终止,不挂起,立即返回。WUNTRACED
:不仅检查进程是否被终止,如果子进程被停止,也返回。WCONTINUED
:不仅检查进程是否被终止,如果一个被停止的进程因为受到 SIGCONT
信号重新开始执行,也返回。waitpid
会在 statusp
指向的地址上存放子进程的状态。有如下的宏用来解释状态的信息:
WIFEXITED(status)
:如果子进程是由于 exit
或者 main
函数返回而正常终止的,则返回真。WEXITSTATUS(status)
:获取正常终止的子进程的退出状态。WIFEXITED(status)
为真时才可以获取。WIFSIGNALED(status)
:如果子进程是因为一个未被捕获的信号终止的,那么就返回真。WTERMSIG(status)
:返回导致子进程终止的信号的编号。WIFSIGNALED(status)
为真时才可以获取。WIFSTOPPED(status)
:如果子进程是停止而导致 waitpid
返回的,则返回真。WSTOPSIG(status)
:返回引起子进程停止的信号的编号。WIFSTOPPED(status)
为真时才可以获取。WIFCONTINUED(status)
:如果子进程是收到 SIGCONT
信号重新启动而导致 waitpid
返回的,则返回真。WNOHANG
且没有引发返回的子进程,则返回 0。如果是其他错误,则返回 -1,并设置 errno
。errno
如下:
ECHILD
:当前进程没有子进程。准确来说这不算什么严重错误,应该特判一下。EINTR
:当前进程由于 waitpid
被挂起的过程中,又被一个信号中断了。pid_t wait(int *statusp);
sys/wait.h
中。wait(&status)
等价于调用 waitpid(-1, &status, 0)
unsigned int sleep(unsigned int secs);
unistd.h
中。sleep
函数被一个信号中断而过早返回,那么就返回非 0 的值。int pause(void);
unistd.h
中。char *getenv(const char *name);
stdlib.h
中。envp
中寻找 name=value
形式的字符串。如果找到了则返回对应的 value
,反之返回空指针。int setenv(const char *name, const char *newvalue, int overwrite);
stdlib.h
中。name=newvalue
添加到当前进程的 envp
数组中。name
已经存在了,那么就看 override
值,overwrite
值非零的话那么就覆盖,反之则不进行任何操作。void unsetenv(const char *name);
stdlib.h
中。name
从当前的 envp
数组中删去。如果不存在的话就什么也不干。pid_t getpgrp(void);
unistd.h
中。int setpgid(pid_t pid, pid_t pgid);
unistd.h
中。pid
的进程组改为 pgid
。pid
为 0 时,那么就使用当前进程的 PID,如果 pgid
时是 0 ,那么就用 pid
指定的进程 PID 作为进程组 ID。Linux 系统支持如下 30 种不同的信号:
使用 /bin/kill 程序
bash
1/bin/kill -9 15213
则可以向 PID 为 15213 的进程发送信号 9(SIGKILL)。
bash
1/bin/kill -9 -15213
负数则表示一个进程组,将会向进程组中的每个进程发送信号。
这里要用完整的路径 /bin/kill
,因为有些 Shell 有自己内置的 kill
指令。
使用键盘
Shell 在任何时刻都有至多一个前台进程组和若干个后台进程组。比如输入
bash1ls | sort
Shell 则会创建一个 ls
进程,一个 sort
进程,这两个进程属于同一个进程组,然后这个进程组是前台进程组。
在键盘上输入 Ctrl + C 会使内核发送 SIGINT 信号到前台进程组中的每个进程组。输入 Ctrl + Z 会发送一个 SIGTSTP 信号到前台进程组中的每个进程组。
使用 kill 函数
int kill(pid_t pid, int sig);
signal.h
中。pid
大于 0,那么发送信号 sig
到进程 pid
。如果 pid
为 0,那么信号发送到调用进程所在进程组中的每个进程,包括调用进程自己。如果 pid
小于 0 ,那么信号发送到进程组 ID 为 pid
绝对值的进程组中的每个进程。使用 alarm 函数
unsigned int alarm(unsigned int secs);
unistd.h
中。secs
秒后发送一个 SIGALRM 信号给当前进程。如果 secs
为 0,那么不会发送信号。alarm
前存在待处理的时钟,那么将会取消之前的那个,并返回之前的时钟剩余的秒数。如果调用前不存在待处理的时钟,则返回 0。内核准备调度一个进程时,会检查该进程的待处理信号集合。如果这个集合为空,那么继续执行这个进程的指令。否则,内核会选择待处理信号集合中的某个信号(通常是编号最小的信号)并进行处理。注意,进程每次被调度时都只处理一个信号,但可能处理一个信号时被内核切出去了,调度回来时又处理另一个信号(信号可以嵌套处理)。每个信号只会被加入到待处理集合中一个,也就是即使接收到多个同一信号,待处理集合里只会有一个。
处理信号时,每个信号都会有一个默认行为:
进程可以通过如下函数设置信号处理程序:
sighandler_t signal(int signum, sighandler_t handler);
signal.h
中。signum
的处理函数为 handler
。不能设置 SIGSTOP 和 SIGKILL 的处理函数。sighandler_t
的定义为 typedef void (*sighandler_t)(int);
,即传递的函数应该接收一个 int 参数且无返回值。调用处理函数时,会将触发处理函数的信号编号以参数传递。这样可以允许一个函数作为多个信号的处理函数。
handler
为 SIG_IGN
,那么进程忽略该信号。handler
为 SIG_DFL
,那么恢复该信号的默认行为。信号处理完毕,处理函数返回之后,一般情况下会回到进程之前被打断的地方继续执行。但是一些函数比较特殊,如果函数执行到一半,被信号打断的话,则会立刻返回,并返回一个错误。
Linux 可以阻塞信号。当内核检查待处理信号集合的时候,会忽略掉其中也处于阻塞集合中的信号。但是注意,信号被阻塞,也仍然可以处于待处理集合中。可能出现某个信号被阻塞时受到了这个信号,先不处理,取消阻塞时又处理该信号。
Linux 中有一个隐式阻塞机制,即内核默认阻塞当前正在处理的信号。
我们可以使用如下的函数阻塞信号:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
定义在 signal.h
中。
how
值可以选择如下的值,表示函数进行的操作:
SIG_BLOCK
:把 set
中的信号添加到阻塞集合中。SIG_UNBLOCK
:从 block
中删除 set
中的信号。SIG_SETMASK
:直接设置当前阻塞集合为 set
。如果 oldset
非空,那么会将操作之前的阻塞集合放入 oldset
中
成功返回 0,错误返回 -1。
有如下的函数可以用来处理信号集合:
int sigempty(sigset_t *set);
定义在 signal.h
中。
将 set
清空。
成功返回 0,错误返回 -1。
int sigfillset(sigset_t *set);
定义在 signal.h
中。
把所有的信号都加入到 set
中。
成功返回 0,错误返回 -1。
int sigaddset(sigset_t *set, int signum);
定义在 signal.h
中。
将 signum
加入到 set
中。
成功返回 0,错误返回 -1。
int sigismember(sigset_t *set, int signum);
定义在 signal.h
中。
如果 signum
位于 set
中则返回 1,否则返回 0,错误返回 -1。
一般按如下的方法来临时阻塞某个信号:
编写信号处理程序需要遵循如下的原则:
printf
、sprintf
、malloc
、exit
都不在此列,因此在处理程序里面产生输出的唯一安全的方法是使用 write
函数,并且退出程序要用 exit
的异步信号安全的变种 _exit
。errno
,但这样会干扰主程序中依赖于 errno
的部分。所以我们最好在处理程序的开头保存当前的 errno
,并在返回时还原 errno
。volatile
修饰会被主程序和处理程序共享的变量。如果处理程序更新全局变量,主程序里周期性读取这个全局变量,那么编译器可能会认为反复的周期性读取这个变量是无意义的,因为它看起来没发生变化,所以编译器会将其缓存到寄存器中,导致主程序无法看到这个共享变量的变动。使用 volatile
修饰之后,会强制编译器每次访问这个变量的时候都是从内存中读取的。sig_atomic_t
声明标志。sig_atomic_t
是一个整数类型,能够保证对这个类型进行的读写都是原子的。比较完整的是 volatile sig_atomic_t flag;
。Shell 的一个需求是,子进程终止或是停止时,Shell 需要更新自己维护的 Jobs 集合。
一个实现是,在 SIGCHLD 信号的处理程序中使用 waitpid
:
但是这样可能会出问题,因为我们之前提到即使发来了多个同种的信号,也只会出现在待处理集合中一次,只会被处理一次。所以我们不要用信号来对事件计数,信号触发时说明至少有一次信号触发,可能有多次。所以对于上面的需求,我们要使用 while 循环:
Shell 的另一个需求是,创建子进程之后,新增一个 job,收到 SIGCHLD 信号之后又将 job 删除。
一个简单的实现如下:
但是这样还有问题,因为有可能 fork 之后,内核优先调度子进程,子进程又立刻退出,发送了一个 SIGCHLD 信号给 Shell,Shell 被内核调度时,由于收到了信号,转到了处理程序,处理程序删除对应的 job。但是 Shell 还没开始把 job 添加到 job 列表中,这就出现了错误。
所以应该要在 fork 之前就屏蔽 SIGCHLD 信号,使得主程序不会被打断。但也要注意要在子进程中解除屏蔽:
Shell 的又一个需求是,创建前台进程之后,Shell 一直等待前台进程结束。一个实现如下:
这里使用了一个忙等待,来等待前台进程的结束。这样的代码运行正确,但是循环会很浪费处理器资源。
又一个选择是在循环中加入 pause
,这样只有接收到信号才会被唤醒。不过这样又会带来竞争问题:有可能在 while 测试通过之后,pause
执行之前受到 SIGCHLD 信号,这样的话 pause
就不再会收到 SIGCHLD 信号了,pause
永远休眠。
又一个选择是用 sleep
代替 pause
。但是这样 sleep
的时间又难以选择,时间小循环又会比较浪费,时间大程序又会太慢。
最合适的做法是使用 sigsuspend
int sigsuspend(const sigset_t *mask);
signal.h
中。mask
替换当前的阻塞集合,然后挂起当前进程,直到收到一个信号,信号处理函数处理完毕之后,还原当前的阻塞集合,然后返回。mask
参数。这样的话调用 sigsuspend
会先解除对信号的阻塞,收到信号后又立刻还原阻塞状态。于是上述代码修改为:
C 语言提供了一个用户级的异常控制流。我们有如下函数:
int setjmp(jmp_buf env);
定义在 setjmp.h
中。
函数会在 env
缓冲区中保存当前调用环境,如程序计数器、栈指针、通用寄存器等。
正常情况下返回 0,如果是通过 longjmp
函数返回到这里,会返回非 0。由于某种原因,setjmp
函数的返回值不能赋值给变量,但是可以使用 if 和 switch 语句对返回值进行测试。
void longjmp(jmp_buf env, int retval);
setjmp.h
中。env
缓冲区中恢复调用环境,然后从最近一次设置 setjmp
函数的地方返回,并带有非零的返回值 retval
。非本地跳转的一个重要应用是,如果从一个深层嵌套的函数调用中发现了一个错误情况,我们可以直接返回到一个普通的错误处理程序,而不必费力解开调用栈。
非本地跳转的又一个重要应用是使一个信号处理程序分支到特殊的代码位置,而不是返回到被信号中断了的指令的位置。此时需要 sigsetjmp
和 siglongjmp
函数,这两个函数是 setjmp
和 longjmp
函数的可以被信号处理程序使用的版本。
比如:
这样可以实现,每次按下 Ctrl + C 之后,程序进行热重启。