Ignition解释器(下二)

原本是讲完栈帧,就是dispatch分发的内容,但是又想到,在栈帧的创建这一部分,属于c++和js的交界,还有API ABI 等比较重要的偏底层的通用知识,似乎可以作为比较重点的内容,稍微详细的学习一下。这部分,学习掌握以后,属于 一劳永逸 ,即使版本升级,除了偏移量等细节部分会有变化, 其他大部分核心知识,适用性强,内容相对稳定,值得多学一下。

这部分内容和上部分内容中的很多知识点,属于 螺旋上升反复念叨 的方式,一个知识点 在多个地方多个角度 多次出现 多次捶打, 适合初学者学习。已经对这部分知识熟悉的朋友可以快速浏览过去。

栈帧部分实际内容并不多,主要是反复多角度的捶打导致的篇幅比较大。然后,感觉 片场宇宙 的设定,似乎有点喧宾夺主抢注意力了,而且后面是分发 还有优化 内存管理 等内容,这么一整套下来,又需要新增不少设定,所以在考虑,是不是后续不再增加新设定,新出现的术语概念名词,都直接使用。

1.前文总结及补充知识点

在前面,我们说 JSEntry Stub这段极速的底层汇编跳板代码,会在操作系统的物理堆栈上,强行砸入一个极其特殊的栈帧——Entry Frame (入口帧),在我们的片场宇宙设定中,jsentry stub 的作用是:驻守 C++ 与 JS 边界。砸下防爆门,并在 Entry Frame 中保存返回地址/寄存器现场/入口状态,确保 C++ 与 JS 间的调用契约被完整维护,防止异常穿透。

我们稍微详细的说明一下:

JSEntry Stub 是一段可执行的汇编代码 (Code): 它是 V8 引擎提前写好的一段极短、极速的机器指令。它的本质是 CPU 要去执行的“动作”。

Entry Frame 是一块物理内存数据 (Data/Memory): 它是操作系统的调用栈(Call Stack)上的一段连续内存空间。里面装着 C++ 的寄存器备份、防爆钢印(Marker)和入口状态。它的本质是存放数据的“容器”。

JSEntry Stub 这段代码的运行,在物理栈上砸出了 Entry Frame 这个结构。 当 C++ 宿主下达开机指令时:CPU 开始执行 JSEntry Stub 的代码。JSEntry Stub 代码里有一系列的 push 指令,把 C++ 的寄存器压入栈中。随着这些数据的压入,栈顶指针(SP)移动,Entry Frame 这个物理结构就正式在内存中诞生了。

从它们的生命周期上来说,它们的配合贯穿了 JS 执行的始终:

  • 开机时(建门): JSEntry Stub 运行,把 C++ 现场保存在 Entry Frame 中,然后跳入 JS 世界。此时 Stub 暂停执行,而 Entry Frame 则躺在栈底。
  • JS 运行时(守门): JS 解释器在 CPU 里运行,在 Entry Frame 之上盖起了一座座 JS 栈帧(剧组)。Entry Frame 提供逻辑上的 但是异常坚硬的边界,防止 JS 破坏 C++ 内存。
  • 结束/熔断时(拆门与恢复): * 如果 JS 正常执行完毕,或者发生未捕获异常一路撕毁栈帧撞到了 Entry Frame 上,控制权会再次交还给 JSEntry Stub 这段代码。JSEntry Stub 重新接管 CPU,它从 Entry Frame 这个内存结构里,把当年存进去的 C++ 寄存器数据原封不动地掏出来,塞回物理 CPU。最后,Entry Frame 这块内存被废弃(门被拆除),C++ 宿主完美复活。

从它们的归属权属上来说,建门与拆门完全由 JSEntry Stub 执行

对于 Entry Frame 的物理写操作(申请内存、压入 C++ 寄存器、压入防爆钢印、销毁并恢复寄存器),这些动作只由 JSEntry Stub 这段代码执行。当大老板(C++)要求执行 JS 时,只有jsentry stub有资格去物理栈上砸出这扇门。当 JS 杀青或彻底熔断时,也只有它有资格把这扇门拆掉,把 C++ 的寄存器原封不动地还给 CPU。

从使用权上来说,当防爆门(Entry Frame)建好,并且 JS 解释器已经在里面热火朝天地拍戏时,JSEntry Stub 这段代码其实处于“休眠/挂起”状态(因为它在调用解释器后,就一直在等待解释器 Return 返回)。

