Linux 进程信号
查看信号列表的方式
可以使用 kill -l
指令查看信号列表:
信号的产生
通过终端按键产生信号
常见的按键组合和相应的信号:
- Ctrl+C (SIGINT): 发送SIGINT信号
- Ctrl+\ (SIGQUIT): 发送SIGQUIT信号,通常用于终止程序,并 生成core dump文件。
- Ctrl+Z (SIGTSTP): 发送SIGTSTP信号,用于挂起程序的执行,将其放入后台运行。
- Ctrl+D (EOF): 发送EOF(End of File)信号,表示输入流已经结束。
CoreDump
Core Dump(核心转储)是指在程序发生严重错误时,操作系统将程序的内存状态保存到一个特殊的文件中,以便进行后续的调试和分析。核心转储文件通常称为core文件。
当程序发生严重错误,比如段错误(Segmentation Fault)或其他致命错误时,操作系统会捕获这些错误,并生成一个core文件。该文件包含了导致程序崩溃的内存状态,包括程序代码、堆栈信息、寄存器状态以及其他相关的调试信息。
由于 coredump 中可能会包含敏感信息,不安全,并且生成 coredump 文件还会占据很大的磁盘空间,因此,生成 coredump 文件默认是禁用的
在开发时,可以通过设置ulimit来启用。通常情况下,core文件会被保存在当前工作目录下,并以core或者core.<进程ID>的文件名格式命名。
可以看到,此时的 core file size = 0
,可以使用 ulimit -c
指令来设置文件的大小:
实例
编写一个访问空指针的程序:
int main(void)
{
int *p = NULL;
*p = 1;
}
运行后,查看 coredump 文件:
由软件条件产生的信号
在 Linux 进程通信 中,介绍过 SIGPIPE 信号,它是一种软件条件产生的信号
接下来主要介绍 SIGALRM 信号
alarm 系统调用
alarm
函数是一个用于设置定时器的系统调用。它允许我们在指定的时间间隔后接收一个SIGALRM
信号,从而可以在程序中实现定时操作。
使用alarm
函数需要包含头文件<unistd.h>
。函数原型如下:
unsigned int alarm(unsigned int seconds);
-
seconds
参数指定定时器的时间间隔,单位为秒。当定时器到期时,将发送一个SIGALRM
信号给调用进程。 -
alarm
函数的返回值为之前设置的定时器剩余的时间。
void sigHandler(int signum)
{
printf("Received SIG signal\n");
}
int main(void)
{
signal(SIGALRM, sigHandler);
unsigned s0 = alarm(10); // 0
printf("%d\n", s0);
unsigned s1 = alarm(3); // s1 = 10,重设闹钟
printf("%d\n", s1);
sleep(1);
unsigned s2 = alarm(1); // 3 - 1 = 2
printf("%d\n", s2);
time_t startTime = time(NULL);
sleep(10); // 在睡了 1s 后,内核发出 SIGALRM 信号给进程,进程被唤醒
time_t endTime = time(NULL);
printf("Sleeped for %lf seconds.\n", difftime(endTime, startTime));
return 0;
}
输出:
信号捕捉
在刚才的例子中,有一个 void sigHandler(int signum)
函数,它是一个用于捕捉信号的函数
但是,光有这个函数是不能捕捉到信号的,还需要「注册」
怎么注册捏?
signal 系统调用
signal
函数是一个用于处理信号的系统调用。它允许我们为特定的信号指定信号处理函数,以定义在接收到信号时需要执行的操作。
使用signal
函数需要包含头文件<signal.h>
。函数原型如下:
void (*signal(int signum, void (*handler)(int)))(int);
-
signum
参数指定要处理的信号的编号,可以是预定义的信号宏(如SIGINT
、SIGALRM
等)或自定义的信号。 -
handler
参数是一个函数指针,指向用户定义的信号处理函数。信号处理函数的原型为void handler(int signum)
,其中signum
是接收到的信号编号。 -
signal
函数返回一个函数指针,指向之前注册的信号处理函数。如果之前没有注册过该信号的处理函数,则返回SIG_DFL
(默认的信号处理行为)。
使用signal
函数的一般流程如下:
-
调用
signal
函数,并传递要处理的信号编号和相应的信号处理函数,这一步就是前面提到的「注册」 -
当程序接收到指定的信号时,操作系统会调用相应的信号处理函数。
-
信号处理函数执行特定的操作,可以是自定义的任何有效函数。
-
信号处理函数执行完毕后,程序 将继续执行原来的流程。
由硬件异常产生信号
下面是一个空指针异常(也就是硬件异常)产生 SIGSEGV 信号的例子:
void sigHandler(int signum)
{
sleep(1);
printf("Received SIG signal\n");
}
int main(void)
{
printf("[%d]\n", getpid());
signal(SIGSEGV, sigHandler);
sleep(1);
int *p = NULL;
*p = 1;
while(1); // 会一直输出 "Received SIG signal",因为 SIGSEGV 是一种严重错误
// 操作系统会反复发送SIGSEGV信号,以确保程序得到处理。
return 0;
}
输出:
信号集
信号的状态
一般来说,信号有两种状态:
- 未决态(Pending),即信号从产生到递达之间的状态
- 递达态(Delivery)
其中:
- 进程可以阻塞(Block)一个信号
- 一个信号若被阻塞,则其在产生时就处于未决状态
- 阻塞与忽略不同,只要信号被阻塞,就不会递达,而忽略是信号 递达以后 ,进程可以选择的操作
信号在内核的表示
在这张图中:
- 每个信号均有一个 block 位,pending 位,还有一个 hander 位,这个 hander 是一个指向处理当前信号的函数指针
- 一个信号产生时,OS 会将 pending 位「置位」,直到该信号递达时,将 pending 位「复位」
- 对于图中的 SIGHUP,它没有阻塞,也没有产生过,处理函数为默认函数 SIG_DFL
- 对于图中的 SIGINT,它被阻塞,且产生过,因此处于未决态,处理函数为 SIG_IGN,即忽略该信号
- 对于图中的 SIGQUIT,它被阻塞,但没有产生过,一旦产生,将处于未决态,直到进程主动取消对它的阻塞,处理函数为用户自定义的函数,即 sighandler
sigset_t 类型
从上面的分析,我们发现:无论是 未决
还是 阻塞
,对于一个信号,只需要一个 bit 位就能反应该信号是否处于 未决(或阻塞)态,因此,一个进程的 阻塞信号集
和 未决信号集
都可以用 sigset_t
这种数据类型(其实就是无符号整型)来存储
注意: 不要试图直接打印一个 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);
- sigemptyset 函数用于将信号集 set 复位,表示不含任何信号
- sigfillset 函数用于将信号集 set 置位,表示包含所有系统支持的信号
- sigaddset 函数用于添加一个信号到 set 中,signo 是要添加的信号的编号
- sigdelset 函数用于在 set 中删除一个信号,signo 是要删除的信号的编号
- sigismember 函数用于判断一个信号是否在信号集 set 中,signo 是要查询的信号的编号
sigprocmask
sigprocmask
函数用于设置或修改进程的屏蔽信号集。
函数原型:
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
参数说明:
how
:用于指定信号屏蔽字的设置方式,可取三个值:SIG_BLOCK
:将set
中的信号添加到当前信号屏蔽字中,相当于|=
操作。SIG_UNBLOCK
:从当前信号屏蔽字中移除set
中的信号,相当于mask = mask & ~set
。SIG_SETMASK
:将当前信号屏蔽字替换为set
,相当于mask = set
。
set
:指向一个sigset_t
类型的数据结构,用于设置新的信号屏蔽字。oldset
:可选参数,用于获取调用该函数前的旧信号屏蔽字。
函数返回值:
- 成功:返回 0。
- 失败:返回 -1,并设置相应的错误码。
使用 sigprocmask
函数可以实现以下操作:
- 阻塞或解除阻塞特定信号:通过设置信号屏蔽字,可以选择阻塞或解除阻塞某些信号的传递。
- 保护临界区:在进入临界区前,通过设置信号屏蔽字来阻塞某些信号,以避免在关键时刻被中断。
- 防止竞态条件:通过阻塞某些信号来避免并发执行引起的竞态条件。
注意: sigprocmask
函数只会影响当前进程的信号屏蔽字,对其他进程无影响。
sigpending
sigpending
函数用于读取进程的未决信号集
#include <signal.h>
int sigpending(sigset_t *)
实例
void printSigset(sigset_t sigSet)
{
char buff[33];
// 1 ~ 31
// for (int curSig = SIGHUP; curSig <= SIGUSR2; ++curSig) // 不同的机器,SIGUSER2 的编号不同,在 macOS 上,该信号的编号为 31
for (int curSig = 1; curSig <= 31; ++curSig)
{
// if ((sig >> curSig) & 1)
if(sigismember(&sigSet, curSig))
buff[curSig] = '1';
else buff[curSig] = '0';
}
buff[0] = '0';
buff[32] = '\0';
printf("%s\n", buff);
}
int main(void)
{
sigset_t blockSigSet, oldSigSet;
sigemptyset(&blockSigSet); // 初始化信号,置 0
sigaddset(&blockSigSet, SIGINT); // 将 SIGINT(中断信号)加入至阻塞信号集
// 将当前进程的阻塞信号集设置为 blockSigSet ,并将进程原来的阻塞信号集存放在 oldSigSet 中
sigprocmask(SIG_BLOCK, &blockSigSet, &oldSigSet);
printf("Original block signal set: ");
printSigset(oldSigSet); // 0000 ...
printf("Current block signal set: ");
printSigset(blockSigSet); // 0010 ...
sigset_t pendingSigSet;
while (1)
{
sigpending(&pendingSigSet); // 获取未决信号集
printSigset(pendingSigSet);
sleep(1);
}
}
输出:
捕捉信号再探
内核捕捉信号的过程
引用自 博主Mindtechnist
例如,用户程序注册了 SIGQUIT 信号的处理函数 sigaction_user。
当前正在执行 main函数,这时发生中断或异常 切换到内核态 。
在 中断处理完毕 后要返回用户态的main函数之前 检查到有信号 SIGQUIT递达 。
内核决定 返回用户态 后不是恢复main函数的上下文继续执行,而是 执行 sigaction_user 函数 。
sigaction_user 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
sigaction_user 函数返回后自动 执行特殊的系统调用 sigreturn 再次进入内核态 。
如果没有新的信号要递达,这次再 返回用户态就是恢复 main函数的上下文继续执行 了。
可以发现,如果使用默认的信号处理函数,状态切换两次
如果使用自定义的信号处理函数,状态切换四次
引入信号集
void printSigset(sigset_t sigSet);
void sigHandler(int signum)
{
printf("---In function sigHander---\n");
if(signum == SIGINT)
printf("Received SIGINT signal\n");
else if(signum == SIGSEGV)
printf("Received SIGSEGV signal\n");
sigset_t curBlockSet;
sigset_t curPendingSet;
sigemptyset(&curBlockSet);
sigemptyset(&curPendingSet);
sigprocmask(SIG_BLOCK, NULL, &curBlockSet); // 由于不修改阻塞信号集,因此获取的是当前阻塞信号集
sigpending(&curPendingSet);
printf("Cur Block Signal Set:\n");
printSigset(curBlockSet);
printf("Cur Pending Signal Set:\n");
printSigset(curPendingSet);
sleep(3);
// exit(0);
}
void printSigset(sigset_t sigSet)
{
char buff[33];
// 1~31
// for (int curSig = SIGHUP; curSig <= SIGUSR2; ++curSig) // 不同的机器,SIGUSER2 的编号不同,在 macOS 上,该信号的编号为 31
for (int curSig = 1; curSig <= 31; ++curSig)
{
// if ((sig >> curSig) & 1)
if(sigismember(&sigSet, curSig))
buff[curSig] = '1';
else buff[curSig] = '0';
}
buff[0] = '0';
buff[32] = '\0';
printf("%s\n", buff);
}
int main(void)
{
printf("---In function main---\n");
signal(SIGSEGV, sigHandler); // 注册 SIGSEGV 信号
signal(SIGINT, sigHandler); // 注册 SIGINT 信号
sigset_t curBlockSet;
sigemptyset(&curBlockSet);
sigprocmask(SIG_BLOCK, NULL, &curBlockSet);
printf("Cur Block Signal Set:\n");
printSigset(curBlockSet);
sleep(1);
int *p = NULL;
*p = 1;
while(1);
return 0;
}
输出:
为什么发出 SIGINT 信号后,在 sigHandler 函数中,阻塞信号集包含 SIGINT 信号,即使我们之前并没有将其添加到阻塞信号集中?
原因是,当信号处理函数执行时,内核会自动将相应的信号添加到阻塞信号集中,以防止同一信号的重复递送。这样做是 为了保护信号处理函数不会被同一信号中断 。因此,当 SIGINT 信号处理函数 sigHandler 执行时,阻塞信号集中可能包含 SIGINT 信号
现在请你结合之前讲的内容,分析一下,在 sigHandler 函数中,为什么 SIGSEGV 一直出现在阻塞信号集中?
此外,为什么未决信号集中,始终不包含 SIGINT 和 SIGSEGV 信号?
你想想,为什么会从 main 函数切换到 sigHandler 函数?肯定是因为程序收到了 SIGINT 或者 SIGSEGV 信号啊,而未决信号集是指:从信号产生到信号递达到过程
在打印未决信号集的时候,信号已经递达了,内核会自动将该信号从未决信号集中移除。这样做是为了确保每个信号只被处理一次,防止信号的重复触发。
所以,打印的未决信号集不包含这两个信号
Volatile 关键字
volatile
是一个关键字,用于 告诉编译器不要对被修饰的变量进行优化。它通常 用于修饰那些可能被程序之外的因素修改的变量,以确保每次访问该变量都从内存中读取最新的值,而不是使用缓存的值。
来看一个与信号相关的例子:
int flag = 0;
void sigHandler(int signum)
{
printf("Changing flag from 0 to 1\n");
flag = 1;
}
int main(void)
{
time_t startTime = time(NULL);
signal(SIGINT, sigHandler);
while(!flag);
time_t endTime = time(NULL);
printf("Process quit normaly with running time %lf s.\n", difftime(endTime, startTime));
return 0;
}
可以看出,只要我们不向该进程发出 SIGINT
信号,它就会一直执行,直到我们发出 SIGINT
信号,被 sigHander
捕获
如果我们在编译时,加上 -O2
选项(优化)呢?
可以发现,无论怎么发出 SIGINT
信号,进程都不会退出!
为啥?
编译器在进行优化时,会尽力利用各种优化策略来提高程序的执行效率。其中一项优化策略是基于所谓的局部性原理,即认为在某个时间段内访问的数据很可能在不久的将来再次被访问。
在循环中使用的变量,如果编译器能够确定它们在循环体内部不会被修改,就可以将其缓存到寄存器或者CPU的高速缓存中,以减少对内存的读取操作。这样可以提高程序的执行效率,因为访问寄存器或高速缓存比访问内存要快得多。
在这里,编译器判断 flag
并不会在循环中改变,因此,会把 flag
放在寄存器中缓存,以换取更快的执行速度
这种优化行为导致了信号处理函数中对全局变量的修改无法被循环条件及时检测到,因为 循环条件使用了被缓存的旧值 ,而 不是从内存中读取最新的值 。
即使信号处理函数修改了全局变量的值,由于是在内存修改,寄存器的 flag
仍然是一开始缓存的 0,因此,循环仍然会继续执行。
通过使用 volatile
关键字修饰变量,可以告诉编译器不要对这个变量进行优化,每次访问都从内存中读取最新的值。这样可以确保循环条件能够及时检测到变量的变化,从而正确退出循环。
加上 volatile
关键字,即 volatile int flag = 0;
: