为什么 printf 仍然可以使用 RAX 低于 XMM 寄存器中 FP args 的数量?

Why does printf still work with RAX lower than the number of FP args in XMM registers?

我正在阅读《Beginning x64 Assembly Programming》这本书,Linux 64 位系统。我正在使用 NASM 和 gcc。
在关于浮点运算的章节中,本书指定了以下用于添加 2 个浮点数的代码。在本书和其他在线资源中,我读到寄存器 RAX 根据调用约定指定要使用的 XMM 寄存器的数量。
书中代码如下:

extern printf
section .data
num1        dq  9.0
num2        dq  73.0
fmt     db  "The numbers are %f and %f",10,0
f_sum       db  "%f + %f = %f",10,0

section .text
global main
main:
    push rbp
    mov rbp, rsp
printn:
    movsd xmm0, [num1]
    movsd xmm1, [num2]
    mov rdi, fmt
    mov rax, 2      ;for printf rax specifies amount of xmm registers
    call printf

sum:
    movsd xmm2, [num1]
    addsd xmm2, [num2]
printsum:
    movsd xmm0, [num1]
    movsd xmm1, [num2]
    mov rdi, f_sum
    mov rax, 3
    call printf

按预期工作。
然后,在最后一次 printf 调用之前,我尝试更改

mov rax, 3

mov rax, 1

然后我重新组装 运行 程序。

我期待一些不同的废话输出,但我很惊讶输出完全一样。 printf 正确输出 3 个浮点值:

The numbers are 9.000000 and 73.000000
9.000000 + 73.000000 = 82.000000

我想当 printf 期望使用多个 XMM 寄存器时存在某种覆盖,只要 RAX 不为 0,它就会使用连续的 XMM 寄存器。我在调用约定和 NASM 手册中搜索了解释,但没有找到。

这样做的原因是什么?

x86-64 SysV ABI 的严格规则允许 实现仅保存指定的 XMM regs 的确切数量,但当前实现仅检查零/non-zero 因为这很有效,尤其是对于 AL=0 常见情况。

如果您在 AL1 中传递的数字小于 XMM 寄存器 args 的实际数量,或者大于 8 的数字,您将违反 ABI,并且它是只有这个实现细节可以阻止你的代码被破坏。 (即 它“恰好可以工作”,但不受任何标准或文档的保证,并且不能移植到其他一些实际实现,例如旧的 GNU/Linux 发行版使用 GCC4.5 或更早版本构建。)

显示了 glibc printf 的当前版本,它只检查 AL!=0,而旧版本的 glibc 将跳转目标计算到一系列 movaps 存储中。 (该问答是关于 AL>8 时的代码中断,使计算出的跳转到它不应该去的地方。)

引用 ABI 文档,并显示 ICC code-gen,它使用与旧 GCC 相同的指令类似地进行计算跳转。


Glibc 的 printf 实现是从 C 源代码编译的,通常由 GCC 编译。 当现代 GCC 编译像 printf 这样的可变参数函数时,它使 asm 只检查一个零与 non-zero AL,如果 non-zero.

,则将所有 8 arg-passing XMM 寄存器转储到堆栈上的数组

GCC4.5 及更早版本实际上 did 使用 AL 中的数字进行计算跳转到一系列 movaps 存储,实际上只保存尽可能多的 XMM根据需要注册。

Nate 在 Godbolt 上使用 GCC4.5 与 GCC11 的评论中的简单示例显示了与反汇编 old/new glibc(由 GCC 构建)的链接答案相同的差异,这不足为奇。此函数 曾经使用 va_arg(v, double);,从不使用整数类型,因此它不会将传入的 RDI...R9 转储到任何地方,这与 printf 不同。它是一个叶函数,因此它可以使用 red-zone(RSP 以下 128 个字节)。

# GCC4.5.3 -O3 -fPIC    to compile like glibc would
add_them:
        movzx   eax, al
        sub     rsp, 48                  # reserve stack space, needed either way
        lea     rdx, 0[0+rax*4]          # each movaps is 4 bytes long
        lea     rax, .L2[rip]            # code pointer to after the last movaps
        lea     rsi, -136[rsp]             # used later by va_arg.  test/jz version does the same, but after the movaps stores
        sub     rax, rdx
        lea     rdx, 39[rsp]               # used later by va_arg, test/jz version also does an LEA like this
        jmp     rax                      # AL=0 case jumps to L2
        movaps  XMMWORD PTR -15[rdx], xmm7     # using RDX as a base makes each movaps 4 bytes long, vs. 5 with RSP
        movaps  XMMWORD PTR -31[rdx], xmm6
        movaps  XMMWORD PTR -47[rdx], xmm5
        movaps  XMMWORD PTR -63[rdx], xmm4
        movaps  XMMWORD PTR -79[rdx], xmm3
        movaps  XMMWORD PTR -95[rdx], xmm2
        movaps  XMMWORD PTR -111[rdx], xmm1
        movaps  XMMWORD PTR -127[rdx], xmm0   # xmm0 last, will be ready for store-forwading last
.L2:
        lea     rax, 56[rsp]       # first stack arg (if any), I think
     ## rest of the function

对比

# GCC11.2 -O3 -fPIC
add_them:
        sub     rsp, 48
        test    al, al
        je      .L15                          # only one test&branch macro-fused uop
        movaps  XMMWORD PTR -88[rsp], xmm0    # xmm0 first
        movaps  XMMWORD PTR -72[rsp], xmm1
        movaps  XMMWORD PTR -56[rsp], xmm2
        movaps  XMMWORD PTR -40[rsp], xmm3
        movaps  XMMWORD PTR -24[rsp], xmm4
        movaps  XMMWORD PTR -8[rsp], xmm5
        movaps  XMMWORD PTR 8[rsp], xmm6
        movaps  XMMWORD PTR 24[rsp], xmm7
.L15:
        lea     rax, 56[rsp]        # first stack arg (if any), I think
        lea     rsi, -136[rsp]      # used by va_arg.  done after the movaps stores instead of before.
...
        lea     rdx, 56[rsp]        # used by va_arg.  With a different offset than older GCC, but used somewhat similarly.  Redundant with the LEA into RAX; silly compiler.

GCC 可能改变了策略,因为计算出的跳转需要更多的静态代码大小(I-cache 足迹),并且 test/jz 比间接跳转更容易预测。更重要的是,在常见的 AL=0 (no-XMM) 情况 2 中执行的微指令更少。即使对于 AL=1 最坏情况(7 个死 movaps 存储但没有完成计算分支目标的工作)也不多了。


相关问答:

  • AL != 0 对比 glibc printf
  • 的计算跳转 code-gen
  • Why is %eax zeroed before a call to printf? 显示现代 GCC code-gen
  • ABI 文档参考,了解为什么会这样
  • mold and lld not linking against libc correctly 讨论各种可能的 ABI-violations 以及程序在从 _start 调用 printf 时可能无法工作的其他方式(取决于 dynamic-linker 挂钩来获取 libc 启动调用的函数)。

Semi-related 当我们谈论 calling-convention 违规行为时:

  • glibc scanf Segmentation faults when called from a function that doesn't align RSP(甚至最近,还有 printf 和 AL=0,使用 movaps 除了将 XMM args 转储到堆栈之外的其他地方)

脚注 1:重要的是 AL,而不是 RAX

x86-64 System V ABI 文档指定可变参数函数必须只查看 AL 以了解 regs 的数量; RAX 的高 7 字节允许存放垃圾。 mov eax, 3 是设置 AL 的有效方法,,尽管它的 machine-code 大小(5 个字节)比 mov al,3(2 个字节)大。 clang 通常使用 mov al, 3.

ABI 文档中的要点,有关更多上下文,请参阅

The prologue should use %al to avoid unnecessarily saving XMM registers. This is especially important for integer only programs to prevent the initialization of the XMM unit.

(最后一点已过时:XMM regs 广泛用于 memcpy/memset 并内联到 zero-init 小型数组/结构。如此之多以至于 Linux 使用“急切的”FPU save/restore 在上下文切换时,而不是在第一次使用 XMM reg 时出错的“惰性”。)

The contents of %al do not need to match exactly the number of registers, but must be an upper bound on the number of vector registers used and is in the range 0–8 inclusive.

AL <= 8 的 ABI 保证允许 computed-jump 实现省略 bounds-checking。 (同样, 是的,可以假设 ABI 违规不会发生,例如,通过编写在这种情况下会崩溃的代码。)


脚注2:两种策略的效率

较小的静态 code-size(I-cache 占用空间)始终是一件好事,而 AL!=0 策略对此有利。

最重要的是,AL==0 情况下执行的指令总数减少了。 printf 不是唯一的可变参数函数; sscanf 并不少见,它从不接受 FP args(仅指针)。如果编译器可以看到一个函数从不使用 va_arg 和 FP 参数,它会完全省略保存,使这一点没有实际意义,但 scanf/printf 函数通常作为 vfscanf 的包装器实现/ vfprintf 调用,所以编译器 没有 看到,它看到 va_list 被传递给另一个函数,所以它必须保存所有内容。 (我认为人们很少会编写自己的可变参数函数,因此在很多程序中,对可变参数函数的唯一调用将是对库函数的调用。)