在这段漫长的拍摄期间,防爆门静静地矗立在栈底,此时如果有其他 V8 内部实体需要对它进行“只读操作”,是不需要唤醒 JSEntry Stub 的:事故调查员(Stack Walker / 栈遍历器): 当 JS 发生未捕获异常,栈遍历器开始疯狂撕毁 JS 栈帧往回找补时,它会去读取 Entry Frame 里的防爆钢印(Marker)。这个读取动作是由 V8 底层的 C++ 异常处理类(如 v8::internal::StackFrameIterator)执行的,而不是 Stub。调查员只是借用了 Stub 建造的路标。场地清道夫(Garbage Collector / 垃圾回收器): 当 GC 触发扫描时,它需要遍历整个调用栈寻找活对象的指针(Root Scanning)。GC 扫描器会一路向下扫,当它扫到 Entry Frame 时,它会读取里面的结构,从而知道“往下是 C++ 的地盘了,没必要再用 JS 的规则扫描了”。这个扫描动作也是由 GC 的 C++ 代码独立执行的。

对于 Entry Frame 的生死存亡(物理结构的创建与销毁),全权由 JSEntry Stub 执行;但在 Entry Frame 存活期间,它作为一座完美的物理界碑,V8 里的 GC 扫描器、异常回溯器等其他 C++ 核心组件,都可以自由地查阅它。



2.API和ABI

在深入栈帧细节之前,必须先理清两个极易混淆但至关重要的概念:API 和 ABI。这也是 C++ 与 JS 能够跨语言交互的契约基础。


API(Application Programming Interface) 是程序员在源码层面直接打交道的接口。它规定了:

  • 函数的名字与签名。
  • 参数的类型与顺序。
  • 返回值的类型。

比如:

int add(int a, int b)    //c语言示例

这里告诉你:

  • 函数名叫 add
  • 传两个 int
  • 返回一个 int

这就是 API 的层面。

API 是源码层面的接口约定,编译器会帮你检查类型是否匹配。

扩展一下:在很久以前,在win32环境下,API仅指操作系统提供的应用接口,比如 messagebox createwindow 等等,后来,API的概念和范围不断延申,现在连网络服务也可通过API的形式提供。但其作为源码的接口约定内核不变。


ABI(Application Binary Interface) 是编译后的二进制代码之间交互的规则。它关心的是代码编译成机器码后,双方如何“对接”。这是 V8 解释器与 C++ 底层交互的关键。

ABI 规定了更底层的细节:

  • 数据布局:结构体字段在内存中如何排列?对齐要求是什么?
  • 调用约定:参数通过寄存器传递还是栈传递?由谁清理栈?
  • 寄存器使用:哪些寄存器由调用者保存,哪些由被调用者保存?

举个简单例子:同样是调用 add(1, 2),API 完全相同,但在 Linux 和 Windows 上编译后的二进制代码可能不兼容 —— 因为 Linux 用 System V ABI,Windows 用 Microsoft x64 ABI,参数放的寄存器不一样。

ABI 是二进制层面的对接约定,它决定调用约定、栈布局、数据布局、寄存器保存规则等。如果两段代码 ABI 不兼容,即使 API 正确,链接在一起也会崩溃。


调用约定

调用约定是 ABI 的核心组成部分,专门解决“函数怎么调用”的问题。 例如,在调用一个函数时,ABI 会明确:

  • System V AMD64 ABI 中,完整的 Callee-saved(被调用者必须保存、返回时必须恢复)寄存器为:rbx、rbp、r12、r13、r14、r15;剩余通用寄存器(rax、rdi、rsi、rdx、rcx、r8、r9、r10、r11)均为 Caller-saved(调用者保存)

1. 为什么是被调用者保存这几个寄存器?

  • rbp (Stack Base Pointer):这是栈帧的“地基”,函数进出必须保持一致,否则栈帧会崩塌,必然是 Callee-saved。
  • rbx (Base Index):历史遗留名字,但在现代 ABI 中,它常用于 PIC(位置无关代码)中存储全局偏移表(GOT)的基址,或者作为通用的“长期存储”寄存器,所以必须保护。
  • r12 ~ r15:这四个是“编号较大”的通用寄存器。在很多 RISC 架构(如 ARM)中,编号大的寄存器通常设计为 callee-saved。AMD64 沿用了这一设计哲学,它们非常适合用来存储那些需要跨越函数调用的局部变量。

2. 关于 rsp 的特殊情况

