c++ 写x64汇编 5参数_V8 引擎如何生成 x64 机器码——以浮点数加法为例

4f694f50a15645ff8d59577821b5c9af.png

摘要

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

为什么 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 %dn", a == d);
    printf("a == e is %dn", a == e);
}

在 Xcode 中运行结果如下:

baf33eafce5d0a2bea728c2cee3c38d3.png

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

b585b5f8501de82f2aeb2031e52a8635.png

双精度浮点数在内存占 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 在内存中的表示是完全一样的。

1af5fe18e7902fe6bb625823553cf7e0.png

可在 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 标准可以精确表示

本节内容总结如下图:

ea64b4b118e6b3dbee64ef6a1c918725.png

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

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

b259a317b9a0d0c6997c4c33ba4e63bd.png
addsd -0x10(%rbp), %xmm0

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

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

83629ab389cca8564c4c347fadda2662.png

打开终端,将目录切换至 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 代码已被编译为机器码,和浮点数加法相关的反汇编截取如下:

599cf75aa74912e11584743ffcc6e9c8.png

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

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 指令的机器码。

f2cbc3e4ef32945d79ff1dfa5a4aca1f.png

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,可参考下图。

6d58e4f3cd5eaf39f8f0a44c096b35cf.png

41b06e14f40abdcd45b31d1bddfb9227.png

从 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 指令。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值