iOS15系统启动时间加速

WWDC21中最有趣的特性被深深地隐藏在 Xcode 13发布说明中:

部署在 macOS 12 或 iOS 15 及更高版本操作系统上的所有程序及 dylibs现在都使用链式修复格式。这种格式使用不同的加载命令和 LINKEDIT 数据,不能在低版本的操作系统上运行或加载。

目前还没有任何文献或会议可以了解更多有关于此更改的信息,但我们可以对其进行逆向工程,以了解 Apple 在新版本上有何不同,它是否优化了App的启动时间。首先,了解控制App启动的程序的一些背景知识。

认识dyld

dyld是苹果的动态链接器,是苹果操作系统一个重要组成部分,是每个App的入口点。它负责让APP的代码做好运行准备,因此对dyld的任何改进都会使得App启动时间缩短。在调用main、运行静态初始化程序或设置 Objective-C运行时间之前,dyld负责执行修正操作,包括变基和绑定操作,这些操作修改App二进制文件中的指针以包含在运行时有效的地址。想要了解它们如何运行,可以使用 dyldinfo 命令行工具。

% xcrun dyldinfo -rebase -bind Snapchat.app/Snapchatrebase information (from compressed dyld info):segment section address type__DATA __got 0x10748C0C8 pointer...bind information:segment section address type addend dylib symbol__DATA __const 0x107595A70 pointer 0 libswiftCore _$sSHMp

这意味着地址 0x10748C0C8 位于 __DATA/__got,需要按一个常量值进行移位。地址 0x107595A70 在 __DATA/__const, 应该指向 Hashable[1] 的协议描述符在libswiftCore.dylib

dyld 使用 LC_DYLD_INFO 载入命令和 dyld_info_command 结构确定二进制文件中变基、绑定和导出符号[2]的位置和大小 。Emerge (声明:我是创始人),解析这些数据,直观了解它们对二进制大小的贡献,建议链接器标志使它们变得更小

一种新的格式

当第一次上传一个为iOS15构建的App时,通过 Emerge,并没有看到dyld修正的效果。因为缺少LC_DYLD_INFO_ONLY加载命令,它已被替换为LC_DYLD_CHAINED_FIXUPS 和 LC_DYLD_EXPORTS_TRIE 。

% otool -l iOS14Example.app/iOS14Example | grep LC_DYLD      cmd LC_DYLD_INFO_ONLY% otool -l iOS15Example.app/iOS15Example | grep LC_DYLD      cmd LC_DYLD_CHAINED_FIXUPS      cmd LC_DYLD_EXPORTS_TRIE

导出数据与之前完全相同,树的每个节点代表符号名称的一部分。 

iOS 15 中唯一的变化是数据现在由 linkedit_data_command 引用,该命令包含第一个节点的偏移量。为了验证这一点,我写了一个简短的 Swift App来解析 iOS 15 二进制文件并打印每个符号:

letbytes = (try!Data(contentsOf: url)asNSData).bytesbytes.processLoadComands { load_command, pointerinifload_command.cmd ==LC_DYLD_EXPORTS_TRIE{letdataCommand = pointer.load(as: linkedit_data_command.self)bytes.advanced(by:Int(dataCommand.dataoff)).readExportTrie()    }      }          extensionUnsafeRawPointer{funcreadExportTrie(){varfrontier = readNode(name:"")guard!frontier.isEmptyelse{return}      repeat{let(prefix, offset) = frontier.removeFirst()letchildren = advanced(by:Int(offset)).readNode(name:prefix)for(suffix, offset)inchildren {frontier.append((prefix+ suffix, offset))        }    }while!frontier.isEmpty    }          // Returns an array of child nodes and their offset    funcreadNode(name: String)-> [(String,UInt)] {guardload(as:UInt8.self) ==0else{// This is a terminal node    print("symbol name \(name)")return[]      }    letnumberOfBranches =UInt(advanced(by:1).load(as:UInt8.self))varmutablePointer =self.advanced(by:2)varresult = [(String,UInt)]()for_in0..

一种新的格式

当第一次上传一个为iOS15构建的App时,通过 Emerge,并没有看到dyld修正的效果。因为缺少LC_DYLD_INFO_ONLY加载命令,它已被替换为LC_DYLD_CHAINED_FIXUPS 和 LC_DYLD_EXPORTS_TRIE 。

% otool -l iOS14Example.app/iOS14Example | grep LC_DYLD      cmd LC_DYLD_INFO_ONLY% otool -l iOS15Example.app/iOS15Example | grep LC_DYLD      cmd LC_DYLD_CHAINED_FIXUPS      cmd LC_DYLD_EXPORTS_TRIE

导出数据与之前完全相同,树的每个节点代表符号名称的一部分。 

iOS 15 中唯一的变化是数据现在由 linkedit_data_command 引用,该命令包含第一个节点的偏移量。为了验证这一点,我写了一个简短的 Swift App来解析 iOS 15 二进制文件并打印每个符号:

letbytes = (try!Data(contentsOf: url)asNSData).bytesbytes.processLoadComands { load_command, pointerinifload_command.cmd ==LC_DYLD_EXPORTS_TRIE{letdataCommand = pointer.load(as: linkedit_data_command.self)bytes.advanced(by:Int(dataCommand.dataoff)).readExportTrie()    }      }          extensionUnsafeRawPointer{funcreadExportTrie(){varfrontier = readNode(name:"")guard!frontier.isEmptyelse{return}      repeat{let(prefix, offset) = frontier.removeFirst()letchildren = advanced(by:Int(offset)).readNode(name:prefix)for(suffix, offset)inchildren {frontier.append((prefix+ suffix, offset))        }    }while!frontier.isEmpty    }          // Returns an array of child nodes and their offset    funcreadNode(name: String)-> [(String,UInt)] {guardload(as:UInt8.self) ==0else{// This is a terminal node    print("symbol name \(name)")return[]      }    letnumberOfBranches =UInt(advanced(by:1).load(as:UInt8.self))varmutablePointer =self.advanced(by:2)varresult = [(String,UInt)]()for_in0..

真正的变化在 LC_DYLD_CHAINED_FIXUPS。 在 iOS 15 之前的版本,变基、绑定和延迟绑定分别存储在单独的表中。现在它们已组合成链,在这个新的加载命令中,包含链起点的指针: 

App二进制文件被分解成多个段,每个段都包含一个可以绑定或变基的修复链(不再有延迟绑定)。二进制文件中的每个 64 位 rebase[3] 定位,对它指向的偏移量以及到下一个修正的偏移量进行编码,如以下结构所示:

structdyld_chained_ptr_64_rebase{uint64_ttarget :36,high8 :8,reserved :7,// 0snext :12,bind :1;// Always 0 for a rebase};

指针对象使用36位,足以容纳 2³ ⁶ = 64GB 的二进制文件,12 位用于提供下一个修正的偏移量(步幅 = 4)。因此,它可以指向 2 ¹² * 4 = 16kb范围内的任何位置——正是 iOS 上的页面大小。

这种非常紧凑的编码意味着遍历链的整个过程可以包含在二进制的现有大小内。 在我的测试中,超过 50% 的 dyld 数据对二进制大小的贡献被保存,因为只保留了少量元数据用来指示每个页面上的第一个修正。最终结果是Swift App的大小减少了 1mb 以上。

这个过程的源代码在 MachOLoaded.cpp 中 ,二进制设计在 /usr/include/macho-o/fixup-chains.h


排序问题

要理解这种改变背后的动机,我们必须注意App启动时开销最大的操作——缺页异常。在App启动期间访问文件系统上的代码时,需要通过缺页异常将其从文件写入到内存。App二进制文件中的每个 16kb区间都映射到内存中的一个页面。一旦页面被修改,它就需要在App运行期间一直保留在 RAM 中(称为脏页面)。iOS 通过压缩最近未使用的页面来优化这一点。

App启动时的修正需要更改App二进制文件中的地址,因此整个页面都被标记为脏页面。让我们看看在app启动期间修正程序使用了多少页面:

% xcrun dyldinfo -rebaseSnapchat.app/Snapchat> rebases% ruby -e 'putsIO.read("rebases").split("\n").drop(2).map{ |a| a.split(" ")[2].to_i(16) /16384}.uniq.count'1554% xcrun dyldinfo -bindSnapchat.app/Snapchat> binds450

对于表的格式,首先解析变基,然后是绑定。这意味着变基需要许多缺页异常,并且最终主要是 IO 绑定 [4]。另一方面,绑定访问了30% 的变基使用的页面,有效地进行了第二次内存传递。

现在在 iOS 15版本中,链式修正将每个内存页面的所有更改组合在一起。dyld 现在可以通过一次遍历内存来更快地处理它们,同时完成变基和绑定。这使得诸如内存压缩器之类的操作系统功能能够利用众所周知的排序,而无需在绑定期间返回并解压缩旧页面。由于这些改变,dyld中的变基函数变成了一个空操作:

 https://opensource.apple.com/source/dyld/dyld-851.27/src/ImageLoaderMachOCompressed.cpp.auto.html

总的来说,这种改变主要影响对 iOS App进行逆向工程和探索动态链接器细节,这很好地提醒了大家,低级的内存管理会影响App性能。虽然这种改变仅在iOS 15版本上的App有效,但请记住,仍然可以做很多事情来优化App启动时间:

减少动态框架的数量

减少应用程序大小,从而减少内存页面的使用(这就是我制作 Emerge 的原因!)

将代码移出 +加载以及静态初始化程序

使用 更少的类

将工作推迟到绘制第一个框架后

参考链接:

[1] The symbol from dyldinfo is mangled, you can get the human readable name with xcrun swift-demangle '_$sSHMp'.

[2] Exports are the second piece of a bind. One binary binds to symbols exported from its dependencies.

[3] The same goes for binds, a pointer is actually a union of rebase and bind (dyld_chained_ptr_64_bind) with a single bit used to differentiate the two. Binds also require the imported symbol name which isn’t discussed here.

[4] https://asciiwwdc.com/2016/sessions/406

原文链接:https://medium.com/geekculture/how-ios-15-makes-your-app-launch-faster-51cf0aa6c520

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,839评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,543评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,116评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,371评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,384评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,111评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,416评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,053评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,558评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,007评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,117评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,756评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,324评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,315评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,539评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,578评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,877评论 2 345

推荐阅读更多精彩内容