Skip to main content

C15 FI-FO, File: File System 和 VFS

kernal 的一个主要任务是处理数据, 包括用户数据和系统数据. 而最终, 数据会被组织为文件和文件夹, 而这些文件和文件夹会被存储到各种类型的文件系统中.

XNU 的 BSD 层负责实现文件系统, 实现方式是使用一个 Virtual FileSystem Switch(VFS) framework. VFS Framework 最初是从 Sun 的 Solaris 系统中实现的, 然后逐步变成来一个标准接口, 用于中 UNIX 中对接 kernal 和各种类型的文件系统实现(本地的和远程的).

开篇: 磁盘设备和分区

OSX 和 iOS 都遵循 BSD 的规范, 将 disk 作为 device nodes. 每 disk 都可作为块设备(/dev/disk#)或原始数据(raw)设备(/dev/rdisk#)被访问. 而分区(partition)在 UNIX 中也可以类似的方式被访问(/dev/[r]disk#s#).

通常情况下, disk 和 partition 都被作为块设备, 因此可使用 mount 系统调用挂载一个文件系统. 而 raw 模式通常被用在更为底层的访问上(进行 fsckpdisk 等调用, 这些调用可以直接 seek 并写磁盘块).

磁盘驱动程序提供了标准的 ioctl 接口, 在 <sys/disk.h> 中定义, 以支持若干种 query 操作. 下面列出这个头文件中的一些操作:

下面的代码苹果已开源:

// <sys/disk.h>
/*
* Definitions
*
* ioctl description
* -------------------------------- --------------------------------------------
* DKIOCEJECT eject media
* DKIOCSYNCHRONIZECACHE flush media
*
* DKIOCFORMAT format media
* DKIOCGETFORMATCAPACITIES get media's formattable capacities
*
* DKIOCGETBLOCKSIZE get media's block size
* DKIOCGETBLOCKCOUNT get media's block count
* DKIOCGETFIRMWAREPATH get media's firmware path
*
* DKIOCISFORMATTED is media formatted?
* DKIOCISWRITABLE is media writable?
*
* DKIOCGETMAXBLOCKCOUNTREAD get maximum block count for reads
* DKIOCGETMAXBLOCKCOUNTWRITE get maximum block count for writes
* DKIOCGETMAXBYTECOUNTREAD get maximum byte count for reads
* DKIOCGETMAXBYTECOUNTWRITE get maximum byte count for writes
* DKIOCGETMAXSEGMENTCOUNTREAD get maximum segment count for reads
* DKIOCGETMAXSEGMENTCOUNTWRITE get maximum segment count for writes
* DKIOCGETMAXSEGMENTBYTECOUNTREAD get maximum segment byte count for reads
* DKIOCGETMAXSEGMENTBYTECOUNTWRITE get maximum segment byte count for writes
*/

如下是使用 ioctl 获取磁盘信息的例子:

#include <sys/disk.h>   // disk ioctls are here..
#include <errno.h> // errno!
#include <stdio.h> // printf, etc..
#include <string.h> // strncpy..
#include <fcntl.h> // O_RDONLY
#include <stdlib.h> // exit(), etc..

#define BUFSIZE 1024

// Simple program to demonstrate use of DKIO* ioctls:
// Usage: ... /dev/disk1 or ... disk1

void main (int argc, char **argv)
{
uint64_t bs, bc,rc;
char fp[BUFSIZE];
char p[BUFSIZE]; // 磁盘路径

strncpy (p, argv[1], BUFSIZE); // 将磁盘路径拷贝到 p 中

// 若不是以 "/" 开头的, 则在路径开头手动附加一个 "/dev"
// 比如输入 "disk1", 则 p 为 "/dev/disk1"
if(p[0] != '/') {
snprintf(p, BUFSIZE -10, "/dev/%s", p);
}

int fd = open(p, O_RDONLY); // 只读方式打开文件

if(fd == -1) {// 打开失败的处理
fprintf(stderr, "%s: unable to open %s\n", argv[0], p);
perror ("open");
exit (1);
}

// 如果成功, bs 会存放磁盘的 block size 大小
rc = ioctl(fd, DKIOCGETBLOCKSIZE, &bs);

if (rc < 0) {// ioctl 出错的处理
fprintf (stderr, "DKIOCGETBLOCKSIZE failed\n"); exit(2);
} else {// 打印获取的 block size 大小
fprintf (stderr, "Block size:\t%d\n",bs);
}

// 获取 block count 并存放到 bc 中
rc = ioctl(fd, DKIOCGETBLOCKCOUNT, &bc);
fprintf (stderr, "Block count:\t%ld\n", bc);

// 获取磁盘上 firmware 的路径, 并存放到 fp 中(通过 fp 指向)
rc = ioctl(fd, DKIOCGETFIRMWAREPATH, &fp);

fprintf (stderr, "Fw Path:\t%s\nTotal size:\t%ldM\n", fp, (bs * bc) / (1024 * 1024));
}

需要注意, 通过 ioctl 获取磁盘数据需要"读"权限, 通常来说需要 root/root组 用户才能执行, 而在 mac 上由于有 SIP, 此时真的是不行, 官方推荐开发时关闭 SIP, 发布时使用 kext 签名后进行.

分区方案(Partition Schemes)

文件系统并不单独存在, 而是存放在磁盘分区上. 每个磁盘至少有一个分区, 而每个分区都可以单独格式化为特定的文件系统, 在另外某些情况下, 一个文件系统可以跨多个分区.

分区方案(Partition Schemes)定义了磁盘的布局, 在逻辑上将磁盘划分为一个或多个由连续 sector 组成的段区域(即分区). 并通常在磁盘的起始位置保留若干连续 sector 存放分区表. 分区表中存放了每个分区的文件系统类型以及每个分区的起始 sector 和 sector 数量(这样也就确定了每个分区的大小).

OSX 支持如下分区方案:

  1. Master Boot Record (MBR): MBR 是一种经典的分区方案, 但也有较多限制, 比如它需要依赖 BIOS 的支持, 另外最多可以有四个分区, 且每个分区长度(sector 数量)表示为 32 位(限制了分区最大大小). 但基本上所有操作系统都支持它.
  2. Apple Partition Map: 仅苹果支持的分区方案, 也是 32 位表示分区长度, 但目前已弃用.
  3. GUID Partition Table (GPT): 一种 64 位的分区方案, 几乎没有分区长度和分区个数的限制, 且是 EFI 标准的一部分.
  4. Lightweight Volume Manager (LwVM): 苹果私有的分区方案, 用于 iOS 5 以及后续版本, 虽然是私有的且没有任何文档, 但可以非常简单地就能逆向它的具体结构.

利用内核扩展可实现额外的或自定义的分区方案: 继承 IOKit 中的 IOPartitionScheme 类来实现.

还可以利用 hdiutil 来创建实验用的 dmg, 这个 dmg 可以有不同的分区方案, 并且可以格式化. 这样可以进行观察实践.

文件系统中的基本概念

不同的文件系统有完全不同的方式来管理磁盘文件, 但内核提供了文件管理的接口: Virtual FileSystem Switch (VFS), 它将不同文件系统中的概念进行了抽象统一.

文件

从文件系统的角度看, 文件是存储介质上的 block 数组, 且这个数组在存储时并非连续(可能有多个分段拼接成), 这被称为 "extent".

尽管文件的物理存储可能碎片化, 但文件系统仍会将文件对外表示为一个连续的可随机访问区域. 外部使用者不需要知道文件的内部实现, 通过 open(获取文件描述符) 或 fopen(获取文件字节流表示 FILE*)即可获取文件的透明访问句柄, 而内核在处理文件请求时将句柄转换为文件系统中的标识符进行实际访问.

扩展属性(Extended Attributes)

在 mac 系统中, 除了普通的 UNIX 文件属性, XNU 的 VFS 还支持文件的扩展属性.

这些扩展属性是用户或系统自定义的, 用于对文件追加其他应用程序或系统本身所需的额外信息, 以在 Darwin 中提供一些高级功能的支持, 比如透明压缩, fork, 或访问控制表.

权限

由于某些文件可能包含敏感信息, 大部分文件系统(FAT 家族除外)都支持文件权限.

UNIX 的各种文件系统, 比如 Mac 的原生 HFS+, 支持经典的 user/group/other read/write/execute 权限管理模型.

在 OSX 10.4 版本后, VFS 支持更为精细的权限管理(类似 NTFS 的权限管理), 而且仍然遵循 POSIX 1.e 安全标准, 这种权限管理又被称为 Access Control Lists(访问控制表), 或 ACLs.

OSX 可以通过 chmod 命令设置和修改 ACLs, 可以通过 ls -e 查看文件是否有 ACLs, 输出中带 + 号的就是有 ACLs 的文件. 而 VFS 使用扩展属性来支持 ACLs, ACLs 的权限验证通过一个独立的模块进行: KAUTH(bsd/kern/kern_authorization.c)

ACLs 的基础讲解: https://www.thegeekdiary.com/unix-linux-access-control-lists-acls-basics/

可以通过 ACLs 精确控制每个用户对文件的访问权限, 而非粗放地使用所有者所属组和其他人.

时间戳

文件系统需要为文件记录时间戳, UNIX 系统中文件相关的时间戳有三个: 创建时间, 修改时间, 最后访问时间.

可以使用 touch -a 修改最后访问时间, 通过 touch -m 修改最后修改时间, 通过 touch -c 修改创建时间.

可以使用 ls -u 展示最后访问时间, 通过 ls -U 展示创建时间, 如果不带参数, ls 默认展示最后修改时间.

硬链接和软链接

绝大部分 UNIX 系统都有硬链接和软链接(symbolic)的支持. 软链接可以通过 ln -s 创建, 创建硬链接则不带 -s 参数. 从 VFS 的角度看, 软链接是一个单独的文件(拥有 l 类型), 而硬链接只是目录中的一个额外入口(指向被链接的文件, 它们表示为同一个 inode).

另外一个角度看, 硬链接只存在于目录的层级, 而软链接存在于文件的层级.

硬链接和软链接都提供了被链接文件的另外一个访问入口, 但不同于软链接, 硬链接可以避免文件被意外删除, 因为硬链接的存在会增加原来文件的链接数(文件只有在链接数为 0 的情况下才会被真正删除).

关于软链接和硬链接的完整讨论, 可以在 man symlink 中找到.

苹果生态中的文件系统

OSX 和 iOS 均支持若干种文件系统, 并且任意多种类型的文件系统都可以被同时挂载到系统中, 只要这些文件系统遵循内核 VFS 的标准, 就可以被正常使用.

可以通过 mount_xxx 族的命令将文件系统挂载到系统, 实际文件系统的支持是通过内核扩展提供的(通常是 xxxfs.kext, 位于 /System/Library/Extensions 中, 另外还有 /System/Library/Filesystems 目录中的 util 二进制文件用于对文件系统提供维护支持).

苹果原生支持的文件系统

HFS(Hierarchical File System)

HFS 曾经是 OSX 之前苹果一直在用的文件系统, 它目前已经过时了, 过渡到了 HFS+.

HFS+(Hierarchical File System Plus)

随着磁盘容量的成倍增长, HFS 的诸多限制导致必须开发一个新的文件系统来解决这些限制造成的问题, 因此苹果开发了 HFS+ 文件系统, 用于 macOS 10.12 及之前的系统.

Apple File System(APFS)

摘自官方文档.

在 macOS 10.13 及之后的系统中, 默认使用的是 APFS 文件系统. 它提供了强加密, 空间共享, 快照, 快速目录尺寸计算等高级功能, 并优化了文件系统的基础性能. APFS 专门对 SSD 等闪存设备进行了优化, 当然它也可以用在老式的机械硬盘上. macOS 10.13 及之后支持可引导的或仅数据的 APFS 卷.

APFS 按需将磁盘空间分配到一个容器(container, 可以看做是分区). 当单一的 APFS 容器拥有多个卷的时候, 容器的空闲空间可以在多个卷之间共享, 并且可以按需自动分配给任意单独的卷. 如果有需要, 用户也可以指定每个卷的保留/配额大小. 每个卷都只使用容器的一部分, 因此空闲空间就是容器的总大小减去所有卷的大小(卷的大小是根据实际存储情况动态增减的).

在 macOS 10.13 及之后, 可以选择如下 APFS 格式:

  1. APFS: 若不需要对卷加密或不需要文件/目录名大小写敏感.
  2. APFS (Encrypted): 对卷进行加密.
  3. APFS (Case-sensitive): 文件或目录的名称是大小写敏感的, 即 fileFile 是不同的文件.
  4. APFS (Case-sensitive, Encrypted): 文件或目录名是大小写敏感的, 且对卷进行加密.

用户可以非常轻松地在 APFS 容器中添加/移除卷, 且容器中的每个卷都可以有它自己的 APFS 格式(上述四种).

如果使用 APFS, 通常磁盘的结构如下(只有一个容器的情况, 也可以理解为只有一个分区), 可以看到容器中一共有 5 个卷, 每个卷的空间都是动态增长的, 空闲空间被所有卷共享:

apfs

Windows 文件系统

这里仅介绍现代的, 老的 FAT 等不再赘述.

NT File System (NTFS)

Windows NT 是微软的第一个款多用户操作系统, 由于 FAT 是 16 位的文件系统, 不支持权限和配额, 已经不适应 NT 系统的需求了. 文件的访问控制需要权限的支持, 配置则用于避免多用户环境下某个用户对文件系统空间的滥用或乱放太多文件(因为文件系统空间是多用户共享的).

因此, 微软开发了 NTFS, 从 Windows 2000 开始作为默认的文件系统.

苹果提供了一个 NTFS 文件系统驱动: ntfs.kext, 但它只支持读 NTFS 文件系统. 不过有许多商用/免费的 NTFS 驱动, 在 macOS 上提供 NTFS 的读写能力.

基于网络的文件系统(Network-Based File Systems)

网络文件系统可以将远程的存储空间用来扩展本地主机的存储. OSX 提供了一个 NetFS 框架以支持网络文件系统的实现.

Apple Filing Protocol(AFP)

苹果的 AFP 是 macOS 8 和 9 的默认网络文件系统, 也称为 AppleShare. 它实际是一个应用协议, 当前使用 TCP 的 427 和 428 端口. AFP URL 的访问形式是 afp://. 使用 mount 和 df 命令后, AFP 文件系统在输出中的名字通常是 afp_xxx.

Network File System(NFS)

NFS 是一个非常古老的应用层协议, 当前最新版本是 NFSv4.1

Server Message Block (SMB/CIFS/SMB2)

SMB/CIFS/SMB2 是微软的网络文件系统实现, 基于 Server Message Block(SMB) 协议. SMB 协议依赖 NetBIOS 协议(NetBIOS 协议甚至比 DNS 协议还要早出现).

而后, 微软将 SMB 重命名为 CIFS(Common Internet File System, 野心比较大, 显然不是给 Internet 用的, 但这个缩写比较诱惑人). CIFS 和 SMB 差异较小, 仅是 CIFS 原生运行在 TCP 445 接口, 且不依赖 NetBIOS. 虽然 SMB 穿马甲变成 CIFS 了, 但性能还是太差, 主要原因是每个事务都牵扯大量的消息传递. 在 Vista 后, SMB 又变回了本来的名字, 并且经过一定修改, 就是现在的 SMB2.

苹果系统中, 同时支持 SMB 和 CIFS, 提供支持的内核扩展是 smbfs.kext, 它处理所有 SMB 客户端的请求. 在 Lion 之前, 苹果依赖 SAMBA 开源库实现 SMB 支持, 但由于 GPLv3 协议的问题, 在 Lion 之后苹果自己实现了一个 SMBX 以支持 SMB, 另外 usr/sbin/smbd 也完全重写了.

File Transfer Protocol(FTP)

FTP 协议是因特网的最古老协议, OSX 仍然提供了 FTP 的支持.

Web Distributed Authoring and Versioning (WebDAV)

WebDAV 是 HTTP 协议的扩展, 在 HTTP 基础上添加了许多文件相关的方法, 比如:

  1. 上传文件(PUT)
  2. 创建目录(MKCOL)
  3. 搜索(PROPFIND)

虽然它因为安全问题存在争议, 但逐渐流行在云计算基础设施中. 经过 RFC4918 的小修改, 它被应用在了若干基于网络的文件系统中, 比如微软的 Web Folders, 亚马逊的 S3 服务等.

伪(Pseudo)文件系统

伪文件系统不是文件系统...

有两种类型的伪文件系统:

  1. 为内核数据结构和设备提供接口的文件: 比如 Linux 的 /proc/sys, 可以提供大量的诊断数据和内核参数信息. 又如 UNIX 的 /dev, 内核通过它里面的文件暴露设备驱动.
  2. 文件系统组件: 它们提供处理特殊文件类型或特殊挂载选项的能力, BSD/XNU 的 deadfs, specfs, FIFOfs, unionfs 等就是这类.

XNU 提供了若干伪文件系统支持, 它们都在 bsd/miscfs 中, 下面来分别讨论.

devfs(重要概念)

devfs 是 device file system 的简写, 用于托管各种 BSD 设备文件(字符设备和块设备). 这些文件在用户模式下用于表示硬件设备, 实用工具可以通过这些文件去访问硬件(主要是磁盘, 如 /dev/disk##/dev/rdisk##. 另外还有终端, 如 /dev/tty##).

devfs 也是 fdesc file system 的主目录, fdesc 文件系统使得进程可以通过 dev/fd/## 来访问它们自己的文件描述符(具体可以参考 mount_fdesc 命令).

通常情况下, 内核会自动创建 device node(对 plug-and-play 事件作出响应), 但用户也可以使用 mknod 命令或对应的 mknod 系统调用创建设备节点(device node).

块设备和字符设备分别通过 bdevswcdevsw 结构来表示, 这两个结构的定义在 bsd/sys/conf.h 中, 另外 devfs 提供了如下四个函数:

  1. devfs_make_node: 用于创建一个设备节点(device node), 类型可以是 DEVFS_CHARDEVFS_BLOCK.

    这个函数返回一个不透明句柄, 使用者必须持有它直至设备被移除.

  2. devfs_make_node_clone: 和 devfs_make_node 函数类似, 但提供"克隆"的功能, 用于在创建设备节点的时候更新设备的次要设备(device minor).

  3. devfs_remove: 移除一个之前已创建的设备. 移除时, 传入创建时获取到的不透明句柄.

  4. devfs_make_link: 链接到之前创建的设备, 这个函数是 KERNEL_PRIVATE, 在 XNU 中未使用.

The FIFOfs vnode Type

FIFO 是 UNIX 中的"命名管道"实现(匿名管道可以通过 pipe 系统调用创建, 但匿名管道无法在多个不关联的进程间共享), FIFO 可以通过 mkfifo 系统调用创建, 调用后, 会创建一个特殊的 pipe 文件, 这个文件的作用仅是为了保证 FIFO 的全局唯一性: 保证不关联的进程可以通过不会冲突的名称访问系统全局可见的命名管道.

而 FIFOfs 的实现就是一系列简单的 vnode 操作(在 bsd/miscfs/fifofs/fifo_ vnops.c) 中可以找到).

这些 vnode 操作都是回调, 且在 VFS 上有默认实现, 当在文件上执行对应的系统调用时, 内核会调用这些 vnode 回调.

FIFOfs 的 vnode 操作重写了默认的 VFS vnode 操作(无效化一些默认操作, 以及使另外一些操作无效化, 另外提供所需操作的实现), 这些操作在 bsd/miscfs/fifofs/fifo.h 中声明. 针对 FIFO 文件的系统调用经过内核后, 就会转而调用在 FIFOfs 中实现的操作, 而非 VFS 上的.

The specfs vnode Type

类似 FIFO, 设备特殊文件(VBLKVCHR) 也有自己的特性, 以及通过 specfs 提供的 vnode 操作重写, 仍然是将 VFS 默认操作进行无效化或重新实现, 大部分操作的声明可以在 bsd/miscfs/specfs/specdev.h 找到.

The deadfs vnode Type

deadfs 主要用在 revoke 系统调用的实现中. 它只能在设备文件上调用, 用于使指定设备文件上的所有已打开文件句柄无效. 为了达到这个目的, 内核将对应的 vnode 的操作映射到 dead_vnodeop_entries(在 bsd/miscfs/deadfs/dead_vnops.c中定义), 因此在这个设备 vnode 上后续的 read/write 等操作都会失败.

revoke 主要用在当初始化一个终端用于登录的过程中. 由于绝大部分终端都是伪终端, 它们会被频繁创建和释放, 而系统必须保证新的终端实例不会有之前的拥有者.

The unionfs Layering Mechanism

unionfs 是一种特殊的分层机制: 使用它可以在同一挂载点挂载多个文件系统, 并将这些文件系统层叠起来, 这样多个文件系统中的文件都可以对外可见. 若出现同名文件, 上层文件系统中的文件将会盖住下层的. 任意文件系统都可以被联合挂载(union-mounted), 只需要在 mount 时指定 -o 参数.

unionfs 在苹果, Linux, BSD 中都可用.