用调用栈在C语言中实现栈数据结构?

3
我对C语言中的内存结构的理解是,程序的内存被分为堆栈和堆两部分,每个部分都从块的两端增长,可以分配所有RAM,但显然抽象出某种OS内存碎片管理器。
堆栈用于处理局部变量(自动存储),堆用于内存分配(动态存储)。
(编辑注:在一些C实现中,自动存储不使用“调用堆栈”,但是此问题假定正常的现代C实现在正常的CPU上使用调用堆栈,如果不能仅使用寄存器,则局部变量将使用调用堆栈。)
假设我想要为某个数据解析算法实现一个栈数据结构。它的生命周期和范围仅限于一个函数。 我能想到三种方法来实现这样的事情,但是在这种情况下,似乎没有一种方式是最干净的。 我的第一个想法是在堆中构建一个栈,就像C++中的std::vector
Some algorithm(Some data)
{
  Label *stack = new_stack(stack_size_estimate(data));
  Iterator i = some_iterator(data);
  while(i)
  {
    Label label = some_label(some_iterator_at(i));
    if (label_type_a(label))
    {
      push_stack(stack,label);
    }
    else if(label_type_b(label))
    {
      some_process(&data,label,pop_stack(stack));
    }
    i = some_iterator_next(i);
  }
  some_stack_cleanup(&data,stack);
  delete_stack(stack);
  return data;
}

这种方法可以用,但它浪费了内存,因为栈大小是一个猜测值,并且在任何时候push_stack可能会调用某些内部malloc或realloc并导致不规则的减速。虽然这些问题对于此算法来说都不是问题,但这种构造似乎更适合于需要在多个上下文中维护堆栈的应用程序。这里并非如此; 堆栈对这个函数是私有的,在退出之前就像自动存储类一样被删除。
我接下来想到的是递归。由于递归使用了内置的栈,这似乎更接近我想要的结果。
Some algorithm(Some data)
{
  Iterator i = some_iterator(data);
  return some_extra(algorithm_helper(extra_from_some(data),&i);
}
Extra algorithm_helper(Extra thing, Iterator* i)
{
  if(!*i)
  {return thing;}
  {
    Label label = some_label(some_iterator_at(i));
    if (label_type_a(label))
    {
      *i = some_iterator_next(*i);
      return algorithm_helper
      (  extra_process( algorithm_helper(thing,i), label),  i  );
    }
    else if(label_type_b(label))
    {
      *i = some_iterator_next(*i);
      return extra_attach(thing,label);
    }
  }
}

这种方法使我免于编写和维护堆栈。对我来说,代码似乎更难理解,但这对我来说并不重要。

我的主要问题是这种方法使用的空间更多。
堆栈帧保存了这个Extra结构的副本(它基本上包含了Some data加上实际想要在堆栈中保存的位)和每个堆栈帧中都有不必要的迭代器指针的副本:因为它比引用某些静态全局变量更"安全"(我不知道如何不这样做)。如果编译器做了一些巧妙的尾递归之类的事情,这就不是一个问题了,但我不知道是否喜欢交叉手指,希望我的编译器很棒。


我能想到的第三种方法涉及一种动态数组,在堆栈上可以增长,并使用我不知道的某种C语言写。
或者是一个extern asm块。

想想看,这就是我要找的,但除非它非常简单且我认为在我的头脑中看起来更简单,否则我不会写一个汇编版本。显然,它不会跨越ISA移植。

我不知道我是否忽略了某些功能,或者我需要找另一种语言,或者我应该重新考虑我的人生选择。这都可能是真的...我希望只是第一个。

我不反对使用一些库。有没有这样的库,如果有,它是如何工作的?我在搜索中没有找到任何东西。


我最近了解了可变长度数组,我真的不明白为什么它们不能被利用作为增长堆栈引用的一种方式,但我也无法想象它们可以这样工作。


1
我承认我不清楚你的担忧是什么。我会选择动态分配堆栈(可能是第一种或第三种变体),在需要时调整大小(猜测通常需要多大的堆栈大小;为此分配足够的堆栈空间,或者可能是两倍大小;如果需要,稍后再增长)。实现简单的东西;测量性能是否真的是一个问题。当你知道简单解决方案中的瓶颈在哪里时,你就会了解如何最好地缓解瓶颈。我不会尝试内联堆栈;我会使用函数,可能是inline函数。 - Jonathan Leffler
2
如果您不知道您的堆栈需要多大,使用可变长度数组(VLA)技术可能不会有所帮助。 - Jonathan Leffler
3个回答

3

tl; dr: 使用std::vector或等效的数据结构。


(已编辑)

关于你的开场白: 分段的时代已经过去了。现在的进程有多个栈(每个线程一个),但它们都共享一个堆。

关于选项1: 不要编写和维护一个栈,也不要猜测它的大小,你应该直接使用std::vector,或者围绕它编写一个C包装器或C克隆版本——无论如何,使用“向量”数据结构即可。

Vector的算法通常是相当高效的。虽然不完美,但通常对许多真实世界的使用情况都很好。

关于选项2: 在C中,你是正确的,只要讨论局限在C范畴内。在C中,递归既浪费资源,也不具备可扩展性。在其他一些语言中,特别是函数式语言中,递归是表达这些算法的方法,并且尾递归优化是语言定义的一部分。

关于选项3: 最接近你要找的C东西是alloca()。它允许你增加堆栈帧,如果堆栈没有足够的内存,操作系统会分配它。然而,围绕它构建一个堆栈对象将非常困难,因为没有realloca(),正如@Peter Cordes所指出的那样。

另一个缺点是堆栈仍然有限。在Linux上,堆栈通常被限制为8 MB。这与递归具有相同的可扩展性限制。

关于可变长度数组: 可变长度数组基本上是一种语法糖,一种符号方便。除了语法之外,它们具有与数组完全相同的功能(实际上甚至更少,即sizeof()无法工作),更别提std::vector的动态能力。


大部分是正确的,但是alloca()不能让你扩展已有的alloca分配。没有reallocalloca版本。其中一个障碍是大多数系统上堆栈向下增长。我发布了一个答案,其中包含一个C实现,严重地滥用alloca和UB来保持向下(朝较低地址)增长堆栈数据结构,主要是为了展示在C中做什么是相当“自然”的汇编语言是多么邪恶。 - Peter Cordes
顺便说一句,如果你想在C中使用一个真正高效的std::vector,那么写一个自己的可以使用realloc的向量。不要实际使用C++ std::vector;它总是会复制而不尝试就地扩展分配,因为C++分配器很愚蠢,不支持realloc接口为什么C++分配器没有重新分配功能?。(虚拟内存意味着在大型分配之后通常会有空闲页面,允许增长而无需复制。) - Peter Cordes

2
并不是一个真正的答案,但对于简单的评论来说有点太长了。
实际上,关于堆和栈相互增长的形象化描述过于简单。在8086处理器系列(至少在一些内存模型中)中,堆和栈共享一个单独的内存段,这曾经是正确的,但即使是旧的Windows 3.1系统也带有一些API函数,允许在堆外分配内存(搜索GlobalAllocLocalAlloc的区别),只要处理器至少是80286。
但现代系统都使用虚拟内存。使用虚拟内存后,不再有一个漂亮的连续段被堆和栈共享,操作系统可以在任何地方提供内存(当然,前提是它能够找到空闲内存)。但栈仍然必须是一个连续的段。由于这个原因,在构建时确定栈的大小并且是固定的,而堆的大小仅受系统可为进程分配的最大内存限制。这就是为什么许多人建议仅将栈用于小数据结构并始终分配大数据结构的原因。此外,我不知道有哪种可移植的方法可以让程序知道它的堆栈大小,更不用说它的空闲堆栈大小了。
所以,在我看来,重要的是要考虑您的堆栈大小是否足够小。如果它超过了一个小限制,请使用分配的内存,因为它将更容易和更健壮。因为您可以(并且应该)测试分配错误,但堆栈溢出总是致命的。
最后,我的建议是不要尝试为自己的专用用途使用系统堆栈,即使仅限于一个单独的函数,除非您可以干净地请求堆栈中的内存数组并在其上构建自己的堆栈管理。使用汇编语言直接使用底层堆栈将增加很多复杂性(更不用说失去可移植性),而获得的收益微不足道。甚至如果您想使用汇编语言指令对堆栈进行低级优化,我的建议是使用专用内存段并将系统堆栈留给编译器。
我的经验表明,您在代码中增加的复杂性越多,维护起来就越困难,也越不健壮。
因此,请遵循最佳实践,并仅在无法避免时才使用低级优化。

2
实际上,如果你不能对可能小于1kiB的大小设置硬上限,通常应该动态分配。如果你能确定大小很小,你可以考虑使用alloca作为堆栈的容器。
(你不能有条件地使用VLA,它必须在作用域内。虽然你可以通过在if()之后声明大小为零的VLA,并将指针变量设置为VLA地址或malloc来实现。但是使用alloca会更容易。)
在C++中,通常使用std::vector,但这很愚蠢,因为它不能/不使用reallocstd::vector是否必须在增加容量时移动对象?或者说,分配器可以“重新分配”吗?)。因此,在C++中,这是更高效的增长与重复发明轮子之间的权衡,尽管仍然是摊销O(1)时间。你可以通过一个相当大的reserve()来减轻大部分问题,因为你分配但从未使用的内存通常不会产生成本。
在 C 语言中,你必须自己编写栈,并且可以使用 realloc 函数。此外,所有的 C 类型都是可平凡复制的,因此没有什么能阻止你使用 realloc。因此,当你需要增长时,可以重新分配存储空间。但如果无法对函数入口设置合理且足够大的上限,并且可能需要增长,则仍应像 std::vector 一样单独跟踪容量和使用大小,不要在每次 push/pop 时调用 realloc
直接使用调用栈作为堆栈数据结构在纯汇编语言中很容易(对于使用调用栈的ISA和ABI,即像x86、ARM、MIPS等“正常”的CPU)。而且,在汇编语言中值得做的是对于你知道将非常小且不值得malloc/free开销的堆栈数据结构。使用asm push或pop指令(或等效序列,针对没有单指令push/pop的ISA)。您甚至可以通过与保存的堆栈指针值进行比较来检查大小/查看堆栈数据结构是否为空。 (或只需在推入/弹出旁边维护整数计数器)。
一个非常简单的例子是一些人编写int->string函数的低效方式。对于像10这样的非2幂基数,您通过除以10来逐个删除数字,以最不重要的顺序生成数字,并将其存储到缓冲区中并递减指针,但有些人编写函数,在除法循环中使用push,然后在第二个循环中使用pop以按打印顺序(最重要的先)获取它们。例如,在How do I print an integer in Assembly Level Programming without printf from the c library?上的Ira的答案。(我在同一问题上的答案显示了高效的方法,一旦你理解了它,也更简单。) 堆栈向堆增长并不特别重要,只要有一些可用空间即可。而且堆栈内存已经映射,并且通常在高速缓存中热。这就是为什么我们可能想要使用它的原因。
在GNU/Linux中,堆栈在堆之上是真实存在的,例如通常将主线程的用户空间堆栈放置在用户空间虚拟地址空间的顶部(例如0x7fff...)。通常有一个堆栈增长限制,该限制比堆栈到堆的距离小得多。您希望意外的无限递归尽早出错,例如在使用8MiB堆栈空间后,而不是在使用几GB堆栈时驱动系统进行交换。根据操作系统的不同,您可以增加堆栈限制,例如使用ulimit -s。线程堆栈通常使用mmap进行分配,与其他动态分配相同,因此无法确定它们相对于其他动态分配的位置。
据我所知,即使使用内联汇编,在 C 语言中也无法实现这一点。(无论如何,不安全。下面的示例说明了您必须如何才能像汇编语言那样在 C 语言中编写此代码。它基本上证明了现代 C 语言不是可移植的汇编语言。)您不能只是将 pushpop 包装在 GNU C 内联汇编语句中,因为没有办法告诉编译器您正在修改堆栈指针。在您的内联汇编语句更改堆栈指针后,它可能尝试相对于堆栈指针引用其他局部变量。
可能如果你知道可以安全地强制编译器为该函数创建一个框架指针(它将用于所有局部变量访问),那么你可以通过修改堆栈指针来解决问题。但如果你想进行函数调用,许多现代ABI要求在调用之前堆栈指针具有超对齐。例如,x86-64系统V在调用之前要求16字节的堆栈对齐,但push/pop以8字节为单位工作。另一方面,32位ARM(和一些32位x86调用约定,如Windows)没有这个特性,因此任意数量的4字节推入将使堆栈正确对齐以进行函数调用。

