(重磅原創)冬之焱: 談談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
內核對出問題進程
/線程棧回溯的功能做進內核,就能找到出問題的應用函數了。
4
當應用程序出現鎖死問題,對應用所有線程棧回溯,分析每個線程的函數執行流程,對查找鎖死問題有幫助。
以上幾例應用,在筆者所做的項目中,內核已經合入相關代碼,功能得到驗證。
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函數,在該函數中列印諸如
[
的字元串。
這個列印的其實是在
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值列印形如
[
的字元串,不再介紹。關鍵還是unwind_stack_by_address函數。mips架構由於沒有像arm架構的fp寄存器,導致棧回溯的過程比arm架構複雜很多,為了讀者理解方便,決定先從mips架構彙編代碼分析,指出與棧回溯有關的指令,推出棧回溯的流程,最後講解內核代碼。
如下是mips架構內核驅動ko文件的 C代碼和彙編代碼。
C
代碼
彙編代碼
這裡說明一下,驅動
ko
反彙編出來的指令是從
0
地址開始的,為了敘述方便,筆者加了
0x80000000
,實際的彙編代碼不是這樣的。
這裡直接介紹根據筆者的分析,總結mips架構內核棧回溯的原理,分析完後再結合源碼驗證。mips架構沒有fp寄存器,假設在
test_c
函數中0X80000048
地址處指令崩潰了,首先利用內核的kallsyms模塊,根據崩潰時的指令地址找出該指令是哪個函數的指令,並且找出該指令地址相對函數指令首地址的偏移ofs,在本案例中ofs =0X10(
0X80000048 – 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;)
函數,就能根據0X80000048
指令地址計算出處於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
個內核棧回溯的應用:
1
應用程序崩潰,像內核棧回溯一樣列印整個崩潰過程,應用函數的調用關係
2
應用程序發生
double free
,像內核棧回溯一樣列印
double free
過程,應用函數的調用關係
3
內核陷入死循環,
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)北京《紫竹園》