Windows I/O 的深层(第1回) ── 所有读写都会变成 IRP:I/O 系统全貌

· · Windows, Win32, I/O, 内核, 设备驱动程序, .NET, CSharp, 故障排查

File.ReadAllText 的一行代码,或者 ReadFile 的一次调用。在这个函数返回之前的那段时间里,Windows 内部到底发生了什么?

在排查故障时打开 Process Monitor,会看到 IRP_MJ_READFASTIO_READ 这类陌生的词汇排成一列。追查句柄泄漏时,明明已经调用过 CloseHandle 的文件却仍然活着。「在网络驱动器上行为不一样」「只有装了杀毒软件的环境才会变慢」──这些在业务应用现场反复遇到的现象,其实都发生在 Windows I/O 系统这同一片地基之上。

从这篇文章开始,我们启动一个把这片地基彻底挖到底的系列——「Windows I/O 的深层」。就像名著《Windows Internals》(中译本《深入解析 Windows 操作系统》)一路深入到内核设计来讲解一样,本系列也不打算讲「API 怎么用」,而是要讲「为什么会这样运作」。1 预定的篇章结构如下:

  1. I/O 系统全貌 ── 所有读写都会变成 IRP(本文)
  2. 同步 I/O 与异步 I/O ── OVERLAPPED 的真正含义
  3. I/O 完成端口(IOCP)与 .NET 线程池 ── async/await 的地下室
  4. 缓存管理器 ── 你的 WriteFile 究竟何时抵达磁盘
  5. NTFS 的内部结构 ── 从 MFT 理解文件系统
  6. 过滤驱动程序与微过滤器 ── Procmon 与杀毒扫描能够介入 I/O 的原因

第 1 回的本文,将用图解把之后所有篇章都要用到的地基——「登场对象与请求的流转」——梳理清楚。这不是一篇教你写驱动程序的文章,而是要让应用开发者能够一路追踪到自己所调用的 API 最终去了哪里

前提知识: 只要用过 C# 的 FileStream,或者在 C/C++ 中调用过 CreateFile/ReadFile,就能读懂本文。不需要驱动程序开发经验,内核侧的代码只会以概念图的形式出现。预计阅读时间:配合图示通读全文约 25 分钟,只看第 1 章的结论则只需 2~3 分钟。

跳读指南: 文章较长,按目的准备了几个入口。

  • 只想抓住全貌 → 第 1 章(结论)→ 第 2 章(名字解析)→ 第 5 章(ReadFile 一次往返)
  • 想理清术语(驱动程序/设备/文件对象、IRP) → 第 3 章・第 4 章
  • 想了解实务中的「故障方式」(句柄泄漏、「文件正在使用中」) → 第 6 章
  • 想动手实践 → 第 7 章

1. 先说结论

  • Windows 的 I/O 是数据包驱动的。发送给设备驱动程序的请求,绝大多数都会被封装成一种叫做 IRP(I/O Request Packet,I/O 请求包)的数据包,在驱动程序堆叠而成的设备堆栈中自上而下流动。23
  • CreateFile 打开的未必是「文件」。名字的解析发生在对象管理器的命名空间中,C: 的实体是指向 \Device\HarddiskVolume3 这类 NT 设备名的符号链接(第 2 章)。45
  • 登场对象一共有三种。拥有处理函数表的「驱动程序对象」、作为请求目的地的「设备对象」、保存一次打开状态的「文件对象」。你手中拿到的 HANDLE,就是对文件对象的一个引用(第 3 章)。6789
  • 每个驱动程序的选择只有三种。「自己完成这个 IRP」「传递给下层驱动程序」「先挂起(pending)稍后再完成」。这三种选择的组合,就能解释过滤驱动程序、缓存机制乃至异步 I/O 的一切(第 4 章)。310
  • 内核底层并不存在一套叫做「同步 I/O」的独立机制。同步 I/O 的真面目只是一种保证——「完成之前不会返回」——只有在请求进入挂起状态时才会真正发生等待。这正是第 2 回、第 3 回的切入点(第 5 章)。11
  • CloseHandle 并非「关闭」,而是「归还一个句柄」。最后一个句柄关闭时触发 cleanup,内核内部的所有引用都消失时才触发 close,分为这两个阶段——「已经关闭却仍在使用中」的真相正藏在这个差异里(第 6 章)。1213
  • 这一层完全可以亲眼观察到。用 WinObj 可以看到命名空间,用 Process Monitor 可以看到 IRP 的流转。Procmon 显示所用的词汇,正是本文所讲的这套术语本身(第 7 章)。14

2. 「所有东西看起来都是文件」的真相

2.1. 传给 CreateFile 的名字去了哪里

