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

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

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

在缺陷調查中開啟 Process Monitor,會看到 IRP_MJ_READFASTIO_READ 這些陌生的詞彙排列著。追蹤控制代碼洩漏時,明明已經呼叫過 CloseHandle 的檔案卻依然存活。「網路磁碟機的行為不一樣」「只有裝了防毒軟體的環境才會變慢」──業務應用程式現場反覆遇到的這些現象,其實都發生在同一片地基之上,那就是 Windows 的 I/O 系統。

從本文開始,我們要展開一個從根基往下挖掘的系列文章「Windows I/O 的深層」。就像經典著作《Windows Internals》一路深入到核心設計層級進行講解一樣,本系列同樣不談「API 的用法」,而是要談「為什麼會這樣運作」。1 預定的架構如下。

  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 回,本文會用圖解掌握「登場角色與要求的流動」,這是往後所有回合的基礎。這不是一篇教你寫驅動程式的文章,而是要讓應用程式開發者能夠親眼見證自己呼叫的 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 這樣的裝置指定──這些全都可以傳給同一個 CreateFile15

這種一致性的背後,是核心中由物件管理員所管理的單一名稱空間。在核心內部,不論是裝置、事件,還是共用記憶體區段,全都會以「物件」的身分登記在這個樹狀的名稱空間中。使用 Sysinternals 的 WinObj,就能直接窺見這個名稱空間。14

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

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

從這張圖,可以解開幾個平常會有的疑問。

  • \\.\ 前綴的意義。\\.\PhysicalDrive0\\.\COM10 這類名稱中的 \\.\,是用來直接指向 Win32 裝置名稱空間(約等於 \?? 目錄)的記法。它不經過磁碟機代號,而是直接點名連結所在的位置。515
  • CONNUL 無法用作檔案名稱的原因。這些名稱被保留為 MS-DOS 裝置名稱,不論寫在路徑的哪個位置,都有可能被解析成裝置。5 相關的實務陷阱,已整理在《MAX_PATH 與 Windows 路徑・檔案名稱的陷阱》中。
  • UNC 路徑也沒有被特別對待。\\server\share 會解析為網路重新導向器的裝置(\Device\Mup),之後便由 SMB 用戶端跨越網路搬運要求。本機與 UNC 行為不同的問題根源,就在於解析出來的裝置不一樣(參閱《網路磁碟機與 UNC 路徑的陷阱》)。

另外,嚴謹一點來說,\?? 並不是某個真實存在目錄的別名,而是表示「先查詢每個登入工作階段各自的本機 DOS 裝置對應表,找不到再落到 \GLOBAL??」這種搜尋順序的虛擬入口。用 net usesubst 建立的磁碟機代號,會進入這個本機端,因此才會出現同一台電腦上、不同使用者(登入工作階段)看到的磁碟機不一樣,或是從服務端看不到使用者的網路磁碟機這類現象。圖 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_CLEANUPIRP_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

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

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

把這張圖記在腦中後,一些實務上的知識就會從「死背」變成「理所當然」。

4. IRP ── I/O 要求變成一個包裹

4.1. 為什麼要包成封包

I/O 管理員收到來自應用程式的要求(開啟、讀取、寫入……)後,會將其裝進一種叫做IRP(I/O Request Packet)的封包,交給驅動程式。送往裝置驅動程式的要求,絕大多數都是以 IRP 的形式送達。3 由於裝置與作業系統的速度不相稱(磁碟比 CPU 慢上好幾個數量級),因此才把要求做成「包裹」而非「呼叫」,讓發出與完成能夠彼此分離2

IRP 除了持有要求整體資訊的表頭之外,還有一塊叫做I/O 堆疊位置的區域,會依預計經過的驅動程式數量並列排列。每個驅動程式都會從屬於自己的堆疊位置中,讀出「給自己的指示」(主要功能碼、參數)。17

4.2. 沿著裝置堆疊往下走

裝置物件會層層堆疊,形成裝置堆疊18 舉例來說,對本機磁碟上檔案的讀取要求,大致會經過下面這樣的路徑。

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

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

