跳到正文

文件系统与 VFS

环境:Windows 11 x64(build 26200)、GCC 13.1.0(MinGW-w64,D:/Qt/Tools/mingw1310_64/bin/gcc.exe),命令行 gcc -std=gnu11 -O2 -static p_fs.c -o p_fs.exe。探针全程只在 %TEMP%\os_fs_dir\ 里建文件、跑完删掉,单次运行 14.4 / 14.9 秒(QPC 频率 10000000 Hz),同一份 exe 跑两次记作运行 A / 运行 B。观测面全是公开 Win32 接口:GetVolumeInformationW/GetDiskFreeSpaceW、GetFileInformationByHandle(Ex)、CreateHardLinkA、DeviceIoControl(FSCTL_QUERY_ALLOCATED_RANGES / _SET_SPARSE / _SET|GET_REPARSE_POINT)、GetFinalPathNameByHandleW、QueryDosDeviceW、FindFirstFileExA。本机没有管理员令牌(TokenElevation=0),所以 \\.\C: 打不开、USN 日志读不到。ext4 / FAT32 / exFAT / tmpfs / ReFS 未在本机实测:这台机器上只有 NTFS 卷(C:/D:/E:/F:)和一个只读 CDFS(U:),也没有 WSL/Linux/容器可以挂别的文件系统。日志结构与崩溃一致性没做破坏性实验(不关机、不拔盘、不改卷设置),只量到”哪一层负责持久性”。

一、卷与分配单位:簇是 4096 B,但用户态看不见它的舍入

  C: 文件系统=NTFS 卷序列号=0x4100565671 名字长度上限=255 卷标志=0x3e72eff
  每簇扇区数=8 扇区字节数=512 → 簇大小=4096 B;总簇=134281983 空闲簇=41522492 → 容量=512.2 GB 空闲=158.4 GB
  标志位:硬链接=1 流(ADS)=1 稀疏=1 压缩=1 USN日志=1 事务=1 完整性流=0 去重=0
  U: 文件系统=CDFS 卷标志=0x1080005 每簇扇区=1×2048 B=2048 B 硬链接=0 稀疏=0
  往 U: 建文件:失败 错误码=5

GetDiskFreeSpaceW 的两个参数很容易读反(我第一次就读了反):第一个是”每簇扇区数”、第二个才是”扇区字节数”,所以 C: 的分配单位是 8×512 = 4096 B,而 U: 那张 CDFS 是 1×2048 = 2048 B。同一个”最小分配单位”在不同文件系统里能差一倍,这就是”拷到 U 盘上 1 万个 1 KB 文件比在 NTFS 上多占两倍空间”的根源。U: 建文件返回 5(拒绝访问)——CDFS 是只读文件系统,这是本机唯一能实测的第二种文件系统。

“1 字节的文件占多少”这个问题,用户态的三个接口都不给你簇级别的答案:

  写        1 B -> 逻辑=1 B 已分配区间数=1 查询返回=1  [偏移 0 长 1 B]
  写     4096 B -> 逻辑=4096 B 已分配区间数=1 查询返回=1  [偏移 0 长 4096 B]
  写     4097 B -> 逻辑=4097 B 已分配区间数=1 查询返回=1  [偏移 0 长 4097 B]
  写  1048576 B -> 逻辑=1048576 B 已分配区间数=1 查询返回=1  [偏移 0 长 1048576 B]
  建 1 个 1 字节文件前后:空闲簇 41522491 → 41522491(变化 +0 簇 = +0 B)

FSCTL_QUERY_ALLOCATED_RANGES 报的是有效数据长度,GetCompressedFileSize 对非压缩/非稀疏文件就等价于逻辑大小,GetDiskFreeSpaceW 的空闲簇是缓存的卷统计(建完 1 字节文件后一位没动)。所以”内部碎片 = 向上取整到簇”这件事在本机是算出来的(4096 的取整),不是测出来的——想看见它,只能靠稀疏文件(第六节)。ext4 的 stat -f/debugfs、FAT 的簇链、tmpfs 的页缓存占用未在本机实测。

