为什么在调用printf之前,%eax被清零?

55

我想学习一些x86汇编知识。我正在64位的Mac上用gcc -S -O0进行编译。

C代码:

printf("%d", 1);

输出:

movl    $1, %esi
leaq    LC0(%rip), %rdi
movl    $0, %eax        ; WHY?
call    _printf

我不理解为什么在调用'printf'之前要将%eax清零。由于printf返回打印到%eax中的字符数,我的最佳猜测是将其清零以为printf做准备,但我本来会认为printf应该负责准备它。另外,相比之下,如果我调用自己的函数int testproc(int p1)gcc看不出有必要准备%eax。因此,我想知道为什么gcc在处理printftestproc时会有所不同。

3个回答

61
在x86_64 ABI中,如果一个函数有可变参数,则期望AL(它是EAX的一部分)保存用于保存该函数参数的向量寄存器数量。
在您的示例中:
printf("%d", 1);

这个函数有一个整数参数,所以不需要使用向量寄存器,因此将 AL 设置为0。

另一方面,如果您将示例更改为:

printf("%f", 1.0f);

然后浮点字面量将被存储在向量寄存器中,相应地,AL 被设置为 1

movsd   LC1(%rip), %xmm0
leaq    LC0(%rip), %rdi
movl    $1, %eax
call    _printf

正如预期:

printf("%f %f", 1.0f, 2.0f);

由于有两个浮点参数,这将导致编译器将 AL 设置为 2

movsd   LC0(%rip), %xmm0
movapd  %xmm0, %xmm1
movsd   LC2(%rip), %xmm0
leaq    LC1(%rip), %rdi
movl    $2, %eax
call    _printf

关于您的其他问题: puts在调用之前也将%eax清零,尽管它只需要一个指针。为什么会这样?
不应该这样。例如:
#include <stdio.h>

void test(void) {
    puts("foo");
}

当使用gcc -c -O0 -S编译时,输出:

pushq   %rbp
movq    %rsp, %rbp
leaq    LC0(%rip), %rdi
call    _puts
leave
ret

而且%eax没有被清零。但是,如果您删除#include <stdio.h>,则生成的汇编代码会在调用puts()之前将%eax清零:

pushq   %rbp
movq    %rsp, %rbp
leaq    LC0(%rip), %rdi
movl    $0, %eax
call    _puts
leave
ret

原因与您的第二个问题有关:
这也发生在调用我的void proc()函数之前(即使设置了-O2),但在调用void proc2(int param)函数时不会清零。
如果编译器没有看到函数的声明,则不会对其参数做出任何假设,该函数可能接受可变参数。如果指定空参数列表(不应该这样做,并且被ISO/IEC标记为过时的C特性),同样适用。由于编译器没有足够的有关函数参数的信息,因此在调用函数之前将%eax清零,因为函数可能被定义为具有可变参数。
例如:
#include <stdio.h>

void function() {
    puts("foo");
}

void test(void) {
    function();
}

function() 拥有一个空参数列表时,会产生以下结果:

pushq   %rbp
movq    %rsp, %rbp
movl    $0, %eax
call    _function
leave
ret

然而,如果您遵循推荐的做法,在函数不接受任何参数时指定void,例如:

#include <stdio.h>

void function(void) {
    puts("foo");
}

void test(void) {
    function();
}

那么编译器就知道function()不接受参数,特别是它不接受可变参数,因此在调用该函数之前不会清除%eax

pushq   %rbp
movq    %rsp, %rbp
call    _function
leave
ret

3
ABI的注释:“我们使用向量寄存器来指代SSE或AVX寄存器。” - Ciro Santilli OurBigBook.com
4
将向量计数传递到%rax寄存器中的优点是什么?它仅仅是为了提高性能,避免在“寄存器保存区域”上保存无用的寄存器吗? - Ciro Santilli OurBigBook.com

43

根据x86_64 System V ABI寄存器使用表:

  • %rax       临时寄存器; 用于可变参数,传递有关使用向量寄存器数量的信息;1号返回寄存器 ...

