原文: Source-based Code Coverage
基于源码计算代码覆盖率
一、简介
这个文档展示如何通过 clang基于源码 条件下计算代码覆盖率特性。因为它是直接操作AST和预处理阶段,所有称为 “基于源码” 的代码覆盖率。并且能够生成非常准确的覆盖数据。
clang 提供两种代码覆盖率的实现:
- SanitizerCoverage : 它在函数、基本块和边缘级别插入对用户定义函数的调用。提供了这些回调的默认实现并实现了简单的覆盖率报告和可视化。低开销、简单的代码覆盖率。
gcov
:是GCC在基于Debuginfo上操作实现的覆盖率实现,可以通过ftest-coverage
or-coverage
来启用
下面所讲述的,都是 “基于源码” 进行代码覆盖率的类型。
二、基本流程
代码覆盖率流程主要包含三个步骤:
- 开启编译选项
- 运行需要检测覆盖率的程序
- 创建生成覆盖率报告
在接下来的流程说明中,我们将使用下面的代码进行实践:
// 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;
}
1、开启编译选项
使用 fprofile-instr-generate -fcoverage-mapping
来开启计算覆盖率的编译选项(编译插桩):
# Step 1: Compile with coverage enabled.
➜ Source-based git:(master) ✗ ls
foo.cc
➜ Source-based git:(master) ✗ clang++ -fprofile-instr-generate -fcoverage-mapping foo.cc -o foo
➜ Source-based git:(master) ✗ ls
foo foo.cc
注意:支持将包含和不包含覆盖检测的代码链接在一起,未插桩的代码不会在报告中解释出来。
2、运行需要检测覆盖率的程序
接下来就是运行程序,当程序退出时,会将环境变量 LLVM_PROFILE_FILE
指定的路径写入 raw profile
(*.profraw
文件,包含代码的基本信息,比如所在文件路径、函数行等信息)。如果 LLVM_PROFILE_FILE
变量不存在,则会在程序的当前路径生成 default.profraw
并写入基本信息。如果 LLVM_PROFILE_FILE
包含的目录不存在,则创建缺失的目录。下面的 特殊字符 将被重写:
- “%p” 表示为进程ID
- “%h” 表示运行该程序的机器主机名
- “%t” 表示
TMPDIR
环境变量的值,在 Darwin 通常时一个临时目录 - “%Nm” 表示所检测二进制的签名。当指定使用该模式时,运行的时候将创建一个包含N个原始的
raw profile
, 用于在线基本信息的合并。运行时负责从池中选择一个raw profile
,锁定它并且在退出之前更新它。如果没有指定N,这默认N为1,N必须时1-9之间。 - “%c” 表示为空,但是这个模式下 配置文件计数器持续同步到文件中,即使程序被杀掉或者崩溃,覆盖数据也可以被恢复。这个模式不支持
PGO
并且只支持 Darwin。对 Linux 的支持也基本完成,但是需要测试。Window的支持还需要更大更多的更改,如果你感兴趣也可以参与进来。
# Step 2: Run the program.
➜ Source-based git:(master) ✗ LLVM_PROFILE_FILE="foo.profraw" ./foo
➜ Source-based git:(master) ✗ ls
foo foo.cc foo.profraw
需要注意的:fuchsia 只支持这个 “%c” 模式,但是它的实现不太一样。Darwin和Linux实现依赖于填充以及在现有内存映射上映射文件的能力,而现有内存映射通常只在POSIX系统上可用,不适用于其他平台。
3、创建生成覆盖率报告
在使用 raw profile
文件生成代码覆盖率之前,需要对他们建立索引(生成 *.profdata
)。可以使用 [llvm-profdata](https://llvm.org/docs/CommandGuide/llvm-profdata.html)
中的 merge
命令(它可以合并多个文件,并且同时索引他们):
# Step 3(a): Index the raw profile. Mac上需要是 xcrun 来调用 llvm 指令
➜ Source-based git:(master) ✗ xcrun llvm-profdata merge -sparse foo.profraw -o foo.profdata
➜ Source-based git:(master) ✗ ls
foo foo.cc foo.profdata foo.profraw
# 多个profdata合并:
# llvm-profdata merge foo.profdata bar.profdata -output merged.profdata
[llvm-cov](https://llvm.org/docs/CommandGuide/llvm-cov.html)
有多种方式生成覆盖率报告,下面是一种简单的生成生成 面向行
的报告:
这个报告包含一个视图摘要以及用于模版化函数及其实例化的专用子视图。在下面的示例程序中,我们会得到 _Z3fooIiEvT_ foo<int>(i)
和 _Z3fooIfEvT_ foo(<float>)(float)
不同的视图。如果启用 show-line-counts-or-regions
, llvm-cov
将显示 内部行
区域计数(包括宏扩展):
# Step 3(b): Create a line-oriented coverage report.
➜ Source-based git:(master) ✗ xcrun llvm-cov show ./foo -instr-profile=foo.profdata
1| 20|#define BAR(x) ((x) || (x))
2| 2|template <typename T> void foo(T x) {
3| 22| for (unsigned I = 0; I < 10; ++I) { BAR(I); }
4| 2|}
------------------
| _Z3fooIiEvT_:
| 2| 1|template <typename T> void foo(T x) {
| 3| 11| for (unsigned I = 0; I < 10; ++I) { BAR(I); }
| 4| 1|}
------------------
| _Z3fooIfEvT_:
| 2| 1|template <typename T> void foo(T x) {
| 3| 11| for (unsigned I = 0; I < 10; ++I) { BAR(I); }
| 4| 1|}
------------------
5| |
6| 1|int main() {
7| 1| foo<int>(0);
8| 1| foo<float>(0);
9| 1| return 0;
10| 1|}
如果是要生成文件级别的代码覆盖率,而不是 面向行
的报告,可以使用下面的命令:
# Step 3(c): Create a coverage summary.
xcrun llvm-cov report ./foo -instr-profile=foo.profdata
Filename Regions Missed Regions Cover Functions Missed Functions Executed Lines Missed Lines Cover
--------------------------------------------------------------------------------------------------------------------------------------------
Source-based/foo.cc 8 0 100.00% 2 0 100.00% 8 0 100.00%
---------------------------------------------------------------------------------------------------------------------------------------------
TOTAL
[llvm-cov](https://llvm.org/docs/CommandGuide/llvm-cov.html)
工具支持指定自定义的请求程序,从目录文件中生成报告,以及生成 html
可视化页面。有关完整的功能列表可以查看 命令指南
最后几个注意事项:
-
-sparse
命令是可选的,它会让索引profdata
文件变小,如果该文件要被 PGO 重用则不应该使用该选项。 -
raw profile
(*.profraw
) 在生成索引文件profdata
后可以被丢弃。 -
[llvm-profdata](https://llvm.org/docs/CommandGuide/llvm-profdata.html)
工具可以合并多个原始或索引文件,例如合并多个索引文件,可以使用如下操作:# 多个profdata合并: # llvm-profdata merge foo.profdata bar.profdata -output merged.profdata
三、其他要点
1、导出覆盖率数据
可以使用 [llvm-cov](https://llvm.org/docs/CommandGuide/llvm-cov.html)
导出 JSON
的覆盖率,可以通过查看 JSON
数据得到更多的信息
# 最终可以生成 foo.json 文件
➜ Source-based git:(master) ✗ xcrun llvm-cov export -format=text ./foo -instr-profile=foo.profdata > foo.json
➜ Source-based git:(master) ✗ ls
foo foo.cc foo.json foo.profdata foo.profraw
2、解释报告
在报告中,有5个统计数据:
- 函数覆盖率:至少执行过一次的函数百分比,如果函数都执行到,则这个文件覆盖率为100%
- 实例化覆盖率:至少执行过一次的函数实例化的百分比,模板函数和头文件中的静态内联函数是两种可以有多个实例化的函数。默认情况下,该统计信息在报告中隐藏,但可以通过
-show- instantiated -summary
选项启用。 - 行覆盖率:至少执行过一次的代码行的百分比,只有函数体中的可执行行才被认为是代码行。
- 区域覆盖率:至少执行过一次的代码区域的百分比,代码区域可能跨多行,一行代码也可能有多个区域(例如 “return x || y && z” ****)。(Region Coverage如何计算还未能理解)
- 分支覆盖率:至少执行过一次的“真”和“假”分支的百分比,每个分支都与源代码中的单个条件相关联,每个条件的计算结果可能是“真”或“假”。这些条件可以包含由布尔逻辑运算符链接的更大的布尔表达式。例如,“x = (y == 2) || (z < 10)”是一个布尔表达式,由两个单独的条件组成,每个条件的计算结果要么为真,要么为假,产生四个总分支结果。
在这5个统计方式中,函数覆盖率粒度最小,分支覆盖率粒度最大。一个函数的分支覆盖率为100%,意味函数的区域覆盖率也是100%。每个统计的项目范围的总数列在摘要中。
3、格式兼容性保证
- 对原始配置文件(
*.profraw
)格式没有向后或向前兼容性保证。原始概要文件可能依赖于用于生成它们的特定编译器修订版。不建议长时间存储原始配置文件。 - 工具必须保持与索引概要文件(
*.profdata
)格式的向后兼容性。这些格式是不向前兼容的:例如,使用格式版本X的工具将无法理解格式版本(X+k)。 - 工具还必须保持与发出到插桩二进制文件的覆盖映射格式的向后兼容性。这些格式是不向前兼容的。
- JSON覆盖导出格式有(主要、次要、补丁)三种版本。只有主版本增量表示向后不兼容的更改。小版本增量用于增加功能,补丁版本增量用于修复bug。
4、llvm优化对覆盖率报告的影响
llvm优化(比如内联或CFG简化)应该不会影响覆盖率报告的质量。这是因为从源区域到配置文件计数器的映射是不可变的,并且是在llvm优化器启动之前生成的。优化器不能证明配置文件计数器检测是可以安全删除的(因为它不是:它会影响程序发出的配置文件),所以不去管它。
请注意,此覆盖特性不依赖于在优化过程中可能降级的信息,如调试信息行表。
5、在没有静态分析下,使用运行时分析法 (线上测试代码覆盖率关注)
默认情况下,编译器运行时使用静态分析来确定输出路径和注册写入函数。为了在不使用静态初始化的情况下收集 raw profraw
文件,需要手动一下操作:
-
在每个需要检测的**共享库(动态库)**和可执行文件导入
int __llvm_profile_runtime
符号。当在链接共享库和可执行文件时找到了这个符号,编译器就会跳过加载包含运行时的静态初始化对象。 -
向前声明
void __llvm_profile_initialize_file(void)
并且在每个可执行文件中调用它一次,这个函数会解析LLVM_PROFILE_FILE
。设置输出路径,并截断该路径上任何现有文件。如果要在不截断现有文件的情况下获取收集raw profraw
文件,可以传递一个文件路径来调用void __llvm_profile_set_filename(char *)
这些调用可以放在任何地方,但是需要在__llvm_profile_write_file
之前调用 -
向前声明
int __llvm_profile_write_file(void)
,并且调用它将内存中的 执行的代码信息 数据写入文件,返回0
表示执行成功,否则返回非零值。多次调用该函数,会将 代码执行信息追加写入到上一个路径文件中:let documentDirectory = try fileManager.url(for: .documentDirectory, in: .userDomainMask, appropriateFor:nil, create:false) let name = "HDCoverageDemo.profraw" let filePath: NSString = documentDirectory.appendingPathComponent(name).path as NSString // 将执行的代码信息指定路径写入,动态库需要独立设置 __llvm_profile_set_filename(filePath.utf8String) // 写入执行代码信息到 filePath 文件中,动态库需要独立设置 __llvm_profile_write_file()
在 C++
文件中,需要将他们声明为 extern "C"
.
6、在没有文件系统下使用运行时分析
运行时分析还支持缺乏文件系统的独立环境中,运行时可以为静态归档提供环境,使得依赖的环境为可选项,这依赖于客户端应用所使用的特性。
第一步需要导入 __llvm_profile_runtime
符号,和上面一样需要禁用默认的静态初始化器。但是不要和上面步骤一样去调用 *_file()
API。需要使用下面的方法将 代码执行信息数据 保持在你设置的缓存区:
- 向前声明
uint64_t __llvm_profile_get_size_for_buffer(void)
并且调用它来确定大小。你需要分配一个返回值大小的缓冲区。 - 向前声明
int __llvm_profile_write_buffer(char *Buffer)
并且调用它,并且将当前计数器复制到 缓冲区 中,该缓冲区已经分配并且足够大的空间,用于存放 代码执行信息数据。 - 可选项,向前声明
void __llvm_profile_reset_counters(void)
并且调用它,在进入分析特定 section之前调用它来重置计数器。只有在 代码执行信息数据 中应该排除默写设置才这样使用。
7、缺陷和局限性
-
在
2.26
版本之前,GNU binutils BFD 链接器不能在设置-gc-sections
模式下设置为fcoverage-mapping
时链接程序。可能的解决方案是:禁用-gc-sections
模式、升级到更新版本的BFD,或者使用 Gold 链接器。 -
出现异常时,代码覆盖率不能精确的处理控制流或在异常情况下准确的展开堆栈。比如下面的函数:
int f() { may_throw(); return 0; }
如果在调用
may_throw()
的异常到f
函数,则代码覆盖工具可能会将return
语句标记为已经执行,即使实际上没有执行到。8、Clang实现细节
对clang代码覆盖率 期望理解更多或者有兴趣或者的人 会对下面的内容感兴趣
8.1、间隙(空白)区域
间隙区域的代码是具有计数的代码区域,所以报告工具不能将间隙区域设置为执行的计数,除非它只有一行间隙区域。
间隙区域用于消除覆盖报告中不自然的工件,例如红色的“未执行”高亮显示在其他覆盖行的末尾,或者蓝色的“执行”高亮显示在其他没有执行的行开始处。
8.2、分支区域
当使用
-show-branches
基于源码的包含子文件查看分支代码(非git分支,只是代码分支)覆盖率详情时,建议用户显示所有宏展开(使用可选项:-show-expansions
),因为宏可能包含隐藏的分支条件。覆盖率报告总是会在函数或者源文件的总体分支覆盖率计数中包含这些基于 宏 的boolean
表达式。对于经常折叠的代码废纸条件,不会跟踪分支覆盖。在基于源码的包含子目录的覆盖率视图中,这些分支将简单的显示为【折叠-忽略】,这样用户就可以知道发生了什么。
分支覆盖率直接与源码中的分支条件绑定在一起。用户不应该看到实际上没有绑定到源码的隐藏分支。
8.3、Switch语句
在
switch
代码中的区域映射由覆盖整个主体的间隙区域(从‘switch (…) {‘
中的{
开始,在最后一个case
结束的地方结束)。此间隙区域的计数为零:这将导致case语句之间的“间隙”区域(不包含可执行代码)显示为未覆盖区域。当访问
switch
的时候,父区域将被扩展:如果父区域没有起始位置,它的起始位置将成为起始位置。这用于支持不带CompoundStmt(AST抽象语法数的一个节点)
主体的switch
语句,其中switch
主体和单个case共享一个计数。在
switch
的主体中,每一个switch
的case
会创建一个新的区域。switch
的每个case
也会生成代码分支区域,包含default case
。如果源码中没有显式写default case
, 编译器会隐式的生成分支区域。隐式分支区域与switch
语句条件的行号和列号绑定,因为不存在隐式情况的源代码。