目录
一、信号的理解
二、信号的种类
2.1 标准信号 (1-31)
2.2 实时信号 (通常是34及以上)
三、信号的产生
3.1 用户通过终端产生信号
3.1.1 signal 函数
3.1.2 demo 测试
3.1.3 demo 现象
3.2 通过系统函数产生信号
3.2.1 demo 测试
3.3 由软件条件产生信号
3.3.1 alarm 函数
3.3.2 demo 测试
3.3.3 demo 现象
3.4 硬件异常产生信号
3.4.1 demo 测试
3.4.2 demo 现象
3.5 进程间通信产生信号
3.5.1 SIGCHLD 信号
3.5.1 demo 测试
3.5.2 demo 现象
四、信号的保存
4.1 信号的产生和递达
4.2 信号的状态
4.3 信号的存储结构
4.4 信号的处理
4.5 sigset_t 类型的信号集
4.5.1 sigprocmask:更改或检查进程的信号屏蔽字。
4.5.2 sigpending:检查当前进程的未决信号集。
4.5.3 sigemptyset:将信号集初始化为空集,即清除信号集中所有的信号,使其不包含任何信号。
4.5.4 sigfillset:将信号集初始化为满集,即包含所有可能的信号。
4.5.5 sigaddset:将指定的信号添加到信号集中。
4.5.6 sigdelset:将指定的信号从信号集中删除。
函数原型
4.5.7 sigismember:检查指定的信号是否在信号集中。
4.6 测试 demo
4.7 demo 现象
五、捕捉信号
5.1 内核态与用户态
用户态(User Mode)
特点
安全性
性能
内核态(Kernel Mode)
特点
安全性
性能
5.2 OS的存储
5.3 信号捕捉的过程
一、信号的理解
想象你在家里等待一个快递包裹的到来:
-
快递员到达通知:
- 当快递员到达你家楼下,他给你打电话或者发短信,告诉你快递到了。这就像一个信号,通知你有一个事件(快递到达)需要处理。
- 在Linux中,这类似于信号的产生和发送。例如,当你按下
Ctrl+C
时,系统会产生一个SIGINT
信号,发送给当前正在运行的前台进程。
-
你正在忙碌:
- 如果你正在忙着做其他事情,比如在看电影,你会告诉快递员放进快递驿站,这段时间你知道有快递到了,但还没有去取。
- 在Linux中,这相当于信号被产生但被屏蔽了,暂时不会立即处理。信号被保留在一个“待处理”的状态,直到你准备好处理它。
-
处理快递:
- 当你处理完手头的事情,你决定去取快递。你有几种选择:
- 默认动作:直接去拿快递,打开包裹,使用里面的物品。
- 自定义动作:你可以决定先不打开包裹,而是把它存放在一个特定的地方,稍后再处理。
- 忽略:你可以选择忽略这个快递,不去取它。
- 在Linux中,进程可以对信号有不同的处理方式:执行默认操作、自定义处理函数、或者忽略信号。例如,
SIGINT
的默认操作是终止进程,但你可以自定义一个处理函数来执行其他动作。
- 当你处理完手头的事情,你决定去取快递。你有几种选择:
二、信号的种类
可以在终端使用 kill -l 查看信号的种类:
2.1 标准信号 (1-31)
标准信号是由POSIX标准定义的,所有Unix和Linux系统都支持这些信号。每个信号都有一个固定的编号和对应的宏定义名称。
信号在Linux系统中是一种用于进程间通信和事件通知的机制。信号的产生可以由多种途径触发,具体包括以下几种方式:
- SIGHUP (1):挂起信号,通常在终端断开时发送给会话控制的进程。
- SIGINT (2):中断信号,通常由用户按下
Ctrl+C
产生,用于终止前台进程。 - SIGQUIT (3):退出信号,通常由用户按下
Ctrl+\
产生,用于终止进程并产生核心转储(core dump)。 - SIGILL (4):非法指令信号,进程执行非法、无效的机器指令时产生。
- SIGABRT (6):进程异常终止信号,通常由
abort()
函数产生。 - SIGFPE (8):浮点异常信号,如除以零等算术错误。
- SIGKILL (9):杀死信号,无法捕捉或忽略,用于立即终止进程。
- SIGSEGV (11):段错误信号,进程非法访问内存时产生。
- SIGPIPE (13):管道破裂信号,进程写入无读端的管道时产生。
- SIGALRM (14):闹钟信号,由
alarm()
函数设定的计时器到期时产生。 - SIGTERM (15):终止信号,默认用于请求进程终止。
这些信号编号在1到31之间,涵盖了大多数常见的进程控制和错误处理机制。
2.2 实时信号 (通常是34及以上)
实时信号的编号范围通常从34开始,根据具体的Linux实现可能有所不同。这些信号是POSIX实时扩展的一部分,提供了更高的优先级和实时性特性。
这里不做过多说明。
三、信号的产生
3.1 用户通过终端产生信号
用户可以通过在终端按特定的键来产生信号。例如:
- SIGINT (信号编号2):当用户按下
Ctrl+C
时,系统会产生一个SIGINT
信号并发送给当前运行的前台进程。这个信号的默认处理动作是终止进程。 - SIGQUIT (信号编号3):当用户按下
Ctrl+\
时,系统会产生一个SIGQUIT
信号,默认处理动作是终止进程并产生一个核心转储(core dump)
3.1.1 signal 函数
在Linux和其他类Unix操作系统中,signal
是一个函数,用于设置进程对特定信号的处理方式。信号(signal)是进程间通信的一种机制,用于通知进程某个事件的发生。
signal
函数用于指定某个信号的处理函数。当进程接收到该信号时,操作系统会调用指定的处理函数。通过这个函数,程序可以定义自定义的行为来响应信号,而不仅仅是执行默认的处理动作(比如终止进程)。
其函数原型如下:
#include <signal.h> void (*signal(int sig, void (*func)(int)))(int);
sig
: 指定的信号编号(例如SIGINT
表示中断信号)。func
: 指向信号处理函数的指针。处理函数接受一个整数参数,该参数是信号的编号。
3.1.2 demo 测试
以下是一个demo,可以用来测试用户通过终端产生信号 。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 自定义的信号处理函数
void handle_sigint(int sig)
{
printf("Caught signal %d (SIGINT). Exiting...\n", sig);
_exit(0);
}
int main()
{
// 设置对 SIGINT 信号的处理函数
signal(SIGINT, handle_sigint);
// 无限循环,等待信号到来
while (1)
{
printf("Running... Press Ctrl+C to stop.\n");
sleep(1);
}
return 0;
}
/*signal(SIGINT, handle_sigint): 设置对 SIGINT 信号的处理函数为 handle_sigint。当进程接收到 SIGINT 信号时,将调用 handle_sigint 函数。
handle_sigint 函数:自定义的信号处理函数,打印接收到的信号编号。*/
demo 解释:
signal(SIGINT, handle_sigint)
: 设置对SIGINT
信号的处理函数为handle_sigint
。当进程接收到SIGINT
信号时,将调用handle_sigint
函数。handle_sigint
函数:自定义的信号处理函数,打印接收到的信号编号。
3.1.3 demo 现象
可见,CTRL + c 就是信号 SIGINT ,按下 CTRL + c 后,系统进入handle函数,执行了 exit
如果对 demo 进行修改,取消掉 exit 后,再按下 CTRL + c 后,程序就不会终止了。
产生这种现象的原因是 signal 函数改变了对 CTRL + c 这种信号的处理方式,把退出的处理方式修改成了 handle_sigint 函数中的方式。
3.2 通过系统函数产生信号
程序可以通过调用系统提供的函数来产生信号,例如:
- kill()函数:可以用于向指定进程发送信号。调用形式如
kill(pid, signo)
,其中pid
是目标进程的进程ID,signo
是要发送的信号编号。 - raise()函数:用于向当前进程自身发送信号,相当于
kill(getpid(), signo)
。 - abort()函数:使当前进程接收到
SIGABRT
信号并异常终止。
3.2.1 demo 测试
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 自定义的信号处理函数
void handle_sigusr1(int sig)
{
printf("Caught signal %d (SIGUSR1)\n", sig);
_exit(0);
}
int main()
{
// 设置对 SIGUSR1 信号的处理函数
signal(SIGUSR1, handle_sigusr1);
pid_t pid = fork();
if (pid == 0)
{
// 子进程
sleep(2);
kill(getppid(), SIGUSR1); // 向父进程发送 SIGUSR1 信号
_exit(0);
}
else
{
// 父进程
while (1)
{
printf("Waiting for SIGUSR1 from child process...\n");
sleep(1);
}
}
return 0;
}
3.2.2 demo 现象
通过上述 demo ,子进程在休息2s后会对父进程发送信号然后退出,父进程进入一个无限循环,等待 SIGUSR1
信号。当信号到达时,信号处理函数 handle_sigusr1
将被调用。
当 handle_sigusr1
被调用时,会打印出 Caught signal %d (SIGUSR1)\n ,然后执行 _exit ,退出程序。
3.3 由软件条件产生信号
一些信号是由特定的软件条件触发的,例如:
- SIGPIPE (信号编号13):当进程尝试向一个没有读端的管道或套接字写入数据时,系统会产生
SIGPIPE
信号。 - SIGALRM (信号编号14):通过
alarm(seconds)
函数设定一个闹钟,在指定的秒数后系统会向当前进程发送SIGALRM
信号。
3.3.1 alarm 函数
函数原型
#include <unistd.h> unsigned int alarm(unsigned int seconds);
seconds
:指定计时器的秒数。设定计时器在seconds
秒之后发送SIGALRM
信号。如果seconds
为 0,表示取消任何现有的计时器。- 返回先前设定的闹钟时间还剩余的秒数。如果没有设定过闹钟,则返回 0。
3.3.2 demo 测试
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 自定义的信号处理函数
void handle_sigalrm(int sig)
{
printf("Caught signal %d (SIGALRM). Time's up!\n", sig);
}
int main()
{
// 设置对 SIGALRM 信号的处理函数
signal(SIGALRM, handle_sigalrm);
alarm(5); // 设定闹钟在 5 秒后触发 SIGALRM 信号
// 无限循环,等待信号到来
while (1)
{
printf("Sleeping... Waiting for alarm...\n");
sleep(1);
}
return 0;
}
3.3.3 demo 现象
首先使用 alarm 函数设定了一个5s后的闹钟,程序会在5s后接收到 SIGALRM 信号,同时使用signal 函数重新设计了 SIGALRM 信号的处理方式,所以执行程序后会看到以下现象:
在Unix和类Unix系统中,alarm
函数只支持设置一个定时器。如果在一个进程中设置了两个alarm
调用,后面的调用会覆盖前面的调用。
具体来说,当你第二次调用alarm
时,它会取消前一个定时器并重新设定一个新的定时器。因此,第一个定时器所关联的SIGALRM
信号将不会被发送,只有最后一次调用alarm
设置的定时器到期时,才会发送SIGALRM
信号。
3.4 硬件异常产生信号
当进程执行非法操作(如除以0或者页表对应失败(数组越界、野指针...))时,硬件会产生异常,内核将这些异常转换为信号并发送给进程,例如:
- SIGSEGV (信号编号11):当进程访问非法内存地址时,系统会产生
SIGSEGV
信号,通常导致进程异常终止。
- SIGFPE (信号编号8):当进程执行非法的算术操作(如除以零)时,系统会产生
SIGFPE
信号
3.4.1 demo 测试
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 自定义的信号处理函数
void handle_sigfpe(int sig)
{
printf("Caught signal %d (SIGFPE). Division by zero!\n", sig);
_exit(1);
}
int main()
{
// 设置对 SIGFPE 信号的处理函数
signal(SIGFPE, handle_sigfpe);
int x = 1;
int y = 0;
int z = x / y; // 这将导致 SIGFPE 信号
printf("Result: %d\n", z);
return 0;
}
3.4.2 demo 现象
3.5 进程间通信产生信号
父进程和子进程之间可以通过信号进行通信。例如,当子进程终止时,会向父进程发送SIGCHLD
信号。父进程可以捕捉并处理该信号,以便执行相应的清理工作,避免产生僵尸进程。
3.5.1 SIGCHLD 信号
SIGCHLD
是一个特定的信号,用于通知父进程其子进程的状态变化。通常,当一个子进程终止或停止时,系统会向父进程发送 SIGCHLD
信号。父进程可以通过捕捉和处理 SIGCHLD
信号来得知其子进程的终止或停止状态,并进行相应的处理,如清理资源或重新启动子进程。
以下是一个子进程对应一个父进程时,子进程退出向父进程发出 SIGCHLD 信号。
3.5.1 demo 测试
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <sys/wait.h>
#include <unistd.h>
// 自定义的信号处理函数
void handle_sigchld(int sig)
{
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0)
{
printf("Child %d terminated\n", pid);
}
}
int main()
{
// 设置对 SIGCHLD 信号的处理函数
signal(SIGCHLD, handle_sigchld);
if (fork() == 0)
{
// 子进程
printf("Child process: %d\n", getpid());
sleep(2);
_exit(0);
}
else
{
// 父进程
while (1)
{
printf("Parent process doing some work...\n");
sleep(1);
}
}
return 0;
}
3.5.2 demo 现象
这里对SIGCHLD信号进行处理,在处理方式中设置了 waitpid 的方法,同时,其中设置了WNOHANG 的方式,防止子进程一部分退出另一部分不退出造成的进程堵塞,这样也会导致父进程无法进行自己的操作。
同时,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用 sigaction 将SIGCHLD 的处理动作置为 SIG_IGN ,这样 fork 出来的子进程在终止时会自动清理掉,不 会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略:
signal(SIGCHLD, SIG_IGN);
四、信号的保存
4.1 信号的产生和递达
- 信号产生:当某个事件发生时(例如用户按下
Ctrl+C
、调用kill
函数、硬件异常等),内核会为目标进程产生一个信号。 - 信号递达:信号递达指的是信号被实际传送到目标进程并触发相应的处理动作。递达的时机可以是立即的,也可以是当进程解除信号阻塞时。
4.2 信号的状态
信号在一个进程的生命周期中可以有三种状态:
- 未决(Pending):信号已经产生,但由于某种原因(例如信号被阻塞)尚未递达。
- 递达(Delivered):信号已经传递到进程,并触发了相应的处理动作。
- 阻塞(Blocked):进程设置了信号屏蔽字,暂时阻止某些信号的递达。
4.3 信号的存储结构
每个进程都有两个重要的数据结构用于信号的管理:
- 未决信号集(Pending Signals Set):记录当前进程所有未决的信号。通常用一个位图来表示,每个比特位对应一个信号,置1表示该信号未决。
- 信号屏蔽字(Signal Mask):记录当前进程哪些信号被阻塞。也用一个位图来表示,每个比特位对应一个信号,置1表示该信号被阻塞。
4.4 信号的处理
当一个信号递达时,内核会根据以下步骤处理信号:
- 检查信号屏蔽字:如果信号被阻塞(在信号屏蔽字中置位),信号不会立即递达,而是保持未决状态。
- 更新未决信号集:将信号添加到未决信号集中。
- 检查信号处理方式:
- 默认处理:执行默认的处理动作,例如终止进程。
- 忽略信号:信号被丢弃,不做任何处理。
- 自定义处理函数:调用用户定义的信号处理函数。
4.5 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);
4.5.1 sigprocmask:更改或检查进程的信号屏蔽字。
函数原型
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
how
:指定如何修改信号屏蔽字(如SIG_BLOCK
阻塞信号)。set
:指向要设置的信号集。oldset
:如果不为 NULL,保存先前的信号屏蔽字。
4.5.2 sigpending:检查当前进程的未决信号集。
函数原型
int sigpending(sigset_t *set);
set
:指向保存未决信号集的信号集。
4.5.3 sigemptyset:将信号集初始化为空集,即清除信号集中所有的信号,使其不包含任何信号。
函数原型
int sigemptyset(sigset_t *set);
set
:指向要初始化的信号集。- 成功时返回 0;出错时返回 -1。
4.5.4 sigfillset:将信号集初始化为满集,即包含所有可能的信号。
函数原型
int sigfillset(sigset_t *set);
set
:指向要初始化的信号集- 成功时返回 0;出错时返回 -1。
4.5.5 sigaddset:将指定的信号添加到信号集中。
函数原型
int sigaddset(sigset_t *set, int signo);
set
:指向要修改的信号集。signo
:要添加到信号集中的信号编号。- 成功时返回 0;出错时返回 -1。
4.5.6 sigdelset:将指定的信号从信号集中删除。
函数原型
int sigdelset(sigset_t *set, int signo);
set
:指向要修改的信号集。signo
:要从信号集中删除的信号编号。- 成功时返回 0;出错时返回 -1。
4.5.7 sigismember:检查指定的信号是否在信号集中。
函数原型
int sigismember(const sigset_t *set, int signo);
set
:指向要检查的信号集。signo
:要检查的信号编号。- 如果信号在信号集中,返回 1;如果不在信号集中,返回 0;出错时返回 -1。
4.6 测试 demo
#include <iostream>
#include <unistd.h>
#include <cstdio>
#include <sys/types.h>
#include <sys/wait.h>
void PrintPending(sigset_t &pending)//打印“位图”
{
std::cout << "curr process[" << getpid() << "]pending: ";
for (int signo = 31; signo >= 1; signo--)
{
if (sigismember(&pending, signo))
{
std::cout << 1;
}
else
{
std::cout << 0;
}
}
std::cout << "\n";
}
void handler(int signo)
{
std::cout << signo << " 号信号被递达!!!" << std::endl;
}
int main()
{
// 0. 捕捉2号信号
signal(2, handler); // 自定义捕捉
signal(2, SIG_IGN); // 忽略一个信号
signal(2, SIG_DFL); // 信号的默认处理动作
// 1. 屏蔽2号信号
sigset_t block_set, old_set;
sigemptyset(&block_set);
sigemptyset(&old_set);
sigaddset(&block_set, SIGINT);
// 1.1 设置进入进程的Block表中
sigprocmask(SIG_BLOCK, &block_set, &old_set); // 真正的修改当前进行的内核block表,完成了对2号信号的屏蔽!
int cnt = 10;
while (true)
{
// 2. 获取当前进程的pending信号集
sigset_t pending;
sigpending(&pending);
// 3. 打印pending信号集
PrintPending(pending);
cnt--;
// 4. 解除对2号信号的屏蔽
if (cnt == 0)
{
std::cout << "解除对2号信号的屏蔽!!!" << std::endl;
sigprocmask(SIG_SETMASK, &old_set, &block_set);
}
sleep(1);
}
}
4.7 demo 现象
五、捕捉信号
通过前面的学习,已经了解了我们可以自定义信号处理的方式,当对某信号进行自定义处理时,系统就要去找自定义的 handler 处理方法,但是,系统拥有最高的权限,它的这种身份被称作内核态,普通用户则被成为用户态。系统会以内核态的方式直接去执行自定义的 handler 函数吗?很显然是不行的。
这样如果某个用户钻了漏子,借用系统内核态的身份完成一些用户态不可以完成的事情,就会惹到麻烦。操作系统在这时就存在着身份的转换。
5.1 内核态与用户态
下面以32位机器为例:
4G的内存中,0-3G是供用户使用的,3-4G是操作系统的所有代码和数据。当用户想访问[3-4]G的地址时,只能使用系统调用!
用户态(User Mode)
特点
- 受限访问:在用户态下,进程只能访问受限的内存区域和受限的硬件资源,不能直接执行可能影响系统稳定性的指令。
- 应用程序运行:大多数应用程序(如文本编辑器、浏览器等)都在用户态运行。
- 系统调用:当用户态进程需要执行特权操作(如文件读写、内存管理等)时,它必须通过系统调用请求内核的帮助。
安全性
- 用户态运行的代码无法直接访问硬件和系统关键资源,防止应用程序错误或恶意代码直接影响系统的稳定性。
性能
- 用户态进程的执行速度较慢,因为它们不能直接访问硬件,需要通过系统调用进行间接访问。
内核态(Kernel Mode)
特点
- 完全访问权限:在内核态下,代码可以访问所有的内存区域和所有的硬件资源,可以执行任何CPU指令。
- 操作系统内核运行:操作系统的内核及其核心服务(如设备驱动程序、文件系统、网络栈等)都在内核态运行。
- 系统调用处理:内核态负责处理来自用户态的系统调用请求,并执行相应的操作。
安全性
- 内核态运行的代码有最高权限,因此必须确保内核代码的正确性和安全性,避免系统崩溃或安全漏洞。
性能
- 内核态进程的执行速度较快,因为它们可以直接访问硬件和系统资源。
5.2 OS的存储
操作系统也是一个软件,它是第一个加载到内存的软件,它的页表只会维护一份,所以当从用户级换到内核级时,无论在哪个进程,相应的系统调用会访问内核地址空间,映射到同一个内核级页表,进而每个进程进入的OS内部都是相同的!
系统调用访问内核地址空间:无论哪个进程发起系统调用,都会进入相同的内核地址空间,访问相同的内核数据结构和代码。
映射到相同的内核级页表:每个进程在进入内核态时,使用的都是相同的内核级页表。这确保了内核环境的一致性和简化了内存管理。
统一的OS内部环境:由于共享相同的内核地址空间和内核级页表,每个进程进入内核态时,看到的OS内部环境是相同的。
5.3 信号捕捉的过程
-
信号的产生(进入内核态):
- 当某个事件(如用户按下
Ctrl+C
或硬件异常)触发信号时,内核会生成该信号并将其标记为待处理状态。此时,进程会从用户态切换到内核态。 - 如果信号是由系统调用(如
kill
或raise
)产生的,同样会引发进程进入内核态。
- 当某个事件(如用户按下
-
信号的检查与处理准备(进入用户态):
- 内核检查当前进程的信号屏蔽字和信号处理设置,确定该信号是否需要处理。
- 如果信号未被阻塞,内核会准备将信号处理函数(用户自定义的或默认的)加入进程的执行上下文中。这将导致进程从内核态返回到用户态。
-
信号处理函数的执行(进入内核态):
- 当信号处理函数被调用时,进程再次从用户态切换到内核态,以便内核进行必要的处理(例如,保存当前的进程上下文)。
- 内核将控制权交给信号处理函数,此时进程切换回用户态,执行用户定义的信号处理函数。
-
信号处理函数的完成(进入内核态并返回用户态):
- 当信号处理函数执行完毕后,进程再次进入内核态,以便内核恢复先前保存的进程上下文。
- 最终,内核将控制权返回给进程的正常执行流,进程回到用户态,继续执行未完成的工作。
用户态 (User Mode)
|
| (事件触发,如 Ctrl+C)
V
内核态 (Kernel Mode)
|
| (信号生成,标记待处理)
V
内核态 (Kernel Mode)
|
| (准备信号处理)
V
用户态 (User Mode)
|
| (执行信号处理函数)
V
内核态 (Kernel Mode)
|
| (信号处理函数执行完毕)
V
用户态 (User Mode)