基于插桩的代码覆盖率
简介
本文档描述如何通过编译器标志 -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 未配置为使用你启用了 profiler 的 rustc 版本,请通过 RUSTC 环境变量显式设置路径。以下是另一个示例,使用 rustc 的 stage1 构建版本编译一个 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必须在1到9之间,如果省略(仅使用%m),则默认为1。%c- 不会向文件名添加任何内容,但在某些平台(包括 Darwin)上启用一种模式,在此模式下,分析计数器更新会持续同步到文件。这意味着即使插桩程序崩溃或被信号终止,仍然可以恢复完美的覆盖率信息。
在上面第一个示例中,生成的文件名中的值 11699812450447639123_0 是已插桩二进制文件的签名,它替换了 %m 模式;值 20944 是正在执行的二进制文件的进程 ID。
安装 LLVM 覆盖率工具
LLVM 提供了两个工具——llvm-profdata 和 llvm-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
此示例中一些值得注意的选项包括:
--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(如果x为true,可能解析表达式)、|| (y && z)(仅当x为false时执行)和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)
前面的示例运行带有 --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的文件,只包含可执行二进制文件。
目前正在解决一个已知问题 (#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 指南。)