025*:LLVM和clang插件开发(预处理编译阶段--编译阶段(词法、语法等的分析、生成中间代码IR【 .ll文件、IR优化、bitCode优化.bc】)--汇编代码 .s文件--生成目标文件 .o文件 --链接【产生的.o文件和.dylib、.a文件链接】--mach-o可执行文件)
问题
(预处理编译阶段--编译阶段(词法、语法等的分析、生成中间代码IR【 .ll文件、IR优化、bitCode优化.bc】)
--汇编代码 .s文件
--生成目标文件 .o文件
--链接【产生的.o文件和.dylib、.a文件链接】--mach-o可执行文件)
目录
1:概念
2:编译流程
3:自定义clang插件
预备
正文
本文主要是理解LLVM的编译流程以及clang插件的开发
一:概念
1: 什么是编译器?
解释型
语言与编译型
语言
python
是解释型语言
,一边翻译
一边执行
。和js
一样,机器可直接执行。C
语言是编译型语言
,不能直接执行,需要编译器
将其转换
成机器识别语言
。
编译型语言
:编译后
输出的是指令
(0、1组合),cpu可直接执行指令解释性语言
:生成的是数据
,不是0、1组合
,机器也能直接识别
编译器
的作用,就是将高级语言
转化为机器
能够识别
的语言
(可执行文件
)。
Q:汇编有指令吗?
早期科学家,使用
0、1编码
。 比如00001111
对应call
,00000111
对应bl
。有了对应关系
后。 再手敲
0和1就有点难受
了。于是写个中间解释器
,我们只用输入call
、bl
这样的标记指令
,经过解释器
,变成0和1的组合,再交给机器去执行。 这就是汇编的由来
。而基于汇编往上,再
映射
和封装
相关对应关系
。就跨时代性
的c
语言,再往上
层封装,就出现了高级语言oc
、swift
等语言。所以汇编执行快
,因为它是直接转换
为机器语言
的。但
汇编
的指令集
,是针对同一操作系统
而言,它不
支持跨平台
。机器指令
是cpu
的在识别
。早期的计算机厂家
非常多
,虽然都用0
和1
的组合
,但相同组合背后却是相应不同
的指令
。所以汇编无法跨平台
,不同操作系统
下,汇编指令
是不同
的。
2:LLVM概述
LLVM
是架构编译器
(compiler
)的框架系统
,以c++
编写而成,用于优化
以任意程序语言
编写的程序的编译时间
(compile-time
)、链接时间
(link-time
)、运行时间
(run-time
)以及空闲时间
(idle-time
),对开发者保持开放,并兼任已有脚本。- 2006年
Chris Lattner
加盟Apple Inc.
并致力于LLVM
在Apple开发体系
中的应用。Apple
也是LLVM计划
的主要资助者
。
目前LLVM
已经被苹果iOS开发工具
、Xilinx Vivado
、Facebook
、Google
等各大公司采用。
2.1 传统编译器的设计
前端任务
是解析源代码
。 会进行词法分析
、语法分析
、语义分析
。检查源代码
是否存在错误
,然后构建抽象语法树
(Abstract Syntax Tree AST),LLVM前端
还会生成中间代码
(intermediate representation, IR) 优化器负责各种优化
。改善
代码的运行时间
,如消除冗余计算
等
映射
到目标指令集
,生成机器语言
,并进行机器相关
的代码优化
(目标指不同操作系统
)iOS的编译器架构:
2.2 LLVM的设计
-
GCC
是一个非常成功
的编译器
,但由于它作为整体应用程序
设计的,用途
受到了限制
。 -
LLVM
最重要的地方:支持多种语言
或多种硬件架构
。使用通用代码
表示形式:IR
(用来在编译器中表示代码的形式) -
LLVM
可以为任何编程语言
独立编写前端
,也可以为任何硬件架构
独立编写后端
. -
所以LLVM
不是
一个简单的编译器
,而是架构编译器
,可以兼容
所有前端
和后端
。
2.3 Clang
Clang
是LLVM项目
的一个子项目
。基于LLVM架构
的轻量级编辑器
,诞生之初
就是为了替代GCC
,提供更快
的编译速度
。 他是负责编译C
、C++
、Objecte-C
语言的编译器
,它属于
整个LLVM架构
中的编译器前端
。
- 对于开发者而言,
研究Clang
可以给我们带来很多好处
。
二:编译流程
1:我们先示例演示一下,查看一下LLVM编译的大致过程。
1:新建一个main.m文件,里面写上下面的代码
int test(int a,int b){ return a + b + 3; } int main(int argc, char * argv[]) { NSString * appDelegateClassName; @autoreleasepool { // Setup code that might create autoreleased objects goes here. appDelegateClassName = NSStringFromClass([AppDelegate class]); int a = test(1, 2); printf("%d",a); } return UIApplicationMain(argc, argv, nil, appDelegateClassName); }
2:通过命令可以打印源码的编译流程
//************命令************ clang -ccc-print-phases main.m //************编译流程************ //0 - 输入文件:找到源文件 +- 0: input, "main.m", objective-c //1 - 预处理阶段:这个过程处理包括宏的替换,头文件的导入 +- 1: preprocessor, {0}, objective-c-cpp-output //2 - 编译阶段:进行词法分析、语法分析、检测语法是否正确,最终生成IR +- 2: compiler, {1}, ir //3 - 后端:这里LLVM会通过一个一个的pass去优化,每个pass做一些事情,最终生成汇编代码 +- 3: backend, {2}, assembler //4 - 汇编代码生成目标文件 +- 4: assembler, {3}, object //5 - 链接:链接需要的动态库和静态库,生成可执行文件 +- 5: linker, {4}, image(镜像文件) //6 - 绑定:通过不同的架构,生成对应的可执行文件 6: bind-arch, "x86_64", {5}, image
1:预处理编译阶段
这个阶段主要是处理包括宏的替换,头文件的导入,可以执行如下命令,执行完毕可以看到头文件的导入和宏的替换
//在终端直接查看替换结果 clang -E main.m //生成对应的文件查看替换后的源码 clang -E main.m >> main2.m
需要注意的是:
-
typedef
在给数据类型取别名时,在预处理阶段不会被替换掉
-
define
则在预处理阶段会被替换
,所以经常被是用来进行代码混淆,目的是为了app安全,实现逻辑是:将app中核心类、核心方法等用系统相似的名称进行取别名
了,然后在预处理阶段就被替换了,来达到代码混淆的目的
2、编译阶段
编译阶段主要是进行词法、语法等的分析和检查,然后生成中间代码IR
-词法分析
,
就是根据空格
和括号
这些将代码拆分
成一个个Token
。标注了位置
是第几行
的第几个字符
开始的。比如大小括号、等于号还有字符串等,
2、语法分析
词法分析完成后就是语法分析
,它的任务是验证语法是否正确
,在词法分析的基础上将单词序列组合成各类此法短语,如程序、语句、表达式 等等,然后将所有节点组成抽象语法树
(Abstract Syntax TreeAST),语法分析程序判断程序在结构上是否正确
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m
下面是语法分析的结果
其中,主要说明几个关键字的含义
- -FunctionDecl 函数
- -ParmVarDecl 参数
- -CallExpr 调用一个函数
- -BinaryOperator 运算符
3、生成中间代码IR
完成以上步骤后,就开始生成中间代码IR了,代码生成器
(Code Generation)会将语法树自顶向下遍历
逐步翻译成LLVM IR
,
3.1:可以通过下面命令可以生成.ll的文本文件
,查看IR代码。OC代码在这一步会进行runtime桥接,:property合成、ARC处理等
clang -S -fobjc-arc -emit-llvm main.m //以下是IR基本语法 @ 全局标识 % 局部标识 alloca 开辟空间 align 内存对齐 i32 32bit,4个字节 store 写入内存 load 读取数据 call 调用函数 ret 返回
使用VSCode
或Sublime Text
可以打开代码:(可以指定文件
的语言
,让代码
有高亮色
)
- Q:图中为何
多创建
那么多局部变量
?(如test函数内的a5、a6) - 因为在上一阶段(
编译阶段
),我们将代码
编译成了语法树结构
。而此时,我们只是沿
着语法树
进行读取
。 语法树每一个层级
,都需要
一个临时变量
来承接
。再返回上一层级处理
。 - 所以会
产生
那么多局部变量
。
Xcode
的Build Settings
中搜索Optimization
,可以看到优化级别。(
Debug模式
默认None [O0]
无优化,Release模式
默认Fastest,Smallest [Os]
最快最小)-
LLVM的优化级别分为
-O0
、-O1
、-O2
、-O3
、-Os
(第一个字母是Optimization的O)。 -
分别选择
O0
和Os
两个优化等级进行中间代码的生成比较:
clang -S -fobjc-arc -emit-llvm main.m -o mainO0.ll // O0 无优化 clang -Os -S -fobjc-arc -emit-llvm main.m -o mainOs.ll // Os 最快最小
优化后
的代码,舒服
多了。之前那些冗余
的临时局部变量
,也都被优化
,代码量减少
很多。
3.3:bitCode再优化
Xcode7之后
,开启bitCode
苹果会再进一步优化
,生成.bc
的中间代码
。
优化体现
:上传APPstore的包
,针对不同型号手机
做了区分
,不同型号手机下载
时,包
的大小不同
。
4: 生成汇编代码
1:完成中间代码
的生成后,可以将代码转变
为汇编代码
了。
此刻我们有4种
不同程度的代码(源代码
->无优化IR代码
->Os优化IR代码
-> bitcode优化代码
):
2:分别对
4种程度
的代码输出汇编
文件:clang -S -fobjc-arc main.m -o main.s clang -S -fobjc-arc main.ll -o mainO0.s clang -S -fobjc-arc mainOs.ll -o mainOs.s clang -S -fobjc-arc main.bc -o mainbc.s
可以看到在生成汇编代码
时,只有选择
了优化等级
,才能减少
汇编代码量
。
【拓展】在生成中间代码
的前后
,都可以
进行优化
。
- [尝试一] 将
main.m
直接选择Os级别
优化生成.s
汇编文件
clang -Os -S -fobjc-arc main.m -o mainOs.s
- [尝试二] 将
main.m
生成无优化
的main.s
,再main.s
选择Os级别
优化生成.s
汇编文件
clang -S -fobjc-arc -emit-llvm main.m -o mainO0.ll
clang -Os -S -fobjc-arc mainO0.ll -o mainOoOs.s
- [尝试三] 将
main.m
选择Os级别
优化生成main.s
,再main.s
选择无优化
级别生成.s
汇编文件
clang -Os -S -fobjc-arc -emit-llvm main.m -o mainOs.ll
clang -S -fobjc-arc mainOs.ll -o mainOsOo.s
- [尝试四] 将
main.m
选择Os级别
优化生成main.s
,再main.s
选择Os级别
优化生成.s
汇编文件
clang -Os -S -fobjc-arc -emit-llvm main.m -o mainOs.ll
clang -Os -S -fobjc-arc mainOs.ll -o mainOsOs.s
- 内容比较:
5:生成目标文件(机器代码)
生成汇编文件
后,汇编器
以汇编代码
作为输入
,将汇编代码转换
为机器代码
,输出
目标文件(object file
)
clang -fmodules -c main.s -o main.o
file
对比一下main.s
汇编代码和main.o
机器代码:
file main3.m
file main.o
- 此时只是把
当前文件
编译为了机器码
,外部符号
(如printf
)无法识别。
undefined:
表示当前文件
暂时找不到符号
。external:
表示这个符号
是外部可以访问
的。(实现
不在我这,在外部
的某个地方
)
所以当前虽转换
成了机器代码
。但是只是目标文件
,并不能
直接执行
,需要将
所有资源链接
起来,才可以执行
。
6:生成可执行文件(链接)
- 通过
链接器
把编译产生的.o
文件和.dylib
、.a
文件链接关联
起来,生成真正的mach-o可执行文件
clang main.o -o main // 将目标文件转成可执行文件 file main // 查看文件 xcrun nm -nm main // 查看main的符号
- 对比
main.o
目标文件,此时生成的main
文件:
- 从
object
文件变成了executable
可执行文件 - 虽然都有
undefined
,但是可执行文件
中指定了该符号
的来源库
。机器在运行时
,会从相应的库
中取读取
该符号
(printf
)
源代码
到可执行文件
的整个流程
:三:clang插件
1、准备工作
由于国内网络限制,需要借助镜像下载llvm的源码,此处为镜像链接
- 下载LLVM项目
git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm/llvm.git
- 在
LLVM
的projects
目录下下载compiler-rt、libcxx、libcxxabi
cd ../projects git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm/compiler-rt.git git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm/libcxx.git git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm/libcxxabi.git
- 在
Clang
的tools
下安装extra
工具
cd ../tools/clang/tools git clone https://mirrors.tuna.tsinghua.edu.cn/git/llvm/clang-tools-extra.git
2、LLVM编译
由于最新的LLVM只支持cmake
来编译,所以需要安装cmake
安装cmake
- 查看brew是否安装cmake,如果已经安装,则跳过下面步骤
brew list
- 通过
brew安装cmake
brew install cmake
编译LLVM
有两种编译方式:
-
通过
xcode
编译LLVM -
通过
ninja
编译LLVM
通过xcode编译LLVM
- cmake编译成Xcode项目
mkdir build_xcode
cd build_xcode
cmake -G Xcode ../llvm
使用xcode编译Clang
-
选择自动创建Schemes
编译(CMD + B),选择ALL_BUILD Secheme
进行编译,预计1+小时
注:这里通过
ALL_BUILD Secheme
编译会报以下错误The i386 architecture is deprecated. You should update your ARCHS build setting to remove the i386 architecture
,尝试着去解决,但是目前尚未找到好的解决方案(后续会补充)
替代方案:选择手动创建Schemes,然后编译
编译Clang + ClangTooling
即可
通过ninja编译LLVM
- 使用
ninja
进行编译则还需要安装ninja
,使用以下命令安装ninja
brew install ninja
-
在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
3、创建插件
在/llvm/tools/clang/tools
下新建插件CJLPlugin
在/llvm/tools/clang/tools
目录下的CMakeLists.txt
文件,新增add_clang_subdirectory(CJLPlugin)
,此处的CJLPlugin
即为上一步创建的插件名称
在CJLPlugin
目录下新建两个文件,分别是CJLPlugi.cpp
和CMakeLists.txt
,并在CMakeLists.txt
中加上以下代码
//1、通过终端在CJLPlugin目录下创建 touch CJLPlugin.cpp touch CMakeLists.txt //2、CMakeLists.txt中添加以下代码 add_llvm_library( CJLPlugin MODULE BUILDTREE_ONLY CJLPlugin.cpp )
- 接下来利用cmake重新生成Xcode项目,在
build_xcode
目录下执行以下命令
cmake -G Xcode ../llvm
最后可以在LLVM的xcode项目中可以看到Loadable modules
目录下由自定义的CJLPlugin目录了,然后可以在里面编写插件代码了
编写插件代码
在CJLPlugin
目录下的CJLPlugin.cpp
文件中,加入以下代码
// create by CJL // 2020/11/15 #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 CJLPlugin { //第三步:扫描完毕的回调函数 //4、自定义回调类,继承自MatchCallback class CJLMatchCallback: public MatchFinder::MatchCallback { private: //CI传递路径:CJLASTAction类中的CreateASTConsumer方法参数 - CJLConsumer的构造函数 - CJLMatchCallback的私有属性,通过构造函数从CJLASTConsumer构造函数中获取 CompilerInstance &CI; //判断是否是用户源文件 bool isUserSourceCode(const string filename) { //文件名不为空 if (filename.empty()) return false; //非xcode中的源码都认为是用户的 if (filename.find("/Applications/Xcode.app/") == 0) 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: CJLMatchCallback(CompilerInstance &CI) :CI(CI) {} //重写run方法 void run(const MatchFinder::MatchResult &Result) { //通过result获取到相关节点 -- 根据节点标记获取(标记需要与CJLASTConsumer构造方法中一致) const ObjCPropertyDecl *propertyDecl = Result.Nodes.getNodeAs<ObjCPropertyDecl>("objcPropertyDecl"); //判断节点有值,并且是用户文件 if (propertyDecl && isUserSourceCode(CI.getSourceManager().getFilename(propertyDecl->getSourceRange().getBegin()).str()) ) { //15、获取节点的描述信息 ObjCPropertyDecl::PropertyAttributeKind attrKind = propertyDecl->getPropertyAttributes(); //获取节点的类型,并转成字符串 string typeStr = propertyDecl->getType().getAsString(); // cout<<"---------拿到了:"<<typeStr<<"---------"<<endl; //判断应该使用copy,但是没有使用copy if (propertyDecl->getTypeSourceInfo() && isShouldUseCopy(typeStr) && !(attrKind & ObjCPropertyDecl::OBJC_PR_copy)) { //使用CI发警告信息 //通过CI获取诊断引擎 DiagnosticsEngine &diag = CI.getDiagnostics(); //通过诊断引擎 report报告 错误,即抛出异常 /* 错误位置:getBeginLoc 节点开始位置 错误:getCustomDiagID(等级,提示) */ diag.Report(propertyDecl->getBeginLoc(), diag.getCustomDiagID(DiagnosticsEngine::Warning, "%0 - 这个地方推荐使用copy!!"))<< typeStr; } } } }; //第二步:扫描配置完毕 //3、自定义CJLASTConsumer,继承自ASTConsumer,用于监听AST节点的信息 -- 过滤器 class CJLASTConsumer: public ASTConsumer { private: //AST节点的查找过滤器 MatchFinder matcher; //定义回调类对象 CJLMatchCallback callback; public: //构造方法中创建matcherFinder对象 CJLASTConsumer(CompilerInstance &CI) : callback(CI) { //添加一个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 &context) { // cout<<"文件解析完毕!"<<endl; //将文件解析完毕后的上下文context(即AST语法树) 给 matcher matcher.matchAST(context); } }; //2、继承PluginASTAction,实现我们自定义的Action,即自定义AST语法树行为 class CJLASTAction: public PluginASTAction { public: //重载ParseArgs 和 CreateASTConsumer方法 bool ParseArgs(const CompilerInstance &ci, const std::vector<std::string> &args) { return true; } //返回ASTConsumer类型对象,其中ASTConsumer是一个抽象类,即基类 /* 解析给定的插件命令行参数。 - param CI 编译器实例,用于报告诊断。 - return 如果解析成功,则为true;否则,插件将被销毁,并且不执行任何操作。该插件负责使用CompilerInstance的Diagnostic对象报告错误。 */ unique_ptr<ASTConsumer> CreateASTConsumer(CompilerInstance &CI, StringRef iFile) { //返回自定义的CJLASTConsumer,即ASTConsumer的子类对象 /* CI用于: - 判断文件是否使用户的 - 抛出警告 */ return unique_ptr<CJLASTConsumer> (new CJLASTConsumer(CI)); } }; } //第一步:注册插件,并自定义AST语法树Action类 //1、注册插件 static FrontendPluginRegistry::Add<CJLPlugin::CJLASTAction> CJL("CJLPlugin", "This is CJLPlugin");
其原理主要分为三步
- 【第一步】注册插件,并自定义AST语法树Action类
- 继承自
PluginASTAction
,自定义ASTAction,需要重载两个方法ParseArgs
和CreateASTConsumer
,其中的重点方法是CreateASTConsumer
,方法中有个参数CI
即编译实例对象,主要用于以下两个方面-
用于判断文件是否是用户的
-
用于抛出警告
-
- 通过
FrontendPluginRegistry
注册插件,需要关联插件名与自定义的ASTAction类
- 继承自
- 【第二步】扫描配置完毕
-
继承自
ASTConsumer
类,实现自定义的子类CJLASTConsumer
,有两个参数MatchFinder
对象matcher
以及CJLMatchCallback
自定义的回调对象callback
-
实现构造函数,主要是创建
MatchFinder
对象,以及将CI床底给回调对象 -
实现两个回调方法
HandleTopLevelDecl
:解析完一个顶级的声明,就回调一次HandleTranslationUnit
:整个文件都解析完成的回调,将文件解析完毕后的上下文context
(即AST语法树) 给matcher
-
- 【第三步】扫描完毕的回调函数
-
继承自
MatchFinder::MatchCallback
,自定义回调类CJLMatchCallback
-
定义
CompilerInstance
私有属性,用于接收ASTConsumer
类传递过来的CI
信息 -
重写run方法
-
1、通过result,根据节点标记,获取相应节点,此时的标记需要与
CJLASTConsumer
构造方法中一致 -
2、判断节点有值,并且是用户文件即
isUserSourceCode
私有方法 -
3、获取节点的描述信息
-
4、获取节点的类型,并转成字符串
-
5、判断应该使用copy,但是没有使用copy
-
6、通过
CI
获取诊断引擎 -
7、通过诊断引擎报告错误
-
-
所以,综上所述,clang插件开发的流程图如下
然后在终端中测试插件
//命令格式 自己编译的clang文件路径 -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.1.sdk/ -Xclang -load -Xclang 插件(.dyld)路径 -Xclang -add-plugin -Xclang 插件名 -c 源码路径 //例子 /Users/XXX/Desktop/build_xcode/Debug/bin/clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.1.sdk/ -Xclang -load -Xclang /Users/XXXX/Desktop/build_xcode/Debug/lib/CJLPlugin.dylib -Xclang -add-plugin -Xclang CJLPlugin -c /Users/XXXX/Desktop/XXX/XXXX/测试demo/testClang/testClang/ViewController.m
4、Xcode集成插件
加载插件
- 打开测试项目,在
target->Build Settings -> Other C Flags
添加以下内容
-Xclang -load -Xclang (.dylib)动态库路径 -Xclang -add-plugin -Xclang CJLPlugin
设置编译器
-
由于clang插件需要使用对应的版本去加载,如果版本不一致会导致编译失败。
-
在
Build Settings
栏目中新增两项用户定义的设置,分别是CC
和CXX
-
CC
对应的是自己编译的clang
的绝对路径 -
CXX
对应的是自己编译的clang++
的绝对路径
-
接下来在Build Settings
中搜索index
,将Enable Index-Wihle-Building Functionality
的Default
改为NO
最后,重新编译测试项目,会出现下面的效果
注意