欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

iOS 底层探索(二十三) LLVM (上)

程序员文章站 2024-03-24 21:43:04
...

iOS 底层探索(二十三) LLVM (上)

什么是编译器

Python解释器

创建helloWorld.py的文件,内容如下

print("hello\n")

然后使用命令行进行运行
iOS 底层探索(二十三) LLVM (上)

其中python命令的意义是python语言的解释器。因为它可以直接运行处结果。

C语言编译器

创建helloWorld.c文件,代码内容如下:

#include <stdio.h>
int main(int a, char *argv[]){
		printf("hello \n");
		return 0;
}

使用clang进行编译,会得到a.out文件,查看a.out文件的格式,如下
iOS 底层探索(二十三) LLVM (上)

由打印可知,a.out文件是Mach-O的可执行文件。这个文件是根据编译的电脑的设备决定的,也就是说,当前的电脑可以执行。
执行a.out文件
iOS 底层探索(二十三) LLVM (上)

解释器与编译器

  • 解释器:一边读一边让CPU执行
  • 编译器:编译成一个可执行文件,这个可执行文件即为CPU可执行的二进制的组合0 - 1组合,即把高级语言变成CPU能看得懂的0 - 1组合

那么在Xcode中使用CMD + B做了什么?
将代码编译成.app包中的可执行文件。

汇编
在早期的程序中,是直接输入01组合进行程序编译,

如:
00001111  -- call
00000111  -- bl

比如上方,前面的二进制代表后面的指令。就相当于给这个特殊的二进制做了个标记,这个标记就是早期的汇编。而能够将这些标记变成前面的特定二进制的指令的东西就是编译器
但是汇编有个非常严重的缺点:不可以跨平台
因为不同厂商生产的CPU对特定的指令识别不同,因此出现了一种高级语言C语言。
跨平台:不同的CPU能够解读相同的代码。

LLVM

LLVM概述

LLVM是架构编译器(compiler)的框架系统,以C++编写而成,用于优化以任意程序语言编写的程序的编译时间(compiler-time)、链接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time),对开发者保持开放,并兼容已有脚本。
LLVM计划启动于2000年,最初由美国UIUC大学的Chris Lattner博士主持开展。2006年Chris Lattner加盟Apple Inc.并致力于LLVM在Apple开发体系中的应用。Apple也是LLVM计划的主要自助者。
目前LLVM已经被苹果iOS开发工具、Xilinx Vivado、Facebook、Google等各大公司采用。

传统编译器设计

iOS 底层探索(二十三) LLVM (上)

编译器前端(Frontend)

编译器前端的任务是解析源代码。编译阶段,它会进行:词法分析、语法分析、语义分析、检查源代码是否存在错误,然后构建抽象语法树(Abstract Syntax Tree),LLVM的前端还会生成中间代码(intermediate representation,IR)。

调用方法,耗时操作。

函数调用栈

func() {
// 压栈
 int a = 10;
 int b = 20; // 局部变量
}
// 出栈

当执行到一个方法时,会申请一段栈空间(16字节对齐的内存)。

  1. 找到方法的代码,内存寻址。
  2. 开辟内存空间
  3. 内存空间输入值,即压栈
  4. 执行代码
  5. 栈平衡,即出栈
  6. 代码回跳。

目前为了程序让程序员可读,高级语言让程序员的效率提高。但如果不做优化,即将高级语言直接编译成机器语言,会浪费时间与空间。

优化器

优化器负责进行各种优化。改善代码的运行时间,例如消除冗余计算等。在LLVM中优化器接收到的代码为IR,给到后端的代码也是IR代码。

后端(Backend)/代码生成器(CodeGenerator)

将代码映射到目标指令集。生成机器语言,并且进行机器相关的代码优化。

iOS的编译器架构

Objective C/C/C++使用的编译器前端是Clang,Swift是Swift,后端都是LLVM
iOS 底层探索(二十三) LLVM (上)

LLVM的设计

当编译器决定支持多种源语言或多种硬件架构是,LLVM最重要的地方就来了。其他的编译器如GCC,它方法非常成功,但由于它是作为整体应用程序设计的,因此它们的用途受到了很大的限制。
LLVM设计的最重要方面是,使用通用的代码表示形式(IR),它是用来在编译器中表示代码的形式,所以LLVM可以为任何编程语言独立编写前端,并且可以为任意硬件架构独立编写后端。LLVM使用的语言为C++。
iOS 底层探索(二十三) LLVM (上)

Clang

