File.ReadAllText 的一行程式碼,或是 ReadFile 的一次呼叫。在這個函式回傳之前,Windows 內部究竟發生了什麼事?
在缺陷調查中開啟 Process Monitor,會看到 IRP_MJ_READ、FASTIO_READ 這些陌生的詞彙排列著。追蹤控制代碼洩漏時,明明已經呼叫過 CloseHandle 的檔案卻依然存活。「網路磁碟機的行為不一樣」「只有裝了防毒軟體的環境才會變慢」──業務應用程式現場反覆遇到的這些現象,其實都發生在同一片地基之上,那就是 Windows 的 I/O 系統。
從本文開始,我們要展開一個從根基往下挖掘的系列文章「Windows I/O 的深層」。就像經典著作《Windows Internals》一路深入到核心設計層級進行講解一樣,本系列同樣不談「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)的封包,並沿著堆疊而成的驅動程式(裝置堆疊)由上往下流動。23
- CreateFile 開啟的未必是「檔案」。名稱解析是在物件管理員的名稱空間中進行,
C:的實體是指向\Device\HarddiskVolume3之類 NT 裝置名稱的符號連結(第 2 章)。45 - 登場的角色是三種物件。持有處理函式表的「驅動程式物件」、成為要求目的地的「裝置物件」,以及持有「開啟一次」這份狀態的「檔案物件」。你手中的 HANDLE,就是指向檔案物件的參照(第 3 章)。6789
- 每個驅動程式的選項只有三個。「自行完成」IRP、「交給下層驅動程式」,或「先保留,之後再完成」。這三選一的組合,能解釋篩選驅動程式、快取,以及非同步 I/O 的全部行為(第 4 章)。310
- 核心的底層並不存在另一套叫做「同步 I/O」的機制。同步 I/O 的真面目,其實只是「完成之前不會返回」這項保證,只有在要求進入保留(pending)狀態時,才會產生等待。這裡正是第 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
這種一致性的背後,是核心中由物件管理員所管理的單一名稱空間。在核心內部,不論是裝置、事件,還是共用記憶體區段,全都會以「物件」的身分登記在這個樹狀的名稱空間中。使用 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 建立的磁碟機代號,會進入這個本機端,因此才會出現同一台電腦上、不同使用者(登入工作階段)看到的磁碟機不一樣,或是從服務端看不到使用者的網路磁碟機這類現象。圖 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 |
通常對應一個 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 leak 的找法與長時間運轉用的日誌設計》)。 - 開啟同一個檔案的兩個控制代碼,各自的檔案指標互不相干,原因就在於指標保存在檔案物件那一側。反過來說,用
DuplicateHandle複製出來的控制代碼,指向的是同一個檔案物件,因此會共用指標。 - 共用違反(sharing violation),是核心把既有那群檔案物件的共用模式,拿來跟新的
CreateFile要求互相比對後所做出的判定。互斥控制的實務內容,已在《檔案整合的互斥控制基礎 - 檔案鎖與原子性 claim 的最佳實務》中討論過。
4. IRP ── I/O 要求變成一個包裹
4.1. 為什麼要包成封包
I/O 管理員收到來自應用程式的要求(開啟、讀取、寫入……)後,會將其裝進一種叫做IRP(I/O Request Packet)的封包,交給驅動程式。送往裝置驅動程式的要求,絕大多數都是以 IRP 的形式送達。3 由於裝置與作業系統的速度不相稱(磁碟比 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["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 的完成收尾
順帶一提,這三者並非互斥的選項。最常見的路徑是「透過(2)交給下層,然後某一層選擇了(3)的保留」,在這種情況下,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 的最佳實踐 - Task.Run 與 ConfigureAwait 的判斷表》從實務角度寫過這個話題),其依據也正是這張圖。
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 都不會到來。共用記憶體相關的實務內容,已在《使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性》中討論過。
- 追查「使用中的檔案」時,光靠控制代碼是查不完的。即使所有控制代碼都已關閉,核心內部的參照(例如已對應的映像)仍可能持續抓著檔案不放。Process Explorer 的搜尋之所以同時涵蓋控制代碼與 DLL(已對應的檔案)兩者,原因正在於此。
- 不應該指望 .NET 的
SafeFileHandle或完成項(finalizer)「總有一天會幫忙關閉」,原因同樣在於:忘記關閉的控制代碼會延遲 cleanup 的到來,讓共用違反或鎖定持續存在的時間拉長。
7. 親自動手確認
到目前為止的內容,只要有一台具備系統管理員權限的 Windows 電腦,就能全部親自觀察到。
需要準備的東西只有三樣。
- 系統管理員權限。Process Monitor 會載入核心驅動程式,因此必須以系統管理員身分執行。WinObj 也一樣,有些物件不是系統管理員就看不到。
- Sysinternals 工具。WinObj 與 Process Monitor 都由 Microsoft 免費提供。可以從各自的下載頁面(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 leak 的找法與長時間運轉用的日誌設計
- 檔案整合的互斥控制基礎 - 檔案鎖與原子性 claim 的最佳實務
- 使用共享記憶體時的陷阱與最佳實踐 - 先整理同步、可見性、壽命、ABI、安全性
- 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 ↩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 隨附的核心模式驅動程式,迷你篩選驅動程式可以透過事前(pre)、事後(post)回呼介入對檔案系統的 I/O 要求,以及各個迷你篩選驅動程式的介入位置(altitude)決定其在 I/O 堆疊中的順序的說明。 ↩
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
Windows I/O 的深層(第 6 回・最終回) ── 篩選驅動程式與迷你篩選驅動程式:Procmon 與病毒掃描為何能夠介入 I/O
本文是透過圖解說明 Windows 篩選驅動程式與迷你篩選驅動程式的系列最終回。整理了篩選管理員與海拔、pre/post 回呼、Procmon 與防毒軟體能夠檢查全部 I/O 的機制,直到「唯獨那個環境慢」的調查步驟。
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)是 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 行為。