更新记录(仅首版,2026年07月29日 发布)
- 首次发布
引用本文(DOI(已登记存档): 10.5281/zenodo.22175240)
以下 DOI 指向先前登记的存档,内容可能与当前正文不同。引用当前正文时,请使用本页网址。
Go Komura(2026)。《Windows I/O 底层原理(第 1 篇)——所有读写最终都会变成 IRP:I/O 系统全貌》。小村软件有限公司。 https://comcomponent.com/zh-CN/blog/windows-io-internals-architecture-irp/
- DOI(已登记存档)
- 10.5281/zenodo.22175240
- DOI(上次登记版本)
- 10.5281/zenodo.22175241
File.ReadAllText 的一行代码,或者 ReadFile 的一次调用。在这个函数返回之前的这段时间里,Windows 内部究竟发生了什么?
了解了这条流程,就可以把 Process Monitor 中出现的 IRP_MJ_READ、FASTIO_READ,调用了 CloseHandle 却仍未被释放的文件,以及只在网络驱动器或装有杀毒软件的环境中才改变的行为,放进同一套机制里来思考。
在“Windows I/O 底层原理”系列中,我们会把视野延伸到《Windows Internals》(中文版《深入解析 Windows 操作系统》)所讲的内核设计层面,不只讲 API 怎么用,还要讲“为什么会这样运作”。1 作为第 1 篇,本文用图梳理作为整个系列地基的名称解析、登场对象、请求与完成的流转。这不是一篇教你写驱动程序的文章,而是让应用程序开发者理解自己调用的 API 最终去了哪里。
前提知识: 只要在 C# 中用过 FileStream,或者在 C/C++ 中调用过 CreateFile / ReadFile,就能读懂本文。不需要驱动程序开发经验,内核侧的代码只作为概念图给出。阅读时间大致是:对照图通读全文约 25 分钟,只看第 1 章的结论约 2~3 分钟。
本系列的构成
| 篇 | 主题 |
|---|---|
| 第 1 篇(本文) | I/O 系统全貌——所有读写最终都会变成 IRP |
| 第 2 篇 | 同步 I/O 与异步 I/O:OVERLAPPED 的真正含义 |
| 第 3 篇 | I/O 完成端口(IOCP)与 .NET 线程池:async/await 的地下室 |
| 第 4 篇 | 缓存管理器:你的 WriteFile 何时才真正写入磁盘 |
| 第 5 篇 | NTFS 的内部结构:从 MFT 理解文件系统 |
| 第 6 篇 | 迷你过滤器的机制与用 Procmon 排查延迟 |
1. 先说结论
首先要抓住的是下面 3 点。
- Windows 的 I/O 是一套“用名称确定目标、用数据包传递请求”的机制。
CreateFile打开的不一定是磁盘上的文件,C:也只是指向 NT 设备名的符号链接。发送给设备驱动程序的请求,绝大多数都会以 IRP(I/O Request Packet,I/O 请求包)的形式在设备栈中流动。不过也存在不创建 IRP 的快速 I/O 这条近路(第 2 章、第 4 章、5.2 节)。2345 - 把“处理请求的东西”“请求的目标”“打开后的状态”分开来看。 驱动程序对象是处理函数的表,设备对象是请求的目标,文件对象保存的是一次打开所对应的状态。应用中的
HANDLE,是对文件对象的引用(第 3 章)。6789 - 发出与完成请求,和关闭句柄、释放引用,分别属于不同的阶段。 驱动程序会把完成 IRP、向下层传递、挂起这几种处理组合起来使用。同步 I/O 只是“完成之前不会返回”这样一个保证,并不是另一套 I/O 机制。另外,最后一个句柄关闭时的 cleanup,与引用全部消失时的 close,也需要区分(第 4~6 章)。310111213
按目的选择读法
| 想了解的内容 | 阅读位置 |
|---|---|
| 调用 API 之后的全貌 | 第 1 章 → 第 2 章(名称解析)→ 第 5 章(ReadFile 的一来一回) |
| 驱动程序对象、设备对象、文件对象与 IRP 的区别 | 第 3 章的对照表 → 第 4 章 |
| 句柄泄漏以及“已关闭却仍在使用中”的原因 | 3.3 节 → 第 6 章 |
| 在自己的电脑上验证的方法 | 第 7 章。用 WinObj 看命名空间,用 Process Monitor 观察 I/O |
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 40 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
2. “一切看起来都像文件”的真相
2.1. 传给 CreateFile 的名称去了哪里
打开文件或设备这个操作的入口是“名称”。本地路径 C:\project\report.csv、UNC 路径 \\server\share\data.csv、设备写法 \\.\COM3,都可以传给同一个 CreateFile。14
支撑这种一致性的,是内核中由对象管理器管理的命名空间。带名称的设备、事件、共享内存节等,都在一个树状的命名空间中管理。使用 Sysinternals 的 WinObj,可以直接查看这个命名空间。15
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 之下
在图 1 中,请把下面这两者分开来看。
| 名称的一侧 | 示例 | 作用 |
|---|---|---|
| Win32 应用使用的名称 | 驱动器号、COM 端口名 | 来自应用的入口 |
| 内核使用的名称 | \Device\HarddiskVolume3 这类 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 的名称解析。前半段是对象管理器的工作,到达设备之后的后半段是文件系统的工作
区分到设备为止的名称与文件系统内部的名称
在图 2 的前半段,Win32 路径被转换成 NT 形式,再顺着链接到达卷的设备对象。之后剩下的 \project\report.csv 的解析,由 I/O 管理器发出 IRP_MJ_CREATE,交给文件系统驱动程序(NTFS)完成。
理解了这个分界,下面这些写法也能按同一条流程来读。
\\.\是指向 Win32 设备命名空间的写法。\\.\PhysicalDrive0和\\.\COM10不经过驱动器号,直接指定设备名的链接。514CON、NUL是保留的 MS-DOS 设备名。 即使写在路径里也可能被解析到设备一侧,因此不能当作普通文件名使用。5 实际开发中的注意事项,在“MAX_PATH 与 Windows 路径、文件名的陷阱”中讨论。- 使用 UNC 路径时,解析到的设备会不同。
\\server\share会解析到网络重定向器的设备(\Device\Mup),再往后由 SMB 客户端把请求通过网络送出去。本地和 UNC 行为不同的原因,可以从这个解析目标的差异去想(参见“网络驱动器与 UNC 路径的陷阱”)。
\?? 与 \GLOBAL?? 不是同一个东西
\?? 并不只是某个真实存在的目录的别名。它是一个检索顺序的入口:先查找每个登录会话各自的本地 DOS 设备映射,找不到再查 \GLOBAL??。图 1 中画出的只有全局一侧的 \GLOBAL??。
用 net use 或 subst 创建的驱动器号会进入本地一侧。因此即使在同一台电脑上,不同登录会话能看到的驱动器也不同,服务有时就看不到用户的网络驱动器。
把上面的内容归纳起来:文件和设备之所以能用同一套 API 处理,是因为可以从名称到达设备对象,并把之后的请求以统一的形式传递下去。接下来梳理表示这个目标和打开状态的几种对象。
3. 登场的是三种对象
先把平时在 Win32 / .NET 代码中看得见的东西与内核侧与之对应的东西并列出来。后面遇到术语拿不准时,请回到这张表。
| 在 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 错误代码再返回上层 |
这张表是为了理解对应关系,并不意味着从 ReadFile 到磁盘始终只有同一个 IRP 在流动。不创建 IRP 的路径见 5.2 节,会另行发出下层 IRP 的边界见 4.2 节。
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 结构体的概念图。
// 概念图。实际是内核中的 C 结构体
class DriverObject
{
// 索引是 IRP_MJ_XXX,共 28 种
public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// 如果是 ntfs.sys,MajorFunction[IRP_MJ_READ] 中放的就是“NTFS 的读取处理”
3.2. 设备对象——请求的目标
驱动程序会为自己管理的每个设备创建一个设备对象(DEVICE_OBJECT)。它就是 I/O 请求的目标。7
不过,它与物理设备不一定是一一对应的。像卷(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:三种对象的关系。句柄经由句柄表指向文件对象,文件对象连到设备,设备再连到驱动程序
“把同一个文件打开两次”和“复制句柄”不一样
| 操作 | 文件对象 | 文件指针 |
|---|---|---|
| 分别打开同一个文件 | 按打开次数分别创建 | 各自独立 |
用 DuplicateHandle 复制句柄 |
引用同一个对象 | 共享 |
即使是指向同一个文件的句柄,是重新打开的还是复制出来的,所共享的状态也不同。
句柄泄漏和共享冲突,同样要从打开状态来想
文件句柄一旦泄漏,文件对象以及它背后的资源就会一直被引用。排查时要数的是句柄表中的条目。Process Explorer 和 handle.exe 显示的信息对应的就是它(参见“Process Explorer / Handle / VMMap 实战”,以及调查手记“工业相机长期运行崩溃调查 - 句柄泄漏篇”)。
共享冲突(sharing violation)是把已有打开状态的共享模式与新的 CreateFile 请求相比对后判定的。互斥控制的实务做法,在“文件集成中互斥控制的基础知识”中讨论。
4. IRP——I/O 请求会被装成包裹
4.1. 为什么要做成数据包
I/O 管理器会把打开、读取、写入这类请求装进 IRP(I/O Request Packet,I/O 请求包),再交给驱动程序。发往设备驱动程序的请求,绝大多数都是这种形式。3
做成数据包的理由,是为了让请求的发出与完成能够分离。磁盘之类的设备不会以和 CPU 相同的速度运行。只要能把请求作为独立的数据包保存下来,就可以在发出之后、在另一个时机让它完成。2
IRP 内部也分成整体信息和给各个驱动程序的指示两部分。17
| 区域 | 保存的内容 |
|---|---|
| 头部 | 整个请求的信息 |
| I/O 堆栈位置 | 每个途经驱动程序对应的请求种类和参数 |
I/O 堆栈位置会按照预计途经的驱动程序数量排成一列。每个驱动程序读取属于自己的那块区域,判断自己被要求做什么处理。
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 篇讨论。
并不是同一个 IRP 一路穿到磁盘
应用发出的请求是“这个文件从偏移 4096 起的 8KB”。NTFS 把它对应到卷上的簇,存储栈再对应到磁盘的扇区。上层不需要知道下层的细节就能发出请求。
这里的关键在于,文件系统一侧与存储一侧是两个不同的栈。NTFS 为了处理发往文件的 IRP,会新建并发出发往卷的下层 IRP。并不是把同一个 IRP 原封不动地从应用一路传到磁盘。
对于碎片化的文件,一次读取还可能被拆分成多个下层 IRP。请求的粒度和生命周期,需要按层分开来考虑。
4.3. 每个驱动程序的三种选择
收到 IRP 的驱动程序,其处理基本上可以归纳为下面 3 种。310
| 处理 | 主要动作 | 示例 |
|---|---|---|
| 自行完成 | 用 IoCompleteRequest 让它完成 |
用手头的缓存满足请求 |
| 传给下层驱动程序 | 用 IoCallDriver 发往下一个设备 |
过滤器检查之后转发 |
| 挂起 | 返回 STATUS_PENDING,之后再完成 |
等待硬件的响应 |
flowchart TB
RECV["驱动程序收到 IRP"]
Q{"如何处理这个请求"}
DONE["(1) 自行完成<br/>调用 IoCompleteRequest<br/>例如用缓存中的数据立即作答"]
PASS["(2) 传给下层设备<br/>调用 IoCallDriver<br/>例如过滤器检查后放行"]
PEND["(3) 挂起(pending)<br/>返回 STATUS_PENDING 并把 IRP 放入队列<br/>例如等待硬件响应"]
LATER["以中断等为契机<br/>稍后调用 IoCompleteRequest"]
UP["完成处理沿栈逆序向上<br/>(各层的完成例程被调用)"]
RECV --> Q
Q --> DONE
Q --> PASS
Q --> PEND
PASS -->|"下层当场完成了"| UP
PASS -->|"下层把它挂起了<br/>(挂起会一路传到调用方)"| LATER
PEND --> LATER
DONE --> UP
LATER --> UP
图5:收到 IRP 的驱动程序的三种选择。这三者并不互斥,最常见的路径是“传给下层之后在那里被挂起”,而且不管哪一种,最后都以 IoCompleteRequest 的完成收尾
“转发”和“挂起”会在同一个请求上叠加
这三者并不是互斥的选项。典型情形是上层驱动程序把请求传给下层,而下游某处把它挂起。这时 STATUS_PENDING 会穿过中间的驱动程序传到调用方。
之后下层让请求完成时,转发时注册过完成例程的各层会按逆序被回调。请把向下传递请求的处理,和把结果向上返回的完成处理,分开来读。
这种组合正是本系列所讲机制的基础。能靠缓存立即完成就会更快(第 4 篇),中途还可以叠加过滤器(第 6 篇)。正因为挂起和完成可以分开,异步 I/O 才能在等待慢速设备期间让线程去做别的工作(第 2 篇、第 3 篇)。
5. 追踪 ReadFile 的一来一回
这里追踪的是未命中缓存、需要到磁盘上读取时的 ReadFile。看图时,请先看发出请求的“去程”,再看数据读完之后的“回程”。
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. 去程和回程是两件不同的事
图 6 的分界点是 STATUS_PENDING。存储驱动程序向硬件发出命令之后,就把请求挂起并返回。“去程”到此告一段落,之后以中断等为契机,“回程”的完成处理才继续推进。10
| 阶段 | 发生的事情 |
|---|---|
| 发出 | 从句柄求出目标,发送原 IRP 和必要的下层 IRP |
| 挂起 | 如果硬件的处理尚未结束,就让请求保持未完成状态 |
| 下层完成 | 读取结束后,存储一侧的下层 IRP 完成 |
| 原请求完成 | 需要的下层 IRP 全部完成后,原 IRP 也随之完成,状态和字节数确定下来 |
同步与异步的区别,在于调用方何时返回
内核中并不存在专供同步 I/O 使用的另一套机制。同步 I/O 只是“完成之前调用不会返回”这样一个保证。 这里说的等待完成只在请求被挂起时才发生,如果请求当场就能完成,不必等待稍后的完成就能返回结果。11
在 Win32 中,通过打开句柄时的 FILE_FLAG_OVERLAPPED 来选择按同步还是异步处理。不过,OVERLAPPED 结构体本身是每个正在进行的操作各需要一个。要把“句柄的设置”和“单个操作的状态”分开来考虑。
理解了这个区别,就能把“为什么异步与否要在打开时决定”“明明是异步却当场完成是怎么回事”接到第 2 篇。.NET 的 async/await 之所以不必仅仅为了等待 I/O 而一直占着线程,原因同样在于发出与完成的分离(参见“C# async/await 实战判断表”)。
5.2. 也有例外——不创建 IRP 的近路
并不是所有 I/O 都会变成 IRP。 同步读写已在缓存中的文件数据时,存在一条称为快速 I/O 的近路,它不组装 IRP,直接在缓存之间复制数据。
在 Procmon 中启用详细输出后,Operation 列中以 FASTIO_ 开头的行就对应这条路径。无法使用这条近路的条件,以及它与缓存管理器的关系,在第 4 篇讨论。
因此,排查时要同时考虑“使用 IRP 的基本路径”和“不创建 IRP 的近路”这两条。不能仅凭调用了 ReadFile 就断定一定创建了 IRP。
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: 取消该文件对象的未完成 I/O<br/>并释放锁
end
Note over OB: 但只要未完成 I/O 或节(内存映射)等<br/>内核内部的引用还在<br/>文件对象就依然活着
alt 引用计数也归零了
IOM->>FS: IRP_MJ_CLOSE
Note over FS: 文件对象的收尾处理完成<br/>到这时才算“关闭完毕”
end
图7:cleanup(最后一个句柄已关闭)与 close(引用也全部消失)这两个阶段
6.1. 区分最后一个句柄和最后一个引用
| 通知 | 发生了什么 | 可能还残留的东西 |
|---|---|---|
IRP_MJ_CLEANUP |
该文件对象的最后一个句柄被关闭了 | 未完成 I/O 等带来的引用 |
IRP_MJ_CLOSE |
文件对象的引用计数归零了 | 文件对象已进入被释放的阶段 |
IRP_MJ_CLEANUP 的文档明确写道,如果还残留着未完成的 I/O 请求,文件对象可能尚未被释放。12 IRP_MJ_CLOSE 会在其之后发出,但不一定紧跟在 cleanup 之后。13
6.2. 排查“正在使用中”时,也要看句柄背后的东西
内存映射拥有独立于文件句柄的生命周期。 只要映射出的节还持有对文件对象的引用,即使关闭了原来的句柄也不会被释放。要一直确认到视图的解除映射和节的关闭(参见“共享内存的陷阱与实务最佳实践”)。
即使找不到句柄,排查也没有结束。 已映射的映像等内核内部的引用,仍可能把文件占着。Process Explorer 的搜索之所以不只针对句柄,还把 DLL(已映射的文件)也纳入范围,正是这个原因。
在 .NET 中,最终会被关闭和在需要的时点关闭,也是两回事。 如果指望 SafeFileHandle 或终结器迟早会帮你关闭,忘记关闭的句柄就会拖延 cleanup,让共享冲突和锁的持有时间延长。
7. 亲手验证
命名空间和 I/O 的流转,可以在拥有管理员权限的 Windows 电脑上观察。请先用安全的本地文件,追踪一下打开、读取、写入、关闭这几个操作。
7.1. 准备观察所需的东西
| 需要的东西 | 准备与注意事项 |
|---|---|
| 管理员权限 | Process Monitor 会加载内核驱动程序,因此要以管理员身份运行。WinObj 也有一些对象不是管理员就看不到 |
| Sysinternals 的工具 | 使用微软免费提供的 WinObj 和 Process Monitor。也可以用 Sysinternals Suite 一次性获取。解压后直接运行 exe 即可,不需要安装程序 |
| 观察的操作 | 用记事本保存一个文件、复制一个小文件,这种程度就足够了。不要使用业务用共享文件夹或生产机器,在手边的本地磁盘上试 |
7.2. 用 WinObj 查看从名称到实体的链接
在 WinObj 中打开 GLOBAL?? 目录,就能确认 C: 是指向 \Device\HarddiskVolumeN 的符号链接。接着查看 \Device 之下,就能看到驱动程序创建的设备对象的真实名称。这是在实际电脑上核对图 1 中名称与链接的步骤。15
7.3. 用 Procmon 区分 IRP 与快速 I/O
在 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。带着读取路径以及从 cleanup 到 close 的阶段意识去追踪,它就不再只是一串操作名的列表。
Procmon 的实务用法,汇总在“Process Monitor(ProcMon)实战指南”中。
7.4. 把 .NET 的选项对应到 Win32 的标志
Windows 上 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,以“句柄 + 指定偏移的 I/O”这种形式来处理。理解了这张表的右侧,就能根据行为来选择左侧的选项。
8. 小结
Windows 的 I/O,按名称解析 → 打开状态 → 发出请求 → 完成 → 释放引用的顺序去追,就能理清。
- 解析名称、确定目标。
C:是符号链接,\\.\是指向 Win32 设备命名空间的写法,UNC 则解析到重定向器。文件和设备能用同一套 API 处理,其地基就在于这种名称解析的一致性。45 - 区分三种对象。 驱动程序对象是处理函数的表,设备对象是目标,文件对象是一次打开所对应的状态。
HANDLE引用的就是该文件对象。6789 - IRP 运送请求,完成可以在另一个时机推进。 绝大多数请求会以 IRP 的形式送入设备栈,各驱动程序把完成、转发、挂起组合起来使用。下层 IRP 与原 IRP 的生命周期各不相同,而快速 I/O 还存在不创建 IRP 的路径。231810
- 同步 I/O 与异步 I/O,要从发出与完成的关系来想。 同步 I/O 是完成之前不返回的保证。对于被挂起的请求,发出,与以中断等为契机推进的完成,是两件不同的事。1110
- 归还句柄与释放对象并不是一回事。 cleanup 对应最后一个句柄消失的阶段,close 对应最后一个引用消失的阶段。排查“已关闭却仍在使用中”时,这个区分很有用。1213
接下来是第 2 篇“同步 I/O 与异步 I/O:OVERLAPPED 的真正含义”。 那一篇讨论从应用一侧利用发出与完成分离所需的 FILE_FLAG_OVERLAPPED、完成通知的 4 种方式、取消,以及“本应是异步却以同步方式返回”的条件。
相关文章
- Process Monitor(ProcMon)实战指南——10 分钟定位“配置未生效”“ACCESS DENIED”
- Process Explorer / Handle / VMMap 实战——从此刻的状态追查挂起、泄漏、“文件正在使用中”
- 工业相机长期运行崩溃调查 - 句柄泄漏篇
- 文件集成中互斥控制的基础知识 - 文件锁与原子 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 内核架构与包括 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
-
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, CreateFileW function. 说明 CreateFile 不仅能打开文件,还能打开物理磁盘、卷、控制台、通信端口(COM 端口)、管道等设备并返回句柄;打开设备时使用 “\\.\” 形式的名称;以及 FILE_FLAG_OVERLAPPED 等各种标志的含义。 ↩ ↩2
-
Microsoft Learn, WinObj - Sysinternals. 说明 WinObj 是显示 NT 对象管理器命名空间的工具,可以浏览命名空间中的对象,包括设备对象和符号链接。 ↩ ↩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 自带的内核模式驱动程序;迷你过滤器驱动程序可以用前置(pre)和后置(post)回调介入发往文件系统的 I/O 请求;各迷你过滤器的介入位置(高度)决定了它们在 I/O 栈中的顺序。 ↩
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows I/O 底层原理(第 6 篇·完结篇)——迷你过滤器的机制与用 Procmon 排查延迟
讲解迷你过滤器监视和控制文件 I/O 的机制。梳理 FltMgr、高度、pre/post 回调与 fltmc 的读法,汇总用 Procmon 定位慢操作的步骤,以及排除项设置和 Dev Drive 的注意事项。
Windows I/O 底层原理(第 4 篇)——缓存管理器:你的 WriteFile 何时才真正写入磁盘
本文是图解讲解 Windows 缓存管理器的系列第 4 篇。梳理以文件映射方式实现的缓存、预读与延迟写入、FlushFileBuffers 与 FILE_FLAG_NO_BUFFERING 的取舍,以及断电导致数据丢失的条件。
Windows I/O 底层原理(第 2 篇)——同步 I/O 与异步 I/O:OVERLAPPED 的真正含义
本文是图解讲解 Windows 同步 I/O 与异步 I/O(重叠 I/O)的系列第 2 篇。梳理 FILE_FLAG_OVERLAPPED 的含义、完成通知的 4 种方式、本应异步却同步完成的条件、取消的正确做法,以及与 .NET 的对应关系。
Windows I/O 底层原理(第 5 篇)——NTFS 的内部结构:从 MFT 理解文件系统
本文是图解 NTFS 内部结构的系列第 5 篇。从开发者视角梳理 MFT 与文件记录、多数据流(Zone.Identifier)、硬链接与 8.3 短名、重解析点、两种日志,以及稀疏与压缩。
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 做的是“归还一个句柄”,而不是“关闭文件”。内核中的文件对象上有两个计数:句柄的数量(句柄计数),以及来自内核组件的引用数量(引用计数)。最后一个句柄关闭时,文件系统会收到 IRP_MJ_CLEANUP,但只要未完成的 I/O 或内存映射文件的节等内核内部的引用还在,文件对象本身就会继续存活,直到引用计数归零才会发出 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 的行为。