rsp (Stack Pointer) 其实也是一个极其特殊的 "Callee-saved" 寄存器。

  • 被调用函数必须保证在返回时,rsp 的值与进入时一致(也就是把栈平衡回去)。
  • 但我们在讨论通用寄存器保存规则时,通常不把 rsp 算在“需要手动 push/pop”的列表里,因为它是由函数序言和尾声自动管理的,不占用额外的栈空间来保存。

3. r10 和 r11 的特殊用途(V8 相关)

r10 和 r11 不仅是“临时寄存器”,它们在系统调用和 V8 中有特殊地位:

  • 系统调用:在 Linux syscall 指令中,内核会破坏 rcx 和 r11(用于保存返回地址和 RFLAGS),所以它们绝对不能存重要数据,必须是 Caller-saved。
  • V8 中的用途:因为它们是 Caller-saved(也就是“用完即弃”),V8 的优化编译器非常喜欢用它们来存储那些“短命”的中间计算结果,因为不需要担心保存恢复的开销。

4. Windows x64 ABI 的对比

  • Windows 的 Callee-saved 列表:rbx, rbp, rdi, rsi, rsp, r12~r15。
  • 关键区别:在 Linux (System V) 下,rdi 和 rsi 是传参寄存器,属于 Caller-saved;但在 Windows 下,rdi 和 rsi 是 Callee-saved!
  • 这意味着:在 Windows 上写汇编,如果函数里用了 rdi/rsi,必须在开头 push 保存,结尾 pop 恢复。而在 Linux 上完全不需要。

两大主流 ABI 标准

由于 ABI 直接操作寄存器和物理栈,它必须与 CPU 架构和操作系统强绑定。

第一个:System V ABI (x86-64 上的 System V AMD64 ABI)

上世纪 80 年代,AT&T 发布 UNIX System V,确立了一套通用的二进制接口标准。如今,它已成为绝大部分的类 UNIX 系统的基石。

  • 名称:System V AMD64 ABI(针对 64 位 x86 架构)。
  • 适用范围:Linux、macOS (Darwin)、BSD 等。
  • 特点:参数传递优先使用寄存器,效率极高。

第二个: Microsoft x64 ABI (Windows 标准)

微软在 64 位 Windows 上采用了自创的调用约定,与 System V 不同。

  • 适用范围:Windows x64。
  • 主要区别:Windows ABI 只有 4 个寄存器用于传参,且对栈帧有特殊的对齐要求。
  • Windows ABI 还有一个 Shadow Space(影子空间 / 预留栈空间)。即调用者必须在栈上为这 4 个寄存器预留 32 字节的“坑位”,不管用不用。

也许你能在其他地方看到不同的组合起来的名称,记住它们的意思就可以了: System V 是unix和类unix标准,microsoft windows 是微软的标准, x86 x64 x86-64 ARM AMD64 这些是cpu相关硬件平台。


API和ABI对 V8 的重要性

对于 V8 的 Ignition 解释器及其底层架构而言,跨越 C++ 与 JS 的边界是家常便饭,这必须严格遵循 ABI 契约:

  1. 从 JS 调用 C++ 内置函数: 当解释器需要执行 C++ Runtime 函数时,不能直接硬调。它必须通过 CEntry Stub 等机制,严格按照当前硬件平台和操作系统的 ABI 规则,将参数正确地放入指定的寄存器或物理栈中,然后再跳入 C++ 代码。
  2. 执行 JSEntry Stub (从 C++ 进入 JS): JSEntry Stub 作为被调用方,必须遵守 ABI 约定,将 C++ 的“被调用者保存寄存器”(Callee-saved Registers)妥善压入 Entry Frame。只有这样,当 JS 剧组杀青返回 C++ 时,大老板C++ 宿主才能从内存中取回完好无损的寄存器现场,仿佛一切都没发生过。


3.栈帧专场---- Entry Frame入口帧概览

ignition解释器(下 二)这一整个部分,都将是栈帧的相关内容。dispatch放在 (下三) 部分学习。


Entry Frame 入口帧 总体概览:

前面我们说了,Entry Frame(入口帧)是一扇防爆门。那么,这扇门的建造过程是如何的?

当大老板(C++ 宿主)扣动 Execution::Call 扳机时,执行流并没有直接撞进 JS 的世界,而是先进入了一个极速的汇编跳板——JSEntry Stub。

JSEntry Stub 在我们的片场设定里,是一个双向海关,在这个海关通道里,汇编级代码会以极其干脆利落的动作,完成 C++ 物理世界向 JS 虚拟世界的切换,并在操作系统的物理栈上,砸出那个叫 Entry Frame 的防爆门。

