wth2jhl.online

Back

仓库地址:github.com/EpicGames/raddebugger ↗

注意:EpicGames 公开仓库里是 raddebugger(多一个 r),不是 raddebugge——你点 GitHub 直链 404,但 gh-proxy 拉 master tarball 一次就过。

这是一篇学习笔记。我不打算把这 26.5 万行 C 全部说一遍——那是写书的事。下面这些是读完 README + CHANGELOG + 几个核心入口(raddbg_main.c / dbg_engine_core.c / lib_rdi/rdi.h / eval2/eval2.c / 几份 .mdesk)+ build.sh 之后,我觉得「这个项目值得学」的地方。

三个最有启发的点我会重点写:RDI 自研格式(为什么不要 PDB)、eval2 嵌入式表达式系统(调试器里跑 C-like 表达式怎么实现的)、metagen + mdesk 表驱动开发(metadata 一份自动派生 enum/string/UI/IPC/doc)。剩下的 RAD Linker 性能、平台同形异构、D_DemonInterface 抽象会点到为止。

一、EpicGames 公开这个项目想干嘛#

README 第一句就是定位:

The RAD Debugger is a native, user-mode, multi-process, graphical debugger. It currently only supports local-machine Windows x64 debugging with PDBs, with plans to expand and port in the future.

native, user-mode, multi-process, graphical——读起来是不是很眼熟?就是 WinDbg 的现代重写版。

但这不是简单的「再写个 WinDbg」。EpicGames 把这事拆成 3 块,每一块都有针对性的工程问题:

  • RAD Debugger(raddbg/):GUI + 控制流,当前 alpha,只支持 Windows x64 + PDB,最近 v0.9.29 刚加了 Linux x64 + Minidump
  • RDI 格式(lib_rdi + lib_rdi_make):自研的 debug info 格式,故意不用 PDB/DWARF 原生,而是 on-demand 转一份 RDI 给 debugger 读
  • RAD Linker(linker/):x64 PE/COFF linker,主攻 huge project(debug info 多 GB),50% link time 优势

后面两块都不是「为了做而做」——是有具体的工程痛点逼出来的。CHANGELOG 直接列了 WinDbg 当前的几个具体问题:statically linked EXE unwinding、asan、multi-thread stepping 这几个场景下 WinDbg 经常莫名其妙就坏。EpicGames 显然觉得这事值得自己再写一遍,顺便把「toolchain」也重做。

RAD Debugger 整体架构

整张图想表达的就一件事:base 是平台无关地基,dbg_engine / eval2 / linker 各自管自己一块逻辑,raddbg 只负责 UI 编排——把 GUI 和核心 engine 拆开后,未来上 Linux 就不用从头来。

二、规模和数据#

find src -name '*.c' -o -name '*.h' | xargs wc -l 给出 26.5 万行 C,加上 C++ 6 个文件(应该是 obj/parser.c 之类的 STL 边缘地带)和 14 万行 third_party。子目录规模:

 476K  src/base/        平台无关基础库(arena/string8/utf8/file_iter/hash/thread)
 444K  src/dbg_engine/  调试引擎核心
 1.8M  src/linker/     PE/COFF linker
8.1M   src/raddbg/     GUI 调试器本体
 452K  src/dwarf/      DWARF 解析
 300K  src/codeview/   CodeView 解析
 296K  src/render/     OpenGL/D3D11 后端
 428K  src/win32/      Windows 系统调用
 464K  src/linux/      Linux 系统调用
 316K  src/ui/         UI 基础控件
 244K  src/mule/       mule task 系统
 228K  src/eval2/      表达式求值器
  62K  src/lib_rdi/    RDI 格式定义
 120K  src/lib_rdi_make/  RDI 格式构造
 124K  src/metagen/    mdesk bootstrap 工具
plaintext

对照旧博客的 384 行版本(2026-10-08 第一次写),这次重新整理了一遍,有几个点上一版没讲到或者讲浅了——下面重点补。

