LLVM 代码覆盖率映射格式

简介

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

本文档旨在帮助那些想要了解 LLVM 代码覆盖率映射如何在幕后工作的人。预先了解 Clang 的profile guided optimization(引导式优化)如何工作是有用的,但不是必需的。对于那些有兴趣使用 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 中可视化每个布尔表达式的 Modified Condition/Decision Coverage (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 section specifier(即 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。这允许链接器合并重复的函数记录。合并重复的 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 字节)。

字符串

[length : LEB128, characters...]

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

文件 ID 映射

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

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

计数器

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

映射区域

[头, 范围]

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

源范围

[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

目前有两个版本:

  • Version1,编码为 0x6174616474736574(ASCII 字符串 testdata)。

  • Version2,编码为 1。

Version1 和 Version2 之间唯一的区别在于 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