brk()系统调用是做什么用的?

249
根据Linux程序员手册:
brk()和sbrk()改变了程序断点的位置,它定义了进程数据段的末尾。
这里的数据段是什么意思?它只是数据段或者数据、BSS和堆结合体吗?
根据维基数据段
有时数据、BSS和堆区域被统称为“数据段”。
我不明白为什么要仅仅改变数据段的大小。如果它是数据、BSS和堆共同占用的空间,那么增加堆的空间就有意义了。
这带来了我的第二个问题。在我阅读的所有文章中,作者都说堆向上增长,而栈向下增长。但他们没有解释当堆占据了堆和栈之间的所有空间时会发生什么?

enter image description here


1
当你的空间不足时,你会怎么做?你会将数据交换到硬盘上。当你使用完空间后,你就可以为其他类型的信息释放它。 - Igoris Azanovas
36
你混淆了物理内存(可以使用虚拟内存将其根据需要交换到磁盘上)和地址空间。当你的地址空间填满时,无论交换多少次都无法重新获得中间的这些地址。 - Daniel Pryden
10
提醒一下,brk() 系统调用在汇编语言中比在 C 语言中更有用。在 C 语言中,为了分配数据,应该使用 malloc() 而不是 brk() -- 但这并不会以任何方式否定所提出的问题。 - alecov
2
@Brian:堆是一种复杂的数据结构,用于处理不同大小和对齐方式的区域、自由池等。线程堆栈始终是完整页面的连续(在虚拟地址空间中)序列。在大多数操作系统中,堆栈、堆和内存映射文件都有一个页面分配器作为基础。 - Ben Voigt
2
@Brian:谁说brk()sbrk()会操作任何“堆栈”?堆栈是由页面分配器在更低的层次上管理的。 - Ben Voigt
显示剩余4条评论
8个回答

301
在你发布的图表中,“break”——由brksbrk操纵的地址——是堆顶部的虚线。

simplified image of virtual memory layout

您所阅读的文档将其描述为“数据段”的末尾,因为在传统(预共享库,预)Unix中,数据段与堆是连续的;在程序启动之前,内核会将“文本”和“数据”块加载到RAM中,从地址零开始(实际上略高于地址零,以便NULL指针确实不指向任何东西),并将断点地址设置为数据段的末尾。然后,第一次调用将使用将断点向上移动,并创建堆,位于数据段顶部和新的更高断点地址之间,如图所示,并且随后对malloc的使用将使用它将堆扩大到必要的大小。
与此同时,栈从内存顶部开始增长。栈不需要显式的系统调用来扩大它的大小;它要么一开始就分配了尽可能多的 RAM(这是传统方法),要么在栈下方有一个保留地址区域,内核会自动分配 RAM 当它注意到有尝试在那里写入数据时(这是现代方法)。无论哪种方式,地址空间底部可能有或可能没有“警戒”区域可用于栈。如果这个区域存在(所有现代系统都这样做),它将永久取消映射;如果任一栈或堆尝试向其中增长,你将获得一个分段错误。然而,传统上内核没有试图强制执行边界;栈可以增长到堆中,或者堆可以增长到栈中,两者都会互相覆盖数据并导致程序崩溃。如果你非常幸运,它会立即崩溃。
我不确定这个图表中的512GB数字来源于何处。它暗示着一个64位虚拟地址空间,这与你所看到的非常简单的内存映射不一致。真正的64位地址空间看起来更像这样:

less simplified address space

              Legend:  t: text, d: data, b: BSS

这张图不是按比例绘制的,也不应该被解释为任何操作系统确切的做法(在我画完后,我发现Linux将可执行文件放得比我想象的更接近地址零,并且共享库位于非常高的地址)。该图中的黑色区域是未映射的--任何访问都会立即导致段错误--相对于灰色区域它们是“巨大的”。浅灰色区域是程序及其共享库(可以有几十个共享库);每个库都有一个独立的文本和数据段(还有一个“bss”段,其中包含全局数据,但初始化为全部零位,而不是占用可执行文件或磁盘上的库中的空间)。堆不再必须与可执行文件的数据段连续--我是这样画的,但看起来至少在Linux上不是这样。栈也不再固定在虚拟地址空间的顶部,堆和栈之间的距离非常巨大,你不必担心越过它。
断点仍然是堆的上限。但是,我没有展示的是,可能会有数十个独立的内存分配,远离那里的黑色,使用而不是< brk >进行分配。(操作系统将尝试将它们保持远离< brk >区域,以免发生冲突。)

