- 前言
當嵌入式應用異常崩潰時,應用進程會接收到內核發送的信號,如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內核的信號處理也並不萬無一失的。