[译] C程序员该知道的内存知识 (1)

2020-05-02 14:57:05 +08:00
 felix021

上篇 《踩坑记:go 服务内存暴涨》还挺受欢迎的。尽管文中的核心内容很少,但是为了让大多数人能读懂,中间花了很大的篇幅来解释。

尽管如此,我仍然觉得讲得不够透,思来想去觉得还是文中提到的《 What a C programmer should know about memory 》[1]讲得好,想借着假期翻译一下,也借机再学习一遍(顺便练习英文)。

内容有点长,我会分成几篇。

以下是正文。

C 程序员应该知道的内存知识

2007 年,Ulrich Drepper 大佬写了一篇“每个程序员都应该知道的内存知识”[2],特别长,但干货满满。

但过去了这么多年(译注:原文写于 2015 年 2 月),“虚拟内存”这个概念对很多人依然很迷,就像某种魔法。呃,实在是忍不住引用一下(译注:应该是指皇后乐队的 A Kind of Magic )。

即使是该文的正确性,这么多年以后仍然被质疑[3](译注:有人在 stackoverflow 上提问文中内容有多少还有效)。出了什么事?

北桥?这是什么鬼?这可不是街头械斗。

(译注:北桥是早年电脑主板上的重要芯片,用来处理来自 CPU 、内存等设备的高速信号)

我会试着展现学习这些知识的实用性(即你可以做什么),包括“学习锁的基本原理”,和更多有趣的东西。你可以把这当成那篇文章和你日常使用的东西之间的胶水。

文中例子会使用 Linux 下的 C99 来写(译注:1999 年版的 c 语言标准),但很多主题都是通用的。注:我对 Windows 不太熟悉,但我会很高兴附上能解释它的文章链接(如果有的话)。我会尽量标注哪些方法是特定平台相关的。但我只是个凡人,如果你发现有出入,请告诉我。

理解虚拟内存 - 错综复杂

除非你在处理某些嵌入式系统或内核空间代码,否则你会在保护模式下工作。(译注:指的是 x86 CPU 提出的保护模式,通过硬件提供的一系列机制,操作系统可以用低权限运行用户代码)。这太棒了,你的程序可以有独立的 [虚拟] 地址空间。“虚拟”这个词在这里很重要。这表示,包括其他一些情况,你不会被可用内存限制住,但也没有资格使用任何可用内存。想用这个空间,你得找 OS 要一些真东西来做“里子”,这叫映射( mapping )。这个里子( backing )可以是物理内存(并不一定需要是 RAM ),或者持久存储(译注:一般指硬盘 )。前者被称为*“匿名映射”*。别急,马上讲重点。

虚拟内存分配器( VMA,virtual memory allocator )可能会给你一段并不由他持有的内存,并且徒劳地希望你不去用它。就像如今的银行一样(译注:应该是指银行存款)。这被称为 overcomiting[4](译注:指允许申请超过可用空间的内存),有一些正当的应用有这种需求(例如稀疏数组),这也意味着内存分配不会简单被拒绝。

char *block = malloc(1024 * sizeof(char));
if (block == NULL) {
    return -ENOMEM; /* sad :( */
}

检查 NULL 返回值是个好习惯,但已经没有过去那么强大了。由于 overcommmit 机制的存在,OS 可能会给你的内存分配器一个有效的指针,但是当你要访问它的时候 —— 铛*。这里的“”是平台相关的,但是通常表现为你的进程被 OOM Killer [5]干掉。(译注:OOM 即 Out Of Memory,当内存不足时,Linux 会根据一定规则挑出一个进程杀掉,并在 dmesg 里留下记录)

—— 这里有点过度简化了;在后面的章节里有进一步的解释,但我倾向于在钻研细节之前先过一遍这些更基础的东西。

进程的内存布局

进程的内存布局在 Gustavo Duarte 的《 Anatomy of a Program in Memory 》[6] 里解释得很好了,所以我只引用原文,希望这算是合理使用。我只有一些小意见,因为该文只介绍了 x86-32 的内存布局,不过还好 x86-64 变化不大,除了进程可以用大得多的空间 —— 在 Linux 下高达 48 位。

