目录
理论篇
信号概述
信号的分类
信号机制
理解硬件中断
异步
信号对应的三种动作
信号产生的条件
终端按键
系统调用
软件条件
硬件异常
除0错误
野指针
OS对于错误的态度
信号在进程中的内核数据结构
信号的处理
CPU的内核态和用户态概述
进程处理信号的时机
编辑
在递达时该信号被屏蔽
实操篇
sigset_t
信号集操作函数
sigprocmask
参数
返回值
sigpending
参数
返回值
sigaction
参数
返回值
struct sigaction 结构
闹钟
参数
返回值
注意事项
signal
个人主页东洛的克莱斯韦克
理论篇
信号概述
在Linux系统中,信号是一种软件中断,用于通知进程发生了某个事件
信号是由操作系统发送给进程的,用于通知进程某个条件已经发生或者需要执行某个动作,进程处理信号的默认动作可用如下命令查看
man 7 signal
信号提供了一种进程间通信的机制,虽然它主要是用来通知异常或中断情况,但也可以被用来实现进程间的同步或控制
信号的分类
信号分为普通信号 和 实时信号
用如下指令查看信号
kill -l
在Linux系统中1号信号到31号信号为普通信号,34号到64号信号为实时信号。每一种信号都有自己的宏定义。
实时信号一般用于特定的系统中,如嵌入式系统,能够在特定的硬件上运行并对外部事件做出迅速响应的系统。一个很贴近生活的例子是车载系统,如果用户层踩了刹车,车载系统即使再忙也要马上做出响应。实时信号会打破进程占用CPU资源的公平性。
而普通信号不会破坏进程占用CPU的资源的公平性,本文重点探讨普通信号。
信号机制
信号的机制属于一种软件中断,它模拟的是硬件中断
理解硬件中断
硬件中断是指当硬件设备(如网卡、硬盘、键盘等)有数据或事件需要处理时,会自动向CPU发送一个中断请求(IRQ),CPU在收到中断请求后,会暂停当前正在执行的任务,转而执行处理该中断请求的程序,这一过程称为硬件中断处理。
也就是说OS不会耗费资源去检查外设的数据情况,而是检测CPU是否收到中断请求。
以键盘为例,OS不可能知道用户什么时候敲键盘,OS也不会检测键盘是否有数据,只有用户敲键盘了,键盘会给CPU发送中断请求,OS检查到CPU的中断请求,才会把数据从键盘文件刷到内核缓冲区。
硬件中断是硬件行为,信号是在软件层模拟硬件中断。操作系统给进程发送相应的信号,进程收到信号完成某些任务。
异步
与硬件中断类似,进程不知道操作系统什么时候给自己发送信号。由于信号可以在进程执行的任何时间点到来,且进程通常不会预先知道何时会收到信号,因此信号的接收是异步的。
信号对应的三种动作
默认动作 忽略 自定义
默认动作:每一个信号对应一个动作。就比如红绿灯的 红灯 ,绿灯, 黄灯 分别对应停止, 前进, 等一等。
信号编号 | 信号名称 | 默认动作 | 备注 |
---|---|---|---|
1 | SIGHUP | 终止进程 | 当用户退出shell时,由该shell启动的所有进程将接收此信号 |
2 | SIGINT | 终止进程 | 相当于Ctrl+C,通常用于终止前台进程 |
3 | SIGQUIT | 终止进程并生成core文件 | 相当于Ctrl+\,除了终止进程外,还会生成core文件用于调试 |
4 | SIGILL | 终止进程并生成core文件 | 非法指令,例如执行了未知或不支持的指令 |
5 | SIGTRAP | 终止进程并生成core文件 | 跟踪/断点陷阱,通常与调试器相关 |
6 | SIGABRT | 终止进程并生成core文件 | 调用abort()函数产生的信号,用于异常终止进程 |
7 | SIGBUS | 终止进程并生成core文件 | 总线错误,非法访问内存地址(如对齐错误) |
8 | SIGFPE | 终止进程并生成core文件 | 浮点异常,如除以0、溢出等 |
9 | SIGKILL | 终止进程 | 不能被捕捉、阻塞或忽略,无条件终止进程 |
10 | SIGUSR1 | 终止进程 | 用户自定义信号1,程序员可以在程序中定义并使用该信号 |
11 | SIGSEGV | 终止进程并生成core文件 | 无效的内存引用,如解引用空指针 |
12 | SIGUSR2 | 终止进程 | 用户自定义信号2,程序员可以在程序中定义并使用该信号 |
13 | SIGPIPE | 终止进程 | 写入没有读端的管道,常见于socket编程中 |
14 | SIGALRM | 终止进程 | 由alarm()函数设置的定时器超时产生 |
15 | SIGTERM | 终止进程 | 请求程序终止,与SIGKILL不同,SIGTERM可以被捕捉、阻塞或忽略 |
16-17 | SIGSTKFLT | 终止进程 | 协处理器栈错误(已废弃,在某些系统上可能不存在) |
SIGCHLD | 忽略 | 子进程停止或终止时,通知父进程,但父进程通常选择忽略此信号 | |
18 | SIGCONT | 继续执行被停止的进程 | 使一个停止的进程继续执行(如果它被停止的话) |
19 | SIGSTOP | 停止进程 | 不能被捕捉、阻塞或忽略,无条件停止进程 |
20 | SIGTSTP | 停止进程 | 相当于Ctrl+Z,用于暂停前台进程 |
21-22 | SIGTTIN | 停止进程 | 后台进程尝试从控制终端读取时产生(如后台进程尝试读取输入) |
SIGTTOU | 停止进程 | 后台进程尝试向控制终端写入时产生(如后台进程尝试输出) | |
23-31 | SIGURG, ... | 依赖于具体实现 | 这些信号的默认动作可能因系统而异,且一些信号可能不在所有系统上都有定义 |
忽略:进程屏蔽了该信号
自定义:进程捕捉了该信号
9号信号和19号信号不能被捕捉也不能被屏蔽。
9号信号是用来杀进程的且一定能杀掉,用来处理有问题的进程,恶意攻击系统的进程等等
19号信号用来停掉进程而且一定能停掉,如果进程正在做一些很重要的事情但确实出了一些问题,可以用19号信号停掉该进程。
信号产生的条件
终端按键
常用的组合键如下
Ctrl+C信号:SIGINT(Interrupt,中断信号)
作用:当用户按下Ctrl+C时,终端驱动程序会接收到这个输入,并调用信号系统向当前的前台进程发送SIGINT信号。默认动作是终止进程。
补充:前台进程指的是你收到键盘输入的进程,linux中只允许有一个前台进程,可以有多个后台进程。
Ctrl+Z信号:SIGTSTP(Stop typed at terminal,终端停止信号)
作用:当用户按下Ctrl+Z时,终端驱动程序会向当前的前台进程发送SIGTSTP信号。这个信号会停止(挂起)进程的执行,但进程并没有被终止。用户可以使用fg
命令将进程恢复到前台继续执行,或者使用bg
命令将其放到后台执行。
Ctrl+\信号:SIGQUIT(Quit,退出信号)
作用:在某些系统中,当用户按下Ctrl+\时,会向当前的前台进程发送SIGQUIT信号。这个信号的默认处理方式是终止进程,并且会生成一个核心转储文件(core dump),该文件包含了进程终止时的内存、寄存器状态等信息,有助于开发者进行调试。
补充:服务器默认是关掉核心转储的,在线上生产环境中,会有大量收到SIGQUIT信号的进程,如果核心转储没有关掉,会有大量的核心转储文件挤压磁盘空间,使服务器无法正常运行。
系统调用
软件条件
【linux】进程间通信
对于管道而言,如果管道的读端全部被关闭,写端就没有意义了。操作系统会给写端进程发送SIGPIPE信号,其信号编号为13。
SIGPIPE信号是Linux中定义的一个标准信号,用于指示一个写操作尝试写入一个没有读端的管道。SIGPIPE信号的默认动作是终止进程,但可以通过设置信号处理函数来捕获或忽略该信号。大多数服务器程序为了避免因SIGPIPE信号而异常终止,会选择忽略该信号。
上述例子就属于进程触发了一些软件条件,从而让操作系统发送信号来通知进程。
硬件异常
硬件异常是指由硬件设备引起的程序执行过程中的错误或异常情况。下面分析除0错误和野指针问题的硬件异常
除0错误
除0错误是很常见的硬件异常,它会使CPU的运算溢出。当进程执行了除以0的指令时,CPU的运算单元会产生异常,并通知内核。内核会解析这个异常为SIGFPE信号,并发送给当前进程。
野指针
当进程访问了非法或未分配的内存地址时,MMU会产生异常,并通知内核。内核会解析这个异常为SIGSEGV信号,并发送给当前进程。
本质上讲,野指针问题都是虚拟地址在页表中转化物理地址失败。【Linux】进程地址空间
OS对于错误的态度
对于一些异常或错误系统往往不会发送9或19号信号,上文中4个条件触发的信号都是可以被捕捉的和屏蔽的。
也就是说即使触发了一些异常或错误,系统做的也只是通知,而不是强硬的杀掉或停掉进程。比如上述的除 0 错误,进程只要捕捉了SIGFPE信号,那么该进程只要被调度到CPU上就会触发除 0 异常,OS就会继续给该进程发送SIGFPE信号。
OS为什么要用信号的方式通知,而不是直接杀进程?
可以搭个场景来理解下,进程A正在向磁盘写入10万条转账记录,但进程A触发了某些异常,此时OS的做法是通知进程A,还是杀掉或停掉进程A呢。如果做的比较极端导致这10万条转账记录丢失了,是进程的问题,还是OS的问题呢。
那么我们也就不难理解,OS为什么要给进程足够多的宽容度。因为应用层可能正在做很重要的事情,OS太极端对应用层的体验会大打折扣。
所以OS会以信号的方式通知进程,而对异常的处理就看上层是怎么设计的,出了问题也和OS没关系。
信号在进程中的内核数据结构
屏蔽:进程不在接收次信号(block)
递达:实际执行信号的处理动作称为信号递达(Delivery)
未决 :信号从产生到递达之间的状态,称为信号未决(Pending)。
每个进程的内核数据都有三张表。
block是位图0表示该信号未被屏蔽,1表示该信号被屏蔽了。
Pending也是位图0表示没有收到该信号,1表示收到了该信号但没有执行。
bandler是一张函数指针数组表,如果该函数要被递达了就会执行对应的方法,可以这些默认方法,也可以执行自定义方法。
需要注意的是Pending位图中没有计数的概念。也就是说,在一段时间内一种信号如果被发送了多次,那么也只会被递达一次。
信号的处理
CPU的内核态和用户态概述
CPU的内核态和用户态是操作系统中的两种重要状态,它们分别代表了CPU在执行不同类型程序时的权限和访问范围。
内核态是CPU的一种特权状态,也称为系统态或管态。在此状态下,CPU可以执行任何机器指令,包括访问和修改所有硬件设备的寄存器,执行内存管理等特权操作。
用户态是CPU的一种非特权状态,也称为目态。在此状态下,CPU只能执行非特权指令,不能直接访问硬件设备或执行特权操作。
内核态(Kernel Mode) | 用户态(User Mode) | |
---|---|---|
定义 | CPU的特权状态,可执行任何机器指令 | CPU的非特权状态,只能执行非特权指令 |
权限 | 最高特权级别(通常为Ring 0) | 较低特权级别(通常为Ring 3) |
运行程序 | 操作系统内核代码 | 用户编写的应用程序和操作系统提供的用户接口程序 |
访问范围 | 可访问和修改系统的任何部分 | 只能访问和操作分配给它的资源 |
安全性 | 必须确保安全性和稳定性 | 权限较低,即使出现错误也不会对系统造成严重影响 |
切换方式 | 通过系统调用、异常和中断等方式实现 | 通过系统调用等方式请求操作系统服务时切换 |
cpu陷入内核后怎么找到内核的数据和代码呢?
在进程地址空间中0到3G是用户空间,3到4G是内核空间,所以进程的内核地址空间都会映射到同一块物理内存(这块物理内中是内核数据),只要CPU陷入内核就有权力访问地址空间中的内核空间。
地址空间示意图
需要注意的是,在执行用户代码是,CPU必须是用户态,因为用户代码中可能会有窃取数据,恶意攻击等非法操作。
进程处理信号的时机
红线以上是用户态,红线以下是内核态。蓝点是CPU切换用户态和内核态的时机,绿点是处理信号的时机。
在递达时该信号被屏蔽
一个信号在被处理时,该信号会被屏蔽。这样就保证了在处理某个信号时,如果这种信号再次产生,那么 它会被阻塞到当前处理结束为止。
实操篇
sigset_t
sigset_t 是一种数据类型,它涵括了上文的三张表。用于表示信号集,即一个或多个信号的集合。
sigset_t
是处理信号时的一个重要数据类型,它允许程序灵活地指定和操作信号集合。通过与相关的函数结合使用,sigset_t
提供了强大的信号处理能力,使得程序能够更精确地控制信号的行为。
信号集操作函数
-
int sigemptyset(sigset_t *set);
此函数用于初始化信号集,将其设置为空集,即不包含任何信号。如果操作成功,则返回0;如果发生错误,则返回-1。
-
int sigfillset(sigset_t *set);
此函数用于将信号集初始化为包含所有信号。这意味着,在调用此函数后,信号集将包含所有可能由系统发送的信号。如果操作成功,则返回0;如果发生错误,则返回-1。
-
int sigaddset(sigset_t *set, int signo);
此函数用于向信号集中添加一个信号。
signo
参数指定了要添加的信号编号。如果操作成功,则返回0;如果发生错误(例如,signo
不是有效信号),则返回-1。 -
int sigdelset(sigset_t *set, int signo);
与
sigaddset
相反,sigdelset
函数用于从信号集中删除一个信号。signo
参数指定了要删除的信号编号。如果操作成功,则返回0;如果发生错误(例如,signo
不是信号集中的成员),则返回-1。 -
int sigismember(const sigset_t *set, int signo);
此函数用于检查指定的信号编号(
signo
)是否属于信号集(set
)。如果signo
是信号集的成员,则返回1;如果不是,则返回0;如果发生错误(例如,set
不是有效的信号集指针),则返回-1。
这些函数在信号处理中非常有用,特别是当需要阻塞或捕捉特定信号时。例如,可以在进行关键操作之前使用sigprocmask
函数和sigemptyset
/sigaddset
来阻塞某些信号,以防止它们干扰这些操作。操作完成后,可以解除对这些信号的阻塞。
sigprocmask
参数
-
how
:指定如何更改信号屏蔽。这个参数可以是以下三个常量之一:-
SIG_BLOCK
:新的信号屏蔽是当前屏蔽与set
指向的集合的并集。即,set
中指定的信号将被添加到当前进程的屏蔽中。 -
SIG_UNBLOCK
:新的信号屏蔽是当前屏蔽与set
指向的集合的差集。即,set
中指定的信号将从当前进程的屏蔽中移除。 -
SIG_SETMASK
:新的信号屏蔽直接设置为set
指向的集合。即,忽略当前进程的屏蔽,并使用set
中指定的新屏蔽。
-
-
set
:指向sigset_t
类型的指针,表示要更改的信号集。如果how
是SIG_SETMASK
,则这个集合将直接成为新的信号屏蔽。如果how
是SIG_BLOCK
或SIG_UNBLOCK
,则这个集合中的信号将被相应地添加到或从当前屏蔽中移除。 -
oset
:如果此参数非空,则指向的sigset_t
变量将被设置为函数调用前的信号屏蔽。这允许调用者保存旧的屏蔽并在以后恢复它。
返回值
成功时,sigprocmask
返回0。失败时,返回-1,并设置errno
以指示错误。
#include <stdio.h>
#include <signal.h>
#include <string.h>
#include <unistd.h>
void handler(int sig) {
printf("Caught signal %d\n", sig);
}
int main() {
sigset_t set, oldset;
// 设置SIGINT的处理程序
signal(SIGINT, handler);
// 初始化信号集
sigemptyset(&set);
sigaddset(&set, SIGINT);
// 阻塞SIGINT
if (sigprocmask(SIG_BLOCK, &set, &oldset) == -1) {
perror("sigprocmask");
return 1;
}
// 现在SIGINT被阻塞了,发送SIGINT信号不会调用handler
printf("SIGINT is blocked. Trying to send SIGINT...\n");
raise(SIGINT); // 尝试发送SIGINT信号,但不会被处理
// 恢复旧的信号屏蔽
if (sigprocmask(SIG_SETMASK, &oldset, NULL) == -1) {
perror("sigprocmask");
return 1;
}
// 现在SIGINT不再被阻塞,发送SIGINT信号将调用handler
printf("SIGINT is unblocked. Trying to send SIGINT...\n");
raise(SIGINT); // 发送SIGINT信号,将调用handler
return 0;
}
sigpending
sigpending
函数是 POSIX 系统中用于查询当前进程阻塞(pending)的信号集合的接口。这些信号已经发送给进程,但由于某些原因(如信号被阻塞)而尚未被处理。通过调用 sigpending
函数,程序可以获取这些待处理的信号,并据此做出相应的处理。
#include <signal.h>
int sigpending(sigset_t *set);
参数
-
set
:指向sigset_t
类型的指针,用于存储查询到的待处理信号集合。
返回值
- 成功时,
sigpending
返回 0。 - 失败时,返回 -1,并设置
errno
以指示错误原因。
#include <stdio.h>
#include <signal.h>
#include <string.h>
void print_sigset(const sigset_t *set) {
printf("Pending signals: ");
for (int i = 1; i < _NSIG; ++i) {
if (sigismember(set, i)) {
printf("%d ", i);
}
}
printf("\n");
}
int main() {
sigset_t pending;
// 查询待处理信号集合
if (sigpending(&pending) == -1) {
perror("sigpending");
return 1;
}
// 打印待处理信号集合
print_sigset(&pending);
return 0;
}
sigaction
sigaction
函数是 POSIX 系统中用于检查和更改与指定信号相关联的处理动作的函数。这个函数比早期用于相同目的的 signal
函数提供了更多的功能和灵活性。通过 sigaction
,程序可以精确地指定信号的处理函数、设置信号处理的选项,并查询信号处理的当前状态。
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
参数
-
signo
:指定要操作的信号的编号。 -
act
:指向struct sigaction
的指针,包含了新的信号处理动作。如果此参数为 NULL,则不会更改信号的处理动作,但可以用来查询当前的处理动作(如果oact
非空)。 -
oact
:如果非空,指向一个struct sigaction
变量,该变量用于存储调用sigaction
之前信号的处理动作。这允许程序在更改信号处理动作之前保存它,以便将来恢复。
返回值
- 成功时,
sigaction
返回 0。 - 失败时,返回 -1,并设置
errno
以指示错误原因。
struct sigaction
结构
struct sigaction
结构体通常包含以下字段(但具体实现可能有所不同):
-
sa_handler
:指向信号处理函数的指针。如果此字段非 NULL,则它是一个普通的信号处理函数。如果为 SIG_IGN,则忽略该信号。如果为 SIG_DFL,则使用信号的默认处理动作。 -
sa_sigaction
:一个指向函数的指针,该函数用于处理信号,并提供了对信号发生时的额外信息的访问(如siginfo_t
结构)。这通常与SA_SIGINFO
标志一起使用。 -
sa_mask
:一个信号集,指定了在执行信号处理函数期间应该被阻塞的信号。这允许信号处理函数在执行时不受其他信号的干扰。 -
sa_flags
:一个标志位集合,用于修改信号处理的行为。常见的标志包括SA_RESTART
(如果信号中断了一个系统调用,则自动重启它),SA_NODEFER
(在执行信号处理函数时不自动阻塞该信号),和SA_SIGINFO
(使用sa_sigaction
而不是sa_handler
)。 -
sa_restorer
:这个字段在现代系统中很少使用,通常被忽略。它最初用于指定一个函数,该函数将恢复被信号处理函数中断的系统调用的执行环境。
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
void signal_handler(int signum) {
printf("Caught signal %d\n", signum);
// 清理资源、关闭文件等操作...
exit(signum);
}
int main() {
struct sigaction act;
// 初始化信号处理结构体
memset(&act, 0, sizeof(act));
act.sa_handler = signal_handler;
// 忽略 SIGPIPE 信号(可选)
signal(SIGPIPE, SIG_IGN);
// 设置 SIGINT 信号的处理函数
if (sigaction(SIGINT, &act, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
// 主循环,等待信号
while (1) {
pause(); // 暂停执行,等待信号
}
return 0; // 实际上永远不会执行到这里
}
闹钟
alarm
函数是 UNIX 和类 UNIX 系统(包括 Linux)中用于设置定时器的一个函数,它定义在 <unistd.h>
头文件中。当你调用 alarm
函数并传递给它一个参数 seconds
时,该函数会设置一个定时器,该定时器在指定的秒数后到期。当定时器到期时,如果进程没有捕获 SIGALRM 信号(通过调用 signal
或 sigaction
函数设置信号处理函数),则进程会收到 SIGALRM 信号。如果进程捕获了该信号,那么可以执行一些特定的操作,比如清理资源、记录日志等。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
参数
-
seconds
:定时器到期前的秒数。如果seconds
是 0,则任何当前设置的定时器都会被取消,但不会发送 SIGALRM 信号。
返回值
- 如果之前已经调用了
alarm
并且设置了定时器,alarm
函数会返回之前设置的剩余时间(秒),直到那个定时器到期。如果之前没有设置定时器,则返回 0。
注意事项
-
定时器重置:如果在一个已经设置了定时器的进程中再次调用
alarm
,那么之前的定时器会被新的定时器替换。返回值是之前定时器的剩余时间(如果有的话)。 -
精度和限制:
alarm
函数的精度和限制取决于系统的实现。在一些系统上,alarm
的精度可能较低,因为它基于系统的定时器中断。此外,一些系统可能对alarm
可以设置的最大时间有限制。 -
与 sleep/pause 的交互:如果进程在调用
alarm
后调用了sleep
或pause
,并且alarm
定时器在sleep
或pause
期间到期,那么sleep
或pause
会被中断,并且进程会收到 SIGALRM 信号(如果未捕获)。 -
信号处理:为了处理 SIGALRM 信号,你需要使用
signal
或sigaction
函数来设置信号处理函数。
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void sigalrm_handler(int sig_num) {
printf("Alarm signal received\n");
// 在这里执行清理或其他操作
}
int main() {
// 设置 SIGALRM 信号的处理函数
signal(SIGALRM, sigalrm_handler);
// 设置 5 秒后的定时器
alarm(5);
// 暂停执行,等待定时器到期或信号
pause();
return 0;
}
signal
在C语言中,signal
函数的原型可能因系统而异,但通常它遵循 POSIX 标准或类似的标准。不过,标准的 C 库(如 glibc)通常会在 <signal.h>
头文件中提供一个符合大多数系统需求的 signal
函数声明。
#include <signal.h>
void (*signal(int sig, void (*func)(int)))(int);
-
参数:
-
int sig
:要处理的信号编号。 -
void (*func)(int)
:指向信号处理函数的指针,该函数接受一个整型参数(信号编号)并返回void
。
-
-
返回值:
- 返回一个指向之前为该信号设置的信号处理函数的指针(也接受一个整型参数并返回
void
),或者返回SIG_ERR
以表示错误,并设置errno
以指示错误原因。
- 返回一个指向之前为该信号设置的信号处理函数的指针(也接受一个整型参数并返回
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
// 信号处理函数
void handler(int sig_num) {
printf("Caught signal %d\n", sig_num);
// 可以在这里执行清理操作或退出程序
exit(0); // 或者使用其他逻辑来响应信号
}
int main() {
// 设置 SIGINT 信号的处理函数
if (signal(SIGINT, handler) == SIG_ERR) {
// 如果 signal 调用失败,则打印错误信息并退出
perror("signal");
exit(EXIT_FAILURE);
}
// 程序的主循环或其他逻辑
while (1) {
printf("Waiting for signal...\n");
sleep(1); // 暂停一秒,以便可以看到信号何时被捕获
}
// 注意:实际上,由于我们在信号处理函数中调用了 exit,
// 所以这行代码永远不会被执行。
return 0;
}
【linux】进程控制——进程创建,进程退出,进程等待-****博客
【linux】进程间通信(IPC)——匿名管道,命名管道与System V内核方案的共享内存,以及消息队列和信号量的原理概述-****博客