二进制重排

image.png

核心逻辑导图

  1. 原理层:为什么需要?(OS 虚拟内存 & 缺页中断)

  2. 选型层:为什么用 Clang 插桩?(对比 objc_msgSend Hook)

  3. 实现层:数据怎么采?(原子操作 + 静态数组,核心考点

  4. 落地层:拿到数据怎么办?(Order File + Linker)

  5. 验证层:怎么证明有效?(System Trace)


面试话术与逻辑展开

1. 原理层:你懂操作系统吗?

面试官问:“为什么二进制重排能优化启动速度?”

你的回答逻辑

  • 切入点:不能只说“快”,要说“减少了 I/O 等待”。

  • 关键概念

    • 虚拟内存:iOS 是按页(Page)加载的,一页 16KB。

    • 缺页中断(Page Fault):App 启动时,代码不会一次性全读入 RAM。执行到某个函数,发现它所在的页不在内存里,就会触发 Page Fault,阻塞线程去磁盘读数据。

    • 现状:默认编译顺序是按文件来的。启动链上的函数(Func A, Func B, Func C)可能分散在第 1、100、500 页。

    • 痛点:为了执行这 3 个小函数,系统要加载 3 个完整的页(即使页里其他代码启动时根本不用)。这就是严重的 I/O 浪费。

    • 目的:把启动用到的 A、B、C 强行挪到一起(都在第 1 页)。这样只触发 1 次 Page Fault 就能把它们都读进来。

高分金句:“本质上是利用局部性原理,用空间换时间,把原本离散的指令排布变得紧凑,减少磁盘 I/O 次数。”


2. 选型层:为什么不用 Fishhook?

面试官问:“采集启动函数有哪些方案?为什么选 Clang 插桩?”

你的回答逻辑

  • 方案 1(淘汰):Hook objc_msgSend

    • 缺点:只能覆盖 Objective-C 方法。漏掉了 C 函数、C++ 构造函数、Swift 代码、Block 内部逻辑。这会导致重排不彻底。
  • 方案 2(主流)Clang SanitizerCoverage

    • 优势:这是编译期技术。Clang 会在每一个函数(包括 C/Swift/Block)内部的边缘插入一段指令(Hook)。
  • 边界:它能覆盖参与该插桩配置、且未被编译器完全消除或内联掉的代码路径。系统库、未开启插桩的预编译库、某些 Swift/优化产物和汇编代码不会天然 100% 覆盖。


3. 实现层(核心):你遇到过死锁吗?

面试官问:“插桩的具体逻辑是什么?有什么坑?” (这是最容易挂掉的地方,一定要强调‘避坑’)

你的回答逻辑

  • 配置:在 Build Settings 里加 flag trace-pc-guard,编译器就会给每个函数插桩。

  • 回调函数:系统会调用 __sanitizer_cov_trace_pc_guard。我们要在这里记录当前执行的函数地址(PC 指针)。

  • ❌ 错误做法:在回调里用 NSMutableArray 存地址,或者用 malloc 开辟节点。

    • :启动时可能多线程并发,普通可变数组会产生数据竞争。malloc 或锁不等于“极易死锁”,但它们可能递归触发被插桩路径、引入重入、分配器竞争和明显观测扰动,所以高频回调应避免复杂 Runtime 操作。
  • ✅ 正确做法(高分点)

    1. 预分配内存:使用一个足够大的 C 语言静态数组(或 mmap 一块内存)。

    2. 原子操作:使用 C11 stdatomic.h 或 C++ std::atomic 申请数组下标,再检查容量后写入 PC。OSAtomic* 系列已经弃用,不应作为新实现推荐。

    • 理由:原子索引能避免数据竞争,但“lock-free”要用 atomic_is_lock_free 或目标平台文档确认;还要处理数组溢出、重复记录、内存序和回调重入。

4. 落地层:符号怎么处理?

面试官问:“采集到地址后怎么生效?”

你的回答逻辑

  • 地址转符号:可以在采集结束后使用 dladdr,或记录 image UUID/slide 与 PC 后离线结合 dSYM 符号化。不要在每一次高频回调里同步做 dladdr

  • 去重与排序:按调用顺序排序,去重。

  • Order File:生成一个 .order 文本文件。

  • Linker 配置:在 Xcode 的 Order File 选项填入路径。链接器(ld)在链接阶段,会读取这个文件,强行按照文件里的顺序把二进制写入 Mach-O 的 __TEXT 段。

补充细节(加分项)

  • 如果是 Swift,得到的符号是 mangle 过的(乱码状),不需要还原成可读代码,直接给链接器用就行,因为链接器只认 mangle 后的符号。

5. 验证层:怎么证明你做好了?

面试官问:“怎么验证优化效果?”

你的回答逻辑: 不要只说“看启动时间”,那个波动太大。要看本质指标。

  1. Link Map 对比:查看优化前后的 Link Map 文件,确认启动函数是不是真的物理地址挨在一起了。

  2. System Trace (Instruments):使用 Instruments 的 System Trace 工具。

    • 关注指标:File Backed Page In(由文件触发的缺页中断)次数。

    • 效果:下降幅度取决于原始布局、启动路径稳定性和系统缓存。没有项目实测数据时不要承诺 50%-70%。


汇总:面试速记卡(Cheat Sheet)

如果面试时紧张,脑子里只需要记住这 4 个关键词

  1. Page Fault:优化的根本目标,减少磁盘 I/O。

  2. SanitizerCoverage:编译期插桩,覆盖范围取决于语言前端、优化、链接和哪些 target 开启了配置。

  3. 原子操作 + 静态数组:采集数据的手段,避免死锁和性能损耗。

  4. Order File:给链接器的“排队指南”。

模拟对话场景

Q: 讲讲二进制重排? A: 二进制重排用于改善冷启动代码的页面局部性。启动函数如果散落在 Mach-O 的多个 file-backed page,冷启动可能触发更多 page-in。我们可以对自有 target 做编译期插桩,低开销记录真实启动路径,再离线符号化生成 Order File。采集回调要处理并发、重入、容量上限和优化导致的覆盖缺口。上线前用 Link Map 确认布局,再用多次冷启动和 System Trace 验证 page-in 与耗时分位数;没有测量就不承诺百分比。

iOS 启动优化之二进制重排(Binary Reordering)技术白皮书

1. 背景与核心原理

1.1 为什么要做二进制重排?

iOS App 启动慢的一个重要原因是 缺页中断(Page Fault) 过多。
现代操作系统(包括 iOS)使用 虚拟内存(Virtual Memory) 管理进程地址空间。每个进程认为自己有一块连续的内存,但实际上是由操作系统将虚拟地址映射到物理内存(RAM)或磁盘(Flash)上的数据页(Page)。

  • 物理内存(RAM):容量有限,访问速度快。
  • 虚拟内存:每个进程独立,按需映射到物理内存。
  • 分页(Paging):iOS(A9 处理器以后)一页大小通常是 16KB

1.2 缺页中断(Page Fault)

启动时,系统不会一次性把整个 Mach-O 二进制加载到内存,而是 按需加载(Demand Paging)

  1. CPU 执行到某个函数 funcA
  2. 虚拟地址查找发现对应的物理页不在内存。
  3. 触发 Page Fault
  4. 内核暂停线程,从磁盘读取包含 funcA 的 16KB 页到内存。
  5. 恢复执行。

问题:如果启动阶段调用的函数分散在不同的页,就会频繁触发 Page Fault。
假设一次 Page Fault 耗时 1ms,2000 次就是 2 秒延迟。

图 1:iOS 启动时 Page Fault 触发流程

flowchart TD A[App 冷启动] --> B[dyld 开始加载 Mach-O 文件
按需映射到虚拟内存] B --> C[CPU 执行到函数 funcA
查找虚拟地址对应的物理页] C --> D[发现该页未加载到 RAM
触发 Page Fault] D --> E[内核暂停线程
从磁盘读取 16KB 页到 RAM] E --> F[建立虚拟地址与物理页映射
恢复线程执行] F --> G{启动函数分散在多页?} G -->|是| D G -->|否| H[继续执行]

1.3 Mach-O 文件与符号布局

iOS App 的可执行文件是 Mach-O 格式,主要包含:

  • __TEXT 段:代码(__text 节)、常量等。
  • __DATA 段:全局变量、可写数据。
  • 其他段:__LINKEDIT(符号表、调试信息)等。

编译器默认按 源文件顺序 和 代码书写顺序 排列函数符号。
启动需要的函数可能分布在 __text 节的不同位置,导致多次 Page Fault。

图 2:Mach-O __TEXT,__text 段符号布局对比

graph LR subgraph 优化前 P1[Page 1: funcA, funcX, funcY] P50[Page 50: funcB, funcZ] P100[Page 100: funcC] P200[Page 200: funcD] end subgraph 优化后 NP1[Page 1: funcA, funcB, funcC, funcD] NP2[Page 2: 其他函数] NP3[Page 3: 其他函数] end P1 --> P50 --> P100 --> P200 NP1 --> NP2 --> NP3

1.4 二进制重排的目标

通过生成 Order File,指导链接器将启动阶段的所有函数紧凑排列在 __text 节的前面,使它们尽量落在同一个或相邻的少量页面中,从而减少 Page Fault 次数。

效果

  • 原来:函数 A、B、C、D 分布在第 1、50、100、200 页 → 需要加载 4 页。
  • 重排后:A、B、C、D 紧挨在第 1 页 → 只需加载 1 页。

2. 技术实现

2.1 启动函数采集的挑战

  • 需要覆盖 C/OC/Swift/Block 等所有语言的函数。
  • 不能只依赖 objc_msgSend Hook(只能捕获 OC 方法)。
  • 必须在 编译期 插入采集逻辑,保证全覆盖。
flowchart TD A[编译期插桩
SanitizerCoverage] --> B[运行采集
冷启动阶段收集符号] B --> C[生成 Order File
按调用顺序写入符号列表] C --> D[链接器读取 Order File
按顺序排列 __text 段符号] D --> E[验证效果
Link Map + System Trace]

2.2 Clang SanitizerCoverage 插桩

LLVM 提供的 SanitizerCoverage 可以在编译期为每个函数插入回调,记录函数执行情况。

编译参数

在 Xcode Build Settings:

  • Other C Flags

    -fsanitize-coverage=func,trace-pc-guard
    
  • Swift 项目

    -sanitize-coverage=func
    -sanitize=undefined
    

2.3 插桩回调实现

#import <libkern/OSAtomic.h>
#import <dlfcn.h>

static OSQueueHead symbolList = OS_ATOMIC_QUEUE_INIT;

typedef struct {
    void *pc;
    void *next;
} SymbolNode;

void __sanitizer_cov_trace_pc_guard_init(uint32_t *start, uint32_t *stop) {
    static uint64_t N;
    if (start == stop || *start) return;
    for (uint32_t *x = start; x < stop; x++) {
        *x = ++N;
    }
}

void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
    if (!*guard) return;
    void *PC = __builtin_return_address(0);
    SymbolNode *node = malloc(sizeof(SymbolNode));
    *node = (SymbolNode){PC, NULL};
    OSAtomicEnqueue(&symbolList, node, offsetof(SymbolNode, next));
}

