# DYLD 及 Mach-O 什么是 DYLD?dynamic loader,动态加载器。在 MacOS/iOS 中,是使用 `/usr/lib/dyld` 程序来加载动态库的。 ## 从源文件到可执行文件做了什么? 问:源文件 -> 可执行文件,是不是只需要经过编译就够了? 不是的。从源文件到可执行文件,需要经过编译、链接2个步骤: - 编译:将源代码转为了二进制机器指令。保存这些二进制机器指令的文件,叫做目标文件 `.o` 文件。每个源文件对应一个目标文件。 - 链接:得到目标文件怎么变成可执行程序呢?链接器将这些目标文件打包成最终可执行程序。除了打包目标文件外,链接器还会打包一个非常重要的东西,就是标准库。可执行程序 = 程序员写的代码 + 使用到的标准库(动态库/静态库)。 那看上去链接器做的事情很简单,不就是个打包工具?**链接器最重要的工作就是决定符号(变量名、函数名)的定义** ```c++ #include int main() { NSLog(@"Hello world"); return 0; } ``` 例如上面的代码 `main.m`。编译器在编译 `main.m` 时遇到 NSLog,根本不知道这个 `NSLog` 符号定义在哪里,这不是编译器该关心的事情。因此,编译器只能看到局部,只聚焦关心一个当前的源文件。到底谁来关心这个 NSLog 符号定义在哪呢?这就是链接器。 链接器打包所有的目标文件,因为链接器可以看到全局,具有上帝视角,因此链接器从依赖的库中去查找 NSLog 这个符号,如果找不到则会报经典的错误 `undefined reference to ***` 编译器只能将 NSLog 这个函数的跳转地址暂时设置为0,随后在链接的时候再去修正它。 一步步验证下: 第一步,将 main.m 编译为 main.o 文件。指令如下 ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -c main.m -o main.o ``` 第二步,已经得到了 main.o 目标文件,也就是二进制文件,利用指令 `objdump -r main.o` 查看目标文件中的内容 可以看到 main 函数中,callq 就是调用 NSLog 函数。后面的地址写为了 0,这里的0会在后面链接的过程中被修正。 第三步,另外为了能让链接器能够定位到这些需要被修正的地址,在代码块中可以看到一个重定位表。指令为 `objdump -r main.o` NSLog 位于偏移量为19的位置, 第四步,链接目标文件到可执行文件,指令为 ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ main.o -o main ``` 因为目前 Foundation 已经存在于系统目录,所以不需要额外指定动态库/静态库路径了。 ## 输出重定向 终端输入 `tty` 敲回车,然后在 Xcode 脚本中,就可以将 echo 输出的结果重定向到终端 `echo "message" > /dev/ttys000` 因为要编写脚本,研究 MachO 文件,但每次编译后的 MachO 还要 show in Finder,之后再切换到终端,然后执行脚本很繁琐。所以可以直接在 Xcode 的 Build Phases 中,增加脚本解决该问题。 但 Build Phases 中的 Run Script 长度有限,不能写很多脚本,所以还是需要结合 Xcconfig。 Xcconfig 中定义的变量在 Run Script 中是可以访问的到。 Demo 验证如下: ## 符号可见性 按照先后顺序 - 生成目标文件阶段:`-O1、O2、O3、Os、Oz` - 链接:死代码剥离 dead code strip - 编译后的产物 mach-o:strip 剥离符号 ## MachO 可读可写 Apple 的机制,只要文件可以正确签名,Apple 就认,可以修改。 ## 编译阶段做了什么 - 汇编 - 将符号归类: - 数据,放在数据段 - 可以获取到地址的符号,变成地址 - 类似 NSLog 这种只有在链接的时候才可以确定一些东西,那这种暂时无法确定地址的符号,都统一暂存起来。叫做“重定位符号表”。fishhook 就是基于此来实现系统符号的 hook - 链接。链接器通过链接将重定位符号表和符号表合并到一张表中,目标文件(`.o` 文件)和符号表,合并到一起, 如何找出需要重定位的符号? ```shell objdump --macho --reloc MachOAndSymbol.o ``` two_levelnamespace & flat_namespace: ⼆级命名空间与⼀级命名空间。链接器默认采⽤⼆级命名空间,也就是除了会记录符号名称,还会记录符号属于哪个Mach-O的,⽐如会记录下来_NSLog来⾃Foundation。 ## 符号 - 全局符号对整个项目可见,对使用的地方可见,整个符号表都可见。 - Static 只对定义所在的文件可见。 符号的种类 | Symbol Type | **说明** | | ----------- | ------------------------------------------------------------ | | **U** | undefined(未定义) | | **A** | absolute(绝对符号) | | **T** | text section symbol(__TEXT.__text) | | **D** | data section symbol(__DATA.__data) | | **B** | bss section symbol(__DATA.__bss) | | **C** | common symbol(只能出现在`MH_OBJECT` 类型的`Mach-O`⽂件中) | | **-** | debugger symbol table | | **S** | 除了上⾯所述的,存放在其他`section`的内容,例如未初始化的全局变量存放在(__DATA,__common)中 | | **I** | indirect symbol(符号信息相同,代表同⼀符号) | | **U** | 动态共享库中的⼩写u表示⼀个未定义引⽤对同⼀库中另⼀个模块中私有外部符号 | 编译 `main.m` 到 `main.o` ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -c main.m -o main.o ``` 使用 `nm -pa .o文件路径` 命令来查看符号 ### 符号的分类 - Common Symbol:在定义时,未初始化的全局符号。 有趣的 feature: ```c++ int global_int_age = 28; int global_int_age; void main() { print("Hello world"); } ``` 上面的代码不会编译报错。当存在2个同名的全局符号,一个初始化了,一个未初始化,不会报错。在编译链接阶段,当找到定义之后,未定义的会被删掉。 针对全局符号: - 当存在2个同名的全局符号,一个初始化了,一个未初始化,不会报错。在编译链接阶段,当找到定义之后,未定义的会被删掉。 - 链接器默认会把未初始化的全局符号,给强制初始化掉。比如 `int global_age;` 初始化为 `int global_age = 0;` - 生成目标文件分过程中,只需要头文件信息(头文件路径),只需要重定位符号表,知道哪些符号需要重定位,链接的时候,会自动将符号重定位。 ## vim 快速查找 API 功能 `man nm` 进入 vim 模式了,看到左下角有 `:` 光标,如果想查看当前 nm 命令的参数,可以快速查找,输入 `/ + 具体参数`,敲回车即可跳转到要匹配到的位置,如果有多个结果,且当前自动跳转到的不是正确的位置,vim 模式下可以输入 `n` 跳转到下一个匹配到的位置(n 即 next),输入 `N` 则跳转到上一个匹配到的位置。 比如查找 `-p`,则输入 `/-p`,敲回车的效果如下 ### 符号的导入导出及 App 瘦身 代码中使用了 Foundation 库的 NSLog,NSLog 对于业务代码来说,就是一个导入的符号,对于 Foundation 库来说,就是一个导出的符号。 什么符号可以是导出的符号?全局符号可以是导出符号。 App 或者一个 MachO 中,所有使用到的动态库的符号,都保存在间接符号表中,这些间接符号表中的数据,来自于动态库中。 动态库,全局符号 -> 导出符号 间接符号表 -> 动态库符号 所以,Strip 符号的时候,可能不能 Strip 全局符号。 OC 代码,默认都是导出的全局符号,所以容易占空间,想让体积变小,就可以尽量不想暴露的符号,使用链接器的能力,将不需要暴露的符号不暴露出去。 ```shell OTHER_LDFLAGS=$(inherited) -Xinker -unexported_symbol -Xlinker _OBJC_CLASS_$_Person ``` 静态库 = `.o` 文件的合集 + 重定位符号表。但重定位符号表中的符号不能 strip,所以只能 strip `.o` 文件的调试符号。 QA:从符号角度出发,动态库还是静态库对于 App 瘦身较好(更有抓手)?使用动态库还是静态库会提及更小? - App 在链接静态库的时候,静态库就是 .o 文件的合集,会把 `.o` 中的符号(包括可以重定位的符号),都放到 App 自身的符号表中,也就意味着可能是:本地符号、全局符号、导出符号。根据 Strip 的原理,Strip 可以脱离除了间接符号表之外的所有符号。 静态库链接的时候,除了间接符号表,其他区域都有可能放。所以链接静态库占用体积更小。 - App 在链接动态库的时候,正好相反,App 链接的动态库的符号都放到了间接符号表中,即使 Strip 所有符号,也不可能脱掉间接符号表中的符号。 所以大家在写 SDK 的时候,可以从符号角度出发想想,是选择静态库还是动态库。针对动态库,可以 strip 导出符号。默认 OC 的符号都是导出的。 ## Strip 静态库/ .o 文件 Strip 流程: 处理 `_DWARF` 段 Strip 的过程,就是在修改 Mach-O 文件中的内容。 动态库 All Symbols Non-Global Symbols(非全局符号): ## 断点 - 终端 LLDB 模式下通过命令添加的断点是通用的,是符号断点。通过 `br write -f 文件路径` 可以将断点导出,共享给其他人 - Xcode GUI 添加的断点是带有绝对路径的,通过 `br write -f 文件路径` 导出的断点信息在带有文件的绝对路径,不方便共享。要做的就是文件路径的替换。 ## 链接 源代码编译成目标文件。 ```shell clang -x objective-c \ -target x86_64-apple-macos11.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -c main.m -o main.o ``` 从 `.o` 目标文件链接为可执行文件 ```shell clang命令参数: -x: 指定编译文件语言类型 -g: 生成调试信息 -c: 生成目标文件,只运行preprocess,compile,assemble,不链接 -o: 输出文件 -isysroot: 使用的SDK路径 1. -I 在指定目录寻找头文件 header search path 2. -L 指定库文件路径(.a\.dylib库文件) library search path 3. -l 指定链接的库文件名称(.a\.dylib库文件)other link flags -lAFNetworking -F 在指定目录寻找framework framework search path -framework 指定链接的framework名称 other link flags -framework AFNetworking ``` ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MaxOSX.platform/Developer/SDKs/MacOSX11.sdk \ -L./AFNetworking \ -lAFNetworking \ test.o -o test ``` ## 探索「静态库就是 .o 文件的合集」 `.a` 静态库,`.dylib` 动态库使用有问题,一般是: - header search path - library search path - other link flags 这3个的一个或者多个造成的问题,着手去排查问题即可。 做个实验,验证下静态库其实就是 `.o` 文件的合集。 第一步,编写 oc 代码,就一个 Person 类,写一个类方法,编译为静态库。`Person.m` 编译为 `Person.o` 第二步,利用 clang 将 `person.o` 转换为静态库。 其实,这里就已经可以验证「静态库就是 .o 文件的合集」。 利用 `objdump --macho --private-header Person.dylib` 查看静态库依旧是 `Object File` 第三步,编写代码 `main.m` 代码,导入静态库 `` ```objective-c #import #import int main(int argc, char * argv[]) { Person *p = [[Person alloc] init]; [p sayHi]; NSLog(@"%@", p); } ``` 第四步,利用 clang 将 `main.m` 编译为 `main.o` 文件。注意,因为用到了 NSLog 和导入了静态库的头文件,所以需要加参数指定 NSLog 该符号从哪确定,也需要指定静态库所需的信息。 ```shell clang -x objective-c \ -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./StaticLibrary \ -c main.m -o main.o ``` 第五步,将第四步得到的 `main.o` 文件和前面编译好的 `Person` 静态库 ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ > -L./StaticLibrary \ > -lPerson \ > main.o -o main ``` 注意:上面第二步,得到的 `Person` 静态库需要重命名下,因为 clang 指令 `-l` 参数的 `Person`,其实就是去找 `libPerson` 的动态库或者静态库。 查找规则:先找 `lib+` 的动态库,找不到,再去找 `lib+` 的静态库,还找不到,就报错 第六步,查看第五步得到的可执行文件,然后执行,看看是否正常? - 成功,则说明 静态库就是`.o` 文件的集合,单个 `main.o` 文件,修改拓展名就可以变为静态库 - 不成功,则相反 这一部分相关的脚本和源码,可以查看 ## Framework Mac OS/iOS 平台还可以使用 Framework,Framework 实际上是一种打包方式,将库的:二进制文件、头文件、和有关资源打包到一起,方便管理和分发。 Framework 和系统 UIKit.Framework 还是有很大区别的。 - 系统的 Framework 不需要拷贝到目标程序中 - 我们自己的 Framework 不管是静态还是动态的,都需要拷贝到 App 中(App 和 Extension 的 Bundle 是共享的) 因此,Apple 把这种 Framework 又叫做 `Embedded Framework`。开发中使用的动态库会被放到 ipa 的 framework 目录下,基于沙盒运行。 不同的 App 使用相拥的动态库,并不会只在系统中存在一份。而是在各个 App 中各自打包、签名、加载一份。 根据 Apple 审核要求,上传到 App Store 的 ipa 可执行文件有大小限制。这里的大小不是指二进制(Mach-O)文件大小,而是指去其中 `__TEXT` 段的大小。 - iOS 7 之前,二进制文件中所有 `__TEXT` 部分总和不得超过 80M - iOS7.x 至 iOS8.x,二进制文件中,每个架构中的 `__TEXT` 部分不得超过 60M - iOS9.0 之后,二进制文件中所有 `__TEXT` 部分的总和不超过 500M 为了实现该效果,很多公司在组件化或者开源库,都采用动态链接的方式。因为动态链接的部分,不算在当前 Mach-O 的 `__TEXT` 段。 Framework: - 动态库:Header + `.dylib` + 签名 + 资源文件 - 静态库:Header + `.a` + 签名 + 资源文件 QA:通常情况下,**同一份代码,一个库制作成动态库体积会比静态库小**。为什么? 静态库是一堆 `.o` 文件的集合。假设 AFNetworking 有15个 `.m` 文件,编译后产生 15个 `.o` 文件。每个 `.o` 文件的都存在下面3部分: - Mach header - Segment - Section Mach header 包括一些基础信息,所以 Mach header 存在冗余(大小端序、CPU 类型等),这也就是为什么静态库比动态库体积大的原因之一。 ```shell Unix_Kernel  ~/Desktop/OCExplore/OCExplore  file Person.o Person.o: Mach-O 64-bit object x86_64 Unix_Kernel  ~/Desktop/OCExplore/OCExplore  otool -h Person.o Person.o: Mach header magic cputype cpusubtype caps filetype ncmds sizeofcmds flags 0xfeedfacf 16777223 3 0x00 1 4 1880 0x00002000 ``` 动态库在 Mach header 这里有改进,AFNetworking 动态库格式如下: 将公共的信息放到一起,公用一个 Mach header。 但「同一份代码,一个库制作成动态库体积会比静态库小」不绝对。 对于 iOS9,Load Commands Segment vmsize 默认是一个内存页,也就是16k 对于 iOS10,Load Commands Segment vmsize 默认是 32k - vmsize:此 segment 占用的虚拟内存的字节数 - filesize:此 segment 在磁盘上占用的内存数 假设项目只有1个源文件,可能打包后的静态库要比动态库体积大。 继续做个实验,验证下 Framework 的结构(上面做了静态库),所以我们可以沿用上面的成果,将静态库包装成 Framework 第一步,新建一个文件夹 `Framworks`,下面创建一个 `Person.Framework` 文件夹,把之前得到的静态库 `Person` 移动到该目录下。并把 `main.m` 移动过去。 第二步,因为 Framework 的结构里有 Header 信息,所以创建 `Headers` 文件夹,把 `Person.h` 文件放进去。 第三步,根据 `main.m` 和 framework 信息,编译成 `main.o` ```shell clang -x objective-c \ -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./Frameworks/Person.framework/Headers \ -c main.m -o main.o ``` 第四步,再根据 `main.o` 和 framework 去完成链接。链接三要素:库的头文件、库所在目录、库的名称。只不过在处理 Framework 的时候,参数不一样 ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -F./Frameworks \ -framework Person \ main.o -o main ``` 说明:main.o 链接 Person.framework 生成 main 可执行文件 - `-F./Frameworks` 在当前目录的子目录 Frameworks 查找需要的库文件 - `-framework Person ` 链接的名称为 `Person.framework` 的动态库或者静态库 查找规则:先找 `Person.framework` 的动态库,找不到,再去找 `Person.framework` 的静态库,还找不到,就报错 ## 动态库 ### 直接链接动态库 继续通过小实验来研究动态库的创建与使用 第一步:创建 dylib 文件夹,下面创建 `Person.h` `Person.m` 类。在 dylib 同层目录创建 main.m 文件。代码如下 第二步:对 main.m 编译成 main.o 文件,指令为 ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./dylib \ -c main.m -o main.o ``` 第三步:到 dylib 文件夹下,对 Person 编译为 Person.o 文件,指令为 ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -c Person.m -o Person.o ``` 第四步:将 Person.o 编译为动态库,指令为 ```shell clang -dynamiclib \ -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ Person.o -o LibPerson.dylib ``` 第五步,将 main.o 和 LibPerson.dylib 链接,成为 main 可执行文件 ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -L./dylib \ -lPerson \ main.o -o main ``` 每次针对动态库的操作都是这些差不多的指令,就是一些参数的不同,写个 Shell 脚本,命名为 `build.sh` ```shell echo "---------------- start --------------" echo "第一步:先对 main.m 编译为 main.o" clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./dylib \ -c main.m -o main.o echo "第二步,再对 Person 编译为 Person.o" pushd ./dylib clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -c Person.m -o Person.o echo "第三步,将 Person.o 编译为 libPerson.dylib 动态库" clang -dynamiclib \ -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ Person.o -o libPerson.dylib echo "第四步,将 main.o 和 libPerson.dylib 链接为可执行文件 main" popd clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -L./dylib \ -lPerson \ main.o -o main echo "---------------- Done --------------" ``` 结果如下: 第六步:对生成的 main 可执行文件进行调试运行,使用 lldb 指令 `lldb -file 可执行文件`,然后输入 r 进行运行: 咦,为什么我用动态库链接后还是无法使用???带着问题研究下 ### 静态库链接成动态库 因为: - `.o` 文件可以链接成静态库 - `.o` 文件可以链接成动态库 所以:能不能推导出这样一个结论:静态库也可以链接成动态库。 做个小实验验证看看: 和上面的材料没有差别,区别在于脚本,其中一步是将静态库链接为动态库。 使用链接器 LD 能力,链接静态库为动态库指令如下 ```shell ld -dylib -arch x86_64 \ -macosx_version_min 13.1 \ -syslibroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -lsystem -framework Foundation \ libPerson.a -o libPerson.dylib ``` `build.sh` 完整脚本如下 ```shell echo "---------------- start --------------" echo "第一步:先对 main.m 编译为 main.o" clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./dylib \ -c main.m -o main.o echo "第二步,再对 Person 编译为 Person.o" pushd ./dylib clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -c Person.m -o Person.o echo "第三步,对 Person.o 编译为 LibPerson.a 静态态库" libtool -static -arch_only x86_64 Person.o -o libPerson.a echo "第四步,LD 链接器将 libPerson.a 链接为 libPerson.dylib 动态库" ld -dylib -arch x86_64 \ -macosx_version_min 13.1 \ -syslibroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -lsystem -framework Foundation \ libPerson.a -o libPerson.dylib echo "第五步,将 main.o 和 libPerson.dylib 链接为可执行文件 main" popd clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -L./dylib \ -lPerson \ main.o -o main echo "---------------- Done --------------" ``` 执行完脚本,又出现了奇怪的现象: 发现,在利用动态库链接成可执行文件时报错了 `undefined symbols for **`,然后利用 `objdump --macho --exports-trie libPerson.dylib` 查看动态库的导出符号,居然是空。为什么? 链接器在链接阶段,默认使用 `-noall_load` 参数。共4个参数: - `-noall_load` :默认是 `-noall_load`,顾名思义就是不会所有符号的加载,而是链接器链接一个静态库之前去扫描静态库文件,找到需要的代码再进行链接 - `-all_load`:链接所有符号,不管代码有没有使用 - `-force_load`: 可以指定要载入所有方法的库,后面必须跟一个只想静态库的路径 - `-ObjC`:告诉链接器把库中定义的 Objective-C 类和 Category 都加载进来,这样编译之后的可执行文件会变大(因为加载了其他的 OC 代码进来)。由于 OC语言符号链接的基本单位是类,静态库链接时首先会链接本类,而 Category 是运行时才会被加载的,因此会被静态链接器直接忽略掉,通过 `-ObjC` 命令是告知链接器链接所有的 OC 代码 知道具体原因那就好办了,修改指令 ```shell ld -dylib -arch x86_64 \ -macosx_version_min 13.1 \ -syslibroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -lsystem -framework Foundation \ -all_load \ libPerson.a -o libPerson.dylib ``` 再次运行 build 脚本,然后对可执行文件执行,还是报错 😂 通过上面的实验可以得出结论: - 静态库是 `.o` 文件的合集 - 动态库是 `.o` 文件链接后的产物 - 静态库可以链接成动态库 - 动态库是最终链接产物。动态库比静态库多走一次链接的过程 ### 动态库 Library not loaded? 为什么动态库链接后的可执行文件运行,会报 `Library not loaded: 'libPerson.dylib'` 错误? 不得不聊聊动态库加载原理 也就是说当 dyld 加载一个可执行文件(main) 的时候,在 Mach-O 中有一个名字叫 `LC_LOAD_DYLIB` 的 Load Command,里面保存了所使用到的动态库的路径。可执行文件的动态库就是靠 dyld 根据动态库路径进行加载的。 用 MachOView 打开另一个 App 看看 对于我们自己链接的可执行文件 main 进行查看,利用 `otool -l main | grep 'DYLIB' -A 5` 指令 可以发现: - name 好像就是动态库的路径 - 链接的其他几个动态库的路径都没问题,就是 LibPerson.dylib 路径有问题。 如何解决? 需要在编译链接生成动态库的时候,有个东西保存动态库路径,这个就是 Mach-O 文件中的另一个 Load Command,即 `LC_ID_DYLIB`。 #### 方式一:通过 `install_name_tool` 指令 通过改变动态库 name 来修改动态库的路径。具体指令为 `install_name_tool -id /Users/unix_kernel/Desktop/LDAndFramework/StaticLibraryLinkedIntoDynamicLibrary/dylib/libPerson.dylib libPerson.dylib` 修改动态库的 name 之后,再次 `otool -l libPerson.dylib | grep 'DYLIB' -A 5` 查看路径信息 动态库有了正确的 name 后,再重新链接生成可执行文件。可执行文件可以正确运行,查看所以来的动态库路径,均正确加载。 上面的方案有点缺点,因为路径是绝对路径,因此没办法迁移。 #### 方式二:`rpath` `rpath`,Runpath search paths,dyld 搜索路径。运行时,`@rpath` 指示 dyld 按顺序搜索路径列表,以找到动态库。 `@rpath` 保存一个或多个路径的变量。 前提说明:模拟下 App 真实环境。创建一个文件夹 `Frameworks`,内部继续创建 `Person.framework` 文件夹,其内部继续创建 `Headers` 文件夹,将 dylib 文件夹下的文件复制过去。结构如下 ```shell // tree -L 4 . ├── Frameworks │   └── Person.framework │   ├── Headers │   │   └── Person.h │   ├── Person.h │   ├── Person.m ├── build.sh ├── dylib │   ├── Person.h │   ├── Person.m │   ├── Person.o │   ├── libPerson.a │   └── libPerson.dylib ├── main.m ``` 第一步,在 ` Frameworks/Person.framework` 下面执行命令,将 `Person.m` 编译为 `Person.o` ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -c Person.m -o Person.o ``` 第二步,将 `Person.o` 链接为动态库 ```shell clang -dynamiclib \ -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ Person.o -o Person ``` 第三步,给动态库利用 `install_name_tool` 修改 id,id 指定 `@rpath` 信息 ```shell install_name_tool -id @rpath/Frameworks/Person.framework/Person /Users/unix_kernel/Desktop/LDAndFramework/StaticLibraryLinkedIntoDynamicLibrary/Frameworks/Person.framework/Person ``` 第四步,利用 `otool` 查看动态库的 name 是否修改好了 `@rpath` 信息 ``` otool -l Person | grep 'ID' -A 5 ``` 第五步,回到根目录,将 `main.m` 编译为 `main.o` ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./Frameworks/Person.framework/Headers \ -c main.m -o main.o ``` 第六步,将 `main.o` 和 `Person.framework` 链接为可执行文件 ```shell clang -target x86_64-apple-macos13.1 \ -fobjc-arc \-isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -F./Frameworks \ -framework Person \ main.o -o main ``` 此时的可执行文件虽然链接成功了,但是可执行文件需要用到动态库的功能,直接运行会报错 `Library not loaded: '@rpath/Frameworks/Person.framework/Person'`。所以需要给可执行文件添加 `rpath` 信息 第七步,给可执行文件 `main` 添加 `rpath` 信息 ```shell install_name_tool -add_rpath /Users/unix_kernel/Desktop/LDAndFramework/StaticLibraryLinkedIntoDynamicLibrary main ``` 添加后验证是否添加成功,指令 `otool -l main | grep 'RPATH' -A 5` 第八步,链接成功后 `main` 可执行文件即可执行 下面是上面全部步骤的截图说明。 给动态库添加 `rpath` 信息。 核心:谁链接动态库,`rpath` 谁来提供,比如一个 Person.framework 路径为: `/usr/meiying/desktop/DynamicExplore/Person.framework` - 可执行文件中 `Load Command ` 中存在 `LC_RPATH` ,值为 `/usr/meiying/desktop/DynamicExplore/Person.framework` - 动态库 Mach-O 中也存在 值为 `@rpath/Frameworks/Person.framework/Person` 反思:上面的方案还是有缺点的,应为可执行文件提供的 `rpath` 还是一个绝对路径。 #### 方式三:@execute_path、@loader_path `@executable_path`:表示可执⾏程序所在的⽬录,解析为可执⾏⽂件的绝对路径。 `@loader_path`:表示被加载的`Mach-O` 所在的⽬录,每次加载时,都可能被设置为不同的路径,由上层指定 可以将可执行文件的 rpath 修改为灵活的,而不是写死的路径,指令为 ```shell install_name_tool -rpath /Users/unix_kernel/Desktop/LDAndFramework/StaticLibraryLinkedIntoDynamicLibrary @executable_path main ``` 这个可不是花里胡哨的烧操作,Cocoapods 也是这么干的 ``` LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' ``` 注意:`@loader_path` 还是比较抽象的,举一个实际场景例子来看看 假设背景是: - 动态库 Cat 具有 `[cat sleep]` 能力 - 动态库 Person 具有 `[person sayHi]` 能力,动态库 Person 使用了动态库 Cat - 可执行文件,导入了动态库 Person 这样一个场景,代码模拟下,文件目录如下 1. 在 Cat.framework 文件夹下运行 build.sh 2. 在 Person.framework 文件夹下运行 build.sh 3. 在 main.m 根目录下运行 build.sh 得到 main 可执行文件,运行报错。 ```shell lldb -file main (lldb) target create "main" Current executable set to '/Users/unix_kernel/Desktop/LDAndFramework/DynamicLibraryUseDynamicLibrary/main' (x86_64). (lldb) r Process 54157 launched: '/Users/unix_kernel/Desktop/LDAndFramework/DynamicLibraryUseDynamicLibrary/main' (x86_64) dyld[54157]: Library not loaded: 'Person' Referenced from: '/Users/unix_kernel/Desktop/LDAndFramework/DynamicLibraryUseDynamicLibrary/main' Reason: tried: 'Person' (no such file), '/usr/local/lib/Person' (no such file), '/usr/lib/Person' (no such file), '/Users/unix_kernel/Desktop/LDAndFramework/DynamicLibraryUseDynamicLibrary/Person' (no such file), '/usr/local/lib/Person' (no such file), '/usr/lib/Person' (no such file) Process 54157 stopped * thread #1, stop reason = signal SIGABRT frame #0: 0x000000010005698e dyld`__abort_with_payload + 10 dyld`: -> 0x10005698e <+10>: jae 0x100056998 ; <+20> 0x100056990 <+12>: movq %rax, %rdi 0x100056993 <+15>: jmp 0x100013150 ; cerror_nocancel 0x100056998 <+20>: retq Target 0: (main) stopped. ``` 得到新的命题:**可执行文件中引入动态库 A,动态库的功能实现依赖动态库 B,链接器该如何链接呢?** 第一种尝试: 对 Cat.framework 下的 build.sh 修改脚本,指定 `@rpath` 信息, `-Xlinker -install_name -Xlinker @rpath/Cat.framework/Cat` ```shell echo "1: 编译 Cat.m 为 Cat.o 可执行文件" clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -c Cat.m -o Cat.o echo "2: 链接 Cat.o 为 Cat 动态库" clang -dynamiclib \ -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -Xlinker -install_name -Xlinker @rpath/Cat.framework/Cat \ Cat.o -o Cat echo "3: 输出动态库的 name(路径)信息" otool -l Cat | grep 'ID' -A 5 ``` 对 Person.framework 下的 build.sh 修改脚本,指定 `@rpath` 信息,` -Xlinker -install_name -Xlinker @rpath/Person.framework/Person` ```shell echo "1:编译 Person.m 为 Person.o 可执行文件" clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./Headers \ -I./Frameworks/Cat.framework/Headers \ -c Person.m -o Person.o echo "2: 链接 Person.o 为 Person 动态库" clang -dynamiclib \ -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -Xlinker -install_name -Xlinker @rpath/Person.framework/Person \ -F./Frameworks/ \ -framework Cat \ Person.o -o Person echo "3: 输出动态库的 dylib 信息" otool -l Person | grep 'DYLIB' -A 5 echo "-----------" echo "4: 输出动态库的 name 信息" otool -l Person | grep 'ID' -A 5 ``` 对可执行文件 main 根目录,指定 `@executable_path` 信息,`-Xlinker -rpath -Xlinker @executable_path/Frameworks` ```shell echo "---------------- start --------------" echo "第一步:先对 main.m 编译为 main.o" clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./Frameworks/Person.framework/Headers \ -c main.m -o main.o echo "第二步,将 main.o 和 Person.dylib 链接为可执行文件 main" clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -Xlinker -rpath -Xlinker @executable_path/Frameworks \ -F./Frameworks \ -framework Person \ main.o -o main echo "第三步,输出信息" otool -l main | grep 'RPATH' -A 3 otool -l main | grep 'DYLIB' -A 3 echo "---------------- Done --------------" ``` 运行报错如下: 为什么还错误了?都已经给可执行文件添加了 `@executable_path`,给2个动态库都添加了 `@rpath`,怎么办? 思路:因为报错是说动态库 Person 找不到动态库 Cat,那是不是聚焦下 Person,在 Person 的链接指令中,给 Person 添加 `@rpath` 就可以了? 动手实践下,修改 Person 动态库的 build.sh 脚本 ```shell echo "1:编译 Person.m 为 Person.o 可执行文件" clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./Headers \ -I./Frameworks/Cat.framework/Headers \ -c Person.m -o Person.o echo "2: 链接 Person.o 为 Person 动态库" clang -dynamiclib \ -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -Xlinker -install_name -Xlinker @rpath/Person.framework/Person \ -Xlinker -rpath -Xlinker @executable_path/Frameworks/Person.framework/Frameworks \ -F./Frameworks/ \ -framework Cat \ Person.o -o Person echo "3: 输出动态库的 dylib 信息" otool -l Person | grep 'DYLIB' -A 5 echo "-----------" echo "4: 输出动态库的 name 信息" otool -l Person | grep 'ID' -A 5 ``` 在 Person.framework运行下 build.sh,然后在根目录下运行 build.sh,得到新的可执行文件,然后可以成功运行 反思:可执行文件依赖动态库 A,动态库 A 依赖动态库 B,上面的配置很繁琐: - 可执行文件提供 `rpath`,指令为: `-Xlinker -rpath -Xlinker @executable_path/Frameworks` - 动态库 A 指定 name 为 `-Xlinker -install_name -Xlinker @rpath/Person.framework/Person`,同时又因为依赖了动态库 B,所以同时又要提供 `rpath`,指令为 `-Xlinker -rpath -Xlinker @executable_path/Frameworks/Person.framework/Frameworks ` - 动态库 B 指定 name,指令为 `-Xlinker -install_name -Xlinker @rpath/Cat.framework/Cat` 好繁琐啊,有没有简化的方法。此时 **`@loader_path`** 呼之欲出了。 在当前场景下,Person 动态库的指令可以简化下。`-Xlinker -rpath -Xlinker @executable_path/Frameworks/Person.framework/Frameworks ` 可以替换为 `-Xlinker -rpath -Xlinker @loader_path/Frameworks ` 修改脚本后,分别在 Person.framework 和 main 可执行文件根目录下执行 build.sh 脚本,然后验证可执行文件是否可以正确运行,加载所需动态库 注:为了方便看清楚脚本执行情况和可执行文件执行结果,这次运行注释了 otool 的打印脚本。 `loader_path` 是标准解决方案。随便打开 AFNetworking 工程看看 链接器可真强大啊,同时 Mach-O 开的口子也多,也就是可以随意修改已经编译好的可执行文件的 `rpath` ,自己也可以创建一个同名的动态库(name)把原有的动态库替换了,这也是为什么 MacOS 那些破解软件的工作原理。(当然,这些也要结合逆向技术) 上述的操作,其实本质就是修改 Mach-O 文件的 Load Command,按照 Apple 的机制,Mach-O 修改后,必须重新签名才可以运行使用破解软件时,总是要求我们重新签名,背后的命令如下: ```shell sudo codesign --force --deep --sign - (应用路径) ``` ### 动态库如何导出所引用的动态库的符号 - 主工程 -> Person 动态库 - Person 动态库 -> Cat 动态库 那么主工程可以直接调用 Cat 动态库的能力吗? 正常写代码肯定可以,但是从链接器角度分析下,如何实现 调用的本质就是符号的发现。也就是 Cat 的符号有没有导出?可执行文件 mian 使用的能力就是动态库导出后,自己导入的。 因为 main 引入了 `import ` ,查看下 Person 动态库的导出符号,使用指令 `objdump --macho --exports-trie Person` 进入 Cat.framework 也查看下 Cat 动态库的导出符号,使用指令 `objdump --macho --exports-trie Cat` 发现 Person 没有导出 Cat 的符号。那在可执行文件中调用不了 Cat 的能力了。 怎么办呢?链接器 LD 已经是很成熟的东西了,对于处理动态库依赖了动态库,且需要将被依赖动态库的符号导出,这样的需求早已满足了。具体是什么参数?终端输入 `man ld` 查看下指令 其中,我们需要用的是 `-reexport_framework` 这个参数。指令为 ` -Xlinker -reexport_framework -Xlinker Cat` 对此,修改 Person.framework 的 build.sh 脚本 ``` echo "1:编译 Person.m 为 Person.o 可执行文件" clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./Headers \ -I./Frameworks/Cat.framework/Headers \ -c Person.m -o Person.o echo "2: 链接 Person.o 为 Person 动态库" clang -dynamiclib \ -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -Xlinker -install_name -Xlinker @rpath/Person.framework/Person \ -Xlinker -rpath -Xlinker @executable_path/Frameworks/Person.framework/Frameworks \ -Xlinker -reexport_framework -Xlinker Cat \ -F./Frameworks/ \ -framework Cat \ Person.o -o Person echo "3: 输出动态库的 dylib 信息" otool -l Person | grep 'DYLIB' -A 5 echo "-----------" echo "4: 输出动态库的 name 信息" otool -l Person | grep 'ID' -A 5 ``` 执行脚本输出如下: 结构如下: 理论上来讲,Person.framework 把 Cat.framework 导出了,实现方式是通过给 Mach-O 的一个叫做 `LC_REEXPORT_DYLIB` 的 Load Command。也就是可执行文件,通过 Person.framework 的 `LC_REEXPORT_DYLIB` load Command 可以实现访问 Cat.framework 的符号。 完整验证下,还需要做2件事情: - 修改 main.m 的代码,因为要访问 Cat.framework 的能力,测试能否正常运行 ```objective-c #import #import "Person.h" #import int main(int argc, char * argv[]) { Person *p = [[Person alloc] init]; [p sayHi]; Cat *cat = [[Cat alloc] init]; [cat sleep]; return 0; } ``` - 修改 main.m 的 build.sh 脚本,因为 Person.framework 已经暴露了 Cat.framework 的能力。LD 链接指令需要加 Cat.framework 头文件的参数 ```shell echo "---------------- start --------------" echo "第一步:先对 main.m 编译为 main.o" clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -I./Frameworks/Person.framework/Headers \ -I./Frameworks/Person.framework/Frameworks/Cat.framework/Headers \ -c main.m -o main.o echo "第二步,将 main.o 和 Person.dylib 链接为可执行文件 main" clang -target x86_64-apple-macos13.1 \ -fobjc-arc \ -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX13.1.sdk \ -Xlinker -rpath -Xlinker @executable_path/Frameworks \ -F./Frameworks \ -framework Person \ main.o -o main # echo "第三步,输出信息" # otool -l main | grep 'RPATH' -A 3 # otool -l main | grep 'DYLIB' -A 3 echo "---------------- Done --------------" ``` 修改完从里到外一次性执行 build.sh,得到 main 可执行文件。一切顺利,输出如下: ## xcframework ### 诞生背景 XCFramework:是苹果官⽅推荐的、⽀持的,可以更⽅便的表示⼀个多平台和架构的分发⼆进制库的格式。专⻔在 2019 年提出的framework 的另⼀种先进格式。 需要 Xcode11 以上⽀持。是为了更好的⽀持 Mac Catalyst 机制和 ARM 芯⽚的 macOS。 ### 优势 胖二进制:Fat Binary,通用二进制格式(Universal Binary)。通用二进制文件实际上就是将支持不同架构的二进制文件打包成一个文件,系统在加载运行时,会根据通用二进制文件中提供的架构,选择和当前系统匹配的二进制文件。 动态库是可以合并的。前提是不同的 CPU 架构。合并之后还是多个不同的动态库,只不过 mach-header 是挨在一起的,所有的库文件也是挨着的。可以理解成是“压缩”。 lipo 指令的缺点:不能合并相同架构。对不同的库处理后,还要处理 `dSYM` 文件,如果库开启了 Bitcode,还会生成 `BCSymbolMaps` 文件。所以使用 lipo 处理动态库的合并、拆分,都需要管理 `dSYM`、`BCSymbolMaps`、`库文件`,较为繁琐。 基于此,Apple 在 2019 诞生了 `xcframework` 技术。 和传统的 framework 相⽐: - 可以⽤单个`.xcframework` ⽂件提供多个平台的分发⼆进制⽂件 - 与 `Fat Header` 相⽐,可以按照平台划分,可以包含相同架构的不同平台的⽂件 - 在使⽤时,不需要再通过脚本去剥离不需要的架构体系(比如默认包含3种架构,armv7、arm64、x86_64 上架前为了包大小,还会用 lipo 指令剔除不需要的架构) ### 如何制作 xcframework 第一步:先创建一个动态库 `pod lib create Person`。里面就一个 Person 类, 包含2个方法。 第二步:利用 `xcodebuild` 指令打包。`xcodebuild` 指令执行的是打包当前的工程目录,会读取 `-workspace` 参数指定的项目配置,然后根据指定的 `-scheme` 和 `-configuration` 模式去打包工程。 先打出模拟器的包,指令如下: ```shell xcodebuild -workspace Person.xcworkspace \ -scheme 'Person-Example' \ -configuration Release \ -destination 'generic/platform=iOS Simulator' \ -archivePath '../archives/Person.framework-iphonesimulator.xcarchive' \ SKIP_INSTALL=NO \ archive ``` 再打出真机的包,指令如下 ```shell xcodebuild -workspace Person.xcworkspace \ -scheme 'Person-Example' \ -configuration Release \ -destination 'generic/platform=iOS' \ -archivePath '../archives/Person.framework-iphoneos.xcarchive' \ SKIP_INSTALL=NO \ archive ``` 打包成功的输出如下: 实体目录如下: 注意:我们打败归档后生成的符号表只有 `.dSYM` 文件,没有 `BCSymbolMaps` 文件,是因为工程没有开启 BitCode,当开启 BitCode 后的,打包会生成 `BCSymbolMaps` 文件,作用也是用于 Crash 后解析堆栈用的。 因为 `.dSYM` 文件是默认生成的,但是 `Bitcode` 需要分别开启(Pod 和 Demo 工程)。开启 Bitcode 后重新打包,看看生成的产物里有没有 `BCSymbolMaps` 和相关的 `.bcsymbolmap` 文件 第三步:利用 `xcodebuild` 打包成 xcframework ```shell xcodebuild -create-xcframework \ -framework 'archives/Person.framework-iphoneos.xcarchive/Products/Library/Frameworks/Person.framework' \ -framework 'archives/Person.framework-iphonesimulator.xcarchive/Products/Library/Frameworks/Person.framework' \ -output 'https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/xcframework/Person.xcframework' ``` 结果如下: 可以看到打包后的 xcframework 自动处理了文件夹。但是缺点是我们提供的 framework,别人使用可能会 crash,所以为了一些使用场景需要在 xcframework 中提供 `.dSYM` 文件或者 `.bcsymbolmap` 文件。 第四步:加入 `.dSYM` 和 `.BCSymbolMaps` 文件,重新生成 xcframework,其参数为 `-debug-symbols`,后面路径必须是绝对路径。 ```shell xcodebuild -create-xcframework \ -framework 'archives/Person.framework-iphoneos.xcarchive/Products/Library/Frameworks/Person.framework' \ -debug-symbols '/Users/unix_kernel/Desktop/LDAndFramework/MultipleArchMerge/Person/archives/Person.framework-iphoneos.xcarchive/BCSymbolMaps/3C61A3F4-4398-322F-8AC9-F078B196C381.bcsymbolmap' \ -debug-symbols '/Users/unix_kernel/Desktop/LDAndFramework/MultipleArchMerge/Person/archives/Person.framework-iphoneos.xcarchive/BCSymbolMaps/B34138DB-2F8F-372C-93D3-7ADDDFC7BDA1.bcsymbolmap' \ -debug-symbols '/Users/unix_kernel/Desktop/LDAndFramework/MultipleArchMerge/Person/archives/Person.framework-iphoneos.xcarchive/dSYMs/Person.framework.dSYM' \ -framework 'archives/Person.framework-iphonesimulator.xcarchive/Products/Library/Frameworks/Person.framework' \ -debug-symbols '/Users/unix_kernel/Desktop/LDAndFramework/MultipleArchMerge/Person/archives/Person.framework-iphonesimulator.xcarchive/dSYMs/Person.framework.dSYM' \ -output 'https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/xcfrmaework/Person.xcframework' ``` 结果如下 可以看到 xcframework 里面不只有不同的动态库,还携带了对应的 `.dSYM` 和 `bcsymbolmap` 文件,用于堆栈、符号还原。 第五步:制作好的 xcframework 接入使用下。 - 新建一个叫做 `XCFrameworkUsageDemo` 的 Xcode iOS App 工程 - 导入创建的 `Person.xcframework` - import 头文件并使用 - 编译运行,查看 products 下面的产物,因为选择的是模拟器运行,所以验证 `Person.framework` 里面的动态库文件大小,是否和 `Person.xcframework` 里面模拟器目录下 Person 动态库的大小一致 可以看到我们打包的 `Person.xcframework` 可以正常使用,除此之外,`Person.xcframework` 包含了模拟器和真机的动态库文件和对应的 `.dSYM` 和 `.bcsymbolmap` 文件,当导入到项目中的时候,Xcode 会根据当前编译的架构,自动从里面选择合适的架构文件。 好处有3: - 不需要处理头文件 - 我们不需要关心上线前处理,重复架构(lipo 剔除) - 调试符号也方便的给到 注意:该部分代码,在 `LDAndFramework/XCFramework` 目录下。 ## Weak Import 先做个小实验 第一步:创建一个 iOS App 工程,然后把上面生成的 `Person.framework` 拖到工程根目录下 第二步:不引入动态库,而是通过 xcconfig 文件,告诉链接器,关于动态库的三要素:头文件位置、动态库名称。 第三步:编译运行。 结论:编译正常,但是运行会报错 `Library not loaded: @rpath/Person.framework/Person` 第一种解决方案是给 xcconfig 添加 rpath 的具体路径。 第二种解决方案是将库声明为“弱引用”。输入 `man ld` 查看具体的参数和说明: ```shell -weak_framework name[,suffix] This is the same as the -framework name[,suffix] but forces the framework and all references to it to be marked as weak imports. Note: due to a clang optimizations, if functions are not marked weak, the compiler will optimize out any checks if the function address is NULL. ``` 修改 xcconfig 文件为 ```shell // 引用一个动态库,3要素:头文件、动态库名称、动态库路径 // 知道了链接的原理之后,就知道可以不用把动态库托到项目中去,指定了3要素就可以链接了。 // 1. -I:头文件信息 HEADER_SEARCH_PATHS = $(inherited) ${SRCROOT}/Person.framework/Headers LD_RUNPATH_SEARCH_PATHS = $(inherited) // 2. -F:framework FRAMEWORK_SEARCH_PATHS = $(inherited) ${SRCROOT} // 3. -weak_framework: 允许该库在运行时消失 OTHER_LDFLAGS = $(inherited) -Xlinker -weak_framework -Xlinker "Person" ``` 修改后编译运行,发现没有报 `Library not loaded: ` 这样的错。 我们对添加了 `_weak-framework` 这个链接器参数的可执行文件查看下,指令为 `otool -l WeakImportDemo` 查看 Mach-O 发现,从 `LC_LOAD_DYLIB` 变为 `LC_LOAD_WEAK_DYLIB` 通常,当链接一个库时,链接器会尝试解析所有从该框架中引用的符号。如果某个符号在库不存在或者没有导出,链接器会报错,因为这是一个强链接(strong linking)的要求。 使用 `-weak_framework` 选项,可以告诉链接器,即使框架中缺少某些符号,也不要报错,而是允许链接继续进行。 这样,应用程序在运行时可以优雅地处理缺少的符号,例如,某个特性可能因为缺少实现而不可用,但应用程序的其他部分仍然可以正常工作。 当一个动态库,在运行时,不能保证 ## 静态库符号冲突原因及其解法 探索下静态库符号冲突的情况下,怎么解决? 来个简单的小实验。 第一步:准备2个 AFNetworking 的静态库,符号一模一样,但是静态库名称不同。 第二步:创建 Demo 工程,将静态库放到根目录下。 第三步:创建 xcconfig 文件。配置参数用于配制一些编译、链接信息。 ```json //// -I HEADER_SEARCH_PATHS = $(inherited) "${SRCROOT}/AFNetworking" "$(SRCROOT)/AFNetworking2" //// -L LIBRARY_SEARCH_PATHS = $(inherited) "${SRCROOT}/AFNetworking" "$(SRCROOT)/AFNetworking2" //// -l OTHER_LDFLAGS = $(inherited) -l"AFNetworking" -l"AFNetworking2" ``` 第四步:尝试编译,编译成功。 QA:为什么链接2个同名同符号的静态库,编译不报错? 会有二级命名空间吗?不是的。静态库本质就是一堆 `.o` 文件,所有的 `.o` 最后都会和主工程的可执行文件进行合并,所以不存在二级命名空间的问题。 核心原因是因为,链接器针对静态库,在 deac code strip 专门为静态库,设置为 `-noall_load`,意思是:完全不加载、直接去优化 可以验证下,在链接的时候修改链接参数为 `-all_load` 就会报错 如何解决?? LD 提供了 ` -load_hidden` 参数。 ```shell -load_hidden path_to_archive Uses specified static library as usual, but treats all global symbols from the static library to as if they are visibility hidden. Useful when building a dynamic library that uses a static library but does not want to export anything from that static library. ``` 修改 LD 链接参数 ```shell OTHER_LDFLAGS = $(inherited) -l"AFNetworking" -l"AFNetworking2" -Xlinker -force_load -Xlinker "${SRCROOT}/AFNetworking/libAFNetworking.a" -Xlinker -load_hidden -Xlinker "${SRCROOT}/AFNetworking2/libAFNetworking2.a" ``` 具体代码见: `LDAndFramework/StaticLibConflictsSolution/StaticLibConflictsDemo` ## 动动 第一步,创建一个名字叫 `NetworkManager` 的 Framework,勾选测试。里面就添加一个 `NetworkDetector` 类,有1个类方法 `+sharedManager` 第二步,给动态库添加 AFNetworking 依赖。 - 项目根目录下执行 `pod init` ,初始化工程 - 添加 `pod 'AFNetworking'` 第三步:目的是为了研究 App -> NetworkManager 动态库 -> AFNetworking 动态库的情况,所以 App 可以用测试工程代替,测试工程就是一个可执行文件,类似一个 App。 发现编译通过,运行报错。 为什么?原因 因为 `Pods-NetworkManager.debug.xcconfig` 里面的配置信息告诉链接器关于编译的3要素:头文件位置、动态库名称、rpath 信息。所以可以链接成功, 运行报错是因为 dyld 找不到 AFNetworking 动态库所在的位置。 使用的 AFNetworking 动态库,所以`NetworkManager.Framework` 的 Load Command `LC_LOAD_DYLIB` 中的 name 就告诉外部,AFNetworking 将会在 `@rpath/AFNetworking.framework/AFNetworking` 下面查找。 遵循原则是谁链接库,谁就提供库所需要的 rpath 信息。所以 `NetworkManager.Framework` 提供 rpath 信息。xcconfig 文件的 `LD_RUNPATH_SEARCH_PATHS` 是 Xcode 项目中的一个设置,它在编译时告诉链接器在生成的可执行文件的运行时路径(rpath)中包含特定的目录( `install_name_tool -add_rpath` 是在二进制文件生成后对其进行修改) ```shell LD_RUNPATH_SEARCH_PATHS = $(inherited) '@executable_path/Frameworks' '@loader_path/Frameworks' '@executable_path/.https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/Frameworks' ``` dyld 在运行起来后,会根据 `LD_RUNPATH_SEARCH_PATHS` 提供的 rpath 信息,和 AFNetworking 的 `name` 拼接去查找。但我们面前的例子,路径下没有 AFNetworking。 如何解决? 方式一:low 一点,直接给测试工程也 `pod AFNetworking`。这是在探究原理,简单解决问题可以这么做。 方式二:不是找不到 AFNetworking 吗?因为 xcconfig 提供的路径找不到,那直接给 `LD_RUNPATH_SEARCH_PATHS` 配置一个可以找到的地方。然后运行成功。这只是为了研究定位问题后,简单解决问题的方案。 方式三:观察标准做法,比如一个 App 使用了 AFNetworking,这个 case Cocoapods 是怎么处理的? 是通过 shell 脚本来处理的。该脚本肯定是 Cocoapods 生成的。属于工程化范畴。核心代码如下 具体怎么做呢?编写 shell 脚本,编译 Person 动态库、AFNetworking 动态库,然后将产物复制到 Frameworks 文件夹下。 Tips > 往一个 Framework 里通过 Cocoapods 导入一个库,并不会真的导入,而是生成链接器链接所需的参数,并不会把依赖的库文件放到自身的 Framework 中。 来个有趣的操作:NetworkManager.Framework 如何使用主工程的功能?也就是反向依赖 **功能的本质就是符号,dyld 在链接的时候,会把所有的导出符号放到一起,只要运行的时候找到所有的符号,就可以动态链接。** 那怎么做呢? 第一步:在 App(我们的实验中就是测试工程)创建 NetworkObject 类 第二步:在 framework 的 HEADER_SEARCH_PATHS 中增加 App 的头文件查找路径 第三步:framework 中增加实现代码,使用 App 中的符号 发现编译报错?符号找不到 - 链接的时候会去找符号的地址 - 但 App 和动态库的符号原则是,链接成功,App 运行起来,动态库自然可以访问到 App 中的符号 所以,如何链接成功?如何处理这个未定义的符号? LD 链接器支持符号的处理。 方法一:修改动态库 xcconfig 添加未定义的符号为动态查找。但风险较大,所有未定义的都不会报错误伤较大。 ```shell OTHER_LDFLAGS = $(inherited) -framework "AFNetworking" -Xlinker -undefined -Xlinker dynamic_lookup ``` 方法二:只对特定的未定义的符号采用动态查找 ```shell OTHER_LDFLAGS = $(inherited) -framework "AFNetworking" -Xlinker -U -Xlinker _OBJC_CLASS_$_NetworkObject ``` ## 动静 模拟:App -> NetworkManager 动态库 -> AFNetworking 静态库。将上面的工程中,NetworkManager 中的 podfile `# use_frameworks!` 注释掉,就会以静态库的形式链接 AF。 动态库依赖静态库,则会把静态库中所有代码链接到动态库中。所以工程可以正常编译、链接。 如何在 App 中引入静态库 AFNetworking 中的符号?因为链接后,动态库中已经包含了静态库中的符号,所以只需要让 Xcode 编译通过即可。符号查找无需关心。 打开 NetworkManager 动态库的 Mach-O 查看下符号,可以看到动态库 NetworkManager 中已经包含了静态库 AFNetworking 的符号。 如何成功编译?告诉链接器 HEADER_SEARCH_PATH 信息即可。 思考:动态库链接静态库后,静态库中暴露的导出符号,在动态库中也是导出符号。假设动态库不想把静态库的符号暴露出来,该怎么做? LD 链接器提供了能力,将静态库的符号不暴露出来。 ```shell OTHER_LDFLAGS = $(inherited) -ObjC -Xlinker -hidden-l"AFNetworking" ``` ## 静静 模拟:App -> NetworkManager 静态库 -> AFNetworking 静态库 - 将上面的工程中,NetworkManager 中的 `Build Settings` -> `Mach-OType` 改为 `Static Library` - 将 Podfile 中 `use_frameworks!` 注释掉,就会以静态库的形式链接 AF 之后编译报错 此时 NetworkManager 静态库中只有自己的符号,没有所依赖的 AFNetworking 中的符号。 App 链接 NetworkManager 静态库,需要告诉链接器:头文件、库名称、库所在位置。 但 NetworkManager 静态库链接 AFNetworking 静态库,没有配置信息告诉 App。所以需要额外配置。App 直接依赖的静态库,静态库所以来的静态库没有对 App 可见。 方法一:Build Settings -> 查找 Other Linker Flags,添加 `-lAFBetworking` 指明链接哪个库;查找 Libarary Search Path,设置库的查找路径 `${BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/AFNetworking` 方法二:直接给 App install 静态库。 具体代码见:`LDAndFramework/StaticLibUseStaticLib` ## 静动 模拟:App -> NetworkManager 静态库 -> AFNetworking 动态库。效果等价于, (App + 静态库)+ 动态库。 - Xcode 中将 NetworkManager 项目的 Build Settings 中的 Mach-O Type 设置为 `Static Library` - Podfile 中将 `use_frameworks!` 注释打开 编译报错,符号未定义 。 所以症结所在:就是把动态库的代码也放到 App 里面,App 才可以使用动态库里面的符号。上面代码运行,NetworkManager 静态库调用 AFNetworking 动态库的能力,就相当于 App 直接访问 AFNetworking 动态库的符号一样。 App 没有配置关于 AFNetworking 的链接三要素,所以找不到 AF。 - AutoLink:当在代码中 `import ` 会自动在目标文件的 Mach-O 中 `OTHER_LDFLAGS` 拼接 `-framework` 参数 - 所以只需要告诉 App framework search path 即可。参考debug.xcconfig 中的 `FRAMEWORK_SEARCH_PATHS = $(inherited) "${PODS_CONFIGURATION_BUILD_DIR}/AFNetworking"`,展开为 `"${BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/AFNetworking"`。将其设置到 Build Settings 的 framework search path 中。 修改后编译没问题,运行报错,因为编译后的 AFNetworking 没有拷贝到 App 所需目录下. 怎么处理? - 参考其他使用 AFNetworking 的项目,Cocoapods 工程化模版写好了脚本,直接拷贝一份到工程根目录下,重命名为 `handle-frameworks.sh` - Xcode Targets 选择 “NetworkManagerTests” -> Build Phases,添加 “Run script”,内容为 `"${SRCROOT}/handle-frameworks.sh"` 运行成功。 具体代码见:`LDAndFramework/StaticLibUseDynamicLib` ## 同时依赖静态库、动态库 ```shell platform :ios, '14.1' target 'InstallDyanmicAndStaticFramework' do use_frameworks! $static_framework = ['AFNetworking'] pre_install do |install| puts install install.pod_targets.each {| pod | if $static_framework.include?(pod.name) def pod.build_type; Pod::BuildType.static_framework # 使用静态库 end end } end # Pods for InstallDyanmicAndStaticFramework pod 'SDWebImage' pod 'AFNetworking' end ``` 代码见:`LDAndFramework/InstallDyanmicAndStaticFramework` ## module ### 定义 一个 Module 是机器代码和数据的最小单位,可以独立于其他代码单元进行链接。 通常,Module 是通过编译单个源文件生成的目标文件。例如,当前的 `test.m` 被编译成目标文件 `test.o` 时,当前的目标文件就代表了一个 Module 。 但是,Module 在调用的时候会产生**开销**,比如我们在使用一个静态库的时候。(这个开销是什么接下去会讲) ### 场景 说人话,平时什么情况下使用的最多?导入库文件的时候,就是 module 的主战场。 当在 App 里导入一个库的时候,发生了什么事情?`.h` 文件也是需要编译的(里面也会承载一些方法信息)。 先谈谈导入方式,导入方式有2种: - `include`: 假设没有开启 module 能力,当使用 `#include <*.h>` 的时候, 头文件 A、B 在 `use.c ` `another-use.c` 中,include 几次就要编译几次。 编译 use.c 就要编译 include 进来的 A、B。编译 another-use.c 同样要编译 A、B。效率低下。 - `import`:与 `include` 相对应,`import ` 语句用于导入模块,而不是简单的文本包含。使用模块可以减少编译时间,因为编译器只需要编译模块的接口而不是整个模块的实现 `clang -fmodules -fmodule-map-file=mo dule.modulemap -fmodules-cache-path=./moduleCache -c use.c -o use.o` : - `-fmodules` 用于告诉 clang 启用 module 编译 - 编译后 module 缓存保存到 `-fmodules-cache-path` 后面的路径中可以看到 A、B2个头文件,编译缓存也存在2个,分别以 A、B 开头 - `-fmodule-map-file` 指明 modulemap 文件路径 module 是 clang 提供的能力。可以把头文件编译成二进制文件,缓存到系统目录中。这样的好处是,在使用某个 `.h` 的多个 `.m` 中,就不会因为多处引入 `.h` 而编译多次 `.h` ### modulemap 规范和实例 #### case 1:上例中的 modulemap ```shell /* module.modulemap */ module A { // 定义了一个名字叫 A 的模块 header "A.h" //模块 A 的公共头文件为 A.h。这意味着任何想要使用模块 A 中定义的类或函数的代码,都需要导入 A.h 文件 } module B { // 定义了一个名为 B 的模块。。 header "B.h" // 模块 B 的公共头文件是 B.h export A // 模块 B 向外暴露模块 A。这意味着任何导入模块 B 的代码,也可以使用模块 A 中定义的类或函数。export A 将模块 A 作为模块 B 的一部分公开,以便在使用模块 B 时,可以隐式使用模块 A 中的内容。 } ``` 假设存在一个 c.h 的文件,需求是想使用模块 A 和模块 B 中定义的内容。 方法一:直接 import 模块对应的头文件 ```objective-c #import "A.h" #import "B.h" ``` 方法二:因为模块 B 已经暴露了模块 A,所以导入 B 就可以使用模块 A 中定义的类和函数 ```objective-c #import "B.h" ``` #### case2:AFNetworking Demo 中的 modulemap AFNetworking 的 [Framework/module.modulemap](https://github.com/AFNetworking/AFNetworking/blob/master/Framework/module.modulemap) ```json framework module AFNetworking { // 定义一个名为 AFNetworking 的框架模块 umbrella header "AFNetworking.h" // 指定了框架的伞头文件,这个文件是包含了所有公共头文件的文件,方便外部嗲欧哦那个 export * // 表示框架中的所有公共接口(类、结构体、枚举、协议等)都被导出, module * { export * } // 意味着框架内部的所有子模块(即 AFNetworking.h 中头文件代表的子模块都会被匹配到),子模块的名称就是 .h 文件的名称,都将被导出,可以被外部所访问 } ``` #### case3:AsyncDisplayKit 的 modulemap ```json framework module AsyncDisplayKit { // 定义了一个名为 AsyncDisplayKit 的框架模块 umbrella header "AsyncDisplayKit.h" // 指定了框架的伞头文件,这个文件是包含所有公共头文件的文件,方便外部调用 export * // 表示框架中所有公共借口(类、结构体、枚举、协议等)都被导出,可以被外部代码访问 module * { // 意味着框架内部的所有子模块(即 AsyncDisplayKit.h 中头文件代表的子模块都会被匹配到),子模块的名称就是 .h 文件的名称,都将被导出,可以被外部所访问 export * } explicit module ASControlNode_Subclasses { // 显示指名,定义了一个名为 ASControlNode_Subclasses 的子模块,它包含了与 ASControlNode 相关的子类 header "ASControlNode+Subclasses.h" // 指定了子模块的头文件,这个头文件包含了子类的定义 export * } explicit module ASDisplayNode_Subclasses { // 定义了一个名为 ASDisplayNode_Subclasses 的子模块,它包含了所有与 ASDisplayNode 相关的子类 header "ASDisplayNode+Subclasses.h" // 指定了子模块的头文件,这个头文件包含了子类的定义 export * } } ``` 看一个例子,AsyncDisplayKit 中 `umbrella header` 伞头文件,即 `AsyncDisplayKit.h` 的内容 ```c++ #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #if AS_IG_LIST_KIT #import #import #endif #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import #import ``` 使用:子模块可以通过大模块访问到,比如 `@AsyncDisplayKit.ASEventLog;` 更多关于 Module 的信息,可以查看 [Clang::Modules](https://clang.llvm.org/docs/Modules.html) ### 实战:Framework 使用 modulemap 第一步:创建动态库 `PersonFramework`,创建一个 iOS App Demo 工程。 第二步:给动态库添加 `PersonFramework.modulemap` 文件 第三步:给主工程、动态库设置在同一个 Xcode 项目中编译调试。 结论: - 工程中 `PersonFramework.modulemap` 命名的 modulemap,在编译后被 Xcode 重命名为 `module.modulemap`,系统只认这个 - `.modulemap` 文件有多种写法,就拿上面的举例,等价于3种写法: - 写法1 ```json framework module PersonFramework { // umbrella 不加 Header,则说明后面要跟文件夹路径。 umbrella "Headers" export * module * { export * } } ``` `nubrella` 后不加 Header,说明跟得是文件夹,文件夹中里面的内容和 `Build Phases` 中 `Header` public 部分的头文件描述的一致。会将 public 的头文件,最后放到 `Headers` 文件夹中。 - 写法2 ```json framework module PersonFramework { umbrella header "PersonFramework.h" export * module * { export * } } ``` `umbrella header + 伞头文件` 意味着伞头文件里所有的 `.h` 将会被放到 `Headers` 文件夹中。且给外部访问 - 写法3 ```json framework module PersonFramework { umbrella header "PersonFramework.h" explicit module Worker { header "Worker.h" export * } explicit module Student { header "Student.h" export * } } ``` 写法3是将需要暴露的头文件,挨个显示声明子模块,指明头文件,然后导出。 ### Swift Framework modulemap 背景:探索 Swift Framework 中,没有桥接文件,Swift 如何访问 OC?如何处理 modulemap 导出文件? 问题1:Swift Framework 中 Swift 里如何访问 OC? 利用 modulemap 解决。 但是,上面的做法有副作用。虽然 Framework 内部 Swift 可以访问 OC 了,但是使用 Framework 的地方,也可以访问到 OCStudent 类。如何解决该问题? 如何实现只在 Swift Framework 内 Swift 代码可以访问 OC 类,在使用 framework 的地方,访问不到 oc 类? module 提供 private modle 的能力。 说明: - private module 文件必须命名为 `PersonSwiftFramework.private.modulemap` 的 `private.modulemap` 格式 - `private.modulemap` 模块名必须为 `PersonSwiftFramework_Private` ```json /* module.modulemap */ framework module PersonSwiftFramework_Private { module OCStudent { header "OCStudent.h" export * } } ``` 文件内容是不希望通过正常预设模块暴露出去的子模块。 什么是正常预设模块?比如 `PersonSwiftFramework` 是正常预设模块,通过 `@import PersonSwiftFramework.` 访问均符合预期。 - private module 是规范,但是还是可以在使用 framework 的地方通过 `@import PersonSwiftFramework_Private.OCStudent;`访问到的 具体代码见: `LDAndFramework/module-collections/ModulePractice` ### Swift 静态库的合并 #### 概念 Xcode 9 之后,Swift 开始支持静态库。 Swift 没有头文件的概念,Swift 要用 public 修饰的类和函数怎么办? Swift 库引入了一个全新的文件 `.swiftmodule`,其包含序列化过的 AST,也包含 SIL(Swift Intermediate Language,Swift 中间语言) #### 实战:Swift 静态库合并 第一步:准备2个 Swift 静态库。其中 FLSwiftWorker 2个类完全一样,FLSwiftA、FLSwiftB 同名方法,方法实现不一样。 编写脚本: `cp -Rv -- "${BUILT_PRODUCTS_DIR}/" "${SOURCE_ROOT}/../Products"`,把产物拷贝到 products 目录下 第二步,打算用 libtool 指令 `libtool -static FLSwiftLibA.framework/FLSwiftLibA FLSwiftLibB.framework/FLSwiftLibB -o libFLSwiftC.a` 进行合并,发现报错 因为存在同名符号,但是又不存在2级命名空间,所以如何处理符号问题?? 需要找 Headers 头文件信息和 Modules 文件夹里面的信息。 ```shell . ├── FLSwiftLibA ├── Headers │   ├── FLSwiftLibA-Swift.h │   └── FLSwiftLibA.h ├── Info.plist ├── Modules │   ├── FLSwiftLibA.swiftmodule │   │   ├── Project │   │   ├── x86_64-apple-ios-simulator.abi.json │   │   ├── x86_64-apple-ios-simulator.swiftdoc │   │   └── x86_64-apple-ios-simulator.swiftmodule │   └── module.modulemap └── _CodeSignature ├── CodeDirectory ├── CodeRequirements ├── CodeRequirements-1 ├── CodeResources └── CodeSignature ``` 第三步,新建一个 iOS App 工程,引入合并后的静态库 libSwiftC.a 然后一步步解决符号冲突问题 - 把静态库拖入到工程中 - 新建 xcconfig 文件,配置静态库的头文件等信息 - 编辑 xcconfig 配置头文件信息,不然编译会报错(引入了头文件)。 ```shell // 头文件信息 HEADER_SEARCH_PATHS = $(inherited) "${SRCROOT}/FLStaticLibC/Public/FLSwiftLibA.framework/Headers" "${SRCROOT}/FLStaticLibC/Public/FLSwiftLibB.framework/Headers" ``` 1.为什么 App 工程中的 OC 类中导入头文件编译成功,但还是无法访问里面的类? 同样,需要一个新的参数,告诉 LD modulemap 的信息,然后系统根据 module.modulemap 去关联查找 `FLSwiftLibA.swiftmodule` 里面的 `x86_64-apple-ios-simulator.swiftmodule` swiftmodule 文件信息。 这个时候就可以在 App oc 代码中访问静态库里 Swift 类了。 2.为什么 App 工程中的 Swift 类中导入头文件报错? 因为上面的配置是 Swift 编译后产生的 modulemap 和 swiftmodule 是配置 `OTHER_CFLAGS`,其实就是配置 c、oc 编译器也就是 clang 的关于 swift 的信息。 而 Swift 编译器是 swiftc,需要额外配置。`SWIFT_INCLUDE_PATHS = "${SRCROOT}/FLStaticLibC/Public/FLSwiftLibA.framework/Modules/" "${SRCROOT}/FLStaticLibC/Public/FLSwiftLibB.framework/Modules/"` 但是配置后还是报错,因为看上去文件路径是对的,Frmaework 生成的时候,就是这么划分文件夹的。但实际编译的时候 Headers和 `module.modulemap` 、`.swiftmodule` 文件夹在同一层目录。所以我们手动编译静态库之后,需要处理静态库产物的文件目录信息。 ## hmap文件与 Header Maps ### 头文件导入技术发展历史 #### 思考 引入头文件的方式: - 路径:头文件所在目录 + `具体文件.h` - module:一堆 `.h` 放到 module,采用预编译的方式,产出二进制,节省编译时间。比如 `#import ` 思考:平时 Xcode 写代码的时候 `#import "ViewController.h"` 背后是怎么工作的?是如何找到具体的文件内容的 1. **本地引用**:当你使用双引号 `"ViewController.h"` 进行导入时,编译器会首先在当前文件的本地目录(即当前 `.m` 或 `.mm` 文件所在的目录)查找头文件。 2. **递归搜索**:如果在当前目录找不到 `ViewController.h`,编译器会递归地搜索所有子目录。 3. **项目设置**:如果本地目录中没有找到文件,编译器会根据 Xcode 项目的设置中的 "Header Search Paths" 来确定接下来搜索的目录。这些路径通常包括: - 项目的其他部分,如其他目标的目录。 - 项目依赖的库或框架的路径。 - 用户或系统级别的额外头文件目录。 4. **相对路径**:在 "Header Search Paths" 中设置的路径可以是相对路径,Xcode 会将其相对于项目文件(`.xcodeproj`)的位置来解析。 5. **绝对路径**:也可以使用绝对路径指定头文件的位置。 6. **环境变量**:有时,Header Search Paths 会包含环境变量,这些变量在编译时会被系统的实际路径替换。 7. **Framework和Library**:如果 `ViewController.h` 是某个框架或库的一部分,编译器还会在 "Framework Search Paths" 中指定的目录下查找。 8. **Header Maps**:为了提高查找效率,项目可能使用 Header Maps。这些是预先生成的文件,列出了目录中所有头文件的索引,帮助编译器快速定位。 9. **编译器缓存**:编译器可能会缓存头文件的查找结果,以避免在后续编译中重复搜索。 10. **错误报告**:如果编译器在所有指定的搜索路径中都没有找到 `ViewController.h`,它会报错指出找不到文件。 #### 早期的 import 最早期是 include,再到后来的 import,区别是什么? - `include`: 是基础引入,编译过程中会被直接展开,其内容会插入到 `#include` 指令的位置。将 `目标.h` 文件中的内容一字不落地拷贝到当前文件中,并替换掉这句 `#include` - `import`: 只引入一次。用于导入模块化的头文件,它遵循模块化的结构,可以提供更好的封装性。会加入缓存,判断 import 的内容之前已经引入过则不再引入。 这段缓存相关逻辑,可以在 [LLVM:HeaderSearch.cpp](https://github.com/llvm/llvm-project/blob/main/clang/lib/Lex/HeaderSearch.cpp) 中查看 ```c++ // Cache all of the lookups performed by this method. Many headers are // multiply included, and the "pragma once" optimization prevents them from // being relex/pp'd, but they would still have to search through a // (potentially huge) series of SearchDirs to find it. LookupFileCacheInfo &CacheLookup = LookupFileCache[Filename]; ``` import 举个例子吧。在 `Person.m` ```objective-c #import @implementation Person - (void)eat { NSLog(@"Person eat"); } @end ``` `DynamicLibA.h` 内容如下 ```objective-c #import #import #import ``` 在找到上面的内容后,编译器将其复制粘贴到 `Person.m` 中 ```objective-c #import #import #import @implementation Person - (void)eat { NSLog(@"Person eat"); } @end ``` 编译器发现存在3个 import,则继续查找其内容 ```objective-c // Student.h @interface Student: NSObject - (void)study; @end ``` 编译器会把其内容复制到 `Person.m` 中。 ```objective-c @interface Student: NSObject - (void)study; @end #import #import @implementation Person - (void)eat { NSLog(@"Person eat"); } @end ``` 这样的步骤,直到整个文件(Perosn.m)中的所有 import 被替换掉。同时 `.m` 可能会变得非常长。 存在2个问题:健壮性、拓展性。 我们大多数情况下,经常需要引入一些头文件来助力实现某些功能,假设某个类只有一个方法,方法本身10行。但是因为导入了某些头文件,最后这个文件按照上述 import 的查找替换过程,最后该文件可能存在10万行代码(import 了 Foundation、UIKit、MapKit 等库)。太浪费、太不合理了 #### PCH(PreCompiled Header) 为了优化前面提到的问题,一种折中的技术方案诞生了,它就是 `PreCompiled Header`。早期做 iOS 开发的都看过 pch 文件。 日常开发中,我们经常可以看到某些组件的头文件会频繁的出现,例如 UIKit,而这很容易让人联想到一个优化点,我们是不是可以通过某种手段,避免重复编译相同的内容呢? 而这就是 PCH 为预编译流程带来的改进点。大体原理就是,在我们编译任意 `.m` 文件前, 编译器会先对 `.pch` 里的内容进行预编译,将其变为一种二进制的中间格式缓存起来,便于后续的使用。当开始编译 `.m` 文件时,如果需要 PCH 里已经编译过的内容,直接读取即可,无须再次编译。 虽然这种技术有一定的优势,但实际应用起来,还存在不少的问题。 首先,它的维护是有一定的成本的: - 对于大部分历史包袱沉重的组件来说,将项目中的引用关系梳理清楚就十分麻烦。因此不知道需要将哪些头文件放到 `.pch` 文件中 - 随着版本的不断迭代,哪些头文件需要移出 PCH,哪些头文件需要移进 PCH 将会变得越来越麻烦 #### clang module 为了解决上面的问题,Clang Module 技术诞生了。 一个 Module 是机器代码和数据的最小单位,可以独立于其他代码单元进行链接。 通常,Module 是通过编译单个源文件生成的目标文件。例如,当前的 `test.m` 被编译成目标文件 `test.o` 时,当前的目标文件就代表了一个 Module 。 在实际编译之时,编译器会创建一个全新的目录,用它来存放已经编译过的 Module 产物。如果在编译的文件中引用到某个 Module 的话,系统将优先在这个列表内查找是否存在对应的中间产物,如果能找到,则说明该文件已经被编译过,则直接使用该中间产物,如果没找到,则把引用到的头文件进行编译,并将产物添加到相应的空间中以备重复使用。 相关的一些使用,可以查看上面的 section。 还是存在问题,因此诞生了 Use Header Maps 技术。 ### 诞生背景 但存在一个问题,如果同时存在多个 `header search path` 的时候,假设一个工作项目有10000个文件,一个类使用了10个头文件,要去10000个文件中分别查找10个头文件具体位置,这个查找过程是发生在编译阶段的。无疑会增加编译耗时。 基于此,诞生了 Header Maps 技术,通过 hmap 文件,让 Xcode 在查找头文件的时候更快速。 ### hmap 结构 创建一个 iOS App,默认模版的基础上添加一个 Person 类,一个继承自 Person 的 Student 类。然后编译 从 Xcode Compile log 可以看到通过 `-I` 参数来指定 hmap 文件。复制目录路径查看所有的 hmap 文件,可以看到, **iOS hmap 文件会根据文件分类和使用方式主要与项目结构和编译需求有关。可以分为项目级别 Header Maps、组件级别 Header Maps、公共与私有 Header Maps等等**。 `hmap` 文件不可读,必须用对应的工具解析,按照指定的格式解析。因为属于编译器的 scope,所以查看 LLVM 源码窥探下。 可以看到 hmap 结构类似 Mach-O ,顶部 HMapHeader 告诉系统,当前有几个 Bucket,下面是 Bucket 信息。然后最下方是 string 区域。 在 HMapBucket 中: - Key: 一个 `uint32_t` 类型的成员,表示 bucket 中键的哈希值或者是一个特殊值表示 bucket 的状态(例如,是否为空或者是一个冲突的bucket) - prefix: 一个 `uint32_t` 类型的成员,通常用于存储键的前缀偏移量,它与 `Suffix` 一起定义了键在整个 Header Map 中的完整字符串 - Suffix: 一个 `uint32_t` 类型的成员,用于存储键的后缀长度,与 `Prefix` 结合使用可以定位到键的具体字符串 ### 编写工具分析 hmap 文件 其结构、工作原理都类似 Mach-O 文件。参考 Mach-O 文件结构和 [LLVM:HeaderMap.cpp](https://github.com/llvm/llvm-project/blob/main/clang/lib/Lex/HeaderMap.cpp) 的实现,编写一个读取 hmap 文件的代码。 具体代码可以在这个 Repo 中查看并运行 [BlogDemos:HMapDump](https://github.com/FantasticLBP/BlogDemos/tree/master/HMapDump) 运行调试的时候,把需要读取的 HMapFile 拷贝到项目根目录。然后 Edit Scheme - Run - Arguments Passed On Launch 如何做到该工具做到像系统自带的 `objdump` 一样,在终端命令行使用: - 在电脑根目录新建 `CustomTools` 文件夹 - 将上面编译后的产物复制进去 - 在 `.zshrc` 文件里将路径添加进去。`export PATH=~/CustomTools:$PATH` - 编辑 `.zshrc` 文件后,在终端执行 `souce .zshrc` - 即可使用 效果如下: ### 编写工具生成 hmap 文件 #### 为什么要生成 hmap 文件 如果2个 `.m` 文件有相同的头文件代码,造成编译浪费。 clang 可以使用 `-I` 来指定 Header Search Path 信息。`-I` 后面可以跟:`目录文件夹`、`.hmap` 文件 iOS 导入方式: - `import <> ` :本质上就是 LD 编译参数 `HEADERS_SEARCH_PATHS -I` - `import ""` :本质上就是 LD 编译参数 `USER_HEADER_SEARCH_PATHS -iquote` Tips:Xcode 设置 Search Path 有三处 `Header Search Path`、`System Header Search Path`、`User Header Search Path`,区别是什么? - System Header Search Path 是针对系统头文件的设置,通常代指 `<>` 方式引入的文件 - User Header Search Path 则是针对非系统头文件的设置,通常代指 `""` 方式引入的文件 - Header Search Path 并不会有任何限制,它普适于任何方式的头文件引用 Xcode 会主动生成 `.hmap` 文件,那为什么还需要研究生成 `hmap` 文件? 有必要。下面来个例子进行说明。虽然平时开发中经常以二进制组件的方式构建 App,但在某些场景下(精准测试、覆盖率统计等),打包构建还是需要以全源码编译的方式进行。而且在实际开发过程中,大多是以源码的方式进行开发,所以我们将实验对象设置为基于全源码编译的流程。 创建一个静态库 `HMapStaticLib` 里面就包含2个类文件 `Person` 类、继承自 `Person` 类的 `Student` 类。再创建一个使用 iOS App,使用该静态库。编译该 App,查看编译日志。 然后查看编译日志中的 ` HMapStaticLibApp-project-headers.hmap` 文件。利用上面制作的 `HMapDump` 工具。 分析:在 App 使用 Static Library 的情况下,假设开启了 `Use Header Map`,静态库中所有头文件类型为 `Project`(只有 Project、Private、Public 3种类型,public 就是字面意思的公开,private 则代表 In Progress, project 才是通常意义上的 Private 含义)的情况,最终生成的 `.hmap` 文件中只会包含类似 `#import "Student.h"` 的键值引用。也就是说使用的地方,只有 `#import "Student.h"` 的这种方式才会走 hmap 策略,否则还是走 `Header Search Path` 来寻找头文件路径。 组件、库使用 `#import ` 是访问的标准做法。好处有3点: 1. 明确头文件的由来,避免歧义 2. 可以让我们在是否开启 clang module 中随意切换 3. Apple 在 WWDC 里曾经不止一次建议开发者使用这种方式来引入头文件 所以可以回答上面的问题了。虽然一个静态库、动态库项目中,`Build Setting` 中虽然 `Use Header Maps` 为 YES,但是某些情况下 Header Maps 带来的加速福利没有享受到。 一个大型项目有300个 Pod,每个 Pod 有100个头文件,也就是共 30000个头文件,在30000个头文件中,如果没有享受 `Header Maps` 带来的福利,老老实实依靠 `Header Search Path` 中提供的头文件所在的文件夹信息查找,或者递归查找。可能存在 n*m 的循环查找过程,效率很低,涉及的 IO 影响大型项目的编译构建时间,影响分发和发生问题时候的及时热修复。 既然知道某些情况下会存在 hmap 文件没有命中的情况,那有必要修改吗?让静态库的情况,也可以使用 hmap 带来的编译红利。 #### 如何生成 HMap 文件 LLVM 真是好东西,把 Xcode 编译、链接等一些幕后的事情变成白盒,有迹可循,感兴趣的可以去查看源码并带着一些关键词来搜索源码进行查看,可能会发现一些平时了解不到的细节。 可以查看 [HeaderMapTest.cpp](https://github.com/llvm/llvm-project/blob/main/clang/unittests/Lex/HeaderMapTest.cpp) 和 [HeaderMapTestUtils.h](https://github.com/llvm/llvm-project/blob/main/clang/unittests/Lex/HeaderMapTestUtils.h) 编写 hmap 的生成代码。编写后的具体代码可以查看 [BlogDemos:HMapWritor](https://github.com/FantasticLBP/BlogDemos/tree/master/HMapWritor) 为了方便平时使用,按照上面的方式,也将二进制放到 `/Users/unix_kernel/CustomTools` 目录下,同时设置 `.zshrc` 可访问性。 ### hmap 助力提升 iOS 项目编译速度 已经编写好生成 `.hmap` 文件的能力了,那如何使用生成的 `.hmap` 文件? - Xcode `Build Setting` 中 `Use Header Maps` 为 NO - `Header Search Path` 设置生成的 hmap 文件路径 实践操作下。把上面静态库无法走 Header Maps 带来的问题解决掉。 第一步,编写 hmap 所需要的 json 信息。 第二步,利用 `HMapWriter` 能力,根据 json 生成静态库所需要的 `StaticLibUsage.hmap` 第三步,在静态库 Xcode 项目中,关闭 `Use Header Maps`(设置为 NO),同时修改 `Header Search Paths` 为生成 `StaticLibUsage.hmap` 路径。 第四步,编译使用静态库的 App 工程。最后比较静态库走自定义 `.hmap` 前后的编译耗时。 可以看到静态库使用了自定义的 Header Maps 文件后,使用静态的 App 前后,编译耗时减少了1.1s,节省了57%。 Demo 及其演示代码见 [HMapStaticLibApp](https://github.com/FantasticLBP/BlogDemos/tree/master/HMapStaticLibApp) 和 [HMapStaticLib](https://github.com/FantasticLBP/BlogDemos/tree/master/HMapStaticLib)。 ### 工程化问题 上面是理论上分析并思考了2个问题: - 为什么需要介入自己生成 hmap 文件 - 生成后的 hmap 文件如何使用 如果每个工程项目都这么做,效率有点低,所以需要站在工程化的角度去设计,如何优化? Cocoapods 提供了很多钩子,可以自定义编写 Ruby 脚本。 - HooksManager 注册 cocoapods 的 `post_install` 钩子 - 通过 `header_mappings_by_file_accessor` 遍历所有头文件和 `header_dir`,由header_dir/header.h和header.h为key,以头文件搜索路径为value,组装成一个Hash,生成所有组件pod头文件的json文件,再通过hmap工具将json文件转成hmap文件。 - 再修改各 pod 中 `.xcconfig` 文件的 `HEADER_SEARCH_PATHS` 值,仅指向生成的新生成的 `.hmap` 文件,删除原来添加的搜索目录; - 修改各 pod 的 `USE_HEADERMAP` 值,关闭对默认的 `.hmap` 文件的访问 ## 死代码剥离 死代码剥离的条件: - 没有被入口点使用则会被干掉 - 没有被导出符号所使用,则会被干掉 dead code strip 和 xlinker 提供的4个参数: - `-noall_load`: 完全不加载、直接去优化。`OTHER_LDFLAGS=-Xlinker -noall_load` - `-all_load`: 完全加载、不要去优化掉。`OTHER_LDFLAGS=-Xlinker -all_load` - `-ObjC` : 排除ObjC的代码、其他的都优化掉。`OTHER_LDFLAGS=-Xlinker -ObjC ` - `-force_load` : 指定哪些静态库不要优化掉; ## DYLD(dyld shared cache) 动态库共享缓存 UIKit、CoreGraphics 等。从 iOS13 开始,为了提高性能,绝大部分的系统动态库文件都被打包存放到了一个缓存文件中(dyld shared cache)中。 存放路径为:`/System/Libarey/Caches/com.apple.dyld/dyld_shared_cache_armX` 其中,`X` 代表 ARM 处理器的指令集架构。V6、V7、V7s、arm64、arm64e。不同架构,对应的动态库缓存不一致。 所有指令集原则是向下兼容的。动态库共享缓存一个非常明显的好处是节省内存。 具体底层源码可以查看,dyld 源码中 `dyld2.cpp` 文件,函数入口为 load 方法。 ```c ImageLoader* load(const char* path, const LoadContext& context, unsigned& cacheIndex); ``` 某个 App 被第一次打开时候, dyld 根据动态库的依赖,循环加载动态库到动态库共享缓存中(内存),后续其他 App 第一次打开发现使用了动态库A已经在动态库共享缓存中存在,则不需要加载。这一步调用方法为 `findInSharedCacheImage` ## dyld 及其工作流程 ### 概念 dyld 是一个动态链接程序。是 iOS 和 macOS 系统中的**动态链接器**(Dynamic Loader)。它负责在程序运行时加载和链接动态库。简单来说,dyld 就如同一个「中间人」,将你的程序代码和它所依赖的所有动态库整合在一起,最终让你的程序能够正常运行。 `libdyld.dylib` 给我们的程序提供在 runtime 期间能使用动态链接功能。比如 dlopen 能力,又或者是系统 lazy-binding 符号表,在运行之后,动态修正符号地址。 懒加载的符号表项通常包括一个或多个指向符号的间接符号(Indirect Symbols),这些间接符号在首次访问时会被动态链接器替换为实际的内存地址。这个过程称为符号绑定(Symbol Binding)。由于懒加载的符号在首次使用前不会被绑定,因此可以减少应用程序的启动时间,并按需加载所需的资源 ```shell objdump --macho --private-headers DSYMDemo ``` - **启动阶段**:应用程序启动时,iOS 系统首先解析 `Info.plist` 文件,加载相关信息,例如启动画面等,并建立沙盒环境以及进行权限检查 - **Mach-O 加载**:系统加载应用程序的可执行文件(Mach-O 格式),这是一个包含程序指令和数据的文件。加载时会包括 dylib(动态库)的加载时间以及偏移修正(rebase)和符号绑定(binding)的时间 - **dyld 的初始化**:在系统内核完成初始化后,从 Mach-O 的 `LC_LOAD_DYLINKER` load command 中,根据 name 路径信息,然后加载,dyld 开始执行。它的主要任务是加载应用程序的主可执行文件以及其他依赖的动态库 ```shell Load command 14 cmd LC_LOAD_DYLIB cmdsize 88 name /System/Library/Frameworks/Foundation.framework/Foundation (offset 24) time stamp 2 Thu Jan 1 08:00:02 1970 current version 1953.255.0 compatibility version 300.0.0 ``` - **依赖分析**:dyld 会分析 Mach-O 文件,找出程序所依赖的所有库,并递归地解析所有依赖关系,形成动态库的依赖图 - **内存映射**:dyld 将匹配 Mach-O 文件到内存空间,确保每个依赖库都被映射到正确的地址 - 解析 Mach Header,判断当前 Mach-O 文件是否可用 比如上面的 Mach header 中 - cputype 是 `X86_64`,dyld 会判断该架构能否在当前系统上运行 - filetype 是 `EXECUTE`,是不是一个可执行文件 - 有 ncmds 16个 Load commands,占 sizeofcmds 2848大小的空间 - 根据 Mach Header,解析 load commands,根据解析的结果,将程序各个部分加载程序到指定的地址空间,同时设置保护标志 ```js Load command 1 cmd LC_SEGMENT_64 // 指定加载命令的类型是 LC_SEGMENT_64,这代表一个 64 位的内存段命令。 cmdsize 792 // 表示这个加载命令的总大小是 792 字节。 segname __TEXT // 段名称,这里是 __TEXT 段,通常包含程序的指令和只读数据。 vmaddr 0x0000000100000000 // 指定了该段在内存中的虚拟地址 vmsize 0x0000000000004000 // 定义了该段在内存中的大小,这里是 16 KiB(4000 十六进制转换为十进制是 16384)。 fileoff 0 // 表示该段在文件中的偏移量,这里是文件的起始位置 filesize 16384 // 表示该段在文件中的实际大小,以字节为单位 maxprot r-x // 定义了该段(代码段)的最大保护级别,这里是 r-x(读和执行),表示该段可以被读取和执行,但不能写入。如果是数据段,则可读可写 rw- initprot r-x // 定义了该段在加载到内存时的初始保护级别,与最大保护级别相同 nsects 9 // 表示该段包含的节(sections)数量,这里是 9 个 flags (none) // 该段没有设置任何特殊标志 ``` - **符号查找和绑定**:进行符号查找,处理程序中的符号引用,并进行符号绑定,确保所有的函数调用和全局变量引用都能正确地指向它们的实现 - **rebase 操作**:由于 ASLR(地址空间布局随机化)的需要,dyld 会对 Mach-O 文件进行 rebase 操作,调整代码和数据的地址以适应随机化的内存布局 - **初始化程序执行**:在链接操作完成后,dyld 会执行初始化程序,包括 Objective-C 的 `+load` 方法和 C 的构造函数,以初始化静态变量 - **main 函数调用**:最后,dyld 读取 Mach-O 文件的 `LC_MAIN` 命令,获取程序的入口地址,并调用 `main` 函数,启动应用程序的主执行流程 - **dyld 3 的优化**:从 iOS 11 开始,引入了 dyld 3,它通过进程外的 Mach-O 分析器/编译器以及进程内的执行引擎,将许多耗时的操作提前处理好,并缓存结果,从而极大提升了启动速度 ### dyld 到底做了什么 1. 执行自身初始化配置加载环境。 `LC_DYLD_INFO_ONLY` ```shell Load command 4 cmd LC_DYLD_INFO_ONLY // 表示这是一个只包含 dyld 信息的加载命令,它通常不包含实际的 rebase、bind 等信息,而是提供给 dyld 用于优化链接过程的信息 cmdsize 48 // 这个命令的总大小是 48 字节 rebase_off 0 // 指示 rebase 信息在文件中的位置 rebase_size 0 // 指示 rebase 信息在文件中的大小 bind_off 0 // 指示 bind 信息的位置 bind_size 0 // 指示 bind 信息的大小 weak_bind_off 0 // 指示弱绑定信息的位置 weak_bind_size 0 // 指示弱绑定信息的大小 lazy_bind_off 0 // 指示延迟绑定信息的位置 lazy_bind_size 0 // 指示延迟绑定信息的大小 export_off 32768 // 导出表(export table)的位置 export_size 48 // 导出表(export table)的大小 ``` 2. 加载当前程序链接的所有动态库到指定的内存中。`LC_LOAD_DYLIB` ```shell Load command 12 cmd LC_LOAD_DYLIB // 指明了 load command 的类型是 LC_LOAD_DYLIB,意味着接下来的信息是关于加载一个动态库的 cmdsize 56 // 这个命令的总大小是56字节 name /usr/lib/libSystem.B.dylib (offset 24) // 指定了要加载的动态库的路径和文件名。这里是 libSystem.B.dylib,位于 /usr/lib/ 目录下。(offset 24) 表示文件名在命令数据中的偏移量 time stamp 2 Thu Jan 1 08:00:02 1970 // 这是动态库的时间戳,用于版本检查。不过,这个时间戳通常是0或一个固定值,因为系统库的加载不依赖于时间戳 current version 1319.0.0 // 这是动态库的当前版本号,用于确保应用程序加载的是兼容的版本 compatibility version 1.0.0 // 这是动态库的兼容版本号,表明应用程序至少需要这个版本的库才能正常运行 ``` 3. 搜索所有动态库,绑定需要在调用程序之前用的符号(非懒加载符号)。`LC_DYSYMTAB` 4. 在 indirect symbol table 中,将需要绑定的导入符号真实地址替换。`LC_DYSYMTAB` Todo:fishhook 原理 5. 向程序提供 Runtime 运行时使用 dyld 的接口(存在于 libdyld .dylib 中,由 `LC_LOAD_DYLIB` 提供) 6. 配置 Runtime,执行所有动态库/image 中使用的全局构造函数 7. dyld 调用程序入口函数,开始执行程序。`LC_MAIN` ``` Load command 11 cmd LC_MAIN // 里的 cmd 指明了加载命令的类型是 LC_MAIN,这个命令用于指定程序的主入口点 cmdsize 24 // 这个命令的大小是24字节,这是命令头加上任何尾部数据的总和 entryoff 16288 // 指定了程序入口点的偏移量,它是相对于文件的开始位置的。在这个例子中,入口点位于文件开头的第 16288 字节处。入口点通常是 main 函数的地址 stacksize 0 // 这个字段指定了为程序的主线程初始栈分配的大小。在这个例子中,栈大小被设置为0,这可能意味着栈的大小将使用默认值,或者在其他地方指定(例如,通过链接器的其他设置或命令行选项) ``` ### main 函数 实验一: 编写2个函数,main 函数和 test 函数,除了方法名不同,在汇编侧是一样的。 实验二: 可以看到创建的 `test.c` 文件中,只有一个 `test` 方法。没有 `main` 方法,然后用 gcc 发现链接报错。 然后利用 gcc 指令 ` gcc -nostartfiles -e_test test.c` 发现可以编译通过,运行也没问题。 当然除了 gcc,很多嵌入式平台,可以在代码中指定 c 程序的起点。 比如 STM32,专门有个汇编文件,用于系统的初始化。 总结一下: - main 函数和其他普通函数并无区别 - main 函数是很多程序的默认起点,但绝不是非它不可,任何函数都可以被设置成程序起点。 iOS 侧,dyld 默认以 main 函数作为函数起点。 ### dyld 加载过程 - 调⽤ `fork `函数,创建⼀个进程 - 调⽤ `execve` 或其衍⽣函数,在该进程上加载,执⾏ `Mach-O`⽂件 - 将 `Mach-O` ⽂件加载到内存 - 开始分析 `Mach-O` 中的 `mach_header`,以确认它是有效的 `Mach-O` ⽂件 - 验证通过,根据 `mach_header `解析 `load commands`。根据解析结果,将程序各个部分加载到指定的地址空间,同时设置保护标记 - 从 `LC_LOAD_DYLINKEN`中加载 `dyld` - `dyld`开始⼯作 - 调用 `__dyld_start()` 函数,通知 `dyld` 开始工作 - 调用 `dyldbootstrap::start` 函数,使 `dyld` ⾃身进⼊可运⾏状态 - 调用 `dyld::_main` 函数,`dyld `的入口函数 - 检查共享缓存中的缓存,如果找到直接返回(红线逻辑),否则继续后面的流程 - 共享缓存中没有找到,则继续下面流程 - 加载所有手动插入的动态库 - 链接程序需要的动态库 - 链接插入的库 - 应用插入函数 - 绑定符号 - 调用 `instantiateMainExecutable` ,为主可执行文件创建镜像 - 调用当前程序与动态库的初始化构造函数(`__attribute__((constructor))`) - 通过 `LC_MAIN` 查找设置程序⼊⼝函数,将胶⽔地址设置成⼊⼝函数地址,否则胶⽔地址为0 - 提供胶水地址,返回到 `dyld::_main` 函数中继续执行 - 通过 `dyld::_main`→`dyldbootstrap::start`→`__dyld_start()`,dyld 配置完成,把控制权交给可执⾏⽂件的⼊⼝函数`main()`,继续后面的流程 ### dyld 应用 窥探系统库底层实现的时候可能需要从动态库共享缓存中提取出某个 Framework,比如 UIKit。这时候要么用第三方工具,要么用 dyld 的能力。 查看 `dsc_extractor.cpp` 代码可以看到是具备抽取能力的。修改源码,将 if 宏定义去掉。如下 ```c #include #include #include typedef int (*extractor_proc)(const char* shared_cache_file_path, const char* extraction_root_path, void (^progress)(unsigned current, unsigned total)); int main(int argc, const char* argv[]) { if ( argc != 3 ) { fprintf(stderr, "usage: dsc_extractor \n"); return 1; } //void* handle = dlopen("/Volumes/my/src/dyld/build/Debug/dsc_extractor.bundle", RTLD_LAZY); void* handle = dlopen("/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/usr/lib/dsc_extractor.bundle", RTLD_LAZY); if ( handle == NULL ) { fprintf(stderr, "dsc_extractor.bundle could not be loaded\n"); return 1; } extractor_proc proc = (extractor_proc)dlsym(handle, "dyld_shared_cache_extract_dylibs_progress"); if ( proc == NULL ) { fprintf(stderr, "dsc_extractor.bundle did not have dyld_shared_cache_extract_dylibs_progress symbol\n"); return 1; } int result = (*proc)(argv[1], argv[2], ^(unsigned c, unsigned total) { printf("%d/%d\n", c, total); } ); fprintf(stderr, "dyld_shared_cache_extract_dylibs_progress() => %d\n", result); return 0; } ``` 然后用 clang++ 编译,命令为 `clang++ dsc_extractor.cpp` 将编译后的产物复制到动态库共享缓存目录下去。然后执行命令`./dsc_extractor dyld_shared_cache_armv7s armv7s`,代表将动态库提取到 armv7s 目录下。 dyld 本身就是一种 MachO 文件,MachO 文件类型为7,是一种包含多种架构的结构,如下图: 可以加载以下类型的 Mach-O 文件:MH_EXECUTE、MH_DYLIB、MH_BUNDLE #### 插入动态库 工作原理:使用 `__attribute__((constructor))` 是 GCC 和 Clang 编译器支持的一个属性,用于标记一个函数为构造函数,该函数会在程序加载动态库时自动执行。 动态库的加载过程通常是在程序启动时进行的,因此 `__attribute__((constructor))` 属性标记的函数会在 `main` 函数执行之前被调用。 第一步:创建 `Inject` 动态库,新建 `Inject.m` 文件 ```c++ #import __attribute__((constructor)) static void customConstructor(int argc, const char **argv) { NSLog(@"Hello,I am an injected dynamic library!"); } ``` 第二步:创建 iOS App 测试工程,接入 Inject 动态库。将 `Inject.framework` 里的动态库拖到 App 工程根目录。 第三步:Edit scheme -> Run -> Environment Variables。Name 为 `DYLD_INSERT_LIBRARIES`,value 为 InjectFunction 动态库路径,`${SRCROOT}/Inject`。 第四步:运行输出如下 具体 [Demo]() #### 函数替换 工作原理: 在 iOS 中,`__DATA, __interpose` 是一个特殊的 Mach-O 段,用于实现函数插桩 (Function Interposing)。它允许你在不修改原始库代码的情况下,拦截并替换库中的某个函数 **`__attribute__((used)) static struct { ... } ... __attribute__ ((section("__DATA, __interpose"))) = { ... };`** 定义一个结构体,它包含两个指向函数的指针:`replacement` 和 `replacee`。结构体使用 `__attribute__((used))` 属性标记,以避免编译器将其优化掉。同时,它被放置在 `__DATA, __interpose` 段,这个段是专门为函数插桩而设计的。 参考 [dyld::dyld-interposing.h](https://opensource.apple.com/source/dyld/dyld-210.2.3/include/mach-o/dyld-interposing.h) 第一步:创建 `InjectFunction` 动态库,新建 `InjectFunction.m` 文件 ```c++ #import #define INTERPOSE(_replacement, _replacee) \ __attribute__((used)) static struct { \ const void* replacement; \ const void* replacee; \ } _interpose_##_replacee __attribute__ ((section("__DATA, __interpose"))) = { \ (const void*) (unsigned long) &_replacement, \ (const void*) (unsigned long) &_replacee \ }; void my_NSLog(NSString *format, ...) { NSLog(@"Injected Function ---> %@", format); } INTERPOSE(my_NSLog, NSLog); ``` 第二步:创建 iOS App 测试工程,接入 InjectFunction 动态库。将 `InjectFunction.framework` 里的动态库托到 App 工程根目录。 第三步:Edit scheme -> Run -> Environment Variables。Name 为 `DYLD_INSERT_LIBRARIES`,value 为 InjectFunction 动态库路径,当有多个动态库的时候,中间用 `:` 把路径隔开。`${SRCROOT}/Inject:${SRCROOT}/InjectFunction`。 第四步:运行输出。发现 NSLog 确实被 hook 做了替换。 应用场景:不能上架,但可以去做探索、验证源码等场景。 ### 调试 dyld 一种是替换系统的 dyld,风险大,需要感知源码。推荐使用 dyld 提供的环境变量来控制 dyld 在运行过程中输出感兴趣的信息。 - `DYLD_PRINT_APIS`: 打印 dyld 内几乎所有发生的调用 - `DYLD_PRINT_LIBRARIES` :打印在应用程序启动期间正在加载的所有动态库 - `DYLD_PRINT_WARNINGS`:打印 dyld 运行过程中的辅助信息 - `DYLD_PATH`:显示 dyld 搜索动态库的目录顺序 - `DYLD_PRINT_ENV`:显示 dyld 初始化的环境变量 - `DLYD_PRINT_SEGMENTS`:打印当前程序的 segment 信息 - `DYLD_PRINT_STATISTICS`:打印 pre-main 耗时 - `DYLD_PRINT_INITIALIZERS`:会在执行每个镜像(image)的初始化器(initializer)时打印出一行信息。这些初始化器包括 C++ 的静态构造函数以及使用 `__attribute__((constructor))` 标记的函数。这个环境变量对于调试和分析程序启动时的初始化顺序和行为非常有用。 怎么用? `环境变量=1 ./可执行文件` 另一种方式是利用 Xcode -> Edit Scheme,增加或修改 dyld 环境变量 ## Mach-O Mach Object 的缩写,是 iOS/MacOS 上用于存储程序、库的标准格式。 Mach-O 格式⽤来替代 BSD 系统的 `a.out` 格式。Mach-O ⽂件格式保存了在编译过程和链接过程中产⽣的机器代码和数据,从⽽为静态链接和动态链接的代码提供了单⼀⽂件格式。 在 XNU 源码中可以查看 Mach-O 的定义。`loader.h` 属于 Mach-O 格式的文件类型有: ```c #define MH_OBJECT 0x1 /* relocatable object file */ #define MH_EXECUTE 0x2 /* demand paged executable file */ #define MH_FVMLIB 0x3 /* fixed VM shared library file */ #define MH_CORE 0x4 /* core file */ #define MH_PRELOAD 0x5 /* preloaded executable file */ #define MH_DYLIB 0x6 /* dynamically bound shared library */ #define MH_DYLINKER 0x7 /* dynamic link editor */ #define MH_BUNDLE 0x8 /* dynamically bound bundle file */ #define MH_DYLIB_STUB 0x9 /* shared library stub for static */ /* linking only, no section contents */ #define MH_DSYM 0xa /* companion file with only debug */ /* sections */ #define MH_KEXT_BUNDLE 0xb /* x86_64 kexts */ ``` ### 常见的 Mach-O 文件类型 - MH_OBJECT - 目标文件(.o) - 静态库文件(.a),静态库其实就是 N 个 `.o` 合并在一起 - MH_EXECUTE:可执行文件 - MH_DYLIB:动态库文件 - dylib - .framework - MH_DYLINKER:动态链接器 (/usr/lib/dyld) - MH_DSYM:存储着二进制文件符号。`.dSYM/Contents/Resource/DWARF/xx` 常用于还原堆栈 Xcode 中也可以查看 Mach-O 文件类型 源代码(比如c文件),编译变为目标文件(比如.o文件),再经过链接变为可执行文件。 Tips:`file` 命令可以查看文件类型。 `find . -name "*.c"` 比如在当前路径查找 .c 文件 ### Universal Binary 通用二进制文件,可以同时适用于多种架构的二进制文件,包含多种不同架构的独立的二进制文件。 - 因为要存储多种架构的代码,所以通用二进制文件通常比单一平台的二进制程序更大 - 由于两种架构有共同的一些资源,所以并不会达到单一版本的2倍多。 - 执行的过程中,只调用一部分代码,运行起来不需要额外的内存。 因为通用二进制文件比原来的大,所以被成为“胖二进制文件”(Fat Binary),使用 Hopper 打开会显示 “FAT archive” 信息查看: - 查看某可执行文件(Test)支持的架构指令集 :`lipo -info Test` - 将某个指令集拆出来比如 arm64:`lipo Test -thin arm64 -o Test_arm64` - 也可以将多个指令集合并:`lipo -create Test_arm64 Test_armv7 -output Test_universal` Xcode 中可以修改指令集。由 Build Settings 的2个配置决定: `Architectures->Architectures` `User-Defined->VALID_ARCHS`,取2者的交集。 其中 `Architectures->Architectures` 的值 `$(ARCHS_STANDARD)` 是 Xcode 内置的环境变量,不同版本的 Xcode 值不一样,是通用的一些架构值。比如 Xcode9下,`$(ARCHS_STANDARD)` 可能为 arm64、armv7。Xcode 4下,`$(ARCHS_STANDARD)` 可能为 armv7 ### Mach-O 结构 一个 Mach-O 文件包含3块 - Header:文件类型、目标架构类型信息 - Load commands:描述文件在虚拟内存中的逻辑结构、布局 - Raw segment data:在 Load Commands 中定义的 segment 的原始数据 可以用 [MachOView:](https://github.com/fangshufeng/MachOView) 和系统自带的 atool 查看 Mach-O 信息 比如我用 otool 查看我编写的一个 SwiftUIDemo 所依赖的共享缓存库 用 MachOView 查看 DDD Mach-O 文件 可以看到在 Mach-O 文件上,`__PAGEZERO` 的 `VM Size` 有值,但是 File Size 为0,也就是说 `__PAGEZERO` 在 Mach-O 中不占据内存,但是程序运行起来之后,会占据虚拟内存。所以代码段在 Mach-O 中 File Offset 为0(如果前面的 `__PAGEZERO` 的 File size 有值,这里的 File Offset 就不为0)。 **在没有 ASLR 的时候:__TEXT 代码段地址 = __PAGEZEROR 地址** ## Mach-O 文件如何查找地址 第一步,编写一个名为 `main.m` 的代码 ```objective-c void test1 () {} void test2() {} int globalValue; int main () { globalValue = 21; globalValue = 20; test1(); test2(); return 0; } ``` 第二步,将其转换为可执行文件,指令为`clang main.m -o main` 第三步,分析查看可执行文件 `objdump --macho -d main` 第四步,将其转为目标文件,指令为 `clang -c main.m -o main.o` 第五步,查看目标文件指令信息 `objdump --macho -d main.o` 分析下指令信息: - 可以看到源码中从上到下声明的函数,在目标文件和可执行文件中顺序是一致的 - 可执行文件中,顺序也是从上到下,依次执行的。左侧是真实指令地址 - 目标文件中,顺序也是从上大下,但没有真实地址,是相对地址,是偏移量。 - 可以看到像函数调用的逻辑,是 ` e8 00 00 00 00 callq _test1`,其中 `e8` 是固定指令,代表 `callq`。后面的 `00 00 00 00 ` 是相对地址,test1 真正的地址是 `下面 4e` + `上面 00 00 00 00` 的结果。这是一种**近地址相对位移技术** - test1、test2 2个函数都存在,地址不一样,为什么都是 `00 00 00 00 ` ?那系统如何确定函数真实地址?需要找个地方将这些符号存起来,然后再找个时机去把真实的地址写进去。需要**重定位符号表**,告诉链接器在链接阶段需要重定位 - 使用 `objdump --macho --reloc main.o` 指令查看 main.o 的重定位符号表。 - 符号表中 `test1` 的地址就是 `0000004a` 对应的数据。`49` 后面就是 `4a` - 符号表中 `test2` 的地址就是 `0000004f` 对应的数据。`4e` 后面就是 `4f` - 符号表中 `globalValue` 的地址就是 `0000003f` 对应的数据,`3c 3d 3e 3f`,经过 `48 8b 05` 到 `3f` ### 如何找到函数的真实地址 以 test1 为例。 ```shell 100003f93: c7 00 14 00 00 00 movl $20, (%rax) 100003f99: e8 b2 ff ff ff callq _test1 100003f9e: e8 bd ff ff ff callq _test2 100003fa3: 31 c0 xorl %eax, %eax ``` 第一步:根据 B2 计算得到原码,然后计算出原码。 iOS 是小端序,从右向左看,`b2 ff ff ff` 可以看到后4位是 ff,所以是一个经过计算后的补码信息。所以需要计算得到原码信息。 补码如何到原码?所有的1取反,然后加1。第一步计算结果为 `0x4E` 第二步:根据近地址相对位移技术,`下一条指令地址+ b2 ff ff ff` 就是函数 test1 的真实地址。但是 FF 是负的,所以 `0x100003f9e - 0x4E = 0x100003F50 `,也就是 Text 段中 test1 的真实地址。 完整的 ### 如何找到全局变量地址 第一步,先用指令查看全局变量的地址值。 `objdump --macho -s main`,可以看到地址值为 `0x100008000` 第二步,手动计算。根据近地址相对位移技术原理,`下一条指令 + 0x407a` = `0x100003f86 +0x407a = 0x100008000` 去掉最开始的 `48 8d 05` ,iOS 小端序,所以是 `0x407a` ```shell 100003f70: 55 pushq %rbp 100003f71: 48 89 e5 movq %rsp, %rbp 100003f74: 48 83 ec 10 subq $16, %rsp 100003f78: c7 45 fc 00 00 00 00 movl $0, -4(%rbp) 100003f7f: 48 8d 05 7a 40 00 00 leaq _globalValue(%rip), %rax 100003f86: c7 00 15 00 00 00 movl $21, (%rax) 100003f8c: 48 8d 05 6d 40 00 00 leaq _globalValue(%rip), %rax 100003f93: c7 00 14 00 00 00 movl $20, (%rax) ``` 可见 `0x100008000` 和数据段的地址是一样的。 至此,可以了解到 Mach-O 文件是按照不同 Section 去加载数据,访问数据的。但不同 Section 是语义上更方便理解的,程序执行最本质的是:不管处于什么 section,只看偏移量。 ## .dSYM 文件 ### 定义 `.dSYM` 文件就是保存 DWARF 格式的调试信息的文件。 `.DSYM` (debugging symbol)文件是保存十六进制函数地址映射信息的中转文件,调试信息(symbols)都包含在该文件中。Xcode 工程每次编译运行都会生成新的 `.DSYM` 文件。默认情况下 debug 模式时不生成 `.DSYM` ,可以在 Build Settings -> Build Options -> Debug Information Format 后将值 `DWARF` 修改为 `DWARF with DSYM File`,这样再次编译运行就可以生成 `.DSYM` 文件。 DWARF 是被众多编译器和调试器使用的,用于支持源码级别调试的调试文件格式。 ### 探索 #### 如何生成 `.dSYM` 文件 第一步,新建 `main.m` 文件 第二步:Xcode 每次编译运行都会生成新的 `.DSYM` 文件。使用 clang -g 参数也可以生成,指令为 `clang -g -c main.m -o main.o` 第三步:查看 Mach-O 中,包含 `__DWARF` 的段,使用指令 `objdump --macho --private-headers main.o` 第四步:编译成可执行文件,指令为 `clang -g main.m -o main` 第五步:查看可执行文件中是否包含 `__DWARF` 段。`objdump --macho --private-headers main` 第六步:查看可执行文件的符号表。指令为:`nm -pa main`。红色区域代表调试符号。 看到了调试符号和 DWARF 信息,那如何生成 .dSYM 文件? - 指令 `clang -g1 main.m -o main`,参数 `-g1` 用于生成 `.dSYM` 文件。 - 指令 `dwarfdump main.dSYM` 用于查看 `.dSYM` 文件 - 读取 `debug map` - 从 `.o` 文件中加载 DWARF - 重新定位所有地址 - 最后将全部的 DWARF 打包成 `.dSYM` bundle 结论: 编译器会在编译阶段,把调试信息放在单独的 `__DWARF` 段中。当去链接的时候,会把 `__DWARF` 段删掉,同时把所有的调试信息放在符号表中。 打包上线的时候会把调试符号等裁剪掉,但是线上统计到的堆栈我们仍然要能够知道对应的源代码,这时候就需要把符号写到另外一个单独的文件里,这个文件就是DSYM。 #### .dSYM 符号化 第一步:新建 iOS 项目,Buidl Setting 切换为 `DWARF with dSYM File ` 。设置模拟 crash(数组越界) 第二步:Mac 自带 `console` App 查看崩溃报告,因为有 `.dSYM` 文件,所以可以看到方法信息 思考:假设线上 crash 了,如何根据 crash 堆栈中没有符号化的地址,找到符号的真实地址? ```shell Last Exception Backtrace: 0 CoreFoundation 0x7ff80042889b __exceptionPreprocess + 226 1 libobjc.A.dylib 0x7ff80004dba3 objc_exception_throw + 48 2 CoreFoundation 0x7ff800304c83 -[__NSArray0 objectEnumerator] + 0 3 DSYMDemo 0x1003f4f16 -[ViewController visitElement] + 54 (ViewController.m:26) 4 DSYMDemo 0x1003f4e64 __29-[ViewController viewDidLoad]_block_invoke + 36 (ViewController.m:20) 5 libdispatch.dylib 0x7ff80013ca3a _dispatch_client_callout + 8 6 libdispatch.dylib 0x7ff80013fe87 _dispatch_continuation_pop + 715 7 libdispatch.dylib 0x7ff80015534a _dispatch_source_invoke + 2046 8 libdispatch.dylib 0x7ff80014c1e9 _dispatch_main_queue_drain + 1015 9 libdispatch.dylib 0x7ff80014bde4 _dispatch_main_queue_callback_4CF + 31 10 CoreFoundation 0x7ff800387b1f __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9 11 CoreFoundation 0x7ff800382436 __CFRunLoopRun + 2482 12 CoreFoundation 0x7ff8003816a7 CFRunLoopRunSpecific + 560 13 GraphicsServices 0x7ff809cb128a GSEventRunModal + 139 14 UIKitCore 0x107850ad3 -[UIApplication _run] + 994 15 UIKitCore 0x1078559ef UIApplicationMain + 123 16 DSYMDemo 0x1003f51be main + 110 (main.m:17) 17 dyld_sim 0x1006312bf start_sim + 10 18 dyld 0x10deea52e start + 462 // ... Binary Images: 0x7ff836115000 - 0x7ff83614cfff libsystem_kernel.dylib (*) /usr/lib/system/libsystem_kernel.dylib 0x7ff83616e000 - 0x7ff836179ff7 libsystem_pthread.dylib (*) /usr/lib/system/libsystem_pthread.dylib 0x7ff8000b5000 - 0x7ff800139ff7 libsystem_c.dylib (*) <8a60f5c1-ea1f-352b-b778-967be44e3677> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/system/libsystem_c.dylib 0x7ff800248000 - 0x7ff80025dffb libc++abi.dylib (*) /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/libc++abi.dylib 0x7ff80002c000 - 0x7ff80005ffe9 libobjc.A.dylib (*) <2a7a213a-fdb2-311c-81d7-efdfd9ddf25a> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/libobjc.A.dylib 0x7ff80013a000 - 0x7ff800185ff3 libdispatch.dylib (*) <59be51c1-e9f3-3a60-8108-cd70ae082897> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/system/libdispatch.dylib 0x7ff800303000 - 0x7ff80068bffc com.apple.CoreFoundation (6.9) <2be0f79f-8b25-3614-9e7e-dbac565f72dd> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation 0x7ff809cae000 - 0x7ff809cb5ff2 com.apple.GraphicsServices (1.0) <16365e42-1d5c-363d-84d1-3bb290a43253> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/GraphicsServices.framework/GraphicsServices 0x106a0f000 - 0x1084dafff com.apple.UIKitCore (1.0) /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/PrivateFrameworks/UIKitCore.framework/UIKitCore 0x1003f3000 - 0x1003f6fff com.unix.kernel.DSYMDemo (1.0) /Users/USER/Library/Developer/CoreSimulator/Devices/5E0D0385-812D-4FE6-83F6-FFE74E964106/data/Containers/Bundle/Application/474123CE-45D0-45A4-A4B1-EC7588A849AB/DSYMDemo.app/DSYMDemo 0x10062f000 - 0x10068efff dyld_sim (*) <6fb74554-3370-3677-93d4-7f7a01ea6a80> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/usr/lib/dyld_sim 0x10dee5000 - 0x10df50fff dyld (*) <499010ac-3054-326e-a050-fefffb5ce89a> /usr/lib/dyld 0x7ff8006fe000 - 0x7ff80102eff4 com.apple.Foundation (6.9) <86cd050d-44fc-3045-a1f3-8ad5047b329e> /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneOS.platform/Library/Developer/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents/Resources/RuntimeRoot/System/Library/Frameworks/Foundation.framework/Foundation ``` 分析: - iOS 存在 ASLR 技术,所以当发生 crash 的时候,获取到的符号是已经经过 dyld 启动加载,赋予过偏移量之后的地址 - `.dSYM` 文件保存的地址是偏移后的地址。因为虚拟地址的 dyld 启动为了安全才做的随机偏移,且 `.dSYM` 文件是编译阶段就生成的,所以不可能是虚拟地址,一定是偏移地址。 - 符号表存储了当前文件的符号信息,静态链接器(ld) 和动态链接器(dyld) 在链接的过程中都会读取符号表,另外调试器也会用符号表来把符号映射到源文件。 - 打包上线的时候会把调试符号等裁剪掉,但是线上统计到的堆栈我们仍然要能够知道对应的源代码,这时候就需要把符号写到另外一个单独的文件里,这个文件就是 DSYM。 - 符号化,什么是符号化?依靠符号表根据地址找对应的符号名、代码文件名的这个过程就叫符号化 - - 但是符号表中记录的信息是未经 ASLR 的,也就是根据偏移地址来记录的。所以 crash 发生拿到的地址,需要计算出原始地址(原始地址也是相对 Mach-O 的地址)才可以根据符号表拿到原始符号、文件信息 - 怎么计算原始地址?假设业务代码(也就是上面的 DSYMDemo)发生 crash,crash 地址为 `0x1003f4f16`,crash 报告最下面也列出了所以来的镜像(Mach-O),也提供了 base 地址。Mach-O 的开始地址为: `0x1003f3000 - 0x1003f6fff com.unix.kernel.DSYMDemo` 。则 ASLR 前的地址为:`0x1003f4f16 - 0x1003f3000`。计算后再去符号表查找对应的符号、文件信息 - 然后利用 `dwarfdump --lookup 地址 --arch 架构 DSYMDemo.app.dSYM` 来找到相关信息,里面有符号名、文件名、代码行数 #### 计算原始地址 ```objective-c #import #import uintptr_t get_slide_address(void) { // 获取 ASLR uintptr_t vmAddress_slide = 0; // 遍历所有加载过的 image,包括 ipa中的可执行文件 + 依赖的动态库 for (uint32_t i = 0; i < _dyld_image_count(); i++) { const char *imageName = (char *)_dyld_get_image_name(i); const struct mach_header *header = _dyld_get_image_header(i); if (header->filetype == MH_EXECUTE) { // 获取 image 当前的偏移地址。偏移是基于该符号所在 image 的 vmAddress_slide = _dyld_get_image_vmaddr_slide(i); } NSString *str = [NSString stringWithUTF8String:imageName]; if ([str containsString:@"DSYMDemo"]) { NSLog(@"image name %s at address 0x%llx and ASLR slide 0x%lx.\n", imageName, (mach_vm_address_t)header, vmAddress_slide); } } return vmAddress_slide; } - (void)getMethodVMAddress { // 获取 sel 的 ASLR 后的地址,因为启动后经过 dyld 做了偏移 IMP imp = class_getMethodImplementation(self.class, @selector(visitElement)); unsigned long imppos = (unsigned long)imp; unsigned long slide = get_slide_address(); // 符号的真实地址,在 ASLR 技术作用下,基于 Mach-O 的一个偏移地址。所以真实地址 = 经过 runtime 拿到的 imp 地址 - 当前 image 的起始地址 unsigned long addr = imppos - slide; NSLog(@"%lu", addr); } ``` 拿到真实地址,然后利用 `dwarfdump --lookup 0x0000000100001ce0 DSYMDemo.app.dSYM` 找到对应的信息,可以看到 - 符号名 - 符号所在代码文件 - 符号所在代码文件的多少行 ## ASLR ### 虚拟内存、物理内存、内存分页、ASLR 早期:应用程序的可执行文件是存储在磁盘上的,启动应用,则需要将可执行文件加载进内存,早期计算机是没有虚拟内存的,一旦加载就会全部加载到内存中,并且进程都是按照顺序排列的,这样存在两个问题: - 当前进程只需要在合适的地址基础上,加减一些地址,就能访问到下一个进程所使用的内存,安全性很低 - 当前软件越来越大,开启多个软件时会直接全部加载到内存中,导致内存不够用。而且使用软件的时候大多数情况只使用部分功能。如果软件一打开就全部加载到内存中,会造成内存的严重浪费。 基于上述2个问题,诞生了虚拟内存技术。App 进程通过内存管理单元(Memory Management Unit, MMU)来管理的。MMU 使用一张特殊的表来跟踪虚拟内存地址和物理内存地址之间的映射关系。这张表通常被称为页表(Page Table) 内存分页: - 页表结构:页表包含了一系列的表项,每个表项对应虚拟内存中的一个页(通常是 4KB)。每个表项包含了该虚拟页对应的物理页的信息,包括物理页的起始地址和一些状态信息。 - 虚拟地址到物理地址的转换:当程序访问一个虚拟地址时,MMU 查看页表来找到对应的表项,然后根据表项中的信息将虚拟地址转换为物理地址。 - 页表缓存(TLB - Translation Lookaside Buffer):为了提高地址转换的速度,MMU 通常会有一个 TLB,它缓存了最近访问的页表项。这样,对于频繁访问的地址,转换过程可以更快地完成 - 页错误处理:如果一个虚拟地址在页表中没有对应的条目,就会发生页错误。操作系统的页错误处理程序会被调用,它负责加载缺失的页到物理内存中,并更新页表。 - 内存分配:当应用程序请求内存时,操作系统会分配虚拟地址空间,并在页表中创建相应的条目。如果需要,操作系统还会分配物理内存页,并将其与虚拟页关联起来。 当物理内存满的时候,会发生覆盖。用户使用的活跃的数据,覆盖内存中最不活跃的数据那一页。对应现实的表现就是:iPhone 上永远可以较好的打开一个 App,比如打开了 App1、App2、App3...,等打开使用了一阵子后,内存紧张,我们尝试切换打开最早的 App1,会发现 App1 重新启动了,之前用的功能 A 的页面1,已经不见了,经历一个新的启动流程。 但虚拟内存方案带来一个问题。比如黑客不断探索发现,某个重要的功能位于第3页,是不是完全可以通过固定的地址去访问?? 因为早期物理内存方案下,App 启动后位于什么地址是不确定的。有了虚拟内存后,App 内符号的地址都是从0到4G,都是相对地址。 为了解决该问题,Apple 又推出了 ASLR 技术。核心就是为了解决虚拟内存地址固定不变的问题。就不能通过固定的地址访问内存或者数据了。 ### 未使用 ASLR 的问题 - 函数代码存放在 `__TEXT` 段 - 全局变量存放在 `__DATA` 段 - 可执行文件的内存地址为 `0x0` - 可执行文件 Header 的内存地址,就是 `LC_SEGMENT(__TEXT)` 中的 VM Address - arm64 :`0x100000000` - 非 arm64:`0x4000` 也可以使用 `size -l -m -x DDD` 指令来查看 Mach-O 的内存分布 利用 MachOView 查看如下: - _PAGEZERO - VM Address:0x0 - VM Size:0x100000000 - _TEXT - VM Address:0x100000000 - VM Size:0x4000 - _DATA_CONST: - VM Address:0x10004000 - VM Size:0x4000 - _DATA - VM Address:0x10008000 - VM Size:0x4000 - _LINKEDIT - VM Address:0x1000C000 - VM Size:0x8000 File Offset:在 Mach-O 文件中的位置 File Size:在 Mach-O 文件中的占据的大小 从下面的图可以看出,`_PAGEZERO` 在真实的 Mach-O 文件中不存在,不占据大小。只在虚拟内存中存在。 我们会发现根据 Mach-O 文件中的信息,File Size、File Offset、VM Address、VM Size 可以判断出 `__TEXT` 段内函数信息,这样子不够安全 ### ASLR 诞生 Address Space Layout Randomization,地址空间布局随机化。是一种针对缓冲区溢出的安全保护技术,通过堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码的位置,达到阻止溢出攻击目的的一种技术。在 iOS 4.3 引入。 - LC_SEGMENT (__TEXT) 的 VM Address `0x10005000` - ASLR 随机偏移 0x5000,也就是可执行文件的内存地址 在有 ASLR 的时候:__TEXT 代码段地址 = __`PAGEZEROR 地址` 在 Mach-O 文件中的地址是原始地址 ```shell 代码运行起来函数真实地址 = ASLR-Offset + __PAGEZERO(arm64:0x100000000,其他:0x4000)+ 函数基于 Mach-O 的地址 ```