二. Ignition解释器(上)
这是第二篇的上半部分,我们主要学习ignition V8的解释器的一些基础和前置知识。
这部分内容,主要是以了解为主,所以在学习的时候,除了第一篇中说的 有些细节做了省略 有些边界情况做了简化表述以外,也不需要过多的深入。 读完了就好。 目的就是对于ignition解释器的一个祛魅。
当然,感兴趣的朋友也可以认真阅读,本文内容依旧保持一定的深度,依旧是力求高准确性,符合规范,贴合实现。 但需注意的是,为了文章的可读性,有可能在前面 仅做简化的通俗的描述,在后面做了详细的讲解描述,所以,可能的情况下,请尽量阅读全文。
通过上一篇的解析,我们手里已经拿到了一份完整的AST抽象语法树。 但是对于cpu来说,它只认指令。
在早期版本的 V8 中,JavaScript 代码在解析完成后,会直接被编译成本机机器码执行。这种方式运行得很快,但机器码体积通常比较大,也不够灵活。
在后来,直到现在,V8不再直接生成庞大复杂的机器码,而是生成了一种非常紧凑 小巧的中间代码,就是 字节码 bytecode。
但是cpu也不认识字节码,V8使用 ignition 模拟了一个可以运行字节码的环境,相当于一个极其轻量的虚拟机。
1 . ignition是什么
Ignition 是 V8 引擎里的字节码解释器,它把 JavaScript 的 AST 编成紧凑的字节码,然后在虚拟机里解释执行,同时收集各种信息,供优化器生成更快的机器码。
ignition的工作 最主要是下面三个:
翻译:把 AST 翻译成字节码。
执行:在一个虚拟的寄存器机里执行这些字节码。
收集情报:在执行过程中,偷偷记录类型信息,为后续的优化做准备。
Ignition 在软件层面模拟了一套 类 CPU 的工作模式:
它不是只用栈,也不是纯寄存器机,而是采用"虚拟寄存器 + 累加器(Accumulator)"的模式。
这里的寄存器并不是 CPU 里的硬件寄存器,而是函数栈帧里的一些槽位(slots),只是把它抽象成寄存器来使用,看起来更像 CPU 工作方式,但成本非常低。
Ignition 还设计了一个解释器独占的累加器 acc。大多数运算的中间结果都会临时存放在 acc 里,这样指令只需要携带一个参数,就不用全部写出所有的目的寄存器,字节码就会变得非常短小。
当一个函数开始执行时,Ignition 会在内存的 栈 (Stack) 上划出一块地盘, 跑起来的时候,本质上就是在那块栈内存上,不停地把数据从一个位置搬到另一个位置,计算,然后再搬回另一个位置,就是这样搬来搬去。ignition操作的并不是真正的cpu内的寄存器,它操作的是内存位置/槽位。
2 . 几个简单的指令
ignition就像一个勤劳干活的老师傅,他有一个随身腰包,他不管干什么活,不管需要用什么工具,都是优先用随身腰包里的东西, 干完活得到的结果,也顺手塞回自己的随身腰包里。
老师傅有个随身腰包, 不管干什么,都优先使用随身腰包。
摆放各种材料的原料架,一格一格的, r0 ,r1,r2.。。。编着号,上面摆满了原材料。
好了,这就是ignition的架构。就是这么简单。
这个随身腰包,就是老师傅最重要的东西了。师傅偷懒全靠它了。
假如没有这个随身包,想象一下,老板下达指令非常啰嗦麻烦:
把 r1原料架 的东西 和 r2原料架 的东西拿下来,拼装好,然后再放回 r3原料架 去
(类似于指令:ADD r1, r2, r3)
而老师傅有了随身包,就简单了,老板只需要喊一声:
"去把 r2 原料架的东西拼进来!"
(指令:Add r2)
老板完全不需要废话"跟谁拼"(默认跟包里的东西拼),也不需要废话"拼完放哪"(默认拼完还放在包里)。
这就是 Ignition 的核心节省秘籍。通过强行规定"随身包优先",每一条指令都能省掉好几个参数的位置。成千上万行代码跑下来,省下的内存就是一个天文数字。
这个老师傅的随身包 就是累加器,原料架 就是内存位置/槽位
-
Lda(Load Accumulator) ---Lda 是个前缀,实际使用时,后面可跟很多合法的数据来源,比如 LdaSmi、LdaConstant、LdaUndefined、Ldar 等等。。。这是使用最高频的指令,因为所有的活 都得在随身包里干,所以第一步,基本上都是把东西装进包里。
- 指令 :
Ldar r1(Load Accumulator from Register r1) - 动作 :老师傅走到 r1 原料架,把那里的材料拿下来,塞进随身包里。
- 状态:此时,随身包里的东西 = r1 的东西。
- Ldar 这个指令 很好记忆,ld是装载,a是累加器,r是寄存器,ldar就是装载累加器from寄存器, 把寄存器的内容装进累加器。
- Ldar r1 就是把寄存器r1里的东西 装进累加器。
- 老师傅把r1的原料装进随身包里。
- 指令 :
-
Star(Store Accumulator)活干完了,结果总不能一直烂在包里,得腾出地方干下一票,或者把结果存起来。
- 指令 :
Star r2(Store Accumulator to Register r2) - 动作 :老师傅把随身包里刚刚加工好的成品掏出来,放到 r2 原料架上去。
- 状态:此时,r2 原料架的东西 = 随身包里的东西。
- star 这个指令,也很好记忆,st是储存,a是累加器,r是寄存器,star就是存储累加器里的东西到寄存器。
- 指令 :
-
Add/Sub...这是真正的关键步骤。
- 指令 :
Add r0 - 动作 :老师傅走到 r0 原料架,拿个东西,直接跟随身包里原本的东西进行合体(相加)。
- 状态:随身包里的东西 = 原包里的值 + r0 的值。
- 重点 :注意 结果依然留在包里,老师傅并没有急着去储存结果。
指令都很简单,ld load ,st store ,a accumulator ,r register
类似的 ldglobal stglobal ldarg0 ldcurrentcontext 也都差不多,
基本上都是 动作+对象 的模式。
另外需要注意的是,累加器 Accumulator 通常会写作 acc。虽然它叫"累加器",但千万不要理解成它只能做加法运算。从本质上讲,acc 就是解释器里唯一的"通用临时寄存器":当前这条字节码要处理的值,或者运算后的中间结果,几乎都会暂时放在这里。
acc 里可以装任何 JavaScript 的值,比如常量、小整数、字符串、对象引用、
undefined等。之所以要设计这么一个"统一的临时位置",就是为了让大多数字节码只需要写明"另一个参与运算的对象是谁",而不用每次都额外声明多个寄存器参数,从而让字节码更短、更规整,也让解释器实现更简单。Ldar r1 ; acc = r1 Add r0 ; acc = acc + r0 Star r2 ; r2 = acc 累加器acc的变化。 是不是非常简单。 - 指令 :
3 . 栈帧和槽位
在第一部分解析篇里 我们也提到过 槽位 这个术语, 上面又提到了,那么,槽位到底是什么呢?
槽位(slot)就是栈帧里一格一格固定大小的"存储单元"或"格子",用于按索引存放函数的参数、局部变量、临时值、以及其它元数据。 它不仅是"位置",还隐含了大小、类型(通常是指针/Tagged 值)、地址计算规则和生命周期语义。
内存可寻址的最小单位是 8bit 即一字节, 虽然最小使用单位是8bit 即一字节。但是,因为需要字节对齐 和 机器指针大小的要求,所以 在32位系统上, 需要4字节表示指针, 64位系统上 需要8字节表示指针。 每个槽位的大小,也是按照操作系统机器指针的大小来划分的。 即 64位系统,一个槽位 占 8个字节 。 只有这样,64位系统使用指针寻址时,8字节,即64bit,才能够装得下一个指针。
所以,每个槽位(slot)占 8个字节大小。
当函数开始执行时,运行时runtime在栈上为它分配一段连续内存作为栈帧 。在 Ignition 中,栈帧大致分为三部分:参数区 、固定头部 , 工作区 。栈帧里的每个"格子"称为槽位(slot) ,按索引存放局部变量和临时值,槽位的大小通常等于机器指针大小, 64 位下为 8 字节。
特别注意 :前面我们一般并没有明确的区分栈帧里槽位和字段,现在明确一下,我们仅仅是把工作区中 按索引的格子(r0, r1, r2...)称为槽位(frame slots) 。固定头部里的字段(返回地址、saved FP、Context、BytecodeArray 等)我们不 把它们称为槽位;参数一般称为 argument slots(a0,a1)或单独描述。
严格从 V8 内存视角来看,栈帧中的每一个 8 字节存储单元(无论是 Context、返回地址还是局部变量)在源码中都统称为 Slot。
但在解释器字节码的语句环境中,为了区分功能:
- 我们将固定位置、用途单一的区域称为 "固定头部字段"(如 Function, Context)。
- 我们将用于存储局部变量和临时结果、通过索引动态访问的区域特称为 "寄存器槽位" 或 "局部变量槽位" 或简称为 槽位 。
- 有时候可能会有混用,将栈上某字段也称为槽位,从规范从v8源码上来说,完全没有错误,只是因为手抖或者写快了,没有按照我们通常的按功能区分的约定称呼。
- 当然,你可以按照自己的意愿,区分或者不区分,都是正确的。前提是 你要知道观察的视角的不同。
下面我们详细介绍一下栈帧的结构。
栈的生长顺序,是从高地址到低地址,即入栈早的在高地址, 最后入栈的处于栈顶 在低地址。
我们首先介绍个术语:Tagged Pointer
Tagged Pointer 标签指针
64位系统中,每个槽位都是 8 字节(64 位),V8 在这里面存数据时,使用了一个编码技巧,叫做 Tagged Pointer (带标签的指针)。
在静态语言立,比如 C++ 这种静态语言,编译器知道变量是
int还是Object*。但在 JavaScript 中,类型是动态的。如果 V8 为每个变量额外存一个"类型字段",内存消耗会翻倍。 V8 的做法是:把类型信息直接编码进这 64 位数据本身。方法就是复用"对齐留下的低位" 在 64 位平台上,内存地址通常是 8 字节对齐 的。合法地址的二进制形式,最低的几位通常都是 0。 V8 就是使用了这些闲置的低位,用来打上类型标签Tag。
Tagged Value 的分类: V8 把槽位里的机器字统称为 Tagged Value,根据低位标签不同,分为两类:
Smi (Small Integer,标签立即量)
- 特征 :最低位通常为
0。- 含义 :这 不是 指针,这 64 位数据本身就存着一个整数。
- 优势:整数直接住在栈上,不需要去堆里申请内存,速度极快。
- 还原 :使用时,通过右移 (Shift) 运算去掉标签,就能得到整数值。
Tagged Pointer (堆对象引用)
- 特征 :最低位通常为
1。- 含义 :这是一个指向堆内存中对象(HeapObject)的 强引用。
- 注意:它不能直接当做物理地址用。
- 还原 :使用时,必须通过位掩码 (Mask) 运算去掉标签(Untag),还原成纯净的内存地址,才能去访问堆里的对象。
这样使用Tag以后, Tagged value 就像给数据穿了一件"马甲"。Ignition 看一眼马甲(标签位),就知道是整数还是对象。虽然使用前必须"脱马甲"(Untag),但这带来的性能提升和内存节省是巨大的。
另外需要注意,使用tag标记, 能直接判定的类型集合很小(主要是 Smi 或HeapObject),更细的类型,需要读取对象头来获取。
那么 ,栈帧的结构是怎样的呢?它的组成如下:
-
第一层 参数区 Arguments
当调用一个函数时,调用者Caller需要给它传实参,同时还有个隐形参数this,这些内容,都在栈帧的第一层参数区。
- Receiver (this) :
- 这是个隐形参数。当你写
obj.func()时,obj就是 Receiver。它是参数列表里的隐形老大哥。
- 这是个隐形参数。当你写
- Arguments (
a0,a1...) :- 这就是
function foo(x, y)里的x和y。 - Ignition 给它们编的号是
a0,a1... - 要注意的是,这里的
a代表 Argument,不要和 Accumulator 搞混了。
- 这就是
- Receiver (this) :
-
第二层 固定头部 Fixed header / fixed frame part
这是整个栈帧中最重要、最关键的区域。
既然 Ignition 是个软件模拟的 CPU,那 CPU 运行时需要的那些状态 比如"我现在运行到哪一行了?"、"我的环境是谁?" 。。。等等信息, 都是存在哪的?
没错,就存在这儿,固定头部。
每个函数栈帧的中间,都夹着这么一块雷打不动的区域,保存着维持虚拟机运行的元数据
它里面的主要内容:
-
Return Address (返回地址):
- 作用:等这个函数执行完,底层调用栈就会根据这个返回地址,跳回调用处继续执行。
- 这里腰注意,返回地址, 是控制流,是返回的应该到代码的哪个位置去继续执行。
-
Caller's Frame Pointer (上一层栈帧指针):
-
作用:链表指针。当前函数执行完、调用者的栈帧在哪里?
-
这样依旧要注意,这个指针,指的是数据,上个字段返回地址,是控制流的返回,这里
-
的上层栈帧指针,是控制流返回以后,继续执行, 应该从哪里去找变量,返回的是那个
-
栈帧,可以理解为数据。
-
在理解上,还可以大致认为,
-
返回地址是等这个函数执行完,要回到哪一行继续执行代码,也就是控制流该跳回哪里。这是时间上的返回 ,代码继续从哪里跑。
上一层栈帧指针是调用我的那个函数,它的栈帧从哪里开始?
这是空间上的返回 ,要去哪一块内存里继续访问局部变量和作用域数据。
-
-
Context (上下文指针)
-
这是什么 :它指向 堆内存 (Heap) 中一个叫做
Context的对象。 -
为什么要它:
如果函数里用到的变量是自己的 let a,直接去栈上找(r0)。
但如果用到了闭包变量(外层函数的变量),Ignition 必须拿着这个 Context 指针,去堆里的上下文链表上一层层找。
-
地位 :它是连接 "栈世界(临时数据)" 和 "堆世界(持久数据)" 的唯一桥梁。
-
这个概念非常重要,值得我们深入了解。另外插一句,虽然说 这整个部分都可以了解为主,但是如果认真学习,能够掌握,还是有很大的用处。比如这个栈帧,对于js开发还是很重要的。
-
上面有个上一层栈帧指针,这里又有个上下文指针,怎么正确而深入的理解他们呢?
-
**上一层栈帧指针是"动态调用链" **
- 回答的问题: "我是被谁调用的?"
- 指向哪里: 指向**栈(Stack)**上的上一级栈帧。
- 作用: 函数执行完(return)后,底层会根据返回地址跳回调用者继续执行,而上一层栈帧指针则用来恢复调用者的栈帧布局,用于继续访问它的局部变量等数据。
**Context 是"静态作用域链" **
- 回答的问题: "我是被定义在哪里的?"
- 指向哪里: 指向**堆(Heap)**上的 Context 对象。
- 作用: 它是数据流的查找路线。当函数访问一个不在自己内部的变量(自由变量)时,V8 会顺着这条链去查找。
-
理解的关键点
-
上一层栈帧指向的是栈内存:栈帧是临时的,函数一返回,栈帧就销毁了。
Context 指向的是堆内存 :这是为了实现 JavaScript 的闭包特性。
-
-
Function / Frame Marker (函数/帧类型标记):
-
这个字段的位置 在
[FP - 16]请注意 这里的偏移值 16 仅是示意。 这种表示方法,后面会详细介绍。 -
这是一个具有多态性 (Polymorphic) 的关键槽位。它用于当前栈帧的身份识别。V8 引擎利用这个槽位来区分当前栈帧是属于标准的 JavaScript 函数调用,还是属于引擎内部的 C++ 调用。
-
V8 的栈遍历器(Stack Walker)在扫描堆栈时(例如进行 GC 标记、生成错误堆栈或反优化时),会读取该槽位的值,并根据 指针标记位 (Tag Bit) 进行判断:
- 如果是对象指针(Heap Object) :判定为 Interpreted Frame(解释器帧)。
- 如果是小整数(Smi) :判定为 Internal Frame(内部帧)。
-
具有两种可能的状态:
-
状态 A:存放
JSFunction(Closure)- 场景:当执行常规 JavaScript 代码时。
- 内容:指向当前正在执行的函数对象(闭包)的指针。
- 作用 :
- 作为资源入口 :解释器通过它访问
SharedFunctionInfo(获取字节码)和FeedbackVector(获取优化反馈)。 - 连接堆与栈:保持对堆上函数对象的强引用,防止被 GC 回收。
- 作为资源入口 :解释器通过它访问
状态 B:存放
StackFrame::Type(Marker)- 场景 :当执行 V8 内部代码(如
EntryFrame,ConstructFrame,BuiltinFrame)时。 - 内容:一个枚举值(Smi),标识具体的帧类型。
- 作用 :
- 路标作用:告诉栈遍历器如何解析当前帧的其余部分(不同类型的内部帧,布局可能不同)。
- 边界界定:标记 JS 代码与 C++ 代码的转换边界
-
-
Bytecode Array (字节码数组指针):
-
这个字段的位置:
[FP - 24]依旧请注意,偏移值 24 仅是示意。 -
这个字段的内容: 一个指向堆内存中
BytecodeArray对象的 Tagged Pointer。 -
定义: 它是解释器 Ignition 真正"读取"和"执行"的指令序列源头。
-
这个字段是一个指针,指向堆(Heap)上的一个
BytecodeArray对象。之所以叫
Array,是因为它的主体部分确实是一串连续的、变长的字节序列。在 V8 的底层 C++ 定义中,凡是符合 "定长头部 + 变长尾部" 结构的对象,通常都以此命名。
- 普通对象 (
JSObject):大小通常是固定的(或者由 Map 描述)。 - 数组类对象 (
FixedArray,ByteArray) :- 它是变长的(在分配时决定大小)。
- 它的主要内容是可以通过索引(Index)访问的序列。
之所以叫
BytecodeArray是为了强调它的存储形态是线性的字节序列。 - 普通对象 (
-
-
Bytecode Offset (字节码偏移量 / PC):
- 作用 :程序计数器。记录当前执行到第几条指令了。
- 位置:
[FP - 32](即StandardFrameConstants::kBytecodeOffset) ,偏移值32,仅为示意,并非确定值。 - 形式: Smi (小整数)。
- 含义: 它记录了当前执行到了
BytecodeArray中的第几个字节。 - 细节 :在正常解释执行期间,PC 状态常驻在真实的物理寄存器,在之中不停的变动,只有在需要外部可见或恢复时(GC/中断/断点/反优化/进入 runtime 等),解释器会把寄存器的值写回栈帧 BytecodeOffset 字段)。恢复时会把它再装回物理寄存器。
-
-
第三层 工作区 Work Area / Virtual Registers
这是栈帧中位于固定头部后面、向低地址延伸的区域。
Ignition 将这段连续的内存槽位,给它们编上号:
r0,r1,r2...虽然它们在物理上只是连续的 8 字节内存格子,但在逻辑上,它们通常划分成了三种截然不同的用途。
1. 显式局部变量 (Explicit Locals)
这是最好理解的部分。它们直接对应你在 JavaScript 代码中声明的局部变量。
生成器(BytecodeGenerator)会按照特定算法(通常与声明顺序相关)为这些变量分配槽位。
- 示例
function demo() {
var name = 'v8'; // 编译器决定:分配给 r0
let age = 10; // 编译器决定:分配给 r1
}
当代码执行到这里时,
r0槽位里就填入了"v8"的指针,r1槽位里填入了10的 Smi 值。-
关键点:作用域分析 (Scope Analysis)
要注意 并不是你写的所有局部变量都能住在这个"栈上的工作区"。
在生成字节码之前,V8 会先进行一次 作用域分析。
- 判断标准 :如果一个变量被内部函数(闭包)捕获 (Captured) 了,它就不能住在栈上
- 原因:栈帧生命是有限的,函数执行完就销毁了。但闭包可能在函数执行完后还需要访问这个变量。
- 结果 :被捕获的变量会被请到堆内存的 Context 对象 中。
- 结论 :所以,能安稳住在
r0, r1里的,都是身家清白的、未被捕获的局部变量。
2. 隐式临时变量 (Implicit Temporaries)
这是在源代码里完全看不到,但机器执行时必须存在的变量。这也是 寄存器分配 (Register Allocation) 算法大显身手的地方。
-
为什么要临时变量?
想象一下计算
var x = a + b + c;Ignition 的累加器(老师傅的随身包)只有一个。
- 先把
a拿进包,把b加进来。包里现在是(a+b)。 - 下一步要加
c。指令要求Add c。 - 发生冲突 :如果
c的获取过程很复杂(比如c是个函数调用getC()),那么在执行getC()的过程中,累加器会被反复使用、覆盖。 - 如何解决 :必须先把
(a+b)的结果找个格子 暂存 Spill 起来。
- 先把
-
物理存在:
Ignition 会在局部变量后面,划出一些格子作为 临时寄存器。
这些格子就像老师傅手边的"小黑板"。
- 复用性:这行代码算完了,这张"小黑板"擦干净,立刻给下一行代码复用。所以即使代码很长,只要不同时通过大量中间结果,Frame Size 也不会很大。
3. 神秘的洞 (The Hole)
这是 ES6 引入
let/const后,V8 在底层实现 TDZ (暂时性死区) 的最硬核手段。在第一部分解析篇中,我们已经详细学习了这个 会吹哨子的警卫thehole,忘记了的朋友,可以复习一下第一篇中的相关内容。这里我们略微的再讲一下。
在栈帧刚刚被创建,但代码还没执行到
let a = ...这一行时,rX槽位里放的是什么?-
对于
var:V8 会把对应的槽位初始化为
undefined。所以在赋值前访问它,拿到的就是undefined(变量提升)。 -
对于
let / const:V8 会把对应的槽位填入一个特殊的 会吹哨子的警卫 ,在内部被称为
The Hole。 -
执行时的检查机制:
Ignition 在执行读取变量的指令(如
LdaRep)时,内置了一段小逻辑:// 伪代码
value = load(r1);
if (value == The_Hole_Value) {
throw ReferenceError("Cannot access before initialization");
}
TDZ 并不难理解,它在物理层面上,就是一个槽位里放着
The Hole,而解释器在读取时不仅读数据,还顺手做了一次安全检查,如果摸到的是警卫,哨子就响。
4. 寻址机制:如何找到
r5?工作区只是一段连续的内存,Ignition 怎么知道
r5在哪?这就要用到汇编里的 基址寻址 了。
-
基准点 :FP (Frame Pointer),指向固定头部的特定位置。
-
有朋友可能会有疑问了,前面说栈帧有3部分,第一部分是参数区,可是为什么FP基准点指向固定头部 ,而不是指向参数部分。
-
参数空间是调用者的区域,因此在语义上它属于caller 的部分 ,而不是 callee 用来分配本地变量/临时的 workspace。FP 作为被调用者的栈帧基准点,通常是不包括参数区的。
-
计算公式:
由于栈是向低地址增长的,所以寄存器的地址是 FP 减去一个偏移量。
Address(rn) = FP - fixed_header_size - (n * slot_size)
其中
fixed_header_size是固定头部的字节长度,slot_size通常等于机器指针大小(在 64 位系统下常为 8 字节) -
示例:
FP指向这里。- 往下走 8 字节... 是
Context[FP-8]。 - 再往下... 是
Function[FP-16]。 - 再往下... 是
BytecodeArray[FP-24]。 - 再往下... 是
BytecodeOffset[FP-32]。(固定头部结束) - 再往下... 终于到了工作区的 r0
[FP-40]。 r0再往下 8 字节是r1。
所以,字节码里的简单指令
Ldar r5,翻译到底层 CPU 动作,就是去读更深处的内存地址。
4 . 调用约定和内存布局
通过前面的学习,我们已经大致了解了栈帧的内容,现在我们就需要在脑子中建立起动态的栈帧模型。
-
建立我们自己的心智模型,内存的想象图
那么 我们怎么想象内存呢? 梯子,高耸入云的梯子,一格一格的代表内存单元。
地面(最底下) :是 高地址 (比如
0xFF...)。对于栈帧来讲,这是稳固的地基。天空(往上看) :是 低地址 (比如
0x00...)。这是延伸空间。最底下是高地址,越往上,地址越低。
有朋友可能会问:"书上或者 V8 源码注释里,通常都是画'高地址在上,低地址在下',栈是'向下生长'的,为什么我们要反着来?"
这其实是为了贴合直觉。 如果你使用过 OllyDbg、x64dbg 或 IDA 等调试工具,你会发现它们的内存视图通常是这样的:
- 上面 显示的是 低地址。
- 下面 显示的是 高地址。
这种视角的好处极其直观:
- 入栈 Push :就像盖楼一样,在现有的楼顶上,往上 再盖一层(地址变低/变小)。
- 出栈 Pop:就像拆楼一样,把最上面的一层拆掉(地址变回高/变大)。
- 栈底:在最下面(高地址),通常存放着调用者的环境,在一个栈帧中,很少变动。
- 栈顶:在最上面(低地址),数据频繁进进出出出栈入栈,变动剧烈。
所以,为了理解起来更顺畅,建议我们在脑海中建立的模型如下:
- 高地址在下(地基)。
- 低地址在上(天空)。
- 栈帧的生长方向 :从下往上,向低地址生长。
-
指针和内存单元
最小刻度:字节 (Byte) 在计算机里,8 bit (1字节) 是内存可寻址的最小单位。
实际步长: 虽然刻度是按 字节 画的,但在 64 位系统里,Ignition 这个老师傅手很大。 他干活时,不会像学友哥那样捏着兰花指去抓 1 个字节。 他每一次伸手,都要抓走 8 个字节 (64 bit)。这 8 个字节合起来,才构成了一个完整的 槽位 (Slot)。
内存对齐: 每一次都要操作 8 个字节,所以,所操作的地址,都是8的倍数:
0 8 16 24 。。。(这些数字仅仅是示意地址是8的倍数)
这就是 内存对齐。
注意: 这并不代表地址
1, 2, 3...7是"空闲"或者"没用"的。 当你向地址0写入一个 64 位指针或者数据时,这个指针或数据用64bit的庞大的身躯填满 了从0到7的所有空间。 只不过,当我们想找到 这个数据时,我们只在这个数据的头部(首地址) 找起。地址 0 :是第一个槽位的门口 。地址 8 :是第二个槽位的门口。
这就解释了我们在前面内容中提到的 Tagged Pointer 原理: 因为地址只在
0, 8, 16这些8的倍数上,所以这些地址的二进制表示,最后 3 位通常是 0。 V8 也是看准了这一点,才敢把这 3 位挪作他用(存类型 Tag)。指针 如何理解?
指针就是地址,之所以说是指针 而不是直接说地址,是因为 指针收紧了地址的概念。
"地址"是物理层面的客观存在,而"指针"是软件层面的主观定义。指针对地址具有收紧和约束作用。
准确的说 指针是对地址概念的一次"收紧"和"赋予语义"。
-
地址
-
本质 :它只是一个冷冰冰的数字编号(比如
0x0000FFFF)。 -
缺陷:它没有任何约束。给你一个地址,你根本不知道那里住的是什么。
- 是 4 个字节的整数?
- 还是 1 个字节的字符?
- 或者是一段可执行的代码?
- 甚至可能是一个无效的垃圾值?
-
状态:如果你只拿到了一个地址,你面对的是未知的、混乱的内存空间。
-
**指针 **
-
本质 :指针 = 地址 + 类型约束(解释方式)。
-
收紧的概念:
当我们定义一个指针(比如 C++ 里的
int* p或 V8 里的Tagged Pointer)时,我们实际上是收紧了对那个内存地址的操作权限和理解方式。- 它告诉 CPU:"别乱猜了,这个地址里存的一定是 对象,而不是整数。"
- 它告诉编译器:"当你去读这个地址时,请按照 8 字节 为单位去读,不要只读 1 个字节。"
在 V8 的 Ignition 中,这种"收紧"体现得更全面:
-
标签 (Tagging):
V8 的指针(Tagged Pointer)利用最低位(Tag Bit)强行规定了语义。
- 如果最后一位是
0:收紧为"立即数"(Smi)。不需要去内存里找,它自己就是值。 - 如果最后一位是
1:收紧为"堆指针"(HeapObject)。必须去堆里找。
- 如果最后一位是
-
隐藏类 (Map/Hidden Class):
当你顺着 V8 的指针找到堆里的对象时,对象的第一个属性通常是 Map(隐藏类)。
这实际上是进一步的"收紧":
- "这个地址不仅是个对象,而且它是一个 数组,长度是 10,元素类型是..."。
最后需要注意,越往底层,比如到了汇编 到了代码调试 ,对于指针和地址的区分,就越趋近于无,很多时候,都是混着叫的,基本上都是使用指针就是地址 这个本质概念了。因为约束已经剥离,只剩本质了。
调用约定
我们在前面学习了栈帧的物理结构:参数在高地址,返回地址在中间,变量在低地址。
这时候,无中生友的朋友又出现了:为什么要这么放?我倒过来放不行吗 我混着放不行吗?
这就引出了一个重要的概念 调用约定。
-
简单来说,调用约定就是 调用者 (Caller) 和 被调用者 (Callee) 之间达成的一份 "协议" 或 "合同"。
想象一下两个人在玩球球:
-
Caller 说:"我会把球抛到你的左手边。"
-
Callee 说:"好的,我会跑到左手边去接球。"
这就是约定。如果 Caller 抛向左边,而 Callee 跑去右边接,球就掉了(程序崩溃)。
-
在 V8 里,这份 协议/合同 规定了三个最核心的问题:
- 参数放哪? (传递方式)
- 是放在 CPU 寄存器里?还是压到栈内存里?
- 如果是压栈,是从左往右压,还是从右往左压?
- 结果放哪? (返回方式)
- 函数算完了,结果放在哪个寄存器里带回去?(通常是累加器/rax)。
- 谁来打扫卫生? (堆栈平衡)
- 参数占用的栈空间,是 Caller 负责回收,还是 Callee 负责回收?
V8的特殊之处:垃圾回收
在c / c++ 中, 标准约定通常会优先把前几个参数放在 物理寄存器 里传递,这样速度会达到极致。
但是在V8的 Ignition 解释器里,我们看到参数几乎都是乖巧的排列在栈上。这是为什么?
因为 V8 有一个幽灵暗卫 ,这就是 垃圾回收器 (GC)。
- GC 的全年无休:GC 需要时刻扫描内存,看看哪些对象还活着(有指针指向它)。
- 寄存器无法跟踪:如果参数散落在各种物理寄存器里,这就很难追踪。
- V8 的折中拖鞋 :确保栈上有一份"可扫描"的备份。
即使某些参数是通过寄存器传进来的,为了方便 GC 撸羊肉串式的扫描,V8 通常也会保证这些参数在栈上有一个确定的位置(或者把寄存器的值"抄写"到栈上)。记得前面说PC的时候,提过一次。
so 这就形成了我们在栈帧图中看到的那样,参数在内存里连续排列,GC 扫起来非常舒服。
再说栈帧的内存布局
在前面我们讲栈帧的结构时,从高地址到低地址,依次是 参数区--固定头部区--工作区,但是
对于栈帧的分界和字段的所有者,并没有详细的说明。 现在我们有了足够的铺垫,可以详细了解了。
我们需要按时间顺序走一遍流程。
这对于理解 FP(栈帧指针)这个"界碑"至关重要。
第一阶段:调用者准备工作
调用者在执行
CALL指令之前,需要先准备好贡品:-
Push 参数:调用者把参数(Receiver, a0, a1...)按顺序压入栈。
- (这是 Caller 划拨的内存,属于 Caller 的栈帧范围,但供 Callee 使用)
-
执行 CALL 指令 :CPU 自动将 返回地址 (Return Address) 压入栈顶,并跳转到 Callee 的代码处。
- (此时,FP 指针依然指向 Caller 的老基准点)
第二阶段:被调用者接手
控制权来到了 Ignition(被调用者)手中,它进门后的头等大事就是建立自己的宗门(栈帧):
- Push Caller's FP :Ignition 做的第一件事,就是把旧的 FP(上一层的基准点)压入栈中保存起来。
- (这一步形成了 Saved FP,也就是栈帧中间的那个连接点)
- Set New FP :Ignition 把当前的栈顶指针 (SP) 赋值给 FP。
- (从此,FP 指向了 Saved FP。新的栈帧基准点正式建立)
- Push Fixed Header:接着,Ignition 依次压入 Context、Function、BytecodeArray、BytecodeOffset 等固定字段。
- Allocate Locals :最后,根据 Frame Size,一次性把栈顶指针 (SP) 往下移,为局部变量(r0, r1...)留出空间,并初始化为
undefined或The Hole。
有了这个流程,我们再看"户口归属"就非常清晰了:
-
FP 及其上方 (参数、返回地址) : 虽然物理上和 FP 连在一起,但它们是 Caller 在第一阶段留下的"遗产"。
- 参数是 Caller 带来的。
- 返回地址是 Caller 带来的。
- Saved FP 是 Callee 为了保护 Caller 而存的。
-
FP 下方 (固定头部、工作区) : 这是 Callee 在第二阶段亲手创建的"资产"。
- Context 是 Callee 找来的。
- 局部变量是 Callee 分配的。
这样,我们再来看栈帧的结构,理解上的逻辑就完全闭环了: Caller 给资源(参数) --- 硬件给退路(返回地址) --- Callee 建地基(保存旧FP) --- Callee 建房子(头部和变量)。
FP和偏移量
在前面我们学习栈帧的固定头部中的字段时,我们使用了 FP加偏移值 的表示方式。
爱琢磨的朋友肯定会有疑问:为什么所有东西都要盯着 FP 看?为什么是这些特定的数字?FP 里面到底装了什么?
- 为什么选 FP (Frame Pointer) 做基准?
你可能会问:"栈顶指针 SP (Stack Pointer) 也是个指针,而且它就在栈顶,为什么不用 SP 来找数据,非要专门维护一个 FP 呢?"
原因就是:SP 是"动"的,FP 是"静"的。
-
SP 的动如脱兔:
在函数执行过程中,Ignition 可能会频繁地入栈、出栈(比如压入临时变量、准备子函数参数)。
这就导致 SP 的位置一直在变。
如果用 SP 做基准,当你找
变量 a时,上一行代码可能是[SP + 8],下一行代码因为压了个临时值,就变成[SP + 16]了。编译器计算起来会疯掉。 -
FP 的静如瘫痪:
一旦栈帧建立完毕(Prologue 结束),在整个函数执行期间,FP 指针就是钉在栈帧的固定位置(Saved FP 那个槽位),雷打不动。
此时,我们以 FP 为原点,向上下看:
往下看(向地基/高地址) :不管栈顶怎么变,参数
a0永远在 FP 往下数 第 2 格的位置(偏移量是正数,如FP + 16)。往上看(向天空/低地址) :不管栈顶怎么变,变量
r0永远在 FP 往上数 第 5 格的位置(偏移量是负数,如FP - 40)。
所以 :FP 提供了一个静态的、绝对的参考坐标系。
2. **FP和地址和内容 **这是初接触的朋友,理解栈帧链表最容易迷糊的地方。
我们要区分三个概念:
- FP 寄存器:
可以简化理解为,这是 CPU 里的一个物理部件(或 Ignition 的虚拟指针)。
- FP 中的内容:
FP中的内容就是 一个内存地址。
这个内存地址是个指针,指向当前栈帧中的一个字段,
同时,这个内存地址/指针,也是当前栈帧的 "零点" 。即
Offset = 0。- FP 指向的内存地址里存的内容:
那么 这个栈帧中的字段,里面的内容是什么?
答案是:Caller's FP (调用者的 FP)。
即:上一层栈帧的基准地址。
这同时也是"栈回溯"的原理:
- 当前 FP 指向
Saved FP。 Saved FP里存着上一层 FP。上一层 FP里存着上上层 FP。- ...
- 这就形成了一条链表。调试器(Debugger)就是顺着这条链子,一层层往上爬,才打印出了完整的调用栈。
- 偏移量 (Offset) 是怎么确定的?
搞懂了 FP 是零点,那么,对于参数和变量的寻址,就非常容易理解了。
让我们站在 FP 这个零点,开始巡视:
A. 往下看:Caller 留下的遗产 (因为我们使用高地址在下,低地址在上的模式)
这里是地址 增加 的方向(Offset 是 正数 +),因为我们在往高地址走。
- Offset +0 (
[FP + 0]) : 就是脚下。这里存的是 Saved FP。 - Offset +8 (
[FP + 8]) : 往下 走 1 格。 这里是 Return Address 。 (为什么是 +8?因为往高地址走了 8 字节。) - Offset +16 (
[FP + 16]) : 再往下 走 1 格。 这里是 Receiver (this) 。 (注:这是雷打不动的专座,离 FP 最近的参数。) - Offset +24 (
[FP + 24]) : 再往下 走 1 格。 这里是 第一个显式参数 (Arguments a0) 。 (注:如果有更多参数 a1, a2... 会继续往下排在 +32, +40...)
B. 往上看:Callee 自己的资产 (低地址区)
关于偏移量的具体值,在前面,特别说明是用于 示意 , V8源码中的偏移如下,暂时可以作为确定值,但是以后很有可能会更改。
FP (基准)
[FP - 8] :
StandardFrameConstants::kContextOffset-> Context[FP - 16] :
StandardFrameConstants::kFunctionOffset-> Function[FP - 24] :
InterpretedFrameConstants::kBytecodeArrayFromFp-> BytecodeArray (解释器特有)[FP - 32] :
InterpretedFrameConstants::kBytecodeOffsetFromFp-> BytecodeOffset (PC)[FP - 40] :
InterpretedFrameConstants::kRegisterFileFromFp-> r0 (Register 0) (工作区起点)这里也需要加一个限定:
在 64 位系统下,Ignition 解释器栈帧的固定头部布局通常如下:
这里是地址 减小 的方向(Offset 是 负数 -),因为我们在往低地址(栈顶)方向爬。
- Offset -8 (
[FP - 8]) : 往上 爬 1 格。 这里是 Context 。 (为什么是负数?因为离天空更近了,地址变小了。) - Offset -16 (
[FP - 16]) : 再往上 爬 1 格。 这里是 Function。 - Offset -24 (
[FP - 24]) : 再往上 爬 1 格。 这里是 BytecodeArray。 - Offset -32 (
[FP - 32]) : 再往上 爬 1 格。 这里是 BytecodeOffset (PC) 。 (注:到这里,固定头部结束) - Offset -40 (
[FP - 40]) : 再往上 爬 1 格。 终于到了 工作区 。这里是 r0。
-
ignition解释器的第一部分,已经完成了,后面将是ignition篇的第二部分BytecodeGenerator。
本想控制篇幅,但是依旧是到了一万一千多字,这部分内容,难度不大,深度不深,主要都是一些前置和基础知识。感兴趣的朋友,多读几遍,都可以理解的。 我觉得 起码比解析篇容易理解多了。
本文首发于: 掘金社区
同步发表于: csdn
码字虽不易 知识脉络的梳理更是不易 ,但是知识的传播更重要,
欢迎转载,请保持全文完整。
谢绝片段摘录。