☰
从入口到主循环:Hermes 引擎执行流程与字节码机制解析
2026/10/26 4:05:05 网站建设 项目流程

1. 从“hello world”到一次对话:Hermes到底在干什么

先交代一下背景。我一直在做 React Native 相关的性能优化,平时跟 JSC、V8 这类 JavaScript 引擎打了不少交道。前两年开始认真接触 Hermes,也就是 Meta 开源出来的那个专为 RN 场景设计的 JS 引擎,越看越觉得这套东西值得写。它的启动速度、内存占用、字节码预编译这些能力,每个点背后都有一大堆工程取舍。这个系列我想带着大家从源码层面把它一层层剥开,第一篇先解决最基础也最关键的问题:一条命令从终端敲进去,到引擎真正执行 JS 代码,这中间到底发生了什么。

标题里说的“一次对话”,其实就是一段最简单的 JS 代码,比如print("hello")或者console.log("hello")。这段代码从命令行传入 Hermes 之后,会经历参数解析、运行时初始化、字节码编译、解释执行这几个大的阶段。整个过程看似只是“敲一下回车”的事,但背后是命令行入口、Runtime 初始化、JSI 层、字节码生成器、解释器主循环、垃圾回收等一大串模块的协同工作。

这篇适合谁看?如果你对 JS 引擎内部机制感兴趣,或者你在做 RN 相关的性能优化、需要理解引擎行为,又或者你只是想知道“编译器/解释器到底是什么”这类问题,都可以跟着这个系列往下走。第一篇不会一上来就堆太多 VM 内部数据结构,我会把主线讲清楚,让你拿到源码之后能顺着一条路从入口走到主循环的核心位置。有了这个整体地图,后面再深入 GC、字节码格式、JSI 实现时就不会迷路。

我用的阅读版本是 Hermes 较新的主干代码,具体的代码行号在不同版本里可能会有变化,但核心架构是比较稳定的。建议你自己 clone 一份源码跟着看,路径大概是这样(以我的工作目录为例):

$ git clone https://github.com/facebook/hermes.git $ cd hermes $ ls API include lib tools test CMakeLists.txt ...

这里面的tools/hvm/hermes.cpp是命令行入口,API/hermes/hermes.cpp是引擎对外暴露的 API 层,lib/VM/存的是运行时核心实现,lib/Interpreter/是解释器和字节码执行逻辑,lib/BCGen/负责把 JS 源码编译成字节码。后面我们会反复用到这几个目录。

2. 入口解析:main 函数里的三重身份

2.1 命令行第一站:main 到 hermesMain

先找到程序真正的入口。在tools/hvm/hermes.cpp里,代码大致是这样组织的:

int main(int argc, char **argv) { ... return hermesMain(argc, argv); }

这个hermesMain就是命令行工具的入口主函数。它做的事情从源码里看起来比较直白:收集命令行参数、解析参数、然后根据参数决定走哪条执行路径,最后返回退出码。

把main和hermesMain拆开是刻意的。这样设计后,接入方可以在自己代码里直接调用hermesMain而不必总是从main进,也为后续的测试代码复用入口逻辑提供了方便。你如果去翻API/hermes/hermes.cpp,会发现引擎还导出了一组以hermes::开头的 C++ API,比如hermes::evalJS、hermes::compileJS。hvm这个命令行工具本质上只是这些 API 的一层壳。

我刚开始看的时候一直在找“到底哪里有 main 函数”,结果发现 hvm 的入口其实很小,真正的大头都在 Runtime 初始化里。这也提醒我们,不要把入口理解成“程序开始的函数”,入口的意义在于“把外部世界的请求转换成引擎内部的动作”。

2.2 参数解析:字符串到 JD/CompileFlags 的映射

hermesMain里有一个用来自定义参数解析的循环。Hermes 并没有直接采用某个第三方命令行解析库,而是自己用一组带短选项和长选项的表格来维护。常用的参数有这么几类:

  • -O:开启优化
  • -W:设置警告级别
  • -b:编译生成字节码文件而不直接执行
  • -dump-bytecode:输出字节码内容,非常适合学习时观察 JS 被编译成什么样
  • -fn、-bs:指定输入文件名或字节码段之类的底层选项
  • -exec:直接从 stdin 读取源码并执行

