vlambda博客
学习文章列表

快速上手 WebAssembly 应用开发:Emscripten 使用入门

作者 | 赵洋
策划 | 蔡芳芳

在上一篇文章中, 我们较为详细地讲述了 WebAssembly 的演变历程,通过 WebAssembly 的演变历程,我们可以对 WebAssembly 的三个优点(二进制格式、Low-Level 的编译目标、接近 Native 的执行效率)有比较深刻的理解。

在本章中我们将选取 Emscripten 及 C/C++ 语言来简要讲述 WebAssembly 相关工具链的使用,通过较为简单的例子帮助大家更快速地上手 WebAssembly 相关的应用开发。请放心,在本章中我们将避免复杂难懂的 C/C++ 语言技巧,力求相关示例简单、直接、易懂。如果你有 Rust、Golang 等支持 WebAssembly 的相关语言背景,那么可以将本章相关内容作为参考,与对应官方工具链结合学习。

关于 Emscripten

Emscripten 是 WebAssembly 工具链里重要的组成部分。从最为简单的理解来说,Emscripten 能够帮助我们将 C/C++ 代码编译为 ASM.js 以及 WebAssembly 代码,同时帮助我们生成部分所需的 JavaScript 胶水代码。

但实质上 Emscripten 与 LLVM 工具链相当接近,其包含了各种我们开发所需的 C/C++ 头文件、宏参数以及相关命令行工具。通过这些 C/C++ 头文件及宏参数,其可以指示 Emscripten 为源代码提供合适的编译流程并完成数据转换,如下图所示:

快速上手 WebAssembly 应用开发:Emscripten 使用入门Emscripten 编译流程(来自官网)

emcc 是整个工具链的编译器入口,其能够将 C/C++ 代码转换为所需要的 LLVM-IR 代码,Clang/LLVM(Fastcomp)能够将通过 emcc 生成的 LLVM-IR 代码转换为 ASM.js 及 WebAssembly 代码,而 emsdk 及.emscripten 文件主要是用来帮助我们管理工具链内部的不同版本的子集工具及依赖关系以及相关的用户编译设置。

在我们的日常业务开发过程中,实际上并不需要太过关心 Emscripten 内部的实现细节,Emscripten 已经非常成熟且易于使用。但相关读者若想知道 Emscripten 内部的更多细节,可以访问 Emscripten 官网 以及 Github 阅读相关 WIKI 进一步了解。

下载、安装与配置

在进行相关操作之前,请先确保已经安装 git 工具并能够使用基本的 git 命令,接下来我们以 Linux 系统下的操作作为示例演示如何下载、安装及配置 Emscripten。若你的操作系统为 Windows 或是 OSX 等其他系统,请参考官方文档中的相关章节进行操作。

  • 安装

进入你自己的安装目录,执行如下命令获取到 Emscripten SDK Manager(emsdk):
> git clone https://github.com/emscripten-core/emsdk.git
  • 下载

进入 emsdk 目录,并执行如下的命令进行安装操作:
> cd emsdk> git pull> ./emsdk install latest
需要注意的是,install 命令可以安装特定版本的 Emscripten 开发包及其依赖的所有自己工具,例如:
> ./emsdk install 1.38.45
  • 激活及配置

当安装完成后,我们可以通过如下命令进行 Emscripten 的激活和配置:
> ./emsdk activate latest # or ./emsdk activate 1.38.45> source ./emsdk_env.sh

现在让我们执行 emcc -v 命令查看相关的信息,若正确输出如下类似信息则说明 Emscripten 安装及配置成功。

快速上手 WebAssembly 应用开发:Emscripten 使用入门emcc -v 的相关信息输出

小试身手
终于进入有趣的部分了,按照惯例,我们先以打印 Hello World! 作为我们学习 WebAssembly 的第一个程序吧!让我们先快速编写一个 C/C++ 的打印 Hello World! 代码,如下所示:
#include <stdio.h>
int main() { printf("Hello World!\n"); return 0;}
这个程序很简单,使用相关的 GCC 等相关编译器能够很正确得到对应的输出。那么如何产出 WebAssembly 的程序呢?依靠 Emscripten 整个操作也非常简单:
> emcc main.c -o hello.html

执行完毕后你将得到三个文件代码,分别是:

  • hello.html

  • hello.js:相关的胶水代码,包括加载 WASM 文件并执行调用等相关逻辑

  • hello.wasm:编译得到的核心 WebAssembly 执行文件

接着我们在当前目录启动一个静态服务器程序(例如 NPM 中的 static-server),然后访问 hello.html 后我们就能看到 Hello World! 在页面上正确输出了!当然,实际上 hello.html 文件并不是一定需要的,如果我们想要让 NodeJS 使用我们代码,那么直接执行:
> emcc main.c
即可得到 a.out.jsa.out.wasm 两个文件,然后我们使用 NodeJS 执行:
node a.out.js

也能正确的得到对应的输出(你可以自行创建 html 文件并引入 a.out.js进行浏览器环境的执行 )。

当然,在我们的日常的业务开发中相关程序是不可能如此简单的。除了我们自己的操作逻辑外,我们还会依赖于非常多商用或开源的第三方库及框架。比如在数据通信及交换中我们往往会使用到 JSON 这种轻量的数据格式。在 C/C++ 中有非常多相关的开源库能解决 JSON 解析的问题,例如cJSON等,那么接下来我们就增加一点点复杂度,结合 cJSON 库编一个简单的 JSON 解析的程序。

首先我们从 Github 中找到 cJSON 的主页,然后下载相关的源码放置在我们项目的 vendor 文件夹中。接着我们在当前项目的根目录下创建一个 CMakeList.txt文件,并填入如下内容:
cmake_minimum_required(VERSION 3.15) # 根据你的需求进行修改project(sample C)
set(CMAKE_C_STANDARD 11) # 根据你的 C 编译器支持情况进行修改set(CMAKE_EXECUTABLE_SUFFIX ".html") # 编译生成.html
include_directories(vendor) # 使得我们能引用第三方库的头文件add_subdirectory(vendor/cJSON)
add_executable(sample main.c)
# 设置 Emscripten 的编译链接参数,我们等等会讲到一些常用参数
set_target_properties(sample PROPERTIES LINK_FLAGS "-s EXIT_RUNTIME=1")target_link_libraries(sample cjson) # 将第三方库与主程序进行链接
那什么是 CMakeList.txt 呢?简单来说, CMakeList.txtCMake 的“配置文件”, CMake 会根据 CMakeList.txt 的内容帮助我们生成跨平台的编译命令。在我们现在及之后的文章中,不会涉及非常复杂的 CMake 的使用,你完全可以把 CMakeList.txt 里的相关内容当成固定配置提供给多个项目的复用,如若需要更深入的了解 CMake 的使用,可以参考 CMake 的 官网教程及文档。好了,现在让我们在代码中引入 cJSON 然后并使用它进行 JSON 的解析操作,代码如下:
#include <stdio.h>#include "cJSON/cJSON.h"
int main() { const char jsonstr[] = "{\"data\":\"Hello World!\"}"; cJSON *json = cJSON_Parse(jsonstr);
const cJSON *data = cJSON_GetObjectItem(json, "data"); printf("%s\n", cJSON_GetStringValue(data));
cJSON_Delete(json); return 0;}
代码的整体逻辑非常简单易懂,在这里就不再赘述。由于我们使用了 CMake,因此 Emscripten 的编译命令需要有一点点修改,我们将不使用 emcc 而是使用 emcmake 及 emmake 来创建我们的相关 WebAssembly 代码,命令如下:
> mkdir build> cd build> emcmake cmake ..> emmake make

我们创建了一个 build 文件夹用来存放 cmake 相关的生成文件及信息,接着进入 build 文件夹并使用 emcmake 及 emmake 命令生成对应的 WebAssembly 代码 sample.html、sample.js、sample.wasm,最后我们执行访问 sample.html 后可以看到其正确的输出了 JSON 的 data 内容。

如若你从未使用过 CMake,请不要为 CMake 的相关内容因不理解而产生沮丧或者畏难情绪。在我的日常的 WebAssembly 开发中,基本都是沿用一套 CMakeList.txt 并进行增删改,与此同时编译流程基本与上诉内容一致,你完全可以将这些内容复制在你的备忘录里,下次需要用到时直接修改即可。

