☰
嵌入式设备上跑JavaScript:6MB轻量级运行时xuan的设计与实现
2026/10/15 4:24:12 网站建设 项目流程

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-8UTF-16UTF-8 对 ASCII 更友好,嵌入式场景英文为主
正则引擎自研简化版引入 PCREPCRE 体积太大,简化版够用
标准库按需裁剪完整 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.2MB3.8MB
基本运算 + 对象操作2.5MB4.2MB
递归 + 闭包 + JSON4.8MB5.6MB
大量字符串拼接8.3MB7.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。这样既不影响性能,又能在需要时获取详细信息。这个简单的机制在实际调试中帮了我大忙。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询