"你把业务逻辑用JavaScript写到文件里,设备端跑一个6MB的运行时把它解释执行,改业务逻辑就只改文件、不下固件。"——这就是我给嵌入式设备写的一个代号叫xuan的JavaScript运行时。
先说清楚这个6MB是什么。它不是一台服务器上的Node.js,也不是手机App里的WebView,而是一个专门给嵌入式环境准备的脚本引擎:完整二进制体积约6MB,包含解释器、内存管理、内置模块和调试钩子,目标板是带外扩Flash的嵌入式设备,少一点也能通过裁剪宏再压掉一半。它解决的问题很实际:物联网网关、工业控制器、边缘采集设备,往往上线之后还要调参数、改上报逻辑、加协议解析,用C语言写死了就只能一次次整包升级固件,改一个阈值要等半天。如果设备里有一个能跑脚本的小引擎,改配置直接下发一个新JS文件,现场人员用串口/U盘/SD卡拷贝就能完成更新,开发和运维成本会低得多。
这篇文章会围绕“6MB怎么拆出来”这条主线展开,讲清楚xuan的架构取舍、核心实现、移植流程和踩过的坑。适合关注嵌入式动态脚本方案、在谈Lua/JS嵌入式方案选型、或者准备在资源受限设备上做脚本引擎的开发者。我会把关键步骤和参数直接贴出来,不搞虚的。
1. 为什么嵌入式需要一套JavaScript运行时
1.1 动态化需求不是伪需求
嵌入式开发者听到“上脚本”的第一反应通常是:C语言都够用了,搞这么重的东西干什么。真做了几个交付项目就会发现,动态化需求往往来自产品定义和现场运维。
最典型的例子是协议解析。设备对接的上位机系统、传感器型号、云端平台都不是固定的,可能今天用A家的传感器,明天换B家的,每家数据格式有细微差异。用C语言为每种情况各写一套解析代码,代码分支会多到难以维护。另一种场景是规则逻辑,工业产线上经常要求“当温度连续3次超过阈值,并且处于非维护状态,才触发报警”,现场工程师要能自己调整这段逻辑。如果是编译进固件的C代码,调整规则就要重新编译、现场烧录、回归测试,一个变更流程下来至少几天。而用脚本方案,规则变成一个可独立下发的脚本文件,测试流程也变成了看日志确认,效率完全不在一个层级。
xuan最初就是为了这类需求设计的:设备端提供GPIO、串口、文件系统、网络请求等基础能力,业务方把这些能力用JavaScript编排起来。产品经理改需求、现场改参数,都不必再动固件。
1.2 现成方案不好用的三个原因
市面上不是没有嵌入式JavaScript引擎,真上手之前我先调研了一圈,然后打消了直接用它们的念头。
首先,通用型引擎是按照运行完整Web应用的规格设计的,涉及大量规范细节(类型、原型链、正则、日期、错误堆栈等),代码体积和运行时内存都偏高。就算把包裁剪得再小,面对只有几百KB Flash、几十KB RAM的单片机还是力不从心。
其次,很多高性能引擎依赖JIT编译、多线程GC或操作系统的系统调用,这类依赖在RTOS或裸机环境里很难满足,移植成本比写一个特定场景引擎还要高。嵌入式设备往往没有MMU,也没有独立编译环境,跑JIT的意义不大。
最后是功能匹配问题。嵌入式业务不要求脚本引擎100%兼容浏览器标准,而要求稳定、可裁剪、内存可控。与其去裁剪一个复杂引擎,不如按需求做一个ES5.1子集加少量ES6特性、体积开销都可控的专用运行时。这也是xuan和electron化思路本质不同的地方:xuan不是“把完整JS塞进去”,而是“把够用的JS做出来”。
1.3 为什么定“6MB”这个目标
定体量指标需要先看目标硬件。xuan瞄准的是两大典型设备:一类是带Linux系统的小型主机(CPU频率500MHz以下,内存几十MB),另一类是带外部Flash的高性能MCU(内部Flash不足、外扩Nor Flash按MB计)。这两类设备的一个共同点是:有几MB连续的非易失存储空间,但离桌面级的运行环境还差得很远。
所以我把目标拆成了硬约束:引擎核心+运行时初始堆+默认内置库的二进制总大小不超过6MB。算一个简单的预算账:
| 组成部分 | 预计体积 | 说明 |
|---|---|---|
| 词法分析、语法分析、字节码编译器 | 1.1MB | 解析器代码量最大,也是裁剪重点 |
| 字节码虚拟机 | 0.9MB | 指令分派、操作数栈、内建函数调用 |
| 运行时核心(类型、对象、作用域) | 1.2MB | 对象表示、属性访问、函数调用 |
| 内存管理GC | 0.6MB | 标记-清除、内存池 |
| 内置标准模块(console/定时器/JSON/字符串/数组等) | 1.4MB | 按需裁剪,很多目录对应具体构造器 |
| 调试钩子与基础框架 | 0.8MB | 错误输出、堆栈回溯、启动配置 |
| 合计 | 约6MB | 实际单个平台Build后会在5.6~6.4MB之间波动 |
这里说的体积不是单纯编译出的.elf大小,而是包括运行时初始化所需的数据结构和默认堆的空间。嵌入式开发里经常有人拿“bin只有200KB”说事,而实际跑起来内存占用却是3MB,那是偷换概念。xuan的体积口径是“二进制文件大小+启动后固定占用的内存空间”,用户拿到手能直接判断目标板能不能跑。
2. 6MB是怎么拆出来的
2.1 架构选型:翻译器而不是JIT
方案设计时我首先定了一个基调:不做JIT,用字节码虚拟机。
JIT的诱惑确实存在——热循环可以直接变成机器码,理论上解释器性能能提一个数量级。但嵌入式场景有几个现实阻力:一是没有稳定的jit编译时间预算,设备可能随时断电,不能容忍启动后花几百毫秒做运行时编译;二是JIT代码缓存的大小很难控制在几MB以内,性能调优就会拉高体积;三是JIT无法在禁止可写执行(W^X)的裸机环境里工作,许多嵌入式安全配置根本不允许动态生成可执行代码。
所以xuan采用“AST + 字节码”模式:源代码先经过词法分析得到token流,语法分析生成AST,编译器遍历AST生成字节码,交给解释器执行。AST在生成字节码后立刻释放,运行时只保留字节码和对应的常量池。这么做有一个额外好处:字节码可以直接预编译成二进制文件,配合OTA下发,设备端不用再保存源码,省掉一大块Flash占用。
2.2 体积从三个方向往下压
第一个方向是“子集化”。ES规范里体积和运行时的“大头”都在闭包、Proxy、Symbol等复杂特性上,嵌入式业务基本不碰。xuan默认支持ES5.1子集,加入了const/let、箭头函数、模板字符串、Promise基础实现等少数ES6特性。去掉Proxy、Reflect、迭代器高级协议,能省掉至少800KB的规范代码和运行时支持逻辑。
第二个方向是按模块裁剪。内置对象采用“core + opt”两级设计:core是Object/Function/Array/String这样的基础对象,必须常驻;opt是RegExp、Date、JSON等需要时通过编译宏打开。因为JS标准库的内部实现牵扯到很多正则表达式和日期算法,代码量非常大,全部编进去对多数设备不划算。
第三个方向是“只链接被用到的部分”。xuan的构建继承了嵌入式开发的链接特性:每个内置对象、每个内置函数都是独立编译单元。你在脚本里没有用到Date,链接器自然会把Date那部分代码从最终二进制里剔除。这要求库代码不能有太多循环依赖,开发时必须格外注意特性之间的耦合。
2.3 裁剪开关和它们的效果
这里把裁剪相关的核心配置宏列出来,这套东西直接决定了你拿到xuan后怎么把它适配到目标板。
| 宏 | 作用 | 关闭后预估体积变化 |
|---|---|---|
XUAN_FULL_ES6 | 开启完整ES6特性,否则按子集模式 | 开启大约增加1.2MB |
XUAN_REGEXP | 开启正则表达式支持 | 开启约增加600KB |
XUAN_DATETIME | 开启Date对象与时间解析 | 开启约增加400KB |
XUAN_FLOAT_OP | 开启完整double运算 | MCU上可关闭,用int+定点代替 |
XUAN_DEBUG_ASSERT | 开启内部断言检查 | 关闭约减小200KB |
XUAN_PREBUILT_BC | 允许预编译字节码加载 | 配合OTA可减源码存储 |
XUAN_MEM_POOL | 使用内存池分配 | 体积略增加,但强度内存碎片 |
XUAN_TRACE_JIT | 性能资源跟踪 | 实际项目中建议开 |
我在一个2048KB Flash的32位MCU上做裸机移植时,把XUAN_REGEXP、XUAN_DATETIME、XUAN_FLOAT_OP全关掉,再配合链接器排除无用内置模块,整个运行时最终压进约1.1MB。闭编译宏的代价是业务侧不能用正则和标准日期格式化,但在点灯、采集、上报这种场景完全够用。
2.4 内存布局与启动流程
适配嵌入式设备时,最忌讳内存分配完全依赖平台的堆管理器。裸机上堆管理行为千差万别,所以xuan默认提供一个“内存池”后端。启动流程分成三步:先为运行时建立固定大小的静态区,包括堆区、操作数栈区和字符串驻留区;然后把平台的存储/外设驱动注册成xuan内建对象;最后把脚本文件读入内存,交给编译器生成字节码,进入事件循环。
核心内存布局可以直接用这样一张表描述:
| 区域 | 用途 | 默认大小(32位MCU) |
|---|---|---|
heap_size | 对象、数组、字符串主体 | 128KB |
stack_size | JS函数调用栈与操作数栈 | 8KB |
string_pool_size | 字符串驻留表 | 16KB |
global_table_size | 全局对象、原型链查找表 | 4KB |
module_static_area | 内置模块静态数据 | 与构建配置相关 |
这些值全部可以在xuan_config.h里修改。MCU上如果堆只有64KB,把heap_size设成64KB也能跑,只是对象多时GC会更频繁。带MMU的Linux环境里则可以直接用虚拟内存做堆,默认就不走内存池了。
3. 核心实现细节:把JS语义塞进小内存
3.1 Value表示:在32位机器上怎么保存double
JavaScript的Number是IEEE754双精度,但嵌入式存储带宽寸土寸金,不能每个变量都白占8字节。xuan在64位平台采用NaN-Boxing:利用IEEE754 NaN的有符号载荷区域来存储指针和标签,常规浮点数直接放64位模式里;在32位MCU上改用“31位整数 + 标记指针”的方案——整数直接存在低31位,数据对象、函数对象、字符串对象以标记指针表示,浮点数则统一封装成一个Float对象。代价是浮点计算性能稍低,但内存占用能降到每个Value 4字节。
这个取舍让vector和对象属性的存储密度非常可观。数组[1, 2, 3]在通用引擎里可能要几十字节甚至上百字节,在xuan里只要12字节存放三个整数标记位,加上少量数组头信息,总开销不到40字节。对只有128KB堆的设备来说,这就意味着能承载的活跃对象数量多一个数量级。
3.2 字符串驻留和属性哈希
字符串是JavaScript动态语言里开销最大、也最频繁出现的类型。xuan在词法阶段就把所有标识符、字符串字面量统一交给全局字符串表,内部以哈希后的索引互相比较。这样做有两个好处:一是做属性查找时不需要逐字节比较字符串,直接比较哈希值;二是脚本里重复出现的同名字符串在内存里只有一份,避免几百字节的重复浪费。
对象属性则用开放寻址哈希表存。与链表法相比,开放寻址在缓存局部性上更好,属性数量在几十个到几百个这个区间时,查找效率也非常稳定。属性表的扩容阈值设置成70%,超过这个值就重新分配更大的表,避免哈希冲突变得严重。我实测下来,一万次属性访问的延迟比最初用单向链表实现时降低了接近一半。
3.3 GC策略:标记-清除,但阈值可控
嵌入式下不能因为“怕GC卡顿”就不做自动内存管理。手动释放会让业务方直接把脚本当成C语言写,必然导致内存泄漏。xuan采用Stop-the-World的标记-清除GC,但增加两个关键设计:按分配触发(每分配满N字节就尝试一次GC)和“可等待回收”模式。GC时提供回调钩子,业务可以关闭中断、暂停采集,把stw的影响控制在毫秒级。
为什么不加分代GC?分代GC可以做得很高效,但要维持“新生代/老生代”两套内存空间和写屏障,代码复杂度会显著增加,编译进二进制的体积也要高出几百KB。xuan大部分对象生命周期很短,频繁分配小对象后立刻丢弃,在这种情况下,简单的一次全堆扫描并不是性能瓶颈。真正要避免的是无限触碰大对象和长期保持的引用链。
3.4 异常处理:setjmp/longjmp
脚本抛异常不能中断整个设备进程。xuan的异常用C标准的setjmp/longjmp实现:每次进入JS函数前设置一个跳转点,异常抛出时沿调用栈逐层搜索最近的handler。为了做到这一点,解释器维护一个handler链,每个handler节点记录栈深度、最近的对象引用,以及一个用于恢复执行上下文的指令指针。
这个方案最大的坑是“中间C函数不能直接跨过”。比如某个内建函数内部先分配了资源再调用JS回调,一旦回调抛出异常,用longjmp跳出去时资源就会泄漏。所以xuan在内建函数里统一用“异常安全包装器”:进入内建函数时先记录清理节点,异常跳过时自动执行清理回调。对业务开发者来说,JS里的try/catch行为跟常规环境基本一致。
4. 实操:把xuan移植到你的设备上
4.1 移植前需要准备的软件和板卡
我用到的环境包含:目标板(带外部Flash的32位MCU,带串口调试)、GCC交叉编译器、有用于烧录调试的OpenOCD/JLink工具。裸机场景下,xuan不依赖操作系统线程,只需要目标C编译器支持C99标准。
拿到xuan源码后,目录结构大致是:
xuan/ src/ core/ # lexer, parser, 字节码编译器 vm/ # 解释器主循环、指令集 runtime/ # 对象模型、属性表、GC builtin/ # 内建对象与模块 platform/ # 平台抽象层 include/ port/ linux_generic/ # 嵌入式Linux参考移植 freertos_mcu/ # FreeRTOS/MCU参考移植 tools/ bc_compile.c # 字节码预编译工具4.2 最小移植:五个步骤
第一步:把src/core、src/vm、src/runtime加入构建系统,链接成静态库。第二步:提供三个平台函数,内存分配、系统时间获取、标准输出重定向,在port目录里实现:
#include "xuan/xuan.h" void *xuan_malloc(size_t size) { return custom_pool_alloc(size); } void xuan_free(void *ptr) { custom_pool_free(ptr); } uint64_t xuan_now_ms(void) { return platform_get_tick_ms(); } void xuan_log_write(const char *msg, uint32_t len) { uart_write_bytes(msg, len); }第三步:创建运行时上下文,注册设备对象:
xuan_ctx_t *ctx = xuan_create_ctx(NULL); xuan_module_add_gpio(ctx); xuan_module_add_uart(ctx);第四步:加载脚本并执行事件循环:
const char *src = read_script_from_flash(); xuan_value_t result = xuan_eval(ctx, src, strlen(src), "main.js"); if (xuan_is_exception(result)) { const char *err = xuan_exception_string(ctx, result); xuan_log_write(err, strlen(err)); } // 进入事件循环,处理定时器与IO事件 xuan_run_loop(ctx);第五步:把xuan_config.h里的内存参数对上目标板实际约束,编译并烧录。
4.3 一个可以跑通的“点灯+上报”脚本
移植完成后先跑一个最简单的点灯脚本验证链路:
var count = 0; setInterval(function () { gpio_write(LED_PIN, count % 2); count++; if (count % 10 === 0) { var payload = JSON.stringify({ count: count, status: "ok" }); uart_send(0, payload, payload.length); } }, 500);这段脚本在C侧通过xuan_eval注册后,定时器回调会按500毫秒周期触发。需要注意的坑:setInterval的回调不是并行线程,而是事件循环里的一个定时任务。因此整块板子的业务逻辑必须是非阻塞的,脚本里不适合做同步阻塞式延时,否则定时器回调会全部串在一起。
4.4 事件循环要自己转
xuan不强制绑定某个RTOS或Linux主循环,它只提供一个统一消息分发器。裸机移植时,定时器用系统Tick驱动,而不是用独占线程:
void main_loop(void) { while (1) { uint64_t now = xuan_now_ms(); xuan_update_timers(ctx, now); xuan_process_io_events(ctx); xuan_collect_if_needed(ctx); idle_wait(); } }这个循环里最容易被忽略的是xuan_collect_if_needed。如果不定期让GC介入,堆会在高频脚本操作中迅速被撑满,最后得到一个“内存泄漏”的假象。GC阈值可以在配置宏里调整,默认是堆使用超过80%时触发,也可以改成每执行N条字节码指令就检查一次。
5. 常见问题与排查技巧实录
5.1 脚本递归深度太深导致栈溢出
这个问题在解释器里最常见。JS代码写了一个递归遍历,深度几百层,MCU的调用栈空间瞬间爆掉。xuan有两道防线:一是JS函数调用栈有上限,超过上限抛出RangeError;二是递归太深的C函数栈必须由用户自行控制。
排查时我推荐的做法:先用xuan_debug_set_trace(ctx, 1)打开调用回溯,看爆栈前停在哪一层;如果确实需要更深递归,把XUAN_STACK_SIZE调大,同时确认目标板主栈容量足够。还有一个很实用的技巧——把递归改成循环加显式栈,这通常能让支持深度提高十倍而不增加运行时成本。
5.2 内存碎片:小对象频繁分配导致堆空间“看着还有很多但分配失败”
内存池模式已经能在很大程度上缓解碎片,但它不是万能的。如果脚本里高频创建短期字符串然后又释放,池内存会碎片化。我踩过一个坑:一个上报逻辑每秒创建一串新的JSON字符串然后丢弃,跑了半天后堆里出现大量几十字节的不可用空洞。
解决方法有两个层面。脚本层面:把需要长期保留的字符串缓存成全局变量,避免反复创建;运行时层面:把内存池按大小类拆分,16字节、32字节、64字节、128字节的分配走各自独立的空闲链表,不再从大块里切来切去。xuan后来默认启用这个机制,碎片问题大幅减少。
5.3 字节码加载慢、OTA包过大
如果设备通过网络下发JS源码,一次下几KB到几十KB的源码还能接受,但脚本复杂以后可能到几百KB,传输和解析都有压力。xuan提供了预编译字节码导出功能,直接在开发机上用bc_compile工具把源码转成字节码文件,设备端不再需要做完整词法、语法分析,加载速度能提升一个数量级。
./bc_compile main.js -o main.bc -target mcu再用xuan_load_bc(ctx, data, size)加载。这样做的另一个好处是业务源码不再以明文形式出现在Flash里,虽然这不是安全屏障,但能挡住绝大多数现场小白把配置文件“改坏”的情况。
5.4 三个容易踩的误区
下面这三个问题,我见过不止一个团队栽进去。
第一个误区是“在中断回调里直接执行JS”。中断里做复杂操作会导致系统抖动,而且中断上下文没有完整的寄存器保存,异常表会把状态搞乱。正确做法是中断只设置事件标记,事件循环里再安排时间处理。
第二个误区是“以为eval是万能的”。子集化后的xuan对动态字符串代码直接解析的安全性已经做了限制,但不建议把用户输入直接拼到eval里。轻则报语法错,重则构造一个极深的嵌套表达式把解析器跑挂。
第三个误区是“只关注初始体积、不关注运行时峰值”。很多开发者看到二进制体积满足要求就上线,结果脚本一加载就多出几十KB堆。所以在定稿前必须要在最恶劣脚本下跑压测,用xuan_memory_usage(ctx)取回堆峰值并打印,我一般要求在日志里保留这个值。
6. 实测数据与后续扩展方向
6.1 性能数据
xuan不是高性能引擎,但在嵌入式场景里“够用”是没问题的。在一个120MHz的32位MCU上,纯整数累加循环大约每秒能执行几百万次加法,配合GPIO控制、定时器、串口输出这类操作完全感觉不到卡顿。解析典型业务脚本(约500行JS代码)只需要几十毫秒,预编译字节码加载则缩短到几毫秒。
内存方面,默认128KB堆运行一个包含10个对象、50个属性、20个定时器的业务脚本,GC压力不大,堆峰值稳定在80KB以下。如果把堆减到64KB,也能正常运行,只是GC触发更频繁。
6.2 体积分布实测
以“带GPIO/串口/JSON/定时器/基础标准库”的典型配置,在ARM 32位平台上的最终体积分布大致如下:
| 段 | 体积 |
|---|---|
| 只读代码段 | 4.1MB |
| 只读数据段 | 0.8MB |
| 初始化数据段 | 0.4MB |
| 静态预留内存 | 0.5MB |
| 合计 | 5.8MB |
这说明6MB预算并没有被某个模块“吃光”,大头还是解释器、编译器本身。如果去掉JSON和模板字符串支持,还能再省几百KB。
6.3 下一步可以扩展的东西
xuan目前的定位仍是“设备端业务脚本引擎”,后续可延伸的方向还有几个:
模块加载器:支持CommonJS子集的require语法,这样业务代码可以拆分成多个文件,便于团队协作和复用。外设对象生成器:根据目标芯片的寄存器MAP文件自动生成GPIO、PWM、ADC等对象的绑定代码,减少人工编写绑定层的工作量。OTA差分更新:配合预编译字节码,做差分包下发,把升级包体积压到KB级别。
这些方向中,模块加载器对工程化的提升最明显,也是我下一步投入的方向。
最后说一点个人体会。真正做完这整个运行时之后,我对“6MB”这个数字有了更真切的感知:它不是一个绝对最低体积,而是一个“在嵌入式设备上足够轻、又不牺牲可用功能”的平衡点。如果目标板只有几百KB Flash,我更建议你直接考虑Lua或更小的专用脚本方案,不要硬塞JS;但如果你想在嵌入式设备上给业务工程师保留一个熟悉的JavaScript环境,这个方向就非常值得投入。实际测试中最值得记住的一句话是:脚本引擎的体积一定要连同运行时的内存峰值一起看,只看bin文件大小会在现场吃大亏。