Solidity 有符号整数允许在智能合约中使用负数。本文记录了它们在 EVM 层面是如何被使用的。假设读者对 EVM 和二进制数有基本的了解。
二进制补码解析
Solidity 和 EVM 使用补码来表示有符号整数
就像其他数据类型一样,Solidity 仍然使用 32 字节的字(words)来表示有符号整数。在 EVM 中没有任何类型的语义指示符,就像没有指示符表明一个 32 字节的插槽实际上是一个布尔值、一个地址还是一个 160 位的数字一样。该值在编译时被“当作”负数处理。
因为你可以使用 “type(int256).max” 获取整数的最大值,或者使用 .min 字段获取最小值。指示一个数字是正数还是负数需要一个额外的位,因此它能存储的数字比无符号版本少一位。
反码(One’s complement)意味着 uint256 变成了 uint255,最左边的位用来指示它是正数还是负数。如果 EVM 使用反码,这意味着 type(int256).max == absoluteValue(type(int256.min)),但事实并非如此。补码负数的最大绝对值比正数的最大绝对值大 1。例如,int8 的最大正数是 127,但 int8 的最大负数绝对值对应的是 -128。
补码算术的模式与示例。
我们不打算深入探讨一堆数学证明,而是使用一些实际例子(这并不旨在作为补码算术的举例证明,对于感兴趣的读者,有大量关于补码的文献可供参考)。
我们使用 int8 来让示例更具可读性。以下内容是二进制格式,而不是十六进制。
int8(0) == 0000 0000
type(int8).max == 0111 1111
type(int8).min == 1000 000
观察 +1 和 -1 的表示是很有启发性的:
int8(1) == 0000 0001
int8(-1) == 1111 1111
让我们在补码中向下计数,以便清楚地看出规律:
int8(-2) == 1111 1110
int8(-3) == 1111 1101
int8(-4) == 1111 1100
int8(-5) == 1111 1011
你可以粗略地把补码负数想象为“倒数”。
这是补码的一个有趣特性。-2 + -2 应该等于 -4,在补码中进行加法并允许溢出即可实现这一点。以下是在 Python 中使用补码表示将 -2 与其自身相加的过程:
>>> (int(b'11111110', 2) + int(b'11111110', 2) ) % 256
252
>>> bin(252)
'0b11111100'
这与上述预期的规律相符。
如果我们把 +4 加上 -2 会怎样?我们应该得到 +2。让我们看看实际效果:
>>> # -2 + 4
>>> (int(b'11111110', 2) + int(b'00000100', 2)) % 256
>>> 2
只有当两个数字都采用补码表示时,这才有效。Solidity 不允许将无符号整数和有符号整数相加,因为这样做的意图是模棱两可的。
补码同样适用于乘法。-2 和 -2 相乘的预期结果是 +4,鼓励读者复制前面的代码来验证这一点。
这并不适用于所有的算术运算。
补码不需要对加法、减法、乘法,甚至是左移(<<)进行任何修改。它们对应于 EVM 操作码 ADD、SUB、MUL 和 SHL。我们将在本教程后面的部分讨论为什么左移在补码中仍然有效。
然而,乘法、取模、右移以及转换为更大的有符号整数不能使用有符号方法完成,并且需要它们专用的操作码。同样,传统的比较运算符也不起作用,因为负数“看起来”比正数更大。
用于有符号算术的 Ethereum 操作码
sdiv
Gas 消耗:5
SDIV,即有符号除法,用于对有符号数进行除法运算。这个操作码在如下代码的幕后被使用。
function divide(int256 a, int256 b) public pure returns (int256 quotient)
{
quotient = a / b;
}
smod
Gas 消耗:5
既然补码算术需要专门的除法操作码,那么对于取模(取余数)来说同样需要专用操作码也就不足为奇了。
function divide(int256 a, int256 b) public pure returns (int256 remainder)
{
remainder = a % b;
}
slt and sgt
Gas 消耗:3
为了比较有符号数的大小,我们首先需要判断它是正数还是负数,然后再比较其数值大小。这些操作码一步完成了该操作。
就像对应的无符号运算一样,在可能的情况下尽量避免使用 >= 和 <=,而使用严格的不等式运算符,这样会更节省 Gas。
sar - signed arithmetic shift right
Gas 消耗:3
SAR 是一个非常少见的操作码,但它会出现在这段 Solidity 代码的编译结果中。请注意,x 是一个整数,而 y 是一个无符号整数。
contract SarExample {
function main(int256 x, uint256 y) public pure returns (int256 res) {
res = x >> y;
}
}
我们该如何理解这一点?对于普通的无符号数字来说,将位向右移一位相当于除以 2,右移两位相当于除以 4,以此类推。
uint256 x = 8 >> 2; // x = 2
uint256 y = 4 >> 1; // y = 2
如果你对有符号整数执行此操作,这种现象同样适用。
int256 x = -8 >> 2; // x = -2
int256 y = -4 >> 1; // y = -2
为什么没有 SAL(有符号算术左移)操作码呢?在下面的示例中会发生什么?
int256 x = -8 << 2; // x = -32
int256 y = -4 << 1; // y = -8
我们分别乘以了 4 和 2。在补码中,左移操作如预期般保留了数值规律。
在底层,实际上使用的是常规的 SHL(左移)操作码。没有必要为算术左移设置特殊情况。这可能看起来不直观,因为当最右侧的位被置零时,数字变得更大了。但请记住,补码中的最大负数是指最左侧位为 1,而所有其他位均为 0 的情况。
signextend evm
Gas 消耗:5
小于 256 位的有符号整数会带有前导零。然而,补码负数始终以最左边的位为 1 开始。因此,如果一个补码整数被向上转换为更大的类型,该值将从负数变为正数,因为最左侧的位将是零。signextend 无缝地处理了这种转换。
signextend solidity
你不能在 Solidity 中直接使用 signextend,但当一个较小的整数被转换为较大的整数时,它会在幕后被使用。以下代码在其编译的字节码中包含 signextend 操作码,用于将 int8 转换为 int256。
contract SignExtendExample {
function main(int8 x) public pure returns (int256 res) {
res = x;
}
}
到此时应该很明显,较大的整数不能被强制转换为较小的整数。
了解更多
在我们的专业 Solidity 培训课程 中学习更多高级主题。
最初发布于 2023 年 4 月 11 日