提问人:pesotsan 提问时间:4/24/2022 最后编辑:Sep Rolandpesotsan 更新时间:4/25/2022 访问量:245
为什么 printf 仍然在 RAX 低于 XMM 寄存器中的 FP 参数数量的情况下工作?
Why does printf still work with RAX lower than the number of FP args in XMM registers?
问:
我正在阅读 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
这是什么原因?
答:
x86-64 SysV ABI 的严格规则允许仅保存指定 XMM regs 的确切数量的实现,但当前的实现仅检查零/非零,因为这很有效,尤其是对于 AL=0 常见情况。
如果在 AL1 中传递的数字低于 XMM 寄存器参数的实际数量,或者传递的数字高于 8,则违反了 ABI,只有此实现细节才能阻止代码中断。(即它“碰巧可以工作”,但不能得到任何标准或文档的保证,并且不能移植到其他一些实际实现中,例如使用 GCC4.5 或更早版本构建的旧 GNU/Linux 发行版。
此问答显示了 glibc printf 的当前版本,它只检查 ,而 glibc 的旧版本则计算到一系列存储中。(该问答是关于代码破解时,使计算出的跳跃转到它不应该去的地方。AL!=0
movaps
AL>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
相关问答:
- 程序集可执行文件不显示任何内容 (x64)AL != 0 与 glibc printf 的计算跳转代码生成
- 为什么在调用 printf 之前 %eax 归零?显示现代 GCC 代码生成
- 为什么 eax 包含向量参数的数量?ABI 文档参考,说明为什么会这样
- mold 和 lld 未正确链接到 libc,讨论各种可能的 ABI 违规以及程序在调用 printf 时可能无法工作的其他方式(取决于动态链接器钩子来调用 libc 启动函数)。
_start
当我们谈论调用约定违规时,半相关:
- glibc scanf 从不对齐 RSP 的函数调用时出现分段错误(甚至最近,AL=0 时,使用将 XMM 参数转储到堆栈以外的其他位置)
printf
movaps
脚注 1:重要的是 AL,而不是 RAX
x86-64 System V ABI 文档指定可变参数函数必须只查看 AL 的注册数;RAX 的高 7 字节被允许保存垃圾。 是设置 AL 的有效方法,避免了写入部分寄存器时可能出现的错误依赖关系,尽管它的机器代码大小(5 字节)大于(2 字节)。Clang 通常使用 .mov eax, 3
mov al,3
mov 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 函数通常作为 / 调用的包装器实现,所以编译器看不到这一点,它看到一个被传递给另一个函数,所以它必须保存所有内容。(我认为人们很少编写自己的可变参数函数,因此在许多程序中,对可变参数函数的唯一调用将是库函数。printf
sscanf
va_arg
vfscanf
vfprintf
va_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
__m128d
movaps
movlps
lea 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 情况执行的指令更少的优势相比,这些原因中的大多数都是微不足道的。(对于我们中的一些人来说)思考现代简单方式的上/下两面很有趣,以表明即使在最坏的情况下,它也不是问题。
评论
:)
movaps
movlps
movups
__m128
movlps
movsd
jmp rel8
评论
rax
al
printf
al