10
你知道malloc是否仍然依赖于brk,还是使用mmap来能够“归还”单独的内存块? - Anders Abel
23
这取决于具体的实现方式,但据我所知,许多当前的 malloc 函数对于小型分配使用 brk 区域,而对于大型分配(例如 >128K)则使用单独的 mmap。例如,在 Linux 的 malloc(3) 手册中可以看到关于 MMAP_THRESHOLD 的讨论。 - zwol
3
确实是一份好的解释。但正如你所说,栈不再位于虚拟地址空间的顶部。这是否仅适用于64位地址空间,还是32位地址空间也是如此?如果栈位于地址空间的顶部,匿名内存映射发生在哪里?它是在虚拟地址空间的顶部,就在栈的前面吗? - nik
3
大多数32位系统将栈放在用户模式地址空间的顶部,这通常只是完整地址空间的较低2或3G(其余空间保留给内核)。我目前想不到任何一个没有这样做的,但我并不知道所有的系统。大多数64位CPU实际上不允许您使用整个64位空间;地址的高10到16位必须全部为零或全部为一。栈通常放置在可用低地址的靠近顶部处。我无法为mmap提供规则;它非常依赖于操作系统。 - zwol
5
@RiccardoBestetti 它浪费了 _地址空间_,但这是无害的 -- 64位虚拟地址空间是如此之大,如果你每秒使用掉1GB,仍需要500年才能用完。[1] 大多数处理器甚至不允许使用超过2^48到2^53位的虚拟地址(我知道的唯一例外是使用哈希页表模式的POWER4)。它并不浪费物理内存;未使用的地址不会分配给内存。 - zwol
显示剩余12条评论

50
最小可运行示例
brk()系统调用是做什么的?
它要求内核允许您读写一个称为堆的连续内存块。
如果您不请求,当您尝试从该区域读写时,可能会导致段错误。
没有brk:
#define _GNU_SOURCE
#include <unistd.h>

int main(void) {
    /* Get the first address beyond the end of the heap. */
    void *b = sbrk(0);
    int *p = (int *)b;
    /* May segfault because it is outside of the heap. */
    *p = 1;
    return 0;
}

使用 brk 标签:
#define _GNU_SOURCE
#include <assert.h>
#include <unistd.h>

int main(void) {
    void *b = sbrk(0);
    int *p = (int *)b;

    /* Move it 2 ints forward */
    brk(p + 2);

    /* Use the ints. */
    *p = 1;
    *(p + 1) = 2;
    assert(*p == 1);
    assert(*(p + 1) == 2);

    /* Deallocate back. */
    brk(b);

    return 0;
}

GitHub 上游

即使没有 brk,上述代码可能不会打开新页面,也不会发生段错误。因此,这里有一个更激进的版本,它分配了 16MiB 的内存,并且很有可能在没有 brk 的情况下发生段错误:

#define _GNU_SOURCE
#include <assert.h>
#include <unistd.h>

int main(void) {
    void *b;
    char *p, *end;

    b = sbrk(0);
    p = (char *)b;
    end = p + 0x1000000;
    brk(end);
    while (p < end) {
        *(p++) = 1;
    }
    brk(b);
    return 0;
}

在Ubuntu 18.04上进行了测试。
虚拟地址空间可视化
在brk之前:
+------+ <-- Heap Start == Heap End

在执行 brk(p + 2) 之后:
+------+ <-- Heap Start + 2 * sizof(int) == Heap End 
|      |
| You can now write your ints
| in this memory area.
|      |
+------+ <-- Heap Start

brk(b)之后:
+------+ <-- Heap Start == Heap End

为了更好地理解地址空间,你应该熟悉分页: x86分页是如何工作的?
为什么我们需要同时使用brksbrk
当然,brk可以通过sbrk + 偏移计算来实现,两者都是为了方便而存在。
在后端,Linux内核v5.0有一个单一的系统调用brk,用于实现这两者:https://github.com/torvalds/linux/blob/v5.0/arch/x86/entry/syscalls/syscall_64.tbl#L23
12  common  brk         __x64_sys_brk
brk是POSIX吗? brk曾经是POSIX的一部分,但在POSIX 2001中被移除,因此需要使用_GNU_SOURCE来访问glibc的包装器。
这个移除很可能是因为引入了mmap,它是一个更强大的超集,允许分配多个范围和更多的分配选项。
我认为现在几乎没有一个有效的情况下你应该使用brk而不是mallocmmapbrkmalloc brk是一种旧的实现malloc的可能性。 mmap是一种更新、更强大的机制,很可能所有的POSIX系统都使用它来实现malloc。这里有一个最小可运行的mmap内存分配示例

我可以混合使用brkmalloc吗?

如果你的malloc是使用brk实现的,我不知道这样做怎么可能不会出问题,因为brk只能管理单个内存范围。

然而,我在glibc文档中没有找到任何相关信息,例如:

事情在那里可能只是工作,我猜测是因为可能使用了mmap来进行malloc。
另请参阅:
- [什么是brk/sbrk的不安全/遗留问题?](link1) - [为什么调用sbrk(0)两次会得到不同的值?](link2)
更多信息:
内部上,内核决定进程是否可以拥有那么多内存,并为其使用分配内存页。
这解释了栈与堆的比较:[x86汇编中寄存器上使用push/pop指令的功能是什么?](link4)

4
因为p是指向int类型的指针,所以这句话应该改为brk(p + 2); - Johan Boulé
小提示:在进攻性版本的for循环表达式中,可能应该是*(p + i) = 1; - lima.sierra
1
@YiLinLiu 我认为这只是一个单内核后端的两个非常相似的C前端(brk系统调用)。 brk稍微更方便一些,可以恢复先前分配的堆栈。 - Ciro Santilli OurBigBook.com
1
考虑到int的大小为4个字节,int *的大小也为4个字节(在32位机器上),我想知道它是否应该只增加4个字节(而不是8个字节 - 2 * sizeof int)。它难道不应该指向下一个可用的堆存储 - 距离为4个字节(而不是8个字节)吗?如果我漏掉了什么,请纠正我。 - Saket Sharad
我不会在这里使用 assert -- 使用 ifprintf 可能会更好。 - S.S. Anne
显示剩余5条评论

10
你可以使用brksbrk来避免大家经常抱怨的"malloc开销",但是当你需要使用malloc时,你就不能轻易地采用这种方法。所以,只适用于不需要free任何东西的情况下。因为你无法释放它们。此外,你应该避免使用可能在内部使用malloc的库调用。例如,strlen可能是安全的,但fopen可能不是。

像使用malloc一样调用sbrk。它返回当前断点的指针并增加那个量的断点。

void *myallocate(int n){
    return sbrk(n);
}

虽然您不能释放单个分配(因为没有 malloc-overhead ,请记住),但是您可以通过使用第一次调用sbrk 返回的值调用 brk 来释放整个空间,从而倒回 brk

void *memorypool;
void initmemorypool(void){
    memorypool = sbrk(0);
}
void resetmemorypool(void){
    brk(memorypool);
}

您甚至可以堆叠这些区域,通过将断点回退到该区域的开头来丢弃最近的区域。


还有一件事...

sbrk代码高尔夫比赛中也很有用,因为它比malloc短2个字符。


