【问题标题】:Order of local variable allocation on the stack堆栈上的局部变量分配顺序
【发布时间】:2010-11-09 06:40:58
【问题描述】:

看看这两个函数:

void function1() {
    int x;
    int y;
    int z;
    int *ret;
}

void function2() {
    char buffer1[4];
    char buffer2[4];
    char buffer3[4];
    int *ret;
}

如果我在gdb 中的function1() 中断,并打印变量的地址,我会得到:

(gdb) p &x  
$1 = (int *) 0xbffff380
(gdb) p &y
$2 = (int *) 0xbffff384
(gdb) p &z
$3 = (int *) 0xbffff388
(gdb) p &ret
$4 = (int **) 0xbffff38c

如果我在 function2() 做同样的事情,我会得到:

(gdb) p &buffer1
$1 = (char (*)[4]) 0xbffff388
(gdb) p &buffer2
$2 = (char (*)[4]) 0xbffff384
(gdb) p &buffer3
$3 = (char (*)[4]) 0xbffff380
(gdb) p &ret
$4 = (int **) 0xbffff38c

您会注意到,在这两个函数中,ret 存储在最靠近堆栈顶部的位置。在function1() 中,紧随其后的是zy,最后是x。在function2() 中,ret 后面是 buffer1,然后是 buffer2buffer3。为什么存储顺序会改变?我们在两种情况下都使用了相同数量的内存(4 字节 ints 与 4 字节 char 数组),所以这不是填充问题。这种重新排序可能有什么原因,此外,是否可以通过查看 C 代码提前确定局部变量的排序方式?

现在我知道 C 的 ANSI 规范没有说明局部变量的存储顺序,并且允许编译器选择自己的顺序,但我想编译器有关于它如何存储的规则会处理这个问题,并解释为什么这些规则是这样的。

作为参考,我在 Mac OS 10.5.7 上使用 GCC 4.0.1

【问题讨论】:

  • 重要吗?需要在特定地址分配变量吗?
  • 不,这不重要,只是一个学术练习。
  • 优化程度会影响答案吗?纯粹的猜测,但也许没有优化/低优化,int 是寄存器分配的候选对象,但 char[4] 不是,并且由于它们的处理方式不同,这两种机制恰好以不同的顺序将它们放在堆栈上。即使优化没有任何区别,但处理自动值的方式中的其他东西意味着 int 总是走一条路线,而数组总是走另一条路线,这似乎是合理的。

标签: c memory gcc x86 stack


【解决方案1】:

我不知道why GCC organizes its stack the way it does(尽管我猜你可以打开它的源代码或this paper 并找出答案),但如果出于某种原因你需要,我可以告诉你如何保证特定堆栈变量的顺序到。只需将它们放在一个结构中:

void function1() {
    struct {
        int x;
        int y;
        int z;
        int *ret;
    } locals;
}

如果我没记错的话,规范保证&ret > &z > &y > &x。我把我的 K&R 留在了工作中,所以我不能引用章节和诗句。

