Code Coverage系列(七)Code Coverage 原理详细说明
- [1. gcov_init,gcov_exit, gcov_flush 说明及-ftest-coverage解析](#1. gcov_init,gcov_exit, gcov_flush 说明及-ftest-coverage解析)
-
- [1.1 gcov_init,gcov_exit, gcov_flush](#1.1 gcov_init,gcov_exit, gcov_flush)
- [1.2 Gcc中指定 -ftest-coverage 等覆盖率测试选项后,gcc 做了什么?](#1.2 Gcc中指定 -ftest-coverage 等覆盖率测试选项后,gcc 做了什么?)
- [2. 重启导致未gcov_flush,数据丢失问题及处理](#2. 重启导致未gcov_flush,数据丢失问题及处理)
-
- [2.1 重启导致未gcov_flush 从而数据丢失问题](#2.1 重启导致未gcov_flush 从而数据丢失问题)
- [2.1 解决因为reboot导致数据丢失](#2.1 解决因为reboot导致数据丢失)
- [3. gcov原理详解:基本块BB+跳转ARC(即:gcov 怎么计算统计数据的)+Demo + info文件说明](#3. gcov原理详解:基本块BB+跳转ARC(即:gcov 怎么计算统计数据的)+Demo + info文件说明)
-
- [3.1 基本块BB](#3.1 基本块BB)
-
- [3.1.1 定义:基本块 (Basic Block, BB) - 一段"不可分割"的代码](#3.1.1 定义:基本块 (Basic Block, BB) - 一段"不可分割"的代码)
- [3.1.2 特点:要么全部执行,要么都不执行](#3.1.2 特点:要么全部执行,要么都不执行)
- [3.1.3 BB 的核心规则](#3.1.3 BB 的核心规则)
- [3.2 跳转ARC](#3.2 跳转ARC)
-
- [3.2.1 定义:](#3.2.1 定义:)
- [3.2.2 Code Demo 及 图示:](#3.2.2 Code Demo 及 图示:)
- [3.2.3 gcov 的秘密:](#3.2.3 gcov 的秘密:)
- [3.3. gcov 怎么用 ARC 计数实现覆盖率?](#3.3. gcov 怎么用 ARC 计数实现覆盖率?)
-
- [3.3.1 核心数学原理和解析:](#3.3.1 核心数学原理和解析:)
- [3.3.2 所以 gcov 有一个关键优化------不需要对每条 ARC 都插桩计数](#3.3.2 所以 gcov 有一个关键优化——不需要对每条 ARC 都插桩计数)
- [3.3.3 对应到 .info 文件解析](#3.3.3 对应到 .info 文件解析)
- [4. 实际编译插桩的流程 -- 再说两个编译参数](#4. 实际编译插桩的流程 -- 再说两个编译参数)
-
- [4.1 编译时加这两个 flag](#4.1 编译时加这两个 flag)
- [4.2 -fprofile-arcs 实际做的事](#4.2 -fprofile-arcs 实际做的事)
- [4.3 代码覆盖率的底层原理](#4.3 代码覆盖率的底层原理)
1. gcov_init,gcov_exit, gcov_flush 说明及-ftest-coverage解析
1.1 gcov_init,gcov_exit, gcov_flush
- 在最终可执行文件进入用户代码主函数 main 前,调用 gcov_init 内部函数以初始化统计区域
- 当用户代码正常调用exit并注册 gcov_exit 内部函数作为退出处理程序时, gcov_exit函数被调用,并继续调用 __gcov_flush 函数将统计数据输出到*.gcda文件
1.2 Gcc中指定 -ftest-coverage 等覆盖率测试选项后,gcc 做了什么?
* 1. 在输出目标文件中留出一段存储区保存统计数据
* 2. 在源代码中每行可执行语句生成的代码之后附加一段更新覆盖率统计结果的代码,
也就是前文说的插桩
* 3. 在最终可执行文件中进入用户代码 main 函数之前调用 gcov_init
内部函数初始化统计数据区,
* 4. 将gcov_exit 内部函数注册为 exit handlers用户代码调用
exit 正常结束时,gcov_exit 函数得到调用,
其继续调用 __gcov_flush 函数输出统计数据到 *.gcda 文件中
2. 重启导致未gcov_flush,数据丢失问题及处理
2.1 重启导致未gcov_flush 从而数据丢失问题
当前代码处理,例如PS都是通过信号来触发 gcov_flush 刷新数据的,如果发生reboot 那么gcda数据都丢失了
例子:
#ifdef CYC_GCOV
if (signum == SIGKILL || signum == SIGSEGV || signum == SIGTERM) {
__gcov_flush();
}
#endif /* CYC_GCOV */
2.1 解决因为reboot导致数据丢失
改为定时刷新gcda数据来替换信号触发
3. gcov原理详解:基本块BB+跳转ARC(即:gcov 怎么计算统计数据的)+Demo + info文件说明
gcov是使用 基本块BB 和 跳转 ARC 计数,结合程序流图来实现代码覆盖率统计的。
3.1 基本块BB
3.1.1 定义:基本块 (Basic Block, BB) - 一段"不可分割"的代码
基本块 (Basic Block, BB) - 一段"不可分割"的代码
如果第一条语句执行了,后面的语句一定会执行
3.1.2 特点:要么全部执行,要么都不执行
void foo(int x) {
int a = 1; // ─┐
int b = 2; // ├── BB1: 这三行要么全部执行,要么全部不执行
int c = a + b; // ─┘
if (c > 0) { // ── BB2: 判断条件本身是一个 BB
printf("yes"); // ── BB3: if 为真走这里
} else {
printf("no"); // ── BB4: if 为假走这里
}
return; // ── BB5: 合流后的代码
}
3.1.3 BB 的核心规则
BB 的核心规则
只有一个入口:只能从第一条指令进入,不能从中间跳入
只有一个出口:只能从最后一条指令离开,中间不能跳走
要么全执行,要么全不执行:知道了第一条的执行次数,就等于知道了每一条的执行次数
为什么要划分 BB?
因为如果一个 BB 执行了 N 次,那它里面的每一行都执行了 N 次。
这样 gcov 就不需要给每一行都计数,只需要给每个 BB 计数就够了------
大大减少了插桩的开销。
什么时候会产生新的 BB?
遇到 if、switch、for、while、goto、return 等控制流语句
遇到函数调用(理论上,不过 gcc 通常不会在每个 call 处都切分)
遇到跳转目标(被别人 goto 到的标签)
3.2 跳转ARC
3.2.1 定义:
通俗理解:
从一个 BB 到另一个 BB 的"路"。
一个基本块到另一个基本块的"箭头"
3.2.2 Code Demo 及 图示:
if (x > 0) { // ← 决策点(产生分支)
printf("正数"); // BB1
} else {
printf("非正数"); // BB2
}
printf("结束"); // BB3
这里有 3 个跳转:
• BB1 → BB3 (if 为真的路径)
• BB2 → BB3 (if 为假的路径)
Demo2 和 图示
Demo2:
void foo(int x) {
int a = 1; // ─┐
int b = 2; // ├── BB1: 这三行要么全部执行,要么全部不执行
int c = a + b; // ─┘
if (c > 0) { // ── BB2: 判断条件本身是一个 BB
printf("yes"); // ── BB3: if 为真走这里
} else {
printf("no"); // ── BB4: if 为假走这里
}
return; // ── BB5: 合流后的代码
}
图:
┌───────────┐
│ BB1 │ a=1; b=2; c=a+b;
│ (入口块) │
└─────┬─────┘
│
▼
┌───────────┐
│ BB2 │ if (c > 0)
│ (条件判断) │
└──┬─────┬──┘
│ │
ARC1 │ │ ARC2
c > 0) │ │ (c <= 0)
true │ │ false
▼ ▼
┌────────┐ ┌────────┐
│ BB3 │ │ BB4 │
│"yes" │ │ "no" │
└───┬────┘ └───┬────┘
│ ARC3 │ ARC4
│ │
▼ ▼
┌───────────┐
│ BB5 │ return;
│ (出口块) │
└───────────┘
这个例子有 5 个 BB,4 条 ARC:
• ARC1: BB2 → BB3(条件为真)
• ARC2: BB2 → BB4(条件为假)
• ARC3: BB3 → BB5
• ARC4: BB4 → BB5
3.2.3 gcov 的秘密:
通过计数每条箭头被经过了多少次,就能知道代码执行了多少次!
3.3. gcov 怎么用 ARC 计数实现覆盖率?
3.3.1 核心数学原理和解析:
流量守恒(类似基尔霍夫电流定律)
解析:
对于任一个 BB:
流入的 ARC 执行次数之和 = 流出的 ARC 执行次数之和 = 该 BB 的执行次数
3.3.2 所以 gcov 有一个关键优化------不需要对每条 ARC 都插桩计数
假设有 N 个 BB 和 E 条 ARC,由于流量守恒提供了 N 个方程(每个 BB 一个),
gcov 只需要实际插桩 E - N + 1 条 ARC,剩余的可以用方程算出来。
具体例子:
假设上图运行后:
ARC1 (BB2→BB3) 被执行了 7 次 ← 实际计数
ARC2 (BB2→BB4) 被执行了 3 次 ← 实际计数
那么可以推算:
BB2 执行了 7+3 = 10 次
BB3 执行了 7 次(只有 ARC1 流入)
BB4 执行了 3 次(只有 ARC2 流入)
BB1 执行了 10 次(流出 = BB2 的流入)
ARC3 执行了 7 次(BB3 的流出必须等于流入)
ARC4 执行了 3 次(同理)
这就是 gcov 的精髓:
只在关键的 ARC 上插桩,通过代数推导出所有 BB 和 ARC 的执行次数,
从而得到每一行代码的执行次数。
3.3.3 对应到 .info 文件解析
例子,对应到 .info 文件
DA:226,552129 ← 第 226 行执行了 552129 次(= 该行所在 BB 的执行次数)
BRDA:143,5,0,7 ← 第 143 行的第 5 个代码块的第 0 号分支(ARC)执行了 7 次
BRDA:143,5,1,3 ← 第 143 行的第 5 个代码块的第 1 号分支(ARC)执行了 3 次
这里:
• DA 行数据 = BB 的执行次数(BB 内所有行执行次数相同)
• BRDA 分支数据 = ARC 的执行次数
• BRDA 中的 block_number 就是 BB 编号,branch_number 就是从该 BB 出发的第几条 ARC
4. 实际编译插桩的流程 -- 再说两个编译参数
4.1 编译时加这两个 flag
# 编译时加这两个 flag
gcc -fprofile-arcs -ftest-coverage -o my_program my_program.c
# -ftest-coverage → 生成 .gcno 文件(控制流图:BB + ARC 的静态结构)
# -fprofile-arcs → 在关键 ARC 上插入计数代码(运行时写入 .gcda)
4.2 -fprofile-arcs 实际做的事
在选定的 ARC 对应的代码位置插入类似这样的代码:
// 伪代码:编译器自动插入
static uint64_t __arc_counters[NUM_ARCS]; // 全局计数器数组
// 在 ARC 经过的位置:
__arc_counters[arc_id]++;
程序退出时(通过 atexit 注册的回调),将 __arc_counters 数组写入 .gcda 文件。
4.3 代码覆盖率的底层原理
一句话总结
│ gcov 把程序分解成"一口气执行完"的 BB,
在 BB 之间的"路"(ARC)上安装计数器,
程序跑完后利用流量守恒算出每行代码被执行了多少次
------ 这就是代码覆盖率的底层原理。