【问题标题】:stack growth and mmap stack?堆栈增长和mmap堆栈?
【发布时间】:2021-12-08 18:15:44
【问题描述】:

我正在阅读这篇关于内存过量使用的 page,它提到了

The C language stack growth does an implicit mremap. If you want absolute
guarantees and run close to the edge you MUST mmap your stack for the 
largest size you think you will need. For typical stack usage this does
not matter much but it's a corner case if you really really care

程序编译时(例如通过gcc),定义了堆栈的大小限制(我记得有一个gcc参数可以调整它)。然后,在程序内部,我们可以继续在堆栈上进行分配。

几个问题:

  1. 在这种情况下,“堆栈增长”是什么意思?这是否意味着如果 C 程序不断在堆栈上分配/释放,有时,mremap() 将在幕后被调用?如果堆栈的大小限制是在编译时定义的,为什么?
  2. 我们如何mmap 堆栈?

【问题讨论】:

  • 我不认为堆栈大小是编译时参数;它在运行时设置为 rlimit。
  • 关于它的帖子很少:herehere。我相信一个堆栈大小限制是由编译器强加的,另一个是由操作系统强加的?
  • 其中一篇文章适用于 MacOS,另一篇适用于 Windows。我认为它们不适用于 Linux(至少不适用于 x86)。
  • 整件事是:如果您知道您的程序超出了在编译/加载时设置的堆栈大小,您可以将其预设为更大的值。这可以避免昂贵的重映射,甚至是堆栈地址范围与堆地址范围冲突。 (或其他地址区域)。 [思考:RT]
  • @wildplasser:也许我应该问一个单独的问题,因为我从未见过实际上如何做到这一点。我寻找了一个可以做到的 ELF 标题或注释选项,但没有找到。

标签: c linux kernel


【解决方案1】:

这里的“魔力”是当进程通过mmap()系统调用向内核请求新内存时MAP_GROWSDOWN标志(由Linux内核实现)的行为,并且它通常用于初始堆栈(进程中第一个线程的堆栈,当它第一次执行时)。

