从程序入手, 信号中断的基本逻辑
下面的程序说明了一个基本的信号处理函数如何建立:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| #define _GNU_SOURCE
#include <signal.h> #include <stdio.h> #include <stdlib.h>
void sighandler(int signo, siginfo_t *info, void *ctx) { printf("处理函数\n"); }
int main() { stack_t s; s.ss_flags = 0; s.ss_size = 40000; s.ss_sp = malloc(40000); sigaltstack(&s, NULL);
struct sigaction action; action.sa_flags = SA_SIGINFO|SA_ONSTACK;
sigfillset(&action.sa_mask); action.sa_sigaction = sighandler; sigaction(SIGINT, &action, NULL);
while (1) { } }
|
此处我们使用了信号自定义栈, 在跳入信号处理函数时, 内核将使用这个栈作为信号处理函数的栈。在main函数中, 有一个无限循环, 如果我们在程序运行时按CTRL+C
, SIGINT
信号将被发送给进程, 并保存在此进程的task_struct
中。内核在调度此进程时, 查到此进程的task_struct
里面有未决
的且未被阻塞的信号, 便向这个自定义栈插入栈帧
(就是向栈顶插入一些信息, 包括一些寄存器信息, 以及原来的栈信息), 并跳入预设的信号处理函数中。在系统设置的栈帧
中包含了足够的寄存器信息, 在信号处理函数中调用sigreturn
, 内核将弹出栈帧
并恢复原来的执行流执行, 此时各种寄存器都会被恢复, 包括栈也会被恢复, 这样就完整恢复了上下文, 回到原来的执行流了。
下面是这个栈帧的示意图。此外, 这里有篇文章, 通过gdb调试探究了栈帧的结构, 值得一读。

