【问题标题】:Dividing with a negative number gives me an overflow in NASM除以负数让我在 NASM 中溢出
【发布时间】:2018-08-07 00:38:23
【问题描述】:

我正在自学一些使用 x86-64 Mac OS 的汇编编程。我试图弄清楚为什么将正整数与负整数相除会导致溢出。例如,5/-2 必须返回 -2。但是,在我的情况下,当我执行 -554/2 而不是 -277 时,它会返回 2147483371... 这就是我的程序集文件中的内容:

; compiling using: nasm -f macho64 -o divide.o divide.s
[bits 64]
global _divide
section .text

; int divide(int dividend, int divisor)
_divide:

    xor rdx, rdx        ; making this to 0
    push rbp            ; base stack pointer
    mov rax, rdi        ; dividend
    mov rcx, rsi        ; divisor
    idiv rcx            ; integer division

    add rsp, 8
    ret

在我的main.c 文件中,我有这个:

#include <stdio.h>
extern int divide(int dividend, int divisor);
int main(void)
{
    printf("divide: %d\n\n", divide(-554,2));
    return (0);
}

输出:divide: 2147483371

谁能向我解释我到底做错了什么?

【问题讨论】:

标签: assembly nasm x86-64


【解决方案1】:

32 位值-554<sub>signed</sub> 相当于4,294,966,742<sub>unsigned</sub>,其中一半是确实 2,147,483,371,你得到的答案。所以它看起来像一个签名/未签名的问题。而且,在检查 the x86 docs for idiv 后,我们看到:

IDIV r/m64 Signed divide RDX:RAX by r/m64, result stored in:
    RAX <- Quotient,
    RDX <- Remainder.

注意第一行,特别是“有符号除以 rdx:rax”位。当英特尔谈到rdx:rax 时,它们的意思是由这两个 64 位寄存器形成的 128 位值。假设这两个 64 位寄存器包含(十六进制)值:

rax : 01234567 89ABCDEF
rdx : 11112222 FFFFEEEE

那么rdx:rax 值将是 128 位值:

rdx:rax : 11112222 FFFFEEEE 01234567 89ABCDEF

现在,因为您将rdx 归零,所以组合值被视为正,因为最高位为零。您实际上需要做的是sign-extend rax 到rdx:rax,这是一种在扩展值中保留符号的方法。例如,考虑 32 位 -1,将符号正确和不正确地扩展为 64 位值:

         ffffffff     32-bit:                        -1.
ffffffff ffffffff     64-bit proper:                 -1.
00000000 ffffffff     64-bit improper:    4,294,967,295.

为了正确地进行符号扩展,如果最右边的位(在您的情况下为rdx)形成一个负数,则最左边的位(在您的情况下为rdx)应该都是一位否则为零位。

当然,那些聪明的英特尔工程师已经想到了这个用例,所以您可以使用 cqo convert-quadword-to-octoword 指令来实现,该指令可以正确扩展符号。考虑到这一点,您设置eax 的代码将变为:

    mov   rax, rdi          ; Get dividend and
    cqo                     ;   sign extend to rdx:rax.

