ARM/Linux平台信号处理功能的实现探究

 

  • 前言

当嵌入式应用异常崩溃时,应用进程会接收到内核发送的信号,如SIGILL/SIGSEGV等。笔者对这一功能的实现感到非常好奇:如果应用注册了信号处理函数,那么这个信息处理函数可以在应用代码执行到任意代码处调用,这个调用者是谁呢?是glibc库,还是Linux内核?此外,因为应用可以在执行到任意的地方被软中断,中断之处没有适当的上下文保存/保护机制,信号处理函数返回后,是如何正确返回,使得应用在某些情况下可以继续正确地运行的?

为了回答这些问题,笔者进行了冗长的调试过程,在此分享如下。

 

  • 应用代码

首先,笔者编写了一个简单的应用代码(signal-test.c),编译后作为被调试的应用。代码如下,信号处理函数使用gcc的内置函数builtin_return_address(0)获取了函数的返回地址,并将返回地址前后若干个机器指令打印到标准输出。

/*
 * Created by [email protected]
 *
 * Simple Signal Test Example
 *
 * 2020/05/01
 */

#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <fcntl.h>
#include <unistd.h>
#include <signal.h>

static volatile int loopFlag;
static void sighandler_dump(int signo, siginfo_t * pit, void * what)
{
	int idx;
	const unsigned int * pinst;
	unsigned long retAddr, stkAddr;

	stkAddr = 0;
	asm volatile ("\tmov %0, sp\n" : "=r" (stkAddr));
	fprintf(stdout, "Received signal: %d, pit: %p, what: %p, stack-pointer: %p\n",
		signo, pit, what, (void *) stkAddr);
	fflush(stdout);

	retAddr = (unsigned long) __builtin_return_address(0);
	retAddr &= ~0x1UL;
	pinst = (const unsigned int *) (retAddr - 0x10);
	for (idx = 0; idx < 0x8; ++idx) {
		unsigned int inst;
		inst = *pinst;
		fprintf(stdout, "[%p]: %08x\n", pinst, inst);
		fflush(stdout); pinst++;
	}
	if (signo == SIGALRM)
		loopFlag = 0;
}

int main(int argc, char *argv[])
{
	int ret;
	struct sigaction sigAct;

	loopFlag = 1;
	memset(&sigAct, 0, sizeof(sigAct));
	sigAct.sa_flags = SA_SIGINFO;
	sigemptyset(&(sigAct.sa_mask));
	sigaddset(&(sigAct.sa_mask), SIGINT);
	sigaddset(&(sigAct.sa_mask), SIGALRM);
	sigAct.sa_sigaction = sighandler_dump;

	ret = sigaction(SIGINT, &sigAct, NULL);
	if (ret == 0)
		ret = sigaction(SIGALRM, &sigAct, NULL);
	if (ret != 0) {
		fprintf(stderr, "Error, failed to register signal handler: %s\n", strerror(errno));
		fflush(stderr);
		exit(1);
	}

	fputs("Waiting for signal...\n", stdout);
	fflush(stdout);
	while (loopFlag != 0) pause();
	return 0;
}

信号处理函数有三个入参,分别为信号量、siginfo_t的结构体指针和一个ucontext的指针(见man 2 sigaction):

Main函数主体的最后,调用了pause系统调用以等待信号。在获得信号并处理之后,该系统调用通常会返回-1并置errno为EINTR。不过奇怪的一点是,pause对应的Linux内核源码,竟没有返回-EINTR之处:

在以下的Linux内核调试过程中,我们将看到,内核如何返回-EINTR的。

  • 应用调试

编译signal-test.c得到可执行文件signal-test:

将signal-test放置于手动上运行,可以得到以下结果:

可以隐约估摸,在信号处理函数返回地址的前后,存在有效的机器指令。此外,还可以得到一个重要的信息,即信号处理函数的第二个和第三个入参指针与C的栈指针相邻(stack-pointer),就此可以断定,调用者在信号处理函数栈空间上分配了这些结构体。最后,注意到栈指针为0xbefff770,与pit指针相间0x20个字节;这一点下面会提到。可以把这些输出到终端的指令保存到二进制文件,并使用arm-linux-gnueabihf-objdump -d -b binary反汇编;但由于笔者比较懒,没有这么做,直接选择使用gdb来调试,调试结如果下:

通过gdb调试可以得知,信号处理函数使用的C语言函数栈与被中断的应用主线程(signal-test只有一个线程)使用的栈是相同的;换句话说,是主线程执行了信号处理任务。注意到上图的pit指针为0xbefff790,而当前的sp寄存器也是0xbefff790,之前提到的sp寄存器与pit指针相间0x20个字节是怎么来的呢?是保存调用者的寄存器,并分配函数的局部变量而来的:

进入信号处理函数后,会保存6个寄存器,并分配8个字节的局部变量存储空间(如上图的两个红色矩形框),加起来正好是0x20个字节;之后上图椭圆形框住了asm volatile(…)对应的汇编指令。

接着使用gdb反汇编函数返回地址的前后,发现了两个nop指令以及一个编号为173的系统调用,即信号处理函数返回后调用了rt_sigreturn:

于是,我们得到初步的结论:信号处理函数可能不是glibc直接调用的(因为返回地址之前的两个nop指令不具有跳转功能,不会调用信号处理函数)。通过查找glibc源码可知,__default_rt_sa_restorer符号定义到libc动态库中,其实现如下:

至此,应用层的调试就结束了。我们仍然存在疑问:信号处理函数是谁直接调用的呢?此时笔者猜测是Linux内核直接调用的,那么接下来就需要使用gdb调试Linux内核了。

 

  • 内核调试

在进行Linux内核的调试前,需要搭建调试环境,并将signal-test调试应用复制到调试的根文件系统镜像中。笔者曾编写过一篇文章,分享了Linux内核的调试环境的搭建过程(详见:https://blog.csdn.net/yeholmes/article/details/98451963)。这里用到的Linux内核版为v5.6.8。笔者首先选择了一个名为kill_pid_info的内核函数,在该函数入口处加上调试断点;之后运行signal-test应用,最后给signal-test进程发送信号SIGINT,其调试结果如下:

上图的黄色方框,标明了发送信号的进程PID为110,而接收信号量的进程PID为114。其实110为shell进程的PID,kill是一个shell内置的命令。在0x80132b74处加上断点3,得到signal-test主线程对应的task_struct结构体指针,可以确认PID为114的进程的父进程为110。

接下来笔者选译调试signal_wake_up_state(…)内核函数,此函数是kill系统调用树相对靠后的函数,亦即kill系统调用即将完成。首先让我们查看其代码实现:

该函数的注释确实是宝贵的资源,就是说此函数告知一个进程它有了一个活动的且未处理的信号。这里调用了set_tsk_thread_flag(…)内联函数。调试至此,接下来反汇编是必不可少的:

结果显示,signal_wake_up_state(…)调用到了_set_bit;这让我们一头雾水;最后还是让我们来看看set_tsk_thread_flag的函数实现吧:

很复杂的内联函数定义。结合反汇编我们知道,两个黄色矩形框中的第二条汇编指定

ldr r1, [r4, #4]

作用是从stask_struct结构体中读取了stack指针的值;同时也可以推断出对于ARM平台,其thread_info结构体中的flags必定是第一个结构体成员变量,这一点很容易验证:

至此,kill系统调用是如何给指定的进程PID发送信号的实现,我们有了一个基本的了解:根据PID查找目标进程,在目标进程中确定一个合适的线程,将其线程的状态更新为TIF_SIGPENDING,并将其唤醒。之后,我们就需要关注PID为114的进程,它在内核中是如何执行的。笔者选择了do_work_pending(…)的内核函数为中断点,调试结果如下:

在do_work_pending的函数入口第一条机器指令加了断点,我们可以根据regs指针得到Linux内核给signal-test应用的返回值为0xfffffdfe(实际上,pt_regs结构体此时保存的应用层的寄存器信息),即pause系统调用返回的-ERESTARTNOHAND,-514。还有,R14对应着signal-test/main函数中pause的返回地址,而R15(即PC指针)则对着libc动态库的pause函数中的指令地址,此时C语言栈指针为R13: 0x7ec09c5c,该栈值下面的计算会用到。

由于内核不能返回-512给应用层,做为pause系统调用的返回值,这个值肯定会被修改为-EINTR;那么就加入一个内存监视断点,当0xfffffdfe被修改时,会触发断点:

由上图可见,经过两次内存监视断点触发后,pt_regs结构体的第一个成员变量ARM_r0被修改为-EINTR;这样,当应用进程signal-test的信号处理函数执行完毕后,pause系统调用会返回一个-EINTR。

在调用应用时,我们确定的被信号处理中断的线程与信号处理函数共用一个C语言栈,而且函数入参的两个函数指针也指向栈空间,那么这个栈空间是如何分配的呢?分配多大?通过查阅内核源码,我们能够找到答案:

上图的调试已经进入了应用层,这是笔者调试操作失误造成的结果;中间缺少调一部分调试过程,感兴趣的读者可以重复实验。内核函数get_sigframe将应用层的栈指针减去一个framesize的大小,并保证其是8字字对齐的。注意到,framesize此时值为sizeof(struct rt_sigframe),即0x378个字节大小。这一点与我们计算出来的结果符合得很好。接着,阅读内核的源码,可以解答之前的疑问:

Linux内核在响应信号处理的线程应用栈上新建了一个栈帧(因为未调用sigaltstack配置新的栈),并将信号处理入函数地址(即sa_handler)写入新建栈帧的PC寄存器中;将glibc中的__default_rt_sa_restorer写入链接寄存器LR中,并配置信号处理的三个参数:如此一来,信号处理就会被自动调用了。由内核切换至应用的代码没有改变,仍复用正常的kernel-to-user特殊操作。

 

  • 总结

在Linux/ARM平台,应用进程的信号处理实现机制主要是由内核完成的,通常会在响应信号的线程用户栈上开辟新的栈帧,在其中构建信号处理函数的参数,最后返回至应用层执行信号处理函数。当信号处理函数返回后,会执行特殊的系统调用(如rt_sigreturn),将内核建立的栈帧弹出,恢复屏蔽的信号等。同时我们也可以大胆预测,当get_sigframe函数中access_ok返回为0时(比如说应用的栈即将达到页对齐的栈尾,栈层之后的内存区域不具备可读写权限),用就不会处理此信号。这样看来,Linux内核的信号处理也并不万无一失的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章