深入剖析 iOS 编译 Clang / LLVM

前言

2000年,伊利诺伊大学厄巴纳-香槟分校(University of Illinois at Urbana-Champaign 简称UIUC)这所享有世界声望的一流公立研究型大学的 Chris Lattner(他的 twitter @clattner_llvm ) 开发了一个叫作 Low Level Virtual Machine 的编译器开发工具套件,后来涉及范围越来越大,可以用于常规编译器,JIT编译器,汇编器,调试器,静态分析工具等一系列跟编程语言相关的工作,于是就把简称 LLVM 这个简称作为了正式的名字。Chris Lattner 后来又开发了 Clang,使得 LLVM 直接挑战 GCC 的地位。2012年,LLVM 获得美国计算机学会 ACM 的软件系统大奖,和 UNIX,WWW,TCP/IP,Tex,JAVA 等齐名。

Chris Lattner 生于 1978 年,2005年加入苹果,将苹果使用的 GCC 全面转为 LLVM。2010年开始主导开发 Swift 语言。

iOS 开发中 Objective-C 是 Clang / LLVM 来编译的。

swift 是 Swift / LLVM,其中 Swift 前端会多出 SIL optimizer,它会把 .swift 生成的中间代码 .sil 属于 High-Level IR, 因为 swift 在编译时就完成了方法绑定直接通过地址调用属于强类型语言,方法调用不再是像OC那样的消息发送,这样编译就可以获得更多的信息用在后面的后端优化上。

LLVM是一个模块化和可重用的编译器和工具链技术的集合,Clang 是 LLVM 的子项目,是 C,C++ 和 Objective-C 编译器,目的是提供惊人的快速编译,比 GCC 快3倍,其中的 clang static analyzer 主要是进行语法分析,语义分析和生成中间代码,当然这个过程会对代码进行检查,出错的和需要警告的会标注出来。LLVM 核心库提供一个优化器,对流行的 CPU 做代码生成支持。lld 是 Clang / LLVM 的内置链接器,clang 必须调用链接器来产生可执行文件。

LLVM 比较有特色的一点是它能提供一种代码编写良好的中间表示 IR,这意味着它可以作为多种语言的后端,这样就能够提供语言无关的优化同时还能够方便的针对多种 CPU 的代码生成。

LLVM 还用在 Gallium3D 中进行 JIT 优化,Xorg 中的 pixman 也有考虑使用 LLVM 优化执行速度, LLVM-Lua 用LLVM 来编译 lua 代码, gpuocelot 使用 LLVM 可以让 CUDA 程序无需重新编译就能够在多种 CPU 机器上跑。

这里是 Clang 官方详细文档: Welcome to Clang’s documentation! — Clang 4.0 documentation

这篇是对 LLVM 架构的一个概述: The Architecture of Open Source Applications

将编译器之前对于编译的前世今生也是需要了解的,比如回答下这个问题,编译器程序是用什么编译的?看看 《linkers and loaders》 这本书就知道了。

编译流程

在列出完整步骤之前可以先看个简单例子。看看是如何完成一次编译的。

#import 
#define DEFINEEight 8

int main(){
    @autoreleasepool {
        int eight = DEFINEEight;
        int six = 6;
        NSString* site = [[NSString alloc] initWithUTF8String:"starming"];
        int rank = eight + six;
        NSLog(@"%@ rank %d", site, rank);
    }
    return 0;
}

在命令行输入

clang -ccc-print-phases main.m

可以看到编译源文件需要的几个不同的阶段

0: input, "main.m", objective-c
1: preprocessor, {0}, objective-c-cpp-output
2: compiler, {1}, ir
3: backend, {2}, assembler
4: assembler, {3}, object
5: linker, {4}, image
6: bind-arch, "x86_64", {5}, image

这样能够了解到过程和重要的信息。
查看oc的c实现可以使用如下命令

clang -rewrite-objc main.m

查看操作内部命令,可以使用 -### 命令

clang -### main.m -o main

想看清clang的全部过程,可以先通过-E查看clang在预处理处理这步做了什么。

clang -E main.m

执行完后可以看到文件

# 1 "/System/Library/Frameworks/Foundation.framework/Headers/FoundationLegacySwiftCompatibility.h" 1 3
# 185 "/System/Library/Frameworks/Foundation.framework/Headers/Foundation.h" 2 3
# 2 "main.m" 2

int main(){
    @autoreleasepool {
        int eight = 8;
        int six = 6;
        NSString* site = [[NSString alloc] initWithUTF8String:"starming"];
        int rank = eight + six;
        NSLog(@"%@ rank %d", site, rank);
    }
    return 0;
}

这个过程的处理包括宏的替换,头文件的导入,以及类似#if的处理。预处理完成后就会进行词法分析,这里会把代码切成一个个 Token,比如大小括号,等于号还有字符串等。

clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m

然后是语法分析,验证语法是否正确,然后将所有节点组成抽象语法树 AST 。

clang -fmodules -fsyntax-only -Xclang -ast-dump main.m

完成这些步骤后就可以开始IR中间代码的生成了,CodeGen 会负责将语法树自顶向下遍历逐步翻译成 LLVM IR,IR 是编译过程的前端的输出后端的输入。

clang -S -fobjc-arc -emit-llvm main.m -o main.ll

这里 LLVM 会去做些优化工作,在 Xcode 的编译设置里也可以设置优化级别-01,-03,-0s,还可以写些自己的 Pass,官方有比较完整的 Pass 教程: Writing an LLVM Pass — LLVM 5 documentation 。

clang -O3 -S -fobjc-arc -emit-llvm main.m -o main.ll

Pass 是 LLVM 优化工作的一个节点,一个节点做些事,一起加起来就构成了 LLVM 完整的优化和转化。

如果开启了 bitcode 苹果会做进一步的优化,有新的后端架构还是可以用这份优化过的 bitcode 去生成。

clang -emit-llvm -c main.m -o main.bc

生成汇编

clang -S -fobjc-arc main.m -o main.s

生成目标文件

clang -fmodules -c main.m -o main.o

生成可执行文件,这样就能够执行看到输出结果

clang main.o -o main
执行
./main
输出
starming rank 14

下面是完整步骤:

  • 编译信息写入辅助文件,创建文件架构 .app 文件
  • 处理文件打包信息
  • 执行 CocoaPod 编译前脚本,checkPods Manifest.lock
  • 编译.m文件,使用 CompileC 和 clang 命令
  • 链接需要的 Framework
  • 编译 xib
  • 拷贝 xib ,资源文件
  • 编译 ImageAssets
  • 处理 info.plist
  • 执行 CocoaPod 脚本
  • 拷贝标准库
  • 创建 .app 文件和签名

Clang 编译 .m 文件

在 Xcode 编译过后,可以通过 Show the report navigator 里对应 target 的 build 中查看每个 .m 文件的 clang 参数信息,这些参数都是通过Build Setting。

具体拿编译 AFSecurityPolicy.m 的信息来看看。首先对任务进行描述。

CompileC DerivedData path/AFSecurityPolicy.o AFNetworking/AFNetworking/AFSecurityPolicy.m normal x86_64 objective-c com.apple.compilers.llvm.clang.1_0.compiler

接下来对会更新工作路径,同时设置 PATH

cd /Users/didi/Documents/Demo/GitHub/GCDFetchFeed/GCDFetchFeed/Pods
    export LANG=en_US.US-ASCII
    export PATH="/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/usr/bin:/Applications/Xcode.app/Contents/Developer/usr/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"

接下来就是实际的编译命令

clang -x objective-c -arch x86_64 -fmessage-length=0 -fobjc-arc... -Wno-missing-field-initializers ... -DDEBUG=1 ... -isysroot iPhoneSimulator10.1.sdk -fasm-blocks ... -I -F -c AFSecurityPolicy.m -o AFSecurityPolicy.o

clang 命令参数