Clang是LLVM项目中的一个子项目。它是基于LLVM架构的轻量级编译器,诞生之初是为了替代GCC,提供更快的编译速度。它是负责编译C、C++、Objective-C语言的编译器,它属于整个LLVM架构中的编译器前端。对于开发者来说,研究Clang可以给我们带来很多好处。

编译流程

通过命令可以打印源码的编译阶段

创建一个macOS的项目,在main.m文件中输入如下内容

#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        NSLog(@"Hello, World!");
    }
    return 0;
}

使用如下命令编译main.m文件。

clang -ccc-print-phases main.m

iOS 底层探索(二十三) LLVM (上)

从打印结果来看,编译过程一共有6步,因为第0步为输入。
0: 输入文件:找到源文件,
1: 预处理阶段:这个过程包括替换头文件的导入
2: 编译阶段:进行词法分析语法分析检测语法是否正确,最终生成IR
3: 后端:这里LLVM会通过一个一个的Pass去优化,每个Pass做一些事情,最终生成汇编代码
4: 生成目标文件,即.o文件
5: 链接:链接需要的动态库和静态库,生成完整可执行文件.o的集合。
6: 通过不同的架构,生成对应的可执行文件,这时是完整的可执行文件

预处理阶段

修改上方的main.m文件为如下代码

#import <stdio.h>

#define C 30

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int a = 10;
        int b = 20;
        printf("%d", a + b + C);
    }
    return 0;
}

然后执行如下命令查看结果

clang -E main.m >> mainE.m

这时会生成一个mainE.m的文件,这个文件就是预处理之后倒出的文件,并不是自动生成的,打开该文件。
iOS 底层探索(二十三) LLVM (上)

可知,我们自己写的代码一共不超过10行,而编译之后变成了500+行,这主要是因为#import <stdio.h>导入文件导致的。
找到main函数的位置,代码如下

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        int a = 10;
        int b = 20;
        printf("%d", a + b + 30);
    }
    return 0;
}

可以发现,预处理中的已经没有了,被宏的值替换掉了。
修改main.m代码如下:即替换int

#import <stdio.h>

#define C 30

typedef int INT_64;

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        INT_64 a = 10;
        INT_64 b = 20;
        printf("%d", a + b + C);
    }
    return 0;
}

重新编译查看。
iOS 底层探索(二十三) LLVM (上)

从结果可知,自定义的INT_64并没有替换成int,原因是typedef主要是给数据类型取别名的。
在日常开发中,typedefdefine都是为了取别名的。但是define可以进行混淆,比如核心类名称、类方法不想让别人知道时,即可利用define的机制进行混淆

编译阶段

词法分析

预处理完成后就会进行词法分析。这里会把代码切割成一个个Token,比如大小括号,等于号还有字符串等。
执行如下命令:

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

这个阶段也是没有生成真正的文件,并且不能导出文件查看。
iOS 底层探索(二十三) LLVM (上)

从输出可以看出,这是在进行词法的短句分析,左侧为词法,右侧为该词法所在的位置。可以根据这个位置进行源代码的一一对照。

语法分析

词法分析完成之后就是语法分析,Radeon任务时验证语法是否正确。在词法分析的基础上将单词序列组合成各类语法短语,如程序语句表达式等等,然后将所有节点组成抽象语法树(Abstract Syntax Tree, AST)。语法分析程序判断源程序在结构上是否正确。

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

如果导入头文件找不到,那么可以指定SDK

clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator12.sdk -fmodules -fsyntax-only -Xclang -ast-dump main.m

运行之后,查看结果。生成语法树
iOS 底层探索(二十三) LLVM (上)

这块的东西我们可能看不太懂,我们找到我们能看得懂的位置,即三个连加方法。如下
iOS 底层探索(二十三) LLVM (上)

可以看到,标记的位置的意义为先让a + b得出的结果再与c相加。有兴趣可以看一下()括号以及* /乘除法的运算。
上方主要树状语法含义。

  • FunctionDecl为方法,
  • ParmVarDecl 参数变量
  • CompoundStmt 复合语句
  • VarDecl 定义变量
  • CallExpr 调用函数
  • BinaryOperator 运算符
  • ReturnStmt 返回值

我们手动写个语法错误,重新编译一下查看结果。
iOS 底层探索(二十三) LLVM (上)

编译结果会出现错误,并且提示你在哪一行的什么错误。

生成中间代码IR(intermediate representation)