[=92=Out-of-order exec 可以很好地处理 AL<8 但 non-zero 情况下的死存储,这要归功于广泛的管道和存储缓冲区,在这些存储发生的同时开始实际工作。

计算和执行间接跳转总共需要 5 条指令,不包括 lea rsi, -136[rsp]lea rdx, 39[rsp]。 test/jz 策略也做那些或类似的,就在 movaps 存储之后,作为 va_arg 代码的设置,它必须弄清楚它何时到达 register-save 区域的末尾并切换查看堆栈参数。

我也没有计算 sub rsp, 48;无论哪种方式,这都是必要的,除非您也制作 XMM-save-area 大小变量,或者只保存每个 XMM reg 的低半部分,这样 8x 8 B = 64 字节将适合 red-zone。理论上,可变参数函数可以在 XMM reg 中采用 16 字节 __m128d arg,因此 GCC 使用 movaps 而不是 movlps。 (我不确定 glibc printf 是否有任何需要进行一次的转换)。在 non-leaf 函数中,例如实际的 printf,您总是需要保留更多 space 而不是使用 red-zone。 (这是 computed-jump 版本中 lea rdx, 39[rsp] 的原因之一:每个 movaps 都需要正好是 4 个字节,因此编译器生成该代码的方法必须确保它们的偏移量在[reg+disp8] 寻址模式的 [-128,+127] 范围,而不是 0 除非 GCC 将使用特殊的 asm 语法强制在那里使用更长的指令。

几乎所有 x86-64 CPU 运行 16 字节存储为单个 micro-fused uop(只有老旧的 AMD K8 和 Bobcat 分裂成 8 字节的一半;参见 https://agner.org/optimize/ ),而且我们通常会触及 128 字节区域下方的堆栈 space。 (此外,computed-jump 策略本身存储到底部,因此它不会避免触及该缓存行。)

因此对于具有一个 XMM arg 的函数,computed-jump 版本总共需要 6 条 single-uop 指令(5 个整数 ALU/jump,一个 movaps)来保存 XMM arg。

test/jz 版本总共需要 9 个 uops(10 条指令,但 test/jz macro-fuse 在 64 位模式下在 Intel 上自 Nehalem,AMD 自 Bulldozer IIRC 以来)。 1 macro-fused test-and-branch 和 8 个 movaps 商店。

这是最好的情况computed-jump版本:有更多的xmm args,它仍然运行s 5条指令来计算跳转目标,但必须 运行 更多 movaps 指令。 test/jz 版本始终为 9 微指令。因此,动态 uop 计数的 break-even 点(实际执行,与坐在内存中占用 I-cache 足迹)是 4 个 XMM args,这可能很少见,但它还有其他优点。特别是在 AL == 0 的情况下,它是 5 对 1。

test/jz 分支对于任意数量的 XMM args 总是去同一个地方,除了零,这使得它 比间接分支 更容易预测,这与 printf("%f %f\n", ...)"%f\n".

computed-jump 版本中的 5 条指令中的 3 条(不包括 jmp)形成来自传入 AL 的依赖链,使得在检测到错误预测之前需要更多的周期(即使chain 可能在调用之前以 mov eax, 1 开头)。但是 dump-everything 策略中的“额外”指令只是一些 XMM1..7 的死存储,它们永远不会重新加载并且不属于任何依赖链。只要存储缓冲区和 ROB/RS 可以吸收它们,out-of-order exec 就可以在空闲时处理它们。

(公平地说,他们会暂时占用 store-data 和 store-address 执行单元,这意味着以后的存储也不会尽快为 store-forwarding 准备好. 而在 store-address uops 运行 在与加载相同的执行单元上的 CPU 上,以后的加载可能会被那些占用这些执行单元的存储 uops 延迟。幸运的是,现代 CPU 至少有 2 个加载执行单元,从 Haswell 到 Skylake 的 Intel 可以在 3 个端口中的任何一个上 运行 store-address uops,具有像这样的简单寻址模式。Ice Lake 有 2 个加载/2 个存储端口,没有重叠。)

计算出的跳转版本最后保存了 XMM0,这很可能是第一个重新加载的 arg。 (大多数可变参数函数按顺序通过它们的参数)。如果有多个 XMM args,computed-jump 方式将不会准备好从该存储 store-forward 直到几个周期之后。但是对于 AL=1 的情况,这是唯一的 XMM 存储,没有其他工作占用 load/store-address 执行单元,少量的 args 可能更常见。

与更小的代码占用空间和更少的 AL==0 情况下执行的指令相比,这些原因中的大多数都是微不足道的。思考现代简单方法的 up/down 方面很有趣(对我们中的一些人来说),以表明即使在最坏的情况下,这也不是问题。