本文以表格的形式讨论了C语言整型数据类型的特点,以及原码、反码、补码和整型取值范围的关系;基于对整型取值范围的认识,对整型溢出的原因做了一个大致分类,也从汇编的层面去探讨了整型“下溢”的本质。
整型数据类型是C语言中的一种基本类型,其存储的是整数,可分为两大类:有符号整型和无符号整型。
各整型类型的位宽及取值范围如下表所示。
类型 | 位宽 | 最小值 | 最大值 |
---|---|---|---|
char | 8 | -128 | 127 |
unsigned char | 8 | 0 | 255 |
short | 16 | -32 768 | 32 767 |
unsigned short | 16 | 0 | 65 535 |
int | 32 | -2 147 483 648 | 2 147 483 647 |
unsigned int | 32 | 0 | 4 294 967 295 |
long | 32 | -2 147 483 648 | 2 147 483 647 |
unsigned long | 32 | 0 | 4 294 967 295 |
long long | 64 | -9 223 372 036 854 775 808 | 9 223 372 036 854 775 807 |
unsigned long long | 64 | 0 | 18 446 744 073 709 551 615 |
位宽
不同的整型类型,有不同的位宽。需要注意的是,不同环境下某些整型类型的位宽定义可能不一样,比如64位Ubuntu17.10+GCC7.2.0编译器的long和64位win7+VS2010的long位宽不一样。16位系统的int和32位系统的int位宽不一样。具体位宽可以通过sizeof()来获知。
符号
无符号整型和有符号整型在编码上的区别在于“符号位”的有无。下图以8位的整型为例,有符号整型的最高位用作符号位,其余位用来表示数值的绝对值,符号位为1代表这是一个负数,为0表示正数。而无符号数没有符号位,整个内存区间都用来表示数值大小。
取值范围
符号位的有无和位宽的大小决定了整型数据的取值范围。对于位宽为n的无符号整型,其取值范围为:[0, 2n-1],这是显而易见的。对于位宽为n的有符号整型,其取值范围为:[-2n-1, 2n-1-1],为什么是这样的取值范围,后面讨论。
下面的表格是正数、负数以及一些具体数值的原码、反码和补码的关系示意。表格第三列中的符号“~”和第四列中的符号“>>”分别代表“按位取反”和“右移”,跟C语言里的定义一样。
十进制 | 原码 | 反码 | 补码 | 右移操作 |
---|---|---|---|---|
正数 | 符号位 = 0 | = 原码 | = 反码 = 原码 = ~负数补码 + 1 |
最高位补0 |
负数 | 符号位 = 1 | 符号位不变, 其余各位取反 |
= 反码 + 1 = ~正数补码 + 1 |
最高位补1 |
4 | 0000 0100 | 0000 0100 | 0000 0100 | 4 >> 1 = 0000 0010补码 = 2 |
-4 | 1000 0100 | 1111 1011 | 1111 1100 | -4 >> 1 = 1111 1110补码 = -2 |
1 | 0000 0001 | 0000 0001 | 0000 0001 | 1 >> 1 = 0000 0000补码 = 0 |
-1 | 1000 0001 | 1111 1110 | 1111 1111 | -1 >> 1 = 1111 1111补码 = -1 |
原码
原码就是“最高位表示符号,其余位表示数值的绝对值”的编码。
反码
相对原码而言,正数的编码不变,负数的编码除符号位外所有位都按位取反,就是反码。
补码
相对反码而言,正数的编码不变,负数的编码加1,就是补码。这是计算机存储数值的编码方式。
前面说到,位宽为n的有符号整型,其取值范围为:[-2n-1, 2n-1-1]。以char型为例,则其取值范围为[-128, 127]。为什么取值范围是[-128, 127],而不是[-127, 128]呢?
我们知道原码非常直观而容易理解,但是这种编码在计算机上进行运算是比较复杂的——需要先判断正负,再进行加减。相比之下,反码的运算则比较简单,因为反码让符号位也参与了运算(省去了符号位的判断),让减法可以用加法来表示(省去了专门的减法电路),比如4-4,可以表示为4+(-4),即0000 0100反码+1111 1011反码=1111 1111反码=1000 0000原码=0
但反码有个问题:由于0的原码有两个:“正零”00000000原码和“负零”10000000原码,导致反码也有两个0:“正零”00000000反码和“负零”11111111反码。
如下表所示,原码和反码的“零”都有两种编码。
十进制 | 原码 | 反码 |
---|---|---|
-127 | 1111 1111 | 1000 0000 |
-126 | 1111 1110 | 1000 0001 |
… | … | … |
-2 | 1000 0010 | 1111 1101 |
-1 | 1000 0001 | 1111 1110 |
-0 | 1000 0000 | 1111 1111 |
0 | 0000 0000 | 0000 0000 |
1 | 0000 0001 | 0000 0001 |
2 | 0000 0010 | 0000 0010 |
… | … | … |
126 | 0111 1110 | 0111 1110 |
127 | 0111 1111 | 0111 1111 |
为了统一两个0,约定正数的编码跟原码、反码一样,负数用其反码+1来表示——也就是我们说的补码,这样原来的“负零”就可以表示为:1111 1111反码 + 1 = 0000 0000补码,而“正零”还是不变:0000 0000原码 = 0000 0000反码 = 0000 0000补码。
于是零编码就统一成了0000 0000补码,而且还腾出了一个编码1000 000补码,对比下表中右边两列,补码相当于把反码整体往上挪了一个位置。
十进制 | 原码 | 反码 | 补码 |
---|---|---|---|
1000 0000 | |||
-127 | 1111 1111 | 1000 0000 | 1000 0001 |
-126 | 1111 1110 | 1000 0001 | 1000 0010 |
… | … | … | … |
-2 | 1000 0010 | 1111 1101 | 1111 1110 |
-1 | 1000 0001 | 1111 1110 | 1111 1111 |
-0 | 1000 0000 | 1111 1111 | 0000 0000 |
0 | 0000 0000 | 0000 0000 | 0000 0000 |
1 | 0000 0001 | 0000 0001 | 0000 0001 |
2 | 0000 0010 | 0000 0010 | 0000 0010 |
… | … | … | … |
126 | 0111 1110 | 0111 1110 | 0111 1110 |
127 | 0111 1111 | 0111 1111 | 0111 1111 |
多出的编码1000 000补码就这样放着吗?当然不是,它正好可以用来表示十进制数-128,因为-128 = -127 - 1 = -127 + (-1) = 1000 0001补码 + 1111 1111补码 = 1000 000补码,不过-128比较特殊——只有补码,没有原码和反码。这就是为什么char型的取值范围是[-128, 127],而不是[-127, 128]。
最后,下面是我们完整的取值范围对照表。
十进制 | 原码 | 反码 | 补码 |
---|---|---|---|
128 | 无 | 无 | 1000 0000 |
-127 | 1111 1111 | 1000 0000 | 1000 0001 |
-126 | 1111 1110 | 1000 0001 | 1000 0010 |
… | … | … | … |
-2 | 1000 0010 | 1111 1101 | 1111 1110 |
-1 | 1000 0001 | 1111 1110 | 1111 1111 |
-0 | 1000 0000 | 1111 1111 | 0000 0000 |
0 | 0000 0000 | 0000 0000 | 0000 0000 |
1 | 0000 0001 | 0000 0001 | 0000 0001 |
2 | 0000 0010 | 0000 0010 | 0000 0010 |
… | … | … | … |
126 | 0111 1110 | 0111 1110 | 0111 1110 |
127 | 0111 1111 | 0111 1111 | 0111 1111 |
当用一个整型变量存放一个超出它可表示范围的数值时,就会发生溢出,称之为整型溢出。
有符号整型的溢出是未定义行为,溢出结果没有约定俗成的标准,由编译器决定,是不可预测的。无符号数的溢出是有定义的:对于位宽为n的无符号整型,其溢出结果是正确值对2n取模,举个例子:
unsigned char a;
a = 300; //溢出
printf("%d\n", a); //a = 300 mod 2^8 = 300 mod 256 = 44
导致整型溢出的原因都有哪些?由于符号位的有无和位宽的大小决定了整型数据的取值范围,那么,整型溢出的原因基本可分为两种:
往一个整型存入比它的位宽更宽的数时所产生的溢出,这是典型的整型溢出,很好理解。原因一般有两种,一种是直接的赋值,另一种是算数运算。
#include "stdio.h"
void main(void)
{
unsigned int ui;
unsigned short us;
unsigned char uc;
ui = 0x12345678;
us = ui;
uc = ui;
printf("ui = 0x%08x\n", ui);
printf("us = 0x%08x\n", us);
printf("uc = 0x%08x\n", uc);
}
由于us和uc太短,装不下ui的0x12345678,结果被“截断”,输出如下:
ui = 0x12345678
us = 0x00005678
uc = 0x00000078
#include "stdio.h"
void main(void)
{
unsigned int a = 0xffffffff;
printf("a = 0x%x\n", a);
printf("a + 1 = 0x%x\n", a + 1);
}
其输出如下:
a = 0xffffffff
a + 1 = 0x0
a+1的正确结果应该是0x100000000,但因为存放该值的临时变量太短,被截断成了0x00000000。
算术运算发生的溢出,有一种可能不易发现:中间结果的溢出。
#include "stdio.h"
void main()
{
int a = 1000000;
int b;
b = a * a / a;
printf("b = %d\n", b);
}
输出如下:
b = -727
上面例子的最终结果是不正确的,其原因在于,在第一步计算a*a时就已发生溢出,导致了最终的错误,这还只是一个简单的例子,如果是一个有很多步的复杂算术运算,整型溢出将藏得很深。
根据前面对取值范围以及补码的讨论,一个十六进制数0xFFFFFFFF,若解释成有符号数,是十进制的-1,若解释成无符号数,则是十进制的4294967295。也就是说,同样的内存数据,对内存数据的解释不一样,得到的数值可能相差十万八千里。所以一个有符号数如果转换成无符号数(或者反过来),将导致数值的突变,从而导致错误。
下面是一个由于符号问题导致溢出的例子。
#include "stdio.h"
int copy_something(char *buf, int len)
{
#define MAX_LEN 256
char mybuf[MAX_LEN];
// do something...
if(len > MAX_LEN) { // <---- [1]
return -1;
}
return memcpy(mybuf, buf, len); // <---- [2]
}
void main()
{
char buffer[512];
copy_something(buffer, -1);
}
当对len传入一个负数,比如-1,将通过位置[1]的检查,len在位置[2]被转成unsigned int,从而变成一个很大的正数,拷贝了超出长度限制的数据到mybuf,使得mybuf后面的内存被覆盖。
一些文章根据溢出的方向,把整型溢出分为“上溢”(overflow)和“下溢”(underflow),“上溢”是指数值大于整型可表示的最大值时发生的溢出,“下溢”则是数值小于整型可表示的最小值时发生的溢出。也有一些文章认为下溢是浮点数的概念,整型没有“下溢”。
那么该如何理解整型“下溢”?先看下面的代码。
#include "stdafx.h"
int main(void)
{
unsigned int a = 0;
int b = 0;
a = a - 1;
b = b - 1;
if(a > 0) {
a = 0;
}
if(b > 0) {
b = 0;
}
return 0;
}
无符号整型a和有符号整型b的初始值都是0,两者都减一,然后分别与0比较大小。a发生了“下溢”,其值变成了一个很大的正数4294967295,因此第一个if判断为真。b没有“下溢”,其值为-1,因此第二个if判断为假。
前面我们讨论过,计算机使用补码可以让符号位参与运算,让减法可以用加法来表示。因此,上面代码的两个减法运算可表示为:
a = a + (-1) = 0 + 0xFFFFFFFF = 0xFFFFFFFF;
b = b + (-1) = 0 + 0xFFFFFFFF = 0xFFFFFFFF;
两者的运算过程完全一样,十六进制结果也一样,只是由于a和b的类型不一样:a是unsigned int型,b是int型,因此a被解释成4294967295,而b被解释成-1。
上面代码在VS2010下的反汇编如下。看到15行和17行,a和b的初始化汇编代码是一样的,而20-22行中a的减一和赋值,和24-26行中b的减一和赋值也是一样的。那么问题来了,既然a和b的十六进制值都一样,它们又是如何根据类型解释成不同的十进制值呢?答案在30和37行。30行的jbe是用于无符号数的小于等于跳转指令,而37行jle是用于有符号数的小于等于跳转指令——汇编代码根据a和b类型的不同,使用了不同的比较跳转指令,实现了对相同十六进制值的不同解释。
#include "stdafx.h"
int main(void)
{
00981350 push ebp
00981351 mov ebp,esp
00981353 sub esp,0D8h
00981359 push ebx
0098135A push esi
0098135B push edi
0098135C lea edi,[ebp-0D8h]
00981362 mov ecx,36h
00981367 mov eax,0CCCCCCCCh
0098136C rep stos dword ptr es:[edi]
unsigned int a = 0;
0098136E mov dword ptr [a],0
int b = 0;
00981375 mov dword ptr [b],0
a = a - 1;
0098137C mov eax,dword ptr [a]
0098137F sub eax,1
00981382 mov dword ptr [a],eax
b = b - 1;
00981385 mov eax,dword ptr [b]
00981388 sub eax,1
0098138B mov dword ptr [b],eax
if(a > 0) {
0098138E cmp dword ptr [a],0
00981392 jbe main+4Bh (98139Bh)
a = 0;
00981394 mov dword ptr [a],0
}
if(b > 0) {
0098139B cmp dword ptr [b],0
0098139F jle main+58h (9813A8h)
b = 0;
009813A1 mov dword ptr [b],0
}
return 0;
009813A8 xor eax,eax
}
所以,从底层角度来看,整型“下溢”跟“上溢”是一样的,都是十六进制数之间的运算赋值,其结果再根据具体的类型来解释:结果太长了?截掉。有符号数?最高位拿来表示正负。无符号数?所有位都拿来表示数值。所以说,整型没有“下溢”。
如果从上层角度来看,“下溢”就是十进制数值的变化超出了变量所能表示的最小值,向下溢出了。也并非不可。
整型溢出问题,轻则导致运算结果出错,重则导致内存被覆盖、缓冲区溢出、被黑客攻击、系统崩溃。
避免整型溢出的一般方向有两个,就是避免前面所说的宽度问题和符号问题。
对于宽度问题:
对于符号问题:
想了解更多整型溢出例子和避免方法,可参考coolshell的 C语言的整型溢出问题。
由于笔者水平有限,文章若存在纰漏,烦请指出。
参考: