dump_stack介绍以及内核符号表的生成和查找过程
內核中的dump_stack()
獲得內核中當前進程的棧回溯信息需要用到的最重要的三個內容就是:
棧指針:sp寄存器,用來跟蹤程序執行過程。
返回地址:ra寄存器,用來獲取函數的返回地址。
程序計數器:epc,用于定位當前指令的位置。
本文的內容都是基于mips體系架構的,如果你不搞mips,就只看個大致流程就可以了,不然可能會被某些內容誤導。在ARM中,這三個寄存器分別為SP、LR和PC寄存器。
dump_stack()用于回溯函數調用關系,他需要做的工作很簡單:
1.???從進程棧中找到當前函數(callee)的返回地址。
2.???根據函數返回地址,從代碼段中定位該地址位于哪個函數中,找到的函數即為caller函數。
3.???打印caller函數的函數名。
4.???重復前3個步驟。直到返回值為0或不在內核記錄的符號表范圍內。
在編譯程序的時候,所有函數所需要的棧空間的大小都已經計算出來,如果函數需要保存返回地址,返回地址在該函數的棧空間中保存的位置也都計算出來了。所以,我們想得到返回地址,只需得到每個函數棧即可,而所有函數棧都放在進程的棧中,棧頂為sp。
返回地址是caller函數中將要執行的指令,是指向代碼段的,這個更容易得到,因為代碼段在編譯時就確定了。
當前函數的位置通過pc的值可以得到。
例如,現在有func0調用func1,func1又調用func2,在func2執行過程中,進程棧空間大致如下:
左圖為棧空間,棧頂為sp,右圖為程序代碼的部分內容。右圖中的實曲線表示出了函數之間的調用和返回關系。調用關系通過跳轉指令完成,返回地址通過左圖每個函數棧空間中存儲的返回地址指定。這樣我們就可以得到函數的調用關系,并通過每個函數的地址打印出函數名。
那dump_stack的工作流程就很清楚了。我就不帖代碼了,因為基本上都是體系結構相關的操作。
需要說明的一個地方是,通過函數的地址來打印函數名是通過格式控制符%pS來打印的:
printk("[<%p>] %pS\n", (void *) ip,(void *) ip);
在內核代碼樹的lib/vsprintf.c中的pointer函數中,說明了printk中的%pS的意思:
[cpp]?view plaincopy
即'S'表示打印符號名,而這個符號名是kallsyms里獲取的。
可以看一下kernel/kallsyms.c中的kallsyms_lookup()函數,它負責通過地址找到函數名,分為兩部分:
1. 如果地址在編譯內核時得到的地址范圍內,就查找kallsyms_names數組來獲得函數名。
2. 如果這個地址是某個內核模塊中的函數,則在模塊加載后的地址表中查找。
kallsyms_lookup()最終返回字符串“函數名+offset/size[mod]”,交給printk打印。
關于內核符號表kallsyms_names可參考我的另一篇文章點擊打開鏈接。
實現應用程序中的dump_stack()
按照如上所述,實現一個用戶態程序的dump_stack好像不是什么難事,因為上面說的步驟在用戶態都可以完成,程序運行的方式也基本上是相同的。
那我們實現一個dump_stack需要做的事情只有兩點:
1.???獲得程序當前運行時間點的pc值和棧指針sp。這樣就可以得到每個函數棧中的返回地址。
2.???構造和內核符號表相同的應用程序符號表。
需要注意,不同用戶進程都擁有自己的虛擬地址空間,所以棧回溯只能在本進程中完成。
具體實現當然也是體系結構相關的。既然原理都知道了,那我就直接給出代碼供參考(mips的)。代碼見https://github.com/castoz/backtrace。
其中backtrace.c實現了棧回溯,uallsyms.c用于生成符號表,main.c中為測試代碼。
backtrace.c中提供了兩個接口供其他文件調用:
show_backtrace():打印函數的回溯信息。
addr_to_name(addr):打印addr對應的函數名。
uallsyms.c文件直接使用內核中的scripts/kallsyms.c,只需要做少量修改,具體的改動為:
1. 符號基準地址改為__start。
2. 需要記錄的符號范圍改為在_init到_fini之間或_init到_end之間。
3. 維護uallsyms_addresses、uallsyms_num_syms和uallsyms_names三個全局變量,不使用壓縮算法,所以不需要其他三個全局變量。
4. 在生成的匯編代碼中刪除"#include <asm/types.h>"一行,因為在編譯時不需要。
測試文件main.c的內容:
[cpp]?view plaincopy
運行結果:
[plain]?view plaincopy
參考自:http://blog.csdn.net/jasonchen_gbd/article/details/44066815
在內核中維護者一張符號表,記錄了內核中所有的符號(函數、全局變量等)的地址以及名字,這個符號表被嵌入到內核鏡像中,使得內核可以在運行過程中隨時獲得一個符號地址對應的符號名。而內核代碼中可以通過?printk("%pS\n", addr)?打印符號名。
本文介紹內核符號表的生成和查找過程。
1. System.map和/proc/kallsyms
System.map文件是編譯內核時生成的,它記錄了內核中的符號列表,以及符號在內存中的虛擬地址。這個文件通過nm命令生成,具體可參考內核目錄下的scripts/mksysmap腳本。System.map中每個條目由三部分組成,例如:
f0081e80 T alloc_vfsmnt
即“地址? 符號類型? 符號名”
其中符號類型有如下幾種:
- ? A =Absolute
- ? B =Uninitialised data (.bss)
- ? C = Comonsymbol
- ? D =Initialised data
- ? G =Initialised data for small objects
- ? I = Indirectreference to another symbol
- ? N =Debugging symbol
- ? R = Readonly
- ? S =Uninitialised data for small objects
- ? T = Textcode symbol
- ? U =Undefined symbol
- ? V = Weaksymbol
- ? W = Weaksymbol
- ?Corresponding small letters are local symbols
/proc/kallsyms文件是在內核啟動后生成的,位于文件系統的/proc目錄下,實現代碼見kernel/kallsyms.c。前提是內核必須打開CONFIG_KALLSYMS編譯選項。它和System.map的區別是它同時包含了內核模塊的符號列表。
通常情況下我們只需要_stext~_etext和_sinittext~_einittext之間的符號,如果需要將nm命令獲得的所有符號都記錄下來,則需要開啟內核的CONFIG_KALLSYMS_ALL編譯選項,不過一般是不需要打開的。
2. 內核符號表
內核在執行過程中,可能需要獲得一個地址所在的函數,比如在輸出某些調試信息的時候。一個典型的例子就是使用dump_stack()函數打印棧回溯信息。
但是內核在查找一個地址對應的函數名時,沒有求助于上述兩個文件,而是在編譯內核時,向vmlinux嵌入了一個符號表,這樣做可能是為了方便快速的查找并避免文件操作帶來的不良影響。
2.1 內核符號表的結構
內嵌的符號表是通過內核目錄下的scripts/kallsyms工具生成的,工具的源碼為相同目錄下的kallsyms.c。這個工具的用法如下:
[plain]?view plaincopy
可見同樣是通過nm命令得到vmlinux的符號表,并將這些符號表信息進行調整,最終生成一個匯編文件。這個匯編文件中包含了6個全局變量:kallsyms_addresses,kallsyms_num_syms,kallsyms_names,kallsyms_markers,kallsyms_token_table和kallsyms_token_index,其中:
- kallsyms_addresses:一個數組,存放所有符號的地址列表,按地址升序排列。
- kallsyms_num_syms:符號的數量。
- kallsyms_names:一個數組,存放所有符號的名稱,和kallsyms_addresses一一對應。
其他三個全局變量的含義后續會提到。
這些變量被嵌入在vmlinux中,所以在內核代碼中直接extern就可以使用。例如dump_stack()就是通過這些變量來查找一個地址對應的函數名的。
那由scripts/kallsyms生成的匯編文件是如何嵌入到vmlinux中的呢。在編譯內核的后期主要進行了一下幾步額外的編譯和鏈接過程:
此時,上面的那6個全局變量被寫進vmlinux中的“.rodata”段(所以還是叫全局常量吧),內核代碼就可以使用了,使用前需extern一下:
[cpp]?view plaincopy
weak屬性表示當我們不確定外部模塊是否提供了某個變量或函數時,可以將這個變量或函數定義為弱屬性,如果外部有定義則使用,沒有定義則相當于自己定義。
在使用這6個全局常量之前,我們先要弄清楚他們都是干什么用的。kallsyms_addresses、kallsyms_num_syms和kallsyms_names在前面已經講過,實際上他們已經可以提供一個[地址 : 符號]的映射關系了,但是內核中幾萬個符號這樣一條一條的存起來會占用大量的空間,所以內核采用一種壓縮算法,將所有符號中出現頻率較高的字符串記錄成一個個的token,然后將原來的符號中和token匹配的子串進行壓縮,這樣可以實現使用一個字符來代替n個字符,以減小符號存儲長度。
因此符號表維護了一個kallsyms_token_table,他有256個元素,對應一個字節的長度。由于符號名的只能出現下劃線、數字和字母,那在kallsyms_token_table[256]數組中,除了這些字符的ASCII碼對應的位置,還有很多未被使用的位置就可以用來存儲壓縮串。kallsyms_token_table表的內容像下面這樣:
[cpp]?view plaincopy
那我們在表示一個函數名時,就可以用0x00來表示“end”,用0x04來表示“to_”等。沒有被壓縮的如0x61仍然表示“a”。
kallsyms_token_index記錄每個token首字符在kallsyms_token_table中的偏移。同token table共256條,在打印token時需要用到。
[cpp]?view plaincopy
至于kallsyms_token_table表是如何生成的,可以閱讀scripts/kallsyms.c的實現,大致就是將所有符號出現的相鄰的兩個字符出現的次數都記錄起來,例如對于“nf_nat_nf_init”,就記錄下“nf”、“f_”、“_n”、“na”、……,每兩個字符組合出現的次數記錄在token_profit[0x10000]數組中(兩個字符一組,共有2^8 * 2^8 = 0x10000中可能組合),然后挑選出現次數最多的一個組合形成一個token,比如用“g”來表示“nf”,那“nf_nat_nf_init”就被改為“g_nat_g_init”。接下來,再在修改后的所有符號中計算每兩個字符的出現次數來挑選出現次數最多的組合,例如用“J”來表示“g_”,那“g_nat_g_init”又被改為“Jnat_Jinit”。直到生成最終的token表。
2.2 內核查找一個符號的過程
這時還沒講到全局常量kallsyms_markers。我們先來看內核如何根據這六個全局常量來查找一個地址對應的函數名的,實現函數為kernel/kallsyms.c中的kallsyms_lookup()。
我不講函數實現,只是用一個例子來說明內核符號的查找過程:
比如我在內核中想打印出0x80216bf4地址所在的函數。首先不管內核怎么做,我們可以先在System.map文件中看到這個地址位于為nf_register_hook和nf_register_hooks兩個符號之間,那可以確定它屬于nf_register_hook函數了。
80060000 A?_text
... ...
80216b8c T nf_unregister_hooks
80216be4 T nf_register_hook
80216c8c T nf_register_hooks
... ...
注意,System.map和內核啟動后的/proc/kallsyms文件中的符號表只是給我們看的,內核不會使用它們。
在由script/kallsyms工具生成的.tmp_kallsyms2.S文件中,kallsyms_addresses數組存放著所有符號的地址,并且是按照地址升序排列的,所以通過二分查找可以定位到0x80216bf4所在函數的起始地址是下面的這個條目:
kallsyms_addresses:
?? ... ...
??? PTR?_text?+ 0x1b6be4
?? ... ...
而這一項在kallsyms_addresses中的index為8801,所以現在需要找到kallsyms_names中的第8801個符號。
我們這時實際上可以在kallsyms_names進行查找了,怎么找呢?我們先看一下kallsyms_names大致的樣子:
[cpp]?view plaincopy
其中每一行存儲一個壓縮后的符號,而index和kallsyms_addresses中的index是一一對應的。每一行的內容分為兩部分:第一個byte指明符號的長度,后續才是符號自身。雖然我們這里看到的符號是一行一行分開的,但實際上kallsyms_names是一個unsigned char的數組,所以想要找第8801個符號,只能這樣來找:
1. 從第一個字節開始,獲得第一個符號的長度len;
2. 向后移len+1個字節,就達到第二個符號的長度字節,這時記錄下已經走過的總長度;
3. 重復前兩步的動作,直到走過的總長度為8801。
這樣找的話,要找到kallsyms_names的第8801個符號就要移動8801次,那如果要尋找最后一個符號,就要移動更多次,時間耗費較多,所以內核通過一個kallsyms_markers數組進行查找。
將kallsyms_names每256個符號分為一組,每一組的第一個字符的位置記錄在kallsyms_markers中,這樣,我們在找kallsyms_names中的某個條目時,可以快速定義到它位于那個組,然后再在組內尋找,組內移動次數最多為255次。
所以我們先通過(8801 >> 8)得到了要找的符號位于第34組,
我們看到kallsyms_markers的第34項為:
??? PTR 91280
這個值指明了kallsyms_names中第34組的起始字符的偏移,所以我們直接找到kallsyms_names[91280]位置,即是第34組所有符號的第一個字節。同時我們可以通過(8801 && 0xFF)得到要找的符號在第34組組內的序號為97,即第97個符號。
接下來尋找第97個符號就只能通過上面講到的方法了。
通過上面一系列的查找,我們定位到第34組中第97個符號如下:
.byte 0x08, 0x05, 0x66, 0xdc, 0xb6, 0xc8, 0x68, 0x6f,0x0b
這個是壓縮后的符號,第一個字節0x08是符號長度,所以我們接下來的任務就剩下解壓了。
每個字節解壓后對應的字符串在kallsyms_token_table中可以找到。于是在kallsyms_token_table表中尋找第5(0x05)項、第5(0x05)項、第102(0x66)項、……、第11(0x0b)項,得到的結果分別為:
"Tn", "f", "_re","gist", "er_", "h", "o", "ok"
由于在壓縮的時候將符號類型“T”也壓進去了,所以要去掉第一個字符,至此就獲得了0x80216bf4地址所在的函數為nf_register_hook。
參考自:http://blog.csdn.net/jasonchen_gbd/article/details/44025681
3. 內核模塊的符號
內核模塊是在內核啟動過程中動態加載到內核中的,所以,不能試圖將模塊中的符號嵌入到vmlinux中。加載模塊時,模塊的符號表被存放在該模塊的struct module結構中。所有已加載的模塊的structmodule結構都放在一個全局鏈表中。
在查找一個內核模塊的符號時,調用的函數依然是kallsyms_lookup(),模塊符號的實際查找工作在get_ksymbol()函數中完成。
附錄:一個.tmp_kallsyms2.S文件
[cpp]?view plaincopy
總結
以上是生活随笔為你收集整理的dump_stack介绍以及内核符号表的生成和查找过程的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 新连接?新商业 一场关于商业变革的活动正
- 下一篇: 手机扫码登录实现原理