深度拆解 Linux 进程信号:从原理到实战全指南
目录

一、信号快速认识
1.1生活角度的信号
你在网上买了很多件商品,再等待不同商品快递的到来。但即便快递没有到来,你也知道快递来临 时,你该怎么处理快递。也就是你能“识别快递”。当快递员到了你楼下,你也收到快递到来的通知,但是你正在打游戏,需5min之后才能去取快递。 那么在在这5min之内,你并没有下去去取快递,但是你是知道有快递到来了。也就是取快递的行为并不是⼀定要立即执行,可以理解成“在合适的时候去取”。在收到通知,再到你拿到快递期间,是有⼀个时间窗口的,在这段时间,你并没有拿到快递,但是你知道有⼀个快递已经来了。本质上是你“记住了有⼀个快递要去取”。当你时间合适,顺利拿到快递之后,就要开始处理快递了。而处理快递⼀般方式有三种:1.执行默认动作(幸福的打开快递,使用商品)2.执行自定义动作(快递是零食,你要送给你的女朋友) 3.忽略快递(快递拿上来之后,扔掉床头,继续开⼀把游戏)。
总的来说:快递到来的整个过程,对你来讲是异步的,你不能准确断定快递员什么时候给你打电话
一句话总结:信号是中断我们正在做的事情,是一种给进程发送的,用来进行事件异步通知的机制。
基本结论:
- 1.进程在信号没有信号产生的时候就已经知道信号怎么处理了。
- 2.信号的处理不是立即处理,而是等合适的时候进行信号的处理。
- 3.OS程序员设计进程时,进程内就已经内置了对于信号的识别和处理方式。
- 4.信号源很多------>给进程产生信号的信号源也非常多。
1.2技术应用角度的信号
产生信号的方式有很多种
#include <iostream>
#include <unistd.h>
int main()
{
int cnt = 0;
while(true)
{
std::cout << "hello" << cnt++ << std::endl;
}
sleep(1);
return 0;
}

二、产生信号
键盘产生信号:crtl+c是给目标进程发送信号,相当一部分信号的处理动作就是让自己终止。
#include <iostream>
#include <unistd.h>
#include <signal.h>
void handlerSig(int sig)
{
std::cout << "获得了一个信号: " << sig << std::endl;
}
int main()
{
signal(SIGINT,handlerSig);
int cnt = 0;
while(true)
{
std::cout << "hello" << cnt++ << std::endl;
sleep(1);
}
return 0;
}

键盘产生的信号也不止一种
#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
void handlerSig(int sig)
{
std::cout << "获得了一个信号: " << sig << std::endl;
}
int main()
{
for(int i = 1;i < 32;i++)
signal(i,handlerSig);
int cnt = 0;
while(true)
{
std::cout << "hello" << cnt++ << ",pid" << getpid() << std::endl;
sleep(1);
}
return 0;
}

这时候我们就会想如果有人把所有信号都恶意捕捉,那很多病毒我们是不是就无法杀掉。所以我们的9号信号是无法被捕捉的。

样例实现自己的kill命令:
#include <iostream>
#include <sys/types.h>
#include <signal.h>
#include <unistd.h>
#include <string>
//运行格式
// ./mykill signumber pid
int main(int argc,char *argv[])
{
if(argc != 3)
{
std::cout << "./mykill signumber pid" << std::endl;
return 1;
}
int signum = std::atoi(argv[1]);
pid_t pid = std::atoi(argv[2]);
int n = kill(pid,signum);
if(n == 0)
{
std::cout << "send" << signum << "to" << pid << "success." << std::endl;
}
return 0;
}

raise 函数可以给当前进程发送指定的信号(自己给自己发信号)。

#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
void handlerSig(int sig)
{
std::cout << "获得了一个信号: " << sig << std::endl;
}
int main()
{
for(int i = 1;i < 32;i++)
signal(i,handlerSig);
for(int i = 1;i < 32;i++)
{
sleep(1);
raise(i);
}
int cnt = 0;
while(true)
{
std::cout << "hello" << cnt++ << ",pid" << getpid() << std::endl;
sleep(1);
}
return 0;
}