我不建议这样做;如果你想要那种级别的优化(并且你知道如何为目标CPU优化汇编代码),最好还是使用汇编语言编写整个函数。


可变长度数组(Variable Length Arrays,VLAs)并不可调整大小。在 C 语言中,一旦定义了 int VLA[n]; 这样的 VLA 数组,它的大小就无法更改。C 语言中没有任何方法可以保证在这个数组之后能够获得更多与之连续的内存空间。
在使用 alloca(size) 函数时也存在同样的问题。该函数是编译器内置的一个特殊函数,在“正常”实现中会将栈指针减去 size 字节(按照栈宽度向上取整),然后返回指向该内存区域的指针。实际上,可能连续多次调用 alloca 函数得到的内存空间是相邻的,但不能保证如此,因此在没有 UB(未定义行为)的保护下,无法安全地使用该函数。虽然,在某些实现中可能会暂时避免触发 UB,但未来的优化可能会假设您的代码无法被访问。
(这可能会在某些调用约定上出现问题,例如x86-64 System V,其中VLAs保证为16字节对齐。在那里使用8字节的alloca可能会向上舍入到16个字节。)
但是,如果您确实想使其工作,您可能会使用long *base_of_stack = alloca(sizeof(long));(最高地址:大多数ISA / ABI的堆栈向下增长 - 这是您必须做出的另一个假设)。
另一个问题是,没有办法释放alloca内存,除非离开函数范围。因此,您的pop必须增加一些top_of_stack C指针变量,而不是实际移动真正的架构“堆栈指针”寄存器。并且,push将必须查看top_of_stack是否在您单独维护的高水位标记之上或之下。如果是,您将alloca更多内存。
在这种情况下,最好一次性使用大于sizeof(long)的块进行alloca,这样正常情况下就不需要再分配更多的内存,只需移动C变量的栈顶指针。例如,可以使用128字节的块。这也解决了某些ABIs保持堆栈指针过度对齐的问题。并且它允许堆栈元素比推送/弹出宽度更窄,而不浪费填充空间。

