当使用'push'或'sub' x86指令时,堆栈内存是如何分配的?

5

我已经浏览了一段时间,试图理解在执行例如下面这样的代码时,内存如何分配到堆栈中:

push rax

或者将堆栈指针移动以为子例程的本地变量分配空间:

sub rsp, X    ;Move stack pointer down by X bytes 

我理解的是,堆栈段在虚拟内存空间中是匿名的,即没有文件支持。
我还理解的是,内核实际上不会将匿名的虚拟内存段映射到物理内存,直到程序对该内存段进行操作,即写入数据。因此,在写入之前尝试读取该段可能会导致错误。
在第一个示例中,如果需要,内核将为物理内存分配一帧页面。在第二个示例中,我认为内核在程序实际写入堆栈段地址之前不会为其分配任何物理内存。
我的理解正确吗?
2个回答

13
是的,你的想法基本上是正确的。sub rsp, X有点像“懒惰”分配:内核只有在触碰新的RSP上方的内存,并且出现#PF页故障异常后才会执行任何操作,而不仅仅是修改寄存器。但是你仍然可以认为内存已经“分配”,也就是可安全使用。
尝试在写入之前读取该段不会导致错误。
不,读取不会导致错误。从未被写入过的匿名页面会被写时复制映射到一个/多个物理零页面,无论它们是否在BSS、堆栈或mmap(MAP_ANONYMOUS)中。
趣闻:在微基准测试中,确保为输入数组的每个页面编写每个页面,否则您实际上正在重复循环相同的物理4k或2M页面的零,并且将获得L1D高速缓存命中,即使您仍然会遇到TLB未命中(和软页错误)! gcc将优化malloc + memset(0)为 calloc ,但 std :: vector 实际上将写入所有内存,无论您是否希望如此。 memset 在全局数组上不会被优化掉,因此可以使用它。(或非零初始化数组将在数据段中备份。)
请注意,我将忽略映射与有线之间的差异,即访问是否会触发软/次要页错误以更新页表,或者它只是TLB缺失,硬件页表遍历将找到映射(到零页面)。
但是,RSP下面的堆栈内存可能根本没有映射,因此在不移动RSP的情况下触摸它可能会导致无效的页面错误,而不是“次要”页面错误来解决写时复制。

栈内存有一个有趣的特点:栈大小限制大约为8MB(ulimit -s),但在Linux中,进程的第一个线程的初始栈是特殊的。例如,我在一个hello-world(动态链接)可执行文件的_start中设置了断点,并查看了它的/proc/<PID>/smaps

7ffffffde000-7ffffffff000 rw-p 00000000 00:00 0                          [stack]
Size:                132 kB
Rss:                   8 kB
Pss:                   8 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         8 kB
Referenced:            8 kB
Anonymous:             8 kB
...

只有8kiB的堆栈被引用并由物理页面支持。这是预期的,因为动态链接器不使用大量堆栈。

只有132kiB的堆栈甚至映射到进程的虚拟地址空间。 但特殊魔法阻止mmap(NULL, ...)在堆栈可以增长到的8MiB虚拟地址空间内随机选择页面。

触及当前堆栈映射下方但在堆栈限制范围内的内存会导致内核增加堆栈映射(在页面故障处理程序中)。

(但仅在调整 rsp ; 仅在rsp下方128字节,因此ulimit -s unlimited不会使触摸rsp以下1GB的内存增长到那里,但如果您将rsp减少到那里,然后触摸内存,则会这样做。)

这仅适用于初始/主线程的堆栈。 pthreads 只使用 mmap(MAP_ANONYMOUS|MAP_STACK) 映射一个 8MiB 的块,它无法增长。(MAP_STACK 目前是无操作的。)因此,在分配后线程堆栈无法增长(除非在它们下面有空间手动使用 MAP_FIXED),并且不受 ulimit -s unlimited 的影响。
这种防止其他内容在堆栈增长区选择地址的魔法并不存在于mmap(MAP_GROWSDOWN),因此不要使用它来分配新线程堆栈。(否则,你可能会得到某些使用了新堆栈下面的虚拟地址空间的东西,使其无法增长)。只需分配完整的8MiB。另请参见进程虚拟地址空间中其他线程的堆栈位于何处?MAP_GROWSDOWN确实有按需增长的功能,mmap(2)手册页中有描述,但没有增长限制(除了接近现有映射),因此(根据手册页)它基于类似Windows使用的保护页面,而不是主线程的堆栈。
在Linux的主线程栈中,触及内存超出MAP_GROWSDOWN区域下面的多个页面可能会导致段错误,这与一些编译器不会生成堆栈“探测”有关。 堆栈“探测”是指在进行大型分配(例如局部数组或alloca)后,确保按顺序触摸每个4k页面的机制。因此,这也是MAP_GROWSDOWN不安全的另一个原因。

