C整型与整型溢出

文章目录

  • 0 摘要
  • 1 整型数据类型
    • 1.1 位宽、符号和取值范围
    • 1.2 原码、反码和补码
    • 1.3 再论有符号数取值范围
  • 2 整型溢出
    • 2.1 何为整型溢出
    • 2.2 宽度问题导致的溢出
    • 2.3 符号问题导致的溢出
    • 2.4 整型“下溢”
    • 2.5 避免整型溢出

0 摘要

本文以表格的形式讨论了C语言整型数据类型的特点,以及原码、反码、补码和整型取值范围的关系;基于对整型取值范围的认识,对整型溢出的原因做了一个大致分类,也从汇编的层面去探讨了整型“下溢”的本质。

1 整型数据类型

整型数据类型是C语言中的一种基本类型,其存储的是整数,可分为两大类:有符号整型和无符号整型。

1.1 位宽、符号和取值范围

各整型类型的位宽及取值范围如下表所示。

类型 位宽 最小值 最大值
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表示正数。而无符号数没有符号位,整个内存区间都用来表示数值大小。
    C整型与整型溢出_第1张图片

  • 取值范围
    符号位的有无和位宽的大小决定了整型数据的取值范围。对于位宽为n的无符号整型,其取值范围为:[0, 2n-1],这是显而易见的。对于位宽为n的有符号整型,其取值范围为:[-2n-1, 2n-1-1],为什么是这样的取值范围,后面讨论。

1.2 原码、反码和补码

下面的表格是正数、负数以及一些具体数值的原码、反码和补码的关系示意。表格第三列中的符号“~”和第四列中的符号“>>”分别代表“按位取反”和“右移”,跟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,就是补码。这是计算机存储数值的编码方式。

1.3 再论有符号数取值范围

前面说到,位宽为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

2 整型溢出

2.1 何为整型溢出

当用一个整型变量存放一个超出它可表示范围的数值时,就会发生溢出,称之为整型溢出。

有符号整型的溢出是未定义行为,溢出结果没有约定俗成的标准,由编译器决定,是不可预测的。无符号数的溢出是有定义的:对于位宽为n的无符号整型,其溢出结果是正确值对2n取模,举个例子:

unsigned char a;
a = 300;				//溢出
printf("%d\n", a);		//a = 300 mod 2^8 = 300 mod 256 = 44

导致整型溢出的原因都有哪些?由于符号位的有无和位宽的大小决定了整型数据的取值范围,那么,整型溢出的原因基本可分为两种:

  • 宽度问题导致的溢出
  • 符号问题导致的溢出

2.2 宽度问题导致的溢出

往一个整型存入比它的位宽更宽的数时所产生的溢出,这是典型的整型溢出,很好理解。原因一般有两种,一种是直接的赋值,另一种是算数运算。

  • 赋值
    看到下面例子中的赋值操作。
#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时就已发生溢出,导致了最终的错误,这还只是一个简单的例子,如果是一个有很多步的复杂算术运算,整型溢出将藏得很深。

2.3 符号问题导致的溢出

根据前面对取值范围以及补码的讨论,一个十六进制数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后面的内存被覆盖。

2.4 整型“下溢”

一些文章根据溢出的方向,把整型溢出分为“上溢”(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  
}

所以,从底层角度来看,整型“下溢”跟“上溢”是一样的,都是十六进制数之间的运算赋值,其结果再根据具体的类型来解释:结果太长了?截掉。有符号数?最高位拿来表示正负。无符号数?所有位都拿来表示数值。所以说,整型没有“下溢”。

如果从上层角度来看,“下溢”就是十进制数值的变化超出了变量所能表示的最小值,向下溢出了。也并非不可。

2.5 避免整型溢出

整型溢出问题,轻则导致运算结果出错,重则导致内存被覆盖、缓冲区溢出、被黑客攻击、系统崩溃。

避免整型溢出的一般方向有两个,就是避免前面所说的宽度问题和符号问题。

对于宽度问题:

  • 避免将一个“更长”的变量赋值给“更短”的变量
  • 考虑参与运算的变量可能取到的最大值,即当变量都以最大值参与运算时,运算的结果或中间结果,是否在变量的取值范围内

对于符号问题:

  • 避免有符号整型和无符号整型之间的运算,比较,赋值

想了解更多整型溢出例子和避免方法,可参考coolshell的 C语言的整型溢出问题。

由于笔者水平有限,文章若存在纰漏,烦请指出。



参考:

  • coolshell
  • Basic Integer Overflows
  • 代码大全
  • C语言程序设计
  • 深入理解计算机系统

你可能感兴趣的:(C语言,C语言,原码,反码,补码,整型溢出)