基于源代码的代码覆盖率 — Clang 20.0.0git 文档

七月 15, 2025 [C/C++] #obsidian #操作系统原理 #编译链接

基于源代码的代码覆盖率 — Clang 20.0.0git 文档

原文链接:https://clang.llvm.net.cn/docs/SourceBasedCodeCoverage.html

简介

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

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

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

代码覆盖率工作流程

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

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

% 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 包含指向不存在目录的路径,则将创建缺少的目录结构。此外,以下特殊的模式字符串 将被重写

# 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<float>(...) 和 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 报告。有关选项的完整列表,请参阅 命令指南。

最后几点说明

导出覆盖率数据

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

解释报告

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

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

格式兼容性保证

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

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

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

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

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

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

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

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

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

在 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。

缺点和限制

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 的源代码。