二、NTFS 的 inode 就写在 FileId64 里:48 位记录号 + 16 位序列号

  新建 c_ino.bin:VolumeSerial=0xF469AAA7 FileId64=0x00100000001C2297(高16位=序列号 16,低48位=记录号 1843863)链接数=1
  FileIdInfo:VolumeSerialNumber=0x16F469C8F469AAA7 128位 FileId 按字节=97221C00000010000000000000000000
  小端低64位=0x00100000001C2297 高64位=0x0000000000000000(与 BY_HANDLE 的 FileId64 相同=1)

两条不同的接口给的”文件身份”完全一致:老结构 BY_HANDLE_FILE_INFORMATION.nFileIndexHigh/Low(64 位)和新接口 GetFileInformationByHandleEx(FileIdInfo) 的 128 位 ID,低 8 字节就是那个 64 位值,高 8 字节全零。卷序列号则有两种宽度:GetVolumeInformationW 和 BY_HANDLE_FILE_INFORMATION 给 32 位(0xF469AAA7),FileIdInfo.VolumeSerialNumber 给 64 位(0x16F469C8F469AAA7,低 32 位与前者一致)。顺带一条我自己的翻车现场:第一节原始输出里那个 卷序列号=0x4100565671 不是系统给的数,是探针里把格式写成 0x%lu(“0x” 前缀配十进制转换)的结果——补跑一次确认:同一个 DWORD 用 %lu 得 4100565671、用 %lX 得 F469AAA7,两者是同一个值。别把探针打印的前缀当真理,这是本节唯一一个我自己造出来的假数字。

硬链接(运行 A,与运行 B 逐字一致):

  CreateHardLinkA 成功;同一句柄再查:链接数=2 FileId64=0x00100000001C2297(与之前相同=1)
  经第二名字写入 "SECOND!" 后,原句柄读回="SECOND!"(数据是同一份)
  DeleteFileA(第一个名字) 后:链接数=1 大小=8 经句柄读回="SECOND!" 错误码=0
  最后一个名字也删掉:按路径重开=失败 错误码=2;旧句柄还能读=1

四行就是 inode 模型的全部内容:名字是目录里的一个项,文件是那条记录;名字可以有几个,记录只有一条;加链不改 ID、不改数据、只把计数器 +1;删名字只减计数器;计数器归零才释放——但”归零”是相对于”路径”而言,还开着的句柄仍能读写(旧句柄还能读=1),这就是 NTFS 也支持 POSIX 的 unlink-while-open 语义(前提是用 FILE_SHARE_DELETE 打开过)。

记录号会被复用,复用时的防歧义靠序列号(运行 A 的 12 轮建→删):

    i= 0 FileId64=0x00110000001C2297 记录号=1843863 序列号=17  跳跃
    i= 1 FileId64=0x00120000001C2297 记录号=1843863 序列号=18  记录号复用
    ...
    i=11 FileId64=0x001C0000001C2297 记录号=1843863 序列号=28  记录号复用
  小结:12 轮里 新建记录=0 复用同一记录=11(其中序列号递增=11)

运行 B 是记录 1843866、序列号 19→30,同样 11 次复用。同一条 MFT 记录被反复使用,序列号每次 +1——所以 (记录号, 序列号) 才是文件在一台机器历史上的唯一身份。备份/索引/USN 工具成对存这两个数,就是为了”你手里那个旧 ID 不能指到新建的文件上”。Linux 侧 stat 的 st_ino + st_dev 是同一层含义,但本机没测过 ext4 的 inode 复用。

三、目录项:批量枚举与逐个按名打开差两个数量级

在 %TEMP% 下建一个目录,放 5000 个 2 字节文件(运行 A / 运行 B):

动作运行 A运行 B说明
建 5000 个小文件797.6 微秒/个679.4 微秒/个含目录项 + MFT 记录 + 数据
删 5000 个小文件662.7 微秒/个665.9 微秒/个同样是元数据操作
FindFirstFile+FindNextFile 枚举全部1.45 微秒/项1.40 微秒/项三次分别 10.3/7.3/6.0 与 7.0/7.4/6.8 ms
按完整名 CreateFile+Close460.8 微秒/次356.1 微秒/次在这个 5000 项的目录里
同样打开,但目录里只有 1 个文件(5 层深目录下)60.6 微秒/次84.4 微秒/次路径更长,反而快 4–7 倍
名字加 \\?\ 前缀646.0(1.40 倍)343.7(0.97 倍)同一份 exe 更早两次是 0.79 倍和 1.26 倍