【讨论】:

    【解决方案2】:

    所以,我做了更多的实验,这就是我的发现。它似乎基于每个变量是否是一个数组。鉴于此输入:

    void f5() {
            int w;
            int x[1];
            int *ret;
            int y;
            int z[1];
    }
    

    我最终在 gdb 中得到了这个:

    (gdb) p &w
    $1 = (int *) 0xbffff4c4
    (gdb) p &x
    $2 = (int (*)[1]) 0xbffff4c0
    (gdb) p &ret 
    $3 = (int **) 0xbffff4c8
    (gdb) p &y
    $4 = (int *) 0xbffff4cc
    (gdb) p &z
    $5 = (int (*)[1]) 0xbffff4bc
    

    在这种情况下,ints 和指针首先被处理,最后在栈顶声明,最靠近栈底的地方首先声明。然后以相反的方向处理数组,声明越早,堆栈上最高。我确信这是有充分理由的。我想知道它是什么。

    【讨论】:

      【解决方案3】:

      ISO C 不仅没有说明堆栈中局部变量的顺序,它甚至不保证堆栈甚至存在。该标准仅讨论块内变量的范围和生命周期。

      【讨论】:

        【解决方案4】:

        通常与对齐问题有关。

        大多数处理器在获取非处理器字对齐的数据时速度较慢。他们必须把它撕成碎片然后拼接在一起。

        可能正在发生的事情是将所有大于或等于处理器最佳对齐的对象放在一起,然后将可能未对齐的对象更紧密地打包。碰巧在您的示例中,所有 char 数组都是 4 个字节,但我敢打赌,如果您将它们设为 3 个字节,它们仍然会出现在相同的位置。

        但是,如果您有四个 1 字节数组,它们可能会在一个 4 字节范围内结束,或者在四个单独的范围内对齐。

        一切都是关于处理器最容易抓取的东西(翻译为“最快”)。

        【讨论】:

        • 好吧,这里 GCC 默认将堆栈对齐为 16 个字节。另外,即使我们处理的是 4 字节对齐,数组和整数的大小也是相同的(每个 4 字节),所以我不知道你为什么要重新排序。
        【解决方案5】:

        C 标准没有规定其他自动变量的任何布局。但是,为了避免疑问,它特别指出

        [...] [function] 参数的存储布局未指定。 (C11 6.9.1p9)

        由此可以理解,对于任何其他对象的存储布局同样是未指定的,除了标准给出的少数要求,包括空指针不能指向任何有效的对象或函数,以及聚合对象内的布局。

        C 标准不包含单个提及单词“stack”;例如,很有可能做一个无堆栈的 C 实现,从堆中分配每个激活记录(尽管这些可能被理解为形成一个堆栈)。

        给编译器一些余地的原因之一是效率。但是,当前的编译器也将其用于安全性,使用地址空间布局随机化和stack canaries 等技巧来尝试使未定义行为的利用更加困难。缓冲区的重新排序是为了使金丝雀的使用更有效。

        【讨论】:

          【解决方案6】:

          也可能是安全问题?

          int main()
          {
              int array[10];
              int i;
              for (i = 0; i <= 10; ++i)
              {
                  array[i] = 0;
              }
          }
          

          如果数组在堆栈上低于 i,则此代码将无限循环(因为它错误地访问并清零数组 [10],即 i)。通过将数组放在堆栈上更高的位置,访问堆栈末尾之外的内存的尝试将更有可能触及未分配的内存并崩溃,而不是导致未定义的行为。

          我曾用 gcc 尝试过同样的代码,但除了使用我现在不记得的特定标志组合外,它无法让它失败。无论如何,它把数组放在离 i 几个字节的地方。

          【讨论】:

          • 不太可能。堆栈溢出和下溢有保护页,但堆栈帧之间没有。
          • 这里的安全问题是代码不正确。是的,它会导致一个具有特定编译器/标志组合的无限循环。但对我来说,这是一种冰冷的安慰。
          • 有趣的是,由于数组是在栈的高端定义的,所以不能溢出数组来覆盖其他非数组变量。
          【解决方案7】:

          我认为这是一个安全问题,或者至少是为保护堆栈而采取的措施的副作用。我正在玩https://ctf101.org/binary-exploitation/buffer-overflow/ 的示例,它具有以下代码:

          #include <stdio.h>
          
          int main() {
              int secret = 0xdeadbeef;
              char name[100] = {0};
              read(0, name, 0x100);
              if (secret == 0x1337) {
                  puts("Wow! Here's a secret.");
              } else {
                  puts("I guess you're not cool enough to see my secret");
              }
          }
          

          当我使用默认值编译它时,即使使用 -O0secret 也被放置在 name 开始之前的 4 个字节处,这使得简单的利用变得不可能。但是,当我添加-fno-stack-protector 时,它在name 开始后将secret 移动到了108 个字节,并且可以通过将所需的字节序列放置在输入的偏移量108 处来修改secret 的值。

          【讨论】:

          • -O0 默认值。如果您启用了任何优化,常量传播将使 secret == 0x1337 成为常​​量 false 并且编译器将完全优化掉 int secret 变量(因为它的地址不会转义函数),更不用说将它存储/重新加载到/从堆栈。无论如何,有趣的是,当启用堆栈保护器时,将数组放在其他本地人之上是有意义的。如果没有堆栈保护器,它可以走任何一条路;一对一可能允许覆盖返回地址而不是“仅”一个本地地址。或者有限的溢出可能只影响本地人而不是 ret addr
          【解决方案8】:

          我的猜测是这与数据如何加载到寄存器中有关。或许,对于 char 数组,编译器会发挥一些神奇的作用来并行处理,这与内存中的位置有关,以便轻松地将数据加载到寄存器中。尝试使用不同级别的优化进行编译,并尝试改用int buffer1[1]

          【讨论】:

            【解决方案9】:

            有趣的是,如果您在 function1 中添加一个额外的 int *ret2 ,那么在我的系统上该顺序是正确的,而仅 3 个局部变量的顺序是错误的。我的猜测是它是这样排序的,因为它反映了将使用的寄存器分配策略。要么是那个,要么是任意的。

            【讨论】:

              【解决方案10】:

              这完全取决于编译器。除此之外,某些过程变量可能永远不会放在堆栈上,因为它们可以在一个寄存器中度过一生。

              【讨论】:

                猜你喜欢
                • 1970-01-01
                • 1970-01-01
                • 2010-12-23
                • 2013-01-10
                • 1970-01-01
                • 2019-01-22
                • 1970-01-01
                • 2011-02-15
                相关资源
                最近更新 更多