Linux:信号
信号是进程之间事件异步通知的⼀种⽅式,属于软中断。
进程之间可以互相通过系统调用kill发送软中断信号。内核也可以因为内部事件而给进程发送信号,通知进程发生了某个事件。
注意,信号只是用来通知某进程发生了什么事件,并不给该进程传递任何数据。
我们先熟悉常用信号,并初步了解信号处理方法。
再根据信号的生命周期来了解信号机制运作的全过程
查看信号
使用 kill -l 可以查看所有的信号:
其中1-31号信号属于标准信号,34-64属于实时信号,本文只讨论标准信号
常用信号:
信号名 | 值 | 默认动作 | 触发原因 |
SIGINT | 2 | Term | 来自键盘的中断(Ctrl+C)。用户要求终止前台进程。 |
SIGQUIT | 3 | Core | 来自键盘的退出(Ctrl+/)。用户要求终止并生成核心转储。 |
SIGABRT | 6 | Core | 调用 abort() 函数触发。通常表示程序检测到严重错误并主动终止。 |
SIGFPE | 8 | Core | 浮点异常(例如除以零、溢出)。 |
SIGKILL | 9 | Term | 强制终止。此信号无法被捕获、阻塞或忽略,用于立即杀死进程。 |
SIGSEGV | 11 | Core | 段错误。无效的内存引用(访问未分配内存、写只读内存等)。最常见错误之一。 |
SIGPIPE | 13 | Term | 管道破裂。向一个读端已关闭的管道或套接字写数据。 |
SIGALRM | 14 | Term | 定时器信号。由 alarm() 或 setitimer() 设置的定时器超时触发。 |
SIGTERM | 15 | Term | 终止。这是 kill 命令默认发送的信号。请求进程正常终止,允许其清理资源。 |
SIGCHLD | 17 | Ign | 子进程状态改变(停止、终止)。父进程通常用此信号回收子进程资源(wait )。 |
SIGCONT | 18 | Cont | 继续执行。如果进程已停止,则使其继续运行(SIGSTOP /SIGTSTP 后)。 |
SIGSTOP | 19 | Stop | 停止进程。此信号无法被捕获、阻塞或忽略,用于暂停进程。 |
SIGTSTP | 20 | Stop | 来自终端的停止信号(Ctrl+Z)。用户要求暂停前台进程。 |
信号处理
进程收到信号后可以对信号进行处理。处理方法可以分为三类:
第一种是执行该信号的系统默认的处理动作,大部分的信号的默认操作是使进程终止。
第二种方法是忽略某个信号。
第三种方法是提供⼀个信号处理函数,要求内核在处理该信号时切换到用户态执⾏这个处理函数,这种⽅式称为⾃定义捕捉(Catch)⼀个信号
为信号设置处理方法需要使用系统调用signal函数::
其中signum参数表示为哪一个信号设置处理方法,handler参数为函数指针,表示为该信号设置什么处理方法,handler参数有两个宏:
SIG_DFL,表示该信号的默认动作
SIG_IGN,表示忽略该信号
返回值是一个函数指针,表示指定信号signum在设置处理方法前的处理方法
使用示例:
编写文件test_signal.cpp
#include<signal.h>
#include<unistd.h>
#include<iostream>void handler(int signum)
{std::cout<<"这是"<<signum<<"号信号的自定义捕捉动作"<<std::endl;
}int main()
{signal(2,handler);while(true){std::cout<<"程序运行中"<<std::endl;sleep(1);}return 0;
}
执行该文件,并键入Ctrl+C向进程发送2号信号(默认动作是终止进程):
执行默认动作:
进程直接终止
执行自定义动作:
进程并未终止且正常运行,且执行了自定义处理函数
忽略该信号:
进程并未终止,且正常运行
信号的生命周期与处理流程
信号产生
信号由事件源(内核、其他进程、自身)触发。
通过按键产生
Ctrl+C (SIGINT) 终止进程
Ctrl+\(SIGQUIT)可以发送终⽌信号并⽣成core dump⽂件,⽤于事后调试
Ctrl+Z(SIGTSTP)可以发送停⽌信号,将当前前台进程挂起到后台
使用kill指令
格式:kill -信号值 -进程pid
含义为:向指定进程发送指定信号
使用函数
kill函数
向指定进程发送指定信号
成功返回0,失败返回-1
raise函数
向当前进程发送指定信号
成功返回0,失败返回非0数
abort函数
向当前进程发送信号SIGABRT,该信号默认动作为终止进程
alarm函数
在指定时间后向当前进程发送信号SIGALRM,默认动作是终止进程
注意:
一个进程至多有一个定时器,新设置的定时器会覆盖旧定时器
若指定时间为0,则取消定时器
返回值为旧定时器的剩余时间
硬件异常
硬件异常被硬件以某种⽅式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。
例如当前进程执⾏了除以0的指令, CPU的运算单元会产⽣异常, 内核将这个异常解释为SIGFPE信号发送给进程。
或者当前进程访问了⾮法内存地址, MMU会产⽣异常,内核将这个异常解释为SIGSEGV信号发送给进程。
Core Dump
对于类型为Term的信号,默认处理动作是终止进程
对于类型为Core的信号,默认处理动作是终止进程,并生成core dump文件,该文件内容为进程在⽤⼾空间的内存数据,⽂件名通常是core。
即使是core类型的信号,也默认不产⽣ core ⽂件(生成的文件可能过大且包含敏感信息)。
可以⽤ ulimit 命令改变这个限制,允许产⽣ core ⽂件。
信号未决(Pending)
当一个信号已经产生,但尚未被进程处理时,该信号就处于未决(Pending) 状态。这是信号产生后、被实际交付给进程执行处理逻辑(默认、忽略或自定义处理函数)之前的 等待状态。
当一个未决信号被交给进程进行处理时,该信号就处于递达(Delivery)状态。
阻塞(Blocking):进程可以通过设置信号掩码(Signal Mask)来阻塞某些信号。被阻塞的信号即使产生了,也会被放入未决集合中,不会转为递达状态,直到进程解除对该信号的阻塞。
信号有未决状态,意味着进程在收到信号后,可能不会立即对信号进行处理。
1.未决状态可以视为对已经收到信号的保存
2.未决状态的存在使得用户可以通过阻塞的方式来推迟进程对信号的处理。
在内核中的表示:
block和pending都用位图来表示,而handler则是一个函数指针数组
每个信号都有两个标志位分别表⽰阻塞(block)和未决(pending),还有⼀个函数指针表⽰处理动作。信号产⽣时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。
sigset_t
为了用户方便对block,pending进行操作,系统定义了一种数据类型sigset_t(信号集)来批量管理多个信号:
typedef struct {
unsigned long sig[_NSIG_WORDS];
} sigset_t;
sigset_t类型对于每种信号用一个bit表示“有效”或“无效”状态,可以通过以下函数来改变信号集变量:
#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置1,表⽰该信号集的有效信号包含系统⽀持的所有信号。
注意,在使⽤sigset_ t类型的变量之前,⼀定要调⽤sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调⽤sigaddset和sigdelset在该信号集中添加或删除某种有效信号。
这四个函数都是成功返回0,出错返回-1。
sigismember判断⼀个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
sigprocmask
调⽤函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
调⽤成功则返回0,出错则返回-1
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
如果oset是⾮空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是⾮空指针,则更改 进程的信号屏蔽字,参数how指⽰如何更改。如果oset和set都是⾮空指针,则先将原来的信号的屏蔽字备份到oset⾥,然后 根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,下表说明了 how参数的可选值。
sigpending
检查pending信号集,获取当前进程pending位图
调⽤成功则返回0,出错则返回-1
#include <signal.h>
int sigpending(sigset_t *set);
信号递达
内核态与用户态
信号递达涉及用户态和内核态的切换,因此需要先了解用户态和内核态:
特性 | 用户态 | 内核态 |
---|---|---|
权限级别 | 低特权级(Ring 3) | 高特权级(Ring 0) |
执行主体 | 应用程序代码 | 操作系统内核代码 |
硬件访问 | 禁止直接访问硬件 | 可直接操作硬件(CPU/内存/设备) |
内存范围 | 仅限进程用户空间(0x0~0xBFFFFFFF) | 0xC0000000 到 0xFFFFFFFF |
指令集 | 受限指令集(无法执行特权指令) | 完整指令集(可执行cli , hlt 等特权指令) |
页表 | 每个进程有自己的页表 | 只有一份内核页表 |
信号递达的全流程:
(1)当进程因系统调用、中断或异常会进入内核态
(2)在处理完中断或异常准备返回用户态前,内核会检查该进程是否有未决(pending)且未被阻塞(unblocked) 的信号。如果有,内核会:
将信号的未决状态置为0
执行该信号的默认行为、忽略操作或用户注册的处理函数
注意:默认情况下,当进程正在处理某信号时,同种信号会被自动阻塞
(3)默认行为和忽略操作的代码是在内核态的,因此执行默认行为或忽略操作不需要进行内核态到用户态的切换,而用户的自定义行为的代码是在用户态的,因此执行自定义行为则需要进行内核态到用户态的切换。
(4)但是由于自定义行为和进入内核态前正在执行的代码(即main函数)并不存在相互调用的关系,因此想要回到之前的代码需要内核态来恢复上下文,因此在执行完用户的自定义行为后还会执行特殊的系统调用sigretrun函数来再次进入内核态,通过内核态恢复上下文,最后回到用户态,继续执行当前执行的代码
总体可以用下图来描述:
下面将补充介绍在中断或系统调用时如何由用户态切换至内核态:
关于系统调用和中断
中断向量表
中断向量表(Interrupt Vector Table, IVT)记录着发生特定类型的中断或异常时,应该去哪里找到相应的中断服务程序来执行。
发生中断时,会通过以下方式来处理中断:
根据中断的类型获取中断号
查找向量表: CPU 将中断号乘以 4(因为每个表项4字节),得到该中断向量在中断向量表中的起始地址
读取入口地址: CPU 从计算出的表项地址处读取 4 个字节:
(1)前 2 字节是中断服务程序的偏移地址(IP)。 (2)后 2 字节是中断服务程序的段地址(CS)。
跳转执行: CPU 将当前的程序状态(主要是 CS:IP 和标志寄存器 FLAGS)压入栈中保(以便返回),然后使用从中断向量表中读取的 CS:IP 值加载到相应的寄存器中。随后执行中断服务程序。
处理并返回: 中断服务程序执行完成后,执行一条
iret
指令。这条指令将之前保存的 CS:IP 和 FLAGS 从栈中弹出,CPU 恢复上下文,继续执行被中断的程序。
时钟中断
要想让多个进程并发运行,就需要内核频繁的调度,这就需要cpu每隔一段时间就强制暂停当前任务并跳转到内核态来进行调度,因此便有了时钟中断
时钟中断是通过硬件定时器,每隔固定时间就触发中断,每次触发中断时都会减少当前进程的剩余时间片,并检测时间片是否用完,如果用完就会调度其他进程来运行
系统调用
有时用户态的程序需要进行系统调用(如打开文件、读写数据、创建进程等),这就需要主动从用户态切换到内核态,为此提供了汇编指令:int和system call,这种主动从用户态陷入内核态的方式被称为陷阱
sigaction
上文为了引入信号处理的概念介绍了signal函数,但该函数是一个早期的接口,由于在不同版本下实现不一致等问题已被sigaction替代:
signum:指定要设置或获取处理程序的信号编号。可以指定SIGKILL和SIGSTOP以外的所有信号。
act:指向sigaction结构体的指针,用于指定新的信号处理方式。如果此参数非空,则根据此参数修改信号的处理动作。
oldact:如果非空,则通过此参数传出该信号原来的处理动作。(如果你想恢复以前的方式,此参数就是保存之前的操作方式)
sigaction函数依赖于同名结构体:
struct sigaction { void (*sa_handler)(int); // 指向信号处理函数的指针,接收信号编号作为参数 void (*sa_sigaction)(int, siginfo_t *, void *); // 另一个信号处理函数指针,支持更丰富的信号信息 sigset_t sa_mask; // 设置在处理该信号时暂时屏蔽的信号集 int sa_flags; // 指定信号处理的其他相关操作 void (*sa_restorer)(void); // 已废弃,不用关心
};