Linux 进程信号机制详解
Linux-进程信号理解
1. 什么是信号?
1.1 概念
信号是一种异步事件通知机制。 这句话包含两个核心概念:
-
异步: 信号可以在任何时候送达进程。进程无法预测下一个信号何时到来,就像你不能预测下一个电话何时会响一样。
-
事件通知: 信号用于通知进程某个事件发生了。这个事件可能是由用户触发的(如按下
Ctrl+C
),也可能是由其他进程或操作系统内核触发的(如子进程退出、访问了非法内存)。
1.2 查看信号列表
在 Linux 系统中,信号(Signals)是进程间通信的一种基本机制,用于通知进程发生了某种事件。信号可以分为两大类:分时信号 和 实时信号。
-
分时信号:也称为标准信号或不可靠信号,信号编号为 1-31。
-
如果同一信号在短时间内多次发送,进程可能只收到一次。
-
对于不同的分时信号,处理顺序是不确定的。
-
-
实时信号(本文不考虑):信号编号为 32-64。
-
多个相同的实时信号会被排队,确保每个都被处理。
-
实时信号会根据 FIFO 的顺序处理信号。
-
kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 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
以上本质都是宏定义,宏的内容对应信号的顺序编号。#define SIGHUP 1 类似。
1.3 基本结论
-
信号处理,进程在信号没有产生的时候,早已经内置了对信号的识别和处理方式。
-
信号不一定是立即处理,而是在合适的时候,进行信号的处理。
2. 信号的产生
2.1 五种信号的产生方式
2.1.1 通过终端按键产生信号
-
Ctrl + C
-> 产生SIGINT(2)
(中断信号)。 -
Ctrl + \
-> 产生SIGQUIT(3)
(退出信号)。 -
Ctrl + Z
-> 产生SIGTSTP(19)
(终端暂停信号)。
衍生的知识点:前台进程&后台进程:
键盘产生的信号,只能发送给前台进程。
- 前台进程:直接与用户交互的进程。能从标准输入获取内容。它会阻塞用户的命令行或Shell,直到该进程结束。
- 启动前台进程:
./xxx
- 启动前台进程:
- 后台进程:无法从标准输入种获取内容。启动后,它会立即将命令行或Shell的控制权交还给用户,允许用户在它运行的同时执行其他任务。
- 启动后台进程:
./xxx &
- 启动后台进程:
1.查看所有的后台任务。
jobs
2.将后台进程提到前台。
fg 任务号
3.进程切换到后台并暂停。
ctrl + z
4.让后台进程恢复运行
bg 任务号
2.1.2 通过系统调用
1.kill:向指定进程发送信号
#include <sys/types.h>
#include <signal.h>int kill(pid_t pid, int sig);
参数:
-
pid_t pid
: 信号发送给进程ID为 pid 的特定进程。 -
int sig
: 要发送的信号。
返回值:
-
成功返回 0。
-
失败返回 -1,并设置
errno
。
2.raise:让当前进程自己给自己发送一个信号
// 等价 kill(getpid(), sig)
#include <signal.h>int raise(int sig);
参数:
int sig
: 要发送的信号。
返回值:
-
成功返回 0。
-
失败返回 非0。
3.abort:也是一个C标准库函数,它的作用是立即异常终止当前进程,并生成一个核心转储文件(core dump)
#include <stdlib.h>void abort(void);
abort() 首先会向当前进程发送 SIGABRT 信号。
即使进程捕捉 SIGABRT 信号,即使信号处理函数正常返回,abort()函数它也会确定进程最终被中止。
2.1.3 调用系统命令向进程发信号
kill -signo pid
2.1.4 硬件异常
硬件异常是指程序执行过程中发生了底层硬件(主要是CPU)可以检测到的错误条件。当这种错误发生时,硬件会通知操作系统内核,内核随后将对应的信号发送给引发该异常的进程。
-
除0错误 -> 产生
SIGFPE(8)
(浮点数异常信号)。 -
非法内存访问 -> 产生
SIGSEGV(11)
(段错误信号)。
2.1.5 软件条件
软件条件产生信号是指操作系统内核在检测到某种软件层面的条件或状态变化时,自动向相关进程发送信号。这些条件不是由硬件错误引起的,而是由程序的行为或系统状态的变化所触发
-
进程向一个已经关闭的管道写数据 -> 产生
SIGPIPE(13)
。 -
子进程退出时 -> 向父进程发送
SIGCHLD(17)
。 -
定时器超时 -> 产生
SIGALRM(14)
。
1.alarm:定时器函数,用于在指定时间后向进程自身发送 SIGALRM
信号
#include <unistd.h>unsigned int alarm(unsigned int seconds);
-
设置一个一次性定时器。
-
经过
seconds
秒后,内核会向当前进程发送SIGALRM
信号。 -
如果
seconds
为 0,则取消之前设置的任何未处理的闹钟。 -
返回之前设置的闹钟剩余的秒数,如果之前没有设置闹钟则返回 0。
2.pause:用于使进程主动进入睡眠状态,直到收到任何信号才返回
#include <unistd.h>int pause(void);
2.2 三种处理信号的方式
2.2.1 认识 signal 信号处理的系统调用
signal
系统调用用于设置进程对特定信号的处理方式,自定义捕捉函数。
#include <signal.h>typedef void (*sighandler_t)(int);sighandler_t signal(int signum, sighandler_t handler);
参数:
-
int signum
: 信号编号,可以是具体的信号值(如 SIGINT)或信号名称。 -
handler
: 信号处理函数指针。
2.2.2 默认处理动作
默认处理动作本质上是执行 SIG_DFL 函数。
-
Terminate (终止): 进程立即退出。
-
Ignore (忽略): 信号被丢弃,没有任何效果。
-
Core (终止): 进程退出,并创建一个核心转储文件(core dump),该文件记录了进程终止时的内存核心数据,用于后续调试。
-
云服务器中,code dump功能是被禁止的。
-
通过
ulimit -a
中core file size
。 -
开启
ulimit -c code-dump-size
。 -
开启
code dump
,直接运行崩溃,gdb
命令:core-file core-filename
,直接帮助我们定位出错行,事后调试。 -
在进程等待的输出型参数 status 中把 core dump 标志位设为1。
-
-
Stop (停止): 进程暂停执行,进入暂停状态,直到收到
SIGCONT
信号才会继续。 -
Continue (继续): 如果进程处于停止状态,此信号会使其继续运行。
2.2.3 自定义处理动作
进程可以捕获一个信号,并指定一个自己编写的函数(信号处理函数)来响应这个信号。这允许进程在收到信号时执行自定义的逻辑,而不是简单地终止或忽略。
通过 signal 系统调用。
2.2.4 忽略处理
进程可以主动告诉内核,它希望忽略某个特定的信号。当该信号产生时,内核会将其丢弃,进程完全不会感知到它的到来。
注意:
-
SIGKILL
和SIGSTOP
这两个信号是不能被捕获、阻塞或忽略的。这是为了给系统管理员一个最终极的手段来管理进程(必杀和必停)。 -
忽略信号与默认动作是忽略不同。前者是进程的主动行为,后者是信号的固有属性。
-
默认处理动作中的忽略:是内核级别的、被动的、不可改变的。
-
自定义处理方式设置为忽略:是用户级别的、主动的、可改变的。
-
设置 signal( , SIG_IGN)。
注意:默认处理动作的函数是
SIG_DFL
,所以默认处理动作中的 Ignore 和 2.2.4 忽略处理不一样。一个执行的是 SIG_DFL 函数,只不过 SIG_DFL 函数中的行为是忽略。一个执行的是 SIG_IGN 函数。
3. 信号的保存
3.1 相关的概念
-
实际执行信号的处理动作称为信号递达 (Delivery)。
-
信号从产生到递达之间的状态,称为信号未决 (Pending)。
-
进程可以选择阻塞 (Block) 某个信号。
-
被阻塞的信号产⽣时将保持在未决状态,直到进程解除对此信号的阻塞,才执⾏递达的动作。
-
注意,阻塞和忽略是不同的,只要信号被阻塞就不会递达,⽽忽略是在递达之后可选的⼀种处理动作。
-
block 和 pending 表是两张位图,handler 是函数指针数组。
-
信号产⽣时,内核在进程控制块中的 pending 位图设置该信号的未决标志1,直到信号递达才清除该标志0。在上图的例⼦中,SIGHUP信号未阻塞也未产⽣过,当它递达时执⾏默认处理动作。若使用 signal 系统调用为该信号注册处理函数,则不会执行系统的默认处理函数,因为使用自定义的处理函数将 hanlder 表中的函数进行了覆盖。
-
SIGINT信号产⽣过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
注意:在阻塞前收到多次同类型信号,分时信号只记一次。
3.2 信号集操作函数
3.2.1 sigset_t 类型
sigset_t
是一个位图结构,每一位代表一个信号,不能直接操作内部,必须使用特定的函数来操作。
未决和阻塞标志可以⽤相同的数据类型 sigset_t 来存储,这个类型可以表⽰每个信号的有效或⽆效状态,,在阻塞信号集中有效和⽆效的含义是该信号是否被阻塞,⽽在未决信号集中,有效和⽆效的含义是该信号是否处于未决状态。
#include <signal.h>typedef __sigset_t sigset_t;#define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
3.2.2 sigset_t 操作函数
#include <signal.h>
int sigemptyset(sigset_t *set); // 初始化信号集,所有位清零
int sigfillset(sigset_t *set); // 初始化信号集,所有位至1
int sigaddset(sigset_t *set, int signo); // 将 signo 信号添加到信号集中
int sigdelset(sigset_t *set, int signo); // 将 signo 信号从信号集中删除
int sigismember(const sigset_t *set, int signo); // 检查 signo 信号是否在集合中
这四个函数都是成功返回0,出错返回-1。sigismember是⼀个布尔函数,⽤于判断⼀个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
3.2.3 sigprocmask
调⽤函数 sigprocmask 可以读取或更改进程的信号屏蔽字 (阻塞信号集)。
函数原型:
#include <signal.h>int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
参数:
-
int how
:SIG_BLOCK
:将 set 中的信号添加到当前阻塞集合,等价mask = mask | set
。
-
SIG_UNBLOCK
:从当前阻塞集合中移除 set 中的信号,等价mask = mask & ~set
。 -
SIG_SETMASK
:用 set 完全替换当前阻塞集合,等价mask = set
。
-
const sigset_t * set
: 新信号集-
指向包含要阻塞/解除阻塞信号的
sigset_t
。 -
如果为
NULL
,则只查询当前掩码,不修改。
-
-
sigset_t* oset
: 旧信号集-
如果不为
NULL
,函数会将调用前的信号掩码保存到此参数。 -
如果为
NULL
,则不保存旧掩码。
-
返回值:
-
成功:返回 0。
-
失败:返回 -1 并设置 errno。
3.2.4 sigpending
sigpending
用于获取当前进程的未决信号集(pending signals)。未决信号是指那些已经发送给进程但被阻塞的信号,它们正在等待被处理(一旦信号被解除阻塞,就会传递给进程)。
函数原型:
#include <signal.h>int sigpending(sigset_t *set);
参数:
-
sigset_t* set
: 指向sigset_t
类型的指针,函数会将当前未决信号集填充到此参数中- 调用成功后,可以通过
sigismember
检查哪些信号在未决状态。
- 调用成功后,可以通过
返回值:
-
成功:返回 0。
-
失败:返回 -1 并设置 errno。
修改 pending 位图的方式就是 2.1 五种信号的产生方式。
当准备递达信号时,首先会清空 pending 信号集的信号位图,之后执行对应的函数。
3.3 代码
#include <iostream>
#include <signal.h>
#include <unistd.h>void print_pending(const sigset_t& pending) {std::cout << "process pid: " << getpid() << ", pending: ";for(size_t i = 31; i >= 1; i--) {std::cout << sigismember(&pending , i);}std::cout << std::endl;
}void handler_sigint(int signo) {std::cout << "我正在处理" << signo << "号信号" << std::endl;
}// demo
int main() {signal(SIGINT , handler_sigint);sigset_t set , oset;sigemptyset(&set);sigemptyset(&oset);// 设置2号信号阻塞sigaddset(&set , SIGINT); sigprocmask(SIG_SETMASK , &set , &oset);// 查看 pending 表sigset_t pending;int cnt = 0;while (true) {sigemptyset(&pending);sigpending(&pending);print_pending(pending);if (cnt == 15) {// 解除屏蔽sigprocmask(SIG_SETMASK , &oset , nullptr);// 观察信号被递达的过程}cnt++;sleep(1);}return 0;
}
4. 信号的捕捉
4.1 第一阶段的理解
如果信号的处理动作是⽤⼾⾃定义函数,在信号递达时就调⽤这个函数,这称为 捕捉信号。
-
用户态:这是普通应用程序运行的模式。在此模式下,代码不能直接访问硬件或敏感的内存区域。如果程序崩溃,只会影响自己,不会拖垮整个系统。
-
内核态:这是操作系统内核运行的模式。在此模式下,代码拥有对计算机硬件和所有内存的完全、无限制的访问权限。可以执行任何CPU指令,访问任何内存地址。
举个例子:
-
用户使用
signal
系统调用注册了SIGINT
(2) 信号的处理函数handler_signal(int)
。 -
当前正在执行
main
函数内部的代码,这时发生中断或异常切换到内核态。 -
在中断处理完毕后返回用户态的
main
函数之前需要检查是否有信号递达(SIGINT)
。 -
发现有
SIGINT
信号递达,从内核态返回用户态,执行hanlder_signal()
函数。 -
handler_signal
函数返回后自动执行特殊的系统调用sigreturn
再次进入内核态。-
可以通过函数压栈的方式记录
sigreturn
的函数地址,完成跳转。 -
函数调用前需要开辟一段空间存放函数的形参等信息,而只需要把存储形参的位置替换为
sigreturn
地址即可。
-
-
如果没有新的信号要递达,这次再返回用户态就是恢复
main
函数的上下文继续执行。
其实信号的处理经过了四次的身份切换(用户 -> 内核,内核 -> 用户)。可以将上图理解为一个倒8,中间划一条横线,而信号的处理就在这条横线下方的交汇位置。
所以信号的处理,是进程从内核态返回用户态时候,进行信号的检查和处理。
在执行我们自己写的自定义捕捉方法,操作系统必须从内核态返回用户态再执行,防止用户的自定义捕捉函数做一些非法的操作。
4.2 第二阶段的理解(操作系统是怎么运行的?中断?陷阱?)
4.2.1 死循环
操作系统内核在启动后,会进入一个无限循环,这个循环是操作系统的核心。在这个循环中,操作系统检查是否有任何事件需要处理,比如中断、系统调用等。如果没有事件,它就会一直暂停等待下一个中断的发生。
for(;;) {pause();
}
操作系统内核本质上就是一个精心设计的、永不停止的死循环。但这个死循环不是傻转,而是在等待和处理各种事件。操作系统本质就是一个基于中断而工作的软件。
4.2.2 硬件中断与时钟中断
硬件中断:
-
中断向量表就是操作系统的⼀部分,启动就加载到内存中。
-
通过外部硬件中断,操作系统就不需要对外设进⾏任何周期性的检测或者轮询。
-
由外部设备触发的,中断系统运⾏流程,叫做硬件中断。
-
软件中的信号概念,本质就是模拟硬件中断而产生的。
硬件中断的处理流程:
-
外部设备触发中断。
-
查中断向量表 -> 内核中断处理函数。
-
关键步骤:处理函数进行设备特定操作(如从网卡读取数据到内核缓冲区),通常不会向当前进程发送信号。
-
唤醒等待该事件的进程。例如,一个正在
recv()
系统调用中阻塞的进程会被标记为就绪状态。 -
中断处理完毕,CPU恢复执行被中断的进程(可能是任何一个进程)。之后调度器可能会选择运行那个被唤醒的进程。
时钟中断:
我们可以先思考一个问题?进程可以在操作系统的调度下运行,那么 操作系统被谁调度?
-
时钟中断是以特定的频率 (HZ),向CPU发送特定的中断。
-
无论 CPU 当前正在执行什么指令(无论是用户代码还是内核代码),都会暂停检查。
-
所以,操作系统收到时钟中断,然后进行调度的检查,检查当前进程的时间片是否耗尽 (Schedule),若耗尽,则进行进程的切换 (Switch_to)。
-
1Hz = 1次/秒,1000Hz = 每1ms一次中断。
-
而时间片本质是一个计数器,单位是时钟中断的次数,每次发生时钟中断,当前运行的进程时间片 - 1。
-
操作系统通过一个全局变量
jiffies
记录全局的时钟中断次数(相对时间,因为中断次数可以转换为时间),配合硬件时钟(绝对时间),因此,操作系统在开机后/离线时也能正确显示时间。
时钟中断的意义:它像是一个无情的监工,定期打断当前任务,把 控制权强行交还给操作系统内核(强制执行操作系统内核代码)。这样,内核才有机会决定:是让这个任务继续跑,还是换另一个任务跑? 这就实现了抢占式多任务(基于时间片的调度)。
时钟中断属于一种陷阱(陷入)。
大致流程:
-
外部设备就绪 / 时钟源触发中断。
-
通知 CPU,CPU获取中断号。
-
CPU保存上下文,方便后续恢复现场。
-
根据中断号,执行中断向量中的中断方法。
-
执行完毕,恢复现场,处理中断前的工作。
4.2.3 软中断与系统调用的理解
软中断是Linux内核中使用的一种延迟处理机制。它允许内核将耗时的任务从紧急的 硬件中断处理程序 中推迟,到一个更安全、更合适的时机执行。
为了让操作系统⽀持进⾏系统调⽤,CPU也设计了对应的 汇编指令(int
或者 syscall
),可以让 CPU 内部触发中断逻辑。让CPU从用户态(用户模式)切换到内核态(内核模式)。这个过程在CPU层面被称为 陷入。
int 0x80
是传统方式(通过中断向量表来寻找和处理函数),syscall
是现代方式(直接、快速的进行系统调用,绕过了传统的中断处理流程)。
问题:我们在用户态怎么调用内核态的系统调用?
用户态程序无法直接执行内核代码,必须通过一个由操作系统提供的、安全的大门 (syscall) 来请求服务。安全的大门指的是 syscall 指令。
-
我们平时使用的系统调用并不是真正的系统调用,而是 Glibc 标准C语言库提供的。
-
而 Glibc 为我们提供的语言级函数,里面主要做两个事情。
-
获取该系统调用的系统调用号(可以简单理解为所有的系统调用存放在一个全局的函数指针数组中,系统调用号本质是下标),并将该系统调用号存放到 CPU 寄存器中,将系统调用的参数也会存储到指定的寄存器中。
-
执行陷入指令
syscall
进行软中断,将用户态切换到内核态,查询系统调用表,找到对应的内核函数地址。
-
-
返回用户态,内核函数执行完毕后,通过专门的指令
sysret
将结果返回给用户(小的数组通过寄存器,大的数据通过用户传入的缓冲区比如 read),并恢复 CPU 进程上下文,继续执行。
4.2.4 异常
异常是一种特殊的中断,异常包括 缺页中断、除0错误、野指针问题。
什么是缺页中断:
缺页中断就是存在虚拟地址,但是没有开辟物理地址,然后检查到缺页中断,开辟物理地址并与虚地址映射,更新页表,继续运行。
异常的处理流程:
-
异常 -> 中断向量表 -> 内核的异常处理函数。
-
内核的异常处理函数 -> 向目标进程发送信号。
-
目标进程 -> 根据信号处理设置做出反应。
硬件中断与异常的区别:
-
硬件中断是通知 CPU 有硬件资源就绪。
-
异常是 CPU 执行当前指令遇到了问题。
-
异常处理函数的终点往往是 信号。
-
中断处理函数的终点往往是 唤醒。
4.2.5 理解用户态内核态
理解进程地址空间
在经典的 32位Linux 内核配置中,进程的虚拟地址空间默认被划分为:
-
0GB - 3GB:用户空间。
-
3GB - 4GB:内核空间。
你可以这样理解,页表分为 系统页表、用户页表,系统页表只有一份(每个进程都是同一个系统页表),而用户页表有多份(每个进程都是不一样的用户页表)。这意味着:无论进程如何调度,我们总能找到操作系统。
而操作系统为了保护自己,不相信任何人,必须采用系统调用的方式进行访问。
权限隔离:操作系统需要严格区分用户程序和内核。用户程序不能随意访问或修改内核的数据和代码,否则会引发严重的安全和稳定性问题。
用户态:以用户身份,只能访问自己的[0,3GB]。
内核态:以内核的身份,运行你通过系统调用的方式,访问OS[3,4GB]。
1️⃣ CPU怎么区分当前是用户态还是内核态?
通过 CS 寄存器与 CPL
-
CS (Code Segment Register):代码段寄存器,它不仅包含了代码段的基地址信息,还包含了一个关键的 2 位字段,称为 当前特权级别。
-
CPL (Current Privilege Level):这 2 位的值定义了当前正在执行的代码的特权级。
-
CPL = 0
: 代表 内核态。操作系统内核运行在此级别。 -
CPL = 3
: 代表 用户态。几乎所有应用程序都运行在此级别。
-
2️⃣ 如何切换?
通过系统调用。
系统调用的过程:
glibc提供的函数的阶段:
-
准备参数:应用程序将系统调用号(代表哪个功能,如read, write)和参数存入指定的寄存器。
-
执行陷入指令:应用程序执行一条特殊的指令(如 int 0x80 或 syscall)。这条指令会触发一个软中断。
glibc提供的函数的阶段。
-
CPU收到中断信号,将cs寄存器中的低2位 由3改为0。
-
保存当前应用程序的现场(寄存器、程序计数器等)。
-
根据中断号,跳转到内核中预先设置好的中断处理程序(也就是系统调用处理程序)。
int 0x80
是传统方式(通过中断向量表来寻找和处理函数),syscall
是现代方式(直接、快速的进行系统调用,绕过了传统的中断处理流程)。
-
内核执行该系统调用。
-
返回结果,将结果放入寄存器。
-
内核执行一条特殊的返回指令(sysret),将cs寄存器中的低2位 由0改为3,恢复之前保存的应用程序现场,并让应用程序继续执行。
将 4.2.3 与这里结合理解会更清楚详细。
4.3 sigaction
4.3.1 基本介绍
sigaction
用于设置信号处理的函数,它比传统的 signal
函数功能更强大,可以用来处理实时信号和分时信号,我们这里不考虑实时信号。
函数原型:
#include <signal.h>int sigaction(int signum, const struct sigaction *restrict act,struct sigaction *restrict oldact);
参数:
-
signum
:要操作的信号编号(如SIGINT
,SIGTERM
,SIGSEGV
等)。不能是SIGKILL
和SIGSTOP
(这两个信号不能被捕获、阻塞或忽略)。 -
act
:一个指向struct sigaction
结构的指针,描述了新的信号处理方式。如果为NULL
,则不改变当前的处理方式。 -
oldact
:一个指向struct sigaction
结构的指针,用于获取信号之前的处理方式。如果为NULL
,则不返回旧信息。
返回值:
-
成功:返回
0
。 -
失败:返回
-1
,并设置errno
。
4.3.2 struct sigaction 结构体
当进程捕获一个信号并执行其自定义处理函数时,内核会自动地将该信号加入到进程的当前信号屏蔽字中。这相当于在处理函数执行期间,为该信号设置了一个临时的锁。
如果没有这个保护,如果一个信号在处理过程中再次发生,它可能会中断当前的处理函数,导致同一个函数被嵌套调用。
此外,信号处理函数的开发者可以通过sigaction
结构体的sa_mask
字段,指定除了当前信号之外,还需要额外屏蔽哪些信号。这些信号会在处理函数执行时被一并阻塞。
无论是因为自动屏蔽当前信号,还是通过sa_mask
额外屏蔽的信号,当信号处理函数正常返回后,内核都会自动恢复到原来的信号屏蔽字状态。
struct sigaction {// 信号处理函数指针void (*sa_handler)(int); // 简单的处理函数void (*sa_sigaction)(int, siginfo_t *, void *); // 不考虑// 信号掩码:在处理函数执行期间,需要阻塞哪些信号sigset_t sa_mask;// 不考虑,直接设为0即可int sa_flags;// 某些架构的保留字段,通常不使用void (*sa_restorer)(void);
};
4.3.3 代码
1️⃣ 等价 signal 函数。
// 1.等价 signal 函数
#include <iostream>
#include <signal.h>void handler_sigint(int signo) {std::cout << "handler sigint 2 signal" << std::endl;
}int main() {struct sigaction s;s.sa_flags = 0;s.sa_handler = handler_sigint;sigaction(SIGINT , &s , nullptr);return 0;
}
2️⃣ 检查处理信号中是否会阻塞当前信号或其他信号。
#include <iostream>
#include <signal.h>
#include <unistd.h>// s.sa_mask 会将2号信号阻塞,也就是说打印pending时,发现2号信号为1,就说明被阻塞没有被执行。
void handler_sigint(int signo) {std::cout << "handler sigint 2 signal" << std::endl;// 不断的打印 pending 表sigset_t set;sigemptyset(&set); while(true) {sigpending(&set);for(int i = 31; i >= 1; i--) {std::cout << sigismember(&set , i);}std::cout << std::endl;sleep(1);}
}int main() {struct sigaction s;s.sa_flags = 0;s.sa_handler = handler_sigint;// 在执行2号信号处理函数时,也可以设置其他信号的屏蔽sigset_t mask;sigemptyset(&mask); sigaddset(&mask , SIGQUIT);s.sa_mask = mask;sigaction(SIGINT , &s , nullptr);while(true) {std::cout << "hello world: " << getpid() << std::endl;sleep(1);}return 0;
}
5. 补充概念
5.1 可重入函数
当一个函数正在执行时(未全部执行完),如果另一个执行流(比如一个中断发生,或者另一个线程被调度)也调用了这个相同的函数,而不会产生错误或数据混乱的函数。
在多任务环境或中断到来时,一个函数很可能在执行到一半时被打断,然后另一个执行流又调用了这个函数。如果这个函数是不可重入的,就会导致数据被破坏、程序崩溃或产生不可预知的结果。否则,则该函数是可重入的。
-
操作了全局变量或静态变量 → 几乎肯定是不可重入的。
-
只操作自己内部的局部变量(在栈上)和传进来的参数 → 就是可重入的。
5.2 volatile
volatile
关键字主要解决了 内存可见性 问题。
先看一段代码:
-
注册信号
SIGINT
(信号2,通常是 Ctrl+C) 的处理函数handler_sig
。 -
进入死循环,等待
flag
变为 1。 -
当用户按下 Ctrl+C 时,信号处理函数将
flag
设为 1。 -
循环退出,程序正常结束。
在没有 volatile
的情况下,编译器优化可能导致程序永远无法退出!
#include <iostream>
#include <signal.h>int flag = 0;void handler_sig(int) {std::cout << "handler 2 signal" << std::endl;flag = 1;
}int main() {signal(2 , handler_sig);// 死循环while(!flag) {}std::cout << "main process end!" << std::endl;return 0;
}
当正常 g++ 进行编译时(g++ code.cc -O0
),按下 Ctrl + C 会打印 “main process end!”。
使用优化选项编译时 g++ -O1 code.cc
,按下 Ctrl + C 则还会卡在死循环中,不会打印。
优化前 - 实际执行流程:
-
从内存地址读取 flag 的值到 CPU 寄存器。
-
检查寄存器中的值是否为真。
-
如果为真,跳转到循环开始。
-
重复步骤 1-3。
特点:每次循环都从内存重新读取 flag
的值,所以信号处理函数修改内存中的 flag
能被立即看到。
优化后 - 实际执行流程:
-
从内存地址读取 flag 的值到 CPU 寄存器(仅此一次!)。
-
检查寄存器中的值是否为真。
-
如果为真,跳转到循环开始。
特点:只从内存读取一次,之后永远使用寄存器中的缓存值,信号处理函数对内存的修改完全被忽略。
解决方法:使用 volatile。
volatile int flag = 0
,含义是每次都从内存读取。
补充个人理解:
也就是说编译器没优化前,CPU每次循环都会去内存读取数据,进行算数或逻辑运算,有必要的话还会写回内存,若编译器优化后,发现没有修改 flag ,则只会导入CPU寄存器一次,之后再也不会访问内存,即使修改了 flag 在其他函数中。因为尽量减少昂贵的内存访问,多用快速的寄存器。
gcc/g++优化级别选项
# O0 - 不优化(默认选项)
gcc -O0 program.c# O1 - 基本优化(在保证编译速度的同时进行优化)
gcc -O1 program.c# O2 - 全面优化(推荐用于发布版本)
gcc -O2 program.c# O3 - 激进优化(可能增加代码大小)
gcc -O3 program.c
5.3 SIGCHLD 解决回收僵尸进程问题
父进程通过 sigaction
将 SIGCHLD
信号的处理动作设置为 SIG_IGN
。此后,由该父进程 fork
出的子进程在终止时会被系统自动回收,既不会变成僵尸进程,也不会再向父进程发送通知。
signal(SIGCHLD, SIG_IGN)
,注意 2.2 底部说的默认处理动作和忽略的区别,SIGCHLD 默认处理函数是 SIG_DFL,只不过 SIG_DFL 函数中的动作是 Ignore,而将处理动作设置为 SIG_IGN 函数是有区别的(理解:函数不一样)。
若需要子进程退出码,则还需要进程等待。
累了就休息一下,但请一定要坚持下去!