iOS系统架构概览
# iOS系统架构概览
精简篇 | Mach内核→CoreOS→CoreServices→Media→CocoaTouch五层架构,以及iOS启动流程与dyld加载。
# 目录介绍
# 1. 案例引入
# 1.1 冷启动卡在白屏
去年团队上线一个新版本,测试环境一切正常。但灰度期间收到大量用户反馈:App 冷启动卡在白屏 5-8 秒才进首页,热启动正常。Xcode Organizer 里看到主线程在启动阶段卡在 dyld 相关调用上。
Time Profiler 采样(启动阶段):
79.2% dyld::ImageLoader::recursiveInitialization
12.1% _dyld_start
5.3% libSystem_initializer
3.4% main
main() 函数只占了 3.4%,意味着 96% 的启动时间都花在了 main 之前——也就是 dyld 加载动态库的阶段。
我们做了什么?查了 MachOView,发现工程链入了 37 个动态库,其中 12 个是第三方 SDK,每个在 +load 方法里做了大量初始化操作。这 37 个库的加载、符号绑定、+load 执行——全部挤在 main() 之前串行完成。
# 1.2 顺藤摸到根因
沿着这条线往下挖:
- 假设 1:是不是 dyld 本身有性能问题?——不是,dyld 3 已经做了大量优化(启动闭包),是库太多导致的。
- 假设 2:能不能把部分库延迟加载?——iOS 不支持
dlopen延迟加载系统库,但自建动态库可以通过dlopen在用到时才加载。 - 假设 3:
+load执行的耗时怎么来的?——Xcode 的 Environment Variables 里设置DYLD_PRINT_STATISTICS,发现 12 个+load方法合计耗时 3.2 秒。 - 假设 4:为什么热启动没问题?——热启动时 dyld 的共享缓存(dyld shared cache)还在内存里,直接跳过加载步骤。
- 假设 5:App 启动到底经历了什么?——从用户点图标到
main(),中间隔着一整套 iOS 系统流程。
这个看似 "库太多" 的工程问题,背后藏着一整套 iOS 系统架构的知识:
① iOS 系统分几层?App 跑在哪一层? → 第 2 章
② Mach-O 是什么格式?为什么不是 ELF? → 第 4 章
③ dyld 怎么加载动态库?为什么有共享缓存? → 第 4 章
④ 从点图标到 main() 发生了什么? → 第 5 章
⑤ 沙盒是怎么隔离 App 的? → 第 6 章
⑥ XNU 内核的 Mach 和 BSD 分别管什么? → 第 3 章
⑦ iOS 后台机制为什么杀的这么狠? → 第 5.3 节
# 1.3 我们要回答什么
这个启动优化案例就是本篇的主线案例。每讲完一层架构就解开一两个问号,最后在第 7 章把案例剖开,给出三种优化方案。
📌 本篇定位:这是整个 iOS 专栏的地基篇。后面 19 篇讲的 Runtime、RunLoop、View 渲染、Block 底层、内存管理——本质都是 "在这张 iOS 架构地图上的某一块里发生的事"。读完本篇,再看任何 iOS 技术话题,都能立刻回答:"它在哪一层"。
# 2. 架构概览
# 2.1 五层总图
iOS 的系统架构自下而上分为五层(从硬件到用户):
┌──────────────────────────────────────────────────────────────┐
│ Cocoa Touch 层 │
│ UIKit · SwiftUI · PushKit · MapKit · MessageUI · ... │ ← App 直接调用的 API
├──────────────────────────────────────────────────────────────┤
│ Media 层 │
│ Core Animation · Core Graphics · Core Audio · AVFoundation │ ← 音视频、2D/3D渲染
│ Metal · OpenGL ES · Core Image · Core Text · SpriteKit │
├──────────────────────────────────────────────────────────────┤
│ Core Services 层 │
│ Foundation · Core Data · Core Location · CFNetwork │ ← 数据存储、网络、定位
│ StoreKit · WebKit · CloudKit · Core Motion │
├──────────────────────────────────────────────────────────────┤
│ Core OS 层 │
│ libSystem (libc, libm, libpthread) · Accelerate · Security │ ← 系统调用入口
│ System Configuration · libdispatch (GCD) · libobjc │
├──────────────────────────────────────────────────────────────┤
│ Kernel & Drivers 层 │
│ XNU Kernel (Mach + BSD + IOKit) · Sandbox · File System │ ← 进程管理、内存、IO
│ Drivers · TCP/IP · Virtual Memory · Power Management │
└──────────────────────────────────────────────────────────────┘
关键边界:
- Cocoa Touch 层以上:你的 App,用户看得见的 UI 和交互逻辑
- Core OS 层以下:内核态,App 通过
syscall间接访问 - Media / Core Services:桥接层——上层调 API,下层调系统调用
# 2.2 为什么这么分层
iOS 分层不是 "画图好看",每一层解决一类独立问题:
| 分层理由 | 说明 |
|---|---|
| 权限隔离 | 内核层拥有最高权限;Core OS 提供系统调用的安全入口;上层 App 受沙盒限制 |
| 复用与演进 | macOS / iOS / watchOS / tvOS 共享 Core OS + Core Services 两层,各自的 Cocoa Touch 层不同 |
| 性能分层 | Metal 直达 GPU 驱动(跳过中间层),比 OpenGL ES 少两层调用;这就是为什么 Metal 比 OpenGL 快 |
| 向后兼容 | iOS 大版本升级时,底层重构不影响 UIKit API —— App 不用改代码就能跑在新系统上 |
对比 Android:Android 的 HAL(硬件抽象层)是单独一层,iOS 把它藏在 IOKit 里,对外不可见。这是 iOS "封闭"的根源。
# 3. XNU内核与Mach
# 3.1 Mach微内核设计
iOS 的 XNU 内核("X is Not Unix")由两个内核融合而成:Mach(来自 CMU)和 BSD(来自 FreeBSD)。
Mach 负责最底层的"硬骨头":
| Mach 职责 | 具体内容 |
|---|---|
| 进程与线程管理 | 每个 App 是一个 Mach 进程(task),内部可以有多个 thread |
| 虚拟内存 | 所有进程看到的地址都是虚拟地址,通过 MMU 翻译到物理地址 |
| IPC 通信 | Mach Port —— iOS 上所有跨进程通信的基础(XPC、App Group 都建立在它之上) |
| 时钟与调度 | 时间片轮转、优先级调度、实时线程 |
Mach 的设计哲学是 "最小内核"——Mach 本身只做进程/线程/内存/IPC 四件事,其它(文件系统、网络栈、用户权限)全部交给 BSD 层。这让内核更容易验证正确性,也更容易移植到 Apple Watch 这类极低功耗设备。
# 3.2 BSD层的角色
BSD 层套在 Mach 之上,提供 POSIX 兼容的 Unix 接口:
App 代码: open("file.txt", O_RDONLY)
│
▼
BSD 层: syscall(SYS_open, ...) ← POSIX 系统调用
│
▼
Mach 层: mach_vm_map(...) ← 最终通过 Mach 操作虚拟内存
BSD 层对开发者最重要的三件事:
- 文件系统:HFS+/APFS 的管理,App 沙盒的文件隔离(第 6 章)
- 网络栈:BSD Socket → TCP/IP → Wi-Fi/蜂窝驱动(
NSURLSession底层最终在这) - 权限模型:
root/mobile/_wireless等多用户体系,每个 App 以mobile用户运行
# 3.3 内存管理与VM
iOS 的虚拟内存系统建立在 Mach VM 之上。每个 App 进程拥有独立的 64 位虚拟地址空间(虽然物理内存只有 2-6 GB)。
关键机制:Mach 不是给 App "一段物理内存",而是给一段 VM Object,物理页在真正访问时(缺页中断)才分配。
App 调用 malloc(100 MB)
│
▼
libmalloc → mmap(MAP_ANON, 100MB) ← 只分配虚拟地址,物理内存为 0
│
▼ (App 第一次写这 100 MB)
Mach 缺页中断 → 分配物理页 ← 此时才真正消耗物理内存
这也是为什么 UIImage(named:) 加载一张 20 MB 的图片,解码后可能占 40 MB 物理内存——解码后的位图是真正被"写"过的内存,触发了物理分配。而压缩的 PNG 数据在文件系统上只占一份。
Jetsam 机制:当系统物理内存不够时,Jetsam(iOS 的 "OOM Killer")会按优先级杀进程。前台 App 几乎不会被杀,后台 App 最先被清除。这就是为什么后台的 App 经常会"重启"——不是 bug,是被 Jetsam 杀了。
# 4. dyld与Mach-O加载
# 4.1 Mach-O文件结构
iOS 的可执行文件和动态库都是 Mach-O(Mach Object)格式——这是 XNU 内核原生支持的二进制格式,与 Linux 的 ELF、Windows 的 PE 是同类概念。
一个 Mach-O 文件由三部分组成:
┌─────────────────────┐
│ Header │ ← CPU 类型(arm64)、文件类型(可执行/动态库)、加载指令数
├─────────────────────┤
│ Load Commands │ ← 每一条告诉 dyld "做什么":
│ - LC_SEGMENT_64 │ 把 __TEXT 段映射到 VA 0x100000000,r-x 权限
│ - LC_SEGMENT_64 │ 把 __DATA 段映射到 VA 0x100008000,rw- 权限
│ - LC_LOAD_DYLIB │ 依赖 UIKit,去找它的路径
│ - LC_MAIN │ 入口点偏移(代替旧的 LC_UNIXTHREAD)
│ - LC_CODE_SIGNATURE│ 代码签名信息
├─────────────────────┤
│ Data │ ← __TEXT(代码)、__DATA(全局变量)、__LINKEDIT(符号表)
│ ┌─── __TEXT ────┐ │
│ │ __text (代码) │ │ 可执行文件 = 一个 Header + N 个 Load Command + 数据
│ │ __stubs │ │ 动态库同理,区别是 type 字段不同
│ │ __cstring │ │
│ ├─── __DATA ────┤ │
│ │ __data │ │
│ │ __bss │ │
│ │ __objc_class │ │ ← ObjC 的类列表、方法列表全在这
│ ├─── __LINKEDIT ─┤ │
│ │ 符号表 │ │
│ │ 字符串表 │ │
│ │ 重定位信息 │ │
│ └───────────────┘ │
└─────────────────────┘
查看 Mach-O 的命令:
xcrun size -l -m App # 查看 __TEXT/__DATA 大小
xcrun otool -l App | grep LC_ # 列出所有 Load Command
xcrun dyldinfo -exports App # 查看导出符号
# 4.2 dyld加载流程
dyld(dynamic link editor)是 iOS 的动态链接器,相当于 Linux 的 ld-linux.so。当一个 App 进程被创建后,内核直接把 dyld 的路径写进进程,dyld 成为进程的第一段代码——它比 main() 先运行。
dyld2 加载流程(iOS 12 之前):
内核 execve() → 加载 dyld 到进程空间
│
┌───────────────┼───────────────┐
▼ ▼ ▼
1. 加载所有 2. 符号绑定 3. 初始化
动态库 (rebase/bind) (initializer)
────── ───────────── ─────────────
解析每个 修复内部指针 执行 +load 方法
Mach-O 文件 指向正确 VA 调用静态初始化
映射到内存 绑定外部符号 创建 ObjC 类
调用 __attribute__((constructor))
│
▼
main() 开始执行
dyld3 的优化(iOS 12+):dyld3 引入了启动闭包——在 App 安装或系统更新时,提前把"这 37 个库怎么加载、符号怎么绑"全部算好,存成缓存。冷启动时直接读闭包,省去解析和符号查找。这是 iOS 12 冷启动提速 30%+ 的核心原因。
# 4.3 动态库与共享缓存
你能在 /usr/lib/ 下找到 UIKit、Foundation、libobjc 等动态库——但它们并不是以独立 .dylib 文件形式存在的。iOS 把所有系统动态库合并成一个巨大的共享缓存(dyld shared cache,位于 /System/Library/Caches/com.apple.dyld/),通常 1-2 GB:
所有 App 进程共享同一个 dyld cache 的只读部分:
App A ─┐
App B ─┼─► 同一份 UIKit __TEXT 的物理页
App C ─┘
这就是为什么:
- iOS 启动比 Android 快(不用每次解析 dex/oat)
- 所有 App 的 UIKit 在物理内存里只占一份
- 即使是 2 GB 内存的设备,跑 50 个 App 也不会 OOM
# 5. SpringBoard与应用生命周期
# 5.1 SpringBoard的角色
SpringBoard 是 iOS 的桌面进程,相当于 macOS 的 Finder + Dock。它负责:
- 显示主屏幕和 App 图标
- 响应用户点击图标 → 启动对应 App
- 管理 App 切换器(多任务卡片)
- 处理锁屏、通知中心、控制中心
从点击图标到 main() 的完整链路:
用户点图标
│
▼
SpringBoard 收到 touch 事件
│
├─ 创建新进程 (fork + exec) ────────────────── 内核
│ ├─ 分配虚拟地址空间
│ ├─ 加载 dyld → dyld 加载 App 及依赖库
│ ├─ 执行 +load → ObjC Runtime 初始化
│ └─ 调用 main()
│
├─ 同时播放启动动画(缩放图标到全屏)
│
▼
App 的 main() → UIApplicationMain() → AppDelegate
SpringBoard 给 App 的启动时间限制:iOS 大约 20 秒内必须完成启动并渲染第一帧,否则会被系统 watchdog 杀掉(0x8badf00d 错误码)。
# 5.2 App五种状态切换
Not Running ──────── 被杀 / 手动杀死 ────────┐
│ │
SpringBoard 点图标 │
│ │
▼ │
Inactive ── 短暂过渡(启动动画期间) │
│ │
▼ │
Active ── 前台活跃(用户正在用) │
│ │
按 Home / 切 App │
│ │
▼ │
Inactive ── 短暂过渡 │
│ │
▼ │
Background ── 后台运行(限时 30s~3min) │
│ │
任务完成 / 超时 │
│ │
▼ │
Suspended ── 挂起(不执行代码,内存保留)────────┘
关键细节:Suspended 状态的 App 内存还在,但不执行任何代码。当系统内存不够时,Jetsam 首先清除 Suspended 状态的 App。用户重新打开时从 Suspended 恢复快,从 Not Running 冷启动慢——这就是"后台被杀掉"和"真正的关闭"的区别。
# 5.3 后台执行机制
iOS 对后台的限制极其严格——这是 iOS "省电"和"流畅"的根基。一个 App 进入后台后通常只有约 30 秒的时间完成善后工作。
后台执行的几种合法方式:
| 机制 | 时长 | 适用场景 |
|---|---|---|
beginBackgroundTask | 30s~3min(iOS 13+ 约 30s) | 保存数据、关闭连接 |
| VoIP Push | 长驻 | 语音/视频通话类 App |
| Background Fetch | 定时唤醒几秒 | 刷新内容、预加载 |
| Remote Notification | 推送触发,30s | 收到消息后更新数据 |
| BGTaskScheduler | 系统择机调度 | 定期清理缓存 |
面试常考题:"App 进入后台后能活多久?" 答:最长 3 分钟(
beginBackgroundTask申请),过期后系统强制挂起。只有 VoIP、定位、音频播放这三类有特权长驻后台。
# 6. 沙盒与安全模型
# 6.1 沙盒的三层隔离
iOS 的沙盒是强制访问控制(MAC) 机制的实现,每个 App 被关在自己的"盒子"里。三层隔离:
第一层:文件系统隔离
┌─────────────────────────────┐
│ /var/mobile/Containers/ │
│ Data/{UUID}/ │ ← 每个 App 的数据容器
│ Documents/ │ (iTunes 备份)
│ Library/Caches/ │ (可清理)
│ Library/Preferences/ │ (NSUserDefaults 存在这)
│ tmp/ │ (随时可能清空)
│ │
│ /var/containers/Bundle/ │
│ {UUID}/YouApp.app/ │ ← App Bundle(只读)
└─────────────────────────────┘
第二层:进程隔离
每个 App 以独立的 mobile 用户身份运行
App A 无法读取 App B 的内存(Mach VM 隔离)
跨进程通信必须通过系统提供的 XPC / URL Scheme
第三层:权限管控(Entitlements)
网络访问?→ com.apple.security.network.client
相机? → com.apple.security.device.camera
后台模式?→ com.apple.developer.background-modes
没有相应的 entitlement,即使代码写了 API 调用也会被内核拒绝
# 6.2 签名与权限控制
iOS App 必须经过 Apple 代码签名才能运行。签名链分为三层:
开发者证书 (Apple 签发)
│
▼
Provisioning Profile (描述文件)
├─ 允许的设备 UDID 列表
├─ Entitlements 白名单
└─ 关联的 App ID
│
▼
App 可执行文件签名 (Code Directory)
└─ 每页代码的哈希值 → 运行时校验是否被篡改
每次 App 启动,内核会验证代码签名。验证通过后开启沙盒限制。企业证书签名的 App 为什么能"侧载"?因为企业证书跳过了 App Store 审核,但签名链仍然存在,权限受到 Profile 中 entitlements 的白名单控制。
# 7. 案例回溯与设计哲学
# 7.1 案例真相揭晓
回到第 1 章的冷启动问题,七个疑问现在逐条作答:
| 疑问 | 答案 |
|---|---|
| ① iOS 系统分几层?App 跑在哪? | 第 2 章:五层架构,App 跑在 Cocoa Touch 层之上 |
| ② Mach-O 为什么不是 ELF? | 第 4.1:Mach-O 是 XNU 原生格式,dyld 解析它的 Load Command 做映射 |
| ③ dyld 怎么加载动态库? | 第 4.2-4.3:dyld 依次 mmap 每个 Mach-O,处理 rebase/bind,执行初始化;共享缓存加速了系统库加载 |
| ④ 点图标到 main() 发生什么? | 第 5.1:SpringBoard→fork→dyld→main,整个链在 400ms 以内(否则影响启动体验) |
| ⑤ 沙盒怎么隔离 App? | 第 6 章:文件系统+进程+entitlements 三层隔离 |
| ⑥ Mach 和 BSD 分别管什么? | 第 3 章:Mach 管进程/内存/IPC,BSD 管文件/网络/权限 |
| ⑦ 后台为什么杀得狠? | 第 5.3:iOS 用"冻结后台→杀 Suspended→Jetsam"三级机制省电,后台特权极为有限 |
三种优化方案(按收益排序):
方案 A:减少 +load 耗时(最直接)
// ❌ 在 +load 里做初始化
+ (void)load {
[self setupSDK]; // dyld 启动阶段串行执行
}
// ✅ 推迟到 +initialize 或首次调用
+ (void)initialize {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{ [self setupSDK]; });
}
方案 B:合并动态库 / 改用静态库
将 37 个动态库中无共享必要的改为静态库,减少 dyld 的 rebase/bind 开销。但注意静态库会增大包体积。
方案 C:使用 DYLD_PRINT_STATISTICS 诊断
// Xcode Scheme → Environment Variables
DYLD_PRINT_STATISTICS = 1
DYLD_PRINT_STATISTICS_DETAILS = 1
下次启动时控制台输出详细耗时表,每个 dylib 的加载时间一目了然。
# 7.2 设计哲学回扣
整理本篇的三条跨篇适用的设计哲学:
哲学 1:分层抽象——App 不需要知道自己跑在什么芯片上
Cocoa Touch 向上屏蔽了所有硬件差异。同样一句 UIView.animate,iPhone 13 的 A15 和 iPhone SE 的 A9 都能跑——硬件差异被 Core Animation → Metal/OpenGL ES → GPU Driver 三层消化了。分层让 App 开发者只关心"做什么",不关心"怎么做"。
哲学 2:以空间换时间——dyld shared cache 的秘密
iOS 把 UIKit 等系统库的代码段合并成一个 1-2 GB 的大文件,映射进每个进程。代价是磁盘空间,收益是所有 App 共享同一份物理页 + 启动时直接 mmap 无需解析。这是 iOS 比 Android 启动快的关键设计。
哲学 3:fail fast——watchdog 与 Jetsam 的共同信仰
iOS 不允许一个 App 拖慢整个系统。启动超 20s → watchdog 杀;后台超时 → suspend;内存不够 → Jetsam 杀。表面"残酷",实际是保护用户体验——宁可杀你一个 App,不让整个手机卡。这与 Unix "fail fast" 哲学一脉相承。
下一篇:你已经知道 App 是怎么启动的,下一章进入 02.Runtime消息转发机制 (opens new window)——把 ObjC 最核心的 "消息传递三件事" 剖到源码级别。