abort 函数使当前进程接收到信号而异常终止

#include <iostream>
#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
void handlerSig(int sig)
{
std::cout << "获得了一个信号: " << sig << std::endl;
}
int main()
{
for(int i = 1;i < 32;i++)
signal(i,handlerSig);
//for(int i = 1;i < 32;i++)
//{
// sleep(1);
// raise(i);
//}
int cnt = 0;
while(true)
{
std::cout << "hello" << cnt++ << ",pid" << getpid() << std::endl;
abort();
sleep(1);
}
return 0;
}
abort给自己发送的是固定6号信号,虽然捕捉了,但是还是要退出

硬件异常产生信号


软件条件产生信号

调用alarm函数可以设定⼀个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发 SIGALRM 信号,该信号的默认处理动作是终止当前进程。
这个函数的返回值是0或者是以前设定的闹钟时间还余下的秒数。打个比方,某⼈要小睡⼀觉,设定闹 钟为30分钟之后响,20分钟后被人吵醒了,还想多睡⼀会儿,于是重新设定闹钟为15分钟之后响,“以前设定的闹钟时间还余下的时间”就是10分钟。如果seconds值为0,表示取消以前设定的闹钟,函数的返回值仍然是以前设定的闹钟时间还余下的秒数。
这里我们刚好解答一个IO效率的问题

设置重复闹钟样例

int main()
{
// signal(SIGALRM, handlerSig);
alarm(1);
while(true)
{
// 我想让进程什么都不做,就想让进程暂停
// 一旦来一个信号,就唤醒一次执行方法
// std::cout << "., " << "pid: " << getpid() << std::endl;
// sleep(1);
pause();
}
return 0;
}
![]()
//////////func////////////
void Sched()
{
std::cout << "我是进程调度" << std::endl;
t.count--;
if(t.count <= 0)
// 切换其他进程
}
void MemManger()
{
std::cout << "我是周期性的内存管理,正在检查有没有内存问题" << std::endl;
}
void Fflush()
{
std::cout << "我是刷新程序,我在定期刷新内存数据,到磁盘" << std::endl;
}
/////////////////////////
using func_t = std::function<void()>;
std::vector<func_t> funcs;
int timestamp = 0;
// 每隔一秒,完成一些任务
void handlerSig(int sig)
{
timestamp++; // 记录时间戳
std::cout << "##############################" << std::endl;
for(auto f : funcs)
f();
std::cout << "##############################" << std::endl;
int n = alarm(1);
}
int main()
{
funcs.push_back(Sched);
funcs.push_back(MemManger);
funcs.push_back(Fflush);
signal(SIGALRM, handlerSig);
alarm(1);
while(true)
{
pause();
}
return 0;
}
简单快速理解闹钟

总结:

三、保存信号
3.1信号其他相关常见概念
- 实际执行信号的处理动作称为信号递达(Delivery),有三种:自定义,默认,忽略。
- 信号从产生到递达之间的状态,称为信号未决(Pending),就是说信号在位图中还来得及处理。
- 进程可以选择阻塞(Block)某个信号,被阻塞的信号产生时将保持在未决状态,直到进程解除对此信号的阻塞,才执行递达的动作。
- 注意:阻塞和忽略是不同的,只要信号被阻塞就不会递达,而忽略是在递达之后可选的⼀种处理动作。
3.2在内核中的表示
信号在内核中的表示意图