-x 编译语言比如objective-c
-arch 编译的架构,比如arm7
-f 以-f开头的。
-W 以-W开头的,可以通过这些定制编译警告
-D 以-D开头的,指的是预编译宏,通过这些宏可以实现条件编译
-iPhoneSimulator10.1.sdk 编译采用的iOS SDK版本
-I 把编译信息写入指定的辅助文件
-F 需要的Framework
-c 标识符指明需要运行预处理器,语法分析,类型检查,LLVM生成优化以及汇编代码生成.o文件
-o 编译结果

构建 Target

编译工程中的第三方依赖库后会构建我们程序的 target,会按顺序输出如下的信息:

Create product structure
Process product packaging
Run custom shell script 'Check Pods Manifest.lock'
Compile ... 各个项目中的.m文件
Link /Users/... 路径
Copy ... 静态文件
Compile asset catalogs
Compile Storyboard file ...
Process info.plist
Link Storyboards
Run custom shell script 'Embed Pods Frameworks'
Run custom shell script 'Copy Pods Resources'
...
Touch GCDFetchFeed.app
Sign GCDFetchFeed.app

从这些信息可以看出在这些步骤中会分别调用不同的命令行工具来执行。

Target 在 Build 过程的控制

在 Xcode 的 Project editor 中的 Build Setting,Build Phases 和 Build Rules 能够控制编译的过程。

Build Phases

构建可执行文件的规则。指定 target 的依赖项目,在 target build 之前需要先 build 的依赖。在 Compile Source 中指定所有必须编译的文件,这些文件会根据 Build Setting 和 Build Rules 里的设置来处理。

在 Link Binary With Libraries 里会列出所有的静态库和动态库,它们会和编译生成的目标文件进行链接。

build phase 还会把静态资源拷贝到 bundle 里。

可以通过在 build phases 里添加自定义脚本来做些事情,比如像 CocoaPods 所做的那样。

Build Rules

指定不同文件类型如何编译。每条 build rule 指定了该类型如何处理以及输出在哪。可以增加一条新规则对特定文件类型添加处理方法。

Build Settings

在 build 的过程中各个阶段的选项的设置。

pbxproj工程文件

build 过程控制的这些设置都会被保存在工程文件 .pbxproj 里。在这个文件中可以找 rootObject 的 ID 值

rootObject = 3EE311301C4E1F0800103FA3 /* Project object */;

然后根据这个 ID 找到 main 工程的定义。

