调试是衡量一门语言或运行时是否成熟的重要标志。一个没有完善调试工具链的环境,开发者往往只能靠最原始的方式排查问题:在代码里插满打印语句,部署,等崩溃,看日志,猜原因,再部署。
在 WebAssembly 的世界里,这个问题长期存在。Cloudflare 的工程师在调试跑在 Workers 上的 Rust 服务时,就一直面临这种困境。这篇文章记录了他们如何通过 Wasm Core Dump 技术,把这件事做得更体面。
原文链接:https://blog.cloudflare.com/wasm-coredumps/
开源仓库:https://github.com/cloudflare/wasm-coredump
wasmgdb:https://github.com/xtuc/wasm-coredump/tree/main/bin/wasmgdb
Core Dump 是什么
在讨论 Wasm 之前,先把 Core Dump 本身讲清楚。
Core Dump(核心转储)是程序在崩溃或异常终止时,操作系统对程序当前内存状态的一次快照,包含:
- 工作内存的完整内容
- 处理器寄存器的状态
- 调用栈(call stack)
- 程序计数器和栈指针
- 其他有助于还原崩溃现场的信息
有了 Core Dump 文件,就可以用 gdb(GNU 调试器)打开它,查看崩溃发生时每一个函数的参数、局部变量的值、调用链路,而不需要复现崩溃------这种事后分析的方式叫做后验调试(post-mortem debugging)。
Python、Java、Go 等语言各自都有对应的 Core Dump 机制。Core Dump 的存在,本身就是一个技术栈走向成熟的标志。
Wasm 的 trap 与 Core Dump 提案
WebAssembly 程序在遇到非法操作时,会进入一个叫做 trap 的状态------类似于原生程序的崩溃。常见的触发条件包括:数组越界访问、除以零、调用 unreachable 指令等。
WebAssembly 社区很早就注意到了调试工具的缺失,并提出了一个 Core Dump 规范草案。这个规范借用了 Linux 世界成熟的 DWARF 调试信息格式------Rust、C、C++、Go 编译时都可以把变量名、源文件位置、函数参数名称等信息嵌入二进制,让调试器能把机器码映射回源代码。
生成的 Core Dump 文件包含以下几个主要部分:
- 进程的基本信息
- 线程及其调用栈帧(每个帧包含函数下标、代码偏移量、局部变量列表、栈上的值)
- Wasm 线性内存的快照(全量或关键区域)
- 可选的全局变量、数据段等信息
Wasmtime 和 Wasmer 这两个主流 Wasm 运行时已经提供了实验性的 --coredump-on-trap 标志,崩溃时会自动写出 Core Dump 文件。
麻烦在于:Workers 运行时还不支持
Cloudflare Workers 的运行时 workerd 基于 V8 引擎,理论上没有技术障碍支持 Wasm Core Dump,但由于这个规范仍处于实验阶段,workerd 作为生产关键基础设施,对引入未定稿的实验性规范持谨慎态度,目前尚未原生支持。
那怎么在 Workers 上用 Core Dump 调试 Rust 代码?
答案是:Polyfill。
Polyfill:在用户态模拟运行时能力
Polyfill 这个词在前端社区里很常见------当浏览器还不支持某个新 API 时,用 JavaScript 代码模拟出等价的行为。Cloudflare 在这里把同样的思路搬到了 Wasm 领域。
核心工具叫做 wasm-coredump-rewriter ,它的作用是对编译好的 Wasm 二进制文件进行重写,在里面注入 Core Dump 运行时逻辑。重写之后的 Wasm 模块,在崩溃时能自己收集调试信息、生成标准格式的 Core Dump 文件。
重写的工作原理
以一段简单的伪代码为例:
function addTwo(v1, v2) {
res = v1 + v2;
throw "something went wrong"; // 触发 trap
return res
}
编译后,throw 对应的是 Wasm 的 unreachable 指令。经过 wasm-coredump-rewriter 重写后,这条指令被替换成:
- 调用
$coredump/unreachable_shim,启动栈回溯过程(unwinding) - 捕获当前帧的函数下标、代码偏移量、所有局部变量的值
- 正常返回到调用方,而不是直接 trap
上层调用方在 addTwo 返回后,会检测"是否正在回溯"这个全局标志,如果是,就同样记录自己帧的信息,然后继续向上传递,直到到达宿主函数边界:
global.get 2 ;; 检测是否正在回溯?
if
call $coredump/start_frame
;; 记录当前帧的局部变量...
call $coredump/write_coredump
unreachable ;; 最终才真正 trap
end
这里有一个有趣的工程细节:Core Dump 数据被写入 Wasm 线性内存的前 1KB。这片区域被 Emscripten、LLVM 的 Wasm 后端等主流工具链约定不使用,Rust 和 Zig 虽然有过修改,但整体上这个约定仍然有效。用同样技巧的还有大名鼎鼎的 Asyncify polyfill。
崩溃发生后,JavaScript 宿主可以捕获异常,从 Wasm 实例的内存里提取 Core Dump 数据:
javascript
try {
wasmInstance.exports.someExportedFunction();
} catch(err) {
const image = new Uint8Array(wasmInstance.exports.memory.buffer);
writeFile("coredump." + Date.now(), image);
}
经过测试,重写后的二进制在性能上的影响几乎可以忽略。因此可以在开发和预发环境默认开启,生产环境按需控制。
wasmgdb:怎么读这个 Core Dump 文件
有了 Core Dump 文件,还需要工具来解读它。wasmgdb 就是 Wasm 世界里的 gdb 等价物,它理解 Core Dump 的文件格式,利用 DWARF 信息提供源码级别的调试体验。
以一个故意引发崩溃的 Rust 程序为例:
bash
$ wasmgdb source-program.wasm /path/to/coredump
wasmgdb>
查看完整调用栈:
wasmgdb> bt
#10 000012 as calculate (value=0x03000000) at src/main.rs
#9 000011 as process_thing (thing=0x2cff0f00) at src/main.rs
#8 000010 as main () at src/main.rs
...
每一行是一个栈帧,包含函数名、参数名和值、以及源文件位置。从这里可以看出,崩溃发生在 src/main.rs 的 calculate 函数里。
跳到指定帧,查看局部变量:
wasmgdb> f 9
000011 as process_thing (thing=0x2cff0f00) at src/main.rs
wasmgdb> info locals
thing: *MyThing = 0xfff1c
检查指针指向的结构体内容:
wasmgdb> p (*thing)
thing (0xfff2c): MyThing = {
value (0xfff2c): usize = 0x00000003
}
还可以直接按内存地址检查内容:
wasmgdb> p (MyThing) 0xfff2c
0xfff2c (0xfff2c): MyThing = {
value (0xfff2c): usize = 0x00000003
}
通过这套交互式命令,可以系统地还原崩溃现场------在上面这个案例里,最终定位到的是一次整数溢出。
端到端集成:Wasm Coredump Service
理解了原理之后,Cloudflare 还进一步把这套机制做成了一个可以直接部署的服务,并开源了整个实现:wasm-coredump。
架构设计
服务由两部分组成:
- 你的应用 Worker:集成 Core Dump 捕获逻辑,崩溃时把 Core Dump 数据发给服务 Worker
- Coredump 服务 Worker:解析 Core Dump,打印包含调用栈信息的异常日志,可选地存入 R2 bucket 或发送到 Sentry
两者之间通过 Service Binding 通信------这是 Workers 提供的进程间直接调用机制,不经过公网,没有网络延迟,也不需要处理鉴权。
代码集成
在你的 Rust Worker 的 JavaScript shim 层,只需要包一层 try/catch:
javascript
import shim, { getMemory, wasmModule } from "../build/worker/shim.mjs"
async function fetch(request, env, ctx) {
try {
return await Promise.race([
shim.fetch(request, env, ctx),
new Promise((r, e) => setTimeout(() => e("timeout"), 20 * 1000))
]);
} catch (err) {
const memory = getMemory();
const coredumpService = env.COREDUMP_SERVICE;
await recordCoredump({ memory, wasmModule, request, coredumpService });
throw err;
}
}
这里用 Promise.race 加了一个超时兜底,原因是 wasm-bindgen 目前有一个已知问题:Rust 异步 panic 时 Promise 可能不会被正确 reject,导致 Core Dump 捕获逻辑永远等不到异常。这个问题 Cloudflare 正在和上游一起修复,目前用超时作为临时规避手段。
大文件问题:DWARF 太大放不进 Worker
Wasm 二进制加上完整的 DWARF 调试信息,有时会超过 Workers 允许的单文件大小上限。
解决方案是 debuginfo-split :把 DWARF 信息从主二进制里剥离,写到一个单独的 debug-{UUID}.wasm 文件里。主二进制里保留同一个 UUID,用于关联。
在 wrangler.toml 里加一行构建命令:
toml
command = "... && debuginfo-split ./build/worker/index.wasm"
效果非常明显。以博客里的示例为例:
| 文件 | 大小 |
|---|---|
debug-63372dbe-....wasm(DWARF 文件) |
4.5 MB |
build/worker/index.wasm(主包) |
313 KB |
剥离后的主包体积下降到原来的 7%,调试信息单独上传到 R2,wasmgdb 调试时自动关联两者。
小结
这篇文章描述的是一套完整的 Wasm 后验调试链路:
- wasm-coredump-rewriter:通过二进制重写,在不依赖运行时原生支持的前提下,让 Wasm 程序崩溃时生成标准格式的 Core Dump 文件
- wasmgdb:提供接近 gdb 的交互式调试体验,能读取完整调用栈、检查变量、追踪指针
- Wasm Coredump Service:把 Core Dump 的捕获、解析、存储和上报封装成可一键部署的 Workers 服务
从工程角度看,这套方案最值得借鉴的思路是:运行时不支持不是终点,Polyfill 是一种合法的过渡路径。把能力注入到编译产物里,让产物自己完成运行时还未实现的工作------这和前端社区用 Babel 把新语法编译成旧语法、用 Polyfill 补全浏览器缺失 API 的思路如出一辙。
随着 V8 等引擎对 Wasm Core Dump 规范的原生支持逐步落地,Polyfill 的使命会慢慢结束。但在此之前,这套方案已经在 Cloudflare 内部的 D1、Constellation 等多个生产服务上得到了实际验证。