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

2024-04-14

我正在关注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

for

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 手册中搜索了解释,但没有找到。

这有效的原因是什么?


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 个字节__m128dXMM 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 情况下执行的指令较少的优势相比,大多数这些原因实际上都是次要的。 (对我们中的一些人来说)思考现代简单方法的优点/缺点,以表明即使在最坏的情况下,这也不是问题,这很有趣。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

为什么 printf 仍然可以在 RAX 小于 XMM 寄存器中 FP 参数数量的情况下工作? 的相关文章

  • 使用 WGET 运行 cronjob PHP

    我尝试执行一个 cron 并每 5 分钟运行一个 url 我尝试使用 WGET 但我不想下载服务器上的文件 我只想运行它 这是我使用的 crontab 5 wget http www example com cronit php 除了 wg
  • 除了 iptables 之外还有数据包管理实用程序吗? [关闭]

    Closed 这个问题正在寻求书籍 工具 软件库等的推荐 不满足堆栈溢出指南 help closed questions 目前不接受答案 我正在寻找一个 Linux 实用程序 它可以根据一组规则更改网络数据包的有效负载 理想情况下 我会使用
  • 两个基本的 ANTLR 问题

    我正在尝试使用 ANTLR 来获取简单的语法并生成汇编输出 我在 ANTLR 中选择的语言是 Python 许多教程看起来非常复杂或详细阐述与我无关的事情 我真的只需要一些非常简单的功能 所以我有两个问题 将值从一个规则 返回 到另一规则
  • 汇编8086监听键盘中断

    我有与此完全相同的问题 边画边听键盘 https stackoverflow com questions 13970325 8086 listen to keyboard while drawing 但第一个答案 接受的答案 只听键盘一次
  • 为什么 GCC 不将 a*a*a*a*a*a 优化为 (a*a*a)*(a*a*a)?

    我正在对科学应用程序进行一些数值优化 我注意到的一件事是 GCC 会优化调用pow a 2 通过将其编译成a a 但是调用pow a 6 没有优化 实际会调用库函数pow 这大大降低了性能 相比之下 英特尔 C 编译器 http en wi
  • 将用户添加到组但运行“id”时未反映

    R 创建了一个名为 Staff 的组 我希望能够在不以 sudo 身份启动 R 的情况下更新软件包 所以我使用以下方法将自己添加到员工中 sudo usermod G adm dialout cdrom plugdev lpadmin ad
  • 类似 wget 的 BitTorrent 客户端或库? [关闭]

    这个问题不太可能对任何未来的访客有帮助 它只与一个较小的地理区域 一个特定的时间点或一个非常狭窄的情况相关 通常不适用于全世界的互联网受众 为了帮助使这个问题更广泛地适用 访问帮助中心 help reopen questions 是否有任何
  • 如何随时暂停 pthread?

    最近我开始将 ucos ii 移植到 Ubuntu PC 上 我们知道 在pthread的回调函数中的 while 循环中简单地添加一个标志来执行暂停和恢复是不可能模拟ucos ii中的 进程 的 如下解决方案 因为ucos ii中的 进程
  • 如何从 C 文件更改终端中的目录

    如何从 C 程序更改将在终端上生效的目录 实际上不要告诉 system 函数或 chdir 函数 这些仅适用于 C 中的进程或子 shell 假设我正在从 bash shell 执行一个 C 程序 其进程 ID 为 10223 那么 我可以
  • 使用 Easy 68K (68000) 组装范围内的随机数

    我正在使用 Easy 68K 模拟器创建一个简单的黑杰克游戏 需要使用随机数来分配牌 我的牌必须在 2 到 11 的范围内 我似乎每次都得到相同的数字 但它不在我预期的范围内 我的卡值需要以 D3 结束 因此我有以下随机数代码 CLR L
  • Nasm 打印到下一行

    我用 nasm Assembly 编写了以下程序 section text global start start Input variables mov edx inLen mov ecx inMsg mov ebx 1 mov eax 4
  • 从 Linux 命令行发送 SNMP 陷阱消息

    Folks 我需要从 Linux 命令行使用此命令 snmptrap 将自定义消息发送到陷阱侦听器 我需要根据用户设置在 v1 和 v2c 中发送相同的消息 这是我发现的 For v1 snmptrap v 1 c Tas hostname
  • 有关 Linux 内存类型的问题

    关于Linux内存我有以下问题 我知道活动内存是最常访问的内存部分 但是有人可以解释一下 linux 如何考虑将内存位置用于活动内存或非活动内存 主动存储器由哪些部分组成 磁盘 文件缓存是否被视为活动内存的一部分 有什么区别Buffers
  • 如何使用libaudit?

    我试图了解如何使用 libaudit 我想接收有关使用 C C 的用户操作的事件 我不明白如何设置规则 以及如何获取有关用户操作的信息 例如 我想获取用户创建目录时的信息 int audit fd audit open struct aud
  • 使用 MongoDB docker 镜像停止虚拟机而不丢失数据

    我已经在 AWS EC2 上的虚拟机中安装了官方的 MongoDB docker 映像 并且数据库上已经有数据 如果我停止虚拟机 以节省过夜费用 我会丢失数据库中包含的所有数据吗 在这些情况下我怎样才能让它持久 有多种选择可以实现此目的 但
  • 如何使用 bash 脚本关闭所有终端,在每个终端中有效地按 Ctrl+Shift+Q

    我经常打开许多终端 其中一些正在运行重要的进程 例如服务器 而另一些则没有运行任何东西并且可以关闭 如果您按 重要 则会弹出确认提示Cntrl Shift Q在其中 如下所示 我想要一个 bash 脚本 它可以关闭所有终端 但将 重要 终端
  • 使用netcat将unix套接字传输到tcp套接字

    我正在尝试使用以下命令将 unix 套接字公开为 tcp 套接字 nc lkv 44444 nc Uv var run docker sock 当我尝试访问时localhost 44444 containers json从浏览器中 它不会加
  • 将 stdout 作为命令行 util 的文件名传递?

    我正在使用一个命令行实用程序 该实用程序需要传递文件名以将输出写入 例如 foo o output txt 它唯一写入的东西stdout是一条消息 表明它运行成功 我希望能够通过管道传输写入的所有内容output txt到另一个命令行实用程
  • 静态链接共享对象?或者损坏的文件?

    我有一个从专有来源获得的库 我正在尝试链接它 但出现以下错误 libxxx so 文件无法识别 文件格式无法识别 Collect2 ld 返回 1 退出状态 确实 ldd libxxx so statically linked 这究竟意味着
  • 如何知道寄存器是否是“通用寄存器”?

    我试图了解寄存器必须具备什么标准才能被称为 通用寄存器 我相信通用寄存器是一个可以用于任何用途的寄存器 用于计算 将数据移入 移出等 并且是一个没有特殊用途的寄存器 现在我读到了ESP寄存器是通用寄存器 我猜是ESP寄存器可以用于任何事情

随机推荐