(来源:Linux 地址空间布局 - by Gustavo Duarte)

译注:针对上图加一些解释备查

  1. 图中显示的地址空间是由高到低,0x00000000 在底部,最高 0xFFFFFFFF,一共 4GB ( 2^32 )。
  2. 高位的 1GB 是内核空间,用户代码 不能 读写,否则会触发段错误。图右侧标注的 0xC0000000 即 3GB ; TASK_SIZE 是 Linux 内核编译配置的名称,表示内核空间的起始地址。
  3. Random stack offset:加上随机偏移量以后可以大幅降低被栈溢出攻击的风险。
  4. Stack ( grows down ): 进程的栈空间,向下增长,栈底在高位地址,PUSH 指令会减小 CPU 的 SP 寄存器( stack pointer )。图右侧的 RLIMIT_STACK 是内核对栈空间大小的限制,一般是 8MB,可以用 setrlimit 系统调用修改。
  5. Memory Mapping Segment:内存映射区,通过 mmap 系统调用,将文件映射到进程的地址空间(包括 libc.so 这样的动态库),或者匿名映射(不需要映射文件,让 OS 分配更多有里子的地址空间)。
  6. Heap:我们常说的堆空间,从下往上增长,通过 brk/sbrk 系统调用扩展其上限
  7. BSS 段:包含未初始化的静态变量
  8. Data 段:代码里静态初始化的变量
  9. Text 段( ELF ):进程的可执行文件(机器码)
  10. 这里说的段( segment )的概念,源于 x86 cpu 的段页式内存管理

图中也展示了 内存映射段( memory mapping segment, MMS )是向下增长的,但并不总是这样。MMS 通常(详见 Linux 内核代码 x86/mm/mmap.c:113 和 arch/mm/mmap.c:1953 )开始于栈的最低地址(译注:即栈底)以下的某个随机地址。注意是“通常”,因为它也可能在栈的上方 ,如果栈空间限制很大(或无限;译注:可用 setrlimit 修改),或者启用了兼容布局。这一点有多重要?——不重要,但可以让你了解到自由地址范围( free address ranges )。

在上图中,你可以看到 3 个不同的变量存放区:进程的数据段(静态存储,或堆内存分配),内存映射段,和栈。我们从这里开始。

理解栈上的内存分配

装备箱:

栈相对比较容易理解,毕竟每个人都知道如何在栈上放一个变量,对吧 ?比如:

int stairway = 2;
int heaven[] = { 6, 5, 4 };

变量的有效性受到作用域的限制。在 C 里,作用域指的就是一对大括号 {}。因此每次遇到一个右大括号,对应的变量作用域就结束了。

然后是 alloca(),在当前 栈帧上动态分配内存。栈帧和内存帧(也叫做物理页)不太一样,它只是一组被压到栈上的数据(函数,参数,变量等)。由于我们在栈顶(译注:SP 寄存器总是指向栈顶),我们可以使用剩下的栈空间,只要不超过栈大小限制。

这就是变长数组( variable-length,VLA )和 alloca 的原理,区别在于 ,VLA 受限于作用域,alloca 分配的内存的有效性可以持续到当前函数返回。这里没有语言律师业务(译注:没人管你,爱咋咋地),但如果你在循环里用 alloca 可能会踩坑,因为你没办法释放它分配的空间:

void laugh(void) {
    for (unsigned i = 0; i < megatron; ++i) {
        char *res = alloca(2);
        memcpy(res, "ha", 2);
        char vla[2] = {'h','a'}
    } /* vla dies, res lives */
} /* all allocas die */

如果要申请大量内存,VLA 和 alloca 都不太好使,因为你几乎无法控制可用的栈空间,如果分配内存超过栈限制,就会遇到令人喜闻乐见的 stack overflow 。有两种办法可以绕过它,但都不太实用:

第一种是用  sigaltstack() 来捕获并处理 SIGSEGV 信号,但这只能让你捕获栈溢出(译注:程序仍然无法获得所需的内存)。

