@qidiandasheng
2021-04-08T18:17:10.000000Z
字数 9391
阅读 3401
iOS理论
Mach-O 文件里面的内容,主要就是代码和数据:代码是函数的定义;数据是全局变量的定义,包括全局变量的初始值。不管是代码还是数据,它们的实例都需要由符号将其关联起来。
为什么呢?因为 Mach-O 文件里的那些代码,比如 if、for、while 生成的机器指令序列,要操作的数据会存储在某个地方,变量符号就需要绑定到数据的存储地址。你写的代码还会引用其他的代码,引用的函数符号也需要绑定到该函数的地址上。
而链接器的作用,就是完成变量、函数符号和其地址绑定这样的任务。而这里我们所说的符号,就可以理解为变量名和函数名。
如果地址和符号不做绑定的话,要让机器知道你在操作什么内存地址,你就需要在写代码时给每个指令设好内存地址。写这样的代码的过程,就像你直接在和不同平台的机器沟通,连编译生成 AST 和 IR 的步骤都省掉了,甚至优化平台相关的代码都需要你自己编写。
这件事儿看起来挺酷,但可读性和可维护性都会很差,比如修改代码后对地址的维护就会让你崩溃。而这种“崩溃”的罪魁祸首就是代码和内存地址绑定得太早。
另外,绑定得太早除了可读性和可维护性差之外,还会有更多的重复工作。因为,你需要针对不同的平台写多份代码,而这些代码本可以通过高级语言一次编译成多份。既然这样,那我们应该怎么办呢?
我们首先想到的就是,用汇编语言来让这种绑定滞后。随着编程语言的进化,我们很快就发现,采用任何一种高级编程语言,都可以解决代码和内存绑定过早产生的问题,同时还能扫掉使用汇编写程序的烦恼。
你肯定不希望一个项目是在一个文件里从头写到尾的吧。项目中文件之间的变量和接口函数都是相互依赖的,所以这时我们就需要通过链接器将项目中生成的多个 Mach-O 文件的符号和地址绑定起来。
没有这个绑定过程的话,单个文件生成的 Mach-O 文件是无法正常运行起来的。因为,如果运行时碰到调用在其他文件中实现的函数的情况时,就会找不到这个调用函数的地址,从而无法继续执行。
链接器在链接多个目标文件的过程中,会创建一个符号表,用于记录所有已定义的和所有未定义的符号。链接时如果出现相同符号的情况,就会出现“ld: dumplicate symbols”
的错误信息;如果在其他目标文件里没有找到符号,就会提示“Undefined symbols”
的错误信息。
重定位的过程如下:
假设有个全局变量叫做var,它在目标文件A里面。我们在目标文件B里面要访问这个全局变量。由于在编译目标文件B的时候,编译器并不知道变量var的目标地址,所以编译器在没法确定的情况下,将目标地址设置为0,等待链接器在目标文件A和B连接起来的时候将其修正。这个地址修正的过程被叫做重定位,每个被修正的地方叫一个重定位入口。
在链接中,我们将函数和变量统称为符号(Symbol
),函数名或变量名就是符号名(Symbol Name
)。
由于链接形式的不同,产生了静态链接和动态链接。
我们常常会链接一些公用库,链接的共用库分为静态库和动态库:静态库是编译时链接的库,需要链接进你的 Mach-O
文件里,如果需要更新就要重新编译一次,无法动态加载和更新;而动态库是运行时链接的库,使用 dyld
就可以实现动态加载。
dyld(the dynamic link editor
)是苹果的动态链接器,是苹果操作系统的一个重要组成部分,当系统内核做好启动程序的准备工作之后,余下的工作会交给dyld来负责处理。
dyld做了以下几件事情:
先执行 Mach-O 文件,根据 Mach-O 文件里 undefined
的符号加载对应的动态库,系统会设置一个共享缓存来解决加载的递归依赖问题;
加载后,将 undefined
的符号绑定到动态库里对应的地址上;
最后再处理 +load
方法,main
函数返回后运行static terminator
。
整个链接过程分为两步:
第一步:空间与地址的分配:
去项目文件里查找目标代码文件里没有定义的变量。
扫描项目中的不同文件,将所有符号定义和引用地址收集起来,并放到全局符号表中。
计算合并后长度及位置,生成同类型的段进行合并,建立绑定。
第二步:符号解析与重定位:
Mach-O 文件是编译后的产物,而动态库在运行时才会被链接,并没参与 Mach-O 文件的编译和链接,所以 Mach-O 文件中并没有包含动态库里的符号定义。也就是说,这些符号会显示为“未定义”,但它们的名字和对应的库的路径会被记录下来。
运行时通过dlopen
和 dlsym
导入动态库时,先根据记录的库路径找到对应的库,再通过记录的名字符号找到绑定的地址。
dlopen
会把共享库载入运行进程的地址空间,载入的共享库也会有未定义的符号,这样会触发更多的共享库被载入。dlopen
也可以选择是立刻解析所有引用还是滞后去做。dlopen
打开动态库后返回的是引用的指针,dlsym
的作用就是通过 dlopen
返回的动态库指针和函数符号,得到函数的地址然后使用。
Linux/unix 提供了使用 dlopen 和 dlsym 方法动态加载库和调用函数,这套方法在 macOS 和 iOS 上也支持。
动态调用 printf
函数,编写测试代码如下:
#import <dlfcn.h>
typedef int (*printf_func_pointer) (const char * __restrict, ...);
void dynamic_call_function(){
//动态库路径
char *dylib_path = "/usr/lib/libSystem.dylib";
//打开动态库
void *handle = dlopen(dylib_path, RTLD_GLOBAL | RTLD_NOW);
if (handle == NULL) {
//打开动态库出错
fprintf(stderr, "%s\n", dlerror());
} else {
//获取 printf 地址
printf_func_pointer printf_func = dlsym(handle, "printf");
//地址获取成功则调用
if (printf_func) {
int num = 100;
printf_func("Hello exchen.net %d\n", num);
printf_func("printf function address 0x%lx\n", printf_func);
}
dlclose(handle); //关闭句柄
}
}
int main(int argc, char * argv[]) {
@autoreleasepool {
dynamic_call_function();
return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class]));
}
}
dlopen
和 dlsym
来加载。这种方式,在编译时是不需要参与链接的。 加载过程开始会修正地址偏移,iOS 会用 ASLR 来做地址偏移避免攻击,确定 Non-Lazy Pointer
地址进行符号地址绑定,加载所有类,最后执行 load
方法和 Clang Attribute
的 constructor
修饰函数。
每个函数、全局变量和类都是通过符号的形式定义和使用的,当把目标文件链接成一个 Mach-O 文件时,链接器在目标文件和动态库之间对符号做解析处理。
Boy.h:
#import <Foundation/Foundation.h>
@interface Boy : NSObject
- (void)say;
@end
Boy.m
#import “Boy.h”
@implementation Boy
- (void)say
{
NSLog(@“hi there again!\n”);
}
@end
main.m
#import "Boy.h"
int main(int argc, char * argv[]) {
@autoreleasepool {
Boy *boy = [[Boy alloc] init];
[boy say];
return 0;
}
}
xcrun clang -c Boy.m
xcrun clang -c main.m
将编译后的文件链接起来,这样就可以生成 a.out
可执行文件了。
xcrun clang main.o Boy.o -Wl,`xcrun --show-sdk-path`/System/Library/Frameworks/Foundation.framework/Foundation
符号表会规定它们的符号,你可以使用 nm 工具查看。
我们先用 nm
工具看一下 main.o
文件:
// 也可以这个方法查看.a文件的符号
xcrun nm -nm main.o
输出:
(undefined) external _OBJC_CLASS_$_Boy
(undefined) external _objc_alloc_init
(undefined) external _objc_autoreleasePoolPop
(undefined) external _objc_autoreleasePoolPush
(undefined) external _objc_msgSend
0000000000000000 (__TEXT,__text) external _main
0000000000000060 (__DATA,__objc_classrefs) non-external _OBJC_CLASSLIST_REFERENCES_$_
0000000000000070 (__DATA,__objc_selrefs) non-external _OBJC_SELECTOR_REFERENCES_
_OBJC_CLASS_$_Boy ,表示 Boy 的 OC 符号。
external ,表示非私有。如果是私有的话,就是 non-external。
external _main ,表示 main() 函数,处理 0 地址,记录在 __TEXT,__text 区域里。
接下来,我们再看看 Boy.o 文件:
xcrun nm -nm Boy.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 -[Boy say]
0000000000000060 (__DATA,__objc_const) non-external __OBJC_METACLASS_RO_$_Boy
00000000000000a8 (__DATA,__objc_const) non-external __OBJC_$_INSTANCE_METHODS_Boy
00000000000000c8 (__DATA,__objc_const) non-external __OBJC_CLASS_RO_$_Boy
0000000000000110 (__DATA,__objc_data) external _OBJC_METACLASS_$_Boy
0000000000000138 (__DATA,__objc_data) external _OBJC_CLASS_$_Boy
0000000000000170 (__DATA,__objc_classlist) non-external _OBJC_LABEL_CLASS_$
undefined
符号表示的是该文件类未定义,我们看到main.o
里_OBJC_CLASS_$_Boy
为undefined
,而在链接生成的a.out
执行文件里_OBJC_CLASS_$_Boy
不为undefined
,而在DATA
段里面,这个过程就是上面说的符号解析。
在Boy.o
里_OBJC_CLASS_$_Boy
的地址为0000000000000138
,而在a.out
里_OBJC_CLASS_$_Boy
的地址变为为0000000100002108
,这就是上面说的地址重定位。
xcrun nm -nm a.out
输出:
(undefined) external _NSLog (from Foundation)
(undefined) external _OBJC_CLASS_$_NSObject (from libobjc)
(undefined) external _OBJC_METACLASS_$_NSObject (from libobjc)
(undefined) external ___CFConstantStringClassReference (from CoreFoundation)
(undefined) external __objc_empty_cache (from libobjc)
(undefined) external _objc_alloc_init (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
0000000100000eb0 (__TEXT,__text) external _main
0000000100000f10 (__TEXT,__text) non-external -[Boy say]
0000000100001030 (__DATA_CONST,__objc_classlist) non-external _OBJC_LABEL_CLASS_$
0000000100002020 (__DATA,__objc_const) non-external __OBJC_METACLASS_RO_$_Boy
0000000100002068 (__DATA,__objc_const) non-external __OBJC_$_INSTANCE_METHODS_Boy
0000000100002088 (__DATA,__objc_const) non-external __OBJC_CLASS_RO_$_Boy
00000001000020e0 (__DATA,__objc_data) external _OBJC_METACLASS_$_Boy
0000000100002108 (__DATA,__objc_data) external _OBJC_CLASS_$_Boy
0000000100002130 (__DATA,__data) non-external __dyld_private
在目标文件和 Fundation framework
动态库做链接处理时,链接器会尝试解析所有的 undefined
符号(我们能看到后面会显示所属的动态库)。
链接器通过动态库解析成符号会记录是通过哪个动态库解析的,路径也会一起记录下来。
注:
我们能看到已静态链接的可执行文件里面指针的偏移量和指针指向的值都是已经确定的(Value跟我们上面xcrun nm -nm a.out
输出的一致)。
我们可以通过 otool
工具来找到符号所需库在哪儿:
xcrun otool -L a.out
输出:
a.out:
/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 1675.129.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1281.100.1)
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1675.129.0)
/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
从 otool 工具输出的结果可以看到,这些 undefined
符号需要的两个库分别是 libSystem
和 libobjc
。查看 libSystem
库的话,你可以看到常用的 GCD 的 libdispatch
,还有 Block
的 libsystem_blocks
。
dylib
这种格式,表示是动态链接的,编译的时候不会被编译到执行文件中,在程序执行的时候才 link
,这样就不用算到包大小里,而且不更新执行程序就能够更新库。
我们可以打印看看什么库被加载了:
(export DYLD_PRINT_LIBRARIES=; ./a.out )
输出:
dyld: loaded: <0FCA1BA4-F1AC-3528-9ED7-31D653EF7923> /Users/dasheng/Desktop/DSDyldDemo/DSDyldDemo/./a.out
dyld: loaded: <9A74FA97-7F7B-3929-B381-D9514B1E4754> /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation
dyld: loaded: <DB8310F1-272D-3533-A840-3B390AF55C26> /usr/lib/libSystem.B.dylib
dyld: loaded: <9E632A1E-9622-33D6-BCCE-23AC16DAA6B7> /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
dyld: loaded: <20AC082F-2DB7-3974-A2D4-8C5E01787584> /usr/lib/libobjc.A.dylib
.........
我们发现加载的库特别多,因为 Fundation 还会依赖一些其他动态库,这些依赖的其他库还会再依赖更多的库,所以相互依赖的符号会很多,需要处理的时间也会比较长。
这里系统上的动态链接器会使用共享缓存,共享缓存在 /var/db/dyld/。当加载 Mach-O 文件时,动态链接器会先检查是否有共享缓存。每个进程都会在自己的地址空间映射这些共享缓存,这样做可以起到优化 App 启动速度的作用。
我们上面说过dyld加载动态库符号绑定时机有程序启动时绑定和符号第一次被用到时绑定。
我们上面的例子中的NSLog
符号就是在第一次被用到时才绑定的。
上面的可执行文件我们是直接命令行编译生成的,因为动态链接是在运行时的,所以现在我们通过Xcode
来生成并查看动态链接的过程。
我们同样的代码放入Xcode
里面,然后编译运行,我们查看Xcode
编译阶段的日志,如下图所示:
红框标注的地方就是我们上面命令行所生成目标文件,我们这里直接展开最后生成的可执行文件DSDyldDemo
(也就是我们上面生成的a.out
),可以查看到可执行文件的路径。
拿到可执行文件后我们可以使用MachOView
查看,如下图所示,NSLog
符号在Section64(__DATA,__la_symbol_ptr)
里:
上图中Offset
是符号(指针)的偏移量,偏移量在编译好的可执行文件中是固定的,而可执行文件每次被重新装载到内存中时被系统分配的起始地址(在 lldb 中用命令image List获取)是不断变化的。运行中的静态函数指针地址其实就等于上述 Offset + Mach0
文件在内存中的首地址。
查看可执行文件的起始地址:
Offset
值为0x4000
,可执行文件起始地址值为0x0000000104d8e000
,所以0x4000+0x0000000104d8e000
就是用于重定向到共享库中NSLog函数地址的那个符号(指针)的内存地址。
我们现在如下位置打上断点:
0x0104d8f4cc
接下来我们过掉第一次断点,再次查看符号表中该指针(依然是 0x4000+0x0000000104d8e000 这个地址)所指向的地址:
我们发现,它指向的地址由之前的 0x0104d8f4cc 变为 0x7fff25931294 了,对应的函数也由之前的 dyld_stub_binder
变为 NSLog
,这意味着该函数的动态绑定已经完成。