WinDbg 与 CDB:从 Dump 上下文到 PDB 符号证据
WinDbg 与 CDB 负责分析现场,不负责定义采集策略
WinDbg 和命令行调试器 CDB 消费可执行文件、Dump 与 PDB,恢复异常上下文、线程、模块和源码位置。能打开文件不等于符号正确,看到函数名也不代表 PDB 属于现场构建;可信结论必须同时绑定映像身份、PDB GUID/age、符号路径和实际加载日志。
WinDbg/CDB 承担调试器安装、live/dump 选择、线程分析、符号身份与 TTD;转储触发、数量限制和采集进程属于 ProcDump 的独立生命周期。
先把三个入口装对
现代 WinDbg 可以从 WinDbg 安装页 直接安装、从 Microsoft Store 获取,或用 winget 安装。安装后先确认命令实际来自哪里:
winget install Microsoft.WinDbg
winget upgrade Microsoft.WinDbg
Get-Command WinDbgX -ErrorAction SilentlyContinue
WinDbgX -?WinDbgX.exe 是现代 WinDbg 的命令行入口,-? 应显示当前安装实际支持的启动参数;进入调试器后再用 version 查看调试引擎版本。它沿用经典调试引擎、命令和扩展,但独立安装 WinDbg 不等于同时安装 CDB、DumpChk、SymStore 与 AgeStore。现代 WinDbg 的官方系统基线与 ProcDump 并不相同:前者支持 Windows 10 1607 及以上和 Windows 11,ProcDump 的当前 Windows 发行页列出客户端 Windows 11 及以上、服务器 Windows Server 2016(产品版本标识)及以上。部署前应分别核对目标系统,不要用 WinDbg 能安装来推导 ProcDump 一定受支持。
需要无界面的 cdb.exe、dump 完整性检查或企业符号仓库工具时,从 Debugging Tools for Windows 入口运行 Windows SDK 安装器,只选择 Debugging Tools for Windows 即可。安装后用实际发现结果决定脚本路径,不要在团队脚本里猜 SDK 版本目录:
$debuggers = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits" `
-Filter cdb.exe -Recurse -ErrorAction SilentlyContinue
$debuggers | Select-Object -ExpandProperty FullName
Get-Command cdb, dumpchk, symstore -ErrorAction SilentlyContinueProcDump 从 Microsoft Sysinternals ProcDump 获取。它是独立工具,首次运行需要处理 EULA;自动化环境应由组织在镜像制作或软件分发阶段完成许可审查,不应让生产作业临时下载并静默运行未知副本。
Get-FileHash .\procdump.exe -Algorithm SHA256
.\procdump.exe -accepteula -?帮助文本和文件摘要分别回答“参数是否是预期版本”和“执行的是否是获准制品”。团队基线应保存下载来源、版本、签名与摘要;受限网络通过已审查的软件仓库分发,不关闭 TLS 或终端安全控制来换取下载成功。
先选 live 还是 dump
调试器 attach 后,break 状态会让目标线程停止执行;即便使用 noninvasive debugging,目标全部线程仍会暂停,只是调试器不能控制目标继续运行。live 调试适合隔离环境里观察动态状态、设置断点和逐步执行,不适合把“只看一眼”包装成零影响生产操作。
dump 是某一时点的离线证据。它不会在分析时继续影响原进程,但采集瞬间仍可能暂停目标,且未采集的内存以后无法补回。选择顺序应当是:
只有在必须观察状态变化、且已有暂停预算、现场 owner 和明确退出条件时,才升级到 live attach。生产事故流程、审批和值班接管仍由组织运行手册负责;工具层必须准确交代会暂停什么、留下什么以及如何退出。
Dump 类型决定以后还能问什么
“Mini”只是名称,不是跨工具统一格式策略。ProcDump 的 -mm 与 WinDbg/CDB 的 .dump /m 保留内容不同,不能把两条命令写成等价替换。
ProcDump 的四个常用档位
| 参数 | 主要内容 | 适合从哪里开始 | 关键限制 |
|---|---|---|---|
-mt | Triage,栈直接引用内存与有限元数据 | 只需初步分型且数据暴露要求很严 | 只是尝试移除敏感信息,不保证脱敏 |
-mm | 默认 Mini,直接和间接引用内存、线程/模块/句柄等元数据 | 崩溃栈、模块和线程初查 | 不含全地址空间,任意目标内存可能缺失 |
-mp | MiniPlus,更多 Private 和可读写 Image/Mapped 内存 | 需要堆或私有内存、又希望小于 Full | 会排除特定大区域;CLR 进程可能升级为 Full |
-ma | Full,Image、Mapped、Private 内存与完整元数据 | 明确需要完整用户态内存的深度分析 | 文件最大,凭证与业务数据暴露面最大 |
-mc <Mask> 可按 MINIDUMP_TYPE 掩码定制,-md <DLL> 可交给回调 DLL 决定内容。两者一旦进入团队流程,就必须把掩码含义、回调 DLL 来源和版本写进评审记录;一个没人能解释的十六进制值不是治理策略。
WinDbg/CDB 的 .dump 档位
在 live 用户态会话里,.dump /m <file> 生成基础 minidump,主要包含模块、线程和栈;.dump /ma <file> 还包括完整内存、句柄、卸载模块、基本内存信息和线程时间。.dump /mA 与 /ma 接近,但遇到不可读内存时继续生成。
.dump /m <dump-path>
.dump /ma <dump-path>.dump /mr 会清零不利于重建栈的部分栈或存储内容,局部变量和值也可能消失;/mR 移除完整模块路径。它们能减少暴露,却不能证明 dump 已脱敏。旧式用户态 .dump /f 已不受支持,不应继续放进新脚本。
对已有 dump 再执行 .dump 可以裁成更小的副本,但不能从 Mini 中重新创造未采集的内存。若问题从“哪条线程崩了”升级成“某个未被栈引用的堆对象是什么”,只能回到采集策略重新取证。
打开 dump 后先固定分析上下文
现代 WinDbg 可以从界面打开 dump,也可从命令行启动;CDB 适合脚本化输出:
WinDbgX -z <dump-path>
cdb.exe -z <dump-path> -c ".symfix; .reload; !analyze -v; q"离线分析中的 q 只关闭调试会话;live CDB 中 q 默认会同时关闭被调试应用,两种场景不能混用。
崩溃 dump 的最小异常链是:
!analyze -v
.exr -1
.ecxr
kv!analyze -v 给出自动分析线索;.exr -1 显示最近异常记录中的异常码、地址、flags 和参数;.ecxr 切换到用户态 crash dump 关联的异常寄存器上下文;随后 kv 才是在正确上下文里显示调用栈。自动分析的 bucket 或“可能由某模块导致”不是最终因果证明,必须继续核对异常地址属于哪个模块、模块是否有正确符号、参数和内存是否实际存在于 dump。
first-chance/second-chance 的控制命令主要用于 live 调试:sxe 在 first chance break,sxd 先显示、未处理后在 second chance break,sxn 只通知,sxi 忽略。gh 以“已处理”继续,gn 以“未处理”继续;在 second chance 执行 gn 会结束应用。只在一次性测试程序里练习这组命令,不在共享服务上试错。
在线程层面证明等待,而不是猜死锁
用户态 ~ 列线程,. 标记当前线程,# 标记原始异常线程或 attach 时活动线程。常用入口如下:
~
~*e kv
~3s
kv~*e kv 遍历全部线程栈,~3s 选择调试器编号为 3 的线程。k 家族始终基于当前线程;kp/kP 想显示完整参数需要 full symbols,优化构建、公共符号或缺失内存都会让参数与局部变量不可靠。
挂起分析可以先选择可疑线程,再运行:
!analyze -hang判断链应包含至少两个时点:负责请求的线程是否持续停在同一等待对象,持锁线程是否存在且栈是否变化,等待关系是否与业务超时吻合。若两个 dump 中线程 ID、等待对象或栈快速变化,现场更像慢处理、饥饿或外部依赖抖动,而不是稳定死锁。
live attach 还存在两套容易混淆的暂停状态。~n/~m 修改 Windows suspend count,detach 后可能残留;~f/~u 是调试器自己的 freeze/unfreeze,detach 时会解冻。实验里使用过 ~n 就必须配对 ~m 并验证目标恢复。保留目标进程退出 live 会话应使用:
qd或:
.detach不要通过关闭窗口、终止调试器进程或在 live CDB 中执行 q 来赌目标能继续运行。
符号路径要分清业务 PDB、公共符号和缓存
微软公共符号服务器只提供 Microsoft 组件符号,不会替团队保存业务 PDB。推荐的搜索顺序是:本次构建的业务符号、受控团队符号服务、Microsoft 公共符号服务,并为远程服务配置独立 downstream cache。
.sympath <build-symbol-dir>;srv*<team-symbol-cache>*<team-symbol-server>;srv*<microsoft-cache>*https://msdl.microsoft.com/download/symbols
.sympath
.reload只需要微软公共符号时可以简化:
.symfix <microsoft-cache>
.reload对应的环境变量形式是:
$env:_NT_SYMBOL_PATH = 'srv*<cache-dir>*https://msdl.microsoft.com/download/symbols'符号路径从左到右搜索。公共服务器要求网络链路支持 TLS 1.2+;企业代理或内网环境应预热缓存、配置经批准的代理或镜像到内部符号服务,不能关闭证书校验,也不能从论坛附件下载同名 PDB。
符号排障保持固定顺序:
lml
lm m your_module v
.sympath
!sym noisy
.reload /f your_module.dll
!lmi your_module栈里出现少量函数名并不证明 private PDB 已加载,导出符号也能提供有限名称。lm ... v 与 !lmi 要显示实际符号状态、PDB 路径和身份,!sym noisy 则解释调试器从哪些位置查找、为何拒绝某个候选。
PDB identity 不是文件名相同
新式 PDB 的关键身份是 GUID 与 age,旧式 PDB 使用 signature 与 age。PE 映像中的 RSDS 调试记录携带预期 PDB 名称、GUID 和 age,调试器据此判断候选是否匹配;把另一次构建的文件改名为 your-app.pdb 不会让身份变正确。
团队需要把以下对象绑定为一个不可拆的构建证据包:
目标 EXE/DLL 及 SHA-256。与映像匹配的 PDB,以及 GUID/age。源码提交和干净/脏工作树状态。
编译器、链接器、目标架构和优化配置。发布版本、构建流水线与制品摘要。
SymStore 对 PDB 按 signature+age 索引,对可执行映像和 DBG 按 timestamp+image size 索引。full/private PDB 与 stripped/public PDB 可能共享同一 signature+age,不能把两者当成同一 store 中可并存的两个身份;团队要分层存储或制定唯一发布策略,避免一个覆盖另一个。
正反实验:让正确 PDB 成功,让错误 PDB 明确失败
下面是使用合成数据的隔离实验。运行者应先进入 x64 Native Tools Command Prompt for VS,确认 MSVC cl.exe 与获准的 ProcDump 可用;正文给出的输出是判定条件,实际结论必须以本机保存的命令输出、文件摘要和符号诊断为准。
先在临时目录创建两个同名、不同函数的构建:
$Lab = Join-Path $env:TEMP 'windbg-pdb-identity-lab'
$A = Join-Path $Lab 'build-a'
$B = Join-Path $Lab 'build-b'
$Dumps = Join-Path $Lab 'dumps'
New-Item -ItemType Directory -Force $A, $B, $Dumps | Out-Null
@'
#include <windows.h>
__declspec(noinline) void BuildAAnchor() { Sleep(60000); }
int main() { BuildAAnchor(); }
'@ | Set-Content -Encoding ascii (Join-Path $A 'main.cpp')
@'
#include <windows.h>
__declspec(noinline) void BuildBAnchor() { Sleep(60000); }
int main() { BuildBAnchor(); }
'@ | Set-Content -Encoding ascii (Join-Path $B 'main.cpp')
cl /nologo /Zi /Od /EHsc /Fo:"$A\main.obj" `
/Fd:"$A\compiler.pdb" /Fe:"$A\pdb_identity_demo.exe" "$A\main.cpp" `
/link /DEBUG /PDB:"$A\pdb_identity_demo.pdb"
if ($LASTEXITCODE -ne 0) { throw "build A failed: $LASTEXITCODE" }
cl /nologo /Zi /Od /EHsc /Fo:"$B\main.obj" `
/Fd:"$B\compiler.pdb" /Fe:"$B\pdb_identity_demo.exe" "$B\main.cpp" `
/link /DEBUG /PDB:"$B\pdb_identity_demo.pdb"
if ($LASTEXITCODE -ne 0) { throw "build B failed: $LASTEXITCODE" }
$Target = Start-Process "$A\pdb_identity_demo.exe" -PassThru
.\procdump.exe -accepteula -ma $Target.Id "$Dumps\build-a.dmp"
if ($LASTEXITCODE -ne 0) { throw "ProcDump failed: $LASTEXITCODE" }
if (!(Test-Path -LiteralPath "$Dumps\build-a.dmp")) {
throw 'ProcDump returned without the expected dump file'
}先从 PowerShell 打开 dump:
WinDbgX -z "$Dumps\build-a.dmp"待调试器命令窗口可用后,只使用 A 的 PDB:
.sympath <lab-dir>\build-a;srv*<cache-dir>*https://msdl.microsoft.com/download/symbols
!sym noisy
.reload /f pdb_identity_demo.exe
lm m pdb_identity_demo v
!lmi pdb_identity_demo
x pdb_identity_demo!*预期正向证据是:模块显示已加载匹配的 PDB,路径指向 build-a,!lmi 中 GUID/age 与映像调试记录一致,x 能找到 BuildAAnchor。不要只截图漂亮调用栈,要保存 lm/!lmi 和符号诊断文本。
关闭会话,重新执行 WinDbgX -z "$Dumps\build-a.dmp" 打开同一 dump,再把符号路径只指向 B。这里不追加公共服务器或旧缓存,避免 A 的 PDB 从其他位置被命中:
.sympath <lab-dir>\build-b
!sym noisy
.reload /f pdb_identity_demo.exe
lm m pdb_identity_demo v
!lmi pdb_identity_demo
x pdb_identity_demo!*预期反向证据是 !sym noisy 明确报告候选 PDB 不匹配或拒绝加载,模块不能冒充已加载正确 private symbols,BuildAAnchor 也不能由 B 的 PDB 正确解析。不要使用忽略 symbol mismatch 的强制选项把实验“修绿”;恢复 A 的路径并再次成功加载,才闭合正反对照。
最后结束测试进程并删除实验目录。删除前验证它确实位于当前用户临时目录,避免变量错误扩大清理范围:
if (!$Target.HasExited) { Stop-Process -Id $Target.Id -ErrorAction Stop }
$resolvedLab = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $Lab).Path)
$resolvedTemp = [IO.Path]::GetFullPath(
(Resolve-Path -LiteralPath $env:TEMP).Path
).TrimEnd([IO.Path]::DirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if ($resolvedLab.StartsWith($resolvedTemp, [StringComparison]::OrdinalIgnoreCase) -and
($resolvedLab + [IO.Path]::DirectorySeparatorChar) -ne $resolvedTemp) {
Remove-Item -LiteralPath $resolvedLab -Recurse -Force
} else {
throw "Refusing to remove path outside the temp directory: $resolvedLab"
}
Test-Path -LiteralPath $resolvedLab预期最后一条为 False。实际项目中不能这样删除受保留策略约束的事故证据;实验目录与正式证据库必须完全分开。
快照回答不了历史时,再升级到 TTD
dump 只能回答采集时刻“有哪些线程、寄存器和内存”,不能直接回答某个字段在此前何时被改坏。若错误能在受控 Windows 用户态进程中复现,且团队能够承受明显的运行时与磁盘开销,可以把同一个合成程序升级为 Time Travel Debugging(TTD) 实验。TTD 会记录进程执行并生成 .run,WinDbg 首次打开后生成加速查询的 .idx;回放可以前进、后退和重复查询,但不能修改已经记录的历史。
TTD 不是更大的 dump。它当前只记录用户态进程,不能记录内核态目标,也不能注入 Protected Process Light 等受保护进程;录制需要管理员权限。安全软件、内存防护和 Electron 一类运行时还可能与注入机制冲突。生产上出现权限或兼容性错误时,正确动作是保存 TTD 日志并转移到隔离复现环境,不是临时关闭整套终端防护。
第一次实验应使用 WinDbg 的 Launch executable (advanced) 和 Record with Time Travel Debugging,目标仍是前面的合成程序,输出目录则放在受限实验盘。需要自动化时,再使用单独安装并经过版本审查的 TTD.exe。三种录制方式的安全语义不同:
| 方式 | 典型入口 | 必须确认的影响 |
|---|---|---|
| Launch | TTD.exe -launch <program> <args> | 目标继承 recorder 的管理员权限,可能改变权限相关故障 |
| Attach | TTD.exe -attach <pid> -out <trace.run> | 目标按原权限运行,但 attach 和注入仍会造成性能扰动 |
| Monitor | TTD.exe -monitor <full-path> -out <trace.run> | 等待后续实例并保持其正常权限;结束监控要显式 Ctrl+C |
先在隔离目录做一轮正向录制:触发确定错误,停止录制,确认 .run、TTD 日志和文件摘要都存在,再用 WinDbg 的 Open trace file 打开。只有 WinDbg 能索引并移动到记录末尾,才说明 trace 可用于分析:
!tt 100
!analyze -v
kv
!tt 0
g!tt 0 和 !tt 100 分别移动到记录开头和结尾;位置也可以写成 sequence:step。在错误点设置代码断点或数据断点后,g- 向后运行,p- 向后跨过调用,t- 向后逐入。预期正向证据是再次到达同一异常地址,反向证据则是停在导致状态变化的写入或调用附近。若只能在结尾看到崩溃、反向搜索找不到业务位置,应先检查业务 PDB 是否匹配、目标代码是否已被记录、断点地址是否属于加载后的正确模块,而不是宣布“TTD 没记录到内存”。
反向实验要故意破坏一个条件。例如把业务 PDB 从符号路径移走后重新打开同一 .run:执行历史仍可回放,但查询结果会出现地址或 UnknownOrMissingSymbols,源码行和参数解释退化;恢复同一构建 PDB 后,函数和源码位置应重新出现。这个对照证明 TTD 保存执行历史,PDB 仍负责把机器地址解释成源码身份,两者不能互相替代。
TTD 的容量必须按 .run + .idx + 日志 + 传输副本 计算。微软给出的经验区间是活跃进程的 .run 约每秒增长 5 MB 到 50 MB,.idx 常见约为 .run 的两倍,典型录制性能损耗约 10 到 20 倍;实际值必须由目标负载测量。磁盘耗尽时录制可能等待并留下不完整 trace,索引耗尽空间还可能产生无效 .idx。因此录制脚本需要时长上限、空闲空间下限和人工停止条件;索引失败时保留原始 .run,删除可重建的 .idx,补足空间后再索引,不能把不完整 trace 当成“已捕获崩溃”。
.run 可能包含路径、注册表、内存和文件内容,敏感级别不低于 Full dump;.idx、日志和导出的查询结果也继承案件分级。录制结束后核销管理员会话、monitor 进程、.run、.idx、错误日志、分析机副本和传输分片。需要协作时共享案件库中的只读副本及 SHA-256,不通过聊天附件传播原始 trace。
