The Depths of Windows I/O (Part 1) — Every Read and Write Becomes an IRP: The Big Picture of the I/O System

· · Windows, Win32, I/O, Kernel, Device Driver, .NET, C#, Bug Investigation

A single line of File.ReadAllText, or one call to ReadFile. What happens inside Windows between the moment you call the function and the moment it returns?

Open Process Monitor to chase down a bug and unfamiliar terms like IRP_MJ_READ and FASTIO_READ line up in front of you. Track down a handle leak and a file you were sure CloseHandle had dealt with is still alive. “It behaves differently on a network drive.” “It’s only slow on machines with antivirus software installed.” These phenomena, which come up again and again on the front lines of business applications, all happen on the same ground: the Windows I/O system.

This article opens a series, “The Depths of Windows I/O”, that digs into that ground from the bottom up. Just as the celebrated book Windows Internals (the Japanese edition is titled Inside Windows) goes all the way down to the kernel’s design to explain it, this series is not about “how to use the API” but about “why it behaves the way it does”.1 Here is the planned line-up:

  1. The Big Picture of the I/O System — Every Read and Write Becomes an IRP (this article)
  2. Synchronous and Asynchronous I/O — What OVERLAPPED Really Means
  3. I/O Completion Ports (IOCP) and the .NET Thread Pool — The Basement Under async/await
  4. The Cache Manager — When Does Your WriteFile Actually Reach Disk?
  5. Inside NTFS — Understanding the File System Through the MFT
  6. Filter Drivers and Minifilters — Why Procmon and Antivirus Scanners Can Intercept Your I/O

This first instalment lays out, with diagrams, the cast of characters and the flow of a request that every later instalment builds on. It is not an article about writing drivers. It is an article for letting application developers follow the API call they just made all the way to its destination.

Prerequisites: you can follow this article if you have used FileStream in C#, or called CreateFile/ReadFile in C/C++. No driver-development experience is required, and kernel-side code appears only as conceptual illustration. As a rough reading time, budget about 25 minutes to go through it in full while looking at the diagrams, or 2–3 minutes if you only want the bottom line of Section 1.

A guide for skipping ahead: this is long, so here are entry points by purpose.

  • Just want the big picture → Section 1 (the bottom line) → Section 2 (name resolution) → Section 5 (one round trip of ReadFile)
  • Want the terminology sorted out (driver/device/file object, IRP) → Sections 3 and 4
  • Want to understand how things break in practice (handle leaks, “file in use”) → Section 6
  • Want to try it yourself → Section 7

1. The Bottom Line First

  • Windows I/O is packet-driven. Most requests sent to a device driver are packed into a packet called an IRP (I/O Request Packet) and flow from top to bottom down a stack of drivers (the device stack).23
  • CreateFile doesn’t necessarily open a “file”. Name resolution happens in the Object Manager namespace, and C: is, underneath, a symbolic link to an NT device name such as \Device\HarddiskVolume3 (Section 2).45
  • There are three characters in this story. The driver object, which holds a table of handler functions; the device object, which is the destination of a request; and the file object, which holds the state of one particular open. The HANDLE you hold is a reference to a file object (Section 3).6789
  • Each driver has only three choices. Complete the IRP itself, pass it down to the driver below, or hold it pending and complete it later. This combination of three choices explains filter drivers, caching, and asynchronous I/O all at once (Section 4).310
  • There is no separate mechanism called “synchronous I/O” down at the bottom of the kernel. Synchronous I/O is really just the guarantee “the call does not return before completion”; waiting only happens when a request goes pending. This is where the doors to Part 2 and Part 3 open (Section 5).11
  • CloseHandle doesn’t mean “close” — it means “return one handle”. There are two stages: cleanup, when the last handle closes, and close, when every reference inside the kernel has also vanished. That gap is exactly why a file can say “in use” after you thought you had closed it (Section 6).1213
  • You can observe this layer yourself. WinObj shows you the namespace, and Process Monitor shows you the flow of IRPs. Procmon’s own vocabulary is the vocabulary of this article (Section 7).14

2. What “Everything Looks Like a File” Really Means

2.1. Where Does the Name You Pass to CreateFile Go?

Every Win32 file API begins with a “name”. A path like C:\project\report.csv, a UNC path like \\server\share\data.csv, a device designation like \\.\COM3 — all of these can be passed to the very same CreateFile.15

