The x86-64 SysV ABI https://stackoverflow.com/questions/18133812/where-is-the-x86-64-system-v-abi-documented的严格规定allow仅保存指定的 XMM 寄存器的确切数量的实现,但当前的实现仅检查零/非零,因为这很有效,特别是对于 AL=0 常见情况。
If you pass a number in AL1 lower than the actual number of XMM register args, or a number higher than 8, you'd be violating the ABI, and it's only this implementation detail which stops your code from breaking. (i.e. it "happens to work", but is not guaranteed by any standard or documentation, and isn't portable to some other real implementations, like older GNU/Linux distros that were built with GCC4.5 or earlier.)
本次问答 https://stackoverflow.com/questions/61622435/assembly-executable-doesnt-show-anything-x64显示 glibc printf 的当前版本,它仅检查AL!=0
,与旧版本的 glibc 相比,它将跳转目标计算为一系列movaps
商店。 (那个问答是关于密码破解的AL>8
,使计算的跳转到不应该的地方。)
为什么eax包含向量参数的数量? https://stackoverflow.com/questions/51488660/why-does-eax-contain-the-number-of-vector-parameters引用 ABI 文档,并显示 ICC 代码生成,它使用与旧 GCC 相同的指令类似地执行计算跳转。
Glibc's printf
实现是从 C 源代码编译的,通常由 GCC 编译。当现代 GCC 编译像 printf 这样的可变参数函数时,它使 asm 仅检查零与非零 AL,如果非零,则将所有 8 个传递参数的 XMM 寄存器转储到堆栈上的数组。
实际上GCC4.5及更早版本did使用 AL 中的数字进行计算跳转到一系列movaps
存储,仅实际保存必要数量的 XMM 寄存器。
内特的简单例子来自评论Godbolt https://godbolt.org/#g:!((g:!((g:!((h:codeEditor,i:(filename:%271%27,fontScale:14,fontUsePx:%270%27,j:1,lang:___c,selection:(endColumn:1,endLineNumber:4,positionColumn:1,positionLineNumber:4,selectionStartColumn:1,selectionStartLineNumber:4,startColumn:1,startLineNumber:4),source:%27%23include+%3Cstdarg.h%3E%0A%0Astatic+double+accum%3B++//+let+GCC+-fPIC+reference+this+without+the+GOT,+so+a+GOT+load+isn!%27t+cluttering+the+top+of+the+function%0A%0Avoid+add_them(void+*unused,+...)+%7B%0A++++va_list+v%3B%0A++++va_start(v,+unused)%3B%0A++++double+x%3B%0A++++do+%7B%0A++++++++x+%3D+va_arg(v,+double)%3B%0A++++++++accum+%2B%3D+x%3B%0A++++%7D+while+(x+!!%3D+0.0)%3B%0A++++va_end(v)%3B%0A%7D%27),l:%275%27,n:%270%27,o:%27C+source+%231%27,t:%270%27)),k:36.26316179015691,l:%274%27,n:%270%27,o:%27%27,s:0,t:%270%27),(g:!((h:compiler,i:(compiler:cg453,filters:(b:%270%27,binary:%271%27,commentOnly:%270%27,demangle:%270%27,directives:%270%27,execute:%271%27,intel:%270%27,libraryCode:%270%27,trim:%271%27),flagsViewOpen:%271%27,fontScale:14,fontUsePx:%270%27,j:1,lang:___c,libs:!(),options:%27-O3+-fPIC%27,selection:(endColumn:1,endLineNumber:1,positionColumn:1,positionLineNumber:1,selectionStartColumn:1,selectionStartLineNumber:1,startColumn:1,startLineNumber:1),source:1,tree:%271%27),l:%275%27,n:%270%27,o:%27x86-64+gcc+4.5.3+(C,+Editor+%231,+Compiler+%231)%27,t:%270%27)),k:30.40350487650978,l:%274%27,n:%270%27,o:%27%27,s:0,t:%270%27),(g:!((h:compiler,i:(compiler:cg112,filters:(b:%270%27,binary:%271%27,commentOnly:%270%27,demangle:%270%27,directives:%270%27,execute:%271%27,intel:%270%27,libraryCode:%270%27,trim:%271%27),flagsViewOpen:%271%27,fontScale:14,fontUsePx:%270%27,j:2,lang:___c,libs:!(),options:%27-O3+-fPIC%27,selection:(endColumn:1,endLineNumber:1,positionColumn:1,positionLineNumber:1,selectionStartColumn:1,selectionStartLineNumber:1,startColumn:1,startLineNumber:1),source:1,tree:%271%27),l:%275%27,n:%270%27,o:%27x86-64+gcc+11.2+(C,+Editor+%231,+Compiler+%232)%27,t:%270%27)),k:33.33333333333333,l:%274%27,n:%270%27,o:%27%27,s:0,t:%270%27)),l:%272%27,n:%270%27,o:%27%27,t:%270%27)),version:4使用 GCC4.5 与 GCC11 显示与旧/新 glibc(由 GCC 构建)反汇编的链接答案相同的差异,这并不奇怪。这个功能only曾经使用过va_arg(v, double);
,绝不是整数类型,因此它不会将传入的 RDI...R9 转储到任何地方,这与printf
。它是一个叶函数,因此可以使用红色区域(低于 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
vs.
# 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 presumably changed strategy because the computed jump takes more static code size (I-cache footprint), and a test/jz is easier to predict than an indirect jump. Even more importantly, it's fewer uops executed in the common AL=0 (no-XMM) case2. And not many more even for the AL=1 worst case (7 dead movaps
stores but no work done computing a branch target).
相关问答:
-
程序集可执行文件不显示任何内容 (x64) https://stackoverflow.com/questions/61622435/assembly-executable-doesnt-show-anything-x64AL != 0 与 glibc printf 的计算跳转代码生成
-
为什么 %eax 在调用 printf 之前被清零? https://stackoverflow.com/questions/6212665/why-is-eax-zeroed-before-a-call-to-printf/58684613#58684613显示现代 GCC 代码生成
-
为什么eax包含向量参数的数量? https://stackoverflow.com/questions/51488660/why-does-eax-contain-the-number-of-vector-parametersABI 文档参考了解为什么会这样
-
Mold 和 lld 未正确链接到 libc https://stackoverflow.com/questions/70598054/mold-and-lld-not-linking-against-libc-correctly讨论各种可能的 ABI 违规以及程序在调用 printf 时可能无法工作的其他方式
_start
(取决于动态链接器挂钩来调用 libc 启动函数)。
当我们谈论违反调用约定时,半相关的:
-
glibc scanf 从未对齐 RSP 的函数调用时出现分段错误 https://stackoverflow.com/questions/51070716/glibc-scanf-segmentation-faults-when-called-from-a-function-that-doesnt-align-r(甚至最近,还
printf
当 AL=0 时,使用movaps
除了将 XMM 参数转储到堆栈之外的其他地方)
脚注 1:重要的是 AL,而不是 RAX
x86-64 System V ABI 文档指定可变参数函数必须仅查看 AL 中的寄存器数量; RAX 的高 7 字节允许存放垃圾。mov eax, 3
是设置 AL 的有效方法,避免写入部分寄存器可能出现的错误依赖关系 https://stackoverflow.com/questions/41573502/why-doesnt-gcc-use-partial-registers,尽管它的机器代码大小(5 个字节)比mov al,3
(2 个字节)。 clang 通常使用mov al, 3
.
ABI 文档中的要点,请参阅为什么eax包含向量参数的数量? https://stackoverflow.com/questions/51488660/why-does-eax-contain-the-number-of-vector-parameters了解更多背景信息:
序言应该使用%al
以避免不必要地保存 XMM 寄存器。这对于纯整数程序尤其重要,可以防止 XMM 单元的初始化。
(最后一点已经过时了:XMM regs 广泛用于 memcpy/memset 并内联到零初始化小数组/结构。如此之多以至于 Linux 在上下文切换上使用“热切”FPU 保存/恢复,而不是“惰性”,其中第一次使用 XMM 寄存器错误。)
的内容%al
不需要精确匹配寄存器的数量,但必须是所使用的向量寄存器数量的上限,并且在 0 到 8 的范围内(含)。
AL C++ 标准是否允许未初始化的布尔值导致程序崩溃?是的,可以假设 ABI 违规行为不会发生,例如通过编写在这种情况下会崩溃的代码。)
脚注2:两种策略的效率
较小的静态代码大小(I 缓存占用空间)始终是一件好事,并且 AL!=0 策略对此有利。
最重要的是,在 AL==0 情况下执行的总指令数更少。printf
不是唯一的可变函数;sscanf
这种情况并不罕见,而且它从不接受 FP args(仅指针)。如果编译器可以看到函数从不使用va_arg
使用 FP 参数,它完全忽略保存,使得这一点毫无意义,但 scanf/printf 函数通常作为包装器实现vfscanf
/ vfprintf
调用,所以编译器doesn't看到那个,它看到一个va_list
被传递给另一个函数,因此它必须保存所有内容。 (我认为人们编写自己的可变参数函数的情况相当罕见,因此在很多程序中,对可变参数函数的唯一调用将是对库函数的调用。)
对于 AL
计算和执行间接跳转总共需要 5 条指令,还不包括lea rsi, -136[rsp]
and lea rdx, 39[rsp]
。 test/jz 策略也在 movaps 存储之后执行这些或类似操作,作为设置va_arg
代码必须弄清楚何时到达寄存器保存区域的末尾并切换到查看堆栈参数。
我也不计算sub rsp, 48
任何一个;不管怎样,这都是必要的,除非您也设置 XMM 保存区域大小变量,或者只保存每个 XMM 寄存器的下半部分,因此 8x 8 B = 64 字节适合红色区域。理论上,可变参数函数可以占用 16 个字节__m128d
XMM reg 中的 arg 以便 GCC 使用movaps
代替movlps
。 (我不确定 glibc printf 是否有任何需要转换的转换)。在非叶函数(例如实际的 printf)中,您始终需要保留更多空间而不是使用红色区域。 (这也是原因之一lea rdx, 39[rsp]
在计算跳转版本中:每个movaps
需要正好是 4 个字节,因此编译器生成该代码的方法必须确保它们的偏移量在 [-128,+127] 范围内[reg+disp8]
寻址模式,而不是0
除非 GCC 将使用特殊的 asm 语法来强制使用更长的指令。
几乎所有 x86-64 CPU 都将 16 字节存储作为单个微融合 uop 运行(只有老旧的 AMD K8 和 Bobcat 分成 8 字节的两半;请参阅https://agner.org/optimize/ https://agner.org/optimize/),而且我们通常会接触 128 字节区域以下的堆栈空间。 (此外,计算跳转策略本身存储到底部,因此它不会避免触及该缓存行。)
因此,对于具有 1 个 XMM 参数的函数,计算跳转版本总共需要 6 个单 uop 指令(5 个整数 ALU/跳转,1 个 movap)才能保存 XMM 参数。
test/jz 版本总共需要 9 个 uops(10 条指令,但自 Nehalem 以来在 Intel 上以 64 位模式进行 test/jz 宏熔断,自 Bulldozer IIRC 以来在 AMD 上进行测试/jz 宏熔断)。 1 个宏融合测试和分支,以及 8 个 movaps 存储。
这就是best计算跳转版本的情况:使用更多 xmm 参数,它仍然运行 5 条指令来计算跳转目标,但必须运行更多 movaps 指令。 test/jz 版本始终为 9 uops。因此,动态 uop 计数(实际执行的,相对于内存中占用 I-cache 占用空间)的收支平衡点是 4 个 XMM 参数,这可能很少见,但它还有其他优点。特别是在 AL == 0 的情况下,它是 5 vs. 1。
对于除零之外的任意数量的 XMM 参数,test/jz 分支始终会转到相同的位置,从而使其比间接分支更容易预测这对于printf("%f %f\n", ...)
vs "%f\n"
.
计算跳转版本中的 5 条指令中的 3 条(不包括 jmp)形成了来自传入 AL 的依赖链,使得在检测到错误预测之前需要更多的周期(即使该链可能以mov eax, 1
就在通话之前)。但是转储一切策略中的“额外”指令只是一些 XMM1..7 的死存储,它们永远不会被重新加载,并且不是任何依赖链的一部分。只要存储缓冲区和 ROB/RS 可以吸收它们,乱序执行程序就可以在闲暇时处理它们。
(公平地说,它们会将存储数据和存储地址执行单元捆绑一段时间,这意味着后面的存储也不会尽快准备好存储转发。并且在运行存储地址微指令的 CPU 上与加载相同的执行单元,后来的加载可能会被那些占用这些执行单元的存储微指令延迟。幸运的是,现代 CPU 至少有 2 个加载执行单元,从 Haswell 到 Skylake 的 Intel 可以在 3 个端口中的任意一个上运行存储地址微指令,具有像这样的简单寻址模式。Ice Lake 有 2 个加载/2 个存储端口,没有重叠。)
计算的跳转版本最后保存了 XMM0,这可能是第一个重新加载的参数。 (大多数可变参数函数都会按顺序遍历它们的参数)。如果有多个 XMM 参数,则计算跳转方式将无法准备好从该存储转发,直到几个周期后。但对于 AL=1 的情况,这是唯一的 XMM 存储,并且没有其他工作占用加载/存储地址执行单元,并且少量的参数可能更常见。
与较小的代码占用空间和在 AL==0 情况下执行的指令较少的优势相比,大多数这些原因实际上都是次要的。 (对我们中的一些人来说)思考现代简单方法的优点/缺点,以表明即使在最坏的情况下,这也不是问题,这很有趣。