(重磅原創)冬之焱: 談談Linux內核的棧回溯與妙用

作者簡介:

冬之焱,杭州某公司linux內核工程師,4年開發經驗,對運用linux內核的某些原理解決實際問題很感興趣。

版權聲明:

本文最先發表於"Linux閱碼場"微信公眾號,轉載請在文章的最開頭,保留本聲明。

1

前言

說起

linux

內核的棧回溯功能,我想這對每個

Linux

內核或驅動開發人員來說,太常見了。如下演示的是

linux

內核崩潰的一個棧回溯列印,有了這個崩潰列印我們能很快定位到在內核哪個函數崩潰,大概在函數什麼位置,大大簡化了問題排查過程。

網上或多或少都能找到棧回溯的一些文章,但是講的都並不完整,沒有將內核棧回溯的功能用於實際的內核、應用程序調試,這是本篇文章的核心:儘可能引導讀者將棧回溯的功能用於實際項目調試,棧回溯的功能很強大。

本文詳細講解了基於

mips

arm

架構

linux

內核棧回溯原理,通過不少例子,儘可能全面給讀者展示各種棧回溯的原理,期望讀者理解透徹棧回溯。在這個基礎上,講解筆者近幾年項目開發過程中使用

linux

內核棧回溯功能的幾處重點應用。

1

        

   

當內核某處陷入死循環,有時運行

sysrq

的內核線程棧回溯功能可以排查,但並不適用所用情況,筆者實際項目遇到過。最後是在系統定時鐘中斷函數,對死循環線程棧回溯

20

多級終於找到死循環的函數。

2

        

   

當應用程序段錯誤,內核捕捉到崩潰,對崩潰的應用空間進程

/

線程棧回溯,像內核棧回溯一樣,列印應用段錯誤進程

/

線程的層層函數調用關係。雖然運用

core

文件分析或者

gdb

也很簡便排查應用崩潰問題,但是對於不容易復現、測試部偶先的、客戶現場偶先的,這二者就很難發揮作用。還有就是如果崩潰發生在

C

庫中,

CPU

pc

lr(arm

架構

)

寄存器指向的函數指令在

C

庫的用戶空間,很難找到應用的代碼哪裡調用了

C

庫的函數。

arm

架構網上能找到應用層棧回溯的例子,但是編譯較麻煩,代碼並不容易理解,況且

mips

能在應用層實現嗎?還是在內核實現應用程序棧回溯比較方便。

3

        

   

應用程序發生

double free

,運用內核的棧回溯功能,找到應用代碼哪裡發生了

double free

double free

C

庫層發現並截獲該事件,然後向當前進程

/

線程發送

SIGABRT

進程終止信號,後續就是內核強制清理該進程

/

線程。

double free

比應用程序段錯誤更麻煩,後者內核還會列印出錯進程

/

線程名字、

pid

pc

lr

寄存器值,

double free

這些列印全沒有。筆者做過的一個項目,發布前,遇到一例

double free

崩潰問題,極難復現,當初要是把

double free

內核對出問題進程

/

線程棧回溯的功能做進內核,就能找到出問題的應用函數了。

當應用程序出現鎖死問題,對應用所有線程棧回溯,分析每個線程的函數執行流程,對查找鎖死問題有幫助。

以上幾例應用,在筆者所做的項目中,內核已經合入相關代碼,功能得到驗證。

 

2

棧回溯的原理解釋

2.1

基於

fp

棧幀寄存器形式的棧回溯

筆者最初學習棧回溯,首先看到的資料就是

arm

架構基於

fp

寄存器的棧回溯,這種資料網上比較多,這裡按照自己理解再描述一遍。這種形式的棧回溯相對來說並不複雜,也容易理解,遵循

APCS(

ARM Procedure Call Standard

)

規範

, APCS

規範了

arm

寄存器的使用、函數調用過程出棧和入棧的約定。如下圖所示,是一個傳統的

arm

架構下函數棧數據分布,函數棧由

fp

sp

寄存器分別指向棧底和棧頂

(

這裡舉的例子函數無形參,無局部變數,方便理解

)

通過

fp

寄存器就可以找到存儲在棧中

lr

寄存器數據,這個數據就是函數返回地址。同時也可以找到保存在函數棧中的上一級函數

fp

寄存器數據,這個數據指向了上一級函數的棧底,如此就可以按照同樣的方法找出上一級函數棧中存儲的

lr

fp

數據,就知道哪個函數調用了上一級函數以及這個函數的棧底地址。這樣就構成了一個棧回溯過程,整個流程以

fp

為核心,依次找出每個函數棧中存儲的

lr

fp

數據,計算出函數返回地址和上一級函數棧底地址,從而找出每一級函數調用關係。

為了使讀者理解更充分,舉一個簡單的例子。以

C

函數調用了

B

函數為例,兩個函數無形參,無局部變數,此時的入棧情況最簡單。兩個函數以偽代碼的形式列出,演示入棧過程,寄存器的入棧及賦值,與實際的彙編代碼有偏差。

假設

C

函數的棧底地址是

0x7fff001c

C

函數的前

5

條入棧指令執行後,

pc

等寄存器的值保存到

C

函數棧中,此時

fp

寄存器的值是

C

函數棧底地址

0x7fff001c

。然後

C

函數跳轉到

B

函數,

B

函數前

5

條指令執行後,

pc

lr

fp

寄存器的值依次保存到

B

函數棧中:

B