三、RDI 跟 PDB 决裂的根本理由#

src/lib_rdi/rdi.h 顶部一行:

#define RDI_ENCODING_VERSION 23
c

外加 36 个 section 枚举(RDI_SectionKind_TopLevelInfo 到 RDI_SectionKind_NameMapNodes,0x00 到 0x2A,共 43 个 slot,36 个用上)。这个数字 v23 已经稳定迭代了很多版,EpicGames 内部用了 7 年以上的格式。

为什么不用 PDB?CHANGELOG 和 raddbg 论坛都明说:PDB 的 32-bit 表溢出。具体场景:几 GB debug info 的 huge EXE(游戏引擎模板实例化、template-heavy C++),PDB 内部那张 stream offset 表是 32-bit,撑爆就坏了——这跟 link /DEBUG:full 出来的 PDB 是不是能读完全是两回事。

RDI 的对策很直接:64-bit 一切 + 36 个独立 section。

RDI 怎么从 PDB 走到 Watch 窗口

几个关键设计点(从左到右走一遍):

  1. 3 套 hash 独立存:name → 全局唯一 string,path → 源码路径,location → 寄存器/内存地址。RDI 不强行做「统一 hash」,因为三种 lookup 模式完全不同,合并只会拖慢

  2. 每张表独立 LZB 压缩:LZB 是 RAD 自己写的无损压缩,2K 左右实现,不需要 zlib 依赖。36 张表独立压缩,意味着可以单 section 解压,不需要把整个 .rdi 一次性读到内存

  3. VMap(B+ 树)按 vaddr 索引:这是「地址 → entity」的核心数据结构。进程内 vaddr 给进来,VMap 走到 leaf node,拿到对应 module + offset。UnitVMap / ScopeVMap / GlobalVMap 三种,VMap 共用同一套实现

  4. 3 种 location bytecode:RDI_LocationBytecodeData / LocationConstantData / LocationSetElements。表达「这个变量在哪」是调试器最复杂的一部分,PDB 用的就是「寄存器/内存/算术」三件套,RDI 选了完全相同的抽象

  5. Encode/Decode 镜像结构:lib_rdi 8K 行,只做读;lib_rdi_make 15K 行,只做写。RDI 写一次(radbin 离线转换),RDI 读无数遍(每次 debugger 启动)。这个 read/write 比例下,decode API 几乎所有调用点都是 inline 函数 + arena scratch,调用者零负担

  6. radbin 离线转换:src/radbin/ 是个独立 CLI 工具,可以把 PDB → RDI / DWARF → RDI / 反向 dump 文本。debugger 启动时不会做转换,转换发生在 raddbg --bin 或者 RAD Linker 链接结束时。这点很关键:debugger 不需要懂 PDB 格式,它只看 RDI

RDI_ENCODING_VERSION = 23 这个数字也说明一件事:他们自己内部用了 7 年,期间无数项目、几 GB debug info、32-bit 溢出情况全遇到过。这不是「为了写个新东西而造轮子」,是「轮子已经不能用,自己造一个」。

四、eval2 嵌入式 C 表达式系统#

src/eval2/ 12.7K 行,做了一件调试器最难的事:让用户在 watch 窗口敲 C 表达式,调试器能 parse → type-check → 求值。

为什么这件事难?因为表达式涉及类型系统,类型系统的根在 RDI;表达式涉及运行期,运行期的根在目标进程。两边要打通。

eval2 表达式求值:从 C 字符串到目标进程的值

eval2 的实现走 4 步:

4.1 parse(Recursive-descent)#

typedef enum E2_ExprParseKind
{
  E2_ExprParseKind_Null,
  E2_ExprParseKind_Prefix,
  E2_ExprParseKind_Postfix,
} E2_ExprParseKind;
c

