当然,在增加了x64扩展这个特性之后,FPU在x86兼容处理器中还是存在的。但是同事,SIMD扩展(SSE, SSE2等)已经有了,他们也可以处理浮点数。数字格式依然相同(使用IEEE754标准)。

所以,x86-64编译器通常都使用SIMD指令。可以说这是一个好消息,因为这让我们可以更容易的使用他们。 24.1 简单的例子

double f (double a, double b)
{
    return a/3.14 + b*4.1;
};

清单24.1: MSFC 2012 x64 /Ox

__real@4010666666666666 DQ 04010666666666666r ; 4.1
__real@40091eb851eb851f DQ 040091eb851eb851fr ; 3.14
a$ = 8
b$ = 16
f PROC
    divsd xmm0, QWORD PTR __real@40091eb851eb851f
    mulsd xmm1, QWORD PTR __real@4010666666666666
    addsd xmm0, xmm1
    ret 0
f ENDP

输入的浮点数被传入了XMM0-XMM3寄存器,其他的通过栈来传递。 a被传入了XMM0,b则是通过XMM1。 XMM寄存器是128位的(可以参考SIMD22一节),但是我们的类型是double型的,也就意味着只有一半的寄存器会被使用。

DIVSD是一个SSE指令,意思是“Divide Scalar Double-Precision Floating-Point Values”(除以标量双精度浮点数值),它只是把一个double除以另一个double,然后把结果存在操作符的低一半位中。 常量会被编译器以IEEE754格式提前编码。 MULSD和ADDSD也是类似的,只不过一个是乘法,一个是加法。 函数处理double的结果将保存在XMM0寄存器中。

这是无优化的MSVC编译器的结果:

清单24.2: MSVC 2012 x64

__real@4010666666666666 DQ 04010666666666666r ; 4.1
__real@40091eb851eb851f DQ 040091eb851eb851fr ; 3.14
a$ = 8
b$ = 16
f PROC
    movsdx QWORD PTR [rsp+16], xmm1
    movsdx QWORD PTR [rsp+8], xmm0
    movsdx xmm0, QWORD PTR a$[rsp]
    divsd xmm0, QWORD PTR __real@40091eb851eb851f
    movsdx xmm1, QWORD PTR b$[rsp]
    mulsd xmm1, QWORD PTR __real@4010666666666666
    addsd xmm0, xmm1
    ret 0
f ENDP

有一些繁杂,输入参数保存在“shadow space”(影子空间,7.2.1节),但是只有低一半的寄存器,也即只有64位存了这个double的值。

GCC编译器生成了几乎一样的代码。

24.2 通过参数传递浮点型变量

#include <math.h>
#include <stdio.h>
int main ()
{
    printf ("32.01 ^ 1.54 = %lf\n", pow (32.01,1.54));
    return 0;
}

他们通过XMM0-XMM3的低一半寄存器传递。

清单24.3: MSVC 2012 x64 /Ox

$SG1354 DB ’32.01 ^ 1.54 = %lf’, 0aH, 00H
__real@40400147ae147ae1 DQ 040400147ae147ae1r ; 32.01
__real@3ff8a3d70a3d70a4 DQ 03ff8a3d70a3d70a4r ; 1.54
main PROC
    sub rsp, 40 ; 00000028H
    movsdx xmm1, QWORD PTR __real@3ff8a3d70a3d70a4
    movsdx xmm0, QWORD PTR __real@40400147ae147ae1
    call pow
    lea rcx, OFFSET FLAT:$SG1354
    movaps xmm1, xmm0
    movd rdx, xmm1
    call printf
    xor eax, eax
    add rsp, 40 ; 00000028H
    ret 0
main ENDP

在Intel和AMD的手册中(见14章和1章)并没有MOVSDX这个指令,而只有MOVSD一个。所以在x86中有两个指令共享了同一个名字(另一个见B.6.2)。显然,微软的开发者想要避免弄得一团糟,所以他们把它重命名为MOVSDX,它只是会多把一个值载入XMM寄存器的低一半中。 pow()函数从XMM0和XMM1中加载参数,然后返回结果到XMM0中。 然后把值移动到RDX中,因为接下来printf()需要调用这个函数。为什么?老实说我也不知道,也许是因为printf()是一个参数不定的函数?

清单24.4:GCC 4.4.6 x64 -O3

.LC2:
.string "32.01 ^ 1.54 = %lf\n"
main:
    sub rsp, 8
    movsd xmm1, QWORD PTR .LC0[rip]
    movsd xmm0, QWORD PTR .LC1[rip]
    call pow
    ; result is now in XMM0
    mov edi, OFFSET FLAT:.LC2
    mov eax, 1 ; number of vector registers passed
    call printf
    xor eax, eax
    add rsp, 8
    ret