因此,虽然默认情况下新进程通常会获得MAP_GROWSDOWN 堆栈,但进程也可以管理自己的堆栈。如果进程创建新线程,它必须为它们创建堆栈。 (目前,pthread_create() 创建一个固定大小的堆栈(默认最大大小,或按 pthread_attr_t 属性块中指定的大小(如果指定),而不是 MAP_GROWSDOWN 堆栈。)

Linux 内核实现MAP_GROWSDOWN 内存映射的方式是实际内存前面有一个额外的页面,称为“保护页面”。 (在 x86-64 上,页面是 4096 字节的对齐单元,但存在其他页面大小;在运行时,使用sysconf(_SC_PAGESIZE) 获取以字节为单位的大小。)

每当第一次访问保护页面时,内核都会将其转换为标准页面(与同一映射中的其他页面相同),并在其下方(在下一个较小的页面地址处)创建一个新的保护页面。如果在这些虚拟地址上已经映射了某些内容,则映射不会更改,并且该进程将收到 SIGSEGV(段违规错误)。因此,只有可用地址空间的数量(以及间接可用的内存)限制了此类堆栈的增长。

这也意味着如果依赖MAP_GROWSDOWN 自动堆栈增长,使用大于页面大小的本地数组可能会导致 SIGSEGV。因此,在 C 中使用动态内存管理——malloc()/realloc()/free(),以及 getline() 和 asprintf() 等接口——比依赖大型堆栈上固定大小的数组更可靠.

本质上,只要堆栈元素的大小最多为一页,这样的堆栈就会根据需要自动增长。

“隐式重映射”因此只适用于初始线程,因为它使用了一个使用MAP_GROWSDOWN 标志的堆栈;并且隐式重映射本身以页面大小为单位引用此自动增长工具。

如果您的进程针对不同类型的分配执行许多单独的mmap() 调用,例如将文件映射到内存等,则它们的位置可能会使得MAP_GROWSDOWN 映射的增长限制为小于过程期望什么。 (出于安全目的,内核给出的地址至少在某种程度上是随机的。)

建议将内核重新映射为可能需要的最大尺寸,这意味着可以——我不确定我是否同意“必须”——在他们程序的开头附近,使用mremap() 进行转换MAP_GROWSDOWN 映射到更大的固定大小的映射;通常,为getrlimit(RLIMIT_STACK,) 报告的大小。因为这实质上是分配地址空间,但在第一次访问之前不会使用实际 RAM 填充页面,因此主要成本是内核元数据(页表等)。

作为设置 C 运行时环境的一部分(例如,在 crt*.o 或 libgcc* 中),您的编译器提供的 C 运行时可能已经执行此操作(达到getrlimit(RLIMIT_STACK, ) 报告的大小) .我没查过。

如果想要,例如在创建一个新线程时,可以使用mmap()(比如mmap((void *)0, size_in_bytes, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK | MAP_GROWSDOWN, -1, 0))来分配任何想要的堆栈,然后使用pthread_attr_init() 来初始化一个线程属性集,把使用pthread_attr_setstack() 将堆栈的地址和大小放入该线程属性中,并提供指向该线程属性集的指针作为pthread_create() 的第二个参数。然后创建的线程将使用该堆栈。

修改当前使用的堆栈要复杂得多,最好在实际编译的 C 代码在进程中运行之前在 C 运行时(用机器代码,用汇编编写)中完成。在 C 中,它可以通过getcontext()/setcontext() 完成,通过创建一个新的上下文(就像它是一个新线程一样),为其设置一个新的堆栈,切换到新的上下文,然后释放旧的堆栈。

在许多情况下,信号处理程序通过调用sigaltstack() 设置为使用单独的堆栈。这是非常有用的,因为然后信号由于例如堆栈溢出仍然可以处理。

最后,回想一下,在 Linux 中,/proc/PID/maps 描述了进程 PID 的所有现有映射。对于流程本身,您始终可以使用/proc/self/maps。在试验这些东西时,您可能会发现以下 dump_maps() 函数很有用:

#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>

/* Returns 0 if memory mappings printed to standard output,
   an errno error code if an error occurs.
*/
int dump_maps(void)
{
    FILE *in;
    int   ch;

    in = fopen("/proc/self/maps", "r");
    if (!in) {
        const int saved_errno = errno;
        fprintf(stderr, "Cannot open /proc/self/maps: %s.\n", strerror(saved_errno));
        return errno = saved_errno;
    }

    printf("  MinAddress-MaxAddress  Perms Offset  Device   Inode                    Pathname-or-Description\n");

    /* Yes, this is the slowest possible way to copy a file to standard output,
       but it should not matter for this use case.  The KISS principle. */
    while ((ch = getc(in)) != EOF)
        putchar(ch);

    putchar('\n');

    fclose(in);
    return 0;
}

int main(void)
{
    dump_maps();

    return EXIT_SUCCESS;
}

有关 /proc/self/maps 和其他 /proc 伪文件的更多信息——它们不是任何存储设备上存在的文件;它们是由内核在访问时生成的,对于这类东西来说是一个非常有效的接口——参见man 5 proc

【讨论】:

  • 感谢您的详细解答。只是想确保我理解您的答案:所以当主线程尝试调用 mmap() 并且 mmap-ed 区域的总大小大于内核在启动时可以告诉的大小时,会调用 mremap()(通过正在查看一些 ELF 标头?)
  • @HCSF: 不。内核文档建议程序必须执行 mremap()/mmap() 调用以将其MAP_GROWSDOWN 堆栈转换为所需的固定大小,最好在执行 main() 之前开始了。 (也就是说,在 C 库运行时代码中,crt*.o 或编译器提供的代码,例如 libgcc*.a。我还没有检查他们是否已经这样做了。)
  • @HCSF:如果不执行上述操作,默认情况下程序的初始线程有一个MAP_GROWSDOWN 堆栈。当程序使用的堆栈太多以至于访问堆栈下方的堆栈保护页面时,内核会在内部将堆栈向下扩展一页。您可以将其视为内核执行 kind-of-an-mmap() 调用以使保护页面之前的页面成为新的保护页面,如果成功,则执行 mprotect() 来转换旧的保护页面到一个普通的堆栈页面;代表用户进程。这就是您引用的部分中“隐式 mremap()”的含义。
  • 我自己有点不同意引用中的“必须”,因为我不在总计一页或更多的函数中使用局部变量:我对数组和缓冲区使用动态内存管理.这意味着MAP_GROWSDOWN 自动增长的堆栈适合我的需要,我不会受到它的限制,并且由于我的进程不使用太多堆栈(仅递归,而且我使用递归受限的数据结构),我的进程不会受到自动增长堆栈的缺点的影响,除非它们已经内存不足。
猜你喜欢
  • 2011-03-23
  • 2011-04-04
  • 2012-10-08
  • 2018-02-12
  • 1970-01-01
  • 2011-06-01
  • 2014-06-23
  • 1970-01-01
  • 2016-12-21
相关资源
最近更新 更多