工具教程LLVM(函数你可以分析器错误文件)「llvm 分析」

在本章中,你将学习如何使用 LLVM 提供的工具集来识别应用程序中的某些错误
所有这些工具都利用了 LLVM 和 Clang 库
你将学习如何使用 sanitizers 对应用程序进行检测,以及如何使用最常见的 sanitizer 来识别广泛的错误类型
然后,你将为你的应用程序实现模糊测试(fuzz testing),这有助于你发现通常在单元测试中未被发现的错误
你还将学习如何识别应用程序中的性能瓶颈,运行静态分析器以识别编译器通常未发现的问题,并创建你自己的基于 Clang 的工具,你可以在其中扩展 Clang 的新功能
本章将涵盖以下主题:使用 sanitizers 对应用程序进行检测使用 libFuzzer 查找错误使用 XRay 进行性能分析使用 Clang 静态分析器检查源代码创建你自己的基于 Clang 的工具到本章结束时,你将知道如何使用各种 LLVM 和 Clang 工具来识别应用程序中的大量错误类型
你还将获得扩展 Clang 的新功能的知识,例如,强制执行命名约定或添加新的源代码分析
技术要求 为了在“使用 XRay 进行性能分析”部分创建火焰图,你需要从 https://github.com/brendangregg/FlameGraph 安装脚本
一些系统,如 Fedora 和 FreeBSD,提供了这些脚本的软件包,你也可以使用
为了在同一部分查看 Chrome 可视化,你需要安装 Chrome 浏览器
你可以从 https://www.google.com/chrome/ 下载浏览器,或者使用你的系统的包管理器安装 Chrome 浏览器
此外,要通过 scan-build 脚本运行静态分析器,你需要在 Fedora 和 Ubuntu 上安装 perl-core 包
使用 sanitizers 对应用程序进行检测 LLVM 提供了几个 sanitizers
这些是将中间表示(IR)进行检测以检查应用程序的某些不当行为的传递
通常,它们需要库支持,这是 compiler-rt 项目的一部分
可以在 Clang 中启用 sanitizers,这使得它们非常容易使用
要构建 compiler-rt 项目,我们可以在构建 LLVM 时简单地添加 -DLLVM_ENABLE_RUNTIMES=compiler-rt CMake 变量到初始的 CMake 配置步骤中
在接下来的部分中,我们将查看地址、内存和线程 sanitizers
首先,我们将查看地址 sanitizer
使用地址 sanitizer 检测内存访问问题 你可以使用地址 sanitizer 来检测应用程序中的不同类型的内存访问错误
这包括常见的错误,例如在释放动态分配的内存后使用它,或在分配的内存边界之外写入动态分配的内存
启用时,地址 sanitizer 将对 malloc() 和 free() 函数的调用替换为自己的版本,并使用检查守卫对所有内存访问进行检测
当然,这为应用程序增加了大量的开销,你只在应用程序的测试阶段使用地址 sanitizer
如果你对实现细节感兴趣,那么你可以找到 pass 的源代码在 llvm/lib/Transforms/Instrumentation/AddressSanitizer.cpp 文件中,以及在 https://github.com/google/sanitizers/wiki/AddressSanitizerAlgorithm 上实现的算法描述
让我们运行一个简短的示例来展示地址 sanitizer 的能力
以下示例应用程序 outofbounds.c 分配了 12 个字节的内存,但初始化了 14 个字节:#include <stdlib.h>#include <string.h>int main(int argc, char argv[]) { char p = malloc(12); memset(p, 0, 14); return (int)p;}你可以编译并运行这个应用程序而不会注意到问题,因为这种行为对于这种类型的错误是典型的
即使在更大的应用程序中,这类错误也可能长时间不被注意到
然而,如果你使用 -fsanitize=address 选项启用了地址 sanitizer,那么应用程序在检测到错误后会停止
同时,启用调试符号与 –g 选项也很有用,因为它有助于识别源代码中错误的位置
以下代码是如何使用地址 sanitizer 和启用调试符号编译源文件的示例:$ clang -fsanitize=address -g outofbounds.c -o outofbounds现在,当你运行应用程序时,会得到一个冗长的错误报告:$ ./outofbounds================================================================1067==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x60200000001c at pc 0x00000023a6ef bp 0x7fffffffeb10 sp 0x7fffffffe2d8WRITE of size 14 at 0x60200000001c thread T0 #0 0x23a6ee in __asan_memset /usr/src/contrib/llvm-project/compiler-rt/lib/asan/asan_interceptors_memintrinsics.cpp:26:3 #1 0x2b2a03 in main /home/kai/sanitizers/outofbounds.c:6:3 #2 0x23331f in _start /usr/src/lib/csu/amd64/crt1.c:76:7报告还包含有关内存内容的详细信息
重要信息是错误类型——在这种情况下是堆缓冲区溢出——以及违规的源代码行
要找到源代码行,你必须查看位置 #1 的堆栈跟踪,这是地址 sanitizer 拦截应用程序执行的最后一个位置
它显示了 outofbounds.c 文件的第 6 行,即包含对 memset() 的调用的行
这是发生缓冲区溢出的确切位置
如果你将 outofbounds.c 文件中包含 memset(p, 0, 14); 的行替换为以下代码,那么你可以引入一旦你释放了内存就访问内存的情况
你需要将源代码保存在 useafterfree.c 文件中:memset(p, 0, 12);free(p);再次编译并运行它,sanitizer 会检测到释放内存后使用的指针:$ clang -fsanitize=address -g useafterfree.c -o useafterfree$ ./useafterfree================================================================1118==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010 at pc 0x0000002b2a5c bp 0x7fffffffeb00 sp 0x7fffffffeaf8READ of size 1 at 0x602000000010 thread T0 #0 0x2b2a5b in main /home/kai/sanitizers/useafterfree.c:8:15 #1 0x23331f in _start /usr/src/lib/csu/amd64/crt1.c:76:7这次,报告指向了包含 p 指针解引用的第 8 行
在 x86_64 Linux 和 macOS 上,你还可以启用泄漏检测器
如果你在运行应用程序之前将 ASAN_OPTIONS 环境变量设置为 detect_leaks=1,那么你还可以得到有关内存泄漏的报告
在命令行上,你可以这样做:$ ASAN_OPTIONS=detect_leaks=1 ./useafterfree地址 sanitizer 非常有用,因为它捕获了一类别通常难以检测到的错误
内存 sanitizer 执行类似的任务
在下一节中,我们将检查它的用例
使用内存 sanitizer 查找未初始化的内存访问 使用未初始化的内存是另一类难以发现的错误
在 C 和 C++ 中,常规的内存分配例程不使用默认值初始化内存缓冲区
自动变量在栈上也是如此
这里有很多出错的机会,内存 sanitizer 可以帮助发现这些错误
如果你对实现细节感兴趣,你可以在 llvm/lib/Transforms/Instrumentation/MemorySanitizer.cpp 文件中找到内存 sanitizer pass 的源代码
文件顶部的注释解释了实现背后的想法
让我们运行一个小型示例,并将以下源代码保存为 memory.c 文件
请注意,变量 x 未初始化并用作返回值:int main(int argc, char argv[]) { int x; return x;}如果没有 sanitizer,应用程序将正常运行
然而,如果你使用 -fsanitize=memory 选项,你将得到一个错误报告:$ clang -fsanitize=memory -g memory.c -o memory$ ./memory==1206==WARNING: MemorySanitizer: use-of-uninitialized-value #0 0x10a8f49 in main /home/kai/sanitizers/memory.c:3:3 #1 0x1053481 in _start /usr/src/lib/csu/amd64/crt1.c:76:7SUMMARY: MemorySanitizer: use-of-uninitialized-value /home/kai/sanitizers/memory.c:3:3 in mainExiting像地址 sanitizer 一样,内存 sanitizer 在发现第一个错误时停止应用程序
如上所示,内存 sanitizer 提供了一个使用初始化值的警告
最后,在下一节中,我们将看到如何使用线程 sanitizer 来检测多线程应用程序中的竞态条件
使用线程 sanitizer 指出数据竞争 为了利用现代 CPU 的能力,应用程序现在使用多个线程
这是一种强大的技术,但它也引入了新的错误来源
多线程应用程序中一个非常常见的问题是对全局数据的访问未受保护,例如,使用互斥锁或信号量
这被称为数据竞争
线程 sanitizer 可以检测基于 Pthreads 的应用程s/Instrumentation/ThreadSanitizer.cpp 文件中找到实现
为了演示线程 sanitizer 的功能,我们将创建一个非常简单的生产者-消费者风格的应用程序
生产者线程增加一个全局变量,而消费者线程减少同一个变量
对全局变量的访问没有受到保护,因此这是一次数据竞争
你需要将以下源代码保存在 thread.c 文件中:#include <pthread.h>int data = 0;void producer(void x) { for (int i = 0; i < 10000; ++i) ++data; return x;}void consumer(void x) { for (int i = 0; i < 10000; ++i) --data; return x;}int main() { pthread_t t1, t2; pthread_create(&t1, NULL, producer, NULL); pthread_create(&t2, NULL, consumer, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); return data;}在前面的代码中,data 变量在两个线程之间共享
这里,它被声明为 int 类型,以简化示例,因为在实际情况下,通常会使用类似 std::vector 类或类似的数据结构
此外,这两个线程运行 producer() 和 consumer() 函数
producer() 函数只增加 data 变量,而 consumer() 函数减少它
没有实现访问保护,所以这是一次数据竞争
main() 函数使用 pthread_create() 函数启动两个线程,使用 pthread_join() 函数等待线程结束,并返回 data 变量的当前值
如果你编译并运行这个应用程序,你将不会注意到错误——也就是说,返回值总是零
如果将执行的循环次数增加 100 倍,那么就会出现错误——在这种情况下,将出现其他值
我们可以使用线程 sanitizer 来识别程序中的数据竞争
要启用线程 sanitizer 编译,你需要传递 -fsanitize=thread 选项给 clang
加上生成调试符号的 -g 选项可以在报告中给出行号,这也很有帮助
注意,你还需要链接 pthread 库:$ clang -fsanitize=thread -g thread.c -o thread -lpthread$ ./thread==================..........ThreadSanitizer: reported 1 warnings报告指向源文件的 6 行和 11 行,全局变量被访问的地方
它还显示了两个名为 T1 和 T2 的线程访问了变量,以及 pthread_create() 函数调用的文件和行号
通过这些,我们学会了如何使用三种不同类型的 sanitizers 来识别应用程序中的常见问题
地址 sanitizer 帮助我们识别常见的内存访问错误,例如越界访问或在释放内存后使用内存
使用内存 sanitizer,我们可以找到未初始化内存的访问,线程 sanitizer 帮助我们识别数据竞争
在接下来的部分中,我们将尝试通过在随机数据上运行应用程序来触发 sanitizers
这个过程被称为模糊测试
使用 libFuzzer 查找错误 要测试你的应用程序,你需要编写单元测试
这是一个很好的方式来确保你的软件按照预期正确运行
然而,由于可能的输入数量呈指数级增长,你可能会错过某些奇怪的输入,以及一些错误
模糊测试可以在此处提供帮助
其思想是向应用程序呈现随机生成的数据,或基于有效输入但带有随机变化的数据
这是重复进行的,以便你的应用程序被大量输入测试,这就是为什么模糊测试可以成为一种强大的测试方法
已有记录显示,模糊测试帮助在网络浏览器和其他软件中发现了数百个错误
有趣的是,LLVM 附带了自己的模糊测试库
最初是 LLVM 核心库的一部分,libFuzzer 实现最终被移到了 compiler-rt 中
该库旨在测试小型和快速的函数
让我们通过一个小型示例来看看 libFuzzer 的工作原理
首先,你需要提供 LLVMFuzzerTestOneInput() 函数
这个函数由模糊测试驱动调用,并为你提供一些输入
下面的函数计算输入中的连续 ASCII 数字
完成这个之后,我们将向它提供随机输入
你需要将示例保存在 fuzzer.c 文件中:#include <stdint.h>#include <stdlib.h>int count(const uint8_t Data, size_t Size) { int cnt = 0; if (Size) while (Data[cnt] >= '0' && Data[cnt] <= '9') ++cnt; return cnt;}int LLVMFuzzerTestOneInput(const uint8_t Data, size_t Size) { count(Data, Size); return 0;}在前面的代码中,count() 函数计算 Data 变量指向的内存中的数字数量
数据的大小只检查是否有可用的字节
在 while 循环中,不检查大小
使用常规的 C 字符串时,不会有错误,因为 C 字符串总是以 0 字节结尾
LLVMFuzzerTestOneInput() 函数是所谓的模糊测试目标,它是由 libFuzzer 调用的函数
它调用我们想要测试的函数,并返回 0,目前是唯一允许的值
要使用 libFuzzer 编译文件,你必须添加 -fsanitize=fuzzer 选项
建议同时启用地址 sanitizer 和生成调试符号
我们可以使用以下命令来编译 fuzzer.c 文件:$ clang -fsanitize=fuzzer,address -g fuzzer.c -o fuzzer当你运行测试时,它会发出一个详细的报告
报告包含比堆栈跟踪更多的信息,让我们仔细看看它:第一行告诉你初始化随机数生成器所使用的种子
你可以使用 –seed= 选项来重复此执行:INFO: Seed: 1297394926默认情况下,libFuzzer 将输入限制在最多 4096 字节
你可以通过使用 –max_len= 选项来更改默认值:INFO: -max_len is not provided; libFuzzer will not generate inputs larger than 4096 bytes现在,我们可以在不提供示例输入的情况下运行测试
所有示例输入的集合称为语料库,对于这次运行它是空的:INFO: A corpus is not provided, starting from an empty corpus接下来是一些关于生成的测试数据的信息
它显示你尝试了 28 个输入,并找到了 6 个输入,这些输入的总长度为 19 字节,它们共同涵盖了 6 个覆盖点或基本块:#28 NEW cov: 6 ft: 9 corp: 6/19b lim: 4 exec/s: 0 rss: 29Mb L: 4/4 MS: 4 CopyPart-PersAutoDict-CopyPart-ChangeByte- DE: "1\x00"-在此之后,检测到缓冲区溢出,并跟随来自地址 sanitizer 的信息
最后,报告告诉你导致缓冲区溢出的输入保存在哪里:artifact_prefix='./'; Test unit written to ./crash-17ba0791499db908433b80f37c5fbc89b870084b使用保存的输入,可以使用相同的崩溃输入再次执行测试用例:$ ./fuzzer crash-17ba0791499db908433b80f37c5fbc89b870084b这有助于识别问题,因为我们可以利用保存的输入作为直接的复制品来修复可能出现的任何问题
然而,仅使用随机数据通常在每种情况下并不是很有帮助
如果你尝试模糊测试 tinylang 词法分析器或解析器,那么纯随机数据会导致输入立即被拒绝,因为找不到有效的标记
在这种情况下,提供一组有效的输入更有用,称为语料库
在这种情况下,语料库中的文件被随机变异并用作输入
你可以认为输入大部分是有效的,只是翻转了一些位
这对于必须具有特定格式的其他输入也有效
例如,对于处理 JPEG 和 PNG 文件的库,你将提供一些小的 JPEG 和 PNG 文件作为语料库
提供语料库的示例如下
你可以在一个或多个目录中保存语料库文件,你可以使用 printf 命令的帮助为我们的模糊测试创建一个简单的语料库:$ mkdir corpus$ printf "012345\0" >corpus/12345.txt$ printf "987\0" >corpus/987.txt运行测试时,你必须在命令行上提供目录:$ ./fuzzer corpus/然后,语料库被用作生成随机输入的基础,报告告诉你:INFO: seed corpus: files: 2 min: 4b max: 7b total: 11b rss: 29Mb此外,如果你正在测试一个在标记或其他魔术值上工作的函数,例如一个编程语言,那么通过提供一个包含标记的字典,可以加快进程
对于编程语言,字典将包含语言中使用的所有关键字和特殊符号
此外,字典定义遵循一个简单的键值风格
例如,要在字典中定义 if 关键字,你可以添加以下内容:kw1="if"然而,键是可选的,你可以省略它
现在,你可以在命令行上使用 –dict= 选项指定字典文件
现在我们已经介绍了如何使用 libFuzzer 查找错误,让我们看看 libFuzzer 实现的限制和替代方案
限制和替代方案 libFuzzer 实现很快,但对测试目标提出了几个限制
它们如下:被测试的函数必须接受内存中的数组作为输入
一些库函数需要数据的文件路径,它们不能使用 libFuzzer 进行测试
不应调用 exit() 函数
不应改变全局状态
不应使用硬件随机数生成器
前两个限制是 libFuzzer 作为库实现的含义
后两个限制是为了避免评估算法中的混淆
如果这些限制中的任何一个没有得到满足,那么两个相同的对模糊测试目标的调用可能会产生不同的结果
最著名的替代模糊测试工具是 AFL,可以在 https://github.com/google/AFL 上找到
AFL 需要一个有工具的二进制文件(提供 LLVM 插件进行工具化),并要求应用程序在命令行上将输入作为文件路径
AFL 和 libFuzzer 可以共享相同的语料库和相同的字典文件
因此,可以使用这两种工具测试应用程序
此外,在 libFuzzer 不适用的情况下,AFL 可能是一个很好的替代品
还有很多方法可以影响 libFuzzer 的工作方式
你可以在 https://llvm.org/docs/LibFuzzer.html 上阅读参考页面以获取更多详细信息
在接下来的部分中,我们将查看应用程序可能存在的不同问题 - 我们将尝试使用 XRay 工具识别性能瓶颈
使用 XRay 进行性能分析 如果你的应用程序运行缓慢,那么你可能想要知道代码中的时间花费在哪里
在这里,使用 XRay 对代码进行检测可以帮助完成这项任务
基本上,在每个函数的入口和出口处,都会插入一个特殊的调用到运行时库
这允许你计算函数被调用了多少次,以及在函数中花费了多少时间
你可以在 llvm/lib/XRay/ 目录中找到工具传递的实现
运行时部分是 compiler-rt 的一部分
在以下示例源代码中,通过调用 usleep() 函数来模拟实际工作
func1() 函数睡眠 10 µs
func2() 函数根据 n 参数是奇数还是偶数,调用 func1() 或睡眠 100 µs
在 main() 函数中,两个函数都在循环中被调用
这已经足够获得有趣的信息了
你需要将以下源代码保存在 xraydemo.c 文件中:#include <unistd.h>void func1() { usleep(10); }void func2(int n) { if (n % 2) func1(); else usleep(100);}int main(int argc, char argv[]) { for (int i = 0; i < 100; i++) { func1(); func2(i); } return 0;}要在编译期间启用 XRay 检测,你需要指定 -fxray-instrument 选项
值得注意的是,少于 200 条指令的函数不会被检测
这是因为这是开发者定义的任意阈值,在我们的案例中,函数将不会被检测
可以使用 -fxray-instruction-threshold= 选项来指定阈值
或者,我们可以添加一个函数属性来控制是否应该检测一个函数
例如,添加以下原型将始终导致我们检测该函数:void func1() __attribute__((xray_always_instrument));同样,通过使用 xray_never_instrument 属性,你可以关闭一个函数的检测
现在,我们将使用命令行选项并编译 xraydemo.c 文件,如下所示:$ clang -fxray-instrument -fxray-instruction-threshold=1 -g\ xraydemo.c -o xraydemo在生成的二进制文件中,默认情况下检测是关闭的
如果你运行二进制文件,你会注意到与非检测二进制文件相比没有区别
XRAY_OPTIONS 环境变量用于控制运行时数据的记录
要启用数据收集,你可以按如下方式运行应用程序:$ XRAY_OPTIONS="patch_premain=true xray_mode=xray-basic"\ ./xraydemoxray_mode=xray-basic 选项告诉运行时我们想要使用基本模式
在这种模式下,收集了所有的运行时数据,这可能会导致大型日志文件
当给出 patch_premain=true 选项时,也会检测在 main() 函数之前运行的函数
运行此命令后,将在目录中创建一个新文件,存储收集的数据
你需要使用 llvm-xray 工具从该文件中提取任何可读信息
llvm-xray 工具支持各种子命令
首先,你可以使用 account 子命令提取一些基本统计信息
例如,要获取最常调用的前 10 个函数,你可以添加 -top=10 选项来限制输出,并使用 -sort=count 选项指定函数调用计数作为排序标准
你还可以使用 -sortorder= 选项影响排序顺序
以下命令可以运行以获取我们程序的统计信息:$ llvm-xray account xray-log.xraydemo.xVsWiE --sort=count\ --sortorder=dsc --instr_map ./xraydemo具有延迟的函数:3 函数id 计数 总和 函数 1 150 0.166002 demo.c:4:0: func1 2 100 0.543103 demo.c:9:0: func2 3 1 0.655643 demo.c:17:0: main如你所见,func1() 函数被调用得最频繁;你还可以看到在这个函数中花费的累积时间
这个例子只有三个函数,所以 –top= 选项在这里没有可见效果,但对于真实应用程序,它非常有用
从收集的数据中,可以重建运行时发生的所有堆栈帧
你使用 stack 子命令来查看前 10 个堆栈
这里显示的输出已经简化,以便于理解:$ llvm-xray stack xray-log.xraydemo.xVsWiE –instr_map\ ./xraydemoUnique Stacks: 3 Top 10 Stacks by leaf sum: Sum: 1325516912 lvl function 计数 总和 #0 main 1 1777862705 #1 func2 50 1325516912 Top 10 Stacks by leaf count: Count: 100 lvl function 计数 总和 #0 main 1 1777862705 #1 func1 100 303596276堆栈帧是函数调用的序列
func2() 函数由 main() 函数调用,这是累积时间最长的堆栈帧
深度取决于调用了多少个函数,堆栈帧通常很大
这个子命令也可以用来从堆栈帧创建火焰图
使用火焰图,你可以很容易地识别哪些函数有大量的累积运行时间
输出是带有计数和运行时间信息的堆栈帧
使用 flamegraph.pl 脚本,你可以将数据转换为可缩放矢量图形(SVG)文件,你可以在浏览器中查看
使用以下命令,你指示 llvm-xray 输出所有堆栈帧与 –all-stacks 选项
使用 –stack-format=flame 选项,输出格式符合 flamegraph.pl 脚本的预期
此外,使用 –aggregation-type 选项,你可以选择按总时间或按调用次数聚合堆栈帧
llvm-xray 的输出被管道传输到 flamegraph.pl 脚本,并将结果输出保存在 flame.svg 文件中:$ llvm-xray stack xray-log.xraydemo.xVsWiE --all-stacks\ --stack-format=flame --aggregation-type=time\ --instr_map ./xraydemo | flamegraph.pl >flame.svg运行命令并生成新的火焰图后,你可以在浏览器中打开生成的 flame.svg 文件
图形如下所示:图 10.1 - llvm-xray 生成的火焰图火焰图乍一看可能会让人感到困惑,因为 X 轴没有经过时间的通常意义
相反,函数按名称字母顺序简单排序
此外,火焰图的 Y 轴显示堆栈深度,从零开始计数
颜色被选择为具有良好的对比度,没有其他含义
从前面的图中,你可以轻松地确定函数的调用层次结构和在函数中花费的时间
将鼠标光标移动到表示框架的矩形上,只会显示有关堆栈框架的信息
通过单击框架,你可以放大此堆栈框架
火焰图对于帮助你识别值得优化的函数非常有用
要了解有关火焰图的更多信息,请访问火焰图的发明者 Brendan Gregg 的网站:http://www.brendangregg.com/flamegraphs.html
此外,你可以使用 convert 子命令将数据转换为 .yaml 格式或 Chrome Trace Viewer Visualization 使用的格式
后者是另一种很好的方式,可以从数据中创建图形
要将数据保存在 xray.evt 文件中,你可以运行以下命令:$ llvm-xray convert --output-format=trace_event\ --output=xray.evt --symbolize --sort\ --instr_map=./xraydemo xray-log.xraydemo.xVsWiE如果你没有指定 –symbolize 选项,那么结果图形中将不显示函数名称
一旦你这样做了,在 Chrome 中打开并输入 chrome:///tracing
接下来,点击加载按钮加载 xray.evt 文件
你将看到以下数据的可视化:图 10.2 - llvm-xray 生成的 Chrome Trace Viewer 可视化在这个视图中,堆栈框架按函数调用发生的时间排序
有关可视化的进一步解释,请阅读 https://www.chromium.org/developers/how-tos/trace-event-profiling-tool 上的教程
提示llvm-xray 工具具有更多适用于性能分析的功能
你可以在 LLVM 网站上的 https://llvm.org/docs/XRay.html 和 https://llvm.org/docs/XRayExample.html 上阅读有关它的更多信息
在本节中,我们学习了如何使用 XRay 对应用程序进行检测,如何收集运行时信息,以及如何可视化这些数据
我们可以使用这些知识来识别应用程序中的性能瓶颈
识别应用程序错误的另一种方法是分析源代码,这是使用 Clang 静态分析器完成的
使用 Clang 静态分析器检查源代码 Clang 静态分析器是一个工具,它对 C、C++ 和 Objective-C 源代码执行额外的检查
静态分析器执行的检查比编译器执行的检查更彻底
它们在时间和所需资源方面也更昂贵
静态分析器有一组检查器,用于检查某些错误
该工具执行源代码的符号解释,它查看应用程序的所有代码路径,并从中推导出应用程序中使用的值的约束
符号解释是编译器中常用的一种技术,例如,用于识别常量值
在静态分析器的上下文中,检查器应用于推导出的值
例如,如果除法的除数为零,静态分析器会警告我们
我们可以通过存储在 div.c 文件中的以下示例来检查这一点:int divbyzero(int a, int b) { return a / b; }int bug() { return divbyzero(5, 0); }静态分析器将警告此示例中的除以零错误
然而,当使用命令 clang -Wall -c div.c 编译文件时,将不显示任何警告
有两种方式可以从命令行调用静态分析器
较旧的工具是 scan-build,它包含在 LLVM 中,并且可以用于简单场景
较新的工具是 CodeChecker,可在 https://github.com/Ericsson/codechecker/ 上获得
要检查单个文件,scan-build 工具是最简单的解决方案
你只需将编译命令传递给工具;其他所有事情都会自动完成:$ scan-build clang -c div.cscan-build: 使用 '/usr/home/kai/LLVM/llvm-17/bin/clang-17' 进行静态分析 div.c:2:12: 警告:零除错误 [core.DivideZero] return a / b; ~~^~~~ 1 个警告已生成
scan-build: 分析运行完成
scan-build: 发现 1 个错误
scan-build: 运行 'scan-view /tmp/scan-build-2021-03-01-023401-8721-1' 以检查错误报告
屏幕上的输出已经告诉你发现了一个错误 - 也就是说,触发了 core.DivideZero 检查器
然而,这还不是全部
在 /tmp 目录的指定子目录中,你将找到一个完整的 HTML 报告
然后,你可以使用 scan-view 命令查看报告,或者在子目录中找到的 index.html 文件在你的浏览器中打开
报告的第一页向你展示了发现的错误的摘要:图 10.3 - 摘要页面对于发现的每个错误,摘要页面显示错误类型、源代码中的位置以及分析器发现错误后的路径长度
还提供了指向错误详细报告的链接
以下截图显示了错误的详细报告:图 10.4 - 详细报告有了这个详细报告,你可以通过跟随编号的气泡来验证错误
我们的简单示例展示了如何将 0 作为参数值传递导致除以零错误
因此,需要人工验证
如果某个检查器的派生约束不够精确,则可能会出现误报 - 也就是说,对于完全正常的代码报告了一个错误
根据报告,你可以使用它们来识别误报
你不限于工具提供的检查器 - 你还可以添加新的检查器
下一节展示了如何做到这一点
向 Clang 静态分析器添加新检查器 许多 C 库提供必须成对使用的函数
例如,C 标准库提供了 malloc() 和 free() 函数
由 malloc() 函数分配的内存必须由 free() 函数精确地释放一次
不调用 free() 函数或调用多次是编程错误
还有更多这种编码模式的实例,静态分析器为其中一些提供了检查器
iconv 库提供将文本从一种编码转换为另一种编码的函数 - 例如,从 Latin-1 编码转换为 UTF-16 编码
执行转换时,实现需要分配内存
为了透明地管理内部资源,iconv 库提供了 iconv_open() 和 iconv_close() 函数,它们必须成对使用,类似于内存管理函数
这些函数没有实现检查器,所以让我们实现一个
要向 Clang 静态分析器添加新检查器,你必须创建一个 Checker 类的新子类
静态分析器尝试所有可能的代码路径
分析器引擎在某些点生成事件 - 例如,在函数调用之前或之后
此外,你的类必须为这些事件提供回调,如果你需要处理它们
Checker 类和事件的注册在 clang/include/clang/StaticAnalyzer/Core/Checker.h 头文件中提供
通常,检查器需要跟踪一些符号
然而,检查器不能管理状态,因为它不知道分析器引擎当前正在尝试哪个代码路径
因此,跟踪的状态必须使用 ProgramStateRef 实例向引擎注册
为了检测错误,检查器需要跟踪从 iconv_open() 函数返回的描述符
分析器引擎为 iconv_open() 函数的返回值返回一个 SymbolRef 实例
我们将此符号与状态关联,以反映 iconv_close() 是否被调用
对于状态,我们创建了 IconvState 类,它封装了一个布尔值
新的 IconvChecker 类需要处理四种类型的事件:PostCall,在函数调用后发生
在调用 iconv_open() 函数后,我们检索了返回值的符号,并将其记住为处于“打开”状态
PreCall,在函数调用前发生
在 iconv_close() 函数被调用之前,我们检查描述符的符号是否处于“打开”状态
如果没有,那么 iconv_close() 函数已经对该描述符调用过了,我们已经检测到了对该函数的重复调用
DeadSymbols,当未使用的符号被清理时发生
我们检查一个未使用的描述符符号是否仍然处于“打开”状态
如果是,那么我们就检测到了对 iconv_close() 的缺失调用,这是一个资源泄漏
PointerEscape,当符号不再能被分析器跟踪时调用
在这种情况下,我们从状态中删除符号,因为我们不能再推断描述符是否已关闭
我们可以创建一个新目录来实现新检查器作为一个 Clang 插件,并在 IconvChecker.cpp 文件中添加实现:为了实现,我们需要包括几个头文件
BugType.h 头文件需要用于发出报告
Checker.h 头文件提供了 Checker 类的声明和事件的回调,这些回调在 CallEvent 文件中声明
此外,CallDescription.h 文件有助于匹配函数和方法
最后,CheckerContext.h 文件需要用于声明 CheckerContext 类,这是提供对分析器状态访问的中心类:#include "clang/StaticAnalyzer/Core/BugReporter/BugType.h"#include "clang/StaticAnalyzer/Core/Checker.h"#include "clang/StaticAnalyzer/Core/PathSensitive/CallDescription.h"#include "clang/StaticAnalyzer/Core/PathSensitive/CallEvent.h"#include "clang/StaticAnalyzer/Core/PathSensitive/CheckerContext.h"#include "clang/StaticAnalyzer/Frontend/CheckerRegistry.h"#include <optional>为了避免输入命名空间名称,我们可以使用 clang 和 ento 命名空间:using namespace clang;using namespace ento;我们为每个表示 iconv 描述符的符号关联一个状态
状态可以是打开或关闭的,我们使用一个布尔类型变量,对于打开状态,其值为 true
状态值被封装在 IconvState 结构体中
这个结构体使用一个 FoldingSet 数据结构,这是一个哈希集合,可以过滤重复条目
为了能够使用这个数据结构的实现,这里添加了 Profile() 方法,它设置了这个结构体的唯一位
我们把结构体放在一个匿名命名空间中,以避免污染全局命名空间
类不直接暴露布尔值,而是提供了 getOpened() 和 getClosed() 工厂方法以及 isOpen() 查询方法:namespace {class IconvState { const bool IsOpen; IconvState(bool IsOpen) : IsOpen(IsOpen) {}public: bool isOpen() const { return IsOpen; } static IconvState getOpened() { return IconvState(true); } static IconvState getClosed() { return IconvState(false); } bool operator==(const IconvState &O) const { return IsOpen == O.IsOpen; } void Profile(llvm::FoldingSetNodeID &ID) const { ID.AddInteger(IsOpen); }};} // namespaceIconvState 结构体表示一个 iconv 描述符的状态,该状态由 SymbolRef 类的符号表示
这最好通过一个映射来完成,映射的键是符号,值是状态
正如前面解释的,检查器不能持有状态
相反,状态必须使用 REGISTER_MAP_WITH_PROGRAMSTATE 宏与全局程序状态注册
这个宏引入了 IconvStateMap 名称,我们将在后面用来访问映射:REGISTER_MAP_WITH_PROGRAMSTATE(IconvStateMap, SymbolRef, IconvState)我们还在一个匿名命名空间中实现了 IconvChecker 类
请求的 PostCall、PreCall、DeadSymbols 和 PointerEscape 事件是 Checker 基类的模板参数:namespace {class IconvChecker : public Checker<check::PostCall, check::PreCall, check::DeadSymbols, check::PointerEscape> { // ...};} // namespaceIconvChecker 类有 CallDescription 类型的字段,用于识别程序中对 iconv_open()、iconv() 和 iconv_close() 的函数调用:CallDescription IconvOpenFn, IconvFn, IconvCloseFn;类还持有检测到的错误类型的引用:std::unique_ptr<BugType> DoubleCloseBugType;std::unique_ptr<BugType> LeakBugType;最后,类有几个方法
除了构造函数和用于调用事件的方法,我们还需要一个方法来发出错误报告:void report(ArrayRef<SymbolRef> Syms, const BugType &Bug, StringRef Desc, CheckerContext &C, ExplodedNode ErrNode, std::optional<SourceRange> Range = std::nullopt) const;public:IconvChecker();void checkPostCall(const CallEvent &Call, CheckerContext &C) const;void checkPreCall(const CallEvent &Call, CheckerContext &C) const;void checkDeadSymbols(SymbolReaper &SymReaper, CheckerContext &C) const;ProgramStateRefcheckPointerEscape(ProgramStateRef State, const InvalidatedSymbols &Escaped, const CallEvent Call, PointerEscapeKind Kind) const;我们可以开始实现 IconvChecker 类的构造函数,使用函数名称初始化 CallDescription 字段,并创建表示错误的 BugType 对象:IconvChecker::IconvChecker() : IconvOpenFn({"iconv_open"}), IconvFn({"iconv"}), IconvCloseFn({"iconv_close"}, 1) { DoubleCloseBugType.reset(new BugType( this, "Double iconv_close", "Iconv API Error")); LeakBugType.reset(new BugType( this, "Resource Leak", "Iconv API Error", /SuppressOnSink=/true));}现在,我们可以实施第一个调用事件方法 checkPostCall()
此方法在分析器执行函数调用后被调用
如果执行的函数不是全局 C 函数且名称不是 iconv_open,则无需进行任何操作:void IconvChecker::checkPostCall( const CallEvent &Call, CheckerContext &C) const { if (!Call.isGlobalCFunction()) return; if (!IconvOpenFn.matches(Call)) return;否则,我们可以尝试将函数的返回值获取为一个符号
为了将符号与打开状态存储在全局程序状态中,我们需要从 CheckerContext 实例中获取一个 ProgramStateRef 实例
状态是不可变的,所以向状态中添加符号会得到一个新的状态
最后,通过调用 addTransition() 方法通知分析器引擎新的状态:if (SymbolRef Handle = Call.getReturnValue().getAsSymbol()) { ProgramStateRef State = C.getState(); State = State->set<IconvStateMap>( Handle, IconvState::getOpened()); C.addTransition(State);}同样,checkPreCall() 方法在分析器执行函数之前被调用
我们只对名为 iconv_close 的全局 C 函数感兴趣:void IconvChecker::checkPreCall( const CallEvent &Call, CheckerContext &C) const { if (!Call.isGlobalCFunction()) { return; } if (!IconvCloseFn.matches(Call)) { return; }如果已知函数的第一个参数的符号(即 iconv 描述符),那么我们可以程序状态中检索符号的状态:if (SymbolRef Handle = Call.getArgSVal(0).getAsSymbol()) { ProgramStateRef State = C.getState(); if (const IconvState St = State->get<IconvStateMap>(Handle)) {如果状态表示已关闭状态,那么我们已经检测到一个双重关闭错误,可以为其生成一个错误报告
调用 generateErrorNode() 可能会返回一个空值,如果已经为这个路径生成了错误报告,所以我们需要检查这种情况:if (!St->isOpen()) { if (ExplodedNode N = C.generateErrorNode()) { report(Handle, DoubleCloseBugType, "Closing a previous closed iconv " "descriptor", C, N, Call.getSourceRange()); } return;}否则,我们必须为符号设置状态为“已关闭”状态:State = State->set<IconvStateMap>( Handle, IconvState::getClosed());C.addTransition(State);checkDeadSymbols() 方法被调用以清理未使用的符号
我们循环遍历我们跟踪的所有符号,并询问 SymbolReaper 实例当前符号是否已死亡:void IconvChecker::checkDeadSymbols( SymbolReaper &SymReaper, CheckerContext &C) const { ProgramStateRef State = C.getState(); SmallVector<SymbolRef, 8> LeakedSyms; for (auto [Sym, St] : State->get<IconvStateMap>()) { if (SymReaper.isDead(Sym)) {如果符号已死亡,那么我们需要检查状态
如果状态仍然是打开的,那么这是一个潜在的资源泄漏
有一个例外:iconv_open() 在出错时返回 -1
如果分析器处于处理此错误的代码路径中,那么假设资源泄漏是错误的,因为函数调用失败了
我们尝试从 ConstraintManager 实例获取符号的值,如果这个值是 -1,我们不考虑符号作为资源泄漏
我们向 SmallVector 实例添加一个泄漏符号,稍后生成错误报告
最后,我们从程序状态中删除死亡符号:if (St.isOpen()) { bool IsLeaked = true; if (const llvm::APSInt Val = State->getConstraintManager().getSymVal( State, Sym)) IsLeaked = Val->getExtValue() != -1; if (IsLeaked) LeakedSyms.push_back(Sym);}State = State->remove<IconvStateMap>(Sym); }}循环结束后,我们调用 generateNonFatalErrorNode() 方法
这个方法转换到新的程序状态,并在此路径上还没有错误节点时返回错误节点
LeakedSyms 容器保存了(可能是空的)泄漏符号列表,我们调用 report() 方法来生成错误报告:if (ExplodedNode N = C.generateNonFatalErrorNode(State)) { report(LeakedSyms, LeakBugType, "Opened iconv descriptor not closed", C, N);}checkPointerEscape() 函数在分析器检测到一个函数调用,其参数不能被跟踪时被调用
在这种情况下,我们必须假设我们不知道 iconv 描述符是否会在函数内部关闭
例外情况是对 iconv() 的调用,它执行转换,已知不会调用 iconv_close() 函数,以及我们在 checkPreCall() 方法中处理的 iconv_close() 函数本身
如果调用在系统头文件中,并且我们知道调用的函数中参数不会逃逸,我们也不会改变状态
在所有其他情况下,我们从状态中删除符号:ProgramStateRef IconvChecker::checkPointerEscape( ProgramStateRef State, const InvalidatedSymbols &Escaped, const CallEvent Call, PointerEscapeKind Kind) const { if (Kind == PSK_DirectEscapeOnCall) { if (IconvFn.matches(Call) || IconvCloseFn.matches(Call)) return State; if (Call->isInSystemHeader() || !Call->argumentsMayEscape()) return State; } for (SymbolRef Sym : Escaped) State = State->remove<IconvStateMap>(Sym); return State;}report() 方法生成一个错误报告
该方法的重要参数是符号数组、错误类型和错误描述
在方法内部,为每个符号创建一个错误报告,并将符号标记为该错误的相关项
如果提供了源范围作为参数,也会将其添加到报告中
最后,发出报告:void IconvChecker::report( ArrayRef<SymbolRef> Syms, const BugType &Bug, StringRef Desc, CheckerContext &C, ExplodedNode ErrNode, std::optional<SourceRange> Range) const { for (SymbolRef Sym : Syms) { auto R = std::make_unique<PathSensitiveBugReport>( Bug, Desc, ErrNode); R->markInteresting(Sym); if (Range) R->addRange(Range); C.emitReport(std::move(R)); }}现在,需要在 CheckerRegistry 实例中注册新检查器
当插件加载时,使用 clang_registerCheckers() 函数执行注册
每个检查器都有一个名称并属于一个包
我们称 IconvChecker 检查器,将其放入 unix 包中,因为 iconv 库是一个标准的 POSIX 接口
这是 addChecker() 方法的第一个参数
第二个参数是功能的简要文档,第三个参数可以是提供有关检查器的更多信息的文档的 URI:extern "C" voidclang_registerCheckers(CheckerRegistry ®istry) { registry.addChecker<IconvChecker>( "unix.IconvChecker", "Check handling of iconv functions", "");}最后,需要声明我们使用的静态分析器 API 的版本,这允许系统确定插件是否兼容:extern "C" const char clang_analyzerAPIVersionString[] = CLANG_ANALYZER_API_VERSION_STRING;这完成了新检查器的实现
要构建插件,我们还需要在与 IconvChecker.cpp 相同的目录中创建 CMakeLists.txt 文件的构建描述:首先定义所需的 CMake 版本和项目名称:cmake_minimum_required(VERSION 3.20.0)project(iconvchecker)接下来,包含 LLVM 文件
如果 CMake 无法自动找到文件,则需要设置 LLVM_DIR 变量,使其指向包含 CMake 文件的 LLVM 目录:find_package(LLVM REQUIRED CONFIG)将包含 CMake 文件的 LLVM 目录添加到搜索路径,并包含 LLVM 的所需模块:list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})include(AddLLVM)include(HandleLLVMOptions)然后,加载 Clang 的 CMake 定义
如果 CMake 无法自动找到文件,则需要设置 Clang_DIR 变量,使其指向包含 CMake 文件的 Clang 目录:find_package(Clang REQUIRED)接下来,定义头文件和库文件的位置,并确定要使用的定义:include_directories("${LLVM_INCLUDE_DIR}" "${CLANG_INCLUDE_DIRS}")add_definitions("${LLVM_DEFINITIONS}")link_directories("${LLVM_LIBRARY_DIR}")前面的定义设置了构建环境
插入以下命令,它定义了你的插件的名称、插件的源文件,并指出它是一个 Clang 插件:add_llvm_library(IconvChecker MODULE IconvChecker.cpp PLUGIN_TOOL clang)在 Windows 上,插件支持与 Unix 不同,必须链接所需的 LLVM 和 Clang 库
以下代码确保了这一点:if(WIN32 OR CYGWIN) set(LLVM_LINK_COMPONENTS Support) clang_target_link_libraries(IconvChecker PRIVATE clangAnalysis clangAST clangStaticAnalyzerCore clangStaticAnalyzerFrontend)endif()现在,我们可以配置并构建插件,假设 CMAKE_GENERATOR 和 CMAKE_BUILD_TYPE 环境变量已设置:$ cmake -DLLVM_DIR=~/LLVM/llvm-17/lib/cmake/llvm \ -DClang_DIR=~/LLVM/llvm-17/lib/cmake/clang \ -B build$ cmake --build build这些步骤在构建目录中创建了 IconvChecker.so 共享库
要测试新检查器,请将以下源代码保存在 conv.c 文件中,该文件对 iconv_close() 函数进行了两次调用:#include <iconv.h>void doconv() { iconv_t id = iconv_open("Latin1", "UTF-16"); iconv_close(id); iconv_close(id);}要使用插件与 scan-build 脚本,你需要通过 -load-plugin 选项指定插件的路径
使用 conv.c 文件运行看起来像这样:$ scan-build -load-plugin build/IconvChecker.so clang-17 \ -c conv.cscan-build: 使用 '/home/kai/LLVM/llvm-17/bin/clang-17' 进行静态分析 conv.c:6:3: 警告: 关闭之前已关闭的 iconv 描述符 [unix.IconvChecker] 6 | iconv_close(id); | ^~~~~~~~~~~~~~~~ 1 个警告已生成
scan-build: 分析运行完成
scan-build: 发现 1 个错误
scan-build: 运行 'scan-view /tmp/scan-build-2023-08-08-114154-12451-1' 以检查错误报告
有了这个,你已经学会了如何使用自己的检查器扩展 Clang 静态分析器
你可以利用这些知识创建新的通用检查器并为社区做出贡献,或者专门为你的需求创建检查器,以提高你的产品质量
静态分析器是利用 Clang 基础设施构建的
下一节将向你介绍如何构建你自己的 Clang 插件
创建你自己的基于 Clang 的工具 静态分析器是一个令人印象深刻的例子,展示了你可以用 Clang 基础设施做什么
你也可以通过插件扩展 Clang,以便你可以向 Clang 添加你自己的功能
这种技术与向 LLVM 添加传递插件非常相似
让我们通过一个简单的插件探索这个功能
LLVM 编码标准要求函数名称以小写字母开始
然而,编码标准已经发展,有许多函数名称以大写字母开始的情况
一个警告命名规则违规的插件可以帮助解决这个问题,让我们试试
因为你想在 AST 上运行用户定义的操作,你需要定义一个 PluginASTAction 类的子类
如果你使用 Clang 库编写自己的工具,那么你可以为操作定义 ASTFrontendAction 类的子类
PluginASTAction 类是 ASTFrontendAction 类的子类,具有解析命令行选项的额外能力
你还需要一个 ASTConsumer 类的子类
AST 消费者是一个类,通过它你可以在 AST 上运行一个操作,无论 AST 的来源如何
对于我们的第一个插件,不需要更多的东西
你可以在 NamingPlugin.cpp 文件中按如下方式创建实现:首先包括所需的头文件
除了提到的 ASTConsumer 类,你还需要一个编译器实例和插件注册表:#include "clang/AST/ASTConsumer.h"#include "clang/Frontend/CompilerInstance.h"#include "clang/Frontend/FrontendPluginRegistry.h"使用 clang 命名空间,并将你的实现放入匿名命名空间以避免名称冲突:using namespace clang;namespace {接下来,定义你的 ASTConsumer 类的子类
稍后,你将希望在检测到命名规则违规时发出警告
为此,你需要一个 DiagnosticsEngine 实例的引用
你需要在类中存储一个 CompilerInstance 实例,之后你可以请求一个 DiagnosticsEngine 实例:class NamingASTConsumer : public ASTConsumer { CompilerInstance &CI;public: NamingASTConsumer(CompilerInstance &CI) : CI(CI) {}ASTConsumer 实例有几个入口方法
HandleTopLevelDecl() 方法适合我们的目的
这个方法对于每个顶层声明都会被调用
这不仅仅是函数 - 例如,变量
所以,你必须使用 LLVM RTTI dyn_cast<>() 函数来确定声明是否是函数声明
HandleTopLevelDecl() 方法有一个声明组作为参数,其中可以包含多个声明
这需要一个循环来遍历声明
以下代码显示了 HandleTopLevelDecl() 方法:bool HandleTopLevelDecl(DeclGroupRef DG) override { for (DeclGroupRef::iterator I = DG.begin(), E = DG.end(); I != E; ++I) { const Decl D = I; if (const FunctionDecl FD = dyn_cast<FunctionDecl(D)) {在找到函数声明后,你需要检索函数的名称
你还需要确保名称不为空: std::string Name = FD->getNameInfo().getName().getAsString(); assert(Name.length() > 0 && "Unexpected empty identifier");如果函数名称不以小写字母开头,那么你发现了命名规则的违规: char &First = Name.at(0); if (!(First >= 'a' && First <= 'z')) {要发出警告,你需要一个 DiagnosticsEngine 实例
此外,你需要一个消息 ID
在 clang 内部,消息 ID 被定义为一个枚举
因为你的插件不是 clang 的一部分,你需要创建一个自定义 ID,然后使用它来发出警告: DiagnosticsEngine &Diag = CI.getDiagnostics(); unsigned ID = Diag.getCustomDiagID( DiagnosticsEngine::Warning, "Function name should start with " "lowercase letter"); Diag.Report(FD->getLocation(), ID);除了关闭所有打开的大括号,你需要从这个函数返回 true 以表示可以继续处理: } } } return true; }};接下来,你需要创建 PluginASTAction 子类,它实现了 clang 调用的接口:class PluginNamingAction : public PluginASTAction {public:你必须实现的第一个方法是 CreateASTConsumer(),它返回你的 NamingASTConsumer 类的实例
这个方法由 clang 调用,传递的 CompilerInstance 实例让你可以访问编译器的所有重要类
以下代码演示了这一点: std::unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI, StringRef file) override { return std::make_unique<NamingASTConsumer>(CI); }插件还可以访问命令行选项
你的插件没有命令行参数,你只会返回 true 以表示成功: bool ParseArgs(const CompilerInstance &CI, const std::vector<std::string> &args) override { return true; }插件的操作类型描述了何时调用操作
默认值是 Cmdline,这意味着必须在命令行上命名插件才能调用它
你需要重写方法并更改值为 AddAfterMainAction,这将自动运行操作: PluginASTAction::ActionType getActionType() override { return AddAfterMainAction; }你的 PluginNamingAction 类的实现完成了;缺少的只是类和匿名命名空间的关闭大括号
按如下方式将它们添加到代码中:};}最后,需要注册插件
第一个参数是插件的名称,而第二个参数是帮助文本:static FrontendPluginRegistry::Add<PluginNamingAction> X("naming-plugin", "naming plugin");这完成了插件的实现
要编译插件,请在与 IconvChecker.cpp 相同的目录中创建 CMakeLists.txt 文件的构建描述
插件位于 Clang 源代码树之外,因此你需要设置一个完整的项目
你可以通过按照以下步骤来完成:首先定义所需的 CMake 版本和项目名称:cmake_minimum_required(VERSION 3.20.0)project(naminglugin)接下来,包含 LLVM 文件
如果 CMake 无法自动找到文件,则需要设置 LLVM_DIR 变量,使其指向包含 CMake 文件的 LLVM 目录:find_package(LLVM REQUIRED CONFIG)将包含 CMake 文件的 LLVM 目录添加到搜索路径,并包含一些所需的模块:list(APPEND CMAKE_MODULE_PATH ${LLVM_DIR})include(AddLLVM)include(HandleLLVMOptions)然后,加载 Clang 的 CMake 定义
如果 CMake 无法自动找到文件,则需要设置 Clang_DIR 变量,使其指向包含 CMake 文件的 Clang 目录:find_package(Clang REQUIRED)接下来,定义头文件和库文件的位置,并确定要使用的定义:include_directories("${LLVM_INCLUDE_DIR}" "${CLANG_INCLUDE_DIRS}")add_definitions("${LLVM_DEFINITIONS}")link_directories("${LLVM_LIBRARY_DIR}")前面的定义设置了构建环境
插入以下命令,它定义了你的插件的名称、插件的源文件,并指出它是一个 Clang 插件:add_llvm_library(NamingPlugin MODULE NamingPlugin.cpp PLUGIN_TOOL clang)在 Windows 上,插件支持与 Unix 不同,必须链接所需的 LLVM 和 Clang 库
以下代码确保了这一点:if(WIN32 OR CYGWIN) set(LLVM_LINK_COMPONENTS Support) clang_target_link_libraries(NamingPlugin PRIVATE clangAST clangBasic clangFrontend clangLex)endif()现在,我们可以配置并构建插件,假设 CMAKE_GENERATOR 和 CMAKE_BUILD_TYPE 环境变量已设置:$ cmake -DLLVM_DIR=~/LLVM/llvm-17/lib/cmake/llvm \ -DClang_DIR=~/LLVM/llvm-17/lib/cmake/clang \ -B build$ cmake --build build这些步骤在构建目录中创建了 NamingPlugin.so 共享库
要测试插件,请将以下源代码保存为 naming.c 文件
函数名称 Func1 违反了命名规则,但 main 名称没有:int Func1() { return 0; }int main() { return Func1(); }要调用插件,你需要指定 –fplugin= 选项:$ clang -fplugin=build/NamingPlugin.so naming.cnaming.c:1:5: warning: Function name should start with lowercase letterint Func1() { return 0; } ^1 warning generated.这种调用方式需要你重写 PluginASTAction 类的 getActionType() 方法,并返回一个不同于默认 Cmdline 值的值
如果你没有这样做 - 例如,因为你想要对插件操作的调用有更多的控制 - 那么你可以按如下方式从编译器命令行运行插件:$ clang -cc1 -load ./NamingPlugin.so -plugin naming-plugin\ naming.c恭喜 - 你已经构建了你的第一个 Clang 插件
这种方法的缺点是它有一些限制
ASTConsumer 类有不同的入口方法,但它们都是粗粒度的
这可以通过使用 RecursiveASTVisitor 类来解决
这个类遍历所有 AST 节点,你可以覆盖你感兴趣的 VisitXXX() 方法
你可以通过按照以下步骤将插件重写为使用访问者:你需要一个额外的 include,用于 RecursiveASTVisitor 类的定义
按如下方式插入:#include "clang/AST/RecursiveASTVisitor.h"然后,在匿名命名空间中定义访问者作为第一个类
你将只存储对 AST 上下文的引用,这将让你访问所有重要的 AST 操作方法,包括 DiagnosticsEngine 实例,这是发出警告所需的:class NamingVisitor : public RecursiveASTVisitor<NamingVisitor> {private: ASTContext &ASTCtx;public: explicit NamingVisitor(CompilerInstance &CI) : ASTCtx(CI.getASTContext()) {}在遍历过程中,每当发现函数声明时,都会调用 VisitFunctionDecl() 方法
将 HandleTopLevelDecl() 函数内部的内层循环的主体复制到这里: virtual bool VisitFunctionDecl(FunctionDecl FD) { std::string Name = FD->getNameInfo().getName().getAsString(); assert(Name.length() > 0 && "Unexpected empty identifier"); char &First = Name.at(0); if (!(First >= 'a' && First <= 'z')) { DiagnosticsEngine &Diag = ASTCtx.getDiagnostics(); unsigned ID = Diag.getCustomDiagID( DiagnosticsEngine::Warning, "Function name should start with " "lowercase letter"); Diag.Report(FD->getLocation(), ID); } return true; }};这完成了访问者的实现
在你的 NamingASTConsumer 类中,你现在只需要存储一个访问者实例: std::unique_ptr<NamingVisitor> Visitor;public: NamingASTConsumer(CompilerInstance &CI) : Visitor(std::make_unique<NamingVisitor>(CI)) {}移除 HandleTopLevelDecl() 方法 - 功能现在在访问者类中,所以你需要重写 HandleTranslationUnit() 方法
这个类针对每个翻译单元调用一次
你将在这里开始 AST 遍历: void HandleTranslationUnit(ASTContext &ASTCtx) override { Visitor->TraverseDecl( ASTCtx.getTranslationUnitDecl()); }这个新实现具有相同的功能
优点是它更容易扩展
例如,如果你想检查变量声明,那么你必须实现 VisitVarDecl() 方法
或者,如果你想处理语句,那么你必须实现 VisitStmt() 方法
通过这种方法,你有了一个访问者方法,用于 C、C++ 和 Objective-C 语言的每个实体
拥有对 AST 的访问权限,你可以构建执行复杂任务的插件
正如本节所描述的,强制执行命名约定是 Clang 的一个有用补充
作为插件,你也可以实现计算软件度量,例如圈复杂度
你也可以添加或替换 AST 节点,允许你,例如,添加运行时插桩
添加插件允许你以你需要的方式扩展 Clang
总结 在本章中,你学习了如何应用各种 sanitizers
你使用地址 sanitizer 检测指针错误,使用内存 sanitizer 检测未初始化的内存访问,并使用线程 sanitizer 执行数据竞争
应用程序错误通常由格式错误的输入触发,你实现了模糊测试,以随机数据测试你的应用程序
你还使用 XRay 对应用程序进行了检测,以识别性能瓶颈,并学习了如何以各种方式可视化数据
本章还教你如何利用 Clang 静态分析器通过解释源代码来识别潜在错误,以及如何创建你自己的 Clang 插件
这些技能将帮助你提高所构建应用程序的质量,因为在应用程序用户抱怨之前发现运行时错误肯定是好的
应用你在本章中学到的知识,你不仅可以找到广泛的常见错误,还可以通过新功能扩展 Clang
在下一章中,你将学习如何向 LLVM 添加新的后端
工具教程LLVM(函数你可以分析器错误文件)
(图片来源网络,侵删)

联系我们

在线咨询:点击这里给我发消息