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值打印形如[
如下是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(0X80000048, &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節棧回溯原理的完善,請讀者自己分析,這里不再贅述。但是有一點請注意,就是藍色注釋,這是針對崩潰的函數沒有執行其他函數的情況,此時該函數沒有類似匯編指令swra,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
+關注
關注
87文章
11292瀏覽量
209330 -
LINUX內核
+關注
關注
1文章
316瀏覽量
21644
原文標題:(重磅原創)冬之焱: 談談Linux內核的棧回溯與妙用
文章出處:【微信號:LinuxDev,微信公眾號:Linux閱碼場】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論