第7章 I/O请求包

跟踪 IRP 从创建、穿过设备栈各层栈位置、经分发例程处理到完成的完整旅程,并掌握 BUFFERED 与 DIRECT 两种用户缓冲区传输方法。

学习目标

  • 能解释 IRP 从创建、穿过设备栈到完成的全过程,以及栈位置在逐层传递中的作用
  • 能改出 Zero 驱动的读分发例程:用 BUFFERED 方法正确计算可复制长度并完成 IRP 的效果
  • 能回答:一个 IRP 被两个驱动层各完成一次会发生什么?为什么每个 IRP 只能完成一次?

为什么请求要在流水线上“一站一站”走

想象一条流水线:订单从窗口进来,先经过质检员看一眼,再到装配工手里干活,最后到打包员手里装车。每道工序只看到订单上跟自己有关的那一行,干完在自己那栏签个字,订单继续往下传。谁也不能把订单留在自己手里不撒手,否则整条线卡死。

内核的 I/O 请求也是这样的一张“订单”。用户程序要读文件、写设备,系统把请求打成一张标准工单,沿着一条由多个驱动叠成的流水线逐层传递,每层只处理自己那一段,最后签收返回。

这一章拆开这张工单:它长什么样(结构)、流水线怎么排(设备栈)、每层看哪一行(栈位置)、怎么签收(完成)。看懂这张工单,你就看懂了内核一半的日常。

IRP:内核的工作单

(I/O Request Packet)是驱动接收工作的单位。用户调用 ReadFile/WriteFile/DeviceIoControl 时,I/O 管理器创建 IRP:填上 MajorFunction(请求类型,如 IRP_MJ_READ)、参数(长度、偏移)、缓冲区引用,然后沿设备栈下发。

IRP 上有两个关键字段:IoStatus.Status(结果码,驱动处理完填)和 IoStatus.Information(传输字节数)。IRP 还挂着一串栈位置(下一节),以及按传输方法指向用户缓冲区的指针或 MDL。驱动拿到 IRP 就像工人拿到工单:看类型、看参数、干活、填结果、签收。

设备栈:叠起来的工位

同一个设备可能被多个驱动共同服务:过滤驱动(检查/修改)、功能驱动(核心逻辑)、总线驱动(硬件通信)。它们各自的设备对象按“上层在下层之上”的顺序叠放,组成(device stack)。

IRP 从栈顶进入,沿栈逐层向下:每层驱动先看请求,决定自己处理、修改还是原样传给下层。你的驱动通常附加为过滤设备(上层)或功能设备(中层)。设备栈让“多个驱动协作处理一个请求”成为可能——这也是第 10 章文件系统过滤驱动的基础。

栈位置:每层只看自己的那行

IRP 携带一串(IO_STACK_LOCATION),栈里每一层各占一个。驱动用 IoGetCurrentIrpStackLocation(Irp) 拿到自己的那格,读取 MajorFunction、IOCTL 码、长度等参数;向下传递时用 IoSkipCurrentIrpStackLocation 把“当前”标记让给下一层。

为什么要分格?因为多层驱动看到的是同一个 IRP,但每层的参数不同(过滤层关注是否拦截,功能层关注具体参数)。栈位置就是“各层工位的工作单副本”——各看各的,互不污染。驱动把参数写错格,下层就会读到错数据。

缓冲 I/O:安全的两趟搬运

访问用户缓冲区有三种传输方法,先在 CTL_CODE 宏第 2 参数里选定。第一种是(METHOD_BUFFERED):I/O 管理器把用户输入复制到内核 SystemBuffer,驱动在 SystemBuffer 上干活,完成后系统把输出复制回用户缓冲区。

特点:用户指针对驱动完全不可见——不存在“用户改地址”的竞态,最安全。代价是两次复制(进+出),大块数据(MB 级)开销明显。适合控制类小请求(IOCTL、小读写)。第 4 章的 IOCTL 例子用的就是它。

直接 I/O:零复制的直通车

第二种是(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 旅程:从用户请求到完成
IRP 旅程:用户请求 → 设备栈 → 完成用户模式请求ReadFile / WriteFile / DeviceIoControl→ I/O 管理器创建 IRP,填栈位置IRP(工作单)MajorFunction: IRP_MJ_READIoStatus: pending → completed设备栈(IRP 逐层向下)过滤驱动可拦截/修改请求功能驱动(你的驱动)处理 IRP 的业务逻辑总线驱动最终落地硬件当前层 I/O 栈位置功能驱动(你的驱动) 的工作单副本MajorFunction: IRP_MJ_READParameters.Read.Length: 4096IoGetCurrentIrpStackLocation(Irp)用户缓冲区传输方法缓冲 I/O(BUFFERED)直接 I/O(DIRECT/MDL)SystemBuffer:输入已复制进来,输出复制回去(两次复制,安全)驱动访问 Irp->SystemBuffer,完成后系统自动把输出拷回用户缓冲区完成:设置 IoStatus.Status / Information → IoCompleteRequest(Irp, IO_NO_INCREMENT)每个 IRP 只完成一次;不完成 = 调用方永久挂起,双完成 = 系统崩溃
IRP: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;
}

段二:读分发 —— 填零并完成

读请求来了:栈位置里有请求长度,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;
}

段三: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;
}

段四:缓冲区比较 —— 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;
}

容易踩的坑

小结

  • 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,内存描述符表。描述一段用户内存的物理页面布局,驱动通过它把用户缓冲区映射到内核空间直接访问。相当于给用户内存办了一张“内核临时通行证”。

讨论

评论区加载中…