首页 > 其他分享 >iOS 覆盖率检测原理与增量代码测试覆盖率工具实现11

iOS 覆盖率检测原理与增量代码测试覆盖率工具实现11

时间:2024-12-30 23:26:16浏览次数:3  
标签:11 文件 BB 覆盖率 代码 iOS gcov gcda

 背景

对苹果开发者而言,由于平台审核周期较长,客户端代码导致的线上问题影响时间往往比较久。如果在开发、测试阶段能够提前暴露问题,就有助于避免线上事故的发生。代码覆盖率检测正是帮助开发、测试同学提前发现问题,保证代码质量的好帮手。

对于开发者而言,代码覆盖率可以反馈两方面信息:

  1. 自测的充分程度。
  2. 代码设计的冗余程度。

尽管代码覆盖率对代码质量有着上述好处,但在 iOS 开发中却使用的不多。我们调研了市场上常用的 iOS 覆盖率检测工具,这些工具主要存在以下四个问题:

  1. 第三方工具有时生成的检测报告文件会出错甚至会失败,开发者对覆盖率生成原理不了解,遇到这类问题容易弃用工具。
  2. 第三方工具每次展示全量的覆盖率报告,会分散开发者的很多精力在未修改部分。而在绝大多数情况下,开发者的关注重点在本次新增和修改的部分。
  3. Xcode 自带的覆盖率检测只适用于单元测试场景,由于需求变更频繁,业务团队开发单元测试的成本很高
  4. 已有工具很难和现有开发流程结合起来,需要额外进行测试,运行覆盖率脚本才能获取报告文件。

为了解决上述问题,我们深入调研了覆盖率报告的生成逻辑,并结合团队的开发流程,开发了一套嵌入在代码提交流程中、基于单次代码提交(git commit)生成报告、对开发者透明的增量代码测试覆盖率工具。开发者只需要正常开发,通过模拟器测试开发代码,commit 本次代码(commit 和测试顺序可交换),推送(git push)到远端,就可以在本地看到这次提交代码的详细覆盖率报告了。

本文分为两部分,先从介绍通用覆盖率检测的原理出发,让读者对覆盖率的收集、解析有直观的认识。之后介绍我们增量代码测试覆盖率工具的实现。

覆盖率检测原理

生成覆盖率报告,首先需要在 Xcode 中配置编译选项,编译后会为每个可执行文件生成对应的 .gcno 文件;之后在代码中调用覆盖率分发函数,会生成对应的 .gcda 文件。

其中,.gcno 包含了代码计数器和源码的映射关系, .gcda 记录了每段代码具体的执行次数。覆盖率解析工具需要结合这两个文件给出最后的检测报表。接下来先看看 .gcno 的生成逻辑。

.gcno

利用 Clang 分别生成源文件的 AST 和 IR 文件,对比发现,AST 中不存在计数指令,而 IR 中存在用来记录执行次数的代码。搜索 LLVM 源码可以找到覆盖率映射关系生成源码。覆盖率映射关系生成源码是 LLVM 的一个 Pass,(下文简称 GCOVPass)用来向 IR 中插入计数代码并生成 .gcno 文件(关联计数指令和源文件)。

下面分别介绍IR插桩逻辑和 .gcno 文件结构。

IR 插桩逻辑

代码行是否执行到,需要在运行中统计,这就需要对代码本身做一些修改,LLVM 通过修改 IR 插入了计数代码,因此我们不需要改动任何源文件,仅需在编译阶段增加编译器选项,就能实现覆盖率检测了。

从编译器角度看,基本块(Basic Block,下文简称 BB)是代码执行的基本单元,LLVM 基于 BB 进行覆盖率计数指令的插入,BB 的特点是:

  1. 只有一个入口。
  2. 只有一个出口。
  3. 只要基本块中第一条指令被执行,那么基本块内所有指令都会顺序执行一次

覆盖率计数指令的插入会进行两次循环,外层循环遍历编译单元中的函数,内层循环遍历函数的基本块。函数遍历仅用来向 .gcno 中写入函数位置信息,这里不再赘述。

一个函数中基本块的插桩方法如下:

  1. 统计所有 BB 的后继数 n,创建和后继数大小相同的数组 ctr[n]。
  2. 以后继数编号为序号将执行次数依次记录在 ctr[i] 位置,对于多后继情况根据条件判断插入。

举个例子,下面是一段猜数字的游戏代码,当玩家猜中了我们预设的数字10的时候会输出Bingo,否则输出You guessed wrong!。这段代码的控制流程图如图1所示(猜数字游戏 )。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSLog(@"Welcome to the game");
    if (guessNumber == 10) {
        NSLog(@"Bingo!");
    } else {
        NSLog(@"You guess is wrong!");
    }
}

这段代码如果开启了覆盖率检测,会生成一个长度为 6 的 64 位数组,对照插桩位置,方括号中标记了桩点序号,图 1 中代码前数字为所在行数。

图 1 桩点位置

图 1 桩点位置

.gcno计数符号和文件位置关联

.gcno 是用来保存计数插桩位置和源文件之间关系的文件。GCOVPass 在通过两层循环插入计数指令的同时,会将文件及 BB 的信息写入 .gcno 文件。写入步骤如下:

  1. 创建 .gcno 文件,写入 Magic number(oncg+version)。
  2. 随着函数遍历写入文件地址、函数名和函数在源文件中的起止行数(标记文件名,函数在源文件对应行数)。
  3. 随着 BB 遍历,写入 BB 编号、BB 起止范围、BB 的后继节点编号(标记基本块跳转关系)。
  4. 写入函数中BB对应行号信息(标注基本块与源码行数关系)。

从上面的写入步骤可以看出,.gcno 文件结构由四部分组成:

  • 文件结构
  • 函数结构
  • BB 结构
  • BB 行结构

通过这四部分结构可以完全还原插桩代码和源码的关联,我们以 BB 结构 / BB 行结构为例,给出结构图 2 (a) BB 结构,(b) BB 行信息结构,在本章末尾覆盖率解析部分,我们利用这个结构图还原代码执行次数(每行等高格代表 64bit):

图2 BB 结构和 BB 行信息结构

图2 BB 结构和 BB 行信息结构

.gcda

入口函数

关于 .gcda 的生成逻辑,可参考覆盖率数据分发源码。这个文件中包含了 __gcov_flush() 函数,这个函数正是分发逻辑的入口。接下来看看 __gcov_flush() 如何生成 .gcda 文件。

通过阅读代码和调试,我们发现在二进制代码加载时,调用了 llvm_gcov_init(writeout_fn wfn, flush_fn ffn) 函数,传入了 _llvm_gcov_writeout(写 gcov 文件),_llvm_gcov_flush(gcov 节点分发)两个函数,并且根据调用顺序,分别建立了以文件为节点的链表结构。(flush_fn_node * ,writeout_fn_node *

__gcov_flush() 代码如下所示,当我们手动调用 __gcov_flush()进行覆盖率分发时,会遍历flush_fn_node *这个链表(即遍历所有文件节点),并调用分发函数_llvm_gcov_flush(curr->fn 正是__llvm_gcov_flush函数类型)。

void __gcov_flush() {
    struct flush_fn_node *curr = flush_fn_head;
    
    while (curr) {
        curr->fn();
        curr = curr->next;
    }
}
具体的分发逻辑

观察__llvm_gcov_flush的 IR 代码,可以看到:

图3 __llvm_gcov_flush 代码示例

图3 __llvm_gcov_flush 代码示例

  1. __llvm_gcov_flush先调用了__llvm_gcov_writeout,来向 .gcda 写入覆盖率信息。
  2. 最后将计数数组清零__llvm_gcov_ctr.xx

而 __llvm_gcov_writeout 逻辑为:

  1. 生成对应源文件的 .gcda 文件,写入 Magic number。
  2. 循环执行
    • llvm_gcda_emit_function: 向 .gcda 文件写入函数信息。
    • llvm_gcda_emit_arcs: 向 .gcda 文件写入BB执行信息,如果已经存在 .gcda 文件,会和之前的执行次数进行合并
  3. 调用llvm_gcda_summary_info,写入校验信息。
  4. 调用llvm_gcda_end_file,写结束符。

感兴趣的同学可以自己生成 IR 文件查看更多细节,这里不再赘述。

.gcda 的文件/函数结构和 .gcno 基本一致,这里不再赘述,统计插桩信息结构如图 4 所示。定制化的输出也可以通过修改上述函数完成。我们的增量代码测试覆盖率工具解决代码 BB 结构变动后合并到已有 .gcda 文件不兼容的问题,也是修改上述函数实现的。

图4 计数桩输出结构

图4 计数桩输出结构

覆盖率解析

在了解了如上所述 .gcno ,.gcda 生成逻辑与文件结构之后,我们以例 1 中的代码为例,来阐述解析算法的实现。

例 1 中基本块 B0,B1 对应的 .gcno 文件结构如下图所示,从图中可以看出,BB 的主结构完全记录了基本块之间的跳转关系。

图5 B0,B1 对应跳转信息

图5 B0,B1 对应跳转信息

B0,B1 的行信息在 .gcno 中表示如下图所示,B0 块因为是入口块,只有一行,对应行号可以从 B1 结构中获取,而 B1 有两行代码,会依次把行号写入 .gcno 文件。

图6 B0,B1 对应行信息

图6 B0,B1 对应行信息

在输入数字 100 的情况下,生成的 .gcda 文件如下:

图7 输入 100 得到的 .gcda 文件

图7 输入 100 得到的 .gcda 文件

通过控制流程图中节点出边的执行次数可以计算出 BB 的执行次数,核心算法为计算这个 BB 的所有出边的执行次数,不存在出边的情况下计算所有入边的执行次数(具体实现可以参考 gcov 工具源码),对于 B0 来说,即看 index=0 的执行次数。而 B1 的执行次数即 index=1,2 的执行次数的和,对照上图中 .gcda 文件可以推断出,B0 的执行次数为 ctr[0]=1,B1 的执行次数是 ctr[1]+ctr[2]=1, B2 的执行次数是 ctr[3]=0,B4 的执行次数为 ctr[4]=1,B5 的执行次数为 ctr[5]=1。

经过上述解析,最终生成的 HTML 如下图所示(利用 lcov):

图8 覆盖率检测报告

图8 覆盖率检测报告

以上是 Clang 生成覆盖率信息和解析的过程,下面介绍美团到店餐饮 iOS 团队基于以上原理做的增量代码测试覆盖率工具。

增量代码覆盖率检测原理

方案权衡

由于 gcov 工具(和前面的 .gcov 文件区分,gcov 是覆盖率报告生成工具)生成的覆盖率检测报告可读性不佳,如图 9 所示。我们做的增量代码测试覆盖率工具是基于 lcov 的扩展,报告展示如上节末尾图 8 所示。

图9 gcov 输出,行前数字代表执行次数,##### 代表没执行

图9 gcov 输出,行前数字代表执行次数,##### 代表没执行

比 gcov 直接生成报告多了一步,lcov 的处理流程是将 .gcno 和 .gcda 文件解析成一个以 .info 结尾的中间文件(这个文件已经包含全部覆盖率信息了),之后通过覆盖率报告生成工具生成可读性比较好的 HTML 报告。

结合前两章内容和覆盖率报告生成步骤,覆盖率生成流程如下图所示。考虑到增量代码覆盖率检测中代码增量部分需要通过 Git 获取,比较自然的想法是用 git diff 的信息去过滤覆盖率的内容。根据过滤点的不同,存在以下两套方案:

  1. 通过 GCOVPass 过滤,只对修改的代码进行插桩,每次修改后需重新插桩。
  2. 通过 .info 过滤,一次性为所有代码插桩,获取全部覆盖率信息,过滤覆盖率信息。

图10 覆盖率生成流程

图10 覆盖率生成流程

分析这两个方案,第一个方案需要自定义 LLVM 的 Pass,进而会引入以下两个问题:

  • 只能使用开源 Clang 进行编译,不利于接入正常的开发流程。
  • 每次重新插桩会丢失之前的覆盖率信息,多次运行只能得到最后一次的结果。

而第二个方案相对更加轻量,只需要过滤中间格式文件,不仅可以解决我们在文章开头提到的问题,也可以避免上述问题:

  • 可以很方便地加入到平常代码的开发流程中,甚至对开发者透明。
  • 未修改文件的覆盖率可以叠加(有修改的那些控制流程图结构可能变化,无法叠加)。

因此我们实际开发选定的过滤点是在 .info 。在选定了方案 2 之后,我们对中间文件 .info 进行了一系列调研,确定了文件基本格式(函数/代码行覆盖率对应的文件的表示),这里不再赘述,具体可以参考 .info 生成文档

增量代码测试覆盖率工具的实现

前一节是实现增量代码覆盖率检测的基本方案选择,为了更好地接入现有开发流程,我们做了以下几方面的优化。

降低使用成本

在接入方面,接入增量代码测试覆盖率工具只需一次接入配置,同步到代码仓库后,团队中成员无需配置即可使用,降低了接入成本。

在使用方面,考虑到插桩在编译时进行,对全部代码进行插桩会很大程度降低编译速度,我们通过解析 Podfile(iOS 开发中较为常用的包管理工具 CocoaPods 的依赖描述文件),只对 Podfile 中使用本地代码的仓库进行插桩(可配置指定仓库),降低了团队的开发成本。

对开发者透明

接入增量代码测试覆盖率工具后,开发者无需特殊操作,也不需要对工程做任何其他修改,正常的 git commit 代码,git push 到远端就会自动生成并上传这次 commit 的覆盖率信息了。

为了做到这一点,我们在接入 Pod 的过程中,自动部署了 Git 的 pre-push 脚本。熟悉 Git 的同学知道,Git 的 hooks 是开发者的本地脚本,不会被纳入版本控制,如何通过一次配置就让这个仓库的所有使用成员都能开启,是做好这件事的一个难点。

我们考虑到 Pod 本身会被纳入版本控制,因此利用了 CocoaPods 的一个属性 script_phase,增加了 Pod 编译后脚本,来帮助我们把 pre-push 插入到本地仓库。利用 script_phase 插入还带来了另外一个好处,我们可以直接获取到工程的缓存文件,也避免了 .gcno / .gcda 文件获取的不确定性。整个流程如下:

图11 pre-push 分发流程

图11 pre-push 分发流程

覆盖率累计

在实现了覆盖率的过滤后,我们在实际开发中遇到了另外一个问题:修改分支/循环结构后生成的 .gcda 文件无法和之前的合并。 在这种情况下,__gcov_flush会直接返回,不再写入 .gcda 文件了导致覆盖率检测失败,这也是市面上已有工具的通用问题

而这个问题在开发过程中很常见,比如我们给例 1 中的游戏增加一些提示,当输入比预设数字大时,我们就提示出来,反之亦然。

- (void)guessNumberGame:(NSInteger)guessNumber
{
    NSInteger targetNumber = 10;
    NSLog(@"Welcome to the game");
    if (guessNumber == targetNumber) {
        NSLog(@"Bingo!");
    } else if (guessNumber > targetNumber) {
        NSLog(@"Input number is larger than the given target!");
    } else {
        NSLog(@"Input number is smaller than the given target!");
    }
}

这个问题困扰了我们很久,也推动了对覆盖率检测原理的调研。结合前面覆盖率检测的原理可以知道,不能合并的原因是生成的控制流程图比原来多了两条边( .gcno 和旧的 .gcda 也不能匹配了),反映在 .gcda 上就是数组多了两个数据。考虑到代码变动后,原有的覆盖率信息已经没有意义了,当发生边数不一致的时候,我们会删除掉旧的 .gcda 文件,只保留最新 .gcda 文件(有变动情况下 .gcno 会重新生成)。如下图所示:

图12 覆盖率冲突解决算法

图12 覆盖率冲突解决算法

整体流程图

结合上述流程,我们的增量代码测试覆盖率工具的整体流程如图 13 所示。

开发者只需进行接入配置,再次运行时,工程中那些作为本地仓库进行开发的代码库会被自动插桩,并在 .git 目录插入 hooks 信息;当开发者使用模拟器进行需求自测时,插桩统计结果会被自动分发出去;在代码被推到远端前,会根据插桩统计结果,生成仅包含本次代码修改的详细增量代码测试覆盖率报告,以及向远端推送覆盖率信息;同时如果测试覆盖率小于 80% 会强制拒绝提交(可配置关闭,百分比可自定义),保证只有经过充分自测的代码才能提交到远端。

图13 增量代码测试覆盖率生成流程图

图13 增量代码测试覆盖率生成流程图

总结

以上是我们在代码开发质量方面做的一些积累和探索。通过对覆盖率生成、解析逻辑的探究,我们揭开了覆盖率检测的神秘面纱。开发阶段的增量代码覆盖率检测,可以帮助开发者聚焦变动代码的逻辑缺陷,从而更好地避免线上问题。

from :

iOS 覆盖率检测原理与增量代码测试覆盖率工具实现 - 美团技术团队

日常开发Guava提效工具库核心实用指南梳理_guava string转list-CSDN博客

从ES的JVM配置起步思考JVM常见参数优化_es jvm配置-CSDN博客

异步处理优化:多线程线程池与消息队列的选择与应用_模版模式使用-CSDN博客

标签:11,文件,BB,覆盖率,代码,iOS,gcov,gcda
From: https://blog.csdn.net/2401_89360015/article/details/144836193

相关文章

  • iOS 覆盖率检测原理与增量代码测试覆盖率工具实现10
     背景对苹果开发者而言,由于平台审核周期较长,客户端代码导致的线上问题影响时间往往比较久。如果在开发、测试阶段能够提前暴露问题,就有助于避免线上事故的发生。代码覆盖率检测正是帮助开发、测试同学提前发现问题,保证代码质量的好帮手。对于开发者而言,代码覆盖率可以反馈两......
  • 基于cd4511的九路抢答器1和7不能锁存而作
    基于cd4511的九路抢答器1和7不能锁存八路抢答器1和7不能锁存proteus仿真抢答器9路四路抢答器CD4511和ne555电路图原理图仿真图数码管洞洞板焊接本篇文章是基于作者tzk0524的九路抢答器电路图及原理图而出现的(封面是改前的)因为当时是为了验证自己的走线猜测,所以简化了......
  • leetcode 1191. K 次串联后最大子数组之和 未解决
    1191.K次串联后最大子数组之和很蠢但能通过的方法:k>=3时,算三次res。如果res2- res2==res3-res2,说明为等差数列。如果res2- res2!=res3-res2,说明循环多次的结果都是一样的。classSolution{public:intkConcatenationMaxSum(vector<int>&arr,intk)......
  • Solution - Luogu P11472 命运黄之瓜
    因为\((a_i,b_i)\)虽然是对的形式,但是异或是同时的。于是可以考虑把两元先缩为一元,只需要让\(a_i,b_i\)互不干扰即可。那就可以把\((a_i,b_i)\)当作数\(c_i=a_i\times2^{31}+b_i\)。那么最后\(c_i\)异或出来的结果\(c\),就可以还原出\(a=\lfloor\frac{c}{2......
  • 2024.9.11
    HTML网页结构下面是一个可视化的HTML页面结构:<html><head><title>页面标题</title></head><body><h1>这是一个标题</h1><p>这是一个段落。</p><p>这是另外一个段落。</p></body></html>HTML版本从初期的网络诞生后,已经出现了许多HTML......
  • Ajax入门以及Axios的详细使用(含Promise)
    1.概述1.1是什么Ajax=AsynchronousJavaScriptandXML(异步的JavaScript和XML)Ajax不是新的编程语言,而是一种用于创建快速动态网页的技术Ajax最大的优点是在不重新加载整个页面的情况下,可以与服务器交换数据并更新部分网页内容,使网页实现异步更新传统的网页(......
  • 一个最简单的ios程序(object_c)的编写
    前言      如何在苹果系统MacOS创建一个简单的ios(iphone)程序,貌似非常的简单。但是,作为习惯了Windows开发的程序员来说,有时候还觉得有点麻烦,至少开始有点很不习惯。    本博文试着把这个过程展现一下,供大家参考。   开发硬件平台是MacBookAir2022版,苹果......
  • VMware ESXi 8.0U3c macOS Unlocker & OEM BIOS xFusion (超聚变) 定制版
    VMwareESXi8.0U3cmacOSUnlocker&OEMBIOSxFusion(超聚变)定制版ESXi8.0U3c标准版,Dell(戴尔)、HPE(慧与)、Lenovo(联想)、Inspur(浪潮)、Cisco(思科)、Hitachi(日立)、Fujitsu(富士通)、NEC(日电)、Huawei(华为)、xFusion(超聚变)OEM定制版请访问原文链接......
  • debian11安装mysql-client
    1、进入下载页面MySQL::DownloadMySQLCommunityServer(ArchivedVersions)2、下载客户端相关的包cd/tmpwgetmysql-common_8.4.0-1debian11_amd64.debwgetmysql-community-client-plugins_8.4.0-1debian11_amd64.debwgetmysql-community-client-core_8.4.0-1debian......
  • 题解:P11487 「Cfz Round 5」Gnirts 10
    枚举\(S\)每个前缀,计算其对答案的贡献。把枚举出来的前缀\(S[1\dotsi]\)看成已有的串,我们要插入一些字符使得它有\(n\)个\(0\),\(m\)个\(1\),且\(S[1\dotsi]\)为\(T\)的子序列,而\(S[1\dotsi+1]\)不是。问方案数(统计到最后答案的时候要乘长度)。发现在\(1\)前......