printf是一个具有可变参数的函数,使用向量寄存器的数量为零。

请注意,printf只需检查%al,因为调用者可以在%rax的高字节中留下垃圾。 (仍然,xor %eax,%eax是将%al清零的最有效方法)

有关更多详细信息,请参见此Q&A和 标签 Wiki,如果上述链接过期,则可以获取最新的ABI链接。


1
puts 在调用之前也将 %eax 清零,尽管它只接受一个指针。为什么会这样? - sh54
1
这种情况也会发生在调用我的 void proc() 函数之前 (即使设置了 -O2),但在调用 void proc2(int param) 函数时不是归零的。 - sh54
9
记录一下,这发生在调用void proc()之前,因为C语言的该函数签名并未说明proc的参数个数,它也可能是一个可变参数函数,因此将rax清零是必要的。void proc()void proc(void)是不同的。请参见https://dev59.com/8HRB5IYBdhLWcg3wLkxM。 - frangio
1
@sh54 FWW 我刚刚在 x86-64 gcc 9.2 上尝试了 puts,在调用之前 eax 没有被清零。 - sevko
我已经测试过了,看起来提供两个fp参数在xmm0xmm1中,实际上无论rax是1还是2都没有关系。我想知道为什么。 - unegare

9
原因是可变参数函数的高效实现。当可变参数函数调用va_start时,编译器通常无法确定va_arg是否会针对浮点参数调用。因此,编译器总是必须保存所有可以容纳参数的向量寄存器,以便潜在的未来va_arg调用即使在此期间寄存器已被破坏,也可以访问它。这相当昂贵,因为在x86-64上有八个这样的寄存器。
因此,调用者将向量寄存器的数量作为优化提示传递给可变参数函数。如果调用中没有涉及向量寄存器,则无需保存它们中的任何一个。例如,在glibc的sprintf函数开始处如下所示:
00000000000586e0 <_IO_sprintf@@GLIBC_2.2.5>:
   586e0:       sub    $0xd8,%rsp
   586e7:       mov    %rdx,0x30(%rsp)
   586ec:       mov    %rcx,0x38(%rsp)
   586f1:       mov    %r8,0x40(%rsp)
   586f6:       mov    %r9,0x48(%rsp)
   586fb:       test   %al,%al
   586fd:       je     58736 <_IO_sprintf@@GLIBC_2.2.5+0x56>
   586ff:       movaps %xmm0,0x50(%rsp)
   58704:       movaps %xmm1,0x60(%rsp)
   58709:       movaps %xmm2,0x70(%rsp)
   5870e:       movaps %xmm3,0x80(%rsp)
   58716:       movaps %xmm4,0x90(%rsp)
   5871e:       movaps %xmm5,0xa0(%rsp)
   58726:       movaps %xmm6,0xb0(%rsp)
   5872e:       movaps %xmm7,0xc0(%rsp)
   58736:       mov    %fs:0x28,%rax

实际上,所有的实现都只将%al用作标志位,如果为零,则跳过向量保存指令。使用计算跳转来避免保存不必要的寄存器似乎并不能提高性能。
此外,如果编译器可以检测到va_arg从未针对浮点参数调用过,则它们将完全优化掉向量寄存器保存操作,因此在这种情况下设置%al是多余的。但调用者无法知道这个实现细节,因此仍然需要设置%al

printf在x86-64 Linux上使用旧版gcc时,以AL = 10陷入无限循环展示了可变参数函数的旧GCC代码生成方式,实际上它执行了计算跳转以仅运行所需数量的movaps指令。对于现代处理器来说,将更多的存储器吸收到存储器缓冲区中比计算分支更便宜,在AL=0的非常常见情况下加快了速度。 - Peter Cordes
有趣的事实:scanf等函数永远不能接受FP参数,但它在内部通过将va_list传递给另一个函数来工作,因此编译器看不到这个事实。(因此,vfscanfscanf可以共享一个实现)。这也是为什么这种节省代码有时无法被优化掉的另一个原因。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接