【问题标题】:How can I emulate a stack frame in C++?如何在 C++ 中模拟堆栈帧?
【发布时间】:2018-08-26 21:10:08
【问题描述】:

我正在编写一个容器,它在内部使用alloca 在堆栈上分配数据。 Risks of using alloca aside,假设我必须将它用于我所在的领域(部分是围绕 alloca 的学习练习,部分是为了研究动态大小的堆栈分配容器的可能实现)。

根据man page for alloca(强调我的):

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

使用特定于实现的功能,我设法强制内联,使调用者堆栈用于此函数级“范围”。

但是,这意味着以下代码将在堆栈上分配大量内存(编译器优化除外):

for(auto iteration : range(0, 10000)) {
    // the ctor parameter is the number of
    // instances of T to allocate on the stack,
    // it's not normally known at compile-time
    my_container<T> instance(32);
}

在不知道这个容器的实现细节的情况下,人们可能会期望当instance 超出范围时它分配的任何内存都会被释放。情况并非如此,并且可能导致在封闭函数期间出现堆栈溢出/高内存使用。

想到的一种方法是显式释放析构函数中的内存。由于没有对生成的程序集进行逆向工程,我还没有找到一种方法(另请参阅this)。

我想到的唯一其他方法是在编译时指定最大大小,使用它来分配固定大小的缓冲区,在运行时指定实际大小并在内部使用固定大小的缓冲区。这样做的问题是它可能非常浪费(假设每个容器的最大值为 256 个字节,但大多数时候只需要 32 个字节)。

因此提出了这个问题;我想找到一种方法来为这个容器的用户提供这些范围语义。不可移植是好的,只要它在其目标平台上是可靠的(例如,一些仅适用于 x86_64 的文档化编译器扩展就可以了)。

我很欣赏这可能是XY problem,所以让我清楚地重申我的目标:

  • 我正在编写一个容器,该容器必须始终在堆栈上分配其内存(据我所知,这排除了 C VLA)。
  • 容器的大小在编译时是未知的。
  • 我希望保持内存的语义,就好像它由容器内的 std::unique_ptr 持有一样。
  • 虽然容器必须具有 C++ API,但使用 C 中的编译器扩展也可以。
  • 代码现在只需要在 x86_64 上运行。
  • 目标操作系统可以是基于 Linux 的操作系统,也可以是基于 Windows 的操作系统,无需同时在两者上运行。

【问题讨论】:

  • 语句“必须始终在堆栈上分配其内存的容器”不会计算,就 C++ 而言。容器本身可以分配在堆栈(自动作用域)或堆(动态作用域)上,这完全由任何实例化容器的对象控制。但是容器本身对此绝对没有影响,无论如何。也许您在问如何声明一个只能在自动范围内声明的类。这不能在 C++ 中完成。
  • 你可以写一个基于alloca的分配器,而不是像你通常用malloc那样做的sbrk
  • 函数返回时释放堆栈上分配的空间。既然这不是你想要的,那你为什么确定要在栈上分配空间呢?
  • @SamVarshavchik:就 C++ 而言,容器可以分配在一堆 20 英镑的钞票上。
  • @LightnessRacesinOrbit 我喜欢那个声音

标签: c++ x86-64 alloca stack-allocation


【解决方案1】:

我正在编写一个必须始终在堆栈上分配其内存的容器(据我所知,这排除了 C VLA)。

大多数编译器中 C VLA 的正常实现是在堆栈上。当然,ISO C++ 没有说明如何自动存储是在底层实现的,但它(几乎?)对于普通机器(确实有一个调用+数据堆栈)上的 C 实现是通用的将其用于所有自动存储,包括 VLA。

如果您的 VLA 太大,您会遇到堆栈溢出,而不是退回到 malloc / free

C 和 C++ 都没有指定alloca;它仅适用于具有类似于“普通”机器的堆栈的实现,即您可以期望 VLA 执行您想要的操作的同一台机器。

所有这些条件都适用于 x86-64 上的所有主要编译器(除了 MSVC 不支持 VLA)。


如果您的 C++ 编译器支持 C99 VLA(如 GNU C++),智能编译器可能会为具有循环范围的 VLA 重用相同的堆栈内存。


在编译时指定最大大小,用它来分配固定大小的缓冲区...浪费

对于您提到的特殊情况,您可以将固定大小的缓冲区作为对象的一部分(大小作为模板参数),如果它足够大,就使用它。如果没有,动态分配。也许使用指针成员指向内部或外部缓冲区,并在析构函数中使用标志来记住是否delete。 (当然,您需要避免在作为对象一部分的数组上使用delete。)

// optionally static_assert (! (internalsize & (internalsize-1), "internalsize not a power of 2")
// if you do anything that's easier with a power of 2 size
template <type T, size_t internalsize>
class my_container {
    T *data;
    T internaldata[internalsize];
    unsigned used_size;
    int allocated_size;   // intended for small containers: use int instead of size_t
    // bool needs_delete;     // negative allocated size means internal
}

allocated_size 只需要在它增长时进行检查,所以我将它设为signed int,这样我们就可以重载它而不需要额外的布尔成员。

通常一个容器使用 3 个指针而不是指针 + 2 个整数,但如果你不经常增长/收缩,那么我们会节省空间(在 x86-64 上,int 是 32 位,指针是 64 位),并允许这种重载。

增长到足以需要动态分配的容器应继续使用该空间,但随后缩小应继续使用动态空间,因此再次增长的成本更低,并且避免复制回内部存储。除非调用者使用函数释放未使用的多余存储,否则复制回来。

移动构造函数应该保持分配原样,但复制构造函数应该尽可能复制到内部缓冲区,而不是分配新的动态存储。

【讨论】:

  • 看起来我对 VLA 的理解不正确,正如您和 David C 指出的那样。我认为它会退回到堆分配。我只是在本地运行了一些测试,至少在我的系统上,它的行为与您描述的一样。对此有任何保证吗?
  • @OMGtechy:保证内存一旦超出范围就可以回收/重用?不,如果循环内的 VLA 相同,我只希望重用。编译器必须知道如何在-O0 上执行此操作,以便为包含常规数组和标量变量声明的循环编写合理的代码,VLA 也不例外。但是,如果您有 2 个不同的范围包含 2 个不同的 VLA(即使它们具有相同的大小),您可能会或可能不会发现编译器通过重用空间进行优化,并且可能只有 -O2。即使是if else的两边,也不能一程同时使用。
  • 你怎么能使用 VLA 来实现容器呢?当构造函数终止时,VLA 将超出范围。或者是否有 C++ 扩展允许 VLA 作为成员或类似的东西?
  • 我想您可以使用宏将您的 my_container 定义扩展为同一范围内的 VLA 本地帮助对象,并调用 my_container 构造函数,该构造函数接受指向 VLA 的指针并使用它作为存储。
  • @BeeOnRope:但是 OP 使用 alloca 执行此操作,在离开构造函数范围时也会释放它。 (我认为他们在问题中提到了宏。)答案的第一部分只是解释 VLA do 如何工作,而不是如何实际使用它们来解决这个问题。像这样的问题是我写答案的第二部分的原因,它提出了一种解决方法,可以为您提供普通的 C++ 对象,但避免为小尺寸动态分配。 (即在没有可变大小堆栈分配的情况下主要解决相同的问题)。
猜你喜欢
  • 2015-01-06
  • 1970-01-01
  • 1970-01-01
  • 2014-06-10
  • 1970-01-01
  • 2020-05-17
  • 2016-01-12
  • 1970-01-01
  • 2021-01-13
相关资源
最近更新 更多