linux0.11源码看信号的处理流程

日常Linux写代码或者使用中难免会使用siganl,包括我们使用ctrl-c结束程序,使用kill命令发送信号,或者说程序core后操作系统向程序发送的信号,以及我们程序内部自定义的信号处理。

我们选择linux0.11一个原因是它比较简单,而且也可以表达出来信号处理的大致原理。

但是信号的处理流程是怎样的呢?这也比较困惑我,去源码学习一下。

首先提出三个疑问:

  1. 信号的处理函数是否在该程序的线程上运行呢?
  2. 是在内核态运行还是用户态运行?
  3. 运行的时机是怎么样呢?是会中断正在运行的程序,还是说执行完某个函数呢?或者其他形式

让我们带着这些问题来继续向下看

示例程序

下边我们展示下示例程序来帮助大家解答:

c++ 复制代码
// 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库来打印函数调用栈)

接下来我们编译运行该程序:

shell 复制代码
$ gcc sig.cpp -o sig -g -rdynamic
$ ./sig
loop this thread:1
signal func:11, this thread:1

可以看到信号的运行线程和loop的线程是同一个。 然后我们在命令行中向该程序发送11的信号:

shell 复制代码
$ kill -11 `pidof sig`

然后看下打印的函数调用栈:

shell 复制代码
 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源码分析下,当然如果不想深入了解到这里也是能够解答上边的疑惑。

源码分析

基本数据结构

c++ 复制代码
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的参数略有不同,我们可以忽略:

c++ 复制代码
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,handlerrestorer参数首先构造一个sigaction,然后赋值给当前进程(current)。其他的细节可以忽略。

发送信号

我们在命令行中发送信后使用的是kill指令,同样对应于内核的函数是sys_kill函数:

c++ 复制代码
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

c++ 复制代码
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内核是在这两种情况下进行信号处理:

  • 程序进行系统调用执行后
  • 一些中断处理后 这块代码是汇编,所以我们只需要简单了解原理即可:
perl 复制代码
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

perl 复制代码
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函数:

c 复制代码
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)
  • 后边则是执行系统调用或者中断压栈进来的值

下边时函数内部的执行逻辑;

  1. 然后获取到指定要处理的信号的sigaction的sa_handler,判断处理是被忽略还是默认行为,如果不是就是被用户捕获则继续。
  2. *(&eip) = sa_handler;是修改内核栈中压入的ip执行的位置为sa_handler,也就是从内核态返回时直接到sa_handler函数执行,而不是之前的位置,那么之前的位置呢,我们继续看。
  3. 接下来就是将用户态栈的空间变大,put_fs_long这里则是向用户态的栈空间压入寄存器的值,供sa_handler函数使用。
  4. 最后将old_eip也压入到用户态栈中,也就是说执行完sa_handler就回继续执行系统调用之前的位置的代码。

调用siganl设定信号处理函数时,首先会调用掉系统库的响应函数,然后才会到系统调用那里,也就是说这里的sa_handler应该是系统库的函数,系统库的handler再去调用你自己设定的处理函数,所以调用sa_handler的参数和你自己的处理函数参数有些不同,因为系统库还需要做额外的工作。

以上总结来说就是,do_signal函数会把之前回到用户态要执行位置换成了sa_handler,然后sa_handler执行完之后再继续执行,这样大家就明白了为什么我的死循环明明没有调用任何函数,栈却显示从loop那里调用到doSignal函数。以下是这个流程的图示:

总结

本文我们从例子出发讲述了信号的处理整体流程,虽然内核的代码已经比较老了,但是总体的流程不变。 我们从信号发射,信号捕获,信号处理等方面分析其流程。

感谢大家,点个赞吧~~

ref

《linux内核完全注释》

相关推荐
海岛日记26 分钟前
centos一键卸载docker脚本
linux·docker·centos
AttackingLin1 小时前
2024强网杯--babyheap house of apple2解法
linux·开发语言·python
Ysjt | 深2 小时前
C++多线程编程入门教程(优质版)
java·开发语言·jvm·c++
ephemerals__2 小时前
【c++丨STL】list模拟实现(附源码)
开发语言·c++·list
Microsoft Word2 小时前
c++基础语法
开发语言·c++·算法
学Linux的语莫2 小时前
Ansible使用简介和基础使用
linux·运维·服务器·nginx·云计算·ansible
一只小小汤圆2 小时前
opencascade源码学习之BRepOffsetAPI包 -BRepOffsetAPI_DraftAngle
c++·学习·opencascade
踏雪Vernon2 小时前
[OpenHarmony5.0][Docker][环境]OpenHarmony5.0 Docker编译环境镜像下载以及使用方式
linux·docker·容器·harmonyos
学Linux的语莫3 小时前
搭建服务器VPN,Linux客户端连接WireGuard,Windows客户端连接WireGuard
linux·运维·服务器