pes*_*san 8 linux assembly x86-64 nasm calling-convention
我正在关注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
Run Code Online (Sandbox Code Playgroud)
这按预期工作。
然后,在最后一次printf通话之前,我尝试更改
mov rax, 3
Run Code Online (Sandbox Code Playgroud)
为了
mov rax, 1
Run Code Online (Sandbox Code Playgroud)
然后我重新组装并运行该程序。
我期待一些不同的无意义输出,但令我惊讶的是输出完全相同。printf正确输出 3 个浮点值:
Run Code Online (Sandbox Code Playgroud)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 寄存器的确切数量的实现,但当前的实现仅检查零/非零,因为这样很有效,特别是对于 AL=0 常见情况。
\n如果您在 AL 1中传递的数字低于 XMM 寄存器参数的实际数量,或者高于 8,则您将违反 ABI,并且只有这个实现细节才能阻止您的代码被破坏。(即它“碰巧工作”,但没有任何标准或文档保证,并且不能移植到其他一些实际实现,例如使用 GCC4.5 或更早版本构建的旧版 GNU/Linux 发行版。)
\n此问答显示了 glibc printf 的当前版本,它仅检查AL!=0,而旧版本的 glibc 则将跳转目标计算到一系列movaps存储中。(那个问答是关于当 时代码被破坏AL>8,使计算的跳转到不应该的地方。)
为什么eax包含向量参数的数量?引用 ABI 文档,并显示 ICC 代码生成,它使用与旧 GCC 相同的指令类似地执行计算跳转。
\nGlibc 的printf实现是从 C 源代码编译的,通常由 GCC 编译。 当现代 GCC 编译像 printf 这样的可变参数函数时,它使 asm 只检查零与非零 AL,如果非零,则将所有 8 个传递参数的 XMM 寄存器转储到堆栈上的数组。
GCC4.5 及更早版本实际上确实使用 AL 中的数字来计算跳转到一系列movaps存储中,以便仅实际保存必要的 XMM 寄存器。
Nate 在使用 GCC4.5 与 GCC11 的Godbolt上的评论中给出的简单示例显示了与旧/新 glibc(由 GCC 构建)反汇编的链接答案相同的差异,这并不奇怪。该函数仅使用va_arg(v, double);,从不使用整数类型,因此它不会将传入的 RDI...R9 转储到任何地方,这与printf. 它是一个叶子函数,因此它可以使用红色区域(低于 RSP 128 字节)。
# GCC4.5.3 -O3 -fPIC to compile like glibc would\nadd_them:\n movzx eax, al\n sub rsp, 48 # reserve stack space, needed either way\n lea rdx, 0[0+rax*4] # each movaps is 4 bytes long\n lea rax, .L2[rip] # code pointer to after the last movaps\n lea rsi, -136[rsp] # used later by va_arg. test/jz version does the same, but after the movaps stores\n sub rax, rdx\n lea rdx, 39[rsp] # used later by va_arg, test/jz version also does an LEA like this\n jmp rax # AL=0 case jumps to L2\n movaps XMMWORD PTR -15[rdx], xmm7 # using RDX as a base makes each movaps 4 bytes long, vs. 5 with RSP\n movaps XMMWORD PTR -31[rdx], xmm6\n movaps XMMWORD PTR -47[rdx], xmm5\n movaps XMMWORD PTR -63[rdx], xmm4\n movaps XMMWORD PTR -79[rdx], xmm3\n movaps XMMWORD PTR -95[rdx], xmm2\n movaps XMMWORD PTR -111[rdx], xmm1\n movaps XMMWORD PTR -127[rdx], xmm0 # xmm0 last, will be ready for store-forwading last\n.L2:\n lea rax, 56[rsp] # first stack arg (if any), I think\n ## rest of the function\nRun Code Online (Sandbox Code Playgroud)\n与
\n# GCC11.2 -O3 -fPIC\nadd_them:\n sub rsp, 48\n test al, al\n je .L15 # only one test&branch macro-fused uop\n movaps XMMWORD PTR -88[rsp], xmm0 # xmm0 first\n movaps XMMWORD PTR -72[rsp], xmm1\n movaps XMMWORD PTR -56[rsp], xmm2\n movaps XMMWORD PTR -40[rsp], xmm3\n movaps XMMWORD PTR -24[rsp], xmm4\n movaps XMMWORD PTR -8[rsp], xmm5\n movaps XMMWORD PTR 8[rsp], xmm6\n movaps XMMWORD PTR 24[rsp], xmm7\n.L15:\n lea rax, 56[rsp] # first stack arg (if any), I think\n lea rsi, -136[rsp] # used by va_arg. done after the movaps stores instead of before.\n...\n 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.\n\nRun Code Online (Sandbox Code Playgroud)\nGCC 可能改变了策略,因为计算的跳转需要更多的静态代码大小(I 缓存占用空间),并且 test/jz 比间接跳转更容易预测。更重要的是,在常见的 AL=0 (no-XMM) 情况2中执行的微指令更少。即使对于 AL=1 最坏的情况(7 人死亡),也没有更多movaps即使对于 AL=1 最坏的情况(7 个死存储,但没有完成计算分支目标的工作),
相关问答:
\n_start(取决于调用 libc 启动函数的动态链接器挂钩)。当我们谈论违反调用约定时,半相关的:
\nprintf使用 AL=0,使用movaps除将 XMM 参数转储到堆栈之外的其他位置)x86-64 System V ABI 文档指定可变参数函数必须仅查看 AL 中的寄存器数量;RAX 的高 7 字节允许存放垃圾。 mov eax, 3是设置 AL 的有效方法,避免了写入部分寄存器时可能出现的错误依赖性,尽管它的机器代码大小(5 字节)比mov al,3(2 字节)大。clang 通常使用mov al, 3.
ABI 文档中的要点,请参阅为什么 eax 包含向量参数的数量?了解更多背景信息:
\n\n\n序言应该用来
\n%al避免不必要地保存 XMM 寄存器。这对于纯整数程序尤其重要,可以防止 XMM 单元的初始化。
(最后一点已经过时了:XMM regs 广泛用于 memcpy/memset 并内联到零初始小数组/结构。如此之多以至于 Linux 在上下文切换上使用“热切”FPU 保存/恢复,而不是“惰性”,其中第一次使用 XMM 寄存器错误。)
\n\n\n的内容
\n%al不需要与寄存器的数量完全匹配,但必须是所使用的向量寄存器数量的上限,并且在 0\xe2\x80\x938 范围内(包括 0\xe2\x80\x938)。
AL <= 8 的 ABI 保证允许计算跳转实现省略边界检查。(同样,C++ 标准是否允许未初始化的 bool 导致程序崩溃?是的,可以假设 ABI 违规不会发生,例如,通过编写在这种情况下会崩溃的代码。)
\n较小的静态代码大小(I 缓存占用空间)始终是一件好事,并且 AL!=0 策略对此有利。
\n最重要的是,在 AL==0 情况下执行的总指令数更少。 printf不是唯一的可变参数函数;sscanf这种情况并不罕见,而且它从不接受 FP args(仅指针)。如果编译器可以看到函数从不使用va_argFP 参数,则它会完全忽略保存,从而使这一点毫无意义,但 scanf/printf 函数通常被实现为 /vfscanf调用的包装器vfprintf,因此编译器看不到这一点,它看到 ava_list被传递给另一个函数,因此它必须保存所有内容。(我认为人们编写自己的可变参数函数的情况相当罕见,因此在很多程序中,对可变参数函数的唯一调用将是对库函数的调用。)
对于 AL<8 但非零的情况,无序执行程序可以很好地处理死存储,这要归功于宽管道和存储缓冲区,在发生这些存储的同时开始真正的工作。
\n计算和执行间接跳转总共需要 5 条指令,不包括lea rsi, -136[rsp]和lea rdx, 39[rsp]。test/jz 策略也在 movaps 存储之后执行这些或类似操作,作为va_arg代码的设置,该代码必须弄清楚何时到达寄存器保存区域的末尾并切换到查看堆栈参数。
我也不计算其中sub rsp, 48任何一个;不管怎样,这都是必要的,除非您也设置 XMM 保存区域大小变量,或者只保存每个 XMM 寄存器的下半部分,因此 8x 8 B = 64 字节适合红色区域。理论上,可变参数函数可以__m128d在 XMM reg 中采用 16 字节参数,因此 GCC 使用movaps而不是movlps. (我不确定 glibc printf 是否有任何需要转换的转换)。在非叶函数(例如实际的 printf)中,您始终需要保留更多空间而不是使用红色区域。lea rdx, 39[rsp](这是计算跳转版本中的原因之一:每个movaps都需要恰好是 4 个字节,因此编译器生成该代码的方法必须确保它们的偏移量在 [-128,+127] 范围内寻址[reg+disp8]模式,除非0GCC 将使用特殊的 asm 语法来强制使用更长的指令。
几乎所有 x86-64 CPU 都将 16 字节存储作为单个微融合 uop 运行(只有老旧的 AMD K8 和 Bobcat 分成 8 字节两半;请参阅https://agner.org/optimize/),并且我们\'无论如何,d 通常都会接触低于该 128 字节区域的堆栈空间。(此外,计算跳转策略本身存储到底部,因此它不会避免触及该缓存行。)
\n因此,对于具有 1 个 XMM 参数的函数,计算跳转版本总共需要 6 个单 uop 指令(5 个整数 ALU/跳转,1 个 movap)才能保存 XMM 参数。
\ntest/jz 版本总共需要 9 个 uops(10 条指令,但自 Nehalem 以来在 Intel 上以 64 位模式进行 test/jz 宏熔断,自 Bulldozer IIRC 以来在 AMD 上进行测试/jz 宏熔断)。1 个宏融合测试和分支,以及 8 个 movaps 存储。
\n这是计算跳转版本的最佳情况:使用更多 xmm 参数,它仍然运行 5 条指令来计算跳转目标,但必须运行更多 movaps 指令。test/jz 版本始终为 9 uops。因此,动态 uop 计数(实际执行的,相对于内存中占用 I-cache 占用空间)的收支平衡点是 4 个 XMM 参数,这可能很少见,但它还有其他优点。特别是在 AL == 0 的情况下,它是 5 比 1。
\n对于除零之外的任意数量的 XMM 参数, test/jz 分支始终会转到相同的位置,这使得它比与vs不同的间接分支更容易预测。printf("%f %f\\n", ...)"%f\\n"
计算跳转版本中的 5 条指令中的 3 条(不包括 jmp)形成了来自传入 AL 的依赖链,使得在检测到错误预测之前需要更多的周期(即使该链可能以之前的一条指令开始mov eax, 1)的电话)。但是转储一切策略中的“额外”指令只是一些 XMM1..7 的死存储,它们永远不会重新加载,并且不是任何依赖链的一部分。只要存储缓冲区和 ROB/RS 可以吸收它们,乱序执行程序就可以在闲暇时处理它们。
(公平地说,它们会将存储数据和存储地址执行单元占用一段时间,这意味着后面的存储也不会尽快准备好存储转发。并且在运行存储地址微指令的 CPU 上在与加载相同的执行单元上,以后的加载可能会被那些占用这些执行单元的存储微指令延迟。幸运的是,现代 CPU 至少有 2 个加载执行单元,从 Haswell 到 Skylake 的 Intel 可以在 3 个中的任何一个上运行存储地址微指令端口,具有像这样的简单寻址模式。Ice Lake 有 2 个加载/2 个存储端口,没有重叠。)
\n计算的跳转版本最后保存了 XMM0,这可能是第一个重新加载的参数。(大多数可变参数函数都会按顺序遍历它们的参数)。如果有多个 XMM 参数,则计算跳转方式将无法准备好从该存储进行存储转发,直到几个周期后。但对于 AL=1 的情况,这是唯一的 XMM 存储,并且没有其他工作占用加载/存储地址执行单元,并且少量的参数可能更常见。
\n与较小的代码占用空间和在 AL==0 情况下执行的指令较少的优势相比,大多数这些原因实际上都是次要的。(对于我们中的一些人来说)思考现代简单方法的优点/缺点,以表明即使在最坏的情况下,这也不是问题。
\n| 归档时间: |
|
| 查看次数: |
275 次 |
| 最近记录: |