You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

21 KiB

26增强更丰富的类型第1步如何支持浮点数

你好,我是宫文学。

我们前面几节课,讲的都是编译器前端的功能。虽然,要实现完善的前端功能,我们要做的工作还有很多。不过,我们现在已经不“虚”了!因为我们已经把编译器前端部分的主要知识点都讲得差不多了,其他的我们可以慢慢完善。

所以现在我们重新把精力放回到编译器后端功能和运行时上来这部分的功能我们还有待加强。在第一部分起步篇中为了尽量简化实现过程我们的语言只支持了整数的运算甚至都没区分整型的长度统一使用了32位的整型。

但这在实用级的语言中可行不通,我们还需要在里面添加各种丰富的数据类型。所以,接下来,我们会花几节课的时间,丰富一下我们语言支持的数据类型。首先我们会添加一些内置的基础类型,比如浮点型、字符串和数组。之后,我们还要通过对面向对象编程特性,支持用户自定义自己的类型。

在这一节课我们先来看一下如何让我们的语言支持浮点型数据。为实现这个目的我们需要先了解CPU为了支持浮点数有哪些特别的设计ABI方面又有一些什么规定以及如何修改汇编代码生成逻辑。而且为了正确地在汇编代码中表示浮点型字面量你还会学到浮点数编码方面的国际标准。

首先让我们了解一下CPU硬件和ABI对浮点数运算提供的支持。

CPU和ABI对浮点数运算的支持

我们先来回顾一下起步篇中关于整数运算的知识。已经有些日子没见到它们了,不知道你还记不记得?你可以和下面我们重点讲解的浮点数的处理模式对比着来看,看看它们有怎样的不同,这也能加强你对这些重点知识的记忆。

X86架构的CPU在64位模式下对整数运算的支持最重要的就是这两个知识点

  • 寄存器。整数运算可以使用16个通用寄存器
  • 指令。对整数进行加减乘除的指令分别是addl、subl、imull和idivl。

另外ABI也针对整数运算做了一些规定比如

  • 参数传递。根据ABI参数传递过程中会使用6个寄存器。超过6个参数则放在调用者的栈桢里
  • 寄存器保护。有一些寄存器要能够跨函数调用保存数据,也就是说函数调用者需要保护这些寄存器,而另一些寄存器则不需要保护;
  • 返回值。根据ABI从函数中返回整数值时使用的是%eax寄存器
  • 栈桢结构。ABI对于栈桢里存放参数、返回地址做了规定并且规定栈桢需要16字节内存对齐还规定了如何使用栈桢外的红区等等。

那么我们再来看看CPU对浮点数运算的支持是怎么样的。

其实最早期的X86CPU只支持整数运算并不支持浮点数运算。如果我们要进行浮点数运算就要用整数运算来模拟。但是这样的话浮点数运算的速度就会比较慢。

而现代CPU解决了这个问题普遍从硬件层面进行浮点运算所以编译器也要直接生成浮点运算的机器码最大程度地发挥硬件的性能。

我们之前说过一款CPU可能支持多个指令集。而某些指令集就是用于支持浮点数计算的。在X86的历史上CPU最早是通过一个协处理器来处理浮点数运算这个协处理器叫做FPU浮点处理单元它采用的指令集叫做X87。后来这个协处理器就被整合到CPU中了。

再后来为了提高对多媒体数据的处理能力厂商往CPU里增加了新的指令集叫做MMX指令集。MMX的具体含义有人说是多媒体扩展MultiMedia eXtension有人说是矩阵数学扩展Matrix Math eXtension。不管缩写的含义是什么MMX主要就是增强了对浮点数的处理能力因为多媒体的处理主要就是浮点数运算。

并且MMX还属于SIMD类型的指令集。SIMDSingle Instruction Multiple Data是一条指令对多个数据完成加减乘数运算的意思因此MMX指令能让CPU的处理效率更高。

MMX指令集后来又升级成为了SSE指令集还形成了多个版本每个版本都会增加一些新的指令和功能。最新的版本是SSE4.2。SSE是流式SIMD扩展Streaming SIMD Extensions的意思。到今天X86计算机进行浮点数运算的时候基本上都是采用SSE指令集不再使用x87指令集除非是使用那些特别早的型号的CPU。

不知道你还记不记得我们之前提过你可以查询自己电脑的CPU所支持的指令集。在macOS上我用下面的命令就可以查到

sysctl machdep.cpu.features machdep.cpu.leaf7_features

然后你会在命令行终端得到关于CPU特性的信息。这些特性就对应着指令集。比如出现在第一个的FPU就对应着X87指令集。你也会从其中看到多个版本的SSE指令集。

图片

如果你嫌上面的命令太长那也可以使用一个短一点的命令。这个命令会打印出更多关于CPU的信息比如CPU所支持的线程数等等。其中也包括该CPU的指令集。

sysctl machdep.cpu

这里我插一个小知识点不知道你会不会有这个疑惑我们操作系统是怎么知道某CPU支持哪些指令集的呢原来X86架构的CPU提供了一个cpuid指令。你用这个指令就可以得到CPU类型、型号、制造商信息、商标信息、序列号、缓存还有支持特性也就是指令集等一系列信息了。所以你看要理解软件的功能经常都需要底层硬件架构的知识。

好了既然我们需要用到SSE指令集那就需要了解一下SSE指令集的特点。并且SSE其实不仅能处理浮点数还能处理整数。不过现在我们主要关心与浮点数有关的特性。这些信息从哪里获得呢当然是从Intel的手册。下面这些信息就来自于《Intel® 64 and IA-32 Architectures Software Developers ManualVolume 1: Basic Architecture》,我给你稍微总结一下。

首先我们看看SSE指令所使用的寄存器。

在64位模式下SSE可以使用16个128位的寄存器分别叫做xmm0~xmm15。

此外SSE还会使用一个32位的MXCSR寄存器用于保存浮点数运算时的控制信息和状态信息。比如如果你做除法的时候除数是0那么就会触发一个异常。而MXCSR寄存器上的某个标志位会决定如何处理该异常是采用内置的标准方法来处理呢还是触发一个软件异常来处理。关于MXCSR的详细信息你可以按需要查看一下手册。

第二我们看一下SSE对数据类型的支持。

SSE指令支持32位的单精度数也支持64位的双精度数。不过单精度数和双精度数的格式都遵循IEEE 754标准。

在SSE指令中寄存器里可以只放一个浮点数这个时候我们把它叫做标量Scalar。还可以把多个浮点数打包放在一个寄存器里这种数据格式叫做打包格式 Packed Data Types或者叫做向量格式。下图就显示了在一个128位寄存器里存放4个单精度浮点数的情况。

图片

打包格式是用于SIMD类型的指令的这样一条指令就能处理寄存器里的4个单精度浮点数的计算。不过我们关注的还是对标量数据的处理所以就先忽略向量数据处理的情况有需要我们再补充。

第三我们看看SSE指令的情况。

SSE对处理浮点数的指令包括向量指令和标量指令。另外在JavaScript中number是以双精度数来表示的所以我们的语言也就可以忽略与单精度浮点数有关的指令直接关注双精度浮点数指令就好了。

我在下面这张表中列出了SSE中与标量的、双精度浮点数处理有关的一些主要的指令

你能看到其实这些指令数量也并不太多很容易掌握。当然SSE完整的指令还是不少的。SSE针对向量数据处理、整型数据处理都有单独的指令还有一些指令是用于管理MXCSR寄存器的状态以及对高速缓存进行管理的。如果你想了解这些可以阅读Intel手册的第二卷Intel® 64 and IA-32 architectures software developers manual combined volumes 2A, 2B, 2C, and  2D: Instruction set reference, A- Z

到此为止我们初步了解了CPU硬件对浮点数计算的支持。接下来我们还需要了解ABI方面的一些知识。

寻找ABI方面的知识当然还是去看手册。下面的信息来自于《System V Application Binary Interface AMD64 Architecture Processor Supplement》我也替你稍微梳理了一下重点。

首先我们要看参数传递。采用SSE指令集传递参数时可以使用8个寄存器从xmm0到xmm7。如果要传递的参数超过8个也是通过栈传递。

再看看返回值。对于64位标量计算来说我们用xmm0来传递返回值就可以了。

最后我们再看对寄存器的保护。SSE指令集用到的寄存器都不需要跨函数调用保存。也就是说Callee不需要保护这些寄存器中的值。

修改汇编代码的生成功能

在了解了硬件对浮点数运算的支持和ABI方面的规定以后我们就可以去动手修改我们语言中汇编代码的生成功能了。

其实为浮点数运算和整数运算生成汇编代码的逻辑整体上是差不多的。你只需要按照CPU架构和ABI的规定做一定的调整就可以了。这些调整主要包括

第一,调整算法所生成的指令。