三件事值得单独说:

  • 枚举比按名打开便宜 200–500 倍。FindNextFile 是把目录里的项成批交给你(一次系统调用返回多项),CreateFile 则是”解析整条路径 + 在目录里查这一项 + 打开文件对象 + 建句柄”。要做”扫一遍目录”的事,千万别写成 for(name) stat(name);反过来,只做一次精确查找也别去枚举全目录。
  • 深路径不是成本,目录里的项数才是:那 60–84 微秒的一组要解析 5 级目录,却比只有 1 级目录、5000 项的那组快 4–7 倍。所以”把一个目录塞几万个文件会变慢”是真的,“路径写深一点会慢”在打开这一步几乎不构成成本。
  • \\?\ 前缀(跳过 Win32 路径规范化)的收益测不出来:同一份代码四次运行给出 1.40x / 0.97x / 0.79x / 1.26x,方向都不一致。这条网上流传的说法在本机没有得到证据,别当结论用。

8.3 短名(FAT 时代的兼容层)还活着,而且是每次建文件都要多维护一份:

  长名=e_longfilename_for_83_test.txt
  cAlternateFileName="E_LONG~1.TXT" (空=8.3 名没生成) 属性=0x20

两次运行都拿到 E_LONG~1.TXT。这个 WIN32_FIND_DATA.cAlternateFileName 字段就是”目录项里除了长名还存了一个 DOS 名”的证据——也是为什么老工具(某些安装程序、16 位程序)还能看见短名。ext4/FAT32 下同名行为未在本机实测。

四、同一个文件的三种名字,和”挂载”这一层

GetFinalPathNameByHandleW 对同一个句柄问三种命名空间,得到三条完全不同的字符串(两次运行逐字一致):

  VOLUME_NAME_DOS (长度56)=\\?\C:\Users\wangt\AppData\Local\Temp\os_fs_dir\j_ns.bin
  VOLUME_NAME_GUID(长度98)=\\?\Volume{c64df068-3f03-4a11-a6d1-b67f261c5866}\Users\wangt\AppData\Local\Temp\os_fs_dir\j_ns.bin
  VOLUME_NAME_NT  (长度73)=\Device\HarddiskVolume3\Users\wangt\AppData\Local\Temp\os_fs_dir\j_ns.bin
  QueryDosDeviceW("C:")=\Device\HarddiskVolume3

这三行就是 Windows 的 VFS 分层:盘符(DOS 设备命名空间,每会话一张符号链接表)→ 卷 GUID(跟挂载走,不跟盘符走)→ \Device\HarddiskVolume3(对象管理器里的设备对象)。C: 不是”磁盘”,它是”指向某个设备对象的一次映射”,同一份数据换个盘符名字不变、换成 GUID 名字也不变。写服务/备份/卷影副本的代码要用 GUID 那条路,因为盘符会被用户改动、会被别的会话占用。这对应 Linux 的 mount//proc/mounts/mount namespace 那一层,但本机没有 Linux,未实测。

重解析点(junction / 符号链接)实测:

  CreateSymbolicLinkA(文件符号链接) 返回=0 1314(客户端没有所需的特权。)
  CreateSymbolicLinkA(目录符号链接) 返回=0 1314(客户端没有所需的特权。)
  以 FILE_FLAG_OPEN_REPARSE_POINT 建联接目录:成功 句柄=00000000000000e0 0(操作成功完成。)
  FSCTL_SET_REPARSE_POINT:失败 错误=4392(在重分析点缓冲区中的数据无效。)
  改用系统自带 mklink /J(输出丢弃,只看返回码):
  system() 返回=0(低 8 位=0 表示 mklink 成功)
  联接路径属性=0x410(目录 REPARSE_POINT )
  经联接路径打开 inside.txt:内容="TARGET" FileId64=0x000A0000001C229A(与目标相同=1)
  它的最终路径=\\?\C:\Users\wangt\AppData\Local\Temp\os_fs_dir\k_target\inside.txt
  FSCTL_GET_REPARSE_POINT:Tag=0xa0000003 ReparseDataLength=228
        替换名="\??\C:\Users\wangt\AppData\Local\Temp\os_fs_dir\k_target" 打印名="C:\...\k_target"
  DeleteFileA(联接)=0 err=5;RemoveDirectoryA(联接)=1 err=5
  联接没了之后,目标里的文件还在=1
  • 符号链接这台机器不让建:CreateSymbolicLink 两次(文件/目录)都返回 1314。原因是令牌里没有 SeCreateSymbolicLinkPrivilege,开发者模式也没开(注册表 HKLM\...\AppModelUnlock 读不到那个值)。Linux 的 symlink() 不需要任何特权——这一条只能按语义对照,未实测。
  • 我自己的 FSCTL_SET_REPARSE_POINT 失败了(4392),原因很清楚:CreateFile(..., CREATE_ALWAYS, FILE_FLAG_OPEN_REPARSE_POINT|FILE_FLAG_BACKUP_SEMANTICS) 建出来的是普通文件(后续属性查询证实:0x20,无 FILE_ATTRIBUTE_DIRECTORY),而 mount point 必须挂在目录对象上(要走 NtCreateFile 的 FILE_DIRECTORY_FILE)。用系统自带的 mklink /J 建成后属性立刻是 0x410 = 目录 | REPARSE_POINT。这条错误值得留着:“重解析点是文件记录上的一个属性,但它必须先是个目录”。
  • 联接的语义是路径级重定向、目标级同一:经联接路径打开的文件 FileId64 与直接打开目标完全相同,GetFinalPathNameByHandle 返回的是目标的真名;FSCTL_GET_REPARSE_POINT 读回的替换名以 \??\ 开头(对象命名空间的前缀),这正是”内核把路径交给文件系统解析、文件系统把解析不动的部分交回 I/O 管理器”的接口。
  • 删联接要用 RemoveDirectory:DeleteFileA 报 5。删掉联接后目标里的文件仍在(还在=1)——联接不是引用计数,和硬链接的本质区别就在这里。

五、写路径的三层:页缓存 / 直写 / 无缓冲

同一个文件、同样 512 次 4 KB 写、写到随机位置(每档 3 次取中位):

打开方式运行 A 微秒/次运行 B 微秒/次吞吐 A/B随后的 FlushFileBuffers
缓冲写(默认)9.711.0404 / 355 MB/s1.1 / 1.4 ms
FILE_FLAG_WRITE_THROUGH47.284.783 / 46 MB/s0.7 / 0.5 ms
FILE_FLAG_NO_BUFFERING31.234.1125 / 115 MB/s0.8 / 0.7 ms
顺序写(缓冲,对照)9.624.8——

更早两次运行(F/G 段代码相同)给的是 缓冲 13.4 / 13.6、直写 112.4 / 87.0、无缓冲 41.3 / 47.8 微秒/次——四次的绝对值差 2–4 倍,但”缓冲 < 无缓冲 < 直写”的次序一次都没变。

  • 默认写只付”把页弄脏”的钱:9.7–13.6 微秒/4 KB(≈300–400 MB/s)。CloseHandle 只用 0.1–0.4 毫秒,FlushFileBuffers 平均每次 1.1–2.3 毫秒——数据在 WriteFile 返回时只是交给了系统缓存,不在盘上。这就是”进程崩了数据还在,机器断电数据没了”的分界线。
  • WRITE_THROUGH 是缓冲写的 4.9–8.4 倍(47.2 / 84.7 / 87.0 / 112.4 微秒/次):每一次写都要等设备确认。
  • NO_BUFFERING(31.2–48.0 微秒)介于两者之间:绕过页缓存、直接下设备,省掉”从用户缓冲区拷进缓存页”那一趟,但每次仍是真 I/O(要求偏移/长度/缓冲区都按扇区对齐,本机 4 KB 对齐正好满足)。
  • 顺序写不比随机写快(9.6 vs 9.7、24.8 vs 11.0、更早两次 15.7 vs 13.4 / 22.1 vs 13.6)。原因不是文件系统聪明,而是这一层的代价根本不在设备队列上——4 KB 都落在页缓存里,把 512 次写合并、排序、下盘是稍后写回线程的事。想看到”顺序远快于随机”必须把 FlushFileBuffers/直写算进来,那正是上面 WRITE_THROUGH 那一行。