参数解析完会得到一个执行需求描述:是“只编译”还是“编译并运行”,是否输出字节码,优化等级是多少。这些信息最终会被组织成CompileFlags和RuntimeConfig。

struct CompileFlags { bool optimize {false}; bool dumpBytecode {false}; ... };

我一般习惯先跑hermes -dump-bytecode -O -exec然后输入一行 JS,来直观感受编译结果。这个过程不仅能帮我们理解参数,也会在后续浏览BytecodeProvider和解释器时提供上下文。

2.3 编译还是执行:分水岭逻辑在 execute()

参数定下之后,执行路径会来到一个核心函数:execute()。它在同一个文件里,本质上是这样:

  1. 根据参数创建RuntimeConfig,配置 GC、堆大小限制、是否开启 ESM 等。
  2. 调用hermes::makeHermesRuntime(runtimeConfig)得到一个运行时实例。
  3. 如果输入的是 JS 源码,就把它编译成字节码;如果输入的是.hbc,就直接读取字节码文件。
  4. 最终把字节码交给运行时去执行。

关键一点:Hermes 对“编译”和“执行”这两件事是分开的。-b模式只编译不执行,生成.hbc文件后退出。这个文件可以被提前打包进 App,在运行时直接加载,省去设备上的编译时间。这也就是 RN 官方推荐“预编译字节码”这个优化方向的基础。

如果你在这里断点调试,建议把断点打在execute()的这几条关键调用上,观察RuntimeConfig长什么样、evalJS的入参是什么。一次对话能不能跑通,全看这里是否正确地完成了从“字符串源代码”到“可执行字节码”的转换。

3. 运行时初始化:Hermes 的“大脑”是怎么创造出来的

3.1 RuntimeConfig 与 makeHermesRuntime

execute()里第一步是创建RuntimeConfig。这个配置项控制了引擎的很多行为,比如:

  • withGCProfile:是否开启 GC 性能分析
  • withTrackIO:是否跟踪 IO 操作
  • withMallocLimit:原生内存上限
  • withMaxHeapSize:JS 堆大小上限

然后调用hermes::makeHermesRuntime。这个工厂函数内部做了什么?先别急着说HermesRuntimeImpl的构造函数,其实在它之前还有一层JSI的封装要过。

std::shared_ptr<HermesRuntime> makeHermesRuntime( const ::hermes::vm::RuntimeConfig &runtimeConfig) { auto runtime = std::shared_ptr<HermesRuntimeImpl>( new HermesRuntimeImpl(runtimeConfig)); return std::shared_ptr<HermesRuntime>(std::move(runtime)); }

HermesRuntime是 JSI 层定义的一个接口,HermesRuntimeImpl是 Hermes 对 JSI 接口的实现。HermesRuntimeImpl内部持有一个std::shared_ptr<vm::Runtime>,也就是真正干活的 VM 核心对象。这里产生了三层关系:

  • JSI 层:定义抽象接口,让上层(RN、js engine 使用者)屏蔽引擎差异
  • HermesRuntimeImpl:实现这些接口,同时管理 VM 生命周期
  • vm::Runtime:Hermes 自己的 VM 数据结构,真正负责解析、执行、GC

我第一次看这层嵌套时觉得有点绕,但实际写一个跨引擎应用就会明白,如果 RN 希望可以在 JSC、V8、Hermes 之间切换,JSI 这层抽象是必须存在的。Hermes 的源码里,API/jsi/jsi.cpp里实现了jsi::Runtime的大部分方法,每次调用都会“翻译”成对vm::Runtime的操作。

3.2 HermesRuntimeImpl 构造:从 C API 到 VM 世界的桥

打开lib/VM/HermesRuntimeImpl.cpp的构造函数,你能看到它做了几件很重要的事情:

  • 初始化vm::Runtime的各个子系统,比如存储管理器(StorageProvider)、GC、模块管理器。
  • 配置全局对象,也就是 JS 里的globalThis。
  • 绑定一组内部原生函数,比如print、eval、gc等,这样 JS 代码才能调用宿主能力。

这个构造函数也是我们追踪“console.log 是怎么实现的”的起点。其实console.log在 Hermes 里不是 ECMAScript 规范强制要求的,它是引擎额外提供的宿主能力。如果继续往下翻,会找到一个Console相关的内置对象定义,它的 API 最终会通过console.log调用映射到原生输出逻辑上。

