Windows I/O 的深層(第 1 回) ── 所有讀寫都會變成 IRP:I/O 系統全貌

· · Windows, Win32, I/O, 核心, 裝置驅動程式, .NET, CSharp, 缺陷調查

更新紀錄(僅初版,2026年07月29日 發布)
初次發布
引用本文(DOI(已登錄存檔): 10.5281/zenodo.22175244)

以下 DOI 指向先前登錄的存檔,內容可能與目前正文不同。引用目前正文時,請使用本頁網址。

Go Komura(2026)。〈Windows I/O 的深層(第 1 回) ── 所有讀寫都會變成 IRP:I/O 系統全貌〉。小村軟體有限公司。 https://comcomponent.com/zh-TW/blog/windows-io-internals-architecture-irp/

DOI(已登錄存檔)
10.5281/zenodo.22175244
DOI(上次登錄版本)
10.5281/zenodo.22175245

File.ReadAllText 的一行程式碼,或是 ReadFile 的一次呼叫。在這個函式回傳之前,Windows 內部究竟發生了什麼事?

了解這段流程之後,Process Monitor 中出現的 IRP_MJ_READFASTIO_READ、明明呼叫過 CloseHandle 卻沒有被釋放的檔案,以及只有在網路磁碟機或裝了防毒軟體的環境下才會改變的行為,就都能放在同一套機制裡思考。

「Windows I/O 的深層」這個系列會把視野延伸到《Windows Internals》所處理的核心設計層級,不只講 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 與病毒掃描為何能夠介入 I/O

1. 先講結論

最先要掌握的是下列三點。

  1. Windows 的 I/O 是一套「用名稱決定目的地,把要求裝成封包傳遞」的機制。CreateFile 開啟的未必是磁碟上的檔案,C: 也是指向 NT 裝置名稱的符號連結。送往裝置驅動程式的要求,大多會以 IRP(I/O 要求封包)的形式流過裝置堆疊。不過,也有不建立 IRP 的捷徑,也就是快速 I/O(第 2 章、第 4 章、5.2 節)。2345
  2. 把「處理的東西」「目的地」「開啟後的狀態」分開來想。驅動程式物件是處理函式表,裝置物件是要求的目的地,檔案物件則持有開啟一次所對應的狀態。應用程式手上的 HANDLE,是指向檔案物件的參照(第 3 章)。6789
  3. 要求的發出與完成,和關閉控制代碼、釋放參照,分別屬於不同的階段。驅動程式會組合「完成 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 這樣的本機路徑、\\server\share\data.csv 這樣的 UNC 路徑、\\.\COM3 這樣的裝置指定,全都可以傳給同一個 CreateFile14

支撐這種一致性的,是核心中由物件管理員管理的命名空間。具名的裝置、事件、共用記憶體區段等,都在樹狀的命名空間中處理。使用 Sysinternals 的 WinObj,就能直接確認這個命名空間。15

\ (命名空間的根)\Device(驅動程式建立的裝置物件)\GLOBAL??(Win32 看得到的全域名稱存放處)\BaseNamedObjects(具名互斥鎖等)HarddiskVolume3Serial0Mup (網路重新導向器)C: → \Device\HarddiskVolume3COM1 → \Device\Serial0PhysicalDrive0 → \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") 的名稱解析,會依下列步驟進行。

應用程式傳入的名稱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 的名稱解析。前半段是物件管理員的工作,抵達裝置之後的後半段則是檔案系統的工作

區分到裝置為止的名稱與檔案系統內部的名稱

在圖 2 的前半段,Win32 路徑被轉換成 NT 格式,再沿著連結抵達磁碟區的裝置物件。之後剩下的 \project\report.csv 的解析,則由 I/O 管理員發出 IRP_MJ_CREATE,交給檔案系統驅動程式(NTFS)處理。