Behind this consistency lies a single namespace managed by the kernel’s Object Manager. Inside the kernel, devices, events, and shared-memory sections alike are all registered as “objects” in this tree-shaped namespace. Sysinternals’ WinObj lets you look straight into this namespace.14

\ - root of the namespace\Devicedevice objects created by drivers\GLOBAL??where the globally visible names Win32 sees live\BaseNamedObjectsnamed mutexes and the likeHarddiskVolume3Serial0Mup - network redirectorC: -> \Device\HarddiskVolume3COM1 -> \Device\Serial0PhysicalDrive0 -> \Device\Harddisk0\DR0

Figure 1: The Object Manager namespace (excerpt). What lives under \GLOBAL?? are symbolic links; the real objects live under \Device

The key point is that the names a Win32 application uses (drive letters, COM port names) and the names the kernel uses (NT device names) live in different tiers. Symbolic links are what connect the two.

When a driver wants its device to be visible from Win32 applications, it calls IoCreateSymbolicLink to create a symbolic link from an MS-DOS device name, such as \DosDevices\COM1, to an NT device name.4 C: works the same way — underneath, it is a link to a volume device such as \Device\HarddiskVolume3.

So name resolution for CreateFile("C:\project\report.csv") proceeds like this:

Name passed by the appC:\project\report.csvWin32 layer converts it to NT form\??\C:\project\report.csvObject Manager searches the namespaceand finds \??\C: is a symbolic linkFollows the link and substitutes it\Device\HarddiskVolume3\project\report.csv\Device\HarddiskVolume3reaches the volume's device objectResolving the remaining \project\report.csv is leftto the file system driver, NTFS -the I/O manager issues IRP_MJ_CREATE

Figure 2: Name resolution for CreateFile. The first half is the Object Manager’s job; once a device is reached, the second half becomes the file system’s job

This picture answers a few questions that come up all the time.

  • What the \\.\ prefix means. The \\.\ in \\.\PhysicalDrive0 or \\.\COM10 is notation for pointing directly at the Win32 device namespace (roughly the \?? directory). It skips the drive letter and names the location of the link directly.515
  • Why CON and NUL can’t be used as file names. They are reserved as MS-DOS device names, and wherever they appear in a path they can be resolved to the device side instead.5 The practical pitfalls around this are collected in “MAX_PATH and Windows Path/Filename Pitfalls”.
  • UNC paths aren’t a special case either. \\server\share resolves to the network redirector’s device (\Device\Mup), and from there the SMB client carries the request across the network. The root cause of behaviour differing between local paths and UNC paths is that the device it resolves to is different (see “Pitfalls of Network Drives and UNC Paths”).

To be precise, \?? is not really an alias for one physical directory — it is a virtual entry point representing a lookup order: “first check the per-logon-session local DOS device map, and if it isn’t there, fall through to \GLOBAL??”. Drive letters created with net use or subst land in that local side, which is why the same PC can show different drives to different users (logon sessions), and why a service cannot see a user’s mapped network drives. Figure 1 above shows only the global side (\GLOBAL??).

In other words, the precise way to say “on Windows, everything looks like a file” is this: every name is ultimately resolved to a device object, and every request from that point on is unified into the same format — the IRP. So what exactly is that device object? Let’s sort out the cast of characters.

3. The Cast Is Three Objects

The structure of the Windows I/O system can be drawn as a relationship among three kinds of kernel objects.

Technical terms are about to pile up. So that you don’t get lost, here is a translation table up front, mapping “what you see in the code you normally write” to “what the kernel calls it”. Sections 3 and 4 are written in the vocabulary of the right-hand column.

What you see on the Win32 / .NET side The kernel-side counterpart In a nutshell
HANDLE / SafeFileHandle An entry in the handle table (a reference to a file object) A ticket number for “one open” (3.3)
The open state a FileStream is holding File object The container for the sharing mode, flags, and current position (3.3)
A drive letter C: or \\.\COM3 Device object (the result of resolving the name) The destination of the request (Section 2, 3.2)
“The NTFS driver”, “the disk driver” Driver object A table of handler functions, one per request type (3.1)
One call to ReadFile / stream.Read Normally, one IRP The request packet carried to its destination (Section 4). Synchronous reads and writes to a cached file take a shortcut called fast I/O that skips creating an IRP (5.2). In Procmon these are the lines that start with FASTIO_
FileOptions / CreateFile flags Attributes recorded on the file object Determine read-ahead and asynchronous behaviour (translation table in Section 7)
The value of GetLastError / a .NET exception NTSTATUS (STATUS_PENDING and so on) The completion status, translated into a Win32 error code on its way up