这个时候算术运算的指令已经从原来的addl和subl等整数运算指令变成了addsd、subsd这些浮点数运算指令。数据移动的指令也从原来的movl变成了movsd。其他指令也做了类似这样的调整。

第二,调整寄存器分配算法。

由于整数运算和浮点数运算使用的寄存器是不同的,所以我们可以针对两种运算分别运行寄存器分配算法来分配寄存器。

第三,调整栈桢维护逻辑。

为什么要调整这个呢是这样如果我们需要把双精度浮点数溢出到栈桢里保存那它要占据8个字节。但是原来的32位整数只需要占据4个字节呀。所以这个时候计算维护栈桢的相关逻辑要调整了包括计算栈桢大小以及需要如何移动栈顶指针rsp等等都需要变动。

第四,调整函数调用逻辑。

在调用函数时如果我们需要传递浮点数的参数那要使用浮点数的寄存器并且可以使用8个。超过了8个之后是放到调用者的栈桢里。对于浮点型的返回值也是放在浮点数寄存器xmm0里的。

但这里其实有一个隐藏的问题如果一个函数既需要传递整型参数又需要传递浮点型参数那该怎么办呢这是一个很有趣的技术点。不仅仅是传这两种参数后面我们还需要传递字符串指针、对象指针还有布尔值等等其实这些在ABI手册里都有规定你直接去查ABI手册就可以了。不过目前我们还是先简化一下我们规定函数的参数和返回值都必须是双精度型的后面我们再去支持混合参数的场景。

做完这些调整以后,我们的汇编代码生成算法,基本上就能够支持浮点数运算了。具体的实现,你仍然可以去查看一下AsmGeneratorLower的代码。

不过,除了上面这些调整以外,还有一个看似很小的技术点,但这个技术点涉及到浮点数运算的原理,所以我们有必要单独拿出来讲一讲。

浮点数的编码标准

编码标准这个小的技术点,涉及到我们如何在汇编代码里使用浮点型的常数,我们也叫它立即数。为什么这是一个问题呢?我们通过示例代码看一下。

我们把param_double.c代码来编译成汇编代码看一下里面是如何使用浮点数常量的。

double foo(double p1, double p2, double p3, double p4, double p5, double p6, double p7, double p8, double p9, double p10);
double bar(){
    return foo(1, 2, 3, 4, 5, 6.1, 7.2, 8.3, 9.4, 10.5);
}

param_double.c的代码很简单。里面首先有一个foo函数的签名但并没有具体实现。然后在bar函数里传了10个参数给foo函数这10个参数都是常数。有的参数是以整数格式提供的但根据语言的设计编译器会把它们强制转换为double。

然后我们用clang -S param_double.c -o param_double.s生成汇编代码。其中bar函数对应的汇编代码如下

图片

在这个汇编代码中你会看到在把常数赋值给寄存器的地方用到的都是一个个的标签。比如LCPI0_0(%rip)是第一个参数的标签这个参数是常量1。这个标签可以被转化为一个文本段也就是代码区的一个地址。我们知道%rip寄存器保存的是CPU下一条要执行的指令的地址所以LCPI0_0(%rip)实际上记录的是常量的地址与下一条指令的地址的偏移量。通过下一条指令的地址加上这个偏移量就能找到常量1的存储位置。

我们再看看这个汇编代码文件的前半部分你会看到一个文本段用于放8字节的字面量。你可以参考下图。我在图上标注了一些必要的说明。

图片

这里,.quad表示一个四个字的字面量。其中每个字是2个字节、16位所以加起来是8个字节、64位正好是一个double型所需要的空间。

相信你也注意到了在计算机里表示整数是比较简单的基本上就是把10进制的数字转化为二进制就行了。顶多再加上一个符号位表示正数和负数。但是要表达浮点数就没有那么简单了。从汇编代码中你能看到即使是1这个最简单的整数表达成一个double型的字面量之后也会显得很复杂。这个double值如果我们用16进制整型格式显示是0x3ff0000000000000。

并且你也能看到像1、2、3、4、5这5个整数以及10.5这个小数用double格式可以精确地表示出它们的数值。而对于6.1、7.2、8.3、9.4这几个小数用double格式就只能近似地表达其值。它跟原始的值还有一点小小的误差大约是小数点后16位的一个很小的值。这都是由浮点数的编码方式决定的。

现在问题就来了在生成汇编代码的过程中我们要把代码里的字面量转换成像0x3ff0000000000000也就是1、0x401ccccccccccccd也就是7.2)这样的格式才可以。所以,你就必须了解浮点数的编码格式。

浮点数的编码是在国际标准IEEE 754中规定的。对于双精度浮点数来说它所占据64位被划分成了3段。一开头是一个符号位符号位是0则表明这是个整数如果是1则表示负数。接下来是11位的指数位最后是52位的有效数字位。这样加起来一共是64位。

图片

那什么是指数位?什么是有效数字位?它们又是如何用来表示一个浮点数的呢?

原来,浮点数使用的是二进制的科学计数法来表示数字。也就是说任何一个数字都可以表达成1.xxxxx乘以2的n次方的格式

图片

比如说5这个数字变成二进制格式是101如果写成二进制的科学计数法我们小数点前面只保留1位就是

图片

那具体编码的时候是不是在有效数字区里存个101在指数区存个10也就是2就行了呢就像下图那样。

图片

这是不行的。这里有两个地方需要调整才能符合IEEE 754标准

**首先,我们要调整有效数字的存储。**由于所有的有效数字的整数位都是1所以我们从节约存储空间的角度看这一位就没有必要存了。也就是说101只存个01就行。

**第二,我们要调整指数的存储。**指数是可以有正有负的。比如0.05写成二进制是0.00001100110011001100110011001100110011001100110011001101,写成科学计数法是:

图片

这个时候,指数位就是-5。

所以为了能够表达指数是正数和负数的情况IEEE754规定要把指数加上一个常数让所有的指数都变成正数来表达。因为2的11次方是2048理论上指数位能够表达2048个指数值。但标准规定当这11位都是1时是用来表示特殊数字的如果有效位数全为零表示正负的无穷大±Infinity如果有效位不全为0则表示NaN。

这样的话我们就只能使用2047个指数了其中整数1023个负数1023个指数0占一个。所以我们把所有的指数都加上常数1023就都变成正数了。而5的指数位就需要记录1023+2=1025表达成二进制是10000000001正好是11位一前一后都是1其他位是0。

好了完成上面两个调整以后字面量5的double格式就是

图片

这个数字如果表示成16进制那就是0x4014000000000000跟汇编代码里字面量5的数值是完全一致的。

好了看到这里我相信你已经掌握了双精度浮点型数字的存储方式了。掌握了原理以后你可以自己写段代码来生成IEEE 754格式的浮点数。在课程的示例代码里我用了一个第三方的node.js库feross/ieee754来把number的常数转换成用16进制的格式就像汇编代码里的那些double常数一样。你需要用“npm install ieee754”命令来安装这个库。

课程小结

好了,今天的内容就是这些。我们用了一节课的时间,讲解了支持浮点数运算的关键知识点。现在我们再来总结一下:

首先对于每一个CPU架构来说对浮点数运算都会有专门的支持。对于X86-64来说我们主要使用SSE指令集进行浮点数运算。这个指令集中标量浮点数运算的指令跟我们前面学过的📄运算的指令是很相似的所以我们很容易接受。这个指令集会用到16个128位寄存器我们做双精度浮点数的标量运算时只会用到这些寄存器的低64位。在寄存器分配算法中我们要针对处理浮点数和整数的指令分别分配寄存器。

第二我们也学习了ABI对于处理浮点数的不同规定。主要表现在参数传递和返回值上。参数传递可以使用xmm0~xmm7共8个寄存器返回值可以使用xmm0寄存器。其他方面比如栈桢格式方面与之前我们学过的知识点没有什么区别。

最后我们学习了浮点数的编码标准。根据IEEE 754标准双精度浮点数使用1个符号位、11个指数位和52个有效数字位构成的。在这里你最好能够了解如何手工计算出一个浮点数的存储格式这会让你加深对这个知识点的理解。而且我建议你不要忽略这个知识点。因为当你能对编码格式了然于胸的时候会有助于你更顺畅地理解使用到浮点型字面量的汇编代码也有助于你理解CPU是如何处理浮点数的。

思考题

到现在我们的课程已经讲到了整型和双精度浮点型这两种数据类型它们都得到了X86架构的CPU的支持。你能不能查一下X86架构的CPU还支持哪些数据类型呢处理这些数据类型的指令都有哪些这些在Intel手册的第一卷都能查到。欢迎你在留言区分享你的发现。

欢迎你把这节课分享给更多感兴趣的朋友。我是宫文学,我们下节课见。

资源链接

这节课涉及的代码都在asm_x86-64_d.ts中。这个代码文件是复制了asm_x86-64.ts把它改成了支持double类型的版本。