背景
目前项目包体下载大小:
- iOS13及以上:110M左右
- iOS13以下:192M左右
下载大小超过200MB的时候,对于低版本的系统,将无法用流量下载,高版本系统也需要用户自己手动设置。
现状
730测试期间,iOS13以下的包体下载大小:
- 优化前:198M
- 优化后:153M
减小了45M,优化效果较为明显。
方案
Apple 在 iOS13 + 去掉了对可执行文件的 __TEXT 段加密,使得iOS13+的系统比低版本系统下载大小减小了30%-40%。
iOS13以下优化方案:可将可执行文件中一部分段从 __TEXT 段中移动到其他段来绕过加密,提高可执行文件的压缩效率,从而使下载大小减小。
1. MACH-O文件格式
使用xcrun size -lm
由该图可知,在该文件中:
Data部分中有5个Segment,分别是:
- __PAGEZERO
- __TEXT
- __DATA_CONST
- __DATA
- __LINKEDIT
除__PAGEZERO 和 __LINKEDIT外,每个段中有多个 Section。
__PAGEZERO 的大小是 4 GB,但并不是它在 Mach-O 文件中的真实大小。这 4 GB 是 Mach-O 加载进内存后, __PAGEZERO 在内存中占中的大小,它不可读,不可写,主要用来捕捉 NULL 指针的引用。如果访问 __PAGEZERO 段,会引起 EXC_BAD_ACCESS 错误。__PAGEZERO 在 Mach-O 中实际上并不占用 Data 部分的空间。
__TEXT、__DATA_CONST、__DATA 用于保存程序的代码指令和数据。
__LINKEDIT 包含启动 App 需要的信息,比如 bind & rebase 的地址,代码签名,符号表等。
2. __TEXT段迁移的原理
程序的构建过程包含 预处理 -> 编译 -> 汇编 -> 链接 等 4 个主要阶段,完成之后就会得到 Mach-O 可执行文件。
可以在 Other Linker Flags 中传递该参数。如:
1 | -Wl,-rename_section,__TEXT,__text,__BD_TEXT,__text |
其中 -Wl 的作用是告诉 Xcode 它后面的参数是添加给 Ld 链接器的,这些参数将在链接阶段生效。
第一行参数会新创建一个 __BD_TEXT 段,并把 __TEXT,__text 移动到 __BD_TEXT,__text。
第二行参数是给 __BD_TEXT 赋予可读和可执行权限。
构建完成后再来看一下移动 __TEXT,__text 后的 Mach-O 文件:
可以看到 __TEXT,__text 已经被移动到了 __BD_TEXT 中去了,它的地址也由起始的 0x100005e5c 变为了 0x100010000 。此时程序仍可以正常的运行,这是因为操作系统只关心段的读/写/执行权限,并不关心段或节的名称。即便是使用了 -rename_section 移动 Segment/Section,各符号的地址也会由链接器修正好,因此段移动后程序也可以正常运行。
3. __TEXT中不可移动section
- __unwind_info
- __eh_frame
- swift相关的
相关影响:
- 在启动阶段会检查 __unwind_info 和 __eh_frame 这两个 Section。如果移动这两个 Section,在启动后程序就会 Crash。
- swift相关的Section不能移动,否则会引起crash
- 自己在代码中指明要读取的 Section。可能目前我们的代码中没有这种 Crash 情况,但是我们的某些脚本中有检测 __TEXT,__text 的代码,在 __TEXT 段迁移后,脚本会受到了影响,因此需要重新适配这类脚本。
4.迁移失败的问题
那是不是我直接无脑将 __TEXT,__text迁移走就完事了呢?如果是体积较小的app,可能没什么问题,但如果 Mach-O 文件足够大,贸然移动 Segment/Section 很容易引发 ld64 链接器异常报错。
CPU在工作的时候,处理器中的跳转指令,可以让处理器跳转到指定的目标地址,从那里继续执行。由于寻址范围是受限的,所以跳转距离不能超出这个限制。而ld64 链接器在最终output输出写可执行文件的时候,会对所有的跳转指令进行检查,若发现跳转距离超出限制就会立即抛出异常,从而链接失败。
常见的CPU具体限制寻址范围如下:
- armv7:16MB
- arm64:128MB
- x86_64:2GB
如果有这种限制,那么岂不是Mach-O文件越来越大,程序就会无法链接成功了?实际上 ld64 链接器知道会出现跳转距离超出限制的情况,所以它在链接过程中会做 Branch Island 算法,对超限制的跳转指令加以保护。
Branch Island 算法会对类型是 typeCode 的 Section 中的跳转指令做检查,如果跳转的距离超出限制,则会在它们之间插入 “branch islands”,跳转指令会先跳到一个 branch island ,再从这个 branch island 跳到目标地址,以此来保证其跳转距离不超过限制。
__TEXT,__text 的类型是 typeCode,因此,__TEXT,__text 中超出范围跳转指令都会被保护,在最后 Output 检查时,就不会出现 branch out of range 的异常。所以,正常构建的 App,即使很大也不会出现链接失败的问题,这都是归功于 Branch Island 算法。
但是Branch Island 算法也会有一些问题
- 算法的检查逻辑没有适配到section被移动的情况,会使在预期对跳转指令做保护的场景实际没做保护。
5.正确迁移
5.1 迁移__TEXT,__text
直接将__TEXT,__text迁移走,是移不干净的,原因是底层源码中会将 __TEXT, __textcoal_nt 和 __TEXT,__StaticInit 都改名(merge)成 __TEXT,__text,还留在 __TEXT,__text 中的部分就是它们。
要让 __TEXT,_text 移干净,只需要把它俩也-rename_section。使用如下配置就可以了:
1 | -Wl,-rename_section,__TEXT,__text,__BD_TEXT,__text, |
5.2 迁移__TEXT,__stubs
不移动 __stubs 导致链接失败。
在 Mach-O 文件中,源地址在 __text中的 跳转指令跳转的情况只有两种:__text -> __text 和 __text -> __stubs,而Branch Island 算法也会对类型是 typeCode的 __text做保护,而如果只移动 __TEXT,__text 而不移动 __TEXT,__stubs ,Branch Island 算法在检查的时候就会出错,算出来的距离会与实际距离不符,在该插入 branch island 的地方没有插入,Output 时检查到错误,抛出异常。
除此之外,在 arm64 中,该 Section 的名称叫做 __stubs,在 armv7 中,该 Section 的名称叫做 __picsymbolstub4。为了适配不同的架构,可以将这个 Section 同时-rename。-rename 不存在的 Section 不会有问题,所以这种写法是可以的。
因此,需要添加如下参数,将 __TEXT,__stubs 也移走:
1 | -Wl,-rename_section,__TEXT,__stubs,__BD_TEXT,__stubs, |
另外要注意的是,移动的时候要按照顺序,__text和__stubs之间是不能有别的section的,否则会报错。
5.3 迁移只读section
因为__text和__stubs之间是不能有别的section的,所以,我们在移动 __cstring、__gcc_except_tab、__const、__objc_methname、__objc_classname、__objc_methtype 这几个只读 Section 的时候,不能把它们移到 __BD_TEXT 段中去,否则它们会出现在 __BD_TEXT,__text 与 __BD_TEXT,__stubs 之间导致错误。
解决的办法就是使用原有的链接参数,将它们移动到另一个 Segment :__RODATA,这样就可以避免这个问题:
1 | -Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring |
5.4 迁移自定义section
__TEXT中跳转指令的所有跳转情况如下:
- text -> text
- text -> stubs
- stub_helper -> stub_helper
- custom_section(自定义的section) -> text
而Branch Island 算法只会检查保护__text中的跳转指令,因为只有 __TEXT,__text 的类型是 typeCode。所以剩下的两个会有问题吗?
__TEXT,__stub_helper -> __TEXT,__stub_helper 不会,因为__stub_helper的大小很小,在比心中,只有18KB,远小于128M,所以它内部的指令再怎么跳都不会超出限制。
__TEXT,__custom_section -> __TEXT,__text,是有可能失败的。
比心中包含__dof_RACSignal 和 __dof_RACCompou 两个 Section,这两个 Section 是由 RAC 引入的,但是它们的 Number of Relocations 是 0,不涉及跳转指令,它们不用处理,不会有链接失败的问题。
其他自定义的section(比心中目前没有其他的,在这里参考别的app出现的情况)
比如这里的__u_selector,Number of Relocations 是 1,涉及了跳转指令。
它是依赖的某静态库引入的,__u_selector中包含一个重定位符号 __Symbol_A,跳转指令会从它跳转到 __text 中的 __Symbol_B。头条开发者们调试发现正常可执行文件中,它们之间的距离是 10M 左右。不会出现链接失败的。可以推测__Symbol_B其实位于__text的底部, 而 __text 很大,如果把__text 移动到到 __u__selector 的下边去,那么这两个指令之间的距离就会增大,超过 128 MB 就会链接失败。
所以在移动 __text 后,__custom_section (含跳转指令的自定义 Section)也必须跟着移动,让它保持在 __text 的下面,保持它们原有的相对位置。
(照此分析,__TEXT 中的自定义 Section 不被 Branch Island 保护,如果二进制文件足够大,而这个 Section 又有跳转指令,当跳转距离超过 128 MB 时,也会链接失败,与是否移动 __text 无关。)
因为比心项目中目前没有这种情况,暂不用考虑。
要移走自定义 Section,需要再添加如下配置:
1
-Wl,-rename_section,__TEXT,__custom_section,__CUSTOM_TEXT,__custom_section
这里必须要使用新的段 __CUSTOM_TEXT,而不能把自定义 Section 放到 __BD_TEXT 中,否则自定义 Section 会出现在__text 与 __stubs 之间,会报错。
6. 设置段的权限
由于将可执行代码移动到了新的段 __BD_TEXT 和 __CUSTOM_TEXT 中。所以需要给这两个段添加可读和可执行权限,否则程序将无法运行:
1 | -Wl,-segprot,__CUSTOM_TEXT,rx,rx |
7. 项目中section的迁移
Y:需要迁移
N:不需要迁移
YR:需要迁移,且为只读
section名字 | 大小 | 操作 | 备注 |
---|---|---|---|
text | 58.2M | Y | 最有必要优化 |
stubs | 0.02M | Y | 移动text后必须移动stubs,详见5.2 |
stub_helper | 0.01M | N | 太小,对优化零作用 |
objc_methname | 1.72M | YR | 只读,可稳定移动 |
const | 1.75M | YR | 只读,可稳定移动 |
gcc_except_tab | 0.94M | YR | 只读,可稳定移动 |
cstring | 3.14M | YR | 只读,可稳定移动 |
objc_classname | 0.24M | YR | 只读,可稳定移动 |
objc_methtype | 0.42M | YR | 只读,可稳定移动 |
ustring | 0.13M | N | 太小,对优化零作用 |
dof_RACSignal | 0.0008M | N | 不涉及跳转指令,不用处理 |
dof_RACCompou | 0.0007M | N | 不涉及跳转指令,不用处理 |
unwind_info | 0.56M | N | 移动启动会crash |
eh_frame | 0.08M | N | 移动启动会crash |
在 Other Linker Flags 中逐行添加以下配置即可
1 | -Wl,-rename_section,__TEXT,__cstring,__RODATA,__cstring |
如果后续二进制文件中存在自定义的section的话,那么需要在最后移走
1 | -Wl,-rename_section,__TEXT,__custom_section,__CUSTOM_TEXT,__text |
8. 迁移后其他可能的影响
- 我们的某些脚本中有检测 __TEXT,__text (或者__TEXT段中别的section)的代码,在 __TEXT 段迁移后,脚本会受到了影响,因此需要重新适配这类脚本。
- 可能会出现Crash.log解析不了的情况
字节那边有碰到过Crash report 中的 Crash.log 中有一部分符号无法解析,展示???的情况。
出现这个问题的原因是,Crash.log 在分析主二进制镜像时,把它在虚拟内存中的地址范围取错了。
如图 0x100010000 - 0x100203fff 的范围只有 2047999(2.0 MB),这明显远小于主二进制文件中__text 原本的大小 100 MB。这个 2.0 MB 的大小基本与 __TEXT 段被迁移后剩余的大小相符,因此猜测 Crash.log 在分析时取的是 __TEXT 段的大小,而我们把大部分 __TEXT 段都移走了。所以当遇到一个符号落在 (2.0M, 100M] 的区间中时,Crash.log 就无法知道这个地址它到底是属于哪个镜像,它就会显示 ??? ,无法解析。
解决办法:这种 Crash.log 使用 atos 工具手动解析,将主镜像名称当做参数传入即可。
比心之前也有碰到过类似的???问题,不过当时的处理是直接过滤掉了,后续可以传入主镜像名称来解决。
- 自定义的section
如果后续增加了新的库,并引入新的section,且这个section涉及到了跳转指令,那么也有可能导致链接失败(编译不会通过),到时候需要手动把这个自定义的section也迁移走。
- 代码中指明对应的section
自己在代码中指明要读取的 section,如下:
__attribute__((section(“__TEXT,section”),如果对应的section被迁移走了,也会存在crash。不过目前比心项目里没有发现类似的代码。
9. 结尾
- __TEXT的section中,并不是都要移走,有的大小很小,移动基本0作用,所以不需要做。
- App在启动时候 Page Load 解密 __TEXT 段也是比较大的性能损耗,迁移这些字段,能同时优化启动时间。