为什么 printf 仍然在 RAX 低于 XMM 寄存器中的 FP 参数数量的情况下工作?

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

提问人:pesotsan 提问时间:4/24/2022 最后编辑:Sep Rolandpesotsan 更新时间:4/25/2022 访问量:245

问:

我正在阅读 Linux 64 系统中的“开始 x64 汇编编程”一书。我正在使用 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

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

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

The numbers are 9.000000 and 73.000000
9.000000 + 73.000000 = 82.000000

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

这是什么原因?

Linux 程序集 x86-64 NASM 调用约定

评论

2赞 Jester 4/24/2022
被调用的函数可以使用(实际上)中的值,但这不是必需的。但是,调用方应正确设置它。某些版本只关心零与非零。在任何情况下,它对参数的位置都没有影响。raxalprintf
1赞 Nate Eldredge 4/24/2022
例如,请参阅 godbolt.org/z/f869oen9W ,如果为零,则不会溢出 xmm 寄存器,如果为非零,则不会溢出所有寄存器。可能只溢出正确数字的代码会比它的价值更麻烦。不必要的内存存储不会花费太多,尤其是对于可能已经在 L1 或 L2 缓存中的堆栈。但希望很明显,你不能依赖被调用的函数来做到这一点。al
0赞 pesotsan 4/24/2022
感谢您的回答。我才刚刚开始,对此感到恼火。
0赞 Solomon Ucko 4/24/2022
仅仅因为它现在似乎有效并不意味着它将永远有效。

答:

7赞 Peter Cordes 4/24/2022 #1

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

