基于仪器的代码覆盖率

简介

Rust 编译器包含两个代码覆盖率实现

  • 一个与 GCC 兼容的基于 gcov 的覆盖率实现,通过 -Z profile 启用,它根据 DebugInfo 推导出覆盖率数据。
  • 一个基于源代码的代码覆盖率实现,通过 -C instrument-coverage 启用,它使用 LLVM 的原生高效覆盖率检测来生成非常精确的覆盖率数据。

本文档介绍了如何通过 -C instrument-coverage 编译器标志启用和使用 LLVM 基于检测的覆盖率。

工作原理

启用 -C instrument-coverage 后,Rust 编译器会通过以下方式增强基于 Rust 的库和二进制文件:

  • 在编译代码中的函数和分支处自动注入对 LLVM 内在函数 (llvm.instrprof.increment) 的调用,以在执行代码的条件部分时递增计数器。
  • 在每个库和二进制文件的数据部分嵌入附加信息(使用 LLVM 代码覆盖率映射格式 版本 5(如果使用 LLVM 12 编译)或 版本 6(如果使用 LLVM 13 或更高版本编译)),以定义正在计数的代码区域(源代码中的起始和结束位置)。

在运行覆盖率检测的程序时,计数器值将在程序终止时写入 profraw 文件。LLVM 包含读取计数器结果、将这些结果与覆盖率映射(嵌入在程序二进制文件中)结合起来并生成多种格式的覆盖率报告的工具。

