Skip to content

PA 1-3 浮点数的表示和运算

§1-3.1 预备知识

NEMU是模拟的x86体系结构,浮点数的表示和运算参照IEEE 754标准的规定。在上一节所实践的整数表示方法中,小数点约定永远在最右侧,因此可以说是一种数的定点表示方法。也可以通过约定小数点在某一特定位置来表示带小数的数字。而本节的实验中,我们参照x86体系结构的现行标准,实现IEEE 754标准的浮点数表示和运算方法。于定点数表述方法不同,浮点数表示的一个数字,其小数点所在的位置是不定(浮动)的,因此称之为浮点数。

根据IEEE 754标准的规定,单精度(32位)浮点数由最高位1位符号位(sign),8位阶码部分(exponent)和23位尾数部分(fraction)构成,其具体的表示方法如下:

  Single-precision Floating Point

  31 30    23 22                    0
  +-+--------+-----------------------+
  |s|exponent|        fraction       |
  +-+--------+-----------------------+
   s - sign                

图1-3 IEEE 754浮点数结构

在单精度浮点数的表示中,8位阶码部分采用移码形式,其偏置常数为127;对于规格化数而言,23位的尾数部分实际上表示了24位的有效数字,其最高位缺省为1。尾数加上最高位可能存在的缺省的1(非规格化浮点数没有缺省的1)后构成的有效数字在框架代码中命名为significand以示和不带隐藏位的fraction的区别。在PA实验中,我们仅关注单精度浮点数的表示和实现。双精度(64位)浮点数的表示方法与单精度浮点数非常类似,只是阶码部分扩展为11位(偏置常数1023),尾数部分扩展为52位。在以后的教程中,如无特殊说明,我们所称的浮点数均指单精度浮点数。

§1-3.2 代码导读和实验理解

§1-3.2.1 浮点数的表示

框架代码提供了FLOAT数据结构来方便地表示浮点数,其定义在头文件nemu/include/cpu/reg_fpu.h中,具体代码如下:

typedef union {
    struct {
        uint32_t fraction    :23;
        uint32_t exponent    :8;
        uint32_t sign        :1;
    };
    float fval;
    uint32_t val;
}FLOAT;

在拥有了CPU中寄存器结构体的实现的基本知识后,相信不难理解这段代码。在FLOAT结构中,我们在同样的一段32位的内存中,同时表示了一个浮点数类型以及其用32位整数所表示的值。在接下来的实验中,我们将实践针对浮点数的加减乘除操作,而这些操作都将在浮点数的32位整形表示的基础上展开。当然,我们可以利用FLOAT类型中的fval方便地实现浮点数的各类运算,这也正是我们在浮点数测试用例里实现黄金版本的方法。但出于教学目的,我们在此声明:

在实现模拟的浮点数的运算时,禁止使用将其整数表示通过类型转换成浮点数表示的方法来实现浮点数的运算操作。

§1-3.2.2 在NEMU中模拟浮点数运算

在x86体系结构中,浮点数的运算功能由浮点数协处理器提供。在NEMU中,这一系列操作则在模拟的FPU中实现。框架代码已经实现了对FPU的基本器件和所提供指令的模拟,即使在后面的指令实现实验中也不涉及浮点指令的模拟。对于框架代码模拟细节感兴趣的同学可以参见Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 1: Basic Architecture,Chapter 8的相关内容,以及在线手册x86 Instruction Set Reference中对相关指令的描述。在本实验中,我们仅将注意力集中在对浮点数运算的模拟上。

在头文件nemu/include/cpu/fpu.h中,框架代码声明了四个供内部使用的浮点数运算函数,具体如下表所示:

// 输入存储在32位无符号整型变量中的两个浮点数 a 和 b 按照IEEE 754编码方式所对应的机器数
// 返回存储在32位无符号整型变量中的浮点数运算结果 a + b 按照IEEE 754编码方式所对应的机器数
uint32_t internal_float_add(uint32_t  b, uint32_t a);

