alloca() 如何在内存级别上工作?

Posted

技术标签:

【中文标题】alloca() 如何在内存级别上工作?【英文标题】:How does alloca() work on a memory level? 【发布时间】:2021-11-23 03:58:54 【问题描述】:

我试图弄清楚alloca() 在内存级别上的实际工作方式。来自linux man page:

alloca() 函数在栈中分配 size 个字节的空间 调用者的框架。这个临时空间会自动释放 当调用 alloca() 的函数返回给它的调用者时。

这是否意味着alloca() 将堆栈指针转发n 字节?或者新创建的内存具体分配在哪里?

这不是和variable length arrays一模一样吗?

我知道实现细节可能留给操作系统和其他东西。但我想知道一般这是如何实现的。

【问题讨论】:

你的理解很准确。 大多数情况下,它完全按照 linux 手册页的描述完成,是的,在这种情况下,堆栈指针减少了 n 个字节(或者由于各种原因可能比 n 多一点像内存对齐等)。是的,当你使用 VLA 时,或多或少会发生同样的事情 @Jabberwocky 请使用“自动 VLA” 术语 如果有人愿意,可能值得展开更详细的解释来说明它是如何实现的(我不确定我是否会解释得很好)。在快速测试中,看起来 gcc 内联了alloca() 的效果,这是有道理的——编译器必须知道堆栈帧已经改变——但它似乎使用了一些与线程本地存储相同的机制,例如使用%fs 寄存器。 @sj95126:您看到的%fs 很可能是stack canary;金丝雀值保存在线程本地存储中。它与 alloca 本身并没有真正的关系,所以-fno-stack-protector 可能会清理一下。 【参考方案1】:

是的,alloca 在功能上等同于局部可变长度数组,即:

int arr[n];

还有这个:

int *arr = alloca(n * sizeof(int));

两者都为堆栈上的int 类型的n 元素分配空间。 arr 在每种情况下的唯一区别是 1) 一个是实际数组,另一个是指向数组第一个元素的指针,以及 2) 数组的生命周期以其封闭范围结束,而 alloca当函数返回时,内存的生命周期结束。在这两种情况下,数组都驻留在堆栈中。

例如,给定以下代码:

#include <stdio.h>
#include <alloca.h>

void foo(int n)

    int a[n];
    int *b=alloca(n*sizeof(int));
    int c[n];
    printf("&a=%p, b=%p, &c=%p\n", (void *)a, (void *)b, (void *)c);


int main()

    foo(5);
    return 0;

当我运行它时,我得到:

&a=0x7ffc03af4370, b=0x7ffc03af4340, &c=0x7ffc03af4320

这表明从alloca 返回的内存位于两个 VLA 的内存之间。

VLA 首次出现在 C99 的 C 标准中,但 alloca 早在此之前就已经存在。 Linux 手册页指出:

符合

POSIX.1-2001 中没有此功能。

有证据表明alloca()函数出现在32V、PWB、 PWB.2、3BSD 和 4BSD。在 4.3BSD 中有一个手册页。 Linux 使用 GNU 版本。

BSD 3 可以追溯到 70 年代后期,因此 alloca 是在 VLA 被添加到标准之前的早期非标准化尝试。

今天,除非您使用不支持 VLA 的编译器(例如 MSVC),否则没有理由使用此功能,因为 VLA 现在是获得相同功能的标准化方法。

【讨论】:

不使用alloca的原因是因为它是非标准的,而VLA是。 VLA 不需要被 C11 和更新的标准支持(例如:它们不受 MSVC 支持) @UnholySheep,是的,但是这个可选功能完全失败了。支持 VLA 的编译器仍然支持它,那些不支持的编译器仍然不支持,而且符合 C 标准的价值只是被稀释了。 Alloca 在循环中的行为非常不同,它很容易耗尽堆栈。这是因为使用 alloca 获取的对象的生命周期在函数返回时结束。而 VLA 的生命周期在其包含块结束时结束。所以 VLA 更安全 @tstanisl 在某些情况下,在函数返回之前生存是首选 alloca 而不是 VLA 的原因,例如,如果您需要有条件地分配一些暂存空间。 【参考方案2】:

另一个answer 精确描述了VLA 和alloca() 的机制。

但是,alloca()自动 VLA 之间存在显着的功能差异。对象的生命周期。

如果是alloca(),则生命周期在函数返回时结束。 对于 VLA,对象在包含块结束时被释放。

char *a;
int n = 10;

  char A[n];
  a = A;

// a is no longer valid


  a = alloca(n);

// is still valid

因此,循环中的堆栈可以轻松耗尽,而 VLA 无法做到这一点。

for (...) 
  char *x = alloca(1000);
  // x is leaking with each iteration consuming stack

for (...) 
  int n = 1000;
  char x[n];
  // x is released

【讨论】:

这让我想知道如果你混合使用 alloca 和 VLA 会发生什么...... 我不确定“a 仍然有效”是否有效 :-) a 没有用,因为您可以(应该?)既不读取也不写入它的值,因为该内存在当前内存之外堆栈“尺寸”/“大小”,并受到下一个函数调用的破坏。一个体面的 CPU/OS 将(应该?)不允许访问“超出范围”的堆栈内存。 “泄漏”有点夸张。不像未释放的 malloc 那样真正的泄漏;因为假设您没有耗尽堆栈和故障而是继续执行,在下一次函数调用或返回时,堆栈指针被重置,后续函数调用、变量或 alloca() 将重用“泄漏”的内存。换句话说,它是在堆栈而不是堆上自动“释放”的。 至少在 linux 上的 alloca 文档特别说明它在函数调用返回时被释放,而不是在您退出块时。 @plugwash 这正是我在答案中写的【参考方案3】:

虽然从语法的角度来看,alloca 看起来像一个函数,但它不能在现代编程环境中作为普通函数实现*。它必须被视为具有类函数接口的编译器特性。

传统的 C 编译器维护两个指针寄存器,一个“堆栈指针”和一个“帧指针”(或基指针)。堆栈指针界定堆栈的当前范围。帧指针保存了函数入口时堆栈指针的值,用于访问局部变量并在函数退出时恢复堆栈指针。

现在大多数编译器在普通函数中默认不使用帧指针。现代调试/异常信息格式已经使它变得不那么简单,但他们仍然了解它是什么并且可以在需要的地方使用它。

特别是对于具有 alloca 或可变长度数组的函数,使用帧指针允许函数跟踪其堆栈帧的位置,同时动态修改堆栈指针以适应可变长度数组。

例如,我在 O1 为 arm 构建了以下代码

#include <alloca.h>
int bar(void * baz);
void foo(int a) 
    bar(alloca(a));

得到(我的cmets)

foo(int):
  push fp, lr     @ save existing link register and frame pointer
  add fp, sp, #4    @ establish frame pointer for this function
  add r0, r0, #7    @ add 7 to a ...
  bic r0, r0, #7    @ ... and clear the bottom 3 bits, thus rounding a up to the next multiple of 8 for stack alignment 
  sub sp, sp, r0    @ allocate the space on the stack
  mov r0, sp        @ make r0 point to the newly allocated space
  bl bar            @ call bar with the allocated space
  sub sp, fp, #4    @ restore stack pointer and frame pointer 
  pop fp, pc      @ restore frame pointer to value at function entry and return.

是的,alloca 和可变长度数组非常相似(尽管另一个答案指出不完全相同)。 alloca 似乎是两个构造函数中较老的一个。


* 使用足够愚蠢/可预测的编译器,可以将 alloca 实现为汇编器中的函数。特别是编译器需要。

始终为所有函数创建帧指针。 始终使用帧指针而不是堆栈指针来引用局部变量。 在为函数调用设置参数时,始终使用堆栈指针而不是帧指针。

