现在的位置: 首页 > 综合 > 正文

剖析Intel IA32架构下C语言及CPU浮点数机制

2012年06月21日 ⁄ 综合 ⁄ 共 5971字 ⁄ 字号 评论关闭

(转载请注明原作者及出处)

(原文(正常大小字体及pdf格式):
http://www.binghua.com/Article/Class1/Class2/200409/259.html)

剖析Intel IA32架构下C语言及CPU浮点数机制

 Version 0.01

哈尔滨工业大学 谢煜波

email: xieyubo@126.com 网址:http://purec.binghua.com

QQ:13916830 哈工大紫丁香BBSID:iamxiaohan

 

前言

    这两天翻看一本C语言书的时候,发现上面有一段这样写到

例:将同一实型数分别赋值给单精度实型和双精度实型,然后打印输出。

       #include

       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/46.0/4运算的结果值是不同的,6/4的值为整数1,而6.0/4的值为实型数1.5。这是因为当其中一个操作数为实数时,则整数与实数运算的结果为double型。

    非常遗憾的说,“整数与实数运算的结果为double型”,这样的表述是不精确的,不论从实际程序的反汇编结果,还是从对CPU硬件结构的分析,这样的说法都非常值得推敲。然而在很多C语言的教程上我们却总是常常看见这样的语句:“所有涉及实数的运算都会先转换成double,然后再运算”。然而实际又是否是这样的呢?

    关于浮点数运算这一部份,绝大多数的C教材没有过多的涉及,这也使得我们在使用C语言的时候,会产生很多疑问。

先来看看下面一段程序:

/* -------------a.c------------------ */

#include

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

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下的gccobjdump命令行工具(非常遗憾的是,现在少有C语言教材会对此进行讲述),另外,你还需要对堆栈操作有所了解,这在任何一部讲解数据结构的书上都会提到。

    由于自身知识及能力有限,如果书中有描述不当的地方或错误,请你与我联系,我也会在哈工大纯C论坛上(http://purec.binghua.com)对所有问题进行跟踪及反馈。

 

一、Intel CPU浮点运算单元的逻辑结构

    在很久以前,由于CPU工艺的限制,无法在一个单一芯片内集成一个高性能的浮点运算器,因此,Intel还专门开发了所谓的协处理器配合主处理器完成高性能的浮点运算,比如80386的协处理器就是80387,后来由于集成电路工艺的发展,人们已经能够将在一个芯片内集成更多的逻辑功能单元,因此,在80486DX的时候,Intel就在80486DX这个芯片内集成了很强大的浮点处理单元。下面,我们就来看看,被集成到主处理器内部之后,这个浮点处理单元的逻辑结构,这是理解Intel CPU对浮点数处理机制的前提条件。

(图1 Intel CPU 浮点处理单元逻辑结构图)

    上图就是Intel IA32 架构CPU浮点处理单元的逻辑结构图,从图中我们可以看出它总共有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语言中的floatdouble

    标志寄存器指出了8个寄存器中每个寄存器的状态,比如它们是否为空,是否可用,是否为零,是否是特殊值(比如NaNNot a Number)等。

    最后一次指令指针寄存器与最后一次数据指针寄存器用来存放最后一条浮点指令(非控制用指令)及所用到的操作数在内存中的位置。由于包括16位的段选择符及32位的内存偏移地址,因此,这两个寄存器都是48位(这涉及到Intel IA32架构下的内存地址访问方法,如果对此不清楚的,可以不用太在意,只需知道它们指明了一个内存地址就行,如果很想弄清楚可以参考看本文的参考文献1)。

    操作码寄存器记录了最后一条浮点指令(非控制用指令)的操作码,这很简单,没什么可多说的。

    下面我们将详细描述一下,浮点处理单元中的那8个数据寄存器,它们与我们通常用的主cpu中的通用寄存器,比如eaxebxecxedx等相比有很大的不同,它们对于我们理解Intel CPU浮点处理机制非常关键!

 

二、Intel CPU浮点运算单元浮点数据寄存器的组织

    Intel CPU浮点运算单元中浮点数据寄存器总共有8个,它们都是80位,即10字节的寄存器,对于每个字节所带表的含义我将在后面描述浮点数格式的时候详细介绍,这里详细介绍的将是这8个寄存器是怎么组织以及怎么使用的。

    Intel CPU把这8个浮点寄存器组织成一个堆栈,并使用了状态寄存器中的一些标志位标志了这个栈的栈顶的位置,我们把这个栈顶记为st(0),紧接着栈顶的下一个元素是st(1),再下一个是st(2),以此类推。由于栈的大小是8,因此,当栈被装满的时候,就可以访问的元素为st(0)~st(7),如下图所示:

(图2 装入不同数据时浮点寄存器中栈顶的位置)

    由上图可以很明显的看出浮点寄存器是怎样被组织及使用的。需要注意的是,我们并不能通过指令直接使用R0~R7,而只能使用st(0)~st(7),这在下边描述浮点运算指令的时候,会有详细描述。

    也许会有朋友对上图产生疑问,当已经放入8个数后,也即当st(0)处于R0的时候,再向里面放入一个数会产生什么情况呢?

当已经有8个数存入浮点寄存器中后,再向里面放入数据,这会根据控制寄存器中的相应的屏蔽位是否设置进行处理。如果没有设置相应的屏蔽位,就会产生异常,就像产生一个中断似的,通过操作系统进行处理,如果设置了相应的屏蔽位,则CPU会简单的用一个不确定的值替换原来的数值。如下图所示:

(图3 装入数据大于八个数时,浮点寄存器状态)

    可见其实浮点寄存器相当于是被组织成了一个环形栈,当st(0)R7位置的时候,如果还有数据装入,则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)" ) ;                                       (**)

 

/* ------------------------------ B部分 ---------------------------------*/

        __asm__( "fistpl %0"::"m"(x[0]) ) ;

        __asm__( "fistpl %0"::"m"(x[1]) ) ;

抱歉!评论已关闭.