构造HermesRuntimeImpl时,可能还会创建jsi::Runtime的实例,并把它暴露给外部调用者。很多上层框架在做 JS 执行时,拿到的并不是HermesRuntime本身,而是它内部的jsi::Runtime&。

jsi::Runtime &rt = runtime->getRuntime();

这个rt后续可以用来创建对象、执行函数、读写 JS 全局变量。理解这套桥接逻辑之后,你看到 RN 里nativeCallSyncHook之类的调用会顺畅很多。

提示:在单步跟踪初始化流程时,建议把断点放在HermesRuntimeImpl构造函数的第一行,然后用“step over”逐个看它生成的全局对象列表,以及RuntimeConfig的默认参数值。这里能直观看到 GC 参数是怎么影响内存策略的。

3.3 Runtime 初始化后续:GC 与存储管理器的启动

vm::Runtime的构造过程会创建GC对象。在 Hermes 里,GC 不仅仅是一个“垃圾回收器”,它同时负责对象分配、堆空间管理、内存统计。具体到代码,lib/VM/MemGC/MemGC.cpp里定义了MemGC类,它会根据RuntimeConfig里的minHeapSize、maxHeapSize、occupancyTarget等参数决定 GC 的行为。

这些参数不在命令行直接暴露,而是通过 RuntimeConfig 传入。比如移动端 App 里,往往会把maxHeapSize设置得比较保守,防止 JS 堆撑爆系统内存。而桌面端通过hermes命令执行时,默认值相对宽松。

类似地,Hermes 的存储管理器支持不同的StorageProvider实现,比较常见的是虚拟内存映射和 malloc 两种。之前版本里有mmap存储,后来为了跨平台稳定性和包体积考虑,默认实现也做过调整。这里面的取舍是:虚拟内存分配更高效,但某些平台或者沙盒环境下受限;malloc 通用性更好,但更容易产生内存碎片。

我在排查过 RN 的偶发闪退问题时,经常先看这两项配置再下手。如果你在一个新平台接入 Hermes,优先确认存储和 GC 配置是否合理,很多内存问题都会迎刃而解。

4. 主循环拆解:字节码如何驱动“一次对话”

4.1 从 evalJS 到 interpretFunction

当源码编译成字节码后,真正执行前会进入一个核心函数,位于API/hermes/hermes.cpp的evalJS或者Runtime::execute。这个函数会根据传入的字节码和参数构造一个CallFrame,然后调用解释器去跑。

解释器的入口在lib/Interpreter/Interpreter.cpp,核心函数是interpretFunction。这个名字看起来有点“传统”,但它的工作方式非常现代:它维持着一个巨大的switch循环,每个字节码指令对应一个case。这个循环就是一句话里的“主循环”。

我经常把这段逻辑类比成“看菜谱做菜”:

  • 字节码:菜谱上的步骤,比如“拿起洋葱”“切三刀”“下锅翻炒”
  • 指令指针(IP):当前读到第几步
  • 寄存器(Registers):案板上的临时区域
  • 函数调用帧(CallFrame):不同厨师的工作台,每个函数有自己的工作台

主循环就是不断重复“读取当前步骤 -> 执行 -> 移到下一步”,直到菜做完(函数返回)为止。由于不同的case执行时间差异巨大,现代解释器通常不追求每一步都做复杂的指令调度优化,而是把热点函数用 JIT 编译到原生码。Hermes 目前默认不启用激进 JIT,而是靠高质量的静态字节码和预编译来缩短启动时间。

4.2 一次函数调用的完整路径:从 JS 调用到 HermesValue

拿这个过程最经典的一段 JS 来说:

function add(a, b) { return a + b; } print(add(1, 2));

它在 Hermes 里的执行路径大致是这样:

  1. 解释器在主循环里遇到Call指令。
  2. 指令会读取add函数对象,计算出参数列表(1和2),然后在当前 CallFrame 之上创建新的 CallFrame。
  3. 新 CallFrame 会分配足够大的寄存器空间,存放局部变量和临时值。
  4. 把函数体的字节码入口写入指令指针,继续执行。
  5. 函数体里执行Add指令,这个指令从寄存器里取出两个操作数,相加后写回目标寄存器。
  6. 遇到Ret指令,解释器把返回值写到调用者的寄存器,然后恢复调用者的 CallFrame 和指令指针。

