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 和物件檔案的大小 - 例如,前端可以將具有相同執行計數的語句分組到程式碼區域中,而不是為函數中的每個語句發出對應資訊,並且僅為這些區域發出對應資訊。
進階概念¶
本指南的其餘部分旨在讓您深入了解涵蓋率對應格式的運作方式。
涵蓋率對應格式在每個函數層級運作,因為設定檔檢測計數器與特定函數相關聯。對於每個需要程式碼涵蓋率的函數,前端都必須建立涵蓋率對應資料,以便在該函數的原始碼範圍和設定檔檢測計數器之間建立對應關係。
對應區域¶
函數的涵蓋率對應資料包含對應區域的陣列。對應區域儲存此區域涵蓋的原始碼範圍、檔案 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 }
分支區域將原始碼中可檢測的分支條件與涵蓋率對應計數器關聯,以追蹤個別條件評估為「true」的次數,以及另一個涵蓋率對應計數器來追蹤該條件評估為「false」的次數。可檢測的分支條件可能包含使用布林邏輯運算子的較大布林表達式。「true」和「false」案例反映了可以追溯到原始碼的唯一分支路徑。例如:
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 分別表示在給定 true 或 false 評估的情況下控制流程中的下一個分支條件。這允許 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 }
零計數器允許程式碼涵蓋率工具顯示無法訪問的行的正確行執行計數,並突出顯示無法訪問的程式碼。如果沒有它們,該工具會認為這些行和區域仍然已執行,因為它不具備前端的知識。
請注意,建立分支區域是為了追蹤原始碼中的分支條件,並參考兩個涵蓋率對應計數器,一個用於追蹤分支條件評估為「true」的次數,另一個用於追蹤分支條件評估為「false」的次數。
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 之間有一個差異:
分支區域的概念已引入,並帶有相應的區域種類。分支區域編碼兩個計數器,一個用於追蹤「true」分支條件被採用的次數,另一個用於追蹤「false」分支條件被採用的次數。
版本 4 和版本 3 之間有兩個差異:
函數記錄現在是具名符號,並標記為 linkonce_odr。這允許連結器合併重複的函數記錄。合併重複的 dummy 記錄(針對翻譯單元中包含但未使用的函數發出)減少了涵蓋率對應資料中的大小膨脹。作為此變更的一部分,函數的區域對應資訊現在包含在函數記錄中,而不是附加到涵蓋率標頭。
翻譯單元的檔案名稱列表可以選擇性地使用 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 格式在整個過程中用於儲存整數。它還包含壓縮的酬載。
範例中的前三個 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 對應、 計數器 表達式、 對應 區域]
翻譯單元檔案名稱使用與每個函數的涵蓋率對應資料相同的編碼類型進行編碼,結構如下:
[numFilenames : LEB128、 filename0 : 字串、 filename1 : 字串、 ...]
類型¶
本節描述編碼格式使用的基本類型,並且可以在 :
之後的 [foo : 類型]
描述中出現。
LEB128¶
LEB128 是一個無符號整數值,使用 DWARF 的 LEB128 編碼進行編碼,針對值較小的情況進行最佳化(小於 128 的值為 1 個位元組)。
字串¶
[長度 : LEB128、 字元...]
字串值使用LEB 值(表示字串的長度)和字元位元組序列進行編碼。
檔案 ID 對應¶
[numIndices : LEB128、 filenameIndex0 : LEB128、 filenameIndex1 : LEB128、 ...]
函數涵蓋率對應流中的檔案 ID 對應包含翻譯單元檔案名稱陣列的索引。
計數器¶
[值 : LEB128]
涵蓋率對應計數器儲存在單個LEB 值中。它由兩部分組成 - 標籤(儲存在最低 2 位元中)和計數器資料(儲存在其餘位元中)。
標籤:¶
計數器的標籤編碼計數器的種類,以及如果計數器是表達式,則編碼表達式的種類。可能的標籤值為:
0 - 計數器為零。
1 - 計數器是對設定檔檢測計數器的參考。
2 - 計數器是減法表達式。
3 - 計數器是加法表達式。
資料:¶
計數器的資料以以下方式解釋:
當計數器是對設定檔檢測計數器的參考時,計數器的資料是設定檔計數器的 ID。
當計數器是表達式時,計數器的資料是計數器表達式陣列的索引。
計數器表達式¶
[numExpressions : LEB128、 expr0LHS : LEB128、 expr0RHS : LEB128、 expr1LHS : LEB128、 expr1RHS : LEB128、 ...]
計數器表達式由兩個計數器組成,因為它們表示二進制算術運算。表達式的種類從參考此表達式的計數器的標籤中確定。
對應區域¶
[numRegionArrays : LEB128、 regionsForFile0、 regionsForFile1、 ...]
對應區域儲存在子陣列陣列中,其中特定子陣列中的每個區域都具有相同的檔案 ID。
區域子陣列的檔案 ID 是該子陣列在主陣列中的索引,例如,第一個子陣列的檔案 ID 為 0。
區域子陣列¶
[numRegions : LEB128、 region0、 region1、 ...]
特定檔案 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
是從原始二進制檔案中提取的 3 個區段。
profileNames
編碼區段的大小、地址和原始資料:
[profileNamesSize : LEB128、 profileNamesAddr : LEB128、 profileNamesData : 位元組]
涵蓋率對應¶
此欄位以零位元組填充,使其為 8 位元組對齊。
coverageMapping
包含原始碼檔案的記錄。在版本 1 中,僅儲存一個記錄:
[填充 : 位元組、 coverageMappingData : 位元組]
版本 2 放寬了此限制,方法是在資料之前將 coverageMappingData
的大小編碼為 LEB128 數字:
[coverageMappingSize : LEB128、 填充 : 位元組、 coverageMappingData : 位元組]
目前版本為 2。
涵蓋率記錄¶
此欄位以零位元組填充,使其為 8 位元組對齊。
coverageRecords
編碼為:
[填充 : 位元組、 coverageRecordsData : 位元組]
檔案中的其餘資料被視為 coverageRecordsData
。