File.ReadAllText 的一行代码,或者 ReadFile 的一次调用。在这个函数返回之前的那段时间里,Windows 内部到底发生了什么?
在排查故障时打开 Process Monitor,会看到 IRP_MJ_READ、FASTIO_READ 这类陌生的词汇排成一列。追查句柄泄漏时,明明已经调用过 CloseHandle 的文件却仍然活着。「在网络驱动器上行为不一样」「只有装了杀毒软件的环境才会变慢」──这些在业务应用现场反复遇到的现象,其实都发生在 Windows I/O 系统这同一片地基之上。
从这篇文章开始,我们启动一个把这片地基彻底挖到底的系列——「Windows I/O 的深层」。就像名著《Windows Internals》(中译本《深入解析 Windows 操作系统》)一路深入到内核设计来讲解一样,本系列也不打算讲「API 怎么用」,而是要讲「为什么会这样运作」。1 预定的篇章结构如下:
- I/O 系统全貌 ── 所有读写都会变成 IRP(本文)
- 同步 I/O 与异步 I/O ── OVERLAPPED 的真正含义
- I/O 完成端口(IOCP)与 .NET 线程池 ── async/await 的地下室
- 缓存管理器 ── 你的 WriteFile 究竟何时抵达磁盘
- NTFS 的内部结构 ── 从 MFT 理解文件系统
- 过滤驱动程序与微过滤器 ── 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 这样的设备指定──这些全都可以传给同一个 CreateFile。15
这种一致性的背后,是内核中由对象管理器统一管理的单一命名空间。在内核内部,设备、事件、共享内存节(section)等等,全都作为「对象」注册在这棵树状命名空间里。使用 Sysinternals 的 WinObj,就能原样窥探这个命名空间。14
flowchart TB
ROOT["\ ── 命名空间的根"]
DEV["\Device<br/>驱动程序创建的设备对象"]
GLB["\GLOBAL??<br/>Win32 可见的全局名字存放处"]
BNO["\BaseNamedObjects<br/>具名互斥体等"]
ROOT --> DEV
ROOT --> GLB
ROOT --> BNO
DEV --> D1["HarddiskVolume3"]
DEV --> D2["Serial0"]
DEV --> D3["Mup ── 网络重定向器"]
GLB --> L1["C: → \Device\HarddiskVolume3"]
GLB --> L2["COM1 → \Device\Serial0"]
GLB --> L3["PhysicalDrive0 → \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") 的名字解析会按如下方式进行。
flowchart TB
A["应用传入的名字<br/>C:\project\report.csv"]
B["Win32 层转换为 NT 形式<br/>\??\C:\project\report.csv"]
C["对象管理器检索命名空间<br/>发现 \??\C: 是一条符号链接"]
D["沿链接替换<br/>\Device\HarddiskVolume3\project\report.csv"]
E["在 \Device\HarddiskVolume3 处<br/>到达卷的设备对象"]
F["剩余的 \project\report.csv 交由<br/>I/O 管理器发出 IRP_MJ_CREATE<br/>委托给文件系统驱动程序,NTFS,来解析"]
A --> B
B --> C
C --> D
D --> E
E --> F
图 2:CreateFile 的名字解析。前半段是对象管理器的工作,到达设备之后的后半段则是文件系统的工作
从这张图中,可以解开平时的几个疑惑。
\\.\前缀的含义。\\.\PhysicalDrive0、\\.\COM10中的\\.\,是直接指向 Win32 设备命名空间(约等于\??目录)的写法。它不经过驱动器号,而是直接点名指定链接所在的位置。515CON、NUL不能用作文件名的原因。这些名字被预留为 MS-DOS 设备名,无论写在路径的哪个位置,都可能被解析到设备一侧。5 这方面的实务陷阱整理在《MAX_PATH 与 Windows 路径・文件名的陷阱》中。- UNC 路径也没有被特殊对待。
\\server\share会被解析到网络重定向器的设备(\Device\Mup),之后由 SMB 客户端将请求跨网络传送出去。本地路径与 UNC 路径行为不同的根源,就在于解析到的设备不同(参见《网络驱动器与 UNC 路径的陷阱》)。
另外,严格来说 \?? 并不是某个真实存在的目录的别名,而是代表一种检索顺序的虚拟入口——「先查找每个登录会话各自的本地 DOS 设备映射,找不到时才落到 \GLOBAL??」。用 net use、subst 创建的驱动器号会进入这个本地侧,因此才会出现同一台 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 的异常 |
NTSTATUS(STATUS_PENDING 等) |
完成状态。会被转译成 Win32 错误码后向上传递 |
3.1. 驱动程序对象 ── 处理函数表
驱动程序被加载时,I/O 管理器会创建一个代表该驱动程序的驱动程序对象(DRIVER_OBJECT)。6 站在应用开发者的角度,其中最重要的成员是 MajorFunction 数组。这是一张「请求种类 → 处理函数」的对照表,请求种类由 IRP_MJ_CREATE(打开)、IRP_MJ_READ(读取)、IRP_MJ_WRITE(写入)、IRP_MJ_CLEANUP、IRP_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
flowchart LR
subgraph P["进程,用户模式"]
H1["HANDLE 0x1A4"]
H2["HANDLE 0x1B8"]
end
subgraph K["内核空间"]
FO1["文件对象 1<br/>以读取方式打开 report.csv<br/>当前偏移量为 4096"]
FO2["文件对象 2<br/>以追加方式打开 report.csv<br/>当前偏移量为 65536"]
DO["设备对象<br/>相当于 HarddiskVolume3"]
DR["驱动程序对象 NTFS<br/>MajorFunction 为处理函数表"]
end
H1 --> FO1
H2 --> FO2
FO1 --> DO
FO2 --> DO
DO --> DR
图 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 举例来说,对本地磁盘上的文件进行读取时,大致会经过下面这样一条路径。
flowchart TB
IOM["I/O 管理器组装 IRP<br/>IRP_MJ_READ + 堆栈位置"]
subgraph FSSTACK["文件系统一侧的堆栈"]
FLT["文件系统过滤器<br/>杀毒软件・加密・Procmon 等"]
NTFS["NTFS<br/>把文件内偏移量转换为卷上的位置"]
end
subgraph STSTACK["存储一侧的堆栈"]
VOL["卷/分区管理<br/>volmgr 等"]
DISK["磁盘类驱动程序<br/>disk.sys"]
PORT["存储端口/微端口<br/>storport 等"]
end
HW[("磁盘设备")]
IOM --> FLT
FLT --> NTFS
NTFS --> VOL
VOL --> DISK
DISK --> PORT
PORT --> HW
图 4:读取请求所经过的路径。文件系统一侧与存储一侧是各自独立的设备堆栈,NTFS 会重新发出一个面向存储一侧的下层 IRP,把工作委托出去
希望你从这张图中记住两件事。
- 过滤器是正当的住户。杀毒软件之所以能够检查所有的文件 I/O,靠的不是什么黑科技,而是因为「插在中间」这套机制本身就是操作系统官方提供的扩展点。19 Process Monitor 站在同一个位置记录全部 I/O。这也是排查「只有那个环境里文件访问才慢」这类问题时,最先应该怀疑的地方(详见第 6 回)。
- 请求的含义会逐层被翻译。应用说的是「这个文件从偏移量 4096 开始的 8KB」,NTFS 把它翻译成「这个卷上的这个簇」,存储堆栈再翻译成「这块磁盘上的这个扇区」。上层不知道下层的具体情况。这里有一点重要的补充:并不是同一个 IRP 会原封不动地从应用一路直达磁盘。文件系统一侧与存储一侧是各自独立的堆栈,NTFS 处理完面向文件的 IRP 之后,会作为结果,重新创建并发出一个面向卷(存储堆栈)的下层 IRP。如果文件发生了碎片化,一次读取甚至可能拆分成多个下层 IRP,请求的粒度与生命周期都会随着层级而变化。
4.3. 每个驱动程序的三种选择
flowchart TB
RECV["驱动程序接收到 IRP"]
Q{"如何处理这个请求"}
DONE["① 自己完成<br/>调用 IoCompleteRequest<br/>例如,用缓存中的数据立即返回"]
PASS["② 传递给下层设备<br/>调用 IoCallDriver<br/>例如,过滤器检查后放行"]
PEND["③ 挂起,pending<br/>返回 STATUS_PENDING 并把 IRP 放入队列<br/>例如,等待硬件响应"]
LATER["以中断等为契机<br/>稍后调用 IoCompleteRequest"]
UP["完成处理沿堆栈逆序向上<br/>依次调用各层的完成例程"]
RECV --> Q
Q --> DONE
Q --> PASS
Q --> PEND
PASS -->|"下层当场就完成了"| UP
PASS -->|"下层选择了挂起,挂起状态会一路传回调用方"| LATER
PEND --> LATER
DONE --> UP
LATER --> UP
图 5:接收到 IRP 的驱动程序面临的三种选择。三者并不互斥,「传递下去之后在下层被挂起」是最常见的路径,最终都会以 IoCompleteRequest 完成而告终
另外,这三者并不是互斥的选项。最常见的路径是「用②传递下去之后,某个层选择了③的挂起」,这种情况下 STATUS_PENDING 会原样传回中间的驱动程序以及最初的调用方。之后下层一旦完成,传递时注册过完成例程的各个层就会按逆序被依次唤起──也就是说,「传递」与「挂起」会在同一个 IRP 上叠加发生。
正是这三种选择,构成了 Windows I/O 灵活性的源泉。
- 缓存命中时当场完成就会很快(第 4 回的缓存管理器)。
- 可以插入任意多层放行式的过滤器(第 6 回的微过滤器)。
- 正因为可以挂起,等待慢速设备响应期间才不必占死一个线程(第 2 回、第 3 回的异步 I/O)。
这个系列剩下的全部内容,其实都只是这张图的脚注。
5. 追踪 ReadFile 的一次往返
登场对象和工具都已备齐,现在来完整追踪一次 ReadFile 调用的往返过程。这里以脱离缓存、一路打到磁盘的情形为例(命中缓存的情况留到第 4 回讲)。
sequenceDiagram
participant App as 应用线程
participant IOM as I/O 管理器
participant FS as 过滤器 + NTFS
participant ST as 存储堆栈
participant HW as 磁盘设备
App->>IOM: ReadFile → NtReadFile(系统调用)
Note over IOM: 根据句柄解析出文件对象,<br/>组装 IRP(IRP_MJ_READ)
IOM->>FS: IoCallDriver(送往堆栈最上层)
FS->>ST: 把位置翻译到卷上,<br/>发出面向存储堆栈的下层 IRP
ST->>HW: 发出读取命令
ST-->>FS: STATUS_PENDING(下层 IRP 处于挂起状态)
FS-->>IOM: 原始 IRP 也保持挂起状态返回<br/>(去程到这里就结束了)
Note over App: 同步 I/O:在此等待完成并进入休眠<br/>异步 I/O:控制权立即返回,可以做别的事
HW-->>ST: 中断通知「数据已读取完毕」
Note over ST: 从中断处理(ISR)<br/>转入 DPC 继续完成处理
ST->>FS: 完成下层 IRP(IoCompleteRequest)<br/>由 NTFS 一侧的完成例程接收
Note over FS: 当所需的下层 IRP<br/>全部完成后
FS->>IOM: 完成原始 IRP(IRP_MJ_READ)
Note over IOM: 逆序执行各层的完成例程,<br/>通过发往请求线程的 APC 确定结果
IOM->>App: 状态与字节数已确定(如事件通知等)
图 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 所做的事情,是从进程的句柄表中删除一个条目。文件对象拥有句柄计数(句柄的数量)和引用计数(来自内核内部的引用数量)这两个计数,二者是各自独立递减的。
sequenceDiagram
participant App as 应用
participant OB as 对象管理器
participant IOM as I/O 管理器
participant FS as 文件系统
App->>OB: CloseHandle(h)
Note over OB: 从句柄表中删除条目,<br/>句柄计数减一
alt 这是最后一个句柄
IOM->>FS: IRP_MJ_CLEANUP
Note over FS: 取消该文件对象上<br/>未完成的 I/O、释放锁
end
Note over OB: 但如果未完成的 I/O 或节(内存映射)等<br/>内核内部的引用仍然存在,<br/>文件对象就依然活着
alt 引用计数也归零了
IOM->>FS: IRP_MJ_CLOSE
Note over FS: 文件对象的收尾工作完成,<br/>到这里才算真正「关闭完毕」
end
图 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 都是微软免费分发的。可以从各自的下载页面(WinObj、Process 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_READ、FASTIO_READ 这类内核侧的词汇。只需追踪一次文件复制,IRP_MJ_CREATE → FASTIO_READ/IRP_MJ_READ → IRP_MJ_WRITE → IRP_MJ_CLEANUP → IRP_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.OpenHandle 和 RandomAccess 类,绕开 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、完成通知的四种方式、取消操作,以及「明明应该是异步却以同步方式返回」的陷阱。
相关文章
- Process Monitor(ProcMon)实战指南 —— 10 分钟定位「配置未生效」「ACCESS DENIED」问题
- Process Explorer / Handle / VMMap 实战 ── 从此刻的状态追查挂起・泄漏・「文件正在使用」问题
- 工业相机长期运行崩溃调查 - handle 泄漏篇
- 文件集成互斥控制基础知识 - 文件锁与原子 claim 的最佳实践
- 共享内存的陷阱与实务最佳实践
- C# async/await 实战判断表:Task.Run 与 ConfigureAwait 怎么选
- 网络驱动器与 UNC 路径的陷阱 ── 业务应用中处理文件服务器(共享文件夹)的实务
- MAX_PATH 与 Windows 路径・文件名的陷阱 ── 260 字符限制、保留名称、末尾句点、大小写
相关咨询领域
合同会社小村软件专注于 Windows 业务应用中围绕文件 I/O 的架构设计与故障排查(句柄泄漏、「文件正在使用中」、特定环境下的 I/O 延迟等)。
参考链接
-
Microsoft Learn, Windows Internals - Sysinternals. 《Windows Internals》(中译本《深入解析 Windows 操作系统》)一书的介绍页面。这是一本涵盖 Windows 内核架构与 I/O 系统等内部结构的经典著作,也是深入学习本系列所讲内容的一个出发点。 ↩
-
Microsoft Learn, I/O manager. 关于 Windows 内核模式 I/O 管理器管理应用程序与设备驱动程序所提供接口之间通信的机制;设备的运行速度与操作系统不一致,因此操作系统与驱动程序之间的通信主要通过 IRP(I/O 请求包)进行;以及 IRP 作为类似网络数据包、Windows 消息的存在,从操作系统传递给驱动程序、又在驱动程序之间传递的说明。 ↩ ↩2 ↩3
-
Microsoft Learn, I/O request packets. 关于发送给设备驱动程序的请求绝大多数都会被封装进 IRP;操作系统组件或驱动程序通过 IoCallDriver(接收指向设备对象的指针与指向 IRP 的指针)向驱动程序发送 IRP;IRP 通常由堆叠成设备堆栈的多个驱动程序依次处理,首先被送往堆栈最上层的设备对象;以及各驱动程序可以选择处理并完成 IRP,或者转发给下层驱动程序的说明。 ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Introduction to MS-DOS device names. 关于 MS-DOS 设备名是指向 NT 风格设备名的符号链接;用户模式的 Windows 应用通过 MS-DOS 设备名(驱动器号或 COM 端口名)访问设备,而驱动程序与内核则使用 NT 风格的名字;以及驱动程序通过 IoCreateSymbolicLink 创建从 \DosDevices\名字 指向设备的符号链接的说明。 ↩ ↩2 ↩3
-
Microsoft Learn, Naming files, paths, and namespaces. 关于 CON、PRN、AUX、NUL、COM1~COM9、LPT1~LPT9 被预留为文件名;Win32 命名空间中存在「文件命名空间」与「设备命名空间」,”\\.” 前缀代表访问 Win32 设备命名空间(例如 \\.\PhysicalDrive0);以及这些名字解析规则的说明。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Introduction to driver objects. 关于驱动程序加载时 I/O 管理器会创建 DRIVER_OBJECT 结构体;驱动程序对象持有指向驱动程序标准例程集合的入口(包含作为分发表的 MajorFunction 数组);以及 I/O 管理器利用这张表来调用对应请求的处理函数的说明。 ↩ ↩2 ↩3
-
Microsoft Learn, Introduction to device objects. 关于 DEVICE_OBJECT 结构体代表逻辑・虚拟・物理设备,作为 I/O 请求的目标;驱动程序通过 IoCreateDevice 创建设备对象;以及设备对象与创建它的驱动程序(驱动程序对象)相互关联的说明。 ↩ ↩2 ↩3
-
Microsoft Learn, Using files in a driver. 关于在内核中文件对象代表「已打开的文件(或设备)的一个实例」;每次打开文件都会创建一个文件对象,并保存打开时的上下文(当前字节偏移量等)的说明。 ↩ ↩2 ↩3
-
Microsoft Learn, File handles. 关于 CreateFile 返回的文件句柄是进程私有的,并与已打开的文件对象相关联;同一个文件多次打开会产生各自独立的句柄(及打开状态);以及句柄不再需要时应通过 CloseHandle 关闭的说明。 ↩ ↩2 ↩3
-
Microsoft Learn, Completing IRPs. 关于完成一次 I/O 操作是通过调用 IoCompleteRequest 实现的;完成时会依次调用由堆栈上层驱动程序注册的 IoCompletion 例程;以及请求的完成会在与发出不同的时机发生,直到最终把状态返回给请求方为止的整个流程的说明。 ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Synchronous and asynchronous I/O. 关于同步 I/O 中函数要等到 I/O 完成才会返回、线程会被阻塞等待,而异步 I/O(重叠 I/O)中发出请求的函数会立即返回、线程可以继续做其他工作;异步 I/O 需要以 FILE_FLAG_OVERLAPPED 指定方式打开句柄;以及存在多种接收完成通知方式的说明。 ↩ ↩2 ↩3
-
Microsoft Learn, IRP_MJ_CLEANUP. 关于接收到这个请求就表示「与目标设备对象相关联的文件对象的最后一个句柄已被关闭」;但由于存在未处理的 I/O 请求,文件对象的释放有时还未发生;以及这个 IRP 是在关闭句柄的那个进程的上下文中被发送的说明。 ↩ ↩2 ↩3
-
Microsoft Learn, IRP_MJ_CLOSE. 关于接收到这个请求就表示「文件对象的引用计数已归零,文件对象即将被释放」;该请求会在 cleanup 请求之后发出,但由于要等待未处理 I/O 完成,不一定会紧接着立即发生的说明。 ↩ ↩2 ↩3
-
Microsoft Learn, WinObj - Sysinternals. 关于 WinObj 是一款用于显示 NT 对象管理器命名空间的工具,可以浏览命名空间中的对象,包括设备对象与符号链接的说明。 ↩ ↩2 ↩3
-
Microsoft Learn, CreateFileW function. 关于 CreateFile 不仅可以打开文件,还能打开物理磁盘、卷、控制台、通信端口(COM 端口)、管道等设备并返回句柄;打开设备时使用 “\\.” 形式的名字;以及 FILE_FLAG_OVERLAPPED 等各种标志位含义的说明。 ↩ ↩2
-
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 等)的一览表,以及各请求分别代表什么含义、应由哪个驱动程序负责处理的说明。 ↩
-
Microsoft Learn, I/O stack locations. 关于 I/O 管理器会为分层驱动程序链中的每一个驱动程序在 IRP 内准备一个 I/O 堆栈位置;每个堆栈位置中存放着主/次功能代码及该请求的参数;以及各驱动程序通过 IoGetCurrentIrpStackLocation 获取属于自己的堆栈位置从而得知请求内容的说明。 ↩
-
Microsoft Learn, Device nodes and device stacks. 关于设备对象层层堆叠构成设备堆栈;IRP 首先被送往堆栈最上层的设备对象,各层进行处理或转发给下层;以及过滤驱动程序的设备对象会以插入堆栈中的形式存在的说明。 ↩ ↩2
-
Microsoft Learn, Filter Manager Concepts. 关于过滤管理器是 Windows 自带的一个内核模式驱动程序;微过滤器驱动程序可以通过对文件系统 I/O 请求的前置(pre)・后置(post)回调进行介入;以及每个微过滤器的介入位置(高度,altitude)决定其在 I/O 堆栈中的顺序的说明。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows I/O 的深层(第6回·最终回) ── 过滤器驱动程序与迷你过滤器:Procmon 与病毒扫描为何能够介入 I/O
本文是通过图解讲解 Windows 过滤器驱动程序与迷你过滤器的系列最终回。整理过滤器管理器与高度、pre/post 回调、Procmon 与杀毒软件能够检查全部 I/O 的机制,直至「唯独那个环境很慢」的排查步骤。
Windows I/O 的深层(第2回) ── 同步 I/O 与异步 I/O:OVERLAPPED 的真正含义
本文是通过图解讲解 Windows 同步 I/O 与异步 I/O(重叠 I/O)的系列第 2 回。整理 FILE_FLAG_OVERLAPPED 的含义、完成通知的四种方式、明明是异步却同步完成的条件、取消的正确做法,以及与 .NET 的对应关系。
Windows I/O 的深层(第5回) ── NTFS 的内部结构:从 MFT 理解文件系统
本文是通过图解讲解 NTFS 内部结构的系列第 5 回。从开发者视角整理 MFT 与文件记录、多数据流(Zone.Identifier)、硬链接与 8.3 短文件名、重解析点、两种日志,直至稀疏与压缩等内容。
Windows I/O 的深层(第4回) ── 缓存管理器:你的 WriteFile 何时才能到达磁盘
本文是图解 Windows I/O 系列的第 4 回,讲解缓存管理器:以文件映射方式实现的缓存、先读与延迟写入、FlushFileBuffers 与 FILE_FLAG_NO_BUFFERING 的使用场景划分,直至断电导致数据丢失的条件。
Windows I/O 的深层(第3回) ── I/O 完成端口(IOCP)与 .NET 线程池:async/await 的地下室
本文是通过图解讲解 I/O 完成端口(IOCP)的系列第 3 回。整理了将完成队列与线程数控制融为一体的设计、并发值与 LIFO 释放,直至 .NET 线程池与 async/await 续体的执行线程等内容。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
常见问题
汇总了咨询这一主题时常见的问题。
- 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 的行为。