C++编译器对溢出的默认处理
在算数运算中,有一个比较头疼又必须要处理的事情:“溢出”,当我们有所疏忽,没有对溢出的情况做处理时,在我们不知情下就会产生很诡异的bug!
那么当我们没有做溢出处理时,编译器的默认处理方式是什么呢?下面我们探究一下这个问题。
测试环境
- Linux 4.15.0 #16.04.1-Ubuntux 86_64 GNU/Linux
- gcc 5.4.0 20160609
- g++ 5.4.0 20160609
表一.C++基本类型的表示范围:
数据类型 | 占内存字节数 | 表示范围 |
---|---|---|
char(signed char) | 1 | -128~127 |
unsigned char | 1 | 0~255 |
short int(signed short int) | 2 | -32768~32767 |
unsigned short int | 2 | 0~65,535 |
int(signed int) | 4 | -2147483648~2147483647(-231~ 231-1) |
unsigned int | 4 | 0~4294967295 |
long int(signed long int) | 4 | -2147483648~2147483647 (-231~ 231-1) |
unsigned long int | 4 | 0~4294967295 |
float | 4 | -3.4x10-38 ~ 3.4x1038 |
double | 8 | -1.7x10-308 ~ 1.7x10308 |
long double | 8 | -1.7x10-308 ~ 1.7x10308 |
1.整数溢出
1.1 无符号溢出
以下是无符号整型的溢出测试代码
#include <stdio.h>
#include <stdint.h>
int main()
{
uint8_t u8_max = 255;
uint16_t u16_max = 65535;
uint32_t u32_max = 4294967295;
uint8_t u8_cnt = u8_max + 10;
uint16_t u16_cnt = u16_max + 10;
uint32_t u32_cnt = u32_max + 10;
printf("u8: %d\nu16: %d\nu32: %d\n", u8_cnt, u16_cnt, u32_cnt);
printf("\n");
u8_cnt = u8_max * 10;
u16_cnt = u16_max * 10;
u32_cnt = u32_max * 10;
printf("u8: %d\nu16: %d\nu32: %d\n", u8_cnt, u16_cnt, u32_cnt);
return 0;
}
输出结果
//原值 -> 16进制值 -> 截断值 -> 十进制显示值
u8: 9 //265 -> 0x109 -> 0x09 -> 9
u16: 9 //65545 -> 0x10009 -> 0x0009 -> 9
u32: 9 //4294967305 -> 0x100000009 -> 0x00000009 -> 9
u8: 246 //2550 -> 0x9F6 -> 0xF6 -> 246
u16: 65526 //655350 -> 0X9FFF6 -> 0XFFF6 -> 65526
u32: -10 //42949673050 -> 0x9FFFFFFF6 -> 0xFFFFFFF6 -> -10
//gcc、 g++ -std=c++11、g++ -std=c++17 的输出结果均一致,同时没有溢出警告
我们对以上输出结果,换算为二进制后,可以明显的看出,可见对于无符号整型,当发生溢出时,编译器默认处理为截断,即舍弃高位的溢出部分
注意: 输出结果是十进制值,是其二进制存储格式的反应,整数以源码形式存储,负数为补码形式!
问题提出:0xFFF6
和0xFFFFFFF6
的最高位都为1,为什么0xFFF6
printf的打印值一个是是65526
,另一个却是-10
?printf函数是如何区分输入变量的正负符号的呢?
1.2 有符号溢出
以下是有符号整型的溢出测试代码
int8_t i8_max = 127;
int16_t i16_max = 32767;
int32_t i32_max = 2147483647;
int8_t i8_cnt = i8_max + 10;
int16_t i16_cnt = i16_max + 10;
int32_t i32_cnt = i32_max + 10;
printf("i8: %d\ni16: %d\ni32: %d\n", i8_cnt, i16_cnt, i32_cnt);
printf("\n");
i8_cnt = i8_max * 10;
i16_cnt = i16_max * 10;
i32_cnt = i32_max * 10;
printf("i8: %d\ni16: %d\ni32: %d\n", i8_cnt, i16_cnt, i32_cnt);
输出结果
//gcc、 g++ -std=c++11、g++ -std=c++17 的输出结果均一致,如下
//原值 -> 16进制值 -> 截断值 -> 十进制显示值
i8: -119 //137 -> 0x89 -> 0x89 -> -199
i16: -32759 //32777 -> 0x8009 -> 0x8009 -> -32759
i32: -2147483639//同理
i8: -10 //1270 -> 0x4F6 -> 0xF6 -> -10
i16: -10 //同理
i32: -10 //同理
从结果看,有符号数的溢出默认处理也是截断,我们从十进制的角度看不出规律,但是以整型的二进制存储格式来看是很清楚的,gcc\g++编译器默认的溢出处理方式就是截断。
题外话: 从上面的测试例子已经可以看出,对C++编译器来说,有符号的类型和无符号类型实际上没有区别。 类型在C++里的作用,只是一个字节大小的声明,编译器不知道也不care里面的存放值是有符号还是无符号的,算数运算遵循最基本的二进制运算规则。
1.3 字面常量的运算溢出
以下用常量运算式进行赋值的测试代码
uint8_t u8_cnt;
const uint8_t u8_cnt2 = 255;
const int8_t i8_cnt = 127;
u8_cnt = 255 + 10;
uint8_t sum = u8_cnt2 + 10;
int8_t sum2 = i8_cnt + 10;
编译输出:
overflow_test.cpp: In function ‘int main()’:
overflow_test.cpp:10:18: warning: unsigned conversion from ‘int’ to ‘uint8_t’ {aka ‘unsigned char’} changes value from ‘265’ to ‘9’ [-Woverflow]
10 | u8_cnt = 255 + 10;
| ~~~~^~~~
overflow_test.cpp:11:27: warning: unsigned conversion from ‘int’ to ‘uint8_t’ {aka ‘unsigned char’} changes value from ‘265’ to ‘9’ [-Woverflow]
11 | uint8_t sum = u8_cnt2 + 10;
| ~~~~~~~~^~~~
打印输出
u8: 9
sum: 9
sum2: -119
可见,在常量运算和赋值时,编译器可以在编译期检查溢出情况,这与非常量运算编译器没有溢出警告是不一样的!因为非常量的运算实在运行时进行的,编译器无法做溢出检查。
但是这种编译期的溢出检查也是有限,只能检查超出类型字节长度的情况,其他的类型收窄的情况无法检查出,如把uint8类型赋值给一个int8类型。
2. 如何避免溢出
C++ 官方库并没有提供溢出检查的宏或者方法,自实现的话比较麻烦,同时在每个运算时加入检查语句也不现实,所以避免溢出的最好方式还是在编码阶段去规避,当硬件性能允许的条件下,多用int、long类型,并用合理的类型去存放运算后的值。合理的代码设计能规避大部分的溢出风险。
3. 对溢出的处理方式
- 截断:自动舍弃高位
- 饱和:返回该类型的最大或者最小值
- panic: 如Rust编译器在debug下存在溢出则触发panic
4. 总结
- gcc g++编译器对整数溢出的默认处理方式为截断
- 编译器可对常量的运算、赋值在编译期做溢出检查,但仅能检查出超字节长度的情况
- 非常量的运算实在运行时进行的,编译器无法做溢出检查
- C++对溢出检查不友善,可在编码阶段通过合理的设计去规避