注意-C instrument-coverage 还会自动启用 -C symbol-mangling-version=v0(跟踪问题 #60705)。强烈建议使用 v0 符号修饰器。可以通过显式添加 -Z unstable-options -C symbol-mangling-version=legacy 来覆盖 v0 反修饰器。

在 Rust 编译器中启用覆盖率分析

Rust 的基于源代码的代码覆盖率需要 Rust 的“分析器运行时”。如果没有它,使用 -C instrument-coverage 编译会生成一个错误,提示缺少分析器运行时。

Rust 的 nightly 分发渠道默认包含分析器运行时。

重要:如果您是从源代码分发版构建 Rust 编译器,则分析器运行时在默认的 config.example.toml启用。编辑您的 config.toml 文件,并确保 profiler 特性设置为 true(在 [build] 部分或单个 [target.<triple>] 的设置下)。

# Build the profiler runtime (required when compiling with options that depend
# on this runtime, such as `-C profile-generate` or `-C instrument-coverage`).
profiler = true

构建反修饰器

LLVM 覆盖率报告工具生成的結果可能包含函数名称和其他符号引用,原始覆盖率结果使用编译器的“修饰”版本的符号名称报告符号,这可能难以解释。为了解决这个问题,LLVM 覆盖率工具还支持用户指定的符号名称反修饰器。

Rust 反修饰器的一个选项是 rustfilt,它可以使用以下命令安装:

cargo install rustfilt

另一个选项是,如果您是从 Rust 编译器源代码分发版构建的,则可以使用 Rust 源代码分发版中包含的 rust-demangler 工具,可以使用以下命令构建:

$ ./x.py build rust-demangler

启用覆盖率编译

设置 -C instrument-coverage 编译器标志以启用 LLVM 基于源代码的代码覆盖率分析。

默认选项会为所有函数生成覆盖率,包括未使用的(从未调用的)函数和泛型。编译器标志支持一个可选值来定制此行为。(参见下面的 -C instrument-coverage=<options>。)

使用 cargo,您可以同时检测您的程序二进制文件依赖项。

例如(如果您的项目的 Cargo.toml 默认构建二进制文件)

$ cd your-project
$ cargo clean
$ RUSTFLAGS="-C instrument-coverage" cargo build

如果 cargo 未配置为使用您的启用 profilerrustc 版本,请通过 RUSTC 环境变量显式设置路径。以下是一个使用 stage1 版本的 rustc 编译 example 二进制文件(来自 json5format 包)的示例:

$ RUSTC=$HOME/rust/build/x86_64-unknown-linux-gnu/stage1/bin/rustc \
    RUSTFLAGS="-C instrument-coverage" \
    cargo build --example formatjson5

注意:某些编译器选项与 -C instrument-coverage 结合使用,可能会生成与 LLVM 覆盖率映射不兼容的 LLVM IR 和/或链接的二进制文件。例如,覆盖率需要对 LLVM IR 中的实际函数的引用。如果任何被覆盖的函数被优化掉,覆盖率工具可能无法处理覆盖率结果。如果您需要传递其他选项,并在启用覆盖率的情况下使用,请尽早测试它们,以确认您将获得预期的覆盖率结果。

运行检测的二进制文件以生成原始覆盖率分析数据

在前面的示例中,cargo 生成了覆盖率检测的二进制文件 formatjson5

$ echo "{some: 'thing'}" | target/debug/examples/formatjson5 -
{
    some: "thing",
}

运行此程序后,当前工作目录中应该会生成一个名为 default_11699812450447639123_0_20944 的新文件。每次运行程序时都会生成一个新的唯一文件名,以避免覆盖以前的数据。

$ echo "{some: 'thing'}" | target/debug/examples/formatjson5 -
...
$ ls default_*.profraw
default_11699812450447639123_0_20944.profraw

您还可以使用环境变量 LLVM_PROFILE_FILE 设置生成的 .profraw 文件的特定文件名或路径。

$ echo "{some: 'thing'}" \
    | LLVM_PROFILE_FILE="formatjson5.profraw" target/debug/examples/formatjson5 -
...
$ ls formatjson5.profraw
formatjson5.profraw

如果 LLVM_PROFILE_FILE 包含指向不存在目录的路径,则会创建缺少的目录结构。此外,以下特殊模式字符串将被重写

  • %p - 进程 ID。
  • %h - 运行程序的机器的主机名。
  • %t - TMPDIR 环境变量的值。
  • %Nm - 被插桩二进制文件的签名:运行时创建一个包含 N 个原始配置文件的池,用于在线配置文件合并。运行时负责从池中选择一个原始配置文件,锁定它,并在程序退出之前更新它。N 必须介于 19 之间,如果省略(仅使用 %m)则默认为 1
  • %c - 不向文件名添加任何内容,但启用一种模式(在某些平台上,包括 Darwin),在这种模式下,配置文件计数器更新会持续同步到文件。这意味着,如果被插桩程序崩溃或被信号杀死,仍然可以恢复完美的覆盖率信息。

在上面的第一个示例中,生成的文件名中的值 11699812450447639123_0 是被插桩二进制文件的签名,它替换了 %m 模式,而值 20944 是正在执行的二进制文件的进程 ID。

安装 LLVM 覆盖率工具

LLVM 提供了两个工具——llvm-profdatallvm-cov——用于处理覆盖率数据并生成报告。有几种方法可以找到和/或安装这些工具,但请注意,Rust 编译器生成的覆盖率映射数据需要 LLVM 12 或更高版本,处理原始数据可能需要与编译器使用的 LLVM 版本完全相同。(llvm-cov --version 通常显示工具的 LLVM 版本号,而 rustc --verbose --version 显示 Rust 编译器使用的 LLVM 版本。)

  • 您可以通过 rustup 组件 llvm-tools-preview 安装这些工具的兼容版本。此组件是推荐的路径,尽管目前可用的特定工具及其接口不受 Rust 通常的稳定性保证的约束。在这种情况下,您可能还会发现 cargo-binutils 作为这些工具的包装器很有用。
  • 您可以从您的操作系统发行版或您的 LLVM 发行版安装兼容版本的 LLVM 工具。
  • 如果您是从源代码构建 Rust 编译器,则可以选择使用捆绑的 LLVM 工具,这些工具是从源代码构建的。这些工具二进制文件通常可以在您的构建平台目录中找到,例如:rust/build/x86_64-unknown-linux-gnu/llvm/bin/llvm-*

本文档中的示例展示了如何直接使用 llvm 工具。

创建覆盖率报告

在使用原始配置文件生成覆盖率报告之前,必须对其进行索引。这可以使用 llvm-profdata merge 完成,它可以合并多个原始配置文件并在同一时间对其进行索引。

$ llvm-profdata merge -sparse formatjson5.profraw -o formatjson5.profdata

最后,.profdata 文件与覆盖率映射(来自程序二进制文件)一起使用,以使用 llvm-cov report 生成覆盖率报告,以获取覆盖率摘要;以及 llvm-cov show,以查看覆盖原始源代码的代码行和区域(字符范围)的详细覆盖率。

这些命令有几个显示和过滤选项。例如

$ llvm-cov show -Xdemangler=rustfilt target/debug/examples/formatjson5 \
    -instr-profile=formatjson5.profdata \
    -show-line-counts-or-regions \
    -show-instantiations \
    -name=add_quoted_string
Screenshot of sample `llvm-cov show` result, for function add_quoted_string



此示例中一些更值得注意的选项包括

  • --Xdemangler=rustfilt - 用于反解 Rust 符号的命令名称或路径(示例中的 rustfilt,但这也可以是 rust-demangler 工具的路径)
  • target/debug/examples/formatjson5 - 被插桩二进制文件(从中提取覆盖率映射)
  • --instr-profile=<path-to-file>.profdata - 由 llvm-profdata merge 创建的 .profdata 文件的位置(来自被插桩二进制文件生成的 .profraw 文件)
  • --name=<exact-function-name> - 显示特定函数的覆盖率(或者,考虑使用其他过滤选项,例如 --name-regex=<pattern>

注意:可以通过使用 [coverage(off) 属性](需要功能标志 #![feature(coverage)])来禁用单个函数的覆盖率。

解释报告

覆盖率摘要中跟踪了四个统计数据

  • 函数覆盖率是至少执行过一次的函数的百分比。如果任何函数实例被执行,则该函数被认为是执行的。
  • 实例覆盖率是至少执行过一次的函数实例的百分比。泛型函数和从宏生成的函数是可能具有多个实例的两种类型的函数。
  • 行覆盖率是至少执行过一次的代码行的百分比。只有函数体内的可执行行被视为代码行。
  • 区域覆盖率是至少执行过一次的代码区域的百分比。代码区域可能跨越多行:例如,在一个没有控制流的大型函数体中。在其他情况下,一行可以包含多个代码区域:return x || (y && z)x(如果 xtrue,则可能解析表达式)、|| (y && z)(仅在 xfalse 时执行)和 return(在任何情况下都执行)具有可计数的代码区域。

在这四个统计数据中,函数覆盖率通常是最不细粒度的,而区域覆盖率是最细粒度的。每个统计数据的项目范围总数列在摘要中。

测试覆盖率

覆盖率分析的典型用例是测试覆盖率。Rust 的基于源代码的覆盖率工具既可以衡量测试的代码覆盖率百分比,也可以查明未测试的函数和分支。

以下示例(使用 json5format 箱,用于演示目的)展示了如何为箱中的所有测试生成和分析覆盖率结果。

由于 cargo test 既构建又运行测试,因此我们设置了额外的 RUSTFLAGS,以添加 -C instrument-coverage 标志。

$ RUSTFLAGS="-C instrument-coverage" \
    cargo test --tests

注意LLVM_PROFILE_FILE 的默认值为 default_%m_%p.profraw。1.65 之前的版本默认值为 default.profraw,因此如果使用这些早期版本,建议明确设置 LLVM_PROFILE_FILE="default_%m_%p.profraw" 以避免多个测试覆盖 .profraw 文件。

请注意测试二进制文件路径,这些路径显示在测试输出中的“Running”一词之后。

   ...
   Compiling json5format v0.1.3 ($HOME/json5format)
    Finished test [unoptimized + debuginfo] target(s) in 14.60s

     Running target/debug/deps/json5format-fececd4653271682
running 25 tests
...
test result: ok. 25 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

     Running target/debug/deps/lib-30768f9c53506dc5
running 31 tests
...
test result: ok. 31 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

现在您应该有一个或多个 .profraw 文件,每个测试二进制文件一个。运行 profdata 工具将它们合并。

$ llvm-profdata merge -sparse default_*.profraw -o json5format.profdata

然后运行 cov 工具,使用 profdata 文件和所有测试二进制文件。

$ llvm-cov report \
    --use-color --ignore-filename-regex='/.cargo/registry' \
    --instr-profile=json5format.profdata \
    --object target/debug/deps/lib-30768f9c53506dc5 \
    --object target/debug/deps/json5format-fececd4653271682
$ llvm-cov show \
    --use-color --ignore-filename-regex='/.cargo/registry' \
    --instr-profile=json5format.profdata \
    --object target/debug/deps/lib-30768f9c53506dc5 \
    --object target/debug/deps/json5format-fececd4653271682 \
    --show-instantiations --show-line-counts-or-regions \
    --Xdemangler=rustfilt | less -R

注意:如果通过 LLVM_PROFILE_FILE 环境变量覆盖默认的 profraw 文件名,强烈建议使用 %m%p 特殊模式字符串在执行多个测试二进制文件的情况下生成唯一的文件名。

注意:命令行选项 --ignore-filename-regex=/.cargo/registry,它从覆盖率结果中排除依赖项的源代码。

自动列出二进制文件的技巧

对于 bash 用户,建议使用以下命令自动完成包含二进制文件列表的 cov 命令

$ llvm-cov report \
    $( \
      for file in \
        $( \
          RUSTFLAGS="-C instrument-coverage" \
            cargo test --tests --no-run --message-format=json \
              | jq -r "select(.profile.test == true) | .filenames[]" \
              | grep -v dSYM - \
        ); \
      do \
        printf "%s %s " -object $file; \
      done \
    ) \
  --instr-profile=json5format.profdata --summary-only # and/or other options

--no-run --message-format=json 添加到与用于运行测试的相同 cargo test 命令(包括相同的环境变量和标志)中,会以 JSON 格式生成输出,jq 可以轻松查询该输出。

printf 命令接受此列表并为每个列出的测试二进制文件生成 --object <binary> 参数。

包含文档测试

前面的示例使用 --tests 运行 cargo test,这会排除文档测试。1

要将文档测试包含在覆盖率结果中,请删除 --tests 标志,并应用 -C instrument-coverage 标志,以及 RUSTDOCFLAGS 环境变量中的一些特定于文档测试的选项。(llvm-profdata 命令不会改变。)

$ RUSTFLAGS="-C instrument-coverage" \
  RUSTDOCFLAGS="-C instrument-coverage -Z unstable-options --persist-doctests target/debug/doctestbins" \
    cargo test
$ llvm-profdata merge -sparse default_*.profraw -o json5format.profdata

需要 -Z unstable-options --persist-doctests 标志,以保存测试二进制文件(及其覆盖率映射)以供 llvm-cov 使用。

$ llvm-cov report \
    $( \
      for file in \
        $( \
          RUSTFLAGS="-C instrument-coverage" \
          RUSTDOCFLAGS="-C instrument-coverage -Z unstable-options --persist-doctests target/debug/doctestbins" \
            cargo test --no-run --message-format=json \
              | jq -r "select(.profile.test == true) | .filenames[]" \
              | grep -v dSYM - \
        ) \
        target/debug/doctestbins/*/rust_out; \
      do \
        [[ -x $file ]] && printf "%s %s " -object $file; \
      done \
    ) \
  --instr-profile=json5format.profdata --summary-only # and/or other options

注意:与没有文档测试的版本相比,此 llvm-cov 调用的区别包括

  • cargo test ... --no-run 命令已更新,其中包含用于构建测试(包括文档测试)的相同环境变量和标志。
  • 文件通配符模式 target/debug/doctestbins/*/rust_out 添加了为文档测试生成的 rust_out 二进制文件(但请注意,一些 rust_out 文件可能不是可执行二进制文件)。
  • [[ -x $file ]] && 过滤传递给 printf 的文件,以仅包含可执行二进制文件。
1

正在进行的工作是为了解决已知问题 (#79417),即文档测试覆盖率在 llvm-cov show 结果中生成不正确的源代码行号。

-C instrument-coverage=<options>

  • -C instrument-coverage=no(或 n/off/false):不启用覆盖率插桩。不会对任何函数进行插桩以进行覆盖率分析。
    • 这与根本不使用 -C instrument-coverage 标志相同。
  • -C instrument-coverage=yes(或 y/on/true):使用默认行为启用覆盖率插桩。目前,这会对所有函数进行插桩,包括未使用的函数和未使用的泛型。
    • 这与 -C instrument-coverage 没有值相同。

其他值

  • -C instrument-coverage=all:目前是 yes 的别名,但如果将来添加了更细粒度的覆盖率选项,则可能会有不同的行为。目前不建议使用此值。

-Z coverage-options=<options>

此不稳定选项提供对覆盖率检测某些方面的更精细控制。传递以下一个或多个值,用逗号分隔。

  • no-branchbranchmcdc
    • branch 启用分支覆盖率检测,而 mcdc 进一步启用修改条件/决策覆盖率检测。no-branch 禁用分支覆盖率检测,这与不传递 branchmcdc 相同。

其他参考资料

Rust 的基于源代码的代码覆盖率实现和工作流程基于与用于实现 Clang 中的基于源代码的代码覆盖率 相同的库和工具。(本文档部分基于 Clang 指南。)