Win32 的文件 API 全部都从「名字」开始。像 C:\project\report.csv 这样的路径,像 \\server\share\data.csv 这样的 UNC 路径,像 \\.\COM3 这样的设备指定──这些全都可以传给同一个 CreateFile15

这种一致性的背后,是内核中由对象管理器统一管理的单一命名空间。在内核内部,设备、事件、共享内存节(section)等等,全都作为「对象」注册在这棵树状命名空间里。使用 Sysinternals 的 WinObj,就能原样窥探这个命名空间。14

\ ── 命名空间的根\Device驱动程序创建的设备对象\GLOBAL??Win32 可见的全局名字存放处\BaseNamedObjects具名互斥体等HarddiskVolume3Serial0Mup ── 网络重定向器C: → \Device\HarddiskVolume3COM1 → \Device\Serial0PhysicalDrive0 → \Device\Harddisk0\DR0

图 1:对象管理器命名空间(节选)。\GLOBAL?? 下面的都是符号链接,实体位于 \Device 之下

关键在于,Win32 应用所使用的名字(驱动器号、COM 端口名)与内核所使用的名字(NT 设备名)处于不同的层级。把两者连接起来的正是符号链接。

2.2. 驱动器号就是符号链接

驱动程序如果希望自己的设备能被 Win32 应用看到,就会用 IoCreateSymbolicLink 创建一条从 \DosDevices\COM1 这样的MS-DOS 设备名指向 NT 设备名的符号链接。4 C: 也是同样的机制,其实体是一条指向 \Device\HarddiskVolume3 这类卷设备的链接。

因此,CreateFile("C:\project\report.csv") 的名字解析会按如下方式进行。

应用传入的名字C:\project\report.csvWin32 层转换为 NT 形式\??\C:\project\report.csv对象管理器检索命名空间发现 \??\C: 是一条符号链接沿链接替换\Device\HarddiskVolume3\project\report.csv在 \Device\HarddiskVolume3 处到达卷的设备对象剩余的 \project\report.csv 交由I/O 管理器发出 IRP_MJ_CREATE委托给文件系统驱动程序,NTFS,来解析

图 2:CreateFile 的名字解析。前半段是对象管理器的工作,到达设备之后的后半段则是文件系统的工作

从这张图中,可以解开平时的几个疑惑。

  • \\.\ 前缀的含义。\\.\PhysicalDrive0\\.\COM10 中的 \\.\,是直接指向 Win32 设备命名空间(约等于 \?? 目录)的写法。它不经过驱动器号,而是直接点名指定链接所在的位置。515
  • CONNUL 不能用作文件名的原因。这些名字被预留为 MS-DOS 设备名,无论写在路径的哪个位置,都可能被解析到设备一侧。5 这方面的实务陷阱整理在《MAX_PATH 与 Windows 路径・文件名的陷阱》中。
  • UNC 路径也没有被特殊对待。\\server\share 会被解析到网络重定向器的设备(\Device\Mup),之后由 SMB 客户端将请求跨网络传送出去。本地路径与 UNC 路径行为不同的根源,就在于解析到的设备不同(参见《网络驱动器与 UNC 路径的陷阱》)。

另外,严格来说 \?? 并不是某个真实存在的目录的别名,而是代表一种检索顺序的虚拟入口——「先查找每个登录会话各自的本地 DOS 设备映射,找不到时才落到 \GLOBAL??。用 net usesubst 创建的驱动器号会进入这个本地侧,因此才会出现同一台 PC 上不同用户(登录会话)看到的驱动器不一样、服务看不到用户的网络驱动器之类的现象。图 1 中画出的只是全局侧(\GLOBAL??)。

也就是说,「在 Windows 中一切看起来都是文件」这句话更准确的说法应该是:「所有名字最终都会被解析为一个设备对象,之后的请求被统一成同一种形式——IRP」。那么这个设备对象究竟是什么呢?下面来整理登场对象。

3. 登场对象是三种对象

Windows I/O 系统的结构,可以用三种内核对象之间的关系来描绘。

接下来会连续出现专业术语。为了不让你在中途迷路,这里先放一张「日常代码中看到的东西」与「内核侧称呼」的对照表。第 3 章、第 4 章都是用右边这一列的词汇写成的。

Win32 / .NET 侧看到的东西 内核侧的对应物 一句话说明
HANDLE / SafeFileHandle 句柄表中的一个条目(对文件对象的引用) 指向「一次打开」的号码牌(3.3)
FileStream 所持有的打开状态 文件对象 存放共享模式・标志・当前位置的容器(3.3)
驱动器号 C:\\.\COM3 设备对象(名字解析的结果) 请求的目的地(第 2 章・3.2)
「NTFS 驱动程序」「磁盘驱动程序」 驱动程序对象 按请求种类划分的处理函数表(3.1)
ReadFile / stream.Read 的一次调用 通常对应 1 个 IRP 送往目的地的请求包(第 4 章)。对已经在缓存中的文件进行同步读写时,会走快速 I/O 这条捷径,不生成 IRP(5.2)。Procmon 中以 FASTIO_ 开头的行就是它
FileOptions / CreateFile 的标志 记录在文件对象上的属性 决定预读、异步行为等(第 7 章对照表)
GetLastError 的值 / .NET 的异常 NTSTATUSSTATUS_PENDING 等) 完成状态。会被转译成 Win32 错误码后向上传递

3.1. 驱动程序对象 ── 处理函数表

驱动程序被加载时,I/O 管理器会创建一个代表该驱动程序的驱动程序对象DRIVER_OBJECT)。6 站在应用开发者的角度,其中最重要的成员是 MajorFunction 数组。这是一张「请求种类 → 处理函数」的对照表,请求种类由 IRP_MJ_CREATE(打开)、IRP_MJ_READ(读取)、IRP_MJ_WRITE(写入)、IRP_MJ_CLEANUPIRP_MJ_CLOSE 这类主功能代码来表示。16

用 C# 硬写出来大致是这样的意象。

// 概念示意图。实际上是内核中的 C 结构体
class DriverObject
{
    // 以 IRP_MJ_XXX 为下标,共 28 种
    public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// 如果是 ntfs.sys,MajorFunction[IRP_MJ_READ] 中存放的就是"NTFS 的读取处理"

3.2. 设备对象 ── 请求的目的地

驱动程序会为自己负责的每个设备创建一个设备对象DEVICE_OBJECT)。7 这就是I/O 请求的目的地。它未必与物理设备一一对应,也可能是卷(HarddiskVolume3)这样的逻辑存在,或者过滤驱动程序为了插入而创建的「仅用于介入的设备」。设备对象指向创建它的那个驱动程序对象,因此一旦目的地确定,对应的处理函数表也就随之确定

3.3. 文件对象 ── 「打开一次」的状态

每当 CreateFile 成功一次,内核就会创建一个文件对象。它并不是「磁盘上的文件」本身,而是代表打开该文件(或设备)这一次会话的对象。8 同一个文件打开两次,就会产生两个文件对象。当前的文件指针(同步句柄的情况下)、打开时的共享模式与标志,都保存在这里。

而应用拿到的 HANDLE,则是经由每个进程各自的句柄表、指向文件对象的一个引用9

内核空间进程,用户模式文件对象 1以读取方式打开 report.csv当前偏移量为 4096文件对象 2以追加方式打开 report.csv当前偏移量为 65536设备对象相当于 HarddiskVolume3驱动程序对象 NTFSMajorFunction 为处理函数表HANDLE 0x1A4HANDLE 0x1B8

图 3:三种对象之间的关系。句柄经由句柄表指向文件对象,文件对象连向设备,设备连向驱动程序

把这张图记在脑子里之后,几条实务知识就会从「死记硬背」变成「理所当然」。

  • 句柄泄漏的真面目,是一批因为持续被引用而始终无法释放的文件对象(以及背后牵连的资源)。真正应该计数的是句柄表中的条目,Process Explorer 和 handle.exe 展示的正是这个(《Process Explorer / Handle / VMMap 实践》;作为调查记录,可参考《工业相机长期运行崩溃调查 - handle 泄漏篇》)。
  • 同一个文件打开出的两个句柄之间文件指针相互独立,是因为指针保存在文件对象一侧。反过来,用 DuplicateHandle 复制出的句柄指向同一个文件对象,因此会共享指针。
  • 共享冲突(sharing violation),是内核把已有文件对象群的共享模式与新的 CreateFile 请求相互核对后作出的判定。互斥控制的实务内容,已经在《文件集成互斥控制基础知识》中讨论过。

4. IRP ── I/O 请求变成包裹

4.1. 为什么要做成数据包

I/O 管理器接到来自应用的请求(打开・读取・写入……)后,会将其封装成一种叫做IRP(I/O Request Packet)的数据包,再交给驱动程序。发往设备驱动程序的请求,绝大多数都是以 IRP 的形式送达的。3 由于设备的速度和 OS 对不上(磁盘比 CPU 慢了好几个数量级),所以才把请求做成「包裹」而非「函数调用」,从而做到发出与完成可以彼此分离2

除了保存请求整体信息的头部之外,IRP 中还并排着若干块叫做I/O 堆栈位置的区域,数量与预计经过的驱动程序数量相同。每个驱动程序都会从属于自己的那块堆栈位置中,读取「给自己的指示」(主功能代码、参数)。17

4.2. 沿设备堆栈向下走

设备对象层层堆叠,构成设备堆栈18 举例来说,对本地磁盘上的文件进行读取时,大致会经过下面这样一条路径。

存储一侧的堆栈文件系统一侧的堆栈卷/分区管理volmgr 等磁盘类驱动程序disk.sys存储端口/微端口storport 等文件系统过滤器杀毒软件・加密・Procmon 等NTFS把文件内偏移量转换为卷上的位置I/O 管理器组装 IRPIRP_MJ_READ + 堆栈位置磁盘设备

图 4:读取请求所经过的路径。文件系统一侧与存储一侧是各自独立的设备堆栈,NTFS 会重新发出一个面向存储一侧的下层 IRP,把工作委托出去

希望你从这张图中记住两件事。

  1. 过滤器是正当的住户。杀毒软件之所以能够检查所有的文件 I/O,靠的不是什么黑科技,而是因为「插在中间」这套机制本身就是操作系统官方提供的扩展点。19 Process Monitor 站在同一个位置记录全部 I/O。这也是排查「只有那个环境里文件访问才慢」这类问题时,最先应该怀疑的地方(详见第 6 回)。
  2. 请求的含义会逐层被翻译。应用说的是「这个文件从偏移量 4096 开始的 8KB」,NTFS 把它翻译成「这个卷上的这个簇」,存储堆栈再翻译成「这块磁盘上的这个扇区」。上层不知道下层的具体情况。这里有一点重要的补充:并不是同一个 IRP 会原封不动地从应用一路直达磁盘。文件系统一侧与存储一侧是各自独立的堆栈,NTFS 处理完面向文件的 IRP 之后,会作为结果,重新创建并发出一个面向卷(存储堆栈)的下层 IRP。如果文件发生了碎片化,一次读取甚至可能拆分成多个下层 IRP,请求的粒度与生命周期都会随着层级而变化。

4.3. 每个驱动程序的三种选择

接收到 IRP 的驱动程序,本质上只能做三件事之一。310

下层当场就完成了下层选择了挂起,挂起状态会一路传回调用方驱动程序接收到 IRP如何处理这个请求① 自己完成调用 IoCompleteRequest例如,用缓存中的数据立即返回② 传递给下层设备调用 IoCallDriver例如,过滤器检查后放行③ 挂起,pending返回 STATUS_PENDING 并把 IRP 放入队列例如,等待硬件响应以中断等为契机稍后调用 IoCompleteRequest完成处理沿堆栈逆序向上依次调用各层的完成例程

图 5:接收到 IRP 的驱动程序面临的三种选择。三者并不互斥,「传递下去之后在下层被挂起」是最常见的路径,最终都会以 IoCompleteRequest 完成而告终

另外,这三者并不是互斥的选项。最常见的路径是「用②传递下去之后,某个层选择了③的挂起」,这种情况下 STATUS_PENDING 会原样传回中间的驱动程序以及最初的调用方。之后下层一旦完成,传递时注册过完成例程的各个层就会按逆序被依次唤起──也就是说,「传递」与「挂起」会在同一个 IRP 上叠加发生

正是这三种选择,构成了 Windows I/O 灵活性的源泉。

  • 缓存命中时当场完成就会很快(第 4 回的缓存管理器)。
  • 可以插入任意多层放行式的过滤器(第 6 回的微过滤器)。
  • 正因为可以挂起,等待慢速设备响应期间才不必占死一个线程(第 2 回、第 3 回的异步 I/O)。

这个系列剩下的全部内容,其实都只是这张图的脚注。

5. 追踪 ReadFile 的一次往返

登场对象和工具都已备齐,现在来完整追踪一次 ReadFile 调用的往返过程。这里以脱离缓存、一路打到磁盘的情形为例(命中缓存的情况留到第 4 回讲)。

磁盘设备存储堆栈过滤器 + NTFSI/O 管理器应用线程磁盘设备存储堆栈过滤器 + NTFSI/O 管理器应用线程根据句柄解析出文件对象,组装 IRP(IRP_MJ_READ)同步 I/O:在此等待完成并进入休眠异步 I/O:控制权立即返回,可以做别的事从中断处理(ISR)转入 DPC 继续完成处理当所需的下层 IRP全部完成后逆序执行各层的完成例程,通过发往请求线程的 APC 确定结果ReadFile → NtReadFile(系统调用)IoCallDriver(送往堆栈最上层)把位置翻译到卷上,发出面向存储堆栈的下层 IRP发出读取命令STATUS_PENDING(下层 IRP 处于挂起状态)原始 IRP 也保持挂起状态返回(去程到这里就结束了)中断通知「数据已读取完毕」完成下层 IRP(IoCompleteRequest)由 NTFS 一侧的完成例程接收完成原始 IRP(IRP_MJ_READ)状态与字节数已确定(如事件通知等)

图 6:脱离缓存的 ReadFile 一次往返。下层 IRP 的完成与原始 IRP 的完成是各自独立的步骤,「去程」与「回程」也是作为各自独立的事件推进的

