前言
符號化能幫助我們在定位 bug 、崩潰和性能瓶頸時,從運行時日志與堆棧找到根本的代碼原因;相信大家了解 atos 或 dSYM 等常用符號化工具,但這些工具是如何運作的?本篇文章將圍繞符號化的定義、原理、實踐與技巧,帶領大家對符號化進一步深層次了解;本篇文章是基于 Session 10211 - Symbolication: Beyond the basics 撰寫,Session 的演講者是Apple - 性能工具團隊的 Alejandro Lucena 工程師
什么是符號化?
「將 App 運行時信息映射為源碼」長話短說就是將運行時信息轉換為源碼信息,符號化是一種機制,將我們在設備運行時 App 的內存地址和關聯的指令信息轉換為源碼文件中具體文件名、方法名、行數等;可以理解為將運行時機器如何看待處理我們 App
的信息轉換成我們開發者如何看待處理我們的 App(源碼)。如果缺少這層轉換,哪怕只有幾行的代碼的 App
,bug 定位也變得難以進行;
Demo
本文為了帶領大家了解符號化的原理,全文所用到的項目是一個簡單的只有幾行的Demo App,他所有代碼如下:
demo 的邏輯很簡單:
randomValue()
可以生成值區間在1-100間的隨機數numberChoices()
可以生成一個包含 10 個上述隨機數的數組selectMagicNumber(choices: numbers)
可以從入參 numbers 數組中,取出一個指定下標的元素generateMagicNumber()
按部執行上述操作,返回取出下標的元素 此處的 MAGIC_CHOICE 是一個隨機值
日常崩潰日志的符號化
第一次執行這個 App
就崩潰了,查看生成的錯誤日志,里面沒有很直觀的信息,是一堆內存地址,我只能看到 App
在主線程上 crash
了;
我嘗試直接 debug
我的 App
,但在執行中沒有復現該問題,看來調試器也不一定能幫得了忙;多次嘗試之后終于復現了,但程序崩潰在匯編中,也沒有直觀的信息,匯編太硬核,搞不定。
上面的崩潰日志和匯編堆棧顯然都不能直接解決問題,但在符號化的幫助下,我們可以不從這些原始內存地址中挖掘錯誤;相信大家都知道在 Xcode Organizer
中載入 App
的 dSYM
文件,他會重新處理崩潰日志,載入后我們就可以得到下面這種可讀的、可以獲得調用信息、文件名、具體行數的崩潰日志,崩潰日志直接告訴我,崩潰時發生了數組越界訪問,非常直觀;根據這些信息回溯到代碼,我們也容易發現隨機值 MAGIC_CHOICE
容易導致,在訪問只有 10 個長度數組訪問時,發生數組越界;
使用 atos
命令行工具,我們也可以得到上述信息
日常 Instruments
堆棧的符號化
另一個符號化的例子是,在 Instruments 中進行性能優化時,檢測提示該 App 會周期性的執行大量寫入操作,出現了周期性的高負荷區間和低負荷區間;但是默認右下角顯示的堆棧信息只能提示 App 正在寫入文件,無論高負荷還是低負荷,都提示了同樣堆棧;
很明顯這兩個區間不會執行同樣代碼,這原因是因為當前的 Instruments 堆棧是被部分符號化的,一般而言,在堆棧中沒有具體文件名和具體行數時,符號化是不徹底,此時我們也可以手動在 Instruments 載入 dSYM 文件,載入后,我們再查看高負荷區,明確提示有多余的調試代碼 addDebugLog() ,而在低負荷區沒有該方法調用;dSYM 不僅可以使只包含內存地址信息的崩潰日志可讀,還可以幫助 Instruments 堆棧信息更加有用,這些都能幫我們找到問題背后的代碼問題;
符號化原理
既然符號化的工具可以幫助我們定位代碼問題,你肯定會問,What ?why?為什么dSYM 可以幫助符號化?How?dSYM如何幫助完成了符號化?dSYM是符號化的一切嗎?除了崩潰日志和Instruments
,別的地方還能載入 dSYM
嗎?atos
的 -o``-i``-l
各自有什么用處?Instruments
為什么未能直接提供完全符號化的堆棧?Xcode
編譯設置對符號化有何影響?帶著這些問題,讓我們深入探究一些符號化的原理。
為此我們首先分解介紹符號化的兩個步驟:第一步:從內存地址回溯到文件**第二步:還原運行時調試信息**
第一步 - 與符號化相關的地址與轉換
從內存地址回溯到文件地址,指的是將運行時隨機的內存地址轉換為磁盤上二進制文件中穩定可用的文件信息;正如內存地址有內存空間一樣,二進制文件在磁盤上也有地址空間;但這兩種地址空間不能直接轉換,需要一種地址轉換機制;
磁盤上的地址空間與二進制文件地址
磁盤地址空間的地址是編譯時 Linker
鏈接器賦予二進制文件的地址;具體而言,linker
會把二進制代碼分組,分組后的部分稱為段 Segment
,每個二進制段都包含了一些數據和屬性,例如段的名稱,大小,地址等;舉例來說,二進制文件中的 __TEXT
段會包含對應的方法和函數,__DATA
段會包含程序的全局狀態,例如全局變量;每個段都被賦予了一個獨一無二的起始地址,這種設計保證了段與段之間不會重疊;
-w871
具體而言 linker
會把段信息記錄在可執行文件頭部,作為 Mach-O
頭的一部分;眾所周知, Mach-O
是一種可執行文件和庫的文件格式,Mach-O
頭中包含許多與段的屬性信息相關的 load
指令,操作系統內核通過讀取這些 load
指令來把對應的二進制段加載入內存;如果 App
用到了 Universal2
打包技術,那每種架構都會有與之對應的 Mach-O
頭和相關段信息;
-w860
上面講了段信息和 load
指令,讓我們來結合最初的小 demo,實踐查看一下相關的 load
指令;我們可以通過 otool -l
來輸出 load
指令信息,結合 grep
(字符串篩選工具)可以過濾出 LC_SEGMENT_64
的 load
指令,如下圖所示;輸出結果提示 __TEXT
段的起始位置為 vmaddr
所示地址,段的長度為 vmsize
所示字節大??;
將二進制文件載入內存
由以上信息,我們了解到 load
指令會包含載入的地址和大小,那為什么內核實際通過 load
指令載入后,二進制段的內存地址和這個 linker
生成的地址不一致?下圖中內存地址和 linker
的 A
、B
、C
地址有啥關系?后文中會將 linker
生成的地址簡稱為 A
、B
、C
Address space layout randomization - 地址空間布局隨機化技術
事實上,現在操作系統中都會有一種「地址空間布局隨機化」技術,該技術是一種防范內存損壞漏洞被利用的計算機安全技術。ASLR
通過隨機放置進程關鍵數據區域的地址空間來防止攻擊者能可靠地跳轉到內存的特定位置來攻擊制定函數?,F代操作系統一般都加設這一機制,以防范惡意程序對已知地址進行 Return-to-libc
攻擊。簡言之,內核在加載二進制段前,會初始化一個隨機值,稱為 ASLR Slide
「內存空間隨機分布偏移量」,后文中會把該偏移量簡稱為 S
;之后內核會將該偏移量 S
疊加到 linker
生成的 load
指令的地址 A
、B
、C
上;因此,內核在執行 load
指令時,不會按照原始的 linker
地址直接載入到內存地址 A
、B
、C
中,而是載入到 A+S
、B+S
、C+S
,我們可以把這些實際的 load
加載地址稱為 Load Address
「加載地址」,后文中,Load Address
將簡稱為 L
通過了解 ASLR
技術,我們弄明白了 linker address
和 load address
之間的差值是 ASLR Slide
隨機內存地址分布偏移量;我們可以得到該公式 ALSR Slide = Load Address - Linker Address
, 簡化為 S = L - A
如何獲取實際的 Linker Address 和 Load Address
前面已經提到 otool
可以幫助我們查看二進制文件的 load
指令信息,進而得到 linker address
(該地址也可以視為 file address
「文件地址」) 而獲得運行時內存地址中的 Load Address
,可以通過崩潰日志中的 Binary Image
列表,Instruments
提供的堆棧,或者通過 vmmap
命令行工具來獲??;具體如何使用 vmmap
在后文中會有講解
計算 ASLR Slide 隨機內存偏移量
結合實踐,我們需要知道 ASLR Slide
隨機內存偏移量,才能夠從崩潰日志和 Instruments
堆棧中的內存地址,減去 ASLR Slide
而獲得文件地址;因此需要先計算出 ASLR Slide
,計算 ASLR Slide
一般以特定段(如 __TEXT
)的 load address
和 linker address
來相減得出,如何獲取這倆地址上面已經說了,結合實踐我們從崩潰日志中獲取了 __TEXT
二進制段的 load address
為 0x10045c000
;通過 otool
我可以獲得 __TEXT
二進制段的 linker address
為 0x100000000
;將這兩者相減我們就可以的得到 ASLR Slide = 0x45c000
;
有了 ASLR Slide
,我們可以從崩潰日志的運行時內存地址,換算出磁盤地址空間中的文件地址,如下圖所示,我們可以得到我們 demo 中崩潰的堆棧的文件地址為 0x10003b70
,有了文件地址,我們可以用來查看源碼,這個后續再說。我們先繼續探索一下其他計算 ASLR Slide
的姿勢
如下圖所示,otool
命令行工具可以用來查看崩潰時發生問題的指令信息, 傳入 -tV
可以輸出匯編堆棧;-arch arm64
是為了讓 otool
正確處理 Universal 2
技術編譯的產物;輸出結構對應上述文件地址,顯示此是 brk
指令,匯編中的 brk
一般代表著 App
出現了異?;騿栴};
atos
命令行工具也可以幫我們計算 ASLR Slide
, atos
的 -o
指令會輸出 file segment address
, -l
指令會輸出 load address
;
除了 atos
和 otool
,還有 vmmap
命令行工具也可以幫助我們獲取 load address
,我們可以用 vmmap
來驗證上面的計算結果, vmmap
輸出崩潰時 __TEXT segment
的 load address
,使用之前公式可以計算出本次運行的 ASLR Slide
為 0x104d14000
,將本次崩潰日志中的 runtime address - ASLR Slide
得到了 file address
為 0x100003b70
,和之前計算的 file address
一樣;
上述兩次不同運行時, 不同崩潰日志,不同的 ASLR Slide
能夠得到同一個 file address
,這不是巧合;是因為內核每次運行的 ASLR Slide
都不同,因此不同時間,不同設備的崩潰日志中所對應的內存地址會變化,但實際的 linkder address
是一樣的;基于此,雖然內存地址每次變化,我們仍然可以定位到相同的 file address
;至此,我們發現了一種機制,能讓我在隨機的運行時內存中,定位到我們 App
源碼級別所發生的的事;通過這種映射機制能夠讓我們從運行時的堆棧信息中,回溯到 App
源碼中;
小結 - 從內存地址回溯到文件地址
以上內容就是「符號化兩步走」中的第一步:從內存地址回溯到文件,總結一下該步驟中的內容和工具
App
和 庫的二進制文件格式是Mach-O
,其中Mach-O
的頭中存放了二進制段的關聯信息和load
指令,這些二進制段是linker
創建的,其中包括了二進制段的地址信息linker address
;otool -l
可以幫助我們輸出Mach-O
中指定二進制段的地址和屬性信息,其中包括linker address
;- 崩潰日志中的
binary image
列表中可以獲取崩潰發生時的load address
; vmmap
也可以獲得正在運行App
的load address
ASLR Slide + Linker address = Load address
第二步 - 分析調試信息
有了以上基礎,我們可以進一步討論符號化的第二步:分析調試信息;調試信息一般包含了 file address
和源碼之間的關系信息;Xcode
會在編譯時生成這些關系信息并存放為 dSYM
文件,也可以把這些關系信息內置在二進制編譯產物中;
這些調試信息有三種類型,每一種都提供了不同級別與 file address 關聯的調試信息;
Function starts
Nlist symbol table
DWARF
下圖中展示了這三種工具分別提供了對應維度的調試信息
Function Starts
從上圖中可知,function starts
相較于其他工具提供最少的信息,該工具只能提供函數對應的起始地址,具體而言,function starts
會提供函數的起始地址和其調用的所在的地址;但這其中不會告訴你這調用地址里是否有其他函數,他只能告訴你這里有個函數出問題 ;
function starts
通過編碼 __LINKEDIT
二進制段中的 linker
地址列表來提供該功能;function starts
基于直接內置在 App
編譯產物中,通過 mach-O
文件的 load
指令的 LC_FUNCTION_STARTS
來描述 function starts
;
實踐中,可以通過 symbols -onlyFuncStartsData
命令行工具來輸出 function starts
相關信息,如下圖所示,其中的 null
是因為 function starts
不提供函數名稱,所以用 null
來做函數名稱的占位符;
基于 function starts
我們可以對未符號化的崩潰日志進行處理,先從崩潰日志的內存地址 0x10045fb70
減去之前計算好的 ASLR Slide``0x45c000
得到 file address``0x100003b70
; 然后結合 function starts
輸出結果,我們發現只有第一個地址 0x100003a68
小于我們算出的 file address``0x100003b70
,所以只有這第一個地址包含了錯誤發生的地址;基于此我們計算這兩個地址之間偏移了 0x108
,換算成十進制 是 264
,也就是我們 file address
與實際錯誤發生地址之間有 264
字節的偏移量;
至此 function starts
可以幫助我們理解崩潰日志中的函數如何被設置,修改了哪些寄存器;但因為 function starts
不提供函數名,我們只能在低級的機器碼層面來分析這些錯誤日志,對于調試開發 App
來說挺有用,但對于分析錯誤日志,我們還需要其他工具;
Nlist symbols List - Nlist 符號表
nlist
是一個結構體,他具體結構如下圖所示,nlist
符號表建立在 function starts
和一個編碼后的 __LINKEDIT``segment
的信息列表,當然 nlist
有自己的 load
指令;與 function starts
不同的是 nlist
不只是編碼內存地址,他在其結構體中編碼了更多信息;如下圖所示,nlist
結構體中包含了名稱和其他幾個屬性,具體而言 nlist
的類型由 n_type
所決定
n_type
有三種類型是我們符號化所感興趣的,這里我們先著重聊一聊其中兩種;第一種是 direct symbole
- 直接符號;直接符號關聯的是在 App
和二方庫中,包含了已被完整定義的方法和函數;直接符號在 nlist_64
結構體中存儲了函數名字和函數文件地址;
Nlist 直接符號
n_type
中的指定二進制位的值決定了該 nlist
的類型,具體而言,n_type
中的第二、三、四的二進制位為 1
時,表明該 nlist
類型為直接符號,這三個位的組合還被叫做 N_SECT
;
我們可以通過 nm -defined-only —numberic-sort
命令行工具來查看 N_SECT
;在這里 nm
遍歷了 magicNumbers``App
的制定符號,并以地址順序羅列出來,具體參照下圖中的輸出;注意此處我們還是用了 xcrun -swift-demangle
來解析 Swift mangling
后的函數名稱;
上圖所示,我們已經可以從結果中獲得了方法名 numberChoices()
、類名 MagicNumbers
、文件名 main
;這是因為這些信息直接在 App
內定義;symbols
查看直接符號 和 nm
工具相似, symbols
命令行工具也提供查看 nlist
數據的方法,并且支持自動 demangle
,具體如下圖
以上兩個方法,讓我們從崩潰日志中的內存地址,關聯到了源碼中的具體函數名稱,至此,崩潰日志的符號化的信息豐富程度更進一步;至此,我們通過 fuction starts 提供的函數入口偏移地址從 direct symbols 中匹配到一個函數入口,并且這個入口有名字,把這些信息放在一起,我們可以發現 crash 發生在 main 方法地址的 264字節偏移處;但 main 并不是崩潰中唯一的函數,這表明我們還有更多的信息有待挖掘;例如我們還沒有弄清楚代碼中的行數信息
我們已經弄清 main
并不是唯一與崩潰關聯的函數,我們還有更多的信息有待挖掘;例如我們還沒獲得文件的行數信息;并且在上述符號化中,部分函數被序列化,還有部分堆棧和崩潰日志信息沒有被符號化
我們在 Instruments
的堆棧中遇到了類似的情況,一些函數名被符號化而可讀,但部分仍是內存地址;發生這種現象的原因是,直接符號表中所包含的函數,只限于在鏈接時被直接鏈接的部分,動態庫等運行時加載的二進制文件不被包含在內,這些未能符號化的方法就是跨模塊從動態庫中調用的方法;我們需要其他手段了符號化這些調試信息;
這種直接符號表的邏輯,有助于減少編譯產物體積;畢竟換位思考,如果把打包時所有相關函數信息都存入符號表,這種操作才有違常識;對于 Frameworks
和 Libraries
,我們需要處理記錄那些被調用的方法,而剝離沒用到的;當然了如果把直接符號表里的主程序內的函數剝離,那符號表里啥也不剩了;
Xcode 編譯設置對 nlist 直接符號的影響
在 Xcode
的編譯設置中,strip
配置項有 strip linked product
、strip style
、strip swift symbols
三個選項。這些編譯設置的選項控制了 App
在編譯鏈接過程中的剝離多余符號表的邏輯;具體來講,strip linked product
為 YES
時,二進制文件中將根據 strip style
的值進行符號表剝離;舉例來說,strip style
值為 all symbols
時,符號表中將執行最激進的剝離策略,最終符號表中只包含最核心的方法;Non globals
類型會剝離應用中不同模塊中共同使用的直接符號,但會留下用于其他 APP
中的符號;Debugging symbols
則刪除了第三種 nlist
類型的符號,這個后續討論 DWARF
時會講到,但該類型的剝離會保留直接用到的符號。
-w875舉例來說,這里有一個定義了兩個
public interface
接口和一個 internal shared
實現的方法的 framework
,由于所有這些函數在鏈接環節中有用,他們都擁有直接的符號項。
如果我按照 non globals
進行剝離,那只有兩個 interface
會留下;由于共享實現的函數只在 framework
內使用,所以它不是全局的,進而也不會被放入符號表;
類似的如果是
all symbols
剝離策略時時,如果這兩個 interface
有被 framework
外部所調用時,他們仍然會被留下;
symbols —onlyNListData
會輸出一些分布在直接符號之間 function starts
的條目;這些條目也表示了函數是存在于直接符號表中,亦或是已經被剝離了。你可以利用這些剝離設定,來實現你需要的符號表可見性;有了這些信息,我們就可以確定什么時候需要直接符號表。在實際應用中,有時候我們能符號化出函數名,但沒有具體行數和文件名;或者符號化結果包含了方法名和方法起始地址,正如此處 framework
的 symbols
指令的例子;
間接符號 - Indirect symbols
與直接符號類似,間接符號的 n_type
的第一位二進制位為 1
,或稱為 n_EXT
通過 nm -m -arch arm64 -undefined-only --numberic-sort MagicNumbers
輸出間接符號的信息;這其中使用 —undefined-only
來替換 —defined-only
,該指令用于查看間接符號;-m
,這可以讓你看到這些方法源自哪個 framework
或 libraries
。下面圖中的輸出結果提示 MagicNumbers``App
依賴了 libSwiftCore
中的一系列 Swift
基礎方法如 print()
。
#### 小結 - Function starts 與 nlist 符號表 文章開頭,我們約定了要討論 function starts
、nlist 符號表
和 DWARF
三種符號化工具;截止現在已經討論了前兩種,在此回顧一下;
Function starts
能提供地址列表,缺少方法名,可以幫助計算崩潰對應的文件地址偏移量;Nlist 符號表
把關聯到一個地址的詳細信息構成結構體存儲,nlist
符號能提供函數名稱,還可以描述在App
內定義的直接符號和在二方庫中提供的間接符號;直接符號表通常保留與鏈接有關的函數,Xcode
項目設置中的strip build style
會影響直接符號表中的內容;- 這兩種符號表都直接嵌入在
App
二進制文件Mach-O
頭中的__LINKEDIT
二進制段中
DWARF
截止現在我們還沒能看到諸如文件名、函數所在行數、崩潰所在行數等符號化信息;這些信息在 DWARF
中都有提供,我們在此詳細討論一下 DWARF
;相較于 nlist
符號表只保留函數部分信息,DWARF
幾乎記錄了函數的所有上下文信息;回顧 function starts
只在一個維度上提供偏移量信息;nlist
基于編碼 nlist_64
結構體將調試信息升級到兩個維度,即地址信息和函數名稱;作為比較 DWARF
增加了第三個維度:關系信息;實際項目中函數不是孤立存在的,函數會被調用和在其內部調用其他函數,函數會有出參入參;通過記錄這些函數的上下文關系信息;DWARF 會帶我們解鎖符號化最牛逼的姿勢;
當我們分析 DWARF
時,一般指的是引用分析一個 dSYM bundle
,該 bundle
中存在由元數據組成的 plist
,還包括一個 DWARF
二進制文件;二進制文件中將 DWARF
的信息記錄在 __DWARF
二進制段中;DWARF
在該二進制段中記錄了我們需要關注的三個數據流;具體而言三個數據流分別是 debug_info
, debug_abbrev
, debug_line
;debug_info
包含了原始數據,debug_abbrev
為原始數據進行了結構化處理,debug_line
包含了文件名和行號;除此之外 DWARF
還定義了需要討論的兩種 vocabulary list
詞匯表:compile unit
編譯單元和 subprogram
子程序;后文會提到第三種詞匯表 - 內聯子程序
Compile Unit - 編譯單元
編譯單元表示了在項目中會被編譯的單個源碼文件;具體來說,在項目中的每個 swift
文件都會有一個編譯單元與之對應;DWARF
為每一個編譯單元賦予了一些屬性,諸如文件名、模塊名稱、__TEXT segment
的函數占位部分等;main.swift
文件對應的編譯單元在 debug_info
數據流中儲存了這些屬性,如左側所示;與之對應的,在 debug_addrev
數據流中包含了一個相關的條目,這些條目告訴我們這些值代表了什么,如右側所示;我們看到圖中右側包含了文件名、語言和一個 low/high
對,用來表述 __TEXT``segment
的范圍
Subprogram - 子程序
子程序表示已被定義的函數;我們已經在 nlist
符號表中找到過已定義的方法,但子程序還可以用來描述靜態方法和本地方法;子程序當然也有自己的名稱和對應的 __TEXT``segment
地址起始范圍
DWARF 關系樹
編譯單元和子程序之間的一個基本關系是,子程序是在編譯單元中被定義的;DWARF
使用樹來表述這種關系;編譯單元在根節點上,子程序是根節點的孩子節點;這些子節點可以通過他們的地址范圍而被檢索到;
我們可以通過 dwarfdump
命令行工具來驗證上述 DWARF
的編譯單元、子程序和關系樹細節 首先我們將查看到一個編譯單元,這句之前提到的編譯單元所攜帶的屬性相吻合(文件名、語言、行數等),dwarfdump
工具結合了 debug_info
和 debug_abbrev
內容來展示 dSYMs
文件中的數據結構與內容
輸出很長,我們往下看,會看到一個子程序 subprogram
;它所占用的地址范圍存在于該編譯單元的地址范圍內,并且可以看到方法名;之前提到過 DWARF
非常詳細的描述符號表和關系信息,我們不會在深入探究 DWARF
的關系樹 設計細節,但了解這些細節能夠幫助我們理解符號化背后的邏輯;
繼續往下看輸出結果,會發現其中還包括參數信息,DWARF
持有一個自己的詞匯表,來描述參數的名稱和類型;參數是子程序的一個子節點;下圖中的輸出,可以發現 numberofChoice
函數的參數 choices
的相關信息;文件名與行數信息
此外,debug_line
數據流中存儲了函數關聯的文件名和具體行數;但 debug_line
數據流不是樹狀結構,相反的,該數據流定義了一個 line table program
行表程序,這個航標程序可以讓鏈接后的文件地址映射到源碼文件中的具體行數;我們可以利用這個行表程序來查找文件地址關聯的具體源碼和行數;
綜上,基于 debug_info
的樹狀結構和 debug_line
的行表程序,我們可以得到一個下面的結構;通過遍歷這棵樹,我們可以找到想要的文件地址;首先從編譯單元開始,遍歷其子節點,然后篩選出包含 debug_line
的子節點;
DWARF 與編譯時函數內聯優化
我們可以使用 atos
命令行工具來完成上述操作,這次我們省略 -i``flag
,可以看到輸出結果少了很多,只剩下方法名、文件名和行數;這里的結果提供了行數,因此我們可以斷定我們在使用 DWARF
來進行符號化;但除了文件名和行數,這個輸出結果和 nlist
符號表的符號化結果沒有太大區別;然后我們再試一試給 atos
加上 -i``flag
,輸出結果是下面第二張圖,大家可以對比這兩個輸出的差異,他們的命令只差了一個 -i``atos -o MagicNumbers.dSYM/Contents/Resources/DWARF/MagicNumbers -arch arm64 -l 0x10045c000 0x10045fb70``atos -o MagicNumbers.dSYM/Contents/Resources/DWARF/MagicNumbers -arch arm64 -l 0x10045c000 -i 0x10045fb70
-w974
大家也許會猜,這 -i
意味著什么;事實上 atos
的 -i
意味著 inlined function
內聯函數,內聯化是一種編譯器執行的常規優化;詳細而言,內聯化就是在編譯中把函數的實現代碼直接替換函數被調用的代碼;這樣的替換操作可以讓函數調用的代碼和函數的定義代碼都「消失了」;在我們的 Demo
中也就是使用 numberOfChoice()
的實現代碼替換了調用代碼;numberOfChoice()
調用代碼不見了~
Inlined subroutines - 內聯子程序
DWARF
使用內聯子程序來表述這種編譯時內聯優化;這就是我們要討論的第三種 vocabulary list
詞匯表類型 :inlined subroutines
內聯子程序;內聯子程序是子程序的一種,所以他也是一種方法,一種被內聯到另一個子程序的方法;所以內聯函數在 DWARF
關系樹中是子程序的一個子節點;這樣的定義意味著會出現遞歸關系;也就是說一個內聯子程序可以有其他內聯子程序作為子節點;
再次使用
dwarfdump
命令行工具,我們可以來檢查一下 DWARF
中的內聯子程序;這些內聯子程序被列為其他節點的子節點,并且有著與子程序類似的屬性,諸如名稱和地址;但是在DWARF
文件中,這些屬性一般會通過一個公共節點來訪問,這種設計叫抽象源;如果存在一個特定函數有很多內聯拷貝,則該函數的公共共享屬性將存儲在抽象源中,如此這些內聯函數就不會被重復多余的拷貝;內聯子程序有一個獨特的屬性是 call site
調用位置;該屬性表述了在源碼中實際調用函數的位置,編譯優化器會替換這些函數調用代碼;例如,我們在 main.swift
文件中第36行調用了 generateANumber()
,這使得需要在樹中新增子節點來記錄這個函數調用;
-w1010
到這里,我們對 DWARF
符號化有了更全面的了解,如下圖所示,我們對 App
的調用邏輯也有了更廣闊的視角。了解內聯函數的優化方式和細節是完全符號化崩潰日志的關鍵所在;-i
指令實際會要求 atos
符號化過程中考慮到上述內聯函數;這些內聯函數的信息同樣在 Instruments
堆棧中缺失;我們在崩潰日志和 Instruments
堆棧中都需要 dSYM
文件,正是由于 dSYM
中精確地包含了上述三種類型的信息:編譯單元、子程序和 DWARF
關系樹;
從庫和目標文件中獲取 DWARF
除了 dSYM
文件中,還可以在靜態庫和目標文件中找到 DWARF
;也就是說即使沒有 dSYM
文件,你仍然可以從靜態庫或目標文件中鏈接的函數,來生成 DWARF
;這種情況下,你會找到調試符號表的 nlist
類型,這些本是可以被 strip
剝離的符號類型之一;但這些 nlist
類型并不直接包含 DWARF
,相反,他們直接把函數關聯到其源碼文件;如果一個庫在構建中包含調試信息,此時,這些 nlist
條目可以給我們提供 DWARF
的相關信息
上述類型的 nlist
條目可以通過 dsymutil -dump-debug-map
命令行工具來輸出和詳細查看;在此我們列出了不同函數方法和他們的出處;這些地址信息可以被掃描并處理成 DWARF
文件中所需的信息;
小結 - DWARF
DWARF
是深度符號化數據的重要來源DWARF
描述了函數與文件之間的重要關系信息;DWARF
妥當處理了編譯時內斂優化的問題;dSYM
文件和靜態庫可以都可包含DWARF
;- 實踐中推薦使用
dSYM
獲取DWARF
,因為從dSYM
中獲取的DWARF
可以方便的在其他工具中使用,并且Xcode
許多內置工具也支持DWARF
;
開發工具與符號化實踐
Xcode 編譯設置 - Debug info format
- 針對本地開發配置建議設置為直接生成
DWARF
- 針對發布編譯配置,請確保生成包含
DWARF
的dSYM
文件 - 提交至
App Store Connect
的App
,你可以在那下載到dSYM
- 即使使用了
bitcode
技術 ,你也可以從App Store Connect
下載到dSYM
文件
查找和確認 dSYM
文件
如下圖所示,在本地 Mac
上可以接住 mdfind
命令行工具檢查 dSYM
文件;這個字母數字組成的字符串是編譯二進制產物的 UUID
,也是運行時 load
指令的唯一標識符;你還可以通過 symbols -uuid
來查看 dSYM
文件的 UUID
;
在少數情況下,編譯過程會生成一個無效的 DWARF
,你可以通過 draftdump -verify
命令來檢驗 DWARF
的有效性;如果這個檢查命令輸出任何錯誤,請直接通過 https://feedbackassistant.apple.com 來進行Developer Tool - 開發工具
的 bug
反饋;
單個 DWARF
二進制文件大小上線是 4GB
,如果上述校驗中報告超過 4GB
的錯誤,你可以考慮將項目的進行組件化拆分,以便每個組件會有一個較小的 dSYM
實際操作中,通過比較 dSYM
的 UUID
和崩潰日志中 binary image
的 UUID
性來匹配兩者;除了在崩潰日志中查看 App
二進制鏡像的 UUID
,你還可以通過 symbols
命令行工具來獲取 UUID
,參照下圖;實際符號化中,需要 dSYM
和崩潰日志的 UUID
匹配;
其他符號化的細節
symbols
命令行工具還可以幫你檢查你 App
編譯產物中包含的可用調試信息;輸出內容的方括號中的標簽,告訴了這些調試信息的來源;當你不知道在調試時使用哪些調試信息時,使用該指令可以看看有哪些調試信息可用;
如果你確信已經有可用 dSYM
文件了,但是仍舊未能將 Instruments
中的堆棧信息符號化,請檢查一下項目的 Entitlements
和代碼簽名配置;具體來說使用 codesign
命令行工具,你可以驗證是否擁有正確的代碼簽名配置;
同時,你還需要檢查本地開發的 entitlement
中是否包含了 get-task-allow
項,該配置授予 Instruments
這類工具在調試中執行對應 App
符號化的權利;一般來說,Xcode
默認自動會設置這個 get-task-allow
配置項;但 Instruments
不能符號化的時候,可以排查一下這個配置項;如果你發現 entitlement
中沒有 get-task-allow
,可以檢查確保 build-setting
-> code signing
-> code signing inject base entitlemens
的值為 true
,來解決該問題;
最后,對于使用 Universal 2
技術的 App
, 在使用文章中提到的命令行工具時,都可以指定架構,諸如 symbols
、otool
、dwarfdump
都有 -arch
的參數可供配置,如此可以只執行特定架構的相關操作;
總結
正如名稱中的「符號化進階」,用以下幾個關鍵點來總結本 Session
- 符號化
UUID
和文件地址是一致且可靠的方式來識別App
在運行時的問題,因為這兩者不受ASLR Slide
偏移量的影響;UUID
和文件地址是運行時信息符號化關鍵的第一步 - 實踐中,盡可能利用
dSYM
完成符號化;dSYM
以DWARF
的形式記錄了最豐富細節的調試信息,并且被Xcode
和Instruments
所良好支持 - 文中介紹了幾款命令行符號化工具,諸如
otool
,vmmap
,nm
,symbols
,dwarfdump
,atos
;這些工具包含在Xcode Command line tool
中,提供了強大的診斷和檢視符號化過程與細節信息的能力;必要時,大家可以將這些工具集成進自己的工作流;
如果你有興趣學習更多鏈接與符號化知識,我在此推薦兩個WWDC18的Session :他們幫助你了解 App 在啟動時如何運行起來,一個是Optimizing app startup time - 優化 App 啟動速度,另一個是App startup time: past ,present, and future - App 啟動的時間線:過去、現在和將來;