今日头条优化实践: iOS 包大小二进制优化,一行代码减少 60 MB 下载大小

摘要

苹果对 iOS App 大小有严格限制:下载大小超限会阻碍用户在蜂窝网络下载 App ,直接影响新用户转化;可执行文件超限将导致 App 审核被拒,直接影响上架。今日头条探索实践 __TEXT 段迁移技术,成功减小下载大小 32%,并且解决了可执行文件大小受限问题。

一、背景知识

1. 下载大小限制

App 大小有下载大小和安装大小的概念。

下载大小是指 App 压缩包(也就是 .ipa 文件)所占的空间,用户在下载 App 时,下载的是压缩包,这样做可以节省流量;当压缩包下载完成后,就会自动解压,解压过程也就是通常所说的安装过程;安装大小就是指压缩包解压后所占用的空间。

安装大小在 App Store 上就可以看见 ,通常它会影响用户的下载意愿:

而下载大小只有研发人员在 App Store Connect 后台才可以看,用户看不见,它影响的是下载消耗的流量和时长:

下载大小超过限制,将无法使用蜂窝网络下载 App( iOS 13 之前),会收到文件容量太大的提示,需通过 Wi-Fi 网络下载。如下,为苹果历年来对 App 下载大小限制的变化情况:

  • 2008 年 7 月,搭载了 App Store 的 iPhone 3G 正式发售,下载限制仅为 10 MB

  • 2010 年 2 月,苹果将 iPhone 3G 的下载限制从 10 MB 提升到 20 MB

  • 2012 年 3 月,iOS 5.1 正式版后,下载限制从 20 MB 提升到 50 MB

  • 2013 年 9 月,iOS 7 正式版后,下载限制从 50 MB 提升至 100 MB

  • 2017 年 9 月,iOS 11 正式版后,下载限制从 100 MB 提升至 150 MB

  • 2019 年 5 月,下载限制从 150 MB 提升至 200 MB

  • 2019 年 9 月,iOS 13 正式版后,若下载大小超过 200 MB,用户可选择是否使用蜂窝网络下载

如今,App 下载大小超出 200 MB 时 ,会出现两种情况:

  • iOS 13 以下的用户,无法通过蜂窝数据下载 App

  • iOS 13 及以上的用户,需要手动设置才可以使用蜂窝网络下载 App

2. 可执行文件大小限制

根据最大构建版本文件大小[1]描述,苹果对可执行文件大小亦有明确限制,超过该限制会导致 App 审核被拒:

ERROR: ERROR ITMS-90122: "Invalid ExecutaBe Size. The size of your app's executaBe file 'News.app/News' is 68534272 bytes for architecture 'arm64', which exceeds the maximum allowed size of 60 MB."

具体限制如下:

  • iOS 7 之前,二进制文件中所有的 __TEXT 段总和不得超过 80 MB

  • iOS 7.X 至 iOS 8.X ,二进制文件中,每个特定架构中的 __TEXT 段不得超过 60 MB

  • iOS 9.0 之后,二进制文件中所有的 __TEXT 段总和不得超过 500 MB

二、面临问题

随着网络普及、流量费用降低,苹果已经放宽了限制。但下载大小若超出 200 MB,可以肯定对新增仍会有一定影响。这对上亿级用户的 App 来说是巨大的损失,并且本着追求极致、在竞品中拔得头筹的理念,我们认为下载大小 200 MB 是包大小的一根红线。

今日头条 App 的下载大小已经接近 180 MB,而经过了多年的极致优化(包括但不限于代码/图片/其它资源的优化、编译/链接参数的优化、推进无用业务下线、准入卡口等),已经很难再有较大幅度的减少。为此平台和各方都投入了极大的人力、甚至牺牲了业务的迭代空间来优化/抑制下载大小。

2020 年下半年,我们另辟蹊径探索实践了 __TEXT 段迁移的方法:将可执行文件的 __TEXT 段中的部分节移动到其它的段,避开苹果的加密机制,提高了可执行文件的压缩效率,使 App 的下载大小减少了 60 MB。

该方案彻底解决了下载大小限制的问题,同时还解决了仍在支持 iOS 8.X 的 App 面临的可执行文件大小限制问题。

三、技术原理

1. Mach-O 文件格式简介

iOS 可执行文件是 Mach-O 格式,主要由 HeaderLoad CommandsData 三部分。

可以简单认为:

  • Header 描述了文件的大概信息。

  • Load Commands 由多条 Load Command 组成,它们描述了 Data 在二进制文件和虚拟内存中的布局信息,有了这个布局信息就能够知道 Data 在二进制文件中和虚拟内存中是怎样排布的,它相当于修房子时的图纸一样。

  • Data 存储了实际的内容,主要是程序的指令和数据,它们的排布完全依照 Load Commands 的描述。

Mach-O 文件中的 Data 部分主要是以 Segment(中文翻译为段)和 Section (中文翻译为节)的方式来组织内容的,好比学校中有年级和班级、公司中有部门和小组一样,把有共同特点的内容组织到一块,可以方便管理,提高效率。

使用 $ xcrun size -lm <binary-path> 指令可以查看 Mach-O 文件 Data 部分的结构和各 Segment/Section 的大小信息(该 Mach-O 文件由 Xcode 的 iOS App 模板工程构建而来)。在不需要更详细的信息时,这条命令很方便。

上图就展示了 Data 中的内容排布的基本信息。

由该图可知,在该文件中:

  • Data 部分中有 5 个 Segment,依次是:

    • __PAGEZERO

    • __TEXT

    • __DATA_CONST

    • __DATA

    • __LINKEDIT

  • __PAGEZERO__LINKEDIT外,每个段中有多个 Section

注意:Data__DATA 是不同的两个概念。Data 是 Mach-O 文件中的一部分,包含多个段。__DATA 只是 Data 中的一个段。

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

通过 $ man ld ,可以发现链接器有一个参数: -rename_p orgSegment orgSection newSegment newSection。使用该参数可以将orgSegment/orgSection的名称修改为newSegment/newSection

可以在 Other Linker Flags 中传递该参数。如:

-Wl,-rename_p,__TEXT,__text,__BD_TEXT,__text
-Wl,-segprot,__BD_TEXT,rx,rx

其中 -Wl 的作用是告诉 Xcode 它后面的参数是添加给 Ld 链接器的,这些参数将在链接阶段生效。

第一行参数会新创建一个 __BD_TEXT 段,并把 __TEXT,__text 移动到 __BD_TEXT,__text

第二行参数是给 __BD_TEXT 赋予可读和可执行权限。

构建完成后再来看一下移动 __TEXT,__text 后的 Mach-O 文件:

可以看到 __TEXT,__text 已经被移动到了 __BD_TEXT 中去了,它的地址也由起始的 0x100005e5c 变为了 0x100010000 。此时程序仍可以正常的运行,这是因为操作系统只关心段的读/写/执行权限,并不关心段或节的名称。即便是使用了 -rename_p 移动 Segment/Section,各符号的地址也会由链接器修正好,因此段移动后程序也可以正常运行。

在最低支持 iOS 8 的时代,很多大型 App 都遇到过可执行文件中 __TEXT 段超 60 MB 的问题。facebook[2] 当时采用了 -rename_p 的技术来避免该问题。他们使用的链接参数为:

-Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring
-Wl,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab
-Wl,-rename_p,__TEXT,__const,__RODATA,__const
-Wl,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname
-Wl,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname
-Wl,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype

参数的作用是将 __TEXT 中的 __cstring__gcc_except_tab__const__objc_methname__objc_classname__objc_methtype 等 6 个节移动到 __RODATA去。由于这 6 个节是只读的,所以他们将新段取名为 __RODATA,意为只读段。这样做之后,__TEXT 的大小就会被减小,而苹果只会扫描 __TEXT 段,所以当 __TEXT 段减小到 60 MB 以下时,就避免了 __TEXT 段超过 60 MB 的问题,该方案当时在国内大型 App 上也很常见。

今日头条 App 在 2018 年 5 月遇到此问题后也采取了该方案,当时是为了避免 __TEXT 段超 60 MB 的问题。现在测试发现,以上参数也对下载大小有 12 MB 的优化。

为什么移动 __TEXT 段会减少下载大小?下一小节会给出详细的解释。

注意,使用 -rename_p 需要关闭 Bitcode

3. 下载大小减少的原理

摘自苹果官方文档[3]:

When your app is approved for the App Store, it is encrypted with DRM and recompressed. The added encryption and DRM affects the ability to compress your binary, and as a result you may see a larger App Store file size for your binary than the binary you uploaded on App Store Connect. The exact final size for your app cannot be determined in advance to the accuracy of a single byte.

对项目工程进行 Archive 后会生成 .xcarchive 文件,该文件中包含了 App、dsYMS 以及其它信息。如图所示为 .xcarchive 文件中包含的内容:

.xcarchive 文件上传到 App Store Connect 后,苹果会对 App 中的可执行文件进行 DRM 加密,然后将 App 压缩成 ipa 文件,才发布到 App Store。加密对可执行文件的大小本身影响很小(对今日头条 App 的影响为 2 MB),但是它会严重影响可执行文件的压缩效率,导致压缩后的 ipa 大小增加,也就是下载大小增大。

实际上,这种加密几乎没有用,只要有越狱手机,使用市面上的脱壳工具就可以很容易地进行解密。

Mach-O 文件代码的解密发生在 Mach-O 文件被加载的时候,由 Mach Loader 进行。Mach Loader 会读取 Mach-O 中的 LC_ENCRYPTION_INFO 这条 Load Command 来判断可执行文件是否加密。

所以,也可以通过 otool -l <binary-path> 的命令来查看 Mach-O 是否被加密过。

Load  command 13
          cmd LC_ENCRYPTION_INFO_64
      cmdsize 24
     cryptoff 16384
    cryptsize 101695488
      cryptid 1
          pad 0

其中 cryptoff 表示加密字段位于文件中偏移 16384 个字节;cryptsize 表示加密内容长度 101695488 个字节;cryptid 表示加密方法为 1,如果为 0 表示不加密。

查看 LC_SEGMENT_64__TEXT 段的范围

Load command 1
      cmd LC_SEGMENT_64
  cmdsize 1432
  segname __TEXT
   vmaddr 0x0000000100000000  4294967296
   vmsize 0x0000000006100000  101711872
  fileoff 0
 filesize 101711872

依据上述结果可以算出加密的内容实际上都位于 __TEXT 中。

可以认为苹果只会对 Mach-O 文件中的 __TEXT 段加密,而不会对其它段加密。只要能把 __TEXT 段中的节移到其它段,就能减少苹果的加密范围,从而使压缩效率提升,减小下载大小。这也解答上个小节提出的问题。

一般来讲,在 App 中可执行文件占 80% 的大小,而加密部分占可执行文件中的 70%,加密会影响 60% 的压缩率,因此移走该加密部分,会提升 34% 的下载大小。根据我们在多个 App 的实践,本方案可以减少 32~34% 的下载大小。

需要注意的是:

苹果在 iOS 13 已经对下载大小做了优化,所以本方案无法再对 iOS 13 的设备的下载大小进一步优化。

即,若用户的设备 < iOS 13,那么本方案可以减少该设备上 App 32~34%的下载大小;

若用户的设备 >= iOS 13,本方案不会对该设备的 App 的下载大小有进一步优化,也不会有负面影响。

因此,如果你看到 App Store Connect 后台展示的下载大小从 iPhone 11 开始大幅减小,不要惊讶,这是因为 iPhone 11 开始默认搭载的是 iOS 13+ 的系统。

目前推测苹果在 iOS 13 也是在针对压缩做了优化,可能是移除了加密或者是先压缩后加密。

苹果在 iOS 13 的更新日志[4]中描述到它们对包大小做了优化,如图:

四、实践

照着上面的思路来看,只要将 __TEXT 段中所有节都移走,就能够最大限度的减少下载大小。

这么简单就可以了吗?实际上并非如此。在小型 App 上,这么做没有任何问题。但在较大型 App 上,这并不是一件轻松的事情。

今日头条 App 在实践过程中解决了 Crash 和一个极为难缠的链接失败的问题。

1. Crash

Crash 的原因是执行代码时找不到指定的节。

在原理中说到:操作系统只关心段的读/写/执行权限,并不关心段或节的名称。即便是使用了-rename_p 移动 Segment/Section,各符号的地址也会由链接器修正好,因此段移动后程序也可以正常运行。

但是如果代码指明了要读取 __TEXT 中的某个 Section ,那么这个 Section 就不能够被移动,否则代码就无法读取到它,就会导致出错。

首先,dyld[5] 在启动阶段会检查 __unwind_info__eh_frame 这两个 Section。如果移动这两个 Section,在启动后程序就会 Crash。

第二,Swift 相关的 Section 不能移动,否则会引起 Crash。

在使用 Swift 之后,二进制中会有一些 Swift 相关的 Section:

它们都不能够被移动,一共有下面这些 Section:

__TEXT,__swift5_typeref
__TEXT,__swift5_reflstr
__TEXT,__swift5_fieldmd
__TEXT,__swift5_types
__TEXT,__swift5_capture
__TEXT,__swift5_assocty
__TEXT,__swift5_proto
__TEXT,__swift5_protos
__TEXT,__swift5_builtin

第三,自己在代码中指明要读取的 Section。目前我们的代码中没有这种 Crash 情况,但是我们的某些脚本中有检测 __TEXT,__text 的代码,在 __TEXT 段迁移后,脚本受到了影响,因此需要重新适配这类脚本。

2. 链接失败

__TEXT 段迁移最难解决的问题是链接失败问题,是由 CPU 对寻址范围的限制以及 ld64 链接器的缺陷导致。

2.1 现象及原因概述

如果 Mach-O 文件足够大,贸然移动 Segment/Section 很容易引发 ld64 链接器异常。

想要让 CPU 工作就必须向它提供指令和数据,程序运行时指令和数据存放在内存中。CPU 通过地址总线来指定内存单元的的地址,地址总线的宽度决定了 CPU 的寻址能力,因此 CPU 对寻址范围有一定的限制。而不同 CPU 的地址总线宽度不同以及它们所采用的指令模式[6]也不一样,所以不同 CPU 的寻址范围也有差异。

B、BL 指令是 ARM 处理器中的跳转指令,可以让处理器跳转到指定的目标地址,从那里继续执行。由于寻址范围是受限的,所以跳转距离不能超出这个限制。ld64 链接器在最终 Output(写可执行文件)时,会对所有的跳转指令进行检查,若发现跳转距离超出限制就会立即抛出 ld: b(l) ARM64 branch out of range异常,从而链接失败,就会出现了图上所示的现象。

在苹果开源的 ld64-530 OutputFile.cpp 文件[7] 中总结出来,常见 CPU 具体限制寻址范围如下:

2.2 ld64 链接器所做的事情

按照上面的描述,随着业务的扩张,代码的膨胀,Mach-O 文件会越来越大,那是不是 Mach-O 文件过大时程序就无法链接成功了?

当然不是!实际上 ld64 链接器知道会出现跳转距离超出限制的情况,所以它在链接过程中会做 Branch Island[8] 算法,对超限制的跳转指令加以保护。

// PowerPC can do PC relative branches as far as +/-16MB. (+/-16MB 可能是因为注释比较老)
// If a branch target is >16MB then we insert one or more
// "branch islands" between the branch and its target that
// allows island hopping to the target.
// Branch Island Algorithm
//
// If the __TEXT Segment < 16MB, then no branch islands needed
// Otherwise, every 14MB into the __TEXT Segment a region is
// added which can contain branch islands. Every out-of-range
// B instruction is checked. If it crosses a region, an island
// is added to that region with the same target and the B is
// adjusted to target the island instead.
//
// In theory, if too many islands are added to one region, it
// could grow the __TEXT enough that other previously in-range
// B branches could be pushed out of range. We reduce the
// probability this could happen by placing the ranges every
// 14MB which means the region would have to be 2MB (512,000 islands)
// before any branches could be pushed out of range.

从原理部分我们知道了 Mach-O 的 Data 部分有很多 Segment/Section。实际上 ld64 链接器还给每个 Section 归了类,归类的代码可以在苹果开源的 ld64-530 中的 ld.hpp 文件的第 547 行找到:

每个 Section 都属于其中一种类型。Branch Island 算法会对类型是 typeCode 的 Section 中的跳转指令做检查,如果跳转的距离超出限制,则会在它们之间插入 "branch islands",跳转指令会先跳到一个 branch island ,再从这个 branch island 跳到目标地址,以此来保证其跳转距离不超过限制。此部分的代码在 branch_island.cpp 文件中可以找到。

__TEXT,__text 的类型是 typeCode,因此,__TEXT,__text 中超出范围跳转指令都会被保护,在最后 Output 检查时,就不会出现 branch out of range 的异常。所以,正常构建的 App,即使很大也不会出现链接失败的问题,这都是归功于 Branch Island 算法。

在 Mach-O 文件中,只有 __TEXT,__text的类型是 typeCode(在使用-rename_p 移动 Segment/Section 之后,Section 的类型不会发生改变)。源地址在 __text 中的 跳转指令跳转的情况只有两种:__text -> __text__text -> __stubs

所以 Branch Island 保护的 跳转指令的所在 Section ,与目标地址所在的 Section, 只有两种情况:

但实际上 Output 时 ld64 链接器会检查文件中所有的跳转指令,不仅限于源地址在__text 中的跳转指令。这意味会检查多种情况:

小结:Branch Island 算法仅会保护 __text 中超出限制的跳转指令。

Output 时,ld64 链接器会检查文件中所有的跳转指令是否超出限制。

2.3 Branch Island 算法的缺陷

既然 Branch Island 算法会保护类型是 typeCode 的 Section 中超限制的跳转指令,并且-rename_p 不会改变 Section 的类型。那为何会-rename_p 后会导致 branch out of range 的异常?

主要是两个原因:

1. Branch Island 算法的检查逻辑没有适配到 Section 被移动的情况。

在分析 Mach-O 文件时只介绍了 Segment/Section,实际链接器认为在 Section 中还存在 atom(链接的基本单元),在 atom 中还存在 fixup(用于描述不同 atom 之间的引用关系。)

如图所示为 ld64-530 的 branch_island.cpp 文件中 Branch Island 算法中的一部分代码。该片段是要判断跳转指令跳转的距离是否超出限制,如果超过限制就会对该跳转指令做保护,否则就不做。

srcAddr 为跳转指令所在的源地址,dstAddr 为目标地址,displacement 为目标地址与源地址的距离。

然而该代码在计算 srcAddrdstAddr 时,用的都是 offset,是相对距离:

  • atom->pOffset()target->pOffset() 都是 atom 相对于各自 Section 起始地址的距离。

  • fit->offsetInAtomaddend 都是 fixup 相对于各自 atom 的距离。

因此,算出来的 srcAddrdstAddr 都是 fixup 相对于各自所在 Section 起始地址的距离。而 displacement 又是根据 dstAddrsrcAddr 相减计算出来的,它的本意是要计算 dstAddrsrcAddr 之间的距离。在没有 -rename_p 的情况下,这种计算方式没有问题;在使用-rename_p 的情况下,会导致计算出来的距离 displacement 不准确,会使在预期对跳转指令做保护的场景实际没做保护。

2. Branch Island 算法不会保护自定义 Section。

Branch Island 算法只会对 typeCode 的 Section 做保护,而自定义 Section 的类型是 typeUnclassified,如果自定义 Section 中的代码使用了跳转指令,并且该跳转指令的跳转距离超出范围,那么无论是否-rename_p 都会出现链接失败的问题。

下面结合 3 个场景,来详细分析 Branch Island 算法的缺陷。

2.3.1 场景一

__TEXT,__text 移不干净导致链接失败。

__text 节在 __TEXT 段中所占比例巨大,要想达到优化效果,必须把它移走,否则几乎没有任何优化效果。头条最开始时,使用-Wl,-rename_p,__TEXT,__text,__BD_TEXT,__text 来尝试迁移 __TEXT,__text,但无论如何也移不干净,总有一小部分还留在 __TEXT,__text 中。