7
-1是因为:malloc/free几乎肯定可以(并且确实会)将内存返还给操作系统。它们可能不总是在您希望它们这样做的时候这样做,但这是启发式算法对您的用例调整不完美的问题。更重要的是,在任何可能调用malloc的程序中使用非零参数调用sbrk是不安全的——几乎所有的C库函数都被允许在内部调用malloc,唯一绝不会这样做的是异步信号安全函数。 - zwol
而“不安全”指的是“您的程序将崩溃”。 - zwol
我已编辑掉“归还内存”的吹嘘,并提到了库函数内部使用malloc的风险。 - luser droog
1
如果你想进行高级内存分配,可以在malloc或mmap的基础上进行。不要触碰brk和sbrk,它们是过去的遗物,会带来更多的伤害而不是好处(即使man手册也告诉你要远离它们!) - Eloff
同意。对于实际应用,它们是不可取的。但是我昨天刚写了一个使用它们的程序。[当然是代码高尔夫比赛。(http://codegolf.stackexchange.com/a/18694/2381)] - luser droog
4
这很愚蠢。如果你想避免为大量小内存分配产生的malloc开销,那么可以进行一次大内存分配(使用malloc或mmap,而不是sbrk),然后自己分配内存。如果将二叉树节点保存在数组中,则可以使用8位或16位索引,而不是64位指针。这在你不需要删除任何节点直到准备好删除所有节点时非常有效(例如,在构建排序字典时)。对于这种情况,使用sbrk仅在代码高尔夫方面有用,因为手动使用mmap(MAP_ANONYMOUS)方式在除源代码大小之外的所有方面都更优。 - Peter Cordes

5

有一个特殊的指定匿名私有内存映射(传统上位于数据/ bss之后,但现代Linux实际上会通过ASLR调整位置)。原则上,它与您使用mmap创建的任何其他映射一样,但是Linux具有一些优化功能,使得可以使用brk系统调用向上扩展此映射的末尾,相对于mmapmremap会产生较少的锁定成本。这使得在实现主堆时,malloc实现使用它非常有吸引力。


你的意思是将此映射的末尾向上扩展,对吗? - zwol
是的,已经修复了。对此感到抱歉! - R.. GitHub STOP HELPING ICE

1

malloc函数使用brk系统调用来分配内存。

#include

int main(void){

char *a = malloc(10); 
return 0;
}

使用strace运行这个简单的程序,它将调用brk系统。


1
堆放在程序的数据段最后。使用brk()来改变(扩展)堆的大小。当堆无法再增长时,任何malloc调用都将失败。

所以您的意思是互联网上所有的图表,比如我问题中的那张图都是错误的。如果可能的话,您能否指出正确的图表给我看? - nik
2
请注意,该图表的顶部是内存的末尾。随着堆栈的增长,堆栈的顶部在图表上向下移动。随着堆的扩展,堆的顶部在图表上向上移动 - Rag

0
我可以回答你的第二个问题。如果使用 malloc 动态分配内存失败,它会返回一个空指针。这就是为什么在动态分配内存时总要检查是否为 null 指针的原因。

3
malloc() 在底层会使用 brk() 和/或 sbrk(),如果你想实现自己定制版本的 malloc(),也可以这样做。 - Daniel Pryden
@Nikkhil,你的图示中,在Heap和empty space之间的边界上有一个小伸缩箭头。它可以向那个空间扩展。在堆和栈之间留下了很多“空”余空间。操作系统会透明地为你提供一个巨大的内存空间的错觉,所以你不必担心这个问题。 - Rag
@Daniel 我不确定操作系统是否管理堆栈。你可以轻松地使用push/pop指令移动堆栈指针,这些指令并不是系统调用。当你希望单独的线程有单独的堆栈时,所有堆栈都必须在堆上以巨大的块动态分配。我记得这是一个问题,因为如果一开始不知道需要多少线程,则很难在线程堆栈之间平均分配堆。 - Rag
2
@Brian:Daniel说操作系统管理的是堆栈,而不是堆栈指针……两者非常不同。关键在于对于堆栈段没有sbrk/brk系统调用——Linux会在尝试写入堆栈段末尾时自动分配页面。 - Jim Balter
1
Brian,你只回答了问题的一半。另一半是如果在没有可用空间的情况下尝试将内容推入堆栈会发生什么...你会得到一个分段错误。 - Jim Balter
显示剩余3条评论

0
数据段是内存的一部分,它保存了所有静态数据,这些数据在启动时从可执行文件中读取,并且通常被填充为零。

它还包含未初始化的静态数据(不在可执行文件中),可能是垃圾数据。 - luser droog
未初始化的静态数据(.bss)在程序启动之前由操作系统初始化为全零比特;这实际上是由C标准保证的。我想有些嵌入式系统可能不会费心进行此操作(我从未见过这样的情况,但我并不是完全从事嵌入式)。 - zwol
@zwol:Linux有一个编译时选项,可以选择不将mmap返回的页面清零,但我认为.bss仍然会被清零。BSS空间可能是表达程序需要一些零数组的最紧凑方式。 - Peter Cordes
1
@PeterCordes 标准规定,未初始化的全局变量被视为初始化为零。将这些变量放在.bss中且不清零.bss的C实现因此是不符合标准的。但是没有任何强制要求C实现必须使用.bss或者拥有这样的东西。 - zwol
@PeterCordes 此外,“C 实现”和程序之间的界限可能非常模糊,例如,通常会有一小段来自实现的代码静态链接到每个可执行文件中,在 main 之前运行;该代码可以清零 .bss 区域而不是让内核执行,这仍然是符合规范的。 - zwol
@zwol:我的观点是,这样的实现比让内核的ELF加载器将bss清零更糟糕。如果你不需要任何零初始化的数组,那么留下它也没有任何好处,因为你仍然需要绕过它才能拥有可用的C实现。请注意,我说的是“可用”,而不是“符合标准”:我们正在谈论一个嵌入式系统。如果内核、编译器和运行时环境的组合适用于你想要运行的代码,那么它就是可用的。 - Peter Cordes

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