使用callstack在C中实现栈数据结构?
Posted
技术标签:
【中文标题】使用callstack在C中实现栈数据结构?【英文标题】:Using the callstack to implement a stack data structure in C? 【发布时间】:2020-04-20 21:31:42 【问题描述】:我对 C 下的内存结构的理解是程序的内存与堆栈和堆分开,每个从块的两端增长,可以想象分配所有 ram,但显然抽象为某种 OS 内存片段管理器。 堆栈设计用于处理局部变量(自动存储)和堆用于内存分配(动态存储)。
(编者注:在某些 C 实现中,自动存储不使用“调用堆栈”,但这个问题假设在普通 CPU 上进行正常的现代 C 实现,如果本地人不能只是生活,他们会使用调用堆栈在寄存器中。)
假设我想为一些数据解析算法实现一个堆栈数据结构。它的生命周期和范围仅限于一个函数。
我可以想到 3 种方法来做这样的事情,但在我看来,考虑到这种情况,它们都不是最干净的方法。
我的第一个想法是在堆中构造一个堆栈,例如 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 语言编写的最后一件事。
或外部asm
块。
考虑到这一点,这就是我正在寻找的东西,但我看不到自己在编写 asm 版本,除非它非常简单,而且我不认为它更容易编写或维护,尽管它在我的脑海中看起来更简单.显然它不能跨 ISA 移植。
我不知道我是否忽略了某些功能,或者我是否需要寻找另一种语言,或者我是否应该重新考虑我的生活选择。一切都可能是真的……我希望这只是第一个。
我不反对使用某些库。有没有,如果有,它是如何工作的?我在搜索中没有找到任何东西。
我最近了解了可变长度数组,但我真的不明白为什么不能将它们用作增加堆栈引用的一种方式,但我也无法想象它们会以这种方式工作。
【问题讨论】:
我承认我不清楚您的担忧是什么。我会使用动态分配的堆栈(可能是第一个或第三个变体),在必要时调整大小(猜测您通常需要多大的堆栈大小;为此分配足够的堆栈空间,或者可能是该大小的两倍; 如有必要,稍后增长。实现一些简单的东西;衡量性能是否真的是一个问题。当您知道简单解决方案中的瓶颈在哪里时,您将获得有关如何最好地缓解瓶颈的信息。我不会尝试内联堆栈; 我会使用函数,可能是inline
的。
如果您不知道堆栈需要多大,使用 VLA(可变长度数组)技术不太可能有帮助。
【参考方案1】:
不是一个真正的答案,只是评论有点太长了。
事实上,堆栈和堆的形象以及相互向对方增长的形象过于简单化了。在 8086 处理器系列(至少在某些内存模型中)曾经是这样,堆栈和堆共享一个内存段,但即使是旧的 Windows 3.1 系统也带有一些 API 函数,允许在堆(搜索 GlobalAlloc
,与 LocalAlloc
相反),前提是处理器至少为 80286。
但是现代系统都使用虚拟内存。有了虚拟内存,堆和栈不再共享一个连续的段,操作系统可以在任何地方提供内存(当然前提是它可以在某处找到空闲内存)。但是堆栈仍然必须是一个连续的段。因此,该段的大小在构建时确定并且是固定的,而堆的大小仅受系统可以分配给进程的最大内存限制。这就是为什么许多人建议仅将堆栈用于小型数据结构并始终分配大型数据结构的原因。此外,我不知道程序没有可移植的方式来知道它的堆栈大小,更不用说它的空闲堆栈大小了。
所以恕我直言,这里重要的是想知道您的堆栈大小是否足够小。如果它可以超过一个小的限制,那就去分配内存,因为它会更容易和更健壮。因为您可以(并且应该)测试分配错误,但堆栈溢出总是致命的。
最后,我的建议是不要尝试将 system 堆栈用于您自己的专用用途,即使仅限于一个功能,除非您可以干净地请求堆栈中的内存数组并在其上构建您自己的堆栈管理。使用汇编语言直接使用底层堆栈会增加很多复杂性(而不是失去可移植性),以获得假设的最小收益。只是不要。即使您想使用汇编语言指令对堆栈进行低级优化,我的建议是使用专用内存段并将系统堆栈留给编译器。
我的经验表明,代码越复杂,维护起来就越困难,而且健壮性越差。
因此,只需遵循最佳实践,仅在无法避免的情况和地点使用低级优化。
【讨论】:
【参考方案2】:tl;博士:使用std::vector
或等效项。
(已编辑)
关于您的开场白:分段的日子已经结束。现在的进程有多个堆栈(每个线程一个),但都共享一个堆。
关于选项 1:您应该直接使用 std::vector
或围绕它的 C 包装器或它的 C 克隆,而不是编写和维护堆栈并猜测其大小 -无论如何,请使用“向量”数据结构。
Vector的算法一般是quite efficient。不完美,但通常适用于许多现实世界的用例。
关于选项 2:您是对的,至少只要讨论仅限于 C。在 C 中,递归既浪费又不可扩展。在其他一些语言中,特别是在函数式语言中,递归是表达这些算法的方式,尾调用优化是语言定义的一部分。
关于选项 3:与您要查找的 C 最接近的是 alloca()。它允许您增加堆栈帧,如果堆栈没有足够的内存,操作系统将分配它。然而,围绕它构建一个堆栈对象将非常困难,因为没有@Peter Cordes 指出的realloca()
。
另一个缺点是堆栈仍然有限。在 Linux 上,堆栈通常限制为 8 MB。这与递归的可伸缩性限制相同。
关于可变长度数组:VLA 基本上是语法糖,一种符号方便。除了语法之外,它们还具有与数组完全相同的功能(实际上,甚至更少,即sizeof()
不起作用),更不用说std::vector
的动态功能了。
【讨论】:
大多数情况下是的,但不是,alloca()
不允许您增加现有的alloca
分配。没有alloca
版本的realloc
。障碍之一是堆栈在大多数系统上向下增长。我发布了 an answer 的 C 实现,它大量 滥用 alloca
和 UB 以保持堆栈数据结构向下(朝向较低地址)增长,主要是为了显示在 C 中做的事情有多么邪恶在 asm 中什么是相当“自然”的。
顺便说一句,如果你想要一个真正高效的 C 语言 std::vector
,请编写你自己的可以使用 realloc
的代码。实际上不要使用 C++ std::vector;它总是复制而不尝试就地扩展分配,因为 C++ 分配器是愚蠢的并且不支持 realloc 接口Why is there no reallocation functionality in C++ allocators?。 (虚拟内存意味着在大分配后有空闲页面是很常见的,允许增长而无需复制。)【参考方案3】:
在实践中,如果您无法将可能大小设置为小于 1kiB 左右的硬上限,则通常应该只动态分配。如果您可以确定大小是那么小,您可以考虑使用alloca
作为您的堆栈的容器。
(您不能有条件地使用 VLA,它必须在范围内。尽管您可以通过在 if()
之后声明它来使其大小为零,并将指针变量设置为 VLA 地址,或者malloc
。但是 alloca 会更容易。)
在 C++ 中,您通常会使用 std::vector
,但它很愚蠢,因为它不能/不使用 realloc
(Does std::vector *have* to move objects when growing capacity? Or, can allocators "reallocate"?)。因此,在 C++ 中,这是更有效的增长与重新发明***之间的权衡,尽管它仍然是 O(1) 时间的摊销。您可以通过预先设置相当大的reserve()
来缓解大部分问题,因为您分配但从未接触过的内存通常不会花费任何费用。
无论如何,在 C 语言中,您都必须编写自己的堆栈,realloc
可用。 (并且所有 C 类型都可以轻松复制,因此没有什么能阻止您使用 realloc)。因此,当您确实需要增长时,您可以重新分配存储空间。但是,如果您无法在函数入口上设置一个合理且绝对足够大的上限并且可能需要增长,那么您仍然应该分别跟踪容量与使用中的大小,例如 std::vector。不要在每次推送/弹出时都调用realloc
。
在纯汇编语言中直接将调用堆栈用作堆栈数据结构很容易(对于使用调用堆栈的 ISA 和 ABI,即 x86、ARM 等“普通”CPU 、MIPS 等)。 是的,在 asm 中你知道的堆栈数据结构值得做的事情将非常小,不值得malloc
/ free
的开销。
使用 asm push
或 pop
指令(或没有单指令推送/弹出的 ISA 的等效序列)。您甚至可以通过与保存的堆栈指针值进行比较来检查堆栈数据结构的大小/是否为空。 (或者只是在您的推送/弹出旁边维护一个整数计数器)。
一个非常简单的例子是一些人编写 int->string 函数的低效方式。对于像 10 这样的非 2 次方基数,您可以通过除以 10 以一次删除一个数字来生成最低有效一阶数字,其中数字 = 余数。您可以只将指针存储到缓冲区中并递减一个指针,但有些人在除法循环中编写push
的函数,然后在第二个循环中编写pop
以使它们按打印顺序排列(最重要的在前)。例如Ira 在How do I print an integer in Assembly Level Programming without printf from the c library? 上的回答(我在同一个问题上的回答显示了有效的方法,一旦你了解它也更简单。)
堆栈向堆增长并不特别重要,只是有一些空间可以使用。并且堆栈内存已经映射,并且通常在缓存中是热的。这就是我们可能想要使用它的原因。
例如,在 GNU/Linux 下,堆上堆栈恰好是真的,这通常将主线程的用户空间堆栈放在用户空间虚拟地址空间的顶部附近。 (例如0x7fff...
)通常有一个堆栈增长限制远小于堆栈到堆的距离。您希望意外的无限递归及早出现故障,例如在消耗 8MiB 的堆栈空间之后,而不是驱动系统进行交换,因为它使用了千兆字节的堆栈。根据操作系统,您可以增加堆栈限制,例如ulimit -s
。并且线程堆栈通常使用mmap
分配,与其他动态分配相同,因此无法确定它们相对于其他动态分配的位置。
AFAIK 在 C 中是不可能的,即使使用内联 asm
(无论如何,这并不安全。下面的示例显示了您必须像在 asm 中那样在 C 中编写它是多么邪恶。它基本上证明了现代 C 不是可移植的汇编语言。)
您不能只在 GNU C 内联 asm 语句中包装 push
和 pop
,因为无法告诉编译器您正在修改堆栈指针。在您的内联 asm 语句更改它之后,它可能会尝试引用相对于堆栈指针的其他局部变量。
如果您知道可以安全地强制编译器为该函数创建一个帧指针(它将用于所有局部变量访问),您就可以摆脱修改堆栈指针。但是如果你想进行函数调用,许多现代 ABI 要求堆栈指针在调用之前过度对齐。例如x86-64 System V 要求在 call
之前进行 16 字节堆栈对齐,但 push
/pop
以 8 字节为单位工作。 OTOH,32 位 ARM(以及一些 32 位 x86 调用约定,例如 Windows)没有该功能,因此任何数量的 4 字节推送都会使堆栈正确对齐以进行函数调用。
不过,我不会推荐它;如果您想要这种级别的优化(并且您知道如何针对目标 CPU 优化 asm),那么在 asm 中编写整个函数可能更安全。
可变长度数组,我真的不明白为什么不能利用它们来增加堆栈引用
VLA 不可调整大小。在您执行int VLA[n];
之后,您将无法调整大小。您在 C 中所做的任何事情都无法保证您拥有更多与该数组相邻的内存。
alloca(size)
也有同样的问题。这是一个特殊的编译器内置函数(在“正常”实现中)将堆栈指针递减size
字节(四舍五入为堆栈宽度的倍数)并返回该指针。 实际上,您可以进行多次alloca
调用,而且它们很可能是连续的,但是对于这一点的保证为零,因此如果没有 UB,您将无法安全使用它。不过,您可能在某些实现上侥幸逃脱,至少现在是这样,直到未来的优化注意到 UB 并假设您的代码无法访问。
(并且它可能会破坏某些调用约定,例如 x86-64 System V,其中 VLA 保证是 16 字节对齐的。一个 8 字节的 alloca
可能会四舍五入到 16。)
但是,如果您确实想完成这项工作,您可能会使用long *base_of_stack = alloca(sizeof(long));
(最高地址:堆栈在大多数但不是所有 ISA / ABI 上向下增长 - 这是您的另一个假设必须做)。
另一个问题是没有办法释放alloca
内存,除非离开函数范围。因此,您的 pop
必须增加一些 top_of_stack C 指针变量,而不是实际移动真正的体系结构“堆栈指针”寄存器。 push
必须查看 top_of_stack
是否高于或低于您也单独维护的高水位线。如果是这样,你 alloca
多一些内存。
此时您最好将alloca
放在大于sizeof(long)
的块中,因此通常情况下您不需要分配更多内存,只需移动C 变量栈顶指针即可。例如可能是 128 字节的块。这也解决了一些 ABI 保持堆栈指针过度对齐的问题。并且它可以让堆栈元素比推入/弹出宽度更窄,而不会浪费填充空间。
这确实意味着我们最终需要更多的寄存器来复制架构堆栈指针(除了 SP 永远不会在弹出时增加)。
请注意,这类似于std::vector
的push_back
逻辑,其中您有一个分配大小和一个正在使用的大小。不同之处在于std::vector
总是在需要时复制更多的空间(因为实现甚至无法尝试realloc
)所以它必须通过指数增长来摊销。当我们通过移动堆栈指针知道增长是 O(1) 时,我们可以使用固定增量。像 128 字节,或者半页会更有意义。我们不会立即触及分配底部的内存;我还没有尝试为需要堆栈探针的目标编译它,以确保您在不接触中间页面的情况下不会将 RSP 移动超过 1 页。 MSVC 可能会为此插入堆栈探测器。
Hacked alloca stack-on-the-callstack:充满了 UB 并且在实践中使用 gcc/clang 编译错误
这主要是为了表明它是多么邪恶,并且 C 不是 一种可移植的汇编语言。有些事情你可以用 asm 做你不能做在 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;
令人惊讶的是,这似乎实际上编译为看起来正确的 asm (on Godbolt),gcc -O1
用于 x86-64(但不是在更高的优化级别)。 clang -O1
和 gcc -O3
优化了 if(top<high_water) alloca(128)
指针比较,因此这在实践中是不可用的。
<
pointer comparison of pointers derived from different objects is UB,而且看起来即使转换为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
在需要更多内存时总是愚蠢地分配+副本。 (AFAIK 没有针对 new
未被覆盖并使用私有 realloc 的情况进行优化)。
它完全不安全且充满了 UB,并且在现代优化编译器的实践中失败
这些页面永远不会返回给操作系统:如果您使用大量堆栈空间,这些页面将无限期地保持脏状态。
如果您可以选择绝对足够大的尺寸,则可以使用该尺寸的 VLA。
我建议从顶部开始向下移动,以避免触及远低于调用堆栈当前使用区域的内存。这样,在不需要“堆栈探测”来将堆栈增长超过 1 页的操作系统上,您可能会避免接触远低于堆栈指针的内存。因此,您在实践中最终使用的少量内存可能都在调用堆栈的已映射页面内,如果最近的一些更深层次的函数调用已经使用它们,甚至可能缓存已经很热的行。
如果您确实使用堆,则可以通过进行相当大的分配来最小化重新分配的成本。除非空闲列表中有一个块,您可以通过较小的分配获得,一般来说,如果您从不接触不需要的部分,过度分配的成本非常低,尤其是如果您在进行更多分配之前释放或缩小它。
即不要memset
它对任何东西。如果您想要归零内存,请使用calloc
,它可能会为您从操作系统获取归零页面。
现代操作系统使用惰性虚拟内存进行分配,因此当您第一次触摸页面时,它通常会发生页面错误并实际连接到硬件页表中。此外,必须将物理内存页面归零以支持此虚拟页面。 (除非访问是读取,否则 Linux 会在写入时复制将页面映射到共享的零物理页面。)
在内核中的范围簿记数据结构中,您甚至从未接触过的虚拟页面将只是更大的尺寸。 (并且在用户空间malloc
分配器中)。这不会增加分配它、释放它或使用您接触过的较早页面的成本。
【讨论】:
以上是关于使用callstack在C中实现栈数据结构?的主要内容,如果未能解决你的问题,请参考以下文章