/* Begin PBXProject section */
        3EE311301C4E1F0800103FA3 /* Project object */ = {
            isa = PBXProject;
            ...
/* End PBXProject section */

在 targets 里会指向各个 taget 的定义

targets = (
    3EE311371C4E1F0800103FA3 /* GCDFetchFeed */,
    3EE311501C4E1F0800103FA3 /* GCDFetchFeedTests */,
    3EE3115B1C4E1F0800103FA3 /* GCDFetchFeedUITests */,
);

顺着这些 ID 就能够找到更详细的定义地方。比如我们通过 GCDFetchFeed 这个 target 的 ID 找到定义如下:

3EE311371C4E1F0800103FA3 /* GCDFetchFeed */ = {
    isa = PBXNativeTarget;
    buildConfigurationList = 3EE311651C4E1F0800103FA3 /* configuration list for PBXNativeTarget "GCDFetchFeed" 
    buildPhases = (
        9527AA01F4AAE11E18397E0C /* Check Pods st.lock */,
        3EE311341C4E1F0800103FA3 /* Sources */,
        3EE311351C4E1F0800103FA3 /* Frameworks */,
        3EE311361C4E1F0800103FA3 /* Resources */,
        C3DDA7C46C0308459A18B7D9 /* Embed Pods Frameworks 
        DD33A716222617FAB49F1472 /* Copy Pods Resources 
    );
    buildRules = (
    );
    dependencies = (
    );
    name = GCDFetchFeed;
    productName = GCDFetchFeed;
    productReference = 3EE311381C4E1F0800103FA3 /* chFeed.app */;
    productType = "com.apple.product-type.application";
};

这个里面又有更多的 ID 可以得到更多的定义,其中 buildConfigurationList 指向了可用的配置项,包含 Debug 和 Release。可以看到还有 buildPhases,buildRules 和 dependencies 都能够通过这里索引找到更详细的定义。

接下来详细的看看 Clang 所做的事情吧。

Clang Static Analyzer静态代码分析

可以在 llvm/clang/ Source Tree - Woboq Code Browser 上查看 Clang 的代码。

Youtube上一个教程:The Clang AST - a Tutorial - YouTube

静态分析前会对源代码分词成 Token,这个过程称为词法分析(Lexical Analysis),在 TokensKind.def 里有 Clang 定义的所有 Token。通过下面的命令可以输出所有 token 和所在文件具体位置

clang -fmodules -E -Xclang -dump-tokens main.m

结果如下

annot_module_include '#import 
int 'int'    [StartOfLine]  Loc=
identifier 'main'    [LeadingSpace] Loc=
l_paren '('     Loc=
r_paren ')'     Loc=
l_brace '{'  [LeadingSpace] Loc=
at '@'   [StartOfLine] [LeadingSpace]   Loc=
identifier 'autoreleasepool'        Loc=
l_brace '{'  [LeadingSpace] Loc=
identifier 'NSString'    [StartOfLine] [LeadingSpace]   Loc=
star '*'     [LeadingSpace] Loc=
identifier 'a'      Loc=
equal '='    [LeadingSpace] Loc=
at '@'   [LeadingSpace] Loc=
string_literal '"aaa"'      Loc=
semi ';'        Loc=
identifier 'NSLog'   [StartOfLine] [LeadingSpace]   Loc=
l_paren '('     Loc=
at '@'      Loc=
string_literal '"hi %@"'        Loc=
comma ','       Loc=
identifier 'a'      Loc=
r_paren ')'     Loc=
semi ';'        Loc=
r_brace '}'  [StartOfLine] [LeadingSpace]   Loc=
return 'return'  [StartOfLine] [LeadingSpace]   Loc=
numeric_constant '0'     [LeadingSpace] Loc=
semi ';'        Loc=
r_brace '}'  [StartOfLine]  Loc=
eof ''      Loc=

可以获得每个 token 的类型,值还有类似 StartOfLine 的位置类型和 Loc= 这个样的具体位置。

接着进行语法分析(Semantic Analysis)将 token 先按照语法组合成语义生成 VarDecl 节点,然后将这些节点按照层级关系构成抽象语法树 Abstract Syntax Tree (AST)。打印语法树的命令

clang -fmodules -fsyntax-only -Xclang -ast-dump main.m

输出结果

TranslationUnitDecl 0x7fa80f018ad0 <> 
|-TypedefDecl 0x7fa80f018fc8 <>  implicit __int128_t '__int128'
| `-BuiltinType 0x7fa80f018d20 '__int128'
|-TypedefDecl 0x7fa80f019028 <>  implicit __uint128_t 'unsigned __int128'
| `-BuiltinType 0x7fa80f018d40 'unsigned __int128'
|-TypedefDecl 0x7fa80f0190b8 <>  implicit SEL 'SEL *'
| `-PointerType 0x7fa80f019080 'SEL *'
|   `-BuiltinType 0x7fa80f018f30 'SEL'
|-TypedefDecl 0x7fa80f019198 <>  implicit id 'id'
| `-ObjCObjectPointerType 0x7fa80f019140 'id' imported
|   `-ObjCObjectType 0x7fa80f019110 'id' imported
|-TypedefDecl 0x7fa80f019278 <>  implicit Class 'Class'
| `-ObjCObjectPointerType 0x7fa80f019220 'Class'
|   `-ObjCObjectType 0x7fa80f0191f0 'Class'
|-ObjCInterfaceDecl 0x7fa80f0192c8 <>  implicit Protocol
|-TypedefDecl 0x7fa80f019618 <>  implicit __NSConstantString 'struct __NSConstantString_tag'
| `-RecordType 0x7fa80f019430 'struct __NSConstantString_tag'
|   `-Record 0x7fa80f019390 '__NSConstantString_tag'
|-TypedefDecl 0x7fa80f0196a8 <>  implicit __builtin_ms_va_list 'char *'
| `-PointerType 0x7fa80f019670 'char *'
|   `-BuiltinType 0x7fa80f018b60 'char'
|-TypedefDecl 0x7fa80f047978 <>  implicit __builtin_va_list 'struct __va_list_tag [1]'
| `-ConstantArrayType 0x7fa80f047920 'struct __va_list_tag [1]' 1 
|   `-RecordType 0x7fa80f0197a0 'struct __va_list_tag'
|     `-Record 0x7fa80f0196f8 '__va_list_tag'
|-ImportDecl 0x7fa80f0486b0  col:1 implicit Foundation
|-FunctionDecl 0x7fa80f048738  line:5:5 main 'int ()'
| `-CompoundStmt 0x7fa80f393998 
|   |-ObjCAutoreleasePoolStmt 0x7fa80f393950 
|   | `-CompoundStmt 0x7fa80f393928 
|   |   |-DeclStmt 0x7fa80f3a3b38 
|   |   | `-VarDecl 0x7fa80f3a3580  col:19 used a 'NSString *' cinit
|   |   |   `-ObjCStringLiteral 0x7fa80f3a3648  'NSString *'
|   |   |     `-StringLiteral 0x7fa80f3a3618  'char [4]' lvalue "aaa"
|   |   `-CallExpr 0x7fa80f3938c0  'void'
|   |     |-ImplicitCastExpr 0x7fa80f3938a8  'void (*)(id, ...)' 
|   |     | `-DeclRefExpr 0x7fa80f3a3b50  'void (id, ...)' Function 0x7fa80f3a3670 'NSLog' 'void (id, ...)'
|   |     |-ImplicitCastExpr 0x7fa80f3938f8  'id':'id' 
|   |     | `-ObjCStringLiteral 0x7fa80f393800  'NSString *'
|   |     |   `-StringLiteral 0x7fa80f3a3bb8  'char [6]' lvalue "hi %@"
|   |     `-ImplicitCastExpr 0x7fa80f393910  'NSString *' 
|   |       `-DeclRefExpr 0x7fa80f393820  'NSString *' lvalue Var 0x7fa80f3a3580 'a' 'NSString *'
|   `-ReturnStmt 0x7fa80f393980 
|     `-IntegerLiteral 0x7fa80f393960  'int' 0
`-

TranslationUnitDecl 是根节点,表示一个源文件。Decl 表示一个声明,Expr 表示表达式,Literal 表示字面量是特殊的 Expr,Stmt 表示语句。

clang 静态分析是通过建立分析引擎和 checkers 所组成的架构,这部分功能可以通过 clang —analyze 命令方式调用。clang static analyzer 分为 analyzer core 分析引擎和 checkers 两部分,所有 checker 都是基于底层分析引擎之上,通过分析引擎提供的功能能够编写新的 checker。

可以通过 clang --analyze -Xclang -analyzer-checker-help 来列出当前 clang 版本下所有 checker。如果想编写自己的 checker,可以在 clang 项目的 lib / StaticAnalyzer / Checkers 目录下找到实例参考,比如 ObjCUnusedIVarsChecker.cpp 用来检查未使用定义过的变量。这种方式能够方便用户扩展对代码检查规则或者对 bug 类型进行扩展,但是这种架构也有不足,每执行完一条语句后,分析引擎会遍历所有 checker 中的回调函数,所以 checker 越多,速度越慢。通过 clang -cc1 -analyzer-checker-help 可以列出能调用的 checker,下面是常用 checker

debug.ConfigDumper              Dump config table
debug.DumpCFG                   Display Control-Flow Graphs
debug.DumpCallGraph             Display Call Graph
debug.DumpCalls                 Print calls as they are traversed by the engine
debug.DumpDominators            Print the dominance tree for a given CFG
debug.DumpLiveVars              Print results of live variable analysis
debug.DumpTraversal             Print branch conditions as they are traversed by the engine
debug.ExprInspection            Check the analyzer's understanding of expressions
debug.Stats                     Emit warnings with analyzer statistics
debug.TaintTest                 Mark tainted symbols as such.
debug.ViewCFG                   View Control-Flow Graphs using GraphViz
debug.ViewCallGraph             View Call Graph using GraphViz
debug.ViewExplodedGraph         View Exploded Graphs using GraphViz

这些 checker 里最常用的是 DumpCFG,DumpCallGraph,DumpLiveVars 和 DumpViewExplodedGraph。

clang static analyzer 引擎大致分为 CFG,MemRegion,SValBuilder,ConstraintManager 和 ExplodedGraph 几个模块。clang static analyzer 本质上就是 path-sensitive analysis,要很好的理解 clang static analyzer 引擎就需要对 Data Flow Analysis 有所了解,包括迭代数据流分析,path-sensitive,path-insensitive ,flow-sensitive等。

编译的概念(词法->语法->语义->IR->优化->CodeGen)在 clang static analyzer 里到处可见,例如 Relaxed Live Variables Analysis 可以减少分析中的内存消耗,使用 mark-sweep 实现 Dead Symbols 的删除。

clang static analyzer 提供了很多辅助方法,比如 SVal.dump(),MemRegion.getString 以及 Stmt 和 Dcel 提供的 dump 方法。Clang 抽象语法树 Clang AST 常见的 API 有 Stmt,Decl,Expr 和 QualType。在编写 checker 时会遇到 AST 的层级检查,这时有个很好的接口 StmtVisitor,这个接口类似 RecursiveASTVisitor。

整个 clang static analyzer 的入口是 AnalysisConsumer,接着会调 HandleTranslationUnit() 方法进行 AST 层级进行分析或者进行 path-sensitive 分析。默认会按照 inline 的 path-sensitive 分析,构建 CallGraph,从顶层 caller 按照调用的关系来分析,具体是使用的 WorkList 算法,从 EntryBlock 开始一步步的模拟,这个过程叫做 intra-procedural analysis(IPA)。这个模拟过程还需要对内存进行模拟,clang static analyzer 的内存模型是基于《A Memory Model for Static Analysis of C Programs》这篇论文而来,pdf地址:http://lcs.ios.ac.cn/~xuzb/canalyze/memmodel.pdf 在clang里的具体实现代码可以查看这两个文件 MemRegion.h和 RegionStore.cpp 。

下面举个简单例子看看 clang static analyzer 是如何对源码进行模拟的。

int main()
{
    int a;
    int b = 10;
    a = b;
    return a;
}

对应的 AST 以及 CFG

#----------------AST-------------------
# clang -cc1 -ast-dump
TranslationUnitDecl 0xc75b450 <> 
|-TypedefDecl 0xc75b740 <>  implicit __builtin_va_list 'char *'
`-FunctionDecl 0xc75b7b0  line:1:5 main 'int (void)'
  `-CompoundStmt 0xc75b978 
    |-DeclStmt 0xc75b870 
    | `-VarDecl 0xc75b840  col:6 used a 'int'
    |-DeclStmt 0xc75b8d8 
    | `-VarDecl 0xc75b890  col:6 used b 'int' cinit
    |   `-IntegerLiteral 0xc75b8c0  'int' 10

<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< a = b <<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<
    |-BinaryOperator 0xc75b928  'int' lvalue '='
    | |-DeclRefExpr 0xc75b8e8  'int' lvalue Var 0xc75b840 'a' 'int'
    | `-ImplicitCastExpr 0xc75b918  'int' 
    |   `-DeclRefExpr 0xc75b900  'int' lvalue Var 0xc75b890 'b' 'int'
<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<

    `-ReturnStmt 0xc75b968 
      `-ImplicitCastExpr 0xc75b958  'int' 
        `-DeclRefExpr 0xc75b940  'int' lvalue Var 0xc75b840 'a' 'int'
#----------------CFG-------------------
# clang -cc1 -analyze -analyzer-checker=debug.DumpCFG
int main()
 [B2 (ENTRY)]
   Succs (1): B1

 [B1]
   1: int a;
   2: 10
   3: int b = 10;
   4: b
   5: [B1.4] (ImplicitCastExpr, LValueToRValue, int)
   6: a
   7: [B1.6] = [B1.5]
   8: a
   9: [B1.8] (ImplicitCastExpr, LValueToRValue, int)
  10: return [B1.9];
   Preds (1): B2
   Succs (1): B0

 [B0 (EXIT)]
   Preds (1): B1

CFG 将程序拆得更细,能够将执行的过程表现的更直观些,为了避免路径爆炸,函数 inline 的条件会设置的比较严格,函数 CFG 块多时不会进行 inline 分析,模拟栈深度超过一定值不会进行 inline 分析,这个默认是5。

在MRC使用的是CFG这样的执行路径模拟,ARC就没有了,举个例子,没有全部条件都返回,CFG就会报错,而AST就不会。

官方 AST 相关文档

  • http://clang.llvm.org/docs/Tooling.html
  • http://clang.llvm.org/docs/IntroductionToTheClangAST.html
  • http://clang.llvm.org/docs/RAVFrontendAction.html
  • http://clang.llvm.org/docs/LibTooling.html
  • http://clang.llvm.org/docs/LibASTMatchers.html

CodeGen 生成 IR 代码

将语法树翻译成 LLVM IR 中间代码,做为 LLVM Backend 输入的桥接语言。这样做的好处在前言里也提到了,方便 LLVM Backend 给多语言做相同的优化,做到语言无关。

这个过程中还会跟 runtime 桥接。

  • 各种类,方法,成员变量等的结构体的生成,并将其放到对应的Mach-O的section中。
  • Non-Fragile ABI 合成 OBJC_IVAR_$_ 偏移值常量。
  • ObjCMessageExpr 翻译成相应版本的 objc_msgSend,super 翻译成 objc_msgSendSuper。
  • strong,weak,copy,atomic 合成 @property 自动实现 setter 和 getter。
  • @synthesize 的处理。
  • 生成 block_layout 数据结构
  • __block 和 __weak
  • _block_invoke
  • ARC 处理,插入 objc_storeStrong 和 objc_storeWeak 等 ARC 代码。ObjCAutoreleasePoolStmt 转 objc_autorealeasePoolPush / Pop。自动添加 [super dealloc]。给每个 ivar 的类合成 .cxx_destructor 方法自动释放类的成员变量。

Clang Attributes

attribute(xx) 的语法格式出现,是 Clang 提供的一些能够让开发者在编译过程中参与一些源码控制的方法。下面列一些会用到的用法:

attribute((format(NSString, F, A))) 格式化字符串

可以查看 NSLog 的用法

FOUNDATION_EXPORT void NSLog(NSString *format, ...) NS_FORMAT_FUNCTION(1,2) NS_NO_TAIL_CALL;

// Marks APIs which format strings by taking a format string and optional varargs as arguments
#if !defined(NS_FORMAT_FUNCTION)
    #if (__GNUC__*10+__GNUC_MINOR__ >= 42) && (TARGET_OS_MAC || TARGET_OS_EMBEDDED)
    #define NS_FORMAT_FUNCTION(F,A) __attribute__((format(__NSString__, F, A)))
    #else
    #define NS_FORMAT_FUNCTION(F,A)
    #endif
#endif

attribute((deprecated(s))) 版本弃用提示

在编译过程中能够提示开发者该方法或者属性已经被弃用

- (void)preMethod:( NSString *)string __attribute__((deprecated("preMethod已经被弃用,请使用newMethod")));
- (void)deprecatedMethod DEPRECATED_ATTRIBUTE; //也可以直接使用DEPRECATED_ATTRIBUTE这个系统定义的宏

attribute((availability(os,introduced=m,deprecated=n, obsoleted=o,message="" VA_ARGS))) 指明使用版本范围

os 指系统的版本,m 指明引入的版本,n 指明过时的版本,o 指完全不用的版本,message 可以写入些描述信息。

- (void)method __attribute__((availability(ios,introduced=3_0,deprecated=6_0,obsoleted=7_0,message="iOS3到iOS7版本可用,iOS7不能用")));

attribute((unavailable(…))) 方法不可用提示

这个会在编译过程中告知方法不可用,如果使用了还会让编译失败。

attribute((unused))

没有被使用也不报警告

attribute((warn_unused_result))

不使用方法的返回值就会警告,目前 swift3 已经支持该特性了。oc中也可以通过定义这个attribute来支持。

attribute((availability(swift, unavailable, message=_msg)))

OC 的方法不能在 Swift 中使用。

attribute((cleanup(…))) 作用域结束时自动执行一个指定方法

作用域结束包括大括号结束,return,goto,break,exception 等情况。这个动作是先于这个对象的 dealloc 调用的。

Reactive Cocoa 中有个比较好的使用范例,@onExit 这个宏,定义如下:

#define onExit \
    rac_keywordify \
    __strong rac_cleanupBlock_t metamacro_concat(rac_exitBlock_, __LINE__) __attribute__((cleanup(rac_executeCleanupBlock), unused)) = ^

static inline void rac_executeCleanupBlock (__strong rac_cleanupBlock_t *block) {
    (*block)();
}

这样可以在就可以很方便的把需要成对出现的代码写在一起了。同样可以在 Reactive Cocoa 看到其使用

if (property != NULL) {
        rac_propertyAttributes *attributes = rac_copyPropertyAttributes(property);
        if (attributes != NULL) {
            @onExit {
                free(attributes);
            };

            BOOL isObject = attributes->objectClass != nil || strstr(attributes->type, @encode(id)) == attributes->type;
            BOOL isProtocol = attributes->objectClass == NSClassFromString(@"Protocol");
            BOOL isBlock = strcmp(attributes->type, @encode(void(^)())) == 0;
            BOOL isWeak = attributes->weak;

            shouldAddDeallocObserver = isObject && isWeak && !isBlock && !isProtocol;
        }
    }

可以看出 attributes 的设置和释放都在一起使得代码的可读性得到了提高。

attribute((overloadable)) 方法重载

能够在 c 的函数上实现方法重载。即同样的函数名函数能够对不同参数在编译时能够自动根据参数来选择定义的函数

__attribute__((overloadable)) void printArgument(int number){
    NSLog(@"Add Int %i", number);
}

__attribute__((overloadable)) void printArgument(NSString *number){
    NSLog(@"Add NSString %@", number);
}

__attribute__((overloadable)) void printArgument(NSNumber *number){
    NSLog(@"Add NSNumber %@", number);
}

attribute((objc_designated_initializer)) 指定内部实现的初始化方法

  • 如果是 objc_designated_initializer 初始化的方法必须调用覆盖实现 super 的 objc_designated_initializer 方法。
  • 如果不是 objc_designated_initializer 的初始化方法,但是该类有 objc_designated_initializer 的初始化方法,那么必须调用该类的 objc_designated_initializer 方法或者非 objc_designated_initializer 方法,而不能够调用 super 的任何初始化方法。

attribute((objc_subclassing_restricted)) 指定不能有子类

相当于 Java 里的 final 关键字,如果有子类继承就会出错。

attribute((objc_requires_super)) 子类继承必须调用 super

声明后子类在继承这个方法时必须要调用 super,否则会出现编译警告,这个可以定义一些必要执行的方法在 super 里提醒使用者这个方法的内容时必要的。

attribute((const)) 重复调用相同数值参数优化返回

用于数值类型参数的函数,多次调用相同的数值型参数,返回是相同的,只在第一次是需要进行运算,后面只返回第一次的结果,这时编译器的一种优化处理方式。

attribute((constructor(PRIORITY))) 和 attribute((destructor(PRIORITY)))

PRIORITY 是指执行的优先级,main 函数执行之前会执行 constructor,main 函数执行后会执行 destructor,+load 会比 constructor 执行的更早点,因为动态链接器加载 Mach-O 文件时会先加载每个类,需要 +load 调用,然后才会调用所有的 constructor 方法。

通过这个特性,可以做些比较好玩的事情,比如说类已经 load 完了,是不是可以在 constructor 中对想替换的类进行替换,而不用加在特定类的 +load 方法里。

Clang 警告处理

先看看这个

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
        sizeLabel = [self sizeWithFont:font constrainedToSize:size lineBreakMode:NSLineBreakByWordWrapping];
#pragma clang diagnostic pop

如果没有#pragma clang 这些定义,会报出 sizeWithFont 的方法会被废弃的警告,这个加上这个方法当然是为了兼容老系统,加上 ignored “-Wdeprecated-declarations” 的作用是忽略这个警告。通过 clang diagnostic push/pop 可以灵活的控制代码块的编译选项。

使用 libclang 来进行语法分析

使用 libclang 里面提供的方法对源文件进行语法分析,分析语法树,遍历语法树上每个节点。

使用这个库可以直接使用 C 的 API,官方也提供了 python binding。还有开源的 node-js / ruby binding,还有 Objective-C的开源库 GitHub - macmade/ClangKit: ClangKit provides an Objective-C frontend to LibClang. Source tokenization, diagnostics and fix-its are actually implemented. 。

写个 python 脚本来调用 clang

pip install clang

#!/usr/bin/python
# vim: set fileencoding=utf-8

import clang.cindex
import asciitree
import sys

def node_children(node):
    return (c for c in node.get_children() if c.location.file == sys.argv[1])

def print_node(node):
    text = node.spelling or node.displayname
    kind = str(node.kind)[str(node.kind).index('.')+1:]
    return '{} {}'.format(kind, text)

if len(sys.argv) != 2:
    print("Usage: dump_ast.py [header file name]")
    sys.exit()

clang.cindex.Config.set_library_file('/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/libclang.dylib')
index = clang.cindex.Index.create()
translation_unit = index.parse(sys.argv[1], ['-x', 'objective-c'])

print asciitree.draw_tree(translation_unit.cursor,
                          lambda n: list(n.get_children()),
                          lambda n: "%s (%s)" % (n.spelling or n.displayname, str(n.kind).split(".")[1]))

基于语法树的分析还可以针对字符串做加密。

LibTooling 对语法树完全的控制

因为 LibTooling 能够完全控制语法树,那么可以做的事情就非常多了。

  • 可以改变 clang 生成代码的方式。
  • 增加更强的类型检查。
  • 按照自己的定义进行代码的检查分析。
  • 对源码做任意类型分析,甚至重写程序。
  • 给 clang 添加一些自定义的分析,创建自己的重构器。
  • 基于现有代码做出大量的修改。
  • 基于工程生成相关图形或文档。
  • 检查命名是否规范,还能够进行语言的转换,比如把 OC 语言转成JS或者 Swift 。

官方有个文档开发者可以按照这个里面的说明去构造 LLVM,clang 和其工具: Tutorial for building tools using LibTooling and LibASTMatchers — Clang 4.0 documentation

按照说明编译完成后进入 LLVM 的目录 ~/llvm/tools/clang/tools/ 在这了可以创建自己的 clang 工具。这里有个范例: GitHub - objcio/issue-6-compiler-tool: Example code for a standalone clang/llvm tool. 可以直接 make 成一个二进制文件。

下面是检查 target 对象中是否有对应的 action 方法存在检查的一个例子

@interface Observer
+ (instancetype)observerWithTarget:(id)target action:(SEL)selector;
@end
//查找消息表达式,observer 作为接受者,observerWithTarget:action: 作为 selector,检查 target 中是否存在相应的方法。
virtual bool VisitObjCMessageExpr(ObjCMessageExpr *E) {
  if (E->getReceiverKind() == ObjCMessageExpr::Class) {
    QualType ReceiverType = E->getClassReceiver();
    Selector Sel = E->getSelector();
    string TypeName = ReceiverType.getAsString();
    string SelName = Sel.getAsString();
    if (TypeName == "Observer" && SelName == "observerWithTarget:action:") {
      Expr *Receiver = E->getArg(0)->IgnoreParenCasts();
      ObjCSelectorExpr* SelExpr = cast(E->getArg(1)->IgnoreParenCasts());
      Selector Sel = SelExpr->getSelector();
      if (const ObjCObjectPointerType *OT = Receiver->getType()->getAs()) {
        ObjCInterfaceDecl *decl = OT->getInterfaceDecl();
        if (! decl->lookupInstanceMethod(Sel)) {
          errs() << "Warning: class " << TypeName << " does not implement selector " << Sel.getAsString() << "\n";
          SourceLocation Loc = E->getExprLoc();
          PresumedLoc PLoc = astContext->getSourceManager().getPresumedLoc(Loc);
          errs() << "in " << PLoc.getFilename() << " <" << PLoc.getLine() << ":" << PLoc.getColumn() << ">\n";
        }
      }
    }
  }
  return true;
}

ClangPlugin

通过自己写个插件,比如上面写的 LibTooling 的 clang 工具,可以将这个插件动态的加载到编译器中,对编译进行控制,可以在 LLVM 的这个目录下查看一些范例 llvm/tools/clang/tools

动态化方案 DynamicCocoa 中就是使用了一个将 OC 源码转 JS 的插件来进行代码的转换,JSPatch 是直接手写 JS 而没有转换的过程,所以也就没有多出这一步,而鹅厂的OCS更猛,直接在端内写了个编译器。在 C 函数的调用上孙源有个 slides 可以看看: Calling Conventions in Cocoa by sunnyxx bang 也有篇文章: 如何动态调用 C 函数 « bang’s blog 。

这三个方案作者都分别写了文章详细说明其实现方案。

  • JSPatch实现原理详解 « bang’s blog
  • DynamicCocoa:滴滴 iOS 动态化方案的诞生与起航
  • OCS——史上最疯狂的iOS动态化方案 -

滴滴的王康在做瘦身时也实现了一个自定义的 clang 插件,具体自定义插件的实现可以查看他的这文章 《基于clang插件的一种iOS包大小瘦身方案》

那么我们要自己动手做应该怎么入门呢,除了本身带的范例外还有些教程可以看看。

  • 收集一些如何使用 clang 库的例子:GitHub - loarabia/Clang-tutorial: A collection of code samples showing usage of clang and llvm as a library
  • 在 Xcode 中添加 clang 静态分析自定义 checks: Running the analyzer within Xcode
  • 将 LLVM C 的 API 用 swift 来包装: GitHub - harlanhaskins/LLVMSwift: A Swifty wrapper for the LLVM C API version 3.9.1

编译后生成的二进制内容 Link Map File

在 Build Settings 里设置 Write Link Map File 为 Yes 后每次编译都会在指定目录生成这样一个文件。文件内容包含 Object files,Sections,Symbols。下面分别说说这些内容

Object files

这个部分的内容都是 .m 文件编译后的 .o 和需要 link 的 .a 文件。前面是文件编号,后面是文件路径。

Sections

这里描述的是每个 Section 在可执行文件中的位置和大小。每个 Section 的 Segment 的类型分为 __TEXT 代码段和 __DATA 数据段两种。

Symbols

Symbols 是对 Sections 进行了再划分。这里会描述所有的 methods,ivar 和字符串,及它们对应的地址,大小,文件编号信息。

每次编译后生成的 dSYM 文件

在每次编译后都会生成一个 dSYM 文件,程序在执行中通过地址来调用方法函数,而 dSYM 文件里存储了函数地址映射,这样调用栈里的地址可以通过 dSYM 这个映射表能够获得具体函数的位置。一般都会用来处理 crash 时获取到的调用栈 .crash 文件将其符号化。

可以通过 Xcode 进行符号化,将 .crash 文件,.dSYM 和 .app 文件放到同一个目录下,打开 Xcode 的 Window 菜单下的 organizer,再点击 Device tab,最后选中左边的 Device Logs。选择 import 将 .crash 文件导入就可以看到 crash 的详细 log 了。

还可以通过命令行工具 symbolicatecrash 来手动符号化 crash log。同样先将 .crash 文件,.dSYM 和 .app 文件放到同一个目录下,然后输入下面的命令

export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
symbolicatecrash appName.crash appName.app > appName.log

Mach-O 文件

记录编译后的可执行文件,对象代码,共享库,动态加载代码和内存转储的文件格式。不同于 xml 这样的文件,它只是二进制字节流,里面有不同的包含元信息的数据块,比如字节顺序,cpu 类型,块大小等。文件内容是不可以修改的,因为在 .app 目录中有个 _CodeSignature 的目录,里面包含了程序代码的签名,这个签名的作用就是保证签名后 .app 里的文件,包括资源文件,Mach-O 文件都不能够更改。

Mach-O 文件包含三个区域

  • Mach-O Header:包含字节顺序,magic,cpu 类型,加载指令的数量等
  • Load Commands:包含很多内容的表,包括区域的位置,符号表,动态符号表等。每个加载指令包含一个元信息,比如指令类型,名称,在二进制中的位置等。
  • Data:最大的部分,包含了代码,数据,比如符号表,动态符号表等。

Mach-O 文件的解析

再通过一个例子来分析下:
这次用 xcrun 来

xcrun clang -v

先创建一个test.c的文件

touch test.c

编辑里面的内容

vi test.c

#include 
int main(int argc, char *argv[])
{
    printf("hi there!\n");
    return 0;
}

编译运行,没有起名默认为 a.out

xcrun clang test.c
./a.out

a.out 就是编译生成的二进制文件,下面看看这个二进制文件时如何生成的把。先看看输出的汇编代码

xcrun clang -S -o - test.c | open -f

输出的结果里 . 开头的行是汇编指令不是汇编代码,其它的都是汇编代码。先看看前几行

.section    __TEXT,__text,regular,pure_instructions
.macosx_version_min 10, 12
.globl  _main
.align  4, 0x90

.section 指令指定接下来执行哪一个段。

.globl 指令说明 _main 是一个外部符号,因为 main() 函数对于系统来说是需要调用它来运行执行文件的。

.align 指出后面代码的对齐方式,16(2^4) 字节对齐, 0x90 补齐。

看看接下来的 main 函数头部部分

_main:                                  ## @main
    .cfi_startproc
## BB#0:
    pushq   %rbp
Ltmp0:
    .cfi_def_cfa_offset 16
Ltmp1:
    .cfi_offset %rbp, -16
    movq    %rsp, %rbp
Ltmp2:
    .cfi_def_cfa_register %rbp
    subq    $32, %rsp

_main 是函数开始的地址,二进制文件会有这个位置的引用。

.cfi_startproc 这个指令用于函数的开始,CFI 是 Call Frame Infomation 的缩写是调用帧信息的意思,在用 debugger 时实际上就是 stepping in / out 的一个调用帧。当出现 .cfi_endproc 时表示匹对结束标记出 main() 函数结束。

pushq %rbp 是汇编代码,## BB#0: 这个 label 里的。ABI 会让 rbp 这个寄存器的被保护起来,当函数调用返回时让 rbp 寄存器的值跟以前一样。 ABI 是 application binary interface 的缩写表示应用二进制接口,它指定了函数调用是如何在汇编代码层面上工作的。pushq %rbp 将 rbp 的值 push 到栈中。

.cfi_def_cfa_offset 16 和 .cfi_offset %rbp, -16 会输出一些堆栈和调试信息,确保调试器要使用这些信息时能够找到。

movq %rsp, %rbp 把局部变量放到栈上。

subq $32, %rsp 会将栈指针移动 32 个字节,就是函数调用的位置。旧的栈指针存在 rbp 里作为局部变量的基址,再更新堆栈指针到会使用的位置。

再看看 printf()

leaq    L_.str(%rip), %rax
movl    $0, -4(%rbp)
movl    %edi, -8(%rbp)
movq    %rsi, -16(%rbp)
movq    %rax, %rdi
movb    $0, %al
callq   _printf

leap 会将 L_.str 这个指针加载到 rax 寄存器里。可以看看 L_.str 的定义

L_.str:                                 ## @.str
    .asciz  "hi there\n"

这个就是我们代码文件里定义的那个字符串。

这里可以看到函数的两个参数分别保存在 edi 和 rsi 寄存器里,根据函数地址做了不同的偏移。

当然也可以看出在这个汇编代码还有能够优化的地方,因为这两个值并没有用,却还是被寄存器存储了。

printf() 是个可变参数的函数,按照 ABI 调用约定存储参数的寄存器数量存储在寄存器 al 中,可变所以数量设置为0,callq 会调用 printf() 函数。

接下来看看返回和函数的结束

xorl    %ecx, %ecx
movl    %eax, -20(%rbp)         ## 4-byte Spill
movl    %ecx, %eax
addq    $32, %rsp
popq    %rbp
retq
.cfi_endproc

xorl %ecx, %ecx 相当于将 ecx 寄存器设置为0。ABI 约定 eax 寄存器用来保存函数返回值,拷贝 ecx 到 eax 中,这样 main() 返回值就是0。

函数执行完会恢复堆栈指针,前面是 subq 32 是把 rsp 下移32字节,addq 就是上移归位。然后把 rbp 的值从栈里 pop 出来。ret 会读取出栈返回的地址,.cfi_endproc 和 .cfi_startproc 配对标记结束。

接下来是字符串输出

    .section    __TEXT,__cstring,cstring_literals
L_.str:                                 ## @.str
    .asciz  "hi there\n"

.subsections_via_symbols

同样 .section 指出进入一个新的段。最后 .subsections_via_symbols 是静态链接器用的。

接下来通过 size 工具来看看 a.out 里的 section。

xcrun size -x -l -m a.out
Segment __PAGEZERO: 0x100000000 (vmaddr 0x0 fileoff 0)
Segment __TEXT: 0x1000 (vmaddr 0x100000000 fileoff 0)
    Section __text: 0x34 (addr 0x100000f50 offset 3920)
    Section __stubs: 0x6 (addr 0x100000f84 offset 3972)
    Section __stub_helper: 0x1a (addr 0x100000f8c offset 3980)
    Section __cstring: 0xa (addr 0x100000fa6 offset 4006)
    Section __unwind_info: 0x48 (addr 0x100000fb0 offset 4016)
    total 0xa6
Segment __DATA: 0x1000 (vmaddr 0x100001000 fileoff 4096)
    Section __nl_symbol_ptr: 0x10 (addr 0x100001000 offset 4096)
    Section __la_symbol_ptr: 0x8 (addr 0x100001010 offset 4112)
    total 0x18
Segment __LINKEDIT: 0x1000 (vmaddr 0x100002000 fileoff 8192)
total 0x100003000

可以看出有四个 segment 和多个section。

在运行时,虚拟内存会把 segment 映射到进程的地址空间,虚拟内存会避免将全部执行文件全部加载到内存。

__PAGEZERO segment 的大小是 4GB,不是文件真实大小,是规定进程地址空间前 4GB 被映射为不可执行,不可写和不可读。

__TEXT segment 包含被执行的代码以只读和可执行的方式映射。

  • __text section 包含编译后的机器码。
  • __stubs 和 __stub_helper 是给动态链接器 dyld 使用,可以允许延迟链接。
  • __cstring 可执行文件中的字符串。
  • __const 不可变的常量。

__DATA segment 以可读写和不可执行的方式映射,里面是会被更改的数据。

  • __nl_symbol_ptr 非延迟指针。可执行文件加载同时加载。
  • __la_symbol_ptr 延迟符号指针。延迟用于可执行文件中调用未定义的函数,可执行文件里没有包含的函数会延迟加载。
  • __const 需要重定向的常量,例如 char * const c = “foo”; c指针指向可变的数据。
  • __bss 不用初始化的静态变量,例如 static int i; ANSI C 标准规定静态变量必须设置为0。运行时静态变量的值是可修改的。
  • __common 包含外部全局变量。例如在函数外定义 int i;
  • __dyld 是section占位符,用于动态链接器。

更多 section 类型介绍可以查看苹果文档: OS X Assembler Reference

接下来用 otool 查看下 section 里的内容:

xcrun otool -s __TEXT __text a.out
a.out:
Contents of (__TEXT,__text) section
0000000100000f50    55 48 89 e5 48 83 ec 20 48 8d 05 47 00 00 00 c7 
0000000100000f60    45 fc 00 00 00 00 89 7d f8 48 89 75 f0 48 89 c7 
0000000100000f70    b0 00 e8 0d 00 00 00 31 c9 89 45 ec 89 c8 48 83 
0000000100000f80    c4 20 5d c3 

这个返回的内容很难读,加个 - v 就可以查看反汇编代码了, -s __TEXT __text 有个缩写 -t

xcrun otool -v -t a.out
a.out:
(__TEXT,__text) section
_main:
0000000100000f50    pushq   %rbp
0000000100000f51    movq    %rsp, %rbp
0000000100000f54    subq    $0x20, %rsp
0000000100000f58    leaq    0x47(%rip), %rax
0000000100000f5f    movl    $0x0, -0x4(%rbp)
0000000100000f66    movl    %edi, -0x8(%rbp)
0000000100000f69    movq    %rsi, -0x10(%rbp)
0000000100000f6d    movq    %rax, %rdi
0000000100000f70    movb    $0x0, %al
0000000100000f72    callq   0x100000f84
0000000100000f77    xorl    %ecx, %ecx
0000000100000f79    movl    %eax, -0x14(%rbp)
0000000100000f7c    movl    %ecx, %eax
0000000100000f7e    addq    $0x20, %rsp
0000000100000f82    popq    %rbp
0000000100000f83    retq

看起来是不是很熟悉,和前面的编译时差不多,不同的就是没有汇编指令。

现在来看看可执行文件。

通过 otool 来看看可执行文件头部, 通过 -h 可以打印出头部信息:

otool -v -h a.out
Mach header
      magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64  X86_64        ALL LIB64     EXECUTE    15       1200   NOUNDEFS DYLDLINK TWOLEVEL PIE

mach_header 结构体

struct mach_header {
  uint32_t      magic;
  cpu_type_t    cputype;
  cpu_subtype_t cpusubtype;
  uint32_t      filetype;
  uint32_t      ncmds;
  uint32_t      sizeofcmds;
  uint32_t      flags;
};

cputype 和 cpusubtype 规定可执行文件可以在哪些目标架构运行。ncmds 和 sizeofcmds 是加载命令。通过 -l 可以查看加载命令

otool -v -l a.out | open -f

加载命令结构体

struct segment_command {
  uint32_t  cmd;
  uint32_t  cmdsize;
  char      segname[16];
  uint32_t  vmaddr;
  uint32_t  vmsize;
  uint32_t  fileoff;
  uint32_t  filesize;
  vm_prot_t maxprot;
  vm_prot_t initprot;
  uint32_t  nsects;
  uint32_t  flags;
};

查看 Load command 1 这个部分可以找到 initprot r-x ,表示只读和可执行。

在加载命令里还是看看 __TEXT __text 的section的内容

Section
  sectname __text
   segname __TEXT
      addr 0x0000000100000f50
      size 0x0000000000000034
    offset 3920
     align 2^4 (16)
    reloff 0
    nreloc 0
      type S_REGULAR
attributes PURE_INSTRUCTIONS SOME_INSTRUCTIONS
 reserved1 0
 reserved2 0

addr 的值表示代码的位置地址,在上面反汇编的代码里可以看到地址是一样的,offset 表示在文件中的偏移量。

单个文件的就这样了,但是工程都是多个源文件的,那么多个文件是怎么合成一个可执行文件的呢?那么建多个文件来看看先。
Foo.h

#import 

@interface Foo : NSObject

- (void)say;

@end

Foo.m

#import "Foo.h"

@implementation Foo

- (void)say
{
    NSLog(@"hi there again!\n");
}

@end

SayHi.m

#import "Foo.h"

int main(int argc, char *argv[])
{
    @autoreleasepool {
        Foo *foo = [[Foo alloc] init];
        [foo say];
        return 0;
    }
}

先编译多个文件

xcrun clang -c Foo.m
xcrun clang -c SayHi.m

再将编译后的文件链接起来,这样就可以生成 a.out 可执行文件了。

xcrun clang SayHi.o Foo.o -Wl,`xcrun --show-sdk-path`/System/Library/Frameworks/Foundation.framework/Foundation

逆向 Mach-O 文件

需要先安装 tweak,安装越狱可以通过 cydia,不越狱直接打包成 ipa 安装包。越狱的话会安装一个 mobilesubstrate 的动态库,使用 theos 开发工具,非越狱的直接把这个库打包进 ipa 中或者直接修改汇编代码。

Mobilesubstrate 提供了三个模块来方便开发。

  • MobileHooker:利用 method swizzling 技术定义一些宏和函数来替换系统或者目标函数。
  • MobileLoader:在程序启动时将我们写的破解程序用的第三方库注入进去。怎么注入的呢,还记得先前说的 clang attribute 里的一个 attribute((constructor)) 么,它会在 main 执行之前执行,所以把我们的 hook 放在这里就可以了。
  • Safe mode:类似安全模式,会禁用的改动。

先前提到 Mach-O 的结构有 Header,Load commands 和 Data,Mobileloader 会通过修改二进制的 loadCommands 来先把自己注入然后再把我们写的第三方库注入进去,这样破解程序就会放在 Load commands 段里面了。

当然如果是我们自己的程序我们是知道要替换哪些方法的,既然是逆向肯定是别人的程序了,这个时候就需要去先分析下我们想替换方法是哪个,网络相关的分析可以用常用那些抓包工具,比如 Charles,WireShark 等,静态的可以通过砸壳,反汇编,classdump 头文件来分析 app 的架构,对应的常用工具dumpdecrypted,hopper disassembler 和 class_dump。运行时的分析可用工具有运行时控制台cycript,远程断点调试lldb+debugserver,logify。

  • 这里有个实例,讲解如何通过逆向实现微信抢红包的插件: 【Dev Club 分享第三期】iOS 黑客技术大揭秘 - DEV CLUB
  • 入门文章可以看看这篇: MyArticles/iOS冰与火之歌 at master · zhengmin1989/MyArticles · GitHub
  • 玩出新花样: 黑科技:把第三方 iOS 应用转成动态库 - Jun’s Blog,作者另一篇文章: iOS符号表恢复&逆向支付宝 - Jun’s Blog

dyld动态链接

生成可执行文件后就是在启动时进行动态链接了,进行符号和地址的绑定。首先会加载所依赖的 dylibs,修正地址偏移,因为 iOS 会用 ASLR 来做地址偏移避免攻击,确定 Non-Lazy Pointer 地址进行符号地址绑定,加载所有类,最后执行 load 方法和 clang attribute 的 constructor 修饰函数。

用先前 Mach-O 章节的例子继续分析,每个函数,全局变量和类都是通过符号的形式来定义和使用的,当把目标文件链接成一个执行文件时,链接器在目标文件和动态库之间对符号做解析处理。

符号表会规定它们的符号,使用 nm 工具看看

xcrun nm -nm SayHi.o
                 (undefined) external _OBJC_CLASS_$_Foo
                 (undefined) external _objc_autoreleasePoolPop
                 (undefined) external _objc_autoreleasePoolPush
                 (undefined) external _objc_msgSend
0000000000000000 (__TEXT,__text) external _main
  • OBJC_CLASS$_Foo 表示 Foo 的 OC 符号。
  • (undefined) external 表示未实现非私有,如果是私有就是 non-external。
  • external _main 表示 main() 函数,处理 0 地址,将要到 __TEXT,__text section

再看看 Foo

xcrun nm -nm Foo.o
                 (undefined) external _NSLog
                 (undefined) external _OBJC_CLASS_$_NSObject
                 (undefined) external _OBJC_METACLASS_$_NSObject
                 (undefined) external ___CFConstantStringClassReference
                 (undefined) external __objc_empty_cache
0000000000000000 (__TEXT,__text) non-external -[Foo say]
0000000000000060 (__DATA,__objc_const) non-external l_OBJC_METACLASS_RO_$_Foo
00000000000000a8 (__DATA,__objc_const) non-external l_OBJC_$_INSTANCE_METHODS_Foo
00000000000000c8 (__DATA,__objc_const) non-external l_OBJC_CLASS_RO_$_Foo
0000000000000110 (__DATA,__objc_data) external _OBJC_METACLASS_$_Foo
0000000000000138 (__DATA,__objc_data) external _OBJC_CLASS_$_Foo

因为 undefined 符号表示该文件类未实现的,所以在目标文件和 Fundation framework 动态库做链接处理时,链接器会尝试解析所有的 undefined 符号。

链接器通过动态库解析成符号会记录是通过哪个动态库解析的,路径也会一起记录。对比下 a.out 符号表看看是怎么解析符号的。

xcrun nm -nm a.out
                 (undefined) external _NSLog (from Foundation)
                 (undefined) external _OBJC_CLASS_$_NSObject (from CoreFoundation)
                 (undefined) external _OBJC_METACLASS_$_NSObject (from CoreFoundation)
                 (undefined) external ___CFConstantStringClassReference (from CoreFoundation)
                 (undefined) external __objc_empty_cache (from libobjc)
                 (undefined) external _objc_autoreleasePoolPop (from libobjc)
                 (undefined) external _objc_autoreleasePoolPush (from libobjc)
                 (undefined) external _objc_msgSend (from libobjc)
                 (undefined) external dyld_stub_binder (from libSystem)
0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
0000000100000e90 (__TEXT,__text) external _main
0000000100000f10 (__TEXT,__text) non-external -[Foo say]
0000000100001130 (__DATA,__objc_data) external _OBJC_METACLASS_$_Foo
0000000100001158 (__DATA,__objc_data) external _OBJC_CLASS_$_Foo

看看哪些 undefined 的符号,有了更多信息,可以知道在哪个动态库能够找到。

通过 otool 可以找到所需库在哪

xcrun otool -L a.out
a.out:
    /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 1349.25.0)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1238.0.0)
    /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1348.28.0)
    /usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)

libSystem 里有很多我们熟悉的lib

  • libdispatch:GCD
  • libsystem_c:C语言库
  • libsystem_blocks:Block
  • libcommonCrypto:加密,比如md5

dylib 这种格式的表示是动态链接的,编译的时候不会被编译到执行文件中,在程序执行的时候才 link,这样就不用算到包的大小里,而且也能够不更新执行程序就能够更新库。

打印什么库被加载了

(export DYLD_PRINT_LIBRARIES=; ./a.out )
dyld: loaded: /Users/didi/Downloads/./a.out
dyld: loaded: /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation
dyld: loaded: /usr/lib/libSystem.B.dylib
dyld: loaded: /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
...

数数还挺多的,因为 Fundation 还会依赖一些其它的动态库,其它的库还会再依赖更多的库,这样相互依赖的符号会很多,需要处理的时间也会比较长,这里系统上的动态链接器会使用共享缓存,共享缓存在 /var/db/dyld/。当加载 Mach-O 文件时动态链接器会先检查共享内存是否有。每个进程都会在自己地址空间映射这些共享缓存,这样可以优化启动速度。

动态链接器的作用顺序是怎么样的呢,可以先看看 Mike Ash 写的这篇关于 dyld 的博客: Dynamic Linking On OS X

dyld 做了些什么事

  • kernel 做启动程序初始准备,开始由dyld负责。
  • 基于非常简单的原始栈为 kernel 设置进程来启动自身。
  • 使用共享缓存来处理递归依赖带来的性能问题,ImageLoader 会读取二进制文件,其中包含了我们的类,方法等各种符号。
  • 立即绑定 non-lazy 的符号并设置用于 lazy bind 的必要表,将这些库 link 到执行文件里。
  • 为可执行文件运行静态初始化。
  • 设置参数到可执行文件的 main 函数并调用它。
  • 在执行期间,通过绑定符号处理对 lazily-bound 符号存根的调用提供 runtime 动态加载服务(通过 dl*() 这个 API ),并为gdb和其它调试器提供钩子以获得关键信息。runtime 会调用 map_images 做解析和处理,load_images 来调用 call_load_methods 方法遍历所有加载了的 Class,按照继承层级依次调用 +load 方法。
  • 在 mian 函数返回后运行 static terminator。
  • 在某些情况下,一旦 main 函数返回,就需要调用 libSystem 的 _exit。

查看运行时的调用 map_images 和 调用 +load 方法的相关 runtime 处理可以通过 RetVal 的可debug 的 objc/runtime RetVal/objc-runtime: objc runtime 706 来进行断点查看调用的 runtime 方法具体实现。在 debug-objc 下创建一个类,在 +load 方法里断点查看走到这里调用的堆栈如下:

0  +[someclass load]
1  call_class_loads()
2  ::call_load_methods
3  ::load_images(const char *path __unused, const struct mach_header *mh)
4  dyld::notifySingle(dyld_image_states, ImageLoader const*, ImageLoader::InitializerTimingList*)
11 _dyld_start

在 load_images 方法里断点 p path 可以打印出所有加载的动态链接库,这个方法的 hasLoadMethods 用于快速判断是否有 +load 方法。

prepare_load_methods 这个方法会获取所有类的列表然后收集其中的 +load 方法,在代码里可以发现 Class 的 +load 是先执行的,然后执行 Category 的。为什么这样做,原因可以通过 prepare_load_methods 这个方法看出,在遍历 Class 的 +load 方法时会执行 schedule_class_load 这个方法,这个方法会递归到根节点来满足 Class 收集完整关系树的需求。

最后 call_load_methods 会创建一个 autoreleasePool 使用函数指针来动态调用类和 Category 的 +load 方法。

如果想了解 Cocoa 的 Fundation 库可以通过 GNUStep 源码来学习。比如 NSNotificationCenter 发送通知是按什么顺序发送的可以查看 NSNotificationCenter.m 里的 addObserver 方法和 postNotification 方法,看看观察者是怎么添加的和怎么被遍历通知到的。

dyld 是开源的: GitHub - opensource-apple/dyld

还可以看看苹果的 WWDC 视频 WWDC 2016 Session 406 里讲解对启动进行优化。

这篇文章也不错: Dynamic Linking of Imported Functions in Mach-O - CodeProject

附:安装编译 LLVM

多种获取方式

  • 官网:http://releases.llvm.org/download.html
  • svn
先下载 LLVM
svn co http://llvm.org/svn/llvm-project/llvm/trunk llvm

在 LLVM 的 tools 目录下下载 Clang
cd llvm/tools
svn co http://llvm.org/svn/llvm-project/cfe/trunk clang

在 LLVM 的 projects 目录下下载 compiler-rt,libcxx,libcxxabi
cd ../projects
svn co http://llvm.org/svn/llvm-project/compiler-rt/trunk compiler-rt
svn co http://llvm.org/svn/llvm-project/libcxx/trunk libcxx
svn co http://llvm.org/svn/llvm-project/libcxxabi/trunk libcxxabi

在 Clang 的 tools 下安装 extra 工具
cd ../tools/clang/tools
svn co http://llvm.org/svn/llvm-project/clang-tools-extra/trunk extra
  • git
git clone http://llvm.org/git/llvm.git
cd llvm/tools
git clone http://llvm.org/git/clang.git
cd ../projects
git clone http://llvm.org/git/compiler-rt.git
cd ../tools/clang/tools
git clone http://llvm.org/git/clang-tools-extra.git

安装

brew install gcc
brew install cmake
mkdir build
cd build
cmake /path/to/llvm/source
cmake --build .

如果希望是 xcodeproject 方式 build 可以使用 -GXcode
mkdir xcodeBuild
cd xcodeBuild
cmake cmake -GXcode /path/to/llvm/source

接下来可以看看 LLVM 的官方教程如何实现一个自己的编程语Kaleidoscope。 LLVM Tutorial: Table of Contents — LLVM 5 documentation

这里有个使用swift的实现的系列教程
Building a Compiler in Swift with LLVM

附:其它编译工具

js写的C++解释器JSCPP

适合学生学习时能够方便的在浏览器里直接编c++程序。项目地址:GitHub - felixhao28/JSCPP: A simple C++ interpreter written in JavaScript

附:资料网址

  • http://llvm.org
  • http://clang.llvm.org/
  • http://www.aosabook.org/en/llvm.html
  • GitHub - loarabia/Clang-tutorial: A collection of code samples showing usage of clang and llvm as a library
  • Using an external Xcode Clang Static Analyzer binary, with additional checks - Stack Overflow

你可能感兴趣的:(深入剖析 iOS 编译 Clang / LLVM)