更新紀錄(僅初版,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_READ 與 FASTIO_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. 先講結論
最先要掌握的是下列三點。
- Windows 的 I/O 是一套「用名稱決定目的地,把要求裝成封包傳遞」的機制。
CreateFile開啟的未必是磁碟上的檔案,C:也是指向 NT 裝置名稱的符號連結。送往裝置驅動程式的要求,大多會以 IRP(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 這樣的本機路徑、\\server\share\data.csv 這樣的 UNC 路徑、\\.\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 |
通常是一個 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 要求封包),再交給驅動程式。送往裝置驅動程式的要求,絕大多數都是這種形式。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 之後的處理,基本上可以整理成下面三種。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: 取消該檔案物件的<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(參照也全部消失)這兩個階段
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 免費提供的 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、四種完成通知方式、取消,以及「明明應該是非同步卻以同步返回」的條件。
相關文章
- 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 要求,以及各個迷你篩選驅動程式的介入位置(altitude)決定其在 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 管理員用來裝載應用程式的讀寫要求等,再交給裝置驅動程式的封包。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 行為。