WASM 的调试
对于开发的 WebAssembly 代码而言,我们对于调试可以使用两种方式,一种方式是通过日志的方式进行输出,另一种方式使用单步调试。使用日志的方式输出调试信息非常容易,Emscripten 能很好的支持 C/C++ 里面的相关 IO 库。而对于单步调试而言,目前最新版本的 Firefox 及 Chrome 浏览器都已经有了一定的支持,例如我们有如下代码:
#include <stdio.h>
int main() { printf("Hello World!"); return 0;}
然后我们使用 emcc 进行编译得到相关的文件:
> emcc -g4 main.c -o main.wasm # -g4 可生成对应的 sourcemap 信息

接着打开 Chrome 及其开发者工具,我们就可以看到对应的 main.c 文件并进行单步调试了。

快速上手 WebAssembly 应用开发:Emscripten 使用入门使用 Chrome 进行单步调试

但值得注意的是,目前 emcmake 对于 soucemap 的生成支持并不是很好,并且浏览器的单步调试支持也仅仅支持了代码层面的映射关系,对于比较复杂的应用来说目前的单步调试能力还比较不可用,因此建议开发时还是以日志调试为主要手段。

JavaScript 调用 WASM
对于 WebAssembly 项目而言,我们经常会需要接收外部 JavaScript 传递的相关数据,难免就会涉及到互操作的问题。回到最开始的 JSON 解析例子,我们一般情况而言是需要从外部 JavaScript 中获取到 JSON 字符串,然后在 WebAssembly 代码中进行解析后做对应的业务逻辑处理,并返回对应的结果给外部 JavaScript。接下来,我们会增强 JSON 解析的相关代码,实现如下:
#include <stdio.h>#include "cJSON/cJSON.h"
int json_parse(const char *jsonstr) { cJSON *json = cJSON_Parse(jsonstr); const cJSON *data = cJSON_GetObjectItem(json, "data"); printf("%s\n", cJSON_GetStringValue(data)); cJSON_Delete(json); return 0;}
在如上代码中,我们将相关逻辑封装在 json_parse 的函数之中,以便外部 JavaScript 能够顺利的调用得到此方法,接着我们修改一下 CMakeList.txt 的编译链接参数:
#....set_target_properties(sample PROPERTIES LINK_FLAGS "\ -s EXIT_RUNTIME=1 \ -s EXPORTED_FUNCTIONS=\"['_json_parse']\"")

EXPORTED_FUNCTIONS 配置用于设置需要暴露的执行函数,其接受一个数组。这里我们需要将 json_parse 进行暴露,因此只需要填写 _json_parse即可。需要注意的是,这里暴露的函数方法名前面以下划线(_)开头。然后我们执行 emcmake 编译即可得到对应的生成文件。

接着我们访问 sample.html,并在控制台执行如下代码完成 JavaScript 到 WebAssembly 的调用:
let jsonstr = JSON.stringify({data:"Hello World!"});jsonstr = intArrayFromString(jsonstr).concat(0);
const ptr = Module._malloc(jsonstr.length);Module.HEAPU8.set(jsonstr, ptr);Module._json_parse(ptr);

在这里,intArrayFromStringModule._malloc 以及 Module.HEAPU8 等都是 Emscripten 提供给我们的方法。intArrayFromString 会将字符串转化成 UTF8 的字符串数组,由于我们知道 C/C++ 中的字符串是需要 \0 结尾的,因此我们在末尾 concat 了一个 0 作为字符串的结尾符。接着,我们使用 Module._malloc 创建了一块堆内存并使用 Module.HEAPU8.set 方法将字符串数组赋值给这块内存,最后我们调用 _json_parse 函数即可完成 WebAssembly 的调用。