prefix / postfix 二元结构 + 优先级递归下降。-> . [] () 是 postfix,* & - 是 prefix,二元运算符走标准优先级表。

4.2 IR 树(E2_Node)#

struct E2_Node { E2_Node *next; U64 kid_count; E2_Node *kids[]; };
c

朴素的 n-ary 树,无类型信息。parse 阶段只产结构,不做语义。

4.3 bytecode 编译#

这一步最关键——所有结果都 u64。指针是 u64,整数是 u64,struct 也当成 u64 看待(基址),浮点走 IEEE 754 位模式 reinterpret。

enum {
  E2_EvalOp_SetCtxID = RDI_EvalOp_COUNT,
  E2_EvalOp_LeafBytecode,
};
c

eval2 复用了 RDI 的 EvalOp 定义(RDI_EvalOp_* 枚举),只额外加了 SetCtxID 和 LeafBytecode 两个扩展。这样表达式 bytecode 的格式跟 RDI 内部 location bytecode 几乎一致,可以共用 interpreter。

为什么全 u64?三个好处:

  1. 栈式 VM 不需要类型分发——所有指令格式统一,interpret 是个干净的 while-switch
  2. 跟 RDI type 树解耦——type 已经在 compile bytecode 阶段查过,interpret 只认「地址」
  3. 浮点也走 u64——不需要 FP 栈指令,不需要 FPU 上下文保存

代价:type 错就 interpret 错,但不崩——read_memory 失败返回 B32 错,但栈上还是 u64,UI 层判断后给「can’t evaluate」提示。

4.4 interpret + read target memory#

每条 bytecode 是栈式 VM 操作:

Push player_addr
Member(bag, offset)   // + struct member offset
Member(items, offset)  // + struct member offset
ArrayIdx(3, elem_size)  // + index * sizeof(elem)
Member(price, offset)  // + struct member offset
plaintext

每一步都通过 D_DemonInterface::read_memory(后面会讲)读目标进程,结果塞 u64 栈。最后一个 pop 出来格式化。

整个 12.7K 行代码做完了这件事,而且没有 GC、没有 closure、没有 RAII——纯 C + arena scratch。这是 C 项目里少见的「该用什么就用什么」的范本:不是「为了纯 C 而纯 C」,是「嵌入式表达式求值需要 predictable 内存 + 可控执行时间」,C 刚好是最匹配的工具。

五、metagen + mdesk 表驱动开发#

raddbg.mdesk 2730 行,dbg_engine.mdesk 也几百行——这个项目里所有的命令、所有的 enum、所有的 UI 元素,都是从一个 .mdesk 文件派生的。

src/metagen/ 124K 行(实际是 .meta.c/h 加上自己用 C 写的 generator),做的事是:

@table(name                      ui_vis   ipc_docs_vis   q_expr                       q_slot       q_allow_files q_allow_folders  q_keep_oi   q_select_oi  q_is_code  q_floating  q_required               canonical_icon        string    display_name                                  desc                                                                                                               search_tags                      filter_tags)
D_CmdTable:     //               |        |              |                            |                 |                  |                                                           /                     /                             |                                             |                                                                                                                  |                                |
{
  //- rjf: low-level target control operations
  {LaunchAndRun                   1        1              "query:targets"              Cfg                0  0  0  0  0  1  1                                                           Play                  "launch_and_run"              "Launch and Run"                              "Starts debugging a new instance of a target, then runs."                                                          "launch,start,run,target"        ""                                                                                            }
  {LaunchAndStepInto              1        1              "query:targets"              Cfg                0  0  0  0  0  1  1                                                           PlayStepForward       "launch_and_step_into"        "Launch and Step Into"                        "Starts debugging a new instance of a target, then stops at the program's entry point."                            "launch,start,entry,point"       ""                                                                                            }
  {Kill                           1        1              "query:processes"            Process            0  0  0  0  0  1  1                                                           X                     "kill"                        "Kill"                                        "Kills the specified existing attached process(es)."                                                               "stop,kill"                      ""                                                                                            }
  // ... 50+ 行命令
}
plaintext

