为什么当我写超出数组末尾时,程序不会崩溃?

21
为什么下面的代码可以在运行时没有崩溃?而且大小完全取决于机器/平台/编译器!!我甚至可以在64位的机器上最多给200。如果主函数中有分段错误,操作系统如何检测到它?
int main(int argc, char* argv[])
{
    int arr[3];
    arr[4] = 99;
}

这个缓冲区的空间是从哪里来的?这是分配给进程的堆栈吗?


9
堆栈溢出是在从堆栈分配过多内存时发生的。假设sizeof(int)==4,在这种情况下,您已经从堆栈中分配了可怜的12个字节。您的代码正在数组的末尾之外进行写入。这不是堆栈溢出,而是_未定义行为_。 - David Hammen
1
来自与你的其余RAM相同的地方,可能是卖给你电脑的人。arr[3]的意思是“指定3个可用于我的使用的int空间”,它并不意味着“从虚无中创建3个int空间”,尽管如果这在物理上是可能的话,那将是一种合法的实现方式。你正在涂写arr旁边(事实上是隔壁的下一个地址)的任何内存/地址,正如David所说的那样,这是UB。是的,它是你的堆栈的一部分(C和C++标准没有谈论堆栈,但在实践中,这就是自动变量的位置)。 - Steve Jessop
1
@vprajan - 我已经更新了你的标题,以反映问题,因为有一个很好的答案可以吸引注意。 - Steve Townsend
“Segmentation fault”和“我访问了不该访问的内存”并不等同。前者是执行后者时出现的一部分症状。 - Lightness Races in Orbit
arr[3] = 99; 已经足够。 - Peter - Reinstate Monica
9个回答

81

以下是我不久前为了教育目的而编写的内容...

考虑下面这个 C 程序:

int q[200];

main(void) {
    int i;
    for(i=0;i<2000;i++) {
        q[i]=i;
    }
}

编译并执行后,会产生一个核心转储(core dump):

$ gcc -ggdb3 segfault.c
$ ulimit -c unlimited
$ ./a.out
Segmentation fault (core dumped)

现在使用gdb进行死后分析:

$ gdb -q ./a.out core
Program terminated with signal 11, Segmentation fault.
[New process 7221]
#0  0x080483b4 in main () at s.c:8
8       q[i]=i;
(gdb) p i
$1 = 1008
(gdb)

哦,当我写超出分配的200个项目时,程序没有出现段错误,而是在i=1008处崩溃了,为什么呢?

输入页面。

在UNIX/Linux上有几种方法可以确定页面大小,一种方法是使用系统函数sysconf(),像这样:

#include <stdio.h>
#include <unistd.h> // sysconf(3)

int main(void) {
    printf("The page size for this system is %ld bytes.\n",
            sysconf(_SC_PAGESIZE));

    return 0;
}

这将会输出:

此系统的页面大小为4096字节。

或者可以使用命令行实用工具 getconf,如下所示:

$ getconf PAGESIZE
4096

死后分析

事实证明,段错误并不出现在i = 200处,而是在i = 1008处,让我们找出原因。启动gdb进行一些死后分析:

$gdb -q ./a.out core