崩溃一致性(未做破坏性实验:不关机、不拔盘、不改卷/页文件设置):

  写 1 MB:0.42 ms;随后 FlushFileBuffers:1.49 ms 返回=1
  文件大小=1048576 B;另开只读句柄读首字节:读到 0x7E(=1 字节)
  打开卷 \\.\C::失败
    5(拒绝访问。)

0x7E 是刚写进去的值:写完不 flush,另一个句柄立刻能读到——一致性由页缓存这一个真相源提供,不需要”两个视图同步”。而”崩溃后文件系统自己怎么恢复”依赖 NTFS 的日志($MFT 事务日志),本机连读日志的口子都被挡住了(\\.\C: → 错误码 5),卷标志里那个”事务=1”也就只能当元信息看。日志结构、检查点、崩溃后的重做/撤销,本节全部未实测;ext4 的 journal 模式(data=ordered/writeback)、FAT 没有日志、tmpfs 全在内存——都只能按语义对照。

六、稀疏文件:分配是”区间”,不是”从 0 到文件大小”

对同一个文件在 0 / 4 MB / 64 MB 三个位置各写 4096 B(运行 A、B 输出一致):

  FSCTL_SET_SPARSE 返回=1 0(操作成功完成。)
  逻辑大小=67112960 B(末字节偏移 67112960)实际分配=131072 B
  FSCTL_QUERY_ALLOCATED_RANGES 返回=1 错误=0(操作成功完成。) 已分配区间数=3
    区间0:偏移=0 长度=65536
    区间1:偏移=4194304 长度=65536
    区间2:偏移=67108864 长度=4096
  SetFileValidData(把有效数据长度拉到整文件) 失败 1314(客户端没有所需的特权。)
  读洞(偏移 32MB):返回=1 读到=4 字节 前 4 字节=00 00 00 00(洞读出来是全零)

四个可以直接用的结论:

  1. 67 MB 的逻辑只花 131072 B(512 倍)。数据库/虚拟机镜像/日志预分配的空间全靠这个” extent + 洞”的表示。第一节看不到簇舍入,这里看到了:写 4096 B 却占 65536 B = 16 个簇,因为 NTFS 对稀疏运行按 64 KB 起批——“最小分配单位”在不同条件下不是一个数,这正是第一节那个”用户态看不见簇”的反证。
  2. FSCTL_QUERY_ALLOCATED_RANGES 交回的就是 extent 表:三段、各自的偏移与长度,和写入位置一一对应。
  3. 洞读出来是全零,不是”上次删掉的数据”——文件系统知道哪些簇从未分配,直接给零页,不读盘。
  4. SetFileValidData 需要 SeManageVolumePrivilege(1314)。这个接口的作用是”告诉文件系统:这之后的字节都算有效数据,别再当洞”,因为它会让别人的旧数据在你文件里可见,所以微软把它锁在特权后面。这条是本节看到的最直接的”文件系统权限边界”证据。

备用数据流(NTFS 特有的 inode 结构表现):

  主流大小=9 B,"i_ads.bin:my-stream" 大小=22 B 内容="stream-body-123456789"
  删掉主文件名后再开那条流:失败 错误码=2

一个文件名后面挂着的不是一份数据,而是一条记录,记录上可以有多个命名数据流($DATA 属性):主流 9 B、具名流 22 B,各自独立读写;但流不是可独立寻址的对象——主名字一删,整条记录连同所有流一起没了(按流名重开报 2,文件找不到)。Mac 的资源分叉、Zone.Identifier(“从网上下载”标记)、某些杀软的处理都走这一层。ext4/FAT 上未实测(它们没有多数据流,等价实现只能靠”另一个文件名”)。