3.1. The Driver Object — A Table of Handler Functions

When a driver is loaded, the I/O manager creates a driver object (DRIVER_OBJECT) to represent it.6 From an application developer’s point of view, the most important member is the MajorFunction array — a table mapping “request type” to “handler function”, where the request type is expressed by a major function code such as IRP_MJ_CREATE (open), IRP_MJ_READ (read), IRP_MJ_WRITE (write), IRP_MJ_CLEANUP, or IRP_MJ_CLOSE.16

Forcing it into C#, the picture looks roughly like this:

// Conceptual illustration. In reality this is a C struct inside the kernel
class DriverObject
{
    // Indexed by IRP_MJ_XXX. 28 kinds in total
    public DispatchRoutine[] MajorFunction = new DispatchRoutine[28];
}
// For ntfs.sys, MajorFunction[IRP_MJ_READ] holds "NTFS's read handler"

3.2. The Device Object — The Destination of a Request

For every device it looks after, a driver creates a device object (DEVICE_OBJECT).7 This is the destination of an I/O request. It isn’t necessarily one-to-one with a physical device — it can be a logical entity such as a volume (HarddiskVolume3), or a “device that exists only to intercept traffic” created by a filter driver. A device object points back to the driver object that created it, so once you know the destination, you also know the table of handler functions.

3.3. The File Object — The State of “One Open”

Every time CreateFile succeeds, the kernel creates one file object. This isn’t “the file on disk” itself — it represents one session of having opened that file (or device).8 Open the same file twice and you get two file objects. The current file pointer (for a synchronous handle), and the sharing mode and flags used when it was opened, all live here.

And the HANDLE your application receives is a reference to a file object, reached through the process’s own handle table.9

Kernel spaceProcess - user modeFile object 1report.csv opened for readingcurrent offset: 4096File object 2report.csv opened for appendcurrent offset: 65536Device objectequivalent to HarddiskVolume3Driver object NTFSMajorFunction = table of handlersHANDLE 0x1A4HANDLE 0x1B8

Figure 3: The relationship among the three objects. A handle points, via the handle table, at a file object; a file object points at a device; a device points at a driver

With this picture in mind, a few pieces of hands-on knowledge stop being things you memorise and start being things that are simply obvious.

  • What a handle leak really is: a pile of file objects (and the resources chained behind them) that keep being referenced and can never be freed. What you’re counting is entries in the handle table — exactly what Process Explorer and handle.exe show you (see “Process Explorer / Handle / VMMap in Practice”, and as an investigation write-up, “Investigating Long-Run Crashes of an Industrial Camera App — The Handle Leak”).
  • Why two handles opened on the same file have independent file pointers: the pointer lives on the file object side. Conversely, a handle duplicated with DuplicateHandle points at the same file object, so the two handles share the pointer.
  • A sharing violation is the kernel checking the sharing modes of the existing group of file objects against the new CreateFile request. We covered the practical side of mutual exclusion in “Mutual Exclusion Fundamentals for File-Based Integration”.

4. The IRP — An I/O Request Becomes a Parcel

4.1. Why Turn It Into a Packet?

When the I/O manager receives a request from an application — open, read, write, and so on — it packs it into a packet called an IRP (I/O Request Packet) and hands it to a driver. Most requests to a device driver arrive as IRPs.3 Devices don’t run at the same speed as the OS (a disk is orders of magnitude slower than the CPU), so requests are turned into “parcels” rather than “calls”, in a form that lets issuing and completion be separated.2

Alongside a header carrying the overall request information, an IRP has an area called an I/O stack location — one for each driver it is expected to pass through. Each driver reads “its own instructions” (the major function code and its parameters) from its own stack location.17

4.2. Going Down the Device Stack

Device objects stack up to form a device stack.18 A read from a file on a local disk, for example, roughly follows this path:

Storage side of the stackFile system side of the stackVolume / partition management(volmgr, etc.)Disk class driver(disk.sys)Storage port / miniport(storport, etc.)File system filters(antivirus, encryption, Procmon, etc.)NTFSconverts the in-file offset into a position on the volumeThe I/O manager assembles the IRP(IRP_MJ_READ + stack locations)Disk hardware

Figure 4: The path a read request takes. The file system side and the storage side are separate device stacks; NTFS issues a new lower IRP addressed to the storage side to get its work done

Two things are worth remembering from this picture.

  1. Filters are legitimate residents. The reason antivirus software can inspect every piece of file I/O is not a hack — it is because this “insert yourself in the middle” mechanism is an official extension point of the OS.19 Process Monitor stands in the same spot and records every I/O request. It is also the first place to suspect when investigating “file access is slow only in this particular environment” (more in Part 6).
  2. The meaning of a request gets translated at every layer. The application says “8 KB starting at offset 4096 of this file”; NTFS translates that into “this cluster on this volume”; the storage stack translates it further into “this sector on this disk”. Each upper layer knows nothing about the layer below it. There’s one important qualification here: a single IRP does not travel from the application straight through to the disk unchanged. The file system side and the storage side are separate stacks, and as part of handling the IRP addressed to the file, NTFS creates and issues a new, lower-level IRP addressed to the volume (the storage stack). For a fragmented file, a single read can even split into multiple lower IRPs — the granularity and lifetime of a request change at every layer.

4.3. Each Driver’s Three Choices

There are, fundamentally, only three things a driver that receives an IRP can do.310

the layer below completed it on the spotthe layer below held it pending(the pending status propagates back to the caller)A driver receives the IRPHow does it handle this request?(1) Complete it itselfcall IoCompleteRequeste.g. answer immediately from data in the cache(2) Pass it to the device belowcall IoCallDrivere.g. a filter inspects it, then passes it straight through(3) Hold it pendingreturn STATUS_PENDING and queue the IRPe.g. waiting on hardware to respondCall IoCompleteRequest latertriggered by an interrupt or similarCompletion processing runs back up the stackin reverse order (each layer's completion routine is called)

Figure 5: The three choices a driver has on receiving an IRP. These three are not mutually exclusive — the most common path is “pending happens further down after being passed on” — and every path ends in completion via IoCompleteRequest

These three are not mutually exclusive choices. The most ordinary path is “(2) pass it down, and some layer further down chooses (3), pending”, in which case STATUS_PENDING propagates unchanged, both to the intervening drivers and to the original caller. When a lower layer completes it later, each layer that registered a completion routine when it passed the IRP down is called back, in reverse order — in other words, “passing on” and “pending” happen layered on top of the very same single IRP.

This three-way choice is the wellspring of Windows I/O’s flexibility.

  • A cache hit lets it complete immediately, which is fast (the Cache Manager, in Part 4).
  • Any number of filters can insert themselves and pass the request straight through (minifilters, in Part 6).
  • Because a request can be held pending, a thread doesn’t have to freeze up while waiting on a slow device (asynchronous I/O, in Parts 2 and 3).

The rest of this series, really, is just a set of footnotes to this one diagram.

5. Following One Round Trip of ReadFile

Now that the cast and the props are in place, let’s follow one full round trip of ReadFile, in the case where it misses the cache and goes all the way to disk (we’ll cover the cached case in Part 4).

Disk hardwareStorage stackFilter + NTFSI/O managerApplication threadDisk hardwareStorage stackFilter + NTFSI/O managerApplication threadResolves the file object from the handleand assembles the IRP (IRP_MJ_READ)Synchronous I/O: the thread sleeps here waiting for completionAsynchronous I/O: control returns and other work can proceedContinues completion processingvia a DPC, triggered from the ISROnce all the lower IRPsit needed have completedRuns each layer's completion routine in reverse order,and settles the result via an APC to the requesting threadReadFile -> NtReadFile (system call)IoCallDriver (to the top of the stack)Translates the position onto the volumeand issues a lower IRP addressed to the storage stackIssues the read commandSTATUS_PENDING (the lower IRP is pending)The original IRP also returns pending(the outbound leg ends here)Interrupt - "data has been read"Completes the lower IRP (IoCompleteRequest)received by NTFS's completion routineCompletes the original IRP (IRP_MJ_READ)Status and byte count are settled (e.g. an event is signalled)

Figure 6: One round trip of ReadFile when it misses the cache. Completion of the lower IRP and completion of the original IRP are separate steps, and the “outbound” and “return” legs proceed as separate events too

5.1. The Outbound Leg and the Return Leg Are Separate Events

The critical line in this diagram is STATUS_PENDING. The storage driver lets go once it has issued a command to the hardware, and the “outbound” processing ends right there. The fact that data has finished being read is announced later, through a completely separate event — an interrupt — and that is what kicks off the “return” completion processing.10

In other words, inside the kernel, issuing an I/O request and completing it are built so they can be separated. There is no separate plumbing called “synchronous I/O” — the precise meaning of synchronous I/O is simply the guarantee that the call does not return before completion. Waiting only happens when a request goes pending; for a request a driver can complete on the spot (the “complete” path in Figure 5), the thread returns with its result without ever sleeping, even for synchronous I/O. The reason Win32 switches between synchronous and asynchronous by how the handle is opened (FILE_FLAG_OVERLAPPED) is precisely because asynchronous isn’t “a special extra feature” — it’s a difference in how you wait.11

Once you hold this view, the questions Part 2 tackles — why whether a call is asynchronous is decided when the handle is opened, not per call (even though the OVERLAPPED structure itself is needed for every individual operation in flight), and what it means for “something that should be asynchronous” to “complete synchronously” — start to look like the natural consequence of how the mechanism is built, rather than arbitrary facts. The reason .NET’s async/await doesn’t consume a thread while waiting on I/O (a story we covered from the practical side in “A Practical Decision Table for C# async/await”) is grounded in this very diagram, too.

5.2. There’s an Exception Too — a Shortcut That Skips the IRP

To be honest, not all I/O becomes an IRP. For synchronous reads and writes to a file that’s sitting in the cache, the file system offers a shortcut called fast I/O — copying directly out of the cache without assembling an IRP at all. In Procmon’s Operation column, these are the lines that start with FASTIO_. We’ll cover the conditions under which this shortcut can’t be used, and its relationship with the Cache Manager, in Part 4.

6. Behind CloseHandle — Cleanup and Close Are Different Things

Finally, how to close what you’ve opened. This connects directly to handle-leak investigations and “file in use” problems.

What CloseHandle does is remove one entry from the process’s handle table. A file object carries a handle count (the number of handles) and a reference count (the number of references held from inside the kernel), and the two go down independently.

File systemI/O managerObject ManagerApplicationFile systemI/O managerObject ManagerApplicationRemoves the entry from the handle tableand decrements the handle countCancels outstanding I/O on that file objectand releases locksalt[that was the last handle]But if kernel-internal references remain -outstanding I/O, a memory-mapped section, etc. -the file object is still aliveThe file object's teardown finishes -only now is it truly "closed"alt[the reference count has also reachedzero]CloseHandle(h)IRP_MJ_CLEANUPIRP_MJ_CLOSE

Figure 7: The two stages — cleanup (the last handle closed) and close (every reference is also gone)

  • IRP_MJ_CLEANUP announces “the last handle has been closed”. But the documentation itself notes that release of the file object may still be pending if outstanding I/O remains.12
  • IRP_MJ_CLOSE announces “the reference count has reached zero”. There is a gap between cleanup and close, and one does not necessarily follow the other immediately.13

Knowing these two stages explains a number of mysteries from the field.

  • A memory-mapped file isn’t released even after you close it. A mapped section keeps holding a reference to the file object, so close doesn’t arrive until the view has been unmapped and the section has been closed. We covered the practical side of shared memory in “Shared Memory Pitfalls and Practical Best Practices”.
  • Hunting down the culprit behind a “file in use” doesn’t end with handles alone. Even with every handle closed, a kernel-internal reference — a mapped image, for instance — can still be gripping the file. This is exactly why Process Explorer’s search covers both handles and DLLs (mapped files).
  • You should never rely on .NET’s SafeFileHandle or a finalizer to “eventually” close things for you, precisely because a forgotten, unclosed handle delays cleanup and drags out sharing violations and held locks.

7. See It for Yourself

Everything up to this point can be observed with nothing more than one Windows PC with administrator rights.

You only need three things.

  • Administrator rights. Process Monitor loads a kernel driver, so it has to run as administrator. WinObj also hides some objects from anyone who isn’t.
  • The Sysinternals tools. WinObj and Process Monitor are distributed free of charge by Microsoft. Get them individually from their download pages (WinObj, Process Monitor), or grab the whole set with the Sysinternals Suite. There’s no installer — just unzip and run the exe.
  • Something small to do while you watch. Saving one file from Notepad, or copying one small file, is plenty. Try it on your local disk, not a shared business folder or a production machine.

Look at the namespace with WinObj. Launch Sysinternals’ WinObj and open the GLOBAL?? directory, and you’ll see, right there, that C: is a symbolic link to \Device\HarddiskVolumeN (Figure 1). Under \Device, you can see the real names of the device objects the drivers have created.14

Read the vocabulary of IRPs in Procmon. Turn on Filter > Enable Advanced Output in Process Monitor’s menu, and the Operation column switches from ReadFile to the kernel-side vocabulary: IRP_MJ_READ, FASTIO_READ, and so on. Just watching one file copy, you’ll see the very flow this article describes play out for real: IRP_MJ_CREATEFASTIO_READ/IRP_MJ_READIRP_MJ_WRITEIRP_MJ_CLEANUPIRP_MJ_CLOSE. We’ve put together the practical side of using Procmon in “A Practical Guide to Process Monitor (ProcMon)”.

Stay aware of the bottom layer from .NET. C#’s FileStream calls CreateFileW internally and holds the handle as a SafeFileHandle. The FileOptions passed to its constructor map almost directly onto Win32 flags.

.NET (FileOptions) Win32 (CreateFile flag) Meaning (relevant instalment)
Asynchronous FILE_FLAG_OVERLAPPED Opens the handle for asynchronous I/O (Parts 2 and 3)
WriteThrough FILE_FLAG_WRITE_THROUGH Doesn’t let writes stop at the cache (Part 4)
SequentialScan FILE_FLAG_SEQUENTIAL_SCAN A hint for read-ahead (Part 4)
RandomAccess FILE_FLAG_RANDOM_ACCESS A hint to suppress read-ahead (Part 4)
DeleteOnClose FILE_FLAG_DELETE_ON_CLOSE Delete once the last handle closes (an application of the mechanism in Section 6)

From .NET 6 onward, File.OpenHandle together with the RandomAccess class also lets you write in a style much closer to Win32’s raw form — “handle plus offset-specified I/O” — without going through the FileStream abstraction. Once you understand what the right-hand column of this table means, the choices on the left stop being things you memorise.

8. Summary

  • Windows I/O is built on a single, consistent design: decide the destination (a device object) in the Object Manager’s namespace, pack the request into an IRP, and flow it down the device stack.2318
  • C: is a symbolic link, \\.\ directly names where a link lives, and a UNC path resolves to a redirector. The real secret behind “everything looks like a file” is the consistency of name resolution.45
  • There are three characters in the cast: the driver object (a table of handler functions), the device object (the destination), and the file object (the state of one open). A HANDLE is a reference to a file object.6789
  • A driver’s choices come down to three: complete, pass through, or hold pending. Filter interception, immediate answers from the cache, and asynchronous I/O are all applications of this one three-way choice.310
  • Issuing and completing a request are built to be separable, and synchronous I/O is the guarantee that “the call does not return before completion”. For a request that goes pending, the outbound leg (issuing) and the return leg (interrupt then completion) proceed as separate events.1110
  • CloseHandle only “returns a handle”. Once you know the two stages — cleanup (the last handle) and close (the last reference) — “closed, but still in use” stops being a mystery.1213

Next up is Part 2, “Synchronous and Asynchronous I/O — What OVERLAPPED Really Means”. We dig into the mechanism that lets an application actually use the “separation of issuing and completion” we saw in this article — FILE_FLAG_OVERLAPPED, the four ways to be notified of completion, cancellation, and the trap where something “supposed to be asynchronous” comes back synchronously.

KomuraSoft LLC handles design and bug investigation around file I/O in Windows business applications — handle leaks, “file in use” errors, I/O slowdowns confined to specific environments, and more.

References

  1. Microsoft Learn, Windows Internals - Sysinternals. The introduction page for the book Windows Internals (published in Japanese as Inside Windows), the standard reference covering the Windows kernel architecture, including the I/O system, and a starting point for going deeper into what this series covers. 

  2. Microsoft Learn, I/O manager. On how the Windows kernel-mode I/O manager manages communication between applications and the interfaces device drivers provide; how, because devices operate at speeds that don’t match the OS, communication between the OS and drivers happens mainly through IRPs (I/O Request Packets); and on IRPs as something resembling network packets or Windows messages, passed from the OS to a driver and from driver to driver.  2 3

  3. Microsoft Learn, I/O request packets. On how most requests sent to a device driver are packaged as IRPs; how OS components and drivers send an IRP to a driver via IoCallDriver (which takes a pointer to a device object and a pointer to the IRP); how an IRP is normally processed by multiple drivers stacked as a device stack, first being sent to the device object at the top of the stack; and on each driver’s ability to choose whether to process and complete the IRP or forward it to the driver below.  2 3 4 5 6

  4. Microsoft Learn, Introduction to MS-DOS device names. On how an MS-DOS device name is a symbolic link to an NT-style device name; how a user-mode Windows application accesses a device by its MS-DOS device name (a drive letter or COM port name), while drivers and the kernel use the NT-style name; and on how a driver creates the symbolic link from \DosDevices\name to the device using IoCreateSymbolicLink.  2 3

  5. Microsoft Learn, Naming files, paths, and namespaces. On how CON, PRN, AUX, NUL, COM1 through COM9, and LPT1 through LPT9 are reserved as file names; on how the Win32 namespace has a “file namespace” and a “device namespace”, and how the \\.\ prefix means accessing the Win32 device namespace (for example, \\.\PhysicalDrive0); and on the rules governing this name resolution.  2 3 4

  6. Microsoft Learn, Introduction to driver objects. On how the I/O manager creates a DRIVER_OBJECT structure when a driver is loaded; how the driver object holds the entry points into the driver’s set of standard routines, including the MajorFunction array, which is a dispatch table; and on how the I/O manager uses this table to call the handler function corresponding to a request.  2 3

  7. Microsoft Learn, Introduction to device objects. On how a DEVICE_OBJECT structure represents a logical, virtual, or physical device and becomes the target of an I/O request; how a driver creates a device object with IoCreateDevice; and on how a device object is tied to the driver (driver object) that created it.  2 3

  8. Microsoft Learn, Using files in a driver. On how, in the kernel, a file object represents “an instance of an opened file (or device)”; and on how a file object is created every time a file is opened, holding the context of that open, such as the current byte offset.  2 3

  9. Microsoft Learn, File handles. On how the file handle returned by CreateFile is specific to a process and tied to an opened file object; how opening the same file multiple times produces separate handles (and separate open states) each time; and on the fact that a handle should be closed with CloseHandle once it is no longer needed.  2 3

  10. Microsoft Learn, Completing IRPs. On how calling IoCompleteRequest is what completes an I/O operation; how, at completion, each IoCompletion routine registered by an upper-stack driver is called in turn; and on the flow by which completion of a request happens at a separate time from issuing it, eventually returning status to the requester.  2 3 4 5

  11. Microsoft Learn, Synchronous and asynchronous I/O. On how, with synchronous I/O, a function does not return until the I/O completes and the thread is made to wait, whereas with asynchronous I/O (overlapped I/O) the function that issued the request returns immediately and the thread can continue other work; on the need to open the handle with FILE_FLAG_OVERLAPPED for asynchronous I/O; and on the several available ways to receive notification of completion.  2 3

  12. Microsoft Learn, IRP_MJ_CLEANUP. On how receiving this request indicates that “the last handle to a file object associated with the target device object has been closed”; on how release of the file object may still be pending because of outstanding I/O requests; and on how this IRP is sent in the context of the process that closed the handle.  2 3

  13. Microsoft Learn, IRP_MJ_CLOSE. On how receiving this request indicates that “the file object’s reference count has reached zero and the file object is about to be released”; and on how it is sent after the cleanup request, but not necessarily immediately afterward, since it waits for outstanding I/O to complete.  2 3

  14. Microsoft Learn, WinObj - Sysinternals. On how WinObj is a tool that displays the NT Object Manager’s namespace, letting you browse objects within it, including device objects and symbolic links.  2 3

  15. Microsoft Learn, CreateFileW function. On how CreateFile can open and return handles not just to files but to devices such as physical disks, volumes, the console, communication (COM) ports, and pipes; on the use of \\.\-style names when opening a device; and on the meaning of various flags including FILE_FLAG_OVERLAPPED.  2

  16. Microsoft Learn, IRP major function codes. A list of IRP major function codes (IRP_MJ_CREATE, IRP_MJ_READ, IRP_MJ_WRITE, IRP_MJ_CLEANUP, IRP_MJ_CLOSE, IRP_MJ_DEVICE_CONTROL, IRP_MJ_PNP, and others) and what each request means and which driver should handle it. 

  17. Microsoft Learn, I/O stack locations. On how the I/O manager prepares an I/O stack location inside the IRP for each driver in a chain of layered drivers; how each stack location holds the major/minor function code and that request’s parameters; and on how each driver retrieves its own stack location, and hence the content of the request, via IoGetCurrentIrpStackLocation. 

  18. Microsoft Learn, Device nodes and device stacks. On how device objects stack up to form a device stack; how an IRP is first sent to the device object at the top of the stack, with each layer either processing it or forwarding it further down; and on how a filter driver’s device object sits inserted within that stack.  2

  19. Microsoft Learn, Filter Manager Concepts. On how the filter manager is a kernel-mode driver that ships with Windows; how minifilter drivers can intercept I/O requests to the file system through pre- and post-operation callbacks; and on how each minifilter’s interception point (its altitude) determines its order within the I/O stack. 

Recent articles sharing the same tags. Deepen your understanding with closely related topics.

These topic pages place the article in a broader service and decision context.

This article connects naturally to the following service pages.

Frequently Asked Questions

Common questions about the topic of this article.

What is an IRP?
An IRP (I/O Request Packet) is the packet the Windows kernel's I/O manager uses to bundle up an application's read, write, and other requests and hand them to device drivers. Microsoft's driver development documentation explains that most requests sent to device drivers are packaged as IRPs. An IRP carries a major function code that indicates the kind of request (create, read, write, cleanup, and so on) and a stack location for each driver it passes through, and it is processed as it flows down the device stack from top to bottom. Each driver chooses whether to complete the IRP itself, pass it to the driver below, or hold it pending and complete it later. Application developers never touch an IRP directly, but notations such as IRP_MJ_READ that appear in Process Monitor's Operation column are this very mechanism made visible.
Why can Windows open a file, a serial port, and a printer with the same CreateFile call?
Because whatever name you pass to CreateFile is ultimately resolved, through the Object Manager namespace, to a device object, and the I/O manager then creates a file object tied to that device and hands back a handle — the same path every time. A drive letter such as C: is, underneath, a symbolic link to an NT device name such as \Device\HarddiskVolume3, and a designation like \\.\COM1 likewise resolves to the serial port's device object. Whatever device the name resolves to, every subsequent request is packaged into the same IRP format and delivered to a driver, which is why files and devices alike can be opened and read or written through the same API. UNC paths follow the identical mechanism — they simply resolve to the network redirector's device. This combination of "namespace plus packet" is the real source of Windows I/O's consistency.
Why doesn't a file get released immediately after CloseHandle is called?
Because what CloseHandle does is "return one handle", not "close the file". A file object inside the kernel carries two separate counts: a handle count (the number of open handles) and a reference count (the number of references held by kernel components). IRP_MJ_CLEANUP is sent to the file system once the last handle is closed, but as long as kernel-internal references remain — outstanding I/O, a memory-mapped file section, and so on — the file object itself stays alive, and IRP_MJ_CLOSE is sent only once the reference count reaches zero. Many of the phenomena where a file cannot be deleted after being memory-mapped, or an application reports a file as "in use" after it has already closed it, are explained by this two-stage mechanism.
What use is knowledge of IRPs and device stacks to an application developer?
Even if you never write an IRP yourself, it pays off in both investigation and design. First, Process Monitor's Operation column (with advanced output enabled) displays exactly the vocabulary of IRPs, such as IRP_MJ_CREATE and IRP_MJ_READ, so knowing the terms at this layer lets you actually read the log. Second, once you know that filter drivers, such as antivirus software, sit across every path that file I/O takes, you have a lead when investigating a problem like "file access is slow only in this particular environment". And once you understand that Windows I/O is built so issuing and completing a request can be separated, and that synchronous I/O is merely a guarantee that "the call does not return before completion", asynchronous I/O, I/O completion ports, and .NET's async/await behaviour all make sense from first principles rather than as memorised facts.

Author Profile

Profile page for the article author.

Go Komura

Representative of KomuraSoft LLC

Focused on Windows software development, technical consulting, and investigations into failures that are difficult to reproduce.

Back to the Blog