LLVM 代码覆盖率映射格式

简介

LLVM 的代码覆盖率映射格式用于使用 LLVM 和 Clang 基于插桩的分析来提供代码覆盖率分析(Clang 的 -fprofile-instr-generate 选项)。

本文档的目标读者是那些希望了解 LLVM 的代码覆盖率映射内部工作原理的人。事先了解 Clang 的配置文件引导优化如何工作是有帮助的,但不是必需的。对于那些有兴趣使用 LLVM 为自己的程序提供代码覆盖率分析的人,请参阅 Clang 文档 <https://clang.llvm.net.cn/docs/SourceBasedCodeCoverage.html>

我们首先简要描述 LLVM 的代码覆盖率映射格式以及 Clang 和 LLVM 的代码覆盖率工具如何使用此格式。在掌握基础知识后,将讨论覆盖率映射格式的更高级功能,例如数据结构、LLVM IR 表示和二进制编码。

高级概述

LLVM 的代码覆盖率映射格式旨在成为一种自包含的数据格式,可以嵌入到 LLVM IR 和目标文件中。在本文档中将其描述为一种**映射**格式,因为其目标是存储代码覆盖率工具所需的数据,以便在文件中的特定源代码范围和运行已插桩程序版本后获得的执行计数之间进行映射。

映射数据在代码覆盖率过程中有两个地方使用

  1. 当 clang 使用 -fcoverage-mapping 编译源文件时,它会生成描述源代码范围和分析插桩计数器之间映射关系的映射信息。此信息会被嵌入到 LLVM IR 中,并在程序链接时方便地最终出现在最终的可执行文件中。

  2. 它也由 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
    }
    

  • 分支区域将源代码中的可插桩分支条件与覆盖率映射计数器关联,以跟踪某个条件评估为“真”的次数,以及另一个覆盖率映射计数器以跟踪该条件评估为假的次数。可插桩分支条件可能包含使用布尔逻辑运算符的更大布尔表达式。“真”和“假”情况反映了可以追溯到源代码的唯一分支路径。例如

    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 IR 中,使用名为 __llvm_coverage_mapping 的全局常量结构变量,并使用 IPSK_covmap 段说明符(即 Windows 上的“.lcovmap$M”以及其他地方的“__llvm_covmap”)。

例如,让我们考虑一个 C 文件以及它是如何编译成 LLVM 的

int foo() {
  return 42;
}
int bar() {
  return 13;
}

Clang 生成的覆盖率映射变量有两个字段

  • 覆盖率映射头。

  • 翻译单元中存在的可选压缩文件名列表。

该变量具有 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。这允许链接器合并重复的函数记录。合并重复的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映射计数器表达式映射区域

结构的格式如下

[file id mapping, counter expressions, mapping regions]

翻译单元文件名使用与每个函数覆盖映射数据相同的编码类型进行编码,结构如下

[numFilenames : LEB128, filename0 : string, filename1 : string, ...]

类型

本节描述编码格式使用的基本类型,这些类型可以在:之后出现在[foo : type]描述中。

LEB128

LEB128是一个无符号整数,使用DWARF的LEB128编码进行编码,优化了值较小的情况(对于小于128的值为1字节)。

字符串

[length : LEB128, characters...]

字符串值使用LEB值(表示字符串的长度)和字节序列(表示其字符)进行编码。

文件ID映射

[numIndices : LEB128, filenameIndex0 : LEB128, filenameIndex1 : LEB128, ...]

函数覆盖映射流中的文件ID映射包含翻译单元的文件名数组中的索引。

计数器

[value : LEB128]

一个覆盖映射计数器存储在一个单独的LEB值中。它由两部分组成——存储在最低两位的标记,以及存储在其余位中的计数器数据

标记:

计数器的标记编码计数器的种类,如果计数器是表达式,则编码表达式的种类。可能的标记值为

  • 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的映射区域存储在一个数组中,该数组按区域的起始位置升序排序。

映射区域

[header, source range]

映射区域记录包含两个子记录——(存储计数器和/或区域的种类)以及源范围(包含此区域的起始和结束位置)。

源范围

[deltaLineStart : LEB128, columnStart : LEB128, numLines : LEB128, columnEnd : LEB128]

源范围记录包含以下字段:

  • deltaLineStart:当前映射区域的起始行与前一个映射区域的起始行之间的差值。

    如果当前映射区域是当前子数组中的第一个区域,则它存储该区域的起始行。

  • columnStart:映射区域的起始列。

  • numLines:当前映射区域的结束行与起始行之间的差值。

  • columnEnd:映射区域的结束列。如果最高位被设置,则当前映射区域为间隙区域。仅当一行上没有其他区域时,间隙区域的计数才用作行执行计数。

测试格式

警告

本节仅供参与 llvm-cov 开发的 LLVM 开发人员参考。

llvm-cov 使用一种特殊的格式(如下所述的 .covmapping)进行测试。此格式是私有的,普通用户不应使用。作为开发人员,您可以通过 llvm-covconvert-for-testing 子命令获取此类文件。

.covmapping 文件的结构如下所示:

[magicNumber : u64, version : u64, profileNames, coverageMapping, coverageRecords]

魔数和版本

魔数为 0x6d766f636d766c6c,是小端序的 ASCII 字符串 llvmcovm

目前有两个版本:

  • 版本 1,编码为 0x6174616474736574(ASCII 字符串 testdata)。

  • 版本 2,编码为 1。

版本 1 和版本 2 之间的唯一区别在于 coverageMapping 字段的编码,这将在后面解释。

配置文件名

profileNamescoverageMappingcoverageRecords 是从原始二进制文件中提取的 3 个部分。

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