Linux內核的?;厮菖c妙用
1 前言
說起linux內核的?;厮莨δ?,我想這對每個Linux內核或驅動開發(fā)人員來說,太常見了。如下演示的是linux內核崩潰的一個?;厮荽蛴。辛诉@個崩潰打印我們能很快定位到在內核哪個函數(shù)崩潰,大概在函數(shù)什么位置,大大簡化了問題排查過程。
網(wǎng)上或多或少都能找到?;厮莸囊恍┪恼?,但是講的都并不完整,沒有將內核?;厮莸墓δ苡糜趯嶋H的內核、應用程序調試,這是本篇文章的核心:盡可能引導讀者將?;厮莸墓δ苡糜趯嶋H項目調試,?;厮莸墓δ芎軓姶蟆?/p>
本文詳細講解了基于mips、arm架構linux內核?;厮菰恚ㄟ^不少例子,盡可能全面給讀者展示各種棧回溯的原理,期望讀者理解透徹?;厮?。在這個基礎上,講解筆者近幾年項目開發(fā)過程中使用linux內核?;厮莨δ艿膸滋幹攸c應用。
1 當內核某處陷入死循環(huán),有時運行sysrq的內核線程棧回溯功能可以排查,但并不適用所用情況,筆者實際項目遇到過。***是在系統(tǒng)定時鐘中斷函數(shù),對死循環(huán)線程?;厮?0多級終于找到死循環(huán)的函數(shù)。
2 當應用程序段錯誤,內核捕捉到崩潰,對崩潰的應用空間進程/線程棧回溯,像內核棧回溯一樣,打印應用段錯誤進程/線程的層層函數(shù)調用關系。雖然運用core文件分析或者gdb也很簡便排查應用崩潰問題,但是對于不容易復現(xiàn)、測試部偶先的、客戶現(xiàn)場偶先的,這二者就很難發(fā)揮作用。
還有就是如果崩潰發(fā)生在C庫中,CPU的pc和lr(arm架構)寄存器指向的函數(shù)指令在C庫的用戶空間,很難找到應用的代碼哪里調用了C庫的函數(shù)。arm架構網(wǎng)上能找到應用層?;厮莸睦?,但是編譯較麻煩,代碼并不容易理解,況且mips能在應用層實現(xiàn)嗎?還是在內核實現(xiàn)應用程序?;厮荼容^方便。
3 應用程序發(fā)生double free,運用內核的?;厮莨δ?,找到應用代碼哪里發(fā)生了double free。double free是C庫層發(fā)現(xiàn)并截獲該事件,然后向當前進程/線程發(fā)送SIGABRT進程終止信號,后續(xù)就是內核強制清理該進程/線程。double free比應用程序段錯誤更麻煩,后者內核還會打印出錯進程/線程名字、pid、pc和lr寄存器值,double free這些打印全沒有。
筆者做過的一個項目,發(fā)布前,遇到一例double free崩潰問題,極難復現(xiàn),當初要是把double free內核對出問題進程/線程棧回溯的功能做進內核,就能找到出問題的應用函數(shù)了。
4 當應用程序出現(xiàn)鎖死問題,對應用所有線程?;厮荩治雒總€線程的函數(shù)執(zhí)行流程,對查找鎖死問題有幫助。
以上幾例應用,在筆者所做的項目中,內核已經合入相關代碼,功能得到驗證。
2 ?;厮莸脑斫忉?/strong>
2.1 基于fp棧幀寄存器形式的棧回溯
筆者最初學習?;厮?,首先看到的資料就是arm架構基于fp寄存器的?;厮?,這種資料網(wǎng)上比較多,這里按照自己理解再描述一遍。
這種形式的?;厮菹鄬碚f并不復雜,也容易理解,遵循APCS(ARM Procedure Call Standard)規(guī)范, APCS規(guī)范了arm寄存器的使用、函數(shù)調用過程出棧和入棧的約定。如下圖所示,是一個傳統(tǒng)的arm架構下函數(shù)棧數(shù)據(jù)分布,函數(shù)棧由fp和sp寄存器分別指向棧底和棧頂(這里舉的例子函數(shù)無形參,無局部變量,方便理解)。
通過fp寄存器就可以找到存儲在棧中l(wèi)r寄存器數(shù)據(jù),這個數(shù)據(jù)就是函數(shù)返回地址。同時也可以找到保存在函數(shù)棧中的上一級函數(shù)fp寄存器數(shù)據(jù),這個數(shù)據(jù)指向了上一級函數(shù)的棧底,如此就可以按照同樣的方法找出上一級函數(shù)棧中存儲的lr和fp數(shù)據(jù),就知道哪個函數(shù)調用了上一級函數(shù)以及這個函數(shù)的棧底地址。
這樣就構成了一個?;厮葸^程,整個流程以fp為核心,依次找出每個函數(shù)棧中存儲的lr和fp數(shù)據(jù),計算出函數(shù)返回地址和上一級函數(shù)棧底地址,從而找出每一級函數(shù)調用關系。
為了使讀者理解更充分,舉一個簡單的例子。以C函數(shù)調用了B函數(shù)為例,兩個函數(shù)無形參,無局部變量,此時的入棧情況最簡單。兩個函數(shù)以偽代碼的形式列出,演示入棧過程,寄存器的入棧及賦值,與實際的匯編代碼有偏差。
假設C函數(shù)的棧底地址是0x7fff001c,C函數(shù)的前5條入棧指令執(zhí)行后,pc等寄存器的值保存到C函數(shù)棧中,此時fp寄存器的值是C函數(shù)棧底地址0x7fff001c。
然后C函數(shù)跳轉到B函數(shù),B函數(shù)前5條指令執(zhí)行后,pc、lr、fp寄存器的值依次保存到B函數(shù)棧中:B函數(shù)棧的第二片內存保存的就是lr值,即B函數(shù)的返回地址;第四片內存保存的是fp值,就是C函數(shù)棧底地址0x7fff001c(在開始執(zhí)行B函數(shù)指令前,fp寄存器的值是C函數(shù)的棧底地址,B函數(shù)的第4條指令又是令fp寄存器入棧);B函數(shù)第五條指令執(zhí)行后,fp寄存器已經更新,其數(shù)據(jù)是B函數(shù)棧的棧底地址0x7fff000c。
當B函數(shù)發(fā)生崩潰,根據(jù)fp寄存器找到B函數(shù)棧底地址,從B函數(shù)棧第二片內存取出的數(shù)據(jù)就是lr,即B函數(shù)返回地址,第4片內存取出的數(shù)據(jù)就是fp,即C函數(shù)棧底地址。有了C函數(shù)棧底地址,就能按照上述方法找出C函數(shù)棧中保存的的lr和fp,實現(xiàn)?;厮?hellip;..
2.2 unwind 形式的?;厮?/strong>
在arm架構下,不少32位系統(tǒng)用的是unwind形式的?;厮?,這種棧回溯要復雜很多。首先需要程序有一個特殊的段.ARM.unwind_idx 或者.ARM.unwind_tab,linux內核本身由多段組成,比如內核驅動初始化函數(shù)的init段。在System.map文件可以搜索到__start_unwind_idx,這就是ARM.unwind_idx段的起始地址。
這個unwind段中存儲著跟函數(shù)入棧相關的關鍵數(shù)據(jù)。當函數(shù)執(zhí)行入棧指令后,在unwind段會保存跟入棧指令一一對應的編碼數(shù)據(jù),根據(jù)這些編碼數(shù)據(jù),就能計算出當前函數(shù)棧大小和cpu的哪些寄存器入棧了,在棧中什么位置。
當棧回溯時,首先根據(jù)當前函數(shù)中的指令地址,就可以計算出函數(shù)unwind段的地址,然后從unwind段取出跟入棧有關的編碼數(shù)據(jù),根據(jù)這些編碼數(shù)據(jù)就能計算出當前函數(shù)棧的大小以及入棧時lr寄存器數(shù)據(jù)在棧中的存儲地址。這樣就可以找到lr寄存器數(shù)據(jù),就是當前函數(shù)返回地址,也就是上一級函數(shù)的指令地址。
此時sp一般指向的函數(shù)棧頂,sp+函數(shù)棧大小就是上一級函數(shù)的棧頂。這樣就完成了一次?;厮?,并且知道了上一級函數(shù)的指令地址和棧頂?shù)刂?,按照同樣的方法就能對上一級函?shù)棧回溯,類推就能實現(xiàn)整個?;厮萘鞒?。為了方便理解,下方舉一個實際調試的示例。該示例中首先列出棧回溯過程每個函數(shù)unwind段的編碼數(shù)據(jù)和棧數(shù)據(jù)。
假設函數(shù)調用過程C->B->A,另外每個函數(shù)中只有一個printk打印。這種情況下函數(shù)的入棧和unwind段的信息就很規(guī)則和簡單,這里就以簡單的來講解,便于理解。此時每個函數(shù)***條指令一般是push{r4,lr},這表示將lr和r4寄存器入棧,此時系統(tǒng)會將跟push{r4,lr}指令相關的編碼數(shù)據(jù)0x80a8b0b0存入C函數(shù)的unwind段中,0x7fffff10跟偏移有關,但是實際用處不大。0x80a8b0b0分離成0x80,0xa8 ,0xb0又有不同的意義,最重要的是0xa8,表示出棧指令pop {r4 r14},r14就是lr寄存器,與push{r4,lr}入棧指令正好相反。C函數(shù)跳轉到B函數(shù)后,會把B函數(shù)的返回地址0xbf004068存入B函數(shù)棧。
B函數(shù)按照同樣的方法執(zhí)行,當執(zhí)行到A函數(shù)***,幾個函數(shù)的棧信息和unwind段信息就如圖所示。假設在A函數(shù)中崩潰了,會首先根據(jù)崩潰的pc值,找到崩潰A函數(shù)的unwind段(每個函數(shù)的指令地址和unwind段都是對應的,內核有標準的函數(shù)可以查找)。如圖所示,從地址0xbf00416c的A函數(shù)unwind段中取出數(shù)據(jù)0x80a8b0b0,分析出其中的0xa8,就知道對應的pop {r4 r14}出棧指令,相應就知道函數(shù)入棧時執(zhí)行的是push{r4,lr}指令,其中有兩個重要信息,一個是函數(shù)入棧時只有l(wèi)r和r4寄存器入棧,并且函數(shù)棧大小是2*4=8個字節(jié),函數(shù)崩潰時棧指針sp指向崩潰函數(shù)A的棧頂,根據(jù)sp就能找到lr寄存器存儲在A函數(shù)棧的數(shù)據(jù)0xbf004038,就是崩潰函數(shù)的返回地址,上一級函數(shù)B的指令地址,而sp+ 2*4就是上一級B函數(shù)的棧頂。
知道了B函數(shù)的指令地址和棧頂?shù)刂?,就能根?jù)指令地址找到B函數(shù)的unwind段,分析出B函數(shù)的入棧指令,按照同樣的方法,就能找到C函數(shù)的返回地址和棧頂。
這只是幾個很簡單unwind?;厮葸^程的演示,省去了很多細節(jié),讀者想研究清楚的話,可以閱讀內核arm架構unwind_frame函數(shù)實現(xiàn)流程,其中最核心的是在unwind_exec_insn函數(shù),根據(jù)0xa8,0xb0這些跟函數(shù)入棧過程有關的編碼數(shù)據(jù),分析入棧過程的詳細信息,計算出函數(shù)lr寄存器保存在棧中的地址和上一級函數(shù)的棧頂?shù)刂贰?/p>
不同的入棧指令在函數(shù)的unwind段對應不同的編碼,0x80a8b0b0只是其中比較簡單的的編碼,還有0x80acb0b0,0x80aab0b0等等很多??梢詧?zhí)行 readelf -u .ARM.unwind_idx vmlinux查看內核init段函數(shù)的unwind段數(shù)據(jù)。比如:
這就表示match_dev_by_uuid函數(shù)在unwind段編碼數(shù)據(jù)是0x808ab0b0,0xc0008af8是該函數(shù)指令首地址。其中有用的是0xa8 ,表示pop {r4,r14}出棧指令,0xb0表示unwind段結束。
為了方便讀者分析對應的棧回溯內核源碼,這里把關鍵點列出,并添加必要注釋。內核版本3.10.104。
- arch/arm/kernel/unwind.c
2.3 fp和unwind形式棧回溯的比較
上文介紹了兩種常用的?;厮菪问降幕驹恚⑤o助了例子說明?;趂p寄存器的?;厮莺蛈nwind形式的?;厮?,各有優(yōu)點和缺點。fp形式的?;厮荩贏PCS規(guī)范,入棧過程必須要將pc、lr、fp等4個寄存器入棧(其實沒必要這樣做,只需把lr和fp入棧),并且消耗的入棧指令要多(除了入棧pc、lr、fp等4個寄存器,還得將棧底地址保存到fp),同時還浪費了寄存器,至少fp寄存器是浪費了,不能參與指令數(shù)據(jù)運算,CPU寄存器是很寶貴的,多一個對加快指令數(shù)據(jù)運算是有積極意義的。
而unwind形式的棧回溯,就沒有這些缺點,僅僅只是將入棧相關的指令的編碼保存到unwind段中,不用把無關的寄存器保存到棧中,也不用浪費fp寄存器。
unwind形式?;厮菔怯腥秉c的,首先?;厮莸乃俣瓤隙ū萬p形式?;厮萋斫怆y度要比fp形式大很多,并且,站在開發(fā)者角度,使用前還得對每個入棧指令編碼,這都是需要工作量的。但是站在使用者角度,這些缺點影響并不大,所以現(xiàn)在有很多arm32系統(tǒng)用的是unwind形式的?;厮荨?/p>
3 linux內核?;厮莸脑?/strong>
當內核崩潰,將會執(zhí)行異常處理程序,這里以mips架構為例,崩潰函數(shù)執(zhí)行流程是:
- do_page_fault()->die()->show_registers()->show_stacktrace()->show_backtrace()
?;厮莸倪^程就是在show_backtrace()函數(shù),arm架構最終是在dump_backtrace()函數(shù),內核崩潰處理流程與mips不同。arm架構棧回溯過程相對來說更簡單,首先講解arm架構的棧回溯過程。
不同內核版本,內核代碼有差異,本內核版本3.10.104
3.1 arm架構內核棧回溯的分析
內核實際的?;厮荽a還是有點復雜的,在正式講解代碼前,先通過一個簡單演示,進一步詳細的介紹?;厮莸脑?。這次演示是基于fp形式的棧回溯,與上文介紹傳統(tǒng)的fp形式棧回溯稍有差異,但是原理是一樣的。
下方以偽匯編指令,演示一個完整的函數(shù)指令執(zhí)行與跳轉流程:C函數(shù)執(zhí)行B函數(shù),B函數(shù)執(zhí)行A函數(shù),然后A函數(shù)發(fā)生空指針崩潰。
數(shù)執(zhí)行A函數(shù),然后A函數(shù)發(fā)生空指針崩潰。
為了幫助讀者理解,做一下解釋,以C函數(shù)的***條指令為例:
0x00034: C函數(shù)返回地址lr入棧指令; C函數(shù)指令1
0x00034:表示匯編指令的內存地址,反匯編的讀者應該熟悉
C函數(shù)返回地址lr入棧指令:表示具體指令的意思,不再用實際匯編指令表示,理解簡單
C函數(shù)指令1:表示C函數(shù)***條指令,為了引用的簡單
其中提到的lr,做過arm內核開發(fā)的讀者肯定熟悉,是CPU的一個寄存器,存儲函數(shù)返回地址,當C函數(shù)跳轉到B函數(shù)時,CPU自動將C函數(shù)的指令地址0x00048存入lr寄存器,這表示B函數(shù)執(zhí)行完返回后,CPU將從0x00048地址取指令繼續(xù)運行(mips架構是ra寄存器,先以arm為例)。
fp寄存器也是arm架構的一個CPU寄存器,英文釋義是frame point,中文有稱為棧幀寄存器,我們這里用來存儲每個函數(shù)棧的第2片內存地址(一片內存地址4個字節(jié),這樣稱呼是為了敘述方便),下方有詳細講解。為了方便讀者理解,特畫出函數(shù)執(zhí)行過程函數(shù)棧數(shù)據(jù)示意圖。
矩形框表示函數(shù)棧,初始化全為0,0x1000、0x1004等表示函數(shù)棧處于內存的地址,函數(shù)棧向下增長。每個函數(shù)前兩條指令都是入棧指令,每個函數(shù)指令執(zhí)行后只占用兩片內存。由于C函數(shù)是初始函數(shù),?;厮葸^程C函數(shù)棧意義不大,就從C函數(shù)跳轉到B函數(shù)指令開始分析。
此時fp寄存器保存的數(shù)據(jù)是C函數(shù)棧地址0x1010,原因下文會分析到。當執(zhí)行C函數(shù)指令5,跳轉到B函數(shù)后,棧指針sp指向地址0x100C(先假設,下文的講解可以驗證),B函數(shù)的返回地址也就是C函數(shù)的指令6的地址0x00048就會自動保存到CPU的lr寄存器,然后執(zhí)行B函數(shù)指令1, 就會將0x00048存入B函數(shù)棧地址0x100C,棧指針sp減一,指向B函數(shù)棧地址0X1008。
接著執(zhí)行B函數(shù)的指令2,將fp寄存器中的數(shù)據(jù)0x1010存入棧指針sp指向的內存地址0x1008,示意圖已經標明。接著執(zhí)行B函數(shù)指令3,將此時棧指針sp指向的地址0x1008(就是B函數(shù)的第二片內存)存入fp寄存器。
指令接著執(zhí)行,由B函數(shù)跳轉到A函數(shù),A函數(shù)前三條指令與B函數(shù)執(zhí)行情況類似,重點就三處,A函數(shù)棧的***片內存存儲A函數(shù)的返回地址,A函數(shù)棧的第二片內存存儲B函數(shù)棧的第二片內存地址,當A函數(shù)執(zhí)行到指令5后,fp寄存器保存的是A函數(shù)棧的第二片內存地址,示意圖中全部標出。當A函數(shù)執(zhí)行指令6崩潰,怎么?;厮?
A函數(shù)崩潰時,按照上文的分析,fp寄存器保存的數(shù)據(jù)是A函數(shù)棧的第二片內存首地址0X1000。0X1000地址中存儲的數(shù)據(jù)就是B函數(shù)的棧地址0x1008(就是B函數(shù)的棧的第二片內存),0x1000+4=0X1004地址就是A函數(shù)棧的***片內存,存儲的數(shù)據(jù)是A函數(shù)的返回地址0X0030,這個指令地址就是B函數(shù)的指令6地址,這樣就知道了時B函數(shù)調用了A函數(shù)。
因為此時已經知道了B函數(shù)棧的第二片內存地址,該地址的數(shù)據(jù)就是C函數(shù)棧的第二片內存地址,B函數(shù)棧的***片內存地址中的數(shù)據(jù)是B函數(shù)的返回地址0X0048(C函數(shù)的指令6內存地址)。這樣就倒著推出函數(shù)調用關系:A函數(shù)ßB函數(shù)ßC函數(shù)。
筆者認為,這種情況棧回溯的核心是:每個函數(shù)棧的第二片內存地址存儲的數(shù)據(jù)是上一級函數(shù)棧的第二片內存地址,每個函數(shù)棧的***片內存地址存儲的數(shù)據(jù)是函數(shù)返回地址。只要獲取到崩潰函數(shù)棧的第二片內存地址(此時就是fp寄存器的數(shù)據(jù)),就能循環(huán)計算出每一級調用的函數(shù)。
3.1.1 內核源碼分析
如果讀者對上一節(jié)的演示理解的話,理解下方的源碼就比較容易。
- arch/arm64/kerneltraps.c
內核崩潰時,產生異常,內核的異常處理程序自動將崩潰時的CPU寄存器存入struct pt_regs結構體,并傳入該函數(shù),相關代碼不再列出。這樣?;厮莸年P鍵環(huán)節(jié)就是紅色標注的代碼,先對frame.fp,frame.sp,frame.pc賦值。
下方進入while循環(huán),先執(zhí)行unwind_frame(&frame) 找出崩潰過程的每個函數(shù)中的匯編指令地址,存入frame.pc(***次while循環(huán)是直接where = frame.pc賦值,這就是當前崩潰函數(shù)的崩潰指令地址),下次循環(huán)存入where變量,再傳入dump_backtrace_entry函數(shù),在該函數(shù)中打印諸如[
這個打印的其實是在print_ip_sym函數(shù)中做的,將ip按照%pS形式打印,就能打印出該函數(shù)指令所在的函數(shù),以及相對函數(shù)首指令的偏移。棧回溯的重點是在unwind_frame函數(shù)。
在正式貼出代碼前,先介紹一下?;厮葸^程的三個核心CPU寄存器:pc、lr、fp。pc指向運行的匯編指令地址;sp指向函數(shù)棧;fp是棧幀指針,不同架構情況不同,但筆者認為它是?;厮葸^程中,聯(lián)系兩個有調用關系函數(shù)的紐帶,下面的分析就能體現(xiàn)出來。
- arch/arm64/kernel/stacktrace.c
首先說明一下,這是arm64位系統(tǒng),一個long型數(shù)據(jù)8個字節(jié)大小。為了敘述方便,假設內核代碼的崩潰函數(shù)流程還是 C函數(shù)->B函數(shù)->A函數(shù),在A函數(shù)崩潰,***在unwind_frame函數(shù)中?;厮?。
接著針對代碼介紹棧回溯的原理。***次執(zhí)行unwind_frame函數(shù)時,第二行,frame->fp保存的就是崩潰時CPU的fp寄存器的值,就是A函數(shù)棧第二片內存地址,frame->sp = fp + 0x10賦值后,frame->sp就是A函數(shù)的棧底地址;frame->fp= *(unsigned long *)(fp)獲取的是存儲在A函數(shù)棧第二片內存中的數(shù)據(jù),就是調用A函數(shù)的B函數(shù)的棧的第二片內存地址;frame->pc = *(unsigned long *)(fp + 8)是獲取A函數(shù)棧的***片內存中的數(shù)據(jù),就是A函數(shù)的返回地址(就是B函數(shù)中指令地址),這樣就知道了是B函數(shù)調用了A函數(shù);經過一次unwind_frame函數(shù)調用,就知道了A函數(shù)的返回地址和B函數(shù)的棧的第二片內存地址,有了B函數(shù)棧的第二片內存地址,就能按照上述過程推出B函數(shù)的返回地址(C函數(shù)的指令地址)和C函數(shù)棧的第二片內存地址,這樣就知道了時C函數(shù)調用了B函數(shù),如此循環(huán),不管有多少級函數(shù)調用,都能按照這個規(guī)律找出函數(shù)調用關系。當然這里的關系是是AßBßC。
為什么?;厮莸脑硎沁@樣?首先這個原理筆者都是實際驗證過的,細心的讀者應該會發(fā)現(xiàn),這個?;厮莸牧鞒谈拔牡?節(jié)演示的簡單?;厮菰硪粯?。是的,第2節(jié)就是筆者按照自己對arm 64位系統(tǒng)?;厮莸睦斫?,用簡單的形式表達出來,還附了演示圖,這里不了解的讀者可以回到第2節(jié)分析一下。
3.1.2 arm架構從匯編代碼角度解釋?;厮莸脑?/strong>
為了使讀者理解的更充分,下文列出一段應用層C語言代碼和反匯編后的代碼
C代碼
匯編代碼
分析test_2函數(shù)的匯編代碼,***條指令stpx29, x30,[sp,#-16],x29就是fp寄存器,x30就是lr寄存器,指令執(zhí)行過程:將x30(lr)、x29(fp)寄存器的值隨著棧指針sp向下偏移依次入棧,棧指針sp共偏移兩次8+8=16個字節(jié)(arm 64位系統(tǒng)棧指針sp減一偏移8個字節(jié),并且棧是向下增長,所以指令是-16)。
mov x29,sp 指令就是將棧指針賦予fp寄存器,此時sp就指向test_2函數(shù)棧的第二片內存,因為sp偏移了兩次,fp寄存器的值就是test_2函數(shù)棧的第二片內存地址。
去除不相關的指令,直接從test_2函數(shù)跳轉到test_1函數(shù)開始分析,看test_1函數(shù)的***條指令stp x29, x30,[sp,#-16],首先棧指針sp減一,將x30(lr)寄存器的數(shù)據(jù)存入test_1函數(shù)棧的***片內存,這就是test_1函數(shù)的返回地址,接著棧指針sp減一,將x29(fp)寄存器值入棧,存入test_1函數(shù)的第二片內存,此時fp寄存器的值正是test_2函數(shù)棧的第二片內存地址,本質就是將test_2函數(shù)棧的第二片內存地址存入test_1函數(shù)棧的第二片內存中。接著執(zhí)行mov x29,sp 指令,就是將棧指針sp賦予fp寄存器,此時sp指向test_1函數(shù)棧的第二片內存…..
這樣就與上一小結的分析一致了, 這里就對arm?;厮莸囊话氵^程,做個較為系統(tǒng)的總結:當C函數(shù)跳轉的B函數(shù)時,先將B函數(shù)的返回地址存入B函數(shù)棧的***片內存,然后將C函數(shù)棧的第二片內存地址存入B函數(shù)棧的第二片內存,接著將B函數(shù)棧的第二片內存地址存入fp寄存器,B函數(shù)跳轉到A函數(shù)流程也是這樣。
當A函數(shù)中崩潰時,先從fp寄存器中獲取A函數(shù)棧的第二片內存地址,從中取出B函數(shù)棧的第二片內存地址,再從A函數(shù)棧的***片內存取出A函數(shù)的返回地址,也就是B函數(shù)中的指令地址,這樣就推導出B函數(shù)調用了A函數(shù),同理推導出C函數(shù)調用了B函數(shù)。
演示的代碼很簡答,但是這個分析是適用于復雜函數(shù)的,已經實際驗證過。
3.1.3 arm 內核棧回溯的“bug”
這個不是我危言聳聽,是實際測出來的。比如如下代碼:
這個函數(shù)調用流程在內核崩潰了,內核?;厮菔遣粫蛴∩线叺腷函數(shù),有arm 64系統(tǒng)的讀者可以驗證一下,我多次驗證得出的結論是,如果崩潰的函數(shù)沒有執(zhí)行其他函數(shù),就會打亂?;厮菀?guī)則,為什么呢?請回頭看上一節(jié)的代碼演示
匯編代碼是
可以發(fā)現(xiàn),test_a_函數(shù)前兩條指令不是stpx29, x30,[sp,#-16]和mov x29,sp,這兩條指令可是?;厮莸年P鍵環(huán)節(jié)。怎么解決呢?仔細分析的話,是可以解決的。
一般情況,函數(shù)崩潰,fp寄存器保存的數(shù)據(jù)是當前函數(shù)棧的第二片內存地址,當前函數(shù)棧的***片內存地址保存的是函數(shù)返回地址,從該地址取出的數(shù)據(jù)與lr寄存器的數(shù)據(jù)應是一致的,因為lr寄存器保存的也是函數(shù)返回地址,如果不相同,說明該函數(shù)中沒有執(zhí)行stp x29, x30,[sp,#-16]指令,此時應使用lr寄存器的值作為函數(shù)返回地址,并且此時fp寄存器本身就是上一級函數(shù)棧的第二片內存地址,有了這個數(shù)據(jù)就能按照前文的方法?;厮萘?。解決方法就是這樣,讀者可以仔細體會一下我的分析。
3.2 mips ?;厮葸^程
前文說過,mips內核崩潰處理流程是
- do_page_fault()->die()->show_registers()->show_stacktrace()->show_backtrace()
打印崩潰函數(shù)流程是在show_backtrace()函數(shù)。
3.2.1 mips 架構內核?;厮菰矸治?/strong>
- arch/mips/kernel/ traps.c
可以發(fā)現(xiàn),與arm架構?;厮萘鞒袒疽恢隆:瘮?shù)開頭是對sp、ra、pc寄存器器賦值,sp和pc與arm架構一致,ra相當于arm架構的lr寄存器,沒有arm架構的fp寄存器。print_ip_sym函數(shù)就是根據(jù)pc值打印形如[
如下是mips架構內核驅動ko文件的 C代碼和匯編代碼。
C代碼
匯編代碼
這里說明一下,驅動ko反匯編出來的指令是從0地址開始的,為了敘述方便,筆者加了0x80000000,實際的匯編代碼不是這樣的。
這里直接介紹根據(jù)筆者的分析,總結mips架構內核?;厮莸脑?,分析完后再結合源碼驗證。mips架構沒有fp寄存器,假設在test_c函數(shù)中0X80000048地址處指令崩潰了,首先利用內核的kallsyms模塊,根據(jù)崩潰時的指令地址找出該指令是哪個函數(shù)的指令,并且找出該指令地址相對函數(shù)指令首地址的偏移ofs,在本案例中ofs = 0X10(0X80000048 – 0X80000038 =0X10),這樣就能算出test_c函數(shù)的指令首地址是 0X80000048 - 0X10 = 0X80000038。然后就從地址0X80000038開始,依次取出每條指令,找到addiu sp,sp,-24 和sw ra,20(sp),內核有標準函數(shù)可以判斷出這兩條指令,下文可以看到。
addiu sp,sp,-24是test_c函數(shù)的***條指令,棧指針向下偏移24個字節(jié),筆者認為是為test_c函數(shù)分配棧大小( 24個字節(jié));sw ra,20(sp)指令將test_c函數(shù)返回地址存入sp +20 內存地址處,此時sp指向的是test_c函數(shù)的棧頂,sp+20就是test_c函數(shù)棧的第二片內存,該函數(shù)棧大小24字節(jié),一共24/4=6片內存。
根據(jù)sw ra,20(sp)指令知道test_c函數(shù)返回地址在test_c函數(shù)棧的存儲位置,取出該地址的數(shù)據(jù),就知道是test_a函數(shù)的指令地址,當然就知道是test_a函數(shù)調用了test_c函數(shù)。并根據(jù)addiu sp,sp,-24指令知道test_c函數(shù)??傆?4字節(jié),因為test_c函數(shù)崩潰時,棧指針sp指向test_c函數(shù)棧頂,sp+24就是test_a函數(shù)的棧頂,因為test_a函數(shù)調用了test_c函數(shù),兩個函數(shù)的棧必是緊挨著的。
按照上述推斷,首先知道了test_a函數(shù)中的指令地址了,使用內核kallsyms功能就推算出test_a函數(shù)的指令首地址,同時也計算出test_a函數(shù)的棧頂,就能按照上述規(guī)律找出誰調用了test_a函數(shù),以及該函數(shù)的棧頂。依次就能找出所有函數(shù)調用關系。
關于內核的kallsyms,筆者的理解是:執(zhí)行過cat /proc/kallsyms命令的讀者,應該了解過,該命令會打印內核所有的函數(shù)的首地址和函數(shù)名稱,還有內核編譯后生成的System.map文件,記錄內核函數(shù)、變量的名稱與內存地址等等,kallsyms也是記錄了這些內容,當執(zhí)行kallsyms_lookup_size_offset(0X80000048, &size,&ofs)函數(shù),就能根據(jù)0X80000048指令地址計算出處于test_c函數(shù),并將相對于test_c函數(shù)指令首地址的偏移0X10存入ofs,test_c函數(shù)指令總字節(jié)數(shù)存入size。
筆者沒有研究過kallsyms模塊,但是可以理解到,內核的所有函數(shù)都是按照分配的地址,順序排布。如果記錄了每個函數(shù)的首地址和名稱,當知道函數(shù)的任何一條指令地址,就能在其中搜索比對,找到該指令處于按個函數(shù),計算出函數(shù)首地址,該指令的偏移。
3.2.2 mips 架構內核?;厮莺诵脑创a分析
3.2.1詳細講述了mips?;厮莸脑?,接著講解棧回溯的核心函數(shù)unwind_stack_by_address。
上述源碼已經在關鍵點做了詳細注釋,其實就是對3.2.1節(jié)?;厮菰淼耐晟疲堊x者自己分析,這里不再贅述。但是有一點請注意,就是藍色注釋,這是針對崩潰的函數(shù)沒有執(zhí)行其他函數(shù)的情況,此時該函數(shù)沒有類似匯編指令sw ra,20(sp) 將函數(shù)返回地址保存到棧中,計算方法就變了,要直接使用ra寄存器的值作為函數(shù)返回地址,計算上一級函數(shù)棧頂?shù)姆椒ㄟ€是一致的,后續(xù)棧回溯的方法與前文相同。
4 linux內核棧回溯的應用
文章最開頭說過,筆者在實際項目開發(fā)過程,已經總結出了3個內核?;厮莸膽茫?/p>
1 應用程序崩潰,像內核?;厮菀粯哟蛴≌麄€崩潰過程,應用函數(shù)的調用關系
2 應用程序發(fā)生double free,像內核棧回溯一樣打印double free過程,應用函數(shù)的調用關系
3 內核陷入死循環(huán),sysrq的內核線程?;厮莨δ軣o法發(fā)揮作用時,在系統(tǒng)定時鐘中斷函數(shù)中對卡死線程棧回溯,找出卡死位置
下文逐一講解。
4.1 應用程序崩潰?;厮?/strong>
筆者在研究過內核棧回溯功能后,不禁發(fā)問,為什么不能用同樣的方法對應用程序的崩潰棧回溯呢?不管是內核空間,應用空間,程序的指令是一樣的,無非是地址有差異,函數(shù)入棧出棧原理是一樣的。?;厮莸娜肟?,arm架構是獲取崩潰線程/進程的pc、fp、lr寄存器值,mips架構是獲取pc、ra、sp寄存器值,有了這些值就能按照各自的回溯規(guī)律,實現(xiàn)棧回溯。從理論上來說,完全是可以實現(xiàn)的。
4.1 .1 arm架構應用程序棧回溯的實現(xiàn)
當應用程序發(fā)生崩潰,與內核一樣,系統(tǒng)自動將崩潰時所有的CPU寄存器存入struct pt_regs結構,一般崩潰入口函數(shù)是do_page_fault,又因為是應用程序崩潰,所以是__do_user_fault函數(shù),這里直接分析__do_user_fault。
在該函數(shù)中,tsk就是崩潰的線程,struct pt_regs *regs就指向線程/進程崩潰時的CPU寄存器結構。regs->[29]就是fp寄存器,regs->[30]是lr寄存器, regs->pc的意義很直觀?,F(xiàn)在有了崩潰應用線程/進程當時的fp、sp、lr寄存器,就能棧回溯了,完全仿照內核dump_backtrace的方法,請看筆者寫在user_thread_ dump_backtrace函數(shù)中的演示代碼。
與內核?;厮菰硪恢?,打印崩潰過程每個函數(shù)的指令地址,然后在應用程序的反匯編文件中查找,就能找到該指令處于的函數(shù),如果不理解,請看文章前方講解的內核棧回溯代碼與原理。請注意,這不是筆者項目實際用的?;厮荽a,實際的改動完善了很多,這只是演示原理的示例代碼。
還有一點就是,筆者在3.1.3節(jié)提到的,假如崩潰的函數(shù)中沒有調用其他函數(shù),那上述棧回溯就會有問題,就不會打印第二級函數(shù),解決方法講的也有,解決的代碼這里就不再列出了。
4.1 .2 mips架構應用程序?;厮莸膶崿F(xiàn)
mips 架構不僅內核棧回溯的代碼比arm復雜,應用程序的?;厮莞鼜碗s,還有未知bug,即便這樣,還是講解一下具體的解決思路,***講一下存在的問題。
先簡單回顧一下內核?;厮莸脑恚紫雀鶕?jù)崩潰函數(shù)的pc值,運用內核kallsyms模塊,計算出該函數(shù)的指令首地址,然后從指令首地址開始分析,找出類似addiu sp,sp,-24和sw ra,20(sp)指令,前者可以找到該函數(shù)的棧大小,棧指針sp加上這個數(shù)值,就知道上一級函數(shù)的棧頂?shù)刂?崩潰時sp指向崩潰函數(shù)的棧頂);后者知道函數(shù)返回地址在該函數(shù)棧中存儲的地址,從該地址就能獲取該函數(shù)的返回地址,就是上一級函數(shù)的指令地址,也就知道了上一級函數(shù)是哪個(同樣使用內核kallsyms模塊)。
知道了上一級函數(shù)的指令地址和棧頂?shù)刂罚凑胀瑯臃椒?,就能知道再上一級的函?shù)…….
問題來了,內核有kallsyms模塊記錄了每個函數(shù)的首地址和函數(shù)名字,函數(shù)還是順序排布。應用程序并沒有kallsyms模塊,即便知道了崩潰函數(shù)的pc值,也無法按照同樣的方法找到崩潰函數(shù)的指令首地址,真的沒有方法?其實還有一個最簡單的方法。先列出一段一個應用程序函數(shù)的匯編代碼,如下所示,與內核態(tài)的有小的差別。
現(xiàn)在假如從0X4006a4地址處取指,運行后崩潰了。崩潰發(fā)生時,能像arm架構一樣獲取崩潰前的CPU寄存器值,最重要就是pc、sp、ra值。
pc值就是0X4006a4,然后令一個unsigned long型指針指向該內存地址0X4006a4,每次減一,并取出該地址的指令數(shù)據(jù)分析,這樣肯定能分析到addiu sp,sp,-32 和sw ra,28(sp)指令,我想看到這里,讀者應該可以清楚方法了。沒錯,就是以崩潰時pc值作為基地址,每次減1并從對應地址取出指令分析,直到分析出久違的addiu sp,sp,-32 和sw ra,28(sp)類似指令,再結合崩潰時的棧指針值sp,就能計算出該函數(shù)的返回地址和上一級函數(shù)的棧頂?shù)刂?。后續(xù)的方法,就與內核?;厮莸倪^程一致了。下方列出演示的代碼。
為了一致性,應用程序?;厮莸暮瘮?shù)還是采用名字user_thread_ dump_backtrace。
如上就是mips應用程序棧回溯的示例代碼,只是一個演示,筆者實際使用的代碼要復雜太多。讀者使用時,要基于這個基本原理,多調試,才能應對各種情況,筆者前后調試幾周才穩(wěn)定。由于這個方法并不是標準的,實際使用時還是會出現(xiàn)誤報函數(shù)現(xiàn)象,分析了發(fā)生誤報的匯編代碼及C代碼,發(fā)現(xiàn)當函數(shù)代碼復雜時,函數(shù)的匯編指令會變得非常復雜,會出現(xiàn)相似指令等等,讀者實際調試時就會發(fā)現(xiàn)。這個mips應用程序棧回溯的方法,可以應對大部分崩潰情況,但是有誤報的可能,優(yōu)化的空間非常大,這點請讀者注意。
4.2 應用程序double free 內核?;厮?/strong>
double free是在C庫層發(fā)生的,正常情況內核無能為力,但是筆者研究過后,發(fā)現(xiàn)照樣可以實現(xiàn)對發(fā)生double free應用進程的?;厮荨?/p>
以arm架構為例,doublefree C庫層的代碼,大體原理是,當檢測到double free(本人實驗時,一片malloc分配的內存free兩次就會發(fā)生),就會執(zhí)行kill系統(tǒng)調用函數(shù),向出問題的進程發(fā)送SIGABRT信號,既然是系統(tǒng)調用,從用戶空間進入內核空間時,就會將應用進程用戶空間運行時的CPU寄存器pc、sp、lr等保存到進程的內核棧中,發(fā)送信號內核必然執(zhí)行send_signal函數(shù)。
在該函數(shù)中,使用struct pt_regs *regs = task_pt_regs(current)方法就能從當前進程內核棧中獲取進入內核空間前,用戶空間運行指令的pc、sp、fp等CPU寄存器值,有了這些值,就能按照用戶空間進程崩潰棧回溯方法一樣,對double free的進程?;厮萘?。比如,A函數(shù)double free,A函數(shù)->C庫函數(shù)1-> C庫函數(shù)2->C庫函數(shù)3(檢測到double free并發(fā)送SIGABRT信號,執(zhí)行系統(tǒng)調用進入內核空間發(fā)送信號)?;厮莸慕Y果是:C庫函數(shù)3 ß C庫函數(shù)2 ß C庫函數(shù)1ß A函數(shù)。
源碼不再列出,相信讀者理解的話是可以自己開發(fā)的。其中task_pt_regs函數(shù)的使用,需要讀者對進程內核棧有一定的了解。
筆者有個理解,當獲取某個進程運行指令某一時間點的CPU寄存器pc、lr、fp的值,就能對該進程進行?;厮荨?/p>
4.3 內核發(fā)生死循環(huán)sysrq無效時?;厮莸膽?/strong>
內核的sysrq中有一個方法,執(zhí)行后可以對所有線程進行內核空間函數(shù)?;厮?,但是本人遇到過一次因某個外設導致的死循環(huán),該方法打印的棧回溯信息都是內核級的函數(shù),沒有頭緒。于是,嘗試在系統(tǒng)定時鐘中斷函數(shù)中實現(xiàn)卡死線程的?;厮?也可以在account_process_tick內核標準函數(shù)中,系統(tǒng)定時鐘中斷函數(shù)會執(zhí)行到)。
原理是,當一個內核線程卡死時,首先考慮在某個函數(shù)陷入死循環(huán),系統(tǒng)定時鐘中斷是不斷產生的,此時current線程很大概率就是卡死線程(要考慮內核搶占,內核支持搶占時,內核某處陷入死循環(huán)照樣可以調度出去),然后使用struct pt_regs *regs = get_irq_regs()方法,就能獲取中斷前線程的pc、sp、fp等寄存器值,有了這些值,就能按照內核線程崩潰?;厮菰恚瑢ㄋ谰€程函數(shù)調用過程?;厮荩业娇ㄋ篮瘮?shù)。mips架構棧回溯的核心函數(shù)show_backtrace()定義如下,只要傳入內核線程的struct task_struct和structpt_regs結構,就能對內核線程當時指令的執(zhí)行進行?;厮荨?/p>
- static void show_backtrace(struct task_struct *task, const struct pt_regs *regs)
4.4 應用程序鎖死時對所有應用線程的?;厮?/strong>
以arm架構為例。當應用鎖死,尤其是偶現(xiàn)的鎖死卡死問題,可以使用棧回溯的思路解決。以單核CPU為例,應用程序的所有線程,正常情況,兩種狀態(tài):正在運行和其他狀態(tài)(大部分情況是休眠)。
休眠的應用線程,一般要先進入內核空間,將應用層運行時的pc、lr、fp等寄存器存入內核棧,執(zhí)行schdule函數(shù)讓出CPU使用權,***線程休眠。此時可以通過tesk_pt_regs函數(shù)從線程內核棧中獲取線程進入內核空間前的pc、lr、fp等寄存器的數(shù)據(jù)。正在運行的應用線程,系統(tǒng)定時鐘中斷產生后,系統(tǒng)要執(zhí)行硬件定時器中斷,此時可以通過get_irq_regs函數(shù)獲取中斷前的pc、lr、fp等寄存器的值。
不管應用線程是否正在運行,都可以獲取線程當時用戶空間運行指令的pc、lr、fp等寄存器數(shù)據(jù)。當應用某個線程,不管是使用鎖異常而長時間休眠,還是陷入死循環(huán),從內核的進程運行隊列中,依次獲取到所有應用線程的pc、lr、fp等寄存器的數(shù)據(jù)后(可以考慮在account_process_tick函數(shù)實現(xiàn)),就可以按照前文思路對應用線程棧回溯,找出懷疑點。
實際使用時,要防止內核線程的干擾,task->mm可以用來判斷,內核線程為NULL。當然也可以通過線程名字加限制,對疑似的幾個線程棧回溯。應用線程正在內核空間運行時,這種情況用這個方法就有問題,這時需加限制,比如通過get_irq_regs函數(shù)獲取到 pc值后,判斷是在內核空間還是用戶空間。讀者實現(xiàn)該功能時,有不少其他細節(jié)要注意,這里不再一一列出。
5 應用程序?;厮莸恼雇?/strong>
關于應用程序的棧回溯,筆者正在考慮一個方法,使應用程序的?;厮菽苷嬲駜群艘粯哟蛴〕龊瘮?shù)的符號及偏移,比如
現(xiàn)有的方法只能實現(xiàn)如下效果:
之后還得對應用程序反匯編才能找到崩潰的函數(shù)。
筆者的分析是,理論上是可以實現(xiàn)的,只要仿照內核的kallsyms方法,按照順序記錄每個應用函數(shù)的函數(shù)首地址和函數(shù)名字到一個文件中,當應用程序崩潰時,內核中讀取這個文件,根據(jù)崩潰的指令地址在這個文件中搜索,就能找到該指令處于哪個函數(shù)中,本質還是實現(xiàn)了與內核kallsyms類似的方法。有了這個功能,不僅應用程序棧回溯能打印函數(shù)的名稱與偏移,還能讓mips架構應用程序崩潰的棧回溯按照內核崩潰?;厮莸脑韥韺崿F(xiàn),不會再出現(xiàn)函數(shù)誤報現(xiàn)象,不知讀者是否理解我的思路?后續(xù)有機會,會嘗試開發(fā)這個功能并分享出來。
6總結
實際項目調試時,發(fā)現(xiàn)?;厮莸膽脙r值非常大,掌握棧回溯的原理,不僅對內核調試有很大幫助,對加深內核的理解也是有不少益處。
這是本人***次投稿,經驗不足,文章可能也有失誤的地方,請讀者及時提出,但是筆者保證,文章講解的內容都是經過理論和實際驗證的,不會有原理性偏差。有問題請發(fā)往筆者郵箱。后續(xù)有機會,筆者會將內存管理、文件系統(tǒng)方面的總結分享出來。