開發一個 Linux 調試器(四):Elves 和 dwarves
到目前為止,你已經偶爾聽到了關於 dwarves、調試信息、一種無需解析就可以理解源碼方式。今天我們會詳細介紹源碼級的調試信息,作為本指南後面部分使用它的準備。
系列文章索引
隨著後面文章的發布,這些鏈接會逐漸生效。
ELF 和 DWARF 簡介
ELF 和 DWARF 可能是兩個你沒有聽說過,但可能大部分時間都在使用的組件。ELF([Executable and Linkable Format](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format "Executable and Linkable Format"),可執行和可鏈接格式)是 Linux 系統中使用最廣泛的目標文件格式;它指定了一種存儲二進位文件的所有不同部分的方式,例如代碼、靜態數據、調試信息以及字元串。它還告訴載入器如何載入二進位文件並準備執行,其中包括說明二進位文件不同部分在內存中應該放置的地點,哪些位需要根據其它組件的位置固定(重分配)以及其它。在這些博文中我不會用太多篇幅介紹 ELF,但是如果你感興趣的話,你可以查看這個很好的信息圖或該標準。
[DWARF](https://en.wikipedia.org/wiki/DWARF "DWARF WIKI")是通常和 ELF 一起使用的調試信息格式。它不一定要綁定到 ELF,但它們兩者是一起發展的,一起工作得很好。這種格式允許編譯器告訴調試器最初的源代碼如何和被執行的二進位文件相關聯。這些信息分散到不同的 ELF 部分,每個部分都銜接有一份它自己的信息。下面不同部分的定義,信息取自這個稍有過時但非常重要的 DWARF 調試格式簡介:
.debug_abbrev
.debug_info
部分使用的縮略語.debug_aranges
內存地址和編譯的映射.debug_frame
調用幀信息.debug_info
包括 DWARF 信息條目 (DIEs)的核心 DWARF 數據.debug_line
行號程序.debug_loc
位置描述.debug_macinfo
宏描述.debug_pubnames
全局對象和函數查找表.debug_pubtypes
全局類型查找表.debug_ranges
DIEs 的引用地址範圍.debug_str
.debug_info
使用的字元串列表.debug_types
類型描述
我們最關心的是 .debug_line
和 .debug_info
部分,讓我們來看一個簡單程序的 DWARF 信息。
int main() {
long a = 3;
long b = 2;
long c = a + b;
a = 4;
}
DWARF 行表
如果你用 -g
選項編譯這個程序,然後將結果傳遞給 dwarfdump
執行,在行號部分你應該可以看到類似這樣的東西:
.debug_line: line number info for a single cu
Source lines (from CU-DIE at .debug_info offset 0x0000000b):
NS new statement, BB new basic block, ET end of text sequence
PE prologue end, EB epilogue begin
IS=val ISA number, DI=val discriminator value
<pc> [lno,col] NS BB ET PE EB IS= DI= uri: "filepath"
0x00400670 [ 1, 0] NS uri: "/home/simon/play/MiniDbg/examples/variable.cpp"
0x00400676 [ 2,10] NS PE
0x0040067e [ 3,10] NS
0x00400686 [ 4,14] NS
0x0040068a [ 4,16]
0x0040068e [ 4,10]
0x00400692 [ 5, 7] NS
0x0040069a [ 6, 1] NS
0x0040069c [ 6, 1] NS ET
前面幾行是一些如何理解 dump 的信息 - 主要的行號數據從以 0x00400670
開頭的行開始。實際上這是一個代碼內存地址到文件中行列號的映射。NS
表示地址標記一個新語句的開始,這通常用於設置斷點或逐步執行。PE
表示函數序言(LCTT 譯註:在彙編語言中,[function prologue](https://en.wikipedia.org/wiki/Function_prologue "function prologue") 是程序開始的幾行代碼,用於準備函數中用到的棧和寄存器)的結束,這對於設置函數斷點非常有幫助。ET
表示轉換單元的結束。信息實際上並不像這樣編碼;真正的編碼是一種非常節省空間的排序程序,可以通過執行它來建立這些行信息。
那麼,假設我們想在 variable.cpp
的第 4 行設置斷點,我們該怎麼做呢?我們查找和該文件對應的條目,然後查找對應的行條目,查找對應的地址,在那裡設置一個斷點。在我們的例子中,條目是:
0x00400686 [ 4,14] NS
假設我們想在地址 0x00400686
處設置斷點。如果你想嘗試的話你可以在已經編寫好的調試器上手動實現。
反過來也是如此。如果我們已經有了一個內存地址 - 例如說,一個程序計數器值 - 想找到它在源碼中的位置,我們只需要從行表信息中查找最接近的映射地址並從中抓取行號。
DWARF 調試信息
.debug_info
部分是 DWARF 的核心。它給我們關於我們程序中存在的類型、函數、變數、希望和夢想的信息。這部分的基本單元是 DWARF 信息條目(DWARF Information Entry),我們親切地稱之為 DIEs。一個 DIE 包括能告訴你正在展現什麼樣的源碼級實體的標籤,後面跟著一系列該實體的屬性。這是我上面展示的簡單事常式序的 .debug_info
部分:
.debug_info
COMPILE_UNIT<header overall offset = 0x00000000>:
< 0><0x0000000b> DW_TAG_compile_unit
DW_AT_producer clang version 3.9.1 (tags/RELEASE_391/final)
DW_AT_language DW_LANG_C_plus_plus
DW_AT_name /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_stmt_list 0x00000000
DW_AT_comp_dir /super/secret/path/MiniDbg/build
DW_AT_low_pc 0x00400670
DW_AT_high_pc 0x0040069c
LOCAL_SYMBOLS:
< 1><0x0000002e> DW_TAG_subprogram
DW_AT_low_pc 0x00400670
DW_AT_high_pc 0x0040069c
DW_AT_frame_base DW_OP_reg6
DW_AT_name main
DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_decl_line 0x00000001
DW_AT_type <0x00000077>
DW_AT_external yes(1)
< 2><0x0000004c> DW_TAG_variable
DW_AT_location DW_OP_fbreg -8
DW_AT_name a
DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_decl_line 0x00000002
DW_AT_type <0x0000007e>
< 2><0x0000005a> DW_TAG_variable
DW_AT_location DW_OP_fbreg -16
DW_AT_name b
DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_decl_line 0x00000003
DW_AT_type <0x0000007e>
< 2><0x00000068> DW_TAG_variable
DW_AT_location DW_OP_fbreg -24
DW_AT_name c
DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_decl_line 0x00000004
DW_AT_type <0x0000007e>
< 1><0x00000077> DW_TAG_base_type
DW_AT_name int
DW_AT_encoding DW_ATE_signed
DW_AT_byte_size 0x00000004
< 1><0x0000007e> DW_TAG_base_type
DW_AT_name long int
DW_AT_encoding DW_ATE_signed
DW_AT_byte_size 0x00000008
第一個 DIE 表示一個編譯單元(CU),實際上是一個包括了所有 #includes
和類似語句的源文件。下面是帶含義注釋的屬性:
DW_AT_producer clang version 3.9.1 (tags/RELEASE_391/final) <-- 產生該二進位文件的編譯器
DW_AT_language DW_LANG_C_plus_plus <-- 原編程語言
DW_AT_name /super/secret/path/MiniDbg/examples/variable.cpp <-- 該 CU 表示的文件名稱
DW_AT_stmt_list 0x00000000 <-- 跟蹤該 CU 的行表偏移
DW_AT_comp_dir /super/secret/path/MiniDbg/build <-- 編譯目錄
DW_AT_low_pc 0x00400670 <-- 該 CU 的代碼起始
DW_AT_high_pc 0x0040069c <-- 該 CU 的代碼結尾
其它的 DIEs 遵循類似的模式,你也很可能推測出不同屬性的含義。
現在我們可以根據新學到的 DWARF 知識嘗試和解決一些實際問題。
當前我在哪個函數?
假設我們有一個程序計數器值然後想找到當前我們在哪一個函數。一個解決該問題的簡單演算法:
for each compile unit:
if the pc is between DW_AT_low_pc and DW_AT_high_pc:
for each function in the compile unit:
if the pc is between DW_AT_low_pc and DW_AT_high_pc:
return function information
這對於很多目的都有效,但如果有成員函數或者內聯(inline),就會變得更加複雜。假如有內聯,一旦我們找到其範圍包括我們的程序計數器(PC)的函數,我們需要遞歸遍歷該 DIE 的所有孩子檢查有沒有內聯函數能更好地匹配。在我的代碼中,我不會為該調試器處理內聯,但如果你想要的話你可以添加該功能。
如何在一個函數上設置斷點?
再次說明,這取決於你是否想要支持成員函數、命名空間以及類似的東西。對於簡單的函數你只需要迭代遍歷不同編譯單元中的函數直到你找到一個合適的名字。如果你的編譯器能夠填充 .debug_pubnames
部分,你可以更高效地做到這點。
一旦找到了函數,你可以在 DW_AT_low_pc
給定的內存地址設置一個斷點。不過那會在函數序言處中斷,但更合適的是在用戶代碼處中斷。由於行表信息可以指定序言的結束的內存地址,你只需要在行表中查找 DW_AT_low_pc
的值,然後一直讀取直到被標記為序言結束的條目。一些編譯器不會輸出這些信息,因此另一種方式是在該函數第二行條目指定的地址處設置斷點。
假如我們想在我們示常式序中的 main
函數設置斷點。我們查找名為 main
的函數,獲取到它的 DIE:
< 1><0x0000002e> DW_TAG_subprogram
DW_AT_low_pc 0x00400670
DW_AT_high_pc 0x0040069c
DW_AT_frame_base DW_OP_reg6
DW_AT_name main
DW_AT_decl_file 0x00000001 /super/secret/path/MiniDbg/examples/variable.cpp
DW_AT_decl_line 0x00000001
DW_AT_type <0x00000077>
DW_AT_external yes(1)
這告訴我們函數從 0x00400670
開始。如果我們在行表中查找這個,我們可以獲得條目:
0x00400670 [ 1, 0] NS uri: "/super/secret/path/MiniDbg/examples/variable.cpp"
我們希望跳過序言,因此我們再讀取一個條目:
0x00400676 [ 2,10] NS PE
Clang 在這個條目中包括了序言結束標記,因此我們知道在這裡停止,然後在地址 0x00400676
處設一個斷點。
我如何讀取一個變數的內容?
讀取變數可能非常複雜。它們是難以捉摸的東西,可能在整個函數中移動、保存在寄存器中、被放置於內存、被優化掉、隱藏在角落裡,等等。幸運的是我們的簡單示例是真的很簡單。如果我們想讀取變數 a
的內容,我們需要看它的 DW_AT_location
屬性:
DW_AT_location DW_OP_fbreg -8
這告訴我們內容被保存在以棧幀基(base of the stack frame)偏移為 -8
的地方。為了找到棧幀基,我們查找所在函數的 DW_AT_frame_base
屬性。
DW_AT_frame_base DW_OP_reg6
從 System V x86_64 ABI 我們可以知道 reg6
在 x86 中是幀指針寄存器。現在我們讀取幀指針的內容,從中減去 8
,就找到了我們的變數。如果我們知道它具體是什麼,我們還需要看它的類型:
< 2><0x0000004c> DW_TAG_variable
DW_AT_name a
DW_AT_type <0x0000007e>
如果我們在調試信息中查找該類型,我們得到下面的 DIE:
< 1><0x0000007e> DW_TAG_base_type
DW_AT_name long int
DW_AT_encoding DW_ATE_signed
DW_AT_byte_size 0x00000008
這告訴我們該類型是 8 位元組(64 位)有符號整型,因此我們可以繼續並把這些位元組解析為 int64_t
並向用戶顯示。
當然,類型可能比那要複雜得多,因為它們要能夠表示類似 C++ 的類型,但是這能給你它們如何工作的基本認識。
再次回到幀基(frame base),Clang 可以通過幀指針寄存器跟蹤幀基。最近版本的 GCC 傾向於使用 DW_OP_call_frame_cfa
,它包括解析 .eh_frame
ELF 部分,那是一個我不會去寫的另外一篇完全不同的文章。如果你告訴 GCC 使用 DWARF 2 而不是最近的版本,它會傾向於輸出位置列表,這更便於閱讀:
DW_AT_frame_base <loclist at offset 0x00000000 with 4 entries follows>
low-off : 0x00000000 addr 0x00400696 high-off 0x00000001 addr 0x00400697>DW_OP_breg7+8
low-off : 0x00000001 addr 0x00400697 high-off 0x00000004 addr 0x0040069a>DW_OP_breg7+16
low-off : 0x00000004 addr 0x0040069a high-off 0x00000031 addr 0x004006c7>DW_OP_breg6+16
low-off : 0x00000031 addr 0x004006c7 high-off 0x00000032 addr 0x004006c8>DW_OP_breg7+8
位置列表取決於程序計數器所處的位置給出不同的位置。這個例子告訴我們如果程序計數器是在 DW_AT_low_pc
偏移量為 0x0
的位置,那麼幀基就在和寄存器 7 中保存的值偏移量為 8 的位置,如果它是在 0x1
和 0x4
之間,那麼幀基就在和相同位置偏移量為 16 的位置,以此類推。
休息一會
這裡有很多的信息需要你的大腦消化,但好消息是在後面的幾篇文章中我們會用一個庫替我們完成這些艱難的工作。理解概念仍然很有幫助,尤其是當出現錯誤或者你想支持一些你使用的 DWARF 庫所沒有實現的 DWARF 概念時。
如果你想了解更多關於 DWARF 的內容,那麼你可以從這裡獲取其標準。在寫這篇博客時,剛剛發布了 DWARF 5,但更普遍支持 DWARF 4。
via: https://blog.tartanllama.xyz/c++/2017/04/05/writing-a-linux-debugger-elf-dwarf/
作者:Simon Brand 譯者:ictlyh 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive