目录
一、信号概念
二、信号捕捉预备知识
三、产生信号
1、通过终端按键
Core Dump 概念
Core Dump 用法
2、系统调用
2.1、kill
2.2、raise
2.3、abort
3、软件条件
4、硬件异常
4.1、除0
4.2、野指针
四、保存信号
1、信号其他相关概念
2、内核中的表示
3、sigset_t
4、信号集操作函数
4.1、sigprocmask
4.2、sigpending
五、捕捉信号
1、内核态与用户态
2、内核实现信号的捕捉
3、sigaction
六、可重入函数
七、volatile
八、SIGCHLD信号
信号是进程之间事件异步通知的一种方式,属于软中断。
查看信号的指令:
kill -l
所有的信号中, 1 ~ 31 是普通信号, 34 ~ 64 是实时信号。
本篇博客只介绍普通信号,不涉及实时信号。
信号的产生对于进程而言是异步的,即OS发送信号的过程与进程运行互不干扰。
普通信号产生之后,不是立即处理的,是在合适的时候再处理的。所以需要把信号保存起来,而在短时间内可能会产生大量的信号,因此需要把这些信号都按照先描述,再组织的原则管理起来。
一般使用位图结构来管理普通信号。所谓的发送信号,本质是写入信号,直接修改特定进程的信号位图中的特定比特位:0->1 。比特位的位置代表信号的编号,比特位的内容代表是否收到该信号。因为这些数据结构都存在于内核之中,所以只能通过系统调用,由OS来修改写入。
信号处理常见方式:
查看信号信息的指令:
man 7 signal
进行信号捕捉的函数:
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signal 函数的参数列表中, signum 为信号编号。 handler 是函数指针。
编写代码:
void handler(int sig)
{
printf("catch a sig : %d\n", sig);
}
int main()
{
signal(2, handler);
while(1)
{
cout << "进程正在运行" << endl;
}
return 0;
}
运行观察结果:
2 号信号,进程的默认处理动作是终止进程。但是通过 signal 函数修改了该信号编号与处理方法的映射关系,使 Ctrl C 被转化的信号 2 由原本的终止进程操作变为了调用函数 handler 。
这叫做执行用户动作的自定义捕捉。当特定信号被发送给当前进程,执行 handler 方法的时候,要自动填充对应的信号给 handler 方法。
OS内部维护了一个函数指针数组,这些指针指向一个个函数。信号编号是数组下标,通过修改数组内指针的指向,就可以更改下标与函数的映射关系。其中, 9 号信号对应的方法无法被修改。
硬件是通过硬件中断的方式通知OS,该硬件已经处于某种状态。硬件通过特定中断电路直接连接到cpu上,当硬件就绪后,直接通知cpu,并产生一个中断号。OS中维护了一个中断向量表,里面包含函数指针,其中中断号作为下标,直接调用对应的方法。例如,键盘对应的中断号调用的方法就是从键盘中读取数据。
用户输入命令,在Shell下启动一个前台进程,之后按下 Ctrl C ,键盘输入产生一个硬件中断,被OS获取,解释成信号,发送给目标前台进程前台进程因为收到信号,进而引起进程退出。
注意:
Linux 系统提供了一种能力:在一个进程在异常的时候,OS可以将该进程的核心代码部分进行核心转储,将内存中进程的相关数据,全部 dump 到磁盘中,一般会在当前进程的运行目录下,形成 core.pid 这样的二进制文件。这种文件叫做核心转储文件。
云服务器是默认关闭核心转储的功能的。
查看系统中特定资源的上限的指令:
ulimit -a
核心转储文件的大小默认被设置为 0 ,这代表系统不允许在当前目录下形成 core 文件。
将核心转储打开:
ulimit -c [指定大小]
下面我们再使用 man 7 signal 指令查看进程的信息:
发现在 Action 这一栏中,不同的信号行为不同:
可以看到进程结束时,自动形成了 core.4841 核心转储文件。
核心转储是为了方便异常后,进行调试存在的,需要配合 gdb 调试器进行使用。
编写代码:
int main()
{
cout << "hello world" << endl;
cout << "hello world" << endl;
int* p = nullptr;
*p = 100;
cout << "hello core" << endl;
cout << "hello core" << endl;
return 0;
}
编译运行,程序肯定崩溃,并生成核心转储文件:
使用 gdb 进行调试:
在 gdb 调试器中输入指令:
core-file [core.pid]
即可直接打印出进程终止的原因、报错代码的位置等信息,不需要我们自己定位问题了。这种调试方式称为事后调试。
通过以上的操作,我们知道核心转储文件是一个非常好用的东西。那么为什么在云服务器上,默认是把 core dump 关闭呢?
这是因为 core 文件通常都很大。并且在云服务器中,一个进程挂掉,需要立刻被云服务器中的检测程序发现,并及时重启。这样就会导致同一个程序有可能在一秒钟内挂掉了成千次,并又被重启了上千次,每一次进程挂掉都会生成一个 core 文件,进而导致硬盘占满,整个服务器崩溃。
关闭 core dump 的指令:
ulimit -c 0
在《进程控制》 中,曾经有一个遗留问题,那就是进程退出码中的 core dump 标志位是什么意思:
现在就可以进行解答,当系统生成 core 文件时,标志位就被置 1 ,否则被置 0 。
int kill(pid_t pid, int sig);
kill 函数的参数列表中, pid 为进程pid。 sig 为信号编号。
用法如下:
void Usage(string proc)
{
cout << "Usage:\n\t";
cout << proc << " 信号编号 目标进程\n" << endl;
}
//./mykill 9 1234
int main(int argc, char* argv[])
{
if(argc != 3)
{
Usage(argv[0]);
exit(1);
}
int signo = atoi(argv[1]);
int target_id = atoi(argv[2]);
int n = kill(target_id, signo);
if(n != 0)
{
cerr << errno << " : " << strerror(errno) << endl;
exit(2);
}
return 0;
}
int raise(int sig);
谁调用 raise 函数,就给谁发信号。
编写代码:
void myhandler(int signo)
{
cout << "get a signal: " << signo << endl;
}
int main(int argc, char* argv[])
{
signal(SIGINT, myhandler);
while(1)
{
sleep(1);
raise(2);
}
}
编译运行:
void abort(void);
谁调用 abort 函数,就给谁发送 SIGABRT 信号。
编写代码:
void myhandler(int signo)
{
cout << "get a signal: " << signo << endl;
}
int main(int argc, char* argv[])
{
signal(SIGABRT, myhandler);
while(1)
{
cout << "begin " << endl;
sleep(1);
abort();
cout << "end " << endl;
}
}
编译运行:
发现程序并没有循环起来,这是因为只要进程执行了 abort 函数,那么函数执行结束后,进程一定要退出。
SIGPIPE 是一种由软件条件产生的信号,在《管道》中已经介绍过了。下面主要介绍 alarm 函数和 SIGALRM 信号。
unsigned int alarm(unsigned int seconds);
调用 alarm 函数可以设定一个闹钟,也就是告诉内核在 seconds 秒之后给当前进程发 14:SIGALRM 信号,该信号的默认处理动作是终止当前进程。
这个函数的返回值是 0 或者是以前设定的闹钟时间还余下的秒数。
编写代码:
int count = 0;
void myhandler(int signo)
{
cout << "get a signal: " << signo << " count: " << count << endl;
exit(0);
}
int main(int argc, char* argv[])
{
signal(SIGALRM, myhandler);
alarm(1);
while(1) count++;
}
运行观察结果:
闹钟默认是一次性的,如果想要多次使用,则可以使用自举的方式:
运行:
如果在闹钟时间还没到时,再使用指令 kill -14 [该进程pid] 设置闹钟,则会重置闹钟,并返回上一个闹钟的剩余秒数。
取消闹钟只需要把 seconds 设置为 0 即可。
硬件异常被硬件以某种方式被硬件检测到并通知内核,然后内核向当前进程发送适当的信号。例如当前进程执行了除以 0 的指令,CPU的运算单元(状态寄存器)会产生异常(被置 1 ),OS内核将这个异常解释为 8:SIGFPE 信号发送给进程,从而结束进程(观察到程序崩溃)。
当前进程访问了非法内存地址,MMU会产生异常,内核将这个异常解释为 11:SIGSEGV 信号发送给进程(观察到程序崩溃)。
例如下面的代码:
int* p = nullptr;
*p = 100;
执行 *p = 100 时,第一步并不是直接写入,而是首先进行虚拟地址到物理地址的转换:
信号在内核中的表示示意图:
我们可以使用 signal 函数来设置递达方式,在信号捕捉中讲过这个函数。 signal 函数的第二个参数还可以设置成如下三种:
从上图来看,每个信号只有一个 bit 的未决标志,非0即1,不记录该信号产生了多少次。阻塞标志也是这样表示的。因此,未决和阻塞标志可以用相同的数据类型 sigset_t 来存储, sigset_t 称为信号集,这个类型可以表示每个信号的“有效”或“无效”状态,在阻塞信号集中“有效”和“无效”的含义是该信号是否被阻塞,而在未决信号集中“有效”和“无效”的含义是该信号是否处于未决状态。阻塞信号集也叫做当前进程的信号屏蔽字(Signal Mask),这里的“屏蔽”应该理解为阻塞而不是忽略。
#include
int sigemptyset(sigset_t *set); //把位图中所有bit位清0
int sigfillset(sigset_t *set); //把位图中所有bit位置1
int sigaddset (sigset_t *set, int signo); //把一个信号在位图中置1
int sigdelset(sigset_t *set, int signo); //把一个信号在位图中置0
int sigismember(const sigset_t *set, int signo); //判断一个信号是否在位图里
这四个函数都是成功返回0,出错返回-1。sigismember是一个布尔函数,用于判断一个信号集的有效信号中是否包含某种信号,若包含则返回1,不包含则返回0,出错返回-1。
调用函数 sigprocmask 可以读取或更改进程的信号屏蔽字(阻塞信号集)。
#include
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
sigprocmask 函数的参数列表中, how 是选项,表示想怎么改。 set 是一个新的信号集。 oset 是输出型参数,在修改之前先把老的 block 通过这个参数返回。返回值:若成功则为 0 ,若出错则为 -1 。
how 的选项如下:
编写代码:
void showBlock(sigset_t* oset)
{
int signo = 1;
for(; signo <= 31; signo++)
{
if(sigismember(oset, signo)) cout << "1";
else cout << "0";
}
cout << endl;
}
int main()
{
//在用户层面上进行设置
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2);
//设置进入进程,谁调用,设置谁
sigprocmask(SIG_SETMASK, &set, &oset); //1、2号信号没有反应
//2、老的block位图应该是全0
int cnt = 0;
while(1)
{
showBlock(&oset);
sleep(1);
cnt++;
if(cnt >= 5)
{
sigprocmask(SIG_SETMASK, &oset, &set);
showBlock(&set);
}
}
return 0;
}
观察现象:
程序运行的前 5 秒,信号 2 没有任何作用, 5 秒之后,信号 2 恢复功能。
#include
int sigpending(sigset_t *set);
读取当前进程的未决信号集,通过 set 参数传出。调用成功则返回0,出错则返回-1。
编写代码:
static void printPending(sigset_t& pending)
{
for(int signo = 1; signo < 32; ++signo)
{
if(sigismember(&pending, signo)) cout << "1";
else cout << "0";
}
cout << endl;
}
int main()
{
//1、屏蔽2号信号
sigset_t set, oset;
sigemptyset(&set);
sigemptyset(&oset);
sigaddset(&set, 2);
sigprocmask(SIG_SETMASK, &set, &oset);
//2、while获取进程的pending信号集合,并01打印
int cnt = 0;
while(1)
{
//获取pending信号集
sigset_t pending;
sigemptyset(&pending);
int n = sigpending(&pending);
assert(n == 0);
(void)n;
printPending(pending);
sleep(1);
//5秒钟之后,解除阻塞
if(cnt++ == 5)
{
sigemptyset(&set);
sigprocmask(SIG_SETMASK, &set, &oset);
}
}
}
观察结果:
未发送信号 2 前,pending信号集是全 0 。发送信号 2 后,第 2 个bit位被置 1 。 5 秒钟之后,阻塞被解除,进程执行信号 2 的默认动作,退出进程。
补充内容:如果一个信号之前被 block ,当他解除 block 的时候,对应的信号会被立即递达。
之前说过,信号的产生是异步的。当一个信号产生时,进程可能正在做更重要的事情,无法及时处理,因此需要先保存信号。
当进程从内核态切换回用户态的时候,进程会在OS的指导下,进行信号的检测与处理。
在《进程地址空间》中,我们讲过,进程使用的是虚拟地址空间,并通过页表映射到物理内存中。以 4G 内存为例,用户空间占据其中 [0,3]G 的空间。之前所说的页表是用户级页表,除此之外,还有一张内核级页表。内核级页表需要将操作系统中的代码和数据全部与物理内存进行映射,以便找到OS所有的代码和数据,内核空间占据内存中 [3,4]G 的空间。
因为内核空间也在进程的虚拟地址空间中,为了防止进程任意访问OS的数据和代码,就定义出了两种状态:用户态、内核态。
CPU中有一个CR3寄存器。当寄存器内标志位是 3 时,表征进程执行级别是用户态。标志位是 0 时,表征进程执行级别是内核态。 当进程想要跳转访问 [3,4] 的内容时,CPU会检测当前进程的标志位,如果不是内核态,则会进行拦截,设置状态标志位为非法访问,进而发送信号结束进程。OS提供的系统调用内部,在正式执行调用逻辑之前,会先去修改执行级别为内核态。
进程被调度的方式:
当OS处理信号,并且信号执行的是自定义动作时,并不是以内核态执行的。OS在内核态下,有权利执行用户的代码,但是一般不这样做。因为以内核态执行自定义方法,如果这个自定义方法中有一些非法操作,可能会造成危害。
信号捕捉的流程如下:
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。
由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:用户程序注册了SIGQUIT信号的处理函数 sighandler 。 当前正在执行main函数,这时发生中断或异常切换到内核态。 在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。 内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。在执行sighandler函数之前,先把pending对应bit位置 0 。sighandler函数返回后自动执行特殊的系统调用 sigreturn 再次进入内核态。 如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
sigaction 函数可以读取和修改与指定信号相关联的处理动作。调用成功则返回 0 ,出错则返回 -1。 signo 是指定信号的编号。若 act 指针非空,则根据 act 修改该信号的处理动作。若 oldact 指针非空,则通过 oldact 传出该信号原来的处理动作。act和oldact指向sigaction结构体。
结构体 sigaction 的成员如下:
将 sa_handler 赋值为常数 SIG_IGN 传给 sigaction 表示忽略信号,赋值为常数 SIG_DFL 表示执行系统默认动作,赋值为一个函数指针表示用自定义函数捕捉信号,或者说向内核注册了一个信号处理函数,该函数返回值为void,可以带一个int参数,通过参数可以得知当前信号的编号,这样就可以用同一个函数处理多种信号。显然,这也是一个回调函数,不是被main函数调用,而是被系统所调用。
当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。 如果在调用信号处理函数时,除了当前信号被自动屏蔽之外,还希望自动屏蔽另外一些信号,则用 sa_mask 字段说明这些需要额外屏蔽的信号,当信号处理函数返回时自动恢复原来的信号屏蔽字。 sa_flags 字段包含一些选项,本章的代码都把 sa_flags 设为 0 , sa_sigaction 是实时信号的处理函数,本章不详细解释这两个字段。
编写代码:
static void printPending(sigset_t& pending)
{
for(int signo = 1; signo < 32; ++signo)
{
if(sigismember(&pending, signo)) cout << "1";
else cout << "0";
}
cout << endl;
}
void handler(int signo)
{
cout << "get a signal: " << signo << endl;
int cnt = 30;
while(cnt--)
{
sigset_t pending;
sigemptyset(&pending);
sigpending(&pending);
printPending(pending);
sleep(1);
}
}
int main()
{
struct sigaction act, oldact;
memset(&act, 0, sizeof(act));
memset(&oldact, 0, sizeof(act));
act.sa_handler = handler;
act.sa_flags = 0;
sigemptyset(&act.sa_mask);
sigaddset(&act.sa_mask, 3);
sigaddset(&act.sa_mask, 4);
sigaddset(&act.sa_mask, 5);
sigaction(2, &act, &oldact);
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函数访问一个全局链表,有可能因为重入而造成错乱,像这样的函数称为 不可重入函数。反之,如果一个函数只访问自己的局部变量或参数,则称为可重入(Reentrant) 函数。
如果一个函数符合以下条件之一则是不可重入的:
编写代码:
int flag = 0;
void handler(int signo)
{
printf("change flag from 0 to 1\n");
flag = 1;
}
int main()
{
signal(2, handler);
while(!flag);
printf("main flag 正常\n");
return 0;
}
标准情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 , while 条件不满足,退出循环,进程退出:
现在使用 gcc 的编译优化,再来重新编译这段代码:
g++ -o $@ $^ -std=c++11 -O2
优化情况下,键入 CTRL-C ,2号信号被捕捉,执行自定义动作,修改 flag=1 ,但是 while 条件依旧满足,进程继续运行。
执行完函数后, flag 肯定已经被修改了,为何循环依旧执行?很明显, while 循环检查的flag,并不是内存中最新的flag,这就存在了数据二义性的问题。 while 检测的 flag 其实已经因为优化,被放在了 CPU 寄存器当中。
由于判断flag是一种计算,所有的计算都在CPU中进行。所以CPU会把flag的值加载到寄存器中,并且由于在程序中,flag 的值仅仅只用于判断,没有被更改。所以在编译器优化后,CPU进行判断就不必每次都到内存中重新读取flag的值了,直接使用寄存器中的值进行判断。这就导致,我们使用信号更改内存中 flag 的值后,CPU对应寄存器中的值没有被更新。从而循环条件一直满足,PC指针无法继续向下面的代码移动。
为了解决内存位置不可见的问题,就需要告诉编译器,保证每次检测都要尝试从内存中进行数据读取,不要使用寄存器中的数据。使用关键字 volatile 来实现。
volatile 作用:保持内存的可见性,告知编译器,被该关键字修饰的变量,不允许被优化,对该变量的任何操作,都必须在真实的内存中进行操作。
再次编译运行:
此时,结果满足预期。
补充内容:
进程一章讲过用wait和waitpid函数清理僵尸进程,父进程可以阻塞等待子进程结束,也可以非阻塞地查询是否有子进程结束等待清理(也就是轮询的方式)。采用第一种方式,父进程阻塞了就不能处理自己的工作了,采用第二种方式,父进程在处理自己的工作的同时还要记得时不时地轮询一 下,程序实现复杂。
其实,子进程在终止时会给父进程发SIGCHLD信号,该信号的默认处理动作是忽略,父进程可以自定义SIGCHLD信号的处理函数,这样父进程只需专心处理自己的工作,不必关心子进程了,子进程终止时会通知父进程,父进程在信号处理函数中调用wait清理子进程即可。
基于信号的回收子进程,实现代码:
pid_t id;
void handler(int signo)
{
printf("捕捉到一个信号:%d, 进程pid: %d\n", signo, getpid());
sleep(5);
//10个进程,一部分退出,一部分没退
while(1)
{
pid_t res = waitpid(-1, NULL, WNOHANG); //使用非阻塞等待
if(res > 0)
{
printf("wait success, res: %d, id: %d\n", res, id);
}
else break; //如果没有子进程了,就退出。
}
printf("handler done..\n");
}
int main()
{
signal(SIGCHLD, handler);
//一次创建10个子进程
for(int i = 0; i <= 10; ++i)
{
id = fork();
if(id == 0)
{
int cnt = 5;
while(cnt)
{
printf("子进程, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(1);
}
}
while(1)
{
//处理别的事
sleep(1);
}
return 0;
}
事实上,由于UNIX的历史原因,要想不产生僵尸进程还有另外一种办法:父进程调用sigaction将SIGCHLD的处理动作置为SIG_IGN,这样fork出来的子进程在终止时会自动清理掉,不会产生僵尸进程,也不会通知父进程。系统默认的忽略动作和用户用sigaction函数自定义的忽略通常是没有区别的,但这是一个特例。此方法对于Linux可用,但不保证在其它UNIX系统上都可用。
实现代码:
int main()
{
//signal(SIGCHLD, handler);
signal(SIGCHLD, SIG_IGN); //特例:这里用户设定为SIG_IGN,与系统默认不同
for(int i = 0; i <= 10; ++i)
{
id = fork();
if(id == 0)
{
int cnt = 5;
while(cnt)
{
printf("子进程, pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
cnt--;
}
exit(1);
}
}
while(1)
{
sleep(1);
}
return 0;
}
当父进程检测到代码 SIGCHLD 与 SIG_IGN 的组合时,除了设置该信号为忽略外,还会设置当前进程PCB的状态位,并被子进程继承。当子进程退出时,会检测标志位,如果标志位被设置,则子进程自动被OS回收。
关于进程信号的相关内容就讲到这里,希望同学们多多支持,如果有不对的地方欢迎大佬指正,谢谢!