希望你能記住這張圖中的兩件事。

  1. 篩選器是正規的住戶。防毒軟體之所以能夠檢查所有的檔案 I/O,並不是什麼駭客手法,而是因為這種「夾在中間」的機制,是作業系統正式提供的擴充點。19 Process Monitor 也是站在同一個位置記錄全部的 I/O。這也是調查「只有那個環境的檔案存取才會變慢」時,最應該優先懷疑的地方(詳情見第 6 回)。
  2. 要求的意義會逐層被翻譯。應用程式說的是「這個檔案從位移量 4096 開始的 8KB」,NTFS 會把它翻譯成「這個磁碟區的這個叢集」,儲存體堆疊再翻譯成「這顆磁碟的這個磁區」。上層並不知道下層的內情。這裡有一個重要的補充:一個 IRP 並不會原封不動地從應用程式一路送到磁碟。檔案系統端與儲存體端是各自獨立的堆疊,NTFS 處理完發往檔案的 IRP 後,會另外建立並發出一個發往磁碟區(儲存體堆疊)的下層 IRP。如果是零散的檔案,一次讀取甚至可能分成好幾個下層 IRP,要求的粒度與生命週期會隨著層級而改變。

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

驅動程式收到 IRP 之後,本質上能做的事只有三種。310

下層就地完成下層選擇保留保留會一路傳遞到呼叫端驅動程式收到 IRP該如何處理這個要求1. 自行完成呼叫 IoCompleteRequest例如用快取中的資料立即回覆2. 交給下層裝置呼叫 IoCallDriver例如篩選器檢查後放行3. 保留,pending回傳 STATUS_PENDING 並將 IRP 放入佇列例如等待硬體回應以中斷等事件為契機之後再呼叫 IoCompleteRequest完成處理沿堆疊逆向往上依序呼叫各層的完成常式

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

順帶一提,這三者並非互斥的選項。最常見的路徑是「透過(2)交給下層,然後某一層選擇了(3)的保留」,在這種情況下,STATUS_PENDING 會原封不動地一路傳遞給中間的驅動程式,也會傳到呼叫端。之後,一旦下層完成了,之前在傳遞時登記過完成常式的每一層,就會依相反的順序被回頭呼叫──也就是說,「交給下層」與「保留」會疊加發生在同一個 IRP 上

這三種選擇,正是 Windows I/O 靈活性的源頭。

  • 命中快取時立即完成,速度就快(第 4 回的快取管理員)。
  • 可以插入任意數量的放行篩選器(第 6 回的迷你篩選驅動程式)。
  • 正因為可以保留,等待速度較慢的裝置時,才不必卡死執行緒(第 2、3 回的非同步 I/O)。

這個系列剩下的所有內容,其實都只是這張圖的註腳。

5. 追蹤 ReadFile 一次往返的過程

角色與工具都已就位,接下來就完整追蹤一次 ReadFile 的往返過程。這裡討論的是未命中快取、一路走到磁碟的情況(命中快取的部分留到第 4 回)。

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

這張圖的關鍵,在於 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 所做的事,是從處理程序的控制代碼表中刪除一筆項目。檔案物件擁有控制代碼計數(控制代碼的數量)與參照計數(來自核心內部參照的數量)這兩個計數,兩者各自獨立遞減。

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

圖 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 免費提供。可以從各自的下載頁面(WinObjProcess 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_READFASTIO_READ 這類核心端的詞彙。只要追蹤一次檔案複製,就能實際看到本文所述 IRP_MJ_CREATEFASTIO_READ/IRP_MJ_READIRP_MJ_WRITEIRP_MJ_CLEANUPIRP_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.OpenHandleRandomAccess 類別,不經過 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、四種完成通知方式、取消,以及「明明是非同步卻以同步方式返回」的陷阱。

相關文章

相關諮詢領域

合同會社小村軟體處理 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 6

  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,WinObj - Sysinternals。關於 WinObj 是顯示 NT 物件管理員名稱空間的工具,可以檢視名稱空間內包含裝置物件、符號連結在內的各種物件的說明。  2 3

  15. Microsoft Learn,CreateFileW function。關於 CreateFile 不只能開啟檔案,還能開啟實體磁碟、磁碟區、主控台、通訊埠(COM 埠)、管道等裝置並回傳控制代碼、開啟裝置時使用 \\.\ 格式的名稱,以及 FILE_FLAG_OVERLAPPED 等各種旗標意義的說明。  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)是 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 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