本文将按照下图流程,由浅入深详细介绍Linux环境下的进程信号机制。
生活中到处处都是信号:十字路口的红绿灯、微信消息提醒、训练员的发令枪、烦人的闹钟、外卖骑手的短信……
以红绿灯为切入点,人为什么能识别红绿灯?
①认识这个信号——明白这个信号的含义;②看到这个信号后会产生反应——这个反应可以是某种想法,也可以是某种行为。
认识信号:
进程是属性与逻辑的结合,通过程序员的设计、编码,进程字诞生起就认识特定信号。
反应:
进程收到信号后,一般有三种反应(默认,自定义,忽略)。
保存在哪里?
如果一个进程收到信号,那么该信号是保存在进程的task_struct(PCB)里。
如何保存?
task_struct中有专属的变量用于保存信号,他通过位图存储信号。
比特位的位置,代表信号编号;比特位的内容,代表是否是收到该信号,0表示没有,1表示有。
如何理解信号发送?
发送信号的本质:修改PCB中的信号位图!
而PCB是系统内核维护的数据结构,故只有操作系统才有能力修改PCB中的信号位图!
所以,下述的发送信号方式,本质都是通过OS提供的系统调用向目标进程发送信号。
综上,我们可以得出信号的概念:
信号是 Linux 操作系统向目标进程发送的一种通知机制。它用于通知目标进程某个特定事件的发生(如用户请求中断、程序错误、子进程状态改变等),请求目标进程中断其当前执行流,并立即给出反应——采取预设的处理动作(如终止、忽略、执行自定义函数或暂停/恢复)。
用kill -l命令可以察看系统定义的信号列表:
说明:
信号 0
: 保留给 kill(pid, 0)
(检查进程是否存在)。
信号 32
和 33
: Linux 内部保留,用户不可用。
信号
34
到 64
(即 SIGRTMIN
到 SIGRTMAX
):为实时信号,不在本文讨论范围。
本文主要介绍前31信号,他们也被称为普通信号。
普通信号 = 编号 1~31,这是所有Unix-like系统的通用标准。
通过键盘输⼊⼀些具有特定意义的组合键,如 CTRL+C 终止进程( 2 号信号 SIGINT )或者 CTRL+\ 终止进程( 3 号信号 SIGQUIT )。 系统收到这类型的组合键后,便会视之为某种信号,并向前台进程执⾏相关操作。
前台进程:当程序运⾏起来后, bash便会成为后台进程,此时终端产⽣的信号只会作⽤于前台正 在运⾏的进程。 所以如果当某进程成为后台进程时,键盘输⼊的信号便对他⽆效。
操作系统具备向进程发送信号的 “ 能⼒ ” ,但他没有这个 “ 权⼒ ” ,发送信号的权⼒在⽤⼾⼿中。所以操作系统为⽤⼾提供了系统调⽤。
这里的kill不是指的命令行上的指令,而是一个系统调用函数
该函数的作用就是向任意进程发送任意信号,参数一为该进程的pid,参数二为信号名或者编号(上图信号列表前面的数字)。
成功返回0,失败返回-1并写入errno。
下面通过 kill 系统调⽤,实现⼀个 “MyKill” 程序,⽽不再⽤ bash 提供的 kill ,以此加深对kill的理解。
Mykill程序代码:
#include <iostream>
#include <sys/types.h>
#include <signal.h>
using namespace std;
int main(int argc, char *argv[])
{
if (argc != 3)
{
cout << "输入格式:\n"
<< "Usage:" << argv[0] << " pid signo\n"
<< endl;
exit(1);
}
pid_t pid = atoi(argv[1]);
int sig=atoi(argv[2]);
int jug=kill(pid,sig);
if(jug<0)perror("error:kill") ;
return 0;
}
测试程序代码:
#include<iostream>
#include<unistd.h>
int main()
{
std::cout<<"我的pid:"<<getpid()<<std::endl;
sleep(30);
std::cout<<"运行结束"<<std::endl;
return 0;
}
运行截图
同时开两个窗口,一个运行test,一个运行Mykill。可以看到test中“运行结束”未打印出便结束,Mykill运行成功。
raise函数的作用,向调用该函数的进程自己发送信号。
成功返回0,失败返回非0值。
下面通过代码验证raise的作用
#include<iostream>
#include<signal.h>
#include<unistd.h>
int main()
{
std::cout<<"我是一个进程,我的pid:"<<getpid()<<std::endl;
raise(2);
std::cout<<"你看不见我"<<std::endl;
return 0;
}
运行截图
abort函数,终止调用abort函数的进程。
程序示例:
#include<iostream>
#include<stdlib.h>
#include<unistd.h>
int main()
{
int cnt=10;
while(cnt--)
{
printf("cnt=%d\r",cnt);
fflush(stdout);
sleep(1);
if(cnt==5)
abort();
}
return 0;
}
运行结果
⾏为:很多情况下,进程收到的信号,⼤部分默认处理都是⼀样的,如终⽌。
意义:信号的不同,代表着事件(异常)的不同,但事件发⽣后的处理动作可以是⼀样的。未来 可以通过信号种类的不同,定位异常发⽣位置,进⽽修正。
信号的产⽣,不⼀定⾮得是系统调⽤或者⽤⼾显式发送。 如:除 0 异常。
当进⾏除 0 操作是,该进程会收到操作系统发出的异常信号 ——8 号信号( SIGFPE ),作⽤是终⽌该进程。
证明该进程收到信号的⽅法:⽤ signal 函数 —— 将某信号的默认⾏为变成⾃定义⾏为
操作系统对于各种信号都有默认出来操作,而signal函数的作用:可以用自定义的处理函数替代默认行为。
参数一:信号名或者编号;
参数二:自定义函数的地址(即函数名)
成功返回之前为指定信号 signum
设置的处理函数的指针;
失败返回-1,并设置erron。
注意:9号信号(SIGINT)是不允许被重定义处理函数替代的!
在明白signal的作用后,我们尝试用代码证实除0错误会触发8号信号:
#include<iostream>
#include<signal.h>
#include<unistd.h>
static void handler(int sig)
{
std::cout<<"检测到8号信号"<<std::endl;
sleep(2);
}
int main()
{
signal(8,handler);
int a=10;
a/=0;
return 0;
}
执行结果
2.解引用野指针异常
int* p;
p=NULL;
*p=100;
系统检测到野指针异常后,便会向该进程发送 ——11 号信号( SIGSEGV ),作⽤还是终⽌进程。
证明同除 0 相同。
补充一点,解引⽤野指针异常,是操作系统⻚表在转换虚拟地址时发现的。
当管道通信双⽅有⼀⽅关闭,⽽另⼀⽅还在写 / 度,操作系统会向未关闭的进程发送信号 ——13 号信号, SIGPIPE 信号,促使其终⽌。
若对管道包有疑惑的读者可查看笔者往期博文:Linux环境管道通信介绍-CSDN博客
alarm函数是一个系统调用,它的作用:可以设定一个闹钟,也就是告诉内核在seconds秒之后给当前进程发SIGALRM信号, 该信号的默认处理动作是终止当前进程。
返回值是0或者是以前设定的闹钟时间还余下的秒数。
使用示例:统计一秒钟能自++的三次的程序
alarm(1);
int i=0;
while(true)
{
std::cout<<i++<<std::endl;
}
结果
当 1 秒后,执⾏信号 14 。
CPU的运算速度不是很快吗,为什么这里才十多万?
答:这里每++一次都得打印,I/O速度过慢,拉低了综合速率。可以 通过在 signal 设置的⾃定义函数中,重置定时器 alarm 的值,可以实现重复执⾏,⽐如通过 alarm 实现 sleep。
alarm ( 0 ),取消闹钟。
通过man手册查看这些信号的详细信息时,我们发现操作系统对于这些信号默认行为有不同:如下图中的Term和Core。
Term 是正常的终⽌进程,⽽ Core除了终止形成还会做⼀些额外的事情。
核心转储的概念:
每当程序因 Core 类型的异常中断时,系统会⾃动在该程序路径下⽣成⼀个 名为“Core. 该程序pid”的⽂件。这个⽂件存储着该进程异常前的有效数据。
最有价值的是,生成的 Core.pid ⽂件中记录了哪条指令引发了异常,当在 gbd 调试时可以引⼊ core ⽂件, gdb 便会 ⾃动跳转到相应异常代码,以便调试。
注意:如果是在云服务器上部署的Linux,上述Core文件功能一般是被默认关闭的,需要打开才能看到。打开方法:ulimit -c 数字(一般为1024);
信号递达:信号递达指的是信号被实际处理的过程。当进程执行信号处理函数或忽略信号(SIG_IGN)时,称为信号已递达。
信号未决:信号未决是指信号已经产生(由内核、其他进程或自身产生),但尚未递达的状态。
信号阻塞:进程可以选择阻塞某些信号。被阻塞的信号产生时将保持未决状态,直到进程解除对该信号的阻塞。
注意:阻塞与忽略是不同的,只要信号被阻塞就不会不会递达,而忽略是递达可选的一种处理动作。
信号在进程中通过三张表存储 ——pending 、 block 的位图表,以及 handler 表(函数指针数组)。
在 task_struct ( PCB )中,有着两个 unsigned int 型的变量 ——pending 、 block ,⽤于保存进程 收到的信号。他们⽤位图的⽅式存储信号, 32 个⽐特位每⼀个分别对应⼀个信号, 0 表⽰未收 到, 1 表⽰收到。
进程检测某信号的流程:
⾸先检测该信号是否阻塞(在 block 中对⽐),若阻塞则⽆后续;若未阻塞,再通过 pending 查看 当前是否收到该信号,若收到则启动后续处理
细节注意:
①一个信号即使没有收到,但不妨碍进程将它阻塞。
(比如你讨厌某人,即使没见到它,但不妨碍讨厌它)
②若同时收到相同信号若干个,则只能保存一份,其他信号都将被丢失。
task_struct 中还有着⼀个函数指针数组 handler32 ,其中每⼀个元素存储着该数组下标对应信号 的处理⽅法。
所以 signal( ) 函数的本质,就是通过传⼊信号的名称找到它在 handler 中的位置,然后将⽤⼾⾃定 义函数的地址填⼊其中。
信号产⽣的时候,不会被⽴即处理,⽽是在合适的时候进⾏处理。
当进程从内核态返回用户态时,进行信号的处理。
CPU 内部存在模式寄存器,如 x86 的CS寄存器低 2 位: 00 = 内核态, 11 = 用户态。操作系统通过检测模式寄存器中的值判断当前是处于用户态还是内核态。
他们的定义:
⼀般程序编译运⾏后都处于用户态,当该进程想要访问系统资源(如内核资源、硬件资源等) 时,会通过系统调用完成访问:系统调⽤完成了从用户态 —> 内核态 —> ⽤户态的转变。
以 32 位系统为例,在进程的地址中 1~3G 是用户空间, 3~4G 是系统空间。
其中用户空间存储着该进程的代码和数据,由用户级级⻚表进⾏映射,每个进程都有⾃⼰的独⽴用户级页表;
⽽系统内核空间存储着操作系统本⾝的代码和数据,这些数据⽤内核级⻚表进⾏映射, 也就是说 所有进程的系统内核空间,都是通过内核级⻚表映射,所得到的数据和代码是⼀样的,因为操作 系统的数据只有⼀份。因此⽆论进程怎么切换,系统空间中的数据都不会发送变化。 (野指针的危害由此也能体现)
(用户级表每个进程都有,内核级⻚表整个系统仅此⼀份)
综上,当进程想要进⾏系统调⽤时,⾸先进⾏用户态 —> 内核态的转变,然后再到该进程⾃⼰的进程地址空间的系统内核级空间进⾏映射,当完成系统调⽤后再从内核态 —> 用户态。
除了系统调⽤外,进程切换、中断、陷⼊指令等都可以实现从用户态转换到内核态。
对进程地址空间包疑的读者,可以翻看笔者之前的博客:进程优先级介绍,详解环境变量,详解进程地址空间-CSDN博客
当进程因某原因发⽣了从用户态到内核态的转变,并执⾏完引起状态转变的原因后,便检查是否有信号待处理,若有则处理。
流程如下:
上图每⼀个绿圈都是⼀次状态的切换。
当从内核态转换为⽤⼾态时会进⾏信号检测,若有则会处理信号。
注意:执⾏信号的处理⾏为时,需要从内核态转换到⽤⼾态。当执⾏完毕后,⼜必须从用户态转 换到内核态才能回到系统调⽤的位置。⼀共经历 4 次状态转换。
问题:进程能否在内核态状态下,执⾏⽤⼾态代码?
答:不⾏!!虽然理论可行,实际上如 witpid 时可以将内核数据写⼊ status ,但在实际设计时, 考虑到如信号的⾃定义处理函数等操作会执⾏代
在内核中的 block 和 pending 都是按位⽅式存储,不便于程序员对其进⾏修改,于是 sigset_t 诞⽣ 了。
sigset_t 是种变量类型,该种变量的作⽤就是将 block 和 pending 的值保存下来,供后续的操作。
① sigemptyset(sigset_t set)
作⽤将上述定义的 sigset 函数初始化:将信号集初始化为空集合(不含任何信号);
② sigfillset(sigset_t set)
将信号集初始化为包含所有⽀持的信号的集合。
③ sigaddset (sigset_t set, int signo)
往 sigset_t 创建的变量中,增加我们想要的信号;
④ sigdelset(sigset_t set, int signo)
往 sigset_t 创建的变量中,减少我们想要的信号;
上述四个函数的返回值都是成功返回 1 ,失败返回 0。
⑤ sigismember ( const sigset_t * set, int signo)
判断 set 中是否包含传⼊的信号 signo ,是 return true ,否则返回 false;
sigprocmask 函数,对 block 位图表进⾏操作,该函数可以将通过信号集处理函数处理后的sigset_t 类型变量,覆盖式传⼊调⽤本函数的进程,以实现对该进程的信号 block 位图操作。
参数 how :
SIG_BLOCK—— 将 set 中的信号加⼊该进程中;
SIG_UNBLOCK—— 将 set 的信号从该进程的 block 中从删除;
SIG_SETMASK—— 将该进程的 block 值完全设置为 set ,推荐使⽤这个参数。
参数 set :即存储着希望对该进程设置 block 值的变量。
参数 oset :⽤于存储在被 sigprocmask 替换之前,该进程的 block 的值。
sigpending 函数,作⽤是将⽬前进程的 spending 表保存到参数 set 中。
sigpending 常与 sigismember 函数联合使⽤,⽤于检测该进程是否收到某型号。
成功返回 1 ,失败返回 0 ;
上面已经详细介绍过,这里简单说明。
作⽤是将某信号的默认处理,替换成⽤⼾⾃定义处理函数。 注意该函数的参数⼆需要传⼊⾃定义函数的函数指针,即函数名。
这里笔者给出一个将2、3号信号阻塞的代码供读者参考,以梗好理解上述内容:
#include<iostream>
#include<signal.h>
#include<unistd.h>
using namespace std;
static void dp_pending(const sigset_t &pending)
{
for(int i=31;i>=1;--i)
{
if(sigismember(&pending,i))
cout<<1;
else
cout<<0;
}
cout<<endl;
}
int main()
{
sigset_t block,oblock,pending;
sigemptyset(&block);
sigemptyset(&oblock);
sigemptyset(&pending);
int b_sig[]={2,3};
for(const auto sig:b_sig)
sigaddset(&block,sig);
sigprocmask(SIG_SETMASK,&block,&oblock);
while(true)
{
sigpending(&pending);
dp_pending(pending);
sleep(1);
}
return 0;
}
到此Linux进程信号的核心内容介绍完毕,下面将介绍信号拓展内容:
所谓重⼊,指:函数被不同的控制流程调⽤ , 有可能在第⼀次调⽤还没返回时就再次进⼊该函数 , 这称为重⼊。
⽐如在单链表中,如果头插某个节点时接到⼀个信号:让⽴即头插另⼀个某节点。此时关于 insert 函数就有两个执⾏流,显然同时让 head 头节点指向两个节点会造成混乱,导致数据丢失内 存泄漏。因此,像这样的函数称为 不可重⼊函数 , 反之 , 如果⼀个函数只访问⾃⼰的局部变量或参数 , 则称为可重入函数。
其实⼦进程在终⽌时会给⽗进程发 17 号信号 SIGCHLD 信号 , 该信号的默认处理动作是忽略 , ⽗进程可以⾃定义 SIGCHLD 信号 的处理函数 , 这样⽗进程只需专⼼处理⾃⼰的⼯作 , 不必关⼼⼦进程了 , ⼦进 程终⽌时会通知⽗进程 , ⽗进程在信号处理函数中调⽤ wait 清理⼦进程即可。
Ign :即内核收到这类信号的处理办法就是忽略。
①对 SIGCHLD 信号⾃定义处理,即在 handler 中进⾏ waitpid 时,由于不知道该⽗进程有多少个⼦ 进程,所以为防⽌同⼀时间两个及以上的⼦进程发送 SIGCHLD 信号,在 handler 中 waitpid 应该采用循环的方式等待。
② waitpid 分为阻塞式等待和轮询等待,考虑到⽗进程可能有其他任务需要完成,不能在信号处理 中陷⼊阻塞,故 waitpid 应该采⽤轮询式等待即参数三设为 WNOHANG。
笔者给出一段实现上述信号SIGCHLD,用以回收子进程的进程等待代码:
#include<iostream>
#include<signal.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
static void handler(int sig)
{
pid_t id;
while((id=waitpid(-1,NULL,WNOHANG))>0)
printf("成功回收子进程:%d\n",id);
}
int main()
{
pid_t id=fork();
if(id==0)
{
printf("我是子进程,pid:%d\n",getpid());
sleep(5);
exit(0);
}
sleep(10);
return 0;
}
由于 UNIX 的历史原因 , 要想不产⽣僵⼫进程还有另外⼀种办法 : ⽗进程调用signal 将 SIGCHLD 的处理动作置为 SIG_IGN, 这样 fork 出来的⼦进程在终⽌时会⾃动清理掉 , 不会产⽣僵⼫ 进程 , 也不会通知⽗进程。 系统默认的忽略动作和用户用signal 函数⾃定义的忽略通常是没有区别的 , 但这是⼀个特例。此⽅法对于 Linux 可⽤ , 但不保证在其它UNIX 系统上都可⽤。
signal(SIGCHLD,SIG_IGN);
细节注意:同词不同意
这⾥的替换忽略与 SIGCHLD 默认忽略是有区别的:
原本 SIGCHLD 的默认忽略,是什么都不做,⼦进程该 wait 还是得 wait ;
当替换成 SIG_IGN 后,该忽略是要接受了⼦进程的 SIGCHLD 并将其替换成 SIG_IGN ,但之后⽆后续处理,⼦进程⾃动被系统回收。
本文介绍的信号大都是【1~31】的普通信号,普通信号有⼀个缺点:若同时收到相同信号若⼲个,则只能保存⼀个信号,其他信号只能丢失。
其实task_struct 中还有着⼀个实时信号队列,只要收到⼀个信号就为其创造⼀个信号节点 —— 包含了 该信号的编号和处理⽅法。
本文系统介绍了Linux进程信号机制,主要内容包括:信号的概念(通知机制、进程识别与保存方式)、信号产生途径(终端输入、系统调用、硬件/软件异常)、信号保存(内核三张表结构)、信号处理流程(用户态/内核态切换)及相关系统调用函数。文章还拓展了可重入函数、SIGCHLD信号应用等知识点,并提供了多个代码示例帮助理解信号机制的实际应用。通过信号,操作系统可以高效管理进程对各种事件的响应和处理。