完成以上步骤后就开始生成中间代码IR了,代码生成器(Code Generation)会将语法树自顶向下遍历逐步翻译成LLVM IR。通过下面命令可以生成.ll的文本文件,查看IR代码

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

Objective-C代码在这一步会进行runtime的桥接:property合成,ARC处理等。

修改main.m中的代码如下

#import <stdio.h>

int sum(int a, int b) {
    return a + b;
}

int main(int argc, const char * argv[]) {
    printf("%d", sum(10, 20) + 30);
    return 0;
}

运行命令后生成main.ll文件,使用VSCode打开它查看。

; ModuleID = 'main.m'
source_filename = "main.m"
target datalayout = "e-m:o-p270:32:32-p271:32:32-p272:64:64-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-macosx10.15.0"

@.str = private unnamed_addr constant [3 x i8] c"%d\00", align 1
; Function Attrs: noinline nounwind optnone ssp uwtable
define i32 @sum(i32 %0, i32 %1) #0 {
  %3 = alloca i32, align 4 // -> int a
  %4 = alloca i32, align 4 // -> int b
  store i32 %0, i32* %3, align 4 // -> a = 10
  store i32 %1, i32* %4, align 4 // -> b = 20
  %5 = load i32, i32* %3, align 4 // -> 取值
  %6 = load i32, i32* %4, align 4 // -> 取值
  %7 = add nsw i32 %5, %6 // -> 10 + 20
  ret i32 %7 // return 30
}
; Function Attrs: noinline optnone ssp uwtable
define i32 @main(i32 %0, i8** %1) #1 {
  %3 = alloca i32, align 4
  %4 = alloca i32, align 4
  %5 = alloca i8**, align 8 // 8字节,指针。
  store i32 0, i32* %3, align 4
  store i32 %0, i32* %4, align 4
  store i8** %1, i8*** %5, align 8
  %6 = call i32 @sum(i32 10, i32 20) // 调用函数
  %7 = add nsw i32 %6, 30
  %8 = call i32 (i8*, ...) @printf(i8* getelementptr inbounds ([3 x i8], [3 x i8]* @.str, i64 0, i64 0), i32 %7)
  ret i32 0
}
declare i32 @printf(i8*, ...) #2

attributes #0 = { noinline nounwind optnone ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "darwin-stkchk-strong-link" "disable-tail-calls"="false" "frame-pointer"="all" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "probe-stack"="___chkstk_darwin" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+cx8,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #1 = { noinline optnone ssp uwtable "correctly-rounded-divide-sqrt-fp-math"="false" "darwin-stkchk-strong-link" "disable-tail-calls"="false" "frame-pointer"="all" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "probe-stack"="___chkstk_darwin" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+cx8,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }
attributes #2 = { "correctly-rounded-divide-sqrt-fp-math"="false" "darwin-stkchk-strong-link" "disable-tail-calls"="false" "frame-pointer"="all" "less-precise-fpmad"="false" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "probe-stack"="___chkstk_darwin" "stack-protector-buffer-size"="8" "target-cpu"="penryn" "target-features"="+cx16,+cx8,+fxsr,+mmx,+sahf,+sse,+sse2,+sse3,+sse4.1,+ssse3,+x87" "unsafe-fp-math"="false" "use-soft-float"="false" }

!llvm.module.flags = !{!0, !1, !2, !3, !4, !5, !6, !7}
!llvm.ident = !{!8}

!0 = !{i32 2, !"SDK Version", [3 x i32] [i32 10, i32 15, i32 6]}
!1 = !{i32 1, !"Objective-C Version", i32 2}
!2 = !{i32 1, !"Objective-C Image Info Version", i32 0}
!3 = !{i32 1, !"Objective-C Image Info Section", !"__DATA,__objc_imageinfo,regular,no_dead_strip"}
!4 = !{i32 4, !"Objective-C Garbage Collection", i32 0}
!5 = !{i32 1, !"Objective-C Class Properties", i32 64}
!6 = !{i32 1, !"wchar_size", i32 4}
!7 = !{i32 7, !"PIC Level", i32 2}
!8 = !{!"Apple clang version 12.0.0 (clang-1200.0.32.21)"}

可以看到这个文件中生成了一定类似乱码的东西,但我们还是可以查看的,因为我们定义了两个方法mainsum函数。

IR的基本语法

@ 全局标识
% 局部标识
alloca 开辟空间
align 内存对齐
i32 32个bit,4个字节
store 写入内存
load 读取数据
call 调用函数
ret 返回

