基于插桩的代码覆盖率

简介

本文档描述如何通过编译器标志 -C instrument-coverage 启用和使用基于 LLVM 插桩的代码覆盖率。

工作原理

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

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

运行启用了覆盖率插桩的程序时,计数器值会在程序终止时写入一个 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 编译器,默认的 bootstrap.example.toml 中并没有启用分析器运行时。编辑你的 bootstrap.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

启用覆盖率进行编译

设置 -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 环境变量显式设置路径。以下是另一个示例,使用 rustcstage1 构建版本编译一个 example 二进制文件(来自 json5format crate):

$ 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)。
  • 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 crate 作为演示目的)展示了如何为 crate 中的所有测试生成和分析覆盖率结果。

由于 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

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

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

包含文档测试 (doc tests)

前面的示例运行带有 --testscargo test,这会排除文档测试。1

要将文档测试包含在覆盖率结果中,去掉 --tests 标志,并在 RUSTDOCFLAGS 环境变量中应用 -C instrument-coverage 标志以及一些文档测试特有的选项。(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 命令已更新,使用与构建测试(包括文档测试)时相同的环境变量和标志。
  • 文件 glob 模式 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>

此不稳定选项在 Unstable Book 中有描述。

其他参考资料

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