1、编译流程
编译器:
1、前端(词法分析等)
2、后端(生成对应平台的二进制)
我们在开发中都是写的代码,最终这些代码会转化成010101形式的机器码,那么中间是经过了一个怎样的流程呢?
以Swift为例,我们编写的Swift代码会先转为Swift AST的语法树,然后再将语法树转为Swift特有的中间代码,之后再转为LLVM的中间代码,这个过程中会对代码进行优化,后续则就是汇编了,之后便是二进制代码整个编译流程大致如上所述。
可以知道最终是转为汇编以后再转为二进制代码,不仅仅是Swift,OC上的流程也是会转为汇编以后再去转为二进制代码,所以我们可以通过汇编分析的不仅仅是Swift,OC上我们也可以通过汇编去分析。
例如,我们创建OC的命令行项目,代码如下:
int main(int argc, const char * argv[]) {
@autoreleasepool {
int size = sizeof(int);
printf("%d",size);
}
return 0;
}
同时断点在sizeof
处,此时查看汇编代码如下:
TestOC`main:
0x100000f20 <+0>: pushq %rbp
0x100000f21 <+1>: movq %rsp, %rbp
0x100000f24 <+4>: subq $0x30, %rsp
0x100000f28 <+8>: movl $0x0, -0x4(%rbp)
0x100000f2f <+15>: movl %edi, -0x8(%rbp)
0x100000f32 <+18>: movq %rsi, -0x10(%rbp)
0x100000f36 <+22>: callq 0x100000f72 ; symbol stub for: objc_autoreleasePoolPush
//将立即数4赋值给了rbp-0x14的存储空间,如果是函数调用那么对应的汇编指令是call
-> 0x100000f3b <+27>: movl $0x4, -0x14(%rbp)
0x100000f42 <+34>: movl -0x14(%rbp), %esi
0x100000f45 <+37>: leaq 0x62(%rip), %rdi ; "%d"
0x100000f4c <+44>: movq %rax, -0x20(%rbp)
0x100000f50 <+48>: movb $0x0, %al
0x100000f52 <+50>: callq 0x100000f78 ; symbol stub for: printf
0x100000f57 <+55>: movq -0x20(%rbp), %rdi
0x100000f5b <+59>: movl %eax, -0x24(%rbp)
0x100000f5e <+62>: callq 0x100000f6c ; symbol stub for: objc_autoreleasePoolPop
0x100000f63 <+67>: xorl %eax, %eax
0x100000f65 <+69>: addq $0x30, %rsp
0x100000f69 <+73>: popq %rbp
0x100000f6a <+74>: retq
可以对比代码看到,在0x100000f36
处是@autoreleasepool
相关,而前面也提到我们断点在sizeof
处,此时箭头所指的地方其实就是sizeof
的实现处,我们也看一通过汇编看到sizeof
非函数调用,否则这里应该是call
指令。
通过swiftc
可以进行相应的操作:
swiftc存放在XCode内部:
/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin
生成语法树:swiftc -dump-ast main.swift
生成最简洁的SIL代码:swiftc -emit-sil main.swift
生成LLVM IR代码:swiftc -emit-ir main.swift
生成汇编代码:swiftc -emit-assembly main.swift
上面的代码可以直接在终端中打印出对应的结果,我们也可以将结果导出为对应的文件:
生成语法树:swiftc -dump-ast main.swift -o main.ast
生成最简洁的SIL代码:swiftc -emit-sil main.swift -o main.txt
生成LLVM IR代码:swiftc -emit-ir main.swift -o main.ll
生成汇编代码:swiftc -emit-assembly main.swift -o main.s
2、汇编相关了解
汇编语言的种类:
8086汇编(16bit)
x86汇编(32bit)
x64汇编(64bit)
ARM汇编(嵌入式、移动设备)
......
x86、x64汇编根据编译器的不同,有2种书写格式
Intel:Windows派系
AT&T :Unix派系
作为iOS开发工程师,最主要的汇编语言是
AT&T汇编 -> iOS模拟器
ARM汇编 -> iOS真机设备
常见汇编指令
平台 | AT&T | Intel | 说明 |
---|---|---|---|
操作数顺序 | movq %rax,%rdx | mov rdx,rax | 将rax的值赋值给rdx |
取内存地址赋值 | leaq -0x18(%rip),%rax | move qword ptr [rip+0x1ff7],0xa | 将0xa赋值给地址为rip+0x1f77的内存空间 |
有16个常用寄存器
rax
、rbx
、rcx
、rdx
、rsi
、rdi
、rbp
、rsp
r8
、r9
、r10
、r11
、r12
、r13
、r14
、r15
寄存器的具体用途
rax
、rdx
常作为函数返回值使用
rdi
、rsi
、rdx
、rcx
、r8
、r9
等寄存器常用于存放函数参数
rsp
、rbp
用于栈操作
rip
作为指令指针
存储着CPU下一条要执行的指令的地址
一旦CPU读取一条指令,rip会自动指向下一条指令(存储下一条指令的地址)
lldb常用指令
读取寄存器的值
register read/格式
register read/x
修改寄存器的值
register write 寄存器名称 数值
register write rax 0
读取内存中的值
x/数量-格式-字节大小 内存地址
x/3xw 0x0000010
修改内存中的值
memory write 内存地址 数值
memory write 0x0000010 10
格式
x
是16进制
,f
是浮点
,d
是十进制
字节大小
b – byte
1字节
h – half word
2字节
w – word
4字节
g – giant word
8字节
expression
表达式
可以简写:expr
表达式
expression $rax
expression $rax = 1
po
表达式
print
表达式
po/x $rax
po (int)$rax
thread step-over
、next
、n
单步运⾏行行,把子函数当做整体⼀一步执⾏行行(源码级别)
thread step-in
、step
、s
单步运⾏行行,遇到子函数会进⼊入子函数(源码级别)
thread step-inst-over
、nexti
、ni
单步运⾏行行,把子函数当做整体⼀一步执⾏行行(汇编级别)
thread step-inst
、stepi
、si
单步运⾏行行,遇到子函数会进⼊入子函数(汇编级别)
thread step-out
、finish
直接执⾏行行完当前函数的所有代码,返回到上一个函数(遇到断点会卡住)
内存规律
内存地址格式为:0x4bdc(%rip)
,一般是全局变量,全局区(数据段)
内存地址格式为:-0x78(%rbp)
,一般是局部变量,栈空间
内存地址格式为:0x10(%rax)
,一般是堆空间
3、分析枚举、类以及结构体的内存结构
3.1、MemoryLayout
我们可以通过MemoryLayout
去查看当前类型或者实例占用空间的情况
print("实际占用的内存:" , MemoryLayout<Int>.size)
print("系统分配的内存:" , MemoryLayout<Int>.stride)
print("内存对齐字节数:" , MemoryLayout<Int>.alignment)
打印结果:
可以看到在当前架构平台下Int
是占8个字节的,因此一个Int
变量系统会给其分配8个字节。至于最后的alignment
为对齐字节,其实就是系统分配的空间肯定是对齐字节的倍数。
假如Int
占用4个字节但是其对齐字节为8,那么实际系统还是会给其分配8个字节,即便后面4个字节不会使用。
3.2、枚举内存分析
但是枚举并不一定是关联值类型全部一致的类型,当关联值类型一致时我们可以很容易的推断其内部的存储,
如:
enum Style {
case plain
case done
case style1(Int)
case style2(Int, Int, Int)
case style3(Int, Int, Int, Int)、
case style4(Int, Int, Bool, Bool)
}
取关联值最多的枚举样式即style3
,此时可以很容易推断内存为8 * 4 + 1 = 33
,又因为内存对齐的关系,此时分配的内存为40 = 8 * 5
。实际上这句话不是很严谨,如果此时存在如下样式:
case style4(Int, Int, Int, Bool, Bool)
即便其关联值最多,但是仍然是以style3
为标准去计算器内存空间。
为什么会这样,上面我也强调过上述说法不严谨,严格来说应该是枚举中以占空间最多的枚举样式确定当前应该分配的空间。
在当前平台,Bool
类型仅占1个字节,而Int
类型是占8个字节的,因此系统在分配时肯定以最大使用空间去分配。换句话说其实就是有备无患,分配的空间可以不用,但是需要使用的时候一定是要有的。
从我们定义的枚举样式来看好像不是只有style1
、style2
、style3
才有关联值,实际上plain
、done
也是有关联值的,我们可以直接通过汇编分析。
我们先看style3
是怎么存储关联值的:
var style = Style.style3(10, 9, 8, 7)
可以看到从断点处开始分4次将关联值写入对应的内存空间,最后一次movb $0x2, 0x6257(%rip)
是写入当前的枚举样式的值,我们也是通过最后一个字节去区分当前的枚举值的。
*
注意我这里所说的最后一个字节是有使用的字节而非分配的内存的最后一个字节,后同,如有其它含义我会指明。
例如系统分配了8个字节而实际使用的是前3个字节,那么我所说的最后一个字节就是第3个字节。
那么此时我们再去看看没有关联值的情况下其汇编指令的写入操作:
var style = Style.done
可以看到在内存的第一个字节处写入的是
$0x1
,后续的字节除了最后一个字节写入的都是$0x0
,你可以理解为没有值,也就是如果没有外部传入的关联值的情况下使用第一个字节来存储默认的关联值的。
除了我们自己推断枚举使用的内存空间我们也可以通过上述的MemoryLayout
直接获知其内存使用:
var style = Style.style3(10, 9, 8, 7)
////实际占用的空间
print("实际占用空间:" + "\(MemoryLayout.size(ofValue: style))")
//系统分配的空间
print("分配的空间:" + "\(MemoryLayout.stride(ofValue: style))")
//对齐字节
print("偏移量:" + "\(MemoryLayout.alignment(ofValue: style))")
=结果=============================================================
实际占用空间:33
分配的空间:40
偏移量:8
Program ended with exit code: 0
这样就可以直接获知其内存使用。
Xcode中查看内存方式:
随便定义一个常量,并在此打上断点:
勾选Xcode -> Debug -> Debug Workflow -> Always Show Disassembly
,即每次断点都进入汇编模式
此时我们run
以后会自动进入到汇编
可以看到当前断点就是我们马上要执行的指令,此时的
movq
指令表示将$0xa
赋值到rip + 0x624f
地址对应的空间,而rip中存储着cup需要执行的下一条指令的地址,那么此时我们取0x100000fa1 + 0x624f
就得到了变量a
对应的存储空间,此时在通过Xcode-Debug-Debug Workflow-View Memory
就可以查看对应的数据了但是我们查看内存为什么值是在第一个字节显示呢,正常16进制值的表示都如0x0087类似,实际上这里就涉及计算机的大小端问题了。
3.3、分析枚举内存使用
无关联值枚举:
enum Season {
case spring
case summer
case autumn
case winter
}
我们可以通过上述方法断点分析看到内存空间为1个字节,里边存储着枚举的原始值。
那如果这样呢:
enum Season : String {
case spring = "spring"
case summer = "summer"
case autumn = "autumn"
case winter = "winter"
}
实际上我们虽然像是定义了关联值,实际上这并不是关联值,我们可以看做是将当前枚举的原始值与后面的常量字符串绑定起来而已。
可以看到我们去调用rawValue
时实际上调用的是其getter
方法。截下来通过si
指令进入到getter
方法内部
可以知道通过比较值判断是顺序往下走还是通过
jne
指令(jump not equal
)跳转到目标地址处。而这里的目标地址正是第2个枚举值对应的字符串的地方。综上其实我们取枚举的
rawValue
时是调用rawValue
的getter
方法,在该方法内部判断取什么值,我们可以将其理解为类似于下面的逻辑:
func rawValue() -> String {
if self == .spring {
return "spring"
}else if self == .summer {
return "summer"
}
}
而我们可以从地址上看spring
对应的地址是0x4ca9(%rip)
,summer
对应的地址是0x4c90(%rip)
。而前面我们也提到过类似这种地址的数据一般情况下是在全局变量,因此对应的字符串并没有存储在枚举变量对应的空间中,而是在取值的时候通过判断后直接从变量区取出。
接下来可以看看稍显复杂的枚举:
enum Style {
case plain
case done
case style1(Int)
case style11(Int)
case style2(Int, Int, Int)
case style3(Int, Int, Int, Int)
case style4(Int, Int, Bool, Bool)
}
此时我们通过分析或者通过MemoryLayout
获知其使用内存为4 * 8 + 1 = 33
,那么其分配获得的内存为40
。
此时实例化plain
样式
var style = Style.plain
断点进入汇编:
TestSwift`main:
0x100000f60 <+0>: pushq %rbp
0x100000f61 <+1>: movq %rsp, %rbp
0x100000f64 <+4>: xorl %eax, %eax
//往第1个8个字节的第1个字节写入关联值`$0x0`
-> 0x100000f66 <+6>: movq $0x0, 0x627f(%rip) ; lazy cache variable for type metadata for Swift.Array<Swift.UInt8> + 4
//往第2个8个字节的第1个字节写入关联值`$0x0`
0x100000f71 <+17>: movq $0x0, 0x627c(%rip) ; TestSwift.style : TestSwift.Style + 4
//往第3个8个字节的第1个字节写入关联值`$0x0`
0x100000f7c <+28>: movq $0x0, 0x6279(%rip) ; TestSwift.style : TestSwift.Style + 12
//往第4个8个字节的第1个字节写入关联值`$0x0`
0x100000f87 <+39>: movq $0x0, 0x6276(%rip) ; TestSwift.style : TestSwift.Style + 20
//往第5个8个字节的第一个字节写入枚举类型
0x100000f92 <+50>: movb $0x5, 0x6277(%rip) ; TestSwift.style : TestSwift.Style + 31
0x100000f99 <+57>: movl %edi, -0x4(%rbp)
0x100000f9c <+60>: movq %rsi, -0x10(%rbp)
0x100000fa0 <+64>: popq %rbp
0x100000fa1 <+65>: retq
此时我们去查看当前实例的内存数据结构
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
05 00 00 00 00 00 00 00
var style = Style.plain
可以看到第33个字节处为枚举类型5
,即写入的立即数$0x5
0x100000f92 <+50>: movb $0x5, 0x6277(%rip) ; TestSwift.style : TestSwift.Style + 31
观察命令可以看到前面4个数据写入时都是movq
命令,而第5个数据写入时是movb
命令,这里的mov
都代表赋值操作,而后边的q
代表的是写入的字节数为8
,而b
代表1个字节,可以看到后面还出现了movl
的命令,这里的l
代表4个字节。还有这边未出现的movw
代表2个字节。
我们再去观察done
样式的汇编以及内存,这里我去掉多于不相干的东西,如下:
0x100000f66 <+6>: movq $0x1, 0x627f(%rip) ; lazy cache variable for type metadata for Swift.Array<Swift.UInt8> + 4
0x100000f71 <+17>: movq $0x0, 0x627c(%rip) ; TestSwift.style : TestSwift.Style + 4
0x100000f7c <+28>: movq $0x0, 0x6279(%rip) ; TestSwift.style : TestSwift.Style + 12
0x100000f87 <+39>: movq $0x0, 0x6276(%rip) ; TestSwift.style : TestSwift.Style + 20
0x100000f92 <+50>: movb $0x5, 0x6277(%rip) ; TestSwift.style : TestSwift.Style + 31
可以看到与上面是一致的,只是第一个字节写入的是1
而已,其实上面也提到过这两个样式虽然没有外部传入的关联值,实际上内部会给他生成默认的关联值,因此第一个样式的关联值为0
,这里的关联值为1
,且他们的样式字节都为$0x5
,那么我们可以推断,当我们在外部使用该实例变量的时候内部先通过样式字节(第33个字节)找到对应的样式,然后再去读取第一个字节去判断是plain
样式还是done
样式。
那你是不是可能会有这样的疑问,直接通过第一个字节去判断是什么样式可行吗?答案是否定的,因为前面的字节,包括第一个字节都是存储的是数据,后面的其他样式也会在第一个字节去存数据,所以第一个字节不能当做区分字节。
那难道不可以把第33个字节写入不同的数据用来区分不用的样式么,比如plain
样式的第33个为$0x1
,done
样式的第33个字节为$0x2
,后面只要保持这个字节完全不同不就可以区分不同的枚举实例吗。这个我也没答案,但是我认为这种思路也是可行的,可能是基于性能或者其他的因素而采用现有的方案吧,后续可以再深入研究了解一下。不过我们可以猜测一下。
因为这里的done
和plain
可以看做是同一种类型,那么我们从上层语言去思考假如我们现在有个业务就是定义,我们可以将其分为有参和无参两大类,那么无参的话只要区分是哪一种就可以了,有参相对来说也比较负责,每一种有参类型都是不一样的,那我么可以定义一个大的结构体一样,结构体下分为有参合无参的结构体,再之下就可以更加去细分类型了。当然这都是我的猜测,至于为何这样存储我也没有答案。
此时我们对比来看有一个关联值的情况下的内存分布:
/**
0A 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00
*/
var style = Style.style1(10)
/**
09 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
01
*/
var style = Style.style11(9)
同样的第一个字节用来存储关联值,第33个字节用来存储类型。
那么此时我们也可以推断出有3个Int
类型关联值和4个Int
类型关联值的枚举其内存接口也是如此,不同的就是3个关联值的是前3个8字节的第一个字节存储数据,4个关联值的是前4个8字节的第一个字节存储数据,直接看内存布局:
/**
0A 00 00 00 00 00 00 00
09 00 00 00 00 00 00 00
08 00 00 00 00 00 00 00
00 00 00 00 00 00 00 00
02
*/
var style = Style.style2(10, 9, 8)
/**
0A 00 00 00 00 00 00 00
09 00 00 00 00 00 00 00
08 00 00 00 00 00 00 00
07 00 00 00 00 00 00 00
03
*/
var style = Style.style3(10, 9, 8, 7)
但是如果是不同类型的关联值呢,比如上面枚举的最后一个枚举类型,同样它也会写入5次数据,其中前4次为关联值,最后一次为样式值。
/**
0A 00 00 00 00 00 00 00
09 00 00 00 00 00 00 00
00 01 00 00 00 00 00 00
00 00 00 00 00 00 00 00
04
*/
var style = Style.style4(10, 9, false, true)
可以看到样式类型一致,前面的关联值也是一定的,其中第3个bool
类型关联值在17个字节,而第18个字节是第4个bool
类型关联值。