基于源代码的代码覆盖率

简介

本文档介绍如何使用 clang 的基于源代码的代码覆盖率功能。它被称为“基于源代码”,因为它直接对 AST 和预处理器信息进行操作。这使得它能够生成非常精确的覆盖率数据。

Clang 附带了另外两种代码覆盖率实现

  • SanitizerCoverage - 一种低开销的工具,旨在与各种清理程序一起使用。它可以提供高达边缘级别的覆盖率。

  • gcov - 一种与 GCC 兼容的覆盖率实现,它在 DebugInfo 上运行。这可以通过 -ftest-coverage--coverage 启用。

从现在开始,“代码覆盖率”将指的是基于源代码的类型。

代码覆盖率工作流程

代码覆盖率工作流程包括三个主要步骤

  • 启用覆盖率进行编译。

  • 运行已插入代码的程序。

  • 创建覆盖率报告。

接下来的几个部分将基于此程序,逐步介绍一个完整的、易于复制粘贴的示例

% cat <<EOF > foo.cc
#define BAR(x) ((x) || (x))
template <typename T> void foo(T x) {
  for (unsigned I = 0; I < 10; ++I) { BAR(I); }
}
int main() {
  foo<int>(0);
  foo<float>(0);
  return 0;
}
EOF

启用覆盖率进行编译

要使用启用覆盖率的代码进行编译,请向编译器传递 -fprofile-instr-generate -fcoverage-mapping

# Step 1: Compile with coverage enabled.
% clang++ -fprofile-instr-generate -fcoverage-mapping foo.cc -o foo

请注意,支持将具有和不具有覆盖率插入代码的代码链接在一起。未插入代码的代码将不会在报告中被计入。

要使用启用了修改条件/决策覆盖率 (MC/DC) 的代码进行编译,请除了上面指定的 clang 选项外,还传递 -fcoverage-mcdc。MC/DC 是一种高级代码覆盖率形式,最适用于嵌入式领域。

运行已插入代码的程序

下一步是运行已插入代码的程序。当程序退出时,它将把一个 **原始配置文件** 写入由 LLVM_PROFILE_FILE 环境变量指定的路径。如果该变量不存在,则配置文件将写入当前目录的 default.profraw。如果 LLVM_PROFILE_FILE 包含指向不存在目录的路径,则将创建缺少的目录结构。此外,以下特殊的 **模式字符串** 将被重写

  • “%p” 展开为进程 ID。

  • “%h” 展开为运行程序的机器的主机名。

  • “%t” 展开为 TMPDIR 环境变量的值。在 Darwin 上,这通常被设置为临时暂存目录。

  • “%Nm” 展开为已插入二进制文件的签名。当指定此模式时,运行时会创建一个包含 N 个原始配置文件的池,用于在线配置文件合并。运行时负责从池中选择一个原始配置文件,锁定它,并在程序退出之前更新它。如果未指定 N(即模式为 “%m”),则假设 N = 1。合并池说明符在每个文件名模式中只能出现一次。

  • “%c” 展开为空,但启用了一种模式,其中配置文件计数器更新会持续同步到一个文件。这意味着,如果已插入代码的程序崩溃或被信号杀死,仍然可以恢复完美的覆盖率信息。连续模式不支持用于 PGO 的值分析,目前仅在 Darwin 上受支持。对 Linux 的支持可能基本完成,但需要测试,而对 Windows 的支持可能需要更广泛的更改:如果您有兴趣移植此功能,请参与。

# Step 2: Run the program.
% LLVM_PROFILE_FILE="foo.profraw" ./foo

请注意,连续模式也用于 Fuchsia,它是唯一支持的模式,但实现方式不同。Darwin 和 Linux 实现依赖于填充和能够将文件映射到现有内存映射的能力,这通常仅在 POSIX 系统上可用,不适用于其他平台。

在 Fuchsia 上,我们依赖于在运行时使用间接级别重新定位计数器。在每次计数器访问时,我们都会向计数器地址添加一个偏差。该偏差存储在 __llvm_profile_counter_bias 符号中,该符号由配置文件运行时提供,最初设置为零,表示没有重新定位。运行时可以将配置文件映射到内存中的任意位置,并将偏差设置为原始位置和新计数器位置之间的偏移量,此时每次后续计数器访问都将指向新位置,这允许更新配置文件,类似于连续模式。

