7、注册表操作:打开与创建键、读写值、枚举子键、注册表回调、配置管理

注册表操作,在驱动开发里是个绕不开的活。说实话,很多应用层程序员觉得注册表就是 RegOpenKeyEx 那套 API,但在内核里,玩法完全不一样。我早期做驱动时,就因为没搞懂内核注册表操作的差异,踩过不少坑。

今天咱们就把这块彻底捋清楚。从最基本的打开创建键,到读写值、枚举子键,再到注册表回调与配置管理,一条线讲下来。

7.1 打开与创建注册表键

内核里操作注册表,核心函数是 ZwCreateKeyZwOpenKey。你可能会问,为什么不用 RegCreateKeyEx?嗯,那是应用层的 API,内核里用不了。内核有自己的一套 ZwXxx 系列函数。

先看一个打开键的例子:

HANDLE hKey = NULL;
OBJECT_ATTRIBUTES objAttr;
UNICODE_STRING ustrKeyPath;
NTSTATUS status;

RtlInitUnicodeString(&ustrKeyPath, L"\\Registry\\Machine\\SOFTWARE\\MyDriver");
InitializeObjectAttributes(&objAttr, &ustrKeyPath, OBJ_KERNEL_HANDLE, NULL, NULL);

status = ZwOpenKey(&hKey, KEY_READ, &objAttr);
if (!NT_SUCCESS(status)) {
    // 处理错误
    DbgPrint("打开注册表键失败: 0x%X\n", status);
}

注意路径写法。内核里注册表路径必须以 \Registry\Machine 开头,对应应用层的 HKEY_LOCAL_MACHINE。如果是 HKEY_CURRENT_USER,对应的是 \Registry\User。这个映射关系,我建议你记牢了,写错路径是新手最常见的错误。

创建键也很类似,只是多了一个 CreateOptions 参数:

status = ZwCreateKey(&hKey, KEY_WRITE, &objAttr, 0, NULL, REG_OPTION_NON_VOLATILE, &disposition);
if (NT_SUCCESS(status)) {
    if (disposition == REG_CREATED_NEW_KEY) {
        DbgPrint("新键已创建\n");
    } else if (disposition == REG_OPENED_EXISTING_KEY) {
        DbgPrint("键已存在,直接打开\n");
    }
}

disposition 这个参数挺有用。它能告诉你这个键是新建的还是已有的。我在项目中就用它来判断是否是第一次加载驱动,从而决定是否写入默认配置。

注意: 内核中打开的句柄默认是内核句柄,不能直接传给应用层使用。如果你需要在应用层和内核层共享句柄,需要设置 OBJ_KERNEL_HANDLE 标志。我个人习惯是统一用内核句柄,需要时再通过 ObReferenceObjectByHandle 转换。

7.2 读写注册表值

读写值用的是 ZwQueryValueKeyZwSetValueKey。这里有个小技巧——查询值时,你通常不知道值有多大,所以需要先查询大小,再分配缓冲区。

看代码:

// 先查询值的大小
KEY_VALUE_PARTIAL_INFORMATION kvpi;
ULONG ulSize = 0;
status = ZwQueryValueKey(hKey, &ustrValueName, KeyValuePartialInformation, 
                         &kvpi, sizeof(kvpi), &ulSize);

if (status == STATUS_BUFFER_OVERFLOW || status == STATUS_BUFFER_TOO_SMALL) {
    // 分配足够大的缓冲区
    PVOID pBuffer = ExAllocatePoolWithTag(PagedPool, ulSize, 'gReT');
    if (pBuffer) {
        status = ZwQueryValueKey(hKey, &ustrValueName, KeyValuePartialInformation,
                                 pBuffer, ulSize, &ulSize);
        if (NT_SUCCESS(status)) {
            PKEY_VALUE_PARTIAL_INFORMATION pKvpi = (PKEY_VALUE_PARTIAL_INFORMATION)pBuffer;
            // 根据 pKvpi->Type 判断数据类型,读取 pKvpi->Data
        }
        ExFreePoolWithTag(pBuffer, 'gReT');
    }
}

写值相对简单:

ULONG ulData = 12345;
status = ZwSetValueKey(hKey, &ustrValueName, 0, REG_DWORD, &ulData, sizeof(ulData));

这里有个坑——ZwQueryValueKey 返回的状态码。我第一次写的时候以为 STATUS_SUCCESS 才是成功,结果发现当缓冲区刚好够用时,它返回的是 STATUS_BUFFER_OVERFLOW。嗯,微软的 API 设计有时候就是这么「巧妙」。

经验之谈: 我建议你写一个通用的注册表值读取函数,封装好各种数据类型(DWORD、SZ、BINARY)的读取逻辑。这样每次用到时直接调用,不用重复写那些繁琐的缓冲区分配代码。

7.3 枚举子键

枚举子键用 ZwEnumerateKey。这个函数需要你循环调用,每次传入一个索引值,从 0 开始递增,直到返回 STATUS_NO_MORE_ENTRIES

核心逻辑是这样的:

for (ULONG i = 0; ; i++) {
    // 先查询子键名称的长度
    KEY_BASIC_INFORMATION kbi;
    ULONG ulResultLen = 0;
    status = ZwEnumerateKey(hKey, i, KeyBasicInformation, &kbi, sizeof(kbi), &ulResultLen);
    
    if (status == STATUS_NO_MORE_ENTRIES) {
        break;
    }
    
    if (status == STATUS_BUFFER_TOO_SMALL) {
        // 分配缓冲区,重新查询
        PKEY_BASIC_INFORMATION pKbi = (PKEY_BASIC_INFORMATION)
            ExAllocatePoolWithTag(PagedPool, ulResultLen, 'mune');
        if (pKbi) {
            status = ZwEnumerateKey(hKey, i, KeyBasicInformation, pKbi, ulResultLen, &ulResultLen);
            if (NT_SUCCESS(status)) {
                // pKbi->Name 就是子键名称
                DbgPrint("子键: %wZ\n", &pKbi->Name);
            }
            ExFreePoolWithTag(pKbi, 'mune');
        }
    }
}

枚举值项用 ZwEnumerateValueKey,用法完全一样。你想想看,这两个函数的设计思路其实很一致——先查大小,再分配,再读取。内核编程里这种模式随处可见。

7.4 注册表回调

注册表回调是驱动开发中比较高级的功能。通过 CmRegisterCallbackEx 注册一个回调函数,你就能监控甚至拦截注册表操作。

注册回调的步骤:

LARGE_INTEGER liAltitude;
RtlInitUnicodeString(&ustrAltitude, L"320000"); // 海拔高度,决定回调顺序
NTSTATUS status = CmRegisterCallbackEx(
    RegistryCallback,        // 回调函数
    &ustrAltitude,           // 海拔
    NULL,                    // 驱动对象
    NULL,                    // 上下文
    &hCookie,                // 返回的Cookie,用于卸载
    NULL                     // 保留
);

回调函数的原型:

NTSTATUS RegistryCallback(
    PVOID CallbackContext,
    PVOID Argument1,        // 操作类型
    PVOID Argument2         // 操作相关数据
) {
    REG_NOTIFY_CLASS NotifyClass = (REG_NOTIFY_CLASS)(ULONG_PTR)Argument1;
    
    switch (NotifyClass) {
    case RegNtPreCreateKeyEx:
        // 创建键前触发
        break;
    case RegNtPreSetValueKey:
        // 设置值前触发
        break;
    case RegNtPostDeleteKey:
        // 删除键后触发
        break;
    }
    
    return STATUS_SUCCESS;
}

我曾经用这个功能做过一个安全驱动,监控注册表启动项的修改。当检测到有进程试图修改 HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Run 时,直接拦截并记录日志。效果还不错。

重要提醒: 注册表回调运行在任意线程上下文中,绝对不能做阻塞操作。另外,回调中不要调用 ZwOpenKey 等注册表 API,否则可能造成死锁。我见过有人在这里递归调用把自己搞崩了。

7.5 配置管理

配置管理说白了就是如何组织你的驱动配置信息。我一般遵循几个原则:

  • 路径统一:所有配置放在 HKLM\SYSTEM\CurrentControlSet\Services\YourDriver\Parameters
  • 版本兼容:配置项增加时,要能兼容旧版本(没有新配置项时使用默认值)
  • 热加载:支持运行时重新加载配置,不需要重启驱动

这里给一个配置管理的结构体示例:

typedef struct _DRIVER_CONFIG {
    ULONG   ulLogLevel;      // 日志级别
    ULONG   ulTimeout;       // 超时时间(ms)
    BOOLEAN bEnableFilter;   // 是否启用过滤
    WCHAR   wszLogPath[256]; // 日志路径
} DRIVER_CONFIG, *PDRIVER_CONFIG;

// 从注册表加载配置
NTSTATUS LoadDriverConfig(PDRIVER_CONFIG pConfig) {
    // 先设置默认值
    pConfig->ulLogLevel = 2;
    pConfig->ulTimeout = 5000;
    pConfig->bEnableFilter = TRUE;
    wcscpy_s(pConfig->wszLogPath, 256, L"\\SystemRoot\\Logs\\MyDriver.log");
    
    // 再从注册表读取,覆盖默认值
    // ... 读取逻辑
}

为什么要有默认值?因为用户可能没配置某些项。我在项目中遇到过,升级驱动后新增了一个配置项,结果旧版本没写这个值,驱动加载时直接蓝屏了。从那以后,我所有配置读取都先设默认值,再覆盖。

7.6 知识体系总览

下面这张图把注册表操作的核心知识点串起来了:

注册表操作知识体系 打开与创建键 ZwCreateKey / ZwOpenKey 读写注册表值 ZwQueryValueKey / ZwSetValueKey 枚举子键与值 ZwEnumerateKey / ZwEnumerateValueKey 注册表回调 CmRegisterCallbackEx — 监控/拦截注册表操作 配置管理 路径统一 → 版本兼容 → 热加载 关键要点 路径格式 · 缓冲区分配 · 回调安全 · 默认值策略

从这张图能看出来,注册表操作其实是个层层递进的过程。先掌握基础的打开、读写、枚举,再学习回调机制,最后用配置管理把整个体系串起来。

嗯,关于注册表操作,今天就讲这么多。这些内容都是我实际项目中反复用到的,希望能帮你少走弯路。记住一点:内核编程没有小事,每个 API 的返回值都要检查,每个句柄都要记得关闭。

核心总结:

  • 使用 ZwXxx 系列函数操作注册表,路径以 \Registry\Machine 开头
  • 读写值前先查询大小,避免缓冲区溢出
  • 枚举时循环调用,直到返回 STATUS_NO_MORE_ENTRIES
  • 注册表回调中不要调用注册表 API,防止死锁
  • 配置管理始终设置默认值,保证兼容性

公众号:蓝海资料掘金营,微信deep3321