从上面的.ll文件中可以看到会有各种的创建以及赋值。这是硬件设备影响的,即CPU从内存中取值进行运算,运行结果传给内存进行存储,当CPU再次使用时,需要再次取值,然后在循环往复。
IR的优化
LLVM的优化级别分别是 -O0 -O1 -O2 -O3 -Os -Ofast -Oz(第一个是大写英文字母O),这个级别可以在Xcode中的Optimization Level中进行查看。

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

重新运行查看结果如下
iOS 底层探索(二十三) LLVM (上)

bitCode
Xcode7以后开启bitCode,苹果会做进一步的优化。生成.bc的中间代码,我们通过优化后的IR代码生成.bc代码

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

在日常开发中,在接入三方库的时候回出现bitCode的错误,就是优化的原因。
生成汇编代码
我们通过最终的.bc或者.ll代码生成汇编代码,

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

为了区分,我们分别生成mainBC.smainLL.s以及mainM.s的文件。进行对照
.s文件中我们可知,mainM.s代码为61行,另外两种.s文件都是54行,之所以mainBC.smainLL.s的代码一致是因为我们写的代码相对简单。并且我们使用的.ll文件是优化后的代码。
生成汇编代码也可以进行优化

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

运行后查看可知.s文件变成了47行,再对.ll文件进行优化查看。

clang -Os -S -fobjc-arc main.ll -o main.s

运行后可知,.ll文件生成的优化文件也是47行,因此可知优化的结果都是相同的。

生成目标文件(汇编器)

目标文件的生成,是汇编器以汇编代码作为输入,将汇编代码转换为机器码,最后输出目标文件(object file)。

clang -fmodules -c main.s -o main.o

.o文件与其他文件都有差别
iOS 底层探索(二十三) LLVM (上)

.o已经变成了Mach-O的文件。
通过nm命令,查看main.o中的符号
iOS 底层探索(二十三) LLVM (上)

_printf 是一个undefined extrnal
undefined表示在当前文件暂时找不到符号_printf
external表示这个符号是外部可以访问的。

生成可执行文件(链接)

连接器把编译产生的.o文件和(.dylib .a)文件,生成一个Mach-O文件

clang main.o -o main

运行后查看结果。
iOS 底层探索(二十三) LLVM (上)

_printf 在运行的时候才会进行最后的绑定,但目前已经知道_printf在哪个框架中了,即libSystem。这时main为可执行的Mach-O文件

Clang插件

  • 下载llvm-project项目
git clone https://github.com/llvm/llvm-project

国内网速较慢,可以使用一下框架

git clone https://gitee.com/mirrors/llvm-project.git

LLVM编译

由于最新的LLVM只支持cmake来编译,因此我们还需要安装cmake

安装cmake

  • 查看brew已安装列表,检查是否安装过cmake,如果有,就跳过此步骤
brew list
  • 如果没有,就使用brew安装
brew install cmake
  • 如果报权限错误,则使用如下命令放开权限
sudo chown -R `whoami`:admin /usr/local/share

通过Xcode编译llvm

  • cmake编译成Xcode项目
cd llvm-project
mkdir build_xcode
cd build_xcode
cmake -G Xcode ../llvm   
// 或者: cmake -G Xcode CMAKE_BUILD_TYPE="Release" ../llvm
// 或者: cmake -G Xcode CMAKE_BUILD_TYPE="debug" ../llvm 
  • 成功之后,可以在build_xcode文件夹中找到LLVM.xcodeproj文件,

  • 打开LLVM.xcodeproj,并选择自动创建Schemes

  • 自动创建完成后,选择ALL_BUILD进行编译,如果找不到,可以在Manager Schemes中进行搜索选择。
    iOS 底层探索(二十三) LLVM (上)

  • 至此,编译完成。

通过 ninja 编译

  • 使用ninja进行编译则还需要安装ninja。使用$ brew install ninja命令即可安装ninja
  • 在llvm源码根目录下新建一个build_ninja目录,最终会在build_ninja目录下生成build.ninja
  • 在llvm源码根目录下新建一个llvm_release目录,最终编译文件会在llvm_release文件夹路径下
$ cd llvm_build
$ cmake -G Ninja ../llvm -DCMAKE_INSTALL_PREFIX=安装路径(本机为 /Users/xxx/xxx/LLVM/llvm_release,注意DCMAKE_INSTALL_PREFIX后面不能有空格)
  • 依次执行编译、安装指令
$ ninja
$ ninja install
  • 至此ninja编译完成