这意味着我们最终需要更多的寄存器来复制体系结构堆栈指针(除了弹出时SP永远不会增加)。

请注意,这类似于std :: vector的push_back逻辑,其中您有一个分配大小和一个使用中的大小。不同之处在于,std :: vector在需要更多空间时总是进行复制(因为实现甚至没有尝试realloc),因此必须通过指数增长来摊销。当我们知道增长是O(1)时,只需移动堆栈指针即可,我们可以使用固定的增量。例如128字节,或者也许半页更有意义。我们不会立即触及分配底部的内存;我还没有尝试为需要堆栈探测以确保不在未经干预的页面上将RSP移动超过1页的目标编译此代码。 MSVC可能会为此插入堆栈探测。
hack up alloca stack-on-the-callstack:充满了UB和实际上与gcc / clang不兼容
这主要是为了展示它有多么邪恶,以及C不是一种可移植的汇编语言。在汇编中,你可以做一些在C中无法做到的事情。(还包括高效地从函数中返回多个值,以不同的寄存器而不是愚蠢的结构体。)
#include <alloca.h>
#include <stdlib.h>

void some_func(char);

// assumptions:
//   stack grows down
//   alloca is contiguous
//   all the UB manages to work like portable assembly language.

// input assumptions: no mismatched { and }