.LC0:
    .long 171798692
    .long 1073259479
.LC1:
    .long 2920577761
    .long 1077936455

GCC让结果更清晰,printf()的值传入到了XMM0中。顺带一提,这是一个因为printf()才把1写入EAX中的例子。这意味着参数会被传递到向量寄存器中,就像标准需求一样(见21章)。

24.3 比较式的例子

double d_max (double a, double b)
{
    if (a>b)
    return a;
    return b;
};

清单 24.5: MSVC 2012 x64 /Ox

a$ = 8
b$ = 16
d_max PROC
    comisd xmm0, xmm1
    ja SHORT $LN2@d_max
    movaps xmm0, xmm1
$LN2@d_max:
    fatret 0
d_max ENDP

优化过的MSVC产生了很容易理解的代码。 COMISD是“Compare Scalar Ordered Double-Precision Floating-Point Values and Set EFLAGS”(比较标量双精度浮点数的值然后设置EFLAG)的缩写,显然,看着名字就知道他要干啥了。 非优化的MSVC代码产生了更加丰富的代码,但是仍然不难理解:

清单 24.6: MSVC 2012 x64

a$ = 8
b$ = 16
d_max PROC
    comisd xmm0, xmm1
    ja SHORT $LN2@d_max
    movaps xmm0, xmm1
    $LN2@d_max:
    fatret 0
d_max ENDP

但是,GCC 4.4.6生成了更多的优化代码,并且使用了MAXSD(“Return Maximum Scalar Double-Precision Floating-Point Value”,返回最大的双精度浮点数的值)指令,它将选中其中一个最大数。

清单24.7: GCC 4.4.6 x64 -O3

a$ = 8
b$ = 16
d_max PROC
    movsdx QWORD PTR [rsp+16], xmm1
    movsdx QWORD PTR [rsp+8], xmm0
    movsdx xmm0, QWORD PTR a$[rsp]
    comisd xmm0, QWORD PTR b$[rsp]
    jbe SHORT $LN1@d_max
    movsdx xmm0, QWORD PTR a$[rsp]
    jmp SHORT $LN2@d_max
    $LN1@d_max:
    movsdx xmm0, QWORD PTR b$[rsp]
    $LN2@d_max:
    fatret 0
d_max ENDP

24.4 总结

只有低一半的XMM寄存器会被使用,一组IEEE754格式的数字也会被存在这里。 显然,所有的指令都有SD后缀(标量双精度数),这些操作数是可以用于IEEE754浮点数的,他们存在XMM寄存器的低64位中。 比FPU更简单的是,显然SIMD扩展并不像FPU以前那么混乱,栈寄存器模型也没使用。 如果你像试着将例子中的double替换成float的话,它们还是会使用同样的指令,但是后缀是SS(标量单精度数),例如MOVSS,COMISS,ADDSS等等。 标量(Scalar)代表着SIMD寄存器会包含仅仅一个值,而不是所有的。可以在所有类型的值中生效的指令都被“封装”成同一个名字。


温度转换

另一个在初学者的编程书中常见的例子是温度转换程序,例如将华氏度转为摄氏度,或者反过来。

我也添加了一个简单的错误处理: 1)我们应该检查用户是否输入了正确的数字 2)我们应该检查摄氏度是否低于-273゜C,因为这比绝对零度还低,学校物理课上的东西应该都还记得。 exit()函数将立即终止程序,而不会回到调用者函数。

25.1 整数值

#include <stdio.h>
#include <stdlib.h>
int main()
{
    int celsius, fahr;
    printf ("Enter temperature in Fahrenheit:\n");
    if (scanf ("%d", &fahr)!=1)
    {
        printf ("Error while parsing your input\n");
        exit(0);
    };
    celsius = 5 * (fahr-32) / 9;
    if (celsius<-273)
    {
        printf ("Error: incorrect temperature!\n");
        exit(0);
    };
    printf ("Celsius: %d\n", celsius);
};

25.1.1 MSVC 2012 x86 /Ox

清单25.1: MSVC 2012 x86 /Ox

