使用现代工具调试 WebAssembly

Ingvar Stepanyan
Ingvar Stepanyan

到目前为止

一年前,Chrome 宣布了对 Chrome DevTools 中原生 WebAssembly 调试功能的初始支持

我们演示了基本单步调试支持,并讨论了未来使用 DWARF 信息(而非源代码映射)为我们带来的机会:

  • 解析变量名称
  • 美观输出类型
  • 评估源语言中的表达式
  • ...等等!

今天,我们很高兴地展示已承诺的功能的实现情况,以及 Emscripten 和 Chrome DevTools 团队今年取得的进展,尤其是在 C 和 C++ 应用方面。

在开始之前,请注意,这仍是全新体验的 Beta 版,您需要自行承担使用所有工具的最新版本的风险。如果您遇到任何问题,请向 https://issues.chromium.org/issues/new?noWizard=true&template=0&component=1456350 报告。

让我们从上次的示例 C 语言开始:

#include <stdlib.h>

void assert_less(int x, int y) {
  if (x >= y) {
    abort();
  }
}

int main() {
  assert_less(10, 20);
  assert_less(30, 20);
}

如需对其进行编译,我们使用最新的 Emscripten 并传递 -g 标志,就像在原始帖子中一样,以包含调试信息:

emcc -g temp.c -o temp.html

现在,我们可以从本地主机 HTTP 服务器(例如,使用 serve)传送生成的页面,然后在最新的 Chrome Canary 中打开该页面。

这次,我们还需要一个与 Chrome 开发者工具集成的辅助扩展程序,以帮助它解读 WebAssembly 文件中编码的所有调试信息。请访问以下链接进行安装:goo.gle/wasm-debugging-extension

您还需要在 DevTools 的 Experiments 中启用 WebAssembly 调试。打开 Chrome DevTools,点击 DevTools 窗格的右上角齿轮图标 (),前往实验面板,然后选中 WebAssembly 调试:启用 DWARF 支持

DevTools 设置的“实验”窗格

关闭设置后,DevTools 会建议重新加载自身以应用设置,我们就照做吧。一次性设置到此就结束了

现在,我们可以返回到来源面板,启用在异常上暂停 (⏸ 图标),然后选中在捕获到异常时暂停,并重新加载页面。您应该会看到 DevTools 在遇到异常时暂停:

“Sources”面板的屏幕截图,显示了如何启用“在遇到异常时暂停”

默认情况下,它会在 Emscripten 生成的粘合代码处停止,但您可以在右侧看到一个表示错误的堆栈轨迹的 Call Stack 视图,并且可以导航到调用 abort 的原始 C 行:

在 `assert_less` 函数中暂停的 DevTools,并在“Scope”视图中显示 `x` 和 `y` 的值

现在,如果您在 Scope 视图中查看,则可以看到 C/C++ 代码中变量的原始名称和值,而无需再弄清楚 $localN 等经过混淆处理的名称的含义以及它们与您编写的源代码的关系。

这不仅适用于整数等基元值,也适用于结构体、类、数组等复合类型!

丰富的类型支持

我们来看一个更复杂的示例,以展示这些内容。这次,我们将使用以下 C++ 代码绘制 Mandelbrot 分形

#include <SDL2/SDL.h>
#include <complex>

int main() {
  // Init SDL.
  int width = 600, height = 600;
  SDL_Init(SDL_INIT_VIDEO);
  SDL_Window* window;
  SDL_Renderer* renderer;
  SDL_CreateWindowAndRenderer(width, height, SDL_WINDOW_OPENGL, &window,
                              &renderer);

  // Generate a palette with random colors.
  enum { MAX_ITER_COUNT = 256 };
  SDL_Color palette[MAX_ITER_COUNT];
  srand(time(0));
  for (int i = 0; i < MAX_ITER_COUNT; ++i) {
    palette[i] = {
        .r = (uint8_t)rand(),
        .g = (uint8_t)rand(),
        .b = (uint8_t)rand(),
        .a = 255,
    };
  }

  // Calculate and draw the Mandelbrot set.
  std::complex<double> center(0.5, 0.5);
  double scale = 4.0;
  for (int y = 0; y < height; y++) {
    for (int x = 0; x < width; x++) {
      std::complex<double> point((double)x / width, (double)y / height);
      std::complex<double> c = (point - center) * scale;
      std::complex<double> z(0, 0);
      int i = 0;
      for (; i < MAX_ITER_COUNT - 1; i++) {
        z = z * z + c;
        if (abs(z) > 2.0)
          break;
      }
      SDL_Color color = palette[i];
      SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a);
      SDL_RenderDrawPoint(renderer, x, y);
    }
  }

  // Render everything we've drawn to the canvas.
  SDL_RenderPresent(renderer);

  // SDL_Quit();
}

