[关闭]
@qidiandasheng 2021-04-08T18:17:10.000000Z 字数 9391 阅读 3366

链接器:符号是怎么绑定到地址上的?

iOS理论


编译时链接器做了什么?

Mach-O 文件里面的内容,主要就是代码和数据:代码是函数的定义;数据是全局变量的定义,包括全局变量的初始值。不管是代码还是数据,它们的实例都需要由符号将其关联起来。

为什么呢?因为 Mach-O 文件里的那些代码,比如 if、for、while 生成的机器指令序列,要操作的数据会存储在某个地方,变量符号就需要绑定到数据的存储地址。你写的代码还会引用其他的代码,引用的函数符号也需要绑定到该函数的地址上。

而链接器的作用,就是完成变量、函数符号和其地址绑定这样的任务。而这里我们所说的符号,就可以理解为变量名和函数名。

为什么要让链接器做符号和地址绑定

如果地址和符号不做绑定的话,要让机器知道你在操作什么内存地址,你就需要在写代码时给每个指令设好内存地址。写这样的代码的过程,就像你直接在和不同平台的机器沟通,连编译生成 AST 和 IR 的步骤都省掉了,甚至优化平台相关的代码都需要你自己编写。

这件事儿看起来挺酷,但可读性和可维护性都会很差,比如修改代码后对地址的维护就会让你崩溃。而这种“崩溃”的罪魁祸首就是代码和内存地址绑定得太早。

另外,绑定得太早除了可读性和可维护性差之外,还会有更多的重复工作。因为,你需要针对不同的平台写多份代码,而这些代码本可以通过高级语言一次编译成多份。既然这样,那我们应该怎么办呢?

我们首先想到的就是,用汇编语言来让这种绑定滞后。随着编程语言的进化,我们很快就发现,采用任何一种高级编程语言,都可以解决代码和内存绑定过早产生的问题,同时还能扫掉使用汇编写程序的烦恼。

为什么要把项目中的多个 Mach-O 文件合并成一个

你肯定不希望一个项目是在一个文件里从头写到尾的吧。项目中文件之间的变量和接口函数都是相互依赖的,所以这时我们就需要通过链接器将项目中生成的多个 Mach-O 文件的符号和地址绑定起来。

没有这个绑定过程的话,单个文件生成的 Mach-O 文件是无法正常运行起来的。因为,如果运行时碰到调用在其他文件中实现的函数的情况时,就会找不到这个调用函数的地址,从而无法继续执行。

链接器在链接多个目标文件的过程中,会创建一个符号表,用于记录所有已定义的和所有未定义的符号。链接时如果出现相同符号的情况,就会出现“ld: dumplicate symbols”的错误信息;如果在其他目标文件里没有找到符号,就会提示“Undefined symbols”的错误信息。

链接的过程

重定位的过程如下:

假设有个全局变量叫做var,它在目标文件A里面。我们在目标文件B里面要访问这个全局变量。由于在编译目标文件B的时候,编译器并不知道变量var的目标地址,所以编译器在没法确定的情况下,将目标地址设置为0,等待链接器在目标文件A和B连接起来的时候将其修正。这个地址修正的过程被叫做重定位,每个被修正的地方叫一个重定位入口。

在链接中,我们将函数和变量统称为符号(Symbol),函数名或变量名就是符号名(Symbol Name)。

静态链接和动态链接

由于链接形式的不同,产生了静态链接和动态链接。

我们常常会链接一些公用库,链接的共用库分为静态库和动态库:静态库是编译时链接的库,需要链接进你的 Mach-O 文件里,如果需要更新就要重新编译一次,无法动态加载和更新;而动态库是运行时链接的库,使用 dyld 就可以实现动态加载。

什么是dyld

dyld(the dynamic link editor)是苹果的动态链接器,是苹果操作系统的一个重要组成部分,当系统内核做好启动程序的准备工作之后,余下的工作会交给dyld来负责处理。

dyld做了以下几件事情:

静态链接

整个链接过程分为两步:

第一步:空间与地址的分配:

第二步:符号解析与重定位:

动态链接

Mach-O 文件是编译后的产物,而动态库在运行时才会被链接,并没参与 Mach-O 文件的编译和链接,所以 Mach-O 文件中并没有包含动态库里的符号定义。也就是说,这些符号会显示为“未定义”,但它们的名字和对应的库的路径会被记录下来。

运行时通过dlopendlsym 导入动态库时,先根据记录的库路径找到对应的库,再通过记录的名字符号找到绑定的地址。

dlopen 会把共享库载入运行进程的地址空间,载入的共享库也会有未定义的符号,这样会触发更多的共享库被载入。dlopen 也可以选择是立刻解析所有引用还是滞后去做。dlopen 打开动态库后返回的是引用的指针,dlsym 的作用就是通过 dlopen 返回的动态库指针和函数符号,得到函数的地址然后使用。

dlopen 和 dlsym

Linux/unix 提供了使用 dlopen 和 dlsym 方法动态加载库和调用函数,这套方法在 macOS 和 iOS 上也支持。

动态调用 printf 函数,编写测试代码如下:

  1. #import <dlfcn.h>
  2. typedef int (*printf_func_pointer) (const char * __restrict, ...);
  3. void dynamic_call_function(){
  4. //动态库路径
  5. char *dylib_path = "/usr/lib/libSystem.dylib";
  6. //打开动态库
  7. void *handle = dlopen(dylib_path, RTLD_GLOBAL | RTLD_NOW);
  8. if (handle == NULL) {
  9. //打开动态库出错
  10. fprintf(stderr, "%s\n", dlerror());
  11. } else {
  12. //获取 printf 地址
  13. printf_func_pointer printf_func = dlsym(handle, "printf");
  14. //地址获取成功则调用
  15. if (printf_func) {
  16. int num = 100;
  17. printf_func("Hello exchen.net %d\n", num);
  18. printf_func("printf function address 0x%lx\n", printf_func);
  19. }
  20. dlclose(handle); //关闭句柄
  21. }
  22. }
  23. int main(int argc, char * argv[]) {
  24. @autoreleasepool {
  25. dynamic_call_function();
  26. return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
  27. }
  28. }

加载动态库的方式有两种

dyld 加载动态库符号绑定时机

加载过程开始会修正地址偏移,iOS 会用 ASLR 来做地址偏移避免攻击,确定 Non-Lazy Pointer 地址进行符号地址绑定,加载所有类,最后执行 load 方法和 Clang Attributeconstructor 修饰函数。

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

使用dyld链接的可执行文件

编写多个文件

Boy.h:

  1. #import <Foundation/Foundation.h>
  2. @interface Boy : NSObject
  3. - (void)say;
  4. @end

Boy.m

  1. #import “Boy.h”
  2. @implementation Boy
  3. - (void)say
  4. {
  5. NSLog(@“hi there again!\n”);
  6. }
  7. @end

main.m

  1. #import "Boy.h"
  2. int main(int argc, char * argv[]) {
  3. @autoreleasepool {
  4. Boy *boy = [[Boy alloc] init];
  5. [boy say];
  6. return 0;
  7. }
  8. }

编译多个文件

  1. xcrun clang -c Boy.m
  2. xcrun clang -c main.m

链接生成可执行文件

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

  1. xcrun clang main.o Boy.o -Wl,`xcrun --show-sdk-path`/System/Library/Frameworks/Foundation.framework/Foundation

查看符号

符号表会规定它们的符号,你可以使用 nm 工具查看。

我们先用 nm 工具看一下 main.o 文件:

  1. // 也可以这个方法查看.a文件的符号
  2. xcrun nm -nm main.o

输出:

  1. (undefined) external _OBJC_CLASS_$_Boy
  2. (undefined) external _objc_alloc_init
  3. (undefined) external _objc_autoreleasePoolPop
  4. (undefined) external _objc_autoreleasePoolPush
  5. (undefined) external _objc_msgSend
  6. 0000000000000000 (__TEXT,__text) external _main
  7. 0000000000000060 (__DATA,__objc_classrefs) non-external _OBJC_CLASSLIST_REFERENCES_$_
  8. 0000000000000070 (__DATA,__objc_selrefs) non-external _OBJC_SELECTOR_REFERENCES_

接下来,我们再看看 Boy.o 文件:

  1. xcrun nm -nm Boy.o

输出:

  1. (undefined) external _NSLog
  2. (undefined) external _OBJC_CLASS_$_NSObject
  3. (undefined) external _OBJC_METACLASS_$_NSObject
  4. (undefined) external ___CFConstantStringClassReference
  5. (undefined) external __objc_empty_cache
  6. 0000000000000000 (__TEXT,__text) non-external -[Boy say]
  7. 0000000000000060 (__DATA,__objc_const) non-external __OBJC_METACLASS_RO_$_Boy
  8. 00000000000000a8 (__DATA,__objc_const) non-external __OBJC_$_INSTANCE_METHODS_Boy
  9. 00000000000000c8 (__DATA,__objc_const) non-external __OBJC_CLASS_RO_$_Boy
  10. 0000000000000110 (__DATA,__objc_data) external _OBJC_METACLASS_$_Boy
  11. 0000000000000138 (__DATA,__objc_data) external _OBJC_CLASS_$_Boy
  12. 0000000000000170 (__DATA,__objc_classlist) non-external _OBJC_LABEL_CLASS_$

undefined 符号

undefined 符号表示的是该文件类未定义,我们看到main.o_OBJC_CLASS_$_Boyundefined,而在链接生成的a.out执行文件里_OBJC_CLASS_$_Boy不为undefined,而在DATA段里面,这个过程就是上面说的符号解析

Boy.o_OBJC_CLASS_$_Boy的地址为0000000000000138,而在a.out_OBJC_CLASS_$_Boy的地址变为为0000000100002108,这就是上面说的地址重定位

  1. xcrun nm -nm a.out

输出:

  1. (undefined) external _NSLog (from Foundation)
  2. (undefined) external _OBJC_CLASS_$_NSObject (from libobjc)
  3. (undefined) external _OBJC_METACLASS_$_NSObject (from libobjc)
  4. (undefined) external ___CFConstantStringClassReference (from CoreFoundation)
  5. (undefined) external __objc_empty_cache (from libobjc)
  6. (undefined) external _objc_alloc_init (from libobjc)
  7. (undefined) external _objc_autoreleasePoolPop (from libobjc)
  8. (undefined) external _objc_autoreleasePoolPush (from libobjc)
  9. (undefined) external _objc_msgSend (from libobjc)
  10. (undefined) external dyld_stub_binder (from libSystem)
  11. 0000000100000000 (__TEXT,__text) [referenced dynamically] external __mh_execute_header
  12. 0000000100000eb0 (__TEXT,__text) external _main
  13. 0000000100000f10 (__TEXT,__text) non-external -[Boy say]
  14. 0000000100001030 (__DATA_CONST,__objc_classlist) non-external _OBJC_LABEL_CLASS_$
  15. 0000000100002020 (__DATA,__objc_const) non-external __OBJC_METACLASS_RO_$_Boy
  16. 0000000100002068 (__DATA,__objc_const) non-external __OBJC_$_INSTANCE_METHODS_Boy
  17. 0000000100002088 (__DATA,__objc_const) non-external __OBJC_CLASS_RO_$_Boy
  18. 00000001000020e0 (__DATA,__objc_data) external _OBJC_METACLASS_$_Boy
  19. 0000000100002108 (__DATA,__objc_data) external _OBJC_CLASS_$_Boy
  20. 0000000100002130 (__DATA,__data) non-external __dyld_private

在目标文件和 Fundation framework动态库做链接处理时,链接器会尝试解析所有的 undefined 符号(我们能看到后面会显示所属的动态库)。

链接器通过动态库解析成符号会记录是通过哪个动态库解析的,路径也会一起记录下来。

注:
截屏2020-07-13 上午8.57.25.png-354.4kB
我们能看到已静态链接的可执行文件里面指针的偏移量和指针指向的值都是已经确定的(Value跟我们上面xcrun nm -nm a.out输出的一致)。

查看undefined符号所需的库

我们可以通过 otool 工具来找到符号所需库在哪儿:

  1. xcrun otool -L a.out

输出:

  1. a.out:
  2. /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 1675.129.0)
  3. /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1281.100.1)
  4. /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1675.129.0)
  5. /usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)

从 otool 工具输出的结果可以看到,这些 undefined 符号需要的两个库分别是 libSystemlibobjc。查看 libSystem 库的话,你可以看到常用的 GCD 的 libdispatch,还有 Blocklibsystem_blocks

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

我们可以打印看看什么库被加载了:

  1. (export DYLD_PRINT_LIBRARIES=; ./a.out )

输出:

  1. dyld: loaded: <0FCA1BA4-F1AC-3528-9ED7-31D653EF7923> /Users/dasheng/Desktop/DSDyldDemo/DSDyldDemo/./a.out
  2. dyld: loaded: <9A74FA97-7F7B-3929-B381-D9514B1E4754> /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation
  3. dyld: loaded: <DB8310F1-272D-3533-A840-3B390AF55C26> /usr/lib/libSystem.B.dylib
  4. dyld: loaded: <9E632A1E-9622-33D6-BCCE-23AC16DAA6B7> /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
  5. dyld: loaded: <20AC082F-2DB7-3974-A2D4-8C5E01787584> /usr/lib/libobjc.A.dylib
  6. .........

我们发现加载的库特别多,因为 Fundation 还会依赖一些其他动态库,这些依赖的其他库还会再依赖更多的库,所以相互依赖的符号会很多,需要处理的时间也会比较长。

这里系统上的动态链接器会使用共享缓存,共享缓存在 /var/db/dyld/。当加载 Mach-O 文件时,动态链接器会先检查是否有共享缓存。每个进程都会在自己的地址空间映射这些共享缓存,这样做可以起到优化 App 启动速度的作用。

运行时dyld加载动态库

我们上面说过dyld加载动态库符号绑定时机有程序启动时绑定和符号第一次被用到时绑定。

我们上面的例子中的NSLog符号就是在第一次被用到时才绑定的。

生成可执行文件

上面的可执行文件我们是直接命令行编译生成的,因为动态链接是在运行时的,所以现在我们通过Xcode来生成并查看动态链接的过程。

我们同样的代码放入Xcode里面,然后编译运行,我们查看Xcode编译阶段的日志,如下图所示:

截屏2020-07-12 下午8.29.55.png-1776.8kB

红框标注的地方就是我们上面命令行所生成目标文件,我们这里直接展开最后生成的可执行文件DSDyldDemo(也就是我们上面生成的a.out),可以查看到可执行文件的路径。

拿到可执行文件后我们可以使用MachOView查看,如下图所示,NSLog符号在Section64(__DATA,__la_symbol_ptr)里:

截屏2020-07-12 下午8.39.10.png-581.7kB

获得符号偏移量

上图中Offset是符号(指针)的偏移量,偏移量在编译好的可执行文件中是固定的,而可执行文件每次被重新装载到内存中时被系统分配的起始地址(在 lldb 中用命令image List获取)是不断变化的。运行中的静态函数指针地址其实就等于上述 Offset + Mach0 文件在内存中的首地址。

查看可执行文件的起始地址:
截屏2020-07-12 下午8.59.28.png-113.2kB

Offset值为0x4000,可执行文件起始地址值为0x0000000104d8e000,所以0x4000+0x0000000104d8e000就是用于重定向到共享库中NSLog函数地址的那个符号(指针)的内存地址。

查看指针保存的值

我们现在如下位置打上断点:
截屏2020-07-12 下午9.06.02.png-94.7kB

截屏2020-07-12 下午9.08.03.png-406.3kB

  1. 拿到该指针当前保存的值,iOS 的 CPU 是小端序,当前机型为 64 位 CPU,所以倒序读 8 个字节就是指针的值:0x0104d8f4cc
  2. dis -s 是反汇编命令,我们发现此时该指针指向的函数正在调用系统动态绑定的函数
  3. 进一步查看调用函数详细信息:libdyld.dylib`dyld_stub_binder

接下来我们过掉第一次断点,再次查看符号表中该指针(依然是 0x4000+0x0000000104d8e000 这个地址)所指向的地址:

截屏2020-07-12 下午9.10.35.png-89.9kB

截屏2020-07-12 下午9.09.35.png-214.6kB

我们发现,它指向的地址由之前的 0x0104d8f4cc 变为 0x7fff25931294 了,对应的函数也由之前的 dyld_stub_binder 变为 NSLog ,这意味着该函数的动态绑定已经完成。

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注