七、文件句柄是内核对象,不是整数

  打开 2000 个文件句柄后:GetProcessHandleCount=2091
  关闭全部句柄:52.04 ms → 26019 纳秒/个;句柄计数 2091 → 91
  Create(CREATE_ALWAYS)+Close 一圈 1000 次:中位 877.4 ms → 877.40 微秒/次

运行 B:2091 → 91、关闭 30448 纳秒/个、Create+Close 一圈 997.1 微秒/次。

基线 91 个句柄是本探针进程一开始就有的(对照《线程与进程模型》里同一台机器那个空 C++ 进程的 84 个,量级一致、进程不同):2000 个打开的文件让计数从 91 涨到 2091,说明一个文件句柄 = 进程句柄表里的一项 + 内核侧一个文件对象,关掉只是拆表项(26–30 微秒/个,绝对值受同期负载影响,别当常数)。而 Create(CREATE_ALWAYS)+Close 一圈 877–997 微秒/次,落在一个有 2000 项的目录里——和第三节”按名打开 356–461 微秒”是同一件事的两个刻度:建文件比打开文件贵一倍多(要写目录项、分配记录、记日志)。这就是”高并发服务要预先建句柄池 / 复用句柄,不要在热路径上开合文件”的全部理由。

小结

  • 分配单位:C: 是 8 扇区 × 512 B = 4096 B 簇,U: 那张 CDFS 是 1 × 2048 = 2048 B 且只读(建文件 err=5)。用户态看不见非稀疏文件的簇舍入(三个接口都只给逻辑大小、空闲簇是缓存值);稀疏文件把它暴露出来了:写 4096 B 实占 65536 B,67 MB 逻辑只占 131072 B(512 倍)。
  • inode:FileId64 = 序列号(高16) + MFT 记录号(低48),与新接口 128 位 ID 的低 8 字节一致;12 轮建→删里 11 次复用同一记录、序列号 +11(A: 17→28,B: 19→30)。硬链接 ID 不变、链接数 1→2、写一边读另一边;最后一个名字删掉后按路径重开报 2、旧句柄仍可读写。
  • 目录项:批量枚举 1.40–1.45 微秒/项,按名打开 356–461 微秒/次(差 200–500 倍);同一打开动作在只有 1 项的目录里降到 60–84 微秒——成本在目录项数量,不在路径深度;\\?\ 免规范化本机测不出收益(1.40x / 0.97x / 0.79x / 1.26x)。8.3 短名仍在生成(E_LONG~1.TXT)。建/删小文件 663–798 微秒/个。
  • VFS 分层:同一句柄三种名字(DOS 盘符 / \\?\Volume{GUID} / \Device\HarddiskVolume3),QueryDosDeviceW("C:") 就是那张盘符映射表。符号链接两次都 1314(无 SeCreateSymbolicLinkPrivilege);FSCTL_SET_REPARSE_POINT 自己建报 4392(建出来的不是目录),mklink /J 建成后属性 0x410、Tag 0xa0000003、替换名带 \??\ 前缀,经联接打开的文件 FileId64 与目标相同;删联接要 RemoveDirectory(DeleteFile 报 5),目标文件不受影响。
  • 写路径:缓冲 9.7–13.6 微秒/4 KB(≈300–400 MB/s,只在页缓存里)、NO_BUFFERING 31.2–48.0、WRITE_THROUGH 47.2–112.4(缓冲的 4.9–8.4 倍);四次运行绝对值差 2–4 倍但次序一次都没变(缓冲 < 无缓冲 < 直写);顺序写不比随机快(9.6 vs 9.7);FlushFileBuffers 1 MB 用 1.5–2.7 毫秒;写完不 flush 别的句柄立刻读得到(0x7E)。日志/崩溃一致性未实测(\\.\C: 是 err=5,非管理员)。
  • 句柄:基线 91 个,开 2000 个文件到 2091,关闭 26–30 微秒/个;Create+Close 一圈 877–997 微秒/次(建比开贵一倍多)。ext4/FAT32/tmpfs/ReFS 的差异、Linux 的 mount namespace 与 symlink 权限、io_uring/aio/epoll 全部未在本机实测——这台机器只有 NTFS 和一张只读 CDFS,异步 IO 与页缓存回写属于下一节。