这种方法的优点是不需要任何特殊的 OS 支持。缺点是由于每次计数器访问所需的额外指令(在二进制大小和性能方面都存在开销)以及计数器的重复(即二进制本身中的一份副本和映射到内存中的另一份副本)而导致的额外开销。可以通过在编译期间向后端传递 -runtime-counter-relocation 选项来为其他平台启用此实现。

对于像 Lit 测试工具这样的程序,该工具调用其他程序,可能需要为每次调用设置 LLVM_PROFILE_FILE。模式字符串 “%p” 或 “%Nm” 可能有助于避免由于并发而导致的损坏。请注意, “%p” 也是一个 Lit 令牌,需要转义为 “%%p”。

% clang++ -fprofile-instr-generate -fcoverage-mapping -mllvm -runtime-counter-relocation foo.cc -o foo

创建覆盖率报告

在生成覆盖率报告之前,必须对原始配置文件进行 **索引**。这可以使用 llvm-profdata 中的 “merge” 工具完成(该工具可以合并多个原始配置文件并在同一时间对它们进行索引)

# Step 3(a): Index the raw profile.
% llvm-profdata merge -sparse foo.profraw -o foo.profdata

有关合并测试创建的多个配置文件的示例,请参见 LLVM 覆盖率构建脚本

有多种不同的方法可以呈现覆盖率报告。最简单的选择是生成一个面向行的报告

# Step 3(b): Create a line-oriented coverage report.
% llvm-cov show ./foo -instr-profile=foo.profdata

此报告包括一个摘要视图以及模板函数及其实例化的专用子视图。对于我们的示例程序,我们获得了 foo<int>(...)foo<float>(...) 的不同视图。如果启用了 -show-line-counts-or-regions,则 llvm-cov 会显示子行区域计数(即使在宏扩展中也是如此)

    1|   20|#define BAR(x) ((x) || (x))
                           ^20     ^2
    2|    2|template <typename T> void foo(T x) {
    3|   22|  for (unsigned I = 0; I < 10; ++I) { BAR(I); }
                                   ^22     ^20  ^20^20
    4|    2|}
------------------
| void foo<int>(int):
|      2|    1|template <typename T> void foo(T x) {
|      3|   11|  for (unsigned I = 0; I < 10; ++I) { BAR(I); }
|                                     ^11     ^10  ^10^10
|      4|    1|}
------------------
| void foo<float>(int):
|      2|    1|template <typename T> void foo(T x) {
|      3|   11|  for (unsigned I = 0; I < 10; ++I) { BAR(I); }
|                                     ^11     ^10  ^10^10
|      4|    1|}
------------------

如果还启用了 --show-branches=count--show-expansions,则子视图将显示详细的分支覆盖率信息,以及区域计数

------------------
| void foo<float>(int):
|      2|    1|template <typename T> void foo(T x) {
|      3|   11|  for (unsigned I = 0; I < 10; ++I) { BAR(I); }
|                                     ^11     ^10  ^10^10
|  ------------------
|  |  |    1|     10|#define BAR(x) ((x) || (x))
|  |  |                             ^10     ^1
|  |  |  ------------------
|  |  |  |  Branch (1:17): [True: 9, False: 1]
|  |  |  |  Branch (1:24): [True: 0, False: 1]
|  |  |  ------------------
|  ------------------
|  |  Branch (3:23): [True: 10, False: 1]
|  ------------------
|      4|    1|}
------------------

如果使用 clang 选项 -fcoverage-mcdc 为修改条件/决策覆盖率 (MC/DC) 插入代码,则可以使用 --show-mcdc 启用 MC/DC 子视图,该子视图将显示每个复杂条件布尔表达式的详细 MC/DC 信息,该表达式最多包含六个条件。

要生成覆盖率统计信息的每个文件级别的摘要,而不是面向行的报告,请尝试

