1. 为什么要在嵌入式设备上跑 JavaScript
第一次跟同行聊起这个项目,对方的第一反应基本都是:“你是不是闲得慌?”说实话,这个反应很正常。嵌入式设备上跑 JavaScript,听起来就像在拖拉机上装涡轮增压——不是不行,但你图什么?
图的就是开发效率。
做过嵌入式开发的人都知道,每次改一行逻辑,编译、烧录、重启、看串口输出,一套流程下来少说几分钟,复杂项目十几分钟都打不住。如果产品已经部署到现场,那调试成本更是成倍上升。而 JavaScript 的动态执行特性意味着你可以在设备上直接改脚本、立即生效,不用重新编译整个固件。这个诱惑力,做过现场调试的人都懂。
这个项目我给它取名叫xuan,目标很明确:做一个能在资源受限的嵌入式设备上运行的 JavaScript 运行时,体积控制在6MB 以内,能跑在只有几十兆内存的 Linux 嵌入式板子上,支持基本的 ES6 语法和常用内置对象,同时提供一套简洁的 C API 让宿主程序能方便地嵌入和调用。
先把这个项目的核心定位说清楚:
- 是什么:一个面向嵌入式 Linux 设备的轻量级 JavaScript 运行时,编译后体积约 6MB,内存占用可控制在 10MB 以内。
- 能做什么:在嵌入式设备上执行 JavaScript 脚本,实现业务逻辑的热更新、设备行为的动态配置、简单的边缘计算逻辑。
- 解决什么问题:嵌入式开发中逻辑修改需要重新编译烧录的痛点,以及传统脚本引擎体积过大、依赖过多的问题。
- 适合谁看:有 C 语言基础、做过嵌入式 Linux 开发、对脚本引擎或运行时实现感兴趣的开发者。完全没接触过嵌入式的纯前端同学也能看懂思路,但实操部分需要一定的系统编程基础。
我选择 JavaScript 而不是 Lua 或 Python,原因也很实际。Lua 确实更轻量,但生态和语法亲和力不如 JS;MicroPython 体积也不小,而且性能在低端芯片上不太理想。JS 的优势在于:语法普及率高、异步模型天然适合事件驱动的嵌入式场景、JSON 原生支持让设备与云端的数据交换非常自然。
注意:这个项目面向的是嵌入式 Linux 环境(比如 ARM Cortex-A 系列),不是裸机单片机。裸机上跑 JS 运行时是另一个量级的挑战,不在本文讨论范围内。
2. 整体架构设计与技术选型
2.1 为什么不用 V8 或 QuickJS
这是被问得最多的问题。V8 不用说了,编译出来几百兆,嵌入式设备根本放不下。QuickJS 其实已经很小了,编译后大约 2-3MB,但它的设计目标是桌面和服务器场景,在嵌入式环境中有几个不太适配的地方:
第一,QuickJS 的内存分配策略偏向通用场景,在内存紧张的设备上容易产生碎片。第二,它的 C API 虽然功能完整,但嵌入使用时需要较多胶水代码。第三,它的错误处理机制在嵌入式场景下不够灵活,比如栈溢出时的行为不太可控。
所以我决定自己写一个。当然,这不是从零实现完整的 ECMAScript 规范——那是几年工作量。我的策略是:实现一个 ES6 子集,覆盖嵌入式场景下 90% 的常用语法和内置对象,把体积和内存控制放在第一位。
2.2 核心架构分层
整个运行时的架构分为四层,从下往上依次是:
第一层:基础层。包含内存分配器、动态数组、哈希表、字符串池等基础数据结构。这一层完全用 C 实现,不依赖任何外部库。内存分配器我选择了一个简单的池式分配器 + 空闲链表的方案,而不是直接 malloc/free。原因很简单:嵌入式设备上频繁 malloc/free 会产生内存碎片,跑几天之后可能就分配不出连续内存了。池式分配器虽然会浪费一些内存,但胜在稳定可控。
第二层:解析层。包含词法分析器和语法分析器。词法分析器把源码拆成 token 流,语法分析器把 token 流构建成抽象语法树(AST)。这一层的关键设计是流式解析——不需要一次性把整个脚本加载到内存,而是边读边解析,这对于大脚本文件很重要。
第三层:执行层。包含字节码编译器和虚拟机。我没有选择直接遍历 AST 解释执行,而是先编译成字节码再执行。为什么?因为 AST 遍历的缓存局部性很差,每次执行都要在树结构上跳来跳去,性能损失明显。字节码是一段连续的指令数组,CPU 缓存命中率高得多。实测下来,字节码方案的执行速度大约是 AST 遍历的 3-5 倍。
第四层:宿主接口层。提供 C API,让嵌入式主程序能注册原生函数、读写 JS 变量、执行脚本片段。这一层的设计原则是最小暴露——只暴露必要的接口,减少宿主程序的集成复杂度。
2.3 体积控制的几个关键决策
6MB 这个目标不是随便定的。主流的嵌入式 Linux 板子,根文件系统分区通常有 64MB 到 256MB,留 6MB 给 JS 运行时是比较合理的比例。为了达到这个目标,我做了几个关键取舍:
| 决策项 | 选择 | 放弃的方案 | 原因 |
|---|---|---|---|
| 垃圾回收 | 引用计数 + 标记清除 | 分代 GC | 分代 GC 需要更多内存和更复杂的实现 |
| 数值类型 | 统一用 double | 整数/浮点分离 | 简化实现,减少代码体积 |
| 字符串编码 | UTF-8 | UTF-16 | UTF-8 对 ASCII 更友好,嵌入式场景英文为主 |
| 正则引擎 | 自研简化版 | 引入 PCRE | PCRE 体积太大,简化版够用 |
| 标准库 | 按需裁剪 | 完整 ES6 标准库 | 只保留嵌入式常用对象 |
这些取舍背后的逻辑是一致的:在功能完整性和资源占用之间找到平衡点,优先保证核心场景可用。
3. 核心模块实现细节
3.1 词法分析器的状态机设计
词法分析器是整个运行时的入口,它的性能直接影响启动速度。我采用的是**确定性有限状态机(DFA)**方案,而不是正则表达式匹配。原因很直接:正则引擎本身就需要额外的代码体积,而且每次匹配都有函数调用开销。DFA 用查表法实现,一个状态转移表加上一个循环就能搞定,速度极快。
具体实现上,我把 JavaScript 的 token 类型分为几大类:标识符、关键字、数字字面量、字符串字面量、运算符、分隔符、注释。状态机的核心是一个二维数组transition[state][char_class],其中char_class是把 ASCII 字符按类别归并后的索引。比如所有字母归为一类,所有数字归为一类,这样转移表的大小就控制在几百个条目以内。
这里有个细节值得说:关键字识别。很多实现方案是把标识符先读出来,然后查哈希表判断是不是关键字。我一开始也是这么做的,但实测发现哈希查找在热路径上开销不小。后来改成在状态机里直接识别关键字——当读入的字符序列匹配到某个关键字时,状态机直接进入终止状态并返回对应的 token 类型。这样省掉了一次哈希查找,启动速度提升了大约 15%。
实操心得:状态机的转移表不要手写,用脚本生成。我写了一个 Python 脚本,输入是状态定义和转移规则,输出是 C 数组。这样修改规则时只需要改脚本重新生成,不容易出错。
3.2 字节码指令集设计
字节码指令集的设计直接决定了虚拟机的执行效率和代码体积。我最终确定了47 条指令,分为以下几类:
- 栈操作:PUSH、POP、DUP、SWAP
- 算术运算:ADD、SUB、MUL、DIV、MOD、NEG
- 比较运算:EQ、NEQ、LT、GT、LTE、GTE
- 逻辑运算:AND、OR、NOT
- 变量操作:LOAD_LOCAL、STORE_LOCAL、LOAD_GLOBAL、STORE_GLOBAL
- 属性操作:GET_PROP、SET_PROP、DEL_PROP
- 函数调用:CALL、RETURN、CALL_NATIVE
- 控制流:JUMP、JUMP_IF_FALSE、JUMP_IF_TRUE
- 对象操作:NEW_OBJECT、NEW_ARRAY、NEW_FUNCTION
- 其他:TYPEOF、INSTANCEOF、THROW、TRY、END_TRY
47 条指令听起来不多,但覆盖了嵌入式场景下绝大多数常用操作。每条指令的长度是4 字节:1 字节操作码 + 3 字节操作数(或者 1 字节操作码 + 1 字节寄存器 + 2 字节立即数)。为什么用 4 字节而不是变长指令?因为定长指令的解码逻辑简单,不需要复杂的位操作,在低端 CPU 上反而更快。
这里有个设计上的取舍:寄存器式 vs 栈式。栈式虚拟机的指令更简单,编译器实现也更容易,但执行时栈操作频繁,内存访问次数多。寄存器式虚拟机指令更复杂,但执行效率更高。我最终选择了栈式,原因是嵌入式设备的 CPU 主频通常不高,栈式虚拟机的简单性带来的代码体积优势更重要,而且栈操作在 L1 缓存里命中率很高,实际性能差距没有想象中大。
3.3 内存管理与垃圾回收
内存管理是嵌入式运行时的命脉。我的方案是引用计数为主,标记清除为辅:
引用计数的好处是实时性好,对象不再被引用时立即释放,不会造成内存堆积。但它有个致命问题:循环引用。两个对象互相引用,引用计数永远不为零,内存就泄漏了。所以我在引用计数的基础上,增加了一个周期性的标记清除 GC,专门处理循环引用。
标记清除的触发条件是:自上次 GC 以来,新分配的对象数量超过阈值。这个阈值我设的是 1024。为什么不用时间触发?因为嵌入式设备的负载不均匀,用时间触发可能在业务高峰期触发 GC,影响响应速度。用分配数量触发更可控。
GC 的根集合包括:全局对象、当前执行栈上的所有局部变量、宿主程序持有的 JS 对象引用。标记阶段从根集合出发,遍历所有可达对象,打上标记。清除阶段遍历所有对象,没打标记的就是垃圾,释放掉。
注意:标记清除 GC 执行时会暂停 JS 执行(Stop-The-World)。为了减少暂停时间,我把 GC 分成了多个小步骤,每次只处理一部分对象,分多次完成。这样单次暂停时间可以控制在 1 毫秒以内。
3.4 宿主 C API 设计
宿主 API 的设计目标是让嵌入式主程序能用最少的代码完成集成。核心 API 只有十几个函数:
// 创建和销毁运行时 xuan_runtime_t* xuan_create(void); void xuan_destroy(xuan_runtime_t* rt); // 执行脚本 xuan_value_t xuan_eval(xuan_runtime_t* rt, const char* code, size_t len); // 注册原生函数 void xuan_register_func(xuan_runtime_t* rt, const char* name, xuan_native_func_t func, void* userdata); // 读写全局变量 xuan_value_t xuan_get_global(xuan_runtime_t* rt, const char* name); void xuan_set_global(xuan_runtime_t* rt, const char* name, xuan_value_t val); // 值操作 double xuan_to_number(xuan_value_t val); const char* xuan_to_string(xuan_value_t val); int xuan_to_boolean(xuan_value_t val);这套 API 的设计哲学是值语义。xuan_value_t是一个 8 字节的结构体,可以直接按值传递,不需要额外的堆分配。对于字符串和对象类型,内部用指针引用,但对外表现为值。这样宿主程序用起来很自然,不需要关心内存管理。
原生函数的注册也很直接。比如你要在 JS 里暴露一个控制 LED 的函数:
static xuan_value_t led_control(xuan_runtime_t* rt, xuan_value_t* args, int argc, void* userdata) { int pin = (int)xuan_to_number(args[0]); int state = (int)xuan_to_number(args[1]); // 调用硬件层控制 LED hal_gpio_write(pin, state); return xuan_make_number(0); } // 注册 xuan_register_func(rt, "setLED", led_control, NULL);然后在 JS 里就可以直接调用setLED(13, 1)来点亮 LED。这种集成方式非常直观,嵌入式工程师不需要学习复杂的绑定规则。
4. 从零到跑通的完整实操过程
4.1 交叉编译环境搭建
嵌入式开发的第一步永远是搭交叉编译工具链。我用的是一块 ARM Cortex-A7 的开发板,运行的是精简版 Linux 系统。工具链选择arm-linux-gnueabihf-gcc,这是最通用的 ARM 硬浮点工具链。
编译 xuan 的命令行大概是这样:
# 配置编译选项 export CROSS_COMPILE=arm-linux-gnueabihf- export CC=${CROSS_COMPILE}gcc export AR=${CROSS_COMPILE}ar # 编译 make clean make CFLAGS="-Os -ffunction-sections -fdata-sections" \ LDFLAGS="-Wl,--gc-sections" \ TARGET=arm-linux这里有几个关键编译选项值得解释:
-Os:优化体积而不是速度。嵌入式设备上 Flash 空间比 CPU 时间更宝贵。-ffunction-sections -fdata-sections:把每个函数和数据放到独立的段里。-Wl,--gc-sections:链接时删除没有被引用的段。这三个选项配合使用,可以显著减小最终二进制体积。
实测下来,不加这些选项编译出来是 8.2MB,加上之后降到了 5.8MB,效果非常明显。
4.2 裁剪标准库
完整的 ES6 标准库太大了,必须裁剪。我的裁剪策略是按使用频率分级:
必留对象:Object、Array、String、Number、Boolean、Math、JSON、Date、Error、RegExp(简化版)。
可选对象:Map、Set、Promise、Symbol、Proxy。这些通过编译宏控制,如果设备内存特别紧张可以关掉。
直接砍掉:TypedArray 系列、ArrayBuffer、SharedArrayBuffer、Atomics、Reflect、WeakMap、WeakSet。这些在嵌入式场景下几乎用不到,而且实现复杂度高。
裁剪之后,标准库部分的代码体积从大约 3MB 降到了 800KB 左右。
4.3 在设备上跑第一个脚本
编译完成后,把二进制文件传到设备上,写一个最简单的测试脚本:
// test.js var count = 0; for (var i = 0; i < 10000; i++) { count += i; } console.log("Sum: " + count); var obj = { name: "xuan", version: "1.0" }; console.log(JSON.stringify(obj));执行命令:
./xuan test.js输出:
Sum: 49995000 {"name":"xuan","version":"1.0"}第一次跑通的时候还是挺激动的。但紧接着就发现了问题:启动时间偏长,大约 200 毫秒。对于一个嵌入式运行时来说,这个启动时间不太理想。
4.4 启动优化实战
排查启动慢的原因,我用perf工具做了采样分析,发现时间主要花在两个地方:
第一,全局对象的初始化。标准库里每个内置对象在启动时都要创建,包括分配内存、注册方法、设置属性。这部分占了大约 120 毫秒。
第二,字符串池的构建。所有内置方法的名称字符串在启动时都要插入字符串池,这部分占了大约 50 毫秒。
优化方案也很直接:
对于全局对象初始化,我改成了惰性初始化。不是启动时把所有内置对象都创建好,而是等到第一次访问时才创建。比如Math对象,只有脚本里用到Math时才初始化。这样对于不使用某些内置对象的脚本,启动时间大幅缩短。
对于字符串池,我改成了预分配 + 静态初始化。把常用的内置方法名称字符串在编译时就确定好,运行时直接引用静态数据,不需要动态插入。这样省掉了启动时的字符串池构建时间。
优化之后,启动时间降到了45 毫秒,对于大多数嵌入式场景已经够用了。
实操心得:嵌入式环境下的性能优化,永远先测量再优化。不要凭直觉猜哪里慢,用工具采样,用数据说话。我一开始以为瓶颈在解析器,结果采样发现解析只占了不到 10% 的时间。
4.5 内存占用实测
内存占用是嵌入式场景的核心指标。我在设备上跑了几个不同复杂度的脚本,用/proc/self/status查看内存使用情况:
| 脚本复杂度 | 堆内存峰值 | 常驻内存 |
|---|---|---|
| 空脚本 | 1.2MB | 3.8MB |
| 基本运算 + 对象操作 | 2.5MB | 4.2MB |
| 递归 + 闭包 + JSON | 4.8MB | 5.6MB |
| 大量字符串拼接 | 8.3MB | 7.1MB |
常驻内存包括运行时本身的代码段、数据段和堆内存。可以看到,即使在比较复杂的场景下,总内存占用也能控制在 10MB 以内,符合设计目标。
5. 踩坑记录与问题排查
5.1 浮点数精度问题
JavaScript 的 Number 类型是双精度浮点数,这在嵌入式设备上有个坑:某些 ARM 芯片的浮点运算单元(FPU)对非规格化数(denormal)的处理很慢。当计算结果非常接近零但不等于零时,会产生非规格化数,FPU 处理这种数可能需要几百个时钟周期。
我遇到的具体问题是:一个计算传感器数据的脚本,在 PC 上跑很快,在设备上跑慢了十倍。排查后发现是浮点运算产生了非规格化数。
解决方案是在编译时开启Flush-to-Zero(FTZ)模式,把非规格化数直接当作零处理。对于嵌入式场景下的业务逻辑,这个精度损失完全可以接受。开启方式是在编译选项中加-mfpu=neon-vfpv4 -ffast-math,或者在运行时设置 FPU 控制寄存器。
5.2 栈溢出导致设备重启
JavaScript 支持递归,但嵌入式设备的栈空间通常很小(默认可能只有 8MB,实际可用更少)。一个深度递归的脚本很容易把栈撑爆,导致段错误甚至设备重启。
我的解决方案是在虚拟机层面做栈深度检查。每次函数调用时检查当前调用深度,超过阈值就抛出RangeError: Maximum call stack size exceeded,而不是让系统崩溃。阈值默认设为 1024 层,可以通过 API 调整。
这里有个细节:C 栈和 JS 调用栈是两回事。JS 函数调用不一定对应 C 函数递归(取决于实现方式)。我的实现里,JS 函数调用是在虚拟机的主循环里处理的,不增加 C 栈深度。所以只需要检查 JS 调用栈深度即可。
5.3 字符串拼接的性能陷阱
字符串拼接是嵌入式脚本里很常见的操作,比如拼接日志、构造 JSON 等。我一开始的实现是每次拼接都分配新内存,结果在大量拼接的场景下性能很差,而且内存碎片严重。
优化方案是引入rope 结构(也叫 cord)。简单说,字符串拼接时不立即分配新内存,而是创建一个树形结构,叶子节点是原始字符串片段。只有当需要读取最终字符串时才做一次性的展平操作。这样多次拼接的时间复杂度从 O(n²) 降到了 O(n)。
实测数据:拼接 10000 个短字符串,优化前耗时 2.3 秒,优化后耗时 0.15 秒,提升超过 15 倍。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 启动时报段错误 | 栈空间不足 | 用 gdb 查看崩溃位置 | 增大线程栈或减少启动时栈使用 |
| 脚本执行越来越慢 | 内存碎片或 GC 频繁 | 打印 GC 日志 | 调整 GC 阈值或优化内存分配 |
| 浮点计算结果异常 | 非规格化数或 FTZ 模式 | 对比 PC 和设备结果 | 开启 FTZ 或避免极端浮点运算 |
| 字符串操作卡顿 | 大量拼接导致频繁分配 | 用 perf 采样 | 启用 rope 结构 |
| 原生函数调用崩溃 | 参数类型不匹配 | 检查 JS 侧传参 | 在原生函数入口做类型检查 |
| 内存泄漏 | 循环引用或宿主未释放 | 定期打印对象数量 | 检查循环引用,确保宿主释放 |
避坑技巧:在嵌入式设备上调试 JS 运行时,串口输出是最可靠的调试手段。我习惯在关键路径上加日志开关,通过环境变量控制是否输出。这样平时不影响性能,出问题时可以快速打开。
6. 实际应用场景与扩展思路
6.1 设备行为动态配置
这是 xuan 最直接的应用场景。传统嵌入式设备的业务逻辑都编译在固件里,改一个参数都要重新烧录。有了 JS 运行时之后,可以把业务逻辑和固件分离:固件只负责硬件驱动和通信协议,业务逻辑用 JS 脚本实现。需要修改时,通过串口、网络或者 U 盘更新脚本文件即可,不用动固件。
我实际做过一个案例:一个数据采集设备,采集频率、上报周期、数据过滤规则都用 JS 脚本配置。现场部署后,客户想调整上报周期,直接改脚本文件重启服务就行,整个过程不到一分钟。
6.2 边缘计算逻辑
在物联网场景下,设备端做简单的数据预处理可以大幅减少上传数据量。比如温度传感器每秒采集一次,但只需要在温度超过阈值时上报。这种逻辑用 JS 写非常自然:
var lastReport = 0; var THRESHOLD = 30.0; function onSensorData(temp) { var now = Date.now(); if (temp > THRESHOLD && now - lastReport > 60000) { report({ temp: temp, time: now }); lastReport = now; } }这种逻辑如果写在固件里,每次调整阈值都要重新编译。用 JS 脚本就灵活多了。
6.3 后续扩展方向
这个项目还有很多可以扩展的地方。我目前正在考虑的几个方向:
异步支持。目前的实现是同步执行的,不支持 Promise 和 async/await。对于需要网络请求的场景,异步支持很有必要。实现思路是在虚拟机里增加事件循环,把异步操作注册为回调。
模块系统。目前只支持单文件脚本,不支持import/export。对于大型项目,模块化是刚需。实现方案可以是简单的文件加载 + 命名空间隔离。
调试协议。目前调试主要靠console.log,效率不高。可以增加一个简单的调试协议,支持断点、单步执行、变量查看。这对复杂脚本的调试会很有帮助。
性能分析器。内置一个采样分析器,能统计每个函数的执行时间和调用次数,帮助开发者定位性能瓶颈。
这些扩展不需要一次性全做,可以根据实际需求逐步添加。关键是保持核心运行时的简洁和稳定,扩展功能通过编译宏控制,不影响基础场景的体积和性能。
7. 一些个人体会
做这个项目最大的感受是:嵌入式开发和 Web 开发的思维方式差异很大。Web 开发习惯了大内存、快 CPU、无限存储,写代码时很少考虑资源限制。嵌入式开发则相反,每一 KB 内存、每一毫秒 CPU 时间都要精打细算。
这种约束反而带来了一些设计上的乐趣。比如为了省几百 KB 体积,我会花半天时间研究编译器的段回收机制;为了减少一次内存分配,我会重新设计数据结构。这些在 Web 开发中很少需要考虑的问题,在嵌入式场景下却是日常。
另外一点体会是:不要过早优化,但也不要忽视优化。我一开始花了很多时间优化解析器性能,后来发现解析只占启动时间的 10%,真正的瓶颈在全局对象初始化。如果一开始就用工具做 profiling,可以少走很多弯路。
最后分享一个实用技巧:在嵌入式设备上跑 JS 运行时,日志分级非常重要。我把日志分为 ERROR、WARN、INFO、DEBUG 四个级别,通过环境变量控制输出级别。生产环境只输出 ERROR,调试时打开 DEBUG。这样既不影响性能,又能在需要时获取详细信息。这个简单的机制在实际调试中帮了我大忙。