- 每个信号都有两个标志位分别表示阻塞(block)和未决(pending),还有⼀个函数指针表示处理动作。信号产生时,内核在进程控制块中设置该信号的未决标志,直到信号递达才清除该标志
- SIGINT信号产生过,但正在被阻塞,所以暂时不能递达。虽然它的处理动作是忽略,但在没有解除阻 塞之前不能忽略这个信号,因为进程仍有机会改变处理动作之后再解除阻塞。
- SIGQUIT信号未产生过,⼀旦产生SIGQUIT信号将被阻塞,它的处理动作是用户自定义函数 sighandler。
- 如果在进程解除对某信号的阻塞之前这种信号产生过多次,将如何处理?POSIX.1允许系统递送该信 号⼀次或多次。Linux是这样实现的:常规信号在递达之前产生多次只计⼀次,而实时信号在递达之前产生多次可以依次放在⼀个队列里
void handler(int sig)
{
std::cout << "hello sig:" << sig << std::endl;
signal(2,SIG_IGN);//2默认动作是终止
std::cout << "恢复处理动作" << std::endl;
}
int main()
{
signal(2, handler);//自定义捕捉
signal(2, SIG_IGN);//忽略信号
while(1)
{
sleep(1);
std::cout << "." << std::endl;
}
return 0;
}
3.3sigset_t
从上图来看,每个信号只有⼀个bit的未决标志,非0即1,不记录该信号产生了多少次,阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型sigset_t来存储,sigset_t称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。下⼀节将详细介绍信号集的各种操作。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask)这里的“屏蔽”应该理解为阻塞而不是忽略。
3.4信号集操作函数
sigset_t类型对于每种信号用⼀个bit表示“有效”或“无效”状态,至于这个类型内部如何存储这些 bit则依赖于系统实现,从使用者的角度是不必关心的,使用者只能调用以下函数来操作sigset_t变量,而不应该对它的内部数据做任何解释,比如用printf直接打印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所指向的信号集,使其中所有信号的对应bit清零,表示该信号集不包含任何有效信号
- 函数sigfillset初始化set所指向的信号集,使其中所有信号的对应bit置位,表示该信号集的有效信号包括系统支持的所有信号
- 注意,在使用sigset_t类型的变量之前,⼀定要调用sigemptyset或sigfillset做初始化,使信号集处于确定的状态。初始化sigset_t变量之后就可以在调用sigaddset和sigdelset在该信号集中添加或删除某种有效信号
- 这四个函数都是成功返回0,出错返回-1。sigismember是⼀个布尔函数,用于判断⼀个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1
3.4.1sigprocmask
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。(block表)

返回值:若成功则为0,若出错则为-1
如果oset是非空指针,则读取进程的当前信号屏蔽字通过oset参数传出。如果set是非空指针,则更改 进程的信号屏蔽字,参数how指示如何更改。如果oset和set都是非空指针,则先将原来的信号屏蔽字 备份到oset里,然后根据set和how参数更改信号屏蔽字。假设当前的信号屏蔽字为mask,how参数可选

如果调用sigprocmask解除了对当前若干个未决信号的阻塞,则在sigprocmask返回前,至少将其中⼀ 个信号递达。
3.4.2sigpending
读取当前进程的未决信号集,通过set参数传出。(pending表)

