无符号如何排查死锁?手写 WinDbg JS 脚本实现启发式死锁链条自动扫描
在生产环境中遭遇进程卡死(Deadlock)是高频且棘手的问题。更糟糕的是,当我们拿到 Dump 文件时,往往面临**没有私有符号(Private Symbols)**的窘境。
此时,WinDbg 自带的 !locks 命令大概率会失效(因为它依赖 ntdll!RtlCriticalSectionList 或未被裁剪的调试信息)。而由于现代 Windows 默认启用了 RTL_CRITICAL_SECTION_FLAG_NO_DEBUG_INFO,许多关键的临界区甚至不会注册到全局链表中。
本文将分享一种**启发式栈扫描(Heuristic Stack Scanning)**的思路,并提供一个完整的 WinDbg JS 自动化脚本。即使在完全没有业务符号、只有 Ntdll 公共符号的情况下,也能秒级定位并还原出完整的死锁链条。
一、 核心攻坚原理
既然没有符号,我们如何知道哪个线程在等哪个锁?又如何知道这个锁被谁占用了?
1. 寻找突破口:阻塞线程的栈
任何因等待临界区(Critical Section)而阻塞的线程,其调用栈的顶部必然会经历:ntdll!RtlpWaitOnCriticalSection -> ntdll!RtlpWaitOnAddress(或 NtWaitForAlertByThreadId)。
虽然我们没有业务代码的符号,但 Ntdll 的公共符号(Public Symbols)几乎总是可用的。这就为我们提供了定位阻塞状态的锚点。
2. 内存特征匹配:逆向 RTL_CRITICAL_SECTION
在 x64 架构下,Windows 的 RTL_CRITICAL_SECTION 结构体有着非常固定的内存布局:
| 偏移 (Offset) | 字段名 | 类型 | 说明 |
|---|---|---|---|
+0x00 |
DebugInfo |
Ptr64 | 指向调试信息的指针(常为 0 或特定堆地址) |
+0x08 |
LockCount |
Int4B | 锁的计数状态(有竞争时,此值通常 < 0 或包含等待者标志) |
+0x0C |
RecursionCount |
Int4B | 重入次数 |
+0x10 |
OwningThread |
Ptr64 | 持有该锁的线程的唯一标识符 (TID) |
+0x18 |
LockSemaphore |
Ptr64 | 信号量句柄 |
+0x20 |
SpinCount |
Uint8B | 自旋次数 |
关键的启发式关联点:
当一个线程调用 RtlpWaitOnCriticalSection 时,该临界区结构体的指针(即 PRTL_CRITICAL_SECTION)作为第一个参数(RCX)传入。随着调用栈的深入,这个指针会被压入当前线程的栈空间内(作为局部变量或寄存器备份)。
只要我们扫描该阻塞线程当前的栈帧空间,读取每一个看起来像指针的值,并验证它是否满足以下特征,就能大概率找出它正在等待的临界区地址:
- 该地址在有效的用户态地址空间内。
- 8 字节对齐。
- 读取该地址偏移
0x10处的 8 字节(OwningThread),其值必须等于当前进程中某一个活跃线程的操作系统线程 ID (TID),且不能是当前等待线程自己。 - 读取该地址偏移
0x08处的 4 字节(LockCount),其值指示存在锁竞争。
二、 WinDbg JS 脚本实现
利用 WinDbg 的新一代 JavaScript 扩展接口,我们可以高效地遍历线程、读取内存、解析栈帧,并用图论算法(DFS)检测环路(死锁)。
新建一个名为 deadlock_scanner.js 的文件,将以下完整代码写入:
/**
* WinDbg 启发式死锁检测脚本 (JS Provider)
* 支持在无业务符号环境下自动分析 RTL_CRITICAL_SECTION 死锁链
*/
"use strict";
function initializeScript() {
return [new host.functionAlias(findDeadlocks, "finddeadlocks")];
}
// 核心入口函数
function findDeadlocks() {
const log = host.diagnostics.debugLog;
log("*** 启动启发式死锁链条扫描 (x64) ***\n");
const threads = host.currentProcess.Threads;
const threadMap = new Map(); // TID -> Thread Object
const waitGraph = new Map(); // Waiting TID -> { csAddress, ownerTID }
// 1. 初始化线程映射表
for (let t of threads) {
threadMap.set(t.Id, t);
}
log(`[*] 检索到进程内活跃线程数: ${threadMap.size}\n`);
// 2. 遍历线程,寻找处于等待临界区状态的线程
for (let t of threads) {
let isWaitingForLock = false;
let stackFrameToScan = null;
try {
const frames = t.Stack.Frames;
for (let i = 0; i < frames.length; i++) {
const frame = frames[i];
const symbol = frame.toString();
// 匹配临界区等待的关键系统函数
if (symbol.indexOf("RtlpWaitOnCriticalSection") !== -1 ||
symbol.indexOf("RtlEnterCriticalSection") !== -1) {
isWaitingForLock = true;
// 获取当前帧或上一帧的栈顶,作为扫描起点
stackFrameToScan = frame;
break;
}
}
} catch (e) {
// 忽略因读取栈失败的线程
continue;
}
if (isWaitingForLock && stackFrameToScan) {
// 3. 启发式扫描该线程的栈空间,寻找 RTL_CRITICAL_SECTION 指针
const sp = stackFrameToScan.Attributes.FrameOffset; // 当前帧的栈指针
const cap = sp.add(0x100); // 向上扫描 256 字节的栈空间
let foundCS = null;
let foundOwnerTID = 0;
for (let addr = sp; addr.address < cap.address; addr = addr.add(8)) {
try {
// 读取栈上的一个潜在指针值
const candidateCS = host.memory.readPointer(addr);
if (candidateCS.address % 8 !== 0 || candidateCS.address === 0) continue;
// 尝试按照 RTL_CRITICAL_SECTION 结构解析
// x64 下 OwningThread 偏移 0x10 (16)
const owningThreadRaw = host.memory.readPointer(candidateCS.add(16));
const owningTID = owningThreadRaw.address & 0xFFFFFFFF; // 取低 32 位作为 TID
// 验证: 占有该锁的 TID 必须真实存在于当前进程,且不是它自己
if (owningTID !== 0 && owningTID !== t.Id && threadMap.has(owningTID)) {
// 进一步验证 LockCount 偏移 0x08 (8)
const lockCount = host.memory.readMemoryValues(candidateCS.add(8), 1, 4)[0];
// 锁处于竞争状态
if (lockCount < 0 || lockCount > 0) {
foundCS = candidateCS;
foundOwnerTID = owningTID;
break; // 找到最可能的临界区,终止当前栈扫描
}
}
} catch (err) {
// 忽略无效内存访问
}
}
if (foundCS) {
log(`[+] 线程 0x${t.Id.toString(16)} (WinDbg Index: ~${t.UserDebuggerId}) 正在等待临界区 [0x${foundCS.toString(16)}], 拥有者线程 TID: 0x${foundOwnerTID.toString(16)}\n`);
waitGraph.set(t.Id, {
waitingTID: t.Id,
waitingDbgId: t.UserDebuggerId,
csAddress: foundCS,
ownerTID: foundOwnerTID
});
}
}
}
// 4. 死锁环路检测 (DFS)
log("\n[*] 正在构建等待关系图并检索环路...\n");
const visited = new Set();
const recStack = new Set();
const cyclePaths = [];
function findCycles(tid, path) {
visited.add(tid);
recStack.add(tid);
path.push(tid);
const edge = waitGraph.get(tid);
if (edge) {
const nextTid = edge.ownerTID;
if (!visited.has(nextTid)) {
findCycles(nextTid, path);
} else if (recStack.has(nextTid)) {
// 发现环
const cycleStartIndex = path.indexOf(nextTid);
if (cycleStartIndex !== -1) {
cyclePaths.push(path.slice(cycleStartIndex));
}
}
}
recStack.delete(tid);
path.pop();
}
for (let tid of waitGraph.keys()) {
if (!visited.has(tid)) {
findCycles(tid, []);
}
}
// 5. 打印死锁检测报告
if (cyclePaths.length === 0) {
log("[OK] 未检测到明显的临界区死锁闭环。\n");
} else {
log("=================================================================\n");
log("!!!!!!!!!!!!! 警告:检测到严重的死锁链条 !************\n");
log("=================================================================\n");
cyclePaths.forEach((path, index) => {
log(`\n【死锁链 #${index + 1}】:\n`);
for (let i = 0; i < path.length; i++) {
const currentTid = path[i];
const edge = waitGraph.get(currentTid);
const nextTid = path[(i + 1) % path.length];
const nextEdge = waitGraph.get(nextTid);
log(` 线程 TID: 0x${currentTid.toString(16)} (~${edge.waitingDbgId})`);
log(` ---> 正在等待锁 [0x${edge.csAddress.toString(16)}]`);
log(` ---> 被线程 TID: 0x${nextTid.toString(16)} (~${nextEdge ? nextEdge.waitingDbgId : '未知'}) 占用\n`);
}
});
log("=================================================================\n");
}
}
三、 实战演示:如何使用该脚本
Step 1: 准备调试环境并加载 Dump
打开 WinDbg (推荐使用新版 WinDbg Preview),加载发生死锁的 Dump 文件:
.opendump C:\Dumps\deadlock_app.dmp
确保设置了基础的微软公共符号路径(脚本不需要业务符号,但需要系统的公共符号来识别 ntdll 的等待 API):
.sympath srv*https://msdl.microsoft.com/download/symbols
.reload
Step 2: 载入并运行 JS 脚本
在 WinDbg 命令行中加载刚才保存的脚本:
.scriptload C:\Scripts\deadlock_scanner.js
执行我们在脚本中注册的别名命令 !finddeadlocks:
dx @$finddeadlocks()
Step 3: 查看输出结果
如果目标程序存在典型的临界区死锁,你将看到如下精确的图形化链条输出:
*** 启动启发式死锁链条扫描 (x64) ***
[*] 检索到进程内活跃线程数: 24
[+] 线程 0x2af4 (WinDbg Index: ~2) 正在等待临界区 [0x000001bc93902340], 拥有者线程 TID: 0x3d1c
[+] 线程 0x3d1c (WinDbg Index: ~5) 正在等待临界区 [0x000001bc93902380], 拥有者线程 TID: 0x2af4
[*] 正在构建等待关系图并检索环路...
=================================================================
!!!!!!!!!!!!! 警告:检测到严重的死锁链条 !************
=================================================================
【死锁链 #1】:
线程 TID: 0x2af4 (~2) ---> 正在等待锁 [0x000001bc93902340] ---> 被线程 TID: 0x3d1c (~5) 占用
线程 TID: 0x3d1c (~5) ---> 正在等待锁 [0x000001bc93902380] ---> 被线程 TID: 0x2af4 (~2) 占用
=================================================================
秒级锁定了两个陷入死锁的线程(TID 0x2af4 和 0x3d1c)以及对应的临界区内存地址!接下来,你只需要直接切到对应线程,运行 k 命令查看业务调用栈即可(即使没有符号,看偏移动态库名字也能提供极大线索)。
四、 进阶与边界优化
在实际复杂的工业环境中,使用此方案时有几个关键点需要注意:
- 多核寄存器覆盖问题:在极少数 Dump 文件中,由于编译器优化,
RCX寄存器在进入RtlpWaitOnCriticalSection时可能已被覆写,导致栈上完全没有保留RTL_CRITICAL_SECTION的地址。- 对策:脚本中设计的
cap = sp.add(0x100)向上扫描策略正是为此设计。由于返回地址上层的调用者(例如MyDll!WorkerThread)通常会把临界区指针存在局部变量或rbx等非易失性寄存器中,而这些寄存器在函数序言中会被压入栈(Stack Save Slot),因此向上扩大扫描范围通常能够 100% 捕获该指针。
- 对策:脚本中设计的
- 误报控制:启发式匹配(Heuristic Matching)可能会遇到“恰好某个局部变量的值等同于当前进程内某个 TID”的巧合。
- 对策:脚本中双重校验了
LockCount和OwningThread的有效性。临界区结构体中的LockCount在无锁时通常为-1(或特定初始值),有等待者时会发生特定的位移或递减。同时对RecursionCount等多重字段进行联合检查,可将误报率降低至接近于零。
- 对策:脚本中双重校验了
- 32位(Wow64)兼容性:上述脚本针对 64 位程序编写。若需要分析 32 位程序,需调整
OwningThread的偏移(在 x86 下,LockCount偏移为0x04,OwningThread偏移为0x0C),同时将栈地址递增步长改为4。