它的标准流程包含了以下四个动作:

第一步:封存 C++ 的历史(物理寄存器保护) 海关的第一步,是保护大老板的财产。 汇编代码会立刻把当前物理 CPU 里所有关键的 C++ 寄存器(包括 rbp 栈底指针、以及系统 ABI 约定的所有非易失性寄存器 callee-saved registers)统统 Push 压入栈中。 动作结果: 此时,C++ 的执行现场被完美冻结在了物理栈的深处。哪怕等会儿 JS 解释器在 CPU 里把寄存器翻个底朝天,C++ 的老家也绝对安全。

第二步:打下界碑与防爆钢印(Entry Frame 核心结构) 历史封存完毕,海关开始铸造防爆门。 它会在栈上压入几个极其特殊的标记字段,其中最核心的是一个叫做 Entry Frame Marker 的防爆钢印。 动作结果: 这是一枚物理路标!未来如果 JS 代码里发生未捕获的报错,V8 的栈遍历器(Stack Walker)在疯狂撕毁 JS 栈帧往回找补的时候,只要一摸到这个特殊的钢印,就会立刻知道:“到头了!前面是 C++ 的地盘,绝对不能再往前撕了!”这就是防爆门能够逼停异常的物理根源。

第三步:参数的“跨界偷渡”(C++ Array 转 JS Stack) 大老板 C++ 传过来的参数,通常是一个指向数组的 C++ 指针(argv)。JS 解释器是不认识这种东西的。 海关 JSEntry Stub 会在这里充当苦力,把 C++ 数组里的参数一个个剥离出来,严格按照 JS 的调用约定,依次重新压入物理栈中。 动作结果: 经过这一步,C++ 的数据结构被完美“洗白”,变成了 JS 解释器能够无缝读取的标准化栈上参数。

第四步:交出 Context 钥匙,点火起飞! 一切准备就绪。海关最后做的一件事,就是把大老板指定的全局逻辑摄影棚(Context)的物理指针,装载到特定的寄存器中。 然后,执行一条汇编的 Call 指令,将执行流纵身一跃,跳入 InterpreterEntryTrampoline(解释器入口跳板)。 动作结果: 控制权正式移交!男一号 Ignition 解释器接管 CPU,第一个真正的 JavaScript 栈帧即将拔地而起。

当 JS 脚本在里面狂奔完毕后,不管它是载誉归来,还是弄得一团糟,控制流最终都会回到 JSEntry Stub 这个海关。在这里,Entry Frame 迎来了它生命周期的终局,通常有两种结果:

结果一:完美杀青(正常 Return) 如果 JS 脚本顺顺当当执行到了最后一行 return,执行流跳回 Entry Frame。 海关的动作极其优雅:它将 JS 返回的结果放入约定的寄存器(rax)中作为战利品。然后,把刚才“第一步”里冻结在栈底的 C++ 物理寄存器,原封不动地 Pop 弹回 CPU。 最后,指令一转,时空无缝衔接,大老板 C++ 就像只是打了个盹一样,拿着 JS 的返回值继续去跑后面的 C++ 逻辑。

结果二:紧急熔断(未捕获异常抛出) 如果 JS 脚本里出现了 throw new Error() 且没有 try-catch,一场灾难性的栈展开(Stack Unwinding)发生了。 当异常撕毁了所有 JS 栈帧,重重撞在 Entry Frame Marker 防爆钢印上时,海关被触发紧急预案: 它会立刻拦截这个 JS 的 Error 对象,将其包装成一个 C++ 可以理解的异常信息(存入 V8 内部的 pending_exception),强行切断 JS 的执行流。 随后,它依然会尽职尽责地执行寄存器恢复动作,把 CPU 完好无损地交还给 C++ 宿主。 结果就是:JS 的世界毁灭了,但 Node.js / Chrome 的主进程因为这扇门的熔断保护,活了下来。


4.栈帧专场---- V8中的各种 帧

一、V8 复杂的“多帧共存”生态

这是理解整个 V8 执行流的重要根基。

很多人第一次接触 V8,会认为:“发生一次函数调用 → 压入一个栈帧”。
但这根本不是 V8 的真实世界。如果去抓取 V8 的调用栈,会发现,调用栈上通常会密密麻麻地交织着各种不同类型的 Frame:

  • Entry Frame(入口帧)
  • Exit Frame(出口帧)
  • Stub Frame(汇编短代码帧)
  • Builtin Frame(内置函数帧)
  • Interpreter Frame(解释器帧 / JS 真正运行的地方)
  • Optimized Frame(TurboFan 优化的机器码帧)

为什么 V8 要搞出这么多花样?
因为不同的 Frame 拥有完全不同的内存布局、标记位和生命周期。V8 的垃圾回收器(GC)和异常回溯机制(Stack Unwinding)在扫描物理内存时,极其依赖这些 Frame 类型。如果不做严格区分,GC 把 C++ 的指针当成了 JS 的对象,引擎当场就会崩溃。

所以,在 V8 的世界里,“Frame”只是一个物理总称。真正重要的是:“脚下这个 Frame 属于什么世界?它承担着什么历史使命?”

二、Entry Frame 与 Exit Frame的双向缓冲层

要先明确 Entry Frame 不是 JS 函数栈帧。

它更像是一道“宿主世界(C++)和 V8 虚拟世界之间的物理防爆门与缓冲层”。
为了彻底理解它,我们需要引入它的孪生兄弟 —— Exit Frame。它们经常是成对出现的“双向边界”:

  • Entry Frame(入口防爆门):负责 宿主世界 → V8 世界。
    当大老板 Node.js(或C++)想要执行一段 JS 脚本时,必须穿过这道门。
  • Exit Frame(出口领事馆):负责 V8 世界 → 宿主世界。
    当 JS 代码里调用了 console.log、fs.readFile,或者执行了某些底层的 C++ Native 绑定时,V8 不能直接用 JS 的规矩去调 C++。它必须建立一个 Exit Frame 来保护当前 JS 的执行现场,然后“切换国籍”,使用 C++ 的规矩去呼叫宿主。

三、为什么必须建 Entry Frame?

墙外,是严谨刻板的 C++ ABI(应用程序二进制接口)世界。
当 Node.js 的 C++ 代码执行 CallJSFunction(...) 准备进入 V8 时,当前的 CPU 完全被宿主操作系统的 ABI 法则支配(比如 Linux x64 System V ABI,前 6 个参数必须放进特定的 VIP 寄存器)。

但墙内,是男一号(Ignition 解释器)的天下。
Ignition 根本不想关心什么 Linux ABI 还是 Windows ABI,它有一套为了追求极致性能而修改过的V8 内部专属调用约定。

两套物理法则发生了剧烈冲突,怎么办?
于是,V8 必须在边界处砸下一个 Entry Frame。在我们的片场设定中,它是一道厚重的防爆门,是一道缓冲层,在防爆门的内部,它就像是“片场的海关大厅”,主要负责三件事:

  1. 隔离规则差异:把外部标准的 C++ 参数,转换成 V8 内部认识的格式。
  2. 保存宿主现场:把大老板(C++)的非易失性寄存器(Callee-saved registers)死死压在物理栈底,防止被 JS 运行时踩坏。
  3. 接管控制权:彻底切换 VM 的内部执行状态。

四、JSEntry Stub 与 Runtime谁是真正的劳动人民

那么,是谁亲手在物理内存上砸下了这道防爆门,建起了用来状态转换的“防爆门/海关大厅”(Entry Frame)?

在 V8 的工程实现中,核心的包工头只有一个:JSEntry Stub(宿主进入 V8 的入口汇编胶水代码)。

这里有一个容易混淆的细节。许多人会把 HandleScope(句柄作用域)的创建、TryCatch 的挂载,也算进 Stub 的职责里,认为它们是一起发生的。

严格来说,这种看法在物理分层上是错位的

我们需要把“进入 V8 前的准备阶段”切分成清晰的两层世界:一层是高级语言的“行政准备”,另一层是纯汇编的“物理施工”。

第一层:上层 C++ Runtime 的“行政审批”(如 Execution::Call)

当 Node.js 或 Chrome 的 C++ 宿主决定调用一段 JS 函数时,它首先调用的是 V8 暴露出的高级 C++ 调用入口(例如内部的 v8::internal::Execution::Call)。

在这个阶段,程序依然跑在纯正的 C++ 世界里。这里干的活儿,全都是高层逻辑的“行政准备”,并不涉及任何底层的栈帧破坏与重建:

  1. 挂载 TryCatch 异常护身符:
    C++ 大老板很清楚,JS 剧组里那帮人经常写出 throw new Error() 这种爆炸代码。为了防止 JS 的错误把整个 Node.js 进程给炸毁,大老板必须提前在 C++ 侧准备好一个 TryCatch 处理块。这就相当于在开机前,先在片场外围买好了一份“医疗保险”。
  2. 搭建 HandleScope(句柄作用域):
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