5、字符串与数据结构:ANSI与UNICODE字符串、链表、自旋锁、内核安全字符串函数

说实话,很多从应用层转内核开发的朋友,第一个栽跟头的地方就是字符串处理。为什么?因为内核里没有你熟悉的 strcpysprintf,甚至连 char* 都不建议直接用。嗯,这背后是有原因的。

我个人习惯,在讲任何内核API之前,先搞清楚它要解决什么问题。字符串这块,核心问题就两个:安全编码。应用层你可以随便写,崩了也就崩了,大不了重启进程。内核里你敢崩?蓝屏伺候。

5.1 ANSI与UNICODE:内核世界的两种语言

Windows内核从NT 3.1开始就全面拥抱UNICODE。你想想看,一个系统要支持全球语言,靠ANSI那256个字符根本不够用。所以内核API绝大多数都接受UNICODE字符串(UTF-16LE编码)。

但现实是,有些老驱动或者硬件厂商的固件接口还是用ANSI。这就带来了一个麻烦:你必须在两种编码之间来回切换

我记得有一次调试一个打印驱动的bug,厂商给的配置数据是ANSI的,但内核的I/O管理器传下来的路径是UNICODE的。两边一碰,乱码了。排查了半天,最后发现是字符串转换时少调了一个函数。

核心数据类型:

  • CHAR / PCHAR — ANSI单字节字符
  • WCHAR / PWCHAR — UNICODE宽字符(2字节)
  • UNICODE_STRING — 内核推荐的UNICODE字符串结构体
  • ANSI_STRING — 对应的ANSI版本

这里要特别强调 UNICODE_STRING。它不是简单的 wchar_t* 包装,而是一个带长度信息的结构体:

typedef struct _UNICODE_STRING {
    USHORT Length;      // 字符串当前字节数(不含结尾0)
    USHORT MaximumLength; // 缓冲区总字节数
    PWSTR  Buffer;      // 指向宽字符缓冲区
} UNICODE_STRING, *PUNICODE_STRING;

为什么要有 LengthMaximumLength?说白了就是为了安全。应用层你可以靠 '\0' 判断字符串结尾,内核里不行——万一有人故意不写结尾0呢?有了长度字段,所有操作都基于这个长度,不会越界。

小技巧: 初始化 UNICODE_STRING 时,建议用 RtlInitUnicodeStringRtlUnicodeStringInit。不要手动赋值 LengthMaximumLength,容易算错字节数。

5.2 内核安全字符串函数:别再惦记strcpy了

微软在WDK里提供了一套安全字符串函数,命名规则是 RtlStringCb*(按字节)和 RtlStringCch*(按字符)。它们跟CRT的字符串函数最大的区别是:所有操作都要求传入缓冲区大小,并且永远保证以NULL结尾

我曾经见过一个同事,在内核里直接用 wcscpy,结果目标缓冲区只分配了64字节,源字符串有128字节。后果?蓝屏,然后被PM追着骂了一周。

来看几个最常用的:

函数 作用 替代CRT函数
RtlStringCbCopyW 按字节数复制UNICODE字符串 wcscpy
RtlStringCchCopyW 按字符数复制UNICODE字符串 wcscpy
RtlStringCbCatW 按字节数拼接UNICODE字符串 wcscat
RtlStringCchPrintfW 安全格式化字符串 swprintf
RtlUnicodeStringToAnsiString UNICODE转ANSI WideCharToMultiByte

这些函数的返回值都是 NTSTATUS。如果缓冲区太小,返回 STATUS_BUFFER_OVERFLOW。你想想看,这比直接崩溃强了多少倍?

UNICODE_STRING src = RTL_CONSTANT_STRING(L"Hello, Kernel World!");
WCHAR destBuf[32];
NTSTATUS status = RtlStringCbCopyW(destBuf, sizeof(destBuf), src.Buffer);
if (!NT_SUCCESS(status)) {
    // 处理错误,比如分配更大的缓冲区
    DbgPrint("Copy failed: 0x%X\n", status);
}

注意: RTL_CONSTANT_STRING 只能用于编译期常量字符串。如果字符串是动态生成的,用 RtlInitUnicodeString

5.3 内核链表:LIST_ENTRY的妙用

应用层写链表,你可能会自己定义 nextprev 指针。内核里不一样,它用了一种侵入式链表的设计——LIST_ENTRY 结构体嵌入到你的数据结构中。

这样做的好处是什么?一个链表操作函数,可以操作任意类型的数据结构。说白了就是泛型,只不过C语言用宏和指针强制转换来实现。

typedef struct _LIST_ENTRY {
    struct _LIST_ENTRY *Flink;  // 指向下一个节点
    struct _LIST_ENTRY *Blink;  // 指向前一个节点
} LIST_ENTRY, *PLIST_ENTRY;

// 你的数据结构
typedef struct _MY_DEVICE_ENTRY {
    LIST_ENTRY ListEntry;  // 链表节点,必须放在第一个字段(习惯上)
    ULONG DeviceId;
    PVOID Context;
} MY_DEVICE_ENTRY, *PMY_DEVICE_ENTRY;

初始化链表头用 InitializeListHead。插入节点用 InsertHeadListInsertTailList。遍历用 CONTAINING_RECORD 宏从 LIST_ENTRY 反推出你的数据结构地址。

我记得第一次看 CONTAINING_RECORD 的宏定义时,愣了半天:

#define CONTAINING_RECORD(address, type, field) \
    ((type *)((PCHAR)(address) - (ULONG_PTR)(&((type *)0)->field)))

它其实就是算了个偏移量。把 LIST_ENTRY 在结构体中的偏移减掉,就得到了结构体的起始地址。很巧妙,对吧?

使用模式:

LIST_ENTRY deviceListHead;
InitializeListHead(&deviceListHead);

// 插入
PMY_DEVICE_ENTRY entry = (PMY_DEVICE_ENTRY)ExAllocatePool2(POOL_FLAG_PAGED, sizeof(MY_DEVICE_ENTRY), 'yMDe');
entry->DeviceId = 1;
InsertTailList(&deviceListHead, &entry->ListEntry);

// 遍历
PLIST_ENTRY pos = deviceListHead.Flink;
while (pos != &deviceListHead) {
    PMY_DEVICE_ENTRY current = CONTAINING_RECORD(pos, MY_DEVICE_ENTRY, ListEntry);
    DbgPrint("Device ID: %lu\n", current->DeviceId);
    pos = pos->Flink;
}

5.4 自旋锁:多核时代的护身符

链表本身是线程不安全的。两个CPU核心同时往链表里插节点,Flink和Blink指针会乱成一锅粥。这时候就需要自旋锁(Spin Lock)出场了。

自旋锁的原理很简单:如果锁被占用,当前CPU核心就原地打转(自旋),直到锁被释放。它不切换线程,不睡眠,所以适合在IRQL >= DISPATCH_LEVEL 的代码中使用。

但要注意,自旋锁是「忙等」。如果你持锁时间太长,其他核心就在那空转,浪费CPU。所以内核编程的铁律是:持锁时间越短越好,千万别在持锁时做内存分配、文件操作等耗时任务

我曾经犯过一个错误:在自旋锁保护的临界区里调用了 ExAllocatePool2。结果系统在压力测试下直接死锁了。为什么?因为内存分配可能触发页面错误,而页面错误处理需要获取同一个锁……嗯,典型的死锁场景。

// 声明和初始化自旋锁
KSPIN_LOCK mySpinLock;
KeInitializeSpinLock(&mySpinLock);

// 使用
KIRQL oldIrql;
KeAcquireSpinLock(&mySpinLock, &oldIrql);
// 操作链表等共享数据
InsertTailList(&deviceListHead, &entry->ListEntry);
KeReleaseSpinLock(&mySpinLock, oldIrql);

关键点:

  • KeAcquireSpinLock 会把当前IRQL提升到 DISPATCH_LEVEL,释放时恢复到原来的IRQL。
  • DISPATCH_LEVEL 及以上,不能访问分页内存(会触发页面错误)。
  • 如果你需要保护的数据可能被低IRQL代码访问,也要用自旋锁——它会帮你提升IRQL,防止被中断抢占。

5.5 综合运用:一个带锁的安全字符串链表

把上面这些知识点串起来,我们实现一个简单的设备名注册表。它用自旋锁保护,用 LIST_ENTRY 管理,用安全字符串函数处理数据。

typedef struct _DEVICE_NAME_NODE {
    LIST_ENTRY ListEntry;
    UNICODE_STRING DeviceName;  // 注意:这里存储的是拷贝,不是指针
    ULONG Flags;
} DEVICE_NAME_NODE, *PDEVICE_NAME_NODE;

LIST_ENTRY g_NameListHead;
KSPIN_LOCK g_NameListLock;

NTSTATUS RegisterDeviceName(PCWSTR name, ULONG flags) {
    NTSTATUS status;
    PDEVICE_NAME_NODE node = NULL;
    KIRQL oldIrql;

    // 1. 分配节点内存
    node = (PDEVICE_NAME_NODE)ExAllocatePool2(POOL_FLAG_PAGED, sizeof(DEVICE_NAME_NODE), 'mNdN');
    if (!node) return STATUS_INSUFFICIENT_RESOURCES;

    // 2. 初始化UNICODE_STRING(拷贝字符串)
    RtlInitUnicodeString(&node->DeviceName, NULL);
    status = RtlUnicodeStringCopy(&node->DeviceName, name);
    if (!NT_SUCCESS(status)) {
        ExFreePool(node);
        return status;
    }
    node->Flags = flags;

    // 3. 加锁插入链表
    KeAcquireSpinLock(&g_NameListLock, &oldIrql);
    InsertTailList(&g_NameListHead, &node->ListEntry);
    KeReleaseSpinLock(&g_NameListLock, oldIrql);

    return STATUS_SUCCESS;
}

你看,这里每一步都考虑了安全:内存分配检查返回值、字符串拷贝用安全函数、链表操作加自旋锁。这就是内核开发的常态——不是写功能,而是写健壮性

5.6 知识体系总览

下面这张图把本章的核心知识点串了起来。你可以看到,字符串、链表、自旋锁这三者是如何配合的:

内核字符串与数据结构知识体系 字符串处理 • UNICODE_STRING / ANSI_STRING • RtlStringCbCopy / RtlStringCchCopy • RtlUnicodeStringCopy • 安全:长度检查 + NULL结尾 链表管理 • LIST_ENTRY 侵入式链表 • InitializeListHead • InsertHeadList / InsertTailList • CONTAINING_RECORD 宏 同步机制 • KSPIN_LOCK 自旋锁 • KeAcquireSpinLock • KeReleaseSpinLock • IRQL提升至DISPATCH_LEVEL 综合应用:带锁的安全字符串链表 1. 数据结构:LIST_ENTRY + UNICODE_STRING(字符串拷贝存储) 2. 初始化:InitializeListHead + KeInitializeSpinLock 3. 插入流程:分配内存 → 安全拷贝字符串 → 加锁 → 插入链表 → 解锁 4. 遍历流程:加锁 → 遍历CONTAINING_RECORD → 解锁

从这张图可以看得很清楚:字符串提供数据载体,链表提供组织方式,自旋锁提供并发保护。三者缺一不可。

避坑指南: 我曾经在遍历链表时忘了加锁,结果另一个核心刚好在删除节点。遍历指针变成了野指针,系统直接蓝屏,错误码是 IRQL_NOT_LESS_OR_EQUAL。从那以后,我养成了一个习惯:任何链表操作,先问自己一句「锁拿了没?」

好了,这一章的内容就到这里。字符串、链表、自旋锁,这三个东西你掌握了,内核里80%的数据结构操作你都能应付。剩下的就是多写、多踩坑、多总结。


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