iOS底层原理之自定义Clang插件

2,588 阅读6分钟

前言

前文主要介绍了下LLVMClang相关的概念、设计思想和编译流程,本篇文章将使用LLVMClang实现一个简单的插件。废话不多说,让我们开始今天的内容吧。

一: LLVM下载

编写Clang插件之前,需要先下载和编译LLVM

由于国内的网络限制,我们需要借助镜像下载LLVM的源码。 mirror.tuna.tsinghua.edu.cn/help/llvm-p…

这里提供两种下载方式,一种是下载整个LLVM(包括各个子仓库,比如clang等等),一种是只下载LLVM,然后根据自己需要再去下载子仓库。

1.1: 下载完整LLVM,包含子仓库(2.78G

git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project.git

1.2: 只下载LLVM,不包含子仓库(1.52G

git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project/llvm.git

1.2.1: 根据需要下载相应的子仓库

自定义插件需要的子仓库

  • LLVMtools目录下下载Clang
cd llvm/tools
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project/clang.git

如果想研究lldb的话,需要在LLVMtools目录下下载lldb。自定义插件不需要。

cd llvm/tools
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project/lldb.git
  • LLVMprojects目录下下载compiler-rt,libcxx,libcxxabi
cd ../projects
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project/compiler-rt.git
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project/libcxx.git
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project/libcxxabi.git
  • Clangtools下安装clang-tools-extra工具。
cd ../tools/clang/tools
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project/clang-tools-extra.git

二: LLVM编译

新版macOS默认的shellzsh,所以,首先进入终端执行:

echo 'export OSX_COMMANDLINE_SDKROOT="/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"' >> ~/.zshrc

然后再执行:

source ~/.zshrc

由于最新的LLVM只支持cmake来编译了,所以我们还需要安装brewcmake。相关安装方法请移步brew和cmake安装

一些常见的构建系统生成器(generator)有:

  • Ninja:大多数LLVM开发人员都使用Ninja
  • Unix Makefiles:用于生成与make兼容的并行makefile
  • Visual Studio:用于生成Visual Studio项目和解决方案。
  • Xcode:用于生成Xcode项目。

作为iOS开发人员,当然首选Xcode来进行编译了。

2.1: 使用Xcode构建LLVM项目

首先使用Xcodegenerator,通过cmakeLLVM编译成Xcode项目。

2.1.1: 完整LLVM编译方法

cd llvm-project                // 进入完整llvm文件夹
mkdir build_xcode              // 新建文件夹build_xcode
cd build_xcode                 // 进入build_xcode
cmake -G <generator> [options] ../llvm // 编译成Xcode项目,具体命令看下面

这里generator我们选择Xcode-DLLVM_ENABLE_PROJECTS就是需要编译的子项目,这里我们需要加上clang,compiler-rt,libcxx,libcxxabi,clang-tools-extra

cmake -G Xcode -DLLVM_ENABLE_PROJECTS='libcxx;libcxxabi;clang;clang-tools-extra;compiler-rt' -DLLDB_USE_SYSTEM_DEBUGSERVER=ON -DLLDB_TEST_COMPILER=clang++ -DCMAKE_OSX_SYSROOT=$OSX_COMMANDLINE_SDKROOT ../llvm

2.1.2: 不完整LLVM编译方法

mkdir build_xcode       // 在llvm所在目录新建文件夹build_xcode
cd build_xcode          // 进入build_xcode
cmake -G Xcode ../llvm  // 编译成Xcode项目

不完整LLVM由于我们已经根据自己的情况下载了子仓库,所以不用添加[options]直接编译就可以了。

开始编译(完整包为例):

image.png

大概几分钟后后,检测映射完成。

image.png

此时build_xcode目录下大概有67M内容(指定不同[options],大小会有所不同):

image.png

2.2: 使用ninja构建LLVM项目(不推荐)

  • 使用ninja进行编译,需要先安装ninja,相关安装方法请移步brew和cmake安装

  • llvm源码根目录下新建build_ninja目录,最终会在build_ninja目录下生成build.ninja

  • llvm源码根目录下新建llvm-release目录,最终编译文件会在llvm-release文件夹路径下。

cd llvm_build

// 注意DCMAKE_INSTALL_PREFIX后面不能有空格
cmake -G Ninja ../llvm -DCMAKE_INSTALL_PREFIX= 安装路径(本机为/ Users/xxx/xxx/LLVM/llvm_release)
  • 依次执行编译、安装指令。
ninja

ninja install

2.3: 使用Xcode编译Clang

  • 进入build_xcode目录打开LLVM.xcodeproj

image.png

  • 进入Xcode界面:

image.png

⚠️注意:不要选择Automatically Create Schemes,选择Manually Manage Schemes

否则会引入一些不必要的scheme,拖累Xcode速度。

原则:使用哪个scheme,就引入哪个。

  • 点击左下角加号,在Target中选择我们需要的添加:

image.png

  • 自定义插件需要添加clangclangTooling

image.png

  • 开始运行clangclangTooling,第一次运行时需要进行编译,往后再运行,即可直接运行:

image.png

⚠️注意:每次运行时要通过Run Without Building运行。这意味着当你编译一次之后,代码没有改变的情况下,不需要重新编译,直接运行现有可执行文件即可。

  • 选择Build & Run

image.png

  • 真正进入编译模式:

image.png

  • 起飞🚀,感受机器的轰鸣吧!!!趁这个时间可以洗个澡或吃个饭😂。

image.png

三: Clang插件

开始创建插件之前先对要实现的功能做一个简单的介绍:

  • 自定义插件想要实现的功能是当检测到NSStringNSArrayNSDictionary类型的属性使用的修饰属性不为copy时,发出警告。

3.1: 创建插件

llvm-project/llvm/tools/clang/tools目录下新建插件目录XJPlugin:

image.png

修改llvm-project/llvm/tools/clang/tools目录(即同目录)下的CMakeLists.txt文件,在最下面新增add_clang_subdirectory(XJPlugin)

image.png

XJPlugin目录下新建一个名为XJPlugin.cpp的文件和CMakeLists.txt的文件。在CMakeLists.txt中添加如下代码:

// 通过终端在XJPlugin目录下创建这两个文件
touch CJLPlugin.cpp 
touch CMakeLists.txt

// CMakeLists.txt文件中添加如下代码
add_llvm_library( XJPlugin MODULE BUILDTREE_ONLY
  XJPlugin.cpp
)

image.png

接下来使用cmake重新构建一下Xcode项目,终端进入build_xcode目录,运行如下命令:

cmake -G Xcode -DLLVM_ENABLE_PROJECTS='libcxx;libcxxabi;clang;clang-tools-extra;compiler-rt' -DLLDB_USE_SYSTEM_DEBUGSERVER=ON -DLLDB_TEST_COMPILER=clang++ -DCMAKE_OSX_SYSROOT=$OSX_COMMANDLINE_SDKROOT ../llvm

重新进入build_xcode目录打开LLVM.xcodeproj,然后添加XJPluginscheme,并进行编译:

image.png

现在在LLVMXcode项目的Loadable modules目录下就可以看到我们的XJPlugin目录了。接下来就在里面编写插件代码。

image.png

工程目录非常多,可以全选之后按住command键,鼠标左键点击目录左边的箭头全部折叠,这样就方便找到Loadable modules目录了。

3.2: 编写插件代码

XJPlugin.cpp文件中加入如下代码:

#include <iostream>
#include "clang/AST/AST.h"
#include "clang/AST/DeclObjC.h"
#include "clang/AST/ASTConsumer.h"
#include "clang/ASTMatchers/ASTMatchers.h"
#include "clang/Frontend/CompilerInstance.h"
#include "clang/ASTMatchers/ASTMatchFinder.h"
#include "clang/Frontend/FrontendPluginRegistry.h"

// 声明使用命名空间
using namespace clang;
using namespace std;
using namespace llvm;
using namespace clang::ast_matchers;

// 插件命名空间
namespace XJPlugin {

    // 第三步:扫描完毕回调
    // 4、自定义回调类,继承自MatchCallback
    class XJMatchCallback : public MatchFinder::MatchCallback {

    private:
        // CI传递路径:XJASTAction类中的CreateASTConsumer方法参数 -> XJASTConsumer的构造函数 -> XJMatchCallback的私有属性,通过构造函数从XJASTConsumer构造函数中获取
        CompilerInstance &CI;

        // 判断是否是自己的文件
        bool isUserSourceCode(const string fileName) {
            // 文件名不为空
            if (fileName.empty()) return false;
            // 非Xcode中的代码都认为是用户的
            if (0 == fileName.find("/Applications/Xcode.app/")) return false;
            return true;
        }

        // 判断是否应该用copy修饰
        bool isShouldUseCopy(const string typeStr) {
            // 判断类型是否是 NSString / NSArray / NSDictionary
            if (typeStr.find("NSString") != string::npos ||
                typeStr.find("NSArray") != string::npos ||
                typeStr.find("NSDictionary") != string::npos) {
                return true;
            }
            return false;
        }

    public:
        // 构造方法
        XJMatchCallback(CompilerInstance &CI):CI(CI) {}

        // 重载run方法
        void run(const MatchFinder::MatchResult &Result) {
            // 通过Result获取节点对象,根据节点id("objcPropertyDecl")获取(此id需要与XJASTConsumer构造方法中bind的id一致)
            const ObjCPropertyDecl *propertyDecl = Result.Nodes.getNodeAs<ObjCPropertyDecl>("objcPropertyDecl");
            // 获取文件名称(包含路径)
            string fileName = CI.getSourceManager().getFilename(propertyDecl->getSourceRange().getBegin()).str();
            // 如果节点有值 && 是用户文件
            if (propertyDecl && isUserSourceCode(fileName)) {
                // 获取节点的类型,并转成字符串
                string typeStr = propertyDecl->getType().getAsString();
                // 节点的描述信息
                ObjCPropertyAttribute::Kind attrKind = propertyDecl->getPropertyAttributes();
                // 应该使用copy,但是没有使用copy
                if (isShouldUseCopy(typeStr) && !(attrKind & ObjCPropertyAttribute::kind_copy)) {
                    // 通过CI获取诊断引擎
                    DiagnosticsEngine &diag = CI.getDiagnostics();
                    // Report 报告
                    /**
                     错误位置:getLocation 节点位置
                     错误:getCustomDiagID(等级,提示)
                     */
                    diag.Report(propertyDecl->getLocation(), diag.getCustomDiagID(DiagnosticsEngine::Warning, "%0 - 这个属性推荐使用copy修饰!!"))<< typeStr;
                }
            }
        }
    };

    // 第二步:扫描配置完毕
    // 3、自定义XJASTConsumer,继承自抽象类 ASTConsumer,用于监听AST节点的信息 -- 过滤器
    class XJASTConsumer : public ASTConsumer {
    private:
        // AST 节点查找器(过滤器)
        MatchFinder matcher;
        // 回调对象
        XJMatchCallback callback;

    public:
        // 构造方法中创建MatchFinder对象
        XJASTConsumer(CompilerInstance &CI):callback(CI) { // 构造即将CI传递给callback
            // 添加一个MatchFinder,每个objcPropertyDecl节点绑定一个objcPropertyDecl标识(去匹配objcPropertyDecl节点)
            // 回调callback,其实是在CJLMatchCallback里面重写run方法(真正回调的是回调run方法)
matcher.addMatcher(objcPropertyDecl().bind("objcPropertyDecl"), &callback);
        }

        // 重载两个方法 HandleTopLevelDecl 和 HandleTranslationUnit

        // 解析完毕一个顶级的声明就回调一次(顶级节点,即全局变量,属性,函数等)
        bool HandleTopLevelDecl(DeclGroupRef D) {
//            cout<<"正在解析..."<<endl;
            return true;
        }

        // 当整个文件都解析完毕后回调
        void HandleTranslationUnit(ASTContext &Ctx) {
//            cout<<"文件解析完毕!!!"<<endl;
            // 将文件解析完毕后的上下文context(即AST语法树) 给 matcher
            matcher.matchAST(Ctx);
        }
    };

    //2、继承PluginASTAction,实现我们自定义的XJASTAction,即自定义AST语法树行为
    class XJASTAction : public PluginASTAction {
    public:

        // 重载ParseArgs 和 CreateASTConsumer方法

        /*
         解析给定的插件命令行参数
         - param CI 编译器实例,用于报告诊断。
         - return 如果解析成功,则为true;否则,插件将被销毁,并且不执行任何操作。该插件负责使用CompilerInstance的Diagnostic对象报告错误。
         */
        bool ParseArgs(const CompilerInstance &CI, const std::vector<std::string> &arg) {
            return true;
        }

        // 返回自定义的XJASTConsumer对象,抽象类ASTConsumer的子类
        unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI, StringRef InFile) {
            /**
             传递CI
             CI用于:
             - 判断文件是否是用户的
             - 抛出警告
             */
            return unique_ptr<XJASTConsumer>(new XJASTConsumer(CI));
        }
    };
}

// 第一步:注册插件,并自定义XJASTAction类
// 1、注册插件
static FrontendPluginRegistry::Add<XJPlugin::XJASTAction> X("XJPlugin", "this is XJPlugin");

原理主要分为三步:

  • 【第一步】注册插件,并自定义XJASTAction类

    • 自定义XJASTAction类(继承自抽象类PluginASTAction),重载两个函数ParseArgsCreateASTConsumer,在CreateASTConsumer中创建XJASTConsumer类对象,并将编译器实例CI传递过去。CI主要用于以下两个方面
      • 判断文件是否是用户的
      • 抛出警告
    • 通过FrontendPluginRegistry注册插件,需要关联插件名与自定义的XJASTAction类。
  • 【第二步】扫描配置完毕

    • 自定义XJASTConsumer类(继承自ASTConsumer),声明节点查找器MatchFinder matcher和回调对象XJMatchCallback callback
    • 实现构造函数,创建MatchFinder对象,并将CI传递给回调对象callback
    • 重载两个方法
      • HandleTopLevelDecl:解析完毕一个顶级的声明就回调一次
      • HandleTranslationUnit:当整个文件都解析完毕后回调,将文件解析完毕后的上下文context(即AST语法树)给matcher
  • 【第三步】扫描完毕的回调

    • 自定义回调类XJMatchCallback(继承自MatchCallback),声明私有变量CI,用于接收ASTConsumer类传递过来的CI
    • 重写run方法
      • 1、通过Result根据节点id获取节点对象(此id需要与XJASTConsumer构造方法中bindid一致)。
      • 2、判断节点有值并且是用户文件
      • 3、获取属性节点的描述信息
      • 4、获取属性节点的类型,并转成字符串
      • 5、判断属性是否需要用copy但是没有用copy
      • 6、通过CI获取诊断引擎
      • 7、通过诊断引擎报告错误

通过终端测试插件:

// 命令格式
自己编译的clang文件路径 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.5.sdk(SDK路径)/ -Xclang -load -Xclang 插件(.dyld)路径 -Xclang -add-plugin -Xclang 插件名 -c 源码路径

// 例子
/Users/用户名/llvm-project/build_xcode/Debug/bin/clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.5.sdk/ -Xclang -load -Xclang /Users/用户名/llvm-project/build_xcode/Debug/lib/XJPlugin.dylib -Xclang -add-plugin -Xclang XJPlugin -c /Users/用户名/Desktop/DemoCode/PluginTestDemo/PluginTestDemo/ViewController.m

image.png

3.3: Xcode集成插件

此插件只作为研究clang之用,实际开发的项目中最好不要继承,因为会影响Xcode编译速度。此插件集成是针对项目,不是针对整个Xcode,测试项目可以放心集成。

3.3.1: 加载插件

  • 打开测试项目,在target -> Build Settings -> Other C Flags添加如下内容:
 -Xclang -load -Xclang (.dylib)插件动态库路径 -Xclang -add-plugin -Xclang 插件名

image.png

3.3.2: 设置编译器

  • 由于clang插件需要使用对应的版本去加载,如果版本不一致会导致编译失败,如下所示:

image.png

  • Build Settings栏目中新增两项用户定义的设置,分别是CCCXX

    • CC 对应的是自己编译的clang的绝对路径
    • CXX 对应的是自己编译的clang++的绝对路径

image.png

  • 接下来在Build Settings中搜索index,将Enable Index-Wihle-Building FunctionalityDefault改为NO

image.png

  • 最后,重新编译测试项目,会出现我们想要的效果:

image.png

总结

关于LLVMClang的研究到此就告一段落了。下篇文章将进入启动优化的探索,敬请期待。