2.4 生成 Order File

在启动完成(如首屏渲染后)调用:

- (void)generateOrderFile {
    NSMutableArray *symbols = [NSMutableArray array];
    while (true) {
        SymbolNode *node = OSAtomicDequeue(&symbolList, offsetof(SymbolNode, next));
        if (!node) break;
        Dl_info info;
        dladdr(node->pc, &info);
        NSString *name = @(info.dli_sname);
        // 处理符号名(去下划线、Swift demangle 等)
        [symbols addObject:name];
    }
    NSArray *result = [[symbols reverseObjectEnumerator] allObjects];
    NSString *orderContent = [[NSOrderedSet orderedSetWithArray:result] allObjects].componentsJoinedByString(@"\n");
    [orderContent writeToFile:@"/tmp/app.order" atomically:YES encoding:NSUTF8StringEncoding error:nil];
}

2.5 链接器配置

  • 将 app.order 放到工程目录。
  • Xcode Build Settings → Order File → 填写路径。
  • 重新编译,ld 会按顺序排列符号。

3. 验证与效果评估

3.1 验证方法

  1. Link Map:检查 __TEXT,__text 段符号顺序是否与 Order File 一致。

  2. Instruments - System Trace:对比优化前后 File Backed Page In 次数。

  3. DYLD_PRINT_STATISTICS

    export DYLD_PRINT_STATISTICS=1
    

    查看启动阶段的 Page In 时间。


3.2 效果示例

指标优化前优化后提升
Page Fault 次数1200300-75%
冷启动耗时2.3s1.8s+22%

4. 常见坑点与注意事项

  • 符号缺失:Order File 中不存在的符号会被忽略。
  • LTO 优化:可能导致符号合并或消失,需关闭 LTO 生成 Order File。
  • 多架构:符号名与架构无关,但需确保一致性。
  • Swift 符号:需用 swift-demangle 解析。
  • 仅开发阶段插桩:Release 版本移除 -fsanitize-coverage,避免性能和包体积影响。

5. 与其他启动优化的配合

  • 动态库合并:减少 dyld 加载耗时。
  • 移除 +load 方法:减少 pre-main 阶段执行。
  • 延迟初始化:非关键逻辑延后执行。
  • 资源懒加载:减少启动时磁盘 IO。

6. 面试问答策略

(保留你原有的 Q&A,并补充一些追问场景)


7. 总结

二进制重排的本质是 用空间换时间,通过减少启动阶段的 Page Fault 次数来提升启动速度。
核心步骤:

  1. 编译期插桩采集启动函数。
  2. 生成 Order File。
  3. 链接器按顺序排列符号。
  4. 验证效果并与其他优化配合。