需要注意的是,由于 WebAssembly 端的 C/C++ 代码接收的是指针,因此你是不能够将 JavaScript 的字符串直接传给 WebAssembly 的。但如果你传递的是 int、float 等基本类型,那么就可以直接进行传递操作。当然,上面的代码我们还可以进一步简化为:
const jsonstr = JSON.stringify({data:"Hello World!"});const ptr = allocate(intArrayFromString(jsonstr), 'i8', ALLOC_NORMAL);Module._json_parse(ptr);
那为何需要如此繁琐的方式才能进行引用 / 指针类型的调用传参呢?在这里我们深入一点 Emscripten 的底层实现,为了方便说明,我们以 ASM.js 的相关逻辑作为参考进行剖析(WASM 实现同理)。我们调整下对应的 CMakeList.txt 将代码编译为 ASM.js:
set_target_properties(sample PROPERTIES LINK_FLAGS " \ -s WASM=0 \ -s TOTAL_MEMORY=16777216 \ -s EXIT_RUNTIME=1 \ -s EXPORTED_FUNCTIONS=\"['_json_parse']\" \")
在这里我们将对应的编译链接参数增加 -s WASM=0-s TOTAL_MEMORY=16777216,然后进行相关的编译操作得到 sample.htmlsample.js。首先我们来了解一下 -s TOTAL_MEMORY=16777216 的作用,我们搜索 16777216 这个数字时我们可以看到如下的代码:
function updateGlobalBufferAndViews(buf) { buffer = buf; Module['HEAP8'] = HEAP8 = new Int8Array(buf); Module['HEAP16'] = HEAP16 = new Int16Array(buf); Module['HEAP32'] = HEAP32 = new Int32Array(buf); Module['HEAPU8'] = HEAPU8 = new Uint8Array(buf); Module['HEAPU16'] = HEAPU16 = new Uint16Array(buf); Module['HEAPU32'] = HEAPU32 = new Uint32Array(buf); Module['HEAPF32'] = HEAPF32 = new Float32Array(buf); Module['HEAPF64'] = HEAPF64 = new Float64Array(buf);}
var STATIC_BASE = 8, STACK_BASE = 2960, STACKTOP = STACK_BASE, STACK_MAX = 5245840, DYNAMIC_BASE = 5245840, DYNAMICTOP_PTR = 2928;
// ....
var INITIAL_TOTAL_MEMORY = Module['TOTAL_MEMORY'] || 16777216;
// ....

if (Module['buffer']) { buffer = Module['buffer'];} else { buffer = new ArrayBuffer(INITIAL_TOTAL_MEMORY);}
INITIAL_TOTAL_MEMORY = buffer.byteLength;updateGlobalBufferAndViews(buffer);

在这段代码中我们可以看到实际上 Emscripten 帮助我们使用 ArrayBuffer 开辟了一块内存,并将这块内存分为了 栈(STACK)堆(DYNAMIC/HEAP) 两个区域,而这里的 TOTAL_MEMORY 实际上是指明了程序运行内存的实际可用大小(这里非常像简化版的进程内存布局)。同时我们可以看到我们在上面提及的 Module.HEAPU8 等实际上只是这块内存上的不同类型的指针类型(或者说不同的 ArrayBuffer 类型)。因此当我们在进行 Module.HEAPU8.set 的相关操作时,其本质上也是在对这块内存进行相关的操作。

接着我们查找 _json_parse 关键字, _json_parse 的编译后代码如下所示:
function _json_parse($jsonstr) { $jsonstr = $jsonstr|0; // ... sp = STACKTOP; STACKTOP = STACKTOP + 16|0; // ... $jsonstr$addr = $jsonstr; $0 = $jsonstr$addr; $call = (_cJSON_Parse($0)|0); // ... HEAP32[$vararg_buffer>>2] = $call2; (_printf(1005,$vararg_buffer)|0); STACKTOP = sp;return 0;}
WASM 调用 JavaScript

WebAssembly 在执行完成之后可能会需要返回部分返回值,针对这个场景其也分为两种情况:

  • 如果返回 int、float、double 等基础类型,那么直接函数声明返回类型后返回即可;

  • 如果需要返回数组、指针等类型,则可以通过 EM_ASM 或是 Memory Copy 的方式进行处理;

例如我们在 WebAssembly 端接收并解析 JSON 字符串后,判断对应数值然后返回修改后的 JSON 字符串,这个需求我们采用 EM_ASM 方式的代码如下:
#include <stdio.h>#include "cJSON/cJSON.h"#ifdef __EMSCRIPTEN__#include <emscripten.h>#endif
int json_parse(const char *jsonstr) { cJSON *json = cJSON_Parse(jsonstr); cJSON *data = cJSON_GetObjectItem(json, "data"); cJSON_SetValuestring(data, "Hi!");
const char *result = cJSON_Print(json); #ifdef __EMSCRIPTEN__ EM_ASM({ if(typeof window.onRspHandler == "function"){ window.onRspHandler(UTF8ToString($0)) } }, result); #endif
cJSON_Delete(json); return 0;}
首先我们引入 emscripten.h 头文件,接着我们使用 EM_ASM 调用外部的 window.onRspHandler 回调方法即可完成对应需求。 EM_ASM 大括号内可以书写任意的 JavaScript 代码,并且可以对其进行传参操作。在本例中,我们将 result 传递给 EM_ASM 方法,其 $0 为传参的等价替换,若还有更多参数则可以写为 $1$2等。接着,我们编译对应代码,然后访问 sample.html,并在控制台执行如下代码完成 JavaScript 到 WebAssembly 的调用:
window.onRspHandler = (result) => { console.log(result); // output: {"data":"Hi!"}};
const jsonstr = JSON.stringify({data:"Hello World!"});const ptr = allocate(intArrayFromString(jsonstr), 'i8', ALLOC_NORMAL);Module._json_parse(ptr);

可以看到,window.onRspHandler 函数被调用并正确的进行了结果输出。实际上 Emscripten 给我们提供了非常多的 JavaScript 调用函数及宏,包括:

  • EM_ASM

  • EM_ASM_INT

  • emscripten_run_script

  • emscripten_run_script_int

  • emscripten_run_script_string

  • emscripten_async_run_script

  • .......

但是在一般实践中我们推荐使用 EM_ASM_* 的相关宏来进行对应的 JavaScript 调用,其原因在于 EM_ASM_* 的内容在编译中会被抽出内联为对应的 JavaScript 函数,上面的例子在编译之后实际上得到的内容如下所示:
function _json_parse($jsonstr) { // ... $call4 = _emscripten_asm_const_ii(0,($4|0))|0; // ...}
我们可以看到在这里,我们 EM_ASM 的调用其实质是直接调用了 _emscripten_asm_const_ii,而 _emscripten_asm_const_ii 函数内容如下:
var ASM_CONSTS = [function($0) { if(typeof window.onRspHandler == "function"){ window.onRspHandler(UTF8ToString($0)) }}];
function _emscripten_asm_const_ii(code, a0) { return ASM_CONSTS[code](a0);}
我们所编写的 JavaScript 代码被放置到了 ASM_CONSTS 数组之中,然后被通过对应的索引位置进行调用。而对于 emscripten_run_script_* 相关函数而言,其实质是调用了 eval 来进行执行。因此两者在频繁调用的场景下会有比较大的性能差距。分析完 EM_ASM 的方式,那如果我们使用 Memory Copy 的话怎么做呢?代码如下:
#include <stdio.h>#include <memory.h>#include <string.h>#include "cJSON/cJSON.h"
int json_parse(const char *jsonstr, char *output) { cJSON *json = cJSON_Parse(jsonstr); cJSON *data = cJSON_GetObjectItem(json, "data"); cJSON_SetValuestring(data, "Hi!");
const char *string = cJSON_Print(json); memcpy(output, string, strlen(string));
cJSON_Delete(json); return 0;}
我们相比之前的实现多传递了一个参数 output,在 WebAssembly 端解析、改写 JSON 完成后,使用 memcpy 将对应结果复制到 output 当中。接着,我们编译对应代码,然后访问 sample.html,并在控制台执行如下代码完成 JavaScript 到 WebAssembly 的调用:
const jsonstr = JSON.stringify({data:"Hello World!"});const ptr = allocate(intArrayFromString(jsonstr), 'i8', ALLOC_NORMAL);
const output = Module._malloc(1024);Module._json_parse(ptr, output);console.log(UTF8ToString(output)); // output: {"data":"Hi!"}

如上所示,我们使用 Malloc._malloc 创建了一块堆内存,并传递给 _json_parse 函数,同时使用 UTF8ToString 方法将对应 JSON 字符串结果输出。

使用更多的 Emscripten 的 API
实际上 Emscripten 为了方便我们在 C/C++ 中编写代码,其提供了非常多的 API 供我们使用,其中包括:Fetch、File System、VR、HTML5、WebSocket 等诸多实现。例如我们以 Fetch 为例:
#include <stdio.h>#include <string.h>
#ifdef __EMSCRIPTEN__#include <emscripten/fetch.h>void downloadSucceeded(emscripten_fetch_t *fetch) { printf("%llu %s.\n", fetch->numBytes, fetch->url); emscripten_fetch_close(fetch);}
void downloadFailed(emscripten_fetch_t *fetch) { emscripten_fetch_close(fetch);}#endif
int main() {#ifdef __EMSCRIPTEN__ emscripten_fetch_attr_t attr; emscripten_fetch_attr_init(&attr); strcpy(attr.requestMethod, "GET"); attr.attributes = EMSCRIPTEN_FETCH_LOAD_TO_MEMORY; attr.onsuccess = downloadSucceeded; attr.onerror = downloadFailed; emscripten_fetch(&attr, "http://myip.ipip.net/");#endif}
在上面的代码中我们使用了 emscripten_fetch 相关函数来进行浏览器宿主环境 fetch 方法的调用。为了启用 Emscripten 中的 Fetch 能力,我们还需要修改编译链接参数,为其增加 -s FETCH=1:
#....set_target_properties(sample PROPERTIES LINK_FLAGS "\ -s NO_EXIT_RUNTIME=1 \ -s FETCH=1 \")

想要了解更多的可用 API 及细节,你可以访问 Emscripten 官网阅读 API Reference 相关章节。

https://emscripten.org/docs/api_reference/index.html

使用更多的 Emscripten 的 API

在上面实践中我们使用了一些编译连接的参数,包括:

  • -g
  • -s EXIT_RUNTIME
  • -s EXPORTED_FUNCTIONS
  • -s FETCH
  • -s NO_EXIT_RUNTIME

实际上,Emscripten 包含了非常丰富的相关设置参数帮助我们在编译和链接时优化我们的代码。其中部分常用的参数包括:

  • -O1、-O2、-O3、-Oz、-Os、-g 等:编译优化,具体可参考 Emscripten 官网相关章节;
  • -s ENVIRONMENT:设定编译代码的可执行环境,默认值为"web,work,node";
  • -s SINGLE_FILE:是否将 ASM.js 或 WebAssembly 代码以 Base64 的方式嵌入到 JavaScript 胶水代码中,可取值 0/1;
  • -s WASM:是否编译为 WebAssembly 代码,0 编译为 ASM.js,1 编译为 WebAssembly;
  • -s FETCH:是否启用 Fetch 模块,可取值 0/1;
  • -s DISABLE_EXCEPTION_CATCHING:禁止生成异常捕获代码,可取值 0/1;
  • -s ERROR_ON_UNDEFINED_SYMBOLS:编译时出现 Undefined Symbols 后是否退出,可取值 0/1;
  • -s EXIT_RUNTIME: 执行完毕 main 函数后是否退出,可取值 0/1;
  • -s FILESYSTEM:是否启用 File System 模块,可取值 0/1;
  • -s INVOKE_RUN:是否执行 C/C++ 的 main函数,可取值 0/1;
  • -s ASSERTIONS:是否给运行时增加断言,可取值 0/1;
  • -s TOTAL_MEMORY:总的可用内存使用数,可取以 16777216 为基数的整数值;
  • -s ALLOW_MEMORY_GROWTH:当可用内存不足时,是否自动增长,可取值 0/1;
  • -s EXPORTED_FUNCTIONS:暴露的函数列表名称;
  • -s LEGACY_VM_SUPPORT:是否增加部分兼容函数以兼容低版本浏览器(iOS9、老版本 Chrome 等),可取值 0/1;
  • -s MEM_INIT_METHOD:是否将.mem 文件以 Base64 的方式嵌入到 JavaScript 胶水代码中,可取值 0/1;
  • -s ELIMINATE_DUPLICATE_FUNCTIONS:将重复函数进行自动剔除,可取值 0/1;
  • --closure: 是否使用 Google Closure 进行最终代码的压缩,可取值 0/1;
  • --llvm-lto:是否进行 LLVM 的链接时优化,可取值 0-3;
  • --memory-init-file:同 -s MEM_INIT_METHOD;
  • ......

更多编译链接参数设置可以参考 emsdk/src/settings.js 文件。

总   结

在本章中我们较为详细地介绍了 Emscripten 的入门使用,关于 Emscripten 的更多内容(代码性能及体积优化、API 使用等)可以参考 Emscripten 官网 或 Github 的 WIKI。在接下来的文章中,我们会以具体需求实例为入口,帮助大家能够更好地学习 Emscripten 在实际生产中的使用。






点个在看少个 bug 👇