C++编译器对溢出的默认处理

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 的输出结果均一致,同时没有溢出警告

我们对以上输出结果,换算为二进制后,可以明显的看出,可见对于无符号整型,当发生溢出时,编译器默认处理为截断,即舍弃高位的溢出部分

注意: 输出结果是十进制值,是其二进制存储格式的反应,整数以源码形式存储,负数为补码形式!

问题提出:0xFFF60xFFFFFFF6的最高位都为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. 总结

  1. gcc g++编译器对整数溢出的默认处理方式为截断
  2. 编译器可对常量的运算、赋值在编译期做溢出检查,但仅能检查出超字节长度的情况
  3. 非常量的运算实在运行时进行的,编译器无法做溢出检查
  4. C++对溢出检查不友善,可在编码阶段通过合理的设计去规避
posted @ 2024-04-12 15:38  HL棣  阅读(145)  评论(0编辑  收藏  举报