開發一個 Linux 偵錯程式(四):Elves 和 dwarves
https://linux.cn/article-8719-1.html
到目前為止,你已經偶爾聽到了關於 dwarves、除錯資訊、一種無需解析就可以理解原始碼方式。今天我們會詳細介紹原始碼級的除錯資訊,作為本指南後面部分使用它的準備。
系列文章索引
隨著後面文章的釋出,這些連結會逐漸生效。
ELF 和 DWARF 簡介
ELF 和 DWARF 可能是兩個你沒有聽說過,但可能大部分時間都在使用的元件。ELF(
DWARF是通常和 ELF 一起使用的除錯資訊格式。它不一定要繫結到 ELF,但它們兩者是一起發展的,一起工作得很好。這種格式允許編譯器告訴偵錯程式最初的原始碼如何和被執行的二進位制檔案相關聯。這些資訊分散到不同的 ELF 部分,每個部分都銜接有一份它自己的資訊。下面不同部分的定義,資訊取自這個稍有過時但非常重要的
.debug_abbrev
.debug_info
部分使用的縮略語.debug_aranges
記憶體地址和編譯的對映.debug_frame
呼叫幀資訊.debug_info
包括 DWARF 資訊條目(DWARF Information Entries)(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 是程式開始的幾行程式碼,用於準備函式中用到的棧和暫存器)的結束,這對於設定函式斷點非常有幫助。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/