Clang-编译器前端-全-
Clang 编译器前端(全)
原文:
zh.annas-archive.org/md5/ad69180d589cadf3f212c0b7308e9ae0
译者:飞龙
前言
低级虚拟机(LLVM)是一组模块化和可重用的编译器和工具链技术,用于开发编译器和编译工具,如代码检查器和重构工具。LLVM 用 C++ 编写,可以被认为是使用有趣技术使其可重用和高效的优秀项目示例。该项目也可以被认为是编译器架构的优秀示例;深入了解它将让你了解编译器是如何组织和工作的。这应该有助于理解使用模式并相应地应用它们。
LLVM 的一个关键组件是名为 Clang 的 C/C++ 编译器。这个编译器在各种公司中被广泛使用,并被指定为某些开发环境的默认编译器,特别是在 macOS 开发中。Clang 将是我们这本书的主要研究对象,特别关注其前端——即最接近 C/C++ 编程语言的部分。具体来说,本书将包括一些示例,展示 C++ 标准如何在编译器中实现。
LLVM 设计的一个关键方面是其模块化,这有助于创建利用编译器全面功能的自定义工具。本书中涵盖的一个显著例子是 Clang-Tidy 代码检查框架,旨在识别不希望的代码模式并推荐修正。尽管它包括数百个检查,但你可能找不到一个专门针对你项目需求的检查。然而,本书将为你提供从零开始开发此类检查所需的基础。
LLVM 是一个每年有两个主要版本发布的活跃项目。在本书编写时,最新的稳定版本是 17 版。同时,18 版本的候选发布版在 2024 年 1 月推出,预计其正式发布将与本书的出版同步。本书的内容已与最新的编译器版本 18 进行了验证,确保它基于最先进的编译器实现提供见解。
本书面向的对象
本书是为那些没有编译器知识但希望获得这种知识并将其应用于日常活动的 C++ 工程师编写的。它提供了 Clang 编译器前端概述,这是 LLVM 的一个基本但常被低估的部分。这个编译器部分,连同一系列强大的工具,使程序员能够提高代码质量和整体开发过程。例如,Clang-Tidy 提供了超过 500 种不同的 lint 检查,用于检测代码中的反模式(如移动后的使用)并帮助维护代码风格和标准。另一个值得注意的工具是 Clang-Format,它允许指定适合您项目的各种格式化规则。这些工具也可以被认为是开发过程的一个组成部分。例如,语言服务器(Clangd)是一个关键服务,为您的 IDE 提供导航和重构支持。
理解编译器内部结构可能对任何想要创建和使用此类工具的人来说至关重要。本书提供了开始这段旅程所需的必要基础,涵盖了基本的 LLVM 架构,并详细描述了 Clang 的内部结构。它包括来自 LLVM 源代码和扩展编译器基本功能的自定义工具的示例。此外,本书还讨论了编译数据库和可以增强项目构建速度的各种性能优化。这些知识应有助于 C++ 开发者正确地将编译器应用于他们的工作活动中。
本书涵盖的内容
第一章**,环境设置,描述了为未来使用 Clang 进行实验所需的基本步骤,适用于基于 Unix 的系统,如 Linux 和 Darwin(macOS)。此外,读者还将学习如何下载、配置和构建 LLVM 源代码。我们还将创建一个简单的 Clang 工具来验证提供的源代码的语法。
第二章**,Clang 架构,探讨了 Clang 编译器的内部架构。从编译器的基本概念开始,我们将探讨它在 Clang 中的实现方式。我们将查看编译器的各个部分,包括驱动程序、预处理器(词法分析器)和解析器。我们还将检查示例,展示 C++ 标准如何在 Clang 中实现。
第三章**,Clang 抽象语法树,讨论了 Clang 抽象语法树 (AST),这是解析器产生的基
第四章**,基本库和工具,探讨了基本 LLVM 库和工具,包括跨所有 LLVM 代码使用的 LLVM 抽象数据类型 (ADT) 库。我们将研究 TableGen,这是一种用于在 LLVM 的各个部分生成 C++ 代码的 领域特定语言 (DSL)。此外,我们还将探索 LLVM 集成测试器 (LIT) 工具,它用于创建强大的端到端测试。利用所获得的知识,我们将创建一个简单的 Clang 插件来估计源代码的复杂性。
第五章**,Clang-Tidy 检查框架,涵盖了基于 Clang AST 的 Clang-Tidy 检查框架,并创建了一个简单的 Clang-Tidy 检查。我们还将讨论编译错误如何影响 AST 以及不同 Clang 工具(如 Clang-Tidy)提供的结果。
第六章**,高级代码分析,进一步考虑了另一种用于代码分析的高级数据结构:控制流图 (CFG)。我们将研究其应用的典型情况,并创建一个简单的 Clang-Tidy 检查,利用这种数据结构。
第七章**,重构工具,Clang 提供了用于代码修改和重构的高级工具。我们将探索创建自定义重构工具的不同方法,包括基于 Clang-Tidy 检查框架的一个。我们还将探索 Clang-Format,这是一个用于自动代码格式化的极快实用工具。
第八章**,IDE 支持 和 Clangd,介绍了 Clangd——一种在诸如 Visual Studio Code (VS Code) 等各种 IDE 中使用的语言服务器,用于提供智能支持,包括导航和代码修改。Clangd 展示了 LLVM 强大的模块化架构的实用性。它利用了各种 Clang 工具,如 Clang-Tidy 和 Clang-Format,以增强 VS Code 中的开发体验。编译器性能对于这个工具至关重要,我们将探讨 Clangd 使用的几种提高其性能的技术,从而为开发者提供最佳体验。
附录 1:编译数据库,描述了编译数据库——一种向不同的 Clang 工具提供复杂编译命令的方法。这种功能对于将 Clang 工具(如 Clangd 和 Clang-Tidy)集成到实际的 C/C++ 项目中至关重要。
附录 2:构建速度优化,涵盖了几个编译器性能优化,这些优化可以用来提高编译器的性能。我们将介绍 Clang 预编译头和 Clang 模块,它们代表了一种序列化的抽象语法树(AST),可以比从头开始构建快得多。
为了充分利用这本书
您需要了解 C++,特别是 C++17,这是用于 LLVM 以及本书中的示例的。提供的示例假定在类 Unix 操作系统上运行,Linux 和 Darwin(Mac OS)被视为本书的操作系要求。我们将使用 Git 来克隆 LLVM 源代码树并开始工作。还需要安装一些工具,例如 CMake 和 Ninja,这些工具将用于构建示例和 LLVM 源代码。
如果您正在使用本书的数字版,我们建议您 亲自输入代码或从本书的 GitHub 仓库(下一节中有一个链接)访问代码。这样做将 帮助您避免与代码的复制和粘贴相关的任何潜在错误。
下载示例代码文件
书籍的代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Clang-Compiler-Frontend-Packt
。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的图书和视频目录的代码包可供使用,请访问github.com/PacktPublishing/
。查看它们!
使用的约定
本书使用了多种文本约定。
CodeInText
:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL 和用户输入。以下是一个示例:“前两个参数指定声明(clang::Decl
)和声明语句(clang::Stmt
)。”
代码块设置如下:
1 int main() {
2 return 0;
3 }
任何命令行输入或输出都应如下编写:
$ ninja clang
我们使用 <...>
作为克隆 LLVM 源代码的文件夹占位符。
一些代码示例将表示 shell 的输入。您可以通过特定的提示字符来识别它们:
-
(``lldb``)
用于交互式 LLDB shell -
$
用于 Bash shell(macOS 和 Linux) -
>
用于由不同的 Clang 工具提供的交互式 shell,例如 Clang-Query
重要提示
警告或重要提示如下所示。
小贴士
技巧和窍门如下所示。
联系我们
我们欢迎读者的反馈。
一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并给我们发送电子邮件至 customercare@packtpub.com。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/support/errata
,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果你在网上以任何形式遇到我们作品的非法副本,我们将非常感激如果你能提供位置地址或网站名称。请通过版权@packt.com 与我们联系,并提供材料的链接。
如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你感兴趣的是撰写或为书籍做出贡献,请访问partnerships.packt.com/contributors/
。
分享你的想法
一旦你阅读了Clang 编译器前端,我们很乐意听到你的想法!请点击此处直接跳转到这本书的亚马逊评论页面并分享你的反馈。
你的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。
下载这本书的免费 PDF 副本
感谢购买这本书!
你喜欢在旅途中阅读,但无法携带你的印刷书籍到处走吗?你的电子书购买是否与你的选择设备不兼容?
别担心,现在每购买一本 Packt 书籍,你都可以免费获得该书的 DRM 免费 PDF 版本。
在任何地方、任何时间、任何设备上阅读。直接从你最喜欢的技术书籍中搜索、复制和粘贴代码到你的应用程序中。
优惠并未停止,你还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到你的邮箱。
按照以下简单步骤获取福利:
-
扫描下面的二维码或访问以下链接:
-
提交你的购买证明。
-
就这样!我们将直接将你的免费 PDF 和其他福利发送到你的邮箱。
第一部分
Clang 设置和架构
您可以找到有关 LLVM 内部架构以及 Clang 如何融入其中的信息。还包括了如何安装和构建 Clang 以及 Clang-Tools 的描述,对 LLVM 项目中使用的和 Clang 开发所必需的基本 LLVM 库和工具的描述。您还可以找到一些 Clang 特性和它们内部实现的描述。
本部分包含以下章节:
-
第一章, 基本库和工具
-
第二章, Clang 架构
-
第三章, Clang 抽象语法树
-
第四章, 基本库和工具
第一章:环境设置
在本章中,我们将讨论为未来使用 Clang 进行实验而设置环境的基本步骤。该设置适用于基于 Unix 的系统,如 Linux 和 Mac OS(Darwin)。此外,你将获得有关如何下载、配置和构建 LLVM 源代码的重要信息。我们将继续一个简短的会话,解释如何构建和使用LLVM 调试器(LLDB),它将作为本书中代码调查的主要工具。最后,我们将完成一个简单的 Clang 工具,可以检查 C/C++文件的编译错误。我们将使用 LLDB 为创建的工具和 clang 内部进行简单的调试会话。我们将涵盖以下主题:
-
先决条件
-
了解 LLVM
-
源代码编译
-
如何创建自定义 Clang 工具
1.1 技术要求
下载和构建 LLVM 代码非常简单,不需要任何付费工具。你需要以下内容:
-
基于 Unix 的操作系统(Linux,Darwin)
-
命令行 git
-
构建工具:CMake 和 Ninja
我们将使用调试器作为源代码调查工具。LLVM 有自己的调试器,LLDB。我们将从 LLVM monorepo 构建它作为我们的第一个工具:github.com/llvm/llvm-project.git
。
任何构建过程都包括两个步骤。第一个是项目配置,最后一个步骤是构建本身。LLVM 使用 CMake 作为项目配置工具。它还可以使用广泛的构建工具,如 Unix Makefiles 和 Ninja。它还可以为流行的 IDE 生成项目文件,如 Visual Studio 和 XCode。我们将使用 Ninja 作为构建工具,因为它可以加快构建过程,并且大多数 LLVM 开发者都在使用它。你可以在这里找到有关这些工具的更多信息:llvm.org/docs/GettingStarted.html
。
本章的源代码位于本书 GitHub 仓库的chapter1
文件夹中:github.com/PacktPublishing/Clang-Compiler-Frontend-Packt/tree/main/chapter1
1.1.1 CMake 作为项目配置工具
CMake 是一个源代码、跨平台的构建系统生成器。自 2013 年发布的 3.3 版本以来,它一直被用作 LLVM 的主要构建系统。
在 LLVM 开始使用 CMake 之前,它使用 autoconf,这是一个生成配置脚本的工具,可以在广泛的类 Unix 系统上用于构建和安装软件。然而,autoconf 有几个限制,例如难以使用和维护,以及对跨平台构建的支持不佳。CMake 被选为 autoconf 的替代品,因为它解决了这些限制,并且更容易使用和维护。
除了用作 LLVM 的构建系统之外,CMake 还被用于许多其他软件项目,包括 Qt、OpenCV 和 Google Test。
1.1.2 Ninja 作为构建工具
Ninja 是一个专注于速度的小型构建系统。它被设计成与构建生成器(如 CMake)一起使用,CMake 会生成一个描述项目构建规则的构建文件。
Ninja 的主要优势之一是其速度。它通过仅重建完成构建所需的最小文件集,能够比其他构建系统(如 Unix Makefiles)更快地执行构建。这是因为它跟踪构建目标之间的依赖关系,并且只重建过时的目标。
此外,Ninja 简单易用。它拥有一个小巧且直观的命令行界面,并且它使用的构建文件是简单的文本文件,易于阅读和理解。
总体而言,当速度是关键因素,并且需要简单易用的工具时,Ninja 是构建系统的良好选择。
最有用的 Ninja 选项之一是 -j
。此选项允许您指定要并行运行的线程数。您可能需要根据所使用的硬件来指定该数值。
我们下一个目标是下载 LLVM 代码并调查项目结构。我们还需要设置构建过程所需的必要工具,并为未来使用 LLVM 代码的实验建立环境。这将确保我们拥有进行工作的工具和依赖项,以便高效地继续工作。
1.2 了解 LLVM
让我们从介绍一些关于 LLVM 的基础知识开始,包括项目历史以及其结构。
1.2.1 简短的 LLVM 历史
Clang 编译器是 LLVM 项目的一部分。该项目始于 2000 年,由 Chris Lattner 和 Vikram Adve 在伊利诺伊大学厄巴纳-香槟分校作为他们的项目启动 [26]。
LLVM 最初被设计为一个下一代代码生成基础设施,可用于构建多种编程语言的优化编译器。然而,它已经发展成为一个功能齐全的平台,可用于构建各种工具,包括调试器、性能分析器和静态分析工具。
LLVM 已在软件行业中得到广泛应用,并被许多公司和组织用于构建各种工具和应用。它也被用于学术研究和教学,并激发了其他领域类似项目的开发。
当苹果公司在 2005 年聘请了 Chris Lattner 并组建了一支团队来开发 LLVM 时,项目得到了额外的推动。LLVM 成为了苹果公司(XCode)开发工具的一个组成部分。
最初,GNU 编译器集合(GCC)被用作 LLVM 的 C/C++前端。但这也存在一些问题。其中之一与 GNU 通用公共许可证(GPL)有关,这阻止了在某些专有项目中的前端使用。另一个缺点是当时 GCC 对 Objective-C 的支持有限,这对苹果公司来说很重要。Clang 项目由 Chris Lattner 于 2006 年启动,旨在解决这些问题。
Clang 最初被设计为 C 语言家族(包括 C、Objective-C、C++和 Objective-C++)的统一解析器。这种统一旨在通过使用单个前端实现来简化维护,而不是为每种语言维护多个实现。该项目很快就取得了成功。Clang 和 LLVM 成功的一个主要原因是它们的模块化。LLVM 中的所有内容都是一个库,包括 Clang。这为基于 Clang 和 LLVM 创建大量惊人的工具打开了机会,例如 clang-tidy 和 clangd,这些将在本书的后续章节中介绍(第5章,Clang-Tidy Linter Framework 和 第8章,IDE 支持与 Clangd)。
LLVM 和 Clang 具有非常清晰的架构,并且是用 C++编写的。这使得任何 C++开发者都可以对其进行研究和使用。我们可以看到围绕 LLVM 形成的巨大社区以及其使用量的极快增长。
1.2.2 操作系统支持
我们计划在这里专注于个人电脑的操作系统,例如 Linux、Darwin 和 Windows。另一方面,Clang 不仅限于个人电脑,还可以用于编译 iOS 和不同嵌入式系统等移动平台的代码。
Linux
GCC 是 Linux 上的默认开发工具集,特别是gcc
(用于 C 程序)和g++
(用于 C++程序)是默认的编译器。Clang 也可以用于在 Linux 上编译源代码。此外,它模仿gcc
并支持其大多数选项。然而,LLVM 对某些 GNU 工具的支持可能有限;例如,GNU Emacs 不支持 LLDB 作为调试器。但尽管如此,Linux 是最适合 LLVM 开发和研究的操作系统,因此我们将主要使用这个操作系统(Fedora 39)进行未来的示例。
Darwin(macOS)
Clang 被认为是 Darwin 的主要构建工具。整个构建基础设施基于 LLVM,Clang 是默认的 C/C++编译器。开发工具,如调试器(LLDB),也来自 LLVM。您可以从 XCode 获取主要开发工具,它们基于 LLVM。然而,您可能需要安装额外的命令行工具,例如 CMake 和 Ninja,无论是作为单独的包还是通过 MacPorts 或 Homebrew 等包系统。
例如,您可以使用 Homebrew 获取 CMake,如下所示:
$ brew install cmake
或者对于 MacPorts:
$ sudo port install cmake
Windows
在 Windows 上,Clang 可以用作命令行编译器,也可以作为更大开发环境(如 Visual Studio)的一部分。Windows 上的 Clang 包括对Microsoft Visual C++ (MSVC) ABI 的支持,因此您可以使用 Clang 编译使用Microsoft C 运行时库(CRT)和 C++ 标准模板库(STL)的程序。Clang 还支持许多与 GCC 相同的语言特性,因此在许多情况下,它可以用作 Windows 上 GCC 的替代品。
值得注意的是clang-cl
[9]。它是一个用于 Clang 的命令行编译器驱动程序,旨在作为 MSVC 编译器cl.exe
的替代品。它是作为 Clang 编译器的一部分引入的,并创建用于与 LLVM 工具链一起使用。
与cl.exe
类似,clang-cl
被设计为 Windows 程序构建过程的一部分,它支持与 MSVC 编译器相同的许多命令行选项。它可以在 Windows 上编译 C、C++和 Objective-C 代码,并且也可以用于链接目标文件和库以创建可执行程序或动态链接库(DLLs)。
Windows 的开发过程与类 Unix 系统不同,这需要额外的具体说明,可能会使本书的材料相当复杂。为了避免这种复杂性,我们的主要目标是专注于基于 Unix 的系统,如 Linux 和 Darwin,本书将省略 Windows 特定的示例。
1.2.3 LLVM/Clang 项目结构
Clang 源代码是 LLVM 单一仓库(monorepo)的一部分。LLVM 从 2019 年开始使用 monorepo 作为其向 Git 过渡的一部分 [4]。这一决定是由几个因素驱动的,例如更好的代码重用、提高效率和协作。因此,您可以在一个地方找到所有 LLVM 项目。正如前言中提到的,本书将使用 LLVM 版本 18.x。以下命令将允许您下载它:
$ git clone https://github.com/llvm/llvm-project.git -b release/18.x
$ cd llvm-project
图 1.1:获取 LLVM 代码库
重要提示
18 版本是 LLVM 的最新版本,预计将于 2024 年 3 月发布。本书基于 2024 年 1 月 23 日的版本,当时创建了发布分支。
本书将使用llvm-project的最重要的部分,如图图 1.2 所示。
图 1.2:LLVM 项目树
有:
-
lld
: LLVM 链接器工具。您可能希望将其用作标准链接器工具(如 GNUld
)的替代品。 -
llvm
: LLVM 项目的通用库 -
clang
: Clang 驱动程序和前端 -
clang-tools-extra
: 这些是本书第二部分将涵盖的不同 Clang 工具
大多数项目都具有图 1.3 中所示的结构。
图 1.3:典型的 LLVM 项目结构
LLVM 项目,如clang
或llvm
,通常包含两个主要文件夹:include
和lib
。include
文件夹包含项目接口(头文件),而lib
文件夹包含实现。每个 LLVM 项目都有各种不同的测试,可以分为两个主要组:位于unittests
文件夹中的单元测试,使用 Google Test 框架实现,以及使用LLVM 集成测试器 (LIT)框架实现的端到端测试。您可以在第 4.5.2 节**中获取更多关于 LLVM/Clang 测试的信息,LLVM 测试框架*。
对我们来说,最重要的项目是clang
和clang-tools-extra
。clang
文件夹包含前端和驱动。
重要提示
编译器驱动用于运行编译的不同阶段(解析、优化、链接等)。您可以在第 2.3 节**中获取更多关于它的信息,Clang 驱动概述*。
例如,词法分析器的实现位于clang/lib/Lex
文件夹中。您还可以看到包含端到端测试的clang/test
文件夹,以及包含前端和驱动单元测试的clang/unittest
文件夹。
另一个重要的文件夹是clang-tools-extra
。它包含基于不同 Clang 库的一些工具。具体如下:
-
clang-tools-extra/clangd
:一个语言服务器,为 VSCode 等 IDE 提供导航信息 -
clang-tools-extra/clang-tidy
:一个功能强大的 lint 框架,具有数百种不同的检查 -
clang-tools-extra/clang-format
:一个代码格式化工具
在获取源代码并设置构建工具后,我们就可以编译 LLVM 源代码了。
1.3 源代码编译
我们以调试模式编译源代码,使其适合未来的调试器调查。我们使用 LLDB 作为调试器。我们将从构建过程概述开始,并以构建 LLDB 作为具体示例。
1.3.1 使用 CMake 进行配置
创建一个构建文件夹,编译器和相关工具将在其中构建:
$ mkdir build
$ cd build
最小配置命令看起来像这样:
$ cmake -DCMAKE_BUILD_TYPE=Debug ../llvm
命令需要指定构建类型(例如,在我们的例子中是Debug
)以及指向包含构建配置文件的文件夹的主要参数。配置文件存储为CMakeLists.txt
,位于llvm
文件夹中,这解释了../llvm
参数的使用。该命令在构建文件夹中生成Makefile
,因此您可以使用简单的make
命令来启动构建过程。
我们将在本书中使用更高级的配置命令。其中一个命令看起来像这样:
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=../install -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_ENABLE_PROJECTS="lldb;clang;clang-tools-extra" -DLLVM_USE_SPLIT_DWARF=ON ../llvm
图 1.4:基本的 CMake 配置
指定了几个 LLVM/cmake 选项:
-
-G Ninja
指定 Ninja 作为构建生成器,否则它将使用 make(这很慢)。 -
-DCMAKE_BUILD_TYPE=Debug
设置构建模式。将创建带有调试信息的构建。Clang 内部调查有一个主要构建配置。 -
-DCMAKE_INSTALL_PREFIX=../install
指定安装文件夹。 -
-DLLVM_TARGETS_TO_BUILD="X86"
设置要构建的确切目标。这将避免构建不必要的目标。 -
-DLLVM_ENABLE_PROJECTS="lldb;clang;clang-tools-extra"
指定我们想要构建的 LLVM 项目。 -
-DLLVM_USE_SPLIT_DWARF=ON
将调试信息分割成单独的文件。此选项在 LLVM 构建过程中可以节省磁盘空间以及内存消耗。
我们使用-DLLVM_USE_SPLIT_DWARF=ON
来在磁盘上节省一些空间。例如,启用选项的 Clang 构建(ninja clang
构建命令)占用 20 GB,但禁用选项时将占用 27 GB 空间。请注意,此选项要求用于构建的编译器支持它。您也可能注意到我们为特定架构创建构建:X86
。此选项也为我们节省了一些空间,因为否则,所有支持的架构都将被构建,所需空间也将从 20 GB 增加到 27 GB。
重要提示
如果您的宿主平台不是 X86,例如 ARM,您可能希望避免使用-DLLVM_TARGETS_TO_BUILD="X86"
设置。对于 ARM,您可以使用以下配置:-DLLVM_TARGETS_TO_BUILD="ARM;X86;AArch64"
[15]。支持的完整平台列表可以在[7]中找到,截至 2023 年 3 月,包括 19 个不同的目标。
您也可以使用默认设置,不指定LLVM_TARGETS_TO_BUILD
配置设置。请准备好构建时间和使用空间都会增加。
如果您使用动态库而不是静态库,可以节省更多空间。配置设置-DBUILD_SHARED_LIBS=ON
将构建每个 LLVM 组件为共享库。使用的空间将是 14 GB,整体配置命令将如下所示:
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=../install -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_ENABLE_PROJECTS="lldb;clang;clang-tools-extra" -DLLVM_USE_SPLIT_DWARF=ON -DBUILD_SHARED_LIBS=ON ../llvm
图 1.5:启用共享库而不是静态库的 CMake 配置
为了性能考虑,在 Linux 上,您可能希望使用gold
链接器而不是默认链接器。gold
链接器是 GNU 链接器的替代品,它是 GNU 二进制实用工具(binutils)包的一部分开发的。它旨在比 GNU 链接器更快、更高效,尤其是在链接大型项目时。它实现这一目标的一种方式是使用更有效的符号解析算法和更紧凑的文件格式来生成可执行文件。可以通过-DLLVM_USE_LINKER=gold
选项启用它。结果配置命令将如下所示:
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=../install -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_ENABLE_PROJECTS="lldb;clang;clang-tools-extra" -DLLVM_USE_LINKER=gold -DLLVM_USE_SPLIT_DWARF=ON -DBUILD_SHARED_LIBS=ON ../llvm
图 1.6:使用 gold 链接器的 CMake 配置
调试构建可能会非常慢,因此您可能想考虑一个替代方案。在可调试性和性能之间取得良好平衡的是带有调试信息的发布构建。要获得此构建,您可以在整体配置命令中将 CMAKE``_BUILD``_TYPE
标志更改为 RelWithDebInfo
。命令将如下所示:
cmake -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo _DCMAKE_INSTALL_PREFIX=../install -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_ENABLE_PROJECTS="lldb;clang;clang-tools-extra" -DLLVM_USE_SPLIT_DWARF=ON ../llvm
图 1.7: 使用 RelWithDebInfo 构建类型的 CMake 配置
以下表格列出了一些流行的选项(llvm.org/docs/CMake.html
)。
|
|
|
选项 | 描述 |
---|
|
|
|
CMAKE``_BUILD``_TYPE |
指定构建配置。 |
---|---|
可能的值有 Release|Debug|RelWithDebInfo|MinSizeRel . |
|
Release 和 RelWithDebInfo 优化了性能,而 |
|
MinSizeRel 优化了大小。 |
|
|
|
CMAKE``_INSTALL``_PREFIX |
安装前缀 |
---|
|
|
|
CMAKE``_C,CXX``_FLAGS |
用于编译的额外 C/C++ 标志 |
---|
|
|
|
CMAKE``_C,CXX``_COMPILER |
用于编译的 C/C++ 编译器。 |
---|---|
您可能想指定一个非默认编译器来使用一些 | |
选项,这些选项不可用或不支持默认编译器。 |
|
|
|
LLVM``_ENABLE``_PROJECTS |
要启用的项目。我们将使用 clang;clang-tools-extra . |
---|
|
|
|
LLVM``_USE``_LINKER |
指定要使用的链接器。 |
---|---|
有几个选项,包括 gold 和 lld 。 |
|
|
|
表 1.1: 配置选项
1.3.2 构建
我们需要调用 Ninja 来构建项目。如果您想构建所有指定的项目,可以在没有参数的情况下运行 Ninja:
$ ninja
Clang 构建命令将如下所示:
$ ninja clang
您还可以使用以下命令为编译器运行单元和端到端测试:
$ ninja check-clang
编译器二进制文件是 bin/clang
,可以在 build
文件夹中找到。
您还可以将二进制文件安装到 -DCMAKE``_INSTALL``_PREFIX
选项指定的文件夹中。可以按照以下方式完成:
$ ninja install
../install
文件夹(在图 1.4 中指定为安装文件夹)将具有以下结构:
$ ls ../install
bin include lib libexec share
1.3.3 LLVM 调试器,其构建和使用
LLVM 调试器 LLDB 是在观察 GNU 调试器 (GDB) 的基础上创建的。其中一些命令与 GDB 的对应命令相同。您可能会问:“如果我们已经有了好的调试器,为什么还需要一个新的调试器?” 答案可以在 GCC 和 LLVM 使用的不同架构解决方案中找到。LLVM 使用模块化架构,编译器的不同部分可以被重用。例如,Clang 前端可以在调试器中重用,从而支持现代 C/C++ 特性。例如,lldb
中的打印命令可以指定任何有效的语言结构,您可以使用 lldb
打印命令使用一些现代 C++ 特性。
相比之下,GCC 使用单一架构,很难将 C/C++ 前端与其他部分分离。因此,GDB 必须单独实现语言特性,这可能需要一些时间,才能在现代 GCC 中实现的语言特性在 GDB 中可用。
您可以在以下示例中找到有关 LLDB 构建和一些典型使用场景的信息。我们将为发布构建创建一个单独的文件夹:
$ cd llvm-project
$ mkdir release
$ cd release
图 1.8: LLVM 的发布构建
我们以发布模式配置我们的项目,并仅指定 lldb
和 clang
项目:
cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=../install -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_ENABLE_PROJECTS="lldb;clang" ../llvm
图 1.9: 使用发布构建类型的 CMake 配置
我们将使用系统可用的最大线程来构建 Clang 和 LLDB:
$ ninja clang lldb -j $(nproc)
您可以使用以下命令安装创建的执行文件:
$ ninja install-clang install-lldb
二进制文件将通过 -DCMAKE_INSTALL_PREFIX
配置命令参数指定的文件夹安装。
我们将使用以下简单的 C++ 程序作为示例调试会话:
1 int main() {
2 return 0;
3 }
图 1.10: 测试 C++ 程序:main.cpp
可以使用以下命令编译程序(<...>
用于引用克隆 llvm-project 的文件夹):
$ <...>/llvm-project/install/bin/clang main.cpp -o main -g -O0
如您所注意到的,我们没有使用优化(-O0
选项)并将调试信息存储在二进制文件中(使用 -g
选项)。
创建的执行文件的典型调试会话如图图 1.11 所示。
1$ <...>/llvm-project/install/bin/lldb main
2 (lldb) target create "./main"
3 ...
4 (lldb) b main
5 Breakpoint 1: where = main‘main + 11 at main.cpp:2:3,...
6 (lldb) r
7 Process 1443051 launched: ...
8 Process 1443051 stopped
9 * thread #1, name = ’main’, stop reason = breakpoint 1.1
10 frame #0: 0x000055555555513b main‘main at main.cpp:2:3
11 1 int main() {
12 -> 2 return 0;
13 3 }
14 (lldb) q
图 1.11: LLDB 会话示例
需要采取以下几项行动:
-
使用
<...>/llvm-project/install/bin/lldb
main
运行调试会话,其中main
是我们想要调试的可执行文件。参见图 1.11,第 1 行。 -
我们在
main
函数中设置了一个断点。参见图 1.11,第 4 行。 -
使用
"r"
命令运行会话。参见图 1.11,第 6 行。 -
我们可以看到,进程在断点处被中断。参见图 1.11,第 8 行、第 12 行。
-
我们使用
"q"
命令结束会话。参见图 1.11,第 14 行。
我们将使用 LLDB 作为我们 Clang 内部调查的工具之一。我们将使用图 1.11 中显示的相同命令序列。您也可以使用具有与 LLDB 相似命令集的其他调试器,如 GDB。
1.4 测试项目 – 使用 Clang 工具进行语法检查
对于我们的第一个测试项目,我们将创建一个简单的 Clang 工具,该工具运行编译器并检查提供的源文件的语法。我们将创建一个所谓的树外 LLVM 项目,即一个将使用 LLVM 但位于主 LLVM 源树之外的项目。
创建项目需要采取以下几项行动:
-
必须构建和安装所需的 LLVM 库和头文件。
-
我们必须为我们的测试项目创建一个构建配置文件。
-
使用 LLVM 的源代码必须被创建。
我们将首先安装 Clang 支持库和头文件。我们将使用以下 CMake 配置命令:
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=../install -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_ENABLE_PROJECTS="clang" -DLLVM_USE_LINKER=gold -DLLVM_USE_SPLIT_DWARF=ON -DBUILD_SHARED_LIBS=ON ../llvm
图 1.12:简单语法检查 Clang 工具的 LLVM CMake 配置
如您所注意到的,我们只启用了一个项目:clang
。所有其他选项都是我们调试构建的标准选项。必须从 LLVM 源树中创建的 build
文件夹中运行该命令,正如在 第 1.3.1 节**, 使用 CMake 进行配置 中所建议的。
重要提示
图 1.12 中指定的配置将是本书中使用的默认构建配置。
与共享库的配置相比,除了减小大小外,还有简化依赖项指定的优势。您只需指定项目直接依赖的共享库,动态链接器会处理其余部分。
可以使用以下命令安装所需的库和头文件:
$ ninja install
库和头文件将安装到 install
文件夹中,正如 CMAKE_INSTALL_PREFIX
选项所指定的。
我们必须为我们的项目创建两个文件:
-
CMakeLists.txt
:项目配置文件 -
TestProject.cpp
:项目源代码
项目配置文件 CMakeLists.txt
将通过 LLVM_HOME
环境变量接受 LLVM 安装文件夹的路径。文件如下:
1 cmake_minimum_required(VERSION 3.16)
2 project("syntax-check")
3
4 if ( NOT DEFINED ENV{LLVM_HOME})
5 message(FATAL_ERROR "$LLVM_HOME is not defined")
6 else()
7 message(STATUS "$LLVM_HOME found: $ENV{LLVM_HOME}")
8 set(LLVM_HOME $ENV{LLVM_HOME} CACHE PATH "Root of LLVM installation")
9 set(LLVM_LIB ${LLVM_HOME}/lib)
10 set(LLVM_DIR ${LLVM_LIB}/cmake/llvm)
11 find_package(LLVM REQUIRED CONFIG)
12 include_directories(${LLVM_INCLUDE_DIRS})
13 link_directories(${LLVM_LIBRARY_DIRS})
14 set(SOURCE_FILES SyntaxCheck.cpp)
15 add_executable(syntax-check ${SOURCE_FILES})
16 set_target_properties(syntax-check PROPERTIES COMPILE_FLAGS "-fno-rtti")
17 target_link_libraries(syntax-check
18 LLVMSupport
19 clangBasic
20 clangFrontend
21 clangSerialization
22 clangTooling
23 )
24 endif()
图 1.13:简单语法检查 Clang 工具的 CMake 文件
文件最重要的部分如下:
-
第 2 行:我们指定项目名称(syntax-check)。这也是我们可执行文件的名字。
-
第 4-7 行:测试
LLVM_HOME
环境变量。 -
第 10 行:我们设置 LLVM CMake 辅助工具的路径。
-
第 11 行:我们从 第 10 行 指定的路径加载 LLVM CMake 包。
-
第 14 行:我们指定应编译的源文件。
-
第 16 行:我们设置了一个额外的编译标志:
-fno-rtti
。当 LLVM 没有启用 RTTI 构建时,该标志是必需的。这是为了减少代码和可执行文件的大小 [11]。 -
第 18-22 行:我们指定要链接到我们的程序所需的库。
我们工具的源代码如下:
1 #include "clang/Frontend/FrontendActions.h" // clang::SyntaxOnlyAction
2 #include "clang/Tooling/CommonOptionsParser.h"
3 #include "clang/Tooling/Tooling.h"
4 #include "llvm/Support/CommandLine.h" // llvm::cl::extrahelp
5
6 namespace {
7 llvm::cl::OptionCategory TestCategory("Test project");
8 llvm::cl::extrahelp
9 CommonHelp(clang::tooling::CommonOptionsParser::HelpMessage);
10 } // namespace
11
12 int main(int argc, const char **argv) {
13 llvm::Expected<clang::tooling::CommonOptionsParser> OptionsParser =
14 clang::tooling::CommonOptionsParser::create(argc, argv, TestCategory);
15 if (!OptionsParser) {
16 llvm::errs() << OptionsParser.takeError();
17 return 1;
18 }
19 clang::tooling::ClangTool Tool(OptionsParser->getCompilations(),
20 OptionsParser->getSourcePathList());
21 return Tool.run(
22 clang::tooling::newFrontendActionFactory<clang::SyntaxOnlyAction>()
23 .get());
24 }
图 1.14:SyntaxCheck.cpp
文件最重要的部分如下:
-
第 7-9 行:大多数编译器工具都有相同的命令行参数集。LLVM 命令行库 [12] 提供了一些 API 来处理编译器命令选项。我们在 第 7 行 上设置了库。我们还在第 8-10 行设置了额外的帮助信息。
-
第 13-18 行:我们解析命令行参数。
-
第 19-24 行:我们创建并运行我们的 Clang 工具。
-
第 22-23 行:我们使用
clang::SyntaxOnlyAction
前端动作,该动作将在输入文件上运行语法和语义检查。您可以在 第 2.4.1 节**前端动作 中获取更多关于前端动作的信息。
我们必须指定 LLVM install
文件夹的路径来构建我们的工具。如前所述,路径必须通过LLVM_HOME
环境变量指定。我们的配置命令(见图 1.12)指定了 LLVM 项目源树中的install
文件夹的路径。因此,我们可以按以下方式构建我们的工具:
export LLVM_HOME=<...>/llvm-project/install
mkdir build
cd build
cmake -G Ninja ..
ninja
图 1.15:语法检查的构建命令
我们可以按以下方式运行工具:
$ cd build
$ ./syntax-check --help
USAGE: syntax-check [options] <source0> [... <sourceN>]
...
图 1.16:语法检查 –help 输出
如果我们在有效的 C++源文件上运行程序,程序将依次终止,但如果在损坏的 C++文件上运行,它将产生错误信息:
$ ./syntax-check mainbroken.cpp -- -std=c++17
mainbroken.cpp:2:11: error: expected ’;’ after return statement
return 0
^
;
1 error generated.
Error while processing mainbroken.cpp.
图 1.17:对存在语法错误的文件进行的语法检查
在图 1.17 中,我们使用- -
向编译器传递额外的参数,具体表示我们想使用 C++17,选项为-std=c++17
。
我们也可以使用 LLDB 调试器运行我们的工具:
$ <...>/llvm-project/install/bin/lldb \
./syntax-check \
-- \
main.cpp \
-- -std=c++17
图 1.18:在调试器下运行的语法检查
我们将syntax-check
作为主二进制文件运行,并将main.cpp
源文件作为工具的参数(图 1.18)。我们还向语法检查的可执行文件传递了额外的编译标志(-std=c++17)。
我们可以设置断点并按以下方式运行程序:
1(lldb) b clang::ParseAST
2 ...
3 (lldb) r
4 ...
5 Running without flags.
6 Process 608249 stopped
7 * thread #1, name = ’syntax-check’, stop reason = breakpoint 1.1
8 frame #0: ... clang::ParseAST(...) at ParseAST.cpp:117:3
9 114
10 115 void clang::ParseAST(Sema &S, bool PrintStats, bool SkipFunctionBodies) {
11 116 // Collect global stats on Decls/Stmts (until we have a module streamer).
12 -> 117 if (PrintStats) {
13 118 Decl::EnableStatistics();
14 119 Stmt::EnableStatistics();
15 120 }
16 (lldb) c
17 Process 608249 resuming
18 Process 608249 exited with status = 0 (0x00000000)
19 (lldb)
图 1.19:Clang 工具测试项目的 LLDB 会话
我们在clang::ParseAST
函数中设置了一个断点(图 1.19,第 1 行)。该函数是源代码解析的主要入口点。我们在第 3 行运行程序,并在第 16 行的断点后继续执行。
当我们在书中调查 Clang 的源代码时,我们将使用相同的调试技术。
1.5 摘要
在本章中,我们介绍了 LLVM 项目的历史,获取了 LLVM 的源代码,并探讨了其内部结构。我们了解了用于构建 LLVM 的工具,例如 CMake 和 Ninja。我们研究了构建 LLVM 的各种配置选项以及如何使用它们来优化资源,包括磁盘空间。我们在调试和发布模式下构建了 Clang 和 LLDB,并使用生成的工具编译了一个基本程序,并用调试器运行它。我们还创建了一个简单的 Clang 工具,并用 LLDB 调试器运行它。
下一章将向您介绍编译器设计架构,并解释它在 Clang 的上下文中的表现。我们将主要关注 Clang 前端,但也会涵盖 Clang 驱动程序的重要概念——它是管理编译过程所有阶段的骨干,从解析到链接。
1.6 进一步阅读
-
开始使用 LLVM 系统:
llvm.org/docs/GettingStarted.html
-
使用 CMake 构建 LLVM:
llvm.org/docs/CMake.html
-
Clang 编译器用户手册:
clang.llvm.org/docs/UsersManual.html
第二章:Clang 架构
在本章中,我们将检查 Clang 的内部架构及其与其他 LLVM 组件的关系。我们将从整体编译器架构的概述开始,特别关注 clang 驱动程序。作为编译器的骨架,驱动程序运行所有编译阶段并控制它们的执行。最后,我们将专注于 Clang 编译器的前端部分,这包括词法分析和语义分析,并生成抽象语法树(AST)作为其主要输出。AST 是大多数 Clang 工具的基础,我们将在下一章中更详细地研究它。
本章将涵盖以下主题:
-
编译器概述
-
Clang 驱动程序概述,包括编译阶段及其执行的说明
-
Clang 前端概述,包括预处理步骤、解析和语义分析
2.1 技术要求
本章的源代码位于本书 GitHub 仓库的chapter2
文件夹中:github.com/PacktPublishing/Clang-Compiler-Frontend-Packt/tree/main/chapter2
。
2.2 编译器入门
尽管编译器用于将程序从一种形式转换为另一种形式,但它们也可以被视为使用各种算法和数据结构的大型软件系统。通过研究编译器获得的知识可以用来设计其他可扩展的软件系统。另一方面,编译器也是活跃的科学研究的主题,有许多未探索的领域和主题需要研究。
您可以在此处找到有关编译器内部结构的一些基本信息。我们将尽可能保持其基础性,以便信息适用于任何编译器,而不仅仅是 Clang。我们将简要介绍编译的所有阶段,这将有助于理解 Clang 在整体编译器架构中的位置。
2.2.1 探索编译器工作流程
编译器的主要功能是将用特定编程语言(如 C/C++或 FORTRAN)编写的程序转换为可以在目标平台上执行的形式。这个过程涉及到编译器的使用,它接收源文件和任何编译标志,并生成构建工件,例如可执行文件或对象文件,如图 2.1 所示。
图 2.1:编译器工作流程
“目标平台”这个术语可以具有广泛的意义。它可以指在同一主机上执行的目标机器代码,这是典型情况。但它也可以指交叉编译,在这种情况下,编译器为不同于主机的不同计算机架构生成代码。例如,使用英特尔机器作为主机可以生成在 ARM 上运行的移动应用程序或嵌入式应用程序的代码。此外,目标平台不仅限于机器代码。例如,一些早期的 C++编译器(如“cc”)会生成纯 C 代码作为输出。这是因为在当时,C 是最广泛使用且最成熟的编程语言,C 编译器是生成机器代码最可靠的方式。这种方法允许早期的 C++程序在广泛的平台上运行,因为大多数系统已经提供了 C 编译器。然后,生成的 C 代码可以使用任何流行的 C 编译器(如 GCC 或 LCC)编译成机器代码。
图 2.2:典型的编译器工作流程:源程序通过不同的阶段:前端、中间端和后端
我们将关注生成二进制代码的编译器,并展示这样一个编译器的典型编译器工作流程图 2.2。编译的阶段可以描述如下:
-
前端:前端执行词法分析和解析,包括语法分析和语义分析。语法分析假设你的程序根据语言语法规则组织得很好。语义分析对程序的意义进行检查,并拒绝无效的程序,例如使用错误类型的程序。
-
中间端:中间端对中间表示(IR)代码(Clang 的 LLVM-IR)执行各种优化。
-
后端:编译器后端接收优化或转换后的 IR,并生成目标平台可执行的目标机器代码或汇编代码。
源程序在通过各个阶段时被转换成不同的形式。例如,前端生成 IR 代码,然后由中间端进行优化,最后由后端转换为本地代码(见图 2.3)。
图 2.3:编译器对源代码的转换
输入数据包括源代码和编译选项。源代码通过前端转换成IR。中间端对IR进行不同的优化,并将最终(优化后的)结果传递给后端。后端生成目标代码。前端、中间端和后端使用编译选项作为代码转换的设置。让我们首先研究编译器的前端,它是编译器工作流程的第一个组件。
2.2.2 前端
前端的主要目标是将给定的源代码转换成中间形式。值得注意的是,在生成 IR 之前,前端还将源代码转换成各种形式。前端将是本书的主要焦点,因此我们将检查其组件。前端的第一组件是 Lexer(见图 2.4)。它将源代码转换成一组标记,这些标记用于创建一个特殊的数据结构,即抽象语法树(AST)。最后一个组件是代码生成器(Codegen),它遍历 AST 并从中生成 IR。
图 2.4:编译器前端
源代码通过 Lexer 转换成一组标记(Toks)。解析器(Parser)接收这些标记并创建一个抽象语法树(AST),我们将在第三章,Clang AST中详细探讨。代码生成器(Codegen)从 AST 生成中间表示(IR)。
我们将使用一个简单的 C/C++程序来计算两个数的最大值,以演示前端的工作原理。该程序的代码如下:
1 int max(int a, int b) {
2 if (a > b)
3 return a;
4 return b;
5 }
图 2.5:编译器前端测试程序
前端的第一组件是 lexer。让我们来考察它。
Lexer
前端处理过程从 Lexer 开始,它将输入源代码转换成一系列的标记。在我们的示例程序中(见图 2.5),第一个标记是关键字int
,它代表整数类型。接下来是函数名max
的标识符。下一个标记是左括号(
,以此类推(见图 2.6)。
图 2.6:Lexer:程序源代码被转换成一系列的标记
解析器
解析器是紧随 Lexer 之后的下一个组件。解析器的主要输出称为抽象语法树(AST)。这棵树代表了用编程语言编写的源代码的抽象句法结构。解析器通过将 Lexer 产生的标记流作为输入并组织成树状结构来生成 AST。树中的每个节点代表源代码中的一个结构,如语句或表达式,节点之间的边代表这些结构之间的关系。
图 2.7:计算两个数最大值的示例程序的 AST
我们示例程序的 AST 显示在图 2.7。如图所示,我们的函数(max
)有两个参数(a
和b
)和一个主体。主体在图 2.7 中被标记为复合语句,参见图 2.40,在那里我们从 C++标准中提供了一个复合语句的定义。复合语句由其他语句组成,例如return
和if
。变量a
和b
在这些语句的主体中使用。您可能还对 Clang 为复合语句生成的真实 AST 感兴趣,其结果显示在图 2.8 中。
图 2.8:Clang 生成的复合语句的 AST。由clang -cc1 -ast-view <...>
命令生成的树
解析器执行两个活动:
-
语法分析:解析器通过分析程序的语法来构建抽象语法树(AST)。
-
语义分析:解析器从语义上分析程序。
解析器的一项工作是,如果在语法或语义分析阶段解析失败,则生成错误消息。如果没有发生错误,那么我们得到语法分析的解析树(或 AST),在语义分析的情况下,我们得到语义验证的解析树。我们可以通过考虑语法分析检测到的错误类型以及语义分析检测到的错误类型来获得这种感觉。
语法分析假设程序在语言的指定语法方面应该是正确的。例如,以下程序在语法上无效,因为最后一个返回语句缺少分号:
1 int max(int a, int b) {
2 if (a > b)
3 return a;
4 return b // missing ;
5 }
图 2.9:具有语法错误的程序代码列表
Clang 为该程序生成了以下输出:
max_invalid_syntax.cpp:4:11: error: expected ’;’ after return statement
return b // missing ;
^
;
图 2.10:具有语法错误的程序的编译器输出
另一方面,一个程序可能在语法上是正确的,但没有任何意义。在这种情况下,解析器应该检测到语义错误。例如,以下程序有关返回值类型使用错误的语义错误:
1 int max(int a, int b) {
2 if (a > b)
3 return a;
4 return &b; // invalid return type
5 }
图 2.11:包含语义错误的程序代码列表
Clang 为该程序生成了以下输出:
max_invalid_sema.cpp:4:10: error: cannot initialize return object of type \
’int’ with an rvalue of type ’int *’
return &b; // invalid return type
^~
图 2.12:具有语义错误的程序的编译器输出
AST 主要是语法分析的结果,但对于某些语言,如 C++,语义分析对于构建 AST 也是至关重要的,尤其是对于 C++模板实例化。
在语法分析期间,编译器验证模板声明是否遵循语言的语法和语法规则,包括正确使用“template”和“typename”等关键字,以及模板参数和主体的形成。
另一方面,语义分析涉及编译器执行模板实例化,这为模板的特定实例生成抽象语法树(AST)。值得注意的是,模板的语义分析可能相当复杂,因为编译器必须为每个模板实例化执行类型检查、名称解析等任务。此外,实例化过程可能是递归的,并可能导致大量代码重复,称为代码膨胀。为了解决这个问题,C++编译器采用模板实例化缓存等技术来最小化生成的冗余代码量。
代码生成
代码生成(值得一提的是,我们还有一个作为后端一部分的另一个 Codegen 组件,它生成目标代码)或代码生成器,是编译器前端的最后一个组件,其主要目标是生成中间表示(IR)。为此,编译器遍历由解析器生成的 AST,并将其转换为称为中间表示或 IR 的其他源代码。IR 是一种与语言无关的表示,允许相同的中间端组件用于不同的前端(FORTRAN 与 C++)。使用中间表示(IR)的另一个原因是,如果我们明天有新的架构可用,我们可以生成特定于该架构的目标代码。由于源语言保持不变,所有导致 IR 的步骤都将保持不变。IR 提供了这种灵活性。
编译器中使用 IR 的概念已经存在了几十年。在编译过程中使用中间表示来表示程序源代码的想法随着时间的推移而发展,IR 首次在编译器中引入的确切日期尚不清楚。
然而,众所周知,20 世纪 50 年代和 60 年代的第一批编译器没有使用 IR,而是直接将源代码翻译成机器代码。到 20 世纪 60 年代和 70 年代,研究人员开始尝试在编译器中使用 IR 来提高编译过程的效率和灵活性。
最早广泛使用的 IR 之一是三地址代码,它在 20 世纪 60 年代中期用于 IBM/360 的 FORTRAN 编译器。其他早期的 IR 示例包括 20 世纪 70 年代和 80 年代分别引入的寄存器传输语言(RTL)和静态单赋值(SSA)形式。
今天,编译器中使用 IR 已成为标准做法,许多编译器在整个编译过程中使用多个 IR。这允许应用更强大的优化和代码生成技术。
2.3 Clang 驱动概述
讨论编译器时,我们通常指的是一个命令行工具,用于启动和管理编译过程。例如,要使用 GNU 编译器集合,必须调用gcc
来启动编译过程。同样,要使用 Clang 编译 C++程序,必须将clang
作为编译器调用。控制编译过程的程序被称为驱动程序。驱动程序协调编译的不同阶段并将它们连接在一起。在本书中,我们将重点关注 LLVM,并使用 Clang 作为编译过程的驱动程序。
对于读者来说,可能会感到困惑的是,同一个词“Clang”被用来指代编译器前端和编译驱动程序。相比之下,在其他编译器中,驱动程序和 C++编译器可以是独立的可执行文件,而“Clang”是一个单一的可执行文件,它既作为驱动程序也作为编译器前端。要仅将 Clang 用作编译器前端,必须传递特殊选项-cc1
。
2.3.1 示例程序
我们将使用简单的“Hello world!”示例程序进行 Clang 驱动程序的实验。主要源文件名为hello.cpp
。该文件实现了一个简单的 C++程序,将“Hello world!”打印到标准输出。
1 #include <iostream>
2
3 int main() {
4 std::cout << "Hello world!" << std::endl;
5 return 0;
6 }
图 2.13:示例程序:hello.cpp
您可以使用以下命令编译源代码:
$ <...>/llvm-project/install/bin/clang hello.cpp -o /tmp/hello -lstdc++
图 2.14:hello.cpp
的编译
如您所见,我们使用了clang
可执行文件作为编译器,并指定了-lstdc++
库选项,因为我们使用了标准 C++库中的<iostream>
头文件。我们还使用-o
选项指定了可执行文件的输出(/tmp/hello
)。
2.3.2 编译阶段
我们为示例程序使用了两个输入。第一个是我们的源代码,第二个是标准 C++库的共享库。Clang 驱动程序应将这些输入组合在一起,通过编译过程的各个阶段传递它们,并最终在目标平台上提供可执行文件。
Clang 使用与图 2.2 中所示相同的典型编译器工作流程。您可以使用-ccc-print-phases
附加参数要求 Clang 显示阶段。
$ <...>/llvm-project/install/bin/clang hello.cpp -o /tmp/hello -lstdc++ \
-ccc-print-phases
图 2.15:打印 hello.cpp 编译阶段的命令
命令的输出如下:
+- 0: input, "hello.cpp", c++
+- 1: preprocessor, {0}, c++-cpp-output
+- 2: compiler, {1}, ir
+- 3: backend, {2}, assembler
+- 4: assembler, {3}, object
|- 5: input, "1%dM", object
6 : linker, {4, 5}, image
图 2.16:hello.cpp 的编译阶段
我们可以将输出可视化,如图 2.17 所示。
图 2.17:Clang 驱动程序阶段
如图 2.17 所示,驱动程序接收一个输入文件hello.cpp
,这是一个 C++文件。该文件由预处理器处理,我们获得预处理器输出(标记为c++-cpp-output
)。结果由编译器编译成 IR 形式,然后后端将其转换为汇编形式。这种形式随后被转换成目标文件。最终的目标文件与另一个目标文件(libstdc++
)结合,生成最终的二进制文件(image
)。
2.3.3 工具执行
阶段被组合为几个工具执行。Clang 驱动程序调用不同的程序以生成最终的可执行文件。具体来说,对于我们的示例,它调用clang
编译器和ld
链接器。这两个程序都需要由驱动程序设置的额外参数。
例如,我们的示例程序(hello.cpp
)包含以下头文件:
1#include <iostream>
2 ...
图 2.18:iostream 头文件在 hello.cpp 中
在调用编译时,我们没有指定任何额外的参数(例如,搜索路径,例如,-I
)。然而,不同的架构和操作系统可能具有不同的路径来定位头文件。
在 Fedora 39 上,头文件位于/usr/include/c++/13/iostream
文件夹中。我们可以使用-###
选项检查驱动程序执行的过程和使用的参数的详细描述:
$ <...>/llvm-project/install/bin/clang hello.cpp -o /tmp/hello -lstdc++ -###
图 2.19:打印 hello.cpp 工具执行的命令
该命令的输出相当广泛,此处省略了某些部分。请参阅图 2.20。
1clang version 18.1.0rc (https://github.com/llvm/llvm-project.git ...)
2 "<...>/llvm-project/install/bin/clang-18"
3 "-cc1" ... \
4 "-internal-isystem" \
5 "/usr/include/c++/13" ... \
6 "-internal-isystem" \
7 "/usr/include/c++/13/x86_64-redhat-linux" ... \
8 "-internal-isystem" ... \
9 "<...>/llvm-project/install/lib/clang/18/include" ... \
10 "-internal-externc-isystem" \
11 "/usr/include" ... \
12 "-o" "/tmp/hello-XXX.o" "-x" "c++" "hello.cpp"
13 ".../bin/ld" ... \
14 "-o" "/tmp/hello" ... \
15 "/tmp/hello-XXX.o" \
16 "-lstdc++" ...
图 2.20:Clang 驱动程序工具执行。主机系统是 Fedora 39。
如图 2.20 所示,驱动程序启动了两个过程:带有-cc1
标志的clang-18
(见行 2-12)和链接器ld
(见行 13-16)。Clang 编译器隐式接收几个搜索路径,如行 5、7、9 和 11所示。这些路径对于在测试程序中包含iostream
头文件是必要的。
第一个可执行文件(/tmp/hello-XXX.o
)的输出作为第二个可执行文件的输入(见行 12 和 15)。对于链接器,设置了-lstdc++
和-o /tmp/hello
参数,而第一个参数(hello.cpp)为编译器调用(第一个可执行文件)提供。
图 2.21:Clang 驱动程序工具执行。Clang 驱动程序运行两个可执行文件:带有-cc1 标志的 clang 可执行文件和链接器-ld 可执行文件
该过程可以如图 2.21 所示进行可视化,其中我们可以看到两个可执行文件作为编译过程的一部分被执行。第一个是带有特殊标志(-cc1
)的clang-18
。第二个是链接器:ld
。
2.3.4 将所有内容组合在一起
我们可以使用图 2.22 总结到目前为止所获得的知识。该图说明了 Clang 驱动程序启动的两个不同过程。第一个是clang -cc1
(编译器),第二个是ld
(链接器)。编译器过程与 Clang 驱动程序(clang
)相同的可执行文件,但它使用特殊参数:-cc1
。编译器生成一个对象文件,然后由链接器(ld
)处理以生成最终的二进制文件。
图 2.22:Clang 驱动器:驱动器获取了输入文件 hello.cpp,这是一个 C++文件。它启动了两个进程:clang 和 ld。第一个进程执行真正的编译并启动集成汇编器。最后一个进程是链接器(ld),它从编译器接收到的结果和外部库(libstdc++)生成最终的二进制(镜像)
在图 2.22 中,我们可以观察到之前提到的编译器中的类似组件(参见第 2.2 节**,开始使用编译器)。然而,主要区别在于预处理器(词法分析器的一部分)被单独显示,而前端和中端被组合到编译器中。此外,该图描述了一个由驱动器执行的汇编器,用于生成目标代码。需要注意的是,汇编器可以是集成的,如图图 2.22 所示,或者可能需要单独的进程来执行。
重要提示
这里是一个使用-c
(仅编译)和-o
(输出文件)选项以及适用于您平台的适当标志指定外部汇编器的示例:
$<...>/llvm-project/install/bin/clang -c hello.cpp \
-o /tmp/hello.o
as -o /tmp/hello.o /tmp/hello.s
2.3.5 调试 Clang
我们将逐步通过我们的编译过程调试会话,如图图 2.14 所示。
重要提示
我们将使用之前在第 1.3.3 节**, LLVM 调试器、其构建和使用中创建的 LLDB 构建,以及本书中其他调试会话进行此调试会话。您也可以使用主机系统提供的 LLDB。
我们选择的感兴趣点,或断点,是clang::ParseAST
函数。在一个典型的调试会话中,它类似于图 1.11 中概述的,你会在"- -"
符号之后输入命令行参数。命令应该看起来像这样:
$ lldb <...>/llvm-project/install/bin/clang -- hello.cpp -o /tmp/hello \
-lstdc++
图 2.23:编译 hello.cpp 文件时的调试器运行
在这种情况下,<...>
代表用于克隆 LLVM 项目的目录路径。
不幸的是,这种方法与 Clang 编译器不兼容:
1$ lldb <...>/llvm-project/install/bin/clang -- hello.cpp -o /tmp/hello.o -lstdc++
2 ...
3 (lldb) b clang::ParseAST
4 ...
5 (lldb) r
6 ...
72 locations added to breakpoint 1
8 ...
9 Process 247135 stopped and restarted: thread 1 received signal: SIGCHLD
10 Process 247135 stopped and restarted: thread 1 received signal: SIGCHLD
11 Process 247135 exited with status = 0 (0x00000000)
12 (lldb)
图 2.24:失败的干扰调试会话
如我们从第 7 行可以看到,断点已设置,但进程成功完成(第 11 行)而没有任何中断。换句话说,在这个实例中,我们的断点没有触发。
理解 Clang 驱动程序的内部结构可以帮助我们识别当前的问题。如前所述,clang
可执行文件在此上下文中充当驱动程序,运行两个独立的进程(参见图 2.21)。因此,如果我们想调试编译器,我们需要使用 -cc1
选项来运行它。
重要提示
值得注意的是,Clang 在 2019 年实现了一种特定的优化 [22]。当使用 -c
选项时,Clang 驱动程序不会为编译器启动一个新的进程:
$ <...>/llvm-project/install/bin/clang -c hello.cpp \
-o /tmp/hello.o \
-###
clang version 18.1.0rc ...
InstalledDir: <...>/llvm-project/install/bin
(in-process)
"<...>/llvm-project/install/bin/clang-18" "-cc1"..."hello.cpp"
...
如上图所示,Clang 驱动程序不会启动一个新的进程,而是在同一个进程中调用“cc1”工具。这个特性不仅提高了编译器的性能,还可以用于 Clang 调试。
使用 -cc1
选项并排除 -lstdc++
选项(这是针对第二个进程,即 ld 链接器的特定选项)后,调试器将生成以下输出:
1$ lldb <...>/llvm-project/install/bin/clang -- -cc1 hello.cpp -o /tmp/hello.o
2 ...
3 (lldb) b clang::ParseAST
4 ...
5 (lldb) r
6 ...
72 locations added to breakpoint 1
8 Process 249890 stopped
9 * thread #1, name = ’clang’, stop reason = breakpoint 1.1
10 frame #0: ... at ParseAST.cpp:117:3
11 114
12 115 void clang::ParseAST(Sema &S, bool PrintStats, bool SkipFunctionBodies) {
13 116 // Collect global stats on Decls/Stmts (until we have a module streamer).
14 -> 117 if (PrintStats) {
15 118 Decl::EnableStatistics();
16 119 Stmt::EnableStatistics();
17 120 }
18 (lldb) c
19 Process 249890 resuming
20 hello.cpp:1:10: fatal error: ’iostream’ file not found
21 #include <iostream>
22 ^~~~~~~~~~
231 error generated.
24 Process 249890 exited with status = 1 (0x00000001)
25 (lldb)
图 2.25:缺少搜索路径的调试会话
因此,我们可以看到我们成功地设置了断点,但进程以错误结束(见 行 20-24)。这个错误是由于我们省略了某些搜索路径,这些路径通常是 Clang 驱动程序隐式附加的,对于成功编译所需的所有包含文件是必要的。
如果我们在编译器调用中明确包含所有必要的参数,我们可以成功执行该过程。以下是这样做的方法:
lldb <...>/llvm-project/install/bin/clang -- -cc1 \
-internal-isystem /usr/include/c++/13 \
-internal-isystem /usr/include/c++/13/x86_64-redhat-linux \
-internal-isystem <...>/llvm-project/install/lib/clang/18/include \
-internal-externc-isystem /usr/include \
hello.cpp \
-o /tmp/hello.o
图 2.26:使用指定搜索路径运行调试器。主机系统是 Fedora 39
然后,我们可以设置 clang::ParseAST
的断点并运行调试器。执行将无错误完成,如下所示:
1(lldb) b clang::ParseAST
2 ...
3 (lldb) r
4 ...
52 locations added to breakpoint 1
6 Process 251736 stopped
7 * thread #1, name = ’clang’, stop reason = breakpoint 1.1
8 frame #0: 0x00007fffe803eae0 ... at ParseAST.cpp:117:3
9 114
10 115 void clang::ParseAST(Sema &S, bool PrintStats, bool SkipFunctionBodies) {
11 116 // Collect global stats on Decls/Stmts (until we have a module streamer).
12 -> 117 if (PrintStats) {
13 118 Decl::EnableStatistics();
14 119 Stmt::EnableStatistics();
15 120 }
16 (lldb) c
17 Process 251736 resuming
18 Process 251736 exited with status = 0 (0x00000000)
19 (lldb)
图 2.27:编译器的成功调试会话
总之,我们已经成功地演示了 Clang 编译器调用的调试。所介绍的技术可以有效地用于探索编译器的内部结构和解决与编译器相关的错误。
2.4 Clang 前端概述
很明显,Clang 编译器工具链符合各种编译器书籍中广泛描述的模式 [1, 18]。然而,Clang 的前端部分与典型的编译器前端有显著差异。这种差异的主要原因是 C++ 语言的复杂性。一些特性,如宏,可以修改源代码本身,而其他特性,如 typedef,可以影响标记的类型。Clang 还可以生成多种格式的输出。例如,以下命令生成了图 2.5中显示的程序的美观 HTML 视图:
$ <...>/llvm-project/install/bin/clang -cc1 -emit-html max.cpp
注意,我们将输出源程序 HTML 形式的参数传递给 Clang 前端,使用-cc1
选项指定。或者,您也可以通过-Xclang
选项将选项传递给前端,这需要一个额外的参数来表示选项本身,例如:
$ <...>/llvm-project/install/bin/clang -Xclang -emit-html max.cpp \
-fsyntax-only
你可能会注意到,在前面的命令中,我们使用了-fsyntax-only
选项,指示 Clang 只执行预处理器、解析器和语义分析阶段。
因此,我们可以指示 Clang 前端执行不同的操作,并根据提供的编译选项生成不同类型的输出。这些操作的基类被称为FrontendAction
。
2.4.1 前端操作
Clang 前端一次只能执行一个前端操作。前端操作是基于提供的编译器选项,前端执行的具体任务或过程。以下是一些可能的前端操作列表(表格仅包含可用前端操作的一个子集):
|
|
|
|
FrontendAction | 编译器选项 | 描述 |
---|
|
|
|
|
EmitObjAction | -emit-obj (默认) |
编译为对象文件 |
---|
|
|
|
|
EmitBCAction | -emit-llvm-bc |
编译为 LLVM 字节码 |
---|
|
|
|
|
EmitLLVMAction | -emit-llvm |
编译为 LLVM 可读形式 |
---|
|
|
|
|
ASTPrintAction | -ast-print |
构建抽象语法树(AST)并格式化输出。 |
---|
|
|
|
|
HTMLPrintAction | -emit-html |
以 HTML 形式打印程序源代码 |
---|
|
|
|
|
DumpTokensAction | -dump-tokens |
打印预处理器标记 |
---|
|
|
|
|
表 2.1: 前端操作
图 2.28: Clang 前端组件
图 2.28 所示为基本前端架构,该架构与图 2.4 所示的架构类似。然而,Clang 有一些特定的显著差异。
一个显著的变化是词法分析器的命名。在 Clang 中,词法分析器被称为预处理器。这种命名约定反映了词法分析器实现被封装在Preprocessor
类中的事实。这种变更受到了 C/C++语言独特方面的启发,包括需要特殊预处理的特殊类型的标记(宏)。
另一个值得注意的差异出现在解析器组件中。虽然传统的编译器通常在解析器中同时执行语法和语义分析,但 Clang 将这些任务分配到不同的组件中。Parser
组件专注于语法分析,而Sema
组件处理语义分析。
此外,Clang 还提供以不同形式或格式生成输出的能力。例如,CodeGenAction
类是各种代码生成操作的基类,如 EmitObjAction
或 EmitLLVMAction
。
我们将使用 图 2.5 中的 max
函数代码进行我们未来对 Clang 前端内部结构的探索:
1 int max(int a, int b) {
2 if (a > b)
3 return a;
4 return b;
5 }
图 2.29:max
函数的源代码:max.cpp
通过使用 -cc1
选项,我们可以直接调用 Clang 前端,绕过驱动程序。这种方法允许我们更详细地检查和分析 Clang 前端的内部工作原理。
2.4.2 预处理器
第一部分是 Lexer,在 Clang 中被称为预处理器。其主要目标是把输入程序转换成令牌流。您可以使用以下 -dump-tokens
选项打印令牌流:
$ <...>/llvm-project/install/bin/clang -cc1 -dump-tokens max.cpp
命令的输出如下所示:
int ’int’ [StartOfLine] Loc=<max.cpp:1:1>
identifier ’max’ [LeadingSpace] Loc=<max.cpp:1:5>
l_paren ’(’ Loc=<max.cpp:1:8>
int ’int’ Loc=<max.cpp:1:9>
identifier ’a’ [LeadingSpace] Loc=<max.cpp:1:13>
comma ’,’ Loc=<max.cpp:1:14>
int ’int’ [LeadingSpace] Loc=<max.cpp:1:16>
identifier ’b’ [LeadingSpace] Loc=<max.cpp:1:20>
r_paren ’)’ Loc=<max.cpp:1:21>
l_brace ’{’ [LeadingSpace] Loc=<max.cpp:1:23>
if ’if’ [StartOfLine] [LeadingSpace] Loc=<max.cpp:2:3>
l_paren ’(’ [LeadingSpace] Loc=<max.cpp:2:6>
identifier ’a’ Loc=<max.cpp:2:7>
greater ’>’ [LeadingSpace] Loc=<max.cpp:2:9>
identifier ’b’ [LeadingSpace] Loc=<max.cpp:2:11>
r_paren ’)’ Loc=<max.cpp:2:12>
return ’return’ [StartOfLine] [LeadingSpace] Loc=<max.cpp:3:5>
identifier ’a’ [LeadingSpace] Loc=<max.cpp:3:12>
semi ’;’ Loc=<max.cpp:3:13>
return ’return’ [StartOfLine] [LeadingSpace] Loc=<max.cpp:4:3>
identifier ’b’ [LeadingSpace] Loc=<max.cpp:4:10>
semi ’;’ Loc=<max.cpp:4:11>
r_brace ’}’ [StartOfLine] Loc=<max.cpp:5:1>
eof ’’ Loc=<max.cpp:5:2>
图 2.30:Clang 输出令牌
如我们所见,存在不同类型的令牌,例如语言关键字(例如 int
、return
)、标识符(例如 max
、a
、b
等)和特殊符号(例如分号、逗号等)。我们小程序的令牌被称为 普通 令牌,由令牌化器返回。
除了普通令牌外,Clang 还有一种称为 注解令牌 的额外令牌类型。主要区别是这些令牌还存储了额外的语义
信息。例如,一系列普通令牌可以被解析器替换为包含类型或 C++ 范围信息的单个注解令牌。使用此类令牌的主要原因是为了性能,因为它允许在解析器需要回溯时防止重新解析。
由于注解令牌用于解析器的内部实现,因此考虑一个使用 LLDB 的示例可能会有所帮助。假设我们有以下 C++ 代码:
1 namespace clangbook {
2 template <typename T> class A {};
3 } // namespace clangbook
4 clangbook::A<int> a;
图 2.31:使用注解标记的源代码:annotation.cpp
代码的最后一行声明了变量 a
,其类型如下:
clangbook``::``A``<``int``>
. 该类型表示为一个注解标记,如下面的 LLDB 会话所示:
1$ lldb <...>/llvm-project/install/bin/clang -- -cc1 annotation.cpp
2 ...
3 (lldb) b clang::Parser::ConsumeAnnotationToken
4 ...
5 (lldb) r
6 ...
7 608 }
8 609
9 610 SourceLocation ConsumeAnnotationToken() {
10 -> 611 assert(Tok.isAnnotation() && "wrong consume method");
11 612 SourceLocation Loc = Tok.getLocation();
12 613 PrevTokLocation = Tok.getAnnotationEndLoc();
13 614 PP.Lex(Tok);
14 (lldb) p Tok.getAnnotationRange().printToString(PP.getSourceManager())
15 (std::string) "<annotation.cpp:4:1, col:17>"
图 2.32:annotation.cpp 的 LLDB 会话
如我们所见,Clang 从 图 2.31 中显示的程序的第 4 行消耗了一个注解标记。该标记位于第 1 列和第 7 列之间。参见 图 2.32。这对应于以下用作标记的文本:clangbook``::``A``<``int``>
. 该标记由其他标记组成,例如 'clangbook'、'::' 等。将所有标记组合在一起将显著简化解析并提高整体解析性能。
图 2.33:预处理器(Clang 令牌化器)类内部结构
C/C++语言有一些特定的方面影响了Preprocessor
类的内部实现。第一个是关于宏的。Preprocessor
类有两个不同的辅助类来检索令牌:
-
Lexer
类用于将文本缓冲区转换为令牌流。 -
TokenLexer
类用于从宏展开中检索令牌。
应该注意的是,一次只能激活这些辅助类中的一个。
C/C++的另一个特定方面是#include
指令(这也适用于导入指令)。在这种情况下,我们需要维护一个包含栈,其中每个包含可以有自己的TokenLexer
或Lexer
,这取决于其中是否包含宏展开。因此,Preprocessor
类为每个#include
指令保留了一个令牌化器栈(IncludeMacroStack
类),如图 2.33 所示。
2.4.3 解析器和语义分析
解析器和语义分析是 Clang 编译器前端的关键组件。它们处理源代码的语法和语义分析,输出 AST(抽象语法树)。可以使用以下命令可视化我们的测试程序:
$ <...>/llvm-project/install/bin/clang -cc1 -ast-dump max.cpp
此命令的输出如下:
TranslationUnitDecl 0xa9cb38 <<invalid sloc>> <invalid sloc>
|-TypedefDecl 0xa9d3a8 <<invalid sloc>> <invalid sloc>
implicit __int128_t ’__int128’
| ‘-BuiltinType 0xa9d100 ’__int128’
...
‘-FunctionDecl 0xae6a98 <max.cpp:1:1, line:5:1> line:1:5 max
’int (int, int)’
|-ParmVarDecl 0xae6930 <col:9, col:13> col:13 used a ’int’
|-ParmVarDecl 0xae69b0 <col:16, col:20> col:20 used b ’int’
‘-CompoundStmt 0xae6cd8 <col:23, line:5:1>
|-IfStmt 0xae6c70 <line:2:3, line:3:12>
| |-BinaryOperator 0xae6c08 <line:2:7, col:11> ’bool’ ’>’
| | |-ImplicitCastExpr 0xae6bd8 <col:7> ’int’ <LValueToRValue>
| | | ‘-DeclRefExpr 0xae6b98 <col:7> ’int’ lvalue ParmVar 0xae6930
’a’ ’int’
| | ‘-ImplicitCastExpr 0xae6bf0 <col:11> ’int’ <LValueToRValue>
| | ‘-DeclRefExpr 0xae6bb8 <col:11> ’int’ lvalue ParmVar 0xae69b0
’b’ ’int’
| ‘-ReturnStmt 0xae6c60 <line:3:5, col:12>
| ‘-ImplicitCastExpr 0xae6c48 <col:12> ’int’ <LValueToRValue>
| ‘-DeclRefExpr 0xae6c28 <col:12> ’int’ lvalue ParmVar 0xae6930
’a’ ’int’
‘-ReturnStmt 0xae6cc8 <line:4:3, col:10>
‘-ImplicitCastExpr 0xae6cb0 <col:10> ’int’ <LValueToRValue>
‘-DeclRefExpr 0xae6c90 <col:10> ’int’ lvalue ParmVar 0xae69b0
’b’ ’int’
图 2.34:Clang AST 转储输出
Clang 使用手写的递归下降解析器 [10]。这个解析器可以被认为是简单的,这种简单性是其选择的关键原因之一。此外,为 C/C++语言指定的复杂规则需要一个具有易于适应规则的临时解析器。
让我们通过我们的示例来探索它是如何工作的。解析从顶级声明开始,称为TranslationUnitDecl
,代表一个单独的翻译单元。C++标准将翻译单元定义为如下 [21, lex.separate]:
一个源文件,包括所有头文件(16.5.1.2)和通过预处理指令#include 包含的源文件(15.3),但不包括任何由条件包含(15.2)预处理指令跳过的源代码行,被称为翻译单元。
解析器首先识别出源代码的初始令牌对应于 C++标准中定义的函数定义 [21, dcl.fct.def.general]:
function-definition :
... declarator ... function-body
...
图 2.35:C++标准中的函数定义
相应的代码如下:
1int max(...) {
2 ...
3 }
图 2.36:与 C++标准中函数定义相对应的示例代码的一部分
函数定义需要声明符和函数体。我们将从声明符开始,它在 C++标准中定义为 [21, dcl.decl.general]:
declarator:
...
... parameters-and-qualifiers ...
...
parameters-and-qualifiers:
( parameter-declaration-clause ) ...
...
parameter-declaration-clause:
parameter-declaration-list ...
parameter-declaration-list:
parameter-declaration
parameter-declaration-list , parameter-declaration
图 2.37:C++标准中的声明符定义
换句话说,声明符在括号内指定了一系列参数声明。相应的源代码片段如下:
1... (int a, int b)
2 ...
图 2.38:与 C++标准中声明符相对应的示例代码的一部分
如上所述,函数定义还需要一个函数体。C++标准如下指定了函数体:[21, dcl.fct.def.general]
function-body:
... compound-statement
...
图 2.39: C++标准的函数体定义
因此,函数体由一个复合语句组成,这在 C++标准中如下定义 [21, stmt.block]:
compound-statement:
{ statement-seq ... }
statement-seq:
statement
statement-seq statement
图 2.40: C++标准的复合语句定义
因此,它描述了一个被{...
括号包围的语句序列。
我们程序有两种类型的语句:条件语句(if
)和返回语句。这些在 C++语法定义中如下表示 [21, stmt.pre]:
statement:
...
selection-statement
...
jump-statement
...
图 2.41: C++标准的语句定义
在此上下文中,selection
语句对应于我们程序中的if
条件,而jump
语句对应于return
运算符。
让我们更详细地检查jum
语句 [21, stmt.jump.general]:
jump-statement:
...
return expr-or-braced-init-list;
...
图 2.42: C++标准的跳转语句定义
其中expr-or-braced-init-list
定义为 [21, dcl.init.general]:
expr-or-braced-init-list:
expression
...
图 2.43: C++标准的返回表达式定义
在此上下文中,return
关键字后面跟着一个表达式和一个分号。在我们的情况下,有一个隐式转换表达式,它自动将变量转换为所需类型(int
)。
通过 LLDB 调试器检查解析器的操作可能会有所启发:
$ lldb <...>/llvm-project/install/bin/clang -- -cc1 max.cpp
调试会话输出显示在图 2.44。如您所见,在行 1,我们已为返回语句的解析设置了断点。我们的程序有两个返回语句。我们跳过了第一个调用(行 4)并在第二个方法调用处停止(行 9)。从行 13的bt
命令的回溯显示了解析过程的调用栈。这个栈反映了我们之前描述的解析块,遵循[21, lex.separate]中详细描述的 C++语法。
1(lldb) b clang::Parser::ParseReturnStatement
2 (lldb) r
3 ...
4 (lldb) c
5 ...
6 * thread #1, name = ’clang’, stop reason = breakpoint 1.1
7 frame #0: ... clang::Parser::ParseReturnStatement(...) ...
8 2421 StmtResult Parser::ParseReturnStatement() {
9 -> 2422 assert((Tok.is(tok::kw_return) || Tok.is(tok::kw_co_return)) &&
10 2423 "Not a return stmt!");
11 2424 bool IsCoreturn = Tok.is(tok::kw_co_return);
12 2425 SourceLocation ReturnLoc = ConsumeToken(); // eat the ’return’.
13 (lldb) bt
14 * frame #0: ... clang::Parser::ParseReturnStatement( ...
15 ...
16 frame #2: ... clang::Parser::ParseStatementOrDeclaration( ...
17 frame #3: ... clang::Parser::ParseCompoundStatementBody( ...
18 frame #4: ... clang::Parser::ParseFunctionStatementBody( ...
19 frame #5: ... clang::Parser::ParseFunctionDefinition( ...
20 ...
图 2.44: 在 max.cpp 示例程序中解析第二个返回语句
解析结果生成 AST。我们还可以使用调试器检查 AST 的创建过程。为此,我们需要在clang::ReturnStmt::Create
方法设置相应的断点:
1$ lldb <...>/llvm-project/install/bin/clang -- -cc1 max.cpp
2 ...
3 (lldb) b clang::ReturnStmt::Create
4 (lldb) r
5 ...
6 (lldb) c
7 ...
8 * thread #1, name = ’clang’, stop reason = breakpoint 1.1
9 frame #0: ... clang::ReturnStmt::Create(...) at Stmt.cpp:1205:8
10 1202
11 1203 ReturnStmt *ReturnStmt::Create(const ASTContext &Ctx, SourceLocation RL,
12 1204 Expr *E, const VarDecl *NRVOCandidate) {
13 -> 1205 bool HasNRVOCandidate = NRVOCandidate != nullptr;
14 1206 ...
15 1207 ...
16 1208 return new (Mem) ReturnStmt(RL, E, NRVOCandidate);
17 (lldb) bt
18 * thread #1, name = ’clang’, stop reason = breakpoint 1.1
19 * frame #0: ... clang::ReturnStmt::Create( ...
20 frame #1: ... clang::Sema::BuildReturnStmt( ...
21 frame #2: ... clang::Sema::ActOnReturnStmt( ...
22 frame #3: ... clang::Parser::ParseReturnStatement( ...
23 frame #4: ... clang::Parser::ParseStatementOrDeclarationAfterAttributes( ...
24 ...
图 2.45: 在clang::ReturnStmt::Create
处的断点
如所见,返回语句的 AST 节点是由 Sema 组件创建的。
返回语句解析器的开始可以定位在帧 4:
1(lldb) f 4
2 frame #4: ... clang::Parser::ParseStatementOrDeclarationAfterAttributes( ...
3 323 SemiError = "break";
4 324 break;
5 325 case tok::kw_return: // C99 6.8.6.4: return-statement
6 -> 326 Res = ParseReturnStatement();
7 327 SemiError = "return";
8 328 break;
9 329 case tok::kw_co_return: // C++ Coroutines: ...
10 (lldb)
图 2.46: 在调试器中解析返回语句
如我们所见,有一个对相应语句的 C99 标准的引用 [25]。该标准 [25] 提供了关于该语句及其处理过程的详细描述。
代码假设当前标记的类型为tok::kw_return
,在这种情况下,解析器调用相关的clang::Parser::ParseReturnStatement
方法。
虽然 AST 节点创建的过程在不同 C++结构中可能有所不同,但它通常遵循图 2.47 中显示的模式。
图 2.47: Clang 前端 C++解析
在图 2.47 中,方框表示相应的类,函数调用表示为带有调用函数作为边标签的边。可以看出,Parser
调用Preprocessor::Lex
方法从词法分析器检索一个标记。然后它调用与标记相对应的方法,例如,对于标记XXX
,调用Parser::ParseXXX
。然后该方法调用Sema::ActOnXXX
,使用XXX::Create
创建相应的对象。然后使用新的标记重复此过程。
通过这种方式,我们现在已经完全探索了 Clang 中典型编译器前端流程的实现。我们可以看到词法分析器组件(预处理程序)如何与解析器(包括解析器和语义组件)协同工作,以生成未来代码生成的初级数据结构:抽象语法树(AST)。AST 不仅对代码生成至关重要,也对代码分析和修改至关重要。Clang 提供了对 AST 的便捷访问,从而使得开发各种编译器工具成为可能。
2.5 概述
在本章中,我们获得了对编译器架构的基本理解,并深入探讨了编译过程的各个阶段,重点关注 Clang 驱动程序。我们探索了 Clang 前端内部,研究了将程序转换为一系列标记的预处理程序,以及与称为“Sema”的组件交互的解析器。这些元素共同执行语法和语义分析。
下一章将专注于 Clang 抽象语法树(AST)——Clang 工具中使用的首要数据结构。我们将讨论其构建和遍历它的方法。
2.6 进一步阅读
-
C++编程语言标准草案:
eel.is/c++draft/
-
“Clang” CFE 内部手册:
clang.llvm.org/docs/InternalsManual.html
-
Keith Cooper 和 Linda Torczon:《编译器工程》,2012 [18]
第三章:Clang AST
任何编译器的解析阶段都会生成一个解析树,抽象语法树 (AST) 是在给定输入程序的解析过程中生成的基本算法结构。AST 作为 Clang 前端的框架,也是各种 Clang 工具(包括代码检查器)的主要工具。Clang 提供了用于搜索(或匹配)各种 AST 节点的复杂工具。这些工具使用 领域特定语言 (DSL) 实现。理解其实现对于有效地使用这些工具至关重要。
我们将从 Clang 构建 AST 所使用的基层数据结构和类层次结构开始。此外,我们还将探索用于 AST 遍历的方法,并突出一些在遍历过程中帮助节点匹配的辅助类。我们将涵盖以下主题:
-
用于构建 AST 的基本块
-
如何遍历 AST
-
递归访问者是基本的 AST 遍历工具
-
AST 匹配器和它们在辅助 AST 遍历中的作用
-
Clang-Query 作为探索 AST 内部的基本工具
-
编译错误及其对 AST 的影响
3.1 技术要求
本章的源代码位于本书 GitHub 仓库的 chapter3
文件夹中:github.com/PacktPublishing/Clang-Compiler-Frontend-Packt/tree/main/chapter3
。
3.2 AST
AST 通常被表示为树形结构,其叶节点对应于各种对象,例如函数声明和循环体。通常,AST 表示语法分析的结果,即解析。Clang 的 AST 节点被设计为不可变的。这种设计要求 Clang AST 存储语义分析的结果,这意味着 Clang AST 代表了语法和语义分析的结果。
重要注意事项
虽然 Clang 也使用 AST,但值得注意的是,Clang 的 AST 不是一个真正的树。存在反向边使得“图”这个词更适合描述 Clang 的 AST。
在 C++ 中实现的典型树结构具有所有节点都从基类派生。Clang 采用不同的方法。它将不同的 C++ 构造分成不同的组,并为每个组提供基本类:
-
语句:
clang::Stmt
是所有语句的基本类。这包括普通语句,如if
语句(clang::IfStmt
类),以及表达式和其他 C++ 构造。 -
声明:
clang::Decl
是声明的基类。这包括变量、typedef、函数、结构体等。还有一个用于具有上下文声明的单独基类,即可能包含其他声明的声明。此类称为clang::DeclContext
。clang::DeclContext
中包含的声明可以通过clang::DeclContext::decls
方法访问。翻译单元(clang::TranslationUnitDecl
类)和命名空间(clang::NamespaceDecl
类)是具有上下文声明的典型示例。 -
类型:C++有一个丰富的类型系统。它包括基本类型,如用于整数的
int
,以及自定义定义的类型和通过typedef
或using
进行的类型重定义。C++中的类型可以有诸如const
之类的限定符,并且可以表示不同的内存寻址模式,即指针、引用等。Clang 使用clang::Type
作为 AST 中类型表示的基本类。
值得注意的是,组之间存在额外的关系。例如,继承自clang::Stmt
的clang::DeclStmt
类有检索相应声明的功能。此外,继承自clang::Stmt
的表达式(由clang::Expr
类表示)有处理类型的方法。让我们详细看看所有这些组。
3.2.1 语句
Stmt
是所有语句的基本类。语句可以组合成两组(见图 3.1)。第一组包含带有值的语句,而与之相反的组是用于不带值的语句。
图 3.1:Clang AST:语句
不带值的语句组包括不同的 C++构造,例如if
语句(clang::IfStmt
类)或复合语句(clang::CompoundStmt
类)。所有语句中的大多数都归入这一组。
带有值的语句组由一个基类clang::ValueStmt
组成,它有几个子类,如clang::LabelStmt
(用于标签表示)或clang::ExprStmt
(用于表达式表示),见图 3.2。
图 3.2:Clang AST:带有值的语句
3.2.2 声明
声明也可以组合成两个主要组:具有上下文和无上下文的声明。具有上下文的声明可以被认为是其他声明的占位符。例如,C++命名空间以及翻译单元或函数声明可能包含其他声明。友元实体声明(clang::DeclFriend
)可以被认为是无上下文声明的例子。
必须注意的是,从DeclContext
继承的类也有clang::Decl
作为它们的顶级父类。
一些声明可以被重新声明,如下面的例子所示:
1 extern int a;
2 int a = 1;
图 3.3: 声明示例:redeclaration.cpp
这样的声明有一个额外的父类,通过 clang``::``Redeclarable``<...>
模板实现。
3.2.3 类型
C++是一种静态类型语言,这意味着变量的类型必须在编译时声明。类型允许编译器对程序的意义做出合理的推断,这使得类型成为语义分析的重要组成部分。clang``::``Type
是 Clang 中类型的基类。
C/C++中的类型可能有被称为 CV-限定符的限定符,如标准[21,basic.type.qualifier)] 所述。在这里,CV 代表两个关键字 const
和 volatile
,它们可以用作类型的限定符。
重要提示
C99 标准有一个额外的类型限定符 restrict
,Clang 也支持它[25,6.7.3]。类型限定符指示编译器,在指针的生命周期内,不会使用其他指针来访问它所指向的对象。这允许编译器执行诸如向量化等优化,否则这些优化是不可能的。restrict
有助于限制指针别名效应,即当多个指针引用相同的内存位置时发生的效应,从而有助于优化。然而,如果程序员的意图声明没有得到遵循,并且对象被独立指针访问,则会导致未定义的行为。
Clang 有一个特殊类来支持具有限定符的类型,clang``::``QualType
,它是一个指向 clang``::``Type
的指针和一个包含类型限定符信息的位掩码。该类有一个方法来检索指向 clang``::``Type
的指针并检查不同的限定符。以下代码(LLVM 18.x,clang/lib/AST/ExprConstant.cpp
,行 3918)展示了我们如何检查具有 const 限定符的类型:
bool checkConst(QualType QT) {
// Assigning to a const object has undefined behavior.
if (QT.isConstQualified()) {
Info.FFDiag(E, diag::note_constexpr_modify_const_type) << QT;
return false;
}
return true;
}
图 3.4: 从 clang/lib/AST/ExprConstant.cpp 的 checkConst 实现
值得注意的是,clang``::``QualType
实现了 operator``->()
和 operator``*()
,这意味着它可以被视为底层 clang``::``Type
类的智能指针。
除了限定符之外,类型还可以有额外的信息,表示不同的内存地址模型。例如,可以有一个指向对象的指针或引用。clang``::``Type
有以下辅助方法来检查不同的地址模型:
-
`clang
::
Type::
isPointerType``() 用于检查指针类型 -
`clang
::
Type::
isReferenceType``() 用于检查引用类型
C/C++中的类型也可以使用别名,这些别名是通过使用 typedef
或 using
关键字引入的。以下代码将 foo
和 bar
定义为 int
类型的别名。
1using foo = int;
2 typedef int bar;
图 3.5: 类型别名声明
原始类型,在我们的例子中是int
,被称为规范类型。你可以使用clang::QualType::isCanonical()
方法测试类型是否为规范类型。clang::QualType
还提供了一个方法来从别名中检索规范类型:clang::QualType::getCanonicalType()
。
在了解了 Clang 中用于 AST 的基本块之后,现在是时候研究如何使用这些块进行 AST 遍历了。这是编译器和编译器工具使用的基本操作,我们将在整本书中广泛使用它。
3.3 AST 遍历
编译器需要遍历 AST 以生成 IR 代码。因此,对于 AST 设计来说,拥有一个良好的树遍历数据结构至关重要。换句话说,AST 的设计应优先考虑便于树遍历。在许多系统中,一个标准的方法是为所有 AST 节点提供一个公共基类。这个类通常提供了一个方法来检索节点的子节点,允许使用如广度优先搜索(BFS)19 等流行算法进行树遍历。然而,Clang 采取了不同的方法:它的 AST 节点没有共同的祖先。这提出了一个问题:在 Clang 中树遍历是如何组织的?
Clang 采用了三种独特的技术:
-
用于访问者类定义的奇特重复模板模式(CRTP)
-
针对不同节点定制的临时方法
-
宏,可以被视为临时方法和 CRTP 之间的连接层
我们将通过一个简单的程序来探索这些技术,该程序旨在识别函数定义并显示函数名及其参数。
3.3.1 DeclVisitor 测试工具
我们的测试工具将建立在clang::DeclVisitor
类之上,该类被定义为一个简单的访问者类,有助于创建 C/C++声明的访问者。
我们将使用与我们的第一个 Clang 工具创建相同的 CMake 文件(参见图 1.13)。新工具的唯一添加是clangAST
库。结果CMakeLists.txt
文件如图 3.6 所示:
2 project("declvisitor")
3
4 if ( NOT DEFINED ENV{LLVM_HOME})
5 message(FATAL_ERROR "$LLVM_HOME is not defined")
6 else()
7 message(STATUS "$LLVM_HOME found: $ENV{LLVM_HOME}")
8 set(LLVM_HOME $ENV{LLVM_HOME} CACHE PATH "Root of LLVM installation")
9 set(LLVM_LIB ${LLVM_HOME}/lib)
10 set(LLVM_DIR ${LLVM_LIB}/cmake/llvm)
11 find_package(LLVM REQUIRED CONFIG)
12 include_directories(${LLVM_INCLUDE_DIRS})
13 link_directories(${LLVM_LIBRARY_DIRS})
14 set(SOURCE_FILE DeclVisitor.cpp)
15 add_executable(declvisitor ${SOURCE_FILE})
16 set_target_properties(declvisitor PROPERTIES COMPILE_FLAGS "-fno-rtti")
17 target_link_libraries(declvisitor
18 LLVMSupport
19 clangAST
20 clangBasic
21 clangFrontend
22 clangSerialization
23 clangTooling
24 )
图 3.6: DeclVisitor 测试工具的 CMakeLists.txt 文件
我们工具的main
函数如下所示:
1 #include "clang/Tooling/CommonOptionsParser.h"
2 #include "clang/Tooling/Tooling.h"
3 #include "llvm/Support/CommandLine.h" // llvm::cl::extrahelp
4
5 #include "FrontendAction.hpp"
6
7 namespace {
8 llvm::cl::OptionCategory TestCategory("Test project");
9 llvm::cl::extrahelp
10 CommonHelp(clang::tooling::CommonOptionsParser::HelpMessage);
11 } // namespace
12
13 int main(int argc, const char **argv) {
14 llvm::Expected<clang::tooling::CommonOptionsParser> OptionsParser =
15 clang::tooling::CommonOptionsParser::create(argc, argv, TestCategory);
16 if (!OptionsParser) {
17 llvm::errs() << OptionsParser.takeError();
18 return 1;
19 }
20 clang::tooling::ClangTool Tool(OptionsParser->getCompilations(),
21 OptionsParser->getSourcePathList());
22 return Tool.run(clang::tooling::newFrontendActionFactory<
23 clangbook::declvisitor::FrontendAction>()
24 .get());
25 }
图 3.7: DeclVisitor 测试工具的主函数
从第 5 行和第 23 行可以看出,我们使用了针对我们项目定制的自定义前端操作:clangbook::declvisitor::FrontendAction
。
下面是这个类的代码:
1 #include "Consumer.hpp"
2 #include "clang/Frontend/FrontendActions.h"
3
4 namespace clangbook {
5 namespace declvisitor {
6 class FrontendAction : public clang::ASTFrontendAction {
7 public:
8 virtual std::unique_ptr<clang::ASTConsumer>
9 CreateASTConsumer(clang::CompilerInstance &CI,
10 llvm::StringRef File) override {
11 return std::make_unique<Consumer>();
12 }
13 };
14 } // namespace declvisitor
15 } // namespace clangbook
图 3.8: DeclVisitor 测试工具的自定义 FrontendAction 类
你会注意到我们已覆盖了clang::ASTFrontendAction
类中的CreateASTConsumer
函数,以实例化我们自定义的 AST 消费者类Consumer
,该类定义在clangbook::declvisitor
命名空间中,如图 3.8 中突出显示的第 9-12 行。
类的实现如下:
1 #include "Visitor.hpp"
2 #include "clang/Frontend/ASTConsumers.h"
3
4 namespace clangbook {
5 namespace declvisitor {
6 class Consumer : public clang::ASTConsumer {
7 public:
8 Consumer() : V(std::make_unique<Visitor>()) {}
9
10 virtual void HandleTranslationUnit(clang::ASTContext &Context) override {
11 V->Visit(Context.getTranslationUnitDecl());
12 }
13
14 private:
15 std::unique_ptr<Visitor> V;
16 };
17 } // namespace declvisitor
18 } // namespace clangbook
图 3.9:DeclVisitor 测试工具的消费者类
在这里,我们可以看到我们创建了一个示例访问者,并使用clang::ASTConsumer
类中的重写方法HandleTranslationUnit
来调用它(参见图 3.9,第 11 行)。
然而,最引人入胜的部分是访问者的代码:
1 #include "clang/AST/DeclVisitor.h"
2
3 namespace clangbook {
4 namespace declvisitor {
5 class Visitor : public clang::DeclVisitor<Visitor> {
6 public:
7 void VisitFunctionDecl(const clang::FunctionDecl *FD) {
8 llvm::outs() << "Function: ’" << FD->getName() << "’\n";
9 for (auto Param : FD->parameters()) {
10 Visit(Param);
11 }
12 }
13 void VisitParmVarDecl(const clang::ParmVarDecl *PVD) {
14 llvm::outs() << "\tParameter: ’" << PVD->getName() << "’\n";
15 }
16 void VisitTranslationUnitDecl(const clang::TranslationUnitDecl *TU) {
17 for (auto Decl : TU->decls()) {
18 Visit(Decl);
19 }
20 }
21 };
22 } // namespace declvisitor
23 } // namespace clangbook
图 3.10:访问者类实现
我们将在稍后更深入地探索代码。目前,我们观察到它在第 8 行打印函数名,在第 14 行打印参数名。
我们可以使用与测试项目相同的命令序列来编译我们的程序,如第 1.4 节中详细说明的,测试项目 – 使用 Clang 工具进行语法检查。
export LLVM_HOME=<...>/llvm-project/install
mkdir build
cd build
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug ...
ninja
图 3.11:DeclVisitor 测试工具的配置和构建命令
如你所注意到的,我们为 CMake 使用了-DCMAKE_BUILD_TYPE=Debug
选项。我们使用的选项将降低整体性能,但我们使用它是因为我们可能想要在调试器下调查生成的程序。
重要提示
我们为我们的工具使用的构建命令假设所需的库安装在了<...>/llvm-project/install
文件夹下,这是在 CMake 配置命令期间通过-DCMAKE_INSTALL_PREFIX
选项指定的,如第 1.4 节中所述,测试项目 – 使用 Clang 工具进行语法检查。参见图 1.12:
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=../install -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_ENABLE_PROJECTS="clang" -DLLVM_USE_LINKER=gold -DLLVM_USE_SPLIT_DWARF=ON -DBUILD_SHARED_LIBS=ON ../llvm
必须使用ninja install
命令安装所需的构建工件。
我们将使用我们在之前的调查中引用的程序(参见图 2.5)来研究 AST 遍历:
1 int max(int a, int b) {
2 if (a > b)
3 return a;
4 return b;
5 }
图 3.12:测试程序 max.cpp
该程序由一个名为max
的单个函数组成,它接受两个参数a
和b
,并返回这两个数中的最大值。
我们可以按照以下方式运行我们的程序:
$ ./declvisitor max.cpp -- -std=c++17
...
Function: ’max’
Parameter: ’a’
Parameter: ’b’
图 3.13:在测试文件上运行 declvisitor 实用程序的结果
重要提示
我们在图 3.13 中使用了- -
来向编译器传递额外的参数,具体是指定我们想要使用 C++17,并使用选项-std=c++17
。我们也可以传递其他编译器参数。另一种选择是使用-p
选项指定编译数据库路径,如下所示:
$ ./declvisitor max.cpp -p <path>
在这里,<path>
是包含编译数据库的文件夹的路径。你可以在第九章附录 1:编译数据库中找到更多关于编译数据库的信息。
让我们详细调查Visitor
类的实现。
3.3.2 访问者实现
让我们深入探讨Visitor
代码(参见图 3.10)。首先,你会注意到一个不寻常的结构,即我们的类是从一个由我们的类参数化的基类派生的:
5 class Visitor : public clang::DeclVisitor<Visitor> {
图 3.14:访问者类声明
这个结构被称为“奇特重复模板模式”,或简称 CRTP。
当遇到相应的 AST 节点时,Visitor 类有几个回调会被触发。第一个回调针对的是表示函数声明的 AST 节点:
7 void VisitFunctionDecl(const clang::FunctionDecl *FD) {
8 llvm::outs() << "Function: ’" << FD->getName() << "’\n";
9 for (auto Param : FD->parameters()) {
10 Visit(Param);
11 }
12 }
图 3.15: FunctionDecl 回调
如图 3.15 所示,函数名在第 8 行打印出来。我们接下来的步骤是打印参数名。为了检索函数参数,我们可以利用clang::FunctionDecl
类的parameters()
方法。这个方法之前被提及为 AST 遍历的临时方法。每个 AST 节点都提供自己的方法来访问子节点。由于我们有一个特定类型的 AST 节点(即clang::FunctionDecl
*)作为参数,我们可以使用这些方法。
函数参数被传递到基类clang::DeclVisitor<>
的Visit(...)
方法,如图 3.15 中的第 12 行所示。这个调用随后被转换成另一个回调,专门针对clang::ParmVarDecl
AST 节点:
13 void VisitParmVarDecl(const clang::ParmVarDecl *PVD) {
14 llvm::outs() << "\tParameter: ’" << PVD->getName() << "’\n";
15 }
图 3.16: ParmVarDecl 回调
你可能想知道这种转换是如何实现的。答案是 CRTP 和 C/C++宏的组合。为了理解这一点,我们需要深入了解clang::DeclVisitor<>
类的Visit()
方法实现。这个实现严重依赖于 C/C++宏,因此要查看实际代码,我们必须展开这些宏。这可以通过使用shell
-E 编译器选项来完成。让我们对CMakeLists.txt
做一些修改,并引入一个新的自定义目标。
25 add_custom_command(
26 OUTPUT ${SOURCE_FILE}.preprocessed
27 COMMAND ${CMAKE_CXX_COMPILER} -E -I ${LLVM_HOME}/include ${CMAKE_CURRENT_SOURCE_DIR}/${SOURCE_FILE} > ${SOURCE_FILE}.preprocessed
28 DEPENDS ${SOURCE_FILE}
29 COMMENT "Preprocessing ${SOURCE_FILE}"
30 )
31 add_custom_target(preprocess ALL DEPENDS ${SOURCE_FILE}.preprocessed)
图 3.17: 自定义目标以扩展宏
我们可以按照以下方式运行目标:
$ ninja preprocess
生成的文件可以位于之前指定的构建文件夹中,命名为DeclVisitor.cpp.preprocessed
。包含该文件的构建文件夹是我们之前在执行 cmake 命令时指定的(参见图 3.11)。在这个文件中,Visit()
方法的生成代码如下所示:
1RetTy Visit(typename Ptr<Decl>::type D) {
2 switch (D->getKind()) {
3 ...
4 case Decl::ParmVar: return static_cast<ImplClass*>(this)->VisitParmVarDecl(static_cast<typename Ptr<ParmVarDecl>::type>(D));
5 ...
6 }
7 }
图 3.18: Visit()方法的生成代码
这段代码展示了在 Clang 中使用 CRTP。在此上下文中,CRTP 被用来回退到我们的Visitor
类,该类被引用为ImplClass
。CRTP 允许基类从继承的类中调用方法。这种模式可以作为虚拟函数的替代方案,并提供了几个优点,其中最显著的是与性能相关的优点。具体来说,方法调用是在编译时解决的,消除了与虚拟方法调用相关的 vtable 查找的需要。
代码是使用 C/C++宏生成的,如这里所示。这个特定的代码来源于clang/include/clang/AST/DeclVisitor.h
头文件:
34 #define DISPATCH(NAME, CLASS) \
35 return static_cast<ImplClass*>(this)->Visit##NAME(static_cast<PTR(CLASS)>(D))
图 3.19: 从clang/include/clang/AST/DeclVisitor.h
中的 DISPATCH 宏定义
图 3.19 中的NAME
被节点名替换;在我们的例子中,它是ParmVarDecl
。
DeclVisitor
用于遍历 C++声明。Clang 还有StmtVisitor
和TypeVisitor
分别用于遍历语句和类型。这些访问者基于与我们示例中的声明访问者相同的原理。然而,这些访问者存在一些问题。它们只能与特定的 AST 节点组一起使用。例如,DeclVisitor
只能与Decl
类的后代一起使用。另一个限制是我们需要实现递归。例如,我们在第 9-11 行设置了递归以遍历函数声明(参见图 3.10)。相同的递归被用于遍历翻译单元内的声明(参见图 3.10,第 17-19 行)。这又提出了另一个问题:可能会错过递归的一些部分。例如,如果max
函数声明在命名空间内部指定,我们的代码将无法正确运行。为了解决此类场景,我们需要实现一个额外的特定于命名空间声明的访问方法。
这些挑战将由递归访问者解决,我们将在稍后讨论。
3.4 递归 AST 访问者
递归 AST 访问者解决了观察到的专用访问者的局限性。我们将创建相同的程序,该程序搜索并打印函数声明及其参数,但这次我们将使用递归访问者。
递归访问者测试工具的CMakeLists.txt
将以前类似的方式使用。只有项目名称(图 3.20 中的第 2 行和第 15-17 行)和源文件名(图 3.20 中的第 14 行)已更改:
1 cmake_minimum_required(VERSION 3.16)
2 project("recursivevisitor")
3
4 if ( NOT DEFINED ENV{LLVM_HOME})
5 message(FATAL_ERROR "$LLVM_HOME is not defined")
6 else()
7 message(STATUS "$LLVM_HOME found: $ENV{LLVM_HOME}")
8 set(LLVM_HOME $ENV{LLVM_HOME} CACHE PATH "Root of LLVM installation")
9 set(LLVM_LIB ${LLVM_HOME}/lib)
10 set(LLVM_DIR ${LLVM_LIB}/cmake/llvm)
11 find_package(LLVM REQUIRED CONFIG)
12 include_directories(${LLVM_INCLUDE_DIRS})
13 link_directories(${LLVM_LIBRARY_DIRS})
14 set(SOURCE_FILE RecursiveVisitor.cpp)
15 add_executable(recursivevisitor ${SOURCE_FILE})
16 set_target_properties(recursivevisitor PROPERTIES COMPILE_FLAGS "-fno-rtti")
17 target_link_libraries(recursivevisitor
18 LLVMSupport
19 clangAST
20 clangBasic
21 clangFrontend
22 clangSerialization
23 clangTooling
24 )
25 endif()
图 3.20:RecursiveVisitor 测试工具的 CMakeLists.txt 文件
我们工具的main
函数与图 3.7 中定义的‘DeclVisitor’类似。
1 #include "clang/Tooling/CommonOptionsParser.h"
2 #include "clang/Tooling/Tooling.h"
3 #include "llvm/Support/CommandLine.h" // llvm::cl::extrahelp
4
5 #include "FrontendAction.hpp"
6
7 namespace {
8 llvm::cl::OptionCategory TestCategory("Test project");
9 llvm::cl::extrahelp
10 CommonHelp(clang::tooling::CommonOptionsParser::HelpMessage);
11 } // namespace
12
13 int main(int argc, const char **argv) {
14 llvm::Expected<clang::tooling::CommonOptionsParser> OptionsParser =
15 clang::tooling::CommonOptionsParser::create(argc, argv, TestCategory);
16 if (!OptionsParser) {
17 llvm::errs() << OptionsParser.takeError();
18 return 1;
19 }
20 clang::tooling::ClangTool Tool(OptionsParser->getCompilations(),
21 OptionsParser->getSourcePathList());
22 return Tool.run(clang::tooling::newFrontendActionFactory<
23 clangbook::recursivevisitor::FrontendAction>()
24 .get());
25 }
图 3.21:RecursiveVisitor 测试工具的主函数
如您所见,我们仅在第 23 行更改了自定义前端动作的命名空间名称。
前端动作和消费者的代码与图 3.8 和图 3.9 中的相同,唯一的区别是将命名空间从declvisitor
更改为recursivevisitor
。程序中最有趣的部分是Visitor
类的实现。
1 #include "clang/AST/RecursiveASTVisitor.h"
2
3 namespace clangbook {
4 namespace recursivevisitor {
5 class Visitor : public clang::RecursiveASTVisitor<Visitor> {
6 public:
7 bool VisitFunctionDecl(const clang::FunctionDecl *FD) {
8 llvm::outs() << "Function: ’" << FD->getName() << "’\n";
9 return true;
10 }
11 bool VisitParmVarDecl(const clang::ParmVarDecl *PVD) {
12 llvm::outs() << "\tParameter: ’" << PVD->getName() << "’\n";
13 return true;
14 }
15 };
16 } // namespace recursivevisitor
17 } // namespace clangbook
图 3.22:Visitor 类实现
与“DeclVisitor”的代码相比,有几个变化(参见图 3.10)。第一个变化是未实现递归。我们只实现了对我们感兴趣的节点回调。一个合理的问题出现了:递归是如何控制的?答案是另一个变化:我们的回调现在返回一个布尔结果。false
值表示递归应停止,而true
表示访问者应继续遍历。
程序可以使用与我们之前使用的相同命令序列进行编译。参见图 3.11。
我们可以像以下这样运行我们的程序,见图 3.23:
$ ./recursivevisitor max.cpp -- -std=c++17
...
Function: ’max’
Parameter: ’a’
Parameter: ’b’
图 3.23:在测试文件上运行 recursivevisitor 实用程序的结果
如我们所见,它产生了与使用 DeclVisitor 实现获得的结果相同的结果。到目前为止考虑的 AST 遍历技术并不是 AST 遍历的唯一方法。我们后面考虑的大多数工具将使用基于 AST 匹配器的不同方法。
3.5 AST 匹配器
AST 匹配器 [16] 提供了定位特定 AST 节点的另一种方法。它们在搜索不正确的模式使用时特别有用,或者在重构工具中识别要修改的 AST 节点时也很有用。
我们将创建一个简单的程序来测试 AST 匹配。程序将识别一个名为 max
的函数定义。我们将使用之前示例中略微修改的 CMakeLists.txt
文件来包含支持 AST 匹配所需的库:
1 cmake_minimum_required(VERSION 3.16)
2 project("matchvisitor")
3
4 if ( NOT DEFINED ENV{LLVM_HOME})
5 message(FATAL_ERROR "$LLVM_HOME is not defined")
6 else()
7 message(STATUS "$LLVM_HOME found: $ENV{LLVM_HOME}")
8 set(LLVM_HOME $ENV{LLVM_HOME} CACHE PATH "Root of LLVM installation")
9 set(LLVM_LIB ${LLVM_HOME}/lib)
10 set(LLVM_DIR ${LLVM_LIB}/cmake/llvm)
11 find_package(LLVM REQUIRED CONFIG)
12 include_directories(${LLVM_INCLUDE_DIRS})
13 link_directories(${LLVM_LIBRARY_DIRS})
14 set(SOURCE_FILE MatchVisitor.cpp)
15 add_executable(matchvisitor ${SOURCE_FILE})
16 set_target_properties(matchvisitor PROPERTIES COMPILE_FLAGS "-fno-rtti")
17 target_link_libraries(matchvisitor
18 LLVMFrontendOpenMP
19 LLVMSupport
20 clangAST
21 clangASTMatchers
22 clangBasic
23 clangFrontend
24 clangSerialization
25 clangTooling
26 )
27 endif()
图 3.24:AST 匹配器测试工具的 CMakeLists.txt
增加了两个额外的库:LLVMFrontendOpenMP
和 clangASTMatchers
(见图 3.24 中的第 18 和 21 行)。我们的工具的 main
函数如下所示:
1 #include "clang/Tooling/CommonOptionsParser.h"
2 #include "clang/Tooling/Tooling.h"
3 #include "llvm/Support/CommandLine.h" // llvm::cl::extrahelp
4 #include "MatchCallback.hpp"
5
6 namespace {
7 llvm::cl::OptionCategory TestCategory("Test project");
8 llvm::cl::extrahelp
9 CommonHelp(clang::tooling::CommonOptionsParser::HelpMessage);
10 } // namespace
11
12 int main(int argc, const char **argv) {
13 llvm::Expected<clang::tooling::CommonOptionsParser> OptionsParser =
14 clang::tooling::CommonOptionsParser::create(argc, argv, TestCategory);
15 if (!OptionsParser) {
16 llvm::errs() << OptionsParser.takeError();
17 return 1;
18 }
19 clang::tooling::ClangTool Tool(OptionsParser->getCompilations(),
20 OptionsParser->getSourcePathList());
21 clangbook::matchvisitor::MatchCallback MC;
22 clang::ast_matchers::MatchFinder Finder;
23 Finder.addMatcher(clangbook::matchvisitor::M, &MC);
24 return Tool.run(clang::tooling::newFrontendActionFactory(&Finder).get());
25 }
图 3.25:AST 匹配器测试工具的 main 函数
如您所观察到的 (第 21-23 行), 我们使用了 MatchFinder
类并定义了一个自定义回调(通过标题在第 4 行包含),该回调概述了我们打算匹配的特定 AST 节点。回调的实现如下:
1 #include "clang/ASTMatchers/ASTMatchFinder.h"
2 #include "clang/ASTMatchers/ASTMatchers.h"
3
4 namespace clangbook {
5 namespace matchvisitor {
6 using namespace clang::ast_matchers;
7 static const char *MatchID = "match-id";
8 clang::ast_matchers::DeclarationMatcher M =
9 functionDecl(decl().bind(MatchID), matchesName("max"));
10
11 class MatchCallback : public clang::ast_matchers::MatchFinder::MatchCallback {
12 public:
13 virtual void
14 run(const clang::ast_matchers::MatchFinder::MatchResult &Result) final {
15 if (const auto *FD = Result.Nodes.getNodeAs<clang::FunctionDecl>(MatchID)) {
16 const auto &SM = *Result.SourceManager;
17 const auto &Loc = FD->getLocation();
18 llvm::outs() << "Found ’max’ function at " << SM.getFilename(Loc) << ":"
19 << SM.getSpellingLineNumber(Loc) << ":"
20 << SM.getSpellingColumnNumber(Loc) << "\n";
21 }
22 }
23 };
24
25 } // namespace matchvisitor
26 } // namespace clangbook
图 3.26:AST 匹配器测试工具的匹配回调
代码中最关键的部分位于第 7-9 行。每个匹配器都有一个 ID,在我们的情况下是’match-id’。匹配器本身在第 8-9 行定义:
8 clang::ast_matchers::DeclarationMatcher M =
9 functionDecl(decl().bind(MatchID), matchesName("max"));
此匹配器寻找具有特定名称的函数声明,使用 functionDecl``()
,如 matchesName``()
中所示。我们利用专门的领域特定语言(DSL)来指定匹配器。DSL 是通过 C++宏实现的。我们也可以创建自己的匹配器,如第 7.3.3 节,检查实现所示。值得注意的是,递归 AST 访问者作为匹配器实现中 AST 遍历的骨干。
程序可以使用我们之前使用的相同命令序列进行编译。见图 3.11。
我们将使用图 2.5 中显示的示例的略微修改版本,并添加一个额外的函数:
1 int max(int a, int b) {
2 if (a > b) return a;
3 return b;
4 }
5
6 int min(int a, int b) {
7 if (a > b) return b;
8 return a;
9 }
图 3.27:用于 AST 匹配器的测试程序 minmax.cpp
当我们在示例上运行我们的测试工具时,我们将获得以下输出:
./matchvisitor minmax.cpp -- -std=c++17
...
Found the ’max’ function at minmax.cpp:1:5
图 3.28:在测试文件上运行 matchvisitor 实用程序的结果
如我们所见,它只找到了一个具有匹配器指定名称的函数声明。
匹配器的 DSL 通常用于自定义 Clang 工具,如 clang-tidy(如第第五章**,Clang-Tidy Linter Framework)中讨论的),但它也可以作为一个独立的工具使用。一个名为clang-query
的专用程序可以执行不同的匹配查询,这些查询可以用来在分析过的 C++代码中搜索特定的 AST 节点。让我们看看这个工具是如何工作的。
3.6 使用 clang-query 探索 Clang AST
AST 匹配器非常有用,有一个工具可以方便地检查各种匹配器并分析你的源代码的 AST。这个工具被称为clang-query
工具。你可以使用以下命令构建和安装这个工具:
$ ninja install-clang-query
图 3.29: clang-query 的安装
你可以按照以下方式运行这个工具:
$ <...>/llvm-project/install/bin/clang-query minmax.cpp
图 3.30: 在测试文件上运行 clang-query
我们可以使用以下match
命令:
clang-query> match functionDecl(decl().bind("match-id"), matchesName("max"))
Match #1:
minmax.cpp:1:1: note: "match-id" binds here
int max(int a, int b) {
^~~~~~~~~~~~~~~~~~~~~~~
minmax.cpp:1:1: note: "root" binds here
int max(int a, int b) {
^~~~~~~~~~~~~~~~~~~~~~~
1 match.
clang-query>
图 3.31: 使用 clang-query 进行操作
图 3.31 展示了默认输出,被称为’diag’
。在几种可能的输出中,对我们来说最相关的一个是’dump’
。当输出设置为’dump’
时,clang-query 将显示找到的 AST 节点。例如,以下展示了如何匹配名为a
的函数参数:
clang-query> set output dump
clang-query> match parmVarDecl(hasName("a"))
Match #1:
Binding for "root":
ParmVarDecl 0x6775e48 <minmax.cpp:1:9, col:13> col:13 used a ’int’
Match #2:
Binding for "root":
ParmVarDecl 0x6776218 <minmax.cpp:6:9, col:13> col:13 used a ’int’
2 matches.
clang-query>
图 3.32: 使用 dump 输出进行 clang-query 操作
当你想测试特定的匹配器或调查 AST 树的一部分时,这个工具非常有用。我们将使用这个工具来探索 Clang 如何处理编译错误。
3.7 错误情况下处理 AST
Clang 最有趣的特点之一与错误处理相关。错误处理包括错误检测、显示相应的错误消息以及潜在的错误恢复。后者在 Clang AST 方面尤其引人入胜。当 Clang 在遇到编译错误时不会停止,而是继续编译以检测更多问题时,就会发生错误恢复。
这种行为有各种好处。最明显的一个是用户便利性。当程序员编译程序时,他们通常希望在一次编译运行中尽可能多地了解错误。如果编译器在第一个错误处停止,程序员将不得不纠正该错误,重新编译,然后解决后续的错误,并再次重新编译,依此类推。这个过程可能会很繁琐和令人沮丧,尤其是在较大的代码库或复杂的错误中。虽然这种行为对编译语言如 C/C++特别有用,但值得注意的是,解释型语言也表现出这种行为,这可以帮助用户逐步处理错误。
另一个令人信服的原因集中在 IDE 集成上,这将在第八章中更详细地讨论,即 IDE 支持和 Clangd。IDEs 提供结合了集成编译器的导航支持。我们将探讨clangd
作为此类工具之一。在 IDE 中编辑代码通常会导致编译错误。大多数错误局限于代码的特定部分,在这种情况下停止导航可能不是最优的。
Clang 在错误恢复方面采用了各种技术。对于解析的语法阶段,它使用启发式方法;例如,如果用户忘记插入分号,Clang 可能会尝试将其作为恢复过程的一部分添加。恢复阶段可以缩写为 DIRT,其中 D 代表删除一个字符(例如,多余的分号),I 代表插入一个字符(如示例所示),R 代表替换(替换一个字符以匹配特定令牌),T 代表转置(重新排列两个字符以匹配令牌)。
如果可能,Clang 将执行完全恢复,并生成一个与修改后的文件相对应的 AST,其中所有编译错误都已修复。最有趣的情况是当无法进行完全恢复时,Clang 在创建 AST 时实施独特的错误恢复管理技术。
考虑一个程序(maxerr.cpp),它在语法上是正确的,但存在语义错误。例如,它可能使用了未声明的变量。在这个程序中,参考第 3 行,其中使用了未声明的变量ab
:
1 int max(int a, int b) {
2 if (a > b) {
3 return ab;
4 }
5 return b;
6 }
图 3.33:包含语义错误(未声明的变量)的 maxerr.cpp 测试程序
我们对 Clang 生成的 AST 结果感兴趣,我们将使用clang-query
来检查它,可以按照以下方式运行:
$ <...>/llvm-project/install/bin/clang-query maxerr.cpp
...
maxerr.cpp:3:12: error: use of undeclared identifier ’ab’
return ab;
^
图 3.34:编译错误示例
从输出中,我们可以看到 clang-query 显示了编译器检测到的编译错误。值得注意的是,尽管如此,程序仍然生成了一个 AST,我们可以检查它。我们特别感兴趣的是返回语句,并可以使用相应的匹配器突出显示 AST 的相关部分。
我们还将设置输出以生成 AST 并搜索我们感兴趣的返回语句:
clang-query> set output dump
clang-query> match returnStmt()
图 3.35:设置返回语句的匹配器
生成的输出识别出我们程序中的两个返回语句:第一个匹配在第 5 行,第二个匹配在第 3 行:
Match #1:
Binding for "root":
ReturnStmt 0x6b63230 <maxerr.cpp:5:3, col:10>
‘-ImplicitCastExpr 0x6b63218 <col:10> ’int’ <LValueToRValue>
‘-DeclRefExpr 0x6b631f8 <col:10> ’int’ lvalue ParmVar 0x6b62ec8 ’b’ ’int’
Match #2:
Binding for "root":
ReturnStmt 0x6b631b0 <maxerr.cpp:3:5, col:12>
‘-RecoveryExpr 0x6b63190 <col:12> ’<dependent type>’ contains-errors lvalue
2 matches.
图 3.36:在 maxerr.cpp 测试程序中匹配 ReturnStmt 节点
正如我们所见,第一个匹配对应于第 5 行的语义正确代码,并包含对 a
参数的引用。第二个匹配对应于第 3 行,该行存在编译错误。值得注意的是,Clang 插入了一种特殊的 AST 节点:RecoveryExpr
。值得注意的是,在某些情况下,Clang 可能会生成一个不完整的 AST。这可能会影响 Clang 工具,如 lint 检查。在编译错误的情况下,lint 检查可能会产生意外的结果,因为 Clang 无法从编译错误中准确恢复。我们将在探索第五章(Clang-Tidy Linter Framework)的 clang-tidy lint 检查框架时重新审视这个问题。
3.8 摘要
我们探讨了 Clang AST,这是创建各种 Clang 工具的主要工具。我们了解了 Clang AST 实现所选择的架构设计原则,并研究了 AST 遍历的不同方法。我们深入研究了专门的遍历技术,例如针对 C/C++ 声明的遍历技术,还探讨了更通用的技术,这些技术使用了递归访问者和 Clang AST 匹配器。我们的探索以 clang-query
工具结束,并讨论了如何使用它来探索 Clang AST。具体来说,我们用它来理解 Clang 如何处理编译错误。
下一章将讨论在 Clang 和 LLVM 开发中使用的库。我们将探讨 LLVM 代码风格和基础 Clang/LLVM 类,例如 SourceManager
和 SourceLocation
。我们还将介绍用于代码生成的 TableGen 库和 LLVM 集成测试 (LIT) 框架。
3.9 进一步阅读
-
如何编写 RecursiveASTVisitor:
clang.llvm.org/docs/RAVFrontendAction.html
第四章:基础库和工具
LLVM 使用 C++ 语言编写,截至 2022 年 7 月,它使用的是 C++17 版本的 C++ 标准 [6]。LLVM 主动利用 标准模板库 (STL) 提供的功能。另一方面,LLVM 包含了许多针对基本容器的内部实现 [13],主要目的是优化性能。例如,llvm::SmallVector
具有与 std::vector
类似的接口,但具有内部优化的实现。因此,熟悉这些扩展对于希望与 LLVM 和 Clang 一起工作的人来说是必不可少的。
此外,LLVM 还引入了其他开发工具,如 TableGen,这是一个用于结构数据处理 领域特定语言 (DSL),以及 LIT(LLVM 集成测试器),LLVM 测试框架。关于这些工具的更多细节将在本章后面讨论。本章将涵盖以下主题:
-
LLVM 编码风格
-
LLVM 基础库
-
Clang 基础库
-
LLVM 支持工具
-
Clang 插件项目
我们计划使用一个简单的示例项目来展示这些工具。该项目将是一个 Clang 插件,用于估计 C++ 类的复杂度。如果一个类的函数数量超过作为参数指定的阈值,则认为该类是复杂的。虽然这种复杂性的定义可能被认为是微不足道的,但我们将在 第六章 高级代码分析 中探讨更复杂的复杂性定义。
4.1 技术要求
本章的源代码位于本书 GitHub 仓库的 chapter4
文件夹中:github.com/PacktPublishing/Clang-Compiler-Frontend-Packt/tree/main/chapter4
。
4.2 LLVM 编码风格
LLVM 遵循特定的代码风格规则 [11]。这些规则的主要目标是促进熟练的 C++ 实践,特别关注性能。如前所述,LLVM 使用 C++17 并倾向于使用 STL(即 标准模板库)中的数据结构和算法。另一方面,LLVM 提供了许多与 STL 中类似的数据结构的优化版本。例如,llvm::SmallVector<>
可以被视为 std::vector<>
的优化版本,尤其是在向量大小较小时,这是编译器中使用的数据结构的一个常见特性。
在选择 STL 对象/算法及其对应的 LLVM 版本之间,LLVM 编码标准建议优先选择 LLVM 版本。
其他规则与性能限制相关的问题有关。例如,运行时类型信息 (RTTI) 和 C++ 异常都是不允许的。然而,在某些情况下,RTTI 可能会证明是有益的;因此,LLVM 提供了如 llvm::isa<>
和其他类似模板辅助函数的替代方案。更多关于此的信息可以在第 4.3.1 节**,RTTI 替换和转换运算符中找到。而不是使用 C++ 异常,LLVM 经常使用 C 风格的 assert
s。
有时,断言信息不足。LLVM 建议向它们添加文本消息以简化调试。以下是从 Clang 代码中的一个典型示例:
static bool unionHasUniqueObjectRepresentations(const ASTContext &Context,
const RecordDecl *RD,
bool CheckIfTriviallyCopyable) {
assert(RD->isUnion() && "Must be union type");
CharUnits UnionSize = Context.getTypeSizeInChars(RD->getTypeForDecl());
图 4.1:在 clang/lib/AST/ASTContext.cpp 中的 assert() 使用
在代码中,我们检查第二个参数(RD
)是否为联合类型,如果不是,则抛出一个带有相应信息的断言。
除了性能考虑之外,LLVM 还引入了一些额外的要求。其中之一是关于注释的要求。代码注释非常重要。此外,LLVM 和 Clang 都有从代码生成的全面文档。他们使用 Doxygen (www.doxygen.nl/
) 来实现这一点。这个工具是 C/C++ 程序注释的事实标准,你很可能之前已经遇到过它。
Clang 和 LLVM 不是单一的大块代码;相反,它们被实现为一组库。这种设计在代码和功能重用方面提供了优势,我们将在第八章**,IDE 支持和 Clangd中探讨这些优势。这些库也是 LLVM 代码风格执行的优秀示例。让我们详细检查这些库。
4.3 LLVM 基本库
我们将从 LLVM 代码中的 RTTI 替换开始,讨论其实现方式。然后,我们将继续讨论基本容器和智能指针。最后,我们将讨论一些用于表示标记位置的重要类以及 Clang 中诊断的实现方式。稍后,在第 4.6 节**,Clang 插件项目中,我们将在我们的测试项目中使用这些类。
4.3.1 RTTI 替换和转换运算符
如前所述,LLVM 由于性能考虑而避免使用 RTTI。LLVM 引入了几种辅助函数来替代 RTTI 对应的函数,允许将对象从一个类型转换为另一个类型。基本的有以下几种:
-
llvm::isa<>
类似于 Java 的java instanceof
运算符。它根据测试对象的引用是否属于测试的类返回true
或false
。 -
llvm::cast<>
:当你确定对象是特定派生类型时,使用此转换运算符。如果转换失败(即对象不是预期的类型),llvm::cast
将终止程序。仅在确信转换不会失败时使用。 -
llvm``::``dyn_cast``<>
: 这可能是 LLVM 中最常用的类型转换运算符。llvm``::``dyn_cast
用于在预期转换通常成功但存在一些不确定性的情况下进行安全的向下转换。如果对象不是指定的派生类型,llvm``::``dyn_cast``<>
返回nullptr
。
类型转换运算符不接受 nullptr
作为输入。然而,有两个特殊的类型转换运算符可以处理空指针:
-
llvm``::``cast_if_present``<>
:llvm``::``cast``<>
的一个变体,接受nullptr
值 -
llvm``::``dyn_cast_if_present``<>
:llvm``::``dyn_cast``<>
的一个变体,接受nullptr
值
这两个运算符都可以处理 nullptr
值。如果输入是 nullptr
或转换失败,它们将简单地返回 nullptr
。
重要提示
值得注意的是,类型转换运算符 llvm``::``cast_if_present``<>
和 llvm``:
:``dyn_cast_if_present``<>
是最近引入的,具体是在 2022 年。它们作为流行的 llvm``::``cast_or_null``<>
和 llvm``::``dyn_cast_or
_null``<>
的替代品,后者最近已被使用。旧版本仍然得到支持,并且现在将调用重定向到新的类型转换运算符。有关更多信息,请参阅关于此更改的讨论:discourse.llvm.org/t/psa-swapping-out-or-null-with-if-present/65018
.
可能会提出以下问题:如何在没有 RTTI 的情况下执行动态转换操作?这可以通过某些特定的装饰来实现,如下面一个简单的例子所示,该例子受到 如何为你的类层次结构设置 LLVM 风格的 RTTI [14] 的启发。我们将从一个基类 clangbook``::``Animal
开始,该类有两个派生类:clangbook``::``Horse
和 clangbook``::``Sheep
。每匹马可以通过其速度(英里/小时)进行分类,而每只羊可以通过其羊毛质量进行分类。以下是它的用法:
46 void testAnimal() {
47 auto AnimalPtr = std::make_unique<clangbook::Horse>(10);
48 if (llvm::isa<clangbook::Horse>(AnimalPtr)) {
49 llvm::outs()
50 << "Animal is a Horse and the horse speed is: "
51 << llvm::dyn_cast<clangbook::Horse>(AnimalPtr.get())->getSpeed()
52 << "mph \n";
53 } else {
54 llvm::outs() << "Animal is not a Horse\n";
55 }
56 }
图 4.2: LLVM isa``<>
和 dyn_cast``<>
使用示例
代码应生成以下输出:
Animal is a Horse and the horse speed is: 10mph
图 4.2 中的 第 48 行 展示了 llvm``::``isa``<>
的用法,而 第 51 行 展示了 llvm``::``dyn_cast``<>
的用法。在后一个例子中,我们将基类转换为 clangbook``::``Horse
并调用该类特定的方法。
让我们来看看类实现,这将提供关于 RTTI 替换如何工作的见解。我们将从基类 clangbook``::``Animal
开始:
9 class Animal {
10 public:
11 enum AnimalKind { AK_Horse, AK_Sheep };
12
13 public:
14 Animal(AnimalKind K) : Kind(K){};
15 AnimalKind getKind() const { return Kind; }
16
17 private:
18 const AnimalKind Kind;
19 };
图 4.3: clangbook``::``Animal
类
最关键的部分是前面代码中的 第 11 行。它指定了不同的 ”种类” 的动物。一个枚举值用于马 (AK_Horse
),另一个用于羊 (AK_Sheep
)。因此,基类对其派生类有一些了解。clangbook``::``Horse
和 clangbook``::``Sheep
类的实现可以在以下代码中找到:
21 class Horse : public Animal {
22 public:
23 Horse(int S) : Animal(AK_Horse), Speed(S){};
24
25 static bool classof(const Animal *A) { return A->getKind() == AK_Horse; }
26
27 int getSpeed() { return Speed; }
28
29 private:
30 int Speed;
31 };
32
33 class Sheep : public Animal {
34 public:
35 Sheep(int WM) : Animal(AK_Sheep), WoolMass(WM){};
36
37 static bool classof(const Animal *A) { return A->getKind() == AK_Sheep; }
38
39 int getWoolMass() { return WoolMass; }
40
41 private:
42 int WoolMass;
43 };
图 4.4: clangbook``::``Horse
和 clangbook``::``Sheep
类
第 25 行和第 37 行 特别重要,因为它们包含了 classof
静态方法实现。这个方法对于 LLVM 中的类型转换操作至关重要。一个典型的实现可能看起来像以下(简化版本):
1template <typename To, typename From>
2 bool isa(const From *Val) {
3 return To::classof(Val);
4 }
图 4.5:llvm::isa<>
的简化实现
同样的机制可以应用于其他类型转换操作。
我们接下来将讨论各种类型的容器,它们是相应 STL 对应容器的更强大的替代品。
4.3.2 容器
LLVM ADT(代表抽象数据类型)库提供了一套容器。虽然其中一些是 LLVM 独有的,但其他一些可以被认为是 STL 容器的替代品。我们将探讨 ADT 提供的一些最受欢迎的类。
字符串操作
在标准 C++ 库中,用于处理字符串的主要类是 std::string
。尽管这个类被设计成通用的,但它有一些与性能相关的问题。一个重要的问题涉及到复制操作。由于在编译器中复制字符串是一个常见的操作,LLVM 引入了一个专门的类,llvm::StringRef
,它以高效的方式处理这个操作,而不使用额外的内存。这个类与 C++17 中的 std::string_view
[20] 和 C++20 中的 std::span
[21] 相当。
llvm::StringRef
类维护对数据的引用,不需要像传统的 C/C++ 字符串那样以空字符终止。它本质上持有指向数据块的指针和块的大小,使得对象的有效大小为 16 字节。由于 llvm::StringRef
保留的是引用而不是实际数据,它必须从一个现有的数据源构建。这个类可以从基本字符串对象,如 const char*
、std::string
和 std::string_view
实例化。默认构造函数创建一个空对象。llvm::StringRef
的典型使用示例在 图 4.6 中展示:
1 #include "llvm/ADT/StringRef.h"
2 ...
3 llvm::StringRef StrRef("Hello, LLVM!");
4 // Efficient substring, no allocations
5 llvm::StringRef SubStr = StrRef.substr(0, 5);
6
7 llvm::outs() << "Original StringRef: " << StrRef.str() << "\n";
8 llvm::outs() << "Substring: " << SubStr.str() << "\n";
图 4.6:llvm::StringRef
使用示例
代码的输出如下所示:
Original StringRef: Hello, LLVM!
Substring: Hello
在 LLVM 中用于字符串操作的另一类是 llvm::Twine
,它在将多个对象连接成一个对象时特别有用。该类的典型使用示例在 图 4.7 中展示:
1 #include "llvm/ADT/Twine.h"
2 ...
3 llvm::StringRef Part1("Hello, ");
4 llvm::StringRef Part2("Twine!");
5 llvm::Twine Twine = Part1 + Part2; // Efficient concatenation
6
7 // Convert twine to a string (actual allocation happens here)
8 std::string TwineStr = Twine.str();
9 llvm::outs() << "Twine result: " << TwineStr << "\n";
图 4.7:llvm::Twine
使用示例
代码的输出如下所示:
Twine result: Hello, Twine!
另一个广泛用于字符串操作的类是 llvm::SmallString<>
。它表示一个堆栈分配的字符串,大小固定,但也可以超出这个大小,此时它会堆分配内存。这是堆栈分配的空间效率和堆分配的灵活性之间的结合。
llvm::SmallString<>
的优势在于,在许多场景中,尤其是在编译器任务中,字符串往往很小,可以适应栈分配的空间。这避免了动态内存分配的开销。但在需要更大字符串的情况下,llvm::SmallString
仍然可以通过切换到堆内存来容纳。一个典型的使用示例显示在图 4.8 中:
1 #include "llvm/ADT/SmallString.h"
2 ...
3 // Stack allocate space for up to 20 characters.
4 llvm::SmallString<20> SmallStr;
5
6 // No heap allocation happens here.
7 SmallStr = "Hello, ";
8 SmallStr += "LLVM!";
9
10 llvm::outs() << "SmallString result: " << SmallStr << "\n";
图 4.8:llvm::SmallString<>
使用示例
尽管字符串操作在文本解析等编译器任务中至关重要,但 LLVM 还有许多其他辅助类。我们将接下来探讨其顺序容器。
顺序容器
LLVM 推荐一些针对标准库中的数组和向量的优化替代方案。最显著的是:
-
llvm::ArrayRef<>
:一个为接受元素顺序列表进行只读访问的接口设计的辅助类。该类类似于llvm::StringRef<>
,因为它不拥有底层数据,而只是引用它。 -
llvm::SmallVector<>
:一种针对小尺寸情况的优化向量。它类似于在第 4.3.2 节**,字符串操作*中讨论的llvm::SmallString
。值得注意的是,数组的大小不是固定的,允许存储的元素数量增长。如果元素数量保持在N
(模板参数)以下,则不需要额外的内存分配。
让我们通过图 4.9 来检查llvm::SmallVector<>
,以更好地理解这些容器:
1 llvm::SmallVector<int, 10> SmallVector;
2 for (int i = 0; i < 10; i++) {
3 SmallVector.push_back(i);
4 }
5 SmallVector.push_back(10);
图 4.9:llvm::SmallVector<>
使用
向量在行 1初始化,选择了大小为 10(由第二个模板参数指示)。该容器提供了一个类似于std::vector<>
的 API,使用熟悉的push_back
方法添加新元素,如图 4.9,行 3 和 5所示。
前十个元素被添加到向量中,而不需要额外的内存分配(参见图 4.9,行 2-4)。然而,当第 11 个元素在行 5被添加时,数组的大小超过了为 10 个元素预先分配的空间,从而触发了额外的内存分配。这种容器设计有效地最小化了小对象的内存分配。
同时保持灵活性,以便在必要时容纳更大的大小。
类似于映射的容器
标准库提供了几个用于存储键值数据的容器。最常见的是std::map<>
用于通用映射和std::unordered_map<>
用于哈希映射。LLVM 为这些标准容器提供了额外的替代方案:
-
llvm``::``StringMap``<>
: 使用字符串作为键的映射。通常,这比标准的关联容器std``::``unordered_map``<``std``::``string``,
T``>
性能优化得更好。它常用于字符串键占主导地位且性能至关重要的场景,例如在 LLVM 这样的编译器基础设施中。与 LLVM 中的许多其他数据结构不同,llvm``::``StringMap``<>
不存储字符串键的副本。相反,它保留对字符串数据的引用,因此确保字符串数据比映射存在的时间长是防止未定义行为的关键。 -
llvm``::``DenseMap``<>
: 这个映射在大多数情况下比std``::``unordered_map``<>
更节省内存和时间,尽管它带来了一些额外的限制(例如,键和值具有平凡的析构函数)。当你有简单的键值类型并且需要高性能的查找时,它特别有益。 -
llvm``::``SmallDenseMap``<>
: 这个映射类似于llvm``::``DenseMap``<>
,但针对映射大小通常较小的情况进行了优化。对于小映射,它从栈上分配,只有当映射超过预定义大小时才回退到堆分配。 -
llvm``::``MapVector``<>
: 这个容器保留了插入顺序,类似于 Python 的OrderedDict
。它实现为std``::``vector
和llvm``::``DenseMap
或llvm``::``SmallDenseMap
的混合。
值得注意的是,这些容器使用的是二次探测的哈希表机制。这种方法在解决哈希冲突时非常有效,因为在查找元素时不会重新计算缓存。这对于性能关键的应用程序,如编译器来说至关重要。
4.3.3 智能指针
在 LLVM 代码中可以找到不同的智能指针。最受欢迎的是来自标准模板库的:std``::``unique_ptr``<>
和 std``::``shared_ptr``<>
。此外,LLVM 提供了一些辅助类来与智能指针一起使用。其中最突出的是 llvm``::``IntrusiveRefCntPtr``<>
。这个智能指针旨在与支持侵入式引用计数的对象一起使用。与维护自己的控制块以管理引用计数的 std``::``shared_ptr
不同,IntrusiveRefCntPtr
预期对象维护自己的引用计数。这种设计可以更节省内存。这里展示了典型的使用示例:
1 class MyClass : public llvm::RefCountedBase<MyClass> {
2 // ...
3 };
4
5 llvm::IntrusiveRefCntPtr<MyClass> Ptr = new MyClass();
图 4.10: llvm``::``IntrusiveRefCntPtr``<>
使用示例
如我们所见,智能指针显著地使用了前面在 第 3.3 节 中提到的 CRTP(Curiously Recurring Template Pattern),即 AST 遍历。CRTP 对于当引用计数降至 0 且对象必须被删除时的 Release
操作至关重要。实现如下:
1template <class Derived> class RefCountedBase {
2 // ...
3 void Release() const {
4 assert(RefCount > 0 && "Reference count is already zero.");
5 if (--RefCount == 0)
6 delete static_cast<const Derived *>(this);
7 }
8 }
图 4.11: 在 llvm``::``RefCountedBase``<>
中的 CRTP 使用。代码来源于 llvm/ADT/IntrusiveRefCntPtr.h
头文件
由于 图 4.10 中的 MyClass
是从 RefCountedBase
派生的,我们可以在 图 4.11 的 第 6 行 上对其执行类型转换。这种转换是可行的,因为已知要转换的类型,它作为模板参数提供。
我们刚刚完成了 LLVM 基础库。现在是我们转向 Clang 基础库的时候了。Clang 是一个编译器前端,其最重要的操作与诊断相关。诊断需要关于源代码中位置精确的信息。让我们探索 Clang 为这些操作提供的基本类。
4.4 Clang 基础库
Clang 是一个编译器前端,其最重要的操作与诊断相关。诊断需要关于源代码中位置精确的信息。让我们探索 Clang 为这些操作提供的基本类。
4.4.1 SourceManager 和 SourceLocation
Clang 作为编译器,与文本文件(程序)操作,在程序中定位特定位置是请求最频繁的操作之一。让我们看看典型的 Clang 错误报告。考虑来自 第三章* 的一个程序,Clang AST,如 图 3.33 所示。Clang 为该程序生成以下错误消息:
$ <...>/llvm-project/install/bin/clang -fsyntax-only maxerr.cpp
maxerr.cpp:3:12: error: use of undeclared identifier ’ab’
return ab;
^
1 error generated.
图 4.12:maxerr.cpp 中报告的错误
正如我们在 图 4.12 中所看到的,显示消息需要以下信息:
-
文件名:在我们的例子中,它是
maxerr.cpp
-
文件中的行:在我们的例子中,它是
3
-
文件中的列:在我们的例子中,它是
12
存储这些信息的应该尽可能紧凑,因为编译器会频繁使用它。Clang 将所需信息存储在 clang::SourceLocation
对象中。
这个对象经常被使用,因此它应该体积小且复制速度快。我们可以使用 lldb 检查对象的大小。例如,如果我们以调试器运行 Clang,我们可以确定大小如下:
$ lldb <...>/llvm-project/install/clang
...
(lldb) p sizeof(clang::SourceLocation)
(unsigned long) 4
(lldb)
图 4.13:在调试器下确定 clang::SourceLocation 的大小
也就是说,信息是使用单个 unsigned
long
数字编码的。这是如何可能的?这个数字仅仅作为一个文本文件中位置的标识符。需要一个额外的类来正确提取和表示这些信息,即 clang::SourceManager
。SourceManager
对象包含有关特定位置的所有详细信息。在 Clang 中,由于宏、包含和其他预处理指令的存在,管理源位置可能具有挑战性。因此,有几种方式来解释给定的源位置。主要方式如下:
-
拼写位置:指的是在源代码中实际拼写的地方。如果你有一个指向宏体内部的源位置,拼写位置将给出宏内容在源代码中定义的位置。
-
宏展开位置:指宏展开的位置。如果你有一个指向宏体内部的源位置,展开位置将给出宏在源代码中被使用(展开)的位置。
让我们来看一个具体的例子:
1 #define BAR void bar()
2 int foo(int x);
3 BAR;
图 4.14:测试不同类型源位置的示例程序:functions.hpp
在 图 4.14 中,我们定义了两个函数:第 2 行 的 int foo()
和 第 3 行 的 void bar()
。对于第一个函数,拼写和展开位置都指向 第 2 行。然而,对于第二个函数,拼写位置在 第 1 行,而展开位置在 第 3 行。
让我们通过一个测试 Clang 工具来检查这个问题。我们将使用 第 3.4 节 中的测试项目,递归 AST 访问器,并在此处替换一些代码部分。首先,我们必须将 clang::ASTContext
传递给我们的 Visitor
实现中。这是必需的,因为 clang::ASTContext
提供了对 clang::SourceManager
的访问。我们将替换 图 3.8 中的 第 11 行 并按如下方式传递 ASTContext
:
10 CreateASTConsumer(clang::CompilerInstance &CI, llvm::StringRef File) {
11 return std::make_unique<Consumer>(&CI.getASTContext());
Consumer
类(参见图 3.9)将接受参数并将其用作 Visitor
的参数:
8 Consumer(clang::ASTContext *Context)
9 : V(std::make_unique<Visitor>(Context)) {}
主要更改针对 Visitor
类,该类大部分已重写。首先,我们将 clang::ASTContext
传递给类构造函数,如下所示:
5 class Visitor : public clang::RecursiveASTVisitor<Visitor> {
6 public:
7 explicit Visitor(clang::ASTContext *C) : Context(C) {}
图 4.15:Visitor 类实现:构造函数
AST 上下文类存储为我们类的私有成员,如下所示:
25 private:
26 clang::ASTContext *Context;
图 4.16:Visitor 类实现:私有部分
主要处理逻辑在 Visitor::VisitFunctionDecl
方法中,你可以在下面看到:
9 bool VisitFunctionDecl(const clang::FunctionDecl *FD) {
10 clang::SourceManager &SM = Context->getSourceManager();
11 clang::SourceLocation Loc = FD->getLocation();
12 clang::SourceLocation ExpLoc = SM.getExpansionLoc(Loc);
13 clang::SourceLocation SpellLoc = SM.getSpellingLoc(Loc);
14 llvm::StringRef ExpFileName = SM.getFilename(ExpLoc);
15 llvm::StringRef SpellFileName = SM.getFilename(SpellLoc);
16 unsigned SpellLine = SM.getSpellingLineNumber(SpellLoc);
17 unsigned ExpLine = SM.getExpansionLineNumber(ExpLoc);
18 llvm::outs() << "Spelling : " << FD->getName() << " at " << SpellFileName
19 << ":" << SpellLine << "\n";
20 llvm::outs() << "Expansion : " << FD->getName() << " at " << ExpFileName
21 << ":" << ExpLine << "\n";
22 return true;
23 }
图 4.17:Visitor 类实现:VisitFunctionDecl 方法
如果我们在 图 4.14 中的测试文件上编译并运行代码,将生成以下输出:
Spelling : foo at functions.hpp:2
Expansion : foo at functions.hpp:2
Spelling : bar at functions.hpp:1
Expansion : bar at functions.hpp:3
图 4.18:recursivevisitor 可执行程序在 functions.hpp 测试文件上的输出
clang::SourceLocation
和 clang::SourceManager
是非常强大的类。结合其他类,如 clang::SourceRange
(指定源范围开始和结束的两个源位置),它们为 Clang 中使用的诊断提供了一个很好的基础。
4.4.2 诊断支持
Clang 的诊断子系统负责生成和报告警告、错误和其他消息 [8]。涉及的主要类包括:
-
DiagnosticsEngine
:管理诊断 ID 和选项 -
DiagnosticConsumer
: 诊断消费者抽象基类 -
DiagnosticIDs
:处理诊断标志和内部 ID 之间的映射 -
DiagnosticInfo
:表示单个诊断
这里有一个简单的例子,说明了如何在 Clang 中发出警告:
18 // Emit a warning
19 DiagnosticsEngine.Report(DiagnosticsEngine.getCustomDiagID(
20 clang::DiagnosticsEngine::Warning, "This is a custom warning."));
图 4.19:使用 clang::DiagnosticsEngine 发出警告
在我们的例子中,我们将使用一个简单的DiagnosticConsumer
,即clang::TextDiagnosticPrinter
,它格式化和打印处理过的诊断消息。
我们示例的主函数的完整代码显示在图 4.20 中:
7 int main() {
8 llvm::IntrusiveRefCntPtr<clang::DiagnosticOptions> DiagnosticOptions =
9 new clang::DiagnosticOptions();
10 clang::TextDiagnosticPrinter TextDiagnosticPrinter(
11 llvm::errs(), DiagnosticOptions.get(), false);
12
13 llvm::IntrusiveRefCntPtr<clang::DiagnosticIDs> DiagIDs =
14 new clang::DiagnosticIDs();
15 clang::DiagnosticsEngine DiagnosticsEngine(DiagIDs, DiagnosticOptions,
16 &TextDiagnosticPrinter, false);
17
18 // Emit a warning
19 DiagnosticsEngine.Report(DiagnosticsEngine.getCustomDiagID(
20 clang::DiagnosticsEngine::Warning, "This is a custom warning."));
21
22 return 0;
23 }
图 4.20: Clang 诊断示例
代码将产生以下输出
warning: This is a custom warning.
图 4.21: 打印出的诊断信息
在这个例子中,我们首先使用TextDiagnosticPrinter
作为其DiagnosticConsumer
来设置DiagnosticsEngine
。然后我们使用DiagnosticsEngine
的Report
方法来发出一个自定义警告。我们将在创建 Clang 插件的测试项目时添加一个更实际的例子,见第 4.6 节**,Clang 插件 项目。
4.5 LLVM 支持工具
LLVM 项目有自己的工具支持。最重要的 LLVM 工具是 TableGen 和 LIT(代表 LLVM Integrated Tester)。我们将通过 Clang 代码的例子来探讨它们。这些例子应该有助于我们理解工具的目的以及如何使用它们。
4.5.1 TableGen
TableGen 是一种领域特定语言 (DSL)和相关的工具,用于 LLVM 项目中描述和生成表格,特别是描述目标架构的表格。这对于编译器基础设施非常有用,因为经常需要以结构化的方式描述诸如指令集、寄存器以及各种其他特定于目标属性。
TableGen 被用于 Clang 编译器的各个部分。它主要用于需要生成大量相似代码的地方。例如,它可以用于支持需要在大类中进行大量枚举声明的类型转换操作,或者在需要生成代码以处理大量相似诊断信息的诊断子系统中。我们将以 TableGen 在诊断系统中的功能为例进行考察。
我们将从描述 Clang 诊断的Diagnostic.td
文件开始,该文件位于clang/include/clang/Basic/Diagnostic.td
。让我们看看诊断严重性是如何定义的:
16 // Define the diagnostic severities.
17 class Severity<string N> {
18 string Name = N;
19 }
图 4.22: clang/include/clang/Basic/Diagnostic.td 中的严重性定义
在图 4.22 中,我们定义了一个严重性的类(第 17-19 行)。每个严重性都与一个字符串相关联,如下所示:
20 def SEV_Ignored : Severity<"Ignored">;
21 def SEV_Remark : Severity<"Remark">;
22 def SEV_Warning : Severity<"Warning">;
23 def SEV_Error : Severity<"Error">;
24 def SEV_Fatal : Severity<"Fatal">;
图 4.23: clang/include/clang/Basic/Diagnostic.td 中不同严重类型的定义
图 4.23 包含了不同严重性的定义;例如,Warning
严重性在第 22 行被定义。
严重性随后被用来定义Diagnostic
类,其中Warning
诊断被定义为这个类的子类:
// All diagnostics emitted by the compiler are an indirect subclass of this.
class Diagnostic<string summary, DiagClass DC, Severity defaultmapping> {
...
}
...
class Warning<string str> : Diagnostic<str, CLASS_WARNING, SEV_Warning>;
图 4.24: clang/include/clang/Basic/Diagnostic.td 中的诊断定义
使用 Warning
类定义,可以定义类的不同实例。例如,以下是一个定义位于 DiagnosticSemaKinds.td
中的未使用参数警告的实例:
def warn_unused_parameter : Warning<"unused parameter %0">,
InGroup<UnusedParameter>, DefaultIgnore;
图 4.25:在 clang/include/clang/Basic/DiagnosticSemaKinds.td 中未使用参数警告的定义
clang-tblgen
工具将生成相应的 DiagnosticSemaKinds.inc
文件:
DIAG(warn_unused_parameter, CLASS_WARNING, (unsigned)diag::Severity::Ignored, "unused parameter %0", 985, SFINAE_Suppress, false, false, true, false, 2)
图 4.26:在 clang/include/clang/Basic/DiagnosticSemaKinds.inc 中未使用参数警告的定义
此文件保留有关诊断的所有必要信息。这些信息可以通过使用 DIAG
宏的不同定义从 Clang 源代码中检索。
例如,以下代码利用 TableGen 生成的代码来提取诊断描述,如 clang/lib/Basic/DiagnosticIDs.cpp
中所示:
const StaticDiagInfoDescriptionStringTable StaticDiagInfoDescriptions = {
#define DIAG(ENUM, CLASS, DEFAULT_SEVERITY, DESC, GROUP, SFINAE, NOWERROR,\
SHOWINSYSHEADER, SHOWINSYSMACRO, DEFERRABLE, CATEGORY) \
DESC,
...
#include "clang/Basic/DiagnosticSemaKinds.inc"
...
#undef DIAG
};
图 4.27:DIAG 宏定义
C++ 预处理器将扩展为以下内容:
const StaticDiagInfoDescriptionStringTable StaticDiagInfoDescriptions = {
...
"unused parameter %0",
...
};
图 4.28:DIAG 宏展开
提供的示例演示了如何使用 TableGen 在 Clang 中生成代码以及它如何简化 Clang 开发。诊断子系统不是 TableGen 被使用的唯一领域;它还在 Clang 的其他部分被广泛使用。例如,在各种类型的 AST 访问者中使用的宏也依赖于 TableGen 生成的代码;参见 第 3.3.2 节**,访问者 实现。
4.5.2 LLVM 测试框架
LLVM 使用多个测试框架进行不同类型的测试。主要的是 LLVM 集成测试器 (LIT) 和 Google 测试 (GTest) [24]。LIT 和 GTest 都在 Clang 的测试基础设施中扮演着重要角色:
-
LIT 主要用于测试 Clang 工具链的整体行为,重点关注其代码编译能力和产生的诊断信息。
-
GTest 用于单元测试,针对代码库中的特定组件,主要是实用库和内部数据结构。
这些测试对于维护 Clang 项目的质量和稳定性至关重要。
重要提示
我们不会深入探讨 GTest,因为这个测试框架在 LLVM 之外被广泛使用,并且不是 LLVM 本身的一部分。有关 GTest 的更多信息,请访问其官方网站:github.com/google/googletest
我们将重点关注 LIT。LIT 是 LLVM 自有的测试框架,被广泛用于测试 LLVM 中的各种工具和库,包括 Clang 编译器。LIT 被设计为轻量级,并针对编译器测试的需求进行了定制。它通常用于运行本质上为 shell 脚本的测试,通常包含对输出中特定模式的检查。一个典型的 LIT 测试可能包括一个源代码文件以及一组 "RUN" 命令,这些命令指定如何编译、链接或其他方式处理文件,以及预期的输出。
RUN 命令通常使用 FileCheck,LLVM 项目中的另一个实用工具,来检查输出是否符合预期模式。在 Clang 中,LIT 测试通常用于测试前端功能,如解析、语义分析、代码生成和诊断。这些测试通常看起来像源代码文件,其中包含嵌入式注释,指示如何运行测试以及预期结果。
考虑以下来自clang/test/Sema/attr-unknown.c
的示例:
1 // RUN: %clang_cc1 -fsyntax-only -verify -Wattributes %s
2
3 int x __attribute__((foobar)); // expected-warning {{unknown attribute ’foobar’ ignored}}
4 void z(void) __attribute__((bogusattr)); // expected-warning {{unknown attribute ’bogusattr’ ignored}}
图 4.29: 关于未知属性的 Clang 警告的 LIT 测试
示例是一个典型的 C 源代码文件,它可以被 Clang 处理。LIT 的行为由源文本中的注释控制。第一个注释(在第1 行)指定了如何执行测试。如指示,clang
应使用一些额外的参数启动:-fsyntax-only
和-verify
。还有一些以%
符号开始的替换。其中最重要的是%s
,它被源文件名替换。LIT 还会检查以expected-warning
开头的注释,并确保 Clang 输出产生的警告与预期值匹配。
测试可以按以下方式运行:
$ ./build/bin/llvm-lit ./clang/test/Sema/attr-unknown.c
...
-- Testing: 1 tests, 1 workers --
PASS: Clang :: Sema/attr-unknown.c (1 of 1)
Testing Time: 0.06s
Passed: 1
图 4.30: LIT 测试运行
我们从build
文件夹运行llvm-lit
,因为该工具不包括在安装过程中。一旦我们创建了我们的测试 Clang 插件项目并为其配置 LIT 测试,我们就可以获得有关 LIT 设置和其调用的更多详细信息。
4.6 Clang 插件项目
测试项目的目标是创建一个 Clang 插件,该插件将估计类复杂性。具体来说,如果一个类的成员方法数量超过某个阈值,则认为该类是复杂的。我们将利用迄今为止所获得的所有知识来完成此项目。这包括使用递归访问者和 Clang 诊断。此外,我们还将为我们的项目创建一个 LIT 测试。开发插件将需要为 LLVM 创建一个独特的构建配置,这是我们最初的步骤。
4.6.1 环境设置
插件将被创建为一个共享对象,我们的 LLVM 安装应该支持共享库(参见*第 1.3.1 节**,使用 CMake 进行配置):
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug -DCMAKE_INSTALL_PREFIX=../install -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_ENABLE_PROJECTS="clang" -DLLVM_USE_SPLIT_DWARF=ON -DBUILD_SHARED_LIBS=ON ../llvm
图 4.31: Clang 插件项目使用的 CMake 配置
如所示,我们使用第 1.4 节**的构建配置,测试项目 – 使用 Clang 工具进行语法检查,如图图 1.12 所示。在配置中,我们为安装工件设置了一个文件夹到../install
,将我们的构建目标限制在X86
平台,并且只启用clang
项目。此外,我们为调试符号启用大小优化,并使用共享库而不是静态链接。
下一步涉及构建和安装 clang。这可以通过以下命令实现:
$ ninja install
一旦我们完成 clang 的构建和安装,我们就可以继续处理我们项目的CMakeLists.txt
文件。
4.6.2 插件的 CMake 构建配置
我们将使用图 3.20 作为插件构建配置的基础。我们将项目名称更改为classchecker
,ClassComplexityChecker.cpp
将作为我们的主要源文件。文件的主要部分在图 4.32 中展示。如我们所见,我们将构建一个共享库(第 18-20 行),而不是像之前的测试项目那样构建可执行文件。另一个修改是在第 12 行,我们为 LLVM 构建文件夹设置了一个配置参数。这个参数是必要的,以便定位 LIT 可执行文件,正如之前在第 4.5.2 节中提到的,它不包括在标准安装过程中。需要做一些额外的修改来支持 LIT 测试调用,但我们将稍后在第 4.6.8 节中讨论细节,即 LIT 对 clang 插件的测试(参见图 4.44)。
8 message(STATUS "$LLVM_HOME found: $ENV{LLVM_HOME}")
9 set(LLVM_HOME $ENV{LLVM_HOME} CACHE PATH "Root of LLVM installation")
10 set(LLVM_LIB ${LLVM_HOME}/lib)
11 set(LLVM_DIR ${LLVM_LIB}/cmake/llvm)
12 set(LLVM_BUILD $ENV{LLVM_BUILD} CACHE PATH "Root of LLVM build")
13 find_package(LLVM REQUIRED CONFIG)
14 include_directories(${LLVM_INCLUDE_DIRS})
15 link_directories(${LLVM_LIBRARY_DIRS})
16
17 # Add the plugin’s shared library target
18 add_library(classchecker MODULE
19 ClassChecker.cpp
20 )
21 set_target_properties(classchecker PROPERTIES COMPILE_FLAGS "-fno-rtti")
22 target_link_libraries(classchecker
23 LLVMSupport
24 clangAST
25 clangBasic
26 clangFrontend
27 clangTooling
28 )
图 4.32: 类复杂度插件的 CMakeLists.txt 文件
完成构建配置后,我们可以开始编写插件的主体代码。我们将创建的第一个组件是一个名为ClassVisitor
的递归访问者类。
4.6.3 递归访问者类
我们的访问者类位于ClassVisitor.hpp
文件中(参见图 4.33)。这是一个递归访问者,用于处理clang::CXXRecordDecl
,这是 C++类声明的 AST 节点。我们在第 13-16 行计算方法数,如果超过阈值,则在第 19-25 行发出诊断。
1 #include "clang/AST/ASTContext.h"
2 #include "clang/AST/RecursiveASTVisitor.h"
3
4 namespace clangbook {
5 namespace classchecker {
6 class ClassVisitor : public clang::RecursiveASTVisitor<ClassVisitor> {
7 public:
8 explicit ClassVisitor(clang::ASTContext *C, int T)
9 : Context(C), Threshold(T) {}
10
11 bool VisitCXXRecordDecl(clang::CXXRecordDecl *Declaration) {
12 if (Declaration->isThisDeclarationADefinition()) {
13 int MethodCount = 0;
14 for (const auto *M : Declaration->methods()) {
15 MethodCount++;
16 }
17
18 if (MethodCount > Threshold) {
19 clang::DiagnosticsEngine &D = Context->getDiagnostics();
20 unsigned DiagID =
21 D.getCustomDiagID(clang::DiagnosticsEngine::Warning,
22 "class %0 is too complex: method count = %1");
23 clang::DiagnosticBuilder DiagBuilder =
24 D.Report(Declaration->getLocation(), DiagID);
25 DiagBuilder << Declaration->getName() << MethodCount;
26 }
27 }
28 return true;
29 }
30
31 private:
32 clang::ASTContext *Context;
33 int Threshold;
34 };
35 } // namespace classchecker
36 } // namespace clangbook
图 4.33: ClassVisitor.hpp 的源代码
值得注意的是诊断调用。诊断消息在第 20-22 行构建。我们的诊断消息接受两个参数:类名和类的方法数。这些参数在第 22 行使用%1
和%2
占位符进行编码。这些参数的实际值在第 25 行传递,在那里使用DiagBuild
对象构建诊断消息。这个对象是clang::DiagnosticBuilder
类的实例,它实现了资源获取即初始化(RAII)模式。它在销毁时发出实际的诊断。
重要提示
在 C++中,RAII 原则是一个常见的惯用语,用于通过将其与对象的生存期相关联来管理资源生存期。当一个对象超出作用域时,其析构函数会自动调用,这为释放对象持有的资源提供了机会。
ClassVisitor
是在一个 AST 消费者类内部创建的,这将是我们的下一个主题。
4.6.4 插件 AST 消费者类
AST 消费者类在ClassConsumer.hpp
中实现,代表标准的 AST 消费者,正如我们在 AST 访问者测试项目中看到的那样(参见图 3.9)。代码在图 4.35 中展示。
1 namespace clangbook {
2 namespace classchecker {
3 class ClassConsumer : public clang::ASTConsumer {
4 public:
5 explicit ClassConsumer(clang::ASTContext *Context, int Threshold)
6 : Visitor(Context, Threshold) {}
7
8 virtual void HandleTranslationUnit(clang::ASTContext &Context) {
9 Visitor.TraverseDecl(Context.getTranslationUnitDecl());
10 }
11
12 private:
13 ClassVisitor Visitor;
14 };
15 } // namespace classchecker
16 } // namespace clangbook
图 4.34: ClassConsumer.hpp 的源代码
代码在第 10 行初始化Visitor
,并在第 13 行使用 Visitor 类遍历声明,从最顶层开始(翻译单元声明)。消费者必须从一个特殊的 AST 操作类创建,我们将在下一节讨论。
4.6.5 插件 AST 操作类
AST 操作的代码如图图 4.35 所示。可以观察到几个重要的部分:
-
第 7 行:我们从
clang::PluginASTAction
继承我们的ClassAction
-
第 10-13 行:我们实例化
ClassConsumer
并使用MethodCountThreshold
,它是一个可选插件参数的派生 -
第 15-25 行:我们处理插件的可选
threshold
参数
1 namespace clangbook {
2 namespace classchecker {
3 class ClassAction : public clang::PluginASTAction {
4 protected:
5 std::unique_ptr<clang::ASTConsumer>
6 CreateASTConsumer(clang::CompilerInstance &CI, llvm::StringRef) {
7 return std::make_unique<ClassConsumer>(&CI.getASTContext(),
8 MethodCountThreshold);
9 }
10
11 bool ParseArgs(const clang::CompilerInstance &CI,
12 const std::vector<std::string> &args) {
13 for (const auto &arg : args) {
14 if (arg.substr(0, 9) == "threshold") {
15 auto valueStr = arg.substr(10); // Get the substring after "threshold="
16 MethodCountThreshold = std::stoi(valueStr);
17 return true;
18 }
19 }
20 return true;
21 }
22 ActionType getActionType() { return AddAfterMainAction; }
23
24 private:
25 int MethodCountThreshold = 5; // default value
26 };
27 } // namespace classchecker
28 } // namespace clangbook
图 4.35: ClassAction.hpp 的源代码
我们几乎完成了,准备初始化我们的插件。
4.6.6 插件代码
我们的插件注册是在ClassChecker.cpp
文件中完成的,如图图 4.36 所示。
1 #include "clang/Frontend/FrontendPluginRegistry.h"
2
3 #include "ClassAction.hpp"
4
5 static clang::FrontendPluginRegistry::Add<clangbook::classchecker::ClassAction>
6 X("classchecker", "Checks the complexity of C++ classes");
图 4.36: ClassChecker.cpp 的源代码
如我们所见,大多数初始化都被辅助类隐藏了,我们只需要将我们的实现传递给lang::FrontendPluginRegistry::Add
。
现在我们已经准备好构建和测试我们的 Clang 插件。
4.6.7 构建和运行插件代码
我们需要指定我们的 LLVM 项目的安装文件夹的路径。其余的步骤是标准的,我们之前已经使用过,参见图 3.11:
export LLVM_HOME=<...>/llvm-project/install
mkdir build
cd build
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug ..
ninja classchecker
图 4.37: Clang 插件的配置和构建命令
构建工件将位于build
文件夹中。然后我们可以按照以下方式在测试文件上运行我们的插件,其中<filepath>
是我们想要编译的文件:
$ <...>/llvm-project/install/bin/clang -fsyntax-only\
-fplugin=./build/libclasschecker.so\
<filepath>
图 4.38: 在测试文件上运行 Clang 插件的方法
例如,如果我们使用一个名为test.cpp
的测试文件,它定义了一个有三个方法的类(参见图 4.39),我们将不会收到任何警告。
1 class Simple {
2 public:
3 void func1() {}
4 void func2() {}
5 void func3() {}
6 };
图 4.39: Clang 插件的测试:test.cpp
然而,如果我们指定一个较小的阈值,我们将为该文件收到一个警告:
$ <...>/llvm-project/install/bin/clang -fsyntax-only \
-fplugin-arg-classchecker-threshold=2 \
-fplugin=./build/libclasschecker.so \
test.cpp
test.cpp:1:7: warning: class Simple is too complex: method count = 3
1 | class Simple {
| ^
1 warning generated.
图 4.40: 在 test.cpp 上运行的 Clang 插件
现在是时候为我们的插件创建一个 LIT 测试了。
4.6.8 Clang 插件的 LIT 测试
我们将从一个项目组织的描述开始。我们将采用在 Clang 源代码中使用的常见模式,并将我们的测试放在test
文件夹中。这个文件夹将包含以下文件:
-
lit.site.cfg.py.in
:这是主要的配置文件,一个 CMake 配置文件。它将标记为’@...@’的模式替换为 CMake 配置期间定义的相应值。此外,此文件加载lit.cfg.py
。 -
lit.cfg.py
:这是 LIT 测试的主要配置文件。 -
simple_test.cpp
:这是我们的 LIT 测试文件。
基本的工作流程如下:CMake 将lit.site.cfg.py.in
作为模板,并在build/test
文件夹中生成相应的lit.site.cfg.py
。然后,该文件被 LIT 测试用作种子来执行测试。
LIT 配置文件
对于 LIT 测试有两个配置文件。第一个显示在图 4.41 中。
1 config.ClassComplexityChecker_obj_root = "@CMAKE_CURRENT_BINARY_DIR@"
2 config.ClassComplexityChecker_src_root = "@CMAKE_CURRENT_SOURCE_DIR@"
3 config.ClangBinary = "@LLVM_HOME@/bin/clang"
4 config.FileCheck = "@FILECHECK_COMMAND@"
5
6 lit_config.load_config(
7 config, os.path.join(config.ClassComplexityChecker_src_root, "test/lit.cfg.py"))
图 4.41:lit.site.cfg.py.in 文件
此文件是一个 CMake 模板,它将被转换为 Python 脚本。最重要的部分显示在第 6-7 行,其中加载了主要的 LIT 配置。它来自主源树,并且不会被复制到build
文件夹中。
后续配置显示在图 4.42。这是一个包含 LIT 测试主要配置的 Python 脚本。
1 # lit.cfg.py
2 import lit.formats
3
4 config.name = ’classchecker’
5 config.test_format = lit.formats.ShTest(True)
6 config.suffixes = [’.cpp’]
7 config.test_source_root = os.path.dirname(__file__)
8
9 config.substitutions.append((’%clang-binary’, config.ClangBinary))
10 config.substitutions.append((’%path-to-plugin’, os.path.join(config.ClassComplexityChecker_obj_root, ’libclasschecker.so’)))
11 config.substitutions.append((’%file-check-binary’, config.FileCheck))
图 4.42:lit.cfg.py 文件
第 4-7 行定义了基本配置;例如,第 6 行确定哪些文件应该用于测试。test
文件夹中所有扩展名为.cpp
的文件都将被用作 LIT 测试。
第 9-11 行详细说明了将在 LIT 测试中使用的替换。这包括 clang 二进制文件的路径(第 9 行)、带有插件的共享库的路径(第 10 行)以及FileCheck
实用程序的路径(第 11 行)。
我们只定义了一个基本的 LIT 测试,simple_test.cpp
,如图 4.43 所示。
1 // RUN: %clang-binary -fplugin=%path-to-plugin -fsyntax-only %s 2>&1 | %file-check-binary %s
2
3 class Simple {
4 public:
5 void func1() {}
6 void func2() {}
7 };
8
9 // CHECK: :[[@LINE+1]]:{{[0-9]+}}: warning: class Complex is too complex: method count = 6
10 class Complex {
11 public:
12 void func1() {}
13 void func2() {}
14 void func3() {}
15 void func4() {}
16 void func5() {}
17 void func6() {}
18 };
图 4.43:simple_test.cpp 文件
可以在第 1 行观察到替换的使用,其中引用了 clang 二进制文件的路径、插件共享库的路径以及FileCheck
实用程序的路径。在第 9 行使用了该实用程序识别的特殊模式。
最后一块拼图是 CMake 配置。这将设置在lit.site.cfg.py.in
中进行替换所需的变量,并定义一个自定义目标来运行 LIT 测试。
LIT 测试的 CMake 配置
CMakeLists.txt
文件需要一些调整以支持 LIT 测试。必要的更改显示在图 4.44 中。
31 find_program(LIT_COMMAND llvm-lit PATH ${LLVM_BUILD}/bin)
32 find_program(FILECHECK_COMMAND FileCheck ${LLVM_BUILD}/bin)
33 if(LIT_COMMAND AND FILECHECK_COMMAND)
34 message(STATUS "$LIT_COMMAND found: ${LIT_COMMAND}")
35 message(STATUS "$FILECHECK_COMMAND found: ${FILECHECK_COMMAND}")
36
37 # Point to our custom lit.cfg.py
38 set(LIT_CONFIG_FILE "${CMAKE_CURRENT_SOURCE_DIR}/test/lit.cfg.py")
39
40 # Configure lit.site.cfg.py using current settings
41 configure_file("${CMAKE_CURRENT_SOURCE_DIR}/test/lit.site.cfg.py.in"
42 "${CMAKE_CURRENT_BINARY_DIR}/test/lit.site.cfg.py"
43 @ONLY)
44
45 # Add a custom target to run tests with lit
46 add_custom_target(check-classchecker
47 COMMAND ${LIT_COMMAND} -v ${CMAKE_CURRENT_BINARY_DIR}/test
48 COMMENT "Running lit tests for classchecker clang plugin"
49 USES_TERMINAL)
50 else()
51 message(FATAL_ERROR "It was not possible to find the LIT executables at ${LLVM_BUILD}/bin")
52 endif()
图 4.44:CMakeLists.txt 中的 LIT 测试配置
在第 31 和 32 行,我们搜索必要的工具,llvm-lit
和FileCheck
。值得注意的是,它们依赖于$LLVM_BUILD
环境变量,我们在配置的第 12 行也进行了验证(参见图 4.32)。第 41-43 行中的步骤对于从提供的模板文件lit.site.cfg.py.in
生成lit.site.cfg.py
至关重要。最后,我们在第 46-49 行中建立了一个自定义目标来执行 LIT 测试。
现在我们已经准备好开始 LIT 测试。
运行 LIT 测试
要启动 LIT 测试,我们必须设置一个环境变量,使其指向构建文件夹,编译项目,然后执行自定义目标check-classchecker
。以下是这样做的方法:
export LLVM_BUILD=<...>/llvm-project/build
export LLVM_HOME=<...>/llvm-project/install
rm -rf build; mkdir build; cd build
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug ..
ninja classchecker
ninja check-classchecker
图 4.45:Clang 插件的配置、构建和检查命令
执行这些命令后,您可能会看到以下输出:
...
[2/2] Linking CXX shared module libclasschecker.so
[0/1] Running lit tests for classchecker clang plugin
-- Testing: 1 tests, 1 workers --
PASS: classchecker :: simple_test.cpp (1 of 1)
Testing Time: 0.12s
Passed: 1
图 4.46:LIT 测试执行
有了这个,我们完成了我们的第一个综合项目,该项目包含一个可以通过补充插件参数定制的实用 clang 插件。此外,它还包括可以执行以验证其功能的相应测试。
4.7 概述
在本章中,我们熟悉了 LLVM ADT 库中的基本类。我们了解了 Clang 诊断以及 LLVM 用于各种类型测试的测试框架。利用这些知识,我们创建了一个简单的 Clang 插件,用于检测复杂类并发出关于其复杂性的警告。
本章总结了本书的第一部分,其中我们获得了 Clang 编译器前端的初步知识。我们现在准备探索建立在 Clang 库基础上的各种工具。我们将从 Clang-Tidy 开始,这是一个强大的代码检查框架,用于检测 C++ 代码中的各种问题。
4.8 进一步阅读
-
LLVM 编码标准:
llvm.org/docs/CodingStandards.html
-
LLVM 程序员手册:
llvm.org/docs/ProgrammersManual.html
-
“Clang” CFE 内部手册:
clang.llvm.org/docs/InternalsManual.html
-
如何为你的类层次结构设置 LLVM 风格的 RTTI:
llvm.org/docs/HowToSetUpLLVMStyleRTTI.html
-
LIT - LLVM 集成测试器:
llvm.org/docs/CommandGuide/lit.html
第二部分
Clang 工具
您可以在此处找到有关不同 Clang 工具的一些信息。我们将从基于 Clang-Tidy 的检查器开始,继续介绍一些高级代码分析技术(控制流图和实时分析)。下一章将介绍不同的重构工具,如 Clang-Format。最后一章将介绍 IDE 支持。我们将研究如何通过 LLVM(Clangd)提供的语言服务器扩展 Visual Studio Code。
本部分包含以下章节:
-
第五章, Clang-Tidy 检查器框架
-
第六章, 高级代码分析
-
第七章, 重构工具
-
第八章, IDE 支持 和 Clangd
第五章:Clang-Tidy Linter 框架
本章介绍了 Clang-Tidy,这是一个基于 clang 的代码检查框架,它利用 抽象语法树(AST)来识别 C/C++/Objective-C 代码中的反模式。首先,我们将讨论 Clang-Tidy 的功能、它提供的检查类型以及如何使用它们。之后,我们将深入探讨 Clang-Tidy 的架构,并探讨如何创建我们自己的自定义 lint 检查。在本章中,我们将涵盖以下主题:
-
Clang-Tidy 概述,包括对默认提供的不同检查的简要描述
-
Clang-Tidy 的内部设计
-
如何创建自定义的 Clang-Tidy 检查
5.1 技术要求
本章的源代码位于本书 GitHub 存储库的 chapter5
文件夹中:github.com/PacktPublishing/Clang-Compiler-Frontend-Packt/tree/main/chapter5
。
5.2 Clang-Tidy 概述和用法示例
Clang-Tidy 是 C 和 C++ 代码的代码检查器和静态分析工具。它是 Clang 和 LLVM 项目的一部分。该工具建立在 Clang 前端之上,这意味着它能够深入理解你的代码,从而具有捕捉广泛问题的能力。
以下是一些关于 Clang-Tidy 的关键点:
-
检查:Clang-Tidy 包含一系列“检查”,用于识别各种问题或提出改进建议。这些检查涵盖了从性能改进和潜在错误到编码风格和现代 C++ 最佳实践。例如,它可能会建议在某些情况下使用
emplace_back
而不是push_back
,或者识别你可能会意外使用整数溢出的区域。 -
可扩展性:可以向 Clang-Tidy 添加新的检查,使其成为一个高度可扩展的工具。如果你有特定的编码指南或实践想要强制执行,你可以为其编写一个检查。
-
集成:Clang-Tidy 通常用于 CI/CD 管道中或与开发环境集成。许多 IDE 直接支持 Clang-Tidy 或通过插件支持,因此你可以在编写代码时实时获得代码反馈。
-
自动修复:Clang-Tidy 的一个强大功能是它不仅能够识别问题,还能自动修复其中许多问题。这是通过
-fix
选项实现的。然而,审查提出的更改是很重要的,因为自动修复可能并不总是完美的。 -
配置:你可以使用配置文件或命令行选项来配置 Clang-Tidy 执行的检查。这允许团队强制执行特定的编码标准或优先考虑某些类型的问题。例如,
-checks=’-*,modernize-*’
命令行选项将禁用所有检查,但不会禁用 modernize 集中的检查。 -
现代 C++ 最佳实践:Clang-Tidy 被广泛欣赏的一个特性是它对现代 C++ 习语和最佳实践的重视。它可以指导开发者编写更安全、性能更高、更符合 C++ 习惯的代码。
在掌握关于 Clang-Tidy 的基础知识之后,让我们来看看它是如何构建的。
5.2.1 构建 和 测试 Clang-Tidy
我们将使用 Figure 1.4 中指定的基本构建配置,并使用以下 Ninja 命令构建 Clang-Tidy:
$ ninja clang-tidy
Figure 5.1: 使用 Ninja 命令构建 Clang-Tidy
我们可以使用以下命令将 Clang-Tidy 二进制文件安装到指定的 install
文件夹:
$ ninja install-clang-tidy
Figure 5.2: 使用 Ninja 命令安装 Clang-Tidy
使用 Figure 1.4 中的构建配置,命令将在 <...>/llvm-project/install/bin
文件夹下安装 Clang-Tidy 二进制文件。在这里,<...>/llvm-project
指的是克隆 LLVM 代码库的路径(参见 Figure 1.1)。
重要提示
如果您使用具有共享库的构建配置(将 BUILD_SHARED_LIBS
标志设置为 ON
),如 Figure 1.12 所示,那么您可能需要使用 ninja install
安装和构建所有工件。
Clang-Tidy 是 Clang-Tools-Extra 的一部分,其测试是 clang-tools
CMake 目标的一部分。因此,我们可以使用以下命令运行测试:
$ ninja check-clang-tools
Figure 5.3: 使用 Ninja 命令运行 Clang-Tidy 测试
该命令将运行所有 Clang-Tidy 检查的 LIT 测试(参见 Section* 4.5.2**,LLVM 测试* 框架),并且还将运行 Clang-Tidy 核心系统的单元测试。您也可以单独运行特定的 LIT 测试;例如,如果我们想运行 modernize-loop-convert
检查的 LIT 测试,我们可以使用以下命令:
$ cd <...>/llvm-project
$ build/bin/llvm-lit -v \
clang-tools-extra/test/clang-tidy/checkers/modernize/loop-convert-basic.cpp
Figure 5.4: 测试 modernize-loop-convert clang-tidy 检查
该命令将产生以下输出:
-- Testing: 1 tests, 1 workers --
PASS: Clang Tools :: clang-tidy/checkers/modernize/loop-convert-basic.cpp
(1 of 1)
Testing Time: 1.38s
Passed: 1
Figure 5.5: cppcoreguidelines-owning-memory clang-tidy 检查的 LIT 测试输出
在构建和测试 Clang-Tidy 之后,现在是我们运行它来测试一些代码示例的时候了。
5.2.2 Clang-Tidy 使用方法
要测试 Clang-Tidy,我们将使用以下测试程序:
1 #include <iostream>
2 #include <vector>
3
4 int main() {
5 std::vector<int> numbers = {1, 2, 3, 4, 5};
6 for (std::vector<int>::iterator it = numbers.begin(); it != numbers.end();
7 ++it) {
8 std::cout << *it << std::endl;
9 }
10 return 0;
11 }
Figure 5.6: Clang-Tidy 测试程序:loop-convert.cpp
程序是按照旧的 C++ 代码风格正确编写的,即 C++11 之前。Clang-Tidy 有一些检查,鼓励采用现代 C++ 代码风格并使用最新 C++ 标准中可用的新的 C++ 习惯用法。这些检查可以按照以下方式在程序上运行:
1$ <...>/llvm-project/install/bin/clang-tidy \
2 -checks=’-*,modernize-*’ \
3 loop-convert.cpp \
4 -- -std=c++17
Figure 5.7: 在 loop-convert.cpp 上运行 Clang-Tidy 现代化检查
Figure 5.7 中的最重要的部分如下:
-
Line 1: 这里指定了 Clang-Tidy 二进制文件的路径。
-
Line 2: 我们使用
’
-’
选项删除所有检查。然后,我们通过使用’
-modernize-*’
作为’
--checks’
参数的值来启用所有以’
modernize’
前缀的检查。 -
Line 3: 我们指定要测试的代码的路径。
-
Line 4: 我们向编译器传递额外的参数,特别是指定我们希望编译器使用 C++17 作为 C++ 标准。
程序的输出将如下所示:
loop-convert.cpp:4:5: warning: use a trailing return type for this function
...
4 | int main() {
| ~~~ ^
| auto -> int
loop-convert.cpp:6:3: warning: use range-based for loop instead
[modernize-loop-convert]
6 | for (std::vector<int>::iterator it = numbers.begin();
it != numbers.end();
| ^ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| (int & number : numbers)
7 | ++it) {
| ~~~~~
8 | std::cout << *it << std::endl;
| ~~~
| number
loop-convert.cpp:6:8: warning: use auto when declaring iterators
[modernize-use-auto]
6 | for (std::vector<int>::iterator it = numbers.begin();
it != numbers.end();
| ^
note: this fix will not be applied because it overlaps with another fix
图 5.8:在 loop-convert.cpp 上运行 Clang-Tidy 的输出
如我们所见,检测到几个问题,Clang-Tidy 建议了一些修复。不幸的是,其中一些相互冲突,特别是modernize-loop-convert
和modernize-use-auto
,不能一起应用。另一方面,我们可以通过仅运行此特定检查来应用modernize-loop-convert
建议的修复,以避免任何冲突,如下所示:
1$ <...>/llvm-project/install/bin/clang-tidy \
2 -checks=’-*,modernize-loop-convert’ \
3 -fix \
4 loop-convert.cpp \
5 -- -std=c++17
图 5.9:在 loop-convert.cpp 上运行 modernize-loop-convert 检查
如我们所见,与图 5.7 相比,第二行已更改,并添加了另一行(3)。后者指示 Clang-Tidy 应用检查建议的修复。结果代码可以在原始文件中找到:
1 #include <iostream>
2 #include <vector>
3
4 int main() {
5 std::vector<int> numbers = {1, 2, 3, 4, 5};
6 for (int & number : numbers) {
7 std::cout << number << std::endl;
8 }
9 return 0;
10 }
图 5.10:Clang-Tidy 的修复测试程序:loop-convert.cpp
如我们所见,与图 5.6 中的原始代码相比,第 6 行和第 7 行已更改。这种功能使 Clang-Tidy 成为一个强大的工具,它不仅可以检测问题,还可以修复它们。我们将在稍后的第 7.3 节中更深入地探讨这种可能性,即 Clang-Tidy 作为代码修改工具。
5.2.3 Clang-Tidy 检查
Clang-Tidy 拥有多种检查,这些检查被分组到不同的类别中。以下是一些主要类别的简要列表,包括每个类别中的一个示例检查和简要描述:
-
boost-*:
boost-use-to-string
: 建议将boost::lexical_cast<std::string>
替换为boost::to_string
-
bugprone-*:
bugprone-integer-division
: 当在浮点上下文中进行整数除法可能导致意外精度损失时发出警告
-
cert-*(与 CERT C++安全编码标准相关的检查):
cert-dcl03-c
: 确保宏不在不安全上下文中使用
-
cppcoreguidelines-*(来自 C++核心指南的检查):
cppcoreguidelines-slicing
: 在切片(对象切片,即派生对象被赋值给基对象,切掉派生部分)时发出警告
-
google-*(谷歌编码规范):
google-build-using-namespace
: 标记使用 using 指令的标志
-
llvm-*(LLVM 编码规范):
llvm-namespace-comment
: 确保命名空间有结尾注释
-
misc-*(杂项检查):
misc-unused-parameters
: 标记未使用的参数
-
modernize-*(C++现代化检查):
modernize-use-auto
: 建议在适当的情况下使用auto
进行变量声明
-
performance-*:
performance-faster-string-find
: 建议使用更快的字符串搜索替代方案
-
readability-*:
readability-identifier-naming
: 确保一致的标识符命名
这个列表只是可用检查子集的一个表示。每个类别都包含多个检查,工具中还有其他类别。要获取检查的完整、最新列表及其详细描述,请参阅官方 Clang-Tidy 文档 [17] 或在您的系统上使用 clang-tidy -list-checks
命令。
在学习如何构建和使用 clang-tidy 之后,现在是时候深入了解并检查其内部设计。
5.3 Clang-Tidy 的内部设计
Clang-Tidy 是建立在 Clang 之上的。在其核心,Clang-Tidy 利用 Clang 解析和分析源代码到 AST 的能力。Clang-Tidy 中的每个检查本质上都涉及定义与这个 AST 匹配的模式或条件。当找到匹配时,可以引发诊断,在许多情况下,还可以建议自动修复。该工具基于针对特定问题或编码风格的单个“检查”操作。检查作为插件实现,使 Clang-Tidy 具有可扩展性。ASTMatchers
库通过提供用于查询 AST 的领域特定语言来简化这些检查的编写;有关更多信息,请参阅 第 3.5 节 中的 AST 匹配器和官方文档 [16]。这确保了检查既简洁又表达力强。Clang-Tidy 还支持使用编译数据库分析代码库,该数据库提供编译标志等上下文信息(有关更多信息,请参阅 第九章 附录 1:编译数据库)。这种与 Clang 内部功能的综合集成使 Clang-Tidy 成为一个功能强大的静态分析工具,具有精确的代码转换能力。
5.3.1 内部组织
由于与 Clang 库的深度集成,clang-tidy 在 Clang 代码库中的内部组织可能很复杂,但从高层次来看,其组织可以分解如下:
-
源代码和头文件:
clang-tidy
的主要源代码和头文件位于clang-tools-extra
仓库中,具体位于clang-tidy
目录下。 -
主要驱动程序:位于
tool
子文件夹中的ClangTidyMain.cpp
文件是 Clang-Tidy 工具的主要驱动程序。 -
核心基础设施:例如
ClangTidy.cpp
和ClangTidy.h
这样的文件管理着核心功能和选项。 -
检查:检查根据类别(例如,
bugprone
或modernize
)组织到子目录中。 -
实用工具:
utils
目录包含实用类和函数。 -
AST 匹配器:我们之前在 第 3.5 节 中探讨的
ASTMatchers
库,AST 匹配器,对于查询 AST 是至关重要的。 -
Clang 诊断:Clang-Tidy 主动使用 Clang 诊断子系统来打印诊断消息和建议修复(请参阅 第 4.4.2 节 中的诊断支持)。
-
测试:测试位于
test
目录中,并使用 LLVM 的 LIT 框架(见第 4.5.2 节,LLVM 测试框架)。值得注意的是,测试文件夹与clang-tools-extra
文件夹内的其他项目共享。 -
文档:
docs
目录包含 Clang-Tidy 的文档。与测试一样,文档也是clang-tools-extra
文件夹内其他项目的一部分。
这些关系在以下图中以示意图的形式展示:
图 5.11:Clang-Tidy 的内部组织
现在我们已经了解了 Clang-Tidy 的内部结构和它与 Clang/LLVM 其他部分的关系,是时候探索 Clang-Tidy 二进制文件外部的组件了:它的配置和其他利用 Clang-Tidy 提供的功能的工具。
5.3.2 配置和集成
Clang-Tidy 二进制文件可以与其他组件交互,如图 5.12 所示。
图 5.12:Clang-Tidy 的外部组件:配置和集成
Clang-Tidy 可以无缝集成到各种 集成开发环境(IDEs),如 Visual Studio Code、CLion 和 Eclipse,以在编码时提供实时反馈。我们将在第 8.5.2 节中探讨这种可能性,即 Clang-Tidy。
它还可以集成到构建系统,如 CMake 和 Bazel,以在构建期间运行检查。持续集成(CI)平台,如 Jenkins 和 GitHub Actions,通常使用 Clang-Tidy 来确保拉取请求的代码质量。代码审查平台,如 Phabricator,利用 Clang-Tidy 进行自动审查。此外,自定义脚本和静态分析平台可以利用 Clang-Tidy 的功能来实现定制工作流程和组合分析。
Clang-Tidy 在图 5.12 中显示的另一个重要部分是其配置。让我们详细探讨一下。
Clang-Tidy 配置
Clang-Tidy 使用配置文件来指定要运行的检查以及为这些检查设置选项。此配置是通过 .clang-tidy
文件完成的。
.clang-tidy
文件是用 YAML 格式编写的。它通常包含两个主要键:Checks
和 CheckOptions
。
我们将从 Checks
键开始,该键允许我们指定要启用或禁用的检查:
-
使用 - 来禁用一个检查
-
使用 * 作为通配符来匹配多个检查
-
检查项用逗号分隔
这里有一个例子:
1 Checks: ’-*,modernize-*’
图 5.13:.clang-tidy 配置文件的 Checks 键
下一个键是 CheckOptions
。此键允许我们为特定的检查设置选项,每个选项指定为一个键值对。这里提供了一个示例:
1CheckOptions:
2 - key: readability-identifier-naming.NamespaceCase
3 value: CamelCase
4 - key: readability-identifier-naming.ClassCase
5 value: CamelCase
图 5.14:.clang-tidy 配置文件的 CheckOptions 键
当运行 Clang-Tidy 时,它会在正在处理的文件及其父目录中搜索.clang-tidy
文件。当找到文件时,搜索停止。
现在我们已经了解了 Clang-Tidy 的内部设计,是时候根据我们从本书的这些章节和前几章中获得的信息创建我们的第一个自定义 Clang-Tidy 检查了。
5.4 自定义 Clang-Tidy 检查
在本章的这一部分,我们将把我们的插件示例(见第 4.6 节**,Clang 插件项目)转换为一个 Clang-Tidy 检查。此检查将根据类包含的方法数量估计 C++ 类的复杂性。我们将定义一个阈值作为检查的参数。
Clang-Tidy 提供了一个旨在帮助创建检查的工具。让我们首先为我们的检查创建一个骨架。
5.4.1 为检查创建骨架
Clang-Tidy 提供了一个特定的 Python 脚本,add_new_check.py
,以帮助创建新的检查。此脚本位于clang-tools-extra/clang-tidy
目录中。脚本需要两个位置参数:
-
module
:这指的是新 tidy 检查将被放置的模块目录。在我们的情况下,这将是在misc
。 -
check
:这是要添加的新 tidy 检查的名称。为了我们的目的,我们将将其命名为classchecker
。
在llvm-project
目录(其中包含克隆的 LLVM 仓库)中运行脚本,我们得到以下输出:
$ ./clang-tools-extra/clang-tidy/add_new_check.py misc classchecker
...
Updating ./clang-tools-extra/clang-tidy/misc/CMakeLists.txt...
Creating ./clang-tools-extra/clang-tidy/misc/ClasscheckerCheck.h...
Creating ./clang-tools-extra/clang-tidy/misc/ClasscheckerCheck.cpp...
Updating ./clang-tools-extra/clang-tidy/misc/MiscTidyModule.cpp...
Updating clang-tools-extra/docs/ReleaseNotes.rst...
Creating clang-tools-extra/test/clang-tidy/checkers/misc/classchecker.cpp...
Creating clang-tools-extra/docs/clang-tidy/checks/misc/classchecker.rst...
Updating clang-tools-extra/docs/clang-tidy/checks/list.rst...
Done. Now it’s your turn!
图 5.15:为misc-classchecker
检查创建骨架
从输出中,我们可以观察到clang-tools-extra/clang-tidy
目录下的几个文件已被更新。这些文件与检查注册有关,例如misc/MiscTidyModule.cpp
,或与构建配置有关,例如misc/CMakeLists.txt
。脚本还生成了几个新文件,我们需要修改这些文件以实现我们检查所需的功能:
-
misc/ClasscheckerCheck.h
:这是我们的检查的头文件 -
misc/ClasscheckerCheck.cpp
:此文件将包含我们检查的实现
此外,脚本还为我们检查生成了一个 LIT 测试,命名为ClassChecker.cpp
。此测试可以在clang-tools-extra/test/clang-tidy/checkers/misc
目录中找到。
除了源文件外,脚本还修改了clang-tools-extra/docs
目录中的某些文档文件:
-
ReleaseNotes.rst
:此文件包含带有我们新检查占位符的更新版发布说明 -
clang-tidy/checks/misc/classchecker.rst
:这是我们的检查的主要文档 -
clang-tidy/checks/list.rst
:检查列表已更新,包括我们新的检查以及其他来自misc
模块的检查。
现在,我们将把注意力转向实现检查和随后的构建过程。
5.4.2 Clang-Tidy 检查实现
我们将首先修改ClasscheckerCheck.cpp
。生成的文件可以在clang-tools-extra/clang-tidy/misc
目录中找到。让我们用以下代码替换生成的代码(注意:为了简洁,省略了包含许可信息的生成注释):
1 #include "ClasscheckerCheck.h"
2 #include "clang/AST/ASTContext.h"
3 #include "clang/ASTMatchers/ASTMatchFinder.h"
4 using namespace clang::ast_matchers;
5
6 namespace clang::tidy::misc {
7 void ClasscheckerCheck::registerMatchers(MatchFinder *Finder) {
8 // Match every C++ class.
9 Finder->addMatcher(cxxRecordDecl().bind("class"), this);
10 }
11 void ClasscheckerCheck::check(const MatchFinder::MatchResult &Result) {
12 const auto *ClassDecl = Result.Nodes.getNodeAs<CXXRecordDecl>("class");
13 if (!ClassDecl || !ClassDecl->isThisDeclarationADefinition())
14 return;
15 unsigned MethodCount = 0;
16 for (const auto *D : ClassDecl->decls()) {
17 if (isa<CXXMethodDecl>(D))
18 MethodCount++;
19 }
20 unsigned Threshold = Options.get("Threshold", 5);
21 if (MethodCount > Threshold) {
22 diag(ClassDecl->getLocation(),
23 "class %0 is too complex: method count = %1",
24 DiagnosticIDs::Warning)
25 << ClassDecl->getName() << MethodCount;
26 }
27 }
28 } // namespace clang::tidy::misc
图 5.16:对 ClasscheckerCheck.cpp 的修改
我们用第 15-35 行替换了原始的占位符以实现必要的更改。
要将我们的检查集成到 Clang-Tidy 二进制文件中,我们可以在 LLVM 源树中的build
目录内执行标准构建过程;参见图 5.2。
我们的检查名称定义在clang-tools-extra/clang-tidy/misc
文件夹中修改后的MiscTidyModule.cpp
文件中:
40 class MiscModule : public ClangTidyModule {
41 public:
42 void addCheckFactories(ClangTidyCheckFactories &CheckFactories) override {
43 CheckFactories.registerCheck<ClasscheckerCheck>(
44 "misc-classchecker");
45 CheckFactories.registerCheck<ConfusableIdentifierCheck>(
46 "misc-confusable-identifiers");
图 5.17:对 MiscTidyModule.cpp 的修改
如图 5.17 所示(第 43-44 行),我们在名称为"``misc``-``classchecker``"
下注册了新的检查。代码修改后,我们就可以重新编译 Clang-Tidy 了
$ ninja install
我们可以通过以下方式执行 Clang-Tidy 并使用-list-checks
参数来验证检查是否已添加:
<...>/llvm-project/install/bin/clang-tidy -checks ’*’ -list-checks
...
misc-classchecker
...
图 5.18:Clang-Tidy -list-checks
选项
值得注意的是,我们使用-checks '*’
命令行选项启用了所有检查,如图 5.18 所示。
要测试这个检查,我们可以使用图 4.39 中看到的 clang 插件项目中的文件:
1 class Simple {
2 public:
3 void func1() {}
4 void func2() {}
5 void func3() {}
6 };
图 5.19:misc-classchecker clang-tidy 检查的测试文件:test.cpp
此文件包含三个方法。要触发警告,我们必须将阈值设置为 2,如下所示:
1$ <...>/llvm-project/install/bin/clang-tidy \
2 -checks=’-*,misc-classchecker’ \
3 -config="{CheckOptions: [{key:misc-classchecker.Threshold, value:’2’}]}"\
4 test.cpp \
5 -- -std=c++17
图 5.20:在测试文件 test.cpp 上运行 misc-classchecker 检查
输出将如下所示:
test.cpp:1:7: warning: class Simple is too complex: method count = 3
[misc-classchecker]
class Simple {
^
图 5.21:对 test.cpp 测试文件的 misc-classchecker 检查输出
在使用自定义源代码测试文件后,是时候为我们的检查创建一个 LIT 测试了。
5.4.3 LIT 测试
对于 LIT 测试,我们将使用图 4.43 中略微修改的代码。让我们按照以下方式修改位于clang-tools-extra/test/clang-tidy/checkers/misc
文件夹中的classchecker.cpp
:
1 // RUN: %check_clang_tidy %s misc-classchecker %t
2
3 class Simple {
4 public:
5 void func1() {}
6 void func2() {}
7 };
8
9 // CHECK-MESSAGES: :[[LINE+1]]:{{[0-9]+}}: warning: class Complex is too complex: method count = 6 [misc-classchecker]
10 class Complex {
11 public:
12 void func1() {}
13 void func2() {}
14 void func3() {}
15 void func4() {}
16 void func5() {}
17 void func6() {}
18 };
图 5.22:LIT 测试:classchecker.cpp
如我们所见,与图 4.43 相比,唯一的区别在于第 1 行,我们指定了应该运行哪些命令,以及在第 9 行定义了测试模式。
我们可以按照以下方式运行测试:
$ cd <...>/llvm-project
$ build/bin/llvm-lit -v \
clang-tools-extra/test/clang-tidy/checkers/misc/classchecker.cpp
图 5.23:测试 misc-classchecker clang-tidy 检查
命令将产生以下输出:
-- Testing: 1 tests, 1 workers --
PASS: Clang Tools :: clang-tidy/checkers/misc/classchecker.cpp (1 of 1)
Testing Time: 0.12s
Passed: 1
图 5.24:测试 misc-classchecker 的输出
我们还可以使用图 5.3 中显示的命令来运行所有 clang-tidy 检查,包括我们新添加的检查。
当我们在真实代码库上运行检查,而不是合成测试时,我们可能会遇到意外结果。在第 3.7 节**,错误情况下的 AST 处理中已经讨论了一个这样的问题,并涉及到编译错误对 Clang-Tidy 结果的影响。让我们通过一个具体的例子深入研究这个问题。
5.4.4 编译错误情况下的结果
Clang-Tidy 使用 AST 作为检查的信息提供者,如果信息源损坏,检查可能会产生错误的结果。一个典型的情况是当分析代码有编译错误时(见*第 3.7 节**,错误情况下的 AST 处理)。
考虑以下代码作为示例:
1 class MyClass {
2 public:
3 void doSomething();
4 };
5
6 void MyClass::doSometing() {}
图 5.25:包含编译错误的测试文件:error.cpp
在这个例子中,我们在第 6 行中犯了一个语法错误:方法名错误地写成’doSometing’而不是’doSomething’。如果我们不带任何参数运行我们的检查,我们将收到以下输出:
error.cpp:1:7: warning: class MyClass is too complex: method count = 7
[misc-classchecker]
class MyClass {
^
error.cpp:6:15: error: out-of-line definition of ’doSometing’ ...
[clang-diagnostic-error]
void MyClass::doSometing() {}
^~~~~~~~~~
doSomething
error.cpp:3:8: note: ’doSomething’ declared here
void doSomething();
^
Found compiler error(s).
图 5.26:在包含编译错误的文件上运行 misc-classchecker 检查
我们的检查似乎与这段代码不正确地工作。它假设类有七个方法,而实际上只有一个。
编译错误的案例可以被视为边缘情况,并且我们可以正确地处理它。在处理这些情况之前,我们应该调查生成的 AST 以检查问题。
5.4.5 编译错误作为边缘情况
让我们使用clang-query
(见第 3.6 节**,使用clang-query*探索 Clang AST)来探索 AST 发生了什么。修复错误的程序如下所示:
1 class MyClass {
2 public:
3 void doSomething();
4 };
5
6 void MyClass::doSomething() {}
图 5.27:修复了编译错误的 noerror.cpp 测试文件
可以按照以下方式在文件上运行clang-query
命令:
$ <...>/llvm-project/install/bin/clang-query noerror.cpp -- --std=c++17
图 5.28:在修复了编译错误的 noerror.cpp 文件上运行 Clang-Query
然后,我们将 Clang-Query 的输出设置为dump
并找到所有CXXRecordDecl
的匹配项
clang-query> set output dump
clang-query> match cxxRecordDecl()
图 5.29:设置 Clang-Query 输出并运行匹配器
结果如下所示
Match #1:
Binding for "root":
CXXRecordDecl ... <noerror.cpp:1:1, line:4:1> line:1:7 class MyClass
definition
|-DefinitionData ...
| |-DefaultConstructor exists trivial ...
| |-CopyConstructor simple trivial ...
| |-MoveConstructor exists simple trivial ...
| |-CopyAssignment simple trivial ...
| |-MoveAssignment exists simple trivial ...
| ‘-Destructor simple irrelevant trivial ...
|-CXXRecordDecl ... <col:1, col:7> col:7 implicit class MyClass
|-AccessSpecDecl ... <line:2:1, col:7> col:1 public
‘-CXXMethodDecl ... <line:3:3, col:20> col:8 doSomething ’void ()’
...
图 5.30:修复了编译错误的 noerror.cpp 文件的 AST
将它与有错误的代码的输出进行比较(见图 5.25)。我们在 error.cpp 文件上运行 Clang-Query 并设置所需的匹配器如下
$ <...>/llvm-project/install/bin/clang-query error.cpp -- --std=c++17
clang-query> set output dump
clang-query> match cxxRecordDecl()
图 5.31:在 error.cpp 上运行 Clang-Query
找到的匹配项如下所示:
CXXRecordDecl ... <error.cpp:1:1, line:4:1> line:1:7 class MyClass
definition
|-DefinitionData ...
| |-DefaultConstructor exists trivial ...
| |-CopyConstructor simple trivial ..
| |-MoveConstructor exists simple trivial
| |-CopyAssignment simple trivial ...
| |-MoveAssignment exists simple trivial
| ‘-Destructor simple irrelevant trivial
|-CXXRecordDecl ... <col:1, col:7> col:7 implicit class MyClass
|-AccessSpecDecl ... <line:2:1, col:7> col:1 public
|-CXXMethodDecl ... <line:3:3, col:20> col:8 doSomething ’void ()’
|-CXXConstructorDecl ... <line:1:7> col:7 implicit constexpr MyClass
’void ()’ ...
|-CXXConstructorDecl ... <col:7> col:7 implicit constexpr MyClass
’void (const MyClass &)’ ...
| ‘-ParmVarDecl ... <col:7> col:7 ’const MyClass &’
|-CXXMethodDecl ... <col:7> col:7 implicit constexpr operator= ’MyClass
&(const MyClass &)’ inline default trivial ...
| ‘-ParmVarDecl ... <col:7> col:7 ’const MyClass &’
|-CXXConstructorDecl ... <col:7> col:7 implicit constexpr MyClass ’void
(MyClass &&)’ ...
| ‘-ParmVarDecl ... <col:7> col:7 ’MyClass &&’
|-CXXMethodDecl ... <col:7> col:7 implicit constexpr operator= ’MyClass
&(MyClass &&)’ ...
| ‘-ParmVarDecl ... <col:7> col:7 ’MyClass &&’
‘-CXXDestructorDecl ... <col:7> col:7 implicit ~MyClass ’void ()’ inline
default ...
...
图 5.32:包含编译错误的 error.cpp 文件的 AST
如我们所见,所有额外的方法都是隐式添加的。我们可以通过修改第 30 行(见图 5.16)来排除它们,如下所示:
29 for (const auto *D : ClassDecl->decls()) {
30 if (isa<CXXMethodDecl>(D) && !D->isImplicit())
31 MethodCount++;
32 }
图 5.33:从检查报告中排除隐式声明
如果我们在包含编译错误的文件上运行修改后的检查,我们将得到以下输出:
error.cpp:6:15: error: out-of-line definition of ’doSometing’ ...
[clang-diagnostic-error]
void MyClass::doSometing() {}
^~~~~~~~~~
doSomething
error.cpp:3:8: note: ’doSomething’ declared here
void doSomething();
^
Found compiler error(s).
图 5.34:在包含编译错误的文件上运行修复后的 misc-classchecker 检查
如我们所见,编译器错误被报告了,但我们的检查没有触发任何警告。
尽管我们正确处理了不寻常的 clang-tidy 结果,但值得注意的是,并非每个编译错误都能被正确处理。如第 3.7 节中所述,“错误情况下处理 AST”,Clang 编译器即使在遇到编译错误时也会尝试生成 AST。这种做法是因为它被设计为供 IDE 和其他工具使用,即使存在错误,也能尽可能多地提供信息。然而,这种 AST 的“错误恢复”模式可能会产生 Clang-Tidy 可能没有预料到的结构。因此,我们应该遵守以下规则:
小贴士
在运行 Clang-Tidy 和其他 Clang 工具之前,始终确保你的代码没有错误。这保证了 AST 既准确又完整。
5.5 摘要
在本章中,我们深入探讨了 Clang-Tidy,这是一个用于代码分析的强大工具。我们研究了其配置、执行和内部架构。此外,我们还开发了一个定制的 Clang-Tidy 检查来评估类复杂性。我们的检查使用了基本的 AST 匹配器,类似于 AST 中的正则表达式。对于复杂性的确定,我们采用了简单的方法。更复杂的度量,如圈复杂度,需要像控制流图(CFGs)这样的工具。冒险将在下一章继续,我们将深入探讨使用 CFG 设计复杂检查。
5.6 进一步阅读
-
Clang-Tidy 额外 Clang 工具文档:
clang.llvm.org/extra/clang-tidy/
第六章:高级代码分析
如前一章所述,Clang-Tidy 检查依赖于 AST 提供的高级匹配。然而,这种方法可能不足以检测更复杂的问题,例如生命周期问题(即,当对象或资源在已解除分配或超出作用域之后被访问或引用时,可能导致不可预测的行为或崩溃)。在本章中,我们将介绍基于 控制流图(CFG)的高级代码分析工具。Clang 静态分析器是此类工具的绝佳例子,Clang-Tidy 也集成了 CFG 的某些方面。我们将从典型用法示例开始,然后深入探讨实现细节。本章将以一个使用高级技术并扩展类复杂度概念到方法实现的定制检查结束。我们将定义圈复杂度并展示如何使用 Clang 提供的 CFG 库来计算它。在本章中,我们将探讨以下主题:
-
什么是静态分析
-
了解 CFG – 静态分析中使用的基本数据结构
-
如何在自定义 Clang-Tidy 检查中使用 CFG
-
Clang 提供了哪些分析工具以及它们的局限性
6.1 技术要求
本章的源代码位于本书 GitHub 存储库的 chapter6
文件夹中:github.com/PacktPublishing/Clang-Compiler-Frontend-Packt/tree/main/chapter6
。
6.2 静态分析
静态分析是软件开发中的一种关键技术,它涉及在不实际运行程序的情况下检查代码。这种方法侧重于分析源代码或其编译版本,以检测各种问题,例如错误、漏洞和与编码标准的偏差。与需要执行程序的动态分析不同,静态分析允许在非运行时环境中检查代码。
更一般地说,静态分析旨在根据计算机程序的意义检查其特定的属性;也就是说,它可以被认为是语义分析的一部分(参见图** 2.6**,解析器)。例如,如果 𝒞 是所有 C/C++ 程序的集合,而 𝒫 是此类程序的一个属性,那么静态分析的目标是检查特定程序 P ∈𝒞 的属性,即回答 𝒫(P) 是否为真或假的问题。
我们在前一章中提到的 Clang-Tidy 检查(参见节** 5.4**,自定义 Clang-Tidy 检查)是此类属性的一个很好的例子。实际上,它接受具有类定义的 C++ 代码,并根据方法数量决定该类是否复杂。
值得注意的是,并非所有程序的性质都可以进行检查。最明显的例子是著名的停机问题 [31]。
重要提示
停机问题可以表述如下:给定一个程序 P 和一个输入 I,确定当 P 在 I 上执行时,P 是停止运行还是无限期地继续运行。
形式上,问题是要决定,对于给定的程序 P 和输入 I,P(I)的计算最终是否会停止(停机)或永远不会终止(无限循环)。
阿兰·图灵证明了不存在一种通用的算法方法可以解决所有可能的程序-输入对的问题。这个结果意味着没有一种单一的算法可以正确地确定对于每一对(P,I),当 P 在 I 上运行时,P 是否会停止。
尽管并非所有程序的性质都能被证明,但在某些情况下是可以做到的。有相当数量的这种案例使得静态分析成为一个实用的工具。因此,我们可以使用这些工具在这些情况下系统地扫描代码,以确定代码的性质。这些工具擅长识别从简单的语法错误到更复杂的潜在错误的各种问题。静态分析的一个关键优势是它能够在开发周期的早期阶段捕捉到问题。这种早期检测不仅效率高,而且节省资源,因为它有助于在软件运行或部署之前识别和纠正问题。
静态分析在确保软件质量和合规性方面发挥着重要作用。它检查代码是否遵循规定的编码标准和指南,这在大型项目或对监管要求严格的行业中尤为重要。此外,它在揭示常见的安全漏洞方面非常有效,例如缓冲区溢出、SQL 注入漏洞和跨站脚本漏洞。
此外,静态分析通过确定冗余区域、不必要的复杂性和改进机会,有助于代码重构和优化。将此类工具集成到开发过程中,包括持续集成管道,是一种常见做法。这种集成允许对代码进行持续分析,每次提交或构建时都会进行,从而确保持续的质量保证。
我们在上章中创建的 Clang-Tidy 检查可以被视为静态分析程序的一个例子。在本章中,我们将考虑涉及数据结构(如 CFG)的更高级主题,我们将在下一节中看到。
6.3 CFG
CFG是编译设计和静态程序分析中的一个基本数据结构,它表示程序在执行过程中可能遍历的所有路径。
一个 CFG 由以下关键组件组成:
-
节点:对应于基本块,一个具有一个入口点和一个出口点的操作直线序列
-
边:表示从一个块到另一个块的控件流,包括条件和无条件分支
-
起始和结束节点:每个 CFG 都有一个唯一的入口节点和一个或多个出口节点
作为 CFG 的一个示例,考虑我们之前用作示例的两个整数最大值的函数;参见图 2.5:
1 int max(int a, int b) {
2 if (a > b)
3 return a;
4 return b;
5 }
图 6.1: max.cpp 的 CFG 示例 C++代码
相应的 CFG 可以表示如下:
图 6.2: max.cpp 的 CFG 示例
如图 6.2 所示,该图直观地表示了max
函数的 CFG(来自图 6.1),通过一系列连接的节点和有向边:
-
入口节点:在顶部,有一个“entry”节点,表示函数执行的起点。
-
条件节点:在入口节点下方,有一个标记为“a > b”的节点。此节点表示函数中的条件语句,其中比较a和b。
-
真和假条件分支:
-
在真分支(左侧),有一个标记为“返回 a”的节点,通过从“a > b”节点的边连接。这条边标记为“true”,表示如果a大于b,则流程流向此节点。
-
在假分支(右侧),有一个标记为“返回 b”的节点,通过从“a > b”节点的边连接。这条边标记为“false”,表示如果a不大于b,则流程流向此节点。
-
-
出口节点:在“Return a”和“Return b”节点下方,汇聚于一点,有一个“exit”节点。这表示函数的终止点,在返回a或b后,控制流退出函数。
此 CFG 有效地说明了max
函数如何处理输入并基于比较决定返回哪个值。
CFG 表示也可以用来估计函数的复杂度。简而言之,更复杂的图像对应更复杂的系统。我们将使用一个称为循环复杂度的精确复杂度定义,或 M [28],其计算方法如下:
M = E - N + 2P |
---|
其中:
-
E 是图中边的数量
-
N 是图中节点的数量
-
P 是连通分量的数量(对于单个 CFG,P 通常为 1)
对于前面讨论的max
函数,CFG 可以分析如下:
-
节点 (N): 有五个节点(入口,a > b,返回a,b,出口)
-
边 (E): 有五条边(从入口到a > b,从a > b到返回a,从a > b到返回b,从返回a到出口,以及从返回b到出口)
-
连通分量 (P): 由于它是一个单一函数,P = 1
将这些值代入公式,我们得到以下结果:
𝑀 = 5 − 5 + 2 × 1 = 2
因此,基于给定的 CFG,max
函数的循环复杂度为 2。这表明代码中有两条线性独立的路径,对应于 if 语句的两个分支。
我们的下一步将是创建一个使用 CFG 来计算循环复杂度的 Clang-Tidy 检查。
6.4 自定义 CFG 检查
我们将使用在第 5.4 节**中获得的关于自定义 Clang-Tidy 检查的知识来创建一个自定义 CFG 检查。如前所述,该检查将使用 Clang 的 CFG 来计算循环复杂度。如果计算出的复杂度超过阈值,则检查应发出警告。此阈值将作为配置参数设置,允许我们在测试期间更改它。让我们从创建项目骨架开始。
6.4.1 创建项目骨架
我们将使用cyclomaticcomplexity
作为检查的名称,我们的项目骨架可以创建如下:
$ ./clang-tools-extra/clang-tidy/add_new_check.py misc cyclomaticcomplexity
图 6.3:为 misc-cyclomaticcomplexity 检查创建骨架
运行结果将生成多个修改后的新文件。对我们来说,最重要的是位于clang-tools-extra/clang-tidy/misc/
文件夹中的以下两个文件:
-
misc/CyclomaticcomplexityCheck.h
:这是我们的检查的头文件 -
misc/CyclomaticcomplexityCheck.cpp
:此文件将包含我们的检查实现
这些文件需要修改以达到检查所需的函数。
6.4.2 检查实现
对于头文件,我们旨在添加一个用于计算循环复杂度的私有函数。具体来说,需要插入以下代码:
27 private:
28 unsigned calculateCyclomaticComplexity(const CFG *cfg);
图 6.4:对 CyclomaticcomplexityCheck.h 的修改
在.cpp
文件中需要更多的实质性修改。我们将从registerMatchers
方法的实现开始,如下所示:
17 void CyclomaticcomplexityCheck::registerMatchers(MatchFinder *Finder) {
18 Finder->addMatcher(functionDecl().bind("func"), this);
19 }
图 6.5:对 CyclomaticcomplexityCheck.cpp 的修改:registerMatchers 实现
根据代码,我们的检查将仅应用于函数声明,即clang::FunctionDecl
。代码也可以扩展以支持其他 C++结构。
check
方法的实现如图 6.6 所示。在第 22-23 行,我们对匹配的 AST 节点进行基本检查,在我们的例子中是clang::FunctionDecl
。在第 25-26 行,我们使用CFG::buildCFG
方法创建 CFG 对象。前两个参数指定了声明(clang::Decl
)和声明的语句(clang::Stmt
)。在第 30 行,我们使用阈值计算循环复杂度,该阈值可以作为我们检查的"Threshold"
选项获得。这为测试不同的输入程序提供了灵活性。第 31-34 行包含了检查结果打印的实现。
21 void CyclomaticcomplexityCheck::check(const MatchFinder::MatchResult &Result) {
22 const auto *Func = Result.Nodes.getNodeAs<FunctionDecl>("func");
23 if (!Func || !Func->hasBody()) return;
24
25 std::unique_ptr<CFG> cfg =
26 CFG::buildCFG(Func, Func->getBody(), Result.Context, CFG::BuildOptions());
27 if (!cfg) return;
28
29 unsigned Threshold = Options.get("Threshold", 5);
30 unsigned complexity = calculateCyclomaticComplexity(cfg.get());
31 if (complexity > Threshold) {
32 diag(Func->getLocation(), "function %0 has high cyclomatic complexity (%1)")
33 << Func << complexity;
34 }
35 }
图 6.6:对 CyclomaticcomplexityCheck.cpp 的修改:检查实现
calculateCyclomaticComplexity
方法用于计算循环复杂度。它接受创建的clang::CFG
对象作为输入参数。实现如下所示:
37 unsigned CyclomaticcomplexityCheck::calculateCyclomaticComplexity(
38 const CFG *cfg) {
39 unsigned edges = 0;
40 unsigned nodes = 0;
41
42 for (const auto *block : *cfg) {
43 edges += block->succ_size();
44 ++nodes;
45 }
46
47 return edges - nodes + 2; // Simplified formula
48 }
图 6.7:对 CyclomaticcomplexityCheck.cpp 的修改:calculateCyclomaticComplexity 实现
我们在第 42-45 行迭代所有 CFG 块。块的数量对应于节点数,在图 6.2 中用 N 表示。我们计算每个块的后续节点数之和,以计算边的数量,用 E 表示。我们假设对于我们的简化示例,连接组件的数量,用 P 表示,等于一个。
在实现检查后,是时候构建并在我们的示例上运行我们的新检查了;参见图 6.1。
6.4.3 构建和测试循环复杂度检查
我们将使用图 1.4 中指定的基本构建配置,并使用图 5.2 中的标准命令构建 Clang-Tidy:
$ ninja install-clang-tidy
假设从图 1.4 的构建配置,此命令将 Clang-Tidy 二进制文件安装到<...>/llvm-project/install/bin
文件夹中。
重要提示
如果你使用带有共享库的构建配置(将BUILD_SHARED_LIBS
标志设置为ON
),如图 1.12 所示,那么你可能需要使用ninja install
安装和构建所有工件。
我们将在图 6.1 中显示的示例程序上运行我们的检查。正如我们之前计算的,测试的循环复杂度为 2,低于我们在check
方法实现中指定的默认值 5,如图 6.6 所示。因此,我们需要将默认值重写为 1,以便在测试程序中看到警告。这可以通过使用我们之前用于classchecker
检查测试的-config
选项来完成,如图 5.20 所示。测试命令如下:
1$ <...>/llvm-project/install/bin/clang-tidy \
2 -checks="-*,misc-cyclomaticcomplexity" \
3 -config="{CheckOptions: \
4 [{key: misc-cyclomaticcomplexity.Threshold, value: ’1’}]}" \
5 max.cpp \
6 -- -std=c++17
图 6.8:在 max.cpp 示例上测试循环复杂度
图 6.8 中的第 2 行表明我们只想运行一个 Clang-Tidy 检查:misc-cyclomaticcomplexity
。在第 3-4 行中,我们设置了所需的阈值。第 5 行指定了正在测试的文件名(在我们的例子中是max.cpp
),而最后一行,第 6 行包含了我们程序的某些编译标志。
如果我们运行图 6.8 中的命令,将会得到以下输出:
max.cpp:1:5: warning: function ’max’ has high cyclomatic complexity (2) ...
int max(int a, int b) {
^
图 6.9:在 max.cpp 示例上测试循环复杂度:输出
可能会提出以下问题:Clang 是如何构建 CFG 的?我们可以使用调试器来调查这个过程。
6.5 Clang 上的 CFG
CFG 是使用 Clang 工具进行高级静态分析的基本数据结构。Clang 从函数的 AST(抽象语法树)构建 CFG,识别基本块和控制流边。Clang 的 CFG 构建处理各种 C/C++结构,包括循环、条件语句、switch 情况以及如setjmp/longjmp
和 C++异常等复杂结构。让我们使用图 6.1 中的示例来考虑这个过程。
6.5.1 通过示例进行 CFG 构建
我们在图 6.1 中的示例有五个节点,如图图 6.2 所示。让我们运行一个调试器来调查这个过程,如下所示:
1$ lldb <...>/llvm-project/install/bin/clang-tidy -- \
2 -checks="-*,misc-cyclomaticcomplexity" \
3 -config="{CheckOptions: \
4 [{key: misc-cyclomaticcomplexity.Threshold, value: ’1’}]}" \
5 max.cpp \
6 -- -std=c++17 -Wno-all
图 6.10: 运行以调查 CFG 创建过程的调试器会话
我们使用了与图 6.8 中相同的命令,但将命令的第一行改为通过调试器运行检查。我们还改变了最后一行以抑制编译器的所有警告。
重要提示
高级静态分析是语义分析的一部分。例如,如果 Clang 检测到不可达的代码,将会打印警告,由-Wunreachable-code
选项控制。检测器是 Clang 语义分析的一部分,并利用 CFGs(控制流图)以及 ASTs(抽象语法树)作为基本数据结构来检测此类问题。我们可以抑制这些警告,并因此通过指定特殊的-Wno-all
命令行选项来禁用 Clang 中的 CFG 初始化,该选项抑制编译器生成的所有警告。
我们将在CFGBuilder::createBlock
函数上设置断点,该函数创建 CFG 块。
$ lldb <...>/llvm-project/install/bin/clang-tidy -- \
-checks="-*,misc-cyclomaticcomplexity" \
-config="{CheckOptions: \
[{key: misc-cyclomaticcomplexity.Threshold, value: ’1’}]}" \
max.cpp \
-- -std=c++17 -Wno-all
...
(lldb) b CFGBuilder::createBlock
Breakpoint 1: where = ...CFGBuilder::createBlock(bool) const ...
图 6.11: 运行调试器并设置 CFGBuilder::createBlock 的断点
如果我们运行调试器,我们将看到我们的示例函数被调用了五次;也就是说,为我们的max
函数创建了五个 CFG 块:
1(lldb) r
2 ...
3 frame #0: ...CFGBuilder::createBlock...
4 1690 /// createBlock - Used to lazily create blocks that are connected
5 1691 /// to the current (global) successor.
6 1692 CFGBlock *CFGBuilder::createBlock(bool add_successor) {
7 -> 1693 CFGBlock *B = cfg->createBlock();
8 1694 if (add_successor && Succ)
9 1695 addSuccessor(B, Succ);
10 1696 return B;
11
12 (lldb) c
13 ...
14 (lldb) c
15 ...
16 (lldb) c
17 ...
18 (lldb) c
19 ...
20 (lldb) c
21 ...
221 warning generated.
23 max.cpp:1:5: warning: function ’max’ has high cyclomatic complexity (2) [misc-cyclomaticcomplexity]
24 int max(int a, int b) {
25 ^
26 Process ... exited with status = 0 (0x00000000)
图 6.12: 创建 CFG 块,突出显示断点
如图 6.12 中所示的调试器会话可以被认为是 CFG 创建过程的入口点。现在,是时候深入探讨实现细节了。
6.5.2 CFG 构建实现细节
块是按相反的顺序创建的,如图图 6.13 所示。首先创建的是退出块,如图图 6.13 所示,第 4 行。然后,CFG 构建器遍历作为参数传递的clang::Stmt
对象(第 9 行)。入口块最后创建,在第 12 行:
1std::unique_ptr<CFG> CFGBuilder::buildCFG(const Decl *D, Stmt *Statement) {
2 ...
3 // Create an empty block that will serve as the exit block for the CFG.
4 Succ = createBlock();
5 assert(Succ == &cfg->getExit());
6 Block = nullptr; // the EXIT block is empty. ...
7 ...
8 // Visit the statements and create the CFG.
9 CFGBlock *B = Visit(Statement, ...);
10 ...
11 // Create an empty entry block that has no predecessors.
12 cfg->setEntry(createBlock());
13 ...
14 return std::move(cfg);
15 }
图 6.13: 从 clang/lib/Analysis/CFG.cpp 中提取的简化 buildCFG 实现
访问者使用clang::Stmt::getStmtClass
方法根据语句的类型实现一个临时的访问者,如下面的代码片段所示:
1CFGBlock *CFGBuilder::Visit(Stmt * S, ...) {
2 ...
3 switch (S->getStmtClass()) {
4 ...
5 case Stmt::CompoundStmtClass:
6 return VisitCompoundStmt(cast<CompoundStmt>(S), ...);
7 ...
8 case Stmt::IfStmtClass:
9 return VisitIfStmt(cast<IfStmt>(S));
10 ...
11 case Stmt::ReturnStmtClass:
12 ...
13 return VisitReturnStmt(S);
14 ...
15 }
16 }
图 6.14: 状态访问者实现;用于我们示例的情况被突出显示,代码取自 clang/lib/Analysis/CFG.cpp
我们的例子包括两个返回语句和一个if
语句,它们被组合成一个复合语句。访问者的相关部分在图 6.14 中显示。
在我们的例子中,传递的语句是一个复合语句;因此,图 6.14 中的第 6 行被激活。然后执行以下代码:
1CFGBlock *CFGBuilder::VisitCompoundStmt(CompoundStmt *C, ...) {
2 ...
3 CFGBlock *LastBlock = Block;
4
5 for (Stmt *S : llvm::reverse(C->body())) {
6 // If we hit a segment of code just containing ’;’ (NullStmts), we can
7 // get a null block back. In such cases, just use the LastBlock
8 CFGBlock *newBlock = Visit(S, ...);
9
10 if (newBlock)
11 LastBlock = newBlock;
12
13 if (badCFG)
14 return nullptr;
15 ...
16 }
17
18 return LastBlock;
19 }
图 6.15:复合语句访问者,代码来自 clang/lib/Analysis/CFG.cpp
在为我们的例子创建 CFG 时,访问了几个构造。第一个是clang::IfStmt
。相关部分在以下图中显示:
1CFGBlock *CFGBuilder::VisitIfStmt(IfStmt *I) {
2 ...
3 // Process the true branch.
4 CFGBlock *ThenBlock;
5 {
6 Stmt *Then = I->getThen();
7 ...
8 ThenBlock = Visit(Then, ...);
9 ...
10 }
11
12 // Specially handle "if (expr1 || ...)" and "if (expr1 && ...)"
13 // ...
14 if (Cond && Cond->isLogicalOp())
15 ...
16 else {
17 // Now create a new block containing the if statement.
18 Block = createBlock(false);
19 ...
20 }
21 ...
22 }
图 6.16:if
语句访问者,代码来自 clang/lib/Analysis/CFG.cpp
在第 18 行创建了一个特殊的if
语句块。我们还访问了第 8 行的then
条件。
then
条件导致访问返回语句。相应的代码如下:
1CFGBlock *CFGBuilder::VisitReturnStmt(Stmt *S) {
2 // Create the new block.
3 Block = createBlock(false);
4 ...
5 // Visit children
6 if (ReturnStmt *RS = dyn_cast<ReturnStmt>(S)) {
7 if (Expr *O = RS->getRetValue())
8 return Visit(O, ...);
9 return Block;
10 }
11 ...
12 }
图 6.17:返回语句访问者,代码来自 clang/lib/Analysis/CFG.cpp
对于我们的例子,它在第 3 行创建了一个块并访问了第 8 行的返回表达式。我们的返回表达式是一个简单的表达式,不需要创建新的块。
在图 6.13 到图 6.17 中展示的代码片段仅显示了块创建过程。为了简化,省略了一些重要部分。值得注意的是,构建过程还涉及以下内容:
-
边缘创建:一个典型的块可以有一个或多个后继者。每个块的节点(块)列表以及每个块的后继者(边)列表维护整个图结构,表示符号程序执行。
-
存储元信息:每个块存储与其相关的附加元信息。例如,每个块保留该块中语句的列表。
-
处理边缘情况:C++是一种复杂的语言,具有许多不同的语言结构,需要特殊处理。
CFG 是高级代码分析的基本数据结构。Clang 有几个使用 CFG 创建的工具。让我们简要地看看它们。
6.6 Clang 分析工具简要描述
如前所述,CFG 是 Clang 中其他分析工具的基础,其中一些是在 CFG 之上创建的。这些工具也使用高级数学来分析各种情况。最显著的工具如下 [32]:
-
LivenessAnalysis:确定计算值在覆盖之前是否会被使用,为每个语句和 CFGBlock 生成活动性集
-
未初始化变量:通过多次遍历识别未初始化变量的使用,包括对语句的初始分类和后续的变量使用计算
-
线程安全性分析:分析标记的函数和变量以确保线程安全性
Clang 中的 LivenessAnalysis 对于通过确定在某个点计算出的值在覆盖之前是否会被使用来优化代码至关重要。它为每个语句和 CFGBlock 生成活动集,指示变量或表达式的潜在未来使用。这种“可能”的后向分析通过将变量声明和赋值视为写入,将其他上下文视为读取,简化了读写分类,无论是否存在别名或字段使用。它在死代码消除和编译器优化(如高效的寄存器分配)中非常有价值,有助于释放内存资源并提高程序效率。尽管存在边缘案例和文档的挑战,但其直接的实现和缓存查询结果的能力使其成为提高软件性能和资源管理的重要工具。
重要注意事项
前向分析是编程中用来检查数据从程序开始到结束如何流动的方法。随着程序的运行,逐步跟踪数据路径使我们能够看到它的变化或去向。这种方法对于识别诸如设置不当的变量或跟踪程序中的数据流等问题至关重要。它与反向分析形成对比,反向分析从程序的末尾开始,向后工作。
Clang 中的未初始化变量分析旨在检测在初始化之前使用变量的情况,它作为一个前向的“必须”分析操作。它涉及多个遍历,包括对代码进行初始扫描以对语句进行分类,以及随后使用固定点算法通过 CFG 传播信息。它处理比 LivenessAnalysis 更复杂的场景,面临着诸如缺乏对记录字段和非可重用分析结果支持等挑战,这限制了它在某些情况下的效率。
Clang 中的线程安全性分析是一种前向分析,它专注于确保多线程代码中的正确同步。它为每个语句块中的每个语句计算被锁定互斥锁的集合,并利用注解来指示受保护的变量或函数。将 Clang 表达式转换为 TIL(类型中间语言)[32],它有效地处理了 C++ 表达式和注解的复杂性。尽管它对 C++ 有强大的支持,并且对变量交互有深入的理解,但它面临着一些限制,例如缺乏对别名支持,这可能导致误报。
6.7 了解分析的限制
值得提及的是,使用 Clang 的 AST 和 CFG 可以进行一些分析的限制,其中最显著的如下 [2]:
-
Clang 的 AST 局限性:Clang 的 AST 不适合数据流分析和控制流推理,由于丢失了关键的语言信息,导致结果不准确且分析效率低下。分析的健全性也是一个考虑因素,某些分析(如可达性分析)的精确性如果足够精确,那么它们是有价值的,而不是总是保守的。
-
Clang 的 CFG 问题:尽管 Clang 的 CFG 旨在弥合 AST 和 LLVM IR 之间的差距,但它遇到了已知的问题,具有有限的跨程序能力,并且缺乏足够的测试覆盖率。
在[2]中提到的一个例子与 C++20 中引入的新特性 C++ coroutines 有关。该功能的一些方面是在 Clang 前端之外实现的,并且无法通过 Clang 的 AST 和 CFG 等工具看到。这种限制使得对这些功能的分析,尤其是生命周期分析,变得复杂。
尽管存在这些局限性,Clang 的 CFG 仍然是一个在编译器和编译器工具开发中广泛使用的强大工具。还有其他工具正在积极开发中 [27],旨在弥合 Clang 的 CFG 能力上的差距。
6.8 摘要
在本章中,我们研究了 Clang 的 CFG,这是一种强大的数据结构,用于表示程序的符号执行。我们使用 CFG 创建了一个简单的 Clang-Tidy 检查,用于计算环路复杂度,这是一个用于估计代码复杂度的有用度量。此外,我们还探讨了 CFG 创建的细节及其基本内部结构的形成。我们讨论了一些使用 CFG 开发的工具,这些工具对于检测生命周期问题、线程安全和未初始化变量很有用。我们还简要描述了 CFG 的局限性以及其他工具如何解决这些局限性。
下一章将介绍重构工具。这些工具可以使用 Clang 编译器提供的 AST 执行复杂的代码修改。
6.9 未来阅读
-
Flemming Nielson、Hanne Riis Nielson 和 Chris Hankin,程序分析原理,Springer,2005 [29]
-
Xavier Rival 和 Kwangkeun Yi,静态分析导论:抽象解释视角,麻省理工学院出版社,2020 [30]
-
Kristóf Umann Clang 中数据流分析的调查:
lists.llvm.org/pipermail/cfe-dev/2020-October/066937.html
-
Bruno Cardoso Lopes 和 Nathan Lanza 基于 MLIR 的 Clang IR (CIR):
discourse.llvm.org/t/rfc-an-mlir-based-clang-ir-cir/63319
第七章:重构工具
Clang 因其提供代码修复建议的能力而闻名。例如,如果你遗漏了分号,Clang 会建议你插入它。修改源代码的能力不仅限于编译过程,而且在各种代码修改工具中得到了广泛应用,尤其是在重构工具中。提供修复的能力是扩展 lint 框架功能的一个强大特性,例如 Clang-Tidy,它不仅能够检测问题,还能提供修复建议。
在本章中,我们将探讨重构工具。我们将从讨论用于代码修改的基本类开始,特别是 clang``::``Rewriter
。我们将使用 Rewriter 构建一个自定义的重构工具,该工具可以更改类中的方法名。在章节的后面部分,我们将使用 Clang-Tidy 重新实现该工具,并深入研究 clang``::``FixItHint
,这是 Clang 诊断子系统的一个组件,Clang-Tidy 和 Clang 编译器都使用它来修改源代码。
为了结束本章,我们将介绍一个关键的 Clang 工具,称为 Clang-Format。这个工具被广泛用于代码格式化。我们将探讨该工具提供的功能,深入研究其设计,并理解其开发过程中做出的具体设计决策背后的原因。
本章涵盖了以下主题:
-
如何创建自定义的 Clang 代码重构工具
-
如何将代码修改集成到 Clang-Tidy 检查中
-
Clang-Format 概述及其如何与 Clang-Tidy 集成
7.1 技术要求
本章的源代码位于本书 GitHub 仓库的 chapter7
文件夹中:github.com/PacktPublishing/Clang-Compiler-Frontend-Packt/tree/main/chapter7
。
7.2 自定义代码修改工具
我们将创建一个 Clang 工具,帮助我们为用于单元测试的类重命名方法。我们将从对 clang``::``Rewriter
类的描述开始——这是用于代码修改的基本类。
7.2.1 Clang 中的代码修改支持
clang``::``Rewriter
是一个 Clang 库类,它简化了在翻译单元内进行源代码重写操作。它提供了在源代码的抽象语法树(AST)中插入、删除和替换代码的方法。开发者可以使用 clang``::``Rewriter
进行复杂的代码修改,例如重构或生成新的代码结构。它可以应用于代码生成和代码重构任务,使其在多种代码转换目的上具有多功能性。
该类有几个用于文本插入的方法;例如,clang``::``Rewriter
::``InsertText
在指定的源位置插入文本,而clang
::``SourceLocation
用于指定缓冲区中的确切位置,见第 4.4.1 节**,源管理器和源位置*。除了文本插入外,您还可以使用clang``::``Rewriter``::``RemoveText
删除文本,或使用clang``::``Rewriter``::``ReplaceText
用新文本替换文本。后两种使用源范围(clang``::``SourceRange
)来指定要删除或替换的文本位置。
clang``::``Rewriter
使用clang``::``SourceManager
,如第 4.4.1 节**,源管理器和源位置中所述,来访问需要修改的源代码。让我们看看 Rewriter 如何在实际项目中使用。
7.2.2 测试类
假设我们有一个用于测试的类。类的名称以“Test”前缀开头(例如,TestClass
),但类的公共方法没有“test_”前缀。例如,该类有一个名为’pos’的公共方法(TestClass``::``pos
),而不是’test_pos’(`TestClass::
test_pos``())。我们想要创建一个工具,为类方法添加这样的前缀。
1 class TestClass {
2 public:
3 TestClass(){};
4 void pos(){};
5
6 private:
7 void private_pos(){};
8 };
原始代码
**```cpp
1 class TestClass {
2 public:
3 TestClass(){};
4 void test_pos(){};
5
6 private:
7 void private_pos(){};
8 };
**修改后的代码**
****图 7.1**: TestClass 的代码转换
因此,我们希望将方法`TestClass``::``pos`(见图 7.1)在类声明中替换为`TestClass``::``test_pos`。
如果我们有调用方法的代码,应进行以下替换:
```cpp
1 TestClass test;
2 test.pos();
原始代码
**```cpp
1 TestClass test;
2 test.test_pos();
**修改后的代码**
****图 7.2**: TestClass 的方法调用代码转换
工具还应忽略所有已手动或自动应用了所需修改的公共方法。换句话说,如果一个方法已经有了所需的’test_’前缀,则工具不应修改它。
我们将创建一个名为“methodrename”的 Clang 工具,该工具将执行所有必要的代码修改。这个工具将利用在第*第 3.4 节**,递归 AST 访问者*中讨论的递归 AST 访问者。最关键的部分是实现`Visitor`类。让我们详细地检查它。
### 7.2.3 访问者类实现
我们的`Visitor`类应该处理以下 AST 节点中的特定处理:
+ `clang``::``CXXRecordDecl`:这涉及到处理以“Test”前缀开头的 C++类定义。对于此类,所有用户定义的公共方法都应该以“test_”为前缀。
+ `clang``::``CXXMemberCallExpr`:此外,我们还需要识别所有修改后方法的使用实例,并在类定义中方法重命名后进行相应的更改。
对于`clang``::``CXXRecordDecl`节点的处理如下:
```cpp
10 bool VisitCXXRecordDecl(clang::CXXRecordDecl *Class) {
11 if (!Class->isClass())
12 return true;
13 if (!Class->isThisDeclarationADefinition())
14 return true;
15 if (!Class->getName().starts_with("Test"))
16 return true;
17 for (const clang::CXXMethodDecl *Method : Class->methods()) {
18 clang::SourceLocation StartLoc = Method->getLocation();
19 if (!processMethod(Method, StartLoc, "Renamed method"))
20 return false;
21 }
22 return true;
23 }
图 7.3:CXXRecordDecl 访问者实现
图 7.3 中的 第 11-16 行 表示我们从检查的节点中需要的条件。例如,相应的类名应以 "Test" 前缀开头(参见 图 7.3 中的 第 15-16 行),在那里我们使用了 llvm::StringRef
类的 starts_with()
方法。
在验证这些条件之后,我们继续检查找到的类中的方法。
验证过程在 Visitor::processMethod
方法中实现,其实现如下代码片段:
44 bool processMethod(const clang::CXXMethodDecl *Method,
45 clang::SourceLocation StartLoc, const char *LogMessage) {
46 if (Method->getAccess() != clang::AS_public)
47 return true;
48 if (llvm::isa<clang::CXXConstructorDecl>(Method))
49 return true;
50 if (!Method->getIdentifier() || Method->getName().starts_with("test_"))
51 return true;
52
53 std::string OldMethodName = Method->getNameAsString();
54 std::string NewMethodName = "test_" + OldMethodName;
55 clang::SourceManager &SM = Context.getSourceManager();
56 clang::tooling::Replacement Replace(SM, StartLoc, OldMethodName.length(),
57 NewMethodName);
58 Replaces.push_back(Replace);
59 llvm::outs() << LogMessage << ": " << OldMethodName << " to "
60 << NewMethodName << "\n";
61 return true;
62 }
图 7.4:processMethod
的实现
图 7.4 中的 第 46-51 行 包含了对所需条件的检查。例如,在第 46-47 行,我们验证方法是否为公共的。第 48-49 行 用于排除构造函数的处理,而 第 50-51 行 用于排除已经具有所需前缀的方法。
主要的替换逻辑实现在 第 53-58 行。特别是,在第 56-57 行,我们创建了一个特殊的 clang::tooling::Replacement
对象,它作为所需代码修改的包装器。该对象的参数如下:
-
clang::SourceManager
:我们在 第 55 行 从clang::ASTContext
获取源管理器。 -
clang::SourceLocation
:指定替换的起始位置。位置作为我们processMethod
方法的第二个参数传递,如 第 45 行 所见。 -
unsigned
:替换文本的长度。 -
clang::StringRef
:替换文本,我们在 第 54 行 创建。
我们将替换存储在 Replaces
对象中,它是我们访问者类的私有成员:
40 private:
41 clang::ASTContext &Context;
42 std::vector<clang::tooling::Replacement> Replaces;
有一个特殊的获取器可以用于在访问者类外部访问对象:
36 const std::vector<clang::tooling::Replacement> &getReplacements() {
37 return Replaces;
38 }
我们在第 59-60 行使用 LogMessage
作为日志消息的前缀记录操作。对于不同的 AST 节点,使用不同的日志消息;例如,对于 clang::CXXRecordDecl
,我们使用 "Renamed method"(参见 图 7.3,第 19 行*)。
方法调用将会有不同的日志消息。相应的处理在下面的图中展示。
25 bool VisitCXXMemberCallExpr(clang::CXXMemberCallExpr *Call) {
26 if (clang::CXXMethodDecl *Method = Call->getMethodDecl()) {
27 clang::CXXRecordDecl *Class = Method->getParent();
28 if (!Class->getName().starts_with("Test"))
29 return true;
30 clang::SourceLocation StartLoc = Call->getExprLoc();
31 return processMethod(Method, StartLoc, "Renamed method call");
32 }
33 return true;
34 }
图 7.5:CXXMemberCallExpr 访问者实现
我们在第 *27-29 行验证包含测试方法类的类名以 "Test" 前缀开头。替换源位置在第 *30 行获得。在第 *31 行,我们调用我们的 processMethod
函数来处理找到的方法,将 "Renamed method call" 作为日志消息传递给调用。
Visitor
在 Consumer
类中初始化,这将是我们的下一个目标。
7.2.4 Consumer 类实现
Consumer
类在 HandleTranslationUnit
方法中初始化访问者并开始 AST 遍历。该类可以编写如下:
6 class Consumer : public clang::ASTConsumer {
7 public:
8 void HandleTranslationUnit(clang::ASTContext &Context) override {
9 Visitor V(Context);
10 V.TraverseDecl(Context.getTranslationUnitDecl());
11
12 // Apply the replacements.
13 clang::Rewriter Rewrite(Context.getSourceManager(), clang::LangOptions());
14 auto &Replaces = V.getReplacements();
15 for (const auto &Replace : Replaces) {
16 if (Replace.isApplicable()) {
17 Replace.apply(Rewrite);
18 }
19 }
20
21 // Apply the Rewriter changes.
22 if (Rewrite.overwriteChangedFiles()) {
23 llvm::errs() << "Error: Cannot apply changes to the file\n";
24 }
25 }
26 };
27 } // namespace methodrename
图 7.6:Consumer 类实现
我们初始化访问者,并从第 9-10 行开始遍历(见图 7.6)。重写器在第 13 行创建,替换在第 14-19 行应用。最后,结果在第 22-24 行存储在原始文件中。
访问者和消费者类被封装在clangbook::methodrename
命名空间中。消费者实例在 FrontendAction 类中创建。这个类的实现与图 3.8 中详细描述的RecursiveVisitor
和DeclVisitor
的实现类似。唯一的区别是,新工具使用了clangbook::methodrename
命名空间。
7.2.5 构建配置和主函数
我们工具的main
函数与图 3.21 中定义的递归访问者类似:
13 int main(int argc, const char **argv) {
14 llvm::Expected<clang::tooling::CommonOptionsParser> OptionsParser =
15 clang::tooling::CommonOptionsParser::create(argc, argv, TestCategory);
16 if (!OptionsParser) {
17 llvm::errs() << OptionsParser.takeError();
18 return 1;
19 }
20 clang::tooling::ClangTool Tool(OptionsParser->getCompilations(),
21 OptionsParser->getSourcePathList());
22 return Tool.run(clang::tooling::newFrontendActionFactory<
23 clangbook::methodrename::FrontendAction>()
24 .get());
25 }
图 7.7:'methodrename'测试工具的主函数
如您所见,我们只在第 23 行更改了自定义前端动作的命名空间名称。
构建配置如下:
1 cmake_minimum_required(VERSION 3.16)
2 project("methodrename")
3
4 if ( NOT DEFINED ENV{LLVM_HOME})
5 message(FATAL_ERROR "$LLVM_HOME is not defined")
6 else()
7 message(STATUS "$LLVM_HOME found: $ENV{LLVM_HOME}")
8 set(LLVM_HOME $ENV{LLVM_HOME} CACHE PATH "Root of LLVM installation")
9 set(LLVM_LIB ${LLVM_HOME}/lib)
10 set(LLVM_DIR ${LLVM_LIB}/cmake/llvm)
11 find_package(LLVM REQUIRED CONFIG)
12 include_directories(${LLVM_INCLUDE_DIRS})
13 link_directories(${LLVM_LIBRARY_DIRS})
14 set(SOURCE_FILE MethodRename.cpp)
15 add_executable(methodrename ${SOURCE_FILE})
16 set_target_properties(methodrename PROPERTIES COMPILE_FLAGS "-fno-rtti")
17 target_link_libraries(methodrename
18 LLVMSupport
19 clangAST
20 clangBasic
21 clangFrontend
22 clangSerialization
23 clangToolingCore
24 clangRewrite
25 clangTooling
26 )
27 endif()
图 7.8:'methodrename'测试工具的构建配置
与图 3.20 中的代码相比,最显著的变化在第 23 和 24 行,我们添加了两个新的库来支持代码修改:clangToolingCore
和clangRewrite
。其他更改还包括工具的新名称(第 2 行)和包含主函数的源文件(第 14 行)。
一旦我们完成代码,就是时候构建和运行我们的工具了。
7.2.6 运行代码修改工具
程序可以使用与我们在第 3.3 节**,AST 遍历中之前使用的相同命令序列进行编译,见图 3.11:
export LLVM_HOME=<...>/llvm-project/install
mkdir build
cd build
cmake -G Ninja -DCMAKE_BUILD_TYPE=Debug ...
ninja
图 7.9:'methodrename'工具的配置和构建命令
我们可以在以下测试文件(TestClass.cpp
)上运行创建工具:
1 class TestClass {
2 public:
3 TestClass(){};
4 void pos(){};
5 };
6
7 int main() {
8 TestClass test;
9 test.pos();
10 return 0;
11 }
图 7.10:原始 TestClass.cpp
我们可以按以下方式运行工具:
$ ./methodrename TestClass.cpp -- -std=c++17
Renamed method: pos to test_pos
Renamed method call: pos to test_pos
图 7.11:在 TestClass.cpp 上运行 methodrename Clang Tool
如我们所见,方法TestClass::pos
被重命名为TestClass::test_pos
。方法调用也如以下图所示进行了更新:
1 class TestClass {
2 public:
3 TestClass(){};
4 void test_pos(){};
5 };
6
7 int main() {
8 TestClass test;
9 test.test_pos();
10 return 0;
11 }
图 7.12:修改后的 TestClass.cpp
提供的示例演示了 Clang 如何协助创建重构工具。创建的 Clang Tool 使用递归访问者来设置所需的代码转换。另一个可能的选项是使用 Clang-Tidy,我们之前在第五章**,Clang-Tidy Linter Framework中进行了调查。让我们更详细地考察这个选项。
7.3 Clang-Tidy 作为代码修改工具
我们计划研究FixItHint
,它是 Clang 诊断子系统的一部分(参见第 4.4.2 节**,诊断支持)。FixItHint
可以与之前探索过的clang::Rewriter
和clang::tooling::Replacement
集成,提供用于 Clang-Tidy 等强大工具的高级诊断。
7.3.1 FixItHint
clang::FixItHint
是 Clang 编译器中的一个类,它显著增强了其诊断能力。其主要作用是提供编译器检测到的代码错误或问题的自动修正建议。这些建议被称为“修复”,是 Clang 诊断消息的一部分,旨在指导开发者在他们的代码中解决已识别的问题。
当 Clang 遇到编码错误、警告或风格问题时,它会生成一个FixItHint
。这个提示包含对源代码更改的具体建议。例如,它可能建议用修正后的版本替换文本片段或在特定位置插入或删除代码。
例如,考虑以下源代码:
1 void foo() {
2 constexpr int a = 0;
3 constexpr const int *b = &a;
4 }
图 7.13: 测试文件 foo.cpp
如果我们对文件进行编译,我们将得到以下错误:
$ <...>/llvm-project/install/bin/clang -cc1 -emit-obj foo.cpp -o /tmp/foo.o
foo.cpp:3:24: error: constexpr variable ’b’ must be initialized by a
constant expression
3 | constexpr const int *b = &a;
| ^ ~~
foo.cpp:3:24: note: pointer to ’a’ is not a constant expression
foo.cpp:2:17: note: address of non-static constexpr variable ’a’ may differ
on each invocation of the enclosing function; add ’static’ to give it a
constant address
2 | constexpr int a = 0;
| ^
| static
1 error generated.
图 7.14: 在 foo.cpp 中生成的编译错误
如您所见,编译器建议为图 7.13 中显示的程序在第 2 行添加static
关键字。
错误由 Clang 使用 FixItHint 对象处理,如图 7.15 所示。如图 7.15 所示,当 Clang 在源代码中检测到问题并生成诊断时,它还可以生成一个clang::FixItHint
,建议如何修复问题。提示随后由 Clang 诊断子系统处理并显示给用户。
需要强调的是,提示也可以转换为Replacement
对象,它表示所需的精确文本更改。例如,Clang-Tidy 使用Replacement
对象作为其DiagnosticConsumer
类实现中从FixItHint
获取信息的临时存储,允许将 FixItHint 转换为表示所需精确文本更改的Replacement
对象。
if (VarD && VarD->isConstexpr()) {
// Non-static local constexpr variables have unintuitive semantics:
// constexpr int a = 1;
// constexpr const int *p = &a;
// ... is invalid because the address of ’a’ is not constant. Suggest
// adding a ’static’ in this case.
Info.Note(VarD->getLocation(), diag::note_constexpr_not_static)
<< VarD
<< FixItHint::CreateInsertion(VarD->getBeginLoc(), "static ");
图 7.15: 来自 clang/lib/AST/ExprConstant.cpp 的代码片段
总体而言,clang::FixItHint
增强了 Clang 的用户友好性和实用性,为开发者提供了提高代码质量和有效解决问题的实用工具。它集成到 Clang 的诊断系统中,体现了编译器不仅关注定位代码问题,还帮助解决这些问题的重点。我们计划在 Clang-Tidy 检查中使用此功能,该检查将重命名测试类中的方法,并将图 7.10 中的代码转换为图 7.12 中的代码。
7.3.2 创建项目骨架
让我们为我们的 Clang-Tidy 检查创建项目骨架。我们将我们的检查命名为”methodrename”,它将是”misc”集合的一部分 Clang-Tidy 检查。我们将使用第 5.4.1 节中的命令。
$ ./clang-tools-extra/clang-tidy/add_new_check.py misc methodrename
图 7.16:为 misc-methodrename 检查创建骨架
从图 7.16 中的命令应该在克隆的 LLVM 项目的根目录下运行。我们为add_new_check.py
脚本指定了两个参数:misc
– 将包含我们的新检查的检查集,和methodrename
– 我们检查的名称。
命令将产生以下输出:
Updating ./clang-tools-extra/clang-tidy/misc/CMakeLists.txt...
Creating ./clang-tools-extra/clang-tidy/misc/MethodrenameCheck.h...
Creating ./clang-tools-extra/clang-tidy/misc/MethodrenameCheck.cpp...
Updating ./clang-tools-extra/clang-tidy/misc/MiscTidyModule.cpp...
Updating clang-tools-extra/docs/ReleaseNotes.rst...
Creating clang-tools-extra/test/clang-tidy/checkers/misc/methodrename.cpp...
Creating clang-tools-extra/docs/clang-tidy/checks/misc/methodrename.rst...
Updating clang-tools-extra/docs/clang-tidy/checks/list.rst...
Done. Now it’s your turn!
图 7.17:为 misc-methodrename 检查创建的工件
我们必须修改./clang-tools-extra/clang-tidy
/misc
文件夹中的至少两个生成的文件:
-
MethodrenameCheck.h
:这是我们的检查的头文件。在这里,我们想要添加一个额外的私有方法processMethod
,用于检查方法属性并显示诊断信息。 -
MethodrenameCheck.cpp
:此文件包含处理逻辑,我们需要实现三个方法:registerMatchers
、check
和新增的私有方法processMethod
。
7.3.3 检查实现
我们将从修改头文件开始:
27 private:
28 void processMethod(const clang::CXXMethodDecl *Method,
29 clang::SourceLocation StartLoc, const char *LogMessage);
30 };
图 7.18:MethodrenameCheck.h 修改
新增的私有方法MethodrenameCheck::processMethod
具有与我们在 Clang Tool ’methodrename’中较早引入的方法相同的参数,如图 7.4 所示。
我们从我们的检查的MethodrenameCheck::registerMatchers
方法开始实现,如下所示:
26 void MethodrenameCheck::registerMatchers(MatchFinder *Finder) {
27 auto ClassMatcher = hasAncestor(cxxRecordDecl(matchesName("::Test.*$")));
28 auto MethodMatcher = cxxMethodDecl(isNotTestMethod(), ClassMatcher);
29 auto CallMatcher = cxxMemberCallExpr(callee(MethodMatcher));
30 Finder->addMatcher(MethodMatcher.bind("method"), this);
31 Finder->addMatcher(CallMatcher.bind("call"), this);
32 }
图 7.19:registerMatchers 实现
第 30 行和第 31 行注册了两个匹配器。第一个是用于方法声明(绑定到”method”标识符),第二个是用于方法调用(绑定到”call”标识符)。
在这里,我们使用在第 3.5 节中定义的领域特定语言(DSL),即AST 匹配器。ClassMatcher
指定我们的方法声明必须在以“Test”前缀开头的类内部声明。
方法声明匹配器(MethodMatcher
)定义在第 28 行。它必须在由ClassMatcher
指定的类内部声明,并且应该是一个测试方法(关于isNotTestMethod
匹配器的详细信息将在下面描述)。
最后一个匹配器,CallMatcher
,定义在第 29 行,并指定它必须是对满足MethodMatcher
条件的方法的调用。
isNotTestMethod
匹配器是一个专门用于检查我们特定条件的匹配器。我们可以使用AST_MATCHER
和相关宏来定义自己的匹配器。它的实现可以在这里找到:
18 AST_MATCHER(CXXMethodDecl, isNotTestMethod) {
19 if (Node.getAccess() != clang::AS_public) return false;
20 if (llvm::isa<clang::CXXConstructorDecl>(&Node)) return false;
21 if (!Node.getIdentifier() || Node.getName().startswith("test_")) return false;
22
23 return true;
24 }
图 7.20:isNotTestMethod
匹配器实现
宏有两个参数。第一个参数指定我们想要检查的 AST 节点,在我们的情况下是clang
::CXXMethodDecl
。第二个参数是我们想要用于用户定义匹配器的匹配器名称,在我们的情况下是isNotTestMethod
。
AST 节点可以在宏体中以Node
变量访问。宏应该返回true
如果节点符合所需条件。我们使用与我们在图 7.4 中使用的相同条件,用于我们的methodrename
Clang 工具(行 46-51)。
MethodrenameCheck
::check
是我们检查的主要方法,可以如下实现:
34 void MethodrenameCheck::check(const MatchFinder::MatchResult &Result) {
35 if (const auto *Method = Result.Nodes.getNodeAs<CXXMethodDecl>("method")) {
36 processMethod(Method, Method->getLocation(), "Method");
37 }
38
39 if (const auto *Call = Result.Nodes.getNodeAs<CXXMemberCallExpr>("call")) {
40 if (CXXMethodDecl *Method = Call->getMethodDecl()) {
41 processMethod(Method, Call->getExprLoc(), "Method call");
42 }
43 }
44 }
图 7.21:检查实现
代码有两个块。第一个块(行 35-37)处理方法声明,最后一个块(行 39-42)处理方法调用。两者都调用MethodrenameCheck
::
processMethod
来显示诊断并创建所需的代码修改。
让我们检查它的实现以及如何使用clang
::FixItHint
。
46 void MethodrenameCheck::processMethod(const clang::CXXMethodDecl *Method,
47 clang::SourceLocation StartLoc,
48 const char *LogMessage) {
49 diag(StartLoc, "%0 %1 does not have ’test_’ prefix") << LogMessage << Method;
50 diag(StartLoc, "insert ’test_’", DiagnosticIDs::Note)
51 << FixItHint::CreateInsertion(StartLoc, "test_");
52 }
图 7.22:processMethod 实现
我们在行 49打印关于检测到的问题的诊断信息。行 50-51打印有关建议的代码修改的信息性消息,并在行 51创建相应的代码替换。要插入文本,我们使用clang
::FixItHint
::CreateInsertion
。我们还以注释的形式显示插入到主要警告中。
一旦所有必要的更改都应用到生成的骨架中,就是时候在测试文件上构建和运行我们的检查了。
7.3.4 构建和运行检查
我们假设使用了图 1.12 中的构建配置。因此,我们必须运行以下命令来构建我们的检查:
$ ninja clang-tidy
我们可以使用以下命令将其安装到install
文件夹中:
$ ninja install
我们可以在TestClass
上如下运行我们的检查,如图 7.10 所示:
$ <...>/llvm-project/install/bin/clang-tidy \
-checks=’-*,misc-methodrename’ \
./TestClass.cpp \
-- -std=c++17
图 7.23:在测试文件 TestClass.cpp 上运行的 Clang-Tidy misc-methodrename 检查
命令将产生以下输出:
TestClass.cpp:4:8: warning: Method ’pos’ does not have ’test_’ prefix
[misc-methodrename]
void pos(){};
^
TestClass.cpp:4:8: note: insert ’test_’
void pos(){};
^
test_
TestClass.cpp:9:8: warning: Method call ’pos’ does not have ’test_’ prefix
[misc-methodrename]
test.pos();
^
TestClass.cpp:9:8: note: insert ’test_’
test.pos();
^
test_
图 7.24:由 misc-methodrename 检查为 TestClass.cpp 生成的警告
如我们所见,检查正确地检测到两个需要更改方法名的地方并创建了替换。来自图 7.23 的命令不会修改原始源文件。我们必须指定一个额外的参数-fix-notes
来将指定的插入作为注释应用到原始警告中。所需的命令将如下所示:
$ <...>/llvm-project/install/bin/clang-tidy \
-fix-notes \
-checks=’-*,misc-methodrename’ \
./TestClass.cpp \
-- -std=c++17
图 7.25:带有-fix-notes 选项的 Clang-Tidy
命令输出如下:
2 warnings generated.
TestClass.cpp:4:8: warning: Method ’pos’ does not have ’test_’ prefix
[misc-methodrename]
void pos(){};
^
TestClassSmall.cpp:4:8: note: FIX-IT applied suggested code changes
TestClass.cpp:4:8: note: insert ’test_’
void pos(){};
^
test_
TestClass.cpp:9:8: warning: Method call ’pos’ does not have ’test_’ prefix
[misc-methodrename]
test.pos();
^
TestClass.cpp:9:8: note: FIX-IT applied suggested code changes
TestClass.cpp:9:8: note: insert ’test_’
test.pos();
^
test_
clang-tidy applied 2 of 2 suggested fixes.
图 7.26:Clang-Tidy 对 TestClass.cpp 应用的修复
如我们所见,所需的插入已在此处应用。Clang-Tidy 具有强大的工具来控制应用的修复,并且可以被认为是代码修改的重要资源。另一个用于代码修改的流行工具是 Clang-Format。正如其名所示,该工具专门用于代码格式化。让我们详细探讨它。
7.4 代码修改和 Clang-Format
Clang-Format 是 Clang/LLVM 项目中的一个重要工具,旨在格式化 C、C++、Java、JavaScript、Objective-C 或 Protobuf 代码。它在 Clang 工具生态系统扮演着关键角色,提供了解析、分析和操作源代码的能力。
Clang-Format 是 Clang 的一部分,如果已经构建并安装了 Clang 编译器,则必须安装它。让我们看看它是如何使用的。
7.4.1 Clang-Format 配置和使用示例
Clang-Format 使用 .clang-format
配置文件。实用程序将使用最近的配置文件;即,如果该文件位于我们想要格式化的源文件所在的文件夹中,则将使用该文件夹的配置。配置文件的格式是 YAML,与 Clang-Tidy 配置文件使用的格式相同,如 图* 5.12**,Clang-Tidy 配置。让我们创建以下简单的配置文件:
1 BasedOnStyle: LLVM
图 7.27**: 简单的 .clang-format 配置文件
配置文件指出我们将使用由 LLVM 定义的代码风格,请参阅 llvm.org/docs/CodingStandards.html
。
假设我们有一个未格式化的文件 main.cpp
,那么以下命令将格式化它:
$ <...>/llvm-project/install/bin/clang-format -i main.cpp
格式化的结果如下所示:
1 namespace clang {
2 class TestClang {
3 public:
4 void testClang(){};
5 };
6 }int main() {
7 TestClang test;
8 test.testClang();
9 return 0;
10 }
原始代码
[PRE36]
格式化后的代码
**图 7.28**: main.cpp 的格式化
在 图** 7.28 提供的示例中,我们可以看到应用了由 LLVM 代码风格定义的缩进。我们还可以观察到 Clang-Format 将原始源代码中的 第 6 行 分割,并使主函数定义从新的一行开始。此外,我们还可以看到 Clang-Format 在格式化代码的第 6 行添加了一个注释到命名空间关闭括号。
在考虑了使用示例之后,现在是时候看看 Clang-Format 的内部设计了。
7.4.2 设计考虑
Clang-Format 的核心是 Clang Lexer(见 图* 2.5**,Lexer*),它将输入源代码分解成单个标记,如关键字、标识符和字面量。这些标记是格式化决策的基础。
Clang-Format 的初始设计文档考虑了解析器和 AST 作为格式化的基本组件。尽管 AST 等高级数据结构提供了优势,但这种方法也有一些缺点:
-
解析器需要完整的构建过程和相应的构建配置。
-
解析器在处理源文本的一部分时能力有限,这是格式化中的一个典型任务,例如格式化单个函数或源文件的源范围。
-
当使用 AST 作为格式化的基本结构时,格式化宏是一个具有挑战性的任务。例如,处理过的宏可能没有在编译后的代码中调用,因此可能在 AST 中被遗漏。
-
解析器比 Lexer 慢得多。
Clang-Format 利用 clang``::``tooling``::``Replacement
来表示代码格式化更改,并利用 clang``::``Rewriter
将这些更改应用到源代码中。
配置在 Clang-Format 的操作中起着关键作用。用户通过在 .clang-format
文件中配置规则来定义他们首选的格式化风格。此配置指定了缩进宽度、括号放置、换行符等详细信息。
Clang-Format 支持各种预定义和可自定义的格式化风格,如“LLVM”、“Google”和“Chromium”。用户可以选择与项目编码标准一致的风格。
一旦进行标记,Clang-Format 会处理标记流,考虑到当前上下文、缩进级别和配置的样式规则。然后相应地调整空白和换行符,以符合所选风格。
Clang-Format 的一个显著特点是它能够有效地处理宏,保留宏和复杂宏中的原始格式。
自定义是 Clang-Format 的一个关键方面。用户可以通过在配置文件中定义自定义规则和格式化选项来扩展或自定义其行为。这种灵活性允许团队强制执行特定的编码标准或根据项目特定需求调整 Clang-Format。
它提供了一个用户友好的命令行界面,允许手动格式化代码或集成到脚本和自动化中。
Clang-Format 利用 Clang 的格式化库来准确生成格式化代码。这个库确保代码始终遵循所需的格式化风格。设计遵循 LLVM 的主要范式:“一切皆库”,如第 1.2.1 节“简短的 LLVM 历史”中所述。因此,我们可以有效地在其他 Clang 工具中使用格式化功能。例如,可以使用 Clang-Tidy 的修复来格式化代码。让我们考虑一个如何使用此功能的例子。
7.4.3 Clang-Tidy 和 Clang-Format
应用 Clang-Tidy 修复可能会破坏格式。Clang-Tidy 建议使用 -format-style
选项来解决这个问题。此选项将使用 clangFormat 库提供的功能进行格式化。格式化应用于修改后的代码行。考虑我们的 TestClass 格式化损坏的例子。
如果我们像之前一样运行 Clang-Tidy(见图 7.25),则格式将保持不变和损坏:
1 class TestClass {
2 public:
3 TestClass(){};
4 void pos(){};
5 };
6
7 int main() {
8 TestClass test;
9 test.pos();
10 return 0;
11 }
原始代码
**```cpp
1 class TestClass {
2 public:
3 TestClass(){};
4 void test_pos(){};
5 };
6
7 int main() {
8 TestClass test;
9 test.test_pos();
10 return 0;
11 }
**应用修复**
****图 7.29**: 在 TestClassNotFormated.cpp 上应用 Clang-Tidy 修复而不进行格式化
我们使用以下命令生成了图 7.29
```cpp
$ <...>/llvm-project/install/bin/clang-tidy\
-fix-notes \
-checks=’-*,misc-methodrename’ \
./TestClassNotFormated.cpp \
-- -std=c++17
如果我们使用 -format-style
选项运行 Clang-Tidy,结果将不同,例如:
$ <...>/llvm-project/install/bin/clang-tidy\
-format-style ’llvm’ \
-fix-notes \
-checks=’-*,misc-methodrename’ \
./TestClassNotFormated.cpp \
-- -std=c++17
如我们所见,示例中选择了“llvm”格式化风格。结果将在以下图中展示:
1 class TestClass {
2 public:
3 TestClass(){};
4 void pos(){};
5 };
6
7 int main() {
8 TestClass test;
9 test.pos();
10 return 0;
11 }
原始代码
**```cpp
1 class TestClass {
2 public:
3 TestClass(){};
4 void test_pos(){};
5 };
6
7 int main() {
8 TestClass test;
9 test.test_pos();
10 return 0;
11 }
**应用格式化修复**
****图 7.30**: 在 TestClassNotFormated.cpp 上应用 Clang-Tidy 格式化修复
正如我们刚才所展示的,Clang-Tidy 和 Clang-Format 之间的关系可以如图所示:

**图 7.31**: Clang-Tidy 和 Clang-Format 集成
在图中,Clang-Tidy 和 Clang-Format 都使用`clangFormat`库来格式化代码。
提供的示例演示了各种 Clang 工具的集成。模块化,LLVM/Clang 中一个基本的设计决策,是此类集成的一个关键组件。这个例子并不独特,我们将探讨不同 Clang 工具的进一步集成,以增强**集成开发环境**(**IDEs**)如**Visual Studio Code**(**VS Code**)的开发体验。这将是下一章的主题。
## 7.5 摘要
在本章中,我们研究了 Clang 提供的不同代码修改选项。我们创建了一个专门用于重命名测试类中方法的 Clang 工具。我们还使用 Clang-Tidy 重写了工具,并探讨了如何创建自定义 AST 匹配器。此外,我们还深入研究了 Clang 提供的各种不同类,用于代码修改。其中之一,`clang``::``FixItHint`,与 Clang 诊断子系统集成,为 Clang 以及使用 Clang 创建的不同工具中的代码修改提供了一个强大的工具。我们以 Clang-Format 结束,这是本书中唯一不使用 AST 而是利用 Clang 词法分析器进行代码格式化的工具。下一章将专注于 IDE 中不同 Clang 工具的集成。
## 7.6 进一步阅读
+ Clang-Format 样式选项:[`clang.llvm.org/docs/ClangFormatStyleOptions.html`](https://clang.llvm.org/docs/ClangFormatStyleOptions.html)
+ 彼得·戈尔茨伯勒,Clang 中的诊断输出 [23]
+ AST 匹配器参考:[`clang.llvm.org/docs/LibASTMatchersReference.html`](https://clang.llvm.org/docs/LibASTMatchersReference.html)********************
# 第八章:IDE 支持和 Clangd
本章介绍**语言服务器协议(LSP**)以及如何利用它来增强你的**集成开发环境(IDE**)。我们首选的 IDE 是**Visual Studio Code (VS Code**)。LLVM 有自己实现的 LSP,称为**Clangd**。我们将首先描述 LSP,并探讨 Clangd 如何利用它来扩展 IDE 提供的能力。最后,我们将通过示例说明如何通过各种 Clang 工具,如 Clang-Tidy 和 Clang-Format,通过 Clangd 无缝集成到 IDE 中。
本章将涵盖以下主题:
+ 什么是语言服务器协议(LSP)以及它是如何提高 IDE 能力的?
+ 如何安装 VS Code 和 Clangd(Clang LSP 服务器)
+ 通过示例说明如何使用 LSP 连接 VS Code 和 Clangd
+ Clangd 如何与其他 Clang 工具集成
+ 为什么性能对 Clangd 很重要以及为了使 Clangd 快速所进行的优化
## 8.1 技术要求
本章的源代码位于本书 GitHub 仓库的`chapter8`文件夹中:[`github.com/PacktPublishing/Clang-Compiler-Frontend-Packt/tree/main/chapter8`](https://github.com/PacktPublishing/Clang-Compiler-Frontend-Packt/tree/main/chapter8)
## 8.2 语言服务器协议
IDE 是一种软件应用程序或平台,它提供了一套全面的工具和功能,以帮助开发者创建、编辑、调试和管理软件代码。IDE 通常包括具有语法高亮的代码编辑器、调试功能、项目管理功能、版本控制集成,以及通常支持各种编程语言和框架的插件或扩展。
流行 IDE 的例子有 Visual Studio/VS Code、IntelliJ IDEA、Emacs 和 Vim。这些工具旨在简化开发过程,使开发者能够更高效地编写、测试和维护代码。
典型的 IDE 支持多种语言,集成每种语言可能是一项具有挑战性的任务。每种语言都需要特定的支持,这可以在图 8.1 中可视化。值得注意的是,不同编程语言的开发过程有许多相似之处。例如,图 8.1 中显示的语言具有代码导航功能,允许开发者快速定位和查看其代码库中符号或标识符的定义。

**图 8.1**:IDE 中的编程语言集成
本章中将此功能称为**跳转到定义**。这些相似之处表明,通过引入一个称为**语言服务器协议(LSP**)的中间层,可以简化图 8.1 中显示的关系。如上所示:

**图 8.2**: 在 IDE 中使用 LSP 进行编程语言集成
**LSP**项目由微软于 2015 年启动,作为其改进 VS Code(一个轻量级、开源代码编辑器)努力的一部分。微软认识到在 VS Code 和其他代码编辑器中为不同编程语言提供丰富语言服务需要一个标准化的方式。
LSP 在开发者社区中迅速获得了流行和采用。包括 VS Code、Emacs 和 Eclipse 在内的许多代码编辑器和 IDE 开始实现 LSP 的支持。
为各种编程语言出现了语言服务器实现。这些语言服务器由微软和开源社区共同开发,提供了特定于语言的知识和服务,使得将语言功能集成到不同的编辑器中变得更加容易。
在本章中,我们将探讨**Clangd**,它是 clang-tools-extra 的一部分的语言服务器。Clangd 利用 Clang 编译器前端,提供了一套全面的代码分析和语言支持功能。Clangd 通过智能代码补全、语义分析和实时诊断帮助开发者更高效地编写代码,并在开发过程中早期捕捉错误。我们将在此处详细探讨 Clangd,从 IDE(VS Code)和 Clangd 之间的交互的真实示例开始。我们将从环境设置开始,包括 Clangd 构建和 VS Code 设置。
## 8.3 环境设置
我们将开始我们的环境设置,首先构建 Clangd。然后,我们将安装 VS Code,设置 Clangd 扩展,并在其中配置 Clangd。
### 8.3.1 Clangd 构建
值得注意的是,我们应该以发布模式构建 Clangd,就像我们在*第 1.3.3 节*中为 LLDB 所做的那样,即*LLVM 调试器、其构建和使用*。这是因为性能在 IDE 中至关重要。例如,Clangd 需要构建 AST 以提供代码导航功能。如果用户修改了文档,则应重新构建文档,并且导航功能将不会在重新构建过程完成之前可用。这可能导致 IDE 响应延迟。为了防止 IDE 响应缓慢,我们应该确保 Clangd 以所有必需的优化构建。您可以使用以下项目配置命令:
```cpp
cmake -G Ninja -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=../install -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_ENABLE_PROJECTS="clang;clang-tools-extra" ../llvm
图 8.3: Clangd 构建的发布配置
命令必须在我们在第 1.3.3 节中创建的release
文件夹中运行,即 LLVM 调试器、其构建和使用,如图图 1.8 所示。正如您所看到的,我们在图 8.3 中启用了两个项目:clang
和clang-tools-extra
。
您可以使用以下命令构建和安装 Clangd:
$ ninja install-clangd -j $(nproc)
此命令将利用系统上可用的最大线程数,并将二进制文件安装到我们在图 8.3 中指定的 CMake 命令的文件夹中,即 LLVM 源树下的install
文件夹。
在构建 Clangd 二进制文件后,我们的下一步将包括安装 VS Code 并将其配置为与 Clangd 一起使用。
8.3.2 VS Code 安装和配置
您可以从 VS Code 网站 code.visualstudio.com/download
下载并安装 VS Code。
运行 VS Code 后的第一步是安装 Clangd 扩展。有一个开源扩展可以通过 LSP 与 Clangd 一起使用。该扩展的源代码可以在 GitHub 上找到:github.com/clangd/vscode-clangd
。然而,我们可以轻松地从 VS Code 内部直接安装扩展的最新版本。
要做到这一点,请按 Ctrl+Shift+X(或在 macOS 上按 +Shift+X)以打开扩展面板。搜索
Clangd
并单击 安装 按钮。
图 8.4:安装 Clangd 扩展
安装扩展后,我们需要对其进行设置。主要步骤是指定 Clangd 可执行文件的路径。
您可以通过 文件 — 首选项 — 设置 菜单或按 Ctrl + ,(或在 macOS 上按 +,)访问此设置,如下面的截图所示:
图 8.5:设置 Clangd 扩展
如 图 8.5 所示,我们已将 Clangd 路径配置为 /home/ivanmurashko
/clangbook/llvm-project/install/bin/clangd
。此路径在 第 8.3.1 节 中使用,即 Clangd 二进制文件的安装和构建。
您可以打开您喜欢的 C/C++ 源文件并尝试在其中导航。例如,您可以搜索一个标记的定义,在源文件和头文件之间切换,等等。在我们的下一个示例中,我们将研究如何通过 LSP 进行导航,特别是如何实现跳转到定义。
重要提示
我们的设置仅适用于不需要特殊编译标志的简单项目。如果您的项目需要特殊配置才能构建,那么您必须使用生成的 compile_commands.json
文件,该文件应放置在您的项目根目录下。此文件应包含一个 编译数据库(CDB),以 JSON 格式指定项目中每个文件的编译标志。有关设置的更多信息,请参阅 图 9.5 中关于 Clangd 为大型项目设置的内容。
在安装了所需的组件后,我们现在准备进行 LSP 演示,我们将模拟在 IDE 中典型的开发活动(打开和修改文档、跳转到标记定义等)并探索它是如何通过 LSP 表示的。
8.4 LSP 演示
在这个简短的 LSP 演示中,我们将展示 Clangd 如何打开文件并找到符号的定义。Clangd 具有一个全面的日志子系统,它提供了关于其与 IDE 交互的宝贵见解。我们将使用日志子系统来获取必要的信息。
8.4.1 演示描述
在我们的例子中,我们打开一个测试文件,如图所示,并检索doPrivateWork
标记的定义:
图 8.6:跳转到doPrivateWork
标记的定义并悬停
VS Code 通过标准输入/输出与 Clangd 通信,我们将使用 Clangd 日志来捕获交互。
这可以通过在 VS Code 设置中设置包装器 shell 脚本而不是使用实际的 clangd 二进制文件来实现:
图 8.7:在 VS Code 中设置包装器 shell 脚本
我们可以使用以下脚本,clangd.sh
:
1 #!/bin/sh
2 $HOME/clangbook/llvm-project/install/bin/clangd -log verbose -pretty 2> /tmp/clangd.log
图 8.8:clangd 的包装器 shell 脚本
在图 8.8 中,我们使用了两个日志选项:
-
第一个选项,
-log verbose
,激活详细日志记录以确保 Clangd 与 IDE 之间的实际 LSP 消息将被记录。 -
第二个选项,
-pretty
,用于提供格式良好的 JSON 消息。在我们的例子中,我们还把 stderr 输出重定向到日志文件,/tmp/clangd.log
。
因此,该文件将包含我们示例会话的日志。我们可以使用以下命令查看这些日志:
$ cat /tmp/clangd.log
在日志中,我们可以找到由 VS Code 发送的"textDocument/definition"
:
V[16:24:39.336] <<< {
"id": 13,
"jsonrpc": "2.0",
"method": "textDocument/definition",
"params": {
"position": {
"character": 26,
"line": 7
},
"textDocument": {
"uri": "file:///home/ivanmurashko/clangbook/helper.hpp"
}
}
}
图 8.9:IDE 发送的”textDocument/definition”请求
IDE 发送的请求被 Clangd 接收并处理。相应的日志记录如下:
I[16:24:39.336] <-- textDocument/definition(13)
V[16:24:39.336] ASTWorker running Definitions on version 1 of /home/.../
helper.hpp
图 8.10:Clangd 对”textDocument/definition”请求的处理
最后,Clangd 创建响应并将其发送到 IDE。相应的日志记录显示回复已发送:
I[16:24:39.336] --> reply:textDocument/definition(13) 0 ms
V[16:24:39.336] >>> {
"id": 13,
"jsonrpc": "2.0",
"result": [
{
"range": {
"end": {
"character": 20,
"line": 10
},
"start": {
"character": 7,
"line": 10
}
},
"uri": "file:///home/ivanmurashko/clangbook/helper.hpp"
}
]
}
图 8.11:Clangd 的”textDocument/definition”回复
日志将成为我们调查 LSP 内部的主要工具。让我们深入研究更复杂的示例。
8.4.2 LSP 会话
LSP 会话由对 Clangd 服务器的多个请求和响应组成。它从一个 "initialize"
请求开始。然后,我们打开一个文档,VS Code 发送一个 "textDocument/didOpen"
通知。在请求之后,Clangd 将定期响应 "textDocument/publishDiagnostics"
通知,当打开的文件状态发生变化时。例如,这发生在编译完成并且准备好处理导航请求时。接下来,我们发起一个针对标记的跳转到定义请求,Clangd 响应以找到的定义的位置信息。我们还研究了 Clangd 如何处理客户端通过 "textDocument/didChange"
通知通知的文件修改。当我们关闭打开的文件时,我们通过 "textDocument/didClose"
请求结束我们的会话。以下图展示了交互的示意图:
图 8.12: LSP 会话示例
让我们详细看看这个例子。我们将从 "initialize"
请求开始。
初始化
为了建立通信,客户端(代码编辑器或 IDE)和语言服务器交换 JSON-RPC 消息。初始化过程以客户端向语言服务器发送一个 "initialize"
请求开始,指定它支持的功能。VS Code 实际发送的请求相当大,以下是一个简化版本,其中请求的一些部分被 "..."
替换:
1{
2 "id": 0,
3 "jsonrpc": "2.0",
4 "method": "initialize",
5 "params": {
6 "capabilities": {
7 ...
8 "textDocument": {
9 ...
10 "definition": {
11 "dynamicRegistration": true,
12 "linkSupport": true
13 },
14 ...
15 },
16 "clientInfo": {
17 "name": "Visual Studio Code",
18 "version": "1.85.1"
19 },
20 ...
21 }
22 }
图 8.13: VS Code 到 Clangd(初始化请求)
在请求中,客户端(VS Code)告诉服务器(Clangd)客户端支持哪些功能;例如,在 图 8.13 的 第 10-13 行,客户端表示它支持用于跳转到定义请求的 "textDocument/definition"
请求类型。
语言服务器以包含服务器支持的功能的响应来回复请求:
1{
2 "id": 0,
3 "jsonrpc": "2.0",
4 "result": {
5 "capabilities": {
6 ...
7 "definitionProvider": true,
8 ...
9 },
10 "serverInfo": {
11 "name": "clangd",
12 "version": "clangd version 16.0.6 (https://github.com/llvm/llvm-project.git 7cbf1a2591520c2491aa35339f227775f4d3adf6) linux x86_64-unknown-linux-gnu"
13 }
14 }
15 }
图 8.14: Clangd 到 VS Code(初始化回复)
如我们所见,相同的 id
用于将请求与其回复连接起来。Clangd 回复说它支持在 第 7 行 图 8.14 中指定的跳转到定义请求。因此,我们的客户端(VS Code)可以向服务器发送导航请求,我们将在 图 8.19**,跳转到定义 中稍后探讨。
VS Code 通过发送 "initialized"
通知来确认初始化:
1{
2 "jsonrpc": "2.0",
3 "method": "initialized"
4 }
与 "initialize"
请求相反,这里有一个通知,并且它不期望服务器有任何响应。因此,它没有 "id"
字段。"initialized"
通知只能发送一次,并且它应该在客户端发送任何其他请求或通知之前接收。初始化完成后,我们就可以打开文档并发送相应的 "textDocument/didOpen"
通知。
打开文档
当开发者打开一个 C++源文件时,客户端发送"textDocument/didOpen"
通知,通知语言服务器关于新打开的文件。在我们的例子中,打开的文件位于/home/ivanmurashko/clangbook/helper.hpp
,VS Code 发送的相应通知将如下所示:
{
"jsonrpc": "2.0",
"method": "textDocument/didOpen",
"params": {
"textDocument": {
"languageId": "cpp",
"text": "#pragma once\n\nnamespace clangbook {\nclass Helper {\npublic:\n Helper(){};\n\n void doWork() { doPrivateWork(); }\n\nprivate:\n void doPrivateWork() {}\n};\n}; // namespace clangbook\n",
"uri": "file:///home/ivanmurashko/clangbook/helper.hpp",
"version": 1
}
}
}
图 8.15: VS Code 到 Clangd(didOpen 通知)
如我们所见,VS Code 发送了包含在"params/textDocument"
字段中的参数的通知。这些参数包括"uri"
字段中的文件名和"text"
字段中的源文件文本。
Clangd 在接收到’didOpen’通知后开始编译文件。它构建一个 AST 并从中提取关于不同标记的语义信息。服务器使用这些信息来区分具有相同名称的不同标记。例如,我们可以使用名为’foo’的标记,它可能根据使用的范围作为类成员或局部变量,如下面的代码片段所示:
1 class TestClass {
2 public:
3 int foo(){return 0};
4 };
5
6 int main() {
7 TestClass test;
8 int foo = test.foo();
9 return foo;
10 }
图 8.16: foo.hpp 中’foo’标记的出现
正如我们在第 8 行中看到的,我们使用了’foo’
标记两次:作为函数调用和在局部变量定义中。
跳转到定义的请求将延迟到编译过程完成。值得注意的是,大多数请求都被放入队列中,等待编译过程完成。该规则有一些例外,一些请求可以在没有 AST 的情况下执行,并具有有限的提供功能。其中一个例子是代码格式化请求。代码格式化不需要 AST,因此格式化功能可以在 AST 构建之前提供。
如果文件的状态发生变化,Clangd 将通过"textDocument/publishDiagnostics"
通知通知 VS Code。例如,当编译过程完成后,Clangd 将发送通知到 VS Code:
1{
2 "jsonrpc": "2.0",
3 "method": "textDocument/publishDiagnostics",
4 "params": {
5 "diagnostics": [],
6 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp",
7 "version": 1
8 }
9 }
图 8.17: Clangd 到 VS Code(publishDiagnostics 通知)
如我们所见,没有编译错误;params/diagnostics
为空。如果我们的代码包含编译错误或警告,它将包含错误或警告描述,如下所示:
1{
2 "jsonrpc": "2.0",
3 "method": "textDocument/publishDiagnostics",
4 "params": {
5 "diagnostics": [
6 {
7 "code": "expected_semi_after_expr",
8 "message": "Expected ’;’ after expression (fix available)",
9 "range": {
10 "end": {
11 "character": 35,
12 "line": 7
13 },
14 "start": {
15 "character": 34,
16 "line": 7
17 }
18 },
19 "relatedInformation": [],
20 "severity": 1,
21 "source": "clang"
22 }
23 ],
24 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp",
25 "version": 5
26 }
27 }
图 8.18: Clangd 到 VS Code(带有编译错误的 publishDiagnostics)
VS Code 处理诊断信息并将其显示出来,如下面的屏幕截图所示:
图 8.19: helper.hpp 中的编译错误
编译完成后,我们收到"textDocument/publishDiagnostics"
后,Clangd 就准备好处理导航请求,例如"textDocument/definition"
(跳转到定义)。
跳转到定义
要在一个 C++文件中查找符号的定义,客户端向语言服务器发送"textDocument/definition"
请求:
1{
2 "id": 13,
3 "jsonrpc": "2.0",
4 "method": "textDocument/definition",
5 "params": {
6 "position": {
7 "character": 26,
8 "line": 7
9 },
10 "textDocument": {
11 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp"
12 }
13 }
14 }
图 8.20: VS Code 到 Clangd(textDocument/definition 请求)
行位置指定为 7 而不是编辑器中的实际行 8,如图 8.6 所示。这是因为行号从 0 开始。
语言服务器在 C++ 代码中响应定义位置:
1{
2 "id": 13,
3 "jsonrpc": "2.0",
4 "result": [
5 {
6 "range": {
7 "end": {
8 "character": 20,
9 "line": 10
10 },
11 "start": {
12 "character": 7,
13 "line": 10
14 }
15 },
16 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp"
17 }
18 ]
19 }
图 8.21:Clangd 到 VS Code(textDocument/definition 响应)
如我们所见,服务器响应了定义的实际位置。在 IDE 中另一个流行的操作是文档修改。此功能由 "textDocument/didChange"
通知提供。让我们看看它。
更改文档
作为文档修改的一部分,让我们在 第 6 行 插入注释 // Constructor
,如图所示:
图 8.22:更改文档
VS Code 将检测到文档已被修改,并使用以下通知通过 LSP 服务器(Clangd)进行通知:
1{
2 "jsonrpc": "2.0",
3 "method": "textDocument/didChange",
4 "params": {
5 "contentChanges": [
6 {
7 "range": {
8 "end": {
9 "character": 13,
10 "line": 5
11 },
12 "start": {
13 "character": 13,
14 "line": 5
15 }
16 },
17 "rangeLength": 0,
18 "text": "// Constructor"
19 }
20 ],
21 "textDocument": {
22 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp",
23 "version": 2
24 }
25 }
26 }
图 8.23:VS Code 到 Clangd(didChange 通知)
如我们所见,通知包含范围规范和用于替换文档中指定范围的文本。通知的一个重要部分是 "version"
字段,它指定了文档的版本。
我们可以观察到 version
从文档打开时使用的 1 变为 2,用于文档修改(如图 8.23 的第 23 行图 8.23 所示)。
由于文档修改可能导致用于导航请求的结果 AST 发生重大变化,Clangd 将开始文档编译。一旦编译完成,服务器将响应相应的 "textDocument/publishDiagnostics"
通知,如图所示:
1{
2 "jsonrpc": "2.0",
3 "method": "textDocument/publishDiagnostics",
4 "params": {
5 "diagnostics": [],
6 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp",
7 "version": 2
8 }
图 8.24:Clangd 到 VS Code(publishDiagnostics 通知)
如我们所见,由于它包含指向版本 2 的版本字段,因此对修改后的文档发送了诊断信息,这对应于图 8.24 的第 7 行图 8.24。
在示例中的最后一个操作是关闭文档。让我们更仔细地看看它。
关闭文档
当我们完成对文档的工作并关闭它时,VS Code 向语言服务器发送 "textDocument/didClose"
通知:
1{
2 "jsonrpc": "2.0",
3 "method": "textDocument/didClose",
4 "params": {
5 "textDocument": {
6 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp"
7 }
8 }
9 }
图 8.25:VS Code 到 Clangd(textDocument/didClose 请求)
接收到请求后,Clangd 将从其内部结构中删除文档。Clangd 将不再为该文档发送任何更新,因此它将通过发送最终的空 "textDocument/publishDiagnostics"
消息来清空客户端(例如,在 VS Code 的 问题 面板中)上显示的诊断列表,如图所示:
1{
2 "jsonrpc": "2.0",
3 "method": "textDocument/publishDiagnostics",
4 "params": {
5 "diagnostics": [],
6 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp"
7 }
8 }
图 8.26:Clangd 到 VS Code(textDocument/didClose 请求)
所示示例演示了 Clangd 和 VS Code 之间的典型交互。提供的示例利用了 Clang 前端的功能,即基本的 Clang 功能。另一方面,Clangd 与其他 Clang 工具(如 Clang-Format 和 Clang-Tidy)有很强的联系,并且可以重用这些工具提供的功能。让我们更详细地看看这一点。
8.5 与 Clang 工具的集成
Clangd 利用 LLVM 模块架构,并与其他 Clang 工具有非常强的集成。特别是,Clangd 使用 Clang-Format 库来提供格式化功能,并使用 Clang-Tidy 库(例如带有 clang-tidy 检查的库)来支持 IDE 中的代码检查器。集成方案在以下图中展示:
图 8.27:带有 LSP 扩展和 Clangd 服务器用于 C++ 的 VS Code
.clang-format
(参见 第 7.4.1 节**,Clang-Format 配置和使用示例)中的配置用于格式化,而 .clang-tidy
(参见 图 5.12**,Clang-Tidy 配置)用于代码检查器。让我们看看 Clangd 中格式化是如何工作的。
8.5.1 Clangd 使用 LSP 消息支持代码格式化
Clangd 提供了对代码格式化的强大支持。这一功能对于开发者保持 C 和 C++ 项目的代码风格一致性和可读性至关重要。Clangd 利用 LSP 消息,主要是 "textDocument/formatting"
和 "textDocument/rangeFormatting"
请求,来实现这一功能。
格式化整个文档
当开发者想要格式化文档的全部内容时,会使用 "textDocument/formatting"
请求。此请求通常由用户在 VS Code 中通过按 Ctrl + Shift + I(或 macOS 上的 + Shift + I)来启动;IDE 会向 Clangd 发送一个针对整个文档的
"textDocument/formatting"
请求:
1{
2 "id": 9,
3 "jsonrpc": "2.0",
4 "method": "textDocument/formatting",
5 "params": {
6 "options": {
7 "insertSpaces": true,
8 "tabSize": 4
9 },
10 "textDocument": {
11 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp"
12 }
13 }
14 }
图 8.28:VS Code 到 Clangd(textDocument/formatting 请求)
Clangd 通过利用项目 .clang-format
文件中指定的代码风格配置来处理此请求。.clang-format
文件包含格式化规则和首选项,允许开发者定义他们想要的代码风格;参见 第 7.4.1 节**,Clang-Format 配置和使用 示例。
响应中包含要应用到打开文档中的修改列表:
1{
2 "id": 9,
3 "jsonrpc": "2.0",
4 "result": [
5 {
6 "newText": "\n ",
7 "range": {
8 "end": {
9 "character": 0,
10 "line": 5
11 },
12 "start": {
13 "character": 7,
14 "line": 4
15 }
16 }
17 }
18 ]
19 }
图 8.29:Clangd 到 VS Code(textDocument/formatting 响应)
在示例中,我们应该将 图 8.29 中 第 7-16 行 的文本替换为新文本,该文本指定在 第 6 行。
格式化特定的代码范围
除了格式化整个文档外,Clangd 还支持格式化文档中的特定代码范围。这是通过使用"textDocument/rangeFormatting"
请求实现的。开发者可以在代码中选择一个范围,例如一个函数、一段代码块,甚至只有几行,并请求对该特定范围进行格式化,如下面的截图所示:
图 8.30:在 helper.hpp 中重新格式化特定的代码范围
当选择菜单项或按Ctrl + K然后Ctrl + F(或 macOS 上的 + K然后
+ F),VS Code 将向 Clangd 发送以下请求:
1{
2 "id": 89,
3 "jsonrpc": "2.0",
4 "method": "textDocument/rangeFormatting",
5 "params": {
6 "options": {
7 "insertSpaces": true,
8 "tabSize": 4
9 },
10 "range": {
11 "end": {
12 "character": 2,
13 "line": 10
14 },
15 "start": {
16 "character": 0,
17 "line": 3
18 }
19 },
20 "textDocument": {
21 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp"
22 }
23 }
24 }
图 8.31:VS Code 到 Clangd(textDocument/rangeFormatting 请求)
"textDocument/rangeFormatting"
请求指定了文档中需要格式化的范围,Clangd 将.clang-format
文件中的相同格式化规则应用于这个特定的代码段。响应将与格式化请求类似,并将包含应用于原始文本的修改,如图图 8.29 所示。唯一的区别将是方法名,在这种情况下应该是"textDocument/rangeFormatting"
。
另一个通过 Clangd 集成的工具是 Clang-Tidy,它相对于我们刚刚描述的格式化功能,以不同的方式使用 LSP 协议。
8.5.2 Clang-Tidy
如我们所见,Clangd 使用特定的 LSP 方法来实现与 Clang-Format 的集成:
-
"textDocument/formatting"
-
"textDocument/rangeFormatting"
另一方面,与 Clang-Tidy 的集成实现不同,它重用了"publishDiagnostics"
通知来报告 lint 警告和错误。
让我们调查它是如何工作的,并作为第一步创建一个自定义的 Clang-Tidy 配置。
Clang-Tidy 与 LSP 集成
我们将运行我们最近创建的用于测试方法重命名的misc-methodrename
检查,参见第 7.3 节**,Clang-Tidy 作为一个代码修改工具。我们的 Clang-Tidy 配置将如下所示:
1 Checks: ’-*,misc-methodrename’
图 8.32:IDE 集成用的.clang-tidy 配置
应将包含配置的.clang-tidy
文件放置在我们的测试项目文件夹中。
如果我们将辅助类重命名为TestHelper
,我们将能够观察到我们在第 7.3 节**,Clang-Tidy 作为一个代码修改工具中创建的 lint 检查将开始报告关于测试类使用的错误方法名。相应的诊断将在下拉面板和问题选项卡中显示,如下面的截图所示:
图 8.33:Clang-Tidy 集成
消息作为诊断信息的一部分显示。具体来说,以下通知是从 Clang 发送到 VS Code 的:
1{
2 "jsonrpc": "2.0",
3 "method": "textDocument/publishDiagnostics",
4 "params": {
5 "diagnostics": [
6 {
7 "code": "misc-methodrename",
8 "codeDescription": {
9 "href": "https://clang.llvm.org/extra/clang-tidy/checks/misc/
10 methodrename.html"
11 },
12 "message": "Method ’testdoWork’ does not have ’test_’ prefix (fix available)",
13 "range": {
14 "end": {
15 "character": 17,
16 "line": 6
17 },
18 "start": {
19 "character": 7,
20 "line": 6
21 }
22 },
23 "relatedInformation": [],
24 "severity": 2,
25 "source": "clang-tidy"
26 }
27 ],
28 "uri": "file:///home/ivanmurashko/clangbook/helper.hpp",
29 "version": 11
30 }
图 8.34:Clangd 到 VS Code(publishDiagnostics 通知)
如图中(第 11 行)所示,问题的修复也是可用的。在 IDE 中应用 Clang-Tidy 修复有一个惊人的机会。让我们探索如何使用 LSP 实现此功能。
在 IDE 中应用修复
修复可以在 IDE 中应用,并且该功能通过 "textDocument/codeAction"
方法提供。此方法由 VS Code 用于提示 Clangd 计算特定文档和范围的命令。以下示例中提供了命令最重要的部分:
1{
2 "id": 98,
3 "jsonrpc": "2.0",
4 "method": "textDocument/codeAction",
5 "params": {
6 "context": {
7 "diagnostics": [
8 {
9 "code": "misc-methodrename",
10 ...
11 "range": ...,
12 ...
13 },
14 ...
15 }
16 }
图 8.35:VS Code 到 Clangd(textDocument/codeAction 请求)
请求最重要的部分在 第 7-11 行,我们可以看到原始诊断通知的副本。此信息将被用于检索由 clang::FixItHint
在激活的检查中提供的必要文档修改。因此,Clangd 可以响应描述所需修改的动作:
1{
2 "id": 98,
3 "jsonrpc": "2.0",
4 "result": [
5 {
6 "diagnostics": [
7 ...
8 ],
9 "edit": {
10 "changes": {
11 "file:///home/ivanmurashko/clangbook/helper.hpp": [
12 {
13 "newText": "test_",
14 "range": {
15 "end": {
16 "character": 7,
17 "line": 6
18 },
19 "start": {
20 "character": 7,
21 "line": 6
22 }
23 }
24 }
25 ...
26 }
27 ]
28 }
图 8.36:Clangd 到 VS Code(codeAction 响应)
图 8.36 中的 "edit"
字段是响应中最重要的一部分,因为它描述了对原始文本的更改。
由于 Clangd 核心构建 AST 用于导航和诊断目的,因此无需额外计算即可与 Clang-Tidy 集成。AST 可以用作 Clang-Tidy 检查的种子,从而消除运行单独的 Clang-Tidy 可执行文件以从代码检查器检索消息的需求。这不是 Clangd 做出的唯一优化;现在让我们看看 Clangd 中性能优化的另一个示例。
8.6 性能优化
获得平滑的 IDE 体验,提供准确的结果且无可见延迟是一项挑战性任务。实现这种体验的一种方法是通过编译器性能优化,因为良好的导航可以通过良好解析的源代码提供。Clangd 提供了性能优化的优秀示例,我们将详细探讨。我们将从代码修改的优化开始。
8.6.1 修改文档的优化
正如我们在第 4 行中看到的那样,打开文档、导航支持需要 AST 作为基本数据结构,因此我们必须使用 Clang 前端来获取它。此外,当文档发生修改时,我们必须重建 AST。文档修改是开发者的常见活动,如果我们总是从头开始启动构建过程,我们将无法提供良好的 IDE 体验。
源代码前缀
要深入了解用于加速修改文档的抽象语法树(AST)构建的想法,让我们检查一个简单的 C++ 程序:
1 #include <iostream>
2
3 int main() {
4 std::cout << "Hello world!" << std::endl;
5 return 0;
6 }
图 8.37:C++ 程序:helloworld.cpp
程序有六行代码,但结论可能会误导。#include
指令插入了大量的附加代码。如果我们使用带有-E
命令行选项的 Clang 运行并计算行数,我们可以估计预处理器插入的代码量,如下所示:
$ <...>/llvm-project/install/bin/clang -E helloworld.cpp | wc -l
36215
图 8.38:后处理程序中的行数
其中<...>
是克隆 llvm-project 的文件夹;参见图 1.1。
如我们所见,应该解析的代码包含超过 36,000 行代码。这是一个常见的模式,大多数要编译的代码都是从包含的头文件中插入的。位于源文件开头并包含包含指令的部分称为前导部分。
值得注意的是,前导部分的修改是可能的,但很少见,例如,当我们插入一个新的头文件时。大多数的修改都位于前导部分之外的代码中。
性能优化的主要思想是缓存前导部分的 AST,并为其任何修改的文档重用。
在 Clangd 中构建的 AST
Clangd 中进行的性能优化涉及两个部分的编译过程。在第一部分,包含所有包含的头文件的前导部分被编译成一个预编译头文件;参见第 10.2 节**,预编译头文件。然后,这个预编译头文件在编译过程的第二阶段用于构建 AST。
这个复杂的过程作为性能优化,尤其是在用户对需要重新编译的文件进行修改时。尽管编译时间的大部分都花在了头文件上,但这些文件通常不会频繁修改。为了解决这个问题,Clangd 将预编译头文件中的头文件 AST 进行缓存。
因此,当对头文件之外的部分进行修改时,Clangd 不需要从头开始重新构建它们。相反,它可以重用缓存的头文件 AST,显著提高编译性能,并减少处理头文件时重新编译所需的时间。如果用户的修改影响了头文件,那么整个 AST 应该被重新构建,这会导致在这些情况下缓存失效。值得注意的是,对头文件的修改不如对主要源代码(不包括包含的头文件)的修改常见。因此,我们可以预期普通文档修改的缓存命中率相当高。
预编译头文件可以存储在磁盘上的临时文件中,但也可以驻留在内存中,这也可以被视为一种性能优化。
缓存的前导部分是一个强大的工具,它显著提高了 Clangd 处理用户对文档所做的更改的性能。另一方面,我们应该始终考虑涉及前导部分修改的边缘情况。前导部分可以通过两种主要方式修改:
-
显式地:当用户显式修改前言时,例如,通过向其中插入一个新的头文件或删除现有的一个
-
隐式地:当用户隐式修改前言时,例如,通过修改包含在前言中的头文件
第一个可以通过影响前言位置的"textDocument/didChange"
通知轻松检测到。第二个比较棘手,Clangd 应该监控包含的头文件中的修改,以正确处理导航请求。
Clangd 也有一些修改旨在使前言编译更快。其中一些修改需要在 Clang 中进行特定处理。让我们详细探讨一下。
8.6.2 构建前言优化
可以应用一种有趣的优化到函数体上。函数体可以被视为主索引的一个基本部分,因为它包含用户可以点击的符号,例如获取符号的定义。这主要适用于用户在 IDE 中可见的函数体。另一方面,许多函数及其实现(函数体)在包含的头文件中隐藏起来,用户无法从这样的函数体请求符号信息。然而,这些函数体对编译器是可见的,因为编译器解析包含指令并从指令中解析头文件。考虑到一个复杂的项目可能有众多的依赖关系,导致用户打开的文档中包含许多头文件,编译器花费的时间可能是显著的。一个明显的优化是在解析前言中的头文件时跳过函数体。这可以通过使用一个特殊的前端选项来实现:
/// FrontendOptions - Options for controlling the behavior of the frontend.
class FrontendOptions {
...
/// Skip over function bodies to speed up parsing in cases where you do not need
/// them (e.g., with code completion).
unsigned SkipFunctionBodies : 1;
...
};
图 8.39:来自 clang/Frontend/FrontendOptions.h 的 FrontendOptions 类
Clangd 在以下方式构建前言时使用此选项:
1std::shared_ptr<const PreambleData>
2 buildPreamble(PathRef FileName, CompilerInvocation CI,
3 const ParseInputs &Inputs, bool StoreInMemory,
4 PreambleParsedCallback PreambleCallback,
5 PreambleBuildStats *Stats) {
6 ...
7 // Skip function bodies when building the preamble to speed up building
8 // the preamble and make it smaller.
9 assert(!CI.getFrontendOpts().SkipFunctionBodies);
10 CI.getFrontendOpts().SkipFunctionBodies = true;
11 ...
12 auto BuiltPreamble = PrecompiledPreamble::Build(...);
13 ...
14 // When building the AST for the main file, we do want the function
15 // bodies.
16 CI.getFrontendOpts().SkipFunctionBodies = false;
17 ...
18 };
图 8.40:来自 clang-tools-extra/clangd/Preamble.cpp 的 buildPreamble
如我们所见,Clangd 使用前端选项在头文件中跳过函数体,但在构建主文档的 AST 之前禁用它;参见图 8.40 中的第 10 行和第 16 行。
这种优化可以显著提高复杂 C++源文件的文档准备时间(当打开的文档准备好响应用户的导航请求时)。
虽然这里讨论的性能优化为 Clangd 的效率提供了宝贵的见解,但重要的是要注意,Clangd 采用了多种其他技术来确保其可靠性和速度。Clangd 是一个出色的平台,用于实验和实现各种优化策略,使其成为性能增强和创新的灵活环境。
8.7 总结
在本章中,我们了解了 LSP(语言服务器协议),这是一种用于提供开发工具与 IDE 集成的协议。我们探讨了 Clangd,它是 LLVM 的一部分,可以被视为书中讨论的各种工具如何集成的典范。Clangd 使用 Clang 前端来显示编译错误,并利用 AST(抽象语法树)作为基本的数据结构,为导航请求提供信息,例如跳转到定义的请求。此外,Clangd 与前几章中介绍的其他工具无缝集成,例如 Clang-Tidy 和 Clang-Format。这种集成展示了 LLVM/Clang 模块结构的显著优势。
8.8 进一步阅读
-
Clangd 文档:
clangd.llvm.org/
第三部分
附录
Clang 编译器是一个非常复杂的话题,本书的主要章节中省略了一些细节。附录涵盖了 Clang 和 Clang 工具的一些重要方面,如果你开始将此知识应用于包含许多文件和复杂构建规则的复杂项目,这些方面可能非常有价值。
我们将首先讨论如何将 Clang 工具集成到使用复杂编译标志的大型项目中。LLVM 是这类项目的例子之一。
另一个重要方面是复杂 C++ 项目的性能。Clang 提供了一些技术,可用于提高这类项目的构建速度,我们还将探讨这些特性。
本部分包含以下章节:
-
第九章, 编译数据库
-
第十章, 构建速度优化
附录 1
编译数据库
书中考虑的测试示例不需要特殊的编译标志,通常可以不带任何标志进行编译。然而,如果你想在真实项目中使用这些材料,例如在你的代码库上运行 lint 检查,情况就不同了。在这种情况下,你需要为每个要处理的文件提供特殊的编译标志。Clang 提供了各种方法来提供这些标志。我们将详细探讨 JSON 编译数据库,这是向 Clang 工具(如 Clang-Tidy 和 Clangd)提供编译标志的主要工具之一。
9.1 编译数据库定义
编译数据库(CDB)是一个 JSON 文件,它指定了代码库中每个源文件应该如何编译。这个 JSON 文件通常命名为compile_commands.json
,位于项目的根目录中。它提供了构建过程中所有编译器调用的机器可读记录,并且常被各种工具用于更精确的分析、重构等。这个 JSON 文件中的每个条目通常包含以下字段:
-
directory:编译的工作目录。
-
command:实际的编译命令,包括编译器选项。
-
arguments:另一个可以用来指定编译参数的字段。它包含参数列表。
-
file:正在编译的源文件路径。
-
output:此编译步骤创建的输出路径。
从字段描述中我们可以看出,有三种方式可以指定编译标志:使用命令或参数字段。让我们看一个具体的例子。假设我们的 C++文件ProjectLib.cpp
位于/home/user/project/src/lib
文件夹中,可以使用以下调用命令(命令仅作为示例,你可以忽略其参数)
$ cd /home/user/project/src/lib
$ clang -Wall -I../headers ProjectLib.cpp -o ProjectLib.o
以下 CDB 可以用来表示命令:
1[
2 {
3 "directory": "/home/user/project/src/lib",
4 "command": "clang -Wall -I../headers ProjectLib.cpp -o ProjectLib.o",
5 "file": "ProjectLib.cpp",
6 "output": "ProjectLib.o"
7 }
8 ]
图 9.1:ProjectLib.cpp 的编译数据库
在示例中使用了"command"
字段。我们也可以以另一种形式创建 CDB 并使用"arguments"
字段。结果如下:
1[
2 {
3 "directory": "/home/user/project/src/lib",
4 "arguments": [
5 "clang",
6 "-Wall",
7 "-I../headers",
8 "ProjectLib.cpp",
9 "-o",
10 "ProjectLib.o"
11 ],
12 "file": "ProjectLib.cpp",
13 "output": "ProjectLib.o"
14 }
15 ]
图 9.2:ProjectLib.cpp 的 CDB
图 9.2 中显示的CDB与图 9.1 中的相同编译配方,但它使用参数列表("arguments"字段)而不是图 9.1 中使用的调用命令("command"字段)。重要的是要注意,参数列表也包含可执行文件"clang"作为其第一个参数。CDB 处理工具可以使用这个参数在存在不同编译器的环境中(如 GCC 与 Clang)决定使用哪个编译器进行编译。
提供的 CDB 示例只包含一个文件的记录。一个真实的项目可能包含数千条记录。LLVM 是一个很好的例子,如果你查看我们用于 LLVM 构建的 build
文件夹(见 第 1.3.1 节**,使用 CMake 进行配置),你可能会注意到它包含一个 compile_commands.json
文件,其中包含我们选择构建的项目 CDB。值得注意的是,LLVM 默认创建 CDB,但你的项目可能需要一些特殊的操作来创建它。让我们详细看看如何创建 CDB。
9.2 CDB 创建
shell
compile˙commands.json 文件可以通过多种方式生成。例如,构建系统 CMake 内置了对生成编译数据库的支持。一些工具也可以从 Makefiles 或其他构建系统生成此文件。甚至有像 Bear 和 intercept-build 这样的工具可以通过拦截实际编译命令的执行来生成 CDB。
因此,虽然这个术语通常与 Clang 和基于 LLVM 的工具相关联,但这个概念本身更为通用,理论上可以被任何需要理解一组源文件编译设置的工具体现。我们将从使用 CMake 生成 CDB 开始,CMake 是最受欢迎的构建系统之一。
使用 CMake 生成 CDB
使用 CMake 生成 CDB 涉及几个步骤:
-
首先,打开一个终端或命令提示符,并导航到你的项目根目录。
-
然后,使用
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON
选项运行 CMake,该选项指示 CMake 创建一个compile_commands.json
文件。此文件包含项目中所有源文件的编译命令。 -
在使用 CMake 配置你的项目后,你可以在运行配置命令的同一目录中找到
compile_commands.json
文件。
正如我们之前所注意到的,LLVM 默认创建了 CDB。这是可行的,因为 llvm/CMakeLists.txt
包含以下设置:
# Generate a CompilationDatabase (compile_commands.json file) for our build,
# for use by clang_complete, YouCompleteMe, etc.
set(CMAKE_EXPORT_COMPILE_COMMANDS 1)
图 9.3:从 llvm/CMakeLists.txt 的 LLVM-18.x CMake 配置
即,它默认设置了 CDB 生成。
使用 Ninja 生成 CDB
Ninja 也可以用来生成 CDB。我们可以使用一个名为 "compdb"
的 Ninja 子工具将 CDB 输出到 stdout。要运行子工具,我们使用 Ninja 的 -t <subtool>
命令行选项。因此,我们将使用以下命令使用 Ninja 生成 CDB:
$ ninja -t compdb > compile_commands.json
图 9.4:使用 Ninja 创建 CDB
此命令指示 Ninja 生成 CDB 信息并将其保存到 compile_commands.json
文件中。
生成的编译数据库可以与书中描述的不同 Clang 工具一起使用。让我们看看两个最有价值的例子,包括 Clang-Tidy 和 Clangd。
9.3 Clang 工具和 CDB
CDB 的概念并不仅限于 Clang,基于 Clang 的工具广泛地使用了它。例如,Clang 编译器本身可以使用编译数据库来理解如何在项目中编译文件。像 Clang-Tidy 和 Clangd(用于 IDE 中的语言支持)这样的工具也可以使用它来确保它们理解代码的构建方式,从而使它们的分析和转换更加准确。
大型项目的 Clang-Tidy 配置
要使用 CDB 与 clang-tidy,通常不需要任何额外的配置。Clang-tidy 可以自动检测并利用项目根目录中的 compile_commands.json
文件。
另一方面,Clang 工具提供了一个特殊的选项,-p,定义如下:
-p <build-path> is used to read a compile command database
您可以使用此选项在 Clang 源代码的文件上运行 Clang-Tidy。例如,如果您从包含源代码的 llvm-project 文件夹中运行它,它将看起来像这样:
$ ./install/bin/clang-tidy clang/lib/Parse/Parser.cpp -p ./build/
图 9.5:在 LLVM 代码库上运行 Clang-Tidy
在这种情况下,我们正在从安装 Clang-Tidy 的文件夹中运行 Clang-Tidy,如 第 5.2.1 节 中所述,构建和测试 Clang-Tidy。我们还指定了 build
文件夹作为包含 CDB 的项目根文件夹。
Clang-Tidy 是积极使用 CDB 在大型项目上执行的工具之一。另一个工具是 Clangd,我们也将对其进行探讨。
大型项目的 Clangd 设置
Clangd 提供了一个特殊的配置选项来指定 CDB 的路径。此选项定义如下:
$ clangd --help
...
--compile-commands-dir=<string> - Specify a path to look for
compile_commands.json.If the path is invalid, clangd will search
in the current directory and parent paths of each source file.
...
图 9.6:从 clangd –help
输出中获取的 –compile-commands-dir
选项的描述
您可以通过以下图中的 设置 面板在 Visual Studio Code 中指定此选项:
图 9.7:为 clangd 配置 CDB 路径
因此,如果您从 Clang 源代码打开一个文件,您将能够访问 Clangd 提供的导航支持,如图所示:
图 9.8:Clangd 在 clang/lib/Parse/Parser.cpp 为 Parser::Parser 方法提供的悬停信息
将编译命令与 Clang 工具(如 Clang-Tidy 或 Clangd)集成,为探索和分析您的源代码提供了一个强大的工具。
9.4 进一步阅读
-
Clang 文档 - JSON 编译数据库格式规范:
clang.llvm.org/docs/JSONCompilationDatabase.html
-
Clangd 文档 - 编译命令:
clangd.llvm.org/design/compile-commands
附录 2
构建速度优化
Clang 实现了多项功能,旨在提高大型项目的构建速度。其中最有趣的功能之一是预编译头文件和模块。它们可以被视为允许缓存 AST 的某些部分并重新用于不同编译调用的技术。缓存可以显著提高项目的构建速度,并且可以使用这些功能来加速不同的 Clang 工具执行。例如,预编译头文件被用作 Clangd 文档编辑的主要优化。
在本附录中,我们将涵盖两个主要主题
-
预编译头文件
-
模块
10.1 技术要求
本附录的源代码位于本书 GitHub 仓库的 chapter10
文件夹中:github.com/PacktPublishing/Clang-Compiler-Frontend-Packt/tree/main/chapter10
。
10.2 预编译头文件
预编译头文件 PCH 是 Clang 的一项功能,旨在提高 Clang 前端性能。基本思路是为头文件创建一个 AST(抽象语法树),并在编译过程中重用此 AST,用于包含头文件的源文件。
生成预编译头文件很简单 [5]。假设您有以下头文件,header.h
:
1 #pragma once
2
3 void foo() {
4 }
图 10.1:要编译为 PCH 的头文件
您可以使用以下命令为其生成 PCH:
$ <...>/llvm-project/install/bin/clang -cc1 -emit-pch \
-x c++-header header.h \
-o header.pch
这里,我们使用 -x c++-header
选项指定头文件应被视为 C++ 头文件。输出文件将被命名为 header.pch
。
仅生成预编译头文件是不够的;您需要开始使用它们。一个典型的 C++ 源文件,包含头文件可能看起来像这样:
1 #include "header.h"
2
3 int main() {
4 foo();
5 return 0;
6 }
图 10.2:包含 header.h 的源文件
如您所见,头文件包含如下所示:
1 #include "header.h"
图 10.3:包含 header.h
默认情况下,Clang 不会使用 PCH,您必须使用以下命令显式指定:
$ <...>/llvm-project/install/bin/clang -cc1 -emit-obj \
-include-pch header.pch \
main.cpp -o main.o
这里,我们使用 -include-pch
指定包含的预编译头文件:header.pch
。
您可以使用调试器检查此命令,并将给出以下输出:
1$ lldb <...>/llvm-project/install/bin/clang -- -cc1 -emit-obj -include-pch header.pch main.cpp -o main.o
2 ...
3 (lldb) b clang::ASTReader::ReadAST
4 ...
5 (lldb) r
6 ...
7 -> 4431 llvm::TimeTraceScope scope("ReadAST", FileName);
8 4432
9 4433 llvm::SaveAndRestore SetCurImportLocRAII(CurrentImportLoc, ImportLoc);
10 4434 llvm::SaveAndRestore<std::optional<ModuleKind>> SetCurModuleKindRAII(
11 (lldb) p FileName
12 (llvm::StringRef) (Data = "header.pch", Length = 10)
图 10.4:在 clang::ASTReader::ReadAST 中加载预编译头文件
从这个例子中,您可以看到 Clang 从预编译头文件中读取 AST。需要注意的是,预编译头文件是在解析之前读取的,这使得 Clang 在解析主源文件之前能够获取头文件中的所有符号。这使得显式包含头文件变得不必要。因此,您可以从源文件中删除 #include "header.h"
指令并成功编译。
没有预编译头文件,您将遇到以下编译错误:
main.cpp:4:3: error: use of undeclared identifier ’foo’
4 | foo();
| ^
1 error generated.
图 10.5:由于缺少包含而引发的编译错误
值得注意的是,只有第一个--include-pch
选项将被处理;所有其他选项将被忽略。这反映了翻译单元只能有一个预编译头文件的事实。另一方面,一个预编译头文件可以包含另一个预编译头文件。这种功能被称为链式预编译头文件[3],因为它创建了一个依赖链,其中一个预编译头文件依赖于另一个预编译头文件。
预编译头文件的使用不仅限于常规编译。正如我们在*图** 8.38**中看到的那样,Clangd 中的 AST 构建,预编译头文件在 Clangd 中作为包含头文件的序言缓存占位符,被积极用于性能优化。
预编译头文件是一种长期使用的技术,但它有一些限制。其中最重要的限制是只能有一个预编译头文件,这显著限制了 PCH 在实际项目中的使用。模块解决了与预编译头文件相关的一些问题。让我们来探讨这些问题。
10.3 Clang 模块
模块,或称为预编译模块(PCMs),可以被认为是预编译头文件演化的下一步。它们也代表了一种以二进制形式解析的抽象语法树(AST),但形成了一个有向无环图(DAG,树),这意味着一个模块可以包含多个其他模块。
与只能为每个编译单元引入一个预编译头文件的预编译头文件相比,这是一个重大的改进。
C++20 标准[21]引入了与模块相关的两个概念。第一个是普通模块,在[21]的第10 节中描述。另一个是所谓的头单元,主要在第15.5 节中描述。头单元可以被认为是普通头文件和模块之间的一个中间步骤,并允许使用import
指令来导入普通头文件。
我们将关注 Clang 模块,这可以被认为是 C++标准中头单元的实现。使用 Clang 模块有两种不同的选项。第一个被称为显式模块。第二个被称为隐式模块。我们将探讨这两种情况,但将从我们想要使用模块的测试项目的描述开始。
测试项目描述
对于模块的实验,我们将考虑一个包含两个头文件header1.h
和header2.h
的例子,分别定义了void foo1()
和void foo2()
函数,如下所示:
1 #pragma once
2
3 void foo1() {}
头文件:header1.h
**```cpp
1 #pragma once
2
3 void foo2() {}
**头文件:header2.h**
****图 10.6**:用于测试的头文件
这些头文件将在以下源文件中使用:
```cpp
1 #include "header1.h"
2 #include "header2.h"
3
4 int main() {
5 foo1();
6 foo2();
7 return 0;
8 }
图 10.7:源文件:main.cpp
我们将把我们的头文件组织成模块。Clang 使用一个包含逻辑结构的特殊文件,称为模块映射文件。让我们看看我们的测试项目中的文件看起来像什么。
模块映射文件
我们项目的模块映射文件将被命名为module.modulemap
,其内容如下:
1 module header1 {
2 header "header1.h"
3 export *
4 }
5 module header2 {
6 header "header2.h"
7 export *
8 }
图 10.8:模块映射文件:module.modulemap
如图 10.8 所示,我们定义了两个模块,header1和header2。
每个模块只包含一个头文件,并导出其所有符号。
现在我们已经收集了所有必要的部分,我们准备构建和使用模块。模块可以是显式构建或隐式构建。让我们从显式构建开始。
显式模块
模块的结构由模块映射文件描述,如图 10.8 所示。我们每个模块只有一个头文件,但一个真实的模块可能包含多个头文件。因此,为了构建一个模块,我们必须指定模块的结构(模块映射文件)和我们想要构建的模块名称。例如,对于header1模块,我们可以使用以下构建命令:
$ <...>/llvm-project/install/bin/clang -cc1 \
-emit-module -o header1.pcm \
-fmodules module.modulemap -fmodule-name=header1 \
-x c++-header -fno-implicit-modules
编译命令中有几个重要方面。第一个是-cc1选项,它表示我们只调用编译器前端。有关更多信息,请参阅第 2.3 节“Clang 驱动程序概述”。此外,我们指定要创建一个名为header1.pcm
的构建工件(模块),使用以下选项:-emit-module -o header1.pcm
。逻辑结构和要构建的所需模块在module.modulemap
文件中指定,该文件必须使用-fmodule-name=header1
选项作为编译参数指定。启用模块功能是通过使用-fmodules
标志完成的,我们还使用-x c++-header
选项指定我们的头文件是 C++头文件。为了显式禁用隐式模块,我们在命令中包含-fno-implicit-modules
,因为隐式模块默认启用,但我们目前不想使用它们。
第二个模块(header2
)有类似的编译命令:
$ <...>/llvm-project/install/bin/clang -cc1 \
-emit-module -o header2.pcm \
-fmodules module.modulemap -fmodule-name=header2 \
-x c++-header -fno-implicit-modules
下一步是使用生成的模块编译main.cpp
,可以按照以下方式完成:
$ <...>/llvm-project/install/bin/clang -cc1 \
-emit-obj main.cpp \
-fmodules -fmodule-map-file=module.modulemap \
-fmodule-file=header1=header1.pcm \
-fmodule-file=header2=header2.pcm \
-o main.o -fno-implicit-modules
如我们所见,模块名称和构建工件(PCM 文件)都是通过使用-fmodule-file
编译选项指定的。使用的格式,例如header1=header1.pcm
,表示header1.pcm
对应于header1
模块。我们还使用-fmodule-map-file
选项指定模块映射文件。值得注意的是,我们创建了两个构建工件:header1.pcm
和header2.pcm
,并将它们一起用于编译。这与预编译头文件的情况不同,因为如第 10.2 节所述,只允许一个预编译头文件,我们将在图 10.9中稍后研究隐式模块。
我们通过编译命令生成了一个目标文件main.o
。该目标文件可以链接如下:
$ <...>/llvm-project/install/bin/clang main.o -o main -lstdc++
让我们验证模块在编译期间是否被加载。这可以通过 LLDB 完成,如下所示:
1$ lldb <...>/llvm-project/install/bin/clang -- -cc1 -emit-obj main.cpp -fmodules -fmodule-map-file=module.modulemap -fmodule-file=header1=header1.pcm -fmodule-file=header2=header2.pcm -o main.o -fno-implicit-modules
2 ...
3 (lldb) b clang::CompilerInstance::findOrCompileModuleAndReadAST
4 ...
5 (lldb) r
6 ...
7 Process 135446 stopped
8 * thread #1, name = ’clang’, stop reason = breakpoint 1.1
9 frame #0: ... findOrCompileModuleAndReadAST(..., ModuleName=(Data = "header1", Length = 7), ...
10 ...
11 (lldb) c
12 Process 135446 stopped
13 * thread #1, name = ’clang’, stop reason = breakpoint 1.1
14 frame #0: ... findOrCompileModuleAndReadAST(..., ModuleName=(Data = "header2", Length = 7), ....
15 ...
16 (lldb) c
17 Process 135446 resumed
18 Process 135446 exited with status = 0 (0x00000000)
图 10.9:显式模块加载
我们在clang::CompilerInstance::findOrCompileModuleAndReadAST
处设置了一个断点,如图 10.9 中的第 3 行所示。我们两次触发了断点:第一次是在名为header1
的模块的第 9 行,然后是在名为header2
的模块的第 14 行。
当使用显式模块时,必须在所有编译命令中明确定义构建工件并指定它们将被存储的路径,正如我们刚刚发现的。然而,所有必需的信息都存储在模块映射文件中(参见图 10.8)。编译器可以利用这些信息自动创建所有必要的构建工件。对于这个问题的答案是肯定的,并且这种功能由隐式模块提供。让我们来探索一下。
隐式模块
如前所述,模块映射文件包含构建所有模块(header1
和header2
)以及用于依赖文件(main.cpp
)构建所需的所有信息。因此,我们必须指定模块映射文件的路径以及构建工件将存储的文件夹。这可以通过以下方式完成:
$ <...>/llvm-project/install/bin/clang -cc1 \
-emit-obj main.cpp \
-fmodules \
-fmodule-map-file=module.modulemap \
-fmodules-cache-path=./cache \
-o main.o
如我们所见,我们没有指定-fno-implicit-modules
,并且我们也指定了构建工件路径为-fmodules-cache-path=./cache
。如果我们检查路径,我们将能够看到创建的模块:
$ tree ./cache
./cache
|-- 2AL78TH69W6HR
|-- header1-R65CPR1VCRM1.pcm
|-- header2-R65CPR1VCRM1.pcm
|-- modules.idx
2 directories, 3 files
图 10.10:Clang 为隐式模块生成的缓存
Clang 将监控缓存文件夹(在我们的例子中是./cache
),并删除长时间未使用的构建工件。如果它们的依赖项(例如,包含的标题)已更改,它还将重新构建模块。
模块是一个非常强大的工具,但就像每个强大的工具一样,它们可以引入非平凡的问题。让我们来探索由模块可能引起的最有趣的问题。
一些与模块相关的问题
使用模块的代码可能会向你的程序引入一些非平凡的行为。考虑一个由两个标题组成的工程,如下所示:
1 #pragma once
2
3 int h1 = 1;
标题文件:header1.h
[PRE19]
标题文件:header2.h
****图 10.11**:用于测试的标题文件
main.cpp
中只包含了header1.h
,如下所示
1 #include "header1.h"
2
3 int main() {
4 int h = h1 + h2;
5 return 0;
6 }
图 10.12:源文件:main.cpp
代码将无法编译:
$ <...>/llvm-project/install/bin/clang main.cpp -o main -lstdc++
main.cpp:4:16: error: use of undeclared identifier ’h2’
int h = h1 + h2;
^
1 error generated.
图 10.13:由于缺少标题文件而引发的编译错误
错误很明显,因为我们没有包含包含h2
变量定义的第二部分标题。
如果我们使用隐式模块,情况将不同。考虑以下module.modulemap
文件:
1 module h1 {
2 header "header1.h"
3 export *
4 module h2 {
5 header "header2.h"
6 export *
7 }
8 }
图 10.14:引入隐式依赖的模块映射文件
此文件创建了两个模块,h1
和h2
。第二个模块包含在第一个模块中。
如果我们按照以下方式编译,编译将成功:
$ <...>/llvm-project/install/bin/clang -cc1 \
-emit-obj main.cpp \
-fmodules \
-fmodule-map-file=module.modulemap\
-fmodules-cache-path=./cache \
-o main.o
$ <...>/llvm-project/install/bin/clang main.o -o main -lstdc++
图 10.15: 成功编译一个缺少头文件但启用了隐式模块的文件
编译完成后没有出现任何错误,因为 modulemap 隐式地将header2.h
添加到使用的模块(h1
)。我们还使用export *
指令导出了所有符号。因此,当 Clang 遇到#include "header1.h"
时,它加载相应的h1
模块,因此隐式地加载了在h2
模块和header2.h
头文件中定义的符号。
该示例说明了当在项目中使用模块时,可见作用域可能会泄露。当项目启用和禁用模块时构建,这可能导致项目构建出现意外的行为。
10.4 进一步阅读
-
Clang 模块:
clang.llvm.org/docs/Modules.html
-
预编译头文件和模块内部结构:
clang.llvm.org/docs/PCHInternals.html
第九章:参考文献列表
[1] Alfred V. Aho, Monica S. Lam, Ravi Sethi, 和 Jeffrey D. Ullman. 编译原理、技术和工具. Addison-Wesley, 第二版, 2006. ISBN 978-0-321-48681-3.
[2] Bruno Cardoso Lopes 和 Nathan Lanza. [RFC] 基于 MLIR 的 Clang IR (CIR). 2022 年 6 月. URL discourse.llvm.org/t/rfc-an-mlir-based-clang-ir-cir/63319
.
[3] LLVM 社区. 预编译头和模块内部机制. URL clang.llvm.org/docs/PCHInternals.html
.
[4] LLVM 社区. 将 LLVM 项目迁移到 GitHub. 2019. URL llvm.org/docs/Proposals/GitHubMove.html
.
[5] LLVM 社区. Clang 编译器用户手册. 2022. URL clang.llvm.org/docs/UsersManual.html
.
[6] LLVM 社区. [LLVM] 更新 C++ 标准到 17. 2022. URL reviews.llvm.org/D130689
.
[7] LLVM 社区. 使用 CMake 构建 LLVM. 2023. URL llvm.org/docs/CMake.html
.
[8] LLVM 社区. “Clang” CFE 内部手册. 2023. URL clang.llvm.org/docs/InternalsManual.html
.
[9] LLVM 社区. MSVC 兼容性. 2023. URL clang.llvm.org/docs/MSVCCompatibility.html
.
[10] LLVM 社区. Clang 功能. 2023. URL clang.llvm.org/features.html
.
[11] LLVM 社区. LLVM 编码标准. 2023. URL llvm.org/docs/CodingStandards.html
.
[12] LLVM 社区. CommandLine 2.0 库手册. 2023. URL llvm.org/docs/CommandLine.html
.
[13] LLVM 社区. LLVM 程序员手册. 2023. URL llvm.org/docs/ProgrammersManual.html
.
[14] LLVM 社区. 如何为你的类层次结构设置 LLVM 风格的 RTTI. 2023. URL llvm.org/docs/HowToSetUpLLVMStyleRTTI.html
.
[15] LLVM 社区. 如何在 ARM 上构建. 2024. URL llvm.org/docs/HowToBuildOnARM.html
.
[16] LLVM 社区. AST 匹配器参考. 2024. URL clang.llvm.org/docs/LibASTMatchersReference.html
.
[17] LLVM 社区. 额外的 Clang 工具文档:Clang-Tidy. 2024. URL clang.llvm.org/extra/clang-tidy/
.
[18] Keith Cooper 和 Linda Torczon. 编译器工程. Elsevier Inc., 第二版, 2012. ISBN 978-0-12-088478-0.
[19] Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest 和 Clifford Stein. 算法导论。麻省理工学院出版社,第 3 版,2009 年。
[20] 国际标准化组织。国际 标准 ISO/IEC 14882:2017(E) – 程序设计语言 – C++. 国际标准化组织,2017 年。URL www.iso.org/standard/69466.html
.
[21] 国际标准化组织。国际 标准 ISO/IEC 14882:2020(E) – 程序设计语言 – C++. 国际标准化组织,2020 年。URL www.iso.org/standard/73560.html
.
[22] Alexandre Ganea. [Clang][Driver] 在 cc1 调用中重用调用进程而不是创建新的进程。2019 年。URL reviews.llvm.org/D69825
.
[23] Peter Goldsborough. 在 clang 中发出诊断信息。URL www.goldsborough.me/c++/clang/llvm/tools/2017/02/24/00-00-06-emitting_diagnostics_and_fixithints_in_clang_tools/
.
[24] Google. Google Test。2023 年。URL github.com/google/googletest
. C++测试框架。
[25] 国际标准化组织 (ISO). ISO/IEC 9899:1999 - 程序设计语言 - C. 国际标准化组织 (ISO),1999 年。URL www.iso.org/standard/23482.html
.
[26] Chris Lattner 和 Vikram Adve. LLVM:用于终身程序分析和转换的编译框架。2004 年国际代码生成和优化研讨会 (CGO’04),2004 年 3 月。
[27] Bruno Cardoso Lopes. [RFC] 将 ClangIR 上游化。2024 年 1 月。URL discourse.llvm.org/t/rfc-upstreaming-clangir/76587
.
[28] Thomas J. McCabe. 复杂度度量。IEEE 软件工程汇刊,SE-2(4):308–320,1976 年。ISSN 0098-5589。doi: 10.1109/TSE.1976.233837.
[29] Flemming Nielson, Hanne Riis Nielson 和 Chris Hankin. 程序分析原理。斯普林格出版社,柏林,海德堡,2005 年。ISBN 978-3-540-65410-0.
[30] Xavier Rival 和 Kwangkeun Yi. 静态分析导论:抽象解释视角。麻省理工学院出版社,剑桥,马萨诸塞州,美国,2020 年。ISBN Your-ISBN-Number-Here.
[31] Alan M. Turing. 关于可计算数及其在决定问题中的应用。伦敦数学学会学报,s2-42(1):230–265,1937 年。doi: 10.1112/plms/s2-42.1.230.
[32] Kristóf Umann. Clang 中数据流分析的概述。2020 年 10 月。URL lists.llvm.org/pipermail/cfe-dev/2020-October/066937.html
.