# 3.5.5 特殊的算术操作

正如我们在 2.3 节中看到的,两个 64 位有符号或无符号整数相乘得到的乘积需要 128 位来表示。x86-64 指令集对 128 位(16 字节)数的操作提供有限的支持。延续字(2 字节)、双字(4 字节)和四字(8 字节)的命名惯例,Intel 把 16 字节的数称为八字(oct word)。图 3-12 描述的是支持产生两个 64 位数字的全 128 位乘积以及整数除法的指令。

指令 效果 描述
imulq S R[%rdx]:R[%rax] ← S × R[%rax] 有符号全乘法
mulq S R[%rdx]:R[%rax] ← S × R[%rax] 无符号全乘法
cqto R[%rdx]:R[%rax] ← 符号扩展(R[%rax]) 转换为八字
idivq S R[%rdx] ← R[%rdx]:R[%rax] mod S;R[%rax] ← R[%rdx]:R[%rax] ÷ S 有符号除法
divq S R[%rdx] ← R[%rdx]:R[%rax] mod S;R[%rax] ← R[%rdx]:R[%rax] ÷ S 无符号除法

图 3-12 特殊的算术操作。这些操作提供了有符号和无符号数的全 128 位乘法和除法。一对寄存器 %rdx%rax 组成一个 128 位的八字

imulq 指令有两种不同的形式。其中一种,如图 3-10 所示,是 IMUL 指令类中的一种。这种形式的 imulq 指令是一个“双操作数”乘法指令。它从两个 64 位操作数产生一个 64 位乘积,实现了 2.3.4 和 2.3.5 节中描述的操作 *u64 和 *t64。(回想一下,当将乘积截取到 64 位时,无符号乘和补码乘的位级行为是一样的。)

此外,x86-64 指令集还提供了两条不同的“单操作数”乘法指令,以计算两个 64 位值的全 128 位乘积——一个是无符号数乘法(mulq),而另一个是补码乘法(imulq)。这两条指令都要求一个参数必须在寄存器 %rax 中,而另一个作为指令的源操作数给出。然后乘积存放在寄存器 %rdx(高 64 位)和 %rax(低 64 位)中。虽然 imulq 这个名字可以用于两个不同的乘法操作,但是汇编器能够通过计算操作数的数目,分辨出想用哪条指令。

下面这段 C 代码是一个示例,说明了如何从两个无符号 64 位数字 xy 生成 128 位的乘积:

#include <inttypes.h>

typedef unsigned __int128 uint128_t;

void store_uprod(uint128_t *dest, uint64_t x, uint64_t y) {
    *dest = x * (uint128_t) y;
}

在这个程序中,我们显式地把 xy 声明为 64 位的数字,使用文件 inttypes.h 中声明的定义,这是对标准 C 扩展的一部分。不幸的是,这个标准没有提供 128 位的值。所以我们只好依赖 GCC 提供的 128 位整数支持,用名字 __int128 来声明。代码用 typedef 声明定义了一个数据类型 uint128_t,沿用的 inttypes.h 中其他数据类型的命名规律。这段代码指明得到的乘积应该存放在指针 dest 指向的 16 字节处。

GCC 生成的汇编代码如下:

void store_uprod(uint128_t *dest, uint64_t x, uint64_t y)
dest in %rdi, x in %rsi, y in %rdx

1  store_uprod:
2      movq %rsi, %rax        Copy x to multiplicand
3      mulq %rdx              Multiply by y
4      movq %rax, (%rdi)      Store lower 8 bytes at dest
5      movq %rdx, 8(%rdi)     Store upper 8 bytes at dest+8
6      ret

可以观察到,存储乘积需要两个 movq 指令:一个存储低 8 个字节(第 4 行),一个存储高 8 个字节(第 5 行)。由于生成这段代码针对的是小端法机器,所以高位字节存储在大地址,正如地址 8(%rdi) 表明的那样。

前面的算术运算表(图 3-10)没有列出除法或取模操作。这些操作是由单操作数除法指令来提供的,类似于单操作数乘法指令。有符号除法指令 idivq 将寄存器 %rdx(高 64 位)和 %rax(低 64 位)中的 128 位数作为被除数,而除数作为指令的操作数给出。指令将商存储在寄存器 %rax 中,将余数存储在寄存器 %rdx 中。

对于大多数 64 位除法应用来说,除数也常常是一个 64 位的值。这个值应该存放在 %rax 中,%rdx 的位应该设置为全 0(无符号运算)或者 %rax 的符号位(有符号运算)。后面这个操作可以用指令 cqto1 来完成。这条指令不需要操作数——它隐含读出 %rax 的符号位,并将它复制到 %rdx 的所有位。

我们用下面这个 C 函数来说明 x86-64 如何实现除法,它计算了两个 64 位有符号数的商和余数:

void remdiv(long x, long y, long *qp, long *rp) {
    long q = x / y;
    long r = x % y;
    *qp = q;
    *rp = r;
}

该函数编译得到如下汇编代码:

void remdiv(long x, long y, long *qp, long *rp)
x in %rdi, y in %rsi, qp in %rdx, rp in %rcx

1  remdiv:
2      movq %rdx, %r8         Copy qp
3      movq %rdi, %rax        Move x to lower 8 bytes of dividend
4      cqto                   Sign-extend to upper 8 bytes of dividend
5      idivq %rsi             Divide by y
6      movq %rax, (%r8)       Store quotient at qp
7      movq %rdx, (%rcx)      Store remainder at rp
8      ret

在上述代码中,必须首先把参数 qp 保存到另一个寄存器中(第 2 行),因为除法操作要使用参数寄存器 %rdx。接下来,第 3~4 行准备被除数,复制并符号扩展 x。除法之后,寄存器 %rax 中的商被保存在 qp(第 6 行),而寄存器 %rdx 中的余数被保存在 rp(第 7 行)。

无符号除法使用 divq 指令。通常,寄存器 %rdx 会事先设置为 0。

练习题 3.12 考虑如下函数,它计算两个无符号 64 位数的商和余数:

void uremdiv(unsigned long x, unsigned long y,
             unsigned long *qp, unsigned long *rp) {
    unsigned long q = x / y;
    unsigned long r = x % y;
    *qp = q;
    *rp = r;
}

修改有符号除法的汇编代码来实现这个函数。

1 在 Intel 的文档中,这条指令叫做 cqo,这是指令的 ATT 格式名字和 Intel 名字无关的少数情况之一。