访问数组越界不会报错,为什么?

241

我正在C++程序中这样超出边界地赋值:

#include <iostream>
using namespace std;
int main()
{
    int array[2];
    array[0] = 1;
    array[1] = 2;
    array[3] = 3;
    array[4] = 4;
    cout << array[3] << endl;
    cout << array[4] << endl;
    return 0;
}

这个程序会输出34,这不应该发生。我使用的是g++ 4.3.3。

以下是编译和运行命令:

$ g++ -W -Wall errorRange.cpp -o errorRange
$ ./errorRange
3
4

只有在赋值 array[3000]=3000 时,程序才会出现分段错误。

如果gcc不检查数组边界,我该如何确保我的程序正确性,因为这可能会导致一些严重的后果?

我用以下代码替换了上面的代码:

vector<int> vint(2);
vint[0] = 0;
vint[1] = 1;
vint[2] = 2;
vint[5] = 5;
cout << vint[2] << endl;
cout << vint[5] << endl;

并且这个也没有产生错误。


3
相关问题:https://dev59.com/HHRB5IYBdhLWcg3wSVcI中的数组下标越界。 - TSomKes
22
这段代码存在漏洞,但它会生成“未定义”的行为。未定义意味着它可能会运行完整个程序,也可能不会。不能保证程序会崩溃。 - dmckee --- ex-moderator kitten
4
除了在嵌入式/操作系统编程中,C++程序员应该使用容器类而不是原始数组来确保程序的正确性。阅读此文以了解使用容器的原因。http://www.parashift.com/c++-faq-lite/containers.html - jkeys
11
请记住,向量不一定使用 [] 进行范围检查。 使用 .at() 和 [] 效果相同,但会进行范围检查。 - David Thornley
5
当访问越界元素时,vector 不会自动调整大小!这只是未定义行为! - Pavel Minaev
显示剩余19条评论
18个回答

482

欢迎来到每个C/C++程序员最好的朋友:未定义行为

由于各种原因,语言标准中有很多未指定的内容,这就是其中之一。

通常情况下,当你遇到未定义的行为时,任何事情都可能发生。应用程序可能会崩溃,可能会冻结,可能会弹出你的光驱或让魔鬼从你的鼻子里钻出来。它可能会格式化你的硬盘或将你的所有色情材料发送给你的祖母。

如果你很不幸,它甚至会似乎正常工作。

语言只说明了在数组范围内访问元素时应该发生什么。如果你超出边界,将不确定会发生什么。今天在你的编译器上它可能看起来能够工作,但它不是合法的C或C++,而且不能保证它在下一次运行程序时仍然有效。或者它现在可能已经覆盖了重要数据,只是你还没有遇到它即将造成的问题而已。

至于为什么没有边界检查,答案有几个方面:

  • 数组是C中的遗留物。 C数组是最原始的序列元素,具有连续的地址。没有边界检查,因为它只是暴露原始内存。在C中实现强大的边界检查机制几乎是不可能的。
  • 在C++中,类类型可以进行边界检查。但数组仍然是普通的与C兼容的数组。它不是类。此外,C++还建立在另一个规则之上,这使得边界检查不理想。C ++的指导原则是“你不需要付出你不使用的代价”。如果你的代码是正确的,你就不需要边界检查,你也不应该被迫支付运行时边界检查的开销。
  • 因此,C++提供了std::vector类模板,可以同时实现两种访问方式。operator[]被设计为高效的,语言标准并不要求它执行边界检查(虽然也不禁止)。向量还具有at()成员函数,保证执行边界检查。因此,在C++中,如果您使用向量,则可以在需要时获得数组式性能而不进行边界检查,并且可以使用边界检查的功能。

  • 5
    @Jaif:我们已经使用这个数组很长时间了,但为什么还没有测试来检查这样简单的错误? - seg.server.fault
    14
    C++ 的设计原则是它不应该比相同的 C 代码运行得更慢,而 C 并没有对数组边界进行检查的设计原则基本上是为了提高速度,以用于系统编程。数组边界检查需要时间,因此不执行。在大多数情况下,在 C++ 中你应该使用容器而不是数组,通过使用 .at() 或 [] 来访问元素可以选择是否进行边界检查。 - KTC
    5
    这样的检查会花费一些代价。如果您编写正确的代码,您不想付出这样的代价。话虽如此,我已经完全转换到使用std::vector的at()方法,并且该方法是经过检查的。使用它揭示了我认为是“正确”的代码中的许多错误。 - anon
    11
    我相信旧版本的GCC在遇到某些类型的未定义行为时,实际上会在Emacs中启动模拟汉诺塔游戏。就像我说的,“任何事情”都可能发生。 ;) - jalf
    5
    已经有人提出了所有的内容,这里只需要添加一小段。与发布版相比,调试版可以在这些情况下更加宽容。由于调试信息包含在调试二进制文件中,所以重要的内容被覆盖的可能性较小。这就是为什么调试版本有时候看起来工作得很好,而发布版本却会崩溃的原因。 - Rich
    显示剩余8条评论

    41
    使用g++编译器,您可以添加命令行选项:-fstack-protector-all。在您的示例中,它产生了以下结果:
    > g++ -o t -fstack-protector-all t.cc
    > ./t
    3
    4
    /bin/bash: line 1: 15450 Segmentation fault      ./t
    

    它并不会真正帮助你找到或解决问题,但至少段错误会让你知道某些东西出了问题。


    12
    我刚刚发现了一个更好的选择:-fmudflap - Hi-Angel
    4
    现代等效的方法是-fsanitize=address,它在编译时(如果启用了优化)和运行时都会捕获这个错误。 - Nate Eldredge
    2
    @NateEldredge +1,现在我甚至使用-fsanitize=undefined,address。但值得注意的是,在标准库中有一些罕见的情况,当超出边界访问未被检测到时,会出现问题(https://gcc.gnu.org/bugzilla/show_bug.cgi?id=91878)。因此,我建议另外使用`-D_GLIBCXX_DEBUG`选项,这将增加更多的检查。 - Hi-Angel
    2
    感谢你 Hi-Angel。当 -fmudflap-fsanitize=address 对我无效时,-fsanitize=undefined,address 不仅发现了一个没有返回值的函数,还发现了一个超出边界的数组赋值。 - Nav
    我们在其他编译器上有这个吗,比如苹果的clang? - KcFnMi

    16

    g++不会检查数组边界,如果你使用了3,4这样的数字,可能会覆盖一些东西,但并不是非常重要,如果你使用更大的数字,程序会崩溃。

    你只是在覆盖未使用的堆栈部分,继续操作直到达到为堆栈分配的空间的末尾,程序最终会崩溃。

    编辑: 你无法处理这个问题,也许静态代码分析器可以揭示这些问题,但这太简单了,甚至对于静态分析器也可能会存在类似(但更复杂)的问题未被检测到。


    7
    你从哪里得出这样的结论,即在数组的第三个和第四个地址处,有“真正重要的东西其实并不存在”? - namezero
    哪些数字可以被认为是“较大的数字”? - KcFnMi

    10

    据我所知,这是未定义行为。如果你用这个来运行一个较大的程序的话,它将在某个地方崩溃。原始数组(甚至std :: vector)不包括边界检查。

    使用std :: vector和std :: vector :: iterator而不必担心它。

    编辑:

    只是为了好玩,运行这个程序并看看你要多久才会崩溃:

    int main()
    {
       int arr[1];
    
       for (int i = 0; i != 100000; i++)
       {
           arr[i] = i;
       }
    
       return 0; //will be lucky to ever reach this
    }
    

    编辑2:

    不要运行那个。

    编辑3:

    好的,这里是关于数组及其与指针的关系的快速介绍:

    当您使用数组索引时,实际上是在使用一个伪装成指针(称为“引用”)的指针,它会自动解引用。这就是为什么 array[1] 自动返回该索引处的值,而不是 *(array+1)。

    当您有一个指向数组的指针时,就像这样:

    int arr[5];
    int *ptr = arr;
    

    那么第二个声明中的 "array" 其实是退化为指向第一个数组的指针。这等同于以下行为:

    int *ptr = &arr[0];
    

    当你试图访问超出你分配的内存空间时,实际上你只是在使用指向其他内存的指针(这在C++中不会引起错误)。以我上面示例程序为例,这等价于:

    int main()
    {
       int arr[1];
       int *ptr = arr;
    
       for (int i = 0; i != 100000; i++, ptr++)
       {
           *ptr++ = i;
       }
    
       return 0; //will be lucky to ever reach this
    }
    
    编译器不会抱怨,因为在编程中,你经常需要与其他程序通信,特别是操作系统。这经常使用指针来完成。

    3
    我认为你忘记在最后一个例子中增加“ptr”的值了。你不小心写出了一些严格定义的代码。 - Jeff Lake
    1
    哈哈,看到了吧,为什么你不应该使用原始数组? - jkeys
    1
    这就是为什么不用 *(array[1]),而是直接使用 array[1] 就可以自动返回该值的原因。你确定 *(array[1]) 能正常工作吗?我认为应该是 *(array + 1)。附言:哈哈,这就像给过去发送一条消息。但无论如何: - muyustan
    1
    @muyustan 哈哈,你跟过去说话,过去还回应了呢。已经按照你的建议进行了编辑。 - jkeys

    5

    提示

    如果您想使用带有范围错误检查的快速约束大小数组,请尝试使用boost::array(也可以使用 <tr1/array> 中的 std::tr1::array,它将成为下一个 C++ 规范的标准容器)。它比 std::vector 快得多。它在堆上或类实例内保留内存,就像 int array[] 一样。
    这是一个简单的示例代码:

    #include <iostream>
    #include <boost/array.hpp>
    int main()
    {
        boost::array<int,2> array;
        array.at(0) = 1; // checking index is inside range
        array[1] = 2;    // no error check, as fast as int array[2];
        try
        {
           // index is inside range
           std::cout << "array.at(0) = " << array.at(0) << std::endl;
    
           // index is outside range, throwing exception
           std::cout << "array.at(2) = " << array.at(2) << std::endl; 
    
           // never comes here
           std::cout << "array.at(1) = " << array.at(1) << std::endl;  
        }
        catch(const std::out_of_range& r)
        {
            std::cout << "Something goes wrong: " << r.what() << std::endl;
        }
        return 0;
    }
    

    这个程序将会输出:

    array.at(0) = 1
    Something goes wrong: array<>: index out of range
    

    读者注意:过时的答案。自C++11以来,应该使用标准库中的#include<array>std::array,而不是boost的等效物。 - user17732522

    5

    使用Valgrind可能会发现错误。

    正如Falaina所指出的,valgrind无法检测到许多堆栈破坏的情况。我刚刚尝试了在valgrind下运行的样例,它确实没有报告任何错误。然而,Valgrind在找到许多其他类型的内存问题方面非常有用,除非您修改构建以包括--stack-check选项,在这种情况下,它并不特别有用。如果您按照以下方式构建和运行样例:

    g++ --stack-check -W -Wall errorRange.cpp -o errorRange
    valgrind ./errorRange
    

    valgrind会报告一个错误。


    3
    实际上,Valgrind 在检测堆栈上的错误数组访问方面效果不佳。(它能做的最好就是将整个堆栈标记为有效的写入位置。) - Falaina
    @Falaina - 很好的观点,但Valgrind可以检测到至少一些堆栈错误。 - Todd Stout
    Valgrind看不出代码有任何问题,因为编译器足够聪明,可以优化掉数组并简单地输出字面值3和4。这种优化发生在gcc检查数组边界之前,这就是为什么gcc具有的越界警告没有显示出来的原因。 - Goswin von Brederlow

    4

    在C或C++中,不会检查数组访问的边界。

    您正在堆栈上分配数组。通过array[3]索引数组等同于*(array + 3),其中数组是指向&array[0]的指针。这将导致未定义的行为。

    在C中一个有时可以捕捉到这个问题的方法是使用静态检查器,例如splint。如果运行:

    splint +bounds array.c
    

    on,

    int main(void)
    {
        int array[1];
    
        array[1] = 1;
    
        return 0;
    }
    

    那么你将会得到一个警告:

    array.c:(在函数main中) array.c:5:9:可能越界 写入: array[1] 无法解决的约束条件: 要求0>=1 满足前提条件所需的: 要求maxSet(array @ array.c:5:9) >= 1 内存写操作可能会 写入超出分配缓冲区的地址。


    更正:该内存已被操作系统或其他程序分配。他正在覆盖其他内存。 - jkeys
    1
    说“C/C++不会检查边界”并不完全正确——没有什么能阻止特定的兼容实现这样做,无论是默认情况下还是通过一些编译标志。只是它们中没有一个费心去做。 - Pavel Minaev

    3
    你肯定正在覆盖你的堆栈,但这个程序足够简单,以至于这种影响不会被注意到。

    2
    栈是否被覆盖取决于平台。 - Chris Cleeland

    2

    libstdc++是gcc的一部分,具有用于错误检查的特殊调试模式。它通过编译器标志-D_GLIBCXX_DEBUG启用。除其他外,它还对std::vector进行边界检查,但会牺牲性能。这里是使用最新版本的gcc的在线演示

    因此,实际上您可以使用libstdc ++调试模式进行边界检查,但仅应在测试时执行,因为与正常的libstdc ++模式相比,它会显著影响性能。


    我们在其他编译器上有这个吗,比如苹果的clang? - KcFnMi
    是的,LLVM实现的C++标准库也有类似的调试模式,详见 https://libcxx.llvm.org/DesignDocs/DebugMode.html。 - ks1322

    1

    未定义的行为对你有利。显然,你覆盖的任何内存都不包含重要信息。请注意,C和C++不会对数组进行边界检查,因此这样的问题在编译或运行时不会被捕获。


    8
    不,当未定义行为可以干净地崩溃时,这反而对你有利。当它看起来正常工作时,那是最糟糕的情况。 - jalf
    @JohnBode:那么,如果您按照jalf的评论更正措辞,那将更好。 - Destructor

    网页内容由stack overflow 提供, 点击上面的
    可以查看英文原文,
    原文链接