这两天翻看一本C 语言书的时候,发现上面有一段这样写到
例:将同一实型数分别赋值给单精度实型和双精度实型,然后打印输出。
#include <stdio.h>
main()
{
float a;
double b;
a = 123456.789e4;
b = 123456.789e4;
printf(“%f\n%f\n”,a,b);
}
运行结果如下:
1234567936.000000
1234567890.000000
为什么同一个实型数据赋值给float 型变量和double 型变量之后,输出的结果会有所不同呢?是因为将一个实型常量赋值给float 型变量与赋值给double 型变量,它们所接受的有效数字位是不同的。
这一段的说法是正确的,但实在是太模糊了!为什么一个输出的结果会比原来的大?为什么不是比原来的小?这之间到底有没有什么内存的根本性原因还是随机发生的?为什么会出现这样的情况?上面都没有对此进行解释。上面的解释是一种最普通的解释,甚至说它只是说出了现象,而并没有很深刻的解释原因,这不免让人读后觉得非常不过瘾!
书中还有下面一段:
(1)两个整数相除的结果仍为整数,舍去小数部份的值。例如,6/4 与6.0/4 运算的结果值是不同的,6/4 的值为整数1,而6.0/4 的值为实型数1.5。这是因为当其中一个操作数为实数时,则整数与实数运算的结果为double 型。非常遗憾的说,“整数与实数运算的结果为double 型”,这样的表述是不精确的,不论从实际程序的反汇编结果,还是从对CPU 硬件结构的分析,这样的说法都非常值得推敲。然而在很多C 语言的教程上我们却总是常常看见这样的语句:“所有涉及实数的运算都会先转换成double,然后再运算”。然而实际又是否是这样的呢? 关于浮点数运算这一部份,绝大多数的C 教材没有过多的涉及,这也使得我们在使用C 语言的时候,会产生很多疑问。
先来看看下面一段程序:
/* -------------a.c------------------ */
#include <stdio.h>
double f(int x)
{
return 1.0 / x ;
}
void main()
{
double a , b;
int i ;
a = f(10) ;
b = f(10) ;
i = a == b ;
printf( "%d\n" , i ) ;
}
这段程序使用gcc –O2 a.c 编译后,运行它的输出结果是0,也就是说a 不等于b,为
什么?
再看看下面一段,几乎同上面一模一样的程序:
/*---------------- b.c ----------------------*/
#include <stdio.h>
double f(int x)
{
return 1.0 / x ;
}
void main()
{
double a , b , c;
int i ;
a = f(10) ;
b = f(10) ;
c = f(10) ;
i = a == b ;
printf( "%d\n" , i ) ;
}
同样使用gcc –O2 b.c 编译,而这段程序输出的结果却是1,也就是说a 等于b,为什
么? 国内几乎没有一本C 语言书(至少我还没看见),解释了这个问题,在C 语言对浮点数的处理方面,国内的C 语言书几乎都是浅尝即止,蜻蜓点水,而国外的有些书对此就有很详尽的描述,上面的例子就是来源于国外的一本书《Computer Systems A Programmer’s Perspective》(本文参考文献2,以下简称《CSAPP》),这本书对C 语言及CPU 处理浮点数描写的非常细致深入,国内很多书籍明显不足的地方,就在于对于某些细节我们是乎并没有某种深入的精神,没有一定要弄个水落石出的气度,这也注定了我们很少出版一些Bible 级的著作。一本书如果值得长期保留,能成为Bible,那么我认为它必须把某一细节描述的非常清楚,以至于在读了此书之后,再也不需要阅读其它的书籍,就能对此细节了如指掌。《CSAPP》这本书的确非常经典,遗憾的是此书好像目前还没有电子版,因此我打算以此书为基础(一些例子及描述就来自此书),再加上自己看过的一些其它资料,以及自己对此问题的理解与分析,详细谈一下C 语言及Intel CPU 对浮点数的处理,以期望在此方面,能对不清楚这部分内容的学弟学妹们有些许帮助。要无障碍的阅读此文,你需要对C 语言及汇编有所了解,本文的所有实验,均基于Linux 完成,硬件基于Intel IA32 CPU,因此,如果你想从此文中了解更多,你最好能熟练使Linux 下的gcc 及objdump 命令行工具(非常遗憾的是,现在少有C 语言教材会对此进行讲述),
另外,你还需要对堆栈操作有所了解,这在任何一部讲解数据结构的书上都会提到。由于自身知识及能力有限,如果书中有描述不当的地方或错误,请你与我联系,我也会在http://jaminwm.2003y.net对所有问题进行跟踪及反馈。
一、Intel CPU 浮点运算单元的逻辑结构
在很久以前,由于CPU 工艺的限制,无法在一个单一芯片内集成一个高性能的浮点运算器,因此,Intel 还专门开发了所谓的协处理器配合主处理器完成高性能的浮点运算,比如80386 的协处理器就是80387,后来由于集成电路工艺的发展,人们已经能够将在一个芯片内集成更多的逻辑功能单元,因此,在80486DX 的时候,Intel 就在80486DX 这个芯片内集成了很强大的浮点处理单元。下面,我们就来看看,被集成到主处理器内部之后,这个浮点处理单元的逻辑结构,这是理解Intel CPU 对浮点数处理机制的前提条件。
79
R0
R1
R2
R3
R4
R5
R6
R7
0 15
控制寄存器
状态寄存器
标志寄存器
(图1 Intel CPU 浮点处理单元逻辑结构图)
上图就是Intel IA32 架构CPU 浮点处理单元的逻辑结构图,从图中我们可以看出它总
0
8 个数据寄存器
0 47
最近一次指令指针
最近一次操作数指针
0 10
操作码寄存器
共有8 个数据寄存器,每个80 位(10B);一个控制寄存器(Control Register),一个状态寄
存器(Status Register),一个标志寄存器(Tag Register),每个16 位(2B);还有一个最近
一次指令指针(Last Instruction Pointer),及一个最近一次操作数指针(Last Operand Pointer),
每个48 位(6B);以及一个操作码寄存器(Opcode Register)。
状态寄存器用处与常见的主CPU 的程序状态字差不多,用来标记运算是否溢出,是否
产生错误等,最主要的一点是它还记录了8 个数据寄存器的栈顶位置(这点在下面将会有详
细描述)。
控制寄存器中最重要的就是它指定了这个浮点处理单元的舍入的方式(后面将会对此详
细描述)及精度(24 位,53 位,64 位)。Intel CPU 浮点处理器的默认精度是64 位,也称为
Double Extended Precision(中文也许会译为:双扩展精度,但这种专有名词,不译更好,译
了反而感觉更别扭)。而24 位,与53 位的精度,是为了支持IEEE 所定义的浮点标准(IEEE
754 标准),也就是C 语言中的float 与double。
标志寄存器指出了8 个寄存器中每个寄存器的状态,比如它们是否为空,是否可用,是
否为零,是否是特殊值(比如NaN:Not a Number)等。
最后一次指令指针寄存器与最后一次数据指针寄存器用来存放最后一条浮点指令(非控
制用指令)及所用到的操作数在内存中的位置。由于包括16 位的段选择符及32 位的内存偏
移地址,因此,这两个寄存器都是48 位(这涉及到Intel IA32 架构下的内存地址访问方法,
如果对此不清楚的,可以不用太在意,只需知道它们指明了一个内存地址就行,如果很想弄
清楚可以参考看本文的参考文献1)。
操作码寄存器记录了最后一条浮点指令(非控制用指令)的操作码,这很简单,没什么
可多说的。
下面我们将详细描述一下,浮点处理单元中的那8 个数据寄存器,它们与我们通常用的
主cpu 中的通用寄存器,比如eax,ebx,ecx,edx 等相比有很大的不同,它们对于我们理
解Intel CPU 浮点处理机制非常关键!
二、Intel CPU 浮点运算单元浮点数据寄存器的组织
Intel CPU 浮点运算单元中浮点数据寄存器总共有8 个,它们都是80 位,即10 字节的
寄存器,对于每个字节所带表的含义我将在后面描述浮点数格式的时候详细介绍,这里详细
介绍的将是这8 个寄存器是怎么组织以及怎么使用的。
Intel CPU 把这8 个浮点寄存器组织成一个堆栈,并使用了状态寄存器中的一些标志位
标志了这个栈的栈顶的位置,我们把这个栈顶记为st(0),紧接着栈顶的下一个元素是st(1),
再下一个是st(2),以此类推。由于栈的大小是8,因此,当栈被装满的时候,就可以访问的
元素为st(0)~st(7),如下图所示: R0 R0
R1 R1
R2 R2
R3 R3
R4 R4
R5 R5
R6 R6
st(0) R7 R7 xxxxxxxxxx
装入一个数据时
(图2 装入不同数据时浮点寄存器中栈顶的位置)
由上图可以很明显的看出浮点寄存器是怎样被组织及使用的。需要注意的是,我们并不
能通过指令直接使用R0~R7,而只能使用st(0)~st(7),这在下边描述浮点运算指令的时候,
会有详细描述。
也许会有朋友对上图产生疑问,当已经放入8 个数后,也即当st(0)处于R0 的时候,
再向里面放入一个数会产生什么情况呢?
当已经有8 个数存入浮点寄存器中后,再向里面放入数据,这会根据控制寄存器中的相
应的屏蔽位是否设置进行处理。如果没有设置相应的屏蔽位,就会产生异常,就像产生一个
中断似的,通过操作系统进行处理,如果设置了相应的屏蔽位,则CPU 会简单的用一个不
确定的值替换原来的数值。如下图所示:
R0 st(0) 8
R1 st(1) 7
R2 st(2) 6
R3 st(3) 5
st(4) R4 4
R5 st(5) 3
R6 st(6) 2
R7 st(7) 1
装入八个数据时
(图3 装入数据大于八个数时,浮点寄存器状态)
可见其实浮点寄存器相当于是被组织成了一个环形栈,当st(0)在R7 位置的时候,如果
xxxxxxxxxx
xxxxxxxxxx
xxxxxxxxxx
装入三个数据时
再加入一个数
据后,注意现在
的st(0)~st(7)
的位置
R0
R1
R2
R3
R4
R5 st(0)
R6 st(1)
R7 st(2)
R0
R1
R2
R3
R4
R5
R6
R7
st(0) xxxxxxxxxx
st(1) xxxxxxxxxx
st(2) xxxxxxxxxx
st(3) xxxxxxxxxx
st(4) xxxxxxxxxx
st(5) xxxxxxxxxx
st(6) xxxxxxxxxx
st(7) xxxxxxxxxx
装入八个数据时
st(1) 8
st(2) 7
st(3) 6
st(4) 5
st(5) 4
st(6) 3
st(7) 2
st(0) 不确定的值
还有数据装入,则st(0)会回到R0 位置,但这个时候装入st(0)的却是一个不确定的值,这是
因为CPU 将这种超界看做是一种错误。
那么上面的说法倒底对不对呢?别急,在下面描述了浮点运算之后,我将会用一段实验
代码验证上面所述。
三、Intel CPU 浮点运算指令对浮点寄存器的使用
在第二节中,我们指出Intel CPU 将它8 个浮点寄存器组织成为一个环形堆栈结构,并
用st(0)指代栈顶,相应的,Intel CPU 的相当一部份浮点运算指令也只对栈首的数据进行操
作,并且大多数指令都存在两个版本,一个会弹栈,一个不会弹栈。比如下面的一条取数指
令:
fsts 0x12345678
这就是一个不会弹栈的指令,它只是将栈顶,即st(0)的数据存到内存地址为0x12345678
的内存空间中,其中fsts 最后的字母s 表明这是对单精度数进行操作,也就是说它只会把st(0)
中的四个字节存入以0x12345678 开始的内存空间中。具体是那四个字节,这就涉及到从80
位的st(0)到单精度(float)的一个转换,这将在下面介绍浮点数格式的小节中详细描述。
上面的指令执行后,不会进行弹栈操作,即st(0)的值不会丢失,而下面就是同种指令
的弹栈版本:
fstps 0x12345678
这条指令的功能几乎于上面一条指令完全相同,唯一不同的地方就在于这是一个会引起
弹栈操作的指令,其中fstps 中的字母p 指明了这一点。此条指令执行后,原来st(0)中的内
容就丢失了,而原st(1)中的内容成为st(0)中的内容,这种堆栈的弹压栈操作我想对大家是
再熟悉不过了,因此,这里将不再对其进行描述,不清楚的可以参看任一本讲数据结构的书。
本文主旨在于描述一下Intel CPU 浮点数处理机制的基本原则,而并非浮点指令的资料,
因此本文不再对众多的浮点指令进行描述,在下面的描述中,本文仅对所用到的指令进行简
单的解释,如果你想完整了解浮点指令,可以参看本文的参考文献1。
下面,我们将用一个例子结束本节的讲述,这个例子将涉及上节及本节所讲述的内容,
它验证了上面的描述是否正确。
请在Linux 下输入下面的代码:
/* ---------------------------- test.c ------------------------------------ */
void f(int x[])
{
int f[] = {1,2,3,4,5,6,7,8,9} ;
/*----------------------------- A 部分------------------------------*/
__asm__( "fildl %0"::"m"(f[0]) ) ;
__asm__( "fildl %0"::"m"(f[1]) ) ;
__asm__( "fildl %0"::"m"(f[2]) ) ;
__asm__( "fildl %0"::"m"(f[3]) ) ;
__asm__( "fildl %0"::"m"(f[4]) ) ;
__asm__( "fildl %0"::"m"(f[5]) ) ;
__asm__( "fildl %0"::"m"(f[6]) ) ;
__asm__( "fildl %0"::"m"(f[7]) ) ;
// __asm__( "fildl %0"::"m"(f[8]) ) ; (*)
// __asm__( "fst %st(3)" ) ; (**)
。其中S 在V 是正数时取0,
/* ------------------------------ B 部分---------------------------------*/
__asm__( "fistpl %0"::"m"(x[0]) ) ;
__asm__( "fistpl %0"::"m"(x[1]) ) ;
__asm__( "fistpl %0"::"m"(x[2]) ) ;
__asm__( "fistpl %0"::"m"(x[3]) ) ;
__asm__( "fistpl %0"::"m"(x[4]) ) ;
__asm__( "fistpl %0"::"m"(x[5]) ) ;
__asm__( "fistpl %0"::"m"(x[6]) ) ;
__asm__( "fistpl %0"::"m"(x[7]) ) ;
}
void main()
{
int x[8] , j ;
f(x) ;
for( j = 0 ; j < 8 ; ++j )
printf( "%d\n" , x[j] ) ;
}
上面的代码通过内嵌汇编,在A 部分把一个整数数组中的整数压入浮点寄存器中(fildl
指令用于把整数压入浮点寄存器),而后又在B 部分将浮点寄存器中的数取到另一个数组中
(fistpl 指令用于把栈顶数据存入指定内存单元中,指令中的字母p 表明这是一个弹栈指令,
每次都会弹栈)。程序中我们只压入了f[0]~f[7]的8 个数据,而压入的数据顺序是1,2,3,
4,5,6,7,8,因此,取出的顺序应当是8,7,6,5,4,3,2,1,在Linux 下编译并运
行我们会得到同样的结果。
下面,我们将(*)语句处的注释符号“//”去掉,这个时候我们压入了f[0]~f[8]共9 个
数据,这将会引起超界,按照上面的描述,当发生这种情况的时候,st(0)会从R0 的位置变
到R7,并在其中存入一个不确定的值,那么实际情况是不是这样呢?同样请在Linux 下编
译并运行此程序,并将结果与图3 进行比较。这里需要注意的时,我们总是按照st(0),
st(1),……,st(7)的顺序取出数据的。
最后,我们再将(**)语句处的注释符号去掉,“fst %st(3)”这条指令的作用是把st(0)
中的内容存入st(3),指令中并没有p 字母,因此,这并不是一条会引起弹栈的指令。同样
请在Linux 下编译运行,并对照图3 观察它的结果,以验证前文所述内容。
四、浮点数格式
C 语言及CPU 所使用的浮点数格式均遵从IEEE 754 标准,下面我们就对此详细的讨论
一下。
E S IEEE 标准指出,一个数可以表示为: × = . ) 1 ( M V 2 ×
在V 是负数是取1。对应到程序中,这显然就是一个符号位,所以S 占1 位,而V 及M 的
位数由数据类型来决定。如果是单精度型(float),那E 占8 位(e = 8),M 占23 位(m = 32),
如果是双精度型(double),E 占11 位(e = 11),M 占52 位(m = 52),如下图所示: M’(M’作为纯小数) E’ S’
e 位1 位m 位
(图4 IEEE 745 浮点数格式)
这里需要注意的是,我们用的是S’、E’、M’而非S、E、M,这是因为它们之间存在着
一种转换关系。请看下面的描述。
= S 在任何时候都等于<