简介:面向Windows内核驱动开发初学者与进阶者的VC++驱动源程序合集,围绕设备驱动框架、IRP处理与内核调试等核心问题,提供可参考、可复用的工程素材。压缩包共657个文件,约7.73MB,以304个.h头文件、149个.lib库文件、34个.c与12个.cpp源码为主体,并配有.dsp工程、.inf安装信息、.sys驱动模块及.rc资源脚本,便于按模块梳理工程结构与编译依赖。已有155人学习下载。内容涉及DriverEntry初始化、设备对象与驱动接口、中断服务例程、同步互斥、内存与硬件资源分配,以及WDF框架、代码签名和WinDbg调试思路,适合用来对照理解Windows内核机制、搭建实验环境并开展二次开发。
1. VC++ 驱动源程序:一个压缩包解开的到底是些什么
去年帮人收拾一台老工控机,厂商的采集卡驱动早就不更新了,对方只丢过来一个压缩包,解压开全是 .c、.h、.inf、.vcxproj,文件名里清一色带着 drv 后缀。这不是普通 VC++ 工程,而是跑在 Windows 内核里的 VC++ 驱动源程序——用 C 写入口,靠 WDK 编译成 .sys,再由系统加载进 Ring0。它适合两类人:一类是手里有老硬件、老设备,原厂驱动没了但协议还清楚,得自己接上去;另一类是刚接触内核开发,想拿一份能编译、能加载、能跟用户态通信的最小骨架当模板。下面按这个包实际的拆法,从工程骨架一路读到能改出自己 IOCTL 的程度。
2. 工程骨架:WDK 驱动工程和普通 C++ 工程差在哪
2.1 vcxproj 里那几行决定生死的配置
把一个 .vcxproj 拖进编辑器,第一眼看过去和普通控制台工程差不多,但真正决定它是不是驱动的,是ConfigurationType和DriverType这两个节点。普通 exe 是Application,驱动必须是Driver,再配TargetVersion指定目标系统版本,WDK 才会把内核库和 CRTL 换成内核版的运行库,把入口点从main换成DriverEntry。这一步配错了,编出来的文件后缀能是 .sys,但一加载就报“映像格式不正确”,纯属玄学。
<!-- 驱动工程的关键属性,缺一项都会让链接阶段跑偏 --> <PropertyGroup> <ConfigurationType>Driver</ConfigurationType> <!-- 不是 Application --> <DriverType>KMDF</DriverType> <!-- 内核模式驱动,也可写 WDM --> <TargetVersion>Windows10</TargetVersion> <!-- 决定链接哪套内核库 --> <UseDebugLibraries>false</UseDebugLibraries> <PlatformToolset>v143</PlatformToolset> </PropertyGroup> <PropertyGroup> <!-- 内核里没有用户态异常处理,这两个必须关 --> <ExceptionHandling>false</ExceptionHandling> <BufferSecurityCheck>false</BufferSecurityCheck> </PropertyGroup>ConfigurationType写成Driver只是告诉构建系统“我要的是驱动”,DriverType才决定用 KMDF 还是裸 WDM 框架。这个源程序包用的是 WDM 风格,也就是自己IoCreateDevice、自己挂MajorFunction,没有 KMDF 那层对象封装。好处是链路透明,每一层都能看见;坏处是所有清理工作得自己兜。BufferSecurityCheck关掉是因为 /GS 的 cookie 依赖用户态运行时,内核态链接时会找不到符号。常见做法是把TargetVersion和测试机的系统版本对齐,跨版本加载虽然多数时候能成,但DriverEntry里的版本判断会给你添麻烦。
我一般拿到一份陌生驱动工程,先不改代码,只做一件事:把配置从 Debug 切到 Release 各编一遍,看两边产物大小差多少。差得离谱就说明有断言或者调试输出没被条件编译包住,这类代码一旦进正式环境,DbgPrint 刷屏能把整机拖慢。
2.2 目录结构逐层看:哪一层是业务,哪一层是胶水
这个包的目录不算深,但分层挺清楚,顺着看能很快判断出哪块该动、哪块别碰。
MyDrv/ ├── inc/ │ └── MyDrvIoctl.h # 用户态和内核态共用的 IOCTL 码,必须两边一致 ├── src/ │ ├── DriverEntry.c # 入口:创建设备、符号链接、挂分发函数 │ ├── Dispatch.c # IRP 分发:CREATE/CLOSE/DEVICE_CONTROL │ ├── Hardware.c # 真正的业务:端口读写、寄存器操作 │ └── DriverUnload.c # 卸载:删符号链接、删设备对象 ├── inf/ │ └── MyDrv.inf # 安装脚本,写服务项和硬件 ID ├── user/ │ └── MyDrvTest/ # 用户态测试程序,CreateFile + DeviceIoControl └── MyDrv.slninc目录里那个头文件是整个工程里最值得先读的文件,它定义了双方约定的控制码,任何一端改了而另一端没跟着改,通信就断。src/Hardware.c是业务层,和内核 API 耦合最少,二次开发时改动量最大也最安全。user/MyDrvTest是配套的验证工具,很多人下载驱动包只编驱动不编它,结果加载成功也不知道通没通,白白多排查半小时。
inf目录容易被忽略。WDM 驱动有两种加载方式:一种是sc create手工建服务,适合调试;另一种是走 inf 安装,适合分发。inf 里的ServiceBinary路径、StartType、HardwareID三项写不对,设备管理器里就是一个黄色感叹号,而且报的错常常和真实原因无关。
2.3 编译产物:.sys 之外还得盯住两个文件
Release 编完,输出目录里除了.sys,还有.pdb和.cer。.pdb是符号文件,调试时没有它,蓝屏的调用栈就是一堆地址,等于看天书。.cer是签名流程里生成的证书,测试阶段用它给 .sys 签名,测试机上导入这张证书之后才能加载。
编译命令可以直接用 msbuild 驱动解决方案,省得开 IDE:
:: 先确认 WDK 环境变量已就位,再编 Release x64 msbuild MyDrv.sln /p:Configuration=Release /p:Platform=x64 /t:Rebuild/t:Rebuild而不是/t:Build,是因为驱动工程里有过中间文件残留导致链接到旧 .obj 的情况,血泪经验是每次改完头文件都强制重建。/p:Platform=x64别省,默认可能落到 Win32,编出来的东西在 64 位系统上根本加载不了。
3. DriverEntry 到设备对象:内核里第一段必须跑通的代码
3.1 DriverEntry 里四件必须做对的事
DriverEntry是驱动的main,系统加载 .sys 后第一个调的就是它。它的返回值决定加载成败,返回非STATUS_SUCCESS系统会立刻卸载映像,事件日志里只留一句很含糊的话。这段代码要按顺序做四件事:创建设备对象、设置缓冲方式、创建符号链接、挂分发函数。
#include <ntddk.h> #include "MyDrvIoctl.h" #define DEVICE_NAME L"\\Device\\MyDrvDevice" #define SYMLINK_NAME L"\\DosDevices\\MyDrvDevice" // 入口:系统加载映像后第一个调用,返回值直接决定加载成败 NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObj, PUNICODE_STRING pRegistryPath) { NTSTATUS status = STATUS_SUCCESS; PDEVICE_OBJECT pDevObj = NULL; UNICODE_STRING devName, symName; UNREFERENCED_PARAMETER(pRegistryPath); // 1. 创建设备对象,名字必须是 \Device\ 下的完整路径 RtlInitUnicodeString(&devName, DEVICE_NAME); status = IoCreateDevice(pDriverObj, 0, &devName, FILE_DEVICE_UNKNOWN, // 设备类型,未知设备最通用 FILE_DEVICE_SECURE_OPEN, // 做安全检查 FALSE, // 非独占 &pDevObj); if (!NT_SUCCESS(status)) { return status; // 设备建不出来,后面全是空谈 } // 2. 指定缓冲方式,三选一,必须和用户态调用方式匹配 pDevObj->Flags |= DO_BUFFERED_IO; // 3. 建符号链接,用户态才能用 \\.\MyDrvDevice 打开 RtlInitUnicodeString(&symName, SYMLINK_NAME); status = IoCreateSymbolicLink(&symName, &devName); if (!NT_SUCCESS(status)) { IoDeleteDevice(pDevObj); // 失败要回滚,不然设备泄漏 return status; } // 4. 挂分发函数,没挂的 IRP 会返回 STATUS_INVALID_DEVICE_REQUEST pDriverObj->MajorFunction[IRP_MJ_CREATE] = DispatchCreateClose; pDriverObj->MajorFunction[IRP_MJ_CLOSE] = DispatchCreateClose; pDriverObj->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchDeviceControl; pDriverObj->DriverUnload = DriverUnload; pDevObj->Flags &= ~DO_DEVICE_INITIALIZING; // 最后一步才允许外部打开 return STATUS_SUCCESS; }四个参数的坑集中在IoCreateDevice上。第三个参数是设备名,必须是\Device\开头的完整路径;第二个参数是设备扩展大小,这个包传 0,意味着不存额外状态,如果后面要保存端口基址之类的信息,这里得改成sizeof(DEVICE_EXTENSION),读取时用pDevObj->DeviceExtension。最后一个DO_DEVICE_INITIALIZING标志,必须在所有初始化做完之后再清,提前清掉会让外部在你还没准备好时就打开设备,蓝屏的概率不低。
DO_BUFFERED_IO这一行决定了后面 IOCTL 怎么写。三种缓冲方式只能选一种,选DO_BUFFERED_IO就用pIrp->AssociatedIrp.SystemBuffer读写,系统帮你拷贝;选DO_DIRECT_IO就用 MDL 锁页;不设则走METHOD_NEITHER,用户态传进来的裸地址需要自己校验,稍不注意就被利用。
3.2 符号链接与卸载路径:不对偶就是内存泄漏
符号链接是内核设备名和用户态可见名之间的桥。用户态CreateFile(L"\\\\.\\MyDrvDevice")实际被重定向到\Device\MyDrvDevice。名字对不上就是ERROR_FILE_NOT_FOUND,很多人第一反应是驱动没加载,其实sc query一看服务是 RUNNING,只是符号链接拼错了一个字符。
卸载函数必须和DriverEntry严格对偶,建了什么就删什么,顺序还得反过来:
// 卸载:顺序必须和 DriverEntry 相反,先删链接再删设备 VOID DriverUnload(PDRIVER_OBJECT pDriverObj) { UNICODE_STRING symName; RtlInitUnicodeString(&symName, SYMLINK_NAME); IoDeleteSymbolicLink(&symName); // 先断掉用户态入口 IoDeleteDevice(pDriverObj->DeviceObject); // 再销毁设备对象 }先删链接再删设备是有讲究的。反过来的话,在两步之间有一个时间窗,用户态还能通过残留的符号链接去打开一个已经销毁的设备,直接蓝屏。这段代码短,但漏掉任意一行,sc stop之后就再也sc start不起来了,因为符号链接还占着名字,第二次IoCreateSymbolicLink返回STATUS_OBJECT_NAME_COLLISION。
这个包在卸载里还多做了一步:把DeviceExtension里申请的非分页内存和端口资源一并释放。凡是MmAllocateNonCachedMemory、IoAllocateMdl、HalTranslateBusAddress拿到的资源,都必须在DriverUnload里还回去。内核没有垃圾回收,漏一次就永久缺一块内存,重启才能恢复。
4. IRP 分发与 IOCTL 通信:用户态和内核态怎么对上话
4.1 从 IRP 栈里取参数:三个长度必须分清
用户态调DeviceIoControl之后,I/O 管理器把它翻译成一个IRP_MJ_DEVICE_CONTROL的 IRP,交给驱动挂的分发函数。分发函数里第一件事永远是通过IoGetCurrentIrpStackLocation拿到本次请求的参数块,控制码、输入长度、输出长度全在里面。这三个字段混用的后果是缓冲区越界,而内核里的越界不是崩溃,是随机改坏别人的内存,症状千奇百怪。
NTSTATUS DispatchDeviceControl(PDEVICE_OBJECT pDevObj, PIRP pIrp) { PIO_STACK_LOCATION stack; ULONG code, inLen, outLen, info = 0; NTSTATUS status = STATUS_INVALID_DEVICE_REQUEST; PVOID buf; UNREFERENCED_PARAMETER(pDevObj); stack = IoGetCurrentIrpStackLocation(pIrp); code = stack->Parameters.DeviceIoControl.IoControlCode; inLen = stack->Parameters.DeviceIoControl.InputBufferLength; outLen = stack->Parameters.DeviceIoControl.OutputBufferLength; buf = pIrp->AssociatedIrp.SystemBuffer; // BUFFERED_IO 下就是这一块 switch (code) { case MY_IOCTL_GET_VERSION: // 输出缓冲区必须够大,否则返回 STATUS_BUFFER_TOO_SMALL 而不是硬写 if (outLen >= sizeof(ULONG)) { *(ULONG *)buf = 0x00010000; // 高 16 位主版本,低 16 位次版本 info = sizeof(ULONG); // 告诉调用方实际写了多少 status = STATUS_SUCCESS; } else { status = STATUS_BUFFER_TOO_SMALL; } break; case MY_IOCTL_READ_PORT: // 输入长度校验永远放在访问 buf 之前 if (inLen < sizeof(PORT_REQUEST)) { status = STATUS_INVALID_PARAMETER; break; } status = DoReadPort((PPORT_REQUEST)buf, outLen, &info); break; default: break; } pIrp->IoStatus.Status = status; pIrp->IoStatus.Information = info; // 不设这个,用户态拿到的返回长度是 0 IoCompleteRequest(pIrp, IO_NO_INCREMENT); return status; }InputBufferLength和OutputBufferLength是分别由用户态的nInBufferSize、nOutBufferSize决定的,两者都指向同一块SystemBuffer。缓冲区复用意味着写输出之前必须先确认输入已经读完,否则会把输入参数覆盖掉。Information字段返回给用户态的第 7 个参数,忘记赋值的时候,用户态DeviceIoControl返回成功但lpBytesReturned是 0,很容易被误判成失败。
IoCompleteRequest的第二个参数是优先级提升值,绝大多数同步分发用IO_NO_INCREMENT就够。如果驱动内部把 IRP 挂起异步处理,那分发函数必须先返回STATUS_PENDING,再在别的线程里完成它,这两步顺序搞反就是经典的“完成一个已经完成的 IRP”,蓝屏代码0x8E。
4.2 IOCTL 控制码:宏里的四个参数各管什么
控制码用CTL_CODE拼,四个参数分别是设备类型、功能号、传输方式和访问权限。这个宏生成的 32 位值会被 I/O 管理器解析,设备类型和访问权限对不上,请求根本到不了你的分发函数。
// inc/MyDrvIoctl.h,内核态和用户态都要包含这个头文件 #ifndef MYDRV_IOCTL_H #define MYDRV_IOCTL_H #define MY_DEVICE_TYPE 0x8000 // 自定义设备类型,取 0x8000 以上避免和系统冲突 // 功能号从 0x800 开始,0x000-0x7FF 留给系统保留 #define MY_IOCTL_GET_VERSION CTL_CODE(MY_DEVICE_TYPE, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS) #define MY_IOCTL_READ_PORT CTL_CODE(MY_DEVICE_TYPE, 0x801, METHOD_BUFFERED, FILE_READ_ACCESS | FILE_WRITE_ACCESS) #endif第二个参数是功能号,0x800 以下是预留给系统的,自己用必须从 0x800 起。第三个参数METHOD_BUFFERED必须和DriverEntry里设的DO_BUFFERED_IO一致,不一致的时候 I/O 管理器会按控制码里的方式去处理缓冲区,而你的代码按另一种方式访问,等于自己给自己埋雷。第四个参数是访问权限,FILE_ANY_ACCESS最宽松,FILE_READ_ACCESS之类的会让用户态在没带对应GENERIC_READ权限打开设备时直接被拒,错误码是ERROR_ACCESS_DENIED,看起来像权限问题,其实是控制码定义问题。
头文件必须两边共用同一份,不能各写各的。我见过一次翻车就是用户态工程里复制了一份控制码,结果功能号抄错一位,驱动端一直走default分支返回STATUS_INVALID_DEVICE_REQUEST,排查了两小时才发现是复制粘贴的锅。
4.3 用户态配套代码:一个最小验证程序
驱动加载成功不代表能用,必须有一个用户态程序把链路打通。这个包里user/MyDrvTest就是干这个的,核心只有三步:打开设备、发控制码、关句柄。
#include <windows.h> #include <cstdio> #include "../../inc/MyDrvIoctl.h" int main() { // 用户态看到的路径是 \\.\ + 符号链接名,注意转义 HANDLE hDev = CreateFileW(L"\\\\.\\MyDrvDevice", GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hDev == INVALID_HANDLE_VALUE) { printf("open failed, err=%lu\n", GetLastError()); return 1; } ULONG version = 0, returned = 0; BOOL ok = DeviceIoControl(hDev, MY_IOCTL_GET_VERSION, NULL, 0, // 无输入 &version, sizeof(version),// 输出缓冲区 &returned, NULL); if (!ok) { printf("ioctl failed, err=%lu\n", GetLastError()); } else { printf("version=%u.%u returned=%lu\n", (version >> 16) & 0xFFFF, version & 0xFFFF, returned); } CloseHandle(hDev); return 0; }CreateFileW的路径里\\.\是固定写法,四个反斜杠在 C 字符串里转义成两个,写成一个或三个都打不开。dwShareMode传 0 表示独占,如果驱动里IoCreateDevice的Exclusive参数传了 TRUE,这里也必须传 0,两边不一致会返回ERROR_SHARING_VIOLATION。最后一个参数是 OVERLAPPED 指针,同步调用传 NULL 即可,传了就得处理异步完成。
DeviceIoControl的返回值和GetLastError()要分开看。返回 FALSE 时错误码才有意义;返回 TRUE 但returned是 0,说明驱动没设IoStatus.Information,这是驱动侧的问题不是调用侧的问题。这个区分能省掉大量无谓的排查。
5. 编译签名与加载排错:五个真踩过的坑
5.1 从 .sys 到能跑起来,中间还差签名和安装
编出 .sys 只完成了一半,现代 Windows 对内核模块有签名强制。测试阶段最省事的路径是打开测试签名模式,再给自己的 .sys 签一张测试证书。
:: 测试机上执行,开启测试签名模式后必须重启 bcdedit /set testsigning on :: 生成自签证书并入发布者存储(管理员权限) makecert -r -pe -ss PrivateCertStore -n "CN=MyDrvTest" MyDrvTest.cer certmgr -add MyDrvTest.cer -s -r localMachine root certmgr -add MyDrvTest.cer -s -r localMachine trustedpublisher :: 给驱动签名 signtool sign /v /s PrivateCertStore /n MyDrvTest /t http://timestamp.example.com MyDrv.sys时间戳服务器参数在实际环境里要换成本机可达的地址,内网机器访问不了外网时间源的时候signtool会卡住不回。签名完成之后用signtool verify /v /kp MyDrv.sys自查,输出里Kernel Mode Signing那一段必须是通过状态。
加载走手工建服务最快,调试期不用折腾 inf:
:: 注意 type= 和 binPath= 后面那个空格,少一个就报语法错误 sc create MyDrv type= kernel binPath= C:\drv\MyDrv.sys sc start MyDrv sc query MyDrv sc stop MyDrv sc delete MyDrv这套命令里sc create的等号后面必须跟空格,写成type=kernel会直接返回“参数错误”。binPath用绝对路径,相对路径在内核加载时是相对于System32\drivers解析的,放错位置的结果是ERROR_FILE_NOT_FOUND,但事件日志常常不写具体路径。
5.2 五个高频问题:现象、原因、解决
现象一:sc start返回错误 577,事件日志写“数字签名无法验证”。原因是 .sys 没签名,或者签了但证书没进trustedpublisher。测试签名模式下系统只信任已导入的证书链,光开关打开不够。解决办法是重新执行certmgr -add把 .cer 导进本地计算机的“受信任的发布者”,再signtool sign重签一遍。还有一个隐蔽情况:改过代码之后忘了重签,旧签名和新文件哈希对不上,验证一样失败。
现象二:加载成功,但用户态CreateFile一直返回ERROR_FILE_NOT_FOUND。原因是符号链接没建成功,或者名字拼错。IoCreateSymbolicLink的第二个参数是目标设备名,第一个是用户态可见名,两个写反了就会出现“服务在跑但打不开”。排查方法是进 WinObj 之类工具看\Device\下有没有你的设备名、\DosDevices\下有没有对应链接。写反的情况在第一次写驱动时特别常见。
现象三:调用DeviceIoControl直接蓝屏,代码0x0000000A或0x0000001E。原因是分发函数里访问了没校验的指针或者越界缓冲区。METHOD_NEITHER方式下SystemBuffer是 NULL,直接解引用必炸;METHOD_BUFFERED方式下如果用户态传的nOutBufferSize小于驱动写入的字节数,SystemBuffer后面就是别人的内存。解决办法是每个分支进来先判长度再碰指针,所有输出前都对比outLen,宁可返回STATUS_BUFFER_TOO_SMALL也别硬写。写完拿驱动验证工具跑一遍,能提前抓出大半。
现象四:sc stop之后再也sc start不起来,返回STATUS_OBJECT_NAME_COLLISION。原因是DriverUnload里符号链接没删干净,或者第一次加载失败时回滚路径漏了删设备。名字被占住之后,第二次IoCreateSymbolicLink必然冲突。解决分两步:先在 WinObj 里确认残留对象,重启清掉;再把DriverEntry里所有失败分支都补上IoDeleteDevice和IoDeleteSymbolicLink,形成完整的失败回滚链。
现象五:Debug 版能跑,Release 版一加载就崩。原因是代码里有依赖调试运行时的操作,或者断言里藏了副作用。内核态 Release 会关掉 /GS 和部分安全检查,ASSERT在 Release 下整个被编掉,写在里面的赋值语句跟着消失,逻辑就断了。解决办法是把所有带副作用的表达式从ASSERT里挪出来,单独成行;再用DbgPrint加条件编译包住的日志,逐段确认走到哪一步。
6. 二次开发:加一个自己的 IOCTL 到底改哪几处
拿到这份源程序,最常做的改造是加一个业务控制码,比如读某个寄存器或者下发一段配置。改动点固定在四个位置,漏一个链路就断。第一处在inc/MyDrvIoctl.h里加控制码定义,功能号在已有最大值上递增,传输方式沿用METHOD_BUFFERED,别混着来;第二处在DispatchDeviceControl的 switch 里加分支,进来先校验inLen/outLen,再调业务函数;第三处在src/Hardware.c里实现真正的读写逻辑;第四处在用户态测试程序里照着已有调用复制一份,换成新的控制码。
| 改动位置 | 改什么 | 漏改的后果 |
|---|---|---|
| inc/MyDrvIoctl.h | 新增 CTL_CODE 定义 | 两端控制码不一致,走 default 分支 |
| Dispatch.c | switch 新分支 + 长度校验 | 请求被忽略,返回 STATUS_INVALID_DEVICE_REQUEST |
| Hardware.c | 实际业务实现 | 返回成功但没有任何效果 |
| user/MyDrvTest | 调用侧代码 | 无法验证,出问题定位不到哪一端 |
业务函数本身建议保持“只碰缓冲区、不碰 IRP”的形态,把 IRP 相关的东西全留在分发层。这样改的时候业务代码可以单独拷出来测,不用每次都加载驱动。我一般写成NTSTATUS DoXxx(PVOID buf, ULONG inLen, ULONG outLen, PULONG pInfo),分发层只负责判长度和转调,出错返回什么状态码由业务层决定,边界清楚之后调试成本能降一大截。
验证顺序也有讲究:先单编驱动确认无警告,再加载看sc query是不是 RUNNING,然后用测试程序发已知控制码确认老功能没被改坏,最后才发新控制码。这个顺序反过来的话,新功能不通时你会怀疑是驱动没加载、签名失效还是自己逻辑错,三种可能混在一起排查,时间全耗在试错上。改完记得重签名,测试签名模式下旧证书对应的哈希变了同样加载失败。
从那以后我每次动这份源程序,都强制走一遍“改头文件→改分发→改业务→改测试→重建→重签→按顺序验证”的固定路径,一步都不跳。驱动这东西出错代价太大,一次蓝屏可能就是一上午的活白干。希望帮到你。
本文还有配套的精品资源,点击获取