# Step 3(c): Create a coverage summary.
% llvm-cov report ./foo -instr-profile=foo.profdata
Filename           Regions    Missed Regions     Cover   Functions  Missed Functions  Executed       Lines      Missed Lines     Cover     Branches    Missed Branches     Cover
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
/tmp/foo.cc             13                 0   100.00%           3                 0   100.00%          13                 0   100.00%           12                  2    83.33%
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
TOTAL                   13                 0   100.00%           3                 0   100.00%          13                 0   100.00%           12                  2    83.33%

llvm-cov 工具支持指定自定义反汇编器,在目录结构中写入报告,以及生成 html 报告。有关选项的完整列表,请参阅 命令指南

最后几点说明

  • -sparse 标志是可选的,但会导致索引配置文件的大小大幅减小。如果索引配置文件将被重复用于 PGO,则不应使用此选项。

  • 在索引原始配置文件后,可以丢弃它们。配置文件运行时库的高级使用允许已插入代码的程序将分析信息直接合并到磁盘上的现有原始配置文件中。细节不在本文档的范围之内。

  • llvm-profdata 工具可用于合并多个原始配置文件或索引配置文件。要合并程序多次运行的分析数据,请尝试例如

    % llvm-profdata merge -sparse foo1.profraw foo2.profdata -o foo3.profdata
    

导出覆盖率数据

可以使用 llvm-cov export 子命令将覆盖率数据导出为 JSON。在 llvm-cov 源代码中有一个综合参考,该参考在高级别上定义了导出数据的结构。

解释报告

在覆盖率摘要中跟踪了六个统计信息

  • 函数覆盖率是指至少执行过一次的函数的百分比。如果函数的任何实例都执行过,则该函数被认为是执行过的。

  • 实例化覆盖率是指至少执行过一次的函数实例化的百分比。模板函数和来自头文件的静态内联函数是两种可能具有多个实例化的函数。此统计信息在报告中默认隐藏,但可以通过 -show-instantiation-summary 选项启用。

  • 行覆盖率是指至少执行过一次的代码行的百分比。只有函数体内的可执行行被认为是代码行。

  • 区域覆盖率是指至少执行过一次的代码区域的百分比。一个代码区域可能跨越多行(例如,在一个没有控制流的大函数体中)。但是,一个代码行也可能包含多个代码区域(例如,在 “return x || y && z” 中)。

  • 分支覆盖率是指至少执行过一次的“真”和“假”分支的百分比。每个分支都与源代码中的单个条件相关联,这些条件可以分别评估为“真”或“假”。这些条件可以包含由布尔逻辑运算符连接的更大的布尔表达式。例如,“x = (y == 2) || (z < 10)”是一个布尔表达式,它包含两个独立的条件,每个条件都评估为真或假,产生四个总分支结果。

  • 修改后的条件/决策覆盖率 (MC/DC) 是指已证明能独立影响其包含的布尔表达式决策结果的单个分支条件的百分比。这是通过分析执行的控制流来实现的(即测试向量),以表明当条件的结果在“真”和“假”之间变化时,决策的结果也在“真”和“假”之间变化,而所有其他条件的结果保持固定(或被屏蔽为不可评估,就像在逻辑运算符具有短路语义的语言中发生的那样)。MC/DC 在分支覆盖率的基础上构建,并要求已测试所有代码块和所有执行路径。此统计信息在报告中默认隐藏,但可以通过 -show-mcdc-summary 选项启用,只要代码也是使用 clang 选项 -fcoverage-mcdc 编译的。

    • 仅包含一个条件(因此没有逻辑运算符)的布尔表达式不包含在 MC/DC 分析中,并且可以使用分支覆盖率轻松推断。

在这六个统计数据中,函数覆盖率通常是最不细粒度的,而分支覆盖率(包含 MC/DC)是最细粒度的。函数的 100% 分支覆盖率意味着函数的 100% 区域覆盖率。每个统计信息的项目级总数列在摘要中。

格式兼容性保证

  • 原始配置文件格式没有向后或向前兼容性保证。原始配置文件可能取决于用于生成它们的特定编译器版本。不建议长时间存储原始配置文件。

  • 工具必须保留与索引配置文件格式的 **向后** 兼容性。这些格式不向前兼容:即,使用格式版本 X 的工具将无法理解格式版本 (X+k)。

  • 工具还必须保留与插入二进制文件中发出的覆盖率映射格式的 **向后** 兼容性。这些格式不向前兼容。

  • JSON 覆盖率导出格式具有 (主版本、次版本、修订版本) 版本三元组。只有主版本增量表示向后不兼容的更改。次版本增量用于添加功能,修订版本增量用于错误修复。

llvm 优化对覆盖率报告的影响

llvm 优化(如内联或 CFG 简化)不应影响覆盖率报告质量。这是因为从源区域到配置文件计数器的映射是不可变的,并且是在 llvm 优化器启动之前生成的。优化器无法证明配置文件计数器插装是安全的(因为它不是:它会影响程序发出的配置文件),因此会保留它。

请注意,此覆盖率功能不依赖于在优化过程中可能会退化的信息,例如调试信息行表。

在没有静态初始化器的情况下使用配置文件运行时

默认情况下,编译器运行时使用静态初始化器来确定配置文件输出路径并注册写入器函数。要收集不使用静态初始化器的配置文件,请手动执行此操作

  • 从每个插入的共享库和可执行文件中导出一个 int __llvm_profile_runtime 符号。当链接器找到此符号的定义时,它知道要跳过加载包含配置文件运行时的静态初始化器的对象。

  • 前向声明 void __llvm_profile_initialize_file(void) 并从每个插入的可执行文件中调用它一次。此函数解析 LLVM_PROFILE_FILE,设置输出路径,并截断该路径下任何现有文件。要获得相同行为而不截断现有文件,请将文件名模式字符串传递给 void __llvm_profile_set_filename(char *)。这些调用可以放置在任何位置,只要它们位于对 __llvm_profile_write_file 的所有调用之前即可。

  • 前向声明 int __llvm_profile_write_file(void) 并调用它来写入配置文件。此函数在成功时返回 0,否则返回非零值。多次调用此函数会将配置文件数据追加到现有的磁盘上原始配置文件。

在 C++ 文件中,将这些声明为 extern "C"

在没有文件系统的情况下使用配置文件运行时

配置文件运行时还支持缺少文件系统的独立环境。运行时作为静态存档提供,该存档的结构旨在使对托管环境的依赖关系可选,具体取决于客户端应用程序使用哪些功能。

第一步是导出 __llvm_profile_runtime(如上所述),以禁用默认静态初始化器。不要调用上面描述的 *_file() API,而是使用以下方法将配置文件直接保存到您控制的缓冲区中

  • 前向声明 uint64_t __llvm_profile_get_size_for_buffer(void) 并调用它来确定配置文件的大小。您需要为此大小分配一个缓冲区。

  • 前向声明 int __llvm_profile_write_buffer(char *Buffer) 并调用它将当前计数器复制到 Buffer,预计该缓冲区已经分配并足够大以容纳配置文件。

  • 可选地,前向声明 void __llvm_profile_reset_counters(void) 并调用它在进入要分析的特定部分之前重置计数器。这只有在有一些要从配置文件中排除的设置时才有用。

在 C++ 文件中,将这些声明为 extern "C"

为 llvm 项目收集覆盖率报告

要为 llvm(及其任何子项目)准备覆盖率报告,请将 -DLLVM_BUILD_INSTRUMENTED_COVERAGE=On 添加到 cmake 配置中。原始配置文件将写入 $BUILD_DIR/profiles/。要准备 HTML 报告,请运行 llvm/utils/prepare-code-coverage-artifact.py

要指定原始配置文件的备用目录,请使用 -DLLVM_PROFILE_DATA_DIR。要更改配置文件合并池的大小,请使用 -DLLVM_PROFILE_MERGE_POOL_SIZE