// 返回 a - b,注意变量次序
uint32_t  internal_float_sub(uint32_t b, uint32_t a);

// 返回 a * b
uint32_t internal_float_mul(uint32_t  b, uint32_t a);

// 返回 a / b
uint32_t  internal_float_div(uint32_t b, uint32_t a);

NEMU中模拟浮点数运算的基本思想是对符号位、阶码和尾数运用整数运算,在机器数的层面实现浮点数计算的过程。

* 浮点数的加减法运算

首先谈浮点数的加法运算。浮点数加法的大致过程可参照 课本2.7.7节 的相关内容进行展开。本教程结合课本内容,给出实现浮点数加法的大致思路。

第一步,判断边界条件。在进行加减运算之前,我们首先要判断一些包括参与运算的数字是±0±∞NaN的边界条件。在框架代码中我们已经针对各种运算给出了对边界条件的判断。

第二步,提取尾数并对阶。当处理的浮点数FLOAT f是规格化数时尾数需要加上隐藏位1,否则隐藏位为0。上述过程可以用如下伪代码来描述:

// 提取尾数过程,假设对应的32位无符号整型的值存储在uint32_t uf中
FLOAT f;
f.val = uf;
uint32_t significand = f.fraction;
if( /* f是规格化浮点数 */ )
    significand |= 0x800000; // 加上隐藏位1
// else do nothing

对参与加法的两个浮点数fafb都提取了其尾数后,需要执行对阶操作。参照课本描述,对阶的原则是:小阶向大阶看齐,阶小的那个数的尾数右移,右移的位数等于两个阶的差的绝对值。假设参与运算的两个浮点数为FLOAT fa, fb。且fa的阶小于fb的阶。那么fa的尾数需要右移的位数为:

shift = fb.exponent - fa.exponent;

这里有一个特殊情况就是需要处理非规格化浮点数的情况,对于非规格化浮点数而言,其阶码为全0而尾数为非0。对于全0的阶码,其表示的真实值为0.f * 2^-126,不能理解为阶码为0-127 = -127。因此,为了在数值上保证右移位数的正确性,需要对阶码进行判断(此时已经经过第一步,排除了浮点数为0的情况)。

shift = (fb.exponent == 0 ? fb.exponent + 1 : fb.exponent) - (fa.exponent == 0 ? fa.exponent + 1 : fa.exponent);

加法运算结果的临时阶码等于b.exponent

在对fa进行对阶的过程中,尾数每右移一位都会在最高位补0并导致最低有效位移出。为了提高运算结果的精度,我们不能将移出的位直接丢弃。根据IEEE 754的规定,所有浮点数运算的中间结果右边都必须至少保留两位附加位,依次是保护位(guard, G)和舍入位(round, R)。为进一步提高精度,舍入位右侧还有一个粘位(sticky, S),只要舍入位右边有任何非0数字,粘位就置为1,否则置为0。因此,在运算的中间过程中,实际上尾数应该是隐藏位(1位)+小数部分(23位)+G(1位)+R(1位)+S(1位) = 27位,其中小数点前1位,小数点后26位:

     23 22                    0
     +-+-----------------------+-+-+-+
     |*|        Fraction       |G|R|S|
     +-+-----------------------+-+-+-+
      * - Implied Integer

图1-4 框架代码中带GRS bits的浮点数尾数结构

在尾数右边的GRS bits也需要参与运算来保持精度,如果采用额外的变量来存储GRS bits,那实现起来就略显烦琐。在这里提供一种简便的思路,如第二步中的代码段所示,我们可以用一个32位无符号整型变量significand来暂存尾数部分。事实上,尾数部分即使算上隐藏位和GRS bits也就是27位。因此,我们可以将significand左移3位,为GRS bits空出位置来。此时等同于约定运算中间结果的尾数小数点后有26位。

总结上述提取尾数并对阶的过程,我们将步骤整理如下:

  1. 提取尾数至至少为32位(乘除法中用64位较为便利)的无符号整型变量中,并根据是否是规格化数补上隐藏位得到significand

  2. 将参与运算的两个尾数都左移3位,为GRS bits空出位置;

  3. 确定阶小的那个数需要右移的位数shift

  4. 将阶小的那个数的尾数右移执行对阶操作,移出部分自然进入GR两位,S位的取值需根据粘位规则额外操作。

第三步,尾数相加。浮点数的尾数部分采用的是原码表示,而浮点数的符号位保存在最高位。在做尾数相加时,只需按照符号位的取值将原码表示的尾数转变为补码表示,并做无符号整型加法即可。如果你采用了第二步中建议的左移留出GRS bits的方法,那此时这三位自然参与运算。执行尾数相加后,再根据尾数之和的补码取值,判断结果的正负号。根据结果的符号设置运算结果浮点数的符号,并将尾数从补码转回原码表示。

第四步,尾数规格化和舍入。完成尾数相加后,我们接下来需要对尾数进行规格化和舍入。框架代码给出了一个尾数规格化与舍入的内联函数,其原型为:

inline uint32_t internal_normalize(uint32_t sign, int32_t exp, uint64_t sig_grs);

它有三个参数:其中sign是运算结果的符号,exp为待规格化的阶码,是一个带符号的整数,采用带符号的阶码是为了方便应对在乘除法运算时可能获取负数阶码的情况,sig_grs为待规格化的尾数,从变量名中可以反映出这个尾数中是包含GRS bits的,同时也包含最高位的隐藏位(Implied Integer),采用64位整型临时存放尾数是为了方便乘除法的实现。对于传入的尾数sig_grs,我们约定其对应的真值是一个定点数,小数点位于从低到高第26位和27位之间(26位小数),小数点前面的整数部分根据运算的中间结果不同可能有零到多位。

对于加减法而言,在尾数规格化的过程中,我们可以根据最终Implied Integer部分的取值来判断是需要进行左规还是右规。具体规则如下:

如果sig_grs >> (23 + 3) > 1,则需要右规直至sig_grs >> (23 + 3) == 1;反之如果sig_grs >> (23 + 3) == 0且不是非规格化浮点数(exp >),则需要左规直至sig_grs >> (23 + 3) == 1。注意这里的23 + 3的取值是因为我们在临时尾数的最低三位保留了GRS bits的缘故,也就是上文所述的等同于中间结果尾数sig_grs的小数部分约定为26位。在规格化过程中,根据规格化的方向确定尾数右移还是左移,并对阶码进行增减操作。右规过程中需要保留粘位。同时在每次右移前都要检查阶码上溢(exp >= 0xff)的情形。在左规过程中,则有可能发生结果出现非规格化浮点数的情况(阶码变为exp == 0),此时需要将尾数额外右移1位以对应非规格化浮点数阶码是2 ^-126的约定(否则单纯从数值上看阶码全0对应2 ^ -127)。

假设在上述规格化过程中没有发生阶码上溢或下溢,则完成规格化后需要对GRS bits进行舍入操作。舍入的方法采用就近舍入(中间值舍入到偶数),三位GRS bits对应的中间值为4。确定舍入后,完成尾数最后三位的右移操作,丢弃GRS bits。最后,由于在舍入过程中可能涉及尾数加一。因此还要再判断一次是否需要右规。对于规格化浮点数,不要忘记丢弃最高的隐藏位。

完成上述步骤后,我们就能够确定最终的运算结果(包括符号位、阶码和尾数)了,利用FLOAT类型可以方便得到浮点数对应的32位整型表示。为了方便理解上述过程,我们用伪代码给出尾数规格化函数的实现方案如下:

