LLVM 程式碼覆蓋率映射格式¶
簡介¶
LLVM 的程式碼覆蓋率映射格式用於使用 LLVM 和 Clang 基於插裝的效能分析(Clang 的 -fprofile-instr-generate
選項)提供程式碼覆蓋率分析。
本文檔適用於那些想了解 LLVM 的程式碼覆蓋率映射如何在幕後運作的人。 事先了解 Clang 的設定檔引導最佳化如何運作會有所幫助,但並非必要。 對於有興趣使用 LLVM 為自己的程式提供程式碼覆蓋率分析的人,請參閱 Clang 文件 <https://clang.llvm.org/docs/SourceBasedCodeCoverage.html>。
我們首先簡要描述 LLVM 的程式碼覆蓋率映射格式,以及 Clang 和 LLVM 的程式碼覆蓋率工具如何使用這種格式。 在介紹完基本知識之後,將討論覆蓋率映射格式的更多進階功能,例如資料結構、LLVM IR 表示法和二進位編碼。
高階概述¶
LLVM 的程式碼覆蓋率映射格式旨在成為一種獨立的資料格式,可以嵌入到 LLVM IR 和目標檔案中。 本文檔將其描述為一種**映射**格式,因為其目標是儲存程式碼覆蓋率工具在檔案中的特定原始碼範圍與執行已插裝程式版本後獲得的執行次數之間進行映射所需的資料。
映射資料在程式碼覆蓋率過程中的兩個地方使用
當 clang 使用
-fcoverage-mapping
編譯原始碼檔案時,它會產生描述原始碼範圍與效能分析插裝計數器之間映射的映射資訊。 此資訊會嵌入到 LLVM IR 中,並在程式連結時方便地存放在最終的可執行檔中。它也被 *llvm-cov* 使用——映射資訊是從目標檔案中提取的,用於關聯執行次數(效能分析插裝計數器的值)和檔案中的原始碼範圍。 之後,該工具就能夠為程式產生各種程式碼覆蓋率報告。
覆蓋率映射格式旨在成為一種“通用格式”,適用於任何前端(而不僅僅是 Clang)使用。它還旨在讓前端能夠生成最少的覆蓋率映射數據,以減少 IR 和目標文件的體積——例如,前端可以將具有相同執行次數的語句分組到代碼區域中,而不是為函數中的每個語句發出映射信息,並且僅針對這些區域發出映射信息。
進階概念¶
本指南的其餘部分旨在讓您深入了解覆蓋率映射格式的工作原理。
覆蓋率映射格式在每個函數級別上運行,因為配置文件 instrumentation 計數器與特定函數相關聯。對於每個需要代碼覆蓋率的函數,前端必須創建覆蓋率映射數據,以便在源代碼範圍和該函數的配置文件 instrumentation 計數器之間進行映射。
映射區域¶
函數的覆蓋率映射數據包含一個映射區域數組。映射區域存儲此區域覆蓋的源代碼範圍、文件 ID、覆蓋率映射計數器和區域的種類。映射區域有以下幾種:
代碼區域將源代碼的部分與覆蓋率映射計數器相關聯。它們構成了大多數映射區域。代碼覆蓋率工具使用它們來計算行的執行次數、突出顯示從未執行過的代碼區域,以及獲取函數的各種代碼覆蓋率統計信息。例如
int main(int argc, const char *argv[]) { // Code Region from 1:40 to 9:2 if (argc > 1) { // Code Region from 3:17 to 5:4 printf("%s\n", argv[1]); } else { // Code Region from 5:10 to 7:4 printf("\n"); } return 0; }
跳過的區域用於表示被 Clang 的預處理器跳過的源範圍。它們不與覆蓋率映射計數器關聯,因為前端知道它們從未被執行。代碼覆蓋率工具使用它們將函數內跳過的行標記為沒有執行次數的非代碼行。例如
int main() { // Code Region from 1:12 to 6:2 #ifdef DEBUG // Skipped Region from 2:1 to 4:2 printf("Hello world"); #endif return 0; }
展開區域用於表示 Clang 的宏展開。它們具有一個額外屬性 - *展開文件 ID*。代碼覆蓋率工具可以使用此屬性通過檢查其文件 ID 是否與展開文件 ID 匹配來查找由於此宏展開而創建的映射區域。它們不與覆蓋率映射計數器關聯,因為代碼覆蓋率工具可以通過查找具有相應文件 ID 的第一個區域的執行次數來確定此區域的執行次數。例如
int func(int x) { #define MAX(x,y) ((x) > (y)? (x) : (y)) return MAX(x, 42); // Expansion Region from 3:10 to 3:13 }
分支區域將源代碼中可檢測的分支條件與覆蓋率映射計數器相關聯,以跟踪單個條件評估為“真”的次數,以及另一個覆蓋率映射計數器以跟踪該條件評估為假的次數。可檢測的分支條件可以使用布林邏輯運算符包含更大的布林表達式。“真”和“假”情況反映了可以追溯到源代碼的唯一分支路徑。例如
int func(int x, int y) { if ((x > 1) || (y > 3)) { // Branch Region from 3:6 to 3:12 // Branch Region from 3:17 to 3:23 printf("%d\n", x); } else { printf("\n"); } return 0; }
決策區域將多個分支區域與原始碼中的布林運算式關聯起來。此資訊還包括表示運算式執行測試向量所需的位元圖位元數,以及構成運算式的可檢測分支條件總數。決策區域用於在 llvm-cov 中針對每個布林運算式視覺化修改後的條件/決策覆蓋率 (MC/DC)。使用決策區域時,會將控制流程 ID 分配給每個關聯的分支區域。一個 ID 代表目前的分支條件,另外兩個 ID 分別代表在控制流程中,給定真值或假值評估後的下一個分支條件。這允許 llvm-cov 重建條件周圍的控制流程,以便理解潛在可執行測試向量的完整清單。
原始碼範圍:¶
原始碼範圍記錄包含特定映射區域的開始和結束位置。兩個位置都包含行號和列號。
檔案 ID:¶
檔案 ID 是一個整數值,告訴我們這個區域位於哪個原始碼檔案或巨集展開中。它使 Clang 能夠產生巨集中定義的程式碼的映射資訊,如下例所示
void func(const char *str) { // Code Region from 1:28 to 6:2 with file id 0 #define PUT printf("%s\n", str) // 2 Code Regions from 2:15 to 2:34 with file ids 1 and 2 if(*str) PUT; // Expansion Region from 4:5 to 4:8 with file id 0 that expands a macro with file id 1 PUT; // Expansion Region from 5:3 to 5:6 with file id 0 that expands a macro with file id 2 }
計數器:¶
覆蓋率映射計數器可以代表對設定檔檢測計數器的參考。具有此類計數器的區域的執行次數是透過查詢對應的設定檔檢測計數器的值來確定的。
它也可以代表對覆蓋率映射計數器或其他運算式進行運算的二元算術運算式。具有運算式計數器的區域的執行次數是透過評估運算式的參數,然後將它們加總或彼此相減來確定的。在以下範例中,使用減法運算式來計算 else 關鍵字後面的複合語句的執行次數
int main(int argc, const char *argv[]) { // Region's counter is a reference to the profile counter #0 if (argc > 1) { // Region's counter is a reference to the profile counter #1 printf("%s\n", argv[1]); } else { // Region's counter is an expression (reference to the profile counter #0 - reference to the profile counter #1) printf("\n"); } return 0; }
最後,覆蓋率映射計數器也可以代表零的執行次數。零計數器用於為無法到達的語句和運算式提供覆蓋率映射,如下例所示
int main() { return 0; printf("Hello world!\n"); // Unreachable region's counter is zero }
零計數器允許程式碼覆蓋率工具顯示無法到達的行數的正確行執行次數,並突出顯示無法到達的程式碼。如果沒有它們,該工具會認為這些行和區域仍在執行,因為它不具備前端的知識。
請注意,建立分支區域是為了追蹤原始碼中的分支條件,並參考兩個覆蓋率映射計數器,一個用於追蹤分支條件評估為「真」的次數,另一個用於追蹤分支條件評估為「假」的次數。
LLVM IR 表示法¶
覆蓋率映射資料使用名為 __llvm_coverage_mapping 的全域常數結構變數儲存在 LLVM IR 中,並帶有 IPSK_covmap 區段說明符(即 Windows 上的「.lcovmap$M」和其他地方的「__llvm_covmap」)。
例如,讓我們考慮一個 C 檔案以及它如何被編譯成 LLVM
int foo() {
return 42;
}
int bar() {
return 13;
}
Clang 生成的覆蓋率映射變數有 2 個欄位
覆蓋率映射標頭。
轉譯單元中存在的檔案名稱的可選壓縮清單。
該變數具有 8 位元組對齊,因為 ld64 無法始終緊密地打包來自不同目標檔案的符號(字級對齊假設過於深入)。
@__llvm_coverage_mapping = internal constant { { i32, i32, i32, i32 }, [32 x i8] }
{
{ i32, i32, i32, i32 } ; Coverage map header
{
i32 0, ; Always 0. In prior versions, the number of affixed function records
i32 32, ; The length of the string that contains the encoded translation unit filenames
i32 0, ; Always 0. In prior versions, the length of the affixed string that contains the encoded coverage mapping data
i32 3, ; Coverage mapping format version
},
[32 x i8] c"..." ; Encoded data (dissected later)
}, section "__llvm_covmap", align 8
目前版本的格式為版本 6。
版本 6 和版本 5 之間有一個區別
檔名清單中的第一個條目是編譯目錄。當檔名是相對路徑時,編譯目錄會與相對路徑組合以獲得絕對路徑。通過省略檔名中的重複前綴,可以減少大小。
版本 5 和版本 4 之間有一個差異
引入了分支區域的概念以及相應的區域類型。分支區域編碼兩個計數器,一個用於追蹤「真」分支條件被執行的次數,另一個用於追蹤「假」分支條件被執行的次數。
版本 4 和版本 3 之間有兩個差異
函數記錄現在被命名為符號,並標記為 *linkonce_odr*。這允許連結器合併重複的函數記錄。合併重複的 *虛擬* 記錄(為翻譯單元中包含但未使用的函數發出)減少了覆蓋率映射數據中的大小膨脹。作為此更改的一部分,函數的區域映射信息現在包含在函數記錄中,而不是附加到覆蓋率標頭。
翻譯單元的檔名清單可以選擇使用 zlib 壓縮。
版本 3 和版本 2 之間的唯一區別是,引入了列結束位置的特殊編碼來指示間隙區域。
在版本 1 中,*foo* 的函數記錄定義如下
{ i8*, i32, i32, i64 } { i8* getelementptr inbounds ([3 x i8]* @__profn_foo, i32 0, i32 0), ; Function's name
i32 3, ; Function's name length
i32 9, ; Function's encoded coverage mapping data string length
i64 0 ; Function's structural hash
}
在版本 2 中,*foo* 的函數記錄定義如下
{ i64, i32, i64 } {
i64 0x5cf8c24cdb18bdac, ; Function's name MD5
i32 9, ; Function's encoded coverage mapping data string length
i64 0 ; Function's structural hash
覆蓋率映射標頭:¶
如上所示,覆蓋率映射標頭具有以下欄位
附加到覆蓋率標頭的函數記錄數。始終為 0,但為了向後兼容性而保留。
*__llvm_coverage_mapping* 的第三個欄位中字符串的長度,該字符串包含編碼的翻譯單元檔名。
*__llvm_coverage_mapping* 的第三個欄位中字符串的長度,該字符串包含附加到覆蓋率標頭的任何編碼覆蓋率映射數據。始終為 0,但為了向後兼容性而保留。
格式版本。當前版本為 6(編碼為 5)。
函數記錄:¶
函數記錄是以下類型的結構
{ i64, i32, i64, i64, [? x i8] }
它包含函數名稱的 MD5、該函數的編碼映射數據的長度、函數的結構哈希值、函數翻譯單元中檔名的哈希值以及編碼的映射數據。
剖析範例:¶
以下是存儲在先前顯示的覆蓋率映射範例的 IR 中的編碼數據概述
IR 包含以下字符串常量,表示範例翻譯單元的編碼覆蓋率映射數據
c"\01\15\1Dx\DA\13\D1\0F-N-*\D6/+\CE\D6/\C9-\D0O\CB\CF\D7K\06\00N+\07]"
該字符串包含以 LEB128 格式編碼的值,該格式在整個過程中用於存儲整數。它還包含一個壓縮的有效負載。
範例中的前三個 LEB128 編碼數字指定檔名數量、未壓縮檔名的長度以及壓縮有效負載的長度(如果禁用壓縮,則為 0)。在此範例中,有 1 個檔名,長度為 21 個字節(未壓縮),並存儲在 29 個字節(壓縮)中。
第一個函數記錄中的覆蓋率映射在此字符串中編碼
c"\01\00\00\01\01\01\0C\02\02"
此字符串由以下字節組成
0x01
此函數使用的文件 ID 數量。此函數中的映射數據僅使用一個文件 ID。
0x00
檔名數組中的索引,對應於文件「/Users/alex/test.c」。
0x00
此函式所使用的計數器表達式數量。此函式未使用任何表達式。
0x01
存儲在陣列中、用於函式檔案 ID #0 的映射區域數量。
0x01
此函式中第一個區域的覆蓋率映射計數器。值 1 表示它是覆蓋率映射計數器,它是對索引為 0 的設定檔檢測計數器的引用。
0x01
此函式中第一個映射區域的起始行。
0x0C
此函式中第一個映射區域的起始列。
0x02
此函式中第一個映射區域的結束行。
0x02
此函式中第一個映射區域的結束列。
包含第二個函式記錄之編碼覆蓋率映射數據的子字串長度也是 9。其結構類似於第一個函式記錄的映射數據。
結尾的兩個位元組為零,用於填充覆蓋率映射數據,使其達到 8 位元組對齊。
編碼¶
每個函式的覆蓋率映射數據都被編碼為位元組流,結構簡單。該結構由編碼 類型 組成,例如用於編碼 檔案 ID 映射、計數器表達式 和 映射區域 的可變長度無符號整數。
結構的格式如下
[檔案 ID 映射, 計數器 表達式, 映射 區域]
轉譯單元檔名使用與每個函式覆蓋率映射數據相同的編碼 類型 進行編碼,其結構如下
[檔名數量 : LEB128, 檔名0 : 字串, 檔名1 : 字串, ...]
類型¶
本節描述編碼格式使用的基本類型,這些類型可以出現在 :
之後的 [foo : 類型]
描述中。
LEB128¶
LEB128 是一個無符號整數值,使用 DWARF 的 LEB128 編碼進行編碼,針對值較小的情況(值小於 128 時為 1 位元組)進行了優化。
字串¶
[長度 : LEB128, 字元...]
字串值使用 LEB 值 編碼字串長度,並使用一系列位元組編碼其字元。
檔案 ID 映射¶
[索引數量 : LEB128, 檔名索引0 : LEB128, 檔名索引1 : LEB128, ...]
函式覆蓋率映射流中的檔案 ID 映射包含轉譯單元檔名陣列的索引。
計數器¶
[值 : LEB128]
覆蓋率映射計數器 儲存在單個 LEB 值 中。它由兩部分組成 - 儲存在最低 2 位元的 標籤,以及儲存在其餘位元中的 計數器數據。
標籤:¶
計數器的標籤編碼計數器的種類,如果計數器是表達式,則編碼表達式的種類。可能的標籤值為
0 - 計數器為零。
1 - 計數器是對設定檔檢測計數器的引用。
2 - 計數器是減法表達式。
3 - 計數器是加法表達式。
數據:¶
計數器的數據按以下方式解釋
當計數器是對設定檔檢測計數器的引用時,計數器的數據是設定檔計數器的 ID。
當計數器是表達式時,計數器的數據是計數器表達式陣列中的索引。
計數器表達式¶
[表達式數量 : LEB128, 表達式0左運算元 : LEB128, 表達式0右運算元 : LEB128, 表達式1左運算元 : LEB128, 表達式1右運算元 : LEB128, ...]
計數器表達式由兩個計數器組成,因為它們代表二元算術運算。表達式的種類由引用此表達式的計數器的 標籤 決定。
映射區域¶
[區域陣列數量 : LEB128, 檔案0的區域, 檔案1的區域, ...]
映射區域儲存在子陣列的陣列中,其中特定子陣列中的每個區域都具有相同的檔案 ID。
區域子陣列的檔案 ID 是該子陣列在主陣列中的索引,例如,第一個子陣列的檔案 ID 為 0。
區域子陣列¶
[區域數量 : LEB128, 區域0, 區域1, ...]
特定檔案 ID 的映射區域儲存在一個陣列中,該陣列按區域的起始位置以升序排序。
映射區域¶
[標頭, 來源 範圍]
標頭¶
[計數器]
或
[虛擬計數器]
標頭編碼區域的計數器和區域的種類。分支區域將編碼兩個計數器。
計數器標籤的值用於區分計數器和虛擬計數器 - 如果標籤為零,則此標頭包含虛擬計數器,否則此標頭包含普通計數器。
計數器:¶
標頭具有非零標籤計數器的映射區域是程式碼區域。
虛擬計數器:¶
[值 : LEB128]
虛擬計數器儲存在單個 LEB 值 中,就像普通計數器一樣。它具有以下解釋
位元 0-1:標籤,始終為 0。
位元 2:expansionRegionTag。如果設定了此位元,則此映射區域為擴展區域。
剩餘位元:數據。如果此區域是擴展區域,則數據包含該區域的擴展檔案 ID。
否則,數據包含區域的種類。可能的區域種類值為
0 - 此映射區域是計數器為零的程式碼區域。
2 - 此映射區域是被跳過的區域。
4 - 此映射區域是分支區域。
原始碼範圍¶
[deltaLineStart : LEB128, columnStart : LEB128, numLines : LEB128, columnEnd : LEB128]
原始碼範圍記錄包含以下欄位
deltaLineStart:目前映射區域的起始行與先前映射區域的起始行之間的差異。
如果目前映射區域是目前子陣列中的第一個區域,則它儲存該區域的起始行。
columnStart:映射區域的起始列。
numLines:目前映射區域的結束行與起始行之間的差異。
columnEnd:映射區域的結束列。如果設定了最高有效位元,則目前映射區域為間隙區域。僅當一行中沒有其他區域時,間隙區域的計數才會用作行執行計數。
測試格式¶
警告
本節僅適用於正在開發 llvm-cov
的 LLVM 開發人員。
llvm-cov
使用特殊的檔案格式(以下稱為 .covmapping
)進行測試。此格式是私有的,一般用戶不應使用。作為開發人員,您可以透過 llvm-cov
的 convert-for-testing
子命令取得此類檔案。
.covmapping
檔案的結構如下
[magicNumber : u64, version : u64, profileNames, coverageMapping, coverageRecords]
魔數和版本¶
魔數為 0x6d766f636d766c6c
,它是小端序的 ASCII 字串 llvmcovm
。
目前有兩個版本
版本 1,編碼為
0x6174616474736574
(ASCII 字串testdata
)。版本 2,編碼為 1。
版本 1 和版本 2 之間的唯一區別在於 coverageMapping
欄位的編碼,這一點將在稍後說明。
設定檔名稱¶
profileNames
、coverageMapping
和 coverageRecords
是從原始二進制檔案中提取的三個區段。
profileNames
編碼區段的大小、地址和原始數據
[profileNamesSize : LEB128, profileNamesAddr : LEB128, profileNamesData : bytes]
覆蓋率映射¶
此欄位以零字節填充,使其為 8 位元組對齊。
coverageMapping
包含原始碼檔案的記錄。在版本 1 中,只儲存一個記錄
[padding : bytes, coverageMappingData : bytes]
版本 2 放寬了此限制,方法是在數據之前將 coverageMappingData
的大小編碼為 LEB128 數字
[coverageMappingSize : LEB128, padding : bytes, coverageMappingData : bytes]
目前版本為 2。
覆蓋率記錄¶
此欄位以零字節填充,使其為 8 位元組對齊。
coverageRecords
編碼為
[padding : bytes, coverageRecordsData : bytes]
檔案中的其餘數據被視為 coverageRecordsData
。