函數棧的第二片內存保存的就是

lr

值,即

B

函數的返回地址;第四片內存保存的是

fp

值,就是

C

函數棧底地址

0x7fff001c(

在開始執行

B

函數指令前,

fp

寄存器的值是

C

函數的棧底地址,

B

函數的第

4

條指令又是令

fp

寄存器入棧

)

B

函數第五條指令執行後,

fp

寄存器已經更新,其數據是

B

函數棧的棧底地址

0x7fff000c

。當

B

函數發生崩潰,根據

fp

寄存器找到

B

函數棧底地址,從

B

函數棧第二片內存取出的數據就是

lr

,即

B

函數返回地址,第

4

片內存取出的數據就是

fp

,即

C

函數棧底地址。有了

C

函數棧底地址,就能按照上述方法找出

C

函數棧中保存的的

lr

fp

,實現棧回溯

…..

2.2  unwind

形式的棧回溯

arm

架構下,不少

32

位系統用的是

unwind

形式的棧回溯,這種棧回溯要複雜很多。首先需要程序有一個特殊的段

.ARM.unwind_idx

或者

.ARM.unwind_tab

linux

內核本身由多段組成,比如內核驅動初始化函數的

init

段。在

System.map

文件可以搜索到

__start_unwind_idx

,這就是

ARM.unwind_idx

段的起始地址。這個

unwind

段中存儲著跟函數入棧相關的關鍵數據。當函數執行入棧指令後,在

unwind

段會保存跟入棧指令一一對應的編碼數據,根據這些編碼數據,就能計算出當前函數棧大小和

cpu

的哪些寄存器入棧了,在棧中什麼位置。當棧回溯時,首先根據當前函數中的指令地址,就可以計算出函數

unwind

段的地址,然後從

unwind

段取出跟入棧有關的編碼數據,根據這些編碼數據就能計算出當前函數棧的大小以及入棧時

lr

寄存器數據在棧中的存儲地址。這樣就可以找到

lr

寄存器數據,就是當前函數返回地址,也就是上一級函數的指令地址。此時

sp

一般指向的函數棧頂,

sp+

函數棧大小就是上一級函數的棧頂。這樣就完成了一次棧回溯,並且知道了上一級函數的指令地址和棧頂地址,按照同樣的方法就能對上一級函數棧回溯,類推就能實現整個棧回溯流程。為了方便理解,下方舉一個實際調試的示例。該示例中首先列出棧回溯過程每個函數

unwind

段的編碼數據和棧數據。

假設函數調用過程

C->B->A

,另外每個函數中只有一個

printk

列印。這種情況下函數的入棧和

unwind

段的信息就很規則和簡單,這裡就以簡單的來講解,便於理解。此時每個函數第一條指令一般是

push{r4,lr}

,這表示將

lr

r4

寄存器入棧,此時系統會將跟

push{r4,lr}

指令相關的編碼數據

0x80a8b0b0

存入

C

函數的

unwind

段中,

0x7fffff10

跟偏移有關,但是實際用處不大。

0x80a8b0b0

分離成

0x80

0xa8

0xb0

又有不同的意義,最重要的是

0xa8

,表示出棧指令

pop {r4  r14}

r14

就是

lr

寄存器,與

push{r4,lr}

入棧指令正好相反。

C

函數跳轉到

B

函數後,會把

B

函數的返回地址

0xbf004068

存入

B

函數棧。

B

函數按照同樣的方法執行,當執行到

A

函數最後,幾個函數的棧信息和

unwind

段信息就如圖所示。假設在

A

函數中崩潰了,會首先根據崩潰的

pc

值,找到崩潰

A

函數的

unwind

(

每個函數的指令地址和

unwind

段都是對應的,內核有標準的函數可以查找

)

。如圖所示,從地址

0xbf00416c

A

函數

unwind

段中取出數據

0x80a8b0b0

,分析出其中的

0xa8

,就知道對應的

pop {r4  r14}

出棧指令,相應就知道函數入棧時執行的是

push{r4,lr}

指令,其中有兩個重要信息,一個是函數入棧時只有

lr

r4

寄存器入棧,並且函數棧大小是

2*4=8

個位元組,函數崩潰時棧指針

sp

指向崩潰函數

A

的棧頂,根據

sp

就能找到

lr

寄存器存儲在

A

函數棧的數據

0xbf004038

,就是崩潰函數的返回地址,上一級函數

B

的指令地址,而

sp+ 2*4

就是上一級

B

函數的棧頂。知道了

B

函數的指令地址和棧頂地址,就能根據指令地址找到

B

函數的

unwind

段,分析出

B

函數的入棧指令,按照同樣的方法,就能找到

C

函數的返回地址和棧頂。這只是幾個很簡單

unwind

棧回溯過程的演示,省去了很多細節,讀者想研究清楚的話,可以閱讀內核

arm

架構

unwind_frame

函數實現流程,其中最核心的是在

unwind_exec_insn

函數,根據

0xa8

0xb0

這些跟函數入棧過程有關的編碼數據,分析入棧過程的詳細信息,計算出函數

lr

寄存器保存在棧中的地址和上一級函數的棧頂地址。

不同的入棧指令在函數的

unwind

段對應不同的編碼,

0x80a8b0b0

只是其中比較簡單的的編碼,還有

0x80acb0b0

0x80aab0b0

等等很多。可以執行

readelf -u .ARM.unwind_idx vmlinux

查看內核

init

段函數的

unwind

段數據。比如:

這就表示

match_dev_by_uuid

函數在

unwind

段編碼數據是

0x808ab0b0

0xc0008af8

是該函數指令首地址。其中有用的是

0xa8

,表示

pop {r4,r14}

出棧指令,

0xb0

表示

unwind

段結束。

  

為了方便讀者分析對應的棧回溯內核源碼,這裡把關鍵點列出,並添加必要注釋。內核版本

3.10.104

arch/arm/kernel/unwind.c

2.3  fp

unwind

形式棧回溯的比較

  

上文介紹了兩種常用的棧回溯形式的基本原理,並輔助了例子說明。基於

fp

寄存器的棧回溯和

unwind

形式的棧回溯,各有優點和缺點。

fp

形式的棧回溯,基於

APCS

規範,入棧過程必須要將

pc

lr

fp

4

個寄存器入棧

(

其實沒必要這樣做,只需把

lr

fp

入棧

)

,並且消耗的入棧指令要多

(

除了入棧

pc

lr

fp

4

個寄存器,還得將棧底地址保存到

fp)

,同時還浪費了寄存器,至少

fp

寄存器是浪費了,不能參與指令數據運算,

CPU

寄存器是很寶貴的,多一個對加快指令數據運算是有積極意義的。而

unwind

形式的棧回溯,就沒有這些缺點,僅僅只是將入棧相關的指令的編碼保存到

unwind

段中,不用把無關的寄存器保存到棧中,也不用浪費

fp

寄存器。

unwind

形式棧回溯是有缺點的,首先棧回溯的速度肯定比

fp

形式棧回溯慢,理解難度要比

fp

形式大很多,並且,站在開發者角度,使用前還得對每個入棧指令編碼,這都是需要工作量的。但是站在使用者角度,這些缺點影響並不大,所以現在有很多

arm32

系統用的是

unwind

形式的棧回溯。

3 linux

內核棧回溯的原理

 

當內核崩潰,將會執行異常處理程序,這裡以

mips

架構為例,崩潰函數執行流程是:

do_page_fault()->die()->show_registers()->show_stacktrace()->show_backtrace()

 

棧回溯的過程就是在

show_backtrace()

函數,

arm

架構最終是在

dump_backtrace()

函數,內核崩潰處理流程與

mips

不同。

arm

架構棧回溯過程相對來說更簡單,首先講解

arm

架構的棧回溯過程。

   

不同內核版本,內核代碼有差異,本內核版本

3.10.104

 

3.1 arm

架構內核棧回溯的分析

內核實際的棧回溯代碼還是有點複雜的,在正式講解代碼前,先通過一個簡單演示,進一步詳細的介紹棧回溯的原理。這次演示是基於

fp

形式的棧回溯,與上文介紹傳統的

fp

形式棧回溯稍有差異,但是原理是一樣的。

 

下方以偽彙編指令,演示一個完整的函數指令執行與跳轉流程:

C

函數執行

B

函數,

B

函數執行

A

函數,然後

A

函數發生空指針崩潰。

為了幫助讀者理解,做一下解釋,以

C

函數的第一條指令為例:

 

0x00034:  C

函數返回地址

lr

入棧指令

;        C

函數指令

1

 

0x00034

:表示彙編指令的內存地址,反彙編的讀者應該熟悉

C

函數返回地址

lr

入棧指令:表示具體指令的意思,不再用實際彙編指令表示,理解簡單

C

函數指令

1

:表示

C

函數第一條指令,為了引用的簡單

 

   

其中提到的

lr

,做過

arm

內核開發的讀者肯定熟悉,是

CPU

的一個寄存器,存儲函數返回地址,當

C

函數跳轉到

B

函數時,

CPU

自動將

C

函數的指令地址

0x00048

存入

lr

寄存器,這表示

B

函數執行完返回後,

CPU

將從

0x00048

地址取指令繼續運行

(mips

架構是

ra

寄存器,先以

arm

為例

)

fp

寄存器也是

arm

架構的一個

CPU

寄存器,英文釋義是

frame point

,中文有稱為棧幀寄存器,我們這裡用來存儲每個函數棧的第

2

片內存地址

(

一片內存地址

4

個位元組,這樣稱呼是為了敘述方便

)

,下方有詳細講解。為了方便讀者理解,特畫出函數執行過程函數棧數據示意圖。

矩形框表示函數棧,初始化全為

0

0x1000

0x1004

等表示函數棧處於內存的地址,函數棧向下增長。每個函數前兩條指令都是入棧指令,每個函數指令執行後只佔用兩片內存。由於

C

函數是初始函數,棧回溯過程

C

函數棧意義不大,就從

C

函數跳轉到

B

函數指令開始分析。此時

fp

寄存器保存的數據是

C

函數棧地址

0x1010

,原因下文會分析到。當執行

C

函數指令

5

,跳轉到

B

函數後,棧指針

sp

指向地址

0x100C(

先假設,下文的講解可以驗證

)

B

函數的返回地址也就是

C

函數的指令

6

的地址

0x00048

就會自動保存到

CPU

lr

寄存器,然後執行

B

函數指令

1

就會將

0x00048

存入

B

函數棧地址

0x100C

,棧指針

sp

減一,指向

B

函數棧地址

0X1008

。接著執行

B

函數的指令

2

,將

fp

寄存器中的數據

0x1010

存入棧指針

sp

指向的內存地址

0x1008

,示意圖已經標明。接著執行

B

函數指令

3

,將此時棧指針

sp

指向的地址

0x1008(

就是

B

函數的第二片內存

)

存入

fp

寄存器。指令接著執行,由

B

函數跳轉到

A

函數,

A

函數前三條指令與

B

函數執行情況類似,重點就三處,

A

函數棧的第一片內存存儲

A

函數的返回地址,

A

函數棧的第二片內存存儲

B

函數棧的第二片內存地址,當

A

函數執行到指令

5

後,

fp

寄存器保存的是

A

函數棧的第二片內存地址,示意圖中全部標出。當

A

函數執行指令

6

崩潰,怎麼棧回溯?

A

函數崩潰時,按照上文的分析,

fp

寄存器保存的數據是

A

函數棧的第二片內存首地址

0X1000

0X1000

地址中存儲的數據就是

B

函數的棧地址

0x1008(

就是

B

函數的棧的第二片內存

)

0x1000+4=0X1004

地址就是

A

函數棧的第一片內存,存儲的數據是

A

函數的返回地址

0X0030

,這個指令地址就是

B

函數的指令

6

地址,這樣就知道了時

B

函數調用了

A

函數。因為此時已經知道了

B

函數棧的第二片內存地址,該地址的數據就是

C

函數棧的第二片內存地址,

B

函數棧的第一片內存地址中的數據是

B

函數的返回地址

0X0048

(C

函數的指令

6

內存地址

)

。這樣就倒著推出函數調用關係:

A

函數

?

B

函數

?

C

函數。

筆者認為,這種情況棧回溯的核心是:每個函數棧的第二片內存地址存儲的數據是上一級函數棧的第二片內存地址,每個函數棧的第一片內存地址存儲的數據是函數返回地址。只要獲取到崩潰函數棧的第二片內存地址

(

此時就是

fp

寄存器的數據

)

,就能循環計算出每一級調用的函數。

3.1.1

  

內核源碼分析

如果讀者對上一節的演示理解的話,理解下方的源碼就比較容易。

 

arch/arm64/kerneltraps.c

內核崩潰時,產生異常,內核的異常處理程序自動將崩潰時的CPU寄存器存入struct pt_regs結構體,並傳入該函數,相關代碼不再列出。這樣棧回溯的關鍵環節就是紅色標註的代碼,先對frame.fp,frame.sp,frame.pc賦值。下方進入while循環,先執行unwind_frame(&frame;) 找出崩潰過程的每個函數中的彙編指令地址,存入frame.pc(第一次while循環是直接where = frame.pc賦值,這就是當前崩潰函數的崩潰指令地址),下次循環存入where變數,再傳入dump_backtrace_entry函數,在該函數中列印諸如

[] chrdev_open+0x12/0x4B1

的字元串。

這個列印的其實是在

print_ip_sym

函數中做的,將

ip

按照

%pS

形式列印,就能列印出該函數指令所在的函數,以及相對函數首指令的偏移。棧回溯的重點是在

unwind_frame

函數。

 

在正式貼出代碼前,先介紹一下棧回溯過程的三個核心

CPU

寄存器

:pc

lr

fp

pc

指向運行的彙編指令地址;

sp

指向函數棧;

fp

是棧幀指針,不同架構情況不同,但筆者認為它是棧回溯過程中,聯繫兩個有調用關係函數的紐帶,下面的分析就能體現出來。

arch/arm64/kernel/stacktrace.c

首先說明一下,這是

arm64

位系統,一個

long

型數據

8

個位元組大小。為了敘述方便,假設內核代碼的崩潰函數流程還是

C

函數

->B

函數

->A

函數,在

A

函數崩潰,最後在

unwind_frame

函數中棧回溯。

接著針對代碼介紹棧回溯的原理。第一次執行

unwind_frame

函數時,第二行,

frame->fp

保存的就是崩潰時

CPU

fp

寄存器的值,就是

A

函數棧第二片內存地址,

frame->sp = fp + 0x10

賦值後,

frame->sp

就是

A

函數的棧底地址;

frame->fp= *(unsigned long *)(fp)

獲取的是存儲在

A

函數棧第二片內存中的數據,就是調用

A

函數的

B

函數的棧的第二片內存地址;

frame->pc = *(unsigned long *)(fp + 8)

是獲取

A

函數棧的第一片內存中的數據,就是

A

函數的返回地址(就是

B

函數中指令地址),這樣就知道了是

B

函數調用了

A

函數;經過一次

unwind_frame

函數調用,就知道了

A

函數的返回地址和

B

函數的棧的第二片內存地址,有了

B

函數棧的第二片內存地址,就能按照上述過程推出

B

函數的返回地址

(C

函數的指令地址

)

C

函數棧的第二片內存地址,這樣就知道了時

C

函數調用了

B

函數,如此循環,不管有多少級函數調用,都能按照這個規律找出函數調用關係。當然這裡的關係是是

A

?

B

?

C

 

為什麼棧回溯的原理是這樣?首先這個原理筆者都是實際驗證過的,細心的讀者應該會發現,這個棧回溯的流程跟前文第

2

節演示的簡單棧回溯原理一樣。是的,第

2

節就是筆者按照自己對

arm 64

位系統棧回溯的理解,用簡單的形式表達出來,還附了演示圖,這裡不了解的讀者可以回到第

2

節分析一下。

 

3.1.2  arm

架構從彙編代碼角度解釋棧回溯的原理

