记录一下 macOS 如何利用 APFS Volume Group, Signed System Volume 和 firmlink 组织出最终看到的文件系统

从目录树来看, macOS 与其他 Unix 系统一样, 所有路径都从 / 开始; 但目录树中的上下级关系只描述如何找到一个文件, 并不保证这些文件真的存储在同一个文件系统中

macOS 中的 / 并不对应一个完整的可写根分区, 而是由 APFS 中的一组卷共同组成的统一命名空间; /System 等系统目录来自只读卷, /Applications/Users 等可写目录则来自另一个数据卷, 其中最关键的连接机制就是 APFS Volume Group + firmlink

从磁盘到 APFS Volume

从物理结构来看, macOS 的启动磁盘可以抽象为下面几层:

1
2
3
4
5
6
7
8
9
物理磁盘
└── GPT 分区
└── APFS Container
├── System Volume
├── Data Volume
├── Preboot Volume
├── Recovery Volume
├── VM Volume
└── Update Volume

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

一次正常启动大致会经历下面的文件系统组装过程:

  1. 启动程序从 Preboot Volume 中找到所选 System Volume 对应的启动数据
  2. 启动链验证 System Volume snapshot 的 seal
  3. 通过验证的 snapshot 以只读方式挂载到 /
  4. 系统根据 Volume Group 关系找到配对的 Data Volume, 并挂载到 /System/Volumes/Data
  5. APFS 根据 firmlink 映射把 Data 中的可写目录接入根目录树
  6. devfs, VM Volume, 外置磁盘等其他文件系统再挂载到各自的位置

所以用户最终看到的 / 是启动过程组装出的全局命名空间, 而不是磁盘上某一个卷的原始目录树

System Volume 与 Data Volume

macOS Catalina 开始将启动盘拆分为 System 和 Data 两个卷, 目的是将不会变化的操作系统内容与需要写入的数据隔离开

System Volume

System Volume 中主要包含:

1
2
3
4
/System
/bin
/sbin
/usr

这里的 /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
2
3
4
5
6
7
8
9
/System/Volumes/Data
├── Applications
├── Library
├── Users
├── Volumes
├── opt
├── private
└── usr
└── local

然而平时并不会使用 /System/Volumes/Data/Users 访问用户目录, 而是仍然使用熟悉的 /Users; 这两个路径看起来处于根目录的不同位置, 实际却指向 Data Volume 中的同一个目录, 将它们拼接起来的就是 firmlink

可以用 df 直观观察路径最后落在哪个 Volume:

1
df -h /System /Users /Applications

/System 会落在只读 System snapshot 对应的设备上, /Users/Applications 则会落在 Data Volume 对应的设备上

firmlink 可以理解为 macOS 为 System Volume 和 Data Volume 设计的跨卷目录映射, 它由 APFS 和内核的路径解析逻辑处理, 将只读 System 命名空间中的某个目录无缝接到可写 Data Volume

启动时, 系统已经知道 System 与 Data 属于同一个 Volume Group, 因此 firmlink 只需要记录两边目录的对应关系; 当路径解析到 firmlink 边界时, 内核会在配对 Data Volume 中继续查找余下的路径分量, 程序本身不需要知道切换发生过

例如下面这两个路径访问的是同一个目录:

1
2
/Applications
/System/Volumes/Data/Applications

/Applications 在外观上仍然是普通目录:

1
2
ls -ld /Applications
# drwxrwxr-x ... /Applications

它不会像符号链接一样显示 -> 和目标路径, 也没有一段可供应用读取的目标路径字符串; 程序进入 /Applications 后再访问 .., 得到的仍然是 /, 而不是暴露实现细节的 /System/Volumes/Data

也就是说, firmlink 不是简单地进行字符串路径替换, 而是在 VFS 和 APFS 处理目录遍历时切换到卷组中对应的 Data 目录, 同时维持统一的根目录语义; 从普通应用的角度来看, System 和 Data 仍然是一棵连续的目录树

系统使用的主要映射记录在:

1
/usr/share/firmlinks

可以直接查看其中的内容:

1
cat /usr/share/firmlinks

其中比较重要的条目类似于:

1
2
3
4
5
6
7
/Applications        Applications
/Library Library
/Users Users
/Volumes Volumes
/opt opt
/private private
/usr/local usr/local

左侧是统一根目录中呈现给程序的路径, 右侧是相对于 Data Volume 根目录的路径, 所以 /usr/local 最终对应的是 /System/Volumes/Data/usr/local

映射不只能够出现在根目录的第一层, 实际清单中还存在 /System/Library/Caches, /usr/libexec/cups 等更深的路径; 这意味着一个只读目录的部分子树也可以切换到 Data Volume, 而不需要把整个父目录都变成可写

还可以用 stat 观察映射前后的目录:

1
2
3
stat -f '%N inode=%i' \
/Applications \
/System/Volumes/Data/Applications

在当前系统中, 两个路径会得到相同的 inode, 说明它们不是两份同步的数据, 而是同一个目录的两种入口

如果在 /Applications 中创建文件, 数据会直接写入 Data Volume 的 Applications 目录; firmlink 不负责复制和同步文件, 因为映射前后本来就是同一份目录内容

/usr/share/firmlinks 是系统卷中的实现清单, 只适合观察, 不应该修改; 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 之间统一的卷组关系, 因此这些现有机制都不能完全满足拆分后的透明性要求

所以它不是更硬的 hard link, 而是苹果为了拆根目录专门加的一层魔法

根目录是如何组成的

有了前面的结构之后, 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

对外路径 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
2
3
/etc -> private/etc
/tmp -> private/tmp
/var -> private/var

解析 /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
2
# /etc/synthetic.conf
foo System/Volumes/Data/foo

这里的配置使用两列形式, 第一列是根目录下的名称, 第二列是 symbolic link 的目标; 重启之后, 根目录中会出现 /foo, 但这个目录项不是写入 SSV 的真实文件, 而是启动时合成的路径

synthetic link 与 firmlink 的区别是, 前者是用户可配置的根目录 symbolic link 或 mount point, 后者是 macOS 内部用于连接 System/Data Volume Group 的透明目录机制

路径解析过程

综合来看, 一个进程访问文件时, 看到的是统一的 VFS 命名空间, 内核会根据路径中遇到的对象逐层决定下一步去哪里

以三个路径为例:

1
2
3
4
5
6
7
8
9
10
11
/System/Library/Frameworks
└── 始终在只读 System snapshot 中解析

/Users/renxiao/Documents
└── /Users 命中 firmlink
└── 进入 Data Volume 的 Users/renxiao/Documents

/var/log
└── /var 是指向 private/var 的 symbolic link
└── /private 命中 firmlink
└── 进入 Data Volume 的 private/var/log

这套设计让 macOS 同时获得了两种看似矛盾的性质:

  • 系统文件可以整体只读, 签名并通过 snapshot 原子更新
  • 应用仍然可以使用传统 Unix 根目录结构访问用户数据和可变状态

从磁盘层面看, macOS 使用的是 Container 中彼此独立的多个 APFS Volume; 从路径层面看, mount, firmlink, symbolic link, synthetic entity 和虚拟文件系统又将这些内容组合成一棵根目录树

因此理解 macOS 文件系统时, 最重要的是区分目录树中的路径数据实际所属的 Volume; /Applications 看起来是 / 的直接子目录, 并不代表它和 /System 存储在同一个卷中

参考


© 2024 本网站由 Ywang22 使用 Stellar主题 创建
总访问 次 | 本页访问
共发表 83 篇 Blog(s) · 总计 197.4k 字