深入Windows I/O管理器:详解读取I/O请求的初始化过程
摘要
一篇深度技术文章,详细讲解Windows内核I/O管理器如何初始化读取I/O请求——从通过NtReadFile解析目标文件对象,到选择请求路径并分派给驱动程序。
<p><a href="https://lobste.rs/s/jwidjn/inside_windows_i_o_manager_deep_dive_into">评论</a></p>
查看缓存全文
缓存时间: 2026/10/06 19:27
# 深入 Windows I/O 管理器:详解读取 I/O 请求的初始化过程
来源:https://winware31.blogspot.com/2026/10/inside-windows-io-manager-deep-dive.html
## **简介:**
如果你是那种经常调试和逆向各种不同 Windows 程序的人,你可能已经注意到它们之间一个共同点,尽管它们在任何方面都毫不相关。这个共同点是,其中绝大多数程序在某个阶段都会使用一个著名且文档完备的 ***Win32*** API,即由模块 ***KERNELBASE.dll*** 导出的
***ReadFile( )***
对于不从事 Windows 原生编程的人来说,他可能认为 ***ReadFile( )*** 顾名思义仅仅用于从文件中读取数据(文件的传统含义指的是文件系统中的文件)。虽然这不完全错误,但这并不是完整的图景。在继续之前,让我先阐明在 Windows 世界中"文件"的完整定义。简而言之,文件就是一个 I/O 端点,终端用户可以向它发送输入,并从中接收输出。文件系统文件只是其中一种情况,例如,文件系统目录也被视为文件,因为它们是有效的 I/O 端点;命名管道(named pipes)和邮件槽(mailslots)同样是文件;甚至设备对象的已打开实例也被视为文件(设备对象和设备栈超出了本文的范围,如果你想了解这些概念,请查阅官方 [MSDN](https://learn.microsoft.com/en-us/windows-hardware/drivers/gettingstarted/device-nodes-and-device-stacks) 文档)。
从绝大多数 I/O 端点获取数据,是通过系统提供的一个已文档化 API 实现的,正如你可能已经猜到的,它就是我们之前提到的同一个 ***kernelbase!ReadFile( )***;此外还有一个异步版本名为 ***ReadFileEx( )***,它依赖 ***APC***(异步过程调用)来通知调用者读取操作已经结束。上述每个 I/O 端点都以文件对象的形式呈现给用户模式调用者,而文件对象则由内核通过一个名为 **nt!\_FILE\_OBJECT** 的预定义且文档完备的数据结构来表示。因此,无论是设备对象、命名管道还是邮件槽,对于用户模式调用者而言,它都是一个文件对象。
处理 I/O 请求是由内核执行体(executive)层完成的一项任务,需要完成许多步骤和子任务。由于执行体层被划分为多个管理器;处理 I/O 操作被委派给一个独立的管理器负责,它被称为输入/输出管理器,简称 I/O 管理器。这个管理器精确地知道如何区分同步/异步操作、请求应发送到的目标、涉及哪些驱动程序、是否可以遵循快速 I/O(Fast I/O)路径来服务请求,以及许多其他有助于它正确完成请求的方面。所以,正如你所看到的,处理 I/O 操作并不像你可能想象的那样是一个直接简单的任务,但它却是任何操作系统为确保正常运行都必须良好实现的主要任务。
在本文中,我将解释 I/O 管理器在初始化一个 I/O 操作时所遵循的不同步骤——从确定正确的目标、选择正确的路径,最后将请求发送给正确的驱动程序进行处理。为了简化说明,我选择只关注读取请求,因为主要步骤对所有类型的请求都是共通的。
## **确定请求的目标:**
***nt!NtReadFile( )*** 是 ***ntdll!NtReadFile( )*** 的内核对应实现,后者由 ***kernelbase!ReaFile( )*** 内部调用,而后者只是一个包装器,它内部将剩余的工作转发给原生的非文档化层。该例程以 ***HANDLE*** 的形式接收读取请求的目标,因为用户模式调用者被禁止直接通过指针访问对象。首先要做的事情是:将调用者提供的 ***hFile*** 参数传递给对象管理器定义的例程 ***nt!ObReferenceObjectByHandleWithTag( )***,以获取调用者所引用对象对应的对象。该例程会在当前进程的句柄表中执行查找操作,并获取对应的 ***nt!\_HANDLE\_TABLE\_ENTRY***,该条目持有对象主头 ***nt!\_OBJECT\_HEADER*** 的起始内存地址,这足以获得目标对象的指针,其类型为 ***nt!\_FILE\_OBJECT***,因为 ***nt!NtReadFile( )*** 是针对 I/O 端点操作的。在这一步中仅请求 ***FILE\_READ\_ACCESS***,因为调用者想要从所提供的目标中读取数据。关于句柄表查找的完成方式的更多信息,请查看我之前的文章[此处](https://winware31.blogspot.com/2026/05/deep-dive-into-object-creation-flow-in_01568126117.html)。
[](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjarGu9pqnp6OGHOTzmnJ286wOcoj6f1pi7Wg7dJNiYUgDcKkpYMJ1kce172Glb8tWmgPcLALFHhKQECHmIgNHDBldplBi_R6v-3lLvCX-EAwbpe42gvoGB7MC5vFGZG_PVUHIlMSuc50AxlGuQ0G-ZCAqa9t1aRjEOlZVWkl3Gfj81M50A3DJKIXCEdRMJ/s1150/target_0.jpg)
仅获取与调用者提供的 ***hFile*** 参数对应的 ***nt!\_FILE\_OBJECT*** 指针,并不足以正确处理读取请求。文件对象并不是 I/O 操作的真正目标;它们只是这些真正目标的已打开实例,这些真正目标被称为设备对象,并由内核使用文档完备的数据结构 ***nt!\_DEVICE\_OBJECT*** 来表示。单个文件对象可以代表其关联设备对象本身的已打开实例,也可以代表其私有命名空间中某个子项的已打开实例;然而,无论请求是发送给设备对象本身还是其任何一个子项,从内核的角度来看,它们共享同一个目标,即该设备对象。为了获取与前述目标文件对象相关联的设备对象,I/O 管理器提供了 ***nt!IoGetRelatedDeviceObject( )***。确定应使用哪个设备对象并不像你想的那么简单,因为与文件对象关联的设备对象不止一个。正如其声明所示,***nt!\_FILE\_OBJECT*** 结构体有一个名为 ***DeviceObject*** 的成员,它是指向 ***nt!\_DEVICE\_OBJECT*** 结构体的直接指针;它还有一个名为 ***Vpb***(卷参数块)的成员,它是指向名为
***nt!\_VPB*** 的内核数据结构的指针,仅当目标文件对象位于已挂载的卷中(即它是文件系统文件或目录)时才会使用。在这种情况下,文件对象的 ***DeviceObject*** 成员指向存储栈(storage stack);要找到其 I/O 请求的真正目标(即卷设备对象),你必须从关联的
***Vpb*** 的 ***DeviceObject*** 成员中获取它。仅当满足以下两个条件时,目标文件对象对应 ***nt!\_FILE\_OBJECT*** 中找到的直接 ***DeviceObject*** 指针才会被用作 I/O 目标。第一,***Vpb*** 成员或其 ***DeviceObject*** 成员为 ***NULL***。第二,目标文件对象对应 ***nt!\_FILE\_OBJECT::Flags*** 成员中设置了 ***FO\_DIRECT\_DEVICE\_OPEN*** 标志,但 ***Vpb*** 成员或其 ***DeviceObject*** 成员为 ***NULL***(在这种情况下,我指的是目标文件对象对应 ***nt!\_FILE\_OBJECT*** 的 ***DeviceObject*** 成员的成员,而不是文件对象本身的成员)。最后需要记住的一点是,与文件对象关联的 ***Vpb*** 的优先级高于与该文件对象的 ***DeviceObject*** 关联的 ***Vpb***。
[](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgXinCxKmZGIAc12MmDicr1UfHs3-ErElTrIMhZfqUKZNHAjVL-D5A5a6F36llyl82nLQ62gSYpgfhgPeFF8ffTG6ywPyQSOpXLR5I0Uc6tZiYA7-49KHAAuFDjnsR4ybWBMYJ51H4MrkyJFd3w250TjN_9did2c3oprsqGpJphvN7Ig39o0_ZnMUuvEZ8O/s1170/target_1.jpg)
确定了用作读取请求最终目标的设备对象后,我们必须遍历它的设备栈以获取最顶层的设备对象,从而不违反 I/O 管理器的规则,即 I/O 请求必须发送到设备栈的顶部,而不是中间。每个对应的 ***nt!\_DEVICE\_OBJECT*** 结构体都有一个名为 ***AttachedDevice*** 的成员,它代表设备栈中紧邻当前设备对象之上的设备对象。利用这个成员,我们可以轻松地获取涉及为该目标文件对象处理 I/O 请求的设备栈顶端的设备对象。
## **验证用户模式参数:**
与任何其他系统服务例程(即 ***NTDLL.dll*** 中实现的 ***syscall*** 存根的内核对应实现)一样,如果请求是由用户模式线程发出的,***nt!NtReadFile( )*** 必须验证调用者提供的参数。为了确定请求来源是用户线程还是其他线程,会调用 ***nt!KeGetCurrentThread( )*** 来获取与当前线程(即发出当前读取请求的调用者)对应的 ***nt!\_ETHREAD***,然后检查其 ***PreviousMode*** 成员;值 ***0x1*** 是为用户模式保留的。***IoStatusBlock*** 参数是第一个需要验证的;该参数代表输出缓冲区,读取请求的最终状态将写入其中,这就是 ***nt!NtReadFile( )*** 必须确保其两个成员 Status 和 Information 都可写的原因。
[](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiWILY2rFUajcrAtE3hMVQFTJowyfhZpzoG-5-HcdW3ru0PJxxNXzWo5E635VRVNa7i5VhCAlJXfQUz9SDwVfw_NU7tfPu2T6MbSRc9cF5esfznw0178csAmfYZQg3BldKA80c7_p4FyRFvGANra9LJqZq8FqcznrQyvRNiaKCxOzzDIGNr0tKYpRu_uPnx/s1209/validate_user_parameters_0.jpg)
[](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEjROj43dtWkZ5M-kX84zbBz7pTaHeiNmE1pUJJn7aqmUjD1JDGHddSFs_yxd1n8CNdZr_-hf97_DvHxzTszJL1t-p_tAWxkEEfGaNpTUVgpbp4CUG7GqTGaQtDtuUUArOieroyqXNKbRoS35laRSpYPHSD6a_ckoDDRnb0RZ6JgJFJHFbreoH2_Iweljne1/s1111/validate_user_parameters_3.jpg)
下一步是验证调用者提供的内存范围,该范围是读取数据最终必须写入的位置。这个范围由调用者提供的两个参数界定,分别名为 ***Buffer*** 和 ***Length***。整个范围必须位于用户地址空间中,且必须是可写的。为确保可写性,系统采用了一个巧妙的技巧;由于页面保护标志是按页设置的,并且不可能看到同一个页面拥有多个保护属性;系统只需检查该范围所覆盖的每个页面的首个字节是否可写即可。最后需要解释的一点是,***Length*** 参数不能超过某个限制,以避免它与 ***Buffer*** 参数相加得出该范围内最后一个地址时发生溢出。
[](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhVViZqQNtnj-2UzZvgtWbjl911Anwty7h1LwmtTIw99EH95GR4JbSRjUZNbRKiq_NYSxzvE-R8zvEkk1x_JWuN5YL8KgRMU9GydoGI5bioPpWKLXz4PdD5FYNHsLKRZkW05jTyTnADBYhMqrcRidsD0w54GjQGsPfT9LWnHG6YknPphrWU1IbQQYMbPAc/s1209/validate_user_parameters_1.jpg)
[](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiqa54LTh9s2nVBNJ7CQVWHhvTuX5PU42XS53paVbWnUS_dUfVUsDfiGyQD5NhHcH3F-LDDrdzQhK3ardk9q7mDaC9cG9OPSm2tOiv5U98-KdQuqadzGkcgcWleOLafMCCpthTze_20hafFhFV-NY249WflHSMnWIpj8dCKh_7oxAN5yduXeJE1DFvDMyGe/s1254/validate_user_parameters_2.jpg)
在验证了输出参数是有效的用户模式子范围并确保它们全部可写之后,下一个需要特别注意的参数是 ***ApcRoutine***,它代表一个用户模式 ***APC***,当读取操作需要异步完成时,被用作完成机制。如果发起读取请求的调用线程当前正在 32 位进程(即 ***WOW64*** 进程)的上下文中运行,那么 ***ApcRoutine*** 会通过将其第一位设置为 1 来编码。接下来,***nt!NtReadFile( )*** 会确保目标文件对象尚未关联到任何 I/O 完成端口(I/O completion port),然后再继续执行;根据当调用者传递非 NULL 的 ***ApcRoutine*** 参数且目标文件对象关联到 I/O 完成端口时系统的行为,可以说文件 I/O 端点不能同时依赖两种异步完成机制。关于 WOW64 内部实现的更多信息,请查看我之前的文章[此处](https://winware31.blogspot.com/2026/06/wow64-implementation-details.html)。
[](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEj-hpIb7XtXNksJlEAVODIcmet2tREsUIPMS-Cc96QJ14-aXF6ZhqcJ17rClm26RO41e6Zv4H6HwvQCiU-DoAx6H9wbE8DkRWcRz9mDm_2R5KC1Y1Oy3olWj2K6xiAO7yFoe6b-9r2XgN9rDsirPcXUbBisopabScEO0tHHGFTdQdZXLoqr6zOX4WtArL5z/s1101/validate_user_parameters_4.jpg)
[](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiHUMCV-1yfiR1FJuPtWjG_SyPMbjVIMCYVkPDE1yZsuhWFxu2FpgYJ4vjK-YIIa8jC9kUj7K9AsURfOJnqZnx9Z32zgefAG44o78qSUw91LLAd5L9Y6nOqrMyt2ABLdeH6YkwX1yPAvfG273sn6tGaIGr8Og_DTNt0eL-ckT8dHP-SAxVBg950DrboA2Ga/s1150/validate_user_parameters_5.jpg)
***注意:*** ***ApcRoutine*** 参数仅在目标文件对象被以异步 I/O 操作方式打开时才会被编码(其对应的 ***nt!\_FILE\_OBJECT::Flags*** 成员中未设置 ***FO\_SYNCHRONOUS\_IO*** 标志)。否则,I/O 管理器将不会使用 ***ApcRoutine***,因为当前线程将被置于等待状态,直到读取结束。
现在,是时候验证各个参数的**对齐(alignment)**了。首先要确保的是 ***ByteOffset***,它是指向 ***LARGE\_INTEGER*** 的指针,必须按照 ***4*** 字节对齐;否则,将触发数据未对齐异常。某些设备对象,尤其是与存储栈(磁盘和卷)相关的设备对象,对作为读取数据最终写入位置所使用的缓冲区要求特殊的对齐方式,并且对调用者还要求遵守一个定义好的 I/O 传输比率。这些限制仅在目标文件对象不支持中间缓冲(其对应的 ***nt!\_FILE\_OBJECT::Flags*** 成员中设置了 ***FO\_NO\_INTERMEDIATE\_BUFFERING*** 标志)时才是必要的,此时请求无法从缓存中得到满足,将一直到达存储栈。有三个参数会受到这些限制的影响:Buffer、Length 和 ***ByteOffset***。***Length*** 和 ***ByteOffset*** 必须遵守前面确定的目标设备对象的 ***SectorSize*** 成员,因此调用者只能读取完整的扇区,而不能从某个扇区的中间开始读取。Buffer 成员必须按照目标设备对象的 ***Alignment*** 成员所要求的那样正确对齐。在进入下一点之前,我需要澄清与 **ByteOffset** 参数对齐相关的一点;正如我所说,这个参数是一个指针,前面提到的 ***4*** 字节对齐针对的是指针本身而不是偏移值,而第二个 ***SectorSize*** 对齐针对的是偏移值而不是指针本身。
[](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhjvIrwwCm5li0vHV5wmZXFGR2BAjAp9nsBTmdnF42CVVIf5IRKUxT7eDsP8DWRQYqWbxO6bpuvWFLPKjZ1xlPo7jGX-wa9HxmFdkv3G-n7ZfZT4jH2K11GGKQAkyQHdTGYTOUXZS4EH0MUgJTBj8o8H7W9TAhYAh4d6vYY8yvusKE5IV-MoqBcyZPueIG2/s1164/validate_user_parameters_6.jpg)
[](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEgztyC4ahHHgYWuKb6XU76VGnxUA_kAbDpuWfKPVibiDWFROt8rsr65X3DR7WkUD_wIh6gPLJiEA21KTUM2QUPLq3UT43A21dEY9m7Hh4r5KGQ0FEP1rEUbAZQdNyENmcBhxvWhJVyx63BC3ngF7AYF_NbH5jfR3vZZch3hddQaoqPYX2MnyvuFVUnQRpIs/s979/validate_user_parameters_7.jpg)
最后,在本阶段的结尾,会对 ***Key*** 参数进行检查以确保它是可读的(这个参数同样是一个指针)。对于处理读取请求而言,这个参数并不是那么重要,因为它不被视为完成此 I/O 操作的主要数据组成部分。
## **读取应该从哪里开始?**
在将请求发送到驱动层进行处理之前,必须确定系统应从目标文件对象的哪个字节位置开始读取。
相似文章
理解在试图绕过规则时规则背后的原理
本文来自微软的《老东西》博客,解释了Windows内核回调函数最佳实践背后的原理,特别是为什么阻塞或等待工作项会违背其目的,并通过一个关于驱动程序导致系统挂起的警示故事来说明。
WSL 2 正在提升 Windows 文件系统访问速度
WSL 2 迎来一项性能改进:每个 virtio 设备将获得独立的 DMA(SWIOTLB)内存池,从而消除了 virtiofs 路径上的资源竞争,提升 Windows 与 Linux 之间跨系统文件访问的效率。此项更改已于 2026 年 5 月合并,是 WSL 2 持续优化 Windows/Linux 文件 I/O 性能系列改进中的最新进展。
进程死后还有 I/O 吗?当进程死亡时 io_uring 会发生什么
一项针对 Linux 6.6.79 的技术分析显示,io_uring 的 I/O 请求可以在其提交进程结束后继续存在,并在 waitpid() 返回后才完成,而原生 AIO 则会等待排空;作者建议使用独占 flock 锁作为屏障,以防止在快速失败恢复过程中出现竞态条件。
io_uring 不使用预读
本文研究了在 Turso 数据库中 io_uring 实现预读的性能影响,表明批处理 I/O 请求可以通过合并减少设备请求来提高效率。
理解Linux内核:Linux内核启动
本文以太空殖民地隐喻描述初始化阶段,解释了x86_64架构下Linux内核的启动过程,涵盖从引导程序交接至用户空间初始化的完整流程。