这个图片是我从别人的博客上扣下来的, 实际上黄色的那部分是sigaction
设置中的sa_restorer
函数的地址, 在我们使用glibc编程时, 我们其实是没法自定义sa_restorer
的(即使我们设置成自己的, glibc也会用自己设计好的restorer代码, 可见, struct sigaction
的sa_restorer
是冗余字段, 没有任何意义的嘛!), 这个代码很简单, 只是简单的几行汇编, 如下:
1 2 3
| __restore_rt: mov $15, %rax syscall
|
可见, 它只是调用了15号系统调用sigreturn
, 内核会弹出栈帧中的信息, 并用来为进程设置新的上下文, 此外, 栈帧中保存的信号屏蔽字也将被恢复。
这种原子操作很适合做跳转, 它能原子性地设置信号屏蔽字并执行跳转, 是很牛的系统调用, 通过精心设计的栈帧, 它能跳转到任何地方, 并原子地设置信号屏蔽字, 这不就是在用户态模拟内核态的硬件中断吗?
神秘的第三个参数:
我们注意到sa_sigaction
, 也就是sigaction
的信号处理函数签名如下:
1
| void(*) (int, siginfo_t *, void *)
|
其中第一个参数是信号的号码, 比如9代表SIGKILL
。
第二个参数为信号自身附带的信息, 比如我们知道, 一个支持多任务的shell程序能够获悉任务的运行情况, 看下面的测试:
1 2
| markity@mycom ~/D/Blog (main)> cat & markity@mycom ~/D/Blog (main)> fish: Job 1, 'cat &' has stopped
|
既然shell能获得子进程的执行情况, 那么必然是内核提供的某种通知机制。好吧, 其实这个机制的秘密就是这个第二个参数, 里面有SIGCHLD
信号的专属信息(当然其它类型的信号也有专属的额外信息), 比如进程id, 这里不过多展开, 点到为止。
第三个参数, 也是最神秘的参数, 其实它真实的类型不是void *
, 它其实是ucontxt_t *
, 它经过严谨的设计, 它其实就是栈帧的地址。这个参数可以让我们自己修改栈帧, 从而跳转到别的地方去。但如果我们不修改它, 那么栈帧就不会被修改, 最终返回时就会跳转到被中断的原处。下面的程序尝试修改这个结构, 不跳转回原来的地方, 而是跳转到另外的地方。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| #define _GNU_SOURCE
#include <signal.h> #include <stdio.h> #include <stdlib.h> #include <ucontext.h>
void restore_here() { printf("哈哈\n"); }
void sighandler(int signo, siginfo_t *info, void *ctx) { makecontext(ctx, restore_here, 0); }
int main() { stack_t s; s.ss_flags = 0; s.ss_size = 40000; s.ss_sp = malloc(40000); sigaltstack(&s, NULL);
struct sigaction action; action.sa_flags = SA_SIGINFO|SA_ONSTACK;
sigfillset(&action.sa_mask); action.sa_sigaction = sighandler; sigaction(SIGINT, &action, NULL);
while (1) { } }
|
运行时, 按CTRL+C
, 打印哈哈\n
后退出。如果熟悉ucontext
应该能理解这个程序, 如果不太熟悉可以查下ucontext
这个设施, 也可以阅读此篇查看ucontext
怎么使用。
如何重启系统调用?
下面是一段代码, 中断后它总能恢复之前的系统调用, 无论我们按多少次CTRL+C
, 程序也不结束:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| #define _GNU_SOURCE
#include <signal.h> #include <pthread.h> #include <stdio.h> #include <sys/select.h> #include <unistd.h> #include <stdlib.h> #include <ucontext.h>
void sighandler(int signo, siginfo_t *info, void *ctx) { }
int main() { stack_t s; s.ss_flags = 0; s.ss_size = 40000; s.ss_sp = malloc(40000); sigaltstack(&s, NULL);
struct sigaction action; action.sa_flags = SA_SIGINFO|SA_ONSTACK|SA_RESTART;
sigfillset(&action.sa_mask); action.sa_sigaction = sighandler; sigaction(SIGINT, &action, NULL);
getchar();
}
|
相反, 下面这个程序按CTRL+C
就直接结束了:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| #define _GNU_SOURCE
#include <signal.h> #include <pthread.h> #include <stdio.h> #include <sys/select.h> #include <unistd.h> #include <stdlib.h> #include <ucontext.h>
void sighandler(int signo, siginfo_t *info, void *ctx) { }
int main() { stack_t s; s.ss_flags = 0; s.ss_size = 40000; s.ss_sp = malloc(40000); sigaltstack(&s, NULL);
struct sigaction action; action.sa_flags = SA_SIGINFO|SA_ONSTACK|SA_INTERRUPT;
sigfillset(&action.sa_mask); action.sa_sigaction = sighandler; sigaction(SIGINT, &action, NULL);
getchar();
}
|
所以, sa_flags
中的SA_RESTART
可以告知内核, 中断恢复后是否重启系统调用。这个重启的原理是什么呢?
在发生信号中断后, 内核能根据task_struct
的信息判断此进程对某个中断的处理策略, 如果对此信号采取重启系统调用的策略, 那么内核会在设置信号处理函数的栈帧时编辑一下rip
的值, 使之回到syscall
那一行。比如在我的x86_64
架构下, syscall
指令占了两个字节, 那么设置栈帧时, rip
就会被设置为origin_rip-2
, 从而在sigreturn
跳转时, 回到syscall
指令的那一行。从这里可以找到线索。
意义?
了解了sigreturn
系统调用, 我们可以更优雅地了解阻止信号处理函数重入的原理, 从而写出不可能发生重入的平坦化的代码。之前的文章中, 我们用swapcontext
做了上下文切换。然而swapcontext
和setcontext
很拉跨, 它先调用setprocmask
, 再设置了rip
寄存器进行跳转, 在极端情况下, 这就可能重入。然而, 有了sigreturn
, 就能规避这个风险, 那就很完美了!
继续阅读下一篇
信号处理函数是如何返回的(2)?
参考资料