这里的 JS 数字并不是直接以 double 存储的。Hermes 的运行时里大量使用一种叫做HermesValue的标记值结构,它在一个 64 位字里同时存放“类型标记”和“数据”。如果数据适合直接放进标记值里(比如布尔值、小整数、null、undefined、指向对象的指针),就不会触发堆分配。这种设计对性能影响非常大,也是 Hermes 能在移动端保持较低内存占用的重要原因之一。

注意:不要在HermesValue的实现上过早深挖。第一遍阅读时,先把它理解成“既能当整数用又能当指针用的万能盒子”,等后续系列讲到对象模型和 GC 时,再回头看它的标记位布局和编码细节。

4.3 主循环与宿主的线程模型联动

很多第一次接触 Hermes 的朋友会疑惑:“主循环”是不是像浏览器一样自带事件循环?其实不是。Hermes 作为一个嵌入式 JS 引擎,它默认不负责“调度异步任务”,也没有实现像setTimeout这样的事件队列。它只有“执行完一段字节码返回”这种同步模型。RN 里的异步调度是宿主环境做的,比如 RN 在原生端用MessageQueue或者 JSI 调用来实现 JS 与原生模块之间相互调用。

所以 Hermes 的主循环和浏览器驱动 UI 的事件循环不一样,它更贴近“虚拟机内部的取指-译码-执行”循环。RN 之所以能在 JS 层写setTimeout,是因为这些 API 是运行在宿主提供的setTimeout实现之上的。Hermes 只提供一个HostFunction机制,允许原生代码把一个 C++ 函数暴露成 JS 可调用的函数。JS 调用这个函数时,会通过 JSI 层回到原生线程执行。

这样一个模型的优势在于引擎可以很小、很干净,不绑定平台特性。缺点呢,就是如果宿主线程阻塞或者消息队列设计不好,JS 任务的执行时机可能会受影响。做 RN 性能优化时,我们关心的是线程模型和 JSI 调用的开销,而不仅是 JS 执行本身。

4.4 字节码格式速览:为了读懂 dump 输出的最小知识

执行主循环之前,先看看字节码长什么样。用-dump-bytecode跑一段简单代码,你会看到类似这样的输出(具体内容和版本相关):

Function<global>: LoadConstInt r1, 1 // 把常量1加载到寄存器r1 LoadConstInt r2, 2 // 把常量2加载到寄存器r2 Add r0, r1, r2 // r0 = r1 + r2 Ret r0

这个格式非常接近汇编语言。r0、r1、r2是寄存器编号,指令后面的三个数字分别是源操作数和目标操作数。字节码生成器在lib/BCGen/中负责将 AST 转换成这种“栈寄存器混合模型”的指令。

Hermes 的寄存器不是物理 CPU 寄存器,而是一段连续内存,由解释器在栈上分配。这种设计很像 Dalvik 虚拟机采用的寄存器模型。好处是函数调用时不需要像纯栈机那样频繁压栈弹栈,坏处是寄存器分配算法要做得比较好。

理解了这些基本概念之后,你再去看Interpreter.cpp里的switch循环,就会知道每个case其实对应一个“给寄存器搬家/做运算/跳转/调用”之类的操作。整个主循环的本质就是:不停取当前 IP 位置的字节码,读出操作码,执行对应的处理逻辑,更新 IP。

5. 实用技能:怎么高效调试和追踪 Hermes 源码

5.1 搭建最小调试环境与断点规划

我强烈建议你本地编译一个 Debug 版的 Hermes,因为 Release 版默认做了大量内联和优化,源码行号对应关系会失真。用 CMake 配置的时候,打开-DCMAKE_BUILD_TYPE=Debug,顺手加上-DHERMES_ENABLE_DEBUGGER=ON这类选项(具体配置项以当前构建系统为准)。

拉好代码后,我习惯用带有图形界面的调试器来跟,比如 VS Code 的 C++ 插件或者 CLion。断点的设置顺序是这样的:

  1. 先在hermesMain里打一个断点,确认命令行参数解析入口。
  2. 然后在execute()里断住,看RuntimeConfig和CompileFlags的实际值。
  3. 再进到evalJS或Runtime::execute,确认字节码文件内容。
  4. 最后在Interpreter.cpp的interpretFunction入口断住,开始逐步看主循环。

如果你之前没试过跟解释器主循环,第一次进入时会觉得代码很庞大。我的建议是一次只跟 20 到 50 行指令,观察寄存器怎么变化,不要想着一下子理解所有case。