inline uint32_t internal_normalize(uint32_t sign, int32_t exp, uint64_t sig_grs) {
    if( /* 需要进行右规 */ ) {
        while( /* 需要右规 且 未发生阶码上溢*/ ) {
            /* 右规一次 */
        }
        if( /* 发生了阶码上溢 */ ) {
            /* 根据符号将结果置为 +∞ 或 -∞ */
        }
    } else if( /* 需要进行左规 */ ) {
        while( /* 需要左规 且 阶码大于0 */ ) {
            /* 左规一次 */        
        }
        if( /* 发生了阶码==0 */ ) {
            /* 右移一次化为非规格化浮点数 */
        }
    } else if( /* 两个非规格化数运算后得到了一个规格化数 */ ) {
        exp++; 
    }

    if( /* 规格化过程中未发生溢出 */ ) {
        /* 根据sig_grs最后三位GRS bits的取值决定舍入,采取就近舍入到偶数的方式 */
        /* 移除sig_grs最后三位保留的GRS bits*/
        if( /* 舍入后破坏了规格化 */ ) {
            /* 再进行规格化并判断溢出 */
        }
    }

    // 假设最后的结果的符号、阶码、尾数保留在sign, exp, sig_grs中
    FLOAT f;
    f.sign = sign;
    f.exponent = (uint32_t) (exp & 0xff);
    f.fraction = sig_grs; // here only the lowest 23 bits are kept
    return f.val;
}

参照上述过程,在nemu/src/cpu/fpu.c中实现浮点数加法函数internal_float_add()和规格化函数internal_normalize()并通过位于nemu/src/cpu/fpu_test.c中的测试用例fpu_test_add()即可。

再次强调,我们不接受如测试用例中所使用的将无符号整型表示的浮点数先转成float类型后运算的实现方案。

在实现了加法运算后,减法运算只需将减数符号置反后做加法即可。有一些额外的边界条件需要判断,为节省时间,框架代码已给出相应的实现。

* 浮点数的乘除运算

在充分掌握了浮点数的加法运算后,浮点数的乘除运算就十分简单了。其基本步骤和加法类似,相比加法运算,还可以免去对阶的过程。这里只作几点提示:

  1. 尾数的中间结果用uint64_t表示更为便利;

  2. 阶码在做加法(乘法)和减法(除法)时,需要考虑对偏置常数(单精度浮点数为127)所带来的影响,同时必须考虑处理非规格化浮点数时阶码全0的情形;

  3. (特别说明:如果你觉得这一段难以理解,可以参考下一条的说法)在做尾数除法之前,为提高运算精度,先将被除数(假设为fa)尾数存放在64位临时变量中并尽可能左移(直至最高位为1),同时将除数(假设为fb)的尾数尽可能的右移。经过以上处理后再将被除数的尾数除以除数的尾数。假设在上一步左移加右移的次数为shift,此时对应于结果的阶码应该是fa.exponent - fb.exponent + 127 - (shift – 23) + 3。最后减去从shift中减23是因为用整数模拟小数除法时,我们模拟的是小数除法1.aaa / 1.bbb,而实际进行的运算是整数除法1aaa / 1bbb,为了使得最后的结果符合小数除法的运算规则,应该将被除数尾数左移23位再进行除法运算。而为了提高精度,我们左移了shift位,多移动了shift-23位,在此处补上。另外最后三位依照先前的约定是GRS bits,原则上我们应该配合约定将中间结果左移3位再提交给规格化函数。我们省略左移过程,改为通过阶码加3来进行补偿。类似的,在进行乘法时,两个整数表示的尾数通过乘法获取64位的乘积后,结果的阶码要减去23,并保留GRS bits,最终的阶码为fa.exponent + fb.exponent - 127 - 23 + 3(注意:尽管我们做了这么多的努力去提高精度,还是有可能在测试中出现尾数的最后一位与标准结果差1的情况,我们在测试用例的assertion中列举了这种例外情况);

  4. (针对上一点的另一种表述方法)如前所述,加入GRS bits后,等同于约定中间结果的小数部分为26位。对于除法而言,将被除数左移shift位后除以除数得到的小数部分为shift位,为了保证中间结果的小数部分为26位且真值不变,将多余的shift – 26位从阶码中减去,最后的阶码等于fa.exponent - fb.exponent + 127 – (shift – 26)。对于乘法而言,用两个小数点后为23位的尾数相乘得到的乘积尾数小数点后为46位,为了保证中间结果的小数点后依然是26位,我们将多出来的46 – 26 = 20位归于阶码,因此阶码就是fa.exponent + fb.exponent - 127 - 20。在此感谢15级姚荣春助教在审阅过程中给出的建议!

  5. internal_normalize()函数中,右规的条件应增加一种情况,即exp < 0的情况,对应于乘除法获取的结果临时阶码非常小(小于-126)的情况,此时的一次右规也可以称为一次逐级下溢,试图将结果变为非规格化数。此时在右规的while循环退出后,需要判断得到的结果是否是非规格化浮点数;同时也需要判断是否退出while循环后阶码仍为负数的情形(此时发生阶码下溢)。补充之后internal_normalize()规格化函数的伪代码如下:

inline uint32_t internal_normalize(uint32_t sign, int32_t exp, uint64_t sig_grs) {
        if( /* 需要进行右规 (或 扩充条件 exp < 0) */ ) {
        while( /* (需要右规 且 未发生阶码上溢) 或(扩充条件 exp < 0 且 尾数没有被右规至舍入后为0,即,sig_grs > 0x4) */ ) {
            /* 右规一次 或称 逐级下溢一次*/
        }
        if( /* 发生了阶码上溢 */ ) {
            /* 根据符号将结果置为 +∞ 或 -∞ */
        }
        if( /* 阶码为全0,得到了非规格化的浮点数 */ ) {
            /* 右移一次化为非规格化浮点数 */
        }
        if( /* exp仍然小于0,发生阶码下溢 */ ) {
            /* 根据符号将结果置为 +0 或 -0 */
        }
    } else if( /* 需要进行左规 且 阶码大于0*/ ) {
        while( /* 需要左规 且 阶码大于0 */ ) {
            /* 左规一次 */
        }
        if( /* 发生了阶码==0 */ ) {
            /* 右移一次化为非规格化浮点数 */
        }
    } else if( /* 两个非规格化数运算后得到了一个规格化数 */ ) {
        exp++;
    }

    if( /* 规格化过程中未发生溢出 */ ) {
        /* 根据sig_grs最后三位GRS bits的取值决定舍入,采取就近舍入到偶数的方式 */
        /* 移除sig_grs最后三位保留的GRS bits*/
        if( /* 舍入后破坏了规格化 */ ) {
            /* 再进行规格化并判断溢出 */
        }
    }

    // 假设最后的结果的符号、阶码、尾数保留在sign, exp, sig_grs中
    FLOAT f;
    f.sign = sign;
    f.exponent = (uint32_t) (exp & 0xff);
    f.fraction = sig_grs; // here only the lowest 23 bits are kept
    return f.val;
}

浮点数的乘除法对应于nemu/src/cpu/fpu.cinternal_float_mul()internal_float_div()两个函数。所有的测试用例都位于nemu/src/cpu/fpu_test.c中。test_fpu()函数会在NEMU启动时调用。

§1-3.3 实验过程及要求

* 代码要求
  1. 实现nemu/src/cpu/fpu.c中的各个浮点数运算函数;

  2. 使用

make clean

make

命令编译项目;

  1. 使用

make test_pa-1

  1. 使用make test_pa-1命令执行NEMU并通过各个浮点数运算测试用例。
* 实验报告要求

在实验报告中,回答以下问题:

为浮点数加法和乘法各找两个例子:1)对应输入是规格化或非规格化数,而输出产生了阶码上溢结果为正(负)无穷的情况;2)对应输入是规格化或非规格化数,而输出产生了阶码下溢结果为正(负)零的情况。是否都能找到?若找不到,说出理由。

本阶段要修改的代码清单(参考)

  • nemu/src/cpu/fpu.c