使用 LibTooling 和 LibASTMatchers 构建工具教程

本教程旨在展示如何基于 Clang 的 LibTooling 构建一个实用的源代码到源代码的转换工具。它明确针对 Clang 新手,因此您只需要具备 C++ 和命令行方面的基本知识即可。

为了在编译器上进行开发,您需要了解一些关于抽象语法树 (AST) 的基本知识。为此,建议读者浏览一下 Clang AST 简介

步骤 0:获取 Clang

由于 Clang 是 LLVM 项目的一部分,您需要先下载 LLVM 的源代码。Clang 和 LLVM 都位于同一个 git 仓库中,但分别位于不同的目录下。有关更多信息,请参阅 入门指南

mkdir ~/clang-llvm && cd ~/clang-llvm
git clone https://github.com/llvm/llvm-project.git

接下来,您需要获取 CMake 构建系统和 Ninja 构建工具。

cd ~/clang-llvm
git clone https://github.com/martine/ninja.git
cd ninja
git checkout release
./configure.py --bootstrap
sudo cp ninja /usr/bin/

cd ~/clang-llvm
git clone https://gitlab.kitware.com/cmake/cmake.git
cd cmake
git checkout next
./bootstrap
make
sudo make install

好了。现在我们开始构建 Clang!

cd ~/clang-llvm
mkdir build && cd build
cmake -G Ninja ../llvm-project/llvm -DLLVM_ENABLE_PROJECTS="clang;clang-tools-extra" -DCMAKE_BUILD_TYPE=Release -DLLVM_BUILD_TESTS=ON
ninja
ninja check       # Test LLVM only.
ninja clang-test  # Test Clang only.
ninja install

现在已经开始运行了。

所有测试都应该通过。

最后,我们要将 Clang 设置为自己的编译器。

cd ~/clang-llvm/build
ccmake ../llvm-project/llvm

第二个命令将弹出 Clang 配置的 GUI。您需要设置 CMAKE_CXX_COMPILER 的条目。按 't' 键开启高级模式。向下滚动到 CMAKE_CXX_COMPILER,并将其设置为 /usr/bin/clang++,或您安装它的位置。按 'c' 键进行配置,然后按 'g' 键生成 CMake 文件。

最后,再次运行 ninja,您就完成了。

步骤 1:创建 ClangTool

现在我们已经具备足够的背景知识,可以开始创建最简单的 ClangTool:语法检查器。虽然这个工具已经存在于 clang-check 中,但了解其原理至关重要。

首先,我们需要为我们的工具创建一个新目录,并告诉 CMake 该目录的存在。由于这不是核心 Clang 工具,它将位于 clang-tools-extra 仓库中。

cd ~/clang-llvm/llvm-project
mkdir clang-tools-extra/loop-convert
echo 'add_subdirectory(loop-convert)' >> clang-tools-extra/CMakeLists.txt
vim clang-tools-extra/loop-convert/CMakeLists.txt

CMakeLists.txt 应该包含以下内容

set(LLVM_LINK_COMPONENTS support)

add_clang_executable(loop-convert
  LoopConvert.cpp
  )
target_link_libraries(loop-convert
  PRIVATE
  clangAST
  clangASTMatchers
  clangBasic
  clangFrontend
  clangSerialization
  clangTooling
  )

完成这些步骤后,Ninja 就可以编译我们的工具了。让我们提供一些需要编译的内容!将以下内容放入 clang-tools-extra/loop-convert/LoopConvert.cpp。有关各个部分的详细解释,请参阅 LibTooling 文档

// Declares clang::SyntaxOnlyAction.
#include "clang/Frontend/FrontendActions.h"
#include "clang/Tooling/CommonOptionsParser.h"
#include "clang/Tooling/Tooling.h"
// Declares llvm::cl::extrahelp.
#include "llvm/Support/CommandLine.h"

using namespace clang::tooling;
using namespace llvm;

// Apply a custom category to all command-line options so that they are the
// only ones displayed.
static llvm::cl::OptionCategory MyToolCategory("my-tool options");

// CommonOptionsParser declares HelpMessage with a description of the common
// command-line options related to the compilation database and input files.
// It's nice to have this help message in all tools.
static cl::extrahelp CommonHelp(CommonOptionsParser::HelpMessage);

// A help message for this specific tool can be added afterwards.
static cl::extrahelp MoreHelp("\nMore help text...\n");

int main(int argc, const char **argv) {
  auto ExpectedParser = CommonOptionsParser::create(argc, argv, MyToolCategory);
  if (!ExpectedParser) {
    // Fail gracefully for unsupported options.
    llvm::errs() << ExpectedParser.takeError();
    return 1;
  }
  CommonOptionsParser& OptionsParser = ExpectedParser.get();
  ClangTool Tool(OptionsParser.getCompilations(),
                 OptionsParser.getSourcePathList());
  return Tool.run(newFrontendActionFactory<clang::SyntaxOnlyAction>().get());
}

就这样!您可以通过在 build 目录中运行 ninja 来编译我们的新工具。

cd ~/clang-llvm/build
ninja

现在,您就可以在任何源代码文件上运行语法检查器,该检查器位于 ~/clang-llvm/build/bin 中。试试看!

echo "int main() { return 0; }" > test.cpp
bin/loop-convert test.cpp --

请注意,在指定源代码文件后有两个连字符。编译器的其他选项传递到连字符之后,而不是从编译数据库中加载 - 目前不需要任何选项。

中场休息:学习 AST 匹配器基础知识

Clang 最近引入了 ASTMatcher 库,它提供了一种简单、强大、简洁的方式来描述 AST 中的特定模式。作为由宏和模板驱动的 DSL(如果您好奇,请查看 ASTMatchers.h),匹配器提供了一种类似于函数式编程语言中常见代数数据类型的操作体验。

例如,假设您只想检查二元运算符。有一个专门用于此目的的匹配器,名为 binaryOperator。我猜您一定知道这个匹配器是做什么的

binaryOperator(hasOperatorName("+"), hasLHS(integerLiteral(equals(0))))

令人惊讶的是,它将匹配左操作数恰好是字面量 0 的加法表达式。它不会匹配其他形式的 0,例如 '\0'NULL,但它会匹配扩展为 0 的宏。匹配器也不会匹配对重载运算符 '+' 的调用,因为有一个单独的 operatorCallExpr 匹配器来处理重载运算符。

有一些 AST 匹配器可以匹配 AST 的所有不同节点,缩小匹配器范围以仅匹配符合特定条件的 AST 节点,以及用于从一种 AST 节点遍历到另一种 AST 节点的遍历匹配器。有关 AST 匹配器的完整列表,请查看 AST Matcher 参考

所有用名词表示的匹配器都描述了 AST 中的实体,并且可以绑定,以便在找到匹配项时可以引用它们。为此,只需在这些匹配器上调用 bind 方法,例如

variable(hasType(isInteger())).bind("intvar")

步骤 2:使用 AST 匹配器

好了,让我们开始使用匹配器进行实际操作。让我们首先定义一个匹配器,它将捕获所有定义了一个初始化为零的新变量的 for 语句。让我们先从匹配所有 for 循环开始

forStmt()

接下来,我们想指定在循环的第一部分声明了一个变量,因此我们可以将匹配器扩展为

forStmt(hasLoopInit(declStmt(hasSingleDecl(varDecl()))))

最后,我们可以添加一个条件,要求变量初始化为零。

forStmt(hasLoopInit(declStmt(hasSingleDecl(varDecl(
  hasInitializer(integerLiteral(equals(0))))))))

匹配器定义相当易于阅读和理解(“匹配循环,其 init 部分声明了一个初始化为整型字面量 0 的单个变量”),但决定每个部分都是必需的则要困难得多。请注意,此匹配器不会匹配变量初始化为 '\0'0.0NULL 或除整型 0 之外的任何其他形式的零的循环。

最后一步是为匹配器命名并绑定 ForStmt,因为我们想对它做一些事情

StatementMatcher LoopMatcher =
  forStmt(hasLoopInit(declStmt(hasSingleDecl(varDecl(
    hasInitializer(integerLiteral(equals(0)))))))).bind("forLoop");

定义匹配器后,您需要添加一些额外的脚手架才能运行它们。匹配器与 MatchCallback 配对并注册到 MatchFinder 对象中,然后从 ClangTool 中运行。需要编写更多代码!

将以下内容添加到 LoopConvert.cpp

#include "clang/ASTMatchers/ASTMatchers.h"
#include "clang/ASTMatchers/ASTMatchFinder.h"

using namespace clang;
using namespace clang::ast_matchers;

StatementMatcher LoopMatcher =
  forStmt(hasLoopInit(declStmt(hasSingleDecl(varDecl(
    hasInitializer(integerLiteral(equals(0)))))))).bind("forLoop");

class LoopPrinter : public MatchFinder::MatchCallback {
public :
  virtual void run(const MatchFinder::MatchResult &Result) {
    if (const ForStmt *FS = Result.Nodes.getNodeAs<clang::ForStmt>("forLoop"))
      FS->dump();
  }
};

并将 main() 更改为

int main(int argc, const char **argv) {
  auto ExpectedParser = CommonOptionsParser::create(argc, argv, MyToolCategory);
  if (!ExpectedParser) {
    // Fail gracefully for unsupported options.
    llvm::errs() << ExpectedParser.takeError();
    return 1;
  }
  CommonOptionsParser& OptionsParser = ExpectedParser.get();
  ClangTool Tool(OptionsParser.getCompilations(),
                 OptionsParser.getSourcePathList());

  LoopPrinter Printer;
  MatchFinder Finder;
  Finder.addMatcher(LoopMatcher, &Printer);

  return Tool.run(newFrontendActionFactory(&Finder).get());
}

现在,您就可以重新编译并运行代码来发现 for 循环了。创建一个包含几个示例的新文件,并测试我们的新成果

cd ~/clang-llvm/build/
ninja loop-convert
vim ~/test-files/simple-loops.cc
bin/loop-convert ~/test-files/simple-loops.cc

步骤 3.5:更复杂的匹配器

我们的简单匹配器能够发现 for 循环,但我们仍然需要自己过滤掉更多循环。我们可以通过一些精心选择的匹配器来完成大部分剩余工作,但首先我们需要确定要允许哪些属性。

如何描述哪些用于遍历数组的 for 循环可以转换为基于范围的语法?遍历大小为 N 的数组的基于范围的循环,这些循环

  • 从索引 0 开始

  • 连续迭代

  • 在索引 N-1 处结束

我们已经检查了 (1),因此我们只需要在循环条件中添加一个检查,以确保循环的索引变量与 N 进行比较,以及另一个检查,以确保增量步骤只增加同一个变量。匹配器 (2) 很简单:要求对 init 部分中声明的同一个变量进行前增或后增。

不幸的是,这样的匹配器无法编写。匹配器不包含任何用于比较两个任意 AST 节点并确定它们是否相等的逻辑,因此我们能做的最好的事情是匹配比我们想要允许的更多节点,并将额外的比较推迟到回调中。

无论如何,我们可以开始构建这个子匹配器。我们可以要求增量步骤是单目增量,如下所示

hasIncrement(unaryOperator(hasOperatorName("++")))

指定增量的变量引入了 Clang AST 的另一个特性:变量的使用表示为 DeclRefExpr(“声明引用表达式”),因为它们是引用变量声明的表达式。为了找到引用特定声明的 unaryOperator,我们只需添加第二个条件即可

hasIncrement(unaryOperator(
  hasOperatorName("++"),
  hasUnaryOperand(declRefExpr())))

此外,我们可以将匹配器限制为仅在增量变量为整型时才进行匹配

hasIncrement(unaryOperator(
  hasOperatorName("++"),
  hasUnaryOperand(declRefExpr(to(varDecl(hasType(isInteger())))))))

最后一步是为这个变量附加一个标识符,以便我们可以在回调中检索它

hasIncrement(unaryOperator(
  hasOperatorName("++"),
  hasUnaryOperand(declRefExpr(to(
    varDecl(hasType(isInteger())).bind("incrementVariable"))))))

我们可以将这段代码添加到 LoopMatcher 的定义中,并确保我们的程序(配备了新的匹配器)只打印出声明了一个初始化为零的变量并且具有一个增量步骤(由某个变量的单目增量组成)的循环。

现在,我们只需要添加一个匹配器来检查 for 循环的条件部分是否将一个变量与数组的大小进行比较。只有一个问题 - 我们不知道在不查看循环体的情况下正在遍历哪个数组!我们再次只能用匹配器来近似地获得我们想要的结果,并在回调中填入细节。因此,我们从以下代码开始

hasCondition(binaryOperator(hasOperatorName("<")))

确保左侧是一个变量引用,右侧是整型是有意义的。

hasCondition(binaryOperator(
  hasOperatorName("<"),
  hasLHS(declRefExpr(to(varDecl(hasType(isInteger()))))),
  hasRHS(expr(hasType(isInteger())))))

为什么?因为这样做行不通。在 test-files/simple.cpp 中提供的三个循环中,没有一个匹配条件。快速查看循环转换之前版本生成的第一个 for 循环的 AST 转储,就能让我们找到答案