知道這個分界之後,下面這些寫法也能用同一條流程來讀。

  • \\.\ 是指向 Win32 裝置命名空間的寫法。\\.\PhysicalDrive0\\.\COM10 不經過磁碟機代號,而是直接指定裝置名稱的連結。514
  • CONNUL 是保留的 MS-DOS 裝置名稱。即使寫在路徑裡也可能被解析到裝置那一側,因此不能當成一般的檔案名稱使用。5 實務上的注意事項,整理在《MAX_PATH 與 Windows 路徑・檔案名稱的陷阱》中。
  • 使用 UNC 路徑時,解析到的裝置會不一樣。\\server\share 會解析到網路重新導向器的裝置(\Device\Mup),再往後便由 SMB 用戶端跨越網路搬運要求。本機與 UNC 行為不同的原因,可以從解析目標的差異去想(參閱《網路磁碟機與 UNC 路徑的陷阱》)。

\??\GLOBAL?? 不是同一個東西

\?? 並不是某個真實存在目錄的單純別名。它是一個搜尋順序的入口:先查每個登入工作階段各自的本機 DOS 裝置對應表,找不到再查 \GLOBAL??。圖 1 畫出的只有全域那一側的 \GLOBAL??

net usesubst 建立的磁碟機代號會進到本機那一側。因此即使是同一台電腦,不同登入工作階段看得到的磁碟機也不一樣,服務有時就看不到使用者的網路磁碟機。

把上面的內容歸納起來:檔案和裝置之所以能用同一套 API 處理,是因為可以從名稱抵達裝置物件,並把之後的要求以共通的格式傳遞下去。接下來就整理表示這個目的地與開啟狀態的幾種物件。

3. 登場角色是三種物件

先把平常在 Win32 / .NET 程式碼中看得到的東西核心端對應的實體並排列出。之後對用語感到迷惑時,請回到這張表。

在 Win32 / .NET 端看到的東西 核心端對應的實體 一句話概括
HANDLE / SafeFileHandle 控制代碼表中的一筆項目(指向檔案物件的參照) 指向「開啟一次」的號碼牌(3.3)
FileStream 所握有的開啟狀態 檔案物件 共用模式・旗標・目前位置的容器(3.3)
磁碟機代號 C:\\.\COM3 裝置物件(名稱解析後的結果) 要求的目的地(第 2 章、3.2)
「NTFS 驅動程式」「磁碟驅動程式」 驅動程式物件 依要求種類列出處理函式的表(3.1)
呼叫一次 ReadFile / stream.Read 通常是一個 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_CLEANUPIRP_MJ_CLOSE16

只用 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

核心空間處理程序(使用者模式)檔案物件 1以讀取方式開啟 report.csv目前位移量:4096檔案物件 2以附加方式開啟 report.csv目前位移量:65536裝置物件相當於 HarddiskVolume3驅動程式物件 NTFSMajorFunction = 處理函式表HANDLE 0x1A4HANDLE 0x1B8

圖 3:三種物件之間的關係。控制代碼透過控制代碼表指向檔案物件,檔案物件連向裝置,裝置再連向驅動程式

「把同一個檔案開啟兩次」與「複製控制代碼」並不相同

操作 檔案物件 檔案指標
分別開啟同一個檔案 依開啟次數各建立一個 各自獨立
DuplicateHandle 複製控制代碼 參照同一個物件 共用

即使是指向同一個檔案的控制代碼,重新開啟與複製兩者所共用的狀態並不一樣。

控制代碼洩漏與共用違規,同樣要從開啟狀態來想

檔案控制代碼一旦洩漏,檔案物件以及它背後的資源就會持續被參照。調查時要數的是控制代碼表中的項目。Process Explorer 與 handle.exe 呈現的資訊,對應的正是這個(《Process Explorer / Handle / VMMap 實戰》,調查紀錄可參閱《工業相機長期運轉當機調查 - 控制代碼洩漏篇》)。

共用違規(sharing violation),是拿既有開啟狀態的共用模式,與新的 CreateFile 要求互相比對後判定出來的。互斥控制的實務內容,整理在《檔案介接互斥控制的基礎知識》中。

4. IRP ── I/O 要求會被裝成包裹

4.1. 為什麼要裝成封包

I/O 管理員會把開啟、讀取、寫入之類的要求裝進 IRP(I/O 要求封包),再交給驅動程式。送往裝置驅動程式的要求,絕大多數都是這種形式。3

