信号的概念

此信号和信号量没有关系,非要扯关系的话,信号也勉强算进人家通信的一种,因为信号也是进程向另一个进程发送某种信息的方式。

生活角度的信号

  • 早上 8 点定了闹钟,闹钟响了,就知道要上早八了。

  • 某人先在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,这人也知道快递来临时,该怎么处理快递。也就是这人能“识别快递”。

  • 当快递员到了某人楼下,这人也收到快递到来的通知,但是这人正在打游戏,需 50 min 之后才能去取快递。那么在在这 50 min 之内,这人并没有下去去取快递,但是这人是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成“在合适的时候去取”。

  • 偏远山村的小孩,可能一辈子都没见过红绿灯,但支教老师有在上课讲过,因此这个小孩即使走在马路上,也不会因为看不懂交通灯被车送去转生。

这些例子都反映了生活中的信号的特点

  1. 有人提前告诉过我们信号是什么。
  2. 我们能识别信号的含义
  3. 我们知道信号对应的后续的动作
  4. 信号产生时,我们不一定要立即处理它,而是选择合适的时间。因此信号可以被暂时保存
  5. 信号来临的时间,我们并不清楚。信号来临时我们很可能正在做其他工作。

这里的 “我们” 在计算机中可以代指进程。这些特点计算机中的信号也都拥有。

查询 Linux 的信号

信号指向目标进程发送通知消息的一种机制。因为信号属于一个进程将信号数据发送给另一个进程的信息,所以不严格的理解的话信号也算进程间通信的一种。当进程收到信号时,进程会自动地对信号进行处理。

在 Linux,通过指令 kill -l 可查看所有的信号:

[Bjarne@VM-8-8-centos cppTest]$ kill -l
# 省略

这里将 kill -l 的内容整理成表格,并简单描述每个信号的含义。

编号 信号名 信号用途(简单描述)
标准信号(1-31)
1 SIGHUP 挂起信号,终端连接断开时发送,也用于重新加载配置文件
2 SIGINT 中断信号,通常由Ctrl+C产生,用于中断进程
3 SIGQUIT 退出信号,通常由Ctrl+\产生,产生核心转储
4 SIGILL 非法指令,进程尝试执行非法指令
5 SIGTRAP 跟踪/断点陷阱,用于调试器
6 SIGABRT 中止信号,通常由abort()函数产生
7 SIGBUS 总线错误,内存访问错误
8 SIGFPE 浮点异常,算术运算错误
9 SIGKILL 强制终止信号,不可被捕获或忽略
10 SIGUSR1 用户自定义信号1
11 SIGSEGV 段错误,无效内存引用
12 SIGUSR2 用户自定义信号2
13 SIGPIPE 管道破裂,写入无读端的管道
14 SIGALRM 定时器信号,由alarm()函数设置
15 SIGTERM 终止信号,请求进程正常终止
16 SIGSTKFLT 协处理器栈错误
17 SIGCHLD 子进程状态改变
18 SIGCONT 继续执行,恢复之前停止的进程
19 SIGSTOP 停止信号,暂停进程执行,不可被捕获或忽略
20 SIGTSTP 终端停止信号,通常由Ctrl+Z产生
21 SIGTTIN 后台进程尝试从终端读取
22 SIGTTOU 后台进程尝试向终端写入
23 SIGURG 紧急数据到达socket
24 SIGXCPU CPU时间超限
25 SIGXFSZ 文件大小超限
26 SIGVTALRM 虚拟定时器超时
27 SIGPROF 性能分析定时器超时
28 SIGWINCH 窗口大小改变
29 SIGIO I/O就绪
30 SIGPWR 电源故障
31 SIGSYS 系统调用错误
实时信号(34-64)(用于应用程序自定义目的)
34 SIGRTMIN 第一个实时信号
35 SIGRTMIN+1 第二个实时信号
36 SIGRTMIN+2 第三个实时信号
37 SIGRTMIN+3 第四个实时信号
38 SIGRTMIN+4 第五个实时信号
39 SIGRTMIN+5 第六个实时信号
40 SIGRTMIN+6 第七个实时信号
41 SIGRTMIN+7 第八个实时信号
42 SIGRTMIN+8 第九个实时信号
43 SIGRTMIN+9 第十个实时信号
44 SIGRTMIN+10 第十一个实时信号
45 SIGRTMIN+11 第十二个实时信号
46 SIGRTMIN+12 第十三个实时信号
47 SIGRTMIN+13 第十四个实时信号
48 SIGRTMIN+14 第十五个实时信号
49 SIGRTMIN+15 第十六个实时信号
50 SIGRTMAX-14 实时信号
51 SIGRTMAX-13 实时信号
52 SIGRTMAX-12 实时信号
53 SIGRTMAX-11 实时信号
54 SIGRTMAX-10 实时信号
55 SIGRTMAX-9 实时信号
56 SIGRTMAX-8 实时信号
57 SIGRTMAX-7 实时信号
58 SIGRTMAX-6 实时信号
59 SIGRTMAX-5 实时信号
60 SIGRTMAX-4 实时信号
61 SIGRTMAX-3 实时信号
62 SIGRTMAX-2 实时信号
63 SIGRTMAX-1 倒数第二个实时信号
64 SIGRTMAX 最后一个实时信号

异步的概念

同步就是让执行流产生一定的顺序性,本质是两个进程是互相要知道彼此,要考虑彼此的感受。同步状态下的 2 个进程互相会影响。

异步(Asynchronous)则指两个执行流并没有因为彼此停下运行的步伐。

现实生活之中的异步的情况是大量的产生。例如爸爸在同一时间和家人分别后去上班,妈妈在家作家务,这种互相不影响且无顺序性的现象就是异步。

这里信号的整个生命周期和进程是异步的,在后续的测试会证明。

分析系统对设备做出快速反应

只需按下键盘,操作系统就能读取到键盘上的信息;只需挪动鼠标,操作系统就能计算出鼠标箭头的下一个位置并挪动。似乎操作系统总是能跟上用户操作外部设备的动作。想要完成这个操作,操作系统的做法只有 2 个:操作系统定期或一直去进行轮询所有的硬件设备所对应的驱动程序,每一种驱动程序就要提供对应的检测设备状态的接口;或所有设备主动向操作系统发送信息。

首先操作系统不可能定期枚举所有设备,因为设备太多,且所有设备都是异步的,所以只能由硬件通知操作系统。

硬件层面的行为

支撑操作系统能够读取或知道外设就绪的这样的一种技术,在计算机组成原理的概念中叫中断技术。CPU 和其他外部设备通过针脚(引脚)与主板相连,外部设备的光电信号通过主板上的传输线路和针脚被 CPU 获取,这些经过各种协议处理后的光电信号就是中断

这些光电信号由若干个表示不同含义的高低电平组成,通过这些光电信号,就可以做到通知 CPU 哪些事件发生。

冯 · 诺依曼体系中的控制器用于对其他设备进行一定程度的控制。虽然 CPU 处理的主要数据都是存放在内存,但在控制信息层面,控制信息是可以通过针脚和蚀刻在主板上的线路发送给硬件。

这些设备和 CPU 硬件在一定程度上算是间接连接的,而不是毫无关系。

外部设备也一定能把数据拷贝到内存,所以外设肯定和内存也有连接

未来有其他设备插入计算机时,就能通过整个线路接收到来自 CPU 的控制信号。

到这里再来分析上文的描述:计算机能对键盘的变化做出快速反应。

键盘在被用户按下按键时会发出很多光电信号,这些光电信号会引起 CPU 针脚的光电信号强弱的变化,这些强弱的变化最终被 CPU 识别。

光电信号强弱的变化简单理解就是某一个外部设备,一旦被用户按下某个或某些开关时,这个设备就会点亮,对应的某一个针脚就有了高(低)电平(电压变化)。CPU 对应的针脚都是有编号的,此时 CPU 就能够识别到这个针脚上有高电平,并且 CPU 知道这个这个针角的编号,它就可以把这个针脚所对应的编号数据写到寄存器里,所以它也就能够知道整个计算机当中哪个外设是已经就绪了,就去将数据进行读取。

这里可能需要读者了解部分高中物理的知识。

在金属制成的导线中,电子运动会形成电流,电流和水一样总是从 “高“ 的地方流向 ”低“ 的地方,这个 ”高度差“ 就是电势差,也就是电压。

将电子从低电势抬到高电势需要消耗能量,这些能量来自电池,或几千几万里外的发电厂的电。

而 CPU 内部有各种各样的寄存器,经过这些电平变化,就把硬件数据就绪,转化成了具有某种含义的数据,存储在寄存器中。一旦这个数据被放在寄存器里,这个数据就可以被程序随时读取,此时硬件行为被转化成了软件行为,这叫做软硬件结合的本质。

不是所有的外设都有资格向 cpu 发消息,毕竟针脚有数量上限。

但 90% 以上的设备都有中断,于是为了能够更好地支撑更多设备能够运行,向 CPU 发出中断,在计算机主板上的硬件中还有一个 8259 PIC(Programmable Interrupt Controller)。

8259 PIC是 Intel 公司在1970年代设计的可编程中断控制器,用于将外设的中断信息转换成高低电平交给 CPU。参考Linux系统编程初步—管理视角的操作系统-CSDN博客,这么多外部设备的中断要进行管理,肯定要经过先描述再组织的过程,才能被中断控制器和操作系统进行管理。有了中断控制器,就可以给更多的设备分配中断(信)号,让计算机能连接更多的设备。

所以当用户按下组合键例如 Ctrl+c 时,CPU 识别到了中断信号,对当前进程发送 2 号信号。

软件层面的行为

数据从外部设备转化成内部软件能识别的数据,首先就是先将这些数据进行存储。

为了能更快速的对外设进行响应,在操作系统内部会提供一张中断向量表,从 C 语言的观点很像函数指针数组。这个函数指针数组会指向对特定硬件的读取方法,数组的下标就是每个外设被分配的中断号。比如键盘的中断号可以是 0 。

未来每一种设备都有自己唯一的中断号,和匹配的对该设备的读取方法,一个外设就绪了,操作系统会把他手里面的工作部分或全部暂停一下,然后读取中断号,在数组中进行索引,找到硬件对应的方法后由操作系统执行。

执行结束的标志是数据读取成功,并拷贝到进程中,这个过程尽管复杂,路线冗长,但对电信号的速度来说都是小事,所以在人的感知中计算机总是能快速对人的操作做出反应。

但电信号也有极限,那就是电流在金属中的速度,当金属导线非常长,比如从地球到月球的距离,这个电信号完成一次来回就是接近 3 秒,人能感觉到十分明显的数据传输延迟。

通过这种方式,就可以把数据由外设,拷贝到操作系统内的进程中。这样操作系统就不用太过关心任何外设。

信号本身就是一种用软件来模拟中断的行为,只不过在设计上信号和中断是两套机制,一套是软、硬件结合,另一套是纯软件的机制,信号和中断在系统当中同时存在。

信号的概念

总结上面的描述,信号是进程之间事件异步通知的一种方式,属于软中断。

在 Linux 中,每个信号都有一个编号和一个宏定义名称,这些宏定义可以在 signal.h 中找到。

例如 linux-2.6.32.19\include\asm-generic\signal.h 中的定义:

#define SIGHUP		 1
#define SIGINT		 2
//...
#define SIGKILL		 9
//...

结合通过 Shell 程序和 kill -l 指令可知,没有 0 号信号,也没有 32、33 号信号。

退出信号为零,表示进程在运行期间没有收到过任何信号,代码生成的进程一定是正常运行直到终结的。

从这一点上来看的话,信号就不应该有零号信号,因为零号表示的是没有收到对应的信号。

也可能信号在设计时,设计者想让我们不要占用零号位置,零号位置为了能够更好的适配我们去标识进程正常结束的情况。

编号 [1,31] 的信号叫普通信号,编号 34 以上的是实时信号。收到实时信号时,一般要求操作系统要立即处理好,且一般不会出现信号丢失的问题。

这些信号各自在什么条件下产生,默认的处理动作是什么,在 signal(7) ( man 7 signal ) 中都有详细说明:。

信号发送的本质

无论信号有多少种产生方式,最终的信号永远是操作系统向目标进程发送,因为操作系统是软、硬件资源的管理者。用户无法直接绕过操作系统向进程发送信号。

我附庸的附庸不是我的附庸,我要找附庸的附庸做事,还得找附庸。

对于普通信号,进程收到信号之后,进程要表示自己是否收到了某种信号。在进程的 PCB 内部维护有一张表示信号的位图 signal_struct,这个位图的比特位的位置决定信号编号。默认位图的数据全清零,表示该进程没收到信号。

假设某一时刻,操作系统向目标进程发送 2 号信号,进程首先找到位图中 2 号比特位,然后把该比特位由 0 1,此时就完成了信号的发送

除了位图要记录是否接收到信号,进程还会维护一个函数指针数组,数组的下标为信号编号,表示进程收到信号后的后续行为

因为一般的 int 有32位,所以 [ 1 , 31 ] [1,31] [1,31] 号信号可用一个 int 为核心的位图记录状态,其他信号可多申请几个 intlong 进行记录状态。

所以信号发送的本质是写信号,操作系统直接对进程 PCB 内的位图进行修改来向进程传达信息。

但在实际的 Linux 中,信号有关的结构体长这样:

//linux-2.6.32.19\include\linux\sched.h
//对信号进行描述与组织的结构体
struct signal_struct {
    //省略其他成员
	/* shared signal handling: */
	struct sigpending	shared_pending;
    //省略其他成员
};

//进程 PCB
struct task_struct {
    //省略其他成员
	/* signal handlers */
	struct signal_struct *signal;//信号状态的位图
    struct sighand_struct *sighand;//信号动作的函数指针数组
    //省略其他成员
};

//查找sigpending的内容
//linux-2.6.32.19\include\asm-generic\bitsperlong.h
#define __BITS_PER_LONG 32

//linux-2.6.32.19\include\linux\signal.h
#define _NSIG		64
#define _NSIG_BPW	__BITS_PER_LONG

#define _NSIG_WORDS	(_NSIG / _NSIG_BPW)

typedef struct {
    //一个unsigned long是4字节,32比特位
	unsigned long sig[_NSIG_WORDS];
} sigset_t;

struct sigpending {
	struct list_head list;//实时信号的队列(链表)
	sigset_t signal;//普通信号的位图(64位)
};

信号的产生

信号的一种产生方式为按下组合键。

无论是哪种信号,生命周期都需要经历产生保存和后续处理 3 个阶段。信号的后续处理动作有以下三种:

  1. 忽略此信号。

  2. 执行该信号的默认处理动作。

  3. 用户提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。

验证信号由指定方法产生,只能观察信号的后续行为验证。

终端按键组合产生信号

这里以 Ctrl+C 为例。

  1. 用户输入指令,在 Shell 下启动一个前台进程。
  2. 用户按下Ctrl+c ,这个键盘输入产生一个硬件中断,被 OS 获取,解释成信号,发送给目标前台进程。因为前台进程只能有 1 个,操作系统方便找到它。
  3. 前台进程因为收到信号,进而引起进程退出。

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ cat sig.cpp
#include <cstdio>
int main() {
    while (1) {
        printf("I am a process, I am waiting signal!\n");
        sleep(1);
    }
}

[Bjarne@VM-8-8-centos cppTest]$ ./sig.exe
I am a process, I am waiting signal!
^C # 因Ctrl+C被终止
[Bjarne@VM-8-8-centos cppTest]$

前台进程和后台进程补充

关于前台进程,在 Linux的进程状态详解-CSDN博客 中有做过一部分描述。这里再进行补充和整理:

  1. Ctrl+c 产生的信号只能发给前台进程,只有前台进程才能接到像 Ctrl+c 这种控制键产生的信号。
  2. Shell 可以同时运行一个前台进程和任意多个后台进程
  3. 前台进程在运行过程中用户随时可能按下 Ctrl+c 而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到 SIGINT 信号而终止,所以信号相对于进程的控制流程来说是异步的。
  4. 通过指令 jobs 可查看用户创建的正在运行的后台进程
  5. 通过 fg 任务编号 将后台进程提到前台。
  6. bg 任务编号 是将 STAT 状态栏显示为 T 的进程和前台进程放在后台继续执行。
  7. 通过 Ctrl+z 将前台进程暂停,同时变为后台进程fg 可将通过 SIGCONT 信号或 Ctrl + z 暂停的进程(STAT 状态栏显示为 T 的进程),以及正在后台运行的进程换到前台继续执行。
  8. 区分前台进程和后台进程,可判断进程是否能通过键盘获取数据能就是前台进程,不能就是后台进程。

对第4点:通过指令 jobs 可查看用户创建的正在运行的后台进程。

例如这个代码:

#include <cstdio>
#include <unistd.h>
int main() {
    while (1) {
        printf("I am a process, I am waiting signal!\n");
        fflush(stdout);
        sleep(1);
    }
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make
g++ sig.cpp -o sig.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./sig.exe >> out.txt &
[1] 1507
[Bjarne@VM-8-8-centos cppTest]$ jobs
[1]+  Running                 ./sig.exe >> out.txt &
[Bjarne@VM-8-8-centos cppTest]$ 

[1] 1507 表示一个后台进程的信息,[1] 表示任务编号,1507 则是进程 PID。

对第 5 点:通过 fg 任务编号 将后台进程提到前台。

[Bjarne@VM-8-8-centos cppTest]$ ./sig.exe &
[1] 4952
I am a process, I am waiting signal!
[Bjarne@VM-8-8-centos cppTest]$ fg 1
./sig.exe
I am a process, I am waiting signal!
I am a process, I am waiting signal!
^C # 只有前台程序可被ctrl+c终止
[Bjarne@VM-8-8-centos cppTest]$ 

对第6点:bg 任务编号 是将 STAT 状态栏显示为 T 的进程和前台进程放在后台继续执行。

[Bjarne@VM-8-8-centos cppTest]$ ./sig.exe
I am a process, PID: 10549, I am waiting signal!
^Z
[1]+  Stopped                 ./sig.exe
[Bjarne@VM-8-8-centos cppTest]$ bg 1 # 进程放在后台继续执行
[1]+ ./sig.exe &
I am a process, PID: 10549, I am waiting signal!
[Bjarne@VM-8-8-centos cppTest]$ fg 1I am a process, PID: 10549, I am waiting signal!
# ...

对第7点:通过 Ctrl+z 将前台进程暂停,同时变为后台进程。fg 可将通过 SIGCONT 信号或 Ctrl + z 暂停的进程(STAT 状态栏显示为 T 的进程),以及正在后台运行的进程换到前台继续执行。

[Bjarne@VM-8-8-centos cppTest]$ ./sig.exe
I am a process, I am waiting signal!
^Z
[1]+  Stopped                 ./sig.exe
[Bjarne@VM-8-8-centos cppTest]$ ps ajx|head -1&&ps ajx|grep sig.exe|grep -v grep
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
24555  7928  7928 24555 pts/3     8077 T     1001   0:00 ./sig.exe
[Bjarne@VM-8-8-centos cppTest]$ kill -SIGCONT 7928
I am a process, I am waiting signal! # 通过18号信号唤醒进程
[Bjarne@VM-8-8-centos cppTest]$ I am a process, I am waiting signal!
I am a process, I am waiting signal! # 新唤醒的进程以后台进程的形式进行
fg 1 # 转前台进程
./sig.exe
I am a process, I am waiting signal!
^C # ctrl+c 终止
[Bjarne@VM-8-8-centos cppTest]$ 

signal系统调用

在计算机中的组合键,例如 Ctrl+c,操作系统会把这个组合键的信息不交给对应的进程,不放到缓存,而是直接转化成某种动作。

Linux 的 kill -l 给出的信号信息中,每个信号的编号本身就很像数组的下标每个进程都维护一张自己对于信号的处理方法的表(猜测还是函数指针数组),当进程收到了信号对应的编号,进程 PCB 就能找到这张表,根据信号编号索引到对应的方法达成各种行为。例如 2 号信号直接或间接调用 exit() 使进程退出。

所以可以猜测,对前台进程按下 Ctrl+c,最终被操作系统解释为 2 号信号交给进程。

验证这个猜测,需要借助操作系统的系统调用 signal 。参考 man 2 signal

#include <signal.h>
typedef void (*sighandler_t)(int); // 函数指针
sighandler_t signal(int signum, sighandler_t handler);

signal() 将信号 signum 的处理方式设置为 handler,但信号 SIGKILL(9号信号) 和 SIGSTOP (19号信号)不能被捕获或忽视。被 signal 修改了行为的信号称被捕获的信号。

也就是说,操作系统的设计者不希望存在所谓的 ”金刚不坏“ 进程,系统永远会保留手段将进程终结。

进程收到了一个信号,就要想办法去处理这个信号。进程被设计好的对信号的初始行为信号处理的默认机制

收到信号的处理动作分 3 类:

  1. 默认行为。即按照原始设定来。
  2. 忽略处理。忽略本身也是对信号的一种处理动作。
  3. 自定义处理动作。自定义行为叫做信号的捕捉动作。

signal 系统调用提供了一种修改进程对信号的默认行为,使进程接收到信号后转而进行自定义行为的方法。只要修改一次,它会在程序当中一直有效。

signal证明Ctrl+C是2号信号

这里证明 Ctrl+c 是 2 号信号的方法,就是通过 signal 修改 2 号信号的行为,然后分别对进程发送 2 号信号和按下 Ctrl+c 键,看二者是否能产生一样的行为。

再次修改代码:

#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;

void asdfg(int hjkl) {//这里函数名和参数乱取,不推荐模仿
    cout << "捕获一个2号信号,特此通知\n";
    exit(1);
}

int main() {
    signal(2, asdfg);
    while (true) {
        printf("I am a process, PID: %d, I am waiting signal!\n", getpid());
        fflush(stdout);
        sleep(1);
    }
    return 0;
}

交互界面:

# 同IP地址界面1
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
I am a process, PID: 15969, I am waiting signal!
I am a process, PID: 15969, I am waiting signal!
^C捕获一个2号信号,特此通知 # 按下Ctrl+C的现象
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
I am a process, PID: 16218, I am waiting signal!
I am a process, PID: 16218, I am waiting signal!
I am a process, PID: 16218, I am waiting signal!
I am a process, PID: 16218, I am waiting signal!
捕获一个2号信号,特此通知 # 通过同IP地址的其他终端发送2号信号
[Bjarne@VM-8-8-centos cppTest]$ 

# 同IP地址界面2
[Bjarne@VM-8-8-centos cppTest]$ kill -2 16218
[Bjarne@VM-8-8-centos cppTest]$ 

这里 Ctrl+c 和 2号信号的行为是一致的,从而完成了证明。

用同样的方法,也可以证明其他的例如段错误、管道错误也是信号。

signal的其他宏行为

还可通过宏 SIG_IGN 使信号恢复默认行为。

#include <iostream>
#include <signal.h>
#include <unistd.h>

using std::cout;

void handler(int sig) {
    cout << "处理" << sig << "\n";
    exit(-1);
}

int main() {
    cout << "进程ID:" << getpid() << "\n";
    signal(2, handler);
    cout << "自定义捕捉信号\n";
    sleep(5);
    signal(2, SIG_DFL); // 恢复默认行为
    cout << "自定义捕捉终止\n";
    while (true)
        sleep(1);
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11 -g
./a.exe
进程ID:21173
自定义捕捉信号
^C处理2 # 信号捕捉成功后的处理方式被更换
make: *** [a.exe] Error 255

[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
进程ID:21271
自定义捕捉信号
自定义捕捉终止
^C # 信号捕捉成功后的处理方式恢复默认
[Bjarne@VM-8-8-centos cppTest]$ 

这里的 SIG_DFL 在 C 语言的视角中也是一个常量值。

//头文件signum.h
/* Fake signal functions.  */
#define SIG_ERR	((__sighandler_t) -1)		/* Error return.  */
#define SIG_DFL	((__sighandler_t) 0)		/* Default action.  */
#define SIG_IGN	((__sighandler_t) 1)		/* Ignore signal.  */

//头文件signal.h
/* Type of a signal handler.  */
typedef void (*__sighandler_t) (int);

上传 SIG_IGN 表示进程将忽视这个信号。这里不再给出测试。

其他组合键

除了 Ctrl+C ,还有其他组合键也能使操作系统向当前的前台进程发送信号。这些组合键可通过命令 stty -a 查看。

[Bjarne@VM-8-8-centos cppTest]$ stty -a
speed 38400 baud; rows 15; columns 136; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = M-^?; eol2 = M-^?; swtch = <undef>; start = ^Q; stop = ^S; susp = ^Z;
rprnt = ^R; werase = ^W; lnext = ^V; flush = ^O; min = 1; time = 0;
-parenb -parodd -cmspar cs8 hupcl -cstopb cread -clocal -crtscts
-ignbrk brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff -iuclc ixany imaxbel iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt echoctl echoke
[Bjarne@VM-8-8-centos cppTest]$ 

这些信号大致可分成如下几种:

  1. 进程控制信号。
  2. 行编辑控制。
  3. 数据流控制(已基本过时)。
  4. 输入结束与控制字符。
进程控制信号

这里将解释 stty -a 展示的组合键。尽管很多组合键可能用不上,但不排除可能会用。

intr = ^CCtrl+c,表示中断,对应信号 SIGINT (2) ,功能是终止前台进程,但可被捕获。

quit = ^\Ctrl+\,表示退出,对应信号 SIGQUIT (3) ,强制终止前台进程,并尝试生成核心转储(若系统允许)。

erase = ^?Ctrl+z,表示挂起,对应信号 SIGTSTP (20) ,暂停前台进程,将其放入后台作业列表,被暂停的进程可用 fg/bg 恢复。

Core Dump 核心转储

SIGINT 的默认处理动作是终止进程,SIGQUIT 等信号的默认处理动作是终止进程并且Core Dump。

解释什么是 Core Dump :当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上形成临时文件,文件名通常是 core.PID,路径是引起进程崩溃的目录,这叫做 Core Dump。

不同的 Linux ,或不同的操作系统,核心转储文件名可能不同。这里只讨论 CentOS 的情况。

进程异常终止通常是因为有 Bug ,比如非法内存访问导致段错误,事后可以用调试器检查 core 文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)。

一般报 core 的问题,一方面是错误比较严重,另一方面是报错的原因可能尚不清楚,需要进一步排查。

一个进程允许产生多大的 core 文件取决于进程的 Resource Limit,这个信息保存在PCB中。默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全,且产生的 core 文件在进程一直报错时,会大量产生重复的 core 文件占据磁盘,严重的情况下会引起操作系统崩溃。

在一般的企业,最终开发出来的所有的软件,最后都是要在企业的云服务器上,或者在后台服务器上运行。服务器必须在线上 7 × 24 7\times 24 7×24 小时一直运行,一旦某个重要的进程挂掉了,此时这个进程关联的服务,都要被对应的一些人或者是其他程序把它修复,这对于保证服务的正常运行,对于企业来说是一件非常重要的事情。

所以企业一般也愿意花钱雇很多人,帮企业维护整个服务器后台的服务的健康状态,由此衍生出一个职位:运营维护。

随着自动化技术的发展和企业降本的需要,很多企业大都已经慢慢开始展示自动化运维了。即有些机器当前是否健康,是否出问题,是可以用机器来管理的。毕竟数量一增多就会出现管理成本,这个时候就需要有一些人专门去编写自动化运维的软件。

线上某些服务挂掉了,企业也要能够让软件去自动化地把已经挂掉了的服务重新运行起来。特别是半夜,或者其他访问量少的时候,可根据情况进行长达数小时的重启。

为了能支撑这些情况,云服务器一般是讲 Core Dump 给禁止掉的。但虚拟机就可能将 Core Dump 打开。

在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。

首先用 ulimit -c 大小 命令改变 Shell 进程的 Resource Limit ,例如这里允许 core 文件最大为 10240K 。这个设置,它只是内存级的设置,系统重启后会自动失效。

[Bjarne@VM-8-8-centos cppTest]$ ulimit -a | grep core
core file size          (blocks, -c) 0
[Bjarne@VM-8-8-centos cppTest]$ ulimit -c 10240
[Bjarne@VM-8-8-centos cppTest]$ ulimit -a | grep core
core file size          (blocks, -c) 10240
[Bjarne@VM-8-8-centos cppTest]$ 

然后这里写一个会报错的程序:

int main() {
    int *p=0;
    while(1)
        ;
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ g++ a.cpp -o a.exe -g
[Bjarne@VM-8-8-centos cppTest]$ ls
a.cpp  a.exe  makefile  man
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
Segmentation fault (core dumped)
[Bjarne@VM-8-8-centos cppTest]$ ls -l
total 264
-rw-rw-r-- 1 Bjarne Bjarne    155 Feb  3 03:11 a.cpp
-rwxrwxr-x 1 Bjarne Bjarne  19296 Feb  3 03:11 a.exe
-rw------- 1 Bjarne Bjarne 557056 Feb  3 03:12 core.19360 # 这个文件
-rw-rw-r-- 1 Bjarne Bjarne    263 Feb  1 20:46 makefile
drwxrwxr-x 2 Bjarne Bjarne   4096 Feb  3 00:44 man
[Bjarne@VM-8-8-centos cppTest]$ 

ulimit 命令改变了 Shell 进程的 Resource Limit,test 进程的 PCB 由 Shell 进程复制而来,所以也具有和 Shell 进程相同的 Resource Limit 值,这样就可以产生 Core Dump 了。 在调试时可使用 core 文件:

[Bjarne@VM-8-8-centos cppTest]$ gdb a.exe # 调试
# 省略gdb弹出的各种信息
(gdb) core-file core.19360
[New LWP 19360]
Core was generated by `./a.exe'.
Program terminated with signal 11, Segmentation fault.
#0  0x000000000040065d in main () at a.cpp:9
9               *p = 100; # 能定位错误在哪
Missing separate debuginfos, use: debuginfo-install glibc-2.17-326.el7_9.3.x86_64 libgcc-4.8.5-44.el7.x86_64
(gdb) q
[Bjarne@VM-8-8-centos cppTest]$ 

但即使是打开了 Core Dump ,也仅局限于当前的 bash ,再开辟新的 bash 会发现Core Dump 还是关闭的。一旦给当前的 bash 进程设置了,相当于未来就可以给其他子进程也进行设置。
请添加图片描述

行编辑控制

这些组合键用于在输入命令时,在按下回车前编辑当前行,由终端本地处理,不会发送信号。

erase = ^?Ctrl+zBackspace ,功能是删除光标的前一个字符。

kill = ^UCtrl+u,删除从光标到行首的所有字符。

werase = ^WCtrl+w,删除光标前的一个单词(以空格分隔)。

lnext = ^VCtrl+w,使紧随其后的控制字符(如^C)被当作普通字符输入,而不是触发信号。

rprnt = ^RCtrl+r,刷新并重新显示当前输入的行(在输入被其他输出冲乱时有用)。

flush = ^OCtrl+o,丢弃尚未显示的待输出内容(现已较少使用)。

数据流控制(已基本过时)

这些组合键用于控制终端的数据流,不发送信号,主要出现在串口终端时代。

start = ^QCtrl+q ,在输出被 Ctrl+S 暂停后,恢复向屏幕输出数据。

stop = ^SCtrl+s ,立即暂停向屏幕输出数据(进程仍在运行)。这是终端“假死”的常见原因,按 Ctrl+q 可恢复。

输入结束与控制字符

eof = ^D:文件结束符,Ctrl+d 。不是信号,当输入行为空时发送,使 read 返回0,表示输入结束。常用于退出交互式程序(如 bash)。

eol = M-^?eol2 = M-^?:行结束符,Alt+Delete ,替代的行结束符,很少使用。

swtch = <undef> :切换 Shell 层,未设置组合键,在作业控制 Shell 中切换不同进程组,现已很少配置。。

调用系统函数向进程发信号

arise

raise 函数可以给当前进程发送指定的信号,或者说自己给自己发信号。

参考 man 3 arise

#include <signal.h>
int raise(int sig);

raise 运行成功返回 0 ,错误返回 -1 。

raise 的使用示例:

#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;

void handler(int sig) {
    cout << "检测到" << sig << "号新号" << "\n";
    exit(1);
}

int main() {
    signal(2, handler);
    for (int i = 1;; i++) {
        printf("I am a process, PID: %d, I am waiting signal!\n", getpid());
        fflush(stdout);
        if (i == 3)
            raise(2);//条件自我终结
        sleep(1);
    }
    return 0;
}

kill

kill 命令是调用 kill 函数实现的。kill 函数可以给一个指定的进程发送指定的信号。

参考 man 2 kill

#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);

kill 也是运行成功返回0,错误返回-1。

使用 kill 可以模拟 kill 命令。

例如 mykill.cpp:

#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;

int main(int argc, char *argv[]) {//使用方法:./mykill -2 PID
    int sig = std::stoi(argv[1]);
    sig = sig < 0 ? sig * -1 : sig;
    cout << sig << "\n";
    kill(std::stoi(argv[2]), sig);
    return 0;
}

这里还可以添加用户手册,若用户使用不当可触发手册。这里省略。

abort

abort 函数使当前进程接收到 6 号信号 SIGABRT 而异常终止。

#include <stdlib.h>
void abort(void);

就像 exit 函数一样,abort 函数总是会成功的,所以没有返回值。

这里展示如何使用。

#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;

int main() {
    for (int i = 3; i >= 1; i--) {
        cout << i << "\n";
        sleep(1);
    }
    abort();
    return 0;
}

在 MSVC ,使用断言 assert 效果上等价于先 fprintf 一段错误信息,再 abort。而 abort 则是直接终止不给选择。

异常产生信号

这里的异常并非 C++ 的异常语法,而是有意或无意地生成的错误,例如段错误、除零错误等。在 C/C++ 当中除零错误内存越界等异常,在系统层面上,是被当成信号处理的。

除零错误原本只是数学的错误,计算机在设计时是可以设计成输出的结果为无穷大。但这在实际应用时会对现实生活造成严重影响,例如某账户的银行卡,因为某个除零错误,导致存款变成无穷大。

所以进程考虑到这层原因,将除零错误作为浮点异常的一种。

CPU 当中有一个寄存器叫状态寄存器。状态寄存器在 CPU 中只有 1 个,类似位图,可通过多个比特位表示不同的状态,例如零标志 (ZF)(运算结果等于 0 时触发)、溢出标志 (OF)(有符号数运算结果超出表示范围时触发)等。

这里以除 0 错误为例。 0 错误浮点数运算的情况下会触发浮点状态寄存器的标记,但在整型的情况下会触发硬件异常。但无论如何,一旦出现除零,CPU 内的状态标记位会标记,然后通过自带的一些针脚告诉操作系统出的问题,操作系统会调用 kill 接口对进程发送信号。所以操作系统也可以把 CPU 内部各种硬件错误解释成信号,这些错误由进程产生。

有了这些标志位,进程出现错误时会被标记然后等待被处理。因为 CPU轮流执行各种进程,所以出现错误的进程在被阻塞时会拷贝一份状态寄存器的数据作为硬件上下文的一部分,到下一次执行错误进程时,进程再将数据拷贝回去,CPU 执行同样的语句检测到进程被标记了某种错误,就会触发异常产生信号。若用户规定了某种信号的行为,使进程不会退出,则每次执行该进程都会执行那个函数,看起来就像死循环一样。

为了验证这个过程,这里给出一个案例,将除零错误触发的浮点错误的默认动作更改,使得进程不会被终止:

#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;

void handler(int sig) {
    cout << "收到一个信号:" << sig << "\n";
    sleep(1);
}

int main() {
    signal(8, handler);
    int a = 10;
    a /= 0;
    return 0;
}

运行程序的交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make # make 的首条依赖包含2个指令
g++ a.cpp -o a.exe -std=c++11
a.cpp: In function ‘int main()’: # g++警告代码中带除0错误,为测试继续运行
a.cpp:16:7: warning: division by zero [-Wdiv-by-zero]
     a /= 0;
       ^
./a.exe
第0次收到信号:8
第1次收到信号:8
第2次收到信号:8
第3次收到信号:8
^Cmake: *** [a.exe] Interrupt

[Bjarne@VM-8-8-centos cppTest]$ 

运行结果验证了猜想,进程引起了硬件问题,然后被 CPU 通知操作系统,最后操作系统把异常问题转化成信号问题,向目标进程的位图中写入信号。

不要指望操作系统把这个标志位再恢复成零,进程继续运行,自己出的问题,就得自己背锅。

段错误也是同样的原理,通过虚拟地址查找物理地址时,发现物理地址不能用,于是触发另外的硬件异常, MMU(Memory Management Unit,内存管理单元)产生异常,被操作系统内核解释为信号 SIGSEGV 发送给进程。

由软件条件产生信号

在使用管道时,关闭读端但写端仍然进行写入,操作系统会直接想进程发送 SIGPIPE 信号。

SIGPIPE 信号是一种由软件条件产生的信号,也就是说产生信号和异常的来源也可以是软件。因为操作系统是软硬件资源的管理者,哪一个出问题了,操作系统都要处理。

只要软硬件出现异常了,一定是进程收到了信号,但进程收到了信号就不一定是异常了。软件可根据需求产生信号。例如闹钟 alarm 和 14 号信号 SIGALARM 。

alarm

参考 man 2 alarm

#include <unistd.h>
unsigned int alarm(unsigned int seconds);

alarm 安排在当前进程运行 seconds 秒后递送一个 SIGALRM 信号,但有个例外:如果 seconds 为 0,任何待处理的闹钟都将被取消,无论如何(即无论新的 seconds 参数是多少),任何先前设置的 alarm 都会被取消。

alarm 返回先前设置的闹钟距离预定递送还剩余的秒数;如果没有先前设置的闹钟,则返回 0。

alarm 产生的信号也是操作系统写进程的,操作系统是如何知道哪个闹钟超时了?

目前可以想到的是,操作系统维护一个数据结构,可能是红黑树或堆。通过这个数据结构维护的闹钟是有序的,当最头部的一个闹钟超时了,就将这个闹钟替换,直到最头部的闹钟没有超时即可。

例如这里设置一个闹钟,5 秒后进程自动终结。

#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;

int cnt = 0;

void handler(int sig) {
    cout << "闹钟响了,进程结束\n";
    exit(0);
}

int main() {
    signal(SIGALRM, handler);
    alarm(5);
    for (int i = 5;; i--) {
        cout << i << "\n";
        sleep(1);
    }
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
./a.exe
闹钟响了,进程结束
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
./a.exe
5
4
3
2
1
闹钟响了,进程结束
[Bjarne@VM-8-8-centos cppTest]$ 

可在某一时刻调用 alarm(0) 取消之前的闹钟。

例如这个程序,在枚举到 2 时取消闹钟。

#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;

int cnt = 0;

void handler(int sig) {
    cout << "闹钟响了,进程结束\n";
    exit(0);
}

int main() {
    signal(SIGALRM, handler);
    alarm(5);
    for (int i = 5; i >= -5; i--) {
        cout << i << "\n";
        sleep(1);
        if (i == 2)//枚举到2时取消闹钟
            alarm(0);
    }
    return 0;
}

在现实生活中,某人设置的 6 点闹钟响了,于是他把闹钟关了,然后 6 点半又响了。在代码中实现这个过程可通过静态局部变量或全局变量表示。

例如这个代码,闹钟每 2 秒响一次。若不加输出的话,这个代码就是一个模拟实现的 sleep 函数。

#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;

void handler(int sig) {
    static int num = 0;
    cout << "第" << ++num << "个闹钟响了\n";
    alarm(2);
}

int main() {
    signal(SIGALRM, handler);
    alarm(2);
    for (int i = 5; i >= 0; i--) {
        cout << i << "\n";
        sleep(1);
    }
    return 0;
}

alarm返回值是上一个闹钟剩余的时间,若之前没设置闹钟,则返回0。

这个特性可由这个代码验证。

#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;

int cnt = 0;

void handler(int sig) {
    cnt = alarm(5); // alarm返回上个闹钟剩余的时间
    if (sig != SIGALRM) {
        cout << cnt << "\n";
        cout << "信号不是闹钟,结束\n";
        exit(0);
    }
    cout << "闹钟响了\n";
}

int main() {
    signal(SIGALRM, handler); // 设置闹钟行为
    signal(2, handler);       // 设置ctrl+c
    cnt = alarm(5);
    cout << "cnt=" << cnt << "\n";
    for (int i = 100; i >= 0; i--) {
        cout << i << "\n";
        sleep(1);
    }
    return 0;
}

在代码运行的某一时刻,按 Ctrl+c 结束程序。

[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11
./a.exe
cnt=0
100
99
98
97
96
闹钟响了
95
94
^C3 # ctrl + c
信号不是闹钟,结束

[Bjarne@VM-8-8-centos cppTest]$ 

重新理解缓冲区

使用 coutscanf 向终端打印数据本身就很费时。可通过这个程序进行对比:

#include <iostream>
#include <signal.h>
#include <unistd.h>
using std::cout;

int cnt = 0;
bool flag = 0;

void handler2(int sig) {
    cout << "只计时的情况下,cnt=" << cnt << "\n";
    exit(0);
}

void handler(int sig) {
    cout << "边输出边计时的情况下,cnt=" << cnt << "\n";
    flag = 1;
}

int main() {
    signal(SIGALRM, handler); // 设置闹钟行为
    alarm(1);
    while (!flag) {
        cout << ++cnt << "\n";
    }

    cnt = 0;
    signal(SIGALRM, handler2);
    alarm(1);
    while (1) {
        ++cnt;
    }
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
# 这里省略一大堆数字
142758
边输出边计时的情况下,cnt=142758
142758
只计时的情况下,cnt=560809511
[Bjarne@VM-8-8-centos cppTest]$ 

对比 cnt 的大小就可以看出差距。这也是为什么 C 语言要设置缓冲区,待数据囤积一定数量之后再统一通过 I/O 设备输出的原因。

一般的计算机,即使关机一段时间后,再次开机,操作系统仍然记得当前的准确时间点。

这是因为电脑内置了纽扣电池,这个电池一直在给某个硬件供电(实时时钟芯片),这个硬件的功能是固定时间间隔进行计数,当电脑开机后,这个数字可直接转换成时间戳,进而转换成正常的时间。

加深对操作系统的理解

硬件。详细见计算机组成原理等。

计算机中存在一种硬件 CMOS 。它可以以周期性、高频率地向 CPU 发送时钟中断的电信号。计算机组成原理中,评价 CPU 的好坏里面有个指标叫做 CPU 的主频是多少,用于衡量 CPU 执行指令的速度。

软件。

操作系统本质也是进程或者说进程组主体就是一个死循环。操作系统一旦启动后,将一些初始化工作全部做好,然后就一直处于一个 pos 暂停状态。

//操作系统的本质
//这个结构可以在雷纳斯·托瓦茨的早期作品中找到
int main(){
    //各种初始化工作
    for(;;){
        //...
        pause();
    }
}

结合。

CPU 不断给时钟发中断,计算机启动的时候,它会有一个中断向量表。CMOS 以非常高的频率一直在给 CPU 发送时钟中断,时钟中断一旦触发,它就有时钟中断的编号,转而执行中断向量表指向的方法,这个方法最终被 CPU 执行。

所以在这个 CMOS 的驱动之下,操作系统才会运行。除了 CMOS,计算机里还有各种各样的其他硬件,也会发送各种终端,因此操作系统的执行是基于中断的

所以操作系统是个死循环进程或进程组,它在启动之后什么工作都没做,只是暂停了,然后由外部的不断来刺激它,然后让它去执行它的代码。

信号的保存和阻塞

信号递达

实际执行信号的处理动作(函数指针指向的函数)称为信号递达 (Delivery) 。

信号的后续处理动作有以下三种:

  1. 忽略此信号。

  2. 执行该信号的默认处理动作。

  3. 用户提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。

信号的忽略、信号的默认行为和信号的自定义捕捉,这三种方式都叫做信号的递达。

之前的宏:

//头文件signum.h
/* Fake signal functions.  */
#define SIG_ERR	((__sighandler_t) -1)		/* Error return.  */
#define SIG_DFL	((__sighandler_t) 0)		/* Default action.  */
#define SIG_IGN	((__sighandler_t) 1)		/* Ignore signal.  */

//头文件signal.h
/* Type of a signal handler.  */
typedef void (*__sighandler_t) (int);

这里的宏是将 -1、0、1 进行强转,不是让用户去访问的,而是让进程去判断,到底如何处理这个信号。

信号未决和阻塞

信号从产生到递达之间的状态,称为信号未决 (Pending) 。

大白话就是,这个信号产生了,但当前进程可能在做着别的事,所以产生的信号无法被立即处理,它需要在合适的时候处理。

这时就要求进程有一个对信号进行保存的能力。

进程可以选择阻塞 (Block )某个信号。被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。

注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的一种处理动作。

即阻塞是进程根本就没有处理这个信号,而是让信号一直处于未决状态,直到进程解除对该信号的阻塞。

而忽略本身本身就是递达的一种。

一个信号是未决的,不代表该信号一定被阻塞了,而是因为各种原因没能送达,例如第 3 方进程拦截操作系统发送给目标进程的信号。

信号在Linux内核中的表现

信号在 Linux 内核中的表现如下图:
请添加图片描述

每个信号都有 2 个标志位分别表示阻塞 (block) 和未决 (pending) ,还有 1 个函数指针数组 (handler) 表示处理动作。前 2 个标志位 (block 和 pending) 表示的表格是完全一样的位图结构,block 表(信号屏蔽字)的比特位的内容表示的含义是是否对特定的信号进行屏蔽或阻塞, pending 表(未决信号集)则表示信号是否未决。

参考曾经的权限掩码。当特定 bit 位被设定为 1 时,默认生成的文件就会被剥夺特定的权限。

信号产生时,内核在进程控制块中将信号编号作为索引设置该信号的未决标志直到信号递达才清除该标志。所以进程对于信号是预先设置好再去处理

在上图的例子中:

  • SIGHUP 信号未阻塞也未产生过,当它递达时执行默认处理动作。
  • SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。也就是说,进程要忽略信号,可以更改 pending 表的指定比特位。
  • SIGQUIT 信号未产生过,但因为阻塞标志位被标记为1,所以一旦产生信号将被阻塞,它的处理动作是用户自定义函数 sighandler。

block 表、pending 表和 handler 表的同一索引表示某个具体的信号在进程中的情况。有了这 3 张表,进程才能去进行对信号的识别。操作系统要发送信号给进程,所有操作必须围绕着这三张表展开。

操作系统向目标进程发信号,本质是向目标进程中的 pending 表(也可以叫 pending 信号集)写信号。

如果在进程解除对某信号的阻塞之前这种信号产生过多次,POSIX.1允许系统递送该信号一次或多次。Linux 是这样实现的:常规信号即编号为 [1,31] 的信号在递达之前产生多次只计 1 ,而实时信号在递达之前产生多次可以依次放在一个队列里。这里不讨论实时信号。

即如果触发了多次信号发送的判定,它最后接收到的永远都是最新一次。因为 block 表、pending 表都是位图。

[1,31] 号信号只会被记录 1 次。

信号集sigset_t

从上图来看,每个信号只有一个 bit 的未决标志,非 0 即 1 ,不记录该信号产生了多少次,阻塞标志也是这样表示的。

因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储,sigset_t 称为信号集,这个类型可以表示每个信号的 “有效” 或 “无效” 状态。

sigset_t 的原型:

//select.h
typedef __sigset_t sigset_t;

//sigset.h
# define _SIGSET_NWORDS	(1024 / (8 * sizeof (unsigned long int)))
typedef struct
  {
    unsigned long int __val[_SIGSET_NWORDS];
  } __sigset_t;

操作系统不光要给用户提供对应的系统调用接口,能够修改这 3 张表,还要给用户提供对应的数据类型,在往后对位操作,直接对这个数据类型填充即可。

还是因为部分用户的 C 语言功底不够,不放心用户的未操作。

  • 阻塞信号集中 “有效” 和 “无效” 的含义是该信号是否被阻塞。对应 block 表的位图的指定比特位是 1 还是 0。
  • 未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。对应 pending 表的位图的指定比特位是 1 还是 0。

阻塞信号集 ( block 表) 也叫做当前进程的信号屏蔽字 ( Signal Mask ) ,这里的 “屏蔽” 应该理解为阻塞不是忽略。

sigset_t 类型对于每种信号用一个 bit 表示 “有效” 或 “无效” 状态,至于这个类型内部如何存储这些 bit 则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用特定函数来操作 sigset_ t 变量,而不应该对它的内部数据做任何解释,比如用 printf 直接打印sigset_t 变量是没有意义的。

sig系列信号操作函数

修改sigset_t的接口

这些都是 Linux 提供的系统调用,目的是修改 sigset_t 对象。

#include <signal.h>
int sigemptyset(sigset_t *set); // 初始化
int sigfillset(sigset_t *set); // 初始化
int sigaddset (sigset_t *set, int signo); // 添加指定信号
int sigdelset(sigset_t *set, int signo); // 删除指定信号
int sigismember(const sigset_t *set, int signo); // 查看信号
  • 函数 sigemptyset 初始化 set 所指向的信号集,使其中所有信号的对应 bit 清零,表示该信号集不包含任何有效信号
  • 函数 sigfillset 初始化 set 所指向的信号集,使其中所有信号的对应 bit 置位,表示 该信号集的有效信号包括系统支持的所有信号
  • 注意,在使用 sigset_ t 类型的变量之前,一定要调用 sigemptysetsigfillset 做初始化使信号集处于确定的状态,否则结果未定义。初始化 sigset_t 变量之后就可以在调用sigaddsetsigdelset 在该信号集中添加或删除某种有效信号

这四个函数都是成功返回 0 ,出错返回 -1 。sigismember 是一个 bool 函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回 1 ,不包含则返回 0 ,出错返回 -1 。

sigprocmask上传信号

sig 即 signal ,表示信号;proc 即 process,表示进程;mask 就是信号屏蔽字,即 block 表。

使用信号操作函数修改 sigset_t 对象后,相当于得到了一份用户希望进程对所有信号的处理方式的 “文书” ,最后通过 sigprocmask 上传修改进程中信号的 block 表。

调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字 (阻塞信号集) 。

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 

返回值:若成功则为 0 ,若出错则为 -1 。

如果 oset 是非空指针,则读取进程的当前信号屏蔽字通过 oset 参数传出。

如果 set 是非空指针,则更改进程的信号屏蔽字,参数 how 指示如何更改。

如果 osetset 都是非空指针,则先将原来的信号屏蔽字备份到 oset 里,然后根据 sethow 参数更改信号屏蔽字。

假设当前的信号屏蔽字为 mask ,下表说明了 how 参数的可选值。

SIG_BLOCK set包含了我们希望添加到当前信号屏蔽字的信号,相当于mask=mask|set
SIG_UNBLOCK set包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于mask=mask&~set
SIG_SETMASK 设置当前信号屏蔽字为set所指的值,相当于mask=set

如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞,则在 sigprocmask 返回前,至少将其中一个信号递达。

这里对 2 号信号进行屏蔽,5 秒后又将信号屏蔽字接触屏蔽,展示这些信号操作函数如何使用。

#include <iostream>
#include <signal.h>
#include <unistd.h>

using std::cout;

void handler(int sig) {
    cout << "处理" << sig << "号信号\n";
    exit(-1);
}

int main() {
    // 使2号信号处理函数handler
    signal(2, handler);

    // 新、旧信号屏蔽字
    sigset_t block, old_block;
    // 初始化
    sigemptyset(&block);
    sigemptyset(&old_block);

    // 新增对2号信号的屏蔽,但只修改了sigset_t对象,还未上上传
    sigaddset(&block, 2);
    // // 删除对 2 号信号的屏蔽,同样只修改了 sigset_t 对象
    // // 解除注释将对sigaddset对block的设置进行清空
    // sigdelset(&block, 2);
    // 上传,让操作系统屏蔽信号
    sigprocmask(SIG_BLOCK, &block, &old_block);
    cout << "进程PID:" << getpid() << "已屏蔽2号信号\n";
    for (int i = 1;; i++) {
        sleep(1);
        if (i == 3) {
            cout << "接下来将解除对2号信号的屏蔽\n";
            sleep(2);
            // 让操作系统解除屏蔽信号,解除瞬间进程会收到信号
            sigprocmask(SIG_UNBLOCK, &block, nullptr);
        }
    }
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11 -g
./a.exe
进程PID:14308已屏蔽2号信号
^C^C^C接下来将解除对2号信号的屏蔽
处理2号信号
make: *** [a.exe] Error 255

[Bjarne@VM-8-8-centos cppTest]$ 

这里对 2 号信号进行了屏蔽,在屏蔽阶段通过 Ctrl+c 发送 2 号信号,但在解除瞬间,进程会执行 2 号信号的后续动作。

sigpending

信号的产生,无论是异常,还是键盘,或者是系统调用、软件条件等等,它们其实都是在修改 pending 位图。而 sigpending 的作用是获取进程的 pending 位图。

#include <signal.h>
int sigpending(sigset_t *set);

sigpending 读取当前进程的未决信号集,通过 set 参数传出。调用成功则返回 0 ,出错则返回 -1 。

这里再次修改程序:

#include <iostream>
#include <signal.h>
#include <unistd.h>

using std::cout;

void handler(int sig) {
    cout << "处理" << sig << "号信号\n";
    exit(64);
}

void checkpending() {
    // 初始化
    sigset_t pending;
    sigemptyset(&pending);

    // 获取pending表
    if (sigpending(&pending) == -1)
        exit(-1);
    for (int i = 1; i < 32; i++) {
        if (sigismember(&pending, i)) // 检查信号
            cout << 1;
        else
            cout << 0;
    }
    cout << "\n";
}

int main() {
    // 使2号信号处理函数handler
    signal(2, handler);

    // 新、旧信号屏蔽字
    sigset_t block, old_block;
    // 初始化
    sigemptyset(&block);
    sigemptyset(&old_block);

    // 新增对2号信号的屏蔽,但只修改了sigset_t对象,还未上上传
    sigaddset(&block, 2);
    // 上传,让操作系统屏蔽信号
    sigprocmask(SIG_BLOCK, &block, &old_block);
    cout << "进程PID:" << getpid() << "已屏蔽2号信号\n";

    for (int i = 1;; i++) {
        checkpending();
        sleep(1);
        if (i == 3) {
            cout << "接下来将解除对2号信号的屏蔽\n";
            sleep(2);
            // 让操作系统解除屏蔽信号,解除瞬间进程会收到信号
            sigprocmask(SIG_UNBLOCK, &block, nullptr);
        }
    }
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11 -g
./a.exe
进程PID:17390已屏蔽2号信号
0000000000000000000000000000000
^C0100000000000000000000000000000
0100000000000000000000000000000 # 可以看到,2号信号已被接受,但被阻塞了
接下来将解除对2号信号的屏蔽
处理2号信号
make: *** [a.exe] Error 64

[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
进程PID:18287已屏蔽2号信号
0000000000000000000000000000000
^\Quit # 别的信号终止了进程
[Bjarne@VM-8-8-centos cppTest]$ 

程序运行时,每秒钟把各信号的未决状态打印一遍,由于这里阻塞了 SIGINT 信号,按 Ctrl+c 将会使 SIGINT 信号处于未决状态,按Ctrl+\ 仍然可以终止程序,因为SIGQUIT 信号没有阻塞。

这里再修改代码,测试处理信号前后,pending 表何时被改。

#include <iostream>
#include <signal.h>
#include <unistd.h>

using std::cout;

#define Checkpending \
    do { \
        sigset_t pending; \
        sigemptyset(&pending); \
        if (sigpending(&pending) == -1) \
            exit(-1); \
        for (int i = 1; i < 32; i++) \
            if (sigismember(&pending, i)) \
                cout << 1; \
            else \
                cout << 0; \
        cout << "\n"; \
    } while (0)

void handler(int sig) {
    cout << "处理" << sig << "号信号\n";
    Checkpending;
    exit(64);
}

int main() {
    // 使2号信号处理函数handler
    signal(2, handler);

    // 新、旧信号屏蔽字
    sigset_t block, old_block;
    // 初始化
    sigemptyset(&block);
    sigemptyset(&old_block);

    // 新增对2号信号的屏蔽,但只修改了sigset_t对象,还未上上传
    sigaddset(&block, 2);
    // 上传,让操作系统屏蔽信号
    sigprocmask(SIG_BLOCK, &block, &old_block);
    cout << "进程PID:" << getpid() << "已屏蔽2号信号\n";

    for (int i = 1;; i++) {
        cout << "进程" << getpid() << " 正常运行\n";
        if (i == 3) {
            cout << "开始解除对2号信号的屏蔽\n";
            sleep(2);
            Checkpending;
            sigprocmask(SIG_UNBLOCK, &block, nullptr);
        }
        sleep(1);
    }
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11 -g
./a.exe
进程PID:9221已屏蔽2号信号
进程9221 正常运行
^C进程9221 正常运行
进程9221 正常运行
开始解除对2号信号的屏蔽
0100000000000000000000000000000 # 处理前pending2号位为1
处理2号信号
0000000000000000000000000000000 # 处理时2号位已变为0
make: *** [a.exe] Error 64

[Bjarne@VM-8-8-centos cppTest]$ 

说明进程调用信号捕捉函数之前,它的 pending 位图直接由一改成零,然后转而去调用信号捕捉方法。

信号处理常见方式

信号的后续处理动作有以下三种:

  1. 忽略此信号。

  2. 执行该信号的默认处理动作。

  3. 用户提供一个信号处理函数,要求内核在处理该信号时切换到用户态执行这个处理函数,这种方式称为捕捉(Catch)一个信号。

Linux的信号处理

信号的默认动作可通过 man 7 signal 可查看信号的默认动作。

这里提取出表格内容并进行翻译。

信号(Signal) 信号值(Value) 行为(Action) 说明(Comment) 说明的翻译
SIGHUP 1 Term Hangup detected on controlling terminal or death of controlling process 在控制终端上检测到挂起或控制进程死亡
SIGINT 2 Term Interrupt from keyboard 来自键盘的中断
SIGQUIT 3 Core Quit from keyboard 来自键盘的退出
SIGILL 4 Core Illegal Instruction 非法指令
SIGABRT 6 Core Abort signal from abort(3) 来自 abort(3) 的中止信号
SIGFPE 8 Core Floating point exception 浮点异常
SIGKILL 9 Term Kill signal 终止信号
SIGSEGV 11 Core Invalid memory reference 无效内存引用
SIGPIPE 13 Term Broken pipe: write to pipe with no readers 管道破裂:向没有读取者的管道写入
SIGALRM 14 Term Timer signal from alarm(2) 来自 alarm(2) 的定时器信号
SIGTERM 15 Term Termination signal 终止信号
SIGUSR1 30,10,16 Term User-defined signal 1 用户定义信号 1
SIGUSR2 31,12,17 Term User-defined signal 2 用户定义信号 2
SIGCHLD 20,17,18 Ign Child stopped or terminated 子进程停止或终止
SIGCONT 19,18,25 Cont Continue if stopped 如果停止则继续
SIGSTOP 17,19,23 Stop Stop process 停止进程
SIGTSTP 18,20,24 Stop Stop typed at terminal 在终端输入的停止
SIGTTIN 21,21,26 Stop Terminal input for background process 后台进程的终端输入
SIGTTOU 22,22,27 Stop Terminal output for background process 后台进程的终端输出

信号的行为解读:

行为 解释
Term 默认动作是终止进程。
Ign 默认动作是忽略该信号。
Core 默认动作是终止进程并生成核心转储(参见 core(5))。
Stop 默认动作是停止进程。
Cont 默认动作是如果当前已停止,则继续进程。

Linux 一半以上的信号的默认处理动作是让进程终止或暂停。这是因为 UNIX 的早期设计哲学:信号是“软件中断”,用于异步通知进程发生了异常事件;对这些事件的默认反应要么是立即退出,要么是为作业控制而暂停。 这一设计随着 POSIX 标准被 Linux 继承。

内核态和用户态

进程要处理一个信号,前提条件是进程知道它收到了信号,并在合适的时候查进程自身的 pending 位图、block 位图和 handler 表,这些都属于操作系统内核的数据结构,一般的进程无法直接查阅。说明进程一定要处在一种权限有限开放的状态,才能查阅这些数据结构。

而信号从操作系统发出之后,对整个信号的处理是由进程自己完成的。

且进程收到信号时,可能正在做着优先级更高的事,所以它可能来不及处理这个信号,就将信号暂时保存起来,在后续合适的时候再处理这个信号。

内核态和用户态的概念

处理信号合适的时候要求操作系统对进程访问内核数据结构的权限有限开放。

这个合适的时候就是进程从内核态返回到用户态的时候。当进程从内核态返回到用户态的时候,进行信号的检测和处理。因为此时操作系统执行的内核代码流程基本完毕,在返回时对信号进行检测和处理是最合适的。

void kernel_return_to_user() {
    // ... 各种重要的流程
    // 只要属于这个框架内的代码都属于内核态
    // 检查是否有未决信号
    if (has_pending_signal(current_task)) {
        // 选择要处理的信号(按优先级)
        int sig = get_next_signal();

        if (sig_disposition == SIG_DFL) {
            if (default_is_terminate(sig))
                do_exit();        // 直接在内核里终止,不返回用户态
            else if (default_is_stop(sig))
                set_task_stopped();
        } else if (sig_disposition == SIG_IGN) {
            // 什么都不做
        } else { 
            // 用户自定义处理函数
            setup_user_stack_to_call_handler(sig, handler);
            // 下次返回用户态时将先执行 handler
        }
    }
    // 3. 真正执行返回汇编指令(iret / sysret)
    iret_to_usermode();
}

用户态内核态是 CPU 硬件(主要是现代处理器架构)为操作系统提供的一种权限等级隔离机制。简单理解就是一部分代码需要调动重要的资源,于是需要请示操作系统内核,或代码先到内核进行执行,然后再回归正常流程。

操作系统,也就是内核态,拥有的权限最高。用户在操作系统中永远都要处于一种受控的状态不能让用户随意在系统当中访问任意资源。因此用户能够访问的资源是有限的,而很多时候又不得不开放资源给用户,于是就设计了凡是访问重要资源就需要先进入内核态的机制。

用户态到内核态的一种的途径就是系统调用。系统调用最开始的入口处就能够做到从用户到内核的转化,只要是登录成功的合法用户都可以使用系统调用。

通过对 bash 进程输入指令访问系统资源,那个指令底层本身就是封装的系统调用。即使是编程语言,例如 C 语言、 C++、Java、Python 等的库函数、库方法,只要它涉及到硬件的访问,库函数底层必定要封装系统调用。.

系统调用背后,本身就包含了一定的身份变化。

进程的行为

如图是进程的虚拟地址空间。
请添加图片描述

所有进程自身的数据,都需要有自己的用户级页表,来进行用户空间到内核空间的一种映射。每个进程都有 3 GB 的地址空间,每个进程都有,而每个进程的内核级别的页表只需要一张就够就够了。因为所有进程对于操作系统内核数据需要通过 1GB 的内核空间进行映射

即用户态只能访问进程的 0 ∼ \sim 3GB 的虚拟内存空间,而内核态可以访问 3 ∼ \sim 4GB 的内核空间。
请添加图片描述

每个进程访问内核数据 (例如使用系统调用) ,都相当于访问自身虚拟内存空间中的 1GB 内核空间。且所有的进程通过自身的 1 GB 的内核空间访问的内核资源都是一样的。
请添加图片描述

其中操作系统 (内核) 是计算机中被特权保护的内核代码和数据,是计算机资源的管理者,在合适的时候被 CPU 执行。操作系统内核的代码数据从磁盘加载到低地址,然后 CPU 最早处理它,其他进程才能逐一启动。内核的初始化代码构建起进程 0 (idle) 和进程 1(init)的内核数据结构,然后创建内核线程 init (PID 1),它将是第一个用户态进程,现代 Linux 中通常是 systemd

[Bjarne@VM-8-8-centos cppTest]$ ps ajx
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
    0     1     1     1 ?           -1 Ss       0   8:42 /usr/lib/systemd/systemd --switched-root --system --deserialize 22
# 其他的省略

进程的工作状态

用户态到内核态之间的转化并不能由用户直接改变,而是由操作系统通过某种方式转化。

区分进程的当前状态是用户态还是内核态,取决于 CPU工作级别。CPU 内的 CS 寄存器 (code segment) 是代码段寄存器,这个寄存器最终会指向用户的代码数据。在 CS 寄存器中有 2 个比特位 (一般是最低的 2 个) 用于描述当前寄存器所对应的一种工作状态。 2 个比特位可以表示 4 种组合:{00,01,10,11},但多数情况只用得到其中的 0 和 3, 0 表示内核态, 3 表示用户态。

所以切换当前的用户状态,在这里的理解就是修改 CS 寄存器中的后 2 个比特位。至于如何修改,例如 X86 结构的英特尔 CPU 可识别的一条汇编语句是 int 0x80 ,这条语句一旦调用成功,则进程就有权利访问操作系统的内核代码。更现代的方法则是 syscall

除了身份的变化,用于映射的页表也会发生变化。但绝大部分时候用户是不能随意更改 CS 寄存器的内容,只有通过系统调用才能有限修改。

除了系统调用,每个进程都只能占用 CPU 的一个时间片的时间,进程从运行队列中脱离进入阻塞队列即进程被阻塞时,也相当于从内核态转移到了用户态。所以无论进程最终是否通过系统调用,进程的整个生命周期一定会涉及非常多次的内核态用户态的切换

进入内核还可通过一些硬件中断进入。牵扯太多,以后有机会做相关研究再继续。

内核观点的信号处理

进程进行信号处理,是从内核态返回到用户态时,顺手把信号进行处理。因为这时依旧属于内核态,具有可以访问当前进程的内核函数结构的权限。进程的整个生命周期一定会涉及非常多次的内核态、用户态的切换,这就意味着进程有几乎无数次机会对信号进行捕捉。

只要 pending 表被标记,且 block 表没有被标记信号就会被递达。使用 signal 修改了信号的后续行为,进程也要切换到用户态以用户的身份去处理

当 Linux 内核为信号处理函数创建栈帧时,会将一个对 sigreturn 的调用插入到栈帧中。当信号处理函数返回时,sigreturn 将被调用。 这个 sigreturn 调用会撤销为了调用信号处理函数所做的一切:更改进程的信号掩码、切换栈、恢复进程的信号掩码、切换栈,并恢复进程的上下文(寄存器、处理器标志位),从而使进程直接从被信号中断的点恢复执行。

int sigreturn(unsigned long __unused);

sigreturn 是 Linux 特有的,不应在旨在可移植的程序中使用。即不能在用户的代码中使用。

系统调用本质是操作系统不想彻底开放给用户,但又要为用户提供服务不得不开发部分资源,于是对真正触及核心的函数 sys_xxx 封装为 xxx 后,再提供给用户。

即调用系统调用 systemcall,内部调用的接口是 sys_systemcall 。例如 fork 在底层也是 sys_fork ,但除了基础的 sys_fork ,还做了很多检查工作,确保 sys_fork 能顺利进行。

在操作系统当中还有一张表 sys_call_table,本质是函数指针数组,这些系统调用都可以通过这张表进行访问,下标作为系统调用号。这个系统调用号会被放到 CPU 内部的特定寄存器内,通常是 ECX 寄存器;再将用户态转变为内核态,操作系统识别到寄存器内的系统调用号,就会去调用系统调用;调用完成后再从原接口返回。

在 g++ 将 C/C++ 代码翻译成汇编语言时,只要是系统调用,汇编语句都是 call xxx ,而用户自己写的函数却会被各种修饰。

如下图所示,在用户态和内核态的会进行 4 次状态切换(红圈标记)。
请添加图片描述

信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这种行为称为捕捉信号

在上图中,由于信号处理函数的代码是在用户空间的,处理过程比较复杂,这里用之前的代码做举例。

#include <iostream>
#include <signal.h>
#include <unistd.h>

using std::cout;

void handler(int sig) {
    cout << "处理" << sig << "\n";
    exit(-1);
}

int main() {
    cout << "进程ID:" << getpid() << "\n";
    signal(2, handler);
    cout << "自定义捕捉信号\n";
    sleep(5);
    signal(2, SIG_DFL); // 恢复默认行为
    cout << "自定义捕捉终止\n";
    while (true)
        sleep(1);
    return 0;
}
  1. 用户程序注册了 2 号信号 SIGINT 的处理函数 handler
  2. 当前正在执行 main 函数,这时收到 Ctrl+c 发生中断或异常切换到内核态
  3. 中断处理完毕后要返回用户态main 函数之前检查到有信号 SIGINT 递达。
  4. 内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 handler 函数handlermain 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是 2 个独立的控制流程
  5. handler 函数返回后若没有调用 exit ,则自动执行特殊的系统调用 sigreturn 再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行。

这里的第 4 步是进程出现了 2 个不同的执行流主执行流执行 main 函数且被暂停副执行流执行 handler 执行完成后,若没有调用 exit 终结进程则会回归主执行流。在 Linux 的线程中也设计了一个进程可以拥有多个执行流,后续会继续介绍。

sigaction

除了 signalsigaction 也支持修改 handler 表。

这里 sigaction 函数和结构体 sigaction 同名,这是 C 语言语法允许的。但平时的开发不建议如此使用,因为代码阅读不方便。

#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);

结构体 sigaction 的定义:

struct sigaction {
    union {
        __sighandler_t sa_handler;
        void (*sa_sigaction)(int, siginfo_t *, void *);
    } __sigaction_handler;
    __sighandler_t sa_handler;
    __sigset_t sa_mask;
    int sa_flags;
    void (*sa_restorer)(void);
};

这里 sa_handlersa_sigaction 作为一个联合体合并在一起,意味着它们共用同一片内存,一般只用一个即可。

sigaction 函数可以读取和修改与指定信号相关联的处理动作sigaction 函数调用成功则返回 0 ,出错则返回 -1 。

参数

  • signo 是指定信号的编号。若 act 指针非空,则根据 act 修改该信号的处理动作。若 oact 指针非空,则通过 oact 传出该信号原来的处理动作actoact 指向sigaction 结构体对象。即 actoact 配合可以完成将用户希望的处理动作作为信号的处理方式,并将原来的处理动作进行备份的动作,详细见后续代码示例。

  • sa_handler 赋值为常数 SIG_IGN 传给 sigaction 表示忽略信号

  • 赋值为常数 SIG_DFL 表示执行系统默认动作

  • 赋值为一个函数指针表示用自定义函数捕捉信号。或者说向内核注册了一个信号处理函数,该函数返回值为 void ,可以带一个 int 参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被 main 函数调用,而是被系统所调用。

当某个信号的处理函数被调用时内核自动将当前信号加入进程的信号屏蔽字(block 表),当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

例如这个代码:

#include <cstdlib>
#include <iostream>
#include <signal.h>
#include <unistd.h>

using std::cout;

void handler(int sig) {
    cout << "处理" << sig << "号信号\n";
    for (int i = 1; i <= 5; i++) {
        cout << "handler in\n";
        sleep(1);
    }
}

int main() {
    cout << "进程" << getpid() << "\n";
    struct sigaction act, old_act;
    act.sa_handler = handler; // 直接赋值即可
    sigaction(2, &act, &old_act);
    while (1) {
        cout << "运行ing\n";
        sleep(1);
    }
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11 -g
./a.exe
进程10003
运行ing # *2
^C处理2号信号
handler in # *3
^Chandler in # *2
处理2号信号
handler in # *5 ,这里按了2次Ctrl+c,说明第2次先被屏蔽后被释放
运行ing
^\make: *** wait: No child processes.  Stop.
make: *** Waiting for unfinished jobs....
make: *** wait: No child processes.  Stop.
[Bjarne@VM-8-8-centos cppTest]$ 

这里在处理 2 号信号时, 2 号信号会自动被当前进程所屏蔽。此时在处理二号信号的这 5 秒内,如果还有 2 号信号到来时,因为当前的 2 号信号已经被自动屏蔽了,所以再来时 2 号信号就不会被递达了。这也说明 Linux 内核默认不允许同一种信号在正在被处理时继续再被嵌套式处理,它只允许一个一个地处理完后再进行下一个。

如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。

#include <cstdlib>
#include <iostream>
#include <signal.h>
#include <unistd.h>

using std::cout;

void handler(int sig) {
    cout << "处理" << sig << "号信号\n";
    for (int i = 1; i <= 5; i++) {
        cout << "handler in\n";
        sleep(1);
    }
}

int main() {
    cout << "进程" << getpid() << "\n";
    struct sigaction act, old_act;
    act.sa_handler = handler;
    sigemptyset(&act.sa_mask);
    sigaddset(&act.sa_mask, 3);   // 将3号信号也屏蔽
    sigaction(2, &act, &old_act); // 修改默认行为

    while (1) {
        cout << "运行ing\n";
        sleep(1);
    }
    return 0;
}

这里设置了在信号处理时也会将 3 号信号给屏蔽掉,所以在 handler 运行时发送 3 号信号会暂时处于未决状态,当 handler 运行结束时 3 号信号会被递达。

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11 -g
./a.exe
进程15138
运行ing
运行ing
运行ing
^C处理2号信号
handler in
^\handler in # 这里发送了3号信号,被暂时屏蔽
^\handler in
handler in
handler in
make: *** [a.exe] Quit # handler结束后3号信号立马递达
Quit
[Bjarne@VM-8-8-centos cppTest]$ 

sa_flags 字段包含一些选项,这里的代码都把 sa_flags 设为0,sa_sigaction 是实时信号的处理函数,暂时用不上。

多个信号同时递达

这里做个测试,先对多个信号进行屏蔽,然后通过命令向进程发送多个信号,最后再解除屏蔽。

#include <cstdlib>
#include <iostream>
#include <signal.h>
#include <unistd.h>

using std::cout;

void getpending() {
    sigset_t pending;
    sigemptyset(&pending); // 初始化
    if (sigpending(&pending) == -1)
        exit(-1); // 获取pending表
    for (int i = 1; i < 32; i++) {
        if (sigismember(&pending, i)) // 检查信号
            cout << 1;
        else
            cout << 0;
    }
    cout << "\n";
}

void handler(int sig) {
    cout << "处理" << sig << "号信号\n";
}

void make_sig(sigset_t &mask, sigset_t &old_mask) {
    signal(2, handler);
    signal(3, handler);
    signal(4, handler);
    signal(5, handler);
    sigemptyset(&mask);
    sigemptyset(&old_mask);

    sigaddset(&mask, 2);
    sigaddset(&mask, 3);
    sigaddset(&mask, 4);
    sigaddset(&mask, 5);
    sigprocmask(SIG_BLOCK, &mask, &old_mask);
}

int main() {
    sigset_t mask, old_mask;
    make_sig(mask, old_mask); // 设置2、3、4、5号信号
    cout << "进程" << getpid() << "\n";
    for (int i = 15; i > -2; i--) {
        cout << "i=" << i << "\n";
        getpending();
        sleep(1);
        if (i == 0) {
            cout << "取消对2、3、4、5号信号的屏蔽\n";
            sigprocmask(SIG_SETMASK, &old_mask, nullptr);
        }
    }
    return 0;
}

交互界面:

# bash1
[Bjarne@VM-8-8-centos cppTest]$ make
g++ a.cpp -o a.exe -std=c++11 -g
./a.exe
进程26305
i=15
0000000000000000000000000000000
# 省略不重要的信息
i=10
0100000000000000000000000000000 # 这里开始收到信号
i=9
0110000000000000000000000000000
i=8
0111000000000000000000000000000
i=7
0111100000000000000000000000000
# 省略大量重复信息
i=0
0111100000000000000000000000000
取消对2、3、4、5号信号的屏蔽
处理3号信号 # 处理顺序不固定
处理2号信号
处理5号信号
处理4号信号
i=-1
0000000000000000000000000000000
[Bjarne@VM-8-8-centos cppTest]$ 

# bash2
[Bjarne@VM-8-8-centos cppTest]$ signo=2; while [ $signo -le 5 ]; do echo "kill -${signo} PID;";kill -${signo} 26305;sleep 1;let signo++;done
kill -2 PID;
kill -3 PID;
kill -4 PID;
kill -5 PID;
[Bjarne@VM-8-8-centos cppTest]$ 

这个测试说明进程当前同时会存在多个信号,此时操作系统会遍历式地把整个信号全部处理完,然后才返回 main 执行流。

进程在进行信号处理的时候,并没有严格按照 2、3、4、5 这样的顺序进行处理,说明进程在处理信号时也有对应的一个优先级的问题

操作系统大部分时候向进程发送的信号一般都是同类型的,所以操作系统在处理信号时暂时将信号屏蔽,是为了防止一个信号被一直在被捕捉导致主执行流无法返回的问题出现。

其他知识点补充

可重入函数概念的引入

这个概念会在线程重点讲解,这里只做概念引入。

某个场景:main 函数调用 insert 函数向一个链表 head 中插入节点 node1

  1. 插入操作分为 2 步,刚做完第 1 步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到 sighandler 函数。

  2. sighandler 也调用 insert 函数向同一个链表 head 中插入节点 node2

  3. 插入操作的 1 步都做完之后从 sighandler 返回内核态。

  4. 再次回到用户态就从 main 函数调用的 insert 函数中继续往下执行,先前做第 1 步之后被打断,现在继续做完第 2 步。

  5. 结果是,main 函数和 sighandler 先后向链表中插入 2 个节点,而最后只有一个节点真正插入链表中了。

简单来说就是 node1 结点在插入时被突然的信号打断,然后进程接着插入 node2 节点,等node2 节点插入完毕之后,node1 又继续插入,结果就是 node2 丢失,再也无法访问。

整个过程如下图所示:
请添加图片描述

像上例这样,insert 函数被不同的控制流程调用,有可能在第一次调用还没返回时就再次进入该函数,这称为重入insert 函数访问一个全局链表,有可能因为重入而造成错乱,像这样的重复进入的情况下可能会出错的函数称为不可重入函数,反之,如果一个函数只访问自己的局部变量或参数,则称为可重入函数 (Reentrant Function) 。

这里出现了结点丢失的情况,是因为同一个 insert 函数被 2 个毫不相关的执行流同时进行,称这种状态叫做该函数被重入了,全称叫做该函数被重复进入了。

如果一个函数符合以下条件之一则是不可重入的:

  • 调用了 mallocfree ,因为 malloc 也是用全局链表来管理堆的。
  • 调用了标准 I/O 库函数。标准 I/O 库的很多实现都以不可重入的方式使用全局数据结构。

90% 以上的库函数、STL 容器内置方法等都是不可重入的。对多个执行流执行不可重入函数的问题,在线程中会更详细地解决。解决方式就是确定同一时间内只有 1 个执行流在使用公共资源

C语言关键字 volatile

该关键字在 C 当中已经有所涉猎,这里站在信号的角度重新理解一下。

volatile 关键字的作用是保持内存的可见性

这里进行一个优化测试:

#include <cstdlib>
#include <iostream>
#include <signal.h>
#include <unistd.h>

using std::cout;
bool flag = 0;

void handler(int sig) {
    cout << "处理" << sig << "号信号\n";
    flag = 1;
}

int main() {
    signal(2, handler);
    cout << "进程" << getpid() << "开始\n";
    while (!flag)
        ;
    cout << "进程" << getpid() << "结束\n";
    return 0;
}

gcc 和 g++ 自带 4 个等级的优化选项 O0O1O2O3 。在以下的交互界面中将使用最高等级的优化编译程序。

[Bjarne@VM-8-8-centos cppTest]$ g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
进程2208开始
^C处理2号信号
进程2208结束
[Bjarne@VM-8-8-centos cppTest]$ g++ a.cpp -o a.exe -std=c++11 -O3
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
进程2349开始
^C处理2号信号 # ctrl+c 三连击
^C处理2号信号
^C处理2号信号
^\Quit
[Bjarne@VM-8-8-centos cppTest]$ 

优化情况下,键入 Ctrl+c ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件依旧满足,进程继续进行。但是很明显 flag 肯定已经被修改了。

很明显, while 循环检查的 flag并不是内存中最新的flag,这就存在了数据二异性的问题。 即 while 检测的 flag 和 CPU 看到的 flag 不是同一个。while 检测的 flag 其实已经因为优化,被放在了 CPU 寄存器当中。

计算分为两类,一类叫做算术运算,就是典型的加减乘除,还有一类运算叫做逻辑运算。 while 循环做的判断是逻辑运算。

真正执行进程需要将进程放在 CPU 内执行,所以这些计算也都由 CPU 完成。

执行信号处理动作时,若没有任何优化,则 CPU 会正常地从内存获取 flag 的内容进行判断。但当做了优化之后,形成的汇编语言发生了变化:因为编译器发现整个 main 函数里并没有代码修改这个 flag,因为 handler 没有被名义上地调用。

所以 CPU 只有第一次直接把数据加载在内存当中,便不再进行读取内存了,之后 CPU 只需要在自己内部判断代码时,只需要不断检测对应的寄存器就可以了。从而造成了 handler 修改了 flag 的值,但 CPU 却无视的现象。

简单来说就是改的内存和我寄存器有什么关系,我 CPU 只认寄存器。

放现实中就是层级高的领导层,得到的信息是自己的秘书给的,但实际的信息已经发生变化,秘书的信息跟不上变化,于是就出现了领导只会问秘书,无视真正的外部信息的现状。

为了解决这个问题,就添加了关键字 volatile

volatile 的作用:保持内存的可见性。即告知编译器被该关键字修饰的变量不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作

这里再修改代码,在声明/定义阶段就使用 volatile 修饰。

volatile bool flag = 0;

交互界面就可以看到优化不会对 flag 的读取造成影响:

[Bjarne@VM-8-8-centos cppTest]$ g++ a.cpp -o a.exe -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
进程6933开始
^C处理2号信号
进程6933结束
[Bjarne@VM-8-8-centos cppTest]$ g++ a.cpp -o a.exe -std=c++11 -O3
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
进程7057开始
^C处理2号信号
进程7057结束
[Bjarne@VM-8-8-centos cppTest]$ 

SIGCHLD信号

waitwaitpid 函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理 (也就是轮询的方式) 。采用第一种方式,父进程阻塞了就不能处理自己的工作了;采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一下,程序实现复杂。

子进程在终止时会给父进程发 17 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用 wait 清理子进程即可。

这里给个测试代码。若不修改17号信号的处理函数,子进程会变成僵尸进程。

#include <cstdlib>
#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

using std::cout;

void handler(int sig) {
    pid_t id;
    //使用while是因为可能存在多个子进程于是一直等待,直到所有子进程全部回归
    while ((id = waitpid(-1, NULL, WNOHANG)) > 0) {
        printf("等待子进程%d成功\n", id);
    }
    printf("子进程%d退出\n", getpid());
}
int main() {
    signal(SIGCHLD, handler); // 修改17号信号的默认行为
    pid_t cid;
    if ((cid = fork()) == 0) { // child
        printf("子进程 : %d\n", getpid());
        sleep(3);
        exit(1);
    }
    while (1) {
        cout << "此时父进程在做别的事\n";
        sleep(1);
    }
    return 0;
}

交互界面:

[Bjarne@VM-8-8-centos cppTest]$ g++ a.cpp -o a.exe -g -std=c++11
[Bjarne@VM-8-8-centos cppTest]$ ./a.exe
此时父进程在做别的事
子进程 : 2399
此时父进程在做别的事
此时父进程在做别的事
此时父进程在做别的事
等待子进程2399成功
子进程2396退出
此时父进程在做别的事
此时父进程在做别的事
此时父进程在做别的事
^C
[Bjarne@VM-8-8-centos cppTest]$ 

在另一个同 IP 的交互界面监视进程信息:

[Bjarne@VM-8-8-centos cppTest]$ while :; do ps ajx | head -1 &&ps ajx | grep a.exe | grep -v "grep";echo "####";done > out.txt
^C
[Bjarne@VM-8-8-centos cppTest]$ 

out.txt 的内容太多,所以只截取关键部分:

# 省略大量的空跑内容
# 第1部分,a.exe 的2个进程启动
####
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
####
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
18882  2396  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
 2396  2399  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
####
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
18882  2396  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
 2396  2399  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
####
# 省略大量的重复内容
# 第2部分,检测到子进程并没有变成僵尸进程,而是自然消亡
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
18882  2396  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
 2396  2399  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
####
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
18882  2396  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
 2396  2399  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
####
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
18882  2396  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
####
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
18882  2396  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
####
# 省略大量的重复内容
# 第3部分,父进程被Ctrl+C终结,监视脚本重新进入空跑状态,并被Ctrl+C终结
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
18882  2396  2396 18882 pts/0     2396 S+    1001   0:00 ./a.exe
####
 PPID   PID  PGID   SID TTY      TPGID STAT   UID   TIME COMMAND
####
# 省略大量的重复内容

由于 UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用 sigactionSIGCHLD 的处理动作置为 SIG_IGN ,这样 fork 出来的子进程在终止时会自动被系统清理掉不会产生僵尸进程也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它 UNIX 系统上都可用。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