// made up useless algorithm: if('}') total += distance to matching '{'
size_t brace_distance(const char *data)
{
  size_t total_distance = 0;
  volatile unsigned hidden_from_optimizer = 1;
  void *stack_base = alloca(hidden_from_optimizer);      // highest address. top == this means empty
             // alloca(1) would probably be optimized to just another local var, not necessarily at the bottom of the stack frame.  Like  char foo[1]
  static const int growth_chunk = 128;
  size_t *stack_top = stack_base;
  size_t *high_water = alloca(growth_chunk);

  for (size_t pos = 0; data[pos] != '\0' ; pos++) {
    some_func(data[pos]);
    if (data[pos] == '{') {
        //push_stack(stack, pos);
        stack_top--;
        if (stack_top < high_water)      // UB: optimized away by clang; never allocs more space
            high_water = alloca(growth_chunk);
        // assert(high_water < stack_top && "stack growth happened somewhere else");
        *stack_top = pos;
    }
    else if(data[pos] == '}')
    {
        //total_distance += pop_stack(stack);
        size_t popped = *stack_top;
        stack_top++;
        total_distance += pos - popped;
        // assert(stack_top <= stack_base)
    }
  }

  return total_distance;
}

惊人的是,这似乎实际上可以编译成正确的汇编代码(on Godbolt),对于x86-64来说使用gcc -O1(但在更高的优化级别下不行)。clang -O1gcc -O3会优化掉if(top<high_water) alloca(128)指针比较,因此在实践中无法使用。
"< 指针比较来自不同对象的指针是未定义行为,即使转换为 uintptr_t 也不能使其安全。或者可能是GCC根据从未引用过 high_water = alloca() 这一事实进行了优化,因此在循环内部没有 alloca(128)https://godbolt.org/z/ZHULrK 显示了 gcc -O3 输出,其中循环内部没有 alloca。有趣的是,将 volatile int growth_chunk 设为易失性以隐藏优化器中的常量值会使其不被优化掉。因此,我不确定指针比较 UB 是否导致问题,更像是访问第一个 alloca 下面的内存而不是解引用从第二个 alloca 派生的指针,这使得编译器将其优化掉。"
# gcc9.2 -O1 -Wall -Wextra
# note that -O1 doesn't include some loop and peephole optimizations, e.g. no xor-zeroing
# but it's still readable, not like -O1 spilling every var to the stack between statements.

brace_distance:
        push    rbp
        mov     rbp, rsp      # make a stack frame
        push    r15
        push    r14
        push    r13           # save some call-preserved regs for locals
        push    r12           # that will survive across the function call
        push    rbx
        sub     rsp, 24
        mov     r12, rdi
        mov     DWORD PTR [rbp-52], 1
        mov     eax, DWORD PTR [rbp-52]
        mov     eax, eax
        add     rax, 23
        shr     rax, 4
        sal     rax, 4              # some insane alloca rounding?  Why not AND?
        sub     rsp, rax            # alloca(1) moves the stack pointer, RSP, by whatever it rounded up to
        lea     r13, [rsp+15]
        and     r13, -16            # stack_base = 16-byte aligned pointer into that allocation.
        sub     rsp, 144            # alloca(128) reserves 144 bytes?  Ok.
        lea     r14, [rsp+15]
        and     r14, -16            # and the actual C allocation rounds to %16

        movzx   edi, BYTE PTR [rdi]  # data[0] check before first iteration
        test    dil, dil
        je      .L7                  # if (empty string) goto return 0

        mov     ebx, 0               # pos = 0
        mov     r15d, 0              # total_distance = 0
        jmp     .L6