5.1. 去程与回程是两件不同的事

这张图的关键,在于 STATUS_PENDING 那一行。存储驱动程序在向硬件发出命令的那一刻就已经放手,「去程」的处理到此为止。数据读取完成的消息,会在之后以中断这个独立的事件通知过来,「回程」的完成处理正是从那时才开始的。10

也就是说,内核内部的设计,本来就让 I/O 的发出与完成可以彼此分离。并不存在一条叫做「同步 I/O」的独立管道,同步 I/O 准确的含义是一种「完成之前调用不会返回」的保证。只有在请求进入挂起状态时才会真正发生等待;如果驱动程序能够当场完成请求(图 5 中「完成」这条路),即使是同步 I/O,线程也可以一次都不睡眠就带着结果返回。Win32 之所以通过句柄的打开方式(FILE_FLAG_OVERLAPPED)来切换同步与异步,正是因为异步并非「特别追加的功能」,而只是等待方式的不同。11

带着这个视角,第 2 回将要讨论的疑问──为什么是否异步不是按每次调用决定,而是在打开句柄时就已确定(OVERLAPPED 结构体本身则是每个正在进行的操作都需要一个)、「明明应该是异步却同步完成」到底是怎么回事──就会作为这套机制的必然结果显现出来。.NET 的 async/await 之所以在等待 I/O 时不会占用线程(在《C# async/await 实战判断表》中曾从实务角度写过这个话题),依据的也正是这张图。

5.2. 也有例外 ── 不生成 IRP 的捷径

老实补充一句,并非所有 I/O 都会变成 IRP。为了应对命中缓存的文件所进行的同步读写,文件系统提供了一条叫做快速 I/O的捷径──「不组装 IRP,直接从缓存中拷贝数据」。Procmon 的 Operation 列中以 FASTIO_ 开头的行,正是它。这条捷径无法使用的条件,以及它与缓存管理器的关系,留到第 4 回讲解。

6. CloseHandle 的背后 ── cleanup 与 close 是两码事

最后来看看打开之后该怎么关闭。这部分内容直接关系到句柄泄漏排查和「文件正在使用中」问题。

CloseHandle 所做的事情,是从进程的句柄表中删除一个条目。文件对象拥有句柄计数(句柄的数量)和引用计数(来自内核内部的引用数量)这两个计数,二者是各自独立递减的。

文件系统I/O 管理器对象管理器应用文件系统I/O 管理器对象管理器应用从句柄表中删除条目,句柄计数减一取消该文件对象上未完成的 I/O、释放锁alt[这是最后一个句柄]但如果未完成的 I/O 或节(内存映射)等内核内部的引用仍然存在,文件对象就依然活着文件对象的收尾工作完成,到这里才算真正「关闭完毕」alt[引用计数也归零了]CloseHandle(h)IRP_MJ_CLEANUPIRP_MJ_CLOSE

图 7:cleanup(最后一个句柄关闭)与 close(所有引用也都消失)这两个阶段

  • IRP_MJ_CLEANUP 是「最后一个句柄已被关闭」的通知。不过文档本身也明确指出,如果还有未完成的 I/O,文件对象的释放可能还没有真正发生。12
  • IRP_MJ_CLOSE 是「引用计数归零」的通知。cleanup 与 close 之间存在间隙,不一定会紧接着立即发生。13

了解了这两个阶段之后,现场遇到的种种不可思议之处就能得到解释。

  • 关闭了内存映射文件,文件却不会被释放。这是因为被映射的节会持续持有对文件对象的引用,直到视图被取消映射、节被关闭,close 才会到来。共享内存相关的实务内容,已经在《共享内存的陷阱与实务最佳实践》中讨论过。
  • 追查「文件正在使用中」的元凶,光看句柄是不够的。即使所有句柄都已经关闭,内核内部的引用(例如已映射的镜像)仍有可能抓着文件不放。Process Explorer 的搜索功能之所以同时覆盖句柄和 DLL(已映射的文件)这两类对象,原因正在于此。
  • 不能指望 .NET 的 SafeFileHandle 或终结器「总有一天会帮你关闭」,因为忘记关闭的句柄会拖延 cleanup 的到来,让共享冲突或锁的持有时间被无谓地拉长。

7. 亲眼确认

到目前为止讲的这些内容,只要有一台拥有管理员权限的 Windows PC,就全都能亲眼观察到。

需要准备的东西只有三样。

  • 管理员权限。Process Monitor 需要加载内核驱动程序,因此必须以管理员身份运行。WinObj 也有一部分对象,不以管理员身份运行就看不到。
  • Sysinternals 工具。WinObj 和 Process Monitor 都是微软免费分发的。可以从各自的下载页面(WinObjProcess Monitor)分别获取,如果想一次性装齐,也可以用 Sysinternals Suite。没有安装程序,解压后直接运行 exe 即可。
  • 一个用来观察的简单操作。用记事本保存一个文件、复制一个小文件,这种程度就足够了。请在手边的本地磁盘上试验,而不要用业务共享文件夹或生产环境的机器。

用 WinObj 查看命名空间。启动 Sysinternals 的 WinObj,打开 GLOBAL?? 目录,就能原样看到 C: 是指向 \Device\HarddiskVolumeN 的符号链接(图 1)。在 \Device 之下,可以看到各驱动程序创建的设备对象的真实名字。14

用 Procmon 读懂 IRP 的语言。在 Process Monitor 的菜单中启用 Filter > Enable Advanced Output 之后,Operation 列就会从 ReadFile 变成 IRP_MJ_READFASTIO_READ 这类内核侧的词汇。只需追踪一次文件复制,IRP_MJ_CREATEFASTIO_READ/IRP_MJ_READIRP_MJ_WRITEIRP_MJ_CLEANUPIRP_MJ_CLOSE 这条本文所讲的流程,就会真实地依次排开。Procmon 的实务用法,已经整理在《Process Monitor(ProcMon)实战指南》中。

从 .NET 意识到底层。C# 的 FileStream 内部会调用 CreateFileW,并把句柄以 SafeFileHandle 的形式持有。构造函数中的 FileOptions,几乎是直接对应到 Win32 标志位的。

.NET(FileOptions Win32(CreateFile 标志) 含义(相关篇章)
Asynchronous FILE_FLAG_OVERLAPPED 以异步 I/O 方式打开句柄(第 2 回、第 3 回)
WriteThrough FILE_FLAG_WRITE_THROUGH 写入不在缓存处停留(第 4 回)
SequentialScan FILE_FLAG_SEQUENTIAL_SCAN 预读提示(第 4 回)
RandomAccess FILE_FLAG_RANDOM_ACCESS 抑制预读的提示(第 4 回)
DeleteOnClose FILE_FLAG_DELETE_ON_CLOSE 最后一个句柄关闭后删除(第 6 章机制的应用)

.NET 6 及以后的版本,还可以用 File.OpenHandleRandomAccess 类,绕开 FileStream 这层抽象,写出更接近 Win32 原生形态的「句柄 + 指定偏移量 I/O」代码。只要理解了这张表右边一列的含义,左边的选择就不再需要死记硬背。

8. 总结

  • Windows 的 I/O,贯穿着这样一套统一的设计:在对象管理器的命名空间中确定目的地(设备对象),把请求封装进IRP,再流入设备堆栈2318
  • C: 是符号链接,\\.\ 是直接指定名字所在的位置,UNC 则会被解析到重定向器。「一切看起来都是文件」的真相,其实是名字解析的一致性45
  • 登场对象一共三种:驱动程序对象(处理函数表)、设备对象(目的地)、文件对象(一次打开的状态)。HANDLE 则是对文件对象的一个引用。6789
  • 驱动程序的选择只有完成・放行・挂起三种。过滤器的介入、缓存的即时应答、异步 I/O,全都是这三种选择的应用。310
  • 发出与完成被设计成可以彼此分离,同步 I/O 不过是一种「完成之前不会返回」的保证。对于进入挂起状态的请求,去程(发出)与回程(中断 → 完成)会作为各自独立的事件推进。1110
  • CloseHandle 只是「归还一个句柄」而已。只要了解 cleanup(最后一个句柄)与 close(最后一个引用)这两个阶段,「明明关闭了却还在使用中」就不再是谜团。1213

下一篇是第 2 回《同步 I/O 与异步 I/O ── OVERLAPPED 的真正含义》。将深入挖掘应用一侧如何利用本文所见的「发出与完成的分离」──FILE_FLAG_OVERLAPPED、完成通知的四种方式、取消操作,以及「明明应该是异步却以同步方式返回」的陷阱。

相关文章

相关咨询领域

合同会社小村软件专注于 Windows 业务应用中围绕文件 I/O 的架构设计与故障排查(句柄泄漏、「文件正在使用中」、特定环境下的 I/O 延迟等)。

参考链接

  1. Microsoft Learn, Windows Internals - Sysinternals. 《Windows Internals》(中译本《深入解析 Windows 操作系统》)一书的介绍页面。这是一本涵盖 Windows 内核架构与 I/O 系统等内部结构的经典著作,也是深入学习本系列所讲内容的一个出发点。 

  2. Microsoft Learn, I/O manager. 关于 Windows 内核模式 I/O 管理器管理应用程序与设备驱动程序所提供接口之间通信的机制;设备的运行速度与操作系统不一致,因此操作系统与驱动程序之间的通信主要通过 IRP(I/O 请求包)进行;以及 IRP 作为类似网络数据包、Windows 消息的存在,从操作系统传递给驱动程序、又在驱动程序之间传递的说明。  2 3

  3. Microsoft Learn, I/O request packets. 关于发送给设备驱动程序的请求绝大多数都会被封装进 IRP;操作系统组件或驱动程序通过 IoCallDriver(接收指向设备对象的指针与指向 IRP 的指针)向驱动程序发送 IRP;IRP 通常由堆叠成设备堆栈的多个驱动程序依次处理,首先被送往堆栈最上层的设备对象;以及各驱动程序可以选择处理并完成 IRP,或者转发给下层驱动程序的说明。  2 3 4 5 6

  4. Microsoft Learn, Introduction to MS-DOS device names. 关于 MS-DOS 设备名是指向 NT 风格设备名的符号链接;用户模式的 Windows 应用通过 MS-DOS 设备名(驱动器号或 COM 端口名)访问设备,而驱动程序与内核则使用 NT 风格的名字;以及驱动程序通过 IoCreateSymbolicLink 创建从 \DosDevices\名字 指向设备的符号链接的说明。  2 3

  5. Microsoft Learn, Naming files, paths, and namespaces. 关于 CON、PRN、AUX、NUL、COM1~COM9、LPT1~LPT9 被预留为文件名;Win32 命名空间中存在「文件命名空间」与「设备命名空间」,”\\.” 前缀代表访问 Win32 设备命名空间(例如 \\.\PhysicalDrive0);以及这些名字解析规则的说明。  2 3 4

  6. Microsoft Learn, Introduction to driver objects. 关于驱动程序加载时 I/O 管理器会创建 DRIVER_OBJECT 结构体;驱动程序对象持有指向驱动程序标准例程集合的入口(包含作为分发表的 MajorFunction 数组);以及 I/O 管理器利用这张表来调用对应请求的处理函数的说明。  2 3

  7. Microsoft Learn, Introduction to device objects. 关于 DEVICE_OBJECT 结构体代表逻辑・虚拟・物理设备,作为 I/O 请求的目标;驱动程序通过 IoCreateDevice 创建设备对象;以及设备对象与创建它的驱动程序(驱动程序对象)相互关联的说明。  2 3

  8. Microsoft Learn, Using files in a driver. 关于在内核中文件对象代表「已打开的文件(或设备)的一个实例」;每次打开文件都会创建一个文件对象,并保存打开时的上下文(当前字节偏移量等)的说明。  2 3

  9. Microsoft Learn, File handles. 关于 CreateFile 返回的文件句柄是进程私有的,并与已打开的文件对象相关联;同一个文件多次打开会产生各自独立的句柄(及打开状态);以及句柄不再需要时应通过 CloseHandle 关闭的说明。  2 3

  10. Microsoft Learn, Completing IRPs. 关于完成一次 I/O 操作是通过调用 IoCompleteRequest 实现的;完成时会依次调用由堆栈上层驱动程序注册的 IoCompletion 例程;以及请求的完成会在与发出不同的时机发生,直到最终把状态返回给请求方为止的整个流程的说明。  2 3 4 5

  11. Microsoft Learn, Synchronous and asynchronous I/O. 关于同步 I/O 中函数要等到 I/O 完成才会返回、线程会被阻塞等待,而异步 I/O(重叠 I/O)中发出请求的函数会立即返回、线程可以继续做其他工作;异步 I/O 需要以 FILE_FLAG_OVERLAPPED 指定方式打开句柄;以及存在多种接收完成通知方式的说明。  2 3

  12. Microsoft Learn, IRP_MJ_CLEANUP. 关于接收到这个请求就表示「与目标设备对象相关联的文件对象的最后一个句柄已被关闭」;但由于存在未处理的 I/O 请求,文件对象的释放有时还未发生;以及这个 IRP 是在关闭句柄的那个进程的上下文中被发送的说明。  2 3

  13. Microsoft Learn, IRP_MJ_CLOSE. 关于接收到这个请求就表示「文件对象的引用计数已归零,文件对象即将被释放」;该请求会在 cleanup 请求之后发出,但由于要等待未处理 I/O 完成,不一定会紧接着立即发生的说明。  2 3

  14. Microsoft Learn, WinObj - Sysinternals. 关于 WinObj 是一款用于显示 NT 对象管理器命名空间的工具,可以浏览命名空间中的对象,包括设备对象与符号链接的说明。  2 3

  15. Microsoft Learn, CreateFileW function. 关于 CreateFile 不仅可以打开文件,还能打开物理磁盘、卷、控制台、通信端口(COM 端口)、管道等设备并返回句柄;打开设备时使用 “\\.” 形式的名字;以及 FILE_FLAG_OVERLAPPED 等各种标志位含义的说明。  2

  16. Microsoft Learn, IRP major function codes. 关于 IRP 主功能代码(IRP_MJ_CREATE、IRP_MJ_READ、IRP_MJ_WRITE、IRP_MJ_CLEANUP、IRP_MJ_CLOSE、IRP_MJ_DEVICE_CONTROL、IRP_MJ_PNP 等)的一览表,以及各请求分别代表什么含义、应由哪个驱动程序负责处理的说明。 

  17. Microsoft Learn, I/O stack locations. 关于 I/O 管理器会为分层驱动程序链中的每一个驱动程序在 IRP 内准备一个 I/O 堆栈位置;每个堆栈位置中存放着主/次功能代码及该请求的参数;以及各驱动程序通过 IoGetCurrentIrpStackLocation 获取属于自己的堆栈位置从而得知请求内容的说明。 

  18. Microsoft Learn, Device nodes and device stacks. 关于设备对象层层堆叠构成设备堆栈;IRP 首先被送往堆栈最上层的设备对象,各层进行处理或转发给下层;以及过滤驱动程序的设备对象会以插入堆栈中的形式存在的说明。  2

  19. Microsoft Learn, Filter Manager Concepts. 关于过滤管理器是 Windows 自带的一个内核模式驱动程序;微过滤器驱动程序可以通过对文件系统 I/O 请求的前置(pre)・后置(post)回调进行介入;以及每个微过滤器的介入位置(高度,altitude)决定其在 I/O 堆栈中的顺序的说明。 

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

IRP 是什么?
IRP(I/O Request Packet,I/O 请求包)是 Windows 内核的 I/O 管理器用来封装应用程序的读写请求等内容、传递给设备驱动程序的数据包。微软的驱动程序开发文档说明,发送给设备驱动程序的请求,绝大多数都会被封装成 IRP。IRP 带有表示请求种类(创建・读取・写入・清理等)的主功能代码,以及为经过的每个驱动程序准备的堆栈位置,在设备堆栈中自上而下传递并被处理。每个驱动程序都可以选择自行完成该 IRP、将其传递给下层驱动程序,或者将其挂起(pending)后再择机完成。应用开发者不会直接接触 IRP,但 Process Monitor 的 Operation 列中出现的 IRP_MJ_READ 之类的记法,正是这套机制本身的体现。
为什么 Windows 中文件、串口、打印机都能用同一个 CreateFile 打开?
因为无论传给 CreateFile 的名字是什么,都会在对象管理器的命名空间中最终解析为一个设备对象,然后由 I/O 管理器创建与该设备关联的文件对象并返回句柄——这条路径是完全相同的。像 C: 这样的驱动器号,其实体是指向 \Device\HarddiskVolume3 这类 NT 设备名的符号链接,\\.\COM1 这样的指定同样会被解析到串口的设备对象。无论最终解析到哪个设备,之后的请求都会被封装成同一种形式——IRP——送达驱动程序,因此文件和设备才能用同一套 API 打开并读写。UNC 路径也遵循同样的机制,只是被解析到了网络重定向器的设备而已。正是这种「命名空间 + 数据包」的设计,构成了 Windows I/O 一致性的真面目。
为什么调用了 CloseHandle,文件却有时不会立刻被释放?
因为 CloseHandle 所做的事情是「归还一个句柄」,而不是「关闭文件」。内核中的文件对象拥有两个计数:句柄数(handle count)和来自内核组件的引用数(reference count)。最后一个句柄被关闭时,文件系统会收到 IRP_MJ_CLEANUP,但只要未完成的 I/O 或内存映射文件的节(section)等内核内部的引用还存在,文件对象本身就会继续存活,直到引用计数归零才会发出 IRP_MJ_CLOSE。使用内存映射文件之后文件无法删除、应用已经关闭却提示「文件正在使用中」,这类现象大多可以用这个两阶段机制来解释。
IRP 和设备堆栈的知识,对应用开发者有什么用?
即使不会亲自去编写 IRP,这些知识在排查问题和设计架构两方面都能派上用场。首先,Process Monitor 的 Operation 列(启用高级输出后)会直接以 IRP_MJ_CREATE、IRP_MJ_READ 等 IRP 术语显示,掌握这一层的词汇就能读懂日志。其次,了解到杀毒软件之类的过滤驱动程序会插入所有文件 I/O 的必经之路,就能在排查「只有在特定环境下文件访问变慢」这类问题时有的放矢。此外,只要理解 Windows 的 I/O 是把「发出请求」和「完成请求」设计成可以分离的两件事、同步 I/O 不过是「完成之前不会返回」这一种保证,就能从原理上真正理解并放心使用异步 I/O、I/O 完成端口,以及 .NET 的 async/await 的行为。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表