之所以裝成封包,是為了讓要求的發出與完成能夠分離。磁碟這類裝置不會以和 CPU 相同的速度運轉。只要能把要求當成獨立的封包保存下來,就可以在發出之後,於另一個時機讓它完成。2

IRP 的內部,也分成整體的資訊與給各驅動程式的指示。17

區域 持有的東西
表頭 要求整體的資訊
I/O 堆疊位置 每個經過的驅動程式各自的要求種類與參數

I/O 堆疊位置會依照預計經過的驅動程式數量並列排開。每個驅動程式都會讀取屬於自己的區域,判斷自己被要求做哪一項處理。

4.2. 沿著裝置堆疊往下走

裝置物件層層堆疊起來的東西,稱為裝置堆疊18 讀取本機磁碟上的檔案時,要求大致會經過下面這條路徑。

儲存體端的堆疊檔案系統端的堆疊磁碟區/分割區管理(volmgr 等)磁碟類別驅動程式(disk.sys)儲存體連接埠/迷你連接埠(storport 等)檔案系統篩選器(防毒軟體・加密・Procmon 等)NTFS把檔案內的位移量換算成磁碟區上的位置I/O 管理員組裝 IRP(IRP_MJ_READ + 堆疊位置)磁碟裝置

圖 4:讀取要求所走的路徑。檔案系統端與儲存體端是各自獨立的裝置堆疊,NTFS 會另外發出一個發往儲存體端的下層 IRP 來委派工作

篩選器是作業系統備妥的擴充點

防毒軟體之所以能夠檢查檔案 I/O,是因為它使用的是作業系統正式備妥、可以介入要求途中的機制。19 Process Monitor 也是在這個位置記錄 I/O。

在調查「只有那個環境的檔案存取才會變慢」時,位於這條路徑上的篩選器就是最該優先確認的對象。詳細內容會在第 6 回討論。

並不是同一個 IRP 一路穿到磁碟

應用程式提出的要求是「這個檔案從位移量 4096 開始的 8KB」。NTFS 會把它對應到磁碟區上的叢集,儲存體堆疊再對應到磁碟的磁區。上層不必知道下層的細節就能提出要求。

這裡重要的是,檔案系統端與儲存體端是不同的堆疊這一點。NTFS 為了處理發往檔案的 IRP,會另外建立並發出一個發往磁碟區的下層 IRP。並不是把同一個 IRP 就這樣從應用程式一路遞到磁碟。

如果是零散的檔案,一次讀取甚至可能分成好幾個下層 IRP。要求的粒度與生命週期,必須分層來思考。

4.3. 每個驅動程式的三個選項

驅動程式收到 IRP 之後的處理,基本上可以整理成下面三種。310

處理 主要動作 範例
自行完成 IoCompleteRequest 讓它完成 用手邊的快取滿足要求
交給下層驅動程式 IoCallDriver 送往下一個裝置 篩選器檢查後轉送
保留 回傳 STATUS_PENDING,之後再讓它完成 等待硬體回應
下層當場完成了下層設為保留(保留會一路傳到呼叫端)驅動程式收到 IRP該如何處理這個要求(1) 自行完成呼叫 IoCompleteRequest例:用快取中的資料立即回覆(2) 交給下層裝置呼叫 IoCallDriver例:篩選器檢查後放行(3) 設為保留(pending)回傳 STATUS_PENDING 並把 IRP 放進佇列例:等待硬體回應以中斷等事件為契機之後再呼叫 IoCompleteRequest完成處理沿堆疊逆向往上(依序呼叫各層的完成常式)

圖 5:驅動程式收到 IRP 後的三種選擇。這三者並非互斥,最常見的路徑是「交給下層之後在那裡被保留」,而不論哪一條路,最後都會以 IoCompleteRequest 的完成收尾

「轉送」與「保留」會疊加在同一個要求上

這三者並不是互斥的選項。最典型的是上層驅動程式往下交,而下游的某一層把它設為保留的流程。在這種情況下,STATUS_PENDING 會穿過中間的驅動程式傳到呼叫端。

