零内存访问与 34 个 objc_msgSend 分身:dyld 共享缓存与 Stub Islands 的微架构算术跃迁

零内存访问与 34 个 objc_msgSend 分身:dyld 共享缓存与 Stub Islands 的微架构算术跃迁

在 Darwin 与 iOS 操作系统的底层世界中,系统动态链接器(dyld)与动态库共享缓存(dyld_shared_cache)承载着全系统数千个动态库的高速链接与分发。随着系统规模的急剧膨胀,ARM64e 架构下的共享缓存镜像体积已经跃升至近 6 GiB。在如此辽阔的虚拟地址空间内,ARM64 架构固有的硬件跳转与寻址限制,与极致性能诉求之间爆发了尖锐的微架构冲突。

资深安全研究员 CodeColorist 近期针对新一代系统底层展开逆向分析,揭示了 Apple 在 dyld 共享缓存与桩岛跳板(Stub Islands)架构上实施的一系列激进手术:通过彻底剔除二进制死截面、发明利用纯寄存器移位算术替代内存寻址的超宽跳板、在全缓存范围内离散部署 34 个 objc_msgSend 分身副本,以及将全局只读数据段精准锚定在地址空间几何中点,Apple 以巧夺天工的拓扑设计,彻底消除了动态调度的内存读取与指针认证开销。


1. 溯源:独立 Mach-O 与经典 dyld 桩岛(Stub Islands)演进史

在理解新架构的激进突破前,我们首先需要回溯 Darwin 平台动态链接的基石 —— 桩函数(Stubs)与全局偏移表(GOT)。

1.1 独立二进制中的经典 PAC 桩调用

对于未打包进共享缓存的普通独立 Mach-O 二进制,外部符号的解析在运行时具有动态不确定性。当代码调用外部库函数(如 _os_unfair_lock_unlock)时,编译器会发射一条跳转指令进入本二进制的 __auth_stubs 段:

__text:
    BL              _os_unfair_lock_unlock ; 跳转到本模块的 __auth_stubs

__auth_stubs: _os_unfair_lock_unlock
    ADRL            X17, _os_unfair_lock_unlock_ptr ; 获取 __auth_got 表项地址
    LDR             X16, [X17]                      ; 从 GOT 读取已签名的函数指针
    BRAA            X16, X17                        ; 携带密钥与上下文执行 PAC 校验并跳转

在 __auth_got 段中,保存着经过指针身份认证(PAC,Pointer Authentication Code)签名的目标函数地址:

_os_unfair_lock_unlock_ptr DCQ __imp__os_unfair_lock_unlock

这一标准流程存在两大微架构成本:

  1. D-Cache 访存惩罚:必须通过 LDR X16, [X17] 执行一次数据内存读取。如果该 GOT 页面被换出或发生 L1/L2 数据缓存未命中,流水线将直接停顿数个乃至数十个时钟周期;
  2. PAC 验证管线延迟:ARM64e 的 BRAA 指令需要硬件解密并校验签名合法性,虽提升了内存安全防线,但在极高频调用路径上不可避免地消耗额外的执行单元。

1.2 共享缓存与桩岛(Stub Islands)的诞生

当数千个动态库被预先打包进 dyld_shared_cache 时,所有模块之间的相对虚拟地址(VA)在离线构建期就已被完全确定,运行时符号解析退化为确定性的基址重定位(Rebase)。

然而,ARM64 的分支跳转指令 BL 存在严格的硬件物理限制:

  • BL 指令立即数范围:BL 指令使用 26 位有符号立即数,乘以 4 字节对齐后,最大相对寻址范围仅为 ±128 MB(即 -0x8000000 至 +0x7FFFFFC)。

当共享缓存的体积从早期的几百兆字节急剧扩张到数个吉字节(GiB)时,某个 Framework 的 BL 指令根本无法直接触达距离其数百兆乃至数吉字节外的目标函数。