Core was generated by `./a.out'.
Program terminated with signal 11, Segmentation fault.
[New process 4605]
#0  0x080483b4 in main () at seg.c:6
6           q[i]=i;
(gdb) p i
$1 = 1008
(gdb) p &q
$2 = (int (*)[200]) 0x804a040
(gdb) p &q[199]
$3 = (int *) 0x804a35c

q[]以地址0x804a35c结束,或者说,q[199]的最后一个字节在该位置。正如我们之前看到的那样,页大小为4096字节,机器的32位字长将虚拟地址分解为20位页号和12位偏移量。

q[]结束时所在的虚拟页号为:

0x804a = 32842 偏移量:

0x35c = 860 因此,在分配q[]的内存页上仍有:

4096 - 864 = 3232 字节剩余空间。这个空间可以容纳:

3232 / 4 = 808 个整数,而代码则将其视为包含在q的第200到1008个位置的元素。

我们都知道这些元素是不存在的,编译器也没有抱怨,硬件也没有,因为我们对该页具有写权限。只有当i=1008时,q[]才引用了一个不具备写权限的不同页上的地址,虚拟内存硬件检测到了这一点,并触发了段错误。

一个整数占4个字节,这意味着这一页包含808(3236/4)个额外的虚假元素,这意味着仍然可以完全合法地从q[200]、q[201]一直访问到元素199+808=1007(q[1007]),而不会触发段错误。当访问q[1008]时,进入了一个具有不同权限的新页面。


14
很好的回答,除了你说“从q[200],q[201]一直到元素”的那部分 - 事实是对于这个编译器实现,访问这些元素不会引起任何问题,但在技术上访问这些元素是未定义的行为,不同的编译器可能会生成非常不同的结果。也就是说,访问这些元素是非法的,但在这种情况下你可以逃脱惩罚,就像当速限是65mph时开75mph一样 :) - Edward Loper
5
我同意Edward的观点,"合法性"这个概念非常严格定义,让我们不要在这里扭曲它的含义!+1 - Lightness Races in Orbit
很棒的帖子!!如果在main函数以外的函数中执行相同的操作,会检测到分段错误(缓冲区溢出)..!! - vprajan

8

由于您正在数组范围之外写入,因此您的代码行为是未定义的。

未定义行为的本质是任何事情都可能发生,包括缺少segfaults(编译器没有义务执行边界检查)。

您正在写入尚未分配但恰好存在且可能未被用于其他任何目的的内存。如果您对看似不相关的代码部分、操作系统、编译器、优化标志等进行更改,则您的代码可能会表现出不同的行为。

换句话说,一旦进入该领域,所有赌注都取消了。


4

关于本地变量缓冲区溢出在何时/何处崩溃取决于以下几个因素:

  1. 函数调用时栈上已有的数据量,其中包含了溢出变量的访问
  2. 总共写入溢出变量/数组的数据量

请记住,堆栈是向下增长的。也就是说,进程执行始于指针接近内存作为堆栈的“末尾”的位置。不过它并不会从最后一个映射的字开始,这是因为系统初始化代码可能会决定在进程创建时向进程传递某种“启动信息”,而通常是在堆栈上进行。

这是通常的故障模式——从包含溢出代码的函数返回时崩溃。

如果在堆栈上写入缓冲区的数据量大于之前使用的堆栈空间的总量(由调用者/初始化代码/其他变量),那么你将在第一次运行超出堆栈顶部(开头)的内存访问时崩溃。崩溃地址将刚好在页面边界之后——由于访问了未映射的内存而导致SIGSEGV

如果该总量小于此时使用的堆栈的已用部分大小,则一切都可以正常工作,但会在稍后崩溃——实际上,在将返回地址存储在堆栈上的平台(如x86/x64)中,从函数返回时。这是因为CPU指令ret实际上从堆栈中取出一个字(返回地址)并将执行重定向到那里。如果这个地址包含任何垃圾而不是预期的代码位置,则会发生异常并导致程序终止。

举个例子:当调用main()时,在32位x86 UNIX程序上,堆栈看起来像这样:

[ esp          ] <return addr to caller> (which exits/terminates process)
[ esp + 4      ] argc
[ esp + 8      ] argv
[ esp + 12     ] envp <third arg to main() on UNIX - environment variables>
[ ...          ]
[ ...          ] <other things - like actual strings in argv[], envp[]
[ END          ] PAGE_SIZE-aligned stack top - unmapped beyond

main() 开始执行时,它会在栈上为各种目的分配空间,其中包括用于容纳即将溢出的数组。这将使其看起来像:

[ esp          ] <current bottom end of stack>
[ ...          ] <possibly local vars of main()>
[ esp + X      ] arr[0]
[ esp + X + 4  ] arr[1]
[ esp + X + 8  ] arr[2]
[ esp + X + 12 ] <possibly other local vars of main()>
[ ...          ] <possibly other things (saved regs)>

[ old esp      ] <return addr to caller> (which exits/terminates process)
[ old esp + 4  ] argc
[ old esp + 8  ] argv
[ old esp + 12 ] envp <third arg to main() on UNIX - environment variables>
[ ...          ]
[ ...          ] <other things - like actual strings in argv[], envp[]
[ END          ] PAGE_SIZE-aligned stack top - unmapped beyond

这意味着您可以轻松访问比arr[2]更远的位置。
为了体验由缓冲区溢出导致的不同崩溃情况,请尝试以下示例:
#include <stdlib.h>
#include <stdio.h>

int main(int argc, char **argv)
{
    int i, arr[3];

    for (i = 0; i < atoi(argv[1]); i++)
        arr[i] = i;

    do {
        printf("argv[%d] = %s\n", argc, argv[argc]);
    } while (--argc);

    return 0;
}

尝试一下当你溢出缓冲区一点点(比如说10个bit)时和溢出栈末端时,崩溃会有多大差异。使用不同的优化级别和编译器进行尝试,这很形象地展示了行为不良(可能不会正确打印所有的argv[]),以及各种地方的崩溃,甚至是无限循环(如果编译器将iargc放入堆栈中并且代码在循环期间覆盖它)。


3
通过使用C++从C继承来的数组类型,您隐式地要求不进行范围检查。
如果您尝试使用以下方法:
void main(int argc, char* argv[])
{     
    std::vector<int> arr(3);

    arr.at(4) = 99;
} 

如果出现异常,您将会收到一个异常提示。

C++提供了检查和未检查两种接口。选择使用哪一种取决于您自己。


2

这是未定义的行为——你简单地没有观察到任何问题。最有可能的原因是你覆盖了程序行为并不依赖之前的内存区域——那块内存在技术上是可写的(在大多数情况下,堆栈大小约为1兆字节),而你没有看到任何错误指示。你不应该依赖于这个。


1
回答你的问题,为什么它是“未检测到”的:大多数C编译器在编译时不会分析指针和内存的使用情况,因此在编译时没有人注意到你写了一些危险的代码。在运行时,也没有受控的、受管理的环境来监视你的内存引用,因此没有人会阻止你读取你没有权限访问的内存。此时内存恰好被分配给你(因为它只是离你的函数不远的堆栈的一部分),所以操作系统也不会有问题。
如果你想在访问内存时得到帮助,你需要一个像Java或CLI这样的受管理的环境,在这种环境下,你的整个程序都由另一个管理程序运行,该程序会注意这些违规行为。

0

你的代码存在未定义行为。这意味着它可以做任何事情或什么都不做。根据你的编译器和操作系统等,它可能会崩溃。

话虽如此,对于许多编译器,如果不是大多数编译器,你的代码甚至 无法编译

这是因为你使用了 void main,而 C 标准和 C++ 标准都要求使用 int main

唯一支持 void main 的编译器是微软的 Visual C++。

这是一个 编译器缺陷,但由于微软有很多示例文档甚至代码生成工具都会生成 void main,他们可能永远不会修复它。然而,请考虑编写符合标准的 int main 比编写针对 Microsoft 的 void main 多输入一个字符。那么为什么不遵循标准呢?

祝好!


0

当进程试图覆盖它没有拥有的内存页时,会发生分段错误;除非您在缓冲区的末尾远离了很长一段路,否则不会触发分段错误。

堆栈位于应用程序拥有的内存块中的某个位置。在这种情况下,如果您没有覆盖重要内容,那么您只是幸运而已。您可能已经覆盖了一些未使用的内存。如果您再不走运一点,您可能已经覆盖了堆栈上另一个函数的堆栈帧。


0

显然,当您要求计算机在内存中分配一定数量的字节时,例如: char array[10] 它会给我们一些额外的字节,以避免出现段错误,但是仍然不安全使用这些字节,尝试访问更多的内存最终会导致程序崩溃。


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