您可以看到,此应用仍然相当小,它是一个包含 50 行代码的单个文件,但这次我还使用了一些外部 API,例如用于图形的 SDL 库,以及 C++ 标准库中的复数

我将使用与上面相同的 -g 标志进行编译,以添加调试信息,同时还会要求 Emscripten 提供 SDL2 库并允许任意大小的内存:

emcc -g mandelbrot.cc -o mandelbrot.html \
     -s USE_SDL=2 \
     -s ALLOW_MEMORY_GROWTH=1

在浏览器中访问生成的网页时,我可以看到一些随机颜色的美丽分形图形:

演示页面

当我打开 DevTools 时,我再次看到了原始 C++ 文件。不过,这次我们的代码中没有错误(呼!),因此我们改为在代码开头设置一些断点。

当我们再次重新加载页面时,调试程序会直接在我们的 C++ 源代码中暂停:

DevTools 在 `SDL_Init` 调用处暂停

我们可以在右侧看到所有变量,但目前只有 widthheight 已初始化,因此检查内容并不多。

我们在主曼德布罗图形循环中再设置一个断点,然后恢复执行以跳过一些内容。

开发者工具在嵌套循环内暂停

此时,我们的 palette 已填充一些随机颜色,我们可以展开数组本身以及各个 SDL_Color 结构,并检查其组件,以验证一切是否正常(例如,“alpha”通道始终设置为完全不透明)。同样,我们可以展开并检查存储在 center 变量中的复数的实部和虚部。

如果您想访问深层嵌套的属性(否则很难通过镜重视图导航到该属性),也可以使用控制台评估功能!不过,请注意,目前尚不支持更复杂的 C++ 表达式。

显示“palette[10].r”结果的控制台面板

我们来重复几次执行,看看内部 x 是如何变化的,方法是再次查看 Scope 视图、将变量名称添加到监视列表、在控制台中对其进行求值,或在源代码中将鼠标悬停在变量上:

在源代码中将鼠标悬停在变量“x”上,显示其值“3”

从这里,我们可以单步进入或单步跳过 C++ 语句,并观察其他变量的变化情况:

显示 `color`、`point` 和其他变量的值的提示和 Scope 视图

好的,在有调试信息的情况下,所有这些都非常有效,但如果我们想调试未使用调试选项构建的代码,该怎么办?

原始 WebAssembly 调试

例如,我们要求 Emscripten 为我们提供预构建的 SDL 库,而不是自行从源代码编译,因此(至少目前)调试程序无法找到关联的源代码。我们再次进入 SDL_RenderDrawColor

显示 `mandelbrot.wasm` 的反汇编视图的 DevTools

我们将恢复原始的 WebAssembly 调试体验。

这看起来有点吓人,而且大多数 Web 开发者都不需要处理这种情况,但有时您可能需要调试未包含调试信息的构建库,原因可能是它是您无法控制的第三方库,或者您遇到了仅在生产环境中出现的某个 bug。

为了在这类情况下提供帮助,我们还对基本的调试体验进行了一些改进。

首先,如果您之前使用过原始 WebAssembly 调试,可能会注意到,整个反汇编现在显示在单个文件中,您不必再猜测 Sources 条目 wasm-53834e3e/ wasm-53834e3e-7 可能对应于哪个函数。

新的名称生成方案

我们还改进了反汇编视图中的名称。以前,您只会看到数字索引;如果是函数,则不会看到名称。

现在,我们通过使用 WebAssembly name 部分中的提示、导入/导出路径来生成与其他反汇编工具类似的名称,最后,如果所有其他操作都失败,则根据项的类型和索引(例如 $func123)生成名称。如上面的屏幕截图所示,这已经帮助使堆栈轨迹更易于阅读和分解。

当没有可用类型信息时,可能很难检查除了基元之外的任何值。例如,指针会显示为常规整数,无法得知内存中存储在指针后面的内容。

内存检查

以前,您只能展开 WebAssembly 内存对象(在 Scope 视图中用 env.memory 表示)以查找各个字节。这在一些简单场景中可行,但扩展起来并不特别方便,并且不允许以字节值以外的格式重新解释数据。为解决此问题,我们还添加了一项新功能:线性内存检查器。

如果您右键点击 env.memory,现在应该会看到一个名为检查内存的新选项:

范围窗格中 `env.memory` 的上下文菜单,其中显示了“检查内存”项

点击该按钮后,系统会调出内存检查器,您可以在其中以十六进制和 ASCII 视图检查 WebAssembly 内存、导航到特定地址,以及以不同格式解读数据:

开发者工具中的“内存检查器”窗格,显示了内存的十六进制和 ASCII 视图

高级场景和注意事项

对 WebAssembly 代码进行性能分析

当您打开开发者工具时,WebAssembly 代码会降级为未优化的版本,以便进行调试。此版本的速度要慢得多,这意味着您无法在打开 DevTools 时依赖 console.timeperformance.now 和其他方法来衡量代码的速度,因为您获得的数字根本无法代表实际性能。

您应改用开发者工具的“Performance”面板,该面板将全速运行代码,并为您提供在不同函数中花费的时间的详细明细:

显示各种 Wasm 函数的分析面板

或者,您也可以在关闭开发者工具的情况下运行应用,并在运行完成后打开开发者工具以检查 Console

我们日后会改进性能分析场景,但目前请注意这一限制。如需详细了解 WebAssembly 分层场景,请参阅我们的 WebAssembly 编译流水线文档。

在不同机器(包括 Docker / 主机)上构建和调试

在 Docker、虚拟机或远程构建服务器中进行构建时,您可能会遇到以下情况:构建期间使用的源文件的路径与运行 Chrome DevTools 的文件系统上的路径不匹配。在这种情况下,文件会显示在来源面板中,但无法加载。

为了解决此问题,我们在 C/C++ 扩展选项中实现了路径映射功能。您可以使用它重新映射任意路径,并帮助 DevTools 定位来源。

例如,如果主机上的项目位于路径 C:\src\my_project 下,但是在 Docker 容器内构建的,其中该路径表示为 /mnt/c/src/my_project,那么您可以在调试期间通过将这些路径指定为前缀来重新映射它:

C/C++ 调试扩展程序的“选项”页面

第一个匹配的前缀会“胜出”。如果您熟悉其他 C++ 调试程序,则此选项类似于 GDB 中的 set substitute-path 命令或 LLDB 中的 target.source-map 设置。

调试优化型 build

与任何其他语言一样,停用优化功能最有利于调试。优化可能会将一个函数内嵌到另一个函数中、重新排列代码或完全移除代码的某些部分,而所有这些都可能会混淆调试程序,进而混淆您(用户)。

如果您不介意调试体验受到较多限制,但仍想调试经过优化的 build,那么除了函数内嵌之外,大多数优化都会按预期运行。我们计划日后解决其余问题,但目前,请在使用任何 -O 级优化进行编译时使用 -fno-inline 停用它,例如:

emcc -g temp.c -o temp.html \
     -O3 -fno-inline

分离调试信息

调试信息会保留有关代码、定义的类型、变量、函数、范围和位置的大量详细信息,以及任何可能对调试程序有用的信息。因此,它通常比代码本身大。

为了加快 WebAssembly 模块的加载和编译速度,您可能需要将此调试信息拆分到单独的 WebAssembly 文件中。如需在 Emscripten 中执行此操作,请传递带有所需文件名的 -gseparate-dwarf=… 标志:

emcc -g temp.c -o temp.html \
     -gseparate-dwarf=temp.debug.wasm

在这种情况下,主应用将仅存储文件名 temp.debug.wasm,而帮助程序扩展程序将能够在您打开开发者工具时找到并加载该文件。

与上述优化结合使用时,此功能甚至可用于发布几乎经过优化的应用正式版 build,并在日后使用本地文件对其进行调试。在这种情况下,我们还需要替换存储的网址,以帮助扩展程序找到辅助文件,例如:

emcc -g temp.c -o temp.html \
     -O3 -fno-inline \
     -gseparate-dwarf=temp.debug.wasm \
     -s SEPARATE_DWARF_URL=file://[local path to temp.debug.wasm]

未完待续...

哇,真是增加了不少新功能!

通过这些新的集成,Chrome DevTools 不仅成为了适用于 JavaScript 的强大调试工具,还成为了适用于 C 和 C++ 应用的可行调试工具,让您可以比以往更轻松地将采用各种技术构建的应用移植到共享的跨平台 Web 环境。

不过,我们的旅程尚未结束。我们今后将着手解决以下问题:

  • 优化了调试体验中的粗糙边缘。
  • 添加了对自定义类型格式化程序的支持。
  • 正在改进 WebAssembly 应用的性能分析功能。
  • 添加了对代码覆盖率的支持,以便更轻松地查找未使用的代码。
  • 改进了对控制台计算中的表达式的支持。
  • 增加了对更多语言的支持。
  • …等等!

与此同时,请在自己的代码上试用当前 Beta 版,并在 https://issues.chromium.org/issues/new?noWizard=true&template=0&component=1456350 上报告发现的任何问题,以便我们改进。

下载预览渠道

请考虑将 Chrome Canary开发者版Beta 版用作您的默认开发浏览器。通过这些预览渠道,您可以使用最新的开发者工具功能,测试先进的网络平台 API,并帮助您在用户之前发现网站上的问题!

与 Chrome DevTools 团队联系

您可以使用以下选项讨论与 DevTools 相关的新功能、更新或任何其他内容。