为此,Apple 在 dyld 优化器中引入了 “桩岛”(Stub Islands / Branch Islands)机制:

  • dyld 在缓存内以每隔数十兆字节的密度均匀划分出一页页专用的桩岛页面;
  • 优化器重写模块内的 BL 指令,使其优先跳入距离当前 PC 不超过 128 MB 的本地桩岛;
  • 桩岛再通过大跨度寻址中继至最终的目标函数。
flowchart TD
    subgraph Client["调用方 Framework (PC 空间)"]
        A["BL _os_unfair_lock_unlock"]
    end

    subgraph StubIsland["局部桩岛 (Stub Island Page, 距调用方 < 128 MB)"]
        B["桩跳板中继"]
    end

    subgraph Target["目标系统库 (libsystem_kernel.dylib, 跨度数 GiB)"]
        C["_os_unfair_lock_unlock 实际实现"]
    end

    A -->|"BL 硬件直跳 (±128 MB 内)"| B
    B -->|"跨越数 GiB 虚拟地址跨度"| C

1.3 传统桩岛遗留的“历史包袱”

尽管桩岛解决了跨库跳转问题,但在过去数代 iOS 系统中,dyld 共享缓存依然残留着明显的历史包袱:

  1. 死截面冗余(Dead Bloat):各个动态库被收录进缓存后,虽然其内部调用已被重定向到桩岛,但每个 Mach-O 内部原有的 __auth_stubs 和 __auth_got 却未被清除,成为了毫无交叉引用的无用物理填充;
  2. Objective-C 消息分发的双重重负:在传统架构下,高频的 Objective-C 桩调用(__objc_stubs)同样需要经历复杂的加载过程:
__objc_stubs: _objc_msgSend$URLByAppendingPathComponent_
    ADRP            X1, #selRef_URLByAppendingPathComponent_@PAGE
    LDR             X1, [X1, #selRef_URLByAppendingPathComponent_@PAGEOFF] ; 访存 1:加载 selector
    ADRL            X17, _objc_msgSend_ptr ; 访存 2:加载 objc_msgSend 入口
    LDR             X16, [X17]
    BRAA            X16, X17               ; PAC 校验并跳转

一次看似简单的消息发送,在真正进入 objc_msgSend 之前,竟然包含了 两次内存加载(LDR)与 一次 PAC 指针解密(BRAA)!在每秒发生数千万次消息分发的 iOS 系统中,这是无法忽视的微架构税。


2. 瘦身外科手术:死截面剔除与元数据全局归一化

在近期的系统构建中,dyld 团队首先进行了一场大快人心的“减肥手术”。

2.1 剔除无引用节区(Section Pruning)

在构建 dyld 共享缓存的最终阶段,优化器不再保留各个源 Mach-O 镜像内部废弃的 __auth_stubs。整个共享缓存中,只有全局统筹规划的桩岛页面(Stub Island Pages)得以幸存。

更为彻底的是针对 Objective-C 符号元数据的清理:

  • 原本散落在各个 Framework 内部的 __objc_methname(方法名)、__objc_methtype(方法类型编码)以及 __objc_classname(类名)节区被全部物理剥离!
  • 所有数据引用统一指向一个全缓存共享的单例只读数据区 —— __OBJC_RO 段。

2.2 方法列表相对偏移架构演进

为了配合元数据的全局收敛,Objective-C 方法列表(__objc2_meth_list)的解析拓扑也完成了关键演进。

在早期实现中,方法结构体中的选择子(Selector)字段记录的是相对于该字段自身地址的相对偏移值。而共享缓存优化器将其全面改造为基于全局基地址的偏移体系。该全局基地址直接锚定在 dyld 缓存头部的 ObjCOptimizationHeader 结构体中:

struct dyld_cache_header {
    // 省略其他字段...
    uint64_t    objcOptsOffset;         // 缓存头部到 ObjC 优化头部的 VM 偏移
    uint64_t    objcOptsSize;           // ObjC 优化头部尺寸
};

struct VIS_HIDDEN ObjCOptimizationHeader {
    uint32_t version;
    uint32_t flags;
    uint64_t headerInfoROCacheOffset;
    uint64_t headerInfoRWCacheOffset;
    uint64_t selectorHashTableCacheOffset;
    uint64_t classHashTableCacheOffset;
    uint64_t protocolHashTableCacheOffset;
    uint64_t relativeMethodSelectorBaseAddressOffset; // 全局选择子基地址偏移

    // Version 2 新增:类型编码字符串同样纳入全局基地址偏移
    uint64_t relativeMethodSelectorBufferSize;
    uint64_t relativeMethodTypesBufferSize;
};

在 Version 2 的优化规范下,系统不仅对 Selector 字符串应用全局偏移,方法类型编码(Method Types)也紧随其后共享同一套基地址寻址方案。跨库重复的字符串在全局范围内被完全合并去重,既大幅降低了常驻内存集(RSS),又极大提升了 CPU 数据缓存的局部命中率。


3. 算术跃迁:从“内存加载+指针认证”到“零访存纯算术跳板”

在清除了冗余元数据后,Apple 将优化的核心对准了桩岛内部的指令序列。

由于 dyld 共享缓存由内核在开机阶段整体校验签名并以只读方式映射进进程空间,其内部代码与拓扑结构在整个运行时是绝对不可变的、受信任的物理实体。在这一前提下,为每个跨库调用强行施加 GOT 内存加载与 PAC 验证,无异于“对信任区域重复安检”。

Apple 顺理成章地重构了桩岛跳板,演进出了两种极其优雅的纯算术跳板模式。

3.1 变体一:±4 GiB 内的零访存直跳

对于距离目标在 ±4 GiB 范围内的函数调用,桩岛彻底抛弃了 LDR 与 BRAA,演变为仅有两行指令的极致精简形式:

_stubs: _open
    ADRL            X16, _open   ; ADRP + ADD 宏:纯寄存器计算 PC 相对地址
    BR              X16          ; 普通无条件间接跳转
  • ADRL(伪指令,展开为 ADRP + ADD):ADRP 能够提供以 4 KB 页面为粒度的 ±4 GiB 范围寻址,随后的 ADD 指令填补页内 12 位偏移量。
  • 微架构收益:完全在 CPU 算术逻辑单元(ALU)内部完成目标地址合成,访存次数直接降为 0,消除了任何 L1/L2 缓存缺失导致的管线气泡,同时省去了 PAC 硬件校验单元的开销。

3.2 变体二:突破 4 GiB 极限的 128 GiB 算术飞跃

然而,现实的挑战在于:现代 iOS 共享缓存的总体跨度已经达到近 6 GiB。

以 iPhone18,1/dyld_shared_cache_arm64e 为例,其虚拟内存范围从 0x180000000 蔓延至 0x2fb8b8000,尺寸高达 5.93 GiB。一旦源桩岛与目标函数之间的物理距离超过 4 GiB,单条 ADRL 便会因立即数字长受限而发生位溢出。

换作传统思路,此时只能无奈退回到“从内存 GOT 加载指针”的老路。然而 Apple 工程师在 ARM64 架构指令集上展现出了近乎艺术的微架构编码技巧:

ADR             X16, 0x188060060        ; 加载基准锚点地址(PC 附近 ±1 MB 内)
MOV             X17, #0x9B7             ; 存入 16 位无符号立即数
ADD             X16, X16, X17, LSL #21  ; 纯算术大步长移位合成!
BR              X16                     ; 飞向最终目标

让我们对这组指令的数值逻辑进行严格的数学推演:

X16=0x188060060+(0x9B7≪21)X16 = 0x188060060 + (0x9B7 \ll 21)

  1. 执行逻辑左移 21 位:

    0x9B7≪21=0x136E00000≈4.857 GiB0x9B7 \ll 21 = 0x136E00000 \approx 4.857 \text{ GiB}

  2. 基址与偏移相加:

    0x188060060+0x136E00000=0x2BEE600600x188060060 + 0x136E00000 = 0x2BEE60060

计算得出的目标地址 0x2BEE60060,正是位于高位地址空间的数学库函数:libsystem_m.dylib!_acosl!

flowchart LR
    A["ADR X16, 0x188060060<br/>(确定局部基准锚点)"] --> B["MOV X17, #0x9B7<br/>(载入 16 位偏移因子)"]
    B --> C["ADD X16, X16, X17, LSL #21<br/>(左移 21 位:跨越 4.857 GiB 大峡谷)"]
    C --> D["BR X16<br/>(零访存直达 libsystem_m!_acosl)"]

为什么是 LSL #21?

在 ARM64 体系结构中,带移位的 ADD 指令允许对第二操作数进行任意常数移位。左移 21 位相当于以 2 MiB 为步长单元(2^21 = 2,097,152 字节)。

  • 当使用 16 位寄存器立即数 imm16 时,其最大取值可达 0xFFFF;
  • 该指令组合能够覆盖的单向地址跃迁跨度高达:

    0xFFFF≪21≈128 GiB0xFFFF \ll 21 \approx 128 \text{ GiB}

通过引入这种前向偏移(Forward-biased)的寄存器纯算术合成方案,dyld 彻底解决了超大内存跨度下跳转指令溢出的世界性难题。即使在接近 6 GiB 的庞大缓存中跨越银河两端,也无需耗费哪怕一次内存总线读取!


4. 破壁 128MB 限制:34 个 objc_msgSend 分身构筑的分布式跳板网格

如果说跨模块 C 函数调用的重构已经足够惊艳,那么针对 Objective-C 核心命脉 —— 消息发送(Message Dispatch)的重构,则堪称构筑了现代操作系统的微架构奇观。

4.1 极致压缩的两条指令

在新一代系统中,各个二进制调用 Objective-C 方法的桩代码,被压缩至不可思议的两条指令:

ADRL            X1, sel_length                               ; 直接载入选择子指针到 X1 参数寄存器
B               _objc_msgSend ; /usr/lib/objc/libobjcMsgSend.dylib ; 硬件无条件直接跳转!

对比旧时代的 5 条指令、两次内存加载与一次 PAC 解密,现在的跳板没有多余的动作:

  • 第一条指令直接将选择子写入传参寄存器 X1;
  • 第二条指令使用最原始、硬件开销最低的直接分支指令 B 呼叫 _objc_msgSend!

4.2 128 MB 物理围城下的悖论

熟悉 ARM 汇编的工程师看到这里,必然会本能地产生巨大疑窦:

  • B 指令的绝对寻址硬伤:ARM64 的无条件分支指令 B <target> 是将 26 位有符号立即数左移 2 位写入 PC,其硬件跳转极限被锁死在 ±128 MB;
  • 共享缓存总跨度接近 6 GiB,散落在各个角落的上千个系统库,怎么可能单凭一条只能跑 128 MB 的 B 指令,全部跳转到唯一的 /usr/lib/libobjc.A.dylib 中的 _objc_msgSend?

4.3 答案揭晓:分布式的 34 个 libobjcMsgSend 实体分身

逆向工具解析该共享缓存导出的镜像列表(Image List)时,展现出了令人击节赞赏的真相:

/usr/lib/objc/libobjcMsgSend.dylib
/usr/lib/objc/libobjcMsgSend1.dylib
/usr/lib/objc/libobjcMsgSend2.dylib
/usr/lib/objc/libobjcMsgSend3.dylib
...
/usr/lib/objc/libobjcMsgSend33.dylib

在整整 5.93 GiB 的共享缓存全境中,dyld 优化器竟然离散克隆并部署了 整整 34 份完全独立的 objc_msgSend 物理实现副本!

flowchart TD
    subgraph Cluster1["缓存低位区 (0x180000000 ~)"]
        F1["Framework A (UIKit 等)"] -->|"B (直跳 < 128MB)"| M0["libobjcMsgSend.dylib"]
        F2["Framework B"] -->|"B (直跳 < 128MB)"| M1["libobjcMsgSend1.dylib"]
    end

    subgraph Cluster2["缓存中位区 (0x230000000 ~)"]
        F3["Framework C"] -->|"B (直跳 < 128MB)"| M16["libobjcMsgSend16.dylib"]
        F4["Framework D"] -->|"B (直跳 < 128MB)"| M17["libobjcMsgSend17.dylib"]
    end

    subgraph Cluster3["缓存高位区 (~ 0x2fb8b8000)"]
        F5["Framework E"] -->|"B (直跳 < 128MB)"| M32["libobjcMsgSend32.dylib"]
        F6["Framework F"] -->|"B (直跳 < 128MB)"| M33["libobjcMsgSend33.dylib"]
    end

为什么值得为它复制 34 份?

  1. 微小的代码体积:objc_msgSend 是经过高精度微架构打磨的纯汇编例程,核心逻辑仅包含缓存哈希查找(Cache Lookup),总指令数只有数十条,编译后体积仅约几千字节。复制 34 份的内存代价还不到 100 KB,这在近 6 GiB 的共享缓存中完全是九牛一毛;
  2. 彻底解放硬件分支预测器:使用间接跳转指令(BR 或 BRAA)时,CPU 必须依赖分支目标缓冲器(BTB,Branch Target Buffer)进行动态推测,极易引发预测错误与管线冲刷。而直接跳转指令 B 的目标地址直接内嵌于指令码本身,前端译码器可在第一时间捕获目标,实现零惩罚的流水线饱满吞吐!

5. 宇宙中心假说:__OBJC_RO 的黄金分割几何布局

解决了 B _objc_msgSend 的寻址难题,反方向的悖论又浮出了水面:

ADRL            X1, sel_length

前文已经论证,ADRL(ADRP + ADD)的极限寻址能力是 ±4 GiB。

  • objc_msgSend 代码可以通过分布在 34 个副本中的某一个来解决 128 MB 的跳转范围;
  • 但是,所有的选择子(Selector)字符串全部集中收录在唯一的单例只读数据段 __OBJC_RO 中,并未被离散复制!
  • 面对一个跨度 5.93 GiB 的宏大世界,分散在缓存各处的所有二进制,如何仅凭一条寻址范围仅为 ±4 GiB 的 ADRL,全部精准命中唯一的 __OBJC_RO?

5.1 地址空间拓扑实测

查看该 dyld 共享缓存导出的真实内存映射边界:

shared_region_start   0x180000000
shared_region_end     0x2fb8b8000   ; 总跨度 0x17b8b8000,约 5.93 GiB

虽然 libobjc 是整个镜像列表中的第一个动态库,但 dyld 链接器并没有将它的数据段机械地排布在缓存最前端,而是将其 __OBJC_RO 段单独撕裂并搬运到了一个不可思议的位置:

libobjc.A.dylib:__OBJC_RO             0x1f552e240 - 0x1fcff44e8

5.2 严密的几何中点数学验证

让我们以 __OBJC_RO 的基准地址为参照物,分别计算其与共享缓存头部和尾部的物理距离跨度:

  1. 距离缓存起点的跨度:

    0x1f552e240−0x180000000=0x7552e240≈1.83 GiB0x1f552e240 - 0x180000000 = 0x7552e240 \approx 1.83 \text{ GiB}

    1.83 GiB<4.0 GiB(完全落在 ADRL 寻址半径内)\mathbf{1.83 \text{ GiB}} \lt \mathbf{4.0 \text{ GiB}} \quad (\text{完全落在 } ADRL \text{ 寻址半径内})

  2. 距离缓存终点的跨度:

    0x2fb8b8000−0x1fcff44e8=0xfe8c3b18≈3.977 GiB0x2fb8b8000 - 0x1fcff44e8 = 0xfe8c3b18 \approx 3.977 \text{ GiB}

    3.977 GiB<4.0 GiB(极其精准地收敛在 4.0 GiB ADRL 极限边界内!)\mathbf{3.977 \text{ GiB}} \lt \mathbf{4.0 \text{ GiB}} \quad (\text{极其精准地收敛在 } 4.0 \text{ GiB } ADRL \text{ 极限边界内!})

flowchart LR
    Start["0x180000000<br/>(缓存起点)"] --- |"1.83 GiB<br/>(安全在 ±4 GiB 内)"| RO["__OBJC_RO 区域<br/>0x1f552e240 ~ 0x1fcff44e8<br/>(系统的绝对拓扑中心)"]
    RO --- |"3.977 GiB<br/>(极限贴合 4.0 GiB 边界)"| End["0x2fb8b8000<br/>(缓存终点)"]

这是操作系统拓扑规划上令人叹为观止的“几何中心布局法”:
dyld 优化器将单例的 __OBJC_RO 放置在近 6 GiB 缓存的物理黄金几何分割点上。无论是处于最左端 0x180000000 的库,还是处于最右端 0x2fb8b8000 的库,只要向其发射一条 ADRL 指令,都能在不超过 4 GiB 的半径内完美触达这一数据中心!


6. 全维度架构对比:传统 Stubs vs 新一代 Stub Islands

为了系统梳理这一演进带来的本质跃迁,我们将传统动态链接桩与新一代桩岛跳板进行全维度技术对齐:

评估维度 传统二进制 / 早期 DSC 桩实现 新一代微架构桩岛设计(iOS 共享缓存重构) 微架构收益本质
桩代码指令条数 3~5 条指令 2~4 条指令 减少取指与译码队列压力
数据内存访问(LDR) 每次调用 1~2 次(GOT / selrefs) 0 次(纯寄存器算术计算) 彻底消除 D-Cache Miss 停顿
指针认证(PAC)开销 每次调用强制执行 BRAA 受信缓存内部跳过 PAC 校验 节约硬件密码学解密流水线周期
跨模块大范围寻址 依赖外部 GOT 表项间接中转 ADD X16, X16, X17, LSL #21(跨度达 128 GiB) 寄存器纯算术支持超宽地址跃迁
Objective-C 分发跳转 间接跳转 BRAA(依赖 BTB 动态推测) 硬件直接跳转 B(34 个克隆副本就近直跳) 分支预测零失误,消除管线冲刷
选择子寻址方案 查表读取本模块 selrefs 局部指针 ADRL 直达居中的单例 __OBJC_RO 全局统一消重,节约内存且无访存
冗余截面状态 每个 Mach-O 保留废弃 __auth_stubs 彻底裁剪废弃截面,仅留桩岛 减少镜像体积与静态解析死重

7. 总结与系统级工程启示

通过对新一代 dyld 共享缓存与桩岛跳板的逆向拆解,Apple 向系统级软件工程展示了一整套极具震撼力的性能优化哲学:

  1. 确定性环境下的去中心化与零访存:当运行环境具备确定性(静态布局只读缓存)时,不要盲目套用通用的间接内存寻址与安全校验模型。利用 CPU 强大的寄存器移位与加法算术运算取代内存读取,是压榨指令级并发(IPC)的终极手段;
  2. 空间换时间的微观尺度权衡:仅仅消耗几十 KB 的代码空间离散复制 34 份轻量级的 objc_msgSend,就破解了 ARM64 硬件 128 MB 直接跳转的百年物理枷锁,将高频动态分发全部收敛为零惩罚的直接硬件分支;
  3. 内存拓扑也是一种算法:解决寻址距离不足的手段不只有“增加跳板与指针级联”,通过全局规划将热点数据结构布置在虚拟地址空间的几何重心(Centroid),同样能以优雅的几何对称性降维消除物理边界。

这一系列精密如机械钟表般的微架构革新,正是 Apple 系统在日常交互与高频函数调用下始终保持极致丝滑的真正幕后功臣。


8. 原文链接与参考资料