ESP32上运行WebAssembly:CPU不认识也能跑的实现原理
刚在一颗 ESP32 上把一个 WASM 小程序跑通的时候办公室同事围过来第一句话几乎都是同一个“这芯片的 CPU 不是不认识 WebAssembly 吗它怎么跑起来的”这个问题确实问到点子上了。ESP32 的 CPU 是 Tensilica Xtensa老款或者 RISC-V新出的 C3、C6 等型号指令集里压根没有 wasm 字节码对应的机器码从硬件层面讲它确实“不认识” WebAssembly。但结果是WASM 小应用就是实打实地在芯片上运行了还能跟 GPIO、I2C、蓝牙这些外设打交道。这篇文章就把这件事彻底讲透CPU 不认识 WASM 跟它能运行 WASM 到底矛盾在哪、不矛盾的机制是什么以及你自己怎么在 ESP32 上把一个 C 函数编译成 wasm 再加载执行。适合想给设备加“动态脚本能力”但又不想碰 Lua 或者 JS 引擎的嵌入式开发者。1. “CPU 不认识 WebAssembly”到底是怎么一回事1.1 指令集错位CPU 只认自己的“方言”要理解这个问题先得搞清楚一个基础概念CPU 执行程序本质上是执行一串二进制机器码这些机器码必须符合该 CPU 指令集架构ISA的规定。ESP32 的老款芯片用的是 Xtensa LX6ESP32-C3 用的是 RISC-V RV32IMC这两种架构的指令格式、寄存器编号、内存寻址方式完全不同。WebAssembly 则是一套完全独立于任何物理 CPU 的“虚拟指令集”它规定的是栈式虚拟机指令比如i32.add、i32.load、call这些抽象操作。如果让 Xtensa 直接拿 wasm 字节码来执行结果只有一个CPU 会尝试把0x6a这种操作码当成普通指令去解码然后大概率触发 illegal instruction 异常。这就像你把一本西班牙语小说直接丢给一个只懂中文的编辑编辑“从头到尾读完了”但一个字都没理解更不可能把故事复述出来。所以从字面意义上说“ESP32 的 CPU 不认识 WebAssembly”这句话是完全成立的。但这个结论只覆盖了“硬件直接执行”这一种方式。真正的问题是我们为什么非要硬件直接执行一种字节码计算机科学里早就有一个成熟到不能再成熟的答案——解释器。CPU 不认识的字节码可以交给一个 CPU 认识的程序来“翻译”执行。这个程序本身就是由 Xtensa/RISC-V 机器码写成的它逐条读取 wasm 字节码解析出语义然后调用对应的本地操作来完成计算。所以这里有个关键认知要纠正过来ESP32 上运行 WASMCPU 自始至终都在执行 Xtensa 机器码或 RISC-V 机器码从来没有直接执行过一个 wasm 操作码。它在执行的是那个“翻译程序”也就是 WASM 运行时runtime。WASM 字节码对于 CPU 而言不是“程序”而是“数据”——输入给解释器处理的数据。这个区分是整篇文章的核心。1.2 翻译官模式解释器是怎么把字节码变成动作的如果你熟悉 Python 或者早期 Java 的运作方式应该很容易理解这个模型。CPython 解释器本身是一个 C 程序它逐行读取.py源码把它变成字节码再执行JVM 也是一个 C 程序读取.class文件里的 Java 字节码并解释执行。WASM 在 ESP32 上的处境跟 Java 字节码在 JVM 里的处境非常像上层有一套规范明确的字节码格式下层有一个负责翻译的执行引擎。具体到 WASM 解释器它的运行逻辑大致是这样的先从 wasm 二进制文件的 code section 里读出函数体对应的字节码序列然后进入一个“取指令-解码-执行”的循环。比如读到i32.add这个操作码解释器就知道要从虚拟栈上弹出两个 32 位整数相加后把结果压回栈。这些操作在解释器里对应的是几行 C 代码这几行 C 代码早就被编译成了 Xtensa 机器码。所以从效果上看WASM 程序“跑起来”了但每一小步指令的落地执行都是 Xtensa CPU 在跑解释器翻译出来的机器指令。性能上确实有代价——解释执行通常比原生代码慢 5 到 10 倍但对 ESP32 这个级别的应用场景传感器数据聚合、简单控制逻辑、小规模计算来说完全够用。1.3 折腾这么大一圈图什么既然解释器有性能损耗为什么还要在 ESP32 上引入 WASM这里有几个很现实的理由而且是 Lua 或者裸的 JS 引擎给不了的。最核心的是安全沙箱。 WASM 的内存模型是线性内存运行时对越界访问、非法间接调用都有严格检查。你从一个不可信来源下载了一个 wasm 模块它就算恶意代码也只能在自己那块线性内存里折腾没法直接把你固件里的全局变量、外设寄存器搞得一塌糊涂。相比之下如果你用 Lua 解释器Lua 的 C API 一旦注册得不够谨慎很容易爆出绕过沙箱的漏洞。其次是动态更新业务逻辑。 传统嵌入式开发里要改一个业务逻辑就得重新编译固件、走一遍 OTA 流程。引人了 WASM 之后你只需要通过网络或者蓝牙把一个新的.wasm文件传上去让运行时重新加载就能更新算法参数、控制策略固件本身完全不用动。我实际做的那个项目就是这样设备跑在客户现场改了 PID 参数和报警阈值远程推一个新 wasm 过去就行不用等客户同意重启设备。第三是一次编译到处运行。 不管你用的是 ESP32 还是 ESP32-C3 还是以后换国产的 RISC-V 芯片只要运行时能编译过去同一个.wasm文件可以直接用。这对需要维护多硬件版本产品的团队来说省掉了一大笔重复开发成本。2. ESP32 上运行 WASM 的核心原理拆解2.1 线性内存虚拟地址空间往哪儿放WASM 规范里有个非常重要的设计它不直接操作宿主内存而是暴露一个“线性内存”给模块使用。所有读写都发生在这块连续的内存区域里通过运行时提供的memory.grow等指令来扩大范围。对 ESP32 这种内存按 KB 计算的 MCU 来说这个设计既是福音也是约束。福音在于隔离性。ESP32 的 SRAM 一般只有 320KB 到 520KB不同型号差异很大你要是在一个不安全的模块里让它随便操作地址系统分分钟崩掉。线性内存的边界检查机制保证模块代码永远访问不了运行时之外的内存。约束在于性能。WASM 的内存访问在解释器里最终要换算成宿主内存的真实地址。比如模块里写了一条i32.load指令解释器会检查偏移量 基地址是否落在线性内存范围内然后才能算出真正的 C 指针。每一条内存读写指令都多了一次边界检查和地址换算密集计算场景下开销累积起来相当可观。实际部署时我通常给每个 WASM 实例分配 32KB 到 64KB 的线性内存。太小了模块内容易跑飞太大了ESP32 剩余内存扛不住多个实例并发。具体值取决于你的模块需要用多少堆栈和全局数据我一般在代码里显式声明__heap_base和__data_end的位置再结合链接脚本确认总占用。2.2 解释执行还是 AOT 编译这是个选择题WASM 运行时在 MCU 上主要有两条技术路线。一条是纯解释执行interpretation代表作是wasm3还有 WAMRWebAssembly Micro Runtime的 interpreter 模式。另一条是 AOT 编译Ahead-Of-Time compilation把 wasm 字节码在部署前一次性转换成目标平台的机器码运行时直接加载机器码来执行。先解释解释模式。它最大的优势是内存占用极小。wasm3 在 ESP32 上跑起来整个运行时加实例内存大概只要几十 KB flash 和十几 KB RAM这对只有 320KB SRAM 的芯片实在太宝贵了。但解释器的性能瓶颈我也吃过亏纯计算密集型的 wasm 模块跑出来的分数只有同等逻辑 C 原生代码的 1/8 左右。你如果做个 FFT、做个矩阵乘法性能落差感会很明显。AOT 模式呢WAMR 支持在编译时把 wasm 转成 Xtensa 机器码得到的是带.aot后缀的文件。运行性能可以达到原生 C 的 70%~90%但代价是 flash 占用增大、部署流程多了一步编译目标从 wasm 变成 aot 中间格式。JIT 模式在 PC 上的浏览器里很常见但在 MCU 上基本不可行因为 JIT 需要在运行期分配可执行内存ESP32 的指令 cache 和内存保护机制对动态生成机器码的限制很多我一般不推荐在 MCU 上碰 JIT。我的建议如果你的 ESP32 项目以控制逻辑、通信协议处理为主解释模式完全够用如果有明确的密集计算热点优先把热点部分用__attribute__((export_name(xxx)))暴露成 host function 给 C 原生实现或者干脆用 WAMR AOT。2.3 WASI 与 host function模块怎么碰外设光有计算能力还不够一个实际的 werewolfWASMESP32 的组合必须能操作 GPIO、读传感器、发蓝牙数据。WASM 规范本身只有纯计算指令没有任何直接的 I/O 指令。于是就有了两个机制WASIWebAssembly System Interface面向通用操作系统和更通用的 host function宿主函数。WASI 在 PC 上提供了文件读写、网络 socket 等系统调用接口但在 ESP32 上这套接口几乎全被阉割了——没有文件系统没有标准输出也没有 POSIX 线程。所以我在 ESP32 上做的运行时实现重点是用 host function 把外设能力注入给 wasm 模块。具体做法是在 C 侧实现一个esp32_gpio_write(uint32_t pin, uint32_t level)函数注册给运行时wasm 模块里声明extern void esp32_gpio_write(i32, i32)的导入项运行时加载模块时就会把 C 函数的指针交给模块的 import table。模块内部“看起来”是在调用一个外部函数实际执行时直接跳进了 C 函数。这中间有栈切换的细节但整体链路不复杂。要注意的是功能函数越少模块越容易移植把 GPIO 操作、I2C 读写都封装成清晰的最小 API比一股脑把 HAL 全暴露给 wasm 模块好得多。3. 实操把一个小 WASM 应用跑在 ESP32 上这一节直接给一份能复现的流程。我用的是 WAMR ESP-IDF 组合因为 WAMR 对 MCU 的裁剪性做得最好官方就有针对 ESP-IDF 的组件支持。Arduino 用户也可以参考思路完全一致只是编译环境换成 Arduino 的库管理方式。3.1 准备工具链把 C 函数编译成 WASM首先得把模块代码编译成 wasm。我用的工具链是wasi-sdk它基于 Clang目标平台是wasm32-wasi。安装过程很简单下载 release 包解压即可然后把bin目录加入 PATH。你也可以用 Emscriptenemcc但 emcc 默认会给模块附加 JS 胶水代码对 MCU 场景不太干净我建议优先 wasi-sdk。接下来写一个最简单的模块包含一个加法和一个数组求和的导出函数// module.c int add(int a, int b) { return a b; } int sum_array(int *arr, int len) { int sum 0; for (int i 0; i len; i) { sum arr[i]; } return sum; }用 wasi-sdk 编译/opt/wasi-sdk/bin/clang \ --targetwasm32-wasi \ -O3 \ -z stack-size4096 \ -Wl,--no-entry \ -Wl,--exportadd \ -Wl,--exportsum_array \ -Wl,--initial-memory32768 \ -Wl,--max-memory32768 \ -o module.wasm module.c几个参数解释一下。--no-entry表示这个模块不是可执行文件而是库--export指定导出哪些函数--initial-memory指定线性内存初始大小这里设成 32KB--max-memory锁定内存上限防止模块运行期间频繁 grow。编译完可以用wasm-objdump或wasm2wat检查导出段确认只有add和sum_array被导出。3.2 编写 ESP32 侧的加载与调用代码有了.wasm文件下一步是把运行时集成到 ESP-IDF 工程里。WAMR 有官方组件直接在idf.py add-dependency(wasm-micro-runtime)就能拉进来然后在menuconfig里打开 interpreter 和 libc-builtin 选项。构建完成后把module.wasm放进main目录用spiffs或者直接embed方式烧进 flash。我这里用最简单的 embed 方式直接把 wasm 二进制编译进固件。#include wasm_export.h #include esp_system.h // 把 module.wasm 以字节数组形式嵌入固件 extern const uint8_t module_wasm_start[] asm(_binary_module_wasm_start); extern const uint8_t module_wasm_end[] asm(_binary_module_wasm_end); void app_main(void) { // 1. 初始化运行时 RuntimeInitArgs init_args {0}; init_args.mem_alloc_type ALLOC_MEM_TYPE_POOL; init_args.mem_alloc_option.pool.heap_size 128 * 1024; wasm_runtime_full_init(init_args); // 2. 加载模块 uint32_t wasm_size (uint32_t)(module_wasm_end - module_wasm_start); wasm_module_t module wasm_runtime_load( (uint8_t *)module_wasm_start, wasm_size, NULL, 0); // 3. 实例化 wasm_module_inst_t inst wasm_runtime_instantiate( module, 8 * 1024, 0, NULL); // 4. 获取导出函数并调用 wasm_function_inst_t add_func wasm_runtime_lookup_function(inst, add, NULL); uint32_t args[2] {20, 22}; uint32_t results[1] {0}; wasm_runtime_call_wasm(inst, add_func, args, results); ESP_LOGI(WASM, 20 22 %u, results[0]); // 5. 清理实际产品中视生命周期决定 wasm_runtime_deinstantiate(inst); wasm_runtime_unload(module); }这段代码看着简单但有两个细节很容易踩坑。第一个是参数格式WASM 函数的参数在wasm_runtime_call_wasm里统一用uint32_t数组传如果你要传浮点数需要做位级转换比如把float先memcpy成uint32_t。第二个是线性内存访问如果模块要读写数组ESP32 侧拿到i32指针它实际是 wasm 线性内存里的偏移不是 C 指针。你必须通过wasm_runtime_addr_app_to_native(inst, offset)把它转成宿主的有效地址才能操作数组数据。3.3 注册 host function让模块拿到外设能力刚才的add和sum_array都是纯计算不需要碰任何外设。实际项目里肯定要读写传感器。这里演示一下怎么注册一个 host function让 wasm 模块通过 GPIO 控制 LED。先定义 WASM 侧的导入声明// module.c extern void led_ctrl(int pin, int level); void blink(int times) { for (int i 0; i times; i) { led_ctrl(2, 1); led_ctrl(2, 0); } }编译时记得加-Wl,--import-undefined或者直接显式声明导入表把led_ctrl标记为导入符号。ESP32 侧实现static int32_t host_led_ctrl(wasm_exec_env_t exec_env, uint32_t pin, uint32_t level) { gpio_set_level(pin, level); return 0; } // 注册表 static NativeSymbol native_symbols[] { { led_ctrl, host_led_ctrl, (ii)i, NULL } }; // 在 wasm_runtime_load 之前 wasm_runtime_register_natives(env, native_symbols, sizeof(native_symbols) / sizeof(NativeSymbol));这里签名(ii)i表示两个 int 参数、一个 int 返回值。WAMR 通过这个签名做参数类型校验写错的话调用时会直接报错。我在一个早期版本里把(ii)i写成了(ii)v结果每次调用返回的栈都错乱排查了半天。3.4 性能实测解释器到底能跑多快我在 ESP32 上跑了add连续调用 100 万次的耗时测试解释模式大约是 3.2 秒约合每秒 31 万次调用。原生 C 直接调同一个函数同一台机器上 100 万次只用了 0.3 秒左右。十倍差距很直观。但把这 100 万次调用分摊到真实业务里——比如每 100ms 调用一次 wasm 里的 PID 计算每次计算几百个浮点指令——产生的开销在 CPU 占用率上只占不到 0.5%。对大多数 IoT 控制任务来说这个性能完全不是瓶颈。真正要警惕的是频繁的 host function 调用因为每次跨越 wasm/host 边界都有栈切换与参数编码的开销。我做过一个测试wasm 内部空循环 1000 次耗时 2ms但在 wasm 里循环 1000 次调用 host function总耗时飙到 180ms。如果你发现模块性能不对劲先数一数它在循环里调了几次 host API。4. 我踩过的坑性能、内存与兼容性问题4.1 内存不够用先从 footprint 算起ESP32 的 SRAM 是真的小320KB经典款到 512KB部分新料WAMR 启动时如果按 128KB 来申请堆系统几乎没剩多少给 Wi-Fi 协议栈了。我建议在 menuconfig 里把 WAMR 的 pool 大小调成 64KB 或更小同时用wasm_runtime_full_init里的heap_size控制运行时最大可用内存。实现功能之前先做预算每个 WASM 实例的线性内存典型 32KB运行时元数据模块结构体、函数索引表、全局数据等约 8KB解释器的运行栈8KB 左右宿主 C 函数调用栈通常复用系统栈不额外加四个实例并发比如你要跑 4 个不同策略模块加起来大约是4 × (32 8 8) 192KB对 ESP32 已经很紧。我实际项目就砍到 2 个实例释放出来的内存交给 Wi-Fi 和 TLS 缓冲区用。还有一个容易忽略的内存坑模块里的全局变量。 WASM 规范允许模块声明全局数据区和静态内存这些数据在实例化时会复制到线性内存中。如果你编译出来的.wasm文件才 2KB但全局变量占了 100KB运行时照样会撑爆 32KB 线性内存。我有一次加载一个含静态缓冲区8KB的模块--initial-memory忘了调大结果一运行就把内存 grow 请求打到上限直接加载失败。这个错在 PC 上根本看不出来因为内存管够但在 MCU 上非常致命。4.2 AOT 与解释模式的取舍别盲目追性能我见过不少爱好者一上手就直奔 WAMR AOT觉得解释器太慢。但 AOT 在 ESP32 上有一个很大的隐性成本编译好的.aot文件是目标平台相关的只要换芯片型号就得重编而且 AOT 代码体积通常比解释模式大 30%~50%占 flash 空间。如果你只是做控制逻辑、状态机、规则引擎这类 IO 密集任务解释模式完全够用。真正的性能瓶颈场景是加密运算、图像像素处理、音频特征提取这类密集计算。遇到这种情况我的处理方式是两种方案并行96% 的逻辑走解释模式几个热点函数比如一个快速傅里叶变换的蝶形单元用 C 裸写导出成 host function 给 wasm 调用。这样既保留了 WASM 的动态更新、安全隔离价值又把性能损失缩小到了可忽略范围。这种混合架构在实践中比纯 AOT 更灵活部署的时候也不用跟具体硬件型号绑死在编译产物上。4.3 三个最常见的兼容性“玄学”第一个是ABI 不匹配。 wasi-sdk 编译时如果没指定-mexec-modelreactor默认执行的模型可能带着_start入口直接被运行时当成 main 去解析结果模块加载失败。用库模式的模块一定要显式声明--no-entry。第二个是函数签名不一致。 ESP32 侧注册 NativeSymbol 时写了错误的签名串比如把返回值写错类型或者参数个数对不上调用时轻则返回垃圾值重则把 wasm 虚拟栈搞乱。排查方法是用wasm-runtime自带的wasm_runtime_get_function_name和导出表信息去对比签名。这个坑最隐蔽因为它往往只在特定参数组合下才崩溃。第三个是没有初始化浮点环境。 如果你在 wasm 模块里用了浮点运算但 WAMR 的 global 数据段里有浮点常量需要初始化有一类链接选项会导致浮点数被错误解释为整型。我当时在模块里算了一个0.5f * 1000PC 上完美ESP32 上输出0。最后发现是编译时-O3把常量折叠成了 64 位整数而运行时读取 32 位浮点的路径不同。解决方案是把浮点常量改成运行时计算或关闭特定优化选项。我做了一张速查表贴在这里方便你排查问题现象可能原因解决办法模块加载返回 NULL编译目标用了wasm32-unknown-unknown改用wasm32-wasi并加--no-entry调用函数返回垃圾值NativeSymbol 签名串写错检查(ii)i这种签名格式运行一段时间卡死线性内存越界 边界检查不严格增大--initial-memory或使用--max-memory限制浮点结果全变 0优化选项把常量折叠成整型函数内手工初始化浮点常量启动内存爆炸heap_size太大调小到 64KB逐步压力测试4.4 排查工具与现场记录ESP32 上调试 WASM 跟调试普通 C 固件不太一样很多问题发生在解释器内部你没法直接打断点进去看虚拟栈的状态。我常用的三板斧第一开 WAMR 的日志。 在 menuconfig 里打开相关调试输出加载失败时运行时会打印错误码。错误码虽然不是特别人性化但比裸返回 NULL 强多了。第二用 WASI 的proc_exit模拟断言。 我在模块代码里写了一些检查逻辑条件不满足就调用__wasi_proc_exit(1)宿主侧能捕获到模块主动退出码精确定位是哪个分支出了问题。第三在 host function 里回传状态。 每次调用 host function 时把模块内一个调试计数器写入日志环形缓冲区。真实设备上没法接调试器这套日志方案帮我快速定位了 80% 以上的问题。5. 场景延展从“能跑”到“跑得好”5.1 与蓝牙/以太网联动WASM 模块的动态部署跑通了基本调用自然会想动真格怎么把 WASM 模块通过蓝牙或者以太网远程传上去。我自己的做法是做一个简单的二进制分发协议宿主设备通过 BLE notify 或 TCP socket 接收.wasm文件的分片完整收完后校验 SHA256存进 SPIFFS然后重新加载模块。整个流程跟 OTA 固件更新很像但数据量小一个数量级——固件动不动 1MB而一个 wasm 业务模块压缩后通常只有几 KB 到几十 KB。这个架构还有个额外优势安全审计更容易。 固件 OTA 你只能整包替换出问题只能回滚到旧版本WASM 模块更新则可以做到按函数粒度追踪变化甚至可以在 host 侧做权限控制——比如只允许模块调用某几个 host function禁止访问 flash 分区表。我今年给设备加的“算法包热更新”功能就是基于这个思路做的。从工程化角度讲建议提前制定模块版本管理策略。我踩过没做版本兼容的亏老设备上跑的库存模块逻辑变了新接口列表跟固件里注册的 host function 对不上加载直接失败。后来在模块头部加了自定义 name section记录版本号和新旧接口声明host 端解析后自动决定是否拒绝加载。这个经验对任何要在设备上做动态模块部署的团队都适用。5.2 性能优化路线图从解释器到 AOT 再到快照如果你确定性能不够按照我实测的经验优化顺序应该这么排第一步先修解释器配置。 检查是否开了WAMR_INTERP_FAST_INTERP之类的快速解释宏。WAMR 有两种解释器快解释器通过预解码块把 block 之间的操作缓存起来能提升 30%~50% 的性能。第二步考虑 AOT 编译。 把需要棒性能的函数所在编译单元单独拎出来用wamrc编译成.aot跟解释模式模块在一个系统里共存。WAMR 运行时的模块加载过程可以同时接受 wasm 和 aot 格式你只需要按部署场景选择。这也是我目前主力方案。第三步上 snapshot。 WAMR 提供从实例生成快照的能力把初始化后的模块内存状态、解释器执行状态整体导出成二进制。加载速度可以快几十倍特别适合 ESP32 这种 flash 读取慢但重载频繁的场景。不过快照跟具体运行时版本绑定升级 WAMR 库的时候要重新生成。5.3 把 WASM 用出新意多租户与策略隔离最后聊一个我最近在折腾的方向当设备上同时需要跑多个“租户”的策略程序——比如一个云服务商算法包、一个用户自定义自动化脚本——它们的执行环境互相隔离不会因为某个模块崩溃而影响主控流程。WASM 的多实例能力天然适合这种场景每个实例有独立线性内存互相不能直接访问共享数据必须全部走 host function 显式调用。代价是内存压力所以我做了个轻量调度按优先级动态挂起/恢复实例空闲时把整个实例 swap 到 flash。实验下来4 个模块轮流跑 100ms 周期任务基本不影响主控循环。如果你想玩这套优先搞定实例级的生命周期管理再考虑 swap这个方向值得花时间。6. 写在最后的一点实际体会从最早在 PC 上玩 wasm到把它搬上 ESP32整个过程最深的感受是“CPU 不认识”从来都不是问题“没有合适的翻译官”才是。嵌入式系统里的任何高级语言运行时本质上都是在干同一件事——把抽象的执行模型映射到物理硬件的指令集上。WASM 好就好在它把边界划得很清楚计算归计算I/O 归 host。这让模块的安全性、可移植性、热更新能力同时上了台阶。真要说有什么一定要提醒的那就是别一上来就把所有外设 API 都暴露给 WASM 模块。先只给最小的功能集跑通了再逐步放开否则排查问题的时候你会被一个个玄学符号串整到怀疑人生。另外把模块编译、签名、加载的完整流程提前固化到脚本里别每次手动敲命令你调试场景后面会需要反复确认“到底是模块问题还是运行时问题”很多次。我那个远程更新策略的项目已经在客户现场稳定跑了两个多月每次推送新的 wasm 包设备一分钟内就能热切换完新逻辑老固件完全没动过。这套玩法如果只是在 PC 上跑可能也就图个新鲜但放到 MCU 上实实在在解决了我过去最头疼的动态更新与安全隔离问题。你也完全可以动手试试从今天这个 2022 的 add 函数开始。