另一种是编译时指定“split-stacks”,这会将一个大的 stack 分割成用链表组织的“栈碎片”( stacklet )。就我所知,GCC 和 clang 编译器可以用 -fsplit-stasck 选项来启用这个特性。理论上这会改善内存消耗,并降低创建线程的开销,因为刚开始的时候栈可以很小,并按需扩展。但实际上可能会遇到兼容问题,因为这需要一个支持 split-stack 的链接器(例如 gold ;译注:这是 GNU 的 ELF 链接器,不同于我们常用的链接器 ld,针对 ELF 链接性能更好)、而这是对库透明的,还可能有性能问题,例如 Go 的 hot-split 问题,在 Agis Anastasopoulos 的这篇文章[7] 中有详细解释。(译注:Go 1.3 之前用 split stack,即前述用链表串起来的栈,在某些情况可能因反复的栈扩展和收缩带来性能问题; 1.3 开始改成使用连续的栈空间,空间不够时重新分配、拷贝内容、修改指向栈空间的指针,因此也要求编译器能准确分析指针逃逸的情况)



休息一下,第一篇就到这里。

下一篇接着翻译下一节 Understanding  heap allocation,感兴趣的记得关注,等不及的推荐阅读原文。

顺便再贴下之前推送的几篇文章,祝过个充实的五一假期~

欢迎关注

   ▄▄▄▄▄▄▄   ▄      ▄▄▄▄ ▄▄▄▄▄▄▄  
   █ ▄▄▄ █ ▄▀ ▄ ▀██▄ ▀█▄ █ ▄▄▄ █  
   █ ███ █  █  █  █▀▀▀█▀ █ ███ █  
   █▄▄▄▄▄█ ▄ █▀█ █▀█ ▄▀█ █▄▄▄▄▄█  
   ▄▄▄ ▄▄▄▄█  ▀▄█▀▀▀█ ▄█▄▄   ▄    
   ▄█▄▄▄▄▄▀▄▀▄██   ▀ ▄  █▀▄▄▀▄▄█  
   █ █▀▄▀▄▄▀▀█▄▀█▄▀█████▀█▀▀█ █▄  
    ▀▀  █▄██▄█▀  █ ▀█▀ ▀█▀ ▄▀▀▄█  
   █▀ ▀ ▄▄▄▄▄▄▀▄██  █ ▄████▀▀ █▄  
   ▄▀▄▄▄ ▄ ▀▀▄████▀█▀  ▀ █▄▄▄▀▄█  
   ▄▀▀██▄▄  █▀▄▀█▀▀ █▀ ▄▄▄██▀ ▀   
   ▄▄▄▄▄▄▄ █ █▀ ▀▀   ▄██ ▄ █▄▀██  
   █ ▄▄▄ █ █▄ ▀▄▀ ▀██  █▄▄▄█▄  ▀  
   █ ███ █ ▄ ███▀▀▀█▄ █▀▄ ██▄ ▀█  
   █▄▄▄▄▄█ ██ ▄█▀█  █ ▀██▄▄▄  █▄  



参考链接:

[1] What a C programmer should know about memory https://marek.vavrusa.com/memory/

[2] What every programmer should know about memory http://www.akkadia.org/drepper/cpumemory.pdf

[3] stackoverflow.com - What Every Programmer Should Know About Memory? https://stackoverflow.com/questions/8126311/what-every-programmer-should-know-about-memory

[4] Kernel - overcommit accounting https://www.kernel.org/doc/Documentation/vm/overcommit-accounting

[5] Linux - Overcommit and OOM https://www.win.tue.nl/~aeb/linux/lk/lk-9.html#ss9.6

[6] anatomy of a program in memory http://duartes.org/gustavo/blog/post/anatomy-of-a-program-in-memory/

[7] Contiguous stacks in Go http://agis.io/2014/03/25/contiguous-stacks-in-go.html

5319 次点击
所在节点    程序员
21 条回复
baoyexi
2020-05-04 14:45:53 +08:00
基础,点赞

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/668069

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX