记录一下 macOS 如何利用 APFS Volume Group, Signed System Volume 和 firmlink 组织出最终看到的文件系统
从目录树来看, macOS 与其他 Unix 系统一样, 所有路径都从 / 开始; 但目录树中的上下级关系只描述如何找到一个文件, 并不保证这些文件真的存储在同一个文件系统中
macOS 中的 / 并不对应一个完整的可写根分区, 而是由 APFS 中的一组卷共同组成的统一命名空间; /System 等系统目录来自只读卷, /Applications 和 /Users 等可写目录则来自另一个数据卷, 其中最关键的连接机制就是 APFS Volume Group + firmlink
从磁盘到 APFS Volume
从物理结构来看, macOS 的启动磁盘可以抽象为下面几层:
1 | 物理磁盘 |
GPT 负责描述物理磁盘上的分区边界, 其中的 APFS 分区对应一个 Container; Container 是 APFS 管理空间的基本池, 内部可以继续创建多个 Volume, 每个 Volume 都拥有独立的目录树, 元数据和挂载状态
Volume 与传统固定大小的分区不同, 同一个 Container 内的卷共享空闲空间, 不需要在创建时确定一个不可改变的容量边界; 每个卷也可以设置 quota 和 reserve, 前者限制卷最多能够使用的空间, 后者保证卷至少能够获得的空间
1 | diskutil apfs list |
这个命令会按照 Physical Store -> Container -> Volume 的层次列出磁盘结构, 还会显示每个 Volume 的 Role, UUID, Capacity Consumed 与挂载点; Role 并不是卷名, 而是 macOS 用来区分 System, Data, Preboot 等用途的元数据
因此, System, Data, Preboot 等卷虽然是彼此独立的文件系统, 但它们使用的是同一个 Container 空间池; 某个卷新写入数据时会从共享池中分配块, 删除数据后又将块归还给共享池, 对这些卷执行 df 时也往往会看到相同的总容量和可用容量
CoW, clone 与 snapshot
APFS 采用 Copy-on-Write, 简称 CoW; 修改已有数据或文件系统元数据时, APFS 不会直接覆盖当前使用的块, 而是先在新位置写入修改后的内容, 再切换元数据指向, 因此提交前的旧状态不会被半次写入破坏
CoW 也是 clone 和 snapshot 的基础:
- clone 让两个文件或目录先共享已有数据块, 只有后续发生修改的部分才需要复制
- snapshot 保存某个 Volume 在特定时刻的文件系统状态, 创建时不需要复制卷中的全部数据
当 snapshot 建立之后, 当前文件系统仍然可以继续修改; 如果旧数据块仍被 snapshot 引用, APFS 就会保留它, 新版本则写到其他块中, 所以 snapshot 的空间占用会随着后续差异逐渐增加
各个 APFS 卷的作用
- System Volume: 保存启动 macOS 所需的系统文件和 Apple 自带内容
- Data Volume: 保存用户文件, 第三方应用以及所有需要在运行期间修改的数据
- Preboot Volume: 保存引导每个 System Volume 所需的启动数据
- Recovery Volume: 保存 recoveryOS, 在系统卷不可用时仍然能够进入恢复环境
- VM Volume: 保存 swap 等虚拟内存数据
- Update Volume: 在系统更新过程中保存临时状态和待安装内容
其中 System 和 Data 并不是两个互不相关的普通卷, 它们通过 APFS Volume Group 组合成一套 macOS 系统; 一个 System Volume 会有一个对应的 Data Volume, 二者通过 Volume Group UUID 建立关系, 而 Preboot, Recovery 和 VM 等辅助卷可以由同一 Container 中的系统共享
可以使用下面的命令查看卷组关系:
1 | diskutil apfs listVolumeGroups |
一次正常启动大致会经历下面的文件系统组装过程:
- 启动程序从 Preboot Volume 中找到所选 System Volume 对应的启动数据
- 启动链验证 System Volume snapshot 的 seal
- 通过验证的 snapshot 以只读方式挂载到
/ - 系统根据 Volume Group 关系找到配对的 Data Volume, 并挂载到
/System/Volumes/Data - APFS 根据 firmlink 映射把 Data 中的可写目录接入根目录树
- devfs, VM Volume, 外置磁盘等其他文件系统再挂载到各自的位置
所以用户最终看到的 / 是启动过程组装出的全局命名空间, 而不是磁盘上某一个卷的原始目录树
System Volume 与 Data Volume
macOS Catalina 开始将启动盘拆分为 System 和 Data 两个卷, 目的是将不会变化的操作系统内容与需要写入的数据隔离开
System Volume
System Volume 中主要包含:
1 | /System |
这里的 /usr 也有少数例外, 例如 /usr/local 位于 Data Volume, 后面会介绍这种局部跨卷的路径是如何实现的
从 macOS Big Sur 开始, System Volume 又成为 Signed System Volume, 简称 SSV; 系统会为每一部分文件内容和文件系统元数据计算哈希, 上层节点继续对下层节点的哈希进行计算, 最终形成类似 Merkle Tree 的结构, 根节点的哈希就是代表整个系统状态的 seal
Apple 对预期的 seal 进行签名, 启动链则在挂载根文件系统之前验证签名和 seal; 如果底层任意系统文件发生未授权修改, 对应哈希就会改变, 验证过程也无法得到相同的根 seal
正常启动时挂载到 / 的也不是可变的 System Volume 本体, 而是它的一个只读 APFS snapshot:
1 | /dev/disk... on / (apfs, sealed, local, read-only, ...) |
这带来了两个效果:
- 运行中的系统文件拥有确定且可验证的状态
- 系统更新可以准备新的系统状态和 snapshot, 再在启动时切换到新 snapshot; 如果更新失败, 仍然可以回到之前的状态
snapshot 在这里既是可验证的启动状态, 也是系统更新的切换单位; 更新程序可以在后台准备新的 System 内容, 重新计算 seal 并生成新的 snapshot, 重启时再选择新 snapshot 启动
所以 root 用户无法直接修改 /System 并不只是 Unix 权限导致的, 更根本的原因是当前根文件系统本身就是经过验证的只读 snapshot; SIP 与文件权限仍然提供其他层面的保护, 但它们和 SSV 不是同一个机制
Data Volume
Data Volume 保存会变化的内容, 在启动后实际挂载到:
1 | /System/Volumes/Data |
这个挂载点通常带有 nobrowse 属性, Finder 不会把它当成一个需要用户直接进入的普通卷; 它更像是系统暴露出来的底层入口, 日常程序仍然应该使用 /Users, /Applications 等稳定路径
直接查看这个目录可以看到类似下面的结构:
1 | /System/Volumes/Data |
然而平时并不会使用 /System/Volumes/Data/Users 访问用户目录, 而是仍然使用熟悉的 /Users; 这两个路径看起来处于根目录的不同位置, 实际却指向 Data Volume 中的同一个目录, 将它们拼接起来的就是 firmlink
可以用 df 直观观察路径最后落在哪个 Volume:
1 | df -h /System /Users /Applications |
/System 会落在只读 System snapshot 对应的设备上, /Users 和 /Applications 则会落在 Data Volume 对应的设备上
firmlink
firmlink 可以理解为 macOS 为 System Volume 和 Data Volume 设计的跨卷目录映射, 它由 APFS 和内核的路径解析逻辑处理, 将只读 System 命名空间中的某个目录无缝接到可写 Data Volume
启动时, 系统已经知道 System 与 Data 属于同一个 Volume Group, 因此 firmlink 只需要记录两边目录的对应关系; 当路径解析到 firmlink 边界时, 内核会在配对 Data Volume 中继续查找余下的路径分量, 程序本身不需要知道切换发生过
例如下面这两个路径访问的是同一个目录:
1 | /Applications |
但 /Applications 在外观上仍然是普通目录:
1 | ls -ld /Applications |
它不会像符号链接一样显示 -> 和目标路径, 也没有一段可供应用读取的目标路径字符串; 程序进入 /Applications 后再访问 .., 得到的仍然是 /, 而不是暴露实现细节的 /System/Volumes/Data
也就是说, firmlink 不是简单地进行字符串路径替换, 而是在 VFS 和 APFS 处理目录遍历时切换到卷组中对应的 Data 目录, 同时维持统一的根目录语义; 从普通应用的角度来看, System 和 Data 仍然是一棵连续的目录树
firmlink 映射表
系统使用的主要映射记录在:
1 | /usr/share/firmlinks |
可以直接查看其中的内容:
1 | cat /usr/share/firmlinks |
其中比较重要的条目类似于:
1 | /Applications Applications |
左侧是统一根目录中呈现给程序的路径, 右侧是相对于 Data Volume 根目录的路径, 所以 /usr/local 最终对应的是 /System/Volumes/Data/usr/local
映射不只能够出现在根目录的第一层, 实际清单中还存在 /System/Library/Caches, /usr/libexec/cups 等更深的路径; 这意味着一个只读目录的部分子树也可以切换到 Data Volume, 而不需要把整个父目录都变成可写
还可以用 stat 观察映射前后的目录:
1 | stat -f '%N inode=%i' \ |
在当前系统中, 两个路径会得到相同的 inode, 说明它们不是两份同步的数据, 而是同一个目录的两种入口
如果在 /Applications 中创建文件, 数据会直接写入 Data Volume 的 Applications 目录; firmlink 不负责复制和同步文件, 因为映射前后本来就是同一份目录内容
/usr/share/firmlinks 是系统卷中的实现清单, 只适合观察, 不应该修改; firmlink 也不是提供给用户自由创建跨卷链接的通用接口
firmlink 与其他链接机制
firmlink, symbolic link 和 mount 都能让一个路径跳转到另一处, 但三者所在的层次不同:
| 机制 | 表现 | 是否可跨文件系统 | 主要用途 |
|---|---|---|---|
| symbolic link | 一个保存目标路径的特殊文件, ls -l 会显示 -> |
可以 | 普通文件和目录的路径引用 |
| hard link | 多个目录项引用同一个 inode | 通常不可以 | 在同一文件系统中为文件增加名称 |
| mount | 将整个文件系统挂载到一个目录 | 可以 | 将磁盘卷或虚拟文件系统接入全局命名空间 |
| firmlink | 由内核透明连接 Volume Group 中的目录 | 针对配对的 System 和 Data 卷 | 把可写目录嵌入只读根目录 |
firmlink 本身不会作为一个独立文件系统出现在 mount 输出中; 真正的 mount 是 Data Volume 到 /System/Volumes/Data, 在此基础上, firmlink 再负责各个具体目录的路径解析
hard link 依赖 inode, 无法承担跨 Volume 的目录拼接; symbolic link 虽然可以跨文件系统, 但会暴露目标路径并改变 .. 等路径语义; 普通 mount 的管理单位则是文件系统或卷, 如果为每个可写目录分别建立挂载点, 也无法表达 System 与 Data 之间统一的卷组关系, 因此这些现有机制都不能完全满足拆分后的透明性要求
根目录是如何组成的
有了前面的结构之后, macOS 根目录中的常见路径可以分为四类
System Volume 提供的路径
| 路径 | 作用 |
|---|---|
/System |
Apple 的系统框架, 服务和资源, /System/Applications 中保存系统自带应用 |
/bin |
启动和基本环境所需的用户命令 |
/sbin |
系统管理命令 |
/usr/bin |
macOS 提供的常用命令和开发工具入口 |
/usr/lib |
系统库和运行时内容 |
/usr/share |
架构无关的系统资源, 也包含 firmlink 映射表 |
这些路径来自只读 SSV snapshot, 系统更新时整体替换, 不应该作为用户软件的安装位置
/System 保存 Apple 管理的高层系统内容, 而 /bin, /sbin 和 /usr 延续了 Unix/BSD 的传统层次; 它们虽然在根目录下分成多个入口, 但当前 macOS 中都属于同一个 System snapshot
通过 firmlink 来自 Data Volume 的路径
| 对外路径 | Data Volume 中的实际路径 | 作用 |
|---|---|---|
/Applications |
/System/Volumes/Data/Applications |
用户安装和第三方应用 |
/Library |
/System/Volumes/Data/Library |
整台机器共享的可变配置, Application Support 和第三方组件 |
/Users |
/System/Volumes/Data/Users |
本地用户的 home 目录 |
/private |
/System/Volumes/Data/private |
配置, 日志, 临时文件和运行状态 |
/opt |
/System/Volumes/Data/opt |
可选的第三方软件, Apple Silicon 上的 Homebrew 通常使用 /opt/homebrew |
/usr/local |
/System/Volumes/Data/usr/local |
本地管理员安装的软件, Intel Mac 上的 Homebrew 通常使用这里 |
/Volumes |
/System/Volumes/Data/Volumes |
其他文件系统的默认挂载点 |
这里最容易混淆的是三个 Library:
/System/Library: macOS 自身资源, 位于 System Volume/Library: 整台机器共享的第三方和可变数据, 通过 firmlink 位于 Data Volume~/Library: 当前用户自己的应用数据, 位于/Users/<user>之下, 自然也属于 Data Volume
Applications 也进行了类似拆分: Apple 随系统提供的应用主要位于 /System/Applications, 用户安装的应用位于 /Applications
/private 则承担了很多不适合直接展示给普通用户的可变系统状态; 其中 /private/etc 保存本机配置, /private/var 保存日志, 数据库和运行状态, /private/tmp 保存临时文件, 根目录中的 /etc, /var, /tmp 只是它们的传统入口
/Volumes 是一个比较特殊的叠加例子: /Volumes 目录本身通过 firmlink 位于 Data Volume, 当外置磁盘接入后, 系统又把新的文件系统 mount 到 /Volumes/<name>, 因此路径解析会先经过 firmlink, 再跨过 mount point 进入外部文件系统
普通符号链接
根目录中还保留了传统 BSD 路径的符号链接:
1 | /etc -> private/etc |
解析 /etc/hosts 时, 系统首先根据普通 symbolic link 转到 /private/etc/hosts, 再通过 /private 的 firmlink 进入 Data Volume; 所以一条路径解析过程中可以同时经过 symbolic link 和 firmlink
/home 在 macOS 中也不是本地用户的默认 home 根目录, 它通常指向 Data Volume 中由 autofs 管理的位置; autofs 会在路径被访问时按规则自动挂载资源, 传统上可用于网络 home 目录, macOS 本地用户的 home 仍然是 /Users/<user>
运行时挂载的路径
有些根路径既不是 System 文件, 也不是 Data 中的普通目录, 而是在系统启动或设备接入时动态挂载:
/dev: 挂载 devfs, 由内核动态提供磁盘, 终端和伪设备等设备节点/Volumes/<name>: 挂载外置磁盘, 磁盘映像和网络文件系统/System/Volumes/VM: 挂载 APFS VM Volume/System/Volumes/Preboot: 挂载 APFS Preboot Volume/System/Volumes/Update: 系统更新期间使用的 APFS Volume
mount 展示的是这些真实的文件系统挂载关系, 而 firmlink 和 symbolic link 只影响路径解析, 因此不会各自多出一条 mount 记录
synthetic.conf
根 System Volume 只读之后, 用户无法直接在 / 下新建目录; 但某些软件和企业环境需要 /nix 或自定义 NFS mount point 之类的根路径, macOS 因此还提供了 synthetic.conf
配置文件位于 /etc/synthetic.conf, 由 apfs.util 在启动早期读取, 可以声明由内核合成的根目录项; 一列配置表示创建一个虚拟空目录, 通常用于提供 mount point, 两列配置则表示创建指向第二列路径的 symbolic link, 两列之间必须使用 tab 分隔
例如将 Data Volume 中的 foo 暴露为 /foo:
1 | # /etc/synthetic.conf |
这里的配置使用两列形式, 第一列是根目录下的名称, 第二列是 symbolic link 的目标; 重启之后, 根目录中会出现 /foo, 但这个目录项不是写入 SSV 的真实文件, 而是启动时合成的路径
synthetic link 与 firmlink 的区别是, 前者是用户可配置的根目录 symbolic link 或 mount point, 后者是 macOS 内部用于连接 System/Data Volume Group 的透明目录机制
路径解析过程
综合来看, 一个进程访问文件时, 看到的是统一的 VFS 命名空间, 内核会根据路径中遇到的对象逐层决定下一步去哪里
以三个路径为例:
1 | /System/Library/Frameworks |
这套设计让 macOS 同时获得了两种看似矛盾的性质:
- 系统文件可以整体只读, 签名并通过 snapshot 原子更新
- 应用仍然可以使用传统 Unix 根目录结构访问用户数据和可变状态
从磁盘层面看, macOS 使用的是 Container 中彼此独立的多个 APFS Volume; 从路径层面看, mount, firmlink, symbolic link, synthetic entity 和虚拟文件系统又将这些内容组合成一棵根目录树
因此理解 macOS 文件系统时, 最重要的是区分目录树中的路径与数据实际所属的 Volume; /Applications 看起来是 / 的直接子目录, 并不代表它和 /System 存储在同一个卷中