.L10:
        lea     rax, [r13-8]         # tmp_top = top-1
        cmp     rax, r14
        jnb     .L4                  # if(tmp_top < high_water)
        sub     rsp, 144
        lea     r14, [rsp+15]
        and     r14, -16             # high_water = alloca(128) if body
.L4:
        mov     QWORD PTR [r13-8], rbx   # push(pos) - the actual store
        mov     r13, rax             # top = tmp_top completes the --top
            # yes this is clunky, hopefully with more optimization gcc would have just done
            # sub r13, 8  and used [r13] instead of this RAX tmp
.L5:
        add     rbx, 1      # loop condition stuff
        movzx   edi, BYTE PTR [r12+rbx]
        test    dil, dil
        je      .L1
.L6:                        # top of loop body proper, with 8-bit DIL = the non-zero character
        movsx   edi, dil               # unofficial part of the calling convention: sign-extend narrow args
        call    some_func              # some_func(data[pos]
        movzx   eax, BYTE PTR [r12+rbx]   # load data[pos]
        cmp     al, 123                   # compare against braces
        je      .L10
        cmp     al, 125
        jne     .L5                    # goto loop condition check if nothing special
                         # else: it was a '}'
        mov     rax, QWORD PTR [r13+0]
        add     r13, 8                  # stack_top++ (8 bytes)
        add     r15, rbx                # total += pos
        sub     r15, rax                # total -= popped value
        jmp     .L5                     # goto loop condition.
.L7:
        mov     r15d, 0
.L1:
        mov     rax, r15                # return total_distance
        lea     rsp, [rbp-40]           # restore stack pointer to point at saved regs
        pop     rbx                     # standard epilogue
        pop     r12
        pop     r13
        pop     r14
        pop     r15
        pop     rbp
        ret

这就像你为动态分配的堆栈数据结构所做的一样,除了:
  • 它向下增长,就像调用堆栈一样
  • 我们从alloca而不是realloc中获取更多内存。(realloc如果在分配后有空闲的虚拟地址空间也可能很高效)。C ++选择不为其分配器提供realloc接口,因此std::vector总是愚蠢地在需要更多内存时进行分配和复制。(据我所知,没有实现针对new未被覆盖且使用私有realloc的情况进行优化)。
  • 它完全不安全,充满未定义行为,并且在现代优化编译器中无法正常工作
  • 页面永远不会返回到操作系统:如果您使用大量堆栈空间,则这些页面将永久保持脏状态。

如果你能选择一个足够大的大小,你可以使用该大小的VLA。我建议从顶部开始向下移动,以避免触及调用堆栈当前正在使用区域远下方的内存。这样,在不需要“堆栈探测”以增加堆栈超过1页的操作系统上,你可能会避免接触到堆栈指针远下方的内存。因此,你实际使用的少量内存可能全部位于调用堆栈的已映射页面内,甚至是最近某个更深层次的函数调用已经使用过的缓存行内。
如果您使用堆,可以通过进行相当大的分配来最小化realloc成本。 除非存在一个在free-list上的块可以通过较小的分配获得,在一般情况下过度分配如果您从未触及不需要的部分,则成本非常低,特别是如果您在进行任何其他分配之前释放或缩小它。
即不要对其使用memset。 如果您想要零内存,请使用calloc,它可以为您从操作系统获取零页。
现代操作系统对于分配使用延迟虚拟内存,因此第一次触摸页面时,通常必须出现页面错误并实际连接到HW页面表中。此外,物理内存页面必须被清零以支持此虚拟页面。(除非访问是读取,然后Linux将将页面复制到共享的零物理页面上。)
您甚至从未触摸的虚拟页面仅是内核中的一个范围簿记数据结构的更大尺寸。 (以及用户空间malloc分配器)。这不会增加分配它的成本,也不会增加释放它或使用您触及的早期页面的成本。

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