5.2 用好自带调试输出与断言

Hermes 内置了一些很有用的调试工具,除了-dump-bytecode,还有:

  • -dump-ast:输出语法树,观察解析器做了什么
  • -dump-ir:输出中间表示(Hermes IR),比字节码更接近高级语言
  • -trace/ 各种-trace-*:追踪 GC、IO 等子系统行为
  • 源码里大量assert和llvm_unreachable,在 Debug 模式下非常能帮你发现“哪里状态不对”

跑一次-dump-ir你会发现它比字节码更抽象,包含了类型推断、优化后的控制流等信息。学习顺序建议是:AST -> IR -> 字节码。这样才能理解“编译”这个渐进过程。

5.3 常见问题排查速查表

我把实际调试中容易踩的坑整理成表,方便你对照排查:

现象可能原因排查方法
断点没有命中编译的是 Release 版,符号丢失或优化太狠重新编译 Debug 版,并打开调试符号
-dump-bytecode没有输出没有正确使用参数,或执行路径直接走了 precompiled 字节码确认是否传了-dump-bytecode且参数为源码模式
运行hermes时崩溃在 GC堆大小配置过小或存储管理器实现异常检查RuntimeConfig中的堆参数,换mmap/malloc 存储测试
主循环里单步非常慢Debug 版解释器本身开销就大,或机器虚拟化环境不稳缩小测试代码、只跟踪关键指令段
源码路径与文章不一致Hermes 版本更新后文件被重构优先使用grep在源码里定位同名符号

另外有一个我自己的习惯:在源码里找一个异常入口(比如hermes::vm::throwException),给所有异常路径打上日志,就能很快看出某段 JS 代码是不是在某个深层调用里悄悄失败了。Hermes 的报错信息有时比较“底层”,但代码里的错误码基本上都能对应到lib/Support/ErrorHandling.cpp或类似文件里的解释逻辑。

5.4 工具链之外:用最小的 JS 测试集做实验

还有一个值得养成的习惯:准备一组“最小 JS 测试集”,专门用来触发你关注的行为。比如:

  • print(1):验证基本输出链路
  • function f(){} f():验证函数调用路径
  • let a = []; for(...):验证数组和循环
  • throw new Error("x"):验证异常处理机制

每次修改源码或者切换调试目标时,先跑这组测试,可以快速判断改动影响范围。我在做源码分析时经常觉得,与其漫无目的地单步走几千行,不如用这些小例子做“探针”,一旦某个探针行为变了,就能立刻缩小搜索范围。

提示:如果你在调试时发现某个对象在HermesValue里看起来既不是数字也不是指针,先检查是否处于 GC 移动后的对象引用了旧地址。很多“莫名崩溃”都源于没有正确更新对象引用,尤其是自己写 Native 扩展时。

6. 读完这篇之后,下一步往哪走

这篇文章的核心目标,是帮你建立 Hermes 源码的“入口地图”。从命令行参数到字节码执行,一条主线已经打通了。但我必须强调一句:入口和主循环只是整个引擎的表层骨架,真正决定 Hermes 性能的机制,比如 GC 的分代策略、字节码序列化格式、对象内存布局、JSI 的跨线程通信模型,每一个都值得单独拿一整篇来写。

我在实际阅读中一个很深的体会是:看 JS 引擎源码,不能只用“读代码”的方式。更好的方法是“带着问题读”,比如:

  • 为什么 RN 在低端 Android 上选择 Hermes?
  • 为什么预编译字节码能省掉一大段解析时间?
  • 为什么 Hermes 的启动内存比其他引擎小?
  • console.log和原生日志之间到底跨了几层函数?

这些问题会在读源码的过程中被一点点解出来。下一篇我打算深入RuntimeConfig、HermesRuntimeImpl构造过程中创建全局对象的细节,把globalThis、内置对象、原生函数的挂载过程整个拆开讲,正好也是这次入口分析的自然延伸。

最后分享一个小技巧:读源码时,拿一支笔和一张纸画“调用链图”。不用画得多漂亮,只要把hermesMain -> execute -> evalJS -> interpretFunction和每个函数“接下来又调用了谁”的箭头画出来就行。一张图画完,整个入口结构就印在脑子里了。后面几篇系列文章里,我会频繁提到这条调用链,到时候你心里有图,理解会快很多。

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

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

立即咨询