编译器对Windows发出的堆栈探测有所不同。

MAP_GROWSDOWN可能根本无法工作,详见@BeeOnRope's comment。由于映射靠近其他内容可能会导致堆栈冲突安全漏洞,因此从未很安全地用于任何内容。因此,请勿将MAP_GROWSDOWN用于任何内容。我保留提到Windows使用的守卫页机制的描述,因为了解Linux的主线程堆栈设计并不是唯一可能的方案是很有趣的。

1
Linux不使用警卫页来扩大堆栈(实际上,直到相对较近的时间,它甚至没有与堆栈相关的任何东西称为“警卫页”)。编译器无需“探测”堆栈,因此您可以跨越映射页面并触及靠近堆栈“末尾”的页面而不会出现问题(所有中介页面也都被映射了)。有趣的是,Windows确实按照您描述的方式工作:它有一个单一的[0]警卫页,并触及该页面将触发堆栈的扩展,并设置新的警卫页。 - BeeOnRope
2
彼得·科德斯。我进一步研究了一下,答案似乎是“这很复杂,但文档可能是错误的”。在我的计算机上,分配大量堆栈并深入跳转(即,更低的地址),跳过许多页面是可以正常工作的。这与我在内核源代码中的检查一致。在我的计算机上,MAP_GROWNDOWN 根本不起作用:当使用像这样的代码访问映射区域下方时,它总是出错。这似乎是一个新 bug - BeeOnRope
3
据我所知,内核基本上有两个流程:一个是碰到守卫页的流程,最终进入 __do_anonymous_page,另一个是跳过守卫页的流程,最终在 x86 平台上进入 [这里的 __do_page_fault](https://patchwork.kernel.org/patch/9796395/)。你可以看到代码通过检查 rsp处理了MAP_GROWSDOWN的情况,因此你不能将其作为一般的“向下增长”区域,因为内核实际上正在检查rsp` 是否“接近”该区域,否则就会出现错误。 - BeeOnRope
4
最后,这也回答了你之前提出的一个问题:被认为是“堆栈增长区域”的区域似乎可以是任意大的,只要先调整rsp(当然编译器会这么做)。我曾经使用ulimit -s unlimited可以将已分配的堆栈增加1GB,并且Linux很高兴地将堆栈增加到1GB。这只有在主进程堆栈位于VM空间顶部并且在碰到其他东西之前有大约10TB的空间时才有效:对于具有固定堆栈大小且不使用GROWDOWN功能的pthreads线程,此方法不起作用。 - BeeOnRope
2
@BeeOnRope:感谢你做的所有研究工作,我在我的回答中链接了其中几个评论。 - Peter Cordes
显示剩余12条评论

2

栈分配使用与控制地址访问页面故障相同的虚拟内存机制。也就是说,如果您当前的栈具有7ffd41ad2000-7ffd41af3000作为边界:

myaut@panther:~> grep stack /proc/self/maps                                                     
7ffd41ad2000-7ffd41af3000 rw-p 00000000 00:00 0      [stack]

如果CPU尝试读写地址7ffd41ad1fff(栈顶边界前1个字节),它将会产生一个页面错误,因为操作系统没有提供相应的已分配内存块(页面)。所以push或任何其他使用%rsp作为地址的内存访问命令都将触发页面错误

在页面错误处理程序中,内核将检查堆栈是否可以增长,如果可以,它将分配页面支持错误的地址(7ffd41ad1000-7ffd41ad2000),否则,它将触发SIGSEGV,例如,如果堆栈ulimit已超过。


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