缺点和限制

  • 在版本 2.26 之前,GNU binutils BFD 链接器无法以其 --gc-sections 模式链接使用 -fcoverage-mapping 编译的程序。可能的解决方法包括禁用 --gc-sections,升级到更新版本的 BFD,或使用 Gold 链接器。

  • 代码覆盖率不会精确地处理在存在异常的情况下控制流或堆栈展开中的不可预测变化。考虑以下功能

    int f() {
      may_throw();
      return 0;
    }
    

    如果对 may_throw() 的调用将异常传播到 f 中,代码覆盖率工具可能会将 return 语句标记为已执行,即使它没有执行。对 longjmp() 的调用可能会产生类似的效果。

Clang 实现细节

本节可能对希望了解或改进 clang 代码覆盖率实现的人员有用。

间隙区域

间隙区域是带有计数的源区域。报告工具无法将行执行计数设置为来自间隙区域的计数,除非该区域是行上唯一的区域。

间隙区域用于消除覆盖率报告中不自然的伪影,例如覆盖的线的末尾出现的红色“未执行”突出显示,或未执行的线的开头出现的蓝色“已执行”突出显示。

分支区域

使用 --show-branches 在基于源代码的文件级子视图中查看分支覆盖率详细信息时,建议用户显示所有宏展开(使用选项 --show-expansions),因为宏可能包含隐藏的分支条件。覆盖率摘要报告将始终在函数或源代码文件的总体分支覆盖率计数中包含这些基于宏的布尔表达式。

不跟踪常量折叠分支条件的分支覆盖率,因为对于这些情况不会生成分支。在基于源代码的文件级子视图中,这些分支将简单地显示为 [Folded - Ignored],以便用户了解发生了什么。

分支覆盖率与源代码中生成分支的条件直接相关。用户不应该看到与源代码无关的隐藏分支。

MC/DC 插装

使用 clang 选项 -fcoverage-mcdc 对修改后的条件/决策覆盖率 (MC/DC) 进行插装时,存在两个硬限制。

项的最大数量限制为 32767,这对手工编写的表达式来说是实用的。要更严格地强制执行编码规则,请使用 -Xclang -fmcdc-max-conditions=n。超过条件计数 n 的表达式将生成警告,并将被排除在 MC/DC 覆盖率之外。

测试向量(表达式可能组合的最大数量)限制为 2,147,483,646。在这种情况下,大约 256MiB (==2GiB/8) 用于记录测试向量。

为了减少内存使用量,用户可以使用 -Xclang -fmcdc-max-test-vectors=m 限制每个表达式测试向量的最大数量。如果表达式分析产生的测试向量数量超过 m,则会发出警告,并且该表达式将从 MC/DC 覆盖率中排除。

对于表达式中 n 个项,测试向量数量 m 在理论最坏情况下可以是 m <= 2^n,但通常要小得多。在简单情况下,例如由一系列单一运算符组成的表达式,m == n+1。例如,(a && b && c && d && e && f && g) 需要 8 个测试向量。

((a0 && b0) || (a1 && b1) || ...) 这样的表达式会导致测试向量数量呈指数级增长。

此外,如果布尔表达式嵌套在另一个布尔表达式的嵌套中,但被非逻辑运算符隔开,则也不支持。例如,在 x = (a && b && c && func(d && f)) 中,d && f 案例开始了一个新的布尔表达式,该表达式通过运算符 func() 与其他条件隔开。遇到这种情况时,将生成警告,并且布尔表达式不会被检测。

Switch 语句

switch 主体的区域映射包含一个覆盖整个主体的间隙区域(从 'switch (…) {' 中的 '{' 开始,到最后一个 case 结束为止)。该间隙区域的计数为零:这会导致 case 语句之间的“间隙”区域(不包含可执行代码)显示为未覆盖。

当访问 switch case 时,父区域将扩展:如果父区域没有起始位置,则其起始位置将成为 case 的起始位置。这用于支持没有 CompoundStmt 主体的 switch 语句,其中 switch 主体和单个 case 共享一个计数。

对于具有 CompoundStmt 主体的 switch,将在每个 switch case 的开头创建一个新区域。

每个 switch case(包括默认 case)也会生成分支区域。如果源代码中没有明确定义的默认 case,则会生成一个分支区域以对应于编译器生成的隐式默认 case。隐式分支区域与 switch 语句条件的行号和列号相关联,因为不存在隐式 case 的源代码。