之後下層讓要求完成時,在轉送時登記過完成常式的每一層,就會依相反的順序被回頭呼叫。把要求往下傳的處理,與把結果往上送的完成處理,請分開來讀。

這樣的組合,正是本系列所談機制的基礎。能在快取上立即完成就會變快(第 4 回),途中也可以疊上篩選器(第 6 回)。正因為保留與完成可以分開,非同步 I/O 才能在等待較慢的裝置時,讓執行緒去做別的工作(第 2 回、第 3 回)。

5. 追蹤 ReadFile 的一次往返

這裡追蹤的是未命中快取、必須到磁碟上讀取時的 ReadFile。看圖時,請先看發出要求的「去程」,再看資料讀完之後的「回程」。

磁碟裝置儲存體堆疊篩選器 + 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. 去程與回程是不同的事件

圖 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 所做的,是從處理程序的控制代碼表中刪掉一筆項目

檔案物件上有表示控制代碼數量的控制代碼計數,以及管理核心內部參照的參照計數。控制代碼歸零的時點,與對該物件的參照全部消失的時點,未必相同。

檔案系統I/O 管理員物件管理員應用程式檔案系統I/O 管理員物件管理員應用程式從控制代碼表中刪除項目並減少控制代碼計數取消該檔案物件的未完成 I/O,並釋放鎖定alt[這是最後一個控制代碼]但只要未完成的 I/O 或區段(記憶體對應)等核心內部的參照還在檔案物件就依然活著檔案物件的收尾工作完成到這時才真正算是「關閉完畢」alt[參照計數也歸零]CloseHandle(h)IRP_MJ_CLEANUPIRP_MJ_CLOSE

圖 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 的工具 使用 Microsoft 免費提供的 WinObjProcess 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_READFASTIO_READ 這類核心端的語彙。

在檔案複製中,要找的是本文說明過的 IRP_MJ_CREATEFASTIO_READ / IRP_MJ_READIRP_MJ_WRITEIRP_MJ_CLEANUPIRP_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.OpenHandleRandomAccess 類別,不經過 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、四種完成通知方式、取消,以及「明明應該是非同步卻以同步返回」的條件。

相關文章

相關諮詢領域

合同會社小村軟體處理 Windows 業務應用程式檔案 I/O 相關的設計與缺陷調查(控制代碼洩漏、「檔案使用中」、特定環境下的 I/O 延遲等)。

參考連結

  1. Microsoft Learn,Windows Internals - Sysinternals。書籍《Windows Internals》的介紹頁面。這是一本探討 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

  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,CreateFileW function。關於 CreateFile 不只能開啟檔案,還能開啟實體磁碟、磁碟區、主控台、通訊埠(COM 埠)、管道等裝置並回傳控制代碼、開啟裝置時使用 \\.\ 格式的名稱,以及 FILE_FLAG_OVERLAPPED 等各種旗標意義的說明。  2

  15. Microsoft Learn,WinObj - Sysinternals。關於 WinObj 是顯示 NT 物件管理員命名空間的工具,可以檢視命名空間內包含裝置物件、符號連結在內的各種物件的說明。  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 隨附的核心模式驅動程式,迷你篩選驅動程式可以透過事前(pre)、事後(post)回呼介入對檔案系統的 I/O 要求,以及各個迷你篩選驅動程式的介入位置(altitude)決定其在 I/O 堆疊中的順序的說明。 

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

IRP 是什麼?
IRP(I/O Request Packet,I/O 要求封包)是 Windows 核心的 I/O 管理員用來裝載應用程式的讀寫要求等,再交給裝置驅動程式的封包。Microsoft 的驅動程式開發文件說明,送往裝置驅動程式的要求絕大多數都會被裝進 IRP。IRP 持有表示要求種類(建立、讀取、寫入、cleanup 等)的主要功能碼,以及每個經過的驅動程式各自的堆疊位置,並在裝置堆疊中由上往下傳遞的同時被處理。每個驅動程式都可以選擇自行完成 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 行為。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