简介:WinRing0源码包是一份面向Windows系统底层开发者与驱动工程师的完整驱动工具源码,旨在解决用户态程序无法直接访问硬件寄存器的问题,广泛适用于系统性能监控、硬件调试、恶意软件检测及驱动开发等领域。资源共98个文件,压缩包仅825KB,内部按源码、示例与文档分层组织:包含C/C++核心源码(.h/.cpp/.c)、C#调用示例、Visual Studio 2005/2008/2010等多版本工程文件(.sln/.vcproj/.vcxproj)、以及编译好的驱动与库文件(.sys/.dll/.lib),同时提供CHM格式手册、HTML说明和版权文档,方便查阅与二次编译。已有665人学习下载。通过研读源码,开发者可以完整学习WinRing0用户态API与内核驱动之间的协作流程,理解系统调用、I/O端口读写、CPU寄存器访问等底层机制,掌握安全高效的用户态与内核态切换方法,为驱动开发、平台调试和系统优化提供扎实的参考,对理解Windows底层工作原理同样具有重要价值。 如果你用过 HWMonitor、Core Temp、或者 OpenHardwareMonitor 这类 Windows 硬件检测工具,那你其实早就和 WinRing0 打过照面了。这个开源项目没有漂亮的官网,也没有复杂到劝退的文档,但它提供的这套源码,长久以来一直是硬件监控圈子里的“公共底盘”:一个用户态 DLL 加一个内核驱动,就能让普通程序读到 CPU 温度、风扇转速、电压,甚至直接操作 I/O 端口。这篇文章我想做的不是劝你把每一行都背下来,而是站在源码阅读者的角度,把 WinRing0 的骨架、关键路径和集成时最容易翻车的地方拆开讲。
很多第一次接触 WinRing0 源码的人,第一反应是“代码量居然这么小”。但恰恰是这种小而美,让它能常年驻留在各类硬件工具里。你会看到用户态和内核态是如何通过一堆 IOCTL 结构体完成“隔空传话”的,也会看到老派驱动工程师是怎么在不动用复杂框架的情况下,把 MSR、PCI 配置空间、IO 端口这些底层资源收拾得明明白白。
1. 为什么 Windows 硬件监控工具里总有 WinRing0 的影子
1.1 用户态程序读不到的那堆寄存器
先回到最基础的问题:普通 Windows 程序为什么读不了 CPU 温度?
原因很简单——CPU 温度数据主要藏在模型专属寄存器(MSR)里,比如 Intel 的 IA32_THERM_STATUS_MSR(地址 0x19C)就保存着当前核心的热状态。问题在于,读写 MSR 属于特权指令,操作系统把用户态程序关在 Ring 3,这条指令一执行就会触发权限异常,直接让进程崩溃。
Windows 当然提供了一些正规通道,比如 WMI 和 ACIP 温度对象,但它们的问题在于:粒度太粗、延迟不稳定、还受主板固件实现影响。做硬件监控的人想要的是一手数据,而不是系统嚼过一遍再吐出来的东西。于是思路就很自然了:写一个内核驱动,在 Ring 0 下直接执行特权指令,然后通过某种通信机制把结果丢回用户态。Windows 在这里给出的标准答案就是 IOCTL(DeviceIoControl)。
1.2 不是唯一选择,但是最容易被拖走的那一位
你可能会问,既然驱动这么重要,那为什么不是每家工具厂商都自己写一套?因为成本不划算。自己写一个稳定的内核驱动,要考虑平台差异、驱动签名、蓝屏风险、WHQL 认证,这几乎是一条不归路。WinRing0 的价值恰恰在于:它把底层硬件访问能力做成了一份可以反复复制粘贴的公共组件。
在它之前,同类方案也不是没有。比如有些工具选择直接用 Windows 自带的 WDK 样例驱动改,或者用第三方厂商提供的 SDK,但那些要么文档不全、要么授权不友好。WinRing0 走的是完全反过来的路线:源码干净、API 直白、一台机器上几十个项目都能共用同一个驱动文件。也正因为这样,你会发现它在硬件圈子里几乎成了默认基础设施。它的 1.2.0 版本在很长一段时间里就是事实上的最终版,直到今天,仍有大量开源工具基于它二次封装。
2. 读源码前,先看清“驱动 + DLL”的架构分工
2.1 源码目录里的固定套路
去 GitHub 上把 WinRing0 仓库拖下来,你首先看到的是两个视觉上差异很大的工程:一边是内核驱动源码,另一边是用户态 DLL 源码。这个两分结构本身就是理解整份代码的第一把钥匙。
从功能上划分,大概可以这样看:
| 部分 | 对应文件/目录 | 职责 |
|---|---|---|
| 内核驱动 | WinRing0.c / WinRing0.h 等 | 在 Ring 0 执行真正的 MSR / IO Port / PCI / 内存访问 |
| 用户态 DLL | WinRing0.dll / WinRing0x64.dll 对应工程 | 导出 API,把用户参数打包后发给驱动 |
| 公共头文件 | WinRing0.h | 定义 IOCTL 宏、输入输出结构体、API 原型 |
| 安装相关 | INF 文件、安装工具 | 把驱动装进系统并创建服务 |
别小看这个目录安排。很多初学者一上来就扎进 WinRing0.c 的驱动代码里,想在几千行里找到“读温度”的函数,结果翻了半天找不到,然后发现温度读取本质上是通过若干基础寄存器读操作组合出来的。真正方便阅读的入口,其实是那份公共头文件。你在这个文件里能看到一组 IOCTL 编号和结构体定义,而理解了这组 IOCTL,就相当于拿到了整份源码的“通信协议”。
2.2 两块编译产物在运行时如何对接
驱动和 DLL 不是简单地在同一个进程里互相调用,而是通过 Windows 的对象管理器实现跨层通信。驱动在加载时会创建一个设备对象,名字通常是“WinRing0_1_2_0”这种带版本号的格式,同时创建一个符号链接,让用户态程序可以通过\\.\WinRing0_1_2_0这样的路径打开它。
用户态 DLL 做的事情,本质上只有一件:用CreateFile拿到这个设备的句柄,再通过DeviceIoControl把请求连同参数一起发给驱动,等驱动处理完返回结果。整个链路非常像你去柜台办事:你把材料递进窗口,工作人员收走,工序完成后把回执从同一个窗口递给你。在内核驱动里,接收这些“材料”的入口就是DispatchDeviceControl函数,它按 IOCTL 编号分发到不同的处理分支。理解了这条链路,源码里那些看起来杂乱的结构体就各就各位了。
3. 驱动侧核心:IOCTL 分发和四条硬件访问路径
3.1 驱动入口:创建设备,绑定分发函数
打开 WinRing0 驱动源码,第一站不是硬件操作,而是DriverEntry。这里做了三件套:创建设备对象、创建符号链接、给IRP_MJ_DEVICE_CONTROL等分发函数挂上回调。
它选择的设备名带版本号,这在我看来是个非常讲究的设计。Windows 驱动一旦被某进程加载,如果没有卸载,就会常驻内核。带版本号的设备名可以让新版本 DLL 和旧版本驱动共存,避免“DLL 是新版、驱动还是老的”这种错配问题。用户态 DLL 在初始化时还会做一次版本握手,对不上就直接拒绝,省去后面一大堆诡异行为。
然后是 IOCTL 分发函数。这里基本都是switch/case结构,每个 case 对应一个 IOCTL。老派做法一般会把输入参数放在 SystemBuffer,输出结果也写回同一个 SystemBuffer,也就是METHOD_BUFFERED模式。这种模式的好处是安全性相对更好,因为操作系统会先完成缓冲区复制,驱动侧不用自己处理杂乱的用户态地址。
3.2 MSR 读写:一条特权指令背后的分层
读 MSR 是 WinRing0 的主打功能之一。它对外暴露的 API 是Rdmsr(index, &eax, &edx)/Wrmsr(index, eax, edx),但驱动内部拿到的是一个包含 MSR 地址和上下文的结构体,然后在 Ring 0 下执行对应指令。
在 64 位 Windows 驱动里,通常使用编译器内置函数__readmsr/__writemsr,而不是内联汇编。驱动把eax和edx这两个 32 位寄存器拼成 64 位返回,或者反向拆开写入。你如果只是做应用层开发,完全不用关心这些细节——DLL 已经把结构体封装成了四个整型参数。
一个很容易被忽略的点是,MSR 是“跟着逻辑 CPU 走”的。同一个 MSR 地址,在不同核心上运行,读出来的值可能完全不同。也就是说,你调Rdmsr时,它在哪个核心上运行完全由系统调度决定。正常监控工具会自己用SetThreadAffinityMask把线程锁到指定核心,再依次读取,才能得到“所有核心温度”。这个问题在后面讲集成时还得再展开。
3.3 PCI 配置空间与 0xCF8 / 0xCFC 老法门
WinRing0 的另一个常用功能是读写 PCI 配置空间。它的实现思路非常教科书:向 I/O 端口 0xCF8 写入一个 PCI 配置地址,再从 0xCFC 读取数据。
这里有一个必须理解的关键点:0xCF8 / 0xCFC 是传统的 PCI 配置访问机制,本来不是给 PCIe 时代用的,但兼容性极好,几乎所有 x86 平台都支持。WinRing0 处理 PCI 请求时,会把总线号、设备号、功能号、寄存器偏移编码成一个 32 位地址,然后通过端口操作完成事务。
举个例子,你要读某个设备 BAR 空间里的内容,驱动会先计算出配置地址,然后执行一次Out32(0xCF8, addr)和In32(0xCFC)。这个路径看起来古老,但在很多场景下比走 Windows 官方 PCI 驱动接口更直接,也是 WinRing0 被各种主板工具眷顾的原因之一。
3.4 I/O 端口和物理内存访问的补位
除了 MSR 和 PCI,WinRing0 还能直接读写 I/O 端口和物理内存,这正是它能力强大的地方。I/O 端口操作在驱动里就是几条in/out汇编指令,用户态程序没权限执行,所以必须依赖驱动代劳。WinRing0 把这一能力打包成ReadIoPortByte、WriteIoPortByte系列 API,一些刮擦类工具甚至拿它直接操作 EC(嵌入式控制器)寄存器。
物理内存访问则更特殊。Windows 内核默认不允许随便读写物理内存,但驱动可以通过MmMapIoSpace把一段物理地址映射到虚拟地址空间,然后直接操作。WinRing0 利用这个机制实现了对物理内存的读取。这个功能也是最容易被安全软件盯上的原因——因为“能读物理内存”这句话看起来太像 Rootkit 了。但在正经的硬件调试和主板调试场景里,它确实必不可少。
4. 用户态 DLL:把“一层壳”做顺手的细节
4.1 初始化函数里做了哪些事
用户态 DLL 看起来只是简单地转发请求,但InitializeOls这个函数绝对不简单。它要完成设备打开、驱动版本检查、平台区分等一整套逻辑。
正常流程大致是:先检查系统是 32 位还是 64 位,然后通过CreateFile尝试打开设备。如果设备不存在,说明驱动没有加载;有些版本会走到驱动安装逻辑,有些则直接返回失败。而驱动加载本身又包含服务创建、文件复制、启动服务等步骤,需要管理员权限。这也是为什么很多硬件工具第一次运行时都会弹 UAC 提权——不是程序矫情,而是不拿到管理员权限,驱动根本起不来。
初始化成功之后,后面所有 API 调用都基于这个全局设备句柄。你没听错,WinRing0 内部确实维护了一个全局句柄,这意味着如果你的程序里多个线程同时调用不同 API,理论上都可能基于同一个设备句柄发送 IOCTL,这也是并发问题的一个潜在来源。
4.2 以 Rdmsr 为例,拆解参数封送过程
来看一下最常见的Rdmsr调用,它究竟是怎样的包装逻辑。伪代码大致是这样的:
BOOL WINAPI Rdmsr(DWORD index, PDWORD eax, PDWORD edx) { DWORD ret = 0; BYTE output[16] = {0}; // 构造输入/输出共享的结构体 // index 放到 buffer 开头,eax/edx 作为输出位置的地址 if (!DeviceIoControl(hDevice, IOCTL_OLS_READ_MSR, inputBuffer, sizeof(inputBuffer), outputBuffer, sizeof(outputBuffer), &ret, NULL)) { return FALSE; } // 从 outputBuffer 解析出 eax、edx *eax = *(DWORD *)(outputBuffer + offset_eax); *edx = *(DWORD *)(outputBuffer + offset_edx); return TRUE; }你看,整个过程没有魔法。用户传进来的index被塞进一个结构体,通过DeviceIoControl送进内核,驱动返回时把 MSR 结果写回同一个缓冲区,DLL 再把结果拆出来填充到调用者的变量里。WinRing0 源码里有一批这样的函数,包装手法高度统一,读起来非常省脑子。
我特别欣赏的一点是,它把底层结构体细节隐藏得非常干净。用户不需要知道 IOCTL 编号,也不需要关心缓冲区格式,只需要像调用普通库函数一样传入参数。这种“把复杂留给库里,把简单留给调用者”的设计,我觉得是轻型驱动库一个非常值得学习的范本。
4.3 多核心读取与线程亲和性的关键关联
这节虽然是讲 DLL,但实际是给所有调用者提个醒。前面提到 MSR 与逻辑核心绑定,那真实项目里怎么读取所有核心的温度?
常规解法是循环设置线程亲和性:把当前线程锁到 CPU 0,读取温度;再锁到 CPU 1,读取温度;以此类推。WinRing0 的Rdmsr不会帮你做这件事,它只是忠实地返回“当前线程所在核心”的数据。
所以你在源码或者是很多二次封装库里,都会看到针对每个逻辑核心做SetThreadAffinityMask的循环。这里有个容易踩的坑:如果进程只设置了进程级亲和性,但线程级没有设置,或者设置后没有恢复,读取出来的数据很可能会错位。我之前遇到过一种情况:四个核心的温度全部相同,排查了半天,发现是线程被系统固定到了同一个核心上。所以重点不是代码怎么写,而是要对“MSR 是上下文相关的”这件事有清醒认识。
5. 集成实操与避坑:编译、签名、误报和稳定性
5.1 拿到源码后的编译链路
如果你想自己编译一遍 WinRing0,需要准备 Visual Studio 和对应的 WDK(Windows Driver Kit)。驱动工程用 WDK 编译,DLL 工程用普通 Win32 工程编译,两者使用同一份WinRing0.h作为协议约定。
编译最麻烦的地方在于工程文件较老。你可能会遇到_WIN32_WINNT版本宏过低、新 WDK 里某些函数被移除、甚至 INT3 等指令在不同编译器版本下处理方式不同的情况。我的建议是:不要一上来就挑战最新版 WDK,可以先找一个成熟的编译脚本或者参考老版本驱动项目的配置思路。驱动部分如果实在编译不过,也可以考虑直接用官方 release 里已经编好的 .sys 文件,需求只是集成而不是改动驱动时,这反而更省事。
5.2 驱动签名与加载:最大的拦路虎
在 64 位 Windows 上,驱动的加载和签名是绕不开的话题。尤其较新版本的 Windows,开启了内核强制签名,未经正确签名的驱动根本不会启动。
在很多个人工具里,常见做法是临时开启测试签名模式:
bcdedit /set testsigning on然后给驱动打一个测试签名证书。但要注意,这个操作在开了 Secure Boot 的机器上往往无效,只会导致驱动加载失败。我自己调试时通常会在专门的测试机上关掉 Secure Boot,开测试签名,再把驱动加载起来。生产环境如果要分发给普通用户,就得走正规的代码签名证书申请流程,或者采用更温和的方案,比如把驱动签名验证完全交给 Windows 更新链路上的已签名发布版。
另外一个非常现实的问题是杀毒软件误报。因为 WinRing0 能访问物理内存和 I/O 端口,能力边界太接近恶意程序需要的底层原语,很多杀毒引擎会直接报 HackTool/Riskware。这让它在个人工具分发时有点吃力。我见过一些项目作者把整套驱动签名之后依然被某一家引擎误报,最后只能靠提交白名单解决。这一点如果做商业软件,必须提前想清楚合规性和风险。
5.3 并发访问和稳定性
WinRing0 的 API 设计非常轻量,但轻量不代表你可以肆无忌惮地在多线程环境里乱调。因为它内部同一个设备句柄会被多个线程共享,如果两个线程同时发起 IOCTL,驱动侧虽然会串行处理,但用户态拿到输出缓冲区的过程仍然存在数据交错的可能。
我的建议是:在应用层统一做互斥。简单一点,可以给所有 WinRing0 调用加一个全局锁;追求性能的话,至少把同类型操作串行化。比如你要同时读一组 MSR,最好在单个线程里完成整个序列,不要把这组读取拆给多个线程并行,不然你拿到的快照在时间线上就是错位的。
稳定性方面还有一个容易被忽视的点:InitializeOls和DeinitializeOls一定要成对调用,而且不要在驱动句柄尚未初始化时去调用底层 API。很多“莫名其妙崩溃”“退出时蓝屏”的案例,最后定位到的原因都是 DLL 卸载顺序没控制好,设备句柄已经被关闭了还在底层发请求。WinRing0 本身没有问题,问题在于调用者忽略了自己引入的竞态。
5.4 WinRing0 源码值得带走的开发习惯
如果让我总结它最值得借鉴的设计思路,我不会选某个具体寄存器操作,而是它维护协议的方式。公共头文件同时被驱动和 DLL 引用,IOCTL 编号和结构体在同一个地方定义,这种“一份协议,两处实现”的组织方式极大降低了维护成本。你改一个结构体,驱动和 DLL 马上同步,不用在两个工程之间翻来翻去对偏移量。
设备名带版本号、初始化时做版本握手,这种防御性设计也很值得借鉴。在内核驱动这种“没机会热修复”的领域,版本错配等于灾难。用户态 DLL 倒是可以随便换,但驱动一旦加载进内核,卸载和重载就不是一个进程能控制的事了。版本号就是两边确认“我们说的是同一个协议版本”的唯一信物。我在自己的项目里沿用这个套路,省了非常多沟通成本。
WinRing0 这套代码不算复杂,但它是一门很典型的驱动封装课。如果你正准备写自己的硬件访问工具,或者只是想搞明白“用户态怎么安全地碰到硬件寄存器”,它就是一份极佳的入门参考。真去翻的时候,不用着急看细节,先把驱动和 DLL 之间那层 IOCTL 协议打通,后面自然就顺了。
本文还有配套的精品资源,点击获取