Linux内核信号处理机制介绍

On October 9, 2010, in linux, linux内核, by sponge

本文简单介绍下Linux信号处理机制,为介绍二进制翻译下信号处理机制做一个铺垫。
本文主要参考书目《Linux内核源代码情景分析》《独辟蹊径品内核:Linux内核源代码导读》
首先,先说一下什么是信号。信号本质上是在软件层次上对中断机制的一种模拟,其主要有以下几种来源:

  1. 程序错误:除零,非法内存访问…
  2. 外部信号:终端Ctrl-C产生SGINT信号,定时器到期产生SIGALRM…
  3. 显式请求:kill函数允许进程发送任何信号给其他进程或进程组。

在Linux下,可以通过以下命令查看系统所有的信号:

kill -l

可以通过类似下面的命令显式的给一个进程发送一个信号:

kill -2 pid

上面的命令将2号信号发送给进程id为pid的进程。不存在编号为0的信号。

目前Linux支持64种信号。信号分为非实时信号(不可靠信号)和实时信号(可靠信号)两种类型,对应于 Linux 的信号值为 1-31 和 34-64。信号是异步的,一个进程不必通过任何操作来等待信号的到达,事实上,进程也不知道信号到底什么时候到达。本文着重于Linux的信号处理机制,对信号更多的介绍可以参考这里

一般情况下一个进程接受到信号后,会有如下的行为:

进程对信号的响应

  1. 忽略信号:大部分信号可被忽略,除SIGSTOP和SIGKILL信号外(这是超级用户杀掉或停掉任意进程的手段)。
  2. 捕获信号:注册信号处理函数,它对产生的特定信号做处理。
  3. 让信号默认动作起作用:unix内核定义的默认动作,有5种情况:
    • a) 流产abort:终止进程并产生core文件。
    • b) 终止stop:终止进程但不生成core文件。
    • c) 忽略:忽略信号。
    • d) 挂起suspend:挂起进程。
    • e) 继续continue:若进程是挂起的,则resume进程,否则忽略此信号。

注册信号处理函数

如果想要进程捕获某个信号,然后作出相应的处理,就需要注册信号处理函数。同中断类似,内核也为每个进程准备了一个信号向量表,信号向量表中记录着每个信号所对应的处理机制,默认情况下是调用默认处理机制。当进程为某个信号注册了信号处理程序后,发生该信号时,内核就会调用注册的函数。

注册信号处理函数是通过系统调用signal()、sigaction()。其中signal()在可靠信号系统调用的基础上实现, 是库函数。它只有两个参数,不支持信号传递信息,主要是用于前32种非实时信号的安装;而sigaction()是较新的函数(由两个系统调用实 现:sys_signal以及sys_rt_sigaction),有三个参数,支持信号传递信息,主要用来与 sigqueue() 系统调用配合使用,当然,sigaction()同样支持非实时信号的安装。sigaction()优于signal()主要体现在支持信号带有参数。关于这方面的内容,如果想获取更多,也可参考这里

Linux下信号处理机制

进程如何发现和接受信号?

我们知道,信号是异步的,一个进程不可能等待信号的到来,也不知道信号会到来,那么,进程是如何发现和接受信号呢?实际上,信号的接收不是由用户进程来完成的,而是由内核代理。当一个进程P2向另一个进程P1发送信号后,内核接受到信号,并将其放在P1的信号队列当中。当P1再次陷入内核态时,会检查信号队列,并根据相应的信号调取相应的信号处理函数。如下图所示:


其中,动作c:发现和捕捉信号

信号检测和响应时机

刚才我们说,当P1再次陷入内核时,会检查信号队列。那么,P1什么时候会再次陷入内核呢?陷入内核后在什么时机会检测信号队列呢?

  1. 当前进程由于系统调用、中断或异常而进入系统空间以后,从系统空间返回到用户空间的前夕。
  2. 当前进程在内核中进入睡眠以后刚被唤醒的时候(必定是在系统调用中),或者由于不可忽略信号的存在而提前返回到用户空间。

进入信号处理函数

发现信号后,根据信号向量,知道了处理函数,那么该如何进入信号处理程序,又该如何返回呢?