$SG4228 DB ’Enter temperature in Fahrenheit:’, 0aH, 00H
$SG4230 DB ’%d’, 00H
$SG4231 DB ’Error while parsing your input’, 0aH, 00H
$SG4233 DB ’Error: incorrect temperature!’, 0aH, 00H
$SG4234 DB ’Celsius: %d’, 0aH, 00H
_fahr$ = -4 ; size = 4
_main PROC
    push ecx
    push esi
    mov esi, DWORD PTR __imp__printf
    push OFFSET $SG4228 ; ’Enter temperature in Fahrenheit:’
    call esi ; call printf()
    lea eax, DWORD PTR _fahr$[esp+12]
    push eax
    push OFFSET $SG4230 ; ’%d’
    call DWORD PTR __imp__scanf
    add esp, 12 ; 0000000cH
    cmp eax, 1
    je SHORT $LN2@main
    push OFFSET $SG4231 ; ’Error while parsing your input’
    call esi ; call printf()
    add esp, 4
    push 0
    call DWORD PTR __imp__exit
    $LN9@main:
    $LN2@main:
    mov eax, DWORD PTR _fahr$[esp+8]
    add eax, -32 ; ffffffe0H
    lea ecx, DWORD PTR [eax+eax*4]
    mov eax, 954437177 ; 38e38e39H
    imul ecx
    sar edx, 1
    mov eax, edx
    shr eax, 31 ; 0000001fH
    add eax, edx
    cmp eax, -273 ; fffffeefH
    jge SHORT $LN1@main
    push OFFSET $SG4233 ; ’Error: incorrect temperature!’
    call esi ; call printf()
    add esp, 4
    push 0
    call DWORD PTR __imp__exit
    $LN10@main:
    $LN1@main:
    push eax
    push OFFSET $SG4234 ; ’Celsius: %d’
    call esi ; call printf()
    add esp, 8
    ; return 0 - at least by C99 standard
    xor eax, eax
    pop esi
    pop ecx
    ret 0
$LN8@main:
_main ENDP

关于这个我们可以说的是:

  • printf()的地址先被载入了ESI寄存器中,所以printf()调用的序列会被CALL ESI处理,这是一个非常著名的编译器技术,当代码中存在多个序列调用同一个函数的时候,并且/或者有空闲的寄存器可以用上的时候,编译器就会这么做。
  • 我们知道ADD EAX,-32指令会把EAX中的数据减去32。 EAX = EAX + (-32)等同于 EAX = EAX – 32,因此编译器决定用ADD而不是用SUB,也许这样性能比较高吧。
  • LEA指令在值应当乘以5的时候用到了: lea ecx, DWORD PTR [eax+eax4]。 是的,i + i * 4是等同于i5的,而且LEA比IMUL运行的要快。 还有,SHL EAX,2/ ADD EAX,EAX指令对也可以替换这句,而且有些编译器就是会这么优化。
  • 用乘法做除法的技巧也会在这儿用上。
  • 虽然我们没有指定,但是main()函数依然会返回0。C99规范告诉我们[15章, 5.1.2.2.3] main()将在没有return时也会照常返回0。 这个规则仅仅对main()函数有效。 虽然MSVC并不支持C99,但是这么看说不好他还是做到了一部分呢?

25.1.2 MSVC 2012 x64 /Ox

生成的代码几乎一样,但是我发现每个exit()调用之后都有INT 3。

xor ecx, ecx
call QWORD PTR __imp_exit
int 3

INT 3是一个调试器断点。 可以知道的是exit()是永远不会return的函数之一。所以如果他“返回”了,那么估计发生了什么奇怪的事情,也是时候启动调试器了。

25.2 浮点数值

清单11.1: MSVC 2010

#include <stdio.h>
#include <stdlib.h>
int main()
{
    double celsius, fahr;
    printf ("Enter temperature in Fahrenheit:\n");
    if (scanf ("%lf", &fahr)!=1)
    {
        printf ("Error while parsing your input\n");
        exit(0);
    };
    celsius = 5 * (fahr-32) / 9;
    if (celsius<-273)
    {
        printf ("Error: incorrect temperature!\n");
        exit(0);
    };
    printf ("Celsius: %lf\n", celsius);
};

MSVC 2010 x86使用FPU指令…

清单25.2: MSVC 2010 x86 /Ox

