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內核的信號處理也並不萬無一失的。

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