為了使讀者理解的更充分,下文列出一段應用層

C

語言代碼和反彙編後的代碼

C

代碼

彙編代碼

分析

test_2

函數的彙編代碼,第一條指令

stpx29, x30,[sp,#-16]

x29

就是

fp

寄存器,

x30

就是

lr

寄存器,指令執行過程:將

x30(lr)

x29(fp)

寄存器的值隨著棧指針

sp

向下偏移依次入棧,棧指針

sp

共偏移兩次

8+8=16

個位元組

(arm  64

位系統棧指針

sp

減一偏移

8

個位元組,並且棧是向下增長,所以指令是

-16)

mov x29,sp

指令就是將棧指針賦予

fp

寄存器,此時

sp

就指向

test_2

函數棧的第二片內存,因為

sp

偏移了兩次,

fp

寄存器的值就是

test_2

函數棧的第二片內存地址。去除不相關的指令,直接從

test_2

函數跳轉到

test_1

函數開始分析,看

test_1

函數的第一條指令

stp x29, x30,[sp,#-16]

,首先棧指針

sp

減一,將

x30(lr)

寄存器的數據存入

test_1

函數棧的第一片內存,這就是

test_1

函數的返回地址,接著棧指針

sp

減一,將

x29(fp)

寄存器值入棧,存入

test_1

函數的第二片內存,此時

fp

寄存器的值正是

test_2

函數棧的第二片內存地址,本質就是將

test_2

函數棧的第二片內存地址存入

test_1

函數棧的第二片內存中。接著執行

mov x29,sp

指令,就是將棧指針

sp

賦予

fp

寄存器,此時

sp

指向

test_1

函數棧的第二片內存

…..

 

這樣就與上一小結的分析一致了,

這裡就對

arm

棧回溯的一般過程,做個較為系統的總結:當

C

函數跳轉的

B

函數時,先將

B

函數的返回地址存入

B

函數棧的第一片內存,然後將

C

函數棧的第二片內存地址存入

B

函數棧的第二片內存,接著將

B

函數棧的第二片內存地址存入

fp

寄存器,

B

函數跳轉到

A

函數流程也是這樣。當

A

函數中崩潰時,先從

fp

寄存器中獲取

A

函數棧的第二片內存地址,從中取出

B

函數棧的第二片內存地址,再從

A

函數棧的第一片內存取出

A

函數的返回地址,也就是

B

函數中的指令地址,這樣就推導出

B

函數調用了

A

函數,同理推導出

C

函數調用了

B

函數。

演示的代碼很簡答,但是這個分析是適用於複雜函數的,已經實際驗證過。

 

 

3.1.3  arm

內核棧回溯的「

bug

這個不是我危言聳聽,是實際測出來的。比如如下代碼:

這個函數調用流程在內核崩潰了,內核棧回溯是不會列印上邊的

b

函數,有

arm 64

系統的讀者可以驗證一下,我多次驗證得出的結論是,如果崩潰的函數沒有執行其他函數,就會打亂棧回溯規則,為什麼呢?請回頭看上一節的代碼演示

彙編代碼是

可以發現,

test_a_

函數前兩條指令不是

stpx29, x30,[sp,#-16]

mov x29,sp

,這兩條指令可是棧回溯的關鍵環節。怎麼解決呢?仔細分析的話,是可以解決的。一般情況,函數崩潰,

fp

寄存器保存的數據是當前函數棧的第二片內存地址,當前函數棧的第一片內存地址保存的是函數返回地址,從該地址取出的數據與

lr

寄存器的數據應是一致的,因為

lr

寄存器保存的也是函數返回地址,如果不相同,說明該函數中沒有執行

stp x29, x30,[sp,#-16]

指令,此時應使用

lr

寄存器的值作為函數返回地址,並且此時

fp

寄存器本身就是上一級函數棧的第二片內存地址,有了這個數據就能按照前文的方法棧回溯了。解決方法就是這樣,讀者可以仔細體會一下我的分析。

3.2  mips

棧回溯過程

前文說過,

mips

內核崩潰處理流程是

do_page_fault()->die()->show_registers()->show_stacktrace()->show_backtrace()

列印崩潰函數流程是在

show_backtrace()

函數。

3.2.1  mips

架構內核棧回溯原理分析

arch/mips/kernel/ traps.c

可以發現,與arm架構棧回溯流程基本一致。函數開頭是對sp、ra、pc寄存器器賦值,sp和pc與arm架構一致,ra相當於arm架構的lr寄存器,沒有arm架構的fp寄存器。print_ip_sym函數就是根據pc值列印形如

[] chrdev_open+0x12/0x4B1

的字元串,不再介紹。關鍵還是unwind_stack_by_address函數。mips架構由於沒有像arm架構的fp寄存器,導致棧回溯的過程比arm架構複雜很多,為了讀者理解方便,決定先從mips架構彙編代碼分析,指出與棧回溯有關的指令,推出棧回溯的流程,最後講解內核代碼。

 

如下是mips架構內核驅動ko文件的 C代碼和彙編代碼。

C

代碼

彙編代碼

這裡說明一下,驅動

ko

反彙編出來的指令是從

0

地址開始的,為了敘述方便,筆者加了

0x80000000

,實際的彙編代碼不是這樣的。

這裡直接介紹根據筆者的分析,總結mips架構內核棧回溯的原理,分析完後再結合源碼驗證。mips架構沒有fp寄存器,假設在

test_c

函數中0X

80000048

地址處指令崩潰了,首先利用內核的kallsyms模塊,根據崩潰時的指令地址找出該指令是哪個函數的指令,並且找出該指令地址相對函數指令首地址的偏移ofs,在本案例中ofs =

0X10(

0X

80000048 – 0X80000038 =0X10)

,這樣就能算出test_c函數的指令首地址是 0X80000048 - 0X10 = 0X80000038。然後就從地址0X80000038開始,依次取出每條指令,找到addiu  sp,sp,-24 和sw   ra,20(sp),內核有標準函數可以判斷出這兩條指令,下文可以看到。addiu  sp,sp,-24是test_c函數的第一條指令,棧指針向下偏移24個位元組,筆者認為是為test_c函數分配棧大小( 24個位元組);sw   ra,20(sp)指令將test_c函數返回地址存入sp +20 內存地址處,此時sp指向的是test_c函數的棧頂,sp+20就是test_c函數棧的第二片內存,該函數棧大小24位元組,一共24/4=6片內存。

根據sw   ra,20(sp)指令知道test_c函數返回地址在test_c函數棧的存儲位置,取出該地址的數據,就知道是test_a函數的指令地址,當然就知道是test_a函數調用了test_c函數。並根據addiu  sp,sp,-24指令知道test_c函數棧總計24位元組,因為test_c函數崩潰時,棧指針sp指向test_c函數棧頂,sp+24就是test_a函數的棧頂,因為test_a函數調用了test_c函數,兩個函數的棧必是緊挨著的。按照上述推斷,首先知道了test_a函數中的指令地址了,使用內核

kallsyms

功能就推算出test_a函數的指令首地址,同時也計算出test_a函數的棧頂,就能按照上述規律找出誰調用了test_a函數,以及該函數的棧頂。依次就能找出所有函數調用關係。

 

關於內核的

kallsyms

,筆者的理解是:執行過cat 

/proc/kallsyms

命令的讀者,應該了解過,該命令會列印內核所有的函數的首地址和函數名稱,還有內核編譯後生成的System.map文件,記錄內核函數、變數的名稱與內存地址等等,

kallsyms

也是記錄了這些內容,當執行kallsyms_lookup_size_offset(0X

80000048,

&size;,&ofs;)

函數,就能根據0X

80000048

指令地址計算出處於test_c

函數

,並將相對於test_c

函數指令首地址的偏移

0X10

存入

ofs

,test_c

函數指令總位元組數存入

size

。筆者沒有研究過

kallsyms模塊,但是可以理解到,內核的所有函數都是按照分配的地址,順序排布。如果記錄了每個函數的首地址和名稱,當知道函數的任何一條指令地址,就能在其中搜索比對,找到該指令處於按個函數,計算出函數首地址,該指令的偏移。

 

3.2.2  mips

架構內核棧回溯核心源碼分析

3.2.1

詳細講述了

mips

棧回溯的原理,接著講解棧回溯的核心函數

unwind_stack_by_address

上述源碼已經在關鍵點做了詳細注釋,其實就是對

3.2.1

節棧回溯原理的完善,請讀者自己分析,這裡不再贅述。但是有一點請注意,就是藍色注釋,這是針對崩潰的函數沒有執行其他函數的情況,此時該函數沒有類似彙編指

sw  ra,20(sp)

將函數返回地址保存到棧中,計算方法就變了,要直接使用

ra

寄存器的值作為函數返回地址,計算上一級函數棧頂的方法還是一致的,後續棧回溯的方法與前文相同。

 

4  linux

內核棧回溯的應用

文章最開頭說過,筆者在實際項目開發過程,已經總結出了

3

個內核棧回溯的應用:

應用程序崩潰,像內核棧回溯一樣列印整個崩潰過程,應用函數的調用關係

應用程序發生

double free

,像內核棧回溯一樣列印

double free

過程,應用函數的調用關係

內核陷入死循環,

sysrq

的內核線程棧回溯功能無法發揮作用時,在系統定時鐘中斷函數中對卡死線程棧回溯,找出卡死位置

 

下文逐一講解。

 

4.1

應用程序崩潰棧回溯

筆者在研究過內核棧回溯功能後,不禁發問,為什麼不能用同樣的方法對應用程序的崩潰棧回溯呢?不管是內核空間,應用空間,程序的指令是一樣的,無非是地址有差異,函數入棧出棧原理是一樣的。棧回溯的入口,

arm

架構是獲取崩潰線程

/

進程的

pc

fp

lr

寄存器值,

mips

架構是獲取

pc

ra

sp

寄存器值,有了這些值就能按照各自的回溯規律,實現棧回溯。從理論上來說,完全是可以實現的。

 

4.1 .1  arm

架構應用程序棧回溯的實現

   

當應用程序發生崩潰,與內核一樣,系統自動將崩潰時所有的

CPU

寄存器存入

struct pt_regs

結構,一般崩潰入口函數是

do_page_fault

,又因為是應用程序崩潰,所以是

__do_user_fault

函數,這裡直接分析

__do_user_fault

在該函數中,

tsk

就是崩潰的線程,

struct pt_regs *regs

就指向線程

/

進程崩潰時的

CPU

寄存器結構。

regs->[29]

就是

fp

寄存器,

regs->[30]

lr

寄存器,

regs->pc

的意義很直觀

。現在有了崩潰應用

線程

/

進程當時的

fp

sp

lr

寄存器,就能棧回溯了,完全仿照內核

dump_backtrace

的方法,請看筆者寫在

user_thread_ dump_backtrace

函數中的演示代碼。

與內核棧回溯原理一致,列印崩潰過程每個函數的指令地址,然後在應用程序的反彙編文件中查找,就能找到該指令處於的函數,如果不理解,請看文章前方講解的內核棧回溯代碼與原理。請注意,這不是筆者項目實際用的棧回溯代碼,實際的改動完善了很多,這只是演示原理的示例代碼。

還有一點就是,筆者在

3.1.3

節提到的,假如崩潰的函數中沒有調用其他函數,那上述棧回溯就會有問題,就不會列印第二級函數,解決方法講的也有,解決的代碼這裡就不再列出了。

 

 

4.1 .2  mips

架構應用程序棧回溯的實現

mips

架構不僅內核棧回溯的代碼比

arm

複雜,應用程序的棧回溯更複雜,還有未知

bug

,即便這樣,還是講解一下具體的解決思路,最後講一下存在的問題。

先簡單回顧一下內核棧回溯的原理,首先根據崩潰函數的

pc

值,運用內核

kallsyms

模塊,計算出該函數的指令首地址,然後從指令首地址開始分析,找出類似

addiu  sp,sp,-24

sw   ra,20(sp)

指令,前者可以找到該函數的棧大小,棧指針

sp

加上這個數值,就知道上一級函數的棧頂地址

(

崩潰時

sp

指向崩潰函數的棧頂

)

;後者知道函數返回地址在該函數棧中存儲的地址,從該地址就能獲取該函數的返回地址,就是上一級函數的指令地址,也就知道了上一級函數是哪個

(

同樣使用

內核

kallsyms

模塊

)

。知道了上一級函數的指令地址和棧頂地址,按照同樣方法,就能知道再上一級的函數

…….

 

問題來了,內核有

kallsyms

模塊記錄了每個函數的首地址和函數名字,函數還是順序排布。應用程序並沒有

kallsyms

模塊,即便知道了崩潰函數的

pc

值,也無法按照同樣的方法找到崩潰函數的指令首地址,真的沒有方法?其實還有一個最簡單的方法。先列出一段一個應用程序函數的彙編代碼,如下所示,與內核態的有小的差別。

現在假如從

0X4006a4

地址處取指,運行後崩潰了。崩潰發生時,能像

arm

架構一樣獲取崩潰前的

CPU

寄存器值,最重要就是

pc

sp

ra

值。

pc

值就是

0X4006a4

,然後令一個

unsigned long

型指針指向該內存地址

0X4006a4

,每次減一,並取出該地址的指令數據分析,這樣肯定能分析到

addiu sp,sp,-32

sw  ra,28(sp)

指令,我想看到這裡,讀者應該可以清楚方法了。沒錯,就是以崩潰時

pc

值作為基地址,每次減

1

並從對應地址取出指令分析,直到分析出久違的

addiu sp,sp,-32

sw  ra,28(sp)

類似指令,再結合崩潰時的棧指針值

sp

,就能計算出該函數的返回地址和上一級函數的棧頂地址。後續的方法,就與內核棧回溯的過程一致了。下方列出演示的代碼。

為了一致性,應用程序棧回溯的函數還是採用名字

user_thread_ dump_backtrace

如上就是

mips

應用程序棧回溯的示例代碼,只是一個演示,筆者實際使用的代碼要複雜太多。讀者使用時,要基於這個基本原理,多調試,才能應對各種情況,筆者前後調試幾周才穩定。由於這個方法並不是標準的,實際使用時還是會出現誤報函數現象,分析了發生誤報的彙編代碼及

C

代碼,發現當函數代碼複雜時,函數的彙編指令會變得非常複雜,會出現相似指令等等,讀者實際調試時就會發現。這個

mips

應用程序棧回溯的方法,可以應對大部分崩潰情況,但是有誤報的可能,優化的空間非常大,這點請讀者注意。

4.2

應用程序

double free

內核棧回溯

double free

是在

C

庫層發生的,正常情況內核無能為力,但是筆者研究過後,發現照樣可以實現對發生

double free

應用進程的棧回溯。

arm

架構為例,

doublefree C

庫層的代碼,大體原理是,當檢測到

double free(

本人實驗時,一片

malloc

分配的內存

free

兩次就會發生

)

,就會執行

kill

系統調用函數,向出問題的進程發送

SIGABRT

信號,既然是系統調用,從用戶空間進入內核空間時,就會將應用進程用戶空間運行時的

CPU

寄存器

pc

sp

lr

等保存到進程的內核棧中,發送信號內核必然執行

send_signal

函數。在該函數中,使用

struct pt_regs *regs = task_pt_regs(current)

方法就能從當前進程內核棧中獲取進入內核空間前,用戶空間運行指令的

pc

sp

fp

CPU

寄存器值,有了這些值,就能按照用戶空間進程崩潰棧回溯方法一樣,對

double free

的進程棧回溯了。比如,

A

函數

double free

A

函數

->C

庫函數

1-> C

庫函數

2->C

庫函數

3(

檢測到

double free

並發送

SIGABRT

信號,執行系統調用進入內核空間發送信號

)

。回溯的結果是:

C

庫函數

3

?

C

庫函數

2

?

C

庫函數

1

?

A

函數。

 

源碼不再列出,相信讀者理解的話是可以自己開發的。其中

task_pt_regs

函數的使用,需要讀者對進程內核棧有一定的了解。

筆者有個理解,當獲取某個進程運行指令某一時間點的

CPU

寄存器

pc

lr

fp

的值,就能對該進程進行棧回溯。

4.3 

內核發生死循環

sysrq

無效時棧回溯的應用

內核的

sysrq

中有一個方法,執行後可以對所有線程進行內核空間函數棧回溯,但是本人遇到過一次因某個外設導致的死循環,該方法列印的棧回溯信息都是內核級的函數,沒有頭緒。於是,嘗試在系統定時鐘中斷函數中實現卡死線程的棧回溯

(

也可以在

account_process_tick

內核標準函數中,系統定時鐘中斷函數會執行到

)

。原理是,當一個內核線程卡死時,首先考慮在某個函數陷入死循環,系統定時鐘中斷是不斷產生的,此時

current

線程很大概率就是卡死線程(要考慮內核搶佔,內核支持搶佔時,內核某處陷入死循環照樣可以調度出去),然後使用

struct pt_regs *regs = get_irq_regs()

方法,就能獲取中斷前線程的

pc

sp

fp

等寄存器值,有了這些值,就能按照內核線程崩潰棧回溯原理,對卡死線程函數調用過程棧回溯,找到卡死函數。

mips

架構棧回溯的核心函數

show_backtrace()

定義如下,只要傳入內核線程的

struct  task_struct

structpt_regs

結構,就能對內核線程當時指令的執行進行棧回溯。

static void show_backtrace(struct task_struct *task, const struct pt_regs *regs)

 

4.4

應用程序鎖死時對所有應用線程的棧回溯

arm

架構為例。當應用鎖死,尤其是偶現的鎖死卡死問題,可以使用棧回溯的思路解決。以單核

CPU

為例,應用程序的所有線程,正常情況,兩種狀態

:

正在運行和其他狀態

(

大部分情況是休眠

)

。休眠的應用線程,一般要先進入內核空間,將應用層運行時的

pc

lr

fp

等寄存器存入內核棧,執行

schdule

函數讓出

CPU

使用權,最後線程休眠。此時可以通過

tesk_pt_regs

函數從線程內核棧中獲取線程進入內核空間前的

pc

lr

fp

等寄存器的數據。正在運行的應用線程,系統定時鐘中斷產生後,系統要執行硬體定時器中斷,此時可以通過

get_irq_regs

函數獲取中斷前的

pc

lr

fp

等寄存器的值。不管應用線程是否正在運行,都可以獲取線程當時用戶空間運行指令的

pc

lr

fp

等寄存器數據。當應用某個線程,不管是使用鎖異常而長時間休眠,還是陷入死循環,從內核的進程運行隊列中,依次獲取到所有應用線程的

pc

lr

fp

等寄存器的數據後

(

可以考慮在

account_process_tick

函數實現

)

,就可以按照前文思路對應用線程棧回溯,找出懷疑點。

實際使用時,要防止內核線程的干擾,

task->mm

可以用來判斷,內核線程為

NULL

。當然也可以通過線程名字加限制,對疑似的幾個線程棧回溯。應用線程正在內核空間運行時,這種情況用這個方法就有問題,這時需加限制,比如通過

get_irq_regs

函數獲取到

pc

值後,判斷是在內核空間還是用戶空間。讀者實現該功能時,有不少其他細節要注意,這裡不再一一列出。

5

應用程序棧回溯的展望

關於應用程序的棧回溯,筆者正在考慮一個方法,使應用程序的棧回溯能真正像內核一樣列印出函數的符號及偏移,比如

現有的方法只能實現如下效果:

之後還得對應用程序反彙編才能找到崩潰的函數。

 

筆者的分析是,理論上是可以實現的,只要仿照內核的

kallsyms

方法,按照順序記錄每個應用函數的函數首地址和函數名字到一個文件中,當應用程序崩潰時,內核中讀取這個文件,根據崩潰的指令地址在這個文件中搜索,就能找到該指令處於哪個函數中,本質還是實現了與內核

kallsyms

類似的方法。有了這個功能,不僅應用程序棧回溯能列印函數的名稱與偏移,還能讓

mips

架構應用程序崩潰的棧回溯按照內核崩潰棧回溯的原理來實現,不會再出現函數誤報現象,不知讀者是否理解我的思路?後續有機會,會嘗試開發這個功能並分享出來。

6

總結

實際項目調試時,發現棧回溯的應用價值非常大,掌握棧回溯的原理,不僅對內核調試有很大幫助,對加深內核的理解也是有不少益處。

   

這是本人第一次投稿,經驗不足,文章可能也有失誤的地方,請讀者及時提出,但是筆者保證,文章講解的內容都是經過理論和實際驗證的,不會有原理性偏差。有問題請發往筆者郵箱。後續有機會,筆者會將內存管理、文件系統方面的總結分享出來。

查看"Linux閱碼場"精華技術文章請移步:

Linux閱碼場精華文章匯總

掃描二維碼關注

"Linux閱碼場"

 


推薦閱讀:

【原創】一枚清夏,曦絮晨語(文、水港灣)
短短6天擊敗多個強鄰,此國領土擴3倍,一超級大國急了:快停火吧|原創
德國原創性思維方式的啟示
第一場秋雨(組詩)【原創】
【原創】攝影習作(5)北京《紫竹園》

TAG:Linux | 原創 | Linux內核 |