$SG4038 DB ’Enter temperature in Fahrenheit:’, 0aH, 00H
$SG4040 DB ’%lf’, 00H
$SG4041 DB ’Error while parsing your input’, 0aH, 00H
$SG4043 DB ’Error: incorrect temperature!’, 0aH, 00H
$SG4044 DB ’Celsius: %lf’, 0aH, 00H
__real@c071100000000000 DQ 0c071100000000000r ; -273
__real@4022000000000000 DQ 04022000000000000r ; 9
__real@4014000000000000 DQ 04014000000000000r ; 5
__real@4040000000000000 DQ 04040000000000000r ; 32
_fahr$ = -8 ; size = 8
_main PROC
    sub esp, 8
    push esi
    mov esi, DWORD PTR __imp__printf
    push OFFSET $SG4038 ; ’Enter temperature in Fahrenheit:’
    call esi ; call printf
    lea eax, DWORD PTR _fahr$[esp+16]
    push eax
    push OFFSET $SG4040 ; ’%lf’
    call DWORD PTR __imp__scanf
    add esp, 12 ; 0000000cH
    cmp eax, 1
    je SHORT $LN2@main
    push OFFSET $SG4041 ; ’Error while parsing your input’
    call esi ; call printf
    add esp, 4
    push 0
    call DWORD PTR __imp__exit
    $LN2@main:
    fld QWORD PTR _fahr$[esp+12]
    fsub QWORD PTR __real@4040000000000000 ; 32
    fmul QWORD PTR __real@4014000000000000 ; 5
    fdiv QWORD PTR __real@4022000000000000 ; 9
    fld QWORD PTR __real@c071100000000000 ; -273
    fcomp ST(1)
    fnstsw ax
    test ah, 65 ; 00000041H
    jne SHORT $LN1@main
    push OFFSET $SG4043 ; ’Error: incorrect temperature!’
    fstp ST(0)
    call esi ; call printf
    add esp, 4
    push 0
    call DWORD PTR __imp__exit
    $LN1@main:
    sub esp, 8
    fstp QWORD PTR [esp]
    push OFFSET $SG4044 ; ’Celsius: %lf’
    call esi
    add esp, 12 ; 0000000cH
    ; return 0
    xor eax, eax
    pop esi
    add esp, 8
    ret 0
$LN10@main:
_main ENDP

但是MSVC从2012年开始又改成了使用SIMD指令:

清单25.3: MSVC 2010 x86 /Ox

$SG4228 DB ’Enter temperature in Fahrenheit:’, 0aH, 00H
$SG4230 DB ’%lf’, 00H
$SG4231 DB ’Error while parsing your input’, 0aH, 00H
$SG4233 DB ’Error: incorrect temperature!’, 0aH, 00H
$SG4234 DB ’Celsius: %lf’, 0aH, 00H
__real@c071100000000000 DQ 0c071100000000000r ; -273
__real@4040000000000000 DQ 04040000000000000r ; 32
__real@4022000000000000 DQ 04022000000000000r ; 9
__real@4014000000000000 DQ 04014000000000000r ; 5
_fahr$ = -8 ; size = 8
_main PROC
    sub esp, 8
    push esi
    mov esi, DWORD PTR __imp__printf
    push OFFSET $SG4228 ; ’Enter temperature in Fahrenheit:’
    call esi ; call printf
    lea eax, DWORD PTR _fahr$[esp+16]
    push eax
    push OFFSET $SG4230 ; ’%lf’
    call DWORD PTR __imp__scanf
    add esp, 12 ; 0000000cH
    cmp eax, 1
    je SHORT $LN2@main
    push OFFSET $SG4231 ; ’Error while parsing your input’
    call esi ; call printf
    add esp, 4
    push 0
    call DWORD PTR __imp__exit
    $LN9@main:
    $LN2@main:
    movsd xmm1, QWORD PTR _fahr$[esp+12]
    subsd xmm1, QWORD PTR __real@4040000000000000 ; 32
    movsd xmm0, QWORD PTR __real@c071100000000000 ; -273
    mulsd xmm1, QWORD PTR __real@4014000000000000 ; 5
    divsd xmm1, QWORD PTR __real@4022000000000000 ; 9
    comisd xmm0, xmm1
    jbe SHORT $LN1@main
    push OFFSET $SG4233 ; ’Error: incorrect temperature!’
    call esi ; call printf
    add esp, 4
    push 0
    call DWORD PTR __imp__exit
    $LN10@main:
    $LN1@main:
    sub esp, 8
    movsd QWORD PTR [esp], xmm1
    push OFFSET $SG4234 ; ’Celsius: %lf’
    call esi ; call printf
    add esp, 12 ; 0000000cH
    ; return 0
    xor eax, eax
    pop esi
    add esp, 8
    ret 0
$LN8@main:
_main ENDP

当然,SIMD在x86下也是可用的,包括这些浮点数的运算。使用他们计算起来也确实方便点,所以微软编译器使用了他们。 我们也可以注意到 -273 这个值会很早的被载入XMM0。这个没问题,因为编译器并不一定会按照源代码里面的顺序产生代码。

打赏