(ForStmt 0x173b240
  (DeclStmt 0x173afc8
    0x173af50 "int i =
      (IntegerLiteral 0x173afa8 'int' 0)")
  <<>>
  (BinaryOperator 0x173b060 '_Bool' '<'
    (ImplicitCastExpr 0x173b030 'int'
      (DeclRefExpr 0x173afe0 'int' lvalue Var 0x173af50 'i' 'int'))
    (ImplicitCastExpr 0x173b048 'int'
      (DeclRefExpr 0x173b008 'const int' lvalue Var 0x170fa80 'N' 'const int')))
  (UnaryOperator 0x173b0b0 'int' lvalue prefix '++'
    (DeclRefExpr 0x173b088 'int' lvalue Var 0x173af50 'i' 'int'))
  (CompoundStatement ...

我们已经知道声明和增量都匹配,否则这个循环就不会被转储。罪魁祸首是应用于小于运算符第一个操作数(即左侧)的隐式转换,即应用于引用 i 的表达式的左值到右值转换。值得庆幸的是,匹配器库提供了 ignoringParenImpCasts 来解决这个问题,它指示匹配器忽略隐式转换和括号,然后再继续匹配。调整条件运算符将恢复所需的匹配。

hasCondition(binaryOperator(
  hasOperatorName("<"),
  hasLHS(ignoringParenImpCasts(declRefExpr(
    to(varDecl(hasType(isInteger())))))),
  hasRHS(expr(hasType(isInteger())))))

在将我们想要捕获的表达式绑定到标识符,并将标识符字符串提取到变量之后,我们就完成了 array-step-2。

步骤 4:检索匹配的节点

到目前为止,匹配器回调并不是很有趣:它只是转储了循环的 AST。在某个时候,我们需要对输入源代码进行更改。接下来,我们将研究如何使用我们在上一步骤中绑定的节点。

MatchFinder::run() 回调函数接收一个 MatchFinder::MatchResult& 作为参数。我们最感兴趣的是它的 ContextNodes 成员。Clang 使用 ASTContext 类来表示 AST 的上下文信息,顾名思义,虽然最具功能性的细节是许多操作都需要一个 ASTContext* 参数。更直接有用的是匹配节点集,以及我们如何检索它们。

由于我们绑定了三个变量(由 ConditionVarName、InitVarName 和 IncrementVarName 标识),我们可以使用 getNodeAs() 成员函数获取匹配的节点。

LoopConvert.cpp 中添加

#include "clang/AST/ASTContext.h"

LoopMatcher 更改为

StatementMatcher LoopMatcher =
    forStmt(hasLoopInit(declStmt(
                hasSingleDecl(varDecl(hasInitializer(integerLiteral(equals(0))))
                                  .bind("initVarName")))),
            hasIncrement(unaryOperator(
                hasOperatorName("++"),
                hasUnaryOperand(declRefExpr(
                    to(varDecl(hasType(isInteger())).bind("incVarName")))))),
            hasCondition(binaryOperator(
                hasOperatorName("<"),
                hasLHS(ignoringParenImpCasts(declRefExpr(
                    to(varDecl(hasType(isInteger())).bind("condVarName"))))),
                hasRHS(expr(hasType(isInteger())))))).bind("forLoop");

并将 LoopPrinter::run 更改为

void LoopPrinter::run(const MatchFinder::MatchResult &Result) {
  ASTContext *Context = Result.Context;
  const ForStmt *FS = Result.Nodes.getNodeAs<ForStmt>("forLoop");
  // We do not want to convert header files!
  if (!FS || !Context->getSourceManager().isWrittenInMainFile(FS->getForLoc()))
    return;
  const VarDecl *IncVar = Result.Nodes.getNodeAs<VarDecl>("incVarName");
  const VarDecl *CondVar = Result.Nodes.getNodeAs<VarDecl>("condVarName");
  const VarDecl *InitVar = Result.Nodes.getNodeAs<VarDecl>("initVarName");

  if (!areSameVariable(IncVar, CondVar) || !areSameVariable(IncVar, InitVar))
    return;
  llvm::outs() << "Potential array-based loop discovered.\n";
}

Clang 将一个 VarDecl 与每个变量关联,以表示变量的声明。由于每个声明的“规范”形式在地址上是唯一的,所以我们只需要确保两个 ValueDeclVarDecl 的基类)都不是 NULL,并比较规范的 Decl。

static bool areSameVariable(const ValueDecl *First, const ValueDecl *Second) {
  return First && Second &&
         First->getCanonicalDecl() == Second->getCanonicalDecl();
}

如果执行到达 LoopPrinter::run() 的末尾,我们知道循环 shell 如下所示

for (int i= 0; i < expr(); ++i) { ... }

目前,我们只打印一条消息来说明我们找到了一个循环。下一节将讨论递归遍历 AST 以发现所有需要的更改。

顺便说一句,测试两个表达式是否相同并不像看起来那么简单,不过 Clang 已经为我们做了艰苦的工作,提供了一种规范化表达式的途径

static bool areSameExpr(ASTContext *Context, const Expr *First,
                        const Expr *Second) {
  if (!First || !Second)
    return false;
  llvm::FoldingSetNodeID FirstID, SecondID;
  First->Profile(FirstID, *Context, true);
  Second->Profile(SecondID, *Context, true);
  return FirstID == SecondID;
}

此代码依赖于两个 llvm::FoldingSetNodeIDs 之间的比较。正如 Stmt::Profile() 的文档所示,Profile() 成员函数根据节点的属性及其子节点的属性构建 AST 中节点的描述。然后 FoldingSetNodeID 充当我们可以用来比较表达式的哈希值。我们稍后将需要 areSameExpr。在你运行新的代码之前,在 test-files/simple.cpp 中添加了额外的循环,尝试找出哪些循环将被认为是潜在的可转换的。