这显然是它最初是如何实现的 (https://www.tuhs.org/cgi-bin/utree.pl?file=32V/usr/src/libc/sys/alloca.s)。

我想也有可能将实际实现作为汇编函数,但是在编译器中有一个特殊情况,当它看到 alloca 时,它会进入哑/可预测模式,我不知道是否有任何编译器供应商做到了。

【讨论】:

“它不能作为普通函数实现” — 并非总是如此:反例请参见 this。【参考方案4】:

alloca 分配的内存在调用alloca 的函数返回时自动释放。也就是说,使用alloca 分配的内存是特定函数的“堆栈帧”或上下文的本地内存。

alloca 不能移植,并且很难在没有传统堆栈的机器上实现。当它的返回值被直接传递给另一个函数时,它的使用是有问题的(并且在基于堆栈的机器上的明显实现失败),如

fgets(alloca(100), 100, stdin)

如果您在不符合此描述的任何地方使用它,您就是在自找麻烦。如果您在这些地方使用alloca(),您可能会遇到麻烦,因为在调用alloca() 时堆栈上可能有一些东西:

在循环内。 在以局部变量开头的任何块中,函数的最外层块除外,尤其是在退出该块后使用分配的内存时。 在赋值左侧使用比指针变量更复杂的任何表达式,包括指针数组的一个元素。 其中 alloca() 的返回值用作函数参数。 在使用 = 运算符值的任何上下文中,例如

if ((pointer_variable = alloca(sizeof(struct something))) == NULL) ....

而且我希望有人会打电话给我,即使对于某些编译器生成的代码来说,这种高度限制性的限制还不够保守。现在,如果它是作为内置编译器完成的,您可能会设法解决这些问题。

一旦我终于弄明白了alloca() 函数,它就运行得相当好——我记得,它的主要用途是在Bison parser 中。每次调用浪费的 128 个字节加上固定的堆栈大小可能会令人讨厌。为什么我不直接使用GCC?因为这是尝试将最初使用交叉编译器的GCC 移植到一台机器上,结果证明它几乎没有足够的内存来本地编译 GCC(1.35 左右)。当GCC 2 出现时,事实证明它的内存足够了,原生编译自己是不可能的。

【讨论】:

放三样东西会给你带来好处。 嘿亲爱的,如果我的回答不被接受或对所提出的问题没有帮助,你为什么要浪费你的时间。 如果对 alloca 的调用尝试在同一堆栈上为其他函数调用(在本例中为 fgets)的参数列表的准备过程中的堆栈分配一些内存,参数列表很可能会受到干扰。【参考方案5】:

allocaVLAs 之间最重要的区别是失败案例。以下代码:

int f(int n) 
    int array[n];
    return array == 0;

int g(int n) 
    int *array = alloca(n);
    return array == 0;

VLA 不可能检测到分配失败;这是强加于语言结构的非常un-C 的东西。因此,Alloca() 的设计要好得多。

【讨论】:

man alloca: 返回值 alloca() 函数返回一个指向已分配空间开头的指针。如果分配导致堆栈溢出,则程序行为未定义。 我的说法不一样:A pointer to the start of the allocated memory, or NULL if an error occurred (errno is set). 但也许这就是 RTOS 集中在 Dinkum 库而不是 gnu 上的部分原因。 或者alloca() 不是“设计得更好”,而是根本没有很好的设计(并且指定得很差)? 好吧,不。 VLA 没有提供错误恢复的机会; alloca() 可以。几乎是灌篮高手。当然,alloca 的一些玩具实现已经流行起来,但这并不妨碍良好的实现。与 VLA 不同,VLA 几乎就是标准的 du-jour 机构所说的弃用它。 VLA 不会像int A[10000000]; 那样提供恢复机会。 C 标准未定义任何自动对象分配的资源不足。如果您希望 VLA 具有动态存储,只需使用指向 VLA 和malloc() 的指针,甚至“安全”alloca()。最后。 VLA没有被弃用。它们是可选的,与复数、原子、线程、宽字符相同。请更新您的答案,即它仅适用于非常特定的 RTOS。

以上是关于alloca() 如何在内存级别上工作?的主要内容,如果未能解决你的问题,请参考以下文章

C alloca 函数 - 当试图分配太多内存时会发生啥

C:malloc/calloc/realloc/alloca内存分配函数

固定大小的数组与 alloca(或 VLA)

alloca() 可以替代 C++ 中的自动指针吗?

试图了解 x86 上 alloca() 函数的汇编实现

了解 GCC 的 alloca() 对齐和看似错过的优化