基于插桩的代码覆盖率

简介

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

工作原理

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

  • 在已编译代码中的函数和分支处,自动注入对 LLVM 内置函数(llvm.instrprof.increment)的调用,以在执行代码的条件部分时递增计数器。
  • 在每个库和二进制文件的数据部分中嵌入额外的信息(如果使用 LLVM 12 编译,则使用 LLVM 代码覆盖率映射格式 *Version 5*,如果使用 LLVM 13 或更高版本编译,则使用 *Version 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 编译器,则默认的 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

启用覆盖率编译

设置 -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

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

$ 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 标志,并在 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>

此不稳定的选项在《不稳定手册》中进行了描述。

其他参考资料

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