但是,您可能还有一个额外问题。即使 System V x86-64 ABI 指定参数在 64 位寄存器 (rXX) 中传递,但传递 32 位值实际上可能会使高位包含垃圾(我认为你是允许在返回值的上部留下垃圾。详情请参阅this excellent answer。

所以你应该不假设你在整个 64 位寄存器中都有一个合理的值,只有最右边的 32 位。

在您的情况下(假设为 32 位整数),您应该将符号扩展为 32 到 64 而不是 64 到 128,并使用更小宽度的除法指令。这将导致更像:

global _divide
section .text

; int32_t divide(int32_t ediDividend, int32_t esiDivisor)
_divide:
    mov   eax, edi          ; Get 32-bit dividend and
    cdq                     ;   sign extend to 64-bit edx:eax.

    idiv  esi               ; Weave magic here,
                            ;   zeros leftmost rax.

    ret                     ; Return quotient in rax/eax.

这是未经测试的,但应该做你想做的事。我实际上已经删除了rbp 的推送,因为我很确定这没有必要。它似乎没有被破坏(这个函数既没有改变它,也没有调用任何其他可以改变它的函数),而且看起来你从来没有真正在你的原始代码中正确地恢复它。

【讨论】:

  • 好吧,这个问题有点跑题了,但是我看过RDX:RAX,感觉还是不明白背后的意思。你能帮我理解背后的含义吗?尝试自学汇编比我想象的要难,比和同行讨论代码哈哈。
  • @ZeidTisnes,已在答案中添加了更多细节,希望能说明这一点。
  • @pax:你不需要[bits 64]。事实上,您可能不应该使用它,因为它可以将 64 位机器代码意外地组装成 32 位目标文件,而不是在push rdx 上出现错误。 (顺便说一句,您不需要它。x86-64 System V 调用约定(如 Windows)将 RDX 作为调用破坏寄存器,正是因为您必须将它用作一些常用指令的暂存寄存器。)
  • 好的,我认为我已经修复了 cmets 中提出的所有问题,(a) 摆脱了幼稚的方法 (b) 处理高位中的垃圾 (c) 删除的 bits64 (d) non-preservation od rdx (e) 直接使用 esi。让我知道是否还有更多。干杯。
  • @paxdiablo:很好的清理工作。不过,and rax, 0xffffffff 完全是多余的。当 32 位 idiv 写入 eax 和 edx 时,即 implicitly zeros the upper 32 bits of RAX and RDX。将 EAX 显式零扩展为 RAX 的最有效方法是 mov eax,eax。 (您经常会在函数的开头看到 mov ecx,edi,其中 uint32_t 用作数组索引。)此外,and rax, 0xffffffff 不可编码:它不适合符号扩展的 imm32。跨度>
【解决方案2】:

您的代码也因负除数而损坏:divide(5,-2) 将给出零。这纯粹是通过调用约定来解释的。您的零扩展而不是符号扩展错误(请参阅@paxdiablo 的答案)仅对负股息很重要。


您告诉编译器您的函数采用 int 参数,而 int 在 x86-64 System V 调用约定中是 32 位类型。

您假设您的输入被符号扩展为 64 位,但调用约定不需要这样做,因此编译器不会在 10 字节 @ 上浪费代码大小987654335@,当它可以使用5字节mov r32, imm32时。

有关更多详细信息,请参阅这些问答。 (第二个基本上是第一个的副本):


因此,您的编译器将为您的main 发出类似这样的代码:

mov    edi, 5      ; RDI = 0x0000000000000002
mov    esi, -2     ; RSI = 0x00000000FFFFFFFE
call   _divide

我检查了on the Godbolt compiler explorer,这就是 gcc 和 clang 真正做的事情1,即使对于未优化的代码也是如此。


对于divide(5,-2),您的代码将导致

  • RDX=0,RAX=5。即股息= 0x0000000000000000:0000000000000005,这是正确的。 (零和符号扩展对于非负输入是相同的操作)。
  • 除数 = 0x00000000FFFFFFFE = +4294967294,大而正。

64 位 idiv 计算 5 / 4294967294 产生商=RAX=0,余数=RDX=5。

如果您只修复了类型宽度/操作数大小不匹配的错误,您仍然会遇到像 @paxdiablo 的回答所解释的负股息问题。 但要让divide(-554,2) 真正工作,这两个修复都是必要的。


那你应该怎么写呢?

您可以将原型更改为int64_t 或long(在x86-64 System V 中为64 位),并使用cqo 设置签名除法。 (When and why do we sign extend and use cdq with mul/div?)

或者您可以使用 movsxd rax, edi / movsxd rcx, esi 将您的 32 位输入符号扩展为 64 位。但那将是愚蠢的。只需使用 32 位操作数大小,因为这是您告诉编译器通过的。

这很好,因为 64 位除法比 32 位除法慢得多。 (https://agner.org/optimize/ 和 C++ code for testing the Collatz conjecture faster than hand-written assembly - why?)。

这就是我要做的:

global _divide
; inputs: int32_t dividend in EDI, int32_t divisor in ESI
; output: int32_t quotient in EAX,  int32_t remainder in EDX
;  (C callers won't be able to access the remainder, unfortunately)
_divide:
    mov     eax, edi
    cdq                    ; sign-extend the dividend into edx:eax

    idiv    esi            ; no need to copy to ecx/rcx first
    ret

无需推送 RBP;我们没有调用任何其他函数,因此重新对齐堆栈无关紧要,我们也没有修改 RBP 以用作帧指针。

我们可以在不保存/恢复的情况下破坏 RDX:它是 x86-64 System V 和 Windows x64 中的调用破坏寄存器。 (与大多数 32 位调用约定相同)。这是有道理的,因为它被 idiv 等一些常见指令隐式使用。

如果你用 C 语言编写,这就是 gcc 和 clang 发出的(当然启用了优化)。

int divide(int dividend, int divisor) {
    return dividend / divisor;
}

(请参阅上面的 Godbolt 链接,我将它包含在 __attribute__((noinline)) 中,因此我仍然可以看到 main 实际设置函数 args。我本可以将其命名为其他名称。)

像往常一样,查看编译器输出以了解您的代码与编译器所做的事情之间的差异可以提示您做错了什么。 (或者为您提供更好的优化起点。不过,在这种情况下,编译器不会错过任何优化。)请参阅How to remove "noise" from GCC/clang assembly output?。

如果您想查看 64 位整数的代码生成,可以将类型更改为 long(在 x86-64 System V 中为 64 位,与 Windows x64 不同)。并查看调用者如何变化,例如

    mov     edi, 5
    mov     rsi, -2
    call    _divide

脚注1:有趣的是clang -O3的asm输出有mov esi, -2,但clang -O0写成mov edi, 4294967294。

它们都组装到相同的指令,当然,zeroing the upper 32 bits of RDI,因为这就是 AMD 设计 AMD64 的方式,而不是例如隐式符号扩展到完整寄存器中,would have been a valid design choice 但可能不如零便宜- 扩展。

顺便说一句,Godbolt 有针对 Linux 的编译器,但这是相同的调用约定。唯一的区别是 OS X 使用前导 _ 修饰函数名称,而 Linux 没有。

【讨论】:

  • Godbolt 编译器实际上很好,我只是看着它,看看它是如何工作的。挖掘它并渴望了解更多信息看起来真的很有趣。感谢您的帮助和@pax。以后我会回来解答更多装配问题
  • @ZeidTisnes:一定要去观看 Matt Godbolt 的 CppCon2017 演讲(How to remove "noise" from GCC/clang assembly output? 中的链接)。对于了解 C 以查看编译器输出和 x86 asm 的 asm 初学者来说,这是一个很好的介绍。另请参阅stackoverflow.com/tags/x86/info 中的其他链接。但是,是的,Matt 的编译器浏览器是一个很棒的工具。我在编写 SO 答案时一直使用它,用于检查内容是否可以使用不同的编译器有效编译。
猜你喜欢
  • 1970-01-01
  • 2011-08-23
  • 1970-01-01
  • 2016-01-07
  • 2019-09-01
  • 2012-02-22
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多