5、字符串与数据结构:ANSI与UNICODE字符串、链表、自旋锁、内核安全字符串函数
说实话,很多从应用层转内核开发的朋友,第一个栽跟头的地方就是字符串处理。为什么?因为内核里没有你熟悉的 strcpy、sprintf,甚至连 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;
为什么要有 Length 和 MaximumLength?说白了就是为了安全。应用层你可以靠 '\0' 判断字符串结尾,内核里不行——万一有人故意不写结尾0呢?有了长度字段,所有操作都基于这个长度,不会越界。
小技巧: 初始化 UNICODE_STRING 时,建议用 RtlInitUnicodeString 或 RtlUnicodeStringInit。不要手动赋值 Length 和 MaximumLength,容易算错字节数。
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的妙用
应用层写链表,你可能会自己定义 next 和 prev 指针。内核里不一样,它用了一种侵入式链表的设计——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。插入节点用 InsertHeadList 或 InsertTailList。遍历用 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 知识体系总览
下面这张图把本章的核心知识点串了起来。你可以看到,字符串、链表、自旋锁这三者是如何配合的:
从这张图可以看得很清楚:字符串提供数据载体,链表提供组织方式,自旋锁提供并发保护。三者缺一不可。
避坑指南: 我曾经在遍历链表时忘了加锁,结果另一个核心刚好在删除节点。遍历指针变成了野指针,系统直接蓝屏,错误码是 IRQL_NOT_LESS_OR_EQUAL。从那以后,我养成了一个习惯:任何链表操作,先问自己一句「锁拿了没?」
好了,这一章的内容就到这里。字符串、链表、自旋锁,这三个东西你掌握了,内核里80%的数据结构操作你都能应付。剩下的就是多写、多踩坑、多总结。