基础知识——异常控制流
控制流
现代计算机遵循冯诺伊曼体系,指令是顺序执行的,处理器只是简单读取和执行一个指令序列。该指令序列就是处理器的控制流。
改变控制流
传统改变控制流的两种机制
- 跳转和分支
- 调用和返回
以上两种机制使得程序能够对由程序变量表示的内部程序状态的变化作出反应。
但不能对系统状态的变化作出反应,如:
- 磁盘或网卡数据到达
- 除 0 错误
- Ctrl-c
- 定时器超时
现代系统必须要有对突发情况作出反应的机制,称为 “异常控制流”。
异常处理
处理器状态中的变化处罚从应用程序到异常处理程序的突发的控制转移。
异常处理完成后,再将控制返回给被中断的程序或终止。
异常处理程序运行在内核态下。
- 异常号:系统为每种异常类型都分配了唯一一个 unsigned int 的异常号。
- 系统启动时,操作系统分配和初始化一张异常表,表项包含着异常对应的异常处理程序的地址。
异常表的起始地址存放在异常表基址寄存器。
异常分类
- 异步异常(中断)
- 同步异常(故障指令)
-
陷阱
-
故障
-
终止
-
所谓同步、异步即操作系统能否预料异常发生。
同步异常,如:
- 缺页异常,读取数据的时候就能知道该页是否在内存之中。
- 除数为 0(浮点异常),当进行除法运算的时候,除数是否为 0 也是知道的。
- 系统调用,当调用函数时,必然是知道该函数是否为系统级函数。
异步异常,如:
- 网络数据包的到达,根据网络的质量,数据包的到达是不确定,所以操作系统无法得知数据包何时到达。
- 从磁盘中读取数据,磁盘传输速率的不同,数据传入到内存的时间也不一样。
- 键盘敲击 Ctrl-c,同样也无法预测用户何时敲击。
PS:分类是按照 CSAPP 分类的,国外教材与 CSAPP 分类是几乎一致的。国内教材会有略微不同。
信号
介绍
信号是事件发生时对进程的通知机制,有时也称之为软件中断。
一个进程(具有权限)能够向另一个进程发送信号。进程也可向自己发送信号。可以作为一种同步技术,甚至可作为 IPC (进程间通信)。
信号到达默认执行以下操作之一:
- 忽略信号
- 杀死进程
- 产生核心转储文件,并杀死进程
- 暂停进程
- 唤醒进程
除了默认操作以外,也能改变信号的响应行为。称之为信号的处置设置。程序可以对信号做以下的设置:
- 默认操作
- 忽略信号
- 执行信号处理程序
信号处理程序是由程序员编写的函数,根据收到的信号执行对应的任务。无法对信号处置设置为默认执行的 2 和 3,但是可以通过执行信号处理程序中调用 exit 和 abort 间接实现。
信号类型和默认行为
Linux 共有 30 个不同的信号。实际上会有更多。signal.h
定义了所有信号。
可使用 kill -l
查看所有信号
$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL
5) SIGTRAP 6) SIGABRT 7) SIGEMT 8) SIGFPE
9) SIGKILL 10) SIGBUS 11) SIGSEGV 12) SIGSYS
13) SIGPIPE 14) SIGALRM 15) SIGTERM 16) SIGURG
17) SIGSTOP 18) SIGTSTP 19) SIGCONT 20) SIGCHLD
21) SIGTTIN 22) SIGTTOU 23) SIGIO 24) SIGXCPU
25) SIGXFSZ 26) SIGVTALRM 27) SIGPROF 28) SIGWINCH
29) SIGINFO 30) SIGUSR1 31) SIGUSR2
这些信号是早期 UNIX 规定的,是不可靠信号。
不可靠信号: 也称为非实时信号,不支持排队,信号可能会丢失, 比如发送多次相同的信号, 进程只能收到一次. 信号值取值区间为1~31
发送信号
键盘发送信号
ctrl + c
会发送 SIGINT
信号
ctrl + \
会发送 SIGQUIT
信号
ctrl + z
会发送 SIGTSTP
信号
kill 命令
# kill -sig pid
$ kill -9 1782 # 给 1782 进程发送 9 号信号,也就是SIGKILL
$ kill -SIGKILL 1782 # 与上面相同
默认发 SIGTERM(15 号)信号
-
pid > 0
发送给指定的进程。 -
pid = 0
发送给与pid
同组的所有进程。 -
pid < -1
发送给pid
绝对值的进程组内所有下属进程。 -
pid = -1
发送给有权限发送的所有进程,除init
。
发送给另一个进程还需要权限,如果权限不够将会失败。如果发送的是一组进程,只要组中有一个成功就算成功。
发送 0 信号,可以用来检查一个进程是否存在。
$ kill -0 1999
sh: kill: (1999) - No such process
验证进程存在并不能保证特定程序在运行,因为 pid
是可以回收利用的。
函数发送信号
kill()
与 shell 的 kill
命令完全相同,包括参数
#include <signal.h>
int kill(pit_t pid, int sig);
int raise(int sig); // 对自己发 sig 信号
int killpg(pid_t pgrp, int sig); // 对进程组发 sig 信号
#include <unistd.h>
unsigned int alarm(unsigned int s); // 过 s 秒后对自己发 SIGALRM 信号
#include <stdlib.h>
void abort(void) // 对自己发 SIGABRT 信号
信号处置
接收信号
当接收到信号时会触发控制转移,交由到信号处理器处理。信号处理器处理完程序后,会将控制权限返回给被中断的程序。
信号处理器也可以被其他信号打断,可能会带来并发安全问题。
安装信号处理器 signal()
#include <signal.h>
void (*signal(int sig, void (*handler)(int)))(int);
// sig 要修改的信号编号
// handler 接受到信号要调用的函数
// handler 函数一般如下
void handler(int sig) { // 收到的信号作为入参
/* code */
}
handler
可传入 SIG_DEL
(重置默认操作)、SIG_IGN
忽略信号。
等待信号 pause()
等待一个信号的发生。如果不使用这个函数,则需要死循环等待,要知道死循环会造成 CPU 空转,浪费性能。而使用 pause()
则会使进程进入 sleep
状态,从而解放 CPU。
#include <unistd.h>
int pause(); // 暂停该进程执行。收到一个信号转而执行信号处理器,恢复进程执行。如果没有设置对应的信号处理器则会终止进程。
示例
安装信号处理器
接收到信号,打断当前程序流程,由内核代表进程调用信号处理器,信号处理器返回时,程序会从中断点恢复执行。
#include <stdio.h>
#include <signal.h>
void handler(int sig) {
printf("hello, sig: %d\n", sig);
}
int main() {
signal(SIGINT, &handler);
while(1) { // CPU 空转
sleep(3);
}
}
$ ./old
^Chello, sig: 2
^Chello, sig: 2
^Chello, sig: 2
^\Quit
信号打断信号处理器
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
void handler1(int sig) {
while (1) {
printf("handler1, sig: %d\n", sig);
sleep(3);
}
}
void handler2(int sig) {
printf("handler2, sig: %d\n", sig);
}
int main() {
signal(SIGINT, &handler1);
signal(SIGTSTP, &handler2);
pause();
}
$ ./s3
^Chandler1, sig: 2 # ^C 信号,handler1 触发
handler1, sig: 2
handler1, sig: 2
^Zhandler2, sig: 20 # ^Z 信号,handler2 触发
handler1, sig: 2 # handler2 执行完毕返回,权限交由 handler1
^Zhandler2, sig: 20
handler1, sig: 2
handler1, sig: 2
^Zhandler2, sig: 20
handler1, sig: 2
handler1, sig: 2
^Zhandler2, sig: 20
handler1, sig: 2
阻塞和解除阻塞信号
信号集
#include <signal.h>
int sigemptyset(sigset_t *set); // 初始化一个空的信号集
int sigfillset(sigset_t *set); // 初始画一个包括所有信号的信号集
int sigismember(const sigset_t *set, int sig); // 信号集是否包含 sig 信号
int sigaddset(sigset_t set, int sig); // 添加信号
int sigdelset(sigset_t set, int sig); // 删除信号
sigset_t
是一个位掩码,用来标志拥有的信号。
所以空信号集就是 set = 0
,满信号集就是 set = UINT32_MAX
。
添加 N 信号就是在第 set
的第 N 位置 1,删除 N 信号就是在第 set
的第 N 位置 0。
阻塞信号传递
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset); // 即可修改掩码,也可获得原来的掩码
// how 参数
// SIG_BLOCK,在内核掩码里新增 set 包含的所有信号
// SIG_UNBLOCK,从内核掩码里删除 set 包含的所有信号
// SIG_SETMASK,把内核掩码覆盖为 set 包含的所有信号
内核会为每个进程维护一个信号掩码,并将阻塞其针对该进程的传递。如果将阻塞的信号发送给某进程,那么对该信号的传递延后,直到从掩码中删除该信号,从而解除阻塞。
隐式阻塞:内核默认阻塞正在处理信号类型的待处理的信号。
显式阻塞:将自定义的信号集通过 sigprocmask
传入内核,通过更改内核掩码达到显示的阻塞和解除信号。
处于等待状态的信号
某个进程接受了一个该进程正在阻塞的信号,那么会在该信号添加到进程的等待信号集中。当接触了对该信号的锁定,会将信号传递给进程。
#include <signal.h>
int sigpending(sigset_t *set); // 将处于等待状态的信号传递给 set
等待信号集只是个掩码,只记录信号是否发生,并未记录信号发生了多少次,更不会给信号排队。
也就是说即使收到了 n 次阻塞的信号,解除阻塞也就只会响应一次。
再谈等待信号 sigsuspend()
通过更改内核的信号集,可以阻塞所选择的信号,或解除该信号的阻塞。往往利用这种方式来保护不希望被信号中断的代码临界区。自然而然的会相出以下代码。
sigprocmask(SIG_SETMASK, &new, &old); // 1
pause(); // 2
sigprocmask(SIG_SETMASK, &old, NULL); // 3
因为这段代码不具备原子性。当执行到 1 处,若有个你需要捕捉的信号到达了。但是 pause
没有执行,然而该信号触发的信号处理器运行完毕后,轮到 pause
执行了,程序又会阻塞,需要等待下一次信号的到来。如果这个信号只会发送一次,而恰好又是前面的情况,那么该程序将会永久阻塞。
问题就在于 1、2 这两行代码不是原子性的。所以提供了一个增强版本的 sigsuspend
该函数就是上述三行代码的原子版本。
#include <signal.h>
int sigsuspendd(const sigset_t *mask);
示例
// 这些代码都是不安全的,但不影响我们做实验
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
int cnt = 0;
void handler1(int sig) {
printf("sig1handler: %d, cnt: %d\n", sig, cnt);
cnt++;
}
void handler2(int sig) {
printf("sig2handler: %d\n", sig);
}
int main() {
sigset_t set;
sigemptyset(&set);
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, NULL); // 对内核设置掩码,添加对 ^C 的阻塞
signal(20, &handler1);
signal(2, &handler2);
while(1) {
if (cnt >= 10) {
sigprocmask(SIG_UNBLOCK, &set, NULL); // 对内核设置掩码,删除对 ^C 的阻塞
}
sleep(3);
}
}
$ ./set
^Zsig1handler: 20, cnt: 0 # ^Z 信号正常,因为该信号没有被设置为阻塞
^Zsig1handler: 20, cnt: 1
^Zsig1handler: 20, cnt: 2
^Zsig1handler: 20, cnt: 3
^Zsig1handler: 20, cnt: 4
^Zsig1handler: 20, cnt: 5
^C^C^C^C^C^C^C^C^C^C^C^C^C # 发送多次 ^C 信号没有反应,因为被我们设置成了阻塞
^Zsig1handler: 20, cnt: 6
^Zsig1handler: 20, cnt: 7
^Zsig1handler: 20, cnt: 8
^Zsig1handler: 20, cnt: 9 # cnt = 10,解除对 ^C 信号的阻塞
sig2handler: 2 # ^C 信号阻塞解除,虽然发了多次 ^ 信号,但是只会响应一次
^Zsig1handler: 20, cnt: 10
^Zsig1handler: 20, cnt: 11
^Csig2handler: 2 # ^C 信号正常被处理
^Csig2handler: 2
^Csig2handler: 2
^Csig2handler: 2
^Csig2handler: 2
^Csig2handler: 2
信号安全
所谓安全就是,无论其他线程何种状态执行该函数,该函数的结果都是可以预料到的。因为信号处理器和主程序往往是并发执行的,所以会存在并发安全问题。
可重入函数
可重入函数定义:函数由多条线程调用,即使交叉执行,效果也与各线程任意顺序依次调用时一致。
更新全局变量或静态数据结构往往都是不可重入的,如果只更新本地变量则一定是可重入的,标准库中存在大量不可重入的函数。比如说:malloc
、free
、printf
、scanf
、exit
等。
异步信号安全函数
异步信号安全函数保证该函数调用时安全,这些函数要么是可重入的,要么是不可被中断的。
printf
是不安全的,但是上面的代码中经常用到,因为他方便。如果需要安全请用 write
。
以下函数是安全的。
全局变量
volatile
通常会使用全局变量来共享主程序和信号处理器的数据。然而编译器会偷懒,CPU 直接从寄存器中拿出共享数据进行修改,这时主程序和信号处理器的数据就会发生数据不一致。所以常常会用 volatile
避免编译器偷懒。
// f.c
int a = 0;
int f() {
int i;
for (i = 0; i < 10; i++) {
a++;
}
return a;
}
// g.c
volatile int a = 0;
int g() {
int i;
for (i = 0; i < 10; i++) {
a++;
}
return a;
}
编译上面两段程序。
0000000000000000 <f>:
0: 8b 05 00 00 00 00 mov 0x0(%rip),%eax
6: 83 c0 0a add $0xa,%eax # 偷懒,直接在寄存器里+10
9: 89 05 00 00 00 00 mov %eax,0x0(%rip) # 最后再把寄存器里的值存入内存
f: c3 retq
0000000000000000 <g>:
0: ba 0a 00 00 00 mov $0xa,%edx
5: 0f 1f 00 nopl (%rax)
8: 8b 05 00 00 00 00 mov 0x0(%rip),%eax
e: 83 c0 01 add $0x1,%eax
11: 89 05 00 00 00 00 mov %eax,0x0(%rip) # 老老实实加完存到内存
17: 83 ea 01 sub $0x1,%edx
1a: 75 ec jne 8 <g+0x8>
1c: 8b 05 00 00 00 00 mov 0x0(%rip),%eax
22: c3 retq
sig_atomic_t
如果有多个指令对全局变量进行读写操作,即使使用 volatile
也是不安全的。因为哪怕是 ++
、 --
这类操作都不是原子的,都是可以被打断的。
不要以为
++
、--
会被编译为inc
与dec
。
在单核心处理器时代inc
与dec
是原子的,但现在的处理器都是多核的,哪怕是一条汇编指令也不能保证原子性。
如果需要原子性操作则需要加上lock
前缀。具体可查 《intel 技术手册》。
# i++
4: 8b 05 00 00 00 00 mov 0x0(%rip),%eax
a: 83 c0 01 add $0x1,%eax
d: 89 05 00 00 00 00 mov %eax,0x0(%rip)
但是 int
的赋值操作保证一定是原子性的,只有赋值操作。通常我们会用 sig_atomic_t
来提醒自己,这个数据要保证原子操作。对,是提醒自己!sig_atomic_t
并不保证所有操作都是原子操作。这时因为 sig_atomic_t
就是 int
。
// 绝大多数操作系统都是这么定义的
typedef int sig_atomic_t;
注意:
a = 1,是原子操作
a = b,不是原子操作
信号安全总结
编写信号安全的处理程序,有以下建议
- 尽可能的简单
- 只调用异步信号安全函数
- 保存和恢复 errno
- 对全局数据结构的访问,要阻塞所有信号。这样就保证了对全局变量的读写不会被打断
volatile
避免编译器偷懒,导致数据不一致- 用
sig_atomic_t
声明标志,才用自旋的方式更新全局变量。
这节涉及大量并发问题,说到这里已经脱离主线。以后专门出一章并发,再深入讨论。
非本地跳转
非本地跳转可以从当前执行的语句跳转到某个函数的某个位置。类似于 goto
语句,但是 goto
不能跨越函数。
标准 C 中的函数不支持嵌套,所以
goto
不能用于函数间跳转。因为编译器无法知道调用A()
时B()
的栈帧是否在栈上。
setjmp() 与 longjmp()
#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env, int val);
后续 longjmp
跳转的位置,就是 setjmp
调用时的位置。通过 longjmp
跳转会回到 setjmp
位置,再次执行 setjmp
。
通过 setjump
的返回值就能区分,到底是第一次设置位置调用,还是通过 longjmp
跳转后调用。前者返回 0,后者返回 val
参数。
env
用来保存与恢复当前进程上下文。当调用 setjmp
时会将上下文信息保存到 env
,当通过 longjmp
跳转时会将 env
中保存的上下文信息恢复。
longjmp
是否会将信号掩码恢复,取决于操作系统。System V 不会恢复,而 BSD 会恢复。Linux 、MacOS 属于 System V。
#define _BSD_SOURCE // BSD 特性检测宏,定义了这个宏,那么会按照 BSD 语意
sigsetjmp() 与 siglongjump()
POSIX 选择重新定义一对新的函数,来解决上一对函数的可移植性问题。
#include <setjmp.h>
int sigsetjmp(sigjmp_buf env, int savesigs);
void siglongjmp(sigjmp_buf env, int val);
sigsetjmp
、siglongjmp
与 setjump
、longjump
几乎一致。
不同的是多出一个参数 savesigs
,这个参数如果为 1
,将会把掩码存放于 env
中,后期是否恢复交由程序员来决定。
非本地跳转注意事项
只有以下语境中才能使用非本地跳转:
- 选择或循环语句中的整个控制表达式
- 一元操作符
!
可以,其余不行 - 比较运算符的一部分,与其比较的值一定是常量
- 作为独立的函数调用,没有嵌入到更大的表达式中
s = setjmp(env); // 这个语句是不标准的,不能赋值
使用要小心,不要滥用,可能会造成死循环。
如同 goto
一样,不到万不得已,请不要使用。
用非本地跳转来实现异常处理机制
C++ 和 Java 都提供异常处理机制,但是 C 并没有这些后辈们的 try
、catch
、throw
语句。但是我们可以通过非本地跳转来实现。
可以把 try-catch
看作于 setjmp
的封装,把 throw
看作 longjmp
的封装。
如以下两个程序,除数不能为 0
。在 Java 中抛出算术异常,而在 C 中会触发 SIGFPE
信号。通过信号与非本地跳转实现 Java 中的异常机制。
public class Main {
public static int div(int x, int y) {
int res;
try {
res = x / y;
} catch (ArithmeticException e) {
System.out.println("除数不能是 0!");
res = 0;
}
return res;
}
public static void main(String[] args) {
int res = div(1, 0);
System.out.println(res);
}
}
jmp_buf env;
void FPE_handler(int srg) {
printf("除数不能是 0!\n");
longjmp(env, 1);
}
int div(int x, int y) {
signal(SIGFPE, &FPE_handler);
int res;
if (setjmp(env) == 0) {
res = x / y;
} else {
res = 0;
}
return res;
}
int main() {
int res = div(1, 0);
printf("%d\n", res);
return 0;
}