☰
从命令行到主循环:Hermes引擎源码执行链路全解析
2026/10/25 3:49:05 网站建设 项目流程

1. 从命令行出发:Hermes到底是怎么跑起来的

标题里写了“从命令行到一次对话”,这个“对话”其实指的是引擎和你的交互过程——你敲下一行hermes xx.js,引擎从读取源码、编译成字节码,到最终执行并打印结果,整个链条就是一次完整的“对话”。很多人一上来就埋头读lib/VM里的上万行代码,很容易迷路。我的建议是反过来,先顺着入口往下走,搞清楚一条执行链上的骨架,再回头补血肉。

先给不熟悉的同学交代一下背景:Hermes 是一个针对移动端优化的 JavaScript 引擎,主打启动快、内存占用低,很多跨端框架拿它做脚本引擎。它支持直接跑 JS 源码,也能跑预先编译好的 HBC 字节码文件,还带一个 REPL 交互模式。源码以 C++ 为主,目录结构大致是lib/(核心实现)、include/(对外头文件)、tools/(命令行工具)、API/(JS 和 C API 层)。今天我们只干一件事:找到入口,顺着主循环走一遍。

适合谁看?想读引擎源码但不知道从哪下手的同学,或者工作中需要定位"为什么脚本没跑起来"这类问题的同学。不需要提前精通 C++,能看懂函数调用和基本指针就行,遇到关键的模板和智能指针我会顺手解释。

2. 入口文件定位:别急着翻代码,先看命令行怎么分发

2.1 命令行工具的注册与参数解析

Hermes 的命令行入口不在lib/里,而在tools/hermes目录下,主文件叫Main.cpp。这符合一个工程化项目的常规布局:核心库保持纯净,命令行只是外层壳。你调用hermes命令时,操作系统找到可执行文件,进入int main(int argc, char **argv),这里就是源头。

入口做了三件基础工作:

  • 解析参数(llvm::cl风格的选项注册)
  • 根据参数模式(执行文件/执行语句/进入 REPL)走不同分支
  • 构造运行时并进入执行流程

参数解析是重中之重。Hermes 用了一套声明式的选项注册,比手写getopt要清晰得多,每个选项对应一个全局变量。比如-O控制优化等级,-dump-bytecode控制是否导出字节码,-exec直接执行一行语句,-repl进入交互模式。源码里你会看到一大片cl::opt的定义,你完全可以把它理解为“命令行选项的登记表”。

这里有个容易忽略的点:参数解析的时机。不是main一进来就解析,而是先做一些平台级的环境初始化(比如设置 locale、处理信号),再进解析。因为某些解析选项本身依赖环境状态。第一次读源码的时候不必纠结这些细节,你只需要记住:main拿到argc/argv,后面所有行为都来自这里的“选择”。

2.2 三种执行模式的差异和选择逻辑

顺着解析完的参数往下走,入口通常会根据输入参数进入三条路径:

  • 直接执行脚本文件:hermes app.js,最常用
  • 执行内联语句:hermes -exec "print('hi')",适合快速验证
  • 交互式 REPL:hermes -repl,一行一行喂给引擎

这三条路径最终的落点不同,但对新手来说,理解它们的差异能帮你快速定位代码。直接执行文件最省事:读文件、编译、执行、退出。执行内联语句则是把字符串当作源码直接编译。REPL 最复杂,它需要一个“会话状态”的概念——全局对象要持续保留,每次输入都基于上一次的执行结果继续。这就是标题里“一次对话”的本质:REPL 的循环就是对话循环,每一轮输入,都是一次完整的编译加执行。

我建议读入口时按这个顺序:先看“直接执行文件”分支,因为最短;再跳进 REPL 分支,理解主循环;最后回头来看-exec,因为这个分支恰好是“文件执行”和“REPL”的中间形态。

2.3 main 函数的最小骨架(伪代码)

下面这段伪代码不是逐行搬源码,而是我提炼后的执行骨架,方便你对照真实代码时心里有底:

int main(int argc, char **argv) { // 1. 初始化命令行选项 cl::ParseCommandLineOptions(argc, argv, "Hermes\n"); // 2. 根据参数选择执行模式 if (ShowHelp) { cl::PrintHelpMessage(); return 0; } if (!Repl) { // 文件执行或语句执行 return executeScript(argc, argv); } else { // REPL 模式,进入主循环 return runRepl(argc, argv); } }

注意真实的main里还有异常捕获、退出码归一化等细节。但你照着上面这个骨架去读,基本能一路走到runRepl或executeScript。这俩函数才是真正的“对话发起者”。

3. 主循环在哪里?别在 Main.cpp 里死等

很多朋友以为主循环会出现在main函数里,找半天找不到。实际上main在完成模式分发后,很快就把控制权交给了运行时。我们把“主循环”拆成两层来理解:

  • REPL 层的对话循环:在工具层代码里,表现为while (true)等待用户输入
  • VM 层的执行循环:在解释器里,表现为for (;;)不断取指令、执行、跳转

今天的标题重点讲“一次对话”,所以 REPL 层的循环是我们观察的对象,但真正干活的是 VM 层那个“取指-解码-执行”的循环。你可以这样理解:REPL 循环是对话的“外壳”,VM 解释器循环是对话的“内核”。一次对话发生时,外壳循环每次收到一行输入,都会委托内核循环把这一行执行完,再回到外壳等待下一行。

3.1 REPL 循环的实现位置

REPL 循环通常不在Main.cpp里,而是在tools/hermes/下的一个独立文件里(比如Repl.cpp)。它的大致逻辑是:

void runRepl() { // 初始化运行时 auto runtime = HermesRuntime::create(...); // 准备全局对象 runtime->global(); while (true) { std::string line = readLine(); // 读取一行输入 if (line == "quit" || line == "exit") break; auto result = runtime->executeScript(line); // 编译并执行这一行 // 如果结果是值类型,打印出来 if (result.isValue()) { printValue(result.getValue()); } } }

真实代码里还会处理多行输入(花括号没闭合就继续读)、异常回显、断点调试等,但这些都不影响主骨架。你要抓住的关键模式是:读一行,执行一行,显示结果,再读一行。这就是“一次对话”的标准流程。

读到这里你会发现,入口层的工作比想象中少得多,大部分时间它只是在“翻译用户意图”。真正的编译和执行发生在executeScript内部。

3.2 解释器主循环的基本形态

当executeScript把源码字符串交给编译器,产出 HBC 字节码后,运行时拿到字节码创建一个RuntimeModule,最终在解释器里开始执行。解释器主循环的框架大致长这样:

for (;;) { Opcode opcode = (Opcode)*ip; switch (opcode) { case Opcode::LoadConstString: // 处理加载字符串 break; case Opcode::Call: // 处理函数调用 break; case Opcode::Ret: // 处理返回 return; // 还有几百个 opcode... } }

实际上 Hermes 的解释器早就不是这么朴素的switch了,它用了 computed goto 和线程化解释器,性能更好,但阅读逻辑上你可以先当成switch看。理解这一步很重要:主循环的本质,就是不停地把“指令指针”往后推,每推一次就执行一条字节码。

4. 深入 executeScript:编译与执行的完整链路

4.1 从源码字符串到字节码

我们以最简单的print("hello")为例。当你在命令行执行hermes -exec "print('hello')",编译链路依次是:

  1. 词法分析:把字符串切成一串 token
  2. 语法分析:token 变成 AST(抽象语法树)
  3. 语义分析:做变量绑定、类型检查
  4. 字节码生成:AST 变成 HBC 指令流

在 Hermes 里,前几步通常由前端解析器完成,位于lib/Parser和lib/AST,字节码生成在lib/Bytecode。你会在源码里看到parseJS、generateBytecode之类的函数名。为了提升启动速度,Hermes 允许直接加载预编译的.hbc文件,跳过前三步。这时executeScript会走一条简化的加载路径。

在实际执行脚本时,运行时通常会先调用接口将字符串传入编译器:

std::string source = "print('hello');"; auto compiled = hermes::compileSource(source, "interactive.js");

compileSource返回的compiled对象包含字节码缓冲区和可能的警告/错误信息。这一步要注意:编译的错误不等于执行的异常。语法错误在编译阶段就暴露,运行时异常(比如调用不存在的方法)才在执行阶段抛出。看清这一点,排查问题时就能少走弯路。

4.2 运行时初始化与全局对象挂载

执行字节码之前,必须先有“环境”。Hermes 的运行时环境核心是一个Runtime对象,它管理着堆、调用栈、全局对象、内建函数表等。初始化时,引擎会往全局对象上挂载内建对象和函数——print、Object、Array、Math等。你调用print时,看起来是个普通函数,实际上它指向的是一个 C++ 原生函数。

挂载全局对象不是一个for循环全部搞定的事,而是分模块完成的。比如Math单独一个初始化函数,JSON单独一个,String.prototype上的一堆方法也是逐个定义。源码里会看到大量defineProperty和putNativeFunction调用,它们本质上是往一个JSObject的“符号表”里加条目。

这里有个新手容易踩的坑:全局对象的“符号表”容量是预留的。Hermes 为了性能,在创建全局对象时会预先分配一定数量的属性槽位,避免运行期反复扩容。如果往全局对象上挂的属性过多,触发了扩容,性能会下降。这也是为什么源码里初始化的顺序不能乱调,每个模块挂完属性后最好保持“槽位有余量”的状态。

4.3 调用栈与 CallFrame:对话的“记忆”

执行字节码时,最核心的结构是调用栈(CallStack),而栈上的每个单元叫CallFrame。可以把它类比成一次对话中的“上下文快照”:当前执行到哪个函数、指令指到哪一行、局部变量存在哪、返回地址是什么,全部打包在一个CallFrame里。

当发生函数调用时,引擎会压入一个新的CallFrame;函数返回时,弹出这个CallFrame,恢复调用者的现场。解释器主循环里频繁操作的ip(指令指针)、sp(栈指针)、fp(帧指针),全都基于CallFrame的布局。

读源码时,你会看到类似这样的一段逻辑(伪代码):

CallFrame *newFrame = CallFrame::pushFrame(runtime, func); newFrame->setIP(entryPoint); newFrame->setThisVMOwner(runtime);

这就是一次函数调用的开始。理解CallFrame之后,你就理解了执行上下文切换的本质,这对后续读字节码调试器、异常处理、垃圾回收都有帮助。

4.4 executeScript 的最终落点

把上面的块拼起来,executeScript的完整流程可以概括为:

  • 将源码交给编译器,拿到字节码
  • 创建(或复用)RuntimeModule
  • 通过运行时的入口,将首个字节码块的入口地址交给解释器
  • 解释器主循环开始运转,直到执行到返回指令
  • 把最终结果包装成一个JSValue/HermesValue返回给调用层

到这一步,“一次对话”就走完了。你敲下的那行 JS,经过编译、初始化环境、压栈、执行、出栈,最终以返回值的形式回到命令行界面。理解这个链路后,读任何一段 Hermes 源码都有了坐标轴。

5. 核心数据结构与关键名词速查

开始深入前,先把后面会高频碰到的名词表列出来。我看源码时有一段时间被这些名词绕得头晕,后来自己做了个速查表,现在也分享给你:

名词作用类比
Runtime管理堆、栈、全局对象、GC操作系统的进程
HermesValue一种紧凑的值表示,可存数字、指针、标记位一个带类型的联合体
CallFrame一次函数调用的栈帧记录对话中的“当前语境”
RuntimeModule一个字节码文件的运行时表示一个加载进内存的“可执行文件”
CodeBlock某个函数对应的字节码块一个函数编译后的“机器指令”
JSObjectJS 对象的内部表示,含属性表一个带哈希表的字典容器
Handle<>指向 GC 管理堆对象的“安全指针”一个防止被垃圾回收器搬走的遥控器

其中Handle<>特别值得单独说两句。C++ 世界里的裸指针会被对象移动搞得失效,Hermes 的 GC 可能压缩堆、搬动对象,所以运行时中你很少直接持有裸JSObject*,而是通过Handle来引用。你看到auto obj = Handle<JSObject>::vmcast(value)这种代码时,别以为它只是类型转换,它背后还有 GC 安全性的考量。

6. 命令行标志如何影响执行流程

命令行参数不是只用来“选模式”的,它还直接影响编译策略和执行行为。我在调试 Hermes 相关问题时,经常用下面这几组参数定位性能或语义差异:

6.1 优化与调试类标志

  • -O/-O0:控制优化等级。优化等级不同,生成的字节码差异非常大。比如尾调用优化(TCO)、函数内联(inlining)都会在-O下开启,导致调用栈形态完全不同。如果你在看字节码时发现“怎么回事?明明没递归,栈却这么深”,先检查优化等级。
  • -g:生成调试信息,包括行号映射。这个标志对排查“源码行对应字节码哪一条”极其重要,配合-dump-bytecode一起使用效果更好。
  • -dump-bytecode:输出字节码的反汇编。想看解释器到底执行了什么,这是第一手资料。
  • -l/-dump-ast:输出 AST。适合验证解析器的行为。

6.2 执行模式相关标志

  • -exec:直接执行后面的字符串。
  • -repl:进入交互模式。
  • -x:内部实验性参数,一般不用。
  • -rt/-gc:GC 相关配置,调堆参数时再动。

有个技巧值得分享:当你怀疑某个 bug 是“优化后的字节码行为不对”还是“源码语义理解不对”时,用-O0和-O各跑一遍,对比字节码差异,问题往往水落石出。字节码级的信息远比运行时日志直观。

7. 现场实操:从零跟踪一次对话的完整过程

这一节我会带你走一遍“实战流程”,你可以直接照着操作,在自己的机器上复现“从命令行到一次对话”的全过程。环境假设是 Linux/macOS,Hermes 已构建好,可执行文件在build/bin/hermes。

7.1 步骤一:用最长链路监控执行

先准备一个测试脚本:

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

然后执行:

hermes -O0 -dump-bytecode test.js

你会看到终端输出一长串字节码。别急着全看,先找两个关键点:函数add对应的CodeBlock名和Call指令。字节码里会有一行类似Call2或Call1的指令,后跟操作数寄存器索引。这个过程帮助你理解“函数调用在字节码里长什么样”。

7.2 步骤二:验证 REPL 主循环

接着进入 REPL:

hermes -repl

依次输入:

var x = 1; x + 1; function f(y) { return y * 2; } f(x);

注意观察:第一行没有输出(语句没有返回值),第二行输出了2,第三行没有输出,第四行输出了2。这说明 REPL 只在表达式语句有最终值时输出,声明语句不输出。这个行为调试时会用到,比如你怀疑某行没执行,可以先把它改成带副作用的调用。

7.3 步骤三:用内联语句验证编译链

在另一个终端执行:

hermes -exec "print('hello ' + 'hermes')"

如果一切正常,输出hello hermes。此时你可以加上-dump-bytecode再看一遍输出,会看到字符串拼接在字节码层面变成了AddString指令而不是Call,这能帮你区分“运行时运算”和“函数调用”的执行路径。

7.4 步骤四:用调试器打断点

如果你从源码编译的 Hermes 带调试信息,可以在解释器主循环入口打断点:

gdb build/bin/hermes (gdb) break lib/VM/JIT.cpp:...

实际操作中,我更推荐在executeScript系列函数的出入口打断点,因为那可以一次性覆盖“编译完成,准备执行”和“执行结束,准备返回”两个关键节点,对初学者最友好。断点打下去后,运行hermes -exec "1+1",观察调用栈上的每一帧,你会看到main→executeScript→interpretFunction这样一层层的调用关系。

这一步跑完之后,整个入口到主循环的链路就全部打通了。“命令行”和“对话”之间的关系,不再是纸面上的理解。

8. 常见问题与排查技巧实录

8.1 问题一:-repl模式下输入多行函数定义,报语法错误

很多新手在 REPL 里输入:

function foo() { return 1; }

发现按回车后立刻报错,原因很简单:REPL 每次只编译一行,第一行function foo() {尚未闭合,被当成语法错误。解决方法是:要么写成单行函数function foo() { return 1; },要么使用支持多行输入的包装命令。排查思路是看 REPL 循环是否有“累积输入直到语法完整”的逻辑。

8.2 问题二:-dump-bytecode输出太多,找不到关键点

字节码动辄上千行,手翻不现实。我自己的办法是配合-exec只输出一行想要的代码:

hermes -dump-bytecode -O0 -exec "function a(){ return 1 } a()"

这样输出只有几行,一眼就能定位Function定义和Call指令。另外注意-O0和-O的字节码差异巨大,排查时不要混看。

8.3 问题三:执行时出现CallFrame相关崩溃

这类崩溃十有八九是调用栈溢出或栈帧布局不匹配。先检查是不是无意识写了死递归:

function f(x) { return f(x); } f(1);

如果代码没问题,再把优化等级切换到-O0复现一下。因为优化后的字节码可能复用栈帧,崩溃表象和-O0不一样。排查时配合-verbose参数看执行日志,通常能定位到具体函数。

8.4 问题四:想修改全局对象属性,但运行时行为不变

这大概率是你修改的是“属性定义”,不是“属性值”。全局对象上的内建函数(比如print)是在初始化时绑定好的,你后面重新print = xxx只是改了全局对象上的同名属性,不影响引擎内部已经缓存的函数引用。这是引擎内部缓存机制的正常表现,不是 bug。理解这一点后,你就知道为什么有些 JS 库的 polyfill 在 Hermes 上不生效了。

9. 读完入口后的进阶路线建议

如果你已经把从main到executeScript的链路走通,下面这几条延伸路线,按性价比排序供你参考:

  • 读字节码反汇编:对着-dump-bytecode输出,把每条指令和源码对应起来,这是最直接的“源码解析”练习
  • 读CallFrame的推栈与出栈逻辑:理解函数调用的本质,涉及lib/VM/CallFrame.h
  • 读HermesValue的位布局:理解 JS 值在引擎内部是怎么用 64 位表示的,很多人第一次看到HermesValue会惊叹“一个数字还能塞这么多标记位”
  • 读 GC 的堆遍历:理解对象引用关系,这能回答“为什么我用裸指针访问对象会崩”的经典问题

每一层都有大量可挖的细节,但记住一个原则:不要试图一次性读完整条调用链的所有代码,先按“入口-循环-结构”三点建立框架,然后慢慢补血肉。

10. 几个我踩过的坑与经验总结

最后分享几个读源码过程中实打实的经验。

第一,源码阅读要配合运行时实验。只看代码很容易陷入“好像懂了,其实没懂”。每读一个模块,就动手跑一个能触发该模块的小例子,用-dump-bytecode验证假设。比如读CallFrame之前,先跑一次函数递归调用并打印字节码,观察Call指令怎么排列,再回来看代码就会顺畅得多。

第二,不要被模板代码吓住。Hermes 里有大量模板和宏,比如解释器的指令分发。初读时你不需要理解每一个模板参数,先把注意力放在“这个函数输入什么、输出什么”上。等框架立住了,再回头啃模板展开细节。

第三,多利用反汇编输出作为“路标”。我的习惯是:遇到一个执行行为,先跑-dump-bytecode拿到字节码,再从字节码指令名称反查源码里的Opcode定义,最后跳转到对应实现。这个路径比顺着调用链往下追高效得多。

第四,要习惯把自己当“对话的另一方”。读主循环时,想象引擎正在跟你对话:你输入一行,它编译一帧,执行一帧,输出一个结果。这个“对话模型”能帮你把抽象的“运行时”具象化。没有谁比你更清楚你敲的每一行 JS 经历了什么。

如果你从这篇里只带走一件事,那就是:入口和主循环,是读任何引擎代码的第一站,但别指望在第一站解决所有问题。框架立住,后面就是水到渠成的事。下一篇我会接着拆HermesValue和CallFrame,那是真正理解“对话如何记住上下文”的地方。

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

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

立即咨询