如果在 AL1 中传递的数字低于 XMM 寄存器参数的实际数量,或者传递的数字高于 8,则违反了 ABI,只有此实现细节才能阻止代码中断。(即它“碰巧可以工作”,但不能得到任何标准或文档的保证,并且不能移植到其他一些实际实现中,例如使用 GCC4.5 或更早版本构建的旧 GNU/Linux 发行版。

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

为什么 eax 包含向量参数的数量?引用 ABI 文档,并显示 ICC 代码生成,它使用与旧 GCC 相同的指令进行类似的计算跳跃。


Glibc 的 printf 实现是从 C 源代码编译的,通常由 GCC 编译。当现代 GCC 编译像 printf 这样的可变参数函数时,它会使 asm 只检查零与非零 AL,如果非零,则将所有 8 个参数传递的 XMM 寄存器转储到堆栈上的数组中。

GCC4.5 及更早版本实际上确实使用 AL 中的数字来计算跳转到存储序列,以便仅根据需要实际保存尽可能多的 XMM regs。movaps

不出所料,Nate 在 GCC4.5 与 GCC11 的 Godbolt 评论中的简单示例显示了与拆卸旧/新 glibc(由 GCC 构建)的链接答案相同的差异。此函数使用 ,从不使用整数类型,因此它不会转储传入的 RDI...R9 无处不在,不像 .它是一个叶函数,因此它可以使用红色区域(RSP 以下 128 字节)。va_arg(v, double);printf

# 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 中执行的 uops 更少。即使对于 AL=1 的最坏情况(7 个死存储但没有完成计算分支目标的工作),也没有更多。movaps


相关问答:

当我们谈论调用约定违规时,半相关:


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

x86-64 System V ABI 文档指定可变参数函数必须只查看 AL 的注册数;RAX 的高 7 字节被允许保存垃圾。 是设置 AL 的有效方法,避免了写入部分寄存器时可能出现的错误依赖关系,尽管它的机器代码大小(5 字节)大于(2 字节)。Clang 通常使用 .mov eax, 3mov al,3mov al, 3

ABI 文档中的要点,请参阅为什么 eax 包含向量参数的数量? 有关更多上下文:

应使用序言来避免不必要地保存 XMM 寄存器。这对于仅整数程序尤其重要,以防止 XMM 单元初始化。%al

(最后一点已经过时了:XMM regs 广泛用于 memcpy/memset,并内联到零初始化的小数组/结构。如此之多,以至于 Linux 在上下文切换上使用“急切”的 FPU 保存/恢复,而不是在首次使用 XMM reg 时出现故障的“懒惰”。

的内容不需要与寄存器的数量完全匹配,但必须是所用向量寄存器数量的上限,并且范围在 0-8(含 0-8)。%al

AL <= 8 的这种 ABI 保证允许计算跳转实现省略边界检查。(类似地,C++ 标准是否允许未初始化的 bool 使程序崩溃?是的,可以假设 ABI 违规不会发生,例如,通过编写在这种情况下会崩溃的代码。


脚注2:两种战略的效率

更小的静态代码大小(I-cache 占用空间)总是一件好事,而 AL!=0 策略对此有利。

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

对于 AL<8 但非零情况,无序的 exec 可以很好地咀嚼死存储,这要归功于广泛的管道和存储缓冲区,可以在这些存储发生的同时开始实际工作。

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

我也不算;无论哪种方式,这都是必要的,除非您也使 XMM-save-area 大小可变,或者只保存每个 XMM reg 的下半部分,以便 8x 8 B = 64 字节适合红色区域。从理论上讲,可变参数函数可以在 XMM reg 中采用 16 字节的参数,因此 GCC 使用 .(我不确定 glibc printf 是否有任何需要转换的转换)。在非叶函数(如实际的 printf)中,您始终需要保留更多空间,而不是使用红色区域。(这是计算跳转版本中的原因之一:每个字节都需要正好是 4 个字节,因此编译器生成该代码的配方必须确保它们的偏移量在寻址模式的 [-128,+127] 范围内,除非 GCC 打算使用特殊的 asm 语法来强制那里的更长的指令。sub rsp, 48__m128dmovapsmovlpslea rdx, 39[rsp]movaps[reg+disp8]0

几乎所有的 x86-64 CPU 都以单个微融合 uop 的形式运行 16 字节存储(只有硬皮的旧 AMD K8 和 Bobcat 分成 8 字节的两半;参见 https://agner.org/optimize/),无论如何,我们通常会触及 128 字节区域以下的堆栈空间。(此外,计算跳转策略本身存储到底部,因此它无法避免触及该缓存行。

因此,对于具有一个 XMM 参数的函数,计算跳转版本总共需要 6 个单 uop 指令(5 个整数 ALU/跳转,一个 movaps)才能保存 XMM 参数。

test/jz 版本总共需要 9 个 uops(10 条指令,但 test/jz 宏保险丝在 Intel 上以 64 位模式出现,自 Nehalem 以来,AMD 自 Bulldozer IIRC 以来)。1 个宏融合测试和分支,以及 8 个 movaps 存储。

这是计算跳转版本的最佳情况:使用更多的 xmm 参数,它仍然运行 5 条指令来计算跳转目标,但必须运行更多的 movaps 指令。test/jz 版本始终为 9 uops。因此,动态 uop 计数(实际执行,而不是坐在内存中占用 I-cache 占用空间)的盈亏平衡点是 4 个 XMM 参数,这可能很少见,但它还有其他优点。尤其是在 AL == 0 的情况下,它是 5 对 1。

对于除零之外的任意数量的 XMM 参数,test/jz 分支始终位于同一位置,因此比 vs 的间接分支更容易预测printf("%f %f\n", ...)"%f\n"

计算跳转版本中的 5 条指令中有 3 条(不包括 JMP)与传入的 AL 形成依赖链,因此需要更多周期才能检测到错误预测(即使该链可能在调用之前以 right 开头)。但是 dump-everything 策略中的“额外”指令只是一些 XMM1 的死存储。7 个永远不会重新加载,并且不属于任何依赖链。只要存储缓冲区和 ROB/RS 可以吸收它们,无序 exec 就可以在闲暇时处理它们。mov eax, 1

(公平地说,它们会在一段时间内捆绑存储数据和存储地址执行单元,这意味着以后的存储也不会很快准备好进行存储转发。在存储地址 uop 与负载在相同执行单元上运行的 CPU 上,后续加载可能会被那些占用这些执行单元的存储 uop 延迟。幸运的是,现代 CPU 至少有 2 个负载执行单元,从 Haswell 到 Skylake 的 Intel 可以在 3 个端口中的任何一个上运行存储地址 uops,使用这样的简单寻址模式。Ice Lake 有 2 个加载/2 个存储端口,没有重叠。

计算的跳转版本最后保存了 XMM0,这可能是第一个重新加载的 arg。(大多数可变参数函数按顺序遍历它们的参数)。如果有多个 XMM 参数,则计算跳转方式要到几个周期后才能准备好从该存储进行存储转发。但是对于 AL=1 的情况,这是唯一的 XMM 存储,并且没有其他工作来捆绑加载/存储地址执行单元,并且少量的 arg 可能更常见。

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

评论

0赞 David C. Rankin 4/24/2022
非常好的答案,对签名块的补充非常好。你会认为他们也会来找你寻求设计帮助:)
0赞 ecm 4/25/2022
“所以 GCC 使用而不是”你是说这里吗?movapsmovlpsmovups
1赞 Peter Cordes 4/25/2022
@ecm:不,我是说,如果 GCC 代码生成只需要支持 XMM 寄存器中的 4 字节或 8 字节参数(不或其他可能使用完整 XMM 的参数),它只需要 8 字节的保存插槽。转储到这些最好用 ,这与存储相同,但机器代码短了 1 个字节。__m128movlpsmovsd
0赞 Noah 4/25/2022
@PeterCordes回复:“test/jz 比间接跳跃更容易预测”在这种情况下,两者不是等价的吗?间接跳跃 IIRC 动态预测前一个目标,静态预测下一条指令,所以你不能通过让零大小写成为分支之后的下一条指令来获得相同的行为吗?静态上同样好,动态上应该基本上与零非零(最后一个目标=零,或其他=非零)相同。
0赞 Peter Cordes 4/25/2022
@Noah:请注意,AL=2 需要与 AL=1 不同的预测,这与全有或全无策略不同,因此如果它并不总是整数,则预测它是一种更复杂的模式。而且,我认为间接跳跃在误判时可能会有稍高的失误惩罚,这与我所说的不同,但仍然相关。此外,它是一个计算的跳转,而不是一个跳转表,所以除非你对特殊情况零做了更多的整数工作,将保存指令块放在某个地方,否则你不会让 AL=0 情况成为失败。(或者掉进......jmp rel8