LLVM - 覆盖映射格式

覆盖映射格式(Coverage Mapping Format)

LLVM 的代码覆盖映射格式用于配合 LLVM/Clang 的基于插桩的剖面分析(Clang 的 -fprofile-instr-generate 选项)来提供代码覆盖率分析。

本文档面向希望了解 LLVM 覆盖映射内部工作原理的读者。事先了解 Clang 的基于剖面的优化(PGO)有帮助,但并非必须。如果你只是想使用 LLVM/Clang 为自己的程序生成覆盖率报告,请参阅 Clang 文档:https://clang.llvm.org/docs/SourceBasedCodeCoverage.html

下面首先简要介绍覆盖映射格式以及 Clang 与 LLVM 的覆盖工具如何使用该格式。掌握基础后,再讨论格式的高级特性——例如数据结构、LLVM IR 表示与二进制编码等。

总体概述

LLVM 的覆盖映射格式被设计为一种可自包含的数据格式,可嵌入到 LLVM IR 与目标文件中。本文件把它称为“映射”格式,因为其目标是保存代码覆盖工具在将运行时得到的执行计数(来自插桩)与源文件中的具体源范围之间建立映射所需的数据。

覆盖映射数据在覆盖流程中被用在两个地方:

  1. 当 Clang 使用 -fcoverage-mapping 编译一个源文件时,会生成描述源范围与插桩计数之间映射的信息。该信息被嵌入到 LLVM IR 中,并最终随可执行文件一同链接进最终二进制。
  2. llvm-cov 使用该映射信息:它从目标文件中提取映射数据,并把运行时的执行计数(即剖面插桩计数的值)与源文件中的源范围关联起来,从而生成各种覆盖率报告。

覆盖映射格式旨在成为一个“通用格式”,适合任意前端(而不仅仅是 Clang)使用。它同时允许前端生成尽可能精简的映射数据以减小 IR 与目标文件的大小。例如,前端可以把具有相同执行计数的若干语句合并成一个区域,仅为这些区域发出映射信息,而无需为函数内的每个语句单独生成映射信息。

高级概念

余下部分旨在帮助你理解覆盖映射格式的工作细节。

该格式以函数为单位运作,因为插桩计数是与函数相关联的。对于每个需要覆盖信息的函数,前端需要创建覆盖映射数据,以便在该函数内把源代码范围映射到插桩计数。

映射区(Mapping Region)

函数的覆盖映射数据包含一个映射区数组。每个映射区记录:该映射区覆盖的源代码范围(source code range)、文件 id(file id)、覆盖映射计数器(coverage mapping counter)以及区域的类型。映射区有若干种类型:

  • 代码区(Code regions)将源码片段与覆盖映射计数器关联。它们占据映射区的大多数。代码覆盖工具会使用这些区域来计算行执行计数、标示从未执行的代码区域,并计算函数的覆盖统计。例如:
int main(int argc, const char *argv[]) {     // Code Region from 1:40 to 9:2
  if (argc > 1) {                            // Code Region from 3:17 to 5:4
    printf("%s\n", argv[1]);
  } else {                                   // Code Region from 5:10 to 7:4
    printf("\n");
  }
  return 0;
}
  • 跳过区(Skipped regions)用于表示被 Clang 预处理器跳过的源范围(例如 #ifdef 掩盖的代码)。它们不关联覆盖计数器,因为前端已知这些代码不会被执行。覆盖工具将这些行标记为非代码行,不显示执行计数。例如:
int main() {                // Code Region from 1:12 to 6:2
#ifdef DEBUG                // Skipped Region from 2:1 to 4:2
  printf("Hello world");
#endif
  return 0;
}
  • 展开区(Expansion regions)用于表示宏扩展。它们包含一个额外属性:展开文件 id(expanded file id)。覆盖工具可以通过该属性找到由宏展开产生的映射区(即检查这些映射区的 file id 是否与展开文件 id 匹配),并用首个对应 file id 区域的执行计数来决定该宏展开区域的计数。展开区也不直接关联计数器。例如:
int func(int x) {
  #define MAX(x,y) ((x) > (y)? (x) : (y))
  return MAX(x, 42);  // Expansion Region from 3:10 to 3:13
}
  • 分支区(Branch regions)将源码中的可插桩分支条件与两个覆盖计数器关联:一个用于统计条件为 true 的次数,另一个用于统计条件为 false 的次数。可插桩的分支条件可能由布尔逻辑运算组合而成。truefalse 分支反映的是能映射回源码的独特控制流路径。例如:
int func(int x, int y) {
  if ((x > 1) || (y > 3)) {  // Branch Region from 3:6 to 3:12
                             // Branch Region from 3:17 to 3:23
    printf("%d\n", x);
  } else {
    printf("\n");
  }
  return 0;
}
  • 决策区(Decision regions)把多个分支区与源码中的一个布尔表达式关联在一起。它们还包含表示表达式可执行测试向量所需的位图位数,以及构成该表达式的可插桩分支条件总数。决策区用于在 llvm-cov 中可视化修正条件/判定覆盖(MC/DC)。当使用决策区时,会为每个关联的分支区分配控制流 ID:一个表示当前条件、另两个表示在当前条件为 true/false 时下一个控制流目标。这使 llvm-cov 能重建条件周围的控制流,从而理解可能的可执行测试向量列表。
源范围(Source Range)

源范围记录包含某个映射区的起始与结束位置。两端位置均含行号与列号。

文件 ID(File ID)

文件 id 是一个整数值,指示该映射区位于哪个源文件或宏展开中。它允许 Clang 为宏内定义的代码生成映射信息,例如:

void func(const char *str) {         // Code Region from 1:28 to 6:2 with file id 0
  #define PUT printf("%s\n", str)    // 2 Code Regions from 2:15 to 2:34 with file ids 1 and 2
  if(*str)
    PUT;                             // Expansion Region from 4:5 to 4:8 with file id 0 that expands a macro with file id 1
  PUT;                               // Expansion Region from 5:3 to 5:6 with file id 0 that expands a macro with file id 2
}
计数器(Counter)

覆盖映射计数器可以代表对剖面插桩计数器的引用:若映射区的计数器是引用型,则该区域的执行计数通过查找相应剖面插桩计数器的值来得到。

计数器也可以表示以其他计数器或表达式为操作数的二元算术表达式(加或减)。若计数器为表达式类型,则先评估表达式的参数,再进行相加或相减以得到该区域的执行计数。例如下面示例中,else 后复合语句的计数就是 profile counter #0 - profile counter #1

int main(int argc, const char *argv[]) {    // Region's counter is a reference to the profile counter #0
  if (argc > 1) {                           // Region's counter is a reference to the profile counter #1
    printf("%s\n", argv[1]);
  } else {                                  // Region's counter is an expression (ref #0 - ref #1)
    printf("\n");
  }
  return 0;
}

此外,计数器也可以表示零执行计数(zero counter),用于表示不可达语句或表达式。例如:

int main() {
  return 0;
  printf("Hello world!\n");    // Unreachable region's counter is zero
}

零计数器使覆盖工具能够为不可达行显示正确的执行次数并高亮不可达代码;没有零计数器时,工具会错误地认为这些行被执行过,因为它缺乏前端的可达性信息。

注意:分支区会为条件的 truefalse 分别创建两个计数器。

LLVM IR 表示

覆盖映射数据以一个名为 __llvm_coverage_mapping 的全局常量(global constant)结构体变量形式存储在 LLVM IR 中,该变量带有 IPSK_covmap 节(在 Windows 上为 .lcovmap$M,其它平台为 __llvm_covmap)。

例如,考虑一个简单的 C 文件及其编译:

int foo() {
  return 42;
}
int bar() {
  return 13;
}

Clang 生成的覆盖映射变量包含两个字段:

  • 覆盖映射头(Coverage mapping header)
  • 可选的、对本翻译单元中出现的文件名列表进行压缩后的字节串

该变量采用 8 字节对齐,因为 ld64 在合并来自不同目标文件的符号时并不总能紧凑打包(对齐假设在链接器实现中根深蒂固)。

示例(IR 伪表示):

@__llvm_coverage_mapping = internal constant { { i32, i32, i32, i32 }, [32 x i8] } {
  { i32, i32, i32, i32 } ; Coverage map header
  {
    i32 0,  ; Always 0. (以前版本用于附加的函数记录数)
    i32 32, ; Length of the string containing encoded TU filenames
    i32 0,  ; Always 0. (以前版本用于附加的映射数据长度)
    i32 3,  ; Coverage mapping format version
  },
  [32 x i8] c"..." ; Encoded data (后文分解)
}, section "__llvm_covmap", align 8

当前格式版本是 6。

版本间的一些差别:

  • 版本 6 与 5 的区别:
    • 文件名列表的第一个条目为编译目录(compilation directory)。当文件名是相对路径时,可与编译目录结合得到绝对路径,从而通过省略重复前缀减小存储量。
  • 版本 5 与 4 的区别:
    • 引入了分支区及对应的区域种类(branch region),分支区编码两个计数器以分别统计 truefalse 次数。
  • 版本 4 与 3 的差别:
    • 函数记录现在为具名符号并标为 linkonce_odr,允许链接器合并重复记录(可减少为函数但未使用的 dummy 记录带来的体积膨胀)。同时,函数的区域映射信息现包含在函数记录内部(而非附加于头部)。
    • 翻译单元的文件名列表可选地进行 zlib 压缩。
  • 版本 3 与 2 的区别:
    • 引入了用于表示“空隙区域”(gap regions)的列结束位置特殊编码。

早期(版本 1)的函数记录示例为:

{ i8*, i32, i32, i64 } { i8* getelementptr inbounds ([3 x i8]* @__profn_foo, i32 0, i32 0),
  i32 3, ; name length
  i32 9, ; encoded mapping data length
  i64 0  ; structural hash
}

在版本 2 中,函数记录变为如下结构体(含名字 MD5):

{ i64, i32, i64 } {
  i64 0x5cf8c24cdb18bdac, ; function name MD5
  i32 9, ; encoded mapping string length
  i64 0  ; function structural hash
}

覆盖映射头(Coverage Mapping Header)

覆盖映射头包含字段:

  • 附加到头部的函数记录数量(向后兼容,总为 0)。
  • 存放在 __llvm_coverage_mapping 第三字段中、经编码的翻译单元文件名字串的长度。
  • 存放在 __llvm_coverage_mapping 第三字段中、经编码的覆盖映射数据的长度(向后兼容,总为 0)。
  • 格式版本(当前为 6,编码值为 5)。

函数记录(Function record)

函数记录的数据类型大致为:

{ i64, i32, i64, i64, [? x i8] }

它包含:函数名的 MD5、该函数的编码映射数据长度、函数结构哈希值、该函数翻译单元中文件名的哈希、以及该函数的编码映射数据。

样例解析(Dissecting the sample)

示例 IR 中存储的编码覆盖映射数据概览:

  • IR 中包含一个字符串常量,表示该翻译单元的编码覆盖映射数据,例如:
c"\01\15\1Dx\DA\13\D1\0F-N-* \D6/+ \CE\D6/\C9-\D0O\CB\CF\D7K\06\00N+\07]"

(示例字符串包含 LEB128 编码的整数与压缩载荷。)

  • 首三个 LEB128 编码的数指定:文件名数量、未压缩的文件名字节长度、压缩载荷长度(若未压缩则为 0)。示例中有 1 个文件名,未压缩长度为 21 字节,压缩后占 29 字节。
  • 第一个函数记录的覆盖映射编码为:
c"\01\00\00\01\01\01\0C\02\02"

对应字节解释:

  • 0x01:该函数使用的 file id 数(这里只用了 1 个 file id)。
  • 0x00:索引到 filenames 数组,对应文件“/Users/alex/test.c”。
  • 0x00:该函数使用的表达式计数(0,表示无表达式)。
  • 0x01:该 file id 下存储的映射区数量(1)。
  • 0x01:第一个区域的覆盖映射计数 —— 值为 1,表示它是对 profile 插桩计数器索引 0 的引用。
  • 0x01:第一个区域的起始行。
  • 0x0C:第一个区域的起始列。
  • 0x02:第一个区域的结束行。
  • 0x02:第一个区域的结束列。

第二个函数记录的映射子串长度也为 9,结构类似。尾部两个零字节用于把覆盖映射数据填充到 8 字节对齐。

编码(Encoding)

每个函数的覆盖映射数据被编码为字节流,结构由若干编码类型(cvm types)组成,如变长无符号整数等,用于编码文件 id 映射、计数器表达式与映射区域。

每个函数的编码结构遵循:

[file id mapping, counter expressions, mapping regions]

翻译单元的文件名采用相同的编码类型,结构为:

[numFilenames : LEB128, filename0 : string, filename1 : string, ...]

类型(Types)

以下介绍编码格式中常用的基本类型(在文档描述中会以 [...] : type 出现)。

LEB128

LEB128 是使用 DWARF 的 LEB128 编码的无符号整数,对于小值(小于 128)只占一个字节,从而节约空间。

字符串(Strings)

字符串编码为:

[length : LEB128, characters...]

即先以 LEB128 编码长度,然后跟随字节序列。

文件 ID 映射(File ID Mapping)

文件 id 映射在函数的覆盖映射流中编码为:

[numIndices : LEB128, filenameIndex0 : LEB128, filenameIndex1 : LEB128, ...]

这些索引指向翻译单元的文件名数组。

计数器(Counter)

计数器编码为单个 LEB 值:

[value : LEB128]

该值包含两个部分:最低 2 位为 tag(标记计数器类型或表达式类型),其余高位为计数器数据。

Tag(标记)

标记值说明:

  • 0 — 计数器为零(zero counter)。
  • 1 — 计数器引用某个 profile 插桩计数器(reference)。
  • 2 — 计数器为减法表达式(subtraction expression)。
  • 3 — 计数器为加法表达式(addition expression)。
数据(Data)

计数器数据含义:

  • 对引用类型(tag=1),数据位为 profile 计数器的 id(索引)。
  • 对表达式类型(tag=2 或 3),数据位为计数器表达式数组中的索引。

计数器表达式(Counter Expressions)

表达式编码为:

[numExpressions : LEB128, expr0LHS : LEB128, expr0RHS : LEB128, expr1LHS : LEB128, expr1RHS : LEB128, ...]

每个表达式包含两个计数器(左操作数与右操作数),表达式类型由引用该表达式的计数器的 tag 决定(加或减)。

映射区域(Mapping Regions)

整体结构为:

[numRegionArrays : LEB128, regionsForFile0, regionsForFile1, ...]

映射区域按 file id 分成子数组存储。子数组的索引即为对应的 file id(即第一个子数组对应 file id 0,以此类推)。

子数组(Sub-Array of Regions)

每个子数组格式:

[numRegions : LEB128, region0, region1, ...]

每个特定 file id 下的映射区域按起始位置升序排序。

映射区域项(Mapping Region)

每个映射区域为:

[header, source range]

其中 header 存储计数器与区域种类;source range 存储起止位置(见下文)。

Header

Header 可为:

[counter]

[pseudo-counter]

Header 同时编码区域的计数器与区域种类。对于分支区,header 会编码两个计数器。计数器的 tag 若为 0 则表示这是一个 pseudo-counter(伪计数器),否则为普通计数器。

普通计数器(Counter)

当 header 中的计数器 tag 非 0 时,该映射区域为代码区(code region)。

伪计数器(Pseudo-Counter)

伪计数器同样以单个 LEB 值存储,结构如下:

  • bits 0-1:tag,总为 0(标明伪计数器)。
  • bit 2:expansionRegionTag。若该位被置位,表示该映射区域为展开区(expansion region)。
  • 剩余位:data。当为展开区时,data 为展开文件 id;否则 data 表示区域种类(region kind)。区域种类的可能值:
    • 0 — 代码区,计数为零(code region with zero counter)。
    • 2 — 跳过区(skipped region)。
    • 4 — 分支区(branch region)。
      (注:文档中列出的具体枚举值以实现为准。)
源范围(Source Range)

源范围编码为:

[deltaLineStart : LEB128, columnStart : LEB128, numLines : LEB128, columnEnd : LEB128]

字段含义:

  • deltaLineStart:当前映射区域起始行与前一个映射区域起始行之差;若为当前子数组的第一个区域,则直接存储其起始行号。
  • columnStart:起始列。
  • numLines:当前映射区域的行跨度(结束行 - 起始行)。
  • columnEnd:结束列;若该字段的高位被置位,表示当前区域是一个 gap 区域(即空隙区域)。当某行有空隙区域且该行在其他区域没有计数时,gap 区的计数会被用作该行的执行计数。

测试格式(Testing Format)

警告

本节仅供 llvm-cov 开发者参考。

llvm-cov 使用一种专用的文件格式(下称 .covmapping)用于测试目的。该格式为私有格式,一般用户无需使用。你可以通过 llvm-cov 的 convert-for-testing 子命令获取这样的文件。

.covmapping 文件结构为:

[magicNumber : u64, version : u64, profileNames, coverageMapping, coverageRecords]

魔数与版本(Magic Number and Version)

魔数为 0x6d766f636d766c6c,即小端序下的 ASCII 字符串 llvmcovm

目前有两种版本:

  • Version1:编码为 0x6174616474736574(ASCII "testdata")。
  • Version2:编码为 1

Version1 与 Version2 的主要差别在于 coverageMapping 字段的编码方式(下面会解释)。

Profile Names(剖面名)

profileNamescoverageMappingcoverageRecords 是从原始二进制文件中提取的三段数据,profileNames 编码为:

[profileNamesSize : LEB128, profileNamesAddr : LEB128, profileNamesData : bytes]

即包含段的大小、地址与原始字节数据。

Coverage Mapping 字段

该字段用零字节填充以满足 8 字节对齐。

coverageMapping 包含源文件的记录。在版本 1 中仅允许存储一条记录:

[padding : bytes, coverageMappingData : bytes]

版本 2 放宽了此限制,先以 LEB128 编码 coverageMappingData 的大小:

[coverageMappingSize : LEB128, padding : bytes, coverageMappingData : bytes]

当前版本为 2。

Coverage Records(覆盖记录)

该字段也以零字节填充以满足 8 字节对齐。

编码形式为:

[padding : bytes, coverageRecordsData : bytes]

文件剩余的数据均被视为 coverageRecordsData

原文地址:https://llvm.org/docs/CoverageMappingFormat.html

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

csdddn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值