Files
knowledge-kit/Chapter1 - iOS/1.91.md
2024-11-13 14:47:32 +08:00

151 KiB
Raw Blame History

DYLD 及 Mach-O

什么是 DYLDdynamic loader动态加载器。在 MacOS/iOS 中,是使用 /usr/lib/dyld 程序来加载动态库的。

从源文件到可执行文件做了什么?

问:源文件 -> 可执行文件,是不是只需要经过编译就够了?

不是的。从源文件到可执行文件需要经过编译、链接2个步骤

  • 编译:将源代码转为了二进制机器指令。保存这些二进制机器指令的文件,叫做目标文件 .o 文件。每个源文件对应一个目标文件。
  • 链接:得到目标文件怎么变成可执行程序呢?链接器将这些目标文件打包成最终可执行程序。除了打包目标文件外,链接器还会打包一个非常重要的东西,就是标准库。可执行程序 = 程序员写的代码 + 使用到的标准库(动态库/静态库)。

那看上去链接器做的事情很简单,不就是个打包工具?链接器最重要的工作就是决定符号(变量名、函数名)的定义

#include <Foundation/Foundation.h>

int main() {
	NSLog(@"Hello world");
	return 0;
}

例如上面的代码 main.m。编译器在编译 main.m 时遇到 NSLog根本不知道这个 NSLog 符号定义在哪里,这不是编译器该关心的事情。因此,编译器只能看到局部,只聚焦关心一个当前的源文件。到底谁来关心这个 NSLog 符号定义在哪呢?这就是链接器。

链接器打包所有的目标文件,因为链接器可以看到全局,具有上帝视角,因此链接器从依赖的库中去查找 NSLog 这个符号,如果找不到则会报经典的错误 undefined reference to ***

编译器只能将 NSLog 这个函数的跳转地址暂时设置为0随后在链接的时候再去修正它。

一步步验证下:

第一步,将 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 \
  -c main.m -o main.o

第二步,已经得到了 main.o 目标文件,也就是二进制文件,利用指令 objdump -r main.o 查看目标文件中的内容

可以看到 main 函数中callq 就是调用 NSLog 函数。后面的地址写为了 0这里的0会在后面链接的过程中被修正。

第三步,另外为了能让链接器能够定位到这些需要被修正的地址,在代码块中可以看到一个重定位表。指令为 objdump -r main.o

NSLog 位于偏移量为19的位置

第四步,链接目标文件到可执行文件,指令为

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-ostrip 剥离符号

MachO 可读可写

Apple 的机制只要文件可以正确签名Apple 就认,可以修改。

编译阶段做了什么

  • 汇编
  • 将符号归类:
    • 数据,放在数据段
    • 可以获取到地址的符号,变成地址
    • 类似 NSLog 这种只有在链接的时候才可以确定一些东西那这种暂时无法确定地址的符号都统一暂存起来。叫做“重定位符号表”。fishhook 就是基于此来实现系统符号的 hook
  • 链接。链接器通过链接将重定位符号表和符号表合并到一张表中,目标文件(.o 文件)和符号表,合并到一起,

如何找出需要重定位的符号?

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.mmain.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 main.m -o main.o

使用 nm -pa .o文件路径 命令来查看符号

符号的分类

  • Common Symbol在定义时未初始化的全局符号。

    有趣的 feature

    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 库的 NSLogNSLog 对于业务代码来说,就是一个导入的符号,对于 Foundation 库来说,就是一个导出的符号。

什么符号可以是导出的符号?全局符号可以是导出符号。

App 或者一个 MachO 中,所有使用到的动态库的符号,都保存在间接符号表中,这些间接符号表中的数据,来自于动态库中。

动态库,全局符号 -> 导出符号

间接符号表 -> 动态库符号

所以Strip 符号的时候,可能不能 Strip 全局符号。

OC 代码,默认都是导出的全局符号,所以容易占空间,想让体积变小,就可以尽量不想暴露的符号,使用链接器的能力,将不需要暴露的符号不暴露出去。

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 文件路径 导出的断点信息在带有文件的绝对路径,不方便共享。要做的就是文件路径的替换。

链接

源代码编译成目标文件。

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 目标文件链接为可执行文件

 clang命令参数
     -x: 指定编译文件语言类型
     -g: 生成调试信息
     -c: 生成目标文件只运行preprocesscompileassemble不链接
     -o: 输出文件
     -isysroot: 使用的SDK路径
     1. -I<directory> 在指定目录寻找头文件 header search path
     2. -L<dir> 指定库文件路径(.a\.dylib库文件 library search path
     3. -l<library_name> 指定链接的库文件名称(.a\.dylib库文件other link flags -lAFNetworking
     -F<directory> 在指定目录寻找framework framework search path
     -framework <framework_name> 指定链接的framework名称 other link flags -framework AFNetworking
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 代码,导入静态库 <Person.h>

#import <Foundation/Foundation.h>
#import <Person.h>

int main(int argc, char * argv[]) {
    Person *p = [[Person alloc] init];
    [p sayHi];
    NSLog(@"%@", p);
}

第四步,利用 clang 将 main.m 编译为 main.o 文件。注意,因为用到了 NSLog 和导入了静态库的头文件,所以需要加参数指定 NSLog 该符号从哪确定,也需要指定静态库所需的信息。

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 静态库

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+<library_name> 的动态库,找不到,再去找 lib+<library_name> 的静态库,还找不到,就报错

第六步,查看第五步得到的可执行文件,然后执行,看看是否正常?

  • 成功,则说明 静态库就是.o 文件的集合,单个 main.o 文件,修改拓展名就可以变为静态库
  • 不成功,则相反

这一部分相关的脚本和源码,可以查看

Framework

Mac OS/iOS 平台还可以使用 FrameworkFramework 实际上是一种打包方式,将库的:二进制文件、头文件、和有关资源打包到一起,方便管理和分发。

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 类型等),这也就是为什么静态库比动态库体积大的原因之一。

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。

但「同一份代码,一个库制作成动态库体积会比静态库小」不绝对。

对于 iOS9Load Commands Segment vmsize 默认是一个内存页也就是16k

对于 iOS10Load 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

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 的时候,参数不一样

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 文件,指令为

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 文件,指令为

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 编译为动态库,指令为

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 可执行文件

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

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 能力,链接静态库为动态库指令如下

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 完整脚本如下

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 代码

知道具体原因那就好办了,修改指令

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 CommandLC_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

rpathRunpath search pathsdyld 搜索路径。运行时,@rpath 指示 dyld 按顺序搜索路径列表,以找到动态库。

@rpath 保存一个或多个路径的变量。

前提说明:模拟下 App 真实环境。创建一个文件夹 Frameworks,内部继续创建 Person.framework 文件夹,其内部继续创建 Headers

文件夹,将 dylib 文件夹下的文件复制过去。结构如下

// 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

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 链接为动态库

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 修改 idid 指定 @rpath 信息

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

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.oPerson.framework 链接为可执行文件

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 信息

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 修改为灵活的,而不是写死的路径,指令为

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 可执行文件,运行报错。

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

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

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

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 脚本

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 修改后,必须重新签名才可以运行使用破解软件时,总是要求我们重新签名,背后的命令如下:

sudo codesign --force --deep --sign - (应用路径)

动态库如何导出所引用的动态库的符号

  • 主工程 -> Person 动态库
  • Person 动态库 -> Cat 动态库

那么主工程可以直接调用 Cat 动态库的能力吗?

正常写代码肯定可以,但是从链接器角度分析下,如何实现

调用的本质就是符号的发现。也就是 Cat 的符号有没有导出?可执行文件 mian 使用的能力就是动态库导出后,自己导入的。

因为 main 引入了 import <Person.h> ,查看下 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 的能力,测试能否正常运行

    #import <Foundation/Foundation.h>
    #import "Person.h"
    #import <Cat.h>
    
    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 头文件的参数

    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 处理动态库的合并、拆分,都需要管理 dSYMBCSymbolMaps库文件,较为繁琐。

基于此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 模式去打包工程。

先打出模拟器的包,指令如下:

xcodebuild -workspace Person.xcworkspace \
  -scheme 'Person-Example' \
  -configuration Release \
  -destination 'generic/platform=iOS Simulator' \
  -archivePath '../archives/Person.framework-iphonesimulator.xcarchive' \
  SKIP_INSTALL=NO \
  archive

再打出真机的包,指令如下

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

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,后面路径必须是绝对路径。

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 里面不只有不同的动态库,还携带了对应的 .dSYMbcsymbolmap 文件,用于堆栈、符号还原。

第五步:制作好的 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 查看具体的参数和说明:

 -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 文件为

//  引用一个动态库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 文件。配置参数用于配制一些编译、链接信息。

//// -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 参数。

 -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 链接参数

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 是在二进制文件生成后对其进行修改)

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 添加未定义的符号为动态查找。但风险较大,所有未定义的都不会报错误伤较大。

OTHER_LDFLAGS = $(inherited) -framework "AFNetworking" -Xlinker -undefined -Xlinker dynamic_lookup

方法二:只对特定的未定义的符号采用动态查找

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 链接器提供了能力,将静态库的符号不暴露出来。

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 <AFNetworking/AFNetworking.h> 会自动在目标文件的 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

同时依赖静态库、动态库

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.cinclude 几次就要编译几次。

    编译 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

/* 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 模块对应的头文件

#import "A.h"
#import "B.h"

方法二:因为模块 B 已经暴露了模块 A所以导入 B 就可以使用模块 A 中定义的类和函数

#import "B.h"

case2AFNetworking Demo 中的 modulemap

AFNetworking 的 Framework/module.modulemap

framework module AFNetworking {	// 定义一个名为 AFNetworking 的框架模块
    umbrella header "AFNetworking.h"	// 指定了框架的伞头文件,这个文件是包含了所有公共头文件的文件,方便外部嗲欧哦那个
    export *	// 表示框架中的所有公共接口(类、结构体、枚举、协议等)都被导出,
    module * { export * } // 意味着框架内部的所有子模块(即 AFNetworking.h 中头文件代表的子模块都会被匹配到),子模块的名称就是 .h 文件的名称,都将被导出,可以被外部所访问
}

case3AsyncDisplayKit 的 modulemap

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 的内容

#import <AsyncDisplayKit/ASAvailability.h>
#import <AsyncDisplayKit/ASDisplayNode.h>
#import <AsyncDisplayKit/ASDisplayNode+Convenience.h>
#import <AsyncDisplayKit/ASDisplayNodeExtras.h>

#import <AsyncDisplayKit/ASControlNode.h>
#import <AsyncDisplayKit/ASImageNode.h>
#import <AsyncDisplayKit/ASTextNode.h>
#import <AsyncDisplayKit/ASButtonNode.h>
#import <AsyncDisplayKit/ASMapNode.h>
#import <AsyncDisplayKit/ASVideoNode.h>
#import <AsyncDisplayKit/ASVideoPlayerNode.h>
#import <AsyncDisplayKit/ASEditableTextNode.h>

#import <AsyncDisplayKit/ASImageProtocols.h>
#import <AsyncDisplayKit/ASBasicImageDownloader.h>
#import <AsyncDisplayKit/ASPINRemoteImageDownloader.h>
#import <AsyncDisplayKit/ASMultiplexImageNode.h>
#import <AsyncDisplayKit/ASNetworkImageNode.h>
#import <AsyncDisplayKit/ASPhotosFrameworkImageRequest.h>

#import <AsyncDisplayKit/ASTableView.h>
#import <AsyncDisplayKit/ASTableNode.h>
#import <AsyncDisplayKit/ASCollectionView.h>
#import <AsyncDisplayKit/ASCollectionNode.h>
#import <AsyncDisplayKit/ASCollectionViewLayoutInspector.h>
#import <AsyncDisplayKit/ASCollectionViewLayoutFacilitatorProtocol.h>
#import <AsyncDisplayKit/ASCellNode.h>
#import <AsyncDisplayKit/ASSectionContext.h>

#import <AsyncDisplayKit/ASElementMap.h>
#import <AsyncDisplayKit/ASCollectionLayoutContext.h>
#import <AsyncDisplayKit/ASCollectionLayoutState.h>
#import <AsyncDisplayKit/ASCollectionFlowLayoutDelegate.h>

#import <AsyncDisplayKit/ASSectionController.h>
#import <AsyncDisplayKit/ASSupplementaryNodeSource.h>

#if AS_IG_LIST_KIT
#import <AsyncDisplayKit/IGListAdapter+AsyncDisplayKit.h>
#import <AsyncDisplayKit/AsyncDisplayKit+IGListKitMethods.h>
#endif

#import <AsyncDisplayKit/ASScrollNode.h>

#import <AsyncDisplayKit/ASPagerFlowLayout.h>
#import <AsyncDisplayKit/ASPagerNode.h>

#import <AsyncDisplayKit/ASNodeController+Beta.h>
#import <AsyncDisplayKit/ASViewController.h>
#import <AsyncDisplayKit/ASNavigationController.h>
#import <AsyncDisplayKit/ASTabBarController.h>
#import <AsyncDisplayKit/ASRangeControllerUpdateRangeProtocol+Beta.h>

#import <AsyncDisplayKit/ASDataController.h>

#import <AsyncDisplayKit/ASLayout.h>
#import <AsyncDisplayKit/ASDimension.h>
#import <AsyncDisplayKit/ASDimensionInternal.h>
#import <AsyncDisplayKit/ASDimensionDeprecated.h>
#import <AsyncDisplayKit/ASLayoutElement.h>
#import <AsyncDisplayKit/ASLayoutSpec.h>
#import <AsyncDisplayKit/ASBackgroundLayoutSpec.h>
#import <AsyncDisplayKit/ASCenterLayoutSpec.h>
#import <AsyncDisplayKit/ASRelativeLayoutSpec.h>
#import <AsyncDisplayKit/ASInsetLayoutSpec.h>
#import <AsyncDisplayKit/ASOverlayLayoutSpec.h>
#import <AsyncDisplayKit/ASRatioLayoutSpec.h>
#import <AsyncDisplayKit/ASAbsoluteLayoutSpec.h>
#import <AsyncDisplayKit/ASStackLayoutDefines.h>
#import <AsyncDisplayKit/ASStackLayoutSpec.h>

#import <AsyncDisplayKit/_ASAsyncTransaction.h>
#import <AsyncDisplayKit/_ASAsyncTransactionGroup.h>
#import <AsyncDisplayKit/_ASAsyncTransactionContainer.h>
#import <AsyncDisplayKit/_ASDisplayLayer.h>
#import <AsyncDisplayKit/_ASDisplayView.h>
#import <AsyncDisplayKit/ASDisplayNode+Beta.h>
#import <AsyncDisplayKit/ASTextNode+Beta.h>
#import <AsyncDisplayKit/ASTextNodeTypes.h>
#import <AsyncDisplayKit/ASBlockTypes.h>
#import <AsyncDisplayKit/ASContextTransitioning.h>
#import <AsyncDisplayKit/ASControlNode+Subclasses.h>
#import <AsyncDisplayKit/ASDisplayNode+Subclasses.h>
#import <AsyncDisplayKit/ASEqualityHelpers.h>
#import <AsyncDisplayKit/ASHighlightOverlayLayer.h>
#import <AsyncDisplayKit/ASImageContainerProtocolCategories.h>
#import <AsyncDisplayKit/ASLog.h>
#import <AsyncDisplayKit/ASMutableAttributedStringBuilder.h>
#import <AsyncDisplayKit/ASThread.h>
#import <AsyncDisplayKit/ASRunLoopQueue.h>
#import <AsyncDisplayKit/ASTextKitComponents.h>
#import <AsyncDisplayKit/ASTraitCollection.h>
#import <AsyncDisplayKit/ASVisibilityProtocols.h>
#import <AsyncDisplayKit/ASWeakSet.h>
#import <AsyncDisplayKit/ASEventLog.h>

#import <AsyncDisplayKit/CoreGraphics+ASConvenience.h>
#import <AsyncDisplayKit/NSMutableAttributedString+TextKitAdditions.h>
#import <AsyncDisplayKit/UICollectionViewLayout+ASConvenience.h>
#import <AsyncDisplayKit/UIView+ASConvenience.h>
#import <AsyncDisplayKit/UIImage+ASConvenience.h>
#import <AsyncDisplayKit/NSArray+Diffing.h>
#import <AsyncDisplayKit/ASObjectDescriptionHelpers.h>
#import <AsyncDisplayKit/UIResponder+AsyncDisplayKit.h>

#import <AsyncDisplayKit/AsyncDisplayKit+Debug.h>
#import <AsyncDisplayKit/ASDisplayNode+Deprecated.h>

#import <AsyncDisplayKit/ASCollectionNode+Beta.h>

使用:子模块可以通过大模块访问到,比如 @AsyncDisplayKit.ASEventLog;

更多关于 Module 的信息,可以查看 Clang::Modules

实战Framework 使用 modulemap

第一步:创建动态库 PersonFramework,创建一个 iOS App Demo 工程。

第二步:给动态库添加 PersonFramework.modulemap 文件

第三步:给主工程、动态库设置在同一个 Xcode 项目中编译调试。

结论:

  • 工程中 PersonFramework.modulemap 命名的 modulemap在编译后被 Xcode 重命名为 module.modulemap,系统只认这个

  • .modulemap 文件有多种写法就拿上面的举例等价于3种写法

    • 写法1

      framework module PersonFramework {
          // umbrella 不加 Header则说明后面要跟文件夹路径。
          umbrella "Headers"
          export *
          module * {
              export *
          }
      }
      

      nubrella 后不加 Header说明跟得是文件夹文件夹中里面的内容和 Build PhasesHeader public 部分的头文件描述的一致。会将 public 的头文件,最后放到 Headers 文件夹中。

    • 写法2

      framework module PersonFramework {
          umbrella header "PersonFramework.h"
          export *
          module * {
              export *
          }   
      }
      

      umbrella header + 伞头文件 意味着伞头文件里所有的 .h 将会被放到 Headers 文件夹中。且给外部访问

    • 写法3

      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 导出文件?

问题1Swift Framework 中 Swift 里如何访问 OC

利用 modulemap 解决。

但是,上面的做法有副作用。虽然 Framework 内部 Swift 可以访问 OC 了,但是使用 Framework 的地方,也可以访问到 OCStudent 类。如何解决该问题?

如何实现只在 Swift Framework 内 Swift 代码可以访问 OC 类,在使用 framework 的地方,访问不到 oc 类?

module 提供 private modle 的能力。

说明:

  • private module 文件必须命名为 PersonSwiftFramework.private.modulemapprivate.modulemap 格式

  • private.modulemap 模块名必须为 PersonSwiftFramework_Private

    /* 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也包含 SILSwift Intermediate LanguageSwift 中间语言)

实战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 文件夹里面的信息。

.
├── 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 配置头文件信息,不然编译会报错(引入了头文件)。

    // 头文件信息
    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 <UIKit/UIKit.h>

思考:平时 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 中查看

// 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

#import <DynamicLibA/DynamicLibA.h>
@implementation Person
- (void)eat {
  	NSLog(@"Person eat");
}
@end

DynamicLibA.h 内容如下

#import <DynamicLibA/Student.h>
#import <DynamicLibA/Worker.h>
#import <DynamicLibA/Teacher.h>

在找到上面的内容后,编译器将其复制粘贴到 Person.m

#import <DynamicLibA/Student.h>
#import <DynamicLibA/Worker.h>
#import <DynamicLibA/Teacher.h>
@implementation Person
- (void)eat {
  	NSLog(@"Person eat");
}
@end

编译器发现存在3个 import则继续查找其内容

// Student.h
@interface Student: NSObject
- (void)study;
@end

编译器会把其内容复制到 Person.m 中。

@interface Student: NSObject
- (void)study;
@end

#import <DynamicLibA/Worker.h>
#import <DynamicLibA/Teacher.h>
@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 的实现,编写一个读取 hmap 文件的代码。

具体代码可以在这个 Repo 中查看并运行 BlogDemos: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

TipsXcode 设置 Search Path 有三处 Header Search PathSystem Header Search PathUser 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 <SomeFramework/SomeFrameworkExportedHeader.h> 是访问的标准做法。好处有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.cppHeaderMapTestUtils.h 编写 hmap 的生成代码。编写后的具体代码可以查看 BlogDemos:HMapWritor

为了方便平时使用,按照上面的方式,也将二进制放到 /Users/unix_kernel/CustomTools 目录下,同时设置 .zshrc 可访问性。

hmap 助力提升 iOS 项目编译速度

已经编写好生成 .hmap 文件的能力了,那如何使用生成的 .hmap 文件?

  • Xcode Build SettingUse 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 及其演示代码见 HMapStaticLibAppHMapStaticLib

工程化问题

上面是理论上分析并思考了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<key,value>生成所有组件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 指定哪些静态库不要优化掉;

DYLDdyld 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 方法。

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。由于懒加载的符号在首次使用前不会被绑定因此可以减少应用程序的启动时间并按需加载所需的资源

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 开始执行。它的主要任务是加载应用程序的主可执行文件以及其他依赖的动态库

    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_64dyld 会判断该架构能否在当前系统上运行
      • filetype 是 EXECUTE,是不是一个可执行文件
      • 有 ncmds 16个 Load commands占 sizeofcmds 2848大小的空间
    • 根据 Mach Header解析 load commands根据解析的结果将程序各个部分加载程序到指定的地址空间同时设置保护标志

      Load command 1
            cmd LC_SEGMENT_64		// 指定加载命令的类型是 LC_SEGMENT_64这代表一个 64 位的内存段命令。
        cmdsize 792							// 表示这个加载命令的总大小是 792 字节。
        segname __TEXT					// 段名称,这里是 __TEXT 段,通常包含程序的指令和只读数据。
         vmaddr 0x0000000100000000	// 指定了该段在内存中的虚拟地址
         vmsize 0x0000000000004000	// 定义了该段在内存中的大小,这里是 16 KiB4000 十六进制转换为十进制是 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

    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

    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

    Todofishhook 原理

  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::_maindyldbootstrap::start__dyld_start()dyld 配置完成,把控制权交给可执⾏⽂件的⼊⼝函数main(),继续后面的流程

dyld 应用

窥探系统库底层实现的时候可能需要从动态库共享缓存中提取出某个 Framework比如 UIKit。这时候要么用第三方工具要么用 dyld 的能力。

查看 dsc_extractor.cpp 代码可以看到是具备抽取能力的。修改源码,将 if 宏定义去掉。如下

#include <stdio.h>
#include <stddef.h>
#include <dlfcn.h>


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 <path-to-cache-file> <path-to-device-dir>\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 文件

#import <Foundation/Foundation.h>

__attribute__((constructor))
static void customConstructor(int argc, const char **argv) {
    NSLog(@"HelloI am an injected dynamic library!");
}

第二步:创建 iOS App 测试工程,接入 Inject 动态库。将 Inject.framework 里的动态库拖到 App 工程根目录。

第三步Edit scheme -> Run -> Environment Variables。Name 为 DYLD_INSERT_LIBRARIESvalue 为 InjectFunction 动态库路径,${SRCROOT}/Inject

第四步:运行输出如下

具体 Demo

函数替换

工作原理:

在 iOS 中,__DATA, __interpose 是一个特殊的 Mach-O 段,用于实现函数插桩 (Function Interposing)。它允许你在不修改原始库代码的情况下,拦截并替换库中的某个函数

__attribute__((used)) static struct { ... } ... __attribute__ ((section("__DATA, __interpose"))) = { ... }; 定义一个结构体,它包含两个指向函数的指针:replacementreplacee。结构体使用 __attribute__((used)) 属性标记,以避免编译器将其优化掉。同时,它被放置在 __DATA, __interpose 段,这个段是专门为函数插桩而设计的。

参考 dyld::dyld-interposing.h

第一步:创建 InjectFunction 动态库,新建 InjectFunction.m 文件

#import <Foundation/Foundation.h>
#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_LIBRARIESvalue 为 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 格式的文件类型有:

#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文件再经过链接变为可执行文件。

Tipsfile 命令可以查看文件类型。

find . -name "*.c" 比如在当前路径查找 .c 文件

Universal Binary

通用二进制文件,可以同时适用于多种架构的二进制文件,包含多种不同架构的独立的二进制文件。

  • 因为要存储多种架构的代码,所以通用二进制文件通常比单一平台的二进制程序更大

  • 由于两种架构有共同的一些资源所以并不会达到单一版本的2倍多。

  • 执行的过程中,只调用一部分代码,运行起来不需要额外的内存。

因为通用二进制文件比原来的大所以被成为“胖二进制文件”Fat Binary使用 Hopper 打开会显示 “FAT archive”

信息查看:

  • 查看某可执行文件(Test)支持的架构指令集 lipo -info Test

  • 将某个指令集拆出来比如 arm64lipo 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: 和系统自带的 atool 查看 Mach-O 信息

比如我用 otool 查看我编写的一个 SwiftUIDemo 所依赖的共享缓存库

用 MachOView 查看 DDD Mach-O 文件

可以看到在 Mach-O 文件上,__PAGEZEROVM 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 的代码

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 053f

如何找到函数的真实地址

以 test1 为例。

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

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 堆栈中没有符号化的地址,找到符号的真实地址?

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 (*) <c37bfe8a-c5ae-3fe0-b722-33483d9017b9> /usr/lib/system/libsystem_kernel.dylib
    0x7ff83616e000 -     0x7ff836179ff7 libsystem_pthread.dylib (*) <e097f78f-fcfb-30f3-9164-dbc9709ba134> /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 (*) <ae8cbd53-0926-3251-b648-6f32d9330a50> /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) <adb282b1-2fb2-38e0-8492-47f9443eb1ef> /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) <dfa5c389-73ed-3edb-b1cc-0f3b71ce0579> /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发生 crashcrash 地址为 0x1003f4f16crash 报告最下面也列出了所以来的镜像Mach-O也提供了 base 地址。Mach-O 的开始地址为: 0x1003f3000 - 0x1003f6fff com.unix.kernel.DSYMDemo 。则 ASLR 前的地址为:0x1003f4f16 - 0x1003f3000。计算后再去符号表查找对应的符号、文件信息
  • 然后利用 dwarfdump --lookup 地址 --arch 架构 DSYMDemo.app.dSYM 来找到相关信息,里面有符号名、文件名、代码行数

计算原始地址

#import <mach-o/dyld.h>
#import <objc/runtime.h>

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

    • 非 arm640x4000

也可以使用 size -l -m -x DDD 指令来查看 Mach-O 的内存分布

利用 MachOView 查看如下:

  • _PAGEZERO
    • VM Address0x0
    • VM Size0x100000000
  • _TEXT
    • VM Address0x100000000
    • VM Size0x4000
  • _DATA_CONST:
    • VM Address0x10004000
    • VM Size0x4000
  • _DATA
    • VM Address0x10008000
    • VM Size0x4000
  • _LINKEDIT
    • VM Address0x1000C000
    • VM Size0x8000

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 文件中的地址是原始地址

代码运行起来函数真实地址 = ASLR-Offset + __PAGEZEROarm640x100000000其他0x4000+ 函数基于 Mach-O 的地址