1
头图

最近在研究 WebAssembly,也写了几篇全面介绍的文章:

本文是学习 WebAssembly 系列的第三篇文章,也是想探究一下 Chrome 开发者工具对 WebAssembly 的调试支持度如何,通过这个探究的过程,我们会了解到 Chrome 调试工具各种方面的使用方法以及作用,发掘你可能不知道的一些知识点。

所以本文既可以当做学习使用 Chrome Devtools 调试工具的一篇比较全面的文章,也可以当做是介绍现阶段我们如何在浏览器中对 WebAssembly 相关的代码进行调试,帮助你成为一个合格的调试工程师 :)。

WebAssembly 的原始调试方式

Chrome 开发者工具目前已经支持 WebAssembly 的调试,虽然存在一些限制,但是针对 WebAssembly 的文本格式的文件能进行单个指令的分析以及查看原始的堆栈追踪,具体见如下图:

上述的方法对于一些无其他依赖函数的 WebAssembly 模块来说可以很好的运行,因为这些模块只涉及到很小的调试范围。但是对于复杂的应用来说,如 C/C++ 编写的复杂应用,一个模块依赖其他很多模块,且源代码与编译后的 WebAssembly 的文本格式的映射有较大的区别时,上述的调试方式就不太直观了,只能靠猜的方式才能理解其中的代码运行方式,且大多数人很难以看懂复杂的汇编代码。

更加直观的调试方式

现代的 JavaScript 项目在开发时通常也会存在编译的过程,使用 ES6 进行开发,编译到 ES5 及以下的版本进行运行,这个时候如果需要调试代码,就涉及到 Source Map 的概念,source map 用于映射编译后的对应代码在源代码中的位置,source map 使得客户端的代码更具可读性、更方便调试,但是又不会对性能造成很大的影响。

而 C/C++ 到 WebAssembly 代码的编译器 Emscripten 则支持在编译时,为代码注入相关的调试信息,生成对应的 source map,然后安装 Chrome 团队编写的 C/C++ Devtools Support 浏览器扩展,就可以使用 Chrome 开发者工具调试 C/C++ 代码了。

这里的原理其实就是,Emscripten 在编译时,会生成一种 DWARF 格式的调试文件,这是一种被大多数编译器使用的通用调试文件格式,而 C/C++ Devtools Support 则会解析 DWARF 文件,为 Chrome Devtools 在调试时提供 source map 相关的信息,使得开发者可以在 89+ 版本以上的 Chrome Devtools 上调试 C/C++ 代码。

调试简单的 C 应用

因为 DWARF 格式的调试文件可以提供处理变量名、格式化类型打印消化、在源代码中执行表达式等等,现在就让我们实际来编写一个简单的 C 程序,然后编译到 WebAssembly 并在浏览器中运行,查看实际的调试效果吧。

首先让我们进入到之前创建的 WebAssembly 目录下,激活 emcc 相关的命令,然后查看激活效果:

cd emsdk && source emsdk_env.sh

emcc --version # emcc (Emscripten gcc/clang-like replacement) 1.39.18 (a3beeb0d6c9825bd1757d03677e817d819949a77)

接着在 WebAssembly 创建一个 temp 文件夹,然后创建 temp.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);

}

上述代码在执行 asset_less 时,如果遇到 x >= y 的情况会抛出异常,终止程序执行。

在终端切换目录到 temp 目录下执行 emcc 命令进行编译:

emcc -g temp.c -o temp.html

上述命令在普通的编译形式上,加入了 -g 参数,告诉 Emscripten 在编译时为代码注入 DWARF 调试信息。

现在可以开启一个 HTTP 服务器,可以使用 npx serve . ,然后访问 localhost:5000/temp.html 查看运行效果。

需要确保已经安装了 Chrome 扩展:https://chrome.google.com/web...,以及 Chrome Devtools 升级到 89+ 版本。

为了查看调试效果,需要设置一些内容。

  1. 打开 Chrome Devtools 里面的 WebAssembly 调试选项

设置完之后,在工具栏顶部会出现一个 Reload 的蓝色按钮,需要重新加载配置,点击一下就好。

  1. 设置调试选项,在遇到异常的地方暂停

  1. 刷新浏览器,然后你会发现断点停在了 temp.js ,由 Emscripten 编译生成的 JS 胶水代码,然后顺着调用栈去找,可以查看到 temp.c 并定位到抛出异常的位置:

