第7章 I/O请求包
跟踪 IRP 从创建、穿过设备栈各层栈位置、经分发例程处理到完成的完整旅程,并掌握 BUFFERED 与 DIRECT 两种用户缓冲区传输方法。
学习目标
- 能解释 IRP 从创建、穿过设备栈到完成的全过程,以及栈位置在逐层传递中的作用
- 能改出 Zero 驱动的读分发例程:用 BUFFERED 方法正确计算可复制长度并完成 IRP 的效果
- 能回答:一个 IRP 被两个驱动层各完成一次会发生什么?为什么每个 IRP 只能完成一次?
为什么请求要在流水线上“一站一站”走
想象一条流水线:订单从窗口进来,先经过质检员看一眼,再到装配工手里干活,最后到打包员手里装车。每道工序只看到订单上跟自己有关的那一行,干完在自己那栏签个字,订单继续往下传。谁也不能把订单留在自己手里不撒手,否则整条线卡死。
内核的 I/O 请求也是这样的一张“订单”。用户程序要读文件、写设备,系统把请求打成一张标准工单,沿着一条由多个驱动叠成的流水线逐层传递,每层只处理自己那一段,最后签收返回。
这一章拆开这张工单:它长什么样(结构)、流水线怎么排(设备栈)、每层看哪一行(栈位置)、怎么签收(完成)。看懂这张工单,你就看懂了内核一半的日常。
IRP:内核的工作单
↡I/O 请求包,内核为每个用户 I/O 请求创建的标准数据结构,携带请求类型、参数、缓冲区和完成状态(I/O Request Packet)是驱动接收工作的单位。用户调用 ReadFile/WriteFile/DeviceIoControl 时,I/O 管理器创建 IRP:填上 MajorFunction(请求类型,如 IRP_MJ_READ)、参数(长度、偏移)、缓冲区引用,然后沿设备栈下发。
IRP 上有两个关键字段:IoStatus.Status(结果码,驱动处理完填)和 IoStatus.Information(传输字节数)。IRP 还挂着一串栈位置(下一节),以及按传输方法指向用户缓冲区的指针或 MDL。驱动拿到 IRP 就像工人拿到工单:看类型、看参数、干活、填结果、签收。
设备栈:叠起来的工位
同一个设备可能被多个驱动共同服务:过滤驱动(检查/修改)、功能驱动(核心逻辑)、总线驱动(硬件通信)。它们各自的设备对象按“上层在下层之上”的顺序叠放,组成↡处理同一设备 I/O 的多个设备对象按上下层关系组成的链条,IRP 从栈顶逐层下发(device stack)。
IRP 从栈顶进入,沿栈逐层向下:每层驱动先看请求,决定自己处理、修改还是原样传给下层。你的驱动通常附加为过滤设备(上层)或功能设备(中层)。设备栈让“多个驱动协作处理一个请求”成为可能——这也是第 10 章文件系统过滤驱动的基础。
栈位置:每层只看自己的那行
IRP 携带一串↡IRP 中为设备栈每一层预留的参数区,各层通过它读取自己的请求参数(IO_STACK_LOCATION),栈里每一层各占一个。驱动用 IoGetCurrentIrpStackLocation(Irp) 拿到自己的那格,读取 MajorFunction、IOCTL 码、长度等参数;向下传递时用 IoSkipCurrentIrpStackLocation 把“当前”标记让给下一层。
为什么要分格?因为多层驱动看到的是同一个 IRP,但每层的参数不同(过滤层关注是否拦截,功能层关注具体参数)。栈位置就是“各层工位的工作单副本”——各看各的,互不污染。驱动把参数写错格,下层就会读到错数据。
缓冲 I/O:安全的两趟搬运
访问用户缓冲区有三种传输方法,先在 CTL_CODE 宏第 2 参数里选定。第一种是↡输入输出都经系统缓冲区中转的传输方法:用户数据先复制进 SystemBuffer,完成后输出再复制回去(METHOD_BUFFERED):I/O 管理器把用户输入复制到内核 SystemBuffer,驱动在 SystemBuffer 上干活,完成后系统把输出复制回用户缓冲区。
特点:用户指针对驱动完全不可见——不存在“用户改地址”的竞态,最安全。代价是两次复制(进+出),大块数据(MB 级)开销明显。适合控制类小请求(IOCTL、小读写)。第 4 章的 IOCTL 例子用的就是它。
直接 I/O:零复制的直通车
第二种是↡用 MDL 把用户缓冲区锁定并映射到内核,驱动直接读写的传输方法,避免复制(METHOD_IN_DIRECT / METHOD_OUT_DIRECT):I/O 管理器为输出缓冲区创建 ↡内存描述符表,描述一段用户内存的物理页面,驱动用它把用户缓冲区映射到内核空间(Memory Descriptor List),锁定页面并映射到内核,驱动经 MmGetSystemAddressForMdlSafe 拿到地址直接读写——零复制。
适合大块数据传输(磁盘、网络、音视频)。注意:页面被锁定后不会换出,但内容是用户的,可能被并发修改;只映射一次,多次读写共享同一缓冲区。第三种 METHOD_NEITHER 直接把用户指针交给驱动(不复制不映射),最容易翻车,新代码极少用。
动手:跟踪一张 IRP 的旅程
下面的可视化把 IRP 旅程画成一张图:用户请求 → IRP 创建 → 设备栈三层 → 栈位置 → 缓冲区传输 → 完成。点击设备栈的每一层,右侧的栈位置面板跟着变;切换传输方法,观察 BUFFERED 与 DIRECT 的缓冲区处理差异。
猜一猜:把传输方法从“缓冲 I/O”切到“直接 I/O”,驱动访问用户缓冲区的路径会有什么不同?为什么大块数据要用直接 I/O?
IRP(I/O Request Packet)是内核给驱动的工作单:用户调用 ReadFile/WriteFile/DeviceIoControl 时,I/O 管理器创建 IRP,填好请求类型(MajorFunction)、参数和缓冲区,沿设备栈逐层下发。驱动处理完必须调用 IoCompleteRequest 标记完成,请求才能返回用户模式。
代码对照:Zero 驱动的读与写
Zero 驱动是原书的经典示例:一个“什么都返回零”的演示驱动,把 IRP 处理流程压缩到最小。这一节按原书结构拆解 DriverEntry、读分发、写分发和测试应用,覆盖驱动侧全部关键代码。
段一:DriverEntry —— 注册读写分发
Zero 驱动的入口比第 4 章更简单:不建符号链接(用设备名访问),重点是把 IRP_MJ_READ 和 IRP_MJ_WRITE 注册到自己的分发例程。其余请求(CREATE/CLOSE/其他)走通用的兜底分发。
#include <ntddk.h>
#define DEVICE_NAME L"\\Device\\Zero"
NTSTATUS CreateClose(PDEVICE_OBJECT, PIRP);
NTSTATUS ReadWrite(PDEVICE_OBJECT, PIRP);
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING)
{
PDEVICE_OBJECT dev;
UNICODE_STRING name = RTL_CONSTANT_STRING(DEVICE_NAME);
NTSTATUS st = IoCreateDevice(DriverObject, 0, &name,
FILE_DEVICE_UNKNOWN, 0, FALSE, &dev);
if (!NT_SUCCESS(st)) return st;
// 注册读写与其他请求的分发例程
DriverObject->MajorFunction[IRP_MJ_CREATE] = CreateClose;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = CreateClose;
DriverObject->MajorFunction[IRP_MJ_READ] = ReadWrite;
DriverObject->MajorFunction[IRP_MJ_WRITE] = ReadWrite;
dev->Flags |= DO_BUFFERED_IO; // 读/写走缓冲 I/O
dev->Flags &= ~DO_DEVICE_INITIALIZING;
return STATUS_SUCCESS;
}DriverEntry
│
├─→ IoCreateDevice(\Device\Zero) 建设备对象
├─→ MajorFunction[CREATE/CLOSE] = CreateClose
├─→ MajorFunction[READ/WRITE] = ReadWrite
├─→ Flags |= DO_BUFFERED_IO 读写用缓冲 I/O
└─→ Flags &= ~DO_DEVICE_INITIALIZING 完成初始化段二:读分发 —— 填零并完成
读请求来了:栈位置里有请求长度,SystemBuffer 是系统复制好的缓冲区。把缓冲区全部填零(Zero 驱动名字的由来),设置 Information 为实际字节数,完成 IRP。注意先取栈位置、再动缓冲区、最后填 IoStatus 的顺序。
NTSTATUS ReadWrite(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
ULONG len = 0;
if (stack->MajorFunction == IRP_MJ_READ) {
// 请求长度就是可写的最大长度
len = stack->Parameters.Read.Length;
// 整个缓冲区填零(缓冲 I/O:直接写 SystemBuffer)
RtlZeroMemory(Irp->SystemBuffer, len);
Irp->IoStatus.Information = len; // 实际返回的字节数
}
// 写请求:Zero 驱动忽略数据,同样"成功"
else {
len = stack->Parameters.Write.Length;
Irp->IoStatus.Information = len; // 假装都"写"进去了
}
Irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}HANDLE h = CreateFileW(L"\\\\.\\Zero",
GENERIC_READ | GENERIC_WRITE, 0, nullptr,
OPEN_EXISTING, 0, nullptr);
char buf[64];
DWORD bytes = 0;
// 读 64 字节 → 缓冲区应全是 0
ReadFile(h, buf, sizeof(buf), &bytes, nullptr);
for (int i = 0; i < 64; i++) {
if (buf[i] != 0) { /* 驱动没填零! */ }
}
// 写 64 字节 → 返回成功,数据被忽略
WriteFile(h, buf, sizeof(buf), &bytes, nullptr);
CloseHandle(h);段三:CreateClose —— 打开与关闭的兜底
Create/Close 走同一个函数:直接标记成功并完成。任何驱动都不能漏掉这两个分发,否则 CreateFile 会失败(没有 Create 处理 = 打开即失败)。
NTSTATUS CreateClose(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
UNREFERENCED_PARAMETER(DeviceObject);
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}CreateFile(\\.\Zero)
│ IRP_MJ_CREATE 沿栈下发
▼
CreateClose
│ 直接成功 + 完成
▼
用户拿到句柄,后续 Read/Write 正常
CloseHandle(h)
│ IRP_MJ_CLOSE
▼
CreateClose(同一函数)
│ 直接成功 + 完成段四:缓冲区比较 —— BUFFERED 与 DIRECT 的关键差异
同一个功能,两种传输方法写起来完全不同。BUFFERED 直接读写 SystemBuffer(系统已复制);DIRECT 要先拿 MDL 映射地址(MmGetSystemAddressForMdlSafe),用完由系统解锁。逐字段对比:
// DeviceObject->Flags |= DO_BUFFERED_IO(在 DriverEntry 设置)
NTSTATUS ReadZero(PDEVICE_OBJECT, PIRP Irp)
{
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
ULONG len = stack->Parameters.Read.Length;
// SystemBuffer 就是你的工作区,直接读写
RtlZeroMemory(Irp->SystemBuffer, len);
Irp->IoStatus.Information = len;
Irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}// DeviceObject->Flags |= DO_DIRECT_IO(DriverEntry 设置)
NTSTATUS ReadDirect(PDEVICE_OBJECT, PIRP Irp)
{
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
ULONG len = stack->Parameters.Read.Length;
// 拿 MDL 映射地址:DIRECT 下用户页已锁定
PVOID va = MmGetSystemAddressForMdlSafe(
Irp->MdlAddress, NormalPagePriority);
if (!va) { // 映射失败
Irp->IoStatus.Status = STATUS_INSUFFICIENT_RESOURCES;
Irp->IoStatus.Information = 0;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_INSUFFICIENT_RESOURCES;
}
RtlZeroMemory(va, len); // 直接写用户内存(零复制)
Irp->IoStatus.Information = len;
Irp->IoStatus.Status = STATUS_SUCCESS;
IoCompleteRequest(Irp, IO_NO_INCREMENT);
return STATUS_SUCCESS;
}容易踩的坑
小结
- IRP 是内核工作单:类型、参数、缓冲区、状态一单到底
- 设备栈多层协作,IRP 从栈顶逐层下传,每层一个栈位置
- 栈位置是各层的参数格,IoGetCurrentIrpStackLocation 取自己的
- 缓冲 I/O 安全但两次复制,直接 I/O 用 MDL 零复制适合大块
- 每个 IRP 只完成一次:完成权排他,双完成崩溃、不完成挂起
练习
问题 1: 在 Demo 中分别点击设备栈的三层,观察栈位置面板的变化。为什么每层驱动看到的栈位置内容不同?用 IRP 与设备栈的概念解释。
问题 2: 下面的读分发例程有什么问题?至少列出两处。
NTSTATUS BadRead(PDEVICE_OBJECT, PIRP Irp)
{
PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
RtlCopyMemory(Irp->SystemBuffer, g_internalData,
stack->Parameters.Read.Length);
Irp->IoStatus.Status = STATUS_SUCCESS;
Irp->IoStatus.Information = 0;
return STATUS_SUCCESS; // 忘了完成!
}问题 3(独立实现题): 写一个写分发例程:设备为 DIRECT I/O。要求:1)从栈位置取写长度;2)用 MmGetSystemAddressForMdlSafe 拿映射地址,失败返回 STATUS_INSUFFICIENT_RESOURCES;3)把用户数据复制到驱动内部缓冲区(长度取两者较小值,防越界);4)Information 填实际复制字节数并完成 IRP。
知识点对照
7.1 IRP简介
IRP简介:I/O 请求包是内核 I/O 请求的基本单位对象,封装请求并沿设备栈逐层传递。
7.2 设备节点
设备节点是设备栈在 PnP 树中的位置表示,由总线驱动枚举并逐层附加功能驱动程序组成,这也是本章的核心要点。
7.3 IRP和I/O栈位置
IRP和I/O栈位置配合使用:每个驱动用自己的栈位置存放各自的参数信息与完成例程,这也是本章的核心要点。
7.5.3 IRP_MJ_DEVICE_CONTROL的用户缓冲区
IRP_MJ_DEVICE_CONTROL的用户缓冲区按传输方式可分为缓冲、直接与两者混合的三种模式。
7.6 汇总:Zero驱动程序
汇总:Zero驱动程序示例演示读、写与设备控制的完整 IRP 处理流程以及各例程的具体职责。
7.6.1 使用预编译头
使用预编译头加速编译过程,把稳定的系统头文件放入预编译单元,避免重复解析浪费编译时间,这也是本章的核心要点。
7.6.2 DriverEntry例程
DriverEntry例程创建设备对象与符号链接,注册各分发例程并初始化全局状态和卸载例程。
7.7 总结
本章总结了 IRP 结构、分发例程与用户缓冲区访问的核心知识与 Zero 驱动的实战经验。
术语表
名词解释
本章出现的专业名词,用大白话再讲一遍。
- IRP
I/O Request Packet,I/O 请求包。内核为每个用户 I/O 请求创建的工作单:写清请求类型(读/写/控制)、参数、缓冲区和完成状态,沿设备栈逐层传递。驱动处理完必须调用 IoCompleteRequest 签收。相当于工厂里的订单:一单到底,各工序在单上签字。
- 设备栈
处理同一设备 I/O 的多个设备对象按上下层叠放的链条,IRP 从栈顶逐层下发。过滤驱动在上、功能驱动居中、总线驱动在下,各层都有机会处理或拦截请求。相当于一条流水线:多个工位串起来干同一件活。
- 栈位置
IO_STACK_LOCATION,IRP 里为设备栈每一层预留的参数格。各层用 IoGetCurrentIrpStackLocation 读自己的格子(请求类型、长度、IOCTL 码),向下传递时把当前标记让给下一层。相当于各工序只读订单上自己那一行。
- 缓冲 I/O
METHOD_BUFFERED,输入输出都经系统缓冲区中转的传输方法。用户数据先复制进 SystemBuffer,驱动在 SystemBuffer 干活,完成后输出再复制回去。安全但多两次复制,适合小数据控制类请求。
- 直接 I/O
METHOD_IN_DIRECT / METHOD_OUT_DIRECT,用 MDL 把用户缓冲区锁定并映射到内核、驱动直接读写的传输方法。零复制,适合大块数据。页面已锁定不会换出,但内容仍可能被用户并发修改。
- MDL
Memory Descriptor List,内存描述符表。描述一段用户内存的物理页面布局,驱动通过它把用户缓冲区映射到内核空间直接访问。相当于给用户内存办了一张“内核临时通行证”。