When a 64-bit Windows program faults, something has to rebuild the call stack: which function were we in, which registers were saved where, who called whom. The answer is not in the instructions — x64 code does not record frame pointers. It is in a side table the linker emits for every function that touches the stack or calls anything else.
That table is what debuggers walk when you hit a crash dump, what the operating system walks when it dispatches an exception, and what C++ uses to find destructors during unwinding. Read it and crash analysis stops being guesswork: you can map a faulting address to a function and see exactly how big its stack frame is.
Data directory index 3 (Exception Table) points at it. In cli-64.exe it holds RVA 0x6000 with size 0x1EC — the .pdata section.
Think of it as a table of contents for the stack:
The model's missing piece: lookups are binary searches. The spec requires the entries be sorted by function address before the image is emitted, so the unwinder can find the function containing a faulting instruction in one pass.
The PE/COFF specification, chapter "The .pdata Section", defines the x64 and Itanium entry as three 4-byte fields — Begin Address, End Address, Unwind Information — and states plainly: these are RVAs, and the entries must be sorted according to the function addresses before being emitted into the final image. (The older MIPS format stores VAs instead; the x64 layout is the one that matters on modern machines.)
Microsoft's x64 exception handling documentation adds the operational rules:
| Structure | Contents | Rule |
|---|---|---|
| RUNTIME_FUNCTION (12 bytes) | Function start, function end, unwind info address | All addresses image-relative; DWORD aligned; entries sorted; stored in .pdata of a PE32+ image |
| UNWIND_INFO | Version (3 bits), Flags (5 bits), SizeOfProlog, CountOfCodes, FrameRegister and offset, then the unwind codes array | Version is currently 1; the codes array always holds an even number of entries |
| UNWIND_CODE (2 bytes) | Offset in prolog, unwind operation (4 bits), operation info (4 bits) | Array sorted by descending prolog offset |
The dispatch algorithm is documented too: search for a RUNTIME_FUNCTION covering the faulting RIP; if there is none, the code is a leaf function and the return address is simply at [RSP]; if RIP sits in the prolog or epilog, undo or simulate it; otherwise call the language-specific handler if UNW_FLAG_EHANDLER is set, then unwind frame by frame. JIT-generated code must register its own tables with RtlAddFunctionTable or RtlInstallFunctionTableCallback, or exception handling and debugging become unreliable.
Forty-eight bytes from the start of .pdata in cli-64.exe (file offset 0x3200) — three complete entries:
00003200: 1010 0000 3410 0000 c038 0000 4010 0000 ....4....8..@... 00003210: 8510 0000 8038 0000 a010 0000 fc11 0000 .....8.......... 00003220: a438 0000 0012 0000 d012 0000 8c38 0000 .8...........8..
The first entry decodes to Begin 0x1010, End 0x1034, UnwindInfo 0x38C0. Follow it end to end:
sub rsp, 0x28 and the last instruction at 0x140001033 is ret — the byte at 0x140001034 is int3 padding, outside the function, exactly as an exclusive End predicts..rdata (RVA 0x3000, file 0x1C00), so file offset 0x24C0. First 8 bytes:000024c0: 0104 0100 0442 0000 .....B..
conpty.dll has 354 (0x1098 / 12). Its first entry reads Begin 0x10C0, End 0x1105, UnwindInfo 0x14B50 — same layout, bigger program.The imports tell you who consumes these tables: cli-64.exe pulls RtlLookupFunctionEntry, RtlVirtualUnwind, and RtlCaptureContext from KERNEL32.dll, and __C_specific_handler from VCRUNTIME140.dll — the very APIs the unwinder and structured exception handling are built on.
Read the first table entry straight from the file (0x3200 is .pdata for our sample):
$ xxd -s 0x3200 -l 48 cli-64.exe
00003200: 1010 0000 3410 0000 c038 0000 4010 0000 ....4....8..@...
00003210: 8510 0000 8038 0000 a010 0000 fc11 0000 .....8..........
00003220: a438 0000 0012 0000 d012 0000 8c38 0000 .8...........8..Then look at the code the first entry covers (command output, operand syntax reformatted for width):
$ objdump -d --start-address=0x140001010 --stop-address=0x140001036 cli-64.exe
140001010: 48 83 ec 28 sub rsp, 0x28
14000102a: b8 01 00 00 00 mov eax, 1
14000102f: 48 83 c4 28 add rsp, 0x28
140001033: c3 ret
140001034: cc int3What to look for: sub and add of the same 0x28 pair up with the single UWOP_ALLOC_SMALL code, and End lands on the first int3 after ret. If those three numbers agree, your parser is reading the table correctly.
Without looking back: a RUNTIME_FUNCTION reads Begin 0x1010 and End 0x1034. Which byte is the last one inside the function?
An unwind code reads 04 42: offset 0x04, operation 0x2, info 0x4. How much stack does the prolog allocate?
The pattern: identify the operation first, then apply its formula — every unwind opcode packs meaning into those four info bits differently.
You have now met the three runtime tables: resources, TLS, and exceptions. They all describe data the rest of the system consumes. The final module turns to the consumer itself — the loader that turns this file into a running process.
Next: How the Windows Loader Maps a PE — from MZ probe to mapped sections and bound imports.