代码到可执行文件
代码要运行,首先需要被编译(广义) 为可执行文件。这个过程大致可分为预处理、编译、汇编及链接。
预处理是字符串处理过程,不涉及编程中类型之类的概念。#与##以及二次扫描等注意点。
编译:这里是狭义概念,将高层语言代码翻译为汇编的过程,包括词法语法分析,中间语言与优化,后端对应到相关平台。
汇编:将汇编指令替换为对应的机器指令,输出.o文件。
链接:以上三步都是分别在各自的模块(文件中)进行的,最后需要将很多个代码文件合并为一个可执行文件输出。链接就是将上面的多个模块(文件)合并为一个大的文件。核心是修正具体的地址:当被合并为一个大文件之后,函数的具体地址,全局变量的地址会最终被确定下来,需要把使用到这些符号的地方替换为最终确定下来的地址。
动态链接
上述过程中的链接是静态链接,静态链接所设计的代码等,都需要打到可执行文件中。如果只有静态链接,那么意味着一些公共的基础库在每一个可执行文件中都有一份拷贝。假如标准C库的大小为4M,那么如果有100个进程,则会有100份的标准C库的拷贝,虽然有内存换出等技术,这400M不会同时存在于内存中,但是有极大的概率会增加换出的频率,造成性能的损失。
有什么好的方法可以解决这个问题么?很明显,如果一个库会被多个程序使用到,那我们希望这个库被多个程序共享,在内存里只有一份的话,整个系统的效率就会高的多。对于系统库来说更是符合这种场景。
这个问题就变为如果一个库已经被一个进程使用,已经加载到了内存中,其他程序如果可以使用到相同的内存,以做到共享。动态链接技术应用而生。
我们共享的是库里的不可变的部分,比如代码段的内容等,对于可变的部分,还是需要每个进程自己保留一份的,比如可变的数据段的部分。
动态链接发生在可执行文件装载的过程中,其核心还是地址的修正。只要在进入main函数之前,将使用到的共享库中的地址都修正好,在main函数之后执行就不会有问题。
具体任务:
- 在装载过程中,将共享库的mach-o文件映射到我的虚拟地址空间,这样共享库中的函数,全局变量等一切都有了我这个地址的虚拟空间的一个地址。
- 因为这些地址是刚分配的,所以需要对使用这些地址的地方做一些修正。
两个问题
共享库内部
由于共享库被动态链接到不同的进程是 对应的虚拟地址是随机的,所以内部函数的相互调用等不能使用绝对地址,解决办法是将代码编译为位置无关码,地址使用相对位置,rip+地址偏移的形式。
外部
对于外部调用共享库的函数的地方来说,一般在代码段,做地址的修正是不能对代码段进行修改的,代码段是只读的,不能被修改。解决办法是在数据段修复地址,这就是 __got的作用。
懒加载
对于共享库的每一个依赖,extern 全局变量 或者 函数调用,都需要在数据段搞一个引用来占位,然后在动态链接的时候修正这些地址。但是在启动时候全部修正的话,工作量比较大,会增加启动耗时,解决办法是使用懒加载,在第一次使用到这个地址的时候,再修正。以此解决宝贵的启动时间。
在mach-o文件中,可以看到所有需要修正的符号,并且区分了懒加载与非懒加载。对于全局变量等数据,采用非懒加载的方式。对于函数来说,使用懒加载的方式。
汇编查看懒加载的过程
上图是我们代码上调用printf对应的汇编代码,发现这里调用了call指令,地址是一个位于代码段桩区的一个地址:
这个地址的内容是依据汇编代码:
FF2594400000 占用6个字节,表示一个跳转语句。
就是直接跳转到 0x0000000100003fa0 这个地址。
0x0000000100003fa0的地址是相对寻址:rip的值 + 0x4094
rip的值是下一句汇编语句的值,可以通过当前汇编语句的地址值 + 当前汇编指令的长度。
发现算出来的地址是在数据段的一个地址:
注意到桩上的汇编代码的 前面有一个*,是取地址的意思 里面取出来的正好是 0x0000000100003fa0,
再看0x0000000100003fa0地址
这里先push了一个立即数,是0x16
然后跳走了,跳到 0x100003f7c 这个地址。
具体来看一下 0x0000000100003fa0 和 0x100003f7c 都在一个叫做 __Stub_helper的section(区)里。
这个区域里的从第5行开始的内容可以看做是存了一张表,表里的每一项(每一项占用两行)对应着__stubs(桩区)的一个符号。每一项之间只有push语句后面的立即数是不同的,最后都是跳转到头部的地址。
头部的内容是将_dyld_private这个函数的地址放到的R11寄存器里,并且同时压到栈里。
然后跳转到dyld_stub_binder这个函数里。
dyld_stub_binder函数的作用就是用于动态的找到共享库中一个函数的地址,然后动态绑定到数据区的占位符上。
dyld_stub_binder函数里会调用_dyld_fast_stub_entry(void*, long)函数,并且会动栈上的两个位置取出数据作为两个参数。发现这个两个参数正好就是上面push到栈里的 立即数0x16 以及 _dyld_private函数的地址。
之后的调用栈分别是:
dyld`dyld::fastBindLazySymbol
dyld`ImageLoaderMachOCompressed::doBindFastLazySymbol
getLazyBindingInfo通过传入的立即数,找到绑定所需要的信息
ImageLoaderMachOCompressed::bindAt 找到正确的函数地址 同时将地址写到数据区,并返回。
从上图我们可以看到:调用完_dyld_fast_stub_entry函数之后,rax的返回值是printf的地址。并且数据区上的占位的地址也不是初始化的0x0000000100003fa0,而是写入了真正的地址。这样在下一次调用printf的时候,通过装上的跳转指令可以直接跳到printf真正的函数地址,不需要再找一次了。
做完懒加载之后,恢复了各个寄存器的值,恢复调用栈,然后直接跳转到printf的地址,去执行printf函数相关功能。
我们看到倒数第二行指令,对栈顶做了一个 + 0x10 的操作,是因为开始懒加载之后,为了传递参数,使用了两次push操作,所以懒加载完成之后,要将这两个push出栈,恢复懒加载之前的现场。
从上面两个图,我们发现,在进入_stub_helper区开始懒加载的时候,push到栈里的立即数就是对应着lazy bindig info表的偏移量。这样在找到真正的函数地址之后,就可以通过这个变异量找到懒加载的信息,从而可以将地址写到数据区了。