iOS 底层原理之自定义 Clang 插件
↓推荐关注↓
前言
本篇文章将使用LLVM
和Clang
实现一个简单的插件。废话不多说,让我们开始今天的内容吧。
一: LLVM下载
编写Clang
插件之前,需要先下载和编译LLVM
。
这里提供两种下载方式,一种是下载整个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: 根据需要下载相应的子仓库
自定义插件需要的子仓库
在 LLVM
的tools
目录下下载Clang
。
cd llvm/tools
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project/clang.git
如果想研究
lldb
的话,需要在LLVM
的tools
目录下下载lldb
。自定义插件不需要。cd llvm/tools
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project/lldb.git
在 LLVM
的projects
目录下下载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
在 Clang
的tools
下安装clang-tools-extra
工具。
cd ../tools/clang/tools
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm-project/clang-tools-extra.git
二: LLVM
编译
新版macOS
默认的shell
是zsh
,所以,首先进入终端执行:
echo 'export OSX_COMMANDLINE_SDKROOT="/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"' >> ~/.zshrc
然后再执行:
source ~/.zshrc
由于最新的LLVM
只支持cmake
来编译了,所以我们还需要安装brew
和cmake
。相关安装方法请移步brew和cmake安装。
一些常见的构建系统生成器(generator
)有:
Ninja
:大多数LLVM
开发人员都使用Ninja
。Unix Makefiles
:用于生成与make
兼容的并行makefile
。Visual Studio
:用于生成Visual Studio
项目和解决方案。Xcode
:用于生成Xcode
项目。
作为iOS
开发人员,当然首选Xcode
来进行编译了。
2.1: 使用Xcode
构建LLVM
项目
首先使用Xcode
为generator
,通过cmake
将LLVM
编译成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]
直接编译就可以了。
开始编译(完整包为例):
大概几分钟后后,检测映射完成。
此时build_xcode
目录下大概有67M
内容(指定不同[options]
,大小会有所不同):
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)
依次执行编译、安装指令。
ninjaninja install
2.3: 使用Xcode
编译Clang
进入 build_xcode
目录打开LLVM.xcodeproj
:
进入 Xcode
界面:
⚠️注意:不要选择
Automatically Create Schemes
,选择Manually Manage Schemes
。否则会引入一些不必要的
scheme
,拖累Xcode
速度。原则:使用哪个
scheme
,就引入哪个。
点击左下角加号,在 Target
中选择我们需要的添加:
自定义插件需要添加 clang
和clangTooling
:
开始运行 clang
和clangTooling
,第一次运行时需要进行编译,往后再运行,即可直接运行:
⚠️注意:每次运行时要通过
Run Without Building
运行。这意味着当你编译一次之后,代码没有改变的情况下,不需要重新编译,直接运行现有可执行文件即可。
选择 Build & Run
:
真正进入编译模式:
起飞🚀,感受机器的轰鸣吧!!!趁这个时间可以洗个澡或吃个饭😂。
三: Clang
插件
开始创建插件之前先对要实现的功能做一个简单的介绍:
自定义插件想要实现的功能是当检测到 NSString
、NSArray
、NSDictionary
类型的属性使用的修饰属性不为copy
时,发出警告。
3.1: 创建插件
在llvm-project/llvm/tools/clang/tools
目录下新建插件目录XJPlugin
:
修改llvm-project/llvm/tools/clang/tools
目录(即同目录)下的CMakeLists.txt
文件,在最下面新增add_clang_subdirectory(XJPlugin)
。
在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)
接下来使用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
,然后添加XJPlugin
的scheme
,并进行编译:
现在在LLVM
的Xcode
项目的Loadable modules
目录下就可以看到我们的XJPlugin
目录了。接下来就在里面编写插件代码。
工程目录非常多,可以全选之后按住
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
),重载两个函数ParseArgs
和CreateASTConsumer
,在CreateASTConsumer
中创建XJASTConsumer
类对象,并将编译器实例CI
传递过去。CI
主要用于以下两个方面通过
FrontendPluginRegistry
注册插件,需要关联插件名与自定义的XJASTAction
类。【第二步】扫描配置完毕
HandleTopLevelDecl
:解析完毕一个顶级的声明就回调一次HandleTranslationUnit
:当整个文件都解析完毕后回调,将文件解析完毕后的上下文context
(即AST
语法树)给matcher
。自定义 XJASTConsumer
类(继承自ASTConsumer
),声明节点查找器MatchFinder matcher
和回调对象XJMatchCallback callback
。实现构造函数,创建 MatchFinder
对象,并将CI
传递给回调对象callback
。重载两个方法 【第三步】扫描完毕的回调
1、通过 Result
根据节点id
获取节点对象(此id
需要与XJASTConsumer
构造方法中bind
的id
一致)。2、判断节点有值并且是用户文件 3、获取属性节点的描述信息 4、获取属性节点的类型,并转成字符串 5、判断属性是否需要用 copy
但是没有用copy
6、通过 CI
获取诊断引擎7、通过诊断引擎报告错误 自定义回调类
XJMatchCallback
(继承自MatchCallback
),声明私有变量CI
,用于接收ASTConsumer
类传递过来的CI
。重写
run
方法
通过终端测试插件:
// 命令格式
自己编译的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
3.3: Xcode
集成插件
此插件只作为研究clang
之用,实际开发的项目中最好不要继承,因为会影响Xcode
编译速度。此插件集成是针对项目,不是针对整个Xcode
,测试项目可以放心集成。
3.3.1: 加载插件
打开测试项目,在 target -> Build Settings -> Other C Flags
添加如下内容:
-Xclang -load -Xclang (.dylib)插件动态库路径 -Xclang -add-plugin -Xclang 插件名
3.3.2: 设置编译器
由于 clang
插件需要使用对应的版本去加载,如果版本不一致会导致编译失败,如下所示:
在 Build Settings
栏目中新增两项用户定义的设置,分别是CC
和CXX
CC
对应的是自己编译的clang
的绝对路径CXX
对应的是自己编译的clang++
的绝对路径
接下来在 Build Settings
中搜索index
,将Enable Index-Wihle-Building Functionality
的Default
改为NO
最后,重新编译测试项目,会出现我们想要的效果:
转自:掘金 温暖
https://juejin.cn/post/7004633055012864031#heading-2
- EOF -
看完本文有收获?请分享给更多人
关注「 iOS大全 」加星标,关注 iOS 动态
点赞和在看就是最大的支持❤️