文章目录:
- 信号入门
- 从生活角度看信号
- 技术应用角度看信号
- 使用 kill -l 命令查看系统定义的信号列表
- 信号处理常见方式概览
- 产生信号
- 通过终端按键产生信号
- 核心转储(core dump)的作用
- 调用系统函数向进程发信号
- 由软件条件产生信号
- 硬件异常产生信号
- 阻塞信号
- 信号相关常见概念
- 信号在内核中的表示
- sigset_t
- 信号集操作函数
- sigprocmask
- sigpending
- 捕捉信号
- 内核空间与用户空间
- 内核态与用户态
- 内核如何实现信号的捕捉
- sigaction
- 可重入函数
- volatile
- SIGCHLD信号
信号入门
从生活角度看信号
- 你在网上买了很多件商品,在等待不同商品快递的到来。但即便快递没有到来,你也知道快递到来时,你该怎么处理快递。也就是你能 “识别快递”。
- 当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。那么在在这 5min 之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是一定要立即执行,可以理解成 “在合适的时候去取”。
- 在收到通知,再到你拿到快递期间,是有一个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有一个快递已经来了。本质上是你 “记住了有一个快递要去取”。
- 当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递一般方式有三种:1. 执行默认动作(打开快递,使用商品)2. 执行自定义动作(快递是零食,你要送给你朋友)3. 忽略快递(快递拿上来之后,继续你的工作)。
- 快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话。
在 Linux 系统中,信号也是一种异步事件,它可以被进程接收和处理。当一个进程接收到一个信号时,它可以选择忽略、处理或采取自定义动作。🧩
通过这个生活例子,可以看出信号是一种常见的异步事件,它可以打断进程的正常运行,并引发一些操作。在 Linux 中,信号被广泛应用于进程间通信、进程控制以及异常处理等方面。🧩
技术应用角度看信号
编写以下程序在 shell 中运行:
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while (true)
{
cout << "linux signal!" << endl;
sleep(1);
}
return 0;
}
执行以下代码的可执行程序,程序会死循环进程打印,而当按下 Ctrl + c
键之后,进程被终止:
❓为什么用户按下 Ctrl + c
之后该进程就终止了呢?
用户按下 Ctrl + c
时,这时键盘会产生一个硬件中断,被 OS 获取之后解释成信号(Ctrl + c 是 2 号信号),将该中断信号发送给目标前台进程。当一个进程接收到中断信号时,它的默认行为是终止进程。
然而,进程也可以选择捕获中断信号(SIGINT)并执行自定义的处理动作。在该情况下,进程可以决定如何响应中断信号,例如:关闭文件、释放资源或退出进程等。这种自定义处理信号中断的能力使得进程能够更加灵活地控制自己的行为。
如下:我们使用 signal 函数对 2 号信号进行捕捉,证明当按下 Ctrl + c
键时确实收到了 2 号信号。使用 signal 函数传递的两个参数,第一个参数是需要捕捉的信号,第二个参数是该信号对应的自定义处理方法。
#include <iostream>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signal)
{
cout << "receive a signal : " << signal << endl;
}
int main()
{
// 这里不是调用handler方法,是设置了一个回调,当对于的SIGINT信号产生时,该方法才会被调用
// 若不产生该信号,则该方法不会被调用
// ctrl+c:本质上是给前台进程发送了2号信号,发送给目标进程,目标进程对2号信号的默认处理就是终止自己
// 这里对2号信号,设置了自定义的处理方法
signal(2, handler);
while (true)
{
cout << "run ..." << endl;
sleep(1);
}
return 0;
}
运行该程序,按下 ctrl+c
之后进程就会收到 2 号信号,然后执行自定义给出的 handler 方法,而不是默认的退出进程了。此时我们想要结束进程,可以使用 kill -9
命令来终止进程:
说明:
ctrl+c
产生的信号只能发送给前台进程。一个命令后面加&
就可以放到后台运行,这样 shell 就不必等待进程结束就可以接受新的命令,启动新的进程。- shell 可以同时运行一个前台进程和任意多个后台进程,只有前台进程才能接收到像
ctrl+c
这样的控制键产生的信号。 - 前台进程在运行过程中用户可以随时按下
ctrl+c
而产生一个信号,也就是说该进程的用户空间代码执行到任何地方都有可能收到SIGINT
信号而终止,所以信号相对于进程的控制流程是异步(Asynchronous)的。 - 信号是进程之间事件异步通知的一种方式,属于软中断。
使用 kill -l 命令查看系统定义的信号列表
kill -l
命令可以列出系统定义的所有信号以及对应的编号,如下:
- 其中 1-31 号信号是普通信号,34-64 号信号是实时信号。
- 每个信号都有一个编号和一个宏定义名称,这些宏定义可以在
signal.h
中找到。
❓进程是如何知道收到了某个信号的?
每个进程在内核中都有一个对应的 PCB,用于记录进程的各种状态和信息,包括进程的执行状态、进程优先级、进程所属用户、进程所占用的资源等待。在 PCB 中,有一个信号位图用于记录进程接收到的信号。该位图的每一位对应一个信号,如果某一位的值为 1,表示该进程已经收到了相应的信号。
信号处理常见方式概览
当一个进程收到信号时,它可以采取不同的处理动作。通常情况下,进程可以选择以下三种处理方式:
- 忽略此信号。
- 执行该信号的默认处理动作。
- 提供一个信号处理函数,要求内核在处理信号时切换到用户态执行该函数,这种方式称为捕捉(Catch)一个信号。
在 Linux 中,可以使用 man 7 signal
命令来查看各个信号默认的处理动作:
产生信号
通过终端按键产生信号
对于下面的循环打印代码,上面我们介绍了可以通过 ctrl+c
终止进程。
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while (true)
{
cout << "linux signal!" << endl;
sleep(1);
}
return 0;
}
实际上,除了使用 ctrl+c
可以终止进程,使用 ctrl+\
也可以终止该进程,如下:
使用 ctrl+c
和 ctrl+\
都可以终止进程,那它们之间有什么区别吗❓
按 ctrl+c
是向进程发送 2 号信号 SIGINT
,它的默认处理动作是终止进程。按 ctrl+\
是向进程发送 3 号信号 SIGOUT
,它的默认处理动作就是终止进程并且 Core Dump 。
Core Dump(核心转储)
首先解释什么是 Core Dump。当一个进程要异常终止时,可以选择把进程的用户空间内存数据全部保存到磁盘上,文件名通常是 core ,这叫做 Core Dump。进程异常终止通常是因为有 Bug ,比如非法内存访问导致段错误,事后可以用调试器检查 core 文件以查清错误原因,这叫做 Post-mortem Debug(事后调试)。一个进程允许产生多大的 core 文件取决于进程的 Resource Limit (这个信息保存在 PCB 中)。默认是不允许产生 core 文件的,因为 core 文件中可能包含用户密码等敏感信息,不安全。在开发调试阶段可以用 ulimit 命令改变这个限制,允许产生 core 文件。 首先用 ulimit 命令改变 Shell 进程的 Resource Limit ,允许 core 文件最大为 1024K : $ ulimit -c 1024。
在云服务器中,core dump 默认是被关闭的,可以通过 ulimit -a
命令来查看当前资源限定的设置:
如上所示,core file size 显示 core 文件的大小为 0,表示当前的 core dump 是关闭的。
使用 ulimit -c size
命令来设置 core 文件的大小:
core 文件的大小设置完成之后,表示核心转储功能打开。当我们再次使用 ctrl+\
来对进程进行终止时,进行终止后就会显示对应的 core dumped。并且在当前路径下生成一个 core 文件,文件后缀为数字,这串数字实际上是发生该次核心转储的进程的 PID。
ulimit 命令改变了 shell 进程的 Resource Limit,该进程的 PCB 由 shell 进程复制而来,所以也具有和 shell 进程相同的 Resource Limit 值,这样就可以产生 Core Dump 了。
核心转储(core dump)的作用
-
分析程序崩溃原因。程序崩溃时,核心转储可以保存程序在崩溃前的状态,包括内存状态、寄存器状态、堆栈信息等。这些信息可以帮助开发人员分析程序崩溃的原因,快速定位问题。
-
调试程序。核心转储可以帮助开发人员在程序崩溃后,恢复程序的状态,以便进行调试。开发人员可以通过调试工具分析程序崩溃的原因,查找代码中的错误。
-
优化程序性能。通过分析核心转储,可以了解程序在运行时的内存使用情况、调用栈、函数调用次数等信息,帮助开发人员优化程序的性能。
如何使用 core dump
进行程序的调试❓
在下面的这份代码中,会出现除零错误,代码如下:
#include <iostream>
#include <unistd.h>
using namespace std;
int main()
{
while (true)
{
cout << "linux signal!" << endl;
int a=9;
a/=0;
sleep(1);
}
return 0;
}
运行代码,代码出现除零错误导致崩溃,此时在当前程序的执行目录下可以看到 core dump 文件:
如下,使用 gdb 对当前可执行程序进行调试,进入 gdb 之后,使用命令 core-file core文件
加载 core 文件,然后就可以看到关于进程终止的信息:
核心转储功能可以帮助开发人员分析程序崩溃的原因、调试程序、优化程序性能,是一个比较有用的调试工具。
waitpid函数中的 core dump 标志
在之前的 进程控制 中,我们了解了 waitpid 函数,但是忽略了 core dump 标志。现在我们来了解一下。
pid_t waitpid(pid_t pid,int *status,int options);
waitpid 函数的第二个参数 status 是一个输出型参数,是一个指向整形变量的指针,用于存储子进程的退出状态。在 status 中,不同的比特位代表不同的信息,如下(只关注 status 低 16 个比特位):
如果进程正常终止,status 的次低 8 位表示进程的退出状态(状态码)。如果进程是被信号所杀,那 status 的低 7 位表示终止信号,第 8 位则是 core dump 标志。
为了验证子进程被终止时是否进行了核心转储,打开 Linux 的核心转储功能,编写以下代码。在该代码中,main 函数中使用 fork 创建了一个子进程,在子进程中存在除零错误的问题。当子进程执行时则会发生错误。父进程中使用 waitpid 函数等待子进程退出并获取退出状态信息。检测 status 参数的第 7 个比特位,便可得知子进程在被终止时是否进行了 core dump ,代码如下:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>
int main()
{
int a = 10;
if (fork() == 0)
{
cout << "I am a child process" << endl;
a /= 0;
exit(0);
}
int status = 0;
waitpid(-1, &status, 0);
printf("exitCode:%d ,coreDump:%d ,signal:%d\n", (status >> 8) & 0xff, (status >> 7) & 1, status & 0x7f);
return 0;
}
运行测试,发现 core dump 标志的比特位为 1,当前目录下产生了一个 core 文件:
注意:在 Linux 中,并不是所有的信号都可以被杀掉,如:9 号信号,19 号信号 是捕捉不了的。
调用系统函数向进程发信号
指定发送某种信号的 kill 命令可以有多种写法,可以使用 kill -信号名 -进程PID
形式给进程发送特定信号:
也可以使用 kill -信号编号 -进行PID
形式给进程发送特定信号:
kill 函数
kill 命令是调用 kill 函数实现的。kill 函数可以给一个指定的进程发送指定的信号。
#include <signal.h>
int kill(pid_t pid,int signo);
下面使用 kill 函数模拟实现一个 kill 命令:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <signal.h>
static void Usage(const string &proc)
{
cerr << "Usage:\n\t" << proc << " signo pid" << endl;
}
// 使用kill函数实现一个kill命令
// mykill 信号 pid
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(1);
}
if (kill(static_cast<pid_t>(atoi(argv[2])), atoi(argv[1])) == -1)
{
cerr << "kill:" << strerror(errno) << endl;
exit(2);
}
return 0;
}
运行测试如下:
raise 函数
在 Linux/Unix 中,raise 函数可以给当前进程发送指定的信号(自己给自己发送信号)。
#include <signal.h>
int raise(int signo);
调用 raise() 函数成功时则返回 0,出错则返回 -1。
示例:
void handler(int signo)
{
cout<<"receive a signo:"<<signo<<endl;
}
int main()
{
signal(3,handler);
while (true)
{
cout << "run..." << endl;
sleep(1);
raise(3);
}
return 0;
}
运行测试,进程每隔一秒给自己发送 3 号信号:
abort 函数
abort 函数使当前进程接收到信号而异常终止。
#include <stdlib.h>
void abort(void);
// abort函数总是会成功的,所以没有返回值。
示例:
void handler(int signo)
{
cout << "receive a signo:" << signo << endl;
}
int main()
{
signal(6, handler);
while (true)
{
cout << "run..." << endl;
sleep(1);
abort();
}
return 0;
}
运行代码,即使我们对 SIGABRT 信号进行了自定义处理,但是 abort 执行时进程依然异常终止:
由软件条件产生信号
SIGPIPE 信号
SIGPIPE 信号实际上是由软件条件引起的,常见于进程在使用管道或者套接字进行通信时。在这些场景下,读端进程可能会关闭某个管道或者套接字,但写端进程仍然向其中写入数据。此时,操作系统检测到这种情况并向写端进程发送 SIGPIPE 信号,告知它所写入的管道或套接字已经中断。如果进程在此之后仍热继续向管道或套接字写入数据而不处理 SIGPIPE 信号,系统将终止该进程。
以下是一个使用匿名管道进行父子进程通信的示例代码。其中,父进程先创建了一个匿名管道,然后生成子进程,并关闭了管道的读取端。接着,子进程试图向已关闭的管道写入数据,导致收到SIGPIPE信号进而被终止。
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
int fd[2] = {0};
if (pipe(fd) < 0) // 创建匿名管道
{
perror("pipe");
return 1;
}
pid_t id = fork();
if (id == 0)
{
close(fd[0]);
// 子进程向管道写入数据
const char *msg = "I am a child process";
int count = 5;
while (count--)
{
write(fd[1], msg, strlen(msg));
sleep(1);
}
close(fd[1]);
exit(0);
}
close(fd[1]);
close(fd[0]); // 父进程关闭读端,会导致子进程被操作系统所杀
int status = 0;
waitpid(id, &status, 0);
printf("child get signal:%d\n", status & 0x7F);
return 0;
}
运行测试发现,子进程退出时收到 SIGPIPE 信号:
SIGALRM 信号
alarm 函数是一个定时器函数,在系统标准 Unix 中以秒为单位设定一个定时器值,当时间到了之后就产生一个 SIGALRM 信号,并将此信号发送给当前进程。
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
// 调用alarm函数可以设定一个闹钟,也就是高数内核在seconds秒之后给当前进程发送SIGALRM信号
// 该动作的默认动作是终止当前进程。
返回值:
- 调用 alarm 前,如果定时器已经设置,则返回上一个定时器剩余的时间,且本次定时器会覆盖上一次定时器的设置。
- 调用 alarm 前,进程没有设置定时器,则返回 0 。
示例:使用以下代码,测试云服务器在 1s 内可以对变量 count 累加多少次。
#include <cstdio>
#include <signal.h>
#include <cstdlib>
#include <unistd.h>
int count = 0;
void handler(int signo)
{
cout << "get a signal : " << signo << endl;
cout << "count = " << count << endl;
exit(1);
}
int main()
{
signal(SIGALRM, handler);
alarm(1);
while (true)
{
count++;
}
return 0;
}
运行结果如下所示:
硬件异常产生信号
硬件异常是指 CPU 执行指令时发送的错误或异常情况,如:除零、非法访问内存地址、浮点数运算溢出等。硬件异常被硬件以某种方式被硬件检测到并通知内核,内核会根据异常类型将其解释为相应的信号,并向当前进行发送适当的信号。
示例:
如果一个进程试图访问受保护的内存区域,如:已经被释放的内存区域,那么 CPU 的内存管理单元(MMU)会检测到这个异常,并通知内核。内核将该异常解释为 SIGSEGV 信号,并发送个进程。进程可以通过捕捉这个信号来处理该异常情况。
阻塞信号
信号相关常见概念
信号递达(Delivery)
:实际执行信号的处理动作称为信号递达。当一个信号被递达后,进程可根据自己的处理方式来响应该信号。信号未决(Pending)
:信号从产生到递达之间的状态称为信号未决。当一个信号产生时,会先进入未决状态,等待被递达。如果进程在此期间选择阻塞该信号,则该信号会一直保持在未决状态,直到进程解除对此信号的阻塞,才会执行递达的动作。阻塞(Block)
:进程可以选择阻塞某个信号,即将该信号加入阻塞信号集合中。忽略(Ignore)
:忽略是信号递达后可选的一种处理动作。当进程忽略某个信号时,该信号递达后不会执行任何处理动作,直接被丢弃。
信号在内核中的表示
信号在内核中的表示示意图:
在上述的进程控制块中,block 位图记录了每个信号是否被阻塞,pending 位图记录了每个信号是否已经递达但还未被处理,handler 表是一个函数指针数组,每个元素对应一个信号,表示该信号递达时应该执行的函数。这三张表的每个位置是一一对应的,它们共同记录了进程对信号的处理状态。
- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有一个函数指针表示处理动作。当信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志。在上图示例中,SIGHUP 信号未阻塞也未产生过,当它递达时执行默认的处理动作。
- SIGINT 信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作时忽略,但是在没有解除阻塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGOUT 信号未产生过,一旦产生 SIGOUT 信号将被阻塞,它的处理动作是用户自定义函数 sighandler。
- 如果在进程解除对某信号的阻塞之前该信号产生多次,那么将如何处理?POSIX.1 允许系统递达该信号一次或多次。在 Linux 中是这样实现的:常规信号在递达之前产生多次只计一次,而实时信号在递达之前产生多次可以依次放在一个队列里。
sigset_t
在 Linux 中,每个信号只有一个 bit 未决标志,非 0 即 1。未决和阻塞标志可以用相同的数据类型 sigset_t 来存储。在当前的云服务器中,sigset_t 类型的定义如下:(不同操作系统对于 sigset_t 的实现可能不同)
typedef __sigset_t sigset_t;
/* A `sigset_t' has a bit for each signal. */
# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
#endif
其中在阻塞信号集中,“有效” 和 “无效” 的含义是该信号是否被阻塞,而在未决信号集中,“有效” 和 “无效” 的含义是该信号是否处于未决状态。
阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的屏蔽理解为阻塞而不是忽略。
信号集操作函数
sigset_t 类型对于每种信号用一个 bit 表示 “有效” 或 “无效”,至于这个类型内部如何存储这些 bit 则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作 sigset_t 变量,而不应该对它的内部数据做任何解释,如使用 printf 直接打印 sigset_t 变量是没有意义的。
#include <signal.h>
int sigemptyset(sigset_t *set);
// 初始化set所指向的信号集,使其中所有对应的bit清零,表示该信号集中不包含任何有效信号。
int sigfillset(sigset_t *set);
// 初始化set所指向的信号集,使其中所有对应的bit置位,表示该信号集的有效信号包括系统支持的所有信号
int sigaddset (sigset_t *set, int signo);
// 在该信号集中添加某种有效信号
int sigdelset(sigset_t *set, int signo);
// 在该信号集中删除某种有效信号
int sigismember(const sigset_t *set, int signo);
// 该函数是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号
// 若包含则返回1,不包含则返回0,出错则返回-1.
注意:
- 在使用 sigset_t 类型的变量之前,一定要调用 sigemptyset 或 sigfillset 做初始化,使信号集处于确定的状态。
- sigset_t 类型的变量是在用户空间中定义的,而且它所包含的信号集合数据只存在于用户空间中,操作系统并不会直接使用它。因此,我们需要通过系统调用的方式,将 sigset_t 定义的变量中的信号集合数据设置到操作系统中,以便系统可以根据这些数据来进行信号处理。
- 只有将信号集合数据设置到操作系统后,系统才会根据这些数据来进行信号处理。如果我们只是在用户空间中修改信号集合数据,而没有将其设置到操作系统中,那么这些修改是没有任何意义的。
sigprocmask
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#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 参数的可选值:
how 的选项 | 说明 |
---|---|
SIG_BLOCK | set 包含了我们希望添加到当前信号屏蔽字的信号,相当于 mask=mask|set |
SIG_UNBLOCK | set 包含了我们希望从当前信号屏蔽字中解除阻塞的信号,相当于 mask=mask&~set |
SIG_SETMASK | 设置当前信号屏蔽字为 set 所指向的值,相当于 mask=set |
返回值:若 sigprocmask 函数调用成功,则返回 0 ,出错则返回 -1。
注意:如果调用 sigprocmask 解除了对当前若干个未决信号的阻塞,则在 sigprocmask 返回前,至少将其中一个信号递达。
sigpending
该函数用于读取当前进程的未决信号集。
#include <signal.h>
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过 set 参数传出。如果调用成功则返回0,出错则返回-1。
接下来,对上面所了解的函数进行一个简单的实验:
下列代码的功能是打印当前进程未决的信号集,其中 2号 和 5号 信号被设置为阻塞状态:
#include <iostream>
#include <unistd.h>
#include <signal.h>
using namespace std;
// 打印当前信号集中的信号
void printPending(sigset_t *pending)
{
for (int i = 1; i <= 31; ++i)
{
if (sigismember(pending, i))
cout << "1";
else
cout << "0";
}
cout << endl;
}
int main()
{
cout << "pid:" << getpid() << endl;
sigset_t set, oset;
sigemptyset(&set); // 初始化信号集
sigemptyset(&oset);
sigaddset(&set, 2); // 向信号集中添加2号信号SIGINT
sigaddset(&set, 5); // 向信号集中添加5号信号SIGINT
sigprocmask(SIG_SETMASK, &set, &oset); // 设置用户级的信号屏蔽字到内核中,让当前的进行将2号和5号信号屏蔽
sigset_t pending;
sigemptyset(&pending);
while (1)
{
sigpending(&pending);
printPending(&pending);
sleep(1);
}
return 0;
}
允许程序,刚开始还没向该进程发送信号时,该进程的 pending 信号集中没有收到任何信号,由于我们将 2 号信号和 5 号信号进行了屏蔽,因此向该进程发送这两个信号时,pending 信号集中接收到了该信号并显示。最后使用 9 号信号终止该进程。
为了测试解除对某信号的屏蔽之后该信号是否被递达,我们可以设置一段时间之后,自动解除对所有信号的屏蔽,然后观察之前被屏蔽的信号是否被递达,如下:
#include <cstdio>
#include <signal.h>
#include <cstdlib>
#include <unistd.h>
void handler(int signo)
{
cout << "我是一个进程,刚刚获取了一个信息:" << signo << endl;
}
// 打印当前信号集中的信号
void printPending(sigset_t *pending)
{
for (int sig = 1; sig <= 31; ++sig)
{
if (sigismember(pending, sig))
cout << "1";
else
cout << "0";
}
cout << endl;
}
int main()
{
cout << "pid:" << getpid() << endl;
// 屏蔽2号和5号信号
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
// sigfillset();
for (int sig = 1; sig <= 31; sig++)
{
// 将2号和5号信号添加到信号屏蔽字中
sigaddset(&set, 2);
sigaddset(&set, 5);
// signal
signal(sig, handler);
}
// 设置用户级的信号屏蔽字到内核中
sigprocmask(SIG_SETMASK, &set, &oset);
// 不断获取当前进程的pending信号集
sigset_t pending;
int count = 0;
while (true)
{
// 清空信号集
sigemptyset(&pending);
// 获取当前进程的pending信号集
if (sigpending(&pending) == 0)
{
// 打印一个当前进程的pending信号集
printPending(&pending);
}
sleep(1);
count++;
if (count == 20)
{
cout << "解除对所有信号的block..." << endl;
sigemptyset(&set);
sigprocmask(SIG_SETMASK, &set, nullptr);
}
}
return 0;
}
运行程序,结果如下:
捕捉信号
内核空间与用户空间
每个进程在运行时都拥有自己独立的地址空间,其中包括用户空间和内核空间:
- 用户空间主要用来存储进程的可执行文件、库文件、堆栈等数据,用户空间的映射由用户级页表进行管理。
- 内核空间是操作系统内核独占的内存空间,用于执行操作系统的核心功能。用户无法直接访问内核空间,只能通过系统调用向内核发出请求,由内核代表用户执行相应的操作。内核空间的映射由内核级页表进行管理。
内核级页表是一个全局的页表,它维护操作系统的代码和进程之间的关系,同时为所有进程共享。因此,所有的进程看到的内核空间的代码和数据都是一样的,可以被所有的进程访问和执行。
对于用户空间,每个进程都有自己独立的用户级页表,它们用来将进程的虚拟地址空间映射到实际的物理地址空间。用户空间加载的代码和数据都是该进程独有的,不同进程间的用户空间是隔离的。这种地址空间隔离提高了系统的安全性和稳定性,并允许多个进程同时允许而互不干扰。
注意:访问用户空间时需要处于用户态,访问内核空间时需要处于内核态。
内核态与用户态
内核态和用户态是操作系统中的两种不同的运行级别,内核态具有最高权限,可以访问所有系统资源,而用户态只能访问自己的资源,不能直接访问系统资源。
进程在收到信号时,并不是立即处理信号,而是在合适的时机进行处理。这个时机通常是当进程从内核态返回用户态时。
内核态和用户态之间如何进行切换❓
从用户态切换为内核态通常有以下情况:
- 进行系统调用时。
- 时间片轮转。
- 产生异常、中断、陷阱等。
从内核态切换为用户态通常有以下情况:
- 系统调用返回时。
- 进程切换完毕。
- 异常、中断、陷阱等事件处理完毕后。
内核态和用户态之间的切换是由操作系统内核控制的,并且非常频繁。
内核如何实现信号的捕捉
当程序开始执行主控制流时,可能会因为某些情况而陷入内核,当内核处理完相应的情况返回用户态时,需要对 pending 信号集进行检测,若发现未决信号且没有被阻塞时,就需要对该信号进行相应的处理。
如果待处理信号的处理动作是默认或者忽略时,操作系统会直接清除对应信号的 pending 标志位,而不执行任何处理程序,然后返回用户态从上次陷入内核态的地方继续向下执行:
如果待处理信号的处理动作不是默认或者忽略,而是自定义动作,那么就需要返回用户态执行该信号对应的信号处理函数。执行完成之后通过特殊的系统调用 sigreturn 再次陷入内核,清除对应的 pending 标志位,并且检查是否有新的信号需要递达,如果有,则继续处理新信号;如果没有,则将进程从内核态切换到用户态,从主控制流程中上次被中断的地方继续向下执行:
以上的信号捕捉处理流程可以简化为下面的图形进行理解记忆,图中的箭头表示此次状态切换的方向,图形与中间那条直线有几个交点就代表有几次状态切换,绿色原点表示对 pending 信号表进行检查:
当在内核中的 pending 信号表中检查到某信号被捕捉且处理动作是自定义时,为何需要返回用户态执行相应的自定义代码而不是直接在内核中执行❓
当识别到信号的处理动作是自定义时,一般情况下是不能直接在内核态执行用户空间的代码的。这时因为内核态和用户态之间有着严格的权限隔离。这种权限隔离是为了保护内核的安全和稳定,防止用户的恶意代码对内核造成影响和破坏。
sigaction
sigaction 函数也是一个信号处理函数,它可以用于安装信号处理程序,也可以用于查询或修改信号的处理方式。
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
说明:
- sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功返回 0 ,调用失败则返回 -1。
- signum 是指定信号的编号。
- 若 act 指针非空,则根据 act 修改该信号的处理动作。
- 若 oldact 指针非空,则通过 oldact 传出该信号原本的处理动作。
act 和 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); // 未使用
};
将 sa_handler 赋值为常数 SIG_IGN 传给 sigaction 表示忽略信号,赋值为常数 SIG_DFL 表示指向系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为 void,可以带一个 int 参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。很显然,这是一个回调函数,不是被 main 函数调用,而是被系统调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags 字段包含一些选项,接下来的代码都把sa_flags设为0,sa_sigaction是实时信号的处理函数。
示例:下列代码实现了一个信号处理程序,当进程接收到 2 号信号时,会执行 handler 函数:
#include <iostream>
#include <strings.h>
#include <signal.h>
#include <unistd.h>
using namespace std;
void handler(int signo)
{
cout << "get a signo:" << signo << endl;
}
int main()
{
struct sigaction act, oact;
bzero(&act, sizeof(act));
bzero(&oact, sizeof(oact));
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaction(2, &act, &oact);
while (1)
{
cout << "pid:" << getpid() << endl;
sleep(1);
}
return 0;
}
运行测试:
可重入函数
main 函数调用 insert 函数向一个链表 head 中插入结点 node1,插入操作分为两步,第一步完成操作之后,因为硬件中断使进程切换到内核,再次返回用户态之前检查到有信号待处理,于是切换到 sighandler 函数,sighandler 内部也调用了 insert 函数向同一个链表 head 中插入结点 node2,插入操作的两步都完成之后从 sighandler 返回内核态,再次回到用户态就从 main 函数调用的 insert 函数中继续接着上一次执行流陷入内核的地方向下继续执行。执行结果是,main 函数和 sighandler 先后向链表中插入两个结点,而最后只有一个结点真正的插入到链表中。
如上示例,insert 函数被不同的控制流程调用,有可能在第一次调用还没有返回时就再次进入该函数,着称为重入,insert 函数访问一个全局链表,有可能因为冲入而造成错乱,向这样的函数称为不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reemtrant)函数。
为什么两个不同的控制流程调用同一个函数,访问它的同一个局部变量或参数就不会造成错乱❓
在程序执行时,每个控制流程都有自己的栈空间来存储局部变量以及函数参数,栈空间是独立的,每个控制流程都有一份自己的,即使它们访问的是同一个函数中的同一个局部变量或参数,也不会造成错误或冲突。
如果一个函数符合以下条件之一则是不可重入的:
- 调用 malloc 或 free,因为 malloc 也是用全局链表来管理堆的。
- 调用了标准 I/O 库函数。标准 I/O 库的很多实现都是以不可重入的方式使用全局数据结构。
volatile
volatile 关键字用于告诉编译器该变量可能会被意外地修改,因此编译器不应对该变量进行一些优化,以避免出现意外错误。
接下来看一段代码,该段代码的功能是注册一个 SIGINT 信号处理函数 handler,当收到 SIGINT 信号时,将 flags 变量的值更改为 1,即在该进程收到 SIGINT 信号之前该进程会一直处于死循环状态,直到收到 SIGINT 信号时更改 flags 的值为 1 时进程才正常退出:
#include <stdio.h>
#include <signal.h>
int flags = 0;
void handler(int signo)
{
flags = 1;
printf("更改flags:0->1\n");
}
int main()
{
signal(2, handler);
while (!flags);
printf("进程是正常退出的!\n");
return 0;
}
运行测试如下:
对于该程序的运行测试结果实际上是在我们意料之中的。在代码中 main 函数和 handler 函数是两个独立的执行流,while 循环处于 main 函数之中,对于 flags 变量的修改是在 handler 函数中。当编译器优化级别较高的情况下,如果 while 循环每一次判断的时候的值与之前所访问的值都相同,那么编译器可能认为该变量不会改变,从而编译器可能会将 flags 变量的访问优化成了一次对寄存器中变量的访问(将 flags 变量放入寄存器中),而并没有真正的访问内存中的 flags 变量。
因此,在编译器优化级别较高的情况下,进程即使收到了 SIGINT 信号也不会正常退出。
接下来进行验证,在编译代码时携带 -O3
选项从而提高优化级别,然后运行程序:
如上所示,当编译器优化级别较高时,flags 变量被优化到寄存器,当收到 SIGINT 信号时进程依旧不会正常退出。
对于这种情况,可以使用 volatile 关键字对 flags 进行修饰,即告诉编译器,每次读取 flags 变量时都在内存中进行读取,即保持内存的可见性。如下,在 flags 变量前加上 volatile 关键字:
运行测试:
使用 volatile 关键字有以下几个作用:
- 防止编译器对变量进行优化。
- 保证多线程访问变量的可见性。
- 保证对变量的操作的顺序性。
注意:使用 volatile 关键字并不能保证程序的正确性,仅仅时为了避免一些可能出现的错误。在多线程编程中,需要使用更加严格的同步机制来确保出现的正确性。
SIGCHLD信号
为了避免出现僵尸进程,父进程需要使用 wait 或 waitpid 函数等待子进程退出。父进程可以阻塞等待子进程退出,也可以非阻塞地查询是否有子进程结束等待被清理。采用第一种方式,父进程阻塞了就不能处理自己的任务了;采用第二种方式,父进程在处理自己的任务的同时还要时不时的轮询检测子进程是否退出,程序实现复杂。
实际上,子进程终止时会给古今从发送 SIGCHLD 信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD 信号的处理函数,这样父进程就可以只关心处理自己的任务了,不需要关心子进程。子进程退出时会通知父进程,父进程在信号处理函数中调用 wait 或 waitpid 函数清理子进程即可。
示例:下面代码对 SIGCHLD 进行了捕捉,并在该信号的处理函数中调用 waitpid 函数对子进程进行清理。
#include <cstdio>
#include <unistd.h>
#include <signal.h>
#include <cstdlib>
#include <sys/wait.h>
#include <sys/types.h>
void handler(int signo)
{
cout << "get a signo: " << signo << endl;
pid_t id;
while ((id = waitpid(-1, nullptr, WNOHANG)) > 0)
cout << "wait child success :" << id << endl;
}
int main()
{
signal(SIGCHLD, handler);
pid_t cid;
if ((cid = fork()) == 0)
{
cout << "child process pid :" << getpid() << endl;
sleep(3);
exit(1);
}
while (1);
return 0;
}
运行测试:
此时父进程就只需要处理自己的任务就可以了,不必关心子进程了,子进程退出时向父进程发送 SIGCHLD 信号,自定义的函数就会对子进程进行清理。
事实上,由于UNIX 的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调 用 sigaction 将 SIGCHLD 的处理动作设置为 SIG_IGN ,这样 fork 出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用 sigaction 函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于 Linux 可用,但不保证在其它 UNIX 系统上都可用。
示例:将 signal 函数中的 SIGCHLD 信号的处理动作设置为 SIG_IGN:
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdlib>
#include <sys/types.h>
#include <sys/wait.h>
using namespace std;
int main()
{
signal(SIGCHLD, SIG_IGN);
if (fork() == 0)
{
cout << "child process pid :" << getpid() << endl;
sleep(3);
exit(1);
}
while (1) {}
return 0;
}
运行测试如下,当子进程终止时会自动被清理,不会通知父进程,也不会产生僵尸进程: