015-Linux进程信号
Linux信号机制摘要 Linux信号是系统向进程发送特定事件的异步通知机制,主要用于进程间通信和控制。信号分为普通信号(1-31)和实时信号(34-64),可通过kill -l查看所有信号。信号处理方式包括默认动作、忽略和自定义捕捉(signal函数)。信号产生方式有系统调用(kill/raise/abort)、软件条件(alarm)和硬件异常(如除零错误)。核心转储功能可生成调试文件辅助问题定
Linux进程信号
1. 信号的概念
1.1 查看信号
使用kill -l可以查看所有的信号。

其中,131号信号时普通信号,32、33信号不存在,3464号信号是实时信号,我们这里的学习主要是普通信号。
1.2 概念
信号:Linux系统提供的一种,向指定进程发送特定事件的方式,收到信号的进程要做识别和处理
信号的产生是异步的:一个进程在执行的过程中,不知道什么时候能收到信号,信号的发送和进程的执行是两条线,这就是异步。
1.3 信号处理的常见方式
-
默认动作:可以通过man 7 signal查看信号的默认动作

-
忽略动作:忽略信号,不做处理
-
自定义处理—信号的捕捉:不想收到信号后执行默认的动作,而是执行自定义的处理,这就是对信号的捕捉
#include <signal.h> typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);作用:捕捉信号,只需要执行一次,从此以后,只要接收到这个信号,就会一直被捕捉。
参数:
- signum:指定的信号
- sighandler_t:函数指针,指向捕捉到信号以后,需要执行的动作,其中int类型的参数为收到的信号,也可以使用宏SIG_IGN和SIG_DFL,分别代表忽略信号和信号的默认处理动作。
返回值:
- 成功返回信号处理程序的先前值
- 失败返回SIG_ERR,错误码被设置
测试代码:
#include <iostream> #include <signal.h> #include <unistd.h> void hander (int sig) { std::cout << "get a sig: " << sig << std::endl; } int main() { signal(1, hander); signal(2, hander); signal(3, hander); while (true) { std::cout << "hello world, pid: " << getpid() << std::endl; sleep(1); } return 0; }
在上面的代码中,无法在终端中使用ctrl+c将这个进程杀掉,因为ctrl+c就是向终端中正在运行的进程发送2号信号,此时如果使用ctrl+c,发出的2号信号会被捕捉。
补充:
-
ctrl+\的作用是向进程发送3号信号,作用也是杀死进程
-
1~31号信号中,6号信号虽然可以被捕捉,但是执行完后,进程还是会被杀死
-
1~31号信号中,9号信号不允许被捕捉,即使执行了signal,9号信号依旧不会被捕捉
1.4 信号的发送和保存
在进程的task_struct中,存在一个uint32_t类型的整数(无符号32位整数),这个整数是一张位图,初始时位0,每当系统向这个进程发送信号,就是将对应位图的位置从0修改成1。
task_struct是在内核中的,只有OS才能对内核中的数据进行修改,所以无论以什么方式发送信号,本质上都是通知OS,然后让OS发送信号。
2. 信号的产生
2.1 发送信号的系统调用
2.1.1 kill
#include <signal.h>
int kill(pid_t pid, int sig);
功能:向指定进程发送指定信号
参数:
- pid:指定进程id
- sig:指定信号
返回值:
- 成功返回0
- 失败返回-1
2.1.2 raise
#include <signal.h>
int raise(int sig);
功能:给当前进程发送指定信号
参数:
- sig:指定信号
返回值:
- 成功返回0
- 失败返回非0
2.1.3 abort
#include <stdlib.h>
void abort(void);
功能:给当前进程发送6号信号,作用是终止当前进程
注意:6号信号可以被捕捉,但是执行完自定义的行为后,该进程还是会被终止。
2.2 由软件条件产生信号
#include <unistd.h>
unsigned int alarm(unsigned int seconds);
功能:未来的seconds秒后,对当前进程发送14号信号。
参数:
- seconds:指定的秒数,如果设置0,代表取消警报
返回值:
- 返回先前设定的警报时间到期的时间剩余的秒数
- 如果之前没有设定警报,返回0
2.3 硬件异常产生信号
比如除0错误/解引用空指针,因为当这个数据传到硬件中时,被硬件判断出执行的话会出现异常,把异常反馈给OS后,OS发送信号给进程,进程被杀死,除0错误将会发送8号信号,空指针解引用将会发送11号信号。
2.4 核心转储
对于man 7 signal手册中action栏的内容中Term和Core虽然都代表终止进程,但是是有区别的,Term终止异常进程,Core不仅终止进程,还会形成一个debug文件(进程退出的时候的镜像数据),方便我们调试,但是这个功能默认是被关闭的,可以通过ulimit -a查看:

其中core file size这一行为0,代表这个功能默认被关闭了,可以使用ulimit -c unlimited打开:

这里我们可以使用除零错误的代码来测试一下,除零以后,该进程会收到SIGFPE信号,也就是8号信号:
int main ()
{
int a = 10 / 0;
return 0;
}

此时当前目录还是没有生成core文件,可以通过sudo bash -c "echo core > /proc/sys/kernel/core_pattern "来让core文件生成到当前目录:

此时生成了对应的文件。
而之前在进程控制中,waitpid得到的status中,有一个core dump标志,就是这个地方使用的,如果core dump标志被置为1,就说明生成了core文件。

有了这个code文件以后,我们可以使用gdb调试工具打开可执行程序,然后直接输入core-file core,就可以直接跳转到程序出问题的地方:

这种方式就可以比我们自己一行一行去debug方便一些。
3. 信号的保存(信号阻塞)
3.1 相关概念
- 信号递达:实际执行信号的处理动作
- 信号未决:信号从产生到递达之间的过程
- 阻塞信号:进程可以选择阻塞一些信号,当这个信号产生后,永不递达,一直处在未决状态,直到进程解除对该信号的阻塞
3.2 信号在内核中的表示
在task_struct中,维护了三张表,分别是block、pending、handler,其中block和pending表本质上是两张位图,block维护有哪些信号被阻塞,pending表维护有哪些信号未决,handler表是一张函数指针表,指向相应信号的处理函数。
3.3 sigset_t类型
这个类型用于存储block、pending这两张表,本质上是一个信号集。
block的这张表,也就是阻塞信号集,也被称为信号屏蔽字。
3.4 信号集操作函数
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);
int sigprocmask(int how, const sigset_t *set, sigset_t *oset);
int sigpending(sigset_t *set);
- sigemptyset:初始化set指向的信号集,将全部信号置0,代表目前不含任何信号
- sigfillset:初始化set指向的信号集,将全部信号置1,代表目前包含所有信号
- sigaddset:添加指定信号
- sigdelset:删除指定信号
- sigismember:判断是否包含某种信号
-
sigprocmask:读取或更改信号屏蔽字
-
参数:
-
how:有三个选项:

-
set:输入型参数,一个信号集,根据how和set来修改当前的屏蔽字
-
oset:输出型参数,返回修改前的屏蔽字
-
-
-
sigpending:获取当前进程的pending位图。
4. 信号的处理
4.1 捕捉信号

上面这张图是信号捕捉的过程,上面的框是用户态、下面的框是内核态,进程接收到信号后可能不会立即处理,而是等到合适的时候处理,这个合适的时候就是在内核态返回用户态的时候。
4.2 内核态/用户态
4.2.1 再谈地址空间