表驱动开发:mdesk → metagen → C 代码 + UI + IPC + 文档

D_CmdTable 14 列属性:命令名 / UI 可见 / IPC 文档可见 / query 表达式 / 槽位类型 / 各种 query flag / canonical icon / 命令字符串 / display name / desc / search tag / filter tag。

跑 ./build.sh 的时候,metagen 会做几件事:

  1. 派生命名空间级别的 enum:enum D_CmdCode { D_CmdCode_LaunchAndRun, D_CmdCode_LaunchAndStepInto, D_CmdCode_Kill, ... }
  2. 派生字符串表:str8_lit_comp("launch_and_run") → D_CmdCode_LaunchAndRun 的双向映射
  3. 派生 UI palette:raddbg_views.c 直接遍历这张表渲染命令面板
  4. 派生 IPC schema:raddbg_server 远程 controller 走协议时按 cmd string 找本地的 handler
  5. 派生命令文档:desc + search_tags 直接进帮助系统

业务侧只剩 hook:

void cmd_launch_and_run(CmdLine *cmdline) {
  // 真业务逻辑
  dmn_launch(...);
  dmn_run(...);
}
c

新增命令 = .mdesk 加一行 + 写一个 hook 函数。5 个地方同步修改 → 1 个地方。这是把「跨 5 个文件保持一致」这个永恒的痛点,通过代码生成器一次性消掉。

@embed_file 也是 .mdesk 语法里的一部分,直接把字体/图标内嵌成 C 数组:

@embed_file rd_icon_font_bytes:         "../data/icons.ttf"
@embed_file rd_default_main_font_bytes: "../data/segoeui.ttf"
@embed_file rd_default_code_font_bytes:  "../data/JetBrainsMono-Regular.ttf"
@embed_file rd_icon_file_bytes:         "../data/logo.ico"
plaintext

raddbg.exe 单文件能跑,不需要外挂字体——这是 cross-debug 场景(在 host A 调试 target B,A 没装任何字体)的关键设计。

metagen 工具本身也是 C 写的,第一次构建手动编译一次,之后 ./metagen 自动跑。metagen_base/metagen_os/ 是它自己的平台抽象,跟主项目用同一套 base 库——它也用自己生成的部分代码。

六、平台同形异构#

src/win32/ 10.4K + src/linux/ 9.2K + src/base/ 12K + src/win32/base/ + src/linux/base/。

每个平台有自己的 base/ 子目录(放平台相关 inline 函数),主 base/ 只放平台无关的逻辑。thread / mutex / file / process / entity_store 这些模块在三个目录里同名同名同名同名同名——切换平台只换 win32/base/ 或 linux/base/,业务代码不动。

// src/base/os/os.c  纯抽象
internal Os_Handle os_file_open(String8 path, Os_FileFlags flags);
// src/win32/base/os/os.c  实现
internal Os_Handle os_file_open(String8 path, Os_FileFlags flags) {
  HANDLE h = CreateFileA(...);
  return *(Os_Handle*)&h;
}
// src/linux/base/os/os.c  实现
internal Os_Handle os_file_open(String8 path, Os_FileFlags flags) {
  int fd = open(...);
  return *(Os_Handle*)&fd;
}
c

third_party/ 14M 是隔离区——不是 1st-party 代码,build 时不参与 metagen,不参与 enum 派生。llvm/ 子目录是 RAD 团队自带的 LLVM 提取(只用了 DWARF 解析部分,不依赖整个 LLVM),7.2 万行 C++,这是整个项目里最大的「非纯 C」区域。

七、D_DemonInterface:19 个函数指针的 host 抽象#

src/dbg_engine/dbg_engine_ctrl.h:

19 个底层操作,全部函数指针,struct 套 struct,dbg_engine_ctrl.c:7 一行:

D_DemonInterface d_local_demon = { ... };
c

local debugger 走 d_local_demon(Win32 用 DebugActiveProcess / ReadProcessMemory,Linux 用 ptrace / process_vm_readv)。未来要加 remote debug(SSH 到另一台机器调),只需要一份 d_remote_demon,业务代码一字不改。

这个抽象的好处是dbg_engine 完全不知道操作系统。launch / attach / kill / detach + read_memory / write_memory + thread_read_reg_block + halt / run 加起来就够一个调试器用,不需要 Windows API 头,不需要 HANDLE,不需要 pid_t。D_Handle 是个 16 字节 union:

typedef union DMN_Handle DMN_Handle;
union DMN_Handle
{
  U32 u32[2];
  U64 u64[1];
};
c

u32[2] 装 Win32 HANDLE 的两个 U32,u64[1] 装 Linux 的 fd 整型,统一用 16 字节槽位。设计很巧——不用 64-bit 平台假设,因为 32-bit Win HANDLE 也是 16 字节。

八、RAD Linker 流水线#

linker/ 41K 行,目标是 x64 PE/COFF,主攻 huge project(几 GB debug info 的 Unreal 工程)。CHANGELOG 直接写「huge project 50% 更快」——这个数字比抽象的「高性能」实在。

RAD Linker 流水线:从 6000 个 obj 到 PE/COFF

几个值得拎出来说的设计:

  1. 内存映射输入(默认 /RAD:MEMORY_MAP_FILES) — 不把 .obj 完整读进内存,mmap 后随机访问,降低大工程内存峰值。linker/lnk_io.c 实现

  2. lock-free hash table(lf_hash_table.c) — symbol table bucket 级并发,resolve 阶段 N worker 拿不同桶并行。thread_pool/ 是单独子目录,自己调度 worker,不依赖系统线程 API

  3. base relocation 重写 70% 加速 — CHANGELOG 明写,纯算法优化,数据结构没换

  4. 原生 RDI 输出 — linker 直接写 rdi 文件,不绕道 PDB,raddbg 启动即可读

  5. unresolved relocation 带 file:line — linker 自带 debug info 解析,报错信息接近 MSVC。普通 link.exe 报「unresolved external symbol _foo」,RAD Linker 报「_foo referenced from bar.obj line 42」

  6. 新增 stub 兼容 — CHANGELOG 列了 /Brepro /D2 /ErrorReport 三个 stub,目标:让现有 MSVC 工程的 response file 直接能用

  7. /OPT:REF section GC — 常规 linker 也有,RAD 实现应该不差

  8. thread local variables type 修复 — 之前 weak symbol 会把 thread local 指向错的类型,新规则规定 weak 不允许替换 undefined

  9. 不创建不需要的 import thunk — 普通 link.exe 会给每个导入函数生成 JMP thunk,RAD Linker 检测到能直接 call 就直接 call

  10. PDB 兼容性保留 — pdb_ext/ + codeview_ext/ + base_ext/ 是给 RDI → PDB 转码用的,只在 RAD 链上使用,目的是跟其他工具(像 VS)兼容

未深挖但值得知道:linker.natvis 是 Visual Studio 调试可视化配置,让 raddbg 之外的工具也能正确显示 RAD Linker 自己生成的 PE/COFF 内部结构。

九、几个让我印象深刻的「非功能」决策#

License 是 MIT(LICENSE 顶部写得很清楚)。EpicGames 自家工具链公开 + MIT 协议,这是少见的。Lua 也是 MIT,但工具链这种「内部命脉」级别的东西敢 MIT 公开,EpicGames 显然觉得「让大家都用统一的 toolchain 比自己藏着更赚」——商业利益跟开源激励对齐得很标准。

CHANGELOG 极其诚实。v0.9.29 Linux 部分直接列已知问题:

We are not releasing any binaries for Linux yet, so we’ve provided instructions for locally building the debugger in the codebase’s README.md.

We do not support debugging of code which uses fork and vfork yet.

When computing locations of thread-local storage, we currently assume that the debuggee uses the same libc version as the debugger.

We do not currently correctly unwind when the .eh_frame_hdr section is missing. … It can be enabled by passing the --eh-frame-hdr flag to the linker, if it’s not enabled by default.

Bitfield type info is not yet supported correctly.

不掩饰「我们没做完」,直接告诉用户 workaround。这是 alpha 期项目的正确做法——告诉用户边界,比假装「完美」好太多。

project.raddbg 是调试器自己的工程文件——是的,他们用 raddbg 调 raddbg。project.4coder 是 4coder 编辑器用的工程文件(4coder 是 RAD 团队前成员的编辑器,也开源了)。这两个工程文件跟代码同仓,意味着「debugger 能 debug 自己」这件事从仓库结构上就有保障。

obsolete/ 目录。这名字本身就是个故事:有一些代码「现在是 obsolete 但我们保留着」。看 CHANGELOG 也会发现有些 v0.x → v0.y 之间的中间状态被刻意保留——给回头挖历史的人留个锚点。scratch/ 也是类似——开发期临时塞东西,偶尔清理但不删。

data/ 只有 logo + 4 个字体。没有截图,没有示例,没有 marketing 素材。整个 data/ 1.6MB,几乎全是 TrueType。EpicGames 显然觉得「让代码自解释,不要 marketing」。

十、收尾#

RAD Debugger logo

总的看下来,EpicGames/raddebugger 是个**「自我引用型项目」**:

  • 用 raddbg 调 raddbg(同仓 project.raddbg)
  • 用 RAD Linker 链 RDI 给 raddbg 读(linker 写 RDI)
  • 用 metagen 生成 raddbg 的命令表(metagen 自己也是 C 写)
  • 用 base 库给 metagen 提供 platform 抽象(主项目用 metagen,metagen 用 base)

这种自洽是少见的——大部分工具链是「一块搭一块」,自家工具是自家工具的客户这种关系,只有真正「一家子」的项目才有。

设计上值得学的几个点(从大到小排):

  1. RDI 跟 PDB 决裂 —— 32-bit 溢出是真的硬伤,自己造格式不是「为了造而造」
  2. eval2 全 u64 设计 —— 嵌入式表达式求值就该这么简单粗暴
  3. metagen + mdesk 表驱动 —— 5 个地方同步修改 → 1 个地方,这种消痛点的事每个项目都该有
  4. 平台同形异构 —— win32/base/ + linux/base/ + 主 base/ 三层,业务代码完全跨平台
  5. D_DemonInterface 19 个函数指针 —— 调试器 + OS 边界清晰,local/remote 只换 struct

下一版(v0.9.30?) 期待:bitfield type info、fork/vfork、unwind 修复、Linux 二进制发布。如果你想本地试一下,先 clone + ./build.sh,注意默认 build 是 release,Linux 上要装好 X11 + GTK3 deps(具体见 README「Build Instructions」节)。


几个 reference:

  • 仓库:github.com/EpicGames/raddebugger ↗
  • CHANGELOG:CHANGELOG.md 顶部的 v0.9.29 条目把整个项目当前状态讲透了
  • License:MIT(LICENSE)
  • 关联项目:4coder(同作者的编辑器)、RAD Linker 自带原生 RDI 输出、RAD Debugger 自带 D_DemonInterface 抽象

下次想写「eval2 bytecode 怎么跟 RDI location bytecode 复用同一套 interpreter」,以及「D_DemonInterface 在 remote debug 场景怎么加 gRPC 后端」——这两个点这次没展开,但都是值得深挖的。

深入解析 EpicGames/raddebugger:RAD Debugger + RDI + RAD Linker
https://wth2jhl.online/blog/raddebugger-deep-dive/
Author 小吴同学
Published at October 8, 2026