序
日常Linux写代码或者使用中难免会使用siganl,包括我们使用ctrl-c结束程序,使用kill命令发送信号,或者说程序core后操作系统向程序发送的信号,以及我们程序内部自定义的信号处理。
我们选择linux0.11一个原因是它比较简单,而且也可以表达出来信号处理的大致原理。
但是信号的处理流程是怎样的呢?这也比较困惑我,去源码学习一下。
首先提出三个疑问:
- 信号的处理函数是否在该程序的线程上运行呢?
- 是在内核态运行还是用户态运行?
- 运行的时机是怎么样呢?是会中断正在运行的程序,还是说执行完某个函数呢?或者其他形式
让我们带着这些问题来继续向下看
示例程序
下边我们展示下示例程序来帮助大家解答:
// sig.cpp
#include <iostream>
#include <csignal>
#include <thread>
#include <boost/stacktrace.hpp>
void loop() {
std::cout << "loop this thread:" << std::this_thread::get_id() << std::endl;
for(;;) {}
}
void doSignal(int sig) {
std::cout << "signal func:" << sig << ", this thread:" <<
std::this_thread::get_id() << std::endl;
std::cout << boost::stacktrace::stacktrace() << std::endl;
}
int main() {
signal(11, doSignal);
loop();
return 0;
}
代码很简单,设定11这个信号值的信号处理函数是doSignal,然后让程序处于死循环,循环中打印该线程的线程id,doSignal中也会答应线程id,及函数调用栈。(这里我们使用boost库来打印函数调用栈)
接下来我们编译运行该程序:
$ gcc sig.cpp -o sig -g -rdynamic
$ ./sig
loop this thread:1
signal func:11, this thread:1
可以看到信号的运行线程和loop的线程是同一个。
然后我们在命令行中向该程序发送11的信号:
$ kill -11 `pidof sig`
然后看下打印的函数调用栈:
0# doSignal(int) in ./sig
1# 0x00007FAAB4C0A090 in /lib/x86_64-linux-gnu/libc.so.6
2# loop() in ./sig
3# main in ./sig
4# __libc_start_main in /lib/x86_64-linux-gnu/libc.so.6
5# _start in ./sig
所以由上可知,信号处理程序是运行在主程序的用户态下的线程中,且会中断我们程序去调用信号处理函数。
运行的线程这个倒是不奇怪了,但是运行在自己线程且还会中断我们正在运行的函数,这一点确实不常见的,我们去linux0.11源码分析下,当然如果不想深入了解到这里也是能够解答上边的疑惑。
源码分析
基本数据结构
union __sigaction_u {
void (*__sa_handler)(int);
void (*__sa_sigaction)(int, struct __siginfo *,
void *);
};
struct sigaction {
union __sigaction_u __sigaction_u; /* signal handler */
sigset_t sa_mask; /* signal mask to apply */
int sa_flags; /* see signal options below */
};
以上的数据结构就是用来存放信号处理的,sigaction
对象存在于表示进程的结构体(task_struct),然后sigaction
中的__sa_handler就是信号处理函数。
捕获信号
注册信号处理函数,也即覆盖默认信号处理操作,我们这里简单起见,使用signal函数来分析,signal
函数对应于sys_signal
,因为调用signal
函数中间会设计libc的库函数,所以sys_signal
参数和signal
的参数略有不同,我们可以忽略:
int sys_signal(int signum, long handler, long restorer)
{
struct sigaction tmp;
tmp.sa_handler = (void (*)(int)) handler;
tmp.sa_mask = 0;
tmp.sa_flags = SA_ONESHOT | SA_NOMASK;
tmp.sa_restorer = (void (*)(void)) restorer; // 保存恢复处理函数指针
handler = (long) current->sigaction[signum-1].sa_handler;
current->sigaction[signum-1] = tmp;
return handler;
}
可以看到我们使用signum
,handler
及restorer
参数首先构造一个sigaction,然后赋值给当前进程(current)。其他的细节可以忽略。
发送信号
我们在命令行中发送信后使用的是kill
指令,同样对应于内核的函数是sys_kill
函数:
int sys_kill(int pid,int sig)
{
struct task_struct **p = NR_TASKS + task;
int err, retval = 0;
// ...
if ((err=send_sig(sig,*p,1)))
retval = err;
// ...
return retval;
}
代码很简单,这里p代码进程数据结构对象,调用send_sig
函数向该进程发送信号sig
。
static inline int send_sig(long sig,struct task_struct * p,int priv)
{
if (priv || (current->euid==p->euid) || suser())
p->signal |= (1<<(sig-1));
else
return -EPERM;
return 0;
}
经过系列判断,最终给p->signal
赋值,表示该进程收到了number为sig
的信号,这里的p->signal
是一个位图,用来标识收到的是哪个信号。
信号处理
首先要知道信号处理的时机,也就是什么时候去处理收到的信号呢,因为信号实时性较高,所以linux内核是在这两种情况下进行信号处理:
- 程序进行系统调用执行后
- 一些中断处理后
这块代码是汇编,所以我们只需要简单了解原理即可:
system_call:
# ...
call sys_call_table(,%eax,4) # 间接调用指定功能C函数
# ...
ret_from_sys_call:
# ...
movl signal(%eax),%ebx
movl blocked(%eax),%ecx
notl %ecx
andl %ebx,%ecx # 获得许可信号位图
bsfl %ecx,%ecx
je 3f # 如果没有信号则向前跳转退出
# ...
pushl %ecx # 信号值入栈作为调用do_signal的参数之一
call do_signal # 调用C函数信号处理程序(kernel/signal.c)
# ...
iret
以上是简单的系统调用的代码,首先会去执行真正的系统调用sys_call_table
,然后系统调用完成后就会到ret_from_sys_call
中,ret_from_sys_call
会调用do_signal函数,在这之前先找到一个要处理的信号数值作为参数传递给do_signal并调用。
同样在一些中断也会调用到这里ret_from_sys_call
:
timer_interrupt:
movl CS(%esp),%eax
andl $3,%eax # %eax is CPL (0 or 3, 0=supervisor)
pushl %eax
call do_timer # 'do_timer(long CPL)' does everything from
addl $4,%esp # task switching to accounting ...
jmp ret_from_sys_call
我们这里关注的是定时器中断执行完成后会执行ret_from_sys_call
,这说明什么呢?定时器中断被调用时回去调用do_timer
函数,进一步又会去调用schedule
函数,也就是进程会被切换。那中断这里返回意味着什么呢,是说该进程被重新调度时会去做信号处理。这里可以花一秒钟思考下。
接下来就去看do_signal函数:
void do_signal(long signr,long eax, long ebx, long ecx, long edx,
long fs, long es, long ds,
long eip, long cs, long eflags,
unsigned long * esp, long ss)
{
unsigned long sa_handler;
long old_eip=eip;
struct sigaction* sa = current->sigaction + signr - 1;
int longs;
unsigned long * tmp_esp;
sa_handler = (unsigned long) sa->sa_handler;
if (sa_handler==1) // 忽略
return;
if (!sa_handler) { // 默认
if (signr==SIGCHLD)
return;
else
do_exit(1<<(signr-1)); // 不再返回到这里
}
*(&eip) = sa_handler;
// 调整用户栈esp
longs = (sa->sa_flags & SA_NOMASK)?7:8;
*(&esp) -= longs;
verify_area(esp,longs*4);
// 在用户堆栈中从下道上存放sa_restorer、信号signr、屏蔽码blocked(如果SA_NOMASK
// 置位)、eax,ecx,edx,eflags和用户程序原代码指针。
tmp_esp=esp;
put_fs_long((long) sa->sa_restorer,tmp_esp++);
put_fs_long(signr,tmp_esp++);
if (!(sa->sa_flags & SA_NOMASK))
put_fs_long(current->blocked,tmp_esp++);
put_fs_long(eax,tmp_esp++);
put_fs_long(ecx,tmp_esp++);
put_fs_long(edx,tmp_esp++);
put_fs_long(eflags,tmp_esp++);
put_fs_long(old_eip,tmp_esp++);
current->blocked |= sa->sa_mask; // 进程阻塞码(屏蔽码)添上sa_mask中的码位。
}
代码稍微有点长,我们打起精神一点点的来分析下。这段代码还是很深奥的,我们知道当执行系统调用或者中断时,会将用户态的程序暂停,这样用户态程序的寄存器就会被压栈,直到返回用户态时恢复。且用户态的寄存器时压到内核栈里,这样在内核执行完后就可以直接恢复。
首先参数很多,我们简单看下参数:
- signr是在调用do_signal前取出来的信号值,被最后压栈
- 调用sys_call_table后压入栈中的相应系统调用处理函数的返回值(eax)
- 后边则是执行系统调用或者中断压栈进来的值
下边时函数内部的执行逻辑;
- 然后获取到指定要处理的信号的
sigaction
的sa_handler,判断处理是被忽略还是默认行为,如果不是就是被用户捕获则继续。 *(&eip) = sa_handler;
是修改内核栈中压入的ip执行的位置为sa_handler,也就是从内核态返回时直接到sa_handler函数执行,而不是之前的位置,那么之前的位置呢,我们继续看。- 接下来就是将用户态栈的空间变大,
put_fs_long
这里则是向用户态的栈空间压入寄存器的值,供sa_handler函数使用。 - 最后将
old_eip
也压入到用户态栈中,也就是说执行完sa_handler就回继续执行系统调用之前的位置的代码。
调用siganl设定信号处理函数时,首先会调用掉系统库的响应函数,然后才会到系统调用那里,也就是说这里的sa_handler应该是系统库的函数,系统库的handler再去调用你自己设定的处理函数,所以调用sa_handler的参数和你自己的处理函数参数有些不同,因为系统库还需要做额外的工作。
以上总结来说就是,do_signal函数会把之前回到用户态要执行位置换成了sa_handler,然后sa_handler执行完之后再继续执行,这样大家就明白了为什么我的死循环明明没有调用任何函数,栈却显示从loop那里调用到doSignal函数。以下是这个流程的图示:
总结
本文我们从例子出发讲述了信号的处理整体流程,虽然内核的代码已经比较老了,但是总体的流程不变。
我们从信号发射,信号捕获,信号处理等方面分析其流程。
感谢大家,点个赞吧~~
ref
《linux内核完全注释》