42

V8 引擎如何生成 x64 机器码——以浮点数加法为例

 4 years ago
source link: https://www.tuicool.com/articles/yyIVvmF
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

 

nuM73iQ.jpg!web

摘要

本文将主要从以下 3 个方面介绍 V8 引擎如何在 Intel x64 平台下生成浮点数加法的机器码。首先,简要介绍一下 V8 引擎;然后,从 C 语言和 x64 汇编的角度举例说明为什么浮点数加法的运算结果不准确;最后,查看 V8 引擎中生成浮点数加法机器码相关的源码。

V8 简介

V8 是一个由 Google 开源的高性能 JavaScript 和 WebAssembly 引擎,其源代码使用 C++ 编写。V8 被用于 Google 的开源浏览器 Chrome 中,同时也被用于 Node.js,以及其他一些软件中。V8 实现了 ECMAScript 和 WebAssembly 标准,可以运行在 Windows 7 及以上,macOS 10.12+,以及 x64、IA-32、ARM、MIPS 架构的 Linux 系统。V8 可以独立运行,也可以被嵌入到 C++ 应用程序中。

V8 源码可在浏览器中查看,这个网站在代码浏览与检索方面的功能十分强大,可以快速的查看 C++ 变量的定义和引用。缺点是不能查看 V8 某个版本或某个 git tag 的源码,但依然强烈推荐。如果想要查看 V8 某个 tag 的源码,可以访问 v8.git 。如果想要在本地编译 V8 源码,在参考 V8 官方文档的基础上,还要注意墙的问题,浏览器能访问 google 不代表终端也能访问 google,终端也需要设置代理。

以 V8 7.7.0 版本为例,源码目录结构如下:

FrEZNjI.jpg!web

有 10 余个文件夹,存放源码的文件夹为 src 和 third_party,做为一个前端,我们真正关心的源码位于 src 目录,其它目录粗略概述如下:

  • 测试:benchmarks、test、testing、infra

  • C++ 项目嵌入 V8:include、samples

  • 编译构建:build、build_overrides、buildtools、gni

  • 文档:docs,实际上文件夹下除了一个链接 https://v8.dev/docs 外,其它内容可忽略

  • V8 编译成功后的产物:out

  • 工具:tools

  • 其它:base 文件夹下有几百行代码,具体作用不详;custom_deps 文件夹下没有代码

V8 绝大多数的源码由 C++ 编写,少数情况会使用内联汇编,我们真正需要看的源码全部位于 src 文件夹下:

uYbQrqV.png!web

V8 编译 JavaScript 代码的过程如下:

FR3a6nm.jpg!web

parser 将 JavaScript 代码转换成 AST ,代码位于 src/parsing 和 src/ast。interpreter 从 AST 生成字节码,并解释执行,代码位于 src/interpreter。当一段代码被执行多次变成热点代码时,V8 optimizing compiler 会把字节码编译生成机器码,代码位于 src/compiler 和 src/codegen。

V8 的早期版本没有解释器,从 AST 直接生成机器码,这种方法的缺点是机器码比较占内存,而且有些代码只会执行一次,比如做业务需求时经常见到的:

function init () {
        var app = new Vue({
            el: "#app",
            data: {
                someKey: someValue
            }
        })
    }

在一个 Web 前端页面里,这段代码只会执行一次,如果没有解释器,这段 JavaScript 代码在一开始就会被编译成机器码,执行。做为 Vue 框架的使用者,我们知道这段代码只会被执行一次。但 V8 无法确定这段代码是否只会被执行一次,执行一次后。机器码还是会继续占用内存。所以在 2016 年,V8 引入了解释器 Ignition。

为什么 JavaScript 浮点数运算不精确

网络上相关的文章非常多,浮点数运算不精确的语言不只 JavaScript,C/C++/Java/Python/Matlab 做浮点数运算也不精确,甚至汇编也是如此。如以下 C 代码:

#include <stdio.h>
    #include <math.h>
    int main() {
        double a = pow(2, 100);
        double b = pow(2, 47);
        double c = pow(2, 48);
        double d = a + b;
        double e = a + c;
        printf("a == d is %d\n", a == d);
        printf("a == e is %d\n", a == e);
    }

在 Xcode 中运行结果如下:

mQB7Vrv.png!web

为什么会是这样的结果呢?因为 x86 CPU 中浮点数表示遵循 IEEE 754 标准,JavaScript 中的浮点数也遵循 IEEE 754 标准,并且为双精度浮点数,双精度浮点数表示如下:

