详解 Linux 信号机制

基础知识——异常控制流

控制流

现代计算机遵循冯诺伊曼体系,指令是顺序执行的,处理器只是简单读取和执行一个指令序列。该指令序列就是处理器的控制流。
在这里插入图片描述

改变控制流

传统改变控制流的两种机制

  • 跳转和分支
  • 调用和返回

以上两种机制使得程序能够对由程序变量表示的内部程序状态的变化作出反应。

但不能对系统状态的变化作出反应,如:

  • 磁盘或网卡数据到达
  • 除 0 错误
  • Ctrl-c
  • 定时器超时

现代系统必须要有对突发情况作出反应的机制,称为 “异常控制流”。

异常处理

处理器状态中的变化处罚从应用程序到异常处理程序的突发的控制转移。

异常处理完成后,再将控制返回给被中断的程序或终止。

异常处理程序运行在内核态下。
在这里插入图片描述

  • 异常号:系统为每种异常类型都分配了唯一一个 unsigned int 的异常号。
  • 系统启动时,操作系统分配和初始化一张异常表,表项包含着异常对应的异常处理程序的地址。
    在这里插入图片描述
    异常表的起始地址存放在异常表基址寄存器。
    在这里插入图片描述

异常分类

在这里插入图片描述

  • 异步异常(中断)
    在这里插入图片描述
  • 同步异常(故障指令)
    • 陷阱在这里插入图片描述

    • 故障在这里插入图片描述

    • 终止在这里插入图片描述

所谓同步、异步即操作系统能否预料异常发生。
同步异常,如:

  • 缺页异常,读取数据的时候就能知道该页是否在内存之中。
  • 除数为 0(浮点异常),当进行除法运算的时候,除数是否为 0 也是知道的。
  • 系统调用,当调用函数时,必然是知道该函数是否为系统级函数。

异步异常,如:

  • 网络数据包的到达,根据网络的质量,数据包的到达是不确定,所以操作系统无法得知数据包何时到达。
  • 从磁盘中读取数据,磁盘传输速率的不同,数据传入到内存的时间也不一样。
  • 键盘敲击 Ctrl-c,同样也无法预测用户何时敲击。

PS:分类是按照 CSAPP 分类的,国外教材与 CSAPP 分类是几乎一致的。国内教材会有略微不同。

信号

介绍

信号是事件发生时对进程的通知机制,有时也称之为软件中断。

一个进程(具有权限)能够向另一个进程发送信号。进程也可向自己发送信号。可以作为一种同步技术,甚至可作为 IPC (进程间通信)。

信号到达默认执行以下操作之一:

  1. 忽略信号
  2. 杀死进程
  3. 产生核心转储文件,并杀死进程
  4. 暂停进程
  5. 唤醒进程

除了默认操作以外,也能改变信号的响应行为。称之为信号的处置设置。程序可以对信号做以下的设置:

  1. 默认操作
  2. 忽略信号
  3. 执行信号处理程序

信号处理程序是由程序员编写的函数,根据收到的信号执行对应的任务。无法对信号处置设置为默认执行的 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

信号安全

所谓安全就是,无论其他线程何种状态执行该函数,该函数的结果都是可以预料到的。因为信号处理器和主程序往往是并发执行的,所以会存在并发安全问题。

可重入函数

可重入函数定义:函数由多条线程调用,即使交叉执行,效果也与各线程任意顺序依次调用时一致。

更新全局变量或静态数据结构往往都是不可重入的,如果只更新本地变量则一定是可重入的,标准库中存在大量不可重入的函数。比如说:mallocfreeprintfscanfexit 等。

异步信号安全函数

异步信号安全函数保证该函数调用时安全,这些函数要么是可重入的,要么是不可被中断的。

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 也是不安全的。因为哪怕是 ++-- 这类操作都不是原子的,都是可以被打断的。

不要以为 ++-- 会被编译为 incdec
在单核心处理器时代 incdec 是原子的,但现在的处理器都是多核的,哪怕是一条汇编指令也不能保证原子性。
如果需要原子性操作则需要加上 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);

sigsetjmpsiglongjmpsetjumplongjump 几乎一致。

不同的是多出一个参数 savesigs,这个参数如果为 1,将会把掩码存放于 env 中,后期是否恢复交由程序员来决定。

非本地跳转注意事项

只有以下语境中才能使用非本地跳转:

  • 选择或循环语句中的整个控制表达式
  • 一元操作符 ! 可以,其余不行
  • 比较运算符的一部分,与其比较的值一定是常量
  • 作为独立的函数调用,没有嵌入到更大的表达式中
s = setjmp(env); // 这个语句是不标准的,不能赋值

使用要小心,不要滥用,可能会造成死循环。

如同 goto 一样,不到万不得已,请不要使用。

用非本地跳转来实现异常处理机制

C++ 和 Java 都提供异常处理机制,但是 C 并没有这些后辈们的 trycatchthrow 语句。但是我们可以通过非本地跳转来实现。

可以把 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;
}

参考资料

  • 9
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值