导致的问题就是,顶部的 __TEXT,__text 与底部的 __BD_TEXT,__text 中的跳转指令出现了跳转距离超出限制情况,ld64 链接器在 Output 的时候发现了这个错误,抛出异常,链接失败。

前面我们已经知道了 Branch Island 算法会对__text 中的跳转指令做保护,会在跳转距离超出限制时候插入 branch island。那为什么还会出现这种错误?

画图分析,假设在 Mach-O 文件中, __TEXT,__text 的总大小为 110 ,其中有 A、B 两个符号,跳转指令会从 A 跳转到 B,它们距离 Section __TEXT,__text 的 offset 分别是 30 和 90,它们的实际距离为 60。Branch Island 算法会对跳转指令进行保护,计算出 A、B 的间距 displacement 为 60,不会插入 branch island,在 Output 时,ld64 链接器检查出来它们的距离为 60,小于 128,不会抛出异常,链接成功。

在移走了其中 90 大小的 __TEXT,__text 后,__TEXT,__text 的大小变为了 20,B 被移到了 __BD_TEXT,__text, A、B 相对于各自 Section 的 offset 大概也会发生变化(这个不重要),假设分别变成了 5 和 80。

此刻,A 和 B 的实际距离是 80 + 40 + 15 = 135。但是,Branch Island 算法在对跳转指令做保护时,还是依照它们相对各自 Section 的距离来计算,计算出来它们的距离是 80 - 5 = 75,没有插入 branch island。而实际 135 的大小在 arm64 和 armv7 的实际跳转时是会出错的。

在最后 Output 时,ld64 按照 A 和 B 在文件中的绝对地址来计算距离,算出来它们的距离是 135,超出了 128,检查出了这种由移动 Section 以及 Branch Island 算法缺陷导致的错误,抛出了 branch out of range 的异常,链接失败。

因此,若要移动 __TEXT,__text,就必须保证把 __TEXT,__text 全都移走,否则就可能出现链接失败的问题。(如果你的 App 可执行文件比较小,跳转距离始终不会超过 128M 的话,则不会出现这种问题)

在 ld64-530 的 ld.cpp 文件中发现,__TEXT,_text 移不干净,是由 __TEXT, __textcoal_nt__TEXT,__StaticInit 这两个 Section 导致的。在源码中有如下片段:

这段代码会把 __TEXT, __textcoal_nt__TEXT,__StaticInit 都改名(merge)成 __TEXT,__text,还留在 __TEXT,__text 中的部分就是它们。

在网上查询到 __textcoal_nt 是 gcc 产生的 Section,至少在 16 年的时候就已经废弃,但目前还是有不少库中携带的有这个 Section;__StaticInit 并没有查到更多信息。

我在苹果的 ld 更新日志[9]找到这两个 Section 的踪迹,苹果在 07、08 年的时候就已经会将这两个 Section merge 到 __text 中去。

2008-07-15 Nick Kledzik <kledzik@apple.com>
        <rdar://proBem/6061904> automatically order initializers to start of __TEXT
        * src/MachOReaderRelocataBe.hpp: merge __StaticInit into __text

2007-04-30 Nick Kledzik <kledzik@apple.com>
        <rdar://proBem/5065659> unaBe to link VTK because __textcoal_nt too large
        * src/MachOReaderRelocataBe.hpp: when doing a final link map __textcoal_nt to __text

但苹果的 merge 操作发生在我们-rename_p 之后,因此我们使用-rename_p,__TEXT,__text,__BD_TEXT,__text 没有将它俩移走。


要让 __TEXT,_text 移干净,只需要把它俩也-rename_p。使用如下配置就可以了:

-Wl,-rename_p,__TEXT,__text,__BD_TEXT,__text,
-Wl,-rename_p,__TEXT,__textcoal_nt,__BD_TEXT,__text,
-Wl,-rename_p,__TEXT,__StaticInit,__BD_TEXT,__text

注:字符串 __BD_TEXT 中的 BD 是 ByteDance 的缩写,__BD_TEXT 只是一个名称,可以随意更改。

如果你的 App 中使用-rename_p,__TEXT,__text,__BD_TEXT,__text 本身就能移干净的话,那说明它不包含 __TEXT,__textcoal_nt__TEXT,__StaticInit,可以不添加该配置。

2.3.2 场景二

不移动 __stubs 导致链接失败。

__TEXT 段迁移减少包大小的核心就是移走 __TEXT,__text。但是由于存在__TEXT,__text -> __TEXT,__stubs 的这种跳转指令,所以如果只移动 __TEXT,__text 而不移动 __TEXT,__stubs ,就会出现和问题一中描述的类似的情况:Branch Island 算法检查的是__text 中的符号相对于 __BD_TEXT 的距离,__stubs 是相对于 __TEXT 的距离,该方式计算出来的 displacement 与它们的实际距离不符,在该插入 branch island 的地方没有插入,Output 时检查到错误,抛出异常。

__TEXT,__stubs 还有点不一样的地方:

根据源码的逻辑,已知图中框选分部中的 totalTextSize__TEXT,__text__TEXT,__stubs 的总大小。

代码逻辑描述的是:如果 Section 的类型是 typeStub(arm64 中的__stubs,armv7 中的__picsymbolstub4),Branch Island 算法会令跳转指令的目标地址 dstAddr 为 totalTextSize,然后以此来计算间距 displacement

这需要画图来分析:

如图,在一个正常的 Mach-O 文件中,__TEXT,__text 的大小是 110,__TEXT,__stubs 的大小是 20。A 符号存在于 __TEXT,__text 中,B 符号存在于 __TEXT,__stubs 中。A 距离 __TEXT,__text 的 offset 为 90,B 距离 __TEXT,__stubs 的 offset 为 10,A、B 的实际距离是 30。

在检查时,Branch Island 算法发现 B 位于 __TEXT,__stubs,于是直接令 dstAddr = 130(110 + 20 = 130),然后计算出它们的距离 displacement = 130 - 90 = 40,不插入 branch island。在最终 Output 时检查出它们的实际距离为 30,小于 128 ,不会抛出异常,链接成功。

在移动 __TEXT,__text 之后,A 被移动到了 __BD_TEXT,A、B 的实际距离变成了 10 + 40 + 90 = 140,但 Branch Island 算法计算方式 displacement = 130 - 90 = 40,没有插入 branch island,这会导致实际的跳转出错。ld64 链接器在最后的 Output 阶段检查出了这种错误,抛出异常,链接失败。

ld64 链接器知道 B 符号肯定位于__stubs内,所以 Branch Island 算法的这种 dstAddr = totalSize; 的做法只会令 dstAddr 比实际的大。这样可以保证__stubs 中距离超出的跳转指令都会被插入 branch island。但由于 dstAddr 偏大了一些,所以实际上也多保护了一部分 实际上并没有超出限制的跳转指令。

Branch Island 算法采用这种相对距离的计算方式,是因为在这个阶段它拿不到 A 和 B 符号的绝对地址(绝对地址是在 Output 前才确定的),所以它采用了取巧的办法,使用 A 和 B 相对于各自 Section 的 offset 来计算它们的距离。它默认了二进制文件中只有一个类型是typeCode 的 Section, 并且这个 p 在 __TEXT,__stubs 的前面。这种算法在正常的 Mach-O 文件中是完全可行的,但我们如果移动了 Segment/Section,就不符合它的设定了,就会导致问题。

因此需要添加如下参数,将 __TEXT,__stubs 也移走:

-Wl,-rename_p,__TEXT,__stubs,__BD_TEXT,__stubs,
-Wl,-rename_p,__TEXT,__picsymbolstub4,__BD_TEXT,__picsymbolstub4

在 arm64 中,该 Section 的名称叫做 __stubs,在 armv7 中,该 Section 的名称叫做 __picsymbolstub4。为了适配不同的架构,可以将这个 Section 同时-rename-rename 不存在的 Section 不会有问题,所以这种写法是可以的。

这种做法对类型是 typeStub 的 Section(arm64 中的__stubs,armv7 中的__picsymbolstub4) 有另一种限制,就是在移动后, __text__stubs__picsymbolstub4 之间不能有别的 Section,否则可能会出现错误,如图:

在正常 Mach-O 文件中,A 符号 相对于 __text 的 offset 为 0,B 符号相对于 __stubs 的 offset 为 17.5。在移动 __text__stubs 后,如果我们还移动了其它的 Section,那么这个 Section 有可能会出现在 __BD_TEXT,__text__BD_TEXT,__stubs 之间,这将导致错误:

Branch Island 算法的检查方式判断出 A 和 B 的距离为 (110 + 17.9) - 0 = 127.9,小于 128,因此没有插入 branch island 。但移动后它们的实际距离是 110 + 0.5 + 17.5 = 128 ,是会导致跳转出错的,所以 ld64 链接器会抛出异常,链接失败。

不过这种链接失败的情况比较苛刻,如果 A 的 offset 为 0, 那么它目标地址必须要落在 __stubs 中 [17.5, 17.90] 范围,才会出现链接失败,其余情况都不会出现,因为小于 17.50 的话,移动后 A 和 B 的实际距离也不会超出 128。并且 A 的 offset 必须要在 [0, 0.4] 范围内才会出现这种情况,A 如果大于 0.4 的话,那 __text 移动后,A 跳转到 B 的任意位置也不会超过 128M。

基于这一点,我们在移动 __cstring__gcc_except_tab__const__objc_methname__objc_classname__objc_methtype 这几个只读 Section 的时候,不能把它们移到 __BD_TEXT 段中去,否则它们会出现在 __BD_TEXT,__text__BD_TEXT,__stubs 之间导致错误。

解决的办法就是使用原有的链接参数,将它们移动到另一个 Segment :__RODATA,这样就可以避免这个问题:

-Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring  -Wl,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab  -Wl,-rename_p,__TEXT,__const,__RODATA,__const  -Wl,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname  -Wl,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname  -Wl,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype

2.3.3 场景三

