在实践中,如果您无法将可能大小设置为小于 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 分配器中)。这不会增加分配它、释放它或使用您接触过的较早页面的成本。