调用成功则返回0,出错则返回-1
至于修改pending位图就是
实验样例:观察捕获2号信号pending的变化
void PrintPending(sigset_t pending)
{
std::cout << "我是一个进程:" << getpid() << "pending:" << std::endl;
for(int signo = 31;signo >= 1;signo--)
{
if(sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
int main()
{
//屏蔽2号信号
sigset_t block;
sigemptyset(&block);
sigaddset(&block, SIGINT);
int n = sigprocmask(SIG_SETMASK, &block, &block);
(void)n;
while(true)//重复打印
{
//获取pending信号集合
sigset_t pending;
int n =sigpending(&pending);
//打印
PrintPending(pending);
sleep(1);
}
return 0;
}

有解除的情况:
void PrintPending(sigset_t pending)
{
std::cout << "我是一个进程:" << getpid() << "pending:" << std::endl;
for(int signo = 31;signo >= 1;signo--)
{
if(sigismember(&pending, signo))
{
std::cout << "1";
}
else
{
std::cout << "0";
}
}
std::cout << std::endl;
}
void handler(int sig)
{
std::cout << "递达" << sig << "信号" << std::endl;
}
int main()
{
signal(SIGINT, handler);
//屏蔽2号信号
sigset_t block;
sigemptyset(&block);
sigaddset(&block, SIGINT);
int n = sigprocmask(SIG_SETMASK, &block, &block);
(void)n;
int cnt = 0;
while(true)//重复打印
{
//获取pending信号集合
sigset_t pending;
int n =sigpending(&pending);
//打印
PrintPending(pending);
//恢复2号信号的block情况
if(cnt == 10)
{
sigprocmask(SIG_SETMASK, &block, nullptr);
std::cout << "解除对2号信号的屏蔽" << std::endl;
}
sleep(1);
cnt++;
}
return 0;
}

四、捕捉信号
4.1信号捕捉的流程

如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂:
用户程序注册了 SIGQUIT 信号的处理函数 sighandler,当前正在执行main 函数,这时发生中断或异常切换到内核态,在中断处理完毕后要返回用户态的 main 函数之前检查到有信号 SIGQUIT 递达。内核决定返回用户态后不是恢复 main 函数的上下文继续执行,而是执行 sighandler 函数, sighandler 和 main 函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。sighandler 函数返回后自动执⾏特殊的系统调用 sigreturn 再次进入内核态。如果没有新的信号要递达,这次再返回用户态就是恢复 main 函数的上下文继续执行了。

检查pending表就是信号检测的时间点
只要是进程就会被调度就要进入内核!!!(while(1)这种也会)
4.2补充话题:操作系统是怎么运行的
4.2.1硬件中断

- 中断向量表就是操作系统的⼀部分,启动就加载到内存中了
- 通过外部硬件中断,操作系统就不需要对外设进行任何周期性的检测或者轮询
- 由外部设备触发的,中断系统运行流程,叫做硬件中断
4.2.2时钟中断
进程可以在操作系统的指挥下,被调度,被执行,那么操作系统自己被谁指挥,被谁推动执行呢?
外部设备可以触发硬件中断,但是这个是需要用户或者设备自己触发,有没有自己可以定期触发的 设备?

// Linux 内核0.11
// main.c
sched_init(); // 调度程序初始化(加载了任务0 的tr, ldtr) (kernel/sched.c)
// 调度程序的初始化⼦程序。
void sched_init(void)
{
...
set_intr_gate(0x20, &timer_interrupt);
// 修改中断控制器屏蔽码,允许时钟中断。
outb(inb_p(0x21) & ~0x01, 0x21);
// 设置系统调⽤中断⻔。
set_system_gate(0x80, &system_call);
...
}
// system_call.s
_timer_interrupt:
...
;// do_timer(CPL)执⾏任务切换、计时等⼯作,在kernel/shched.c,305 ⾏实现。
call _do_timer ;// 'do_timer(long CPL)' does everything from
// 调度⼊⼝
void do_timer(long cpl)
{
...
schedule();
}
void schedule(void)
{
...
switch_to(next); // 切换到任务号为next 的任务,并运⾏之。
}
4.2.3死循环
如果是这样,操作系统不就可以躺平了吗?对,操作系统自己不做任何事情,需要什么功能,就向中断向量表里面添加⽅法即可。操作系统的本质:就是⼀个死循环!
void main(void) /* 这⾥确实是void,并没错。 */
{ /* 在startup 程序(head.s)中就是这样假设的。 */
...
/*
* 注意!! 对于任何其它的任务,'pause()'将意味着我们必须等待收到⼀个信号才会返
* 回就绪运⾏态,但任务0(task0)是唯⼀的意外情况(参⻅'schedule()'),因为任
* 务0 在任何空闲时间⾥都会被激活(当没有其它任务在运⾏时),
* 因此对于任务0'pause()'仅意味着我们返回来查看是否有其它任务可以运⾏,如果没
* 有的话我们就回到这⾥,⼀直循环执⾏'pause()'。
*/
for (;;)
pause();
} // end main
4.2.4软中断
有没有可能,因为软件原因,也触发上面的逻辑?有!
为了让操作系统支持进行系统调用,CPU也设计了对应的汇编指令(int或者syscall),可以让CPU内 部触发中断逻辑。

- 用户层怎么把系统调用号给操作系统?--寄存器(比如EAX)
- 操作系统怎么把返回值给用户?--寄存器或者用户传入的缓冲区地址
- 系统调用的过程,其实就是先int 0x80、syscall陷入内核,本质就是触发软中断,CPU就会自动执行系统调用的处理方法,而这个⽅法会根据系统调用号,自动查表,执行对应的方法
- 系统调用号的本质:数组下标!
// sys.h
// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
extern int sys_setup (); // 系统启动初始化设置函数。 (kernel/blk_drv/hd.c,71)
extern int sys_exit (); // 程序退出。 (kernel/exit.c, 137)
extern int sys_fork (); // 创建进程。 (kernel/system_call.s, 208)
extern int sys_read (); // 读⽂件。 (fs/read_write.c, 55)
extern int sys_write (); // 写⽂件。 (fs/read_write.c, 83)
extern int sys_open (); // 打开⽂件。 (fs/open.c, 138)
extern int sys_close (); // 关闭⽂件。 (fs/open.c, 192)
extern int sys_waitpid (); // 等待进程终⽌。 (kernel/exit.c, 142)
extern int sys_creat (); // 创建⽂件。 (fs/open.c, 187)
extern int sys_link (); // 创建⼀个⽂件的硬连接。 (fs/namei.c, 721)
extern int sys_unlink (); // 删除⼀个⽂件名(或删除⽂件)。 (fs/namei.c, 663)
extern int sys_execve (); // 执⾏程序。 (kernel/system_call.s, 200)
extern int sys_chdir (); // 更改当前⽬录。 (fs/open.c, 75)
......
// 系统调⽤函数指针表。⽤于系统调⽤中断处理程序(int 0x80),作为跳转表。
fn_ptr sys_call_table[] = { sys_setup, sys_exit, sys_fork, sys_read,
sys_write, sys_open, sys_close, sys_waitpid, sys_creat, sys_link,
sys_unlink, sys_execve, sys_chdir, sys_time, sys_mknod, sys_chmod,
sys_chown, sys_break, sys_stat, sys_lseek, sys_getpid, sys_mount,
sys_umount, sys_setuid, sys_getuid, sys_stime, sys_ptrace, sys_alarm,
sys_fstat, sys_pause, sys_utime, sys_stty, sys_gtty, sys_access,
sys_nice, sys_ftime, sys_sync, sys_kill, sys_rename, sys_mkdir,
sys_rmdir, sys_dup, sys_pipe, sys_times, sys_prof, sys_brk, sys_setgid,
sys_getgid, sys_signal, sys_geteuid, sys_getegid, sys_acct, sys_phys,
sys_lock, sys_ioctl, sys_fcntl, sys_mpx, sys_setpgid, sys_ulimit,
sys_uname, sys_umask, sys_chroot, sys_ustat, sys_dup2, sys_getppid,
sys_getpgrp, sys_setsid, sys_sigaction, sys_sgetmask, sys_ssetmask,
sys_setreuid, sys_setregid
};
// 调度程序的初始化⼦程序。
void sched_init(void)
{
...
// 设置系统调⽤中断⻔。
set_system_gate(0x80, &system_call);
}
_system_call:
cmp eax,nr_system_calls-1 ;// 调⽤号如果超出范围的话就在eax 中置-1 并退出。
ja bad_sys_call
push ds ;// 保存原段寄存器值。
push es
push fs
push edx ;// ebx,ecx,edx 中放着系统调⽤相应的C 语⾔函数的调⽤参数。
push ecx ;// push %ebx,%ecx,%edx as parameters
push ebx ;// to the system call
mov edx,10h ;// set up ds,es to kernel space
mov ds,dx ;// ds,es 指向内核数据段(全局描述符表中数据段描述符)。
mov es,dx
mov edx,17h ;// fs points to local data space
mov fs,dx ;// fs 指向局部数据段(局部描述符表中数据段描述符)。
;// 下⾯这句操作数的含义是:调⽤地址 = _sys_call_table + %eax * 4。参⻅列表后的说明。
;// 对应的C 程序中的sys_call_table 在include/linux/sys.h 中,其中定义了⼀个包括72个
;// 系统调⽤C 处理函数的地址数组表。
call [_sys_call_table+eax*4]
push eax ;// 把系统调⽤号⼊栈。
mov eax,_current ;// 取当前任务(进程)数据结构地址??eax。
;// 下⾯97-100 ⾏查看当前任务的运⾏状态。如果不在就绪状态(state 不等于0)就去执⾏调度程序。
;// 如果该任务在就绪状态但counter[??]值等于0,则也去执⾏调度程序。
cmp dword ptr [state+eax],0 ;// state
jne reschedule
cmp dword ptr [counter+eax],0 ;// counter
je reschedule
;// 以下这段代码执⾏从系统调⽤C 函数返回后,对信号量进⾏识别处理。
ret_from_sys_call:

所以我们用的系统调用,从来没有见过什么 int 0x80 或者 syscall,都是直接调用上层的函数。是因为Linux的gnu C标准库,给我们把几乎所有的系统调用全部封装了。

#define SYS_ify(syscall_name) __NR_##syscall_name :是⼀个宏定义,用于将系统调用的名称转换为对应的系统调用号。比如: SYS_ify(open) 会被展开为__NR_open
而系统调用号,不是 glibc 提供的,是内核提供的,内核提供系统调用入口函数 man 2 syscall ,或者直接提供汇编级别软中断命令 int or syscall ,并提供对应的头文件或者开发入口,让上层语⾔的设计者使用系统调用号,完成系统调用过程
我们之前遇到的缺页中断?内存碎片处理?除零野指针错误?这些问题,全部都会被转换成为CPU内部的软中断, 然后⾛中断处理例程,完成所有处理。有的是进行申请内存,填充页表,进行映射的。有的是用来处理内存碎片的,有的是用来给目标进行发送信号,杀掉进程等等。
void trap_init(void) { int i; set_trap_gate(0,÷_error);// 设置除操作出错的中断向量值。以下雷同。 set_trap_gate(1,&debug); set_trap_gate(2,&nmi); set_system_gate(3,&int3); /* int3-5 can be called from all */ set_system_gate(4,&overflow); set_system_gate(5,&bounds); set_trap_gate(6,&invalid_op); set_trap_gate(7,&device_not_available); set_trap_gate(8,&double_fault); set_trap_gate(9,&coprocessor_segment_overrun); set_trap_gate(10,&invalid_TSS); set_trap_gate(11,&segment_not_present); set_trap_gate(12,&stack_segment); set_trap_gate(13,&general_protection); set_trap_gate(14,&page_fault); set_trap_gate(15,&reserved); set_trap_gate(16,&coprocessor_error); // 下⾯将int17-48 的陷阱⻔先均设置为reserved,以后每个硬件初始化时会重新设置⾃⼰的陷阱 ⻔。 for (i=17;i<48;i++) set_trap_gate(i,&reserved); set_trap_gate(45,&irq13);// 设置协处理器的陷阱⻔。 outb_p(inb_p(0x21)&0xfb,0x21);// 允许主8259A 芯⽚的IRQ2 中断请求。 outb(inb_p(0xA1)&0xdf,0xA1);// 允许从8259A 芯⽚的IRQ13 中断请求。 set_trap_gate(39,¶llel_interrupt);// 设置并⾏⼝的陷阱⻔。 }
总结:
- 操作系统就是躺在中断处理例程上的代码块!
- CPU内部的软中断,比如int 0x80或者syscall,我们叫做陷阱(这个是我们故意的,没有错误)
- CPU内部的软中断,比如除零/野指针等,我们叫做异常
4.3如何理解内核态和用户态
操作系统无论怎么切换进程,都能找到同⼀个操作系统!换句话说操作系统系统调用方法的执行, 是在进程的地址空间中执行的!

4.4捕捉信号的另外一种方法:sigaction

- sigaction函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回0,出错则返回-1。
- signo是指定信号的编号。若act指针非空,则根据act修改该信号的处理动作。若oact指针非空,则通过oact传出该信号原来的处理动作。act和oact指向sigaction结构体
- 将sa_handler赋值为常数SIG_IGN传给sigaction表示忽略信号,赋值为常数SIG_DFL表示执行系统 默认动作,赋值为⼀个函数指针表示用自定义函数捕捉信号,或者说向内核注册了⼀个信号处理函 数,该函数返回值为void,可以带⼀个int参数,通过参数可以得知当前信号的编号,这样就可以用同⼀个函数处理多种信号。显然,这也是⼀个回调函数,不是被main函数调用,而是被系统所调用。
- 当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止
- 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用sa_mask字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。
#include <iostream>
#include <signal.h>
#include <unistd.h>
#include <cstdlib>
void handler(int signum)
{
std::cout << "Signal: " << signum << std::endl;
exit(0);
}
int main()
{
struct sigaction act,oact;
act.sa_handler = handler;
//捕捉2号信号
sigaction(SIGINT, &act, &oact);
while(1)
{
std::cout << "Hello, world!" << std::endl;
sleep(1);
}
return 0;
}
五、可重入函数

main函数调用insert函数向⼀个链表head中插入节点node1,插入操作分为两步,刚做完第⼀步的时候,因为硬件中断使进程切换到内核,再次回用户态之前检查到有信号待处理,于是切换到sighandler函数,sighandler也调用insert函数向同⼀个链表head中插入节点node2,插入操作的两步都做完之后从sighandler返回内核态,再次回到用户态就从main函数调用的insert函数中继续往下执行,先前做第⼀步之后被打断,现在继续做完第⼆步。结果是,main函数和sighandler先后向链表中插入两个节点,而最后只有⼀个节点真正插入链表中了。
insert函数被不同的控制流程调用,有可能在第⼀次调用还没返回时就再次进入该函数,这称为重入。insert函数访问⼀个全局链表,有可能因为重入而造成错乱,像这样的函数称为不可重入函数,反之,如果⼀个函数只访问自己的局部变量或参数,则称为可重入(Reentrant)函数。
如果⼀个函数符合以下条件之⼀则是不可重入的:
- 调用了malloc或free,因为malloc也是用全局链表来管理堆的。
- 调用了标准I/O库函数。标准I/O库的很多实现都以不可重入的放式使用全局数据结构。
六、关键字volatile
今天我们站在信号的角度重新理解⼀下volatile
int flat = 0;
void handler(int signum)
{
std::cout << "更改全局变量:flat = " << flat << "->1" << std::endl;
flat = 1;
}
int main()
{
signal(2,handler);
while(!flat);
std::cout << "process quit normal!" << std::endl;
return 0;
}

标准情况下,键入CTRL-C ,2号信号被捕捉,执行自定义动作,修改flag=1 ,while条件不满足,退出循环,进程退出
我们可以对这种情况进行优化:
优化情况下,键入CTRL-C ,2号信号被捕捉,执行自定义动作,修改flag=1 ,但是while条件依旧满足,进程继续运行!但是很明显flag肯定已经被修改了,但是为何循环依旧执行?很明显, while 循环检查的flag,并不是内存中最新的flag,这就存在了数据⼆异性的问题。while 检测的flag其实已经因为优化,被放在了CPU寄存器当中。在物理内存中修改的flat=1,没有被while检测。说白了就是寄存器覆盖了进程看到变量的真实情况,内存不可见了。
这种情况可以使用volatile解决
volatile int flat = 0;
void handler(int signum)
{
std::cout << "更改全局变量:flat = " << flat << "->1" << std::endl;
flat = 1;
}
int main()
{
signal(2,handler);
while(!flat);
std::cout << "process quit normal!" << std::endl;
return 0;
}
volatile作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作
七、SIGCHLD信号

之前我们说过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第⼀种方式,父进程阻塞了就不能处理自己的工作了;采用第⼆种方式,父进程在处理自己的⼯作的同时还要记得时不时地轮询⼀下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义 SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
void Say(int signum)
{
std::cout << "收到信号:" << signum << std::endl;
}
int main()
{
signal(SIGCHLD,Say);
pid_t id = fork();
if(id == 0)
{
std::cout << "I am child,exit" << std::endl;
sleep(3);
exit(3);
}
waitpid(id,NULL,0);
std::cout << "I am parent,exit" << std::endl;
return 0;
}
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外⼀种办法:父进程调用sigaction将 SIGCHLD的处理动作置为SIG_IGN(原默认是signal(SIGCHLD,SIG_DFL),SIG_DFL指向是lgn(忽略),和直接置为SIG_IGN(忽略)有不同),这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程, 也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是⼀个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
更多推荐



所有评论(0)