自定义 Section 的问题。

在 「2.2 ld64 链接器所做的事情」 中说到,跳转指令共有四种跳转情况。rangeCheck 检查这四种情况;但是 Branch Island 算法只会检查两种跳转情况,它只会保护 __text中的跳转指令。

跳转指令的所有跳转情况:

第 4 种情况只在存在自定义 Section,并且自定义 Section 中有跳转指令时才会出现。

Branch Island 会保护的情况:

有两种情况, Branch Island 算法不会保护:

__TEXT,__stub_helper      ->        __TEXT,__stub_helper
__TEXT,__custom_p   ->        __TEXT,__text

原因是 Branch Island 只会对类型是 typeCode 的 Section 中的跳转指令做检查 ,而只有 __TEXT,__text 的类型是 typeCode

那么,这两种情况的跳转指令,在实际跳转中是否会出错?

  1. __TEXT,__stub_helper -> __TEXT,__stub_helper不会,因为__stub_helper的大小只有 28kb(在头条中),远小于 128M,所以它内部的指令再怎么跳都不会超出限制。

  1. __TEXT,__custom_p -> __TEXT,__text,是有可能失败的。

关于自定义 Section ,我们遇到过两种情况。

A. 在一款 App 中有 __dof_RACSignal__dof_RACCompou 两个 Section。

这两个 Section 是由 RAC 引入的,但是它们的 Number of Relocations 是 0,不涉及跳转指令,它们不用处理,不会有链接失败的问题。

B. 头条中有一个 __u_selector Section:

它是依赖的某静态库引入的,__u_selector中包含一个重定位符号 ___Symbol_A,跳转指令会从它跳转到 __text 中的 ___Symbol_B

调试发现正常可执行文件中,它们之间的距离是 10M 左右。不会出现链接失败的。

可以推测___Symbol_B其实位于__text的底部, 而 __text 很大,如果把__text 移动到到 __u__selector 的下边去,那么这两个指令之间的距离就会增大,超过 128 MB 就会链接失败。如图:

所以在移动 __text 后,__custom_p (含跳转指令的自定义 Section)也必须跟着移动,让它保持在 __text 的下面,保持它们原有的相对位置。

照此分析,__TEXT 中的自定义 Section 不被 Branch Island 保护,如果二进制文件足够大,而这个 Section 又有跳转指令,当跳转距离超过 128 MB 时,也会链接失败,与是否移动 __text 无关。

要移走自定义 Section,需要再添加如下配置:

-Wl,-rename_p,__TEXT,__custom_p,__CUSTOM_TEXT,__custom_p

这里必须要使用新的段 __CUSTOM_TEXT,而不能把自定义 Section 放到 __BD_TEXT 中,否则自定义 Section 会出现在__text__stubs 之间,导致出现 "场景二" 后半部分中描述的问题。

3. 设置段的权限

由于将可执行代码移动到了新的段 __BD_TEXT__CUSTOM_TEXT 中。所以需要给这两个段添加可读和可执行权限,否则程序将无法运行:

-Wl,-segprot,__CUSTOM_TEXT,rx,rx
-Wl,-segprot,__BD_TEXT,rx,rx

五、一行代码

在今日头条 App 中是使用 xcconfig[10] 来管理构建参数的,如果你也使用该方式,那么使用下面这一行代码就能完成配置:

APP_THIN_LINK_FLAGS = -Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab,-rename_p,__TEXT,__const,__RODATA,__const,-rename_p,__TEXT,__text,__BD_TEXT,__text,-rename_p,__TEXT,__textcoal_nt,__BD_TEXT,__text,-rename_p,__TEXT,__StaticInit,__BD_TEXT,__text,-rename_p,__TEXT,__stubs,__BD_TEXT,__stubs,-rename_p,__TEXT,__picsymbolstub4,__BD_TEXT,__picsymbolstub4,-segprot,__BD_TEXT,rx,rx

如果你是没有使用这种方式,在 Other Linker Flags 中逐行添加以下配置即可:

-Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring
-Wl,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname
-Wl,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname
-Wl,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype
-Wl,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab
-Wl,-rename_p,__TEXT,__const,__RODATA,__const
-Wl,-rename_p,__TEXT,__text,__BD_TEXT,__text
-Wl,-rename_p,__TEXT,__textcoal_nt,__BD_TEXT,__text
-Wl,-rename_p,__TEXT,__StaticInit,__BD_TEXT,__text
-Wl,-rename_p,__TEXT,__stubs,__BD_TEXT,__stubs
-Wl,-rename_p,__TEXT,__picsymbolstub4,__BD_TEXT,__picsymbolstub4,
-Wl,-segprot,__BD_TEXT,rx,rx

如果你的二进制文件中存在自定义 Section 的话,比如使用了类似__attribute__((p("__TEXT,__custom_p")))的方式创建了自定义 Section,则可能需要做如下的配置以移走自定义 Section,具体见 「2.3.3 场景三」 的详细分析。

