WinDbg — Windows Debugger
WinDbg for C++ developers — crash dump analysis, kernel debugging, TTD time-travel debugging, symbols, SOS, and the dx command.
TL;DR
WinDbg is the authoritative debugger for Windows C++ development — the only tool that can analyze crash dumps from production machines, perform kernel debugging, run Time Travel Debugging (TTD), and script complex analysis over live or post-mortem state. Use WinDbg Preview (the modern rewrite) rather than classic WinDbg; it ships with a better UI, TTD support baked in, and the dx command that turns the debugger into a LINQ-over-debug-objects engine.
A minimal crash dump analysis session:
windbg -z C:\crash.dmp
0:000> .symfix C:\Symbols
0:000> .reload
0:000> !analyze -v
0:000> kThe four commands above — fix symbols, reload them, run auto-analysis, and print the call stack — resolve the majority of production crashes in under a minute.
Installation
WinDbg Preview is the version you want — it replaces the classic WinDbg shipped with the Windows SDK.
WinDbg Preview (recommended)
Install from the Microsoft Store or via winget:
winget install Microsoft.WinDbgWinDbg Preview supports TTD, the dx command, dark mode, and a JavaScript/TypeScript extension model. It is updated independently of the Windows SDK.
Classic WinDbg via Windows SDK
The classic debugger ships inside the Windows SDK. Install it through the VS Installer or standalone:
# Download the Windows 11 SDK installer
# During install, check only "Debugging Tools for Windows"
# Installs to: C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\Classic WinDbg (windbg.exe) and cdb.exe (headless) are still useful in CI pipelines and on machines without the Store.
Command-line invocation
WinDbg Preview registers as windbgx on the PATH:
windbgx # launch GUI
windbgx -z crash.dmp # open a crash dump
windbgx -g .\myapp.exe arg1 arg2 # launch and run (don't break at entry)
windbgx -p 1234 # attach to PIDVS Code extension
The WinDbg extension (ms-vscode.windbg) connects VS Code to a WinDbg session over DAP. Install from the Extensions marketplace. It provides breakpoints, variable watch, and a call-stack panel while still allowing raw WinDbg commands in the integrated terminal.
Symbol Setup
Symbols transform hexadecimal addresses into function names, file names, and line numbers. Without a correct symbol path, every stack frame shows ??? and !analyze produces only guesses.
The _NT_SYMBOL_PATH environment variable
Set this system-wide so every WinDbg session picks it up automatically:
# PowerShell — set for the current session
$env:_NT_SYMBOL_PATH = "srv*C:\Symbols*https://msdl.microsoft.com/download/symbols"
# Set permanently (system-wide)
[System.Environment]::SetEnvironmentVariable(
"_NT_SYMBOL_PATH",
"srv*C:\Symbols*https://msdl.microsoft.com/download/symbols",
"Machine"
)The srv*<local-cache>*<server-url> syntax tells WinDbg to download symbols on demand and cache them locally under C:\Symbols. The Microsoft Symbol Server hosts PDBs for every Windows OS binary ever shipped.
.symfix and .reload inside a session
0:000> .symfix C:\Symbols
0:000> .sympath+ srv*C:\MySymbols*\\myserver\share\symbols
0:000> .reload /f
0:000> lm # list loaded modules with symbol status.symfix points at the Microsoft server. .sympath+ appends your private symbol store without replacing the existing path. .reload /f forces an immediate reload rather than lazy loading on first access.
Diagnosing symbol problems with !sym noisy
When symbols fail to load, turn on verbose symbol lookup to see exactly what WinDbg is searching for and why it rejects a PDB:
0:000> !sym noisy
0:000> .reload /f myapp.exe
DBGHELP: myapp - public symbols
C:\Symbols\myapp.pdb\<GUID>\myapp.pdbCommon failures: PDB age/GUID mismatch (the PDB does not match the binary), missing private symbols (you only have the public stripped PDB), or a firewall blocking msdl.microsoft.com. The noisy output tells you which.
Private PDB files
Private PDBs contain full type information, local variable names, and inlined function details. Public PDBs (what Microsoft distributes) contain only exported function names. For your own binaries, always ship the private PDB alongside the executable:
# CMakeLists.txt — ensure PDB is emitted alongside the binary
target_compile_options(myapp PRIVATE /Zi)
target_link_options(myapp PRIVATE /DEBUG:FULL)
set_target_properties(myapp PROPERTIES
PDB_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin"
)Opening a Session
WinDbg supports four session types: launching a new process, attaching to a running one, opening a crash dump, and kernel debugging.
Launch a new process
windbgx -g .\myapp.exe arg1 arg2 # -g: don't break at process entry
windbgx .\myapp.exe arg1 arg2 # break at process entry point (default)Without -g, WinDbg breaks before main executes — useful when you need to set breakpoints before any user code runs.
Attach to a running process
windbgx -p 4321 # attach by PID
windbgx -pn myapp.exe # attach by process name (first match)Inside an existing session you can also attach interactively:
File → Attach to Process (or Ctrl+Alt+P in WinDbg Preview)Attaching suspends all threads in the target process. Issue g to resume.
Open a crash dump
windbgx -z C:\dumps\myapp.20260714.dmp
windbgx -z C:\dumps\myapp.20260714.dmp -y "srv*C:\Symbols*https://msdl.microsoft.com/download/symbols"User-mode crash dumps can be collected with Task Manager, ProcDump, or Windows Error Reporting. A full dump captures heap and all thread stacks; a mini-dump captures only the thread stacks and exception context.
Kernel debugging
Kernel debugging attaches WinDbg to a second Windows machine (or a VM) over a serial cable, USB 3 cable, or network:
# On the target VM — enable kernel debugging over network
bcdedit /debug on
bcdedit /dbgsettings net hostip:192.168.1.10 port:50000 key:1.2.3.4
# On the host — attach WinDbg
windbgx -k net:port=50000,key=1.2.3.4Kernel debugging gives access to !process, !thread, !devobj, and the entire Windows kernel object model.
Essential Commands
WinDbg's command language is dense but regular — once you learn the three namespaces (bare commands, . meta-commands, ! extension commands), everything follows a pattern.
Execution control
g # go — run until next breakpoint or exception
p # step over (source line)
t # step into
gu # go up — run until current function returns
pa addr # step to addressBreakpoints
bp myapp!Widget::render # break at symbol (resolved at set time)
bu myapp!Widget::render # break at symbol (resolved when module loads — survives reload)
bm myapp!Widget::* # break on all symbols matching wildcard
bp 0x00007ff6`1234abcd # break at exact address
bp myapp!main+0x1a # break at offset from symbol
bl # list all breakpoints
bc 0 # clear breakpoint #0
bc * # clear all breakpoints
bd 1 # disable breakpoint #1
be 1 # enable breakpoint #1bp resolves the symbol immediately; bu ("unresolved") defers resolution until the module loads — use bu for breakpoints set before the target process has loaded all DLLs.
Call stack inspection
k # call stack — return address + frame pointer chain
kb # call stack with first three parameters per frame
kv # call stack with FPO info and calling convention
kp # call stack with full parameter names and types (requires private PDB)
kn 20 # show 20 frames
.frame 3 # switch to frame 3
dv # display local variables in current frame
lv # list local variables (alias)Registers and memory
r # show all registers
r rax # show single register
r rax=42 # set register value
dd addr # dump DWORD (32-bit) values at address
db addr # dump bytes
dq addr # dump QWORD (64-bit) values
da addr # dump ASCII string at address
du addr # dump Unicode (UTF-16) string at address
dp addr # dump pointer-size values
dps addr # dump pointer-size values + resolve symbolsExpression evaluation
? 0n10 * 0n20 # evaluate C-style expression (decimal prefix 0n)
?? sizeof(MyStruct) # C++ expression evaluator
?? myVar # print variable value using C++ evaluatorThe ? command uses the MASM evaluator (hexadecimal by default, prefix 0n for decimal). The ?? command uses the C++ evaluator — it understands types, member access, and sizeof.
Crash Dump Analysis Workflow
The discipline of dump analysis is: establish the exception context first, then read the stack, then read memory. Working in a different order wastes time.
Step 1 — Switch to the exception context
A crash dump opens on the default thread. The thread that crashed is the exception thread, and .ecxr switches the register context to the point of the fault:
0:003> .ecxr
eax=00000000 ebx=00000001 ecx=00000000 edx=00000000
esi=00000001 edi=00c85098
eip=77c15bfe esp=00f7f8dc ebp=00f7f8e8 iopl=0
cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00010246
ntdll!NtWaitForSingleObject+0xc:After .ecxr, all subsequent k, dv, and register commands operate on the faulting thread's state.
Step 2 — Run auto-analysis
0:003> !analyze -v!analyze -v is WinDbg's expert system. It identifies the exception code, points at the faulting instruction, shows the responsible stack frame, and often names the bug class (heap corruption, stack overflow, null dereference). The -v flag enables verbose output with additional heuristics.
Step 3 — Read the stack and inspect state
Here is an annotated example of a heap corruption crash:
0:003> !analyze -v
*******************************************************************************
* *
* Exception Analysis *
* *
*******************************************************************************
EXCEPTION_RECORD: (.exr -1)
ExceptionAddress: 00007ffb8c1d4567 (ntdll!RtlReportCriticalFailure+0x0047)
ExceptionCode: c0000374 (Heap corruption detected) ; <-- bug class
ExceptionFlags: 00000001
STACK_TEXT:
00 ntdll!RtlReportCriticalFailure
01 ntdll!RtlpHeapHandleError
02 ntdll!RtlpHpHeapHandleError
03 ntdll!RtlFreeHeap
04 myapp!WidgetCache::evict+0x8f ; <-- culprit
05 myapp!WidgetCache::insert+0x112
06 myapp!ProcessRequest+0x3a
07 myapp!WorkerThread+0x67
0:003> .frame 4
04 myapp!WidgetCache::evict+0x8f
0:003> dv
this = 0x00c8a020
entry = 0x00c8b190 ; double-freed pointer
0:003> k
# Child-SP RetAddr Call Site
00 ... ... ntdll!RtlReportCriticalFailure
01 ... ... myapp!WidgetCache::evict+0x8f
02 ... ... myapp!WidgetCache::insert+0x112The !gle command reads the last Win32 error code — useful when a Windows API call returned FALSE or INVALID_HANDLE_VALUE and you need to know why:
0:000> !gle
LastErrorValue: (Win32) 0x57 (87) - The parameter is incorrect.
LastStatusValue: (NTSTATUS) 0xc000000d - An invalid parameter was specified.Time Travel Debugging (TTD)
TTD records a complete execution trace and lets you replay it — including stepping backward. The recording captures every instruction the CPU executes; replay lets you travel to any point in time without re-running the program.
Recording a trace
WinDbg Preview records TTD sessions directly from the UI: File → Start debugging → Launch executable (advanced) → Record with Time Travel Debugging.
From the command line using the standalone TTD recorder:
# Record myapp.exe — produces myapp01.run in the current directory
ttd.exe -launch .\myapp.exe arg1 arg2
# Record an already-running process by PID
ttd.exe -attach 4321
# Record with output in a specific directory
ttd.exe -out C:\traces -launch .\myapp.exeOpen the .run file in WinDbg Preview:
windbgx -z C:\traces\myapp01.runNavigating time
!tt 0:0 # replay from the very beginning of the trace
!tt 100% # jump to the end of the trace
!tt 50% # jump to the midpoint
!tt 1A0:4B # jump to a specific time position (sequence:steps)Time positions use the notation <sequence>:<steps>. Every event in the trace has a unique position printed in the prompt and in !analyze output.
Reverse execution
g- # reverse-go — run backward to the previous breakpoint or event
p- # reverse step over
t- # reverse step into
g- -t # reverse-go, stop on exceptions traveling backwardCombine with breakpoints to find the root cause of a use-after-free:
0:000> bp myapp!Widget::~Widget # break when Widget is destroyed
0:000> g # forward to the destruction
0:000> bp myapp!Widget::update # break when the zombie pointer is used
0:000> g # forward to the use-after-free symptom
0:000> g- # travel backward to find who destroyed it earlyTTD vs rr on Linux
| TTD (Windows) | rr (Linux) | |
|---|---|---|
| Platform | Windows 10+ | Linux x86-64 |
| Integration | Native WinDbg UI | GDB interface |
| Reverse execution | g-, p-, t- | reverse-continue, reverse-next |
| Kernel events | Win32/NT object model | System calls via ptrace |
| Overhead | 10–30× slowdown recording | 2–10× slowdown recording |
| Multi-process | Yes (TTD follows child processes) | -f flag |
| Scripting | dx / JavaScript | Python / GDB scripting |
Both tools solve the same fundamental problem. Choose based on platform; for cross-platform codebases, use each natively on its respective OS.
The dx Command — LINQ over Debug Objects
dx is the most powerful feature in modern WinDbg — it exposes the entire debugger state as a queryable object graph that you can filter, project, and aggregate using LINQ-style expressions.
Navigating the object model
0:000> dx @$curprocess
@$curprocess : myapp.exe [Switch To]
Name : myapp.exe
Id : 0x1234
Threads
Modules
TTD (for TTD sessions)
0:000> dx @$curthread
@$curthread : [object Object]
Id : 0x5678
Stack
Registers
0:000> dx @$curthread.Stack.Frames
@$curthread.Stack.Frames
[0x0] : ntdll!NtWaitForSingleObject+0xc [Inline]
[0x1] : myapp!Widget::render+0x3f
[0x2] : myapp!RenderLoop+0xa1
[0x3] : myapp!main+0x52Traversing processes and sessions
dx Debugger.Sessions
dx Debugger.Sessions.First().Processes
dx Debugger.Sessions.First().Processes.First().ThreadsRecursive expansion with -r
0:000> dx -r2 @$curthread.Stack.Frames.First()
@$curthread.Stack.Frames.First()
Attributes
InstructionOffset : 0x00007ff6`1234abcd
ReturnOffset : 0x00007ff6`1234ef01
FrameOffset : 0x000000a0`bc12d340
LocalVariables
this : 0x00007ff6`...
count : 42Filtering with LINQ
Find all threads currently waiting in WaitForSingleObject:
0:000> dx -g @$curprocess.Threads.Select(t => t.Stack.Frames.First()).Where(f => f.ToDisplayString().Contains("WaitForSingle"))Count heap allocations larger than 1 MB in a TTD trace:
0:000> dx @$curprocess.TTD.Heap().Allocations.Where(a => a.Size > 0x100000).Count()Custom dx queries for C++ debugging
# Find all active threads and their top frame
0:000> dx -g @$curprocess.Threads.Select(t => new { TID = t.Id, Top = t.Stack.Frames.First() })
# Find every frame containing a specific function across all threads
0:000> dx @$curprocess.Threads.SelectMany(t => t.Stack.Frames).Where(f => f.ToDisplayString().Contains("Widget"))
# In a TTD trace: find every call to malloc over 64KB
0:000> dx @$curprocess.TTD.Calls("ucrtbase!malloc").Where(c => c.Parameters[0] > 0x10000)Creating model objects
0:000> dx @$create("Debugger.Models.Process")The @$create function instantiates a new debugger model object, useful for building custom extension scripts that the dx system can navigate.
SOS Extension — Managed Code
When a C++ process hosts .NET — through C++/CLI, COM interop, or embedding the CLR — you need SOS to see the managed side of the heap and call stack.
Loading SOS
In WinDbg Preview, SOS loads automatically when a managed exception is detected. Load it manually:
0:000> .loadby sos clr # .NET Framework
0:000> .loadby sos coreclr # .NET Core / .NET 5+
0:000> !sos.help # list all SOS commandsEssential SOS commands
!sos.threads # list managed threads and their states
!sos.clrstack # managed call stack of the current thread
!sos.dumpheap -stat # heap statistics: type, count, total bytes
!sos.dumpheap -type System.String # all live string instances
!sos.dumpobj 0x... # inspect a managed object at address
!sos.gcroot 0x... # find GC roots keeping an object alive
!sos.finalizequeue # objects waiting for finalizationA typical managed-memory investigation:
0:000> !sos.dumpheap -stat
MT Count TotalSize Class Name
00007ffb1234 4821 386608 System.String
00007ffb5678 1203 120300 MyApp.Widget
00007ffb9abc 12 12582912 System.Byte[] ; <-- suspiciously large
0:000> !sos.dumpheap -mt 00007ffb9abc
Address MT Size
00000200`1a000028 00007ffb9abc 1048576
...
0:000> !sos.gcroot 00000200`1a000028SOS is most relevant when debugging C++/CLI assemblies where native and managed frames are interleaved on the same thread stack. Use !sos.clrstack for the managed frames and k for the native frames.
WinDbg with CMake + MSVC
Build flags control whether WinDbg can resolve variables, types, and inline functions — the difference between a useful debugging session and staring at raw disassembly.
Build flags for full debuggability
# CMakeLists.txt
add_executable(myapp main.cpp widget.cpp)
target_compile_options(myapp PRIVATE
$<$<CXX_COMPILER_ID:MSVC>:
/Zi # emit PDB (separate .pdb file alongside .exe)
/Od # disable optimizations (Debug build)
/RTC1 # runtime checks: stack corruption + uninitialized vars
>
)
target_link_options(myapp PRIVATE
$<$<CXX_COMPILER_ID:MSVC>:/DEBUG:FULL> # full PDB, not FASTLINK
)/DEBUG:FULL is critical: /DEBUG:FASTLINK (the MSVC default in newer toolchains) produces a PDB that references the original object files. Copy the binary to another machine and the PDB becomes useless. /DEBUG:FULL embeds everything.
PDB location and deployment
The PDB lives alongside the .exe by default. When deploying a binary for crash reporting, always ship the PDB to your symbol server:
# Copy PDB to a private symbol store
symstore add /f .\build\Release\myapp.exe /s \\myserver\symbols /t "myapp" /v "1.0.0"
symstore add /f .\build\Release\myapp.pdb /s \\myserver\symbols /t "myapp" /v "1.0.0"Verifying symbols loaded correctly
0:000> lm v m myapp
Browse full module list
start end module name
00007ff6`10000000 00007ff6`10234000 myapp (private pdb symbols)
C:\Symbols\myapp.pdb
0:000> x myapp!Widget::* # list all symbols matching the wildcardThe (private pdb symbols) annotation confirms you have full type information. (pdb symbols) means only the public/stripped PDB loaded. (no symbols) means WinDbg cannot find the PDB at all.
Checking exports with dumpbin
# Verify exported symbols from a DLL
dumpbin /EXPORTS mylib.dll
# Check which PDB a binary references
dumpbin /PDBPATH myapp.exe
# Check debug directory for PDB GUID
dumpbin /HEADERS myapp.exe | Select-String "PDB"Useful Extensions and Scripts
WinDbg's extension model (!command) adds domain-specific analysis on top of the base debugger. Most useful extensions ship with the Windows Debugger package.
Heap analysis
!heap -s # heap summary: all heaps in the process
!heap -stat -h 0 # statistics for heap 0
!heap -l # leak detection — show unreferenced heap blocks
!heap -p -a addr # show heap block details at addr (with PageHeap)Enable PageHeap for your application to catch heap corruption at the point it occurs rather than at the next heap operation:
# Enable PageHeap for myapp.exe
gflags /p /enable myapp.exe /fullProcess and thread environment
!teb # Thread Environment Block — stack base/limit, TLS, locale
!peb # Process Environment Block — loaded modules, command line, env vars
!peb -l # PEB loader data — load order of all DLLs
!locks # critical sections — find threads waiting for locks
!cs -l # list all critical sections and their owners (deadlock diagnosis)!locks and !cs are the fastest path to diagnosing a deadlock: look for threads in WaitForCriticalSection and cross-reference which thread owns the lock they are waiting for.
Session logging
.logopen C:\debug\session.log # start logging all output to file
.logappend C:\debug\session.log # append to existing log
.logclose # close the logLogging is essential when running long analysis sessions — paste the log into a bug report or share it with a colleague who does not have access to the dump.
Command files and automation
$$>< C:\scripts\analyze.txt # run a WinDbg script file
$$>a< C:\scripts\analyze.txt arg1 arg2 # pass arguments to scriptA command file is a plain text file containing WinDbg commands, one per line. Example:
.echo === Exception context ===
.ecxr
k
.echo === Heap summary ===
!heap -s
.echo === Loaded modules ===
lmPyKD — Python scripting inside WinDbg
PyKD exposes a Python API to WinDbg's debug engine. Install from NuGet or the PyKD GitHub releases, then:
# Inside a PyKD script
import pykd
# Print all thread stacks
for t in pykd.getThreadList():
print(f"Thread {t.getId()}")
for frame in t.getStack():
print(f" {frame.getFunctionName()}")0:000> !py C:\scripts\dump_threads.pyPyKD is the Windows equivalent of GDB's Python scripting: you can automate repetitive analysis, write custom ! commands, and integrate with external tools.
WinDbg vs Other Windows Debuggers
Each debugger on Windows occupies a different niche — knowing when to reach for each one saves hours.
| Feature | WinDbg Preview | Visual Studio Debugger | x64dbg | CDB (cdb.exe) |
|---|---|---|---|---|
| Primary use | Crash dumps, kernel, TTD | Live dev debugging | Reverse engineering, malware | CI/headless automation |
| UI | Standalone GUI + DML | Integrated in VS IDE | Standalone GUI | Command-line only |
| Crash dump analysis | Excellent (!analyze) | Basic (limited) | No | Excellent (same engine) |
| Kernel debugging | Yes | No | No | Yes |
| TTD / time-travel | Yes (built-in) | No | No | No |
dx / LINQ queries | Yes | No | No | Yes |
| SOS / managed code | Yes | Yes (better UI) | No | Yes |
| Scripting | JavaScript, PyKD, dx | C# VSIX | Python, JS plugins | .script files |
| Symbol server | Full _NT_SYMBOL_PATH | Full | Manual PDB load | Full |
| Disassembly | Yes | Yes | Excellent (IDA-like) | Yes |
| Source stepping | Yes (with PDB) | Excellent | No | Yes (with PDB) |
| Breakpoints on load | bu unresolved bp | GUI | GUI | bu |
| Install size | ~100 MB (Store) | ~2–10 GB (VS) | ~5 MB | Included with WinDbg |
| License | Free | Free (Community) | Free/open-source | Free |
Rule of thumb:
- Production crash dump from a customer machine → WinDbg Preview
- Day-to-day dev debugging in a VS solution → Visual Studio debugger
- Reverse engineering a binary without source → x64dbg
- CI pipeline automated crash analysis → CDB with
!analyze -v; q
See Also
- MSVC — compiler flags, PDB generation (
/Zi /DEBUG:FULL), and symbol server setup - GDB — Linux counterpart: same concepts (breakpoints, watchpoints, stack inspection), different syntax
- LLDB — macOS/Linux debugger with similar
dx-style expression evaluation vialldb's Python API - rr — Linux time-travel debugging: the open-source equivalent of TTD for x86-64 Linux