对于进程的地址空间,之前学习的都是上图中下半部分的用户空间,而内核空间是干什么用的?
回顾用户空间,用户空间会通过一张页表来映射到自己的代码和数据以及第三方库,这里的页表我们称之为用户级页表。
在我们计算机启动的时候,第一个被加载到内存中的软件就是OS,随之产生的还有另一张页表,我们称之为内核级页表。
对于整个系统中,每个进程都有自己的用户级页表,但是内核级页表只有一张,而进程地址空间中,内核空间,就是通过内核级页表映射到OS,而具体而言,内核空间中存放的就是一个一个的系统调用的虚拟地址,这些虚拟地址可以通过内核级页表映射到OS中来执行相应的系统调用。
OS不相信任何人,进程要访问内核空间时要收到一定约束,进程只可以通过系统调用来访问OS,而不能直接接触到OS的数据。
4.2.2 键盘的输入过程
我们通过键盘输入的时候,时间是随机的,OS是怎么知道键盘什么时候被按下?
键盘是一个外设,对于所有外设都可以给CPU发送一个消息,我们称之为硬件中断,而每个外设都有自己的中断号,键盘也一样,当键盘输入的时候,键盘就会给CPU发送一个硬件中断,把自己的终端号放到CPU中,这样CPU就能够读到这个中断号。
在OS中,维护着一个函数指针数组(中断向量表),这个函数指针数组分别指向执行不同硬件的函数,而所谓的中断号,是被精心设计过的,这个中断号就对应着要执行的函数的下标,也就是说,假设键盘的中断号是3,键盘输入的时候,将3放在CPU的一个寄存器中,CPU读到了这个信号,马上停止手中的其他工作,然后在操作系统维护的这个中断向量表中找到对应的函数,然后执行。
而这个过程似曾相识,和上面说到的信号的机制类似,不过信号是纯软件,而中断是硬件+软件,信号就是模仿中断的过程来设计的。
4.2.3 OS如何正常运行
4.2.3.1 理解系统调用
在OS中有一个函数指针数组,里面存着所有的系统调用,我们只要找到这个数组中对应的数组下标,就能执行系统调用,而这个下标,就是系统调用号。
执行任何系统调用都需要:
- 系统调用号
- 系统调用函数指针表
而CPU是如何执行系统调用的,首先执行系统调用之前,会将对应的系统调用好放在CPU的指定寄存器中,然后CPU形成中断,此时CPU就知道需要执行系统调用了,此时CPU拿到寄存器里的系统调用号,直接就去函数指针表中找到相应的系统调用,然后执行。
这里的中断是CPU内部自己形成的中断,也被称为陷阱/缺陷。
4.2.3.2 OS是如何运行的
OS在我们开机以后就一直在运行,直到电脑关机,这意味着OS本质上就是一个死循环,一直在运行。
而在我们的计算机中,有一个叫做时钟的硬件,一直以很短的时间间隔在给CPU发送中断,而CPU接收到这个中断以后,就意味着当前调度的工作时间片已经结束了,然后去调度下一个任务,而CPU执行的任务就是OS在开机时初始化好的一个个方法,比如系统调用等,而OS在初始化工作完成后,就一直循环,等待CPU的调度。
4.2.4 内核态和用户态
由于OS不相信用户,在用户跳到内核空间执行系统调用的时候,是无法直接跳转过去的。如何做到不让用户直接跳转过去的?
用户必须在特定的条件下才可以跳转到内核空间。如何做到在特定的条件下才能跳转的?
要实现上面两种效果,需要硬件(CPU)的配合。
在CPU中有一个寄存器,其中有两个比特位,这两个比特位如果是0,说明CPU可以执行内核的代码,如果是3,说明CPU只能执行用户的代码。
而用户态和内核态之间的切换就是这两个值的转变。
4.3 信号捕捉的方法
信号捕捉除了使用signal系统调用,还可以使用sigaction。
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oact);
参数:
- signo:信号编号
- act:输入型参数,输入一个结构体,里面记录着我们的自定义函数
- oact:输出型参数,输出改变前的信息,方便后续恢复
struct 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);
};
对于捕捉信号,此时如果正在对一个信号进行处理,那么此时会对这个信号进行屏蔽,当执行完这个信号才会解除屏蔽,这意味着不允许同一个信号进行递归式的处理。
但是假设此时捕捉的是2号信号,虽然2号信号会被屏蔽,但是其他信号还是可以被继续捕捉,如果不想其他信号在执行当前信号的时候被捕捉,就可以通过sa_mask传入指定的信号集,让信号集中的信号也被屏蔽。
此时,同样的,也有部分信号不能被屏蔽,比如9号信号。
5. 可重入函数
现在来看一种场景:

这段代码是链表的一个头插,就上面的代码而言,头插的过程是需要执行两行代码的,此时如果在进行头插的时候,收到了一个信号,然后当它执行完第一行代码的时候,恰好时间片到了,此时由用户态切换为内核态,下次被调度的时候,需要将内核态切换成用户态,此时首先判断是否有未阻塞并且未决的信号,然后发现存在一个信号,然后执行这个信号指定的函数,如果这个信号自定义的函数中,又调用了一个头插,那么就会造成一种情况,如上图,会导致一系列的问题,导致系统的错乱。
像上面这种,一个函数还没有返回的时候,又被进入,这个行为被称为重入。
当一个函数被重入后,会导致一些错误的,这种函数被称为不可重入函数。
相反,如果一个函数被重入后,不会导致问题的发生,这种函数就被称为可重入函数。
简单判断:如果一个函数只使用了自己函数内部的局部变量和资源,一般而言,这种函数大都是可重入的,反之,如果一个函数使用了函数外部的变量和资源,那么这个函数大都是不可重入的。
6. volatile
先看一段代码:
#include <iostream>
#include <signal.h>
bool gflag = false;
void handler(int sig)
{
std::cout << "get a signo: " << sig << std::endl;
gflag = true;
}
int main()
{
signal(2, handler);
while (!gflag);
std::cout << "process quit normal" << std::endl;
return 0;
}

在这段代码中,就是让main函数一直死循环,直到接收到2号信号后,再退出,执行的结果也和我们预测的结果一样。
但是在现在的编译器中,很多编译器会对我们的代码进行一些优化,对于上面的代码,编译器发现,gflag的值在main函数中不会发生任何改变,那么此时编译器就会让刚开始读取到的gflag一直放在寄存器中,不去内存中读取更新新的gflag,我们以优化的方式来编译执行就可以看到:

当我们使用O1优化选项进行编译的时候,就发现,此时无论发送多少个2号信号都无法让进程停止,但是从打印出来的信息来看,我们的handler函数确实也执行了,这也就应证了我们上面的说法,这也就是编译器过度优化导致的问题。
而volatile关键字修饰的作用就是,不允许编译器对这个变量做优化,每次使用的使用都必须重新从内存中读取(保持内存的可见性),修改一下代码:
#include <iostream>
#include <signal.h>
volatile bool gflag = false;
void handler(int sig)
{
std::cout << "get a signo: " << sig << std::endl;
gflag = true;
}
int main()
{
signal(2, handler);
while (!gflag);
std::cout << "process quit normal" << std::endl;
return 0;
}

此时,就不会出现上面的问题了。
7. SIGCHLD信号 - 重谈进程等待
子进程退出的时候,其实并不是静悄悄的退出的,在子进程退出的时候会给父进程发送SIGCHLD信号(17号信号)。
在之前,我们的父进程等待子进程都只能主动的去调用waitpid去等待子进程,而现在我们就可以做一件事:我们对于17号信号进行自定义,在自定义的handler函数中去等待子进程的退出,此时父进程就不必主动的去调用waitpid,可以干自己的事情,每当子进程退出,收到信号以后,它就会自动的去调用这个handler函数,去等待子进程。
但是这里还是会存在一些问题,如果有多个子进程同时退出,这些子进程同时向父进程发送信号,那么父进程可能只收到一次信号,就只等待一次,为了解决这个问题,我们可以在等待的时候,循环的进行等待,直到等待完所有的子进程(等待失败),再跳出循环。
实际上,如果父进程不在乎子进程的退出信息,还有一种方式,就是使用sigaction手动的将17号信号设置为SIG_IGN,这样fork出来的子进程会在终止时自动清理掉,不会产生僵尸进程,也不会通知父进程,此方法对于Linux可用,其它系统不一定可用。
更多推荐


所有评论(0)