我们知道,用户进程提供的信号处理函数是在用户态里的,而我们发现信号,找到信号处理函数的时刻处于内核态中,所以我们需要从内核态跑到用户态去执行信号处理程序,执行完毕后还要返回内核态。这个过程如下图所示:

如图中所见,处理信号的整个过程是这样的:进程由于  系统调用或者中断  进入内核,完成相应任务返回用户空间的前夕,检查信号队列,如果有信号,则根据信号向量表找到信号处理函数,设置好“frame”后,跳到用户态执行信号处理函数。信号处理函数执行完毕后,返回内核态,设置“frame”,再返回到用户态继续执行程序。

在上面这段话中,我提到“frame”,frame是什么?那么为什么要设置frame?为什么在执行完信号处理函数后还要返回内核态呢?

什么叫Frame?

在调用一个子程序时,堆栈要往下(逻辑意义上是往上)伸展,这是因为需要在堆栈中保存子程序的返回地址,还因为子程序往往有局部变量,也要占用堆栈中的空间。此外,调用子程序时的参数也是在堆栈中。子程序调用嵌套越深,则堆栈伸展的层次也越多。在堆栈中的每一个这样的层次,就称为一个”框架”,即frame。

一般来说,当子程序和调用它的程序在同一空间中时,堆栈的伸展,也就是堆栈中框架的建立,过程主要如下:

  1. call指令将返回地址压入堆栈(自动)
  2. 用push指令压入调用参数
  3. 调整堆栈指针来分配局部变量

为什么以及怎么设置frame?

我们知道,当进程陷入内核态的时候,会在堆栈中保存中断现场。因为用户态和内核态是两个运行级别,所以要使用两个不同的栈。当用户进程通过系统调用刚进入内核的时候,CPU会自动在该进程的内核栈上压入下图所示的内容:(图来自《Linux内核完全注释》)

在处理完系统调用以后,就要调用do_signal()函数进行设置frame等工作。这时内核堆栈的状态应该跟下图左半部分类似(系统调用将一些信息压入栈了):

在找到了信号处理函数之后,do_signal函数首先把内核堆栈中存放返回执行点的eip保存为old_eip,然后将eip替换为信号处理函数的地址,然后将内核中保存的“原ESP”(即用户态栈地址)减去一定的值,目的是扩大用户态的栈,然后将内核栈上的内容保存到用户栈上,这个过程就是设置frame.值得注意的是下面两点:

  1. 之所以把EIP的值设置成信号处理函数的地址,是因为一旦进程返回用户态,就要去执行信号处理程序,所以EIP要指向信号处理程序而不是原来应该执行的地址。
  2. 之所以要把frame从内核栈拷贝到用户栈,是因为进程从内核态返回用户态会清理这次调用所用到的内核栈(类似函数调用),内核栈又太小,不能单纯的在栈上保存另一个frame(想象一下嵌套信号处理),而我们需要EAX(系统调用返回值)、EIP这些信息以便执行完信号处理函数后能继续执行程序,所以把它们拷贝到用户态栈以保存起来。

以上这些搞清楚之后,下面的事情就顺利多了。这时进程返回用户空间,就会根据内核栈中的EIP值执行信号处理函数。那么,信号处理程序执行完后,怎么返回程序继续执行呢?

信号处理函数执行完后怎么办?

信号处理程序执行完毕之后,进程会主动调用sigreturn()系统调用再次回到内核,查看有没有其他信号需要处理,如果没有,这时内核就会做一些善后工作,将之前保存的frame恢复到内核栈,恢复eip的值为old_eip,然后返回用户空间,程序就能够继续执行。至此,内核遍完成了一次(或几次)信号处理工作。

欢迎大家进行讨论,如果有问题,可以留言或者发信给我,我将最快时间内答复!

anyShare分享到:

17 Responses to “Linux内核信号处理机制介绍”

  1. 编译点滴 says:

    好文!
    你说程序要保持两个栈,一个在用户态、一个在内核态。每个进程都要有这样两个栈吗?两个栈之间是什么关系?能否详细讲讲?
    我的理解:在系统调用或中断时,因为需要陷入内核态。所以要在内核中保存一些程序运行信息。当从系统调用或者中断中返回时,这些信息用于恢复现场。至于为什么要用栈来保存这些信息,是不是因为可能存在嵌套的中断或者系统调用?

    • sponge says:

      @编译点滴:
      1、每个进程都有这么两个栈。内核负责为每个进程维护其内核栈。其中系统栈的大小是确定的。
      2、你的理解基本正确,陷入内核后,要切换到内核栈,并会把用户栈的ESP,EFLAGS,EIP等信息记录在内核栈中,用户恢复现场。
      3、至于为什么用栈来保存,你理解的是一个方面,嵌套中断是会经常发生的。另一方面就是内核中本身也有函数调用,自然需要栈。

      • 龙湾seo says:

        所以忍不住留言了,当然也算是灌水感谢一下。版主加油!支持你!最近逛了一些博客,很多都很有创意,不过也有些看来看去好多都是相互转载的文章,没有多少是原创的,看多了也眼睛疲劳了,但是版主你这个博客还可以,终于有点新鲜感,瑞安seo

  2. I have been surfing online more than three hours today, yet I never found any interesting article like yours. It is pretty worth enough for me. Personally, if all webmasters and bloggers made good content as you did, the internet will be much more useful than ever before.

  3. RockU says:

    楼主强人!
    我之前一直对这个问题有疑问,看完你的图文并茂的讲解,很快就懂了,谢谢:)

  4. Jinfeng says:

    楼主你好,看了你这篇文章我更加有疑惑了, : - )
    这里有一段程序,我在主程序中进入死循环,这个程序就会一直处于就绪状态(不确定),而且在死循环中不会调用系统调用。那么为什么内核还会处理信号呢?调度程序发现alarm值小于jiffies,就会向进程发送SIGALARM信号,然后清alarm值,难道不是仅仅这些?请帮忙解决一下哈!多谢多谢!
    #include
    #include

    static void alarm_handler(int signo)
    {
    printf("3 seconds end.\n");
    alarm(3);
    }

    int main(void)
    {
    // 该程序仅仅做测试,故无错误检查
    struct sigaction act;
    act.sa_handler = alarm_handler;
    act.sa_flags = 0;
    sigempty(&act.sa_mask);

    sigaction(SIGALRM, &act, NULL);

    alarm(3);
    while(1);

    return 0;
    }

    • Jackwong says:

      除了系统调用,中断也可以打断死循环的执行。CPU在处理完中断切回到进程时,进程获得了从内核态返回到用户态的机会。

  5. owentang says:

    楼主,
    你好,看了你这篇文件非常感触.最近发现一个问题,通过setitimer设置一个定时器每隔5s,发一个alarm信号,但是有一次系统全部用到alarm定时都全部失效.系统重启才恢复正常.这个估计哪里出了问题?

    signal(SIGALRM, alarm_handler);

    timerval.it_interval.tv_sec = INTERVAL;
    timerval.it_interval.tv_usec = 0;
    timerval.it_value.tv_sec = INTERVAL;
    timerval.it_value.tv_usec = 0;

    if (setitimer(ITIMER_REAL, &timerval, NULL) < 0) {
    ERR("%s(%d): setitimer fail\n", __FUNCTION__, __LINE__);
    goto err;
    }

  6. [...] 具体的可参考http://www.spongeliu.com/165.html Linux内核信号处理机制介绍 [...]

  7. [...] http://www.spongeliu.com/165.html 原创文章,作者:Drops,如若转载,请注明出处:http://www.mottoin.com/tech/114696.html 赞 (0) Drops 普通会员 0 0 生成分享图片 扫码分享到微信 企业级无线渗透与无线数据浅析 « 上一篇 4分钟前 [...]

Leave a Reply

Note: Commenter is allowed to use '@User+blank' to automatically notify your reply to other commenter. e.g, if ABC is one of commenter of this post, then write '@ABC '(exclude ') will automatically send your comment to ABC. Using '@all ' to notify all previous commenters. Be sure that the value of User should exactly match with commenter's name (case sensitive).