APP_THIN_LINK_FLAGS = -Wl,-rename_p,__TEXT,__cstring,__RODATA,__cstring,-rename_p,__TEXT,__objc_methname,__RODATA,__objc_methname,-rename_p,__TEXT,__objc_classname,__RODATA,__objc_classname,-rename_p,__TEXT,__objc_methtype,__RODATA,__objc_methtype,-rename_p,__TEXT,__gcc_except_tab,__RODATA,__gcc_except_tab,-rename_p,__TEXT,__const,__RODATA,__const,-rename_p,__TEXT,__text,__BD_TEXT,__text,-rename_p,__TEXT,__textcoal_nt,__BD_TEXT,__text,-rename_p,__TEXT,__StaticInit,__BD_TEXT,__text,-rename_p,__TEXT,__stubs,__BD_TEXT,__stubs,-segprot,__BD_TEXT,rx,rx,-rename_p,__TEXT,__picsymbolstub4,__BD_TEXT,__picsymbolstub4,-rename_p,__TEXT, __custom_p,__CUSTOM_TEXT,__text,-segprot, __CUSTOM_TEXT,rx,rx

六、答疑

1. 为什么不把 __TEXT 段中的所有 Section 都移走,这样不是更好吗?

并不是移走的段越多,压缩就越有效,而是得看移走的大小。例如下面虽然有 15 个 Section,但是它们的大小加起来 578 KB,移走它们对压缩后的下载大小几乎零提升。

Section __stubs: 28488 (addr 0x105f21644 offset 99751492)
Section __stub_helper: 28428 (addr 0x105f2858c offset 99779980)
Section __swift5_typeref: 2216 (addr 0x105f2f498 offset 99808408)
Section __swift5_fieldmd: 1272 (addr 0x105f2fd40 offset 99810624)
Section __swift5_types: 120 (addr 0x105f30238 offset 99811896)
Section __const: 64184 (addr 0x105f302b0 offset 99812016)
Section __ustring: 281012 (addr 0x105f3fd68 offset 99876200)
Section __swift5_reflstr: 796 (addr 0x105f84720 offset 100157216)
Section __swift5_capture: 376 (addr 0x105f84a3c offset 100158012)
Section __swift5_builtin: 120 (addr 0x105f84bb4 offset 100158388)
Section __swift5_assocty: 312 (addr 0x105f84c2c offset 100158508)
Section __swift5_proto: 308 (addr 0x105f84d64 offset 100158820)
Section __swift5_protos: 40 (addr 0x105f84e98 offset 100159128)
Section __u__selector: 36 (addr 0x105f84ec0 offset 100159168)
Section __eh_frame: 184708 (addr 0x1060cf018 offset 101511192)

并且,有的 Section 是不能移走的,会引起 crash,有兴趣的读者可以自行尝试。

2. 出现 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 工具[11]手动解析,将主镜像名称当做参数传入即可。

七、总结

本文从背景知识和面临的实际问题出发,介绍了 __TEXT 段迁移及减少下载大小的原理,描述了我们在实践过程中遇到的问题,并从源码的角度详细分析了问题产生的根本原因以及解决方式,解答了相关疑问和上线后遇到的问题。

目前,该方案已经在字节跳动多个大型 App 中应用,均对下载大小有 30% 以上的优化,且运行稳定。

八、加入我们

我们是字节跳动 General Information Platform - 客户端平台架构 iOS 团队,在性能优化、基础组件、业务架构、研发体系、安全合规、线下质量基础设施、线上问题定位归因平台等方向深耕,负责保障和提升今日头条、西瓜视频和番茄小说的产品质量与开发效率,聚焦于此的同时向外延伸。

如果你对技术充满热情,喜欢追求极致,渴望用自己的代码改变数亿用户的体验,欢迎加入我们。我们期待你与我们共同成长。目前我们在北京、深圳均有招聘需求,简历投递邮箱: tech@bytedance.com ;邮件标题: 姓名 - 工作年限 - GIP - 客户端平台架构 - iOS/Android 

参考文献

[1] 最大版本构建大小
https://help.apple.com/app-store-connect/#/dev611e0a21f

[2] 分析 facebook App
https://blog.timac.org/2016/1018-analysis-of-the-facebook-app-for-ios/

[3] App Store Connect Help
https://help.apple.com/app-store-connect/en.lproj/static.html

[4] iOS 13 更新日志
https://support.apple.com/en-us/HT210393#13

[5] dyld 检查 Section 的源码
https://opensource.apple.com/source/dyld/dyld-750.6/src/dyldExceptions.c.auto.html

[6] ARM 架构
https://en.wikipedia.org/wiki/ARM_architecture#Thumb

[7] ld64-530 OutputFile 源码
https://opensource.apple.com/source/ld64/ld64-530/src/ld/OutputFile.cpp

[8] Branch Island 算法
https://opensource.apple.com/source/ld64/ld64-133.3/src/ld/passes/branch_island.cpp.auto.html

[9] Ld 更新日志
https://opensource.apple.com/source/ld64/ld64-97.2/ChangeLog

[10] xcconfig 介绍
https://nshipster.com/xcconfig/

[11] atos man page
https://www.manpagez.com/man/1/atos/

 

欢迎关注「 字节跳动技术团队 」

已标记关键词 清除标记