在编程中,我们有时会遇到一些看似矛盾的现象。例如,在 C/C++ 语言中,-3>>1 的结果是 -2,而 3>>1 的结果是 1。但如果直接进行除法运算 -3/2,结果却是 -1。为何针对同一数值的“除以2”操作,右移和除法会得到不同的结果?这背后涉及计算机底层对整型数据的存储与运算规则。
补码:负数在计算机中的形态
要理解这个现象,首先需要知道负数在计算机中如何存储。我们通常使用的是补码表示法。简单来说,一个负数的补码,等于其绝对值的二进制表示“按位取反后再加一”。
以 -3 为例,我们可以用一小段代码来查看其在内存中的十六进制形态:
int main()
{
int n = -3;
printf("0x%x",n);
}
这段代码的输出结果是:
0xfffffffd
这个 0xfffffffd 就是 -3 在 32 位有符号整型中的补码形式。为什么采用这种形式?这源于计算机运算的溢出特性。例如,1 的十六进制是 0x1,为了让 1 和 -1 相加等于 0,且考虑到 32 位寄存器中超出范围的进位会被丢弃,-1 就可以表示为 0xffffffff(因为 0xffffffff + 0x1 = 0x100000000,高位的 1 被丢弃,结果为 0)。
基于这个规则,32 位有符号整数的取值范围被分为两部分:正数从 0 到 0x7fffffff,负数从 0x80000000 到 0xffffffff。其中最高位(bit 31)被用作符号位:为 0 表示正数,为 1 表示负数。这也是补码设计带来的一个直观规律。
整型数的移位运算:为何 -3>>1 等于 -2?
右移运算符(>>)的行为在有符号数和无符号数上有所不同。为了确凿地了解其过程,最直接的方法是观察编译器生成的汇编代码。这个过程通常分为三步:
- 编写测试用的 C 代码。
- 使用编译器(如 ARM 交叉编译工具链)编译该代码。
- 反汇编生成的可执行文件,查看对应的汇编指令。
我们编写以下测试函数,分别对有符号数和无符号数进行右移操作:
#include<stdio.h>
int shift(int a, int b)
{
return (a >> b);
}
unsigned int shift_u(unsigned int a, unsigned int b)
{
return (a >> b);
}
main(){
int a = shift(-3, 1);
unsigned int b = shift_u(3, 1);
printf("[%d][%u]",a,b);
}
编译并反汇编后,可以看到核心的汇编指令差异:
- 对于有符号数右移,编译器使用了
asr.w(算术右移)指令。
- 对于无符号数右移,编译器使用了
lsr.w(逻辑右移)指令。
这两条指令的关键区别在于对符号位的处理。根据 ARM 官方文档的说明,asr(算术右移)指令在移位后,会将原始数值的最高位(即符号位)拷贝到结果的高位。而 lsr(逻辑右移)指令则简单地用 0 填充高位。
因此,-3 (0xfffffffd) >> 1 的过程如下:
- 将
0xfffffffd 的二进制位整体右移一位,得到 0x7ffffffe。
- 由于原始符号位是
1,asr 指令会将这个 1 填充回结果的最高位,最终得到 0xfffffffe。
查看补码表可知,0xfffffffe 正是 -2 的补码形式。这就解释了为什么有符号数右移是“向下取整”(-1.5 向下取整为 -2)。
整型数的除法运算:为何 -3/2 等于 -1?
既然右移可以实现除以 2 的幂次,那么为什么编译器不直接用移位来优化 -3/2 呢?因为 C 语言标准规定,整数除法应向零截断(即 truncate toward zero),这与右移的向下取整规则不同。
再次通过汇编来验证。编写除法测试代码:
#include<stdio.h>
int div(int a, int b)
{
return (a / b);
}
unsigned int div_u(unsigned int a, unsigned int b)
{
return (a / b);
}
main(){
int a = div(-3, 2);
unsigned int b = div_u(3, 2);
printf("[%d][%d]",a,b);
}
在现代 ARMv8 架构上,反汇编可能直接看到 sdiv(有符号除)和 udiv(无符号除)指令。为了看清内部逻辑,可以观察 ARMv7 等没有硬件除法指令的架构生成的代码,编译器会用软件例程实现除法。
分析 __divsi3(有符号整型除法)例程的关键部分,可以发现其对有符号除法的处理流程如下:
- 记录符号:通过异或运算,记录被除数与除数的符号是否相同。
- 转为正数:将被除数和除数都转换为它们的绝对值(正数形式)。
- 执行无符号除法:调用与无符号数相同的除法算法,得到正数的商。
- 恢复符号:根据第一步记录的符号信息,如果原被除数与除数符号不同(即结果为负),则将商转换为负数。
因此,-3 / 2 的计算步骤是:
- 判断符号不同,结果应为负。
- 将
-3 和 2 转换为正数 3 和 2。
- 计算
3 / 2,得到商 1。
- 由于结果应为负,将
1 转换为 -1。
这就实现了“向零取整”(-1.5 向零截断为 -1)。一个边界情况是 0x80000000(即 -2147483648),它取负后仍是自身。按照上述流程,0x80000000 / 2 会先将 0x80000000 当作无符号数 2147483648 进行除以 2 操作,得到 0x40000000(1073741824),然后恢复负号,得到结果 -1073741824。
总结
-3>>1 与 -3/2 结果不同的根本原因在于二者遵循不同的数学规则:
- 有符号数右移 (
>>) :是算术右移,它保留符号位,结果等价于向下取整(floor)。
- 有符号数除法 (
/) :在 C/C++ 标准中规定为向零取整(truncate toward zero)。
这种差异源于底层指令的不同设计目标。移位是位操作,直接作用于二进制补码;而除法是算术运算,需要符合高级语言定义的数学语义。理解这些计算机基础原理,尤其是在涉及底层优化或跨平台开发时,能帮助开发者避免细微的错误,写出更稳健的代码。对于想深入探究汇编实现细节,例如除法算法如何用移位和加法模拟的读者,可以进一步研究相关C/C++编译器的底层库实现。
参考资料
[1] 代码里-3>>1是-2但3>>1是1,-3/2却又是-1,为什么?, 微信公众号:mp.weixin.qq.com/s/kblipkg3zujm-xFZI80pxw
版权声明:本文由 云栈社区 整理发布,版权归原作者所有。