可以看到,我们成功在 Chrome Devtools 里面查看了 C 代码,并且代码停在了 abort() 处,同时还可以类似我们调试 JS 时一样,查看当前 scope 下的值:

如上述可以查看 xy 值,将鼠标浮动到 x 上还可以显示此时的值。

查看复杂类型值

实际上 Chrome Devtools 不仅可以查看原 C/C++ 代码中一些变量的普通类型值,如数字、字符串,还可以查看更加复杂的结构,如结构体、数组、类等内容,我们拿另外一个例子来展现这个效果。

我们通过一个在 C++ 里面绘制 曼德博图形 的例子来展示上述的效果,同样在 WebAssembly 目录下创建 mandelbrot 文件夹,然后添加 `mandelbrot.cc 文件,并填入如下内容:

#include <SDL2/SDL.h>

#include <complex>

int main() {

  // 初始化 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);

  // 为画板填充随机的颜色

  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,

    };

  }


  // 计算 曼德博 集合并绘制 曼德博 图形

  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);

    }

  }


  // 将我们在 canvas 绘制的内容渲染出来

  SDL_RenderPresent(renderer);


  // SDL_Quit();

}

上述代码差不多 50 行左右,但是引用了两个 C++ 标准库:SDLcomplex numbers ,这使得我们的代码变得有一点复杂了,我们接下来编译上述代码,来看看 Chrome Devtools 的调试效果如何。

通过在编译时带上 -g 标签,告诉 Emscripten 编译器带上调试信息,并寻求 Emscripten 在编译时注入 SDL2 库以及允许库在运行时可以使用任意内存大小:

emcc -g mandelbrot.cc -o mandelbrot.html \

     -s USE_SDL=2 \

     -s ALLOW_MEMORY_GROWTH=1

同样使用 npx serve . 命令开启一个本地的 Web 服务器,然后访问 http://localhost:5000/mandelb... 可以看到如下效果:

打开开发者工具,然后可以搜索到 mandelbrot.cc 文件,我们可以看到如下内容:

我们可以在第一个 for 循环里面的 palette 赋值语句哪一行打一个断点,然后重新刷新网页,我们发现执行逻辑会暂停到我们的断点处,通过查看右侧的 Scope 面板,可以看到一些有意思的内容。

使用 Scope 面板

我们可以看到复杂类型如 centerpalette ,还可以展开它们,查看复杂类型里面具体的值:

直接在程序中查看

同时将鼠标移动到 palette 等变量上面,同样可以查看值的类型:

在控制台中使用

同时在控制台里面也可以通过输入变量名获取到值,依然可以查看复杂类型:

还可以对复杂类型进行取值、计算相关的操作:

使用 watch 功能

我们也可以把使用调试面板里面的 watch 功能,添加 for 循环里面的 i 到 watch 列表,然后恢复程序执行就可以看到 i 的变化:

更加复杂的步进调试

我们同样可以使用另外几个调试工具:step over、step in、step out、step 等,如我们使用 step over,向后执行两步:

可以查看到当前步的变量值,也可以在 Scope 面板中看到对应的值。

针对非源码编译的第三方库进行调试

在之前我们只编译了 mandelbrot.cc 文件,并在编译时要求 Emscripten 为我们提供内建的 SDL 相关的库,由于 SDL 库并不是我们从源码编译而来,所以不会带上调试相关的信息,所以我们仅仅在 mandelbrot.cc 里面可以通过查看 C++ 代码的形式来调试,而对于 SDL 相关的内容则只能查看 WebAssembly 相关的代码来进行调试。

如我们在 41 行,SDL_SetRenderDrawColor 调用处打上断点,并使用 step in 进入到函数内部:

会变成如下的形式:

我们又回到了原始的 WebAssembly 的调试形式,这也是难以避免的一种情况,因为我们在开发过程中可能会遇到各种第三方库,但是我们并不能保证每个库都能从源码编译而来且带上了类似 DWARF 的调试信息,绝大部分情况下我们无法控制第三方库的行为;而另外一种情况则是有时我们会在生产情况下遇到问题,而生产环境也是没有调试信息的。

上述情况暂时还没有比较好的处理方法,但是开发者工具却改进了上述的调试体验,将所有的代码都打包成单一的 WebAssembly 文件,对应到我们这次就是 mandelbrot.wasm 文件,这样我们再也无需担心其中的某段代码到底来自那个源文件。

新的命名生成策略

之前的调试面板里面,针对 WebAssembly 只有一些数字索引,而对于函数则连名字都没有,如果没有必要的类型信息,那么很难追踪到某个具体的值,因为指针将以整数的形式展示出来,但你不知道这些整数背后存储着什么。

新的命名策略参考了其他反汇编工具的命名策略,使用了 WebAssembly 命名策略部分的内容、import/export 的路径相关的内容,可以看到我们现在的调试面板中针对函数可以展示函数名相关的信息:

即使遇到了程序错误,基于语句的类型和索引也可以生成类似 $func123 这样的名字,大大提高了栈追踪和反汇编的体验。

查看内存面板

如果想要调试此时程序占用的内存相关的内容,可以在 WebAssembly 的上下文下,查看 Scope 面板里的 Module.memories.$env.memory ,但是这只能看到一些独立的字节,无法了解到这些字节对应到的其他数据格式,如 ASCII 格式。但是 Chrome 开发者工具还为我们提供了一些其他更加强大的内存查看形式,当我们右键点击 env.memory 时,可以选择 Reveal in Memory Inspector panel:

或者点击 env.memory 旁边的小图标:

可以打开内存面板:

从内存面板里面可以查看以十六进制或 ASCII 的形式查看 WebAssembly 的内存,导航到特定的内存地址,将特定数据解析成各种不同的格式,如十六进制 65 代表的 e 这个 ASCII 字符。

对 WebAssembly 代码进行性能分析

因为我们在编译时为代码注入了很多调试信息,运行的代码是未经优化且冗长的代码,所以运行时会很慢,所以如果为了评估程序运行的性能,你不能使用 performance.now 或者 console.time 等 API,因为这些函数调用获得的性能相关的数字通常不能反应真实世界的效果。

所以如果需要对代码进行性能分析,你需要使用开发者工具提供的性能面板,性能面板里面会全速运行代码,并且提供不同函数执行时花费时间的明确断点信息:

可以看到上述几个比较典型的时间点如 161ms,或者 461ms 的 LCP 与 FCP ,这些都是能反应真实世界下的性能指标。

或者你可以在加载网页时关闭控制台,这样就不会涉及到调试信息等相关内容的调用,可以确保比较真实的效果,等到页面加载完成,然后再打开控制台查看相关的指标信息。

在不同的机器上进行调试

当在 Docker、虚拟机或者其他原创服务器上进行构建时,你可能会遇到那种构建时使用的源文件路径和本地文件系统上的文件路径不一致,这会导致开发者工具在运行时可以在 Sources 面板里展示出有这个文件,但是无法加载文件内容。

为了解决这个问题,我们需要在之前安装的 C/C++ Devtools Support 配置里面设置路径映射,点击扩展的 “选项”:

然后添加路径映射,在 old/path 里填入之前的源文件构建时的路径,在 new/path 里填入现在存在本地文件系统上的文件路径:

上述映射的功能和一些 C++ 的调试器如 GDB 的 set substitute-path 以及 LLDB 的 target.source-map 很像。这样开发者工具在查找源文件时,会查看是否在配置的路径映射里有对应的映射,如果源路径无法加载文件,那么开发者工具会尝试从映射路径加载文件,否则会加载失败。

调试优化性构建的代码

如果你想调试一些在构建时进行优化后的代码,可能会获得不太理想的调试体验,因为进行优化构建时,函数内联在一起,可能还会对代码进行重排序或去除一部分无用的代码,这些都可能会混淆调试者。

目前开发者工具除了对函数内联时不能搞很好的支持外,能够支持绝大部分优化后代码的调试体验,为了减少函数内联支持能力欠缺带来的调试影响,建议在对代码进行编译时加入 -fno-inline 标志来取消优化构建时(通常是带上 -O 参数)对函数进行内联处理的功能,未来开发者工具会修复这个问题。所以针对之前提到的简单 C 程序的编译脚本如下:

emcc -g temp.c -o temp.html \

     -O3 -fno-inline

将调试信息单独存储

调试信息包含代码的详细信息,定义的类型、变量、函数、函数作用域、以及文件位置等任何有利于调试器使用的信息,所以通常调试信息比源代码还要大。

为了加速 WebAssembly 模块的编译和加载速度,你可以在编译时将调试信息拆分成独立的 WebAssembly 文件,然后单独加载,为了实现拆分单独文件,可以在编译时加入 -gseparate-dwarf 操作:

emcc -g temp.c -o temp.html \

     -gseparate-dwarf=temp.debug.wasm

进行上述操作之后,编译之后的主应用代码只会存储一个 temp.debug.wasm 的文件名,然后在代码加载时,插件会定位到调试文件的位置并将其加载进开发者工具。

如果我们想同时进行优化构建,并将调试信息单独拆分,并在之后需要调试时,加载本地的调试文件进行调试,在这种场景下,我们需要重载调试文件存储的地址来帮助插件能够找到这个文件,可以运行如下命令来处理:

emcc -g temp.c -o temp.html \

     -O3 -fno-inline \

     -gseparate-dwarf=temp.debug.wasm \

     -s SEPARATE_DWARF_URL=file://[temp.debug.wasm 在本地文件系统的存储地址]

在浏览器中调试 ffmpeg 代码

通过这篇文章我们深入了解了如何在浏览器中调试通过 Emscripten 构建而来的 C/C++ 代码,上述讲解了一个普通无依赖的例子以及一个依赖于 C++ 标准库 SDL 的例子,并且讲解了现阶段调试工具可以做的事情和限制,接下来我们就通过学到的知识来了解如何在浏览器中调试 ffmpeg 相关的代码。

带上调试信息的构建

我们只需要修改在之前的文章中提到的构建脚本 build-with-emcc.sh ,加入 -g 对应的标志:

ROOT=$PWD

BUILD_DIR=$ROOT/build

cd ffmpeg-4.3.2-3

ARGS=(

  -g # 在这里添加,告诉编译器需要添加调试

  -I. -I./fftools -I$BUILD_DIR/include

  -Llibavcodec -Llibavdevice -Llibavfilter -Llibavformat -Llibavresample -Llibavutil -Llibpostproc -Llibswscale -Llibswresample -L$BUILD_DIR/lib

  -Qunused-arguments

  -o wasm/dist/ffmpeg-core.js fftools/ffmpeg_opt.c fftools/ffmpeg_filter.c fftools/ffmpeg_hw.c fftools/cmdutils.c fftools/ffmpeg.c

  -lavdevice -lavfilter -lavformat -lavcodec -lswresample -lswscale -lavutil -lpostproc -lm -lx264 -pthread

  -O3                                           # Optimize code with performance first

  -s USE_SDL=2                                  # use SDL2

  -s USE_PTHREADS=1                             # enable pthreads support

  -s PROXY_TO_PTHREAD=1                         # detach main() from browser/UI main thread

  -s INVOKE_RUN=0                               # not to run the main() in the beginning

  -s EXPORTED_FUNCTIONS="[_main, _proxy_main]"  # export main and proxy_main funcs

  -s EXTRA_EXPORTED_RUNTIME_METHODS="[FS, cwrap, setValue, writeAsciiToMemory]"   # export preamble funcs

  -s INITIAL_MEMORY=268435456                    # 268435456 bytes = 268435456 MB

)

emcc "${ARGS[@]}"

cd -

然后以此执行其他操作,最后通过 node server.js 运行我们的脚本,然后打开 http://localhost:8080/ 查看效果如下:

可以看到,我们在 Sources 面板里面可以搜索到构建后的 ffmpeg.c 文件,我们可以在 4865 行,在循环操作 nb_output 时打一个断点:

然后在网页中上传一个 avi 格式的视频,接着程序会暂停到断点位置:

可以发现,我们依然可以像之前一样在程序中鼠标移动上去查看变量值,以及在右侧的 Scope 面板里查看变量值,以及可以在控制台中查看变量值。

类似的,我们也可以进行 step over、step in、step out、step 等复杂调试操作,或者 watch 某个变量值,或查看此时的内存等。

可以看到通过这篇文章介绍的知识,你可以在浏览器中对任意大小的 C/C++ 项目进行调试,并且可以使用目前开发者工具提供的绝大部分功能。

参考链接

❤️/ 感谢支持 /

以上便是本次分享的全部内容,希望对你有所帮助^_^

喜欢的话别忘了 分享、点赞、收藏 三连哦~

欢迎关注公众号 程序员巴士,来自字节、虾皮、招银的三端兄弟,分享编程经验、技术干货与职业规划,助你少走弯路进大厂。


程序员巴士
52 声望8 粉丝

一辆有趣、有范儿、有温度的程序员巴士,涉猎大厂面经、程序员生活、实战教程、技术前沿等内容,关注我,交个朋友。