双精度浮点数在内存占 64bit,可分为 3 部分:

  • 第一部分是符号,占 1bit,正数为0,负数为1

  • 第二部分是指数,占 11bit,指数的值为 1023 + 指数

  • 第三部分是尾数,占 52bit,表示小数,本文暂不涉及

以 2 ^ 100 为例,以 2 为基表示为 1 × 2 ^ 100,按照 IEEE 754 标准:

2 ^ 100 大于 0,第一部分符号位为 0。

因为指数是 100,所以第二部分的值是 1023 + 100 ,即 1123,使用 JavaScript 的 Number.prototype.toString 方法,得到十进制数字 1123 的 16 进制表示为 0x463。JavaScript 代码如下:

num = 1123;
    num.toString(16); // 结果为463

由于没有小数,第三部分全是0。

将符号、指数和尾数结合起来, 2 ^ 100 在内存中的表示为 0x4630000000000000,在 Xcode 中查看内存如下,红色框内分别为上文 C 代码中变量 a,d,e 在内存中的表示。可见,a 和 d 在内存中的表示是完全一样的。

fmeeMnv.jpg!web

可在 https://tool.lu/coderunner/ 编辑运行以上 C 代码。

为什么在数学上 a 明明小于 d,可代码实际运行的结果却是 a 等于 d 呢?

若 2 ^ 47 与 2 ^ 100 相加,由于二者指数不一样,前者需要移位,与后者的指数对齐,移位后表示成二进制如下:

0.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 1 × 2 ^ 1100100 // 小数点后面有 52 个 0,1 个 1,共 53bit

由于 IEEE 754 只能表示 52bit 小数, 2 ^ 47 移位对齐后的小数有 53bit,与 2 ^ 100 相加后,这 53bit 小数的前 52bit 都是 0,保留。最低 bit 位是 1,理论上可以进位或舍弃,实际被舍弃。所以在 x86 中 a 等于 d。

若 2 ^ 48 与 2 ^ 100 相加,同理,为与后者的指数对齐,前者需要移位,表示成二进制如下。相加后的结果不存在小数被进位或舍弃的情况。

0.0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001 ×  2 ^ 1100100 // 小数点后面有 52bit,IEEE 754 标准可以精确表示

本节内容总结如下图:

qIFFviB.jpg!web

V8 如何生成浮点数加法的机器码

通过在 Xcode 中查看上一段 C 代码生成的 x64 汇编和 Intel 软件手册可知,x64 平台浮点数加法指令为 addsd( Add S calar  D ouble-Precision Floating-Point Values)或 vaddsd。

NvMVnqZ.jpg!web

addsd -0x10(%rbp), %xmm0

这是 AT&T 格式的 x86 汇编,左边的 -0x10(%rbp) 是源操作数,右边的 %xmm0 是目的操作数;含义是将内存地址为 rbp - 0x10 的 8 字节浮点数,与寄存器 xmm0 相加,并将相加后的结果存入 xmm0 寄存器。

因为在 V8 引擎中 JavaScript 代码最开始是解释执行的,为了生成机器码,以下代码特意在浮点数加法外添加一层看似多余的 for 循环,目的是把一段 JavaScript 代码反复执行,变成热点代码,从而最终生成机器码。

E3quUnU.jpg!web

打开终端,将目录切换至 V8 目录,执行以下命令

./out/x64.debug/d8  --print-opt-code ./fenxiang/double.js

./out/x64.debug/d8 是 V8 编译后的可执行文件 d8,d 是 debug 的缩写;--print-opt-code 参数的含义是打印汇编;./fenxiang/double.js 是刚才我们看到的 JavaScript 代码。执行完命令后,终端有反汇编输出,说明我们的 JavaScript 代码已被编译为机器码,和浮点数加法相关的反汇编截取如下:

Evieamq.jpg!web

从输出可以看到浮点数加法的汇编语句:

addsd xmm1, xmm0

这是 Intel 格式的 x86 汇编,也是绝大多数大学教材中采用的汇编格式。与上文提到的 AT&T 格式最大的区别是操作数方向相反。左边的 xmm1 是目的操作数,右边的 xmm0 是源操作数。含义是将 xmm1 和 xmm0 相加,并将相加后的结果存入 xmm1 寄存器,即 xmm1 = xmm1 + xmm0;

及其对应的机器码 f20f58c8 ,下面开始看 V8 源码,生成这段机器码的方法是 Assembler::addsd。

void Assembler::addsd(XMMRegister dst, XMMRegister src) {
        EnsureSpace ensure_space(this);
        emit(0xF2);
        emit_optional_rex_32(dst, src);
        emit(0x0F);
        emit(0x58);
        emit_sse_operand(dst, src);
    }

addsd 方法内部 3 次调用 emit 方法,3 次调用 emit 方法的参数分别为 0xF2,0x0F,0x58,对比 Intel 软件手册,可知这 3 个参数对应 addsd 指令的机器码。

iamAVrF.jpg!web

emit 方法定义如下:

void emit(byte x) { *pc_++ = x; }

pc 是一个字节指针,*pc ++ 相当于先执行 pc++ 将指针指向下一字节,然后执行 *pc  = x;emit 方法的作用是将传入的机器码,也就是参数 x,写入内存。变量取名为 pc ,个人猜测是因为多数 CPU 都有一个特殊功能寄存器指向下一条要执行的指令的地址,即程序计数器 PC(Program Counter),x86 中相应的特殊寄存器为 IP(Instruction Pointer)。机器码的执行与生成可以理解为两个相反的过程。机器码在执行阶段会连续从 PC 寄存器指向的内存中读取机器码;机器码在生成阶段会连续向 pc_ 指针指向的内存中写入机器码。

至此,addsd xmm1, xmm0 语句对应的 4 字节机器码 f20f58c8,我们已经分析了其中 3 个字节;因 emit_optional_rex_32 方法没有生成机器码,本文忽略。下面开始分析最后的 1 字节机器码 c8 是如何生成的。这里分别从 Intel 机器码定义和 V8 源码两个角度进行分析。

从 Intel x64 机器码定义的角度来看,可以简述如下:因 addsd xmm1, xmm0 的两个操作数都是寄存器,故 bit7 - bit6 是 11;xmm1 的序号为 001,故 bit5 - bit3 为 001;xmm0 的序号为 000,故 bit2 - bit0 为 000。所以 addsd xmm1, xmm0 语句的机器码的最后一个字节为 c8,可参考下图。

6zaM7nA.jpg!web

vMJJFjq.png!web

从 V8 源码的角度来看,emit_sse_operand 方法定义如下:

void Assembler::emit_sse_operand(XMMRegister dst, XMMRegister src) {
        emit(0xC0 | (dst.low_bits() << 3) | src.low_bits());
    }

方法只有一行代码,代码逻辑完全遵守 Intel 手册中关于机器码的定义。首先,bit 7 - bit 6 是 11,对应源码中的 16 进制数字 0xC0。参数 src 和 dst 是用来描述 x64 中 xmm0 和 xmm1 寄存器的 C++ 对象。类 XMMRegister 继承自类 RegisterBase,相应的继承了类 RegisterBase 的 reg code 字段,reg code 字段表示 Intel 软件手册规定的寄存器的序号。src 对象描述的寄存器是 xmm0,相应的 reg code 字段为 0;dst 对象描述的寄存器是 xmm1,相应的 reg code 字段为 1。

class RegisterBase {
        // 此处省略若干行。。。
        protected:
        explicit constexpr RegisterBase(int code) : reg_code_(code) {}
        int reg_code_;
    };

low_bits 方法定义如下,实际上是取了 reg code 字段的低 3 位,回头再看 emit-sse-operand 方法,可知 dst.low_bits() << 3 的值是 8,src.low_bits() 的值是 0。0xC0 | (dst.low_bits() << 3) | src.low_bits() 相当于0xC0 | 8 | 0,结果刚好为 0xC8。

class XMMRegister : public RegisterBase<XMMRegister, kDoubleAfterLast> {
        public:
        // Return the 3 low bits of the register code.  Used when encoding registers
        // in modR/M, SIB, and opcode bytes.
        int low_bits() const { return reg_code_ & 0x7; }
        // 此处省略若干行。。。
    };

至此,addsd xmm1, xmm0 对应的 4 字节机器码全部分析完毕。Assembler 类的大多数方法的名字都对应一条 x64 汇编指令的名字,方法内部的实现是把对应的汇编指令的机器码写入内存,对汇编还有印象的同学应该能在类 Assembler 的实现中看到很多熟悉的 x86 指令。

IBj6beM.jpg!web

I n


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK