本文主要介绍整数相关的三个问题:类型转换、符号位扩展、数据截断。 通过本文可以了解到以下信息:
本章简单介绍补码、补码的减法运算相关知识点。 了解这些内容,有助于理解本文。如果已经对相关内容比较熟悉,可以直接跳到第二部分。
2
的补码在计算机中,整数是用2
的补码表示的,其定义如下(非官方定义,自己总结的):
注:正数的原码、反码、补码是相同的,这里不再展开。
2
的补码假设系统为32位,十进制整数12345的补码如下(即0x3039):
0000 0000 0000 0000 0011 0000 0011 1001
那么-12345的补码是多少呢?
12345的补码按位取反后如下(即0xFFFF CFC6):
1111 1111 1111 1111 1100 1111 1100 0110
再加1即得到-12345的补码,如下(即0xFFFF CFC7):
1111 1111 1111 1111 1100 1111 1100 0111
同样,我们常用的-1,其补码为0xFFFF FFFF,也就是全1。
2
的补码转换为十进制2
的补码转换为十进制的方法如下:
若一个补码如下:
0000 0000 0000 0000 0011 0000 0011 1001
该数最高位为0,所以为正数,可直接转换为十进制的12345.
若一个补码如下:
1111 1111 1111 1111 1100 1111 1100 0111
该数最高位为1,所以为负数,只能先求其对应正数的值。 先将该数减1得到:
1111 1111 1111 1111 1100 1111 1100 0110
再取反得到:
0000 0000 0000 0000 0011 0000 0011 1001
上面二进制对应的十进制整数为12345,由此可以得到原补码对应的十进制数为-12345。
利用补码,可以将减法运算转换成加法运算。
以下面的减法为例:
12345 - 1
它等价为:
12345 + (-1)
将12345和-1分别转换为补码表示:
(0000 0000 0000 0000 0011 0000 0011 1001) + (1111 1111 1111 1111 1111 1111 1111 1111)
上式的计算结果为:
1 0000 0000 0000 0000 0011 0000 0011 1000
注意它产生了一个进位,该数值总共占用33位,已经溢出。
除去溢出位,剩余的0000 0000 0000 0000 0011 0000 0011 1000
即为十进制的12344
。
以下面的减法为例:
1 - 12345
它等价为:
1 + (-12345)
将1和-12345分别转换为补码表示:
(0000 0000 0000 0000 0000 0000 0000 0001) + (1111 1111 1111 1111 1100 1111 1100 0111)
上式的计算结果为:
1111 1111 1111 1111 1100 1111 1100 1000
该数最高位为1,所以是个负数。根据前面介绍的转换规则,转为十进制后为-1234。
本章以下面的代码为例,看看整数在汇编代码和运行期的形态。
#include <stdio.h>
int main()
{
int signed_int = -12345; /* 补码为0xffffcfc7 */
unsigned int unsigned_int = 12345; /* 补码为0x3039 */
printf("%d %u\n", signed_int, unsigned_int);
return 0;
}
以arm平台为例进行分析,使用下面的指令对a.out
进行反汇编:
helloworld@ubuntu:~$ arm-linux-gnueabihf-gcc -g main.c --static
helloworld@ubuntu:~$ arm-linux-gnueabihf-objdump -d a.out > a.txt
main
函数对应的汇编代码如下所示,其中
movw
将16bit的立即数搬移到寄存器的低16位,并将寄存器的高16bit清零movt
将16bit的立即数搬移到寄存器的高16位从main
函数的汇编代码中可以得到以下信息:
-12345
和12345
翻译成了补码,分别为0xffffcfc7
、0x3039
,均占用4个字节。-12345
在内存的低地址,12345
在内存的高地址,示意图如下(局部变量入栈顺序受优化等级、栈保护等因素影响,不应对入栈顺序做任何假设):
通过gdb可以看到变量signed_int和unsigned_int在内存中的信息如下所示:
从上面我们可以看到,无论是正数还是负数,在内存中都是以2的补码的形式存在的。那么,在不同场景下,程序是如何解读这块内存区域的呢?
下面的代码输出为-12345 4294954951
,其中十进制的4294954951
转换为十六进制为0xffffcfc7
。
#include <stdio.h>
int main()
{
int signed_int = -12345; /* 补码为0xffffcfc7 */
unsigned int unsigned_int = signed_int;
printf("%d %u\n", signed_int, unsigned_int);
return 0;
}
从下图可以看到,变量signed_int和unsigned_int在内存中完全一样。输出结果不同,是由于printf根据格式化字符串(如%u、%d等)对内存中的数据进行解析,并将解析结果输出。也就是说,内存中同样的内容,按照不同的规则解读(格式化字符串不同),会输出不同的内容。
下面的代码,大家都知道为啥输出结果不一样,因为右边的int
被提升为unsigned int
,-12345
被解析成了4294954951
,所以大于1。
但类型转换是如何做到的呢?从gdb信息可以看到,两份代码中变量a、b在内存中是一样的。
我们再对比下二者的汇编代码:
可以看到以下信息:
看来编译器才关心数据类型,它根据不同的类型使用不同的指令。左侧的ble指令比较直观,就不再展开了。对于右侧的bcs,我们简单理下过程:
cmp r2, r3
= r2 - r3
= b - a
= 1 - (-12345)
= 1 + 12345
= 0000 0000 0000 0000 0011 0000 0011 1010
我们看到计算结果无溢出,而bcs只有在计算结果溢出的时候才会执行else分支,所以程序未跳转,继续向下执行,打印出了a > b
的结果。
我们知道,补码和数据类型的长度是有关的:
那么,从长度较小的类型转换为长度较大的类型的时候,为了保持数值不变,必须进行符号位扩展:
举例如下(加粗部分为扩展出来的位数,即在原数值的高位进行扩展): char类型的1的补码为 0000 0001,转换为int类型后的补码为:0000 0000 0000 0000 0000 0000 0000 0001 char类型的-1的补码为 1111 1111,转换为int类型后的补码为:1111 1111 1111 1111 1111 1111 1111 1111
那么,符号位扩展是如何实现的呢?我们通过下面的代码一探究竟:
#include <stdio.h>
int main()
{
signed char a = -1;
int b = (int)a;
printf("b:%d\n", b);
return 0;
}
上面的代码并未输出255,而是-1。也就是说在符号位扩展的时候,保持值不变。
从下面的汇编代码中我们可以看到:
signed char
实际上也占用了4个字节,这就是按字长对齐(32位系统字长为4字节,64位为8字节)。接下来我们看看运行时的调试信息:
从上面我们可以看到,a和b在内存中的关系如下图所示,注意两点:
以下面的代码为例:
#include <stdio.h>
int main()
{
int a = 0xabcdef;
signed char b = a;
signed short c = a;
printf("b=%hhd=0x%hhx, c=%hd=0x%hx\n", b, b, c, c);
return 0;
}
其输出为:
b=-17=0xef, c=-12817=0xcdef
变量a在内存中表示为0xabcdef,占用4字节。若目的类型长度为1字节,截断后在内存中表示为0xef;若目的类型长度为2字节,截断后在内存中表示为0xcdef。
从下面的汇编代码可以看到,变量b和c的赋值流程基本相同,都是先把a的值加载到寄存器r3,不同的是前者调用了strb指令,后者调用了strh指令。strb是将寄存器所存储数值的最低位一字节写到内存中;strh是将寄存器所存储数值的最低位二字节写到内存中,并且保持这二字节的相对顺序不变。
也就是说,数据截断,是把原数据的补码根据目的类型的长度截断,丢弃高位数据,保留低位数据,期间不进行任何语义解析。
好了,本文到此结束。回过头来看看文章开头的结论,理解的是否深刻点了?