标题:[译] C程序员该知道的内存知识 (1) 出处:Felix021 时间:Sat, 02 May 2020 22:22:41 +0000 作者:felix021 地址:https://www.felix021.com/blog/read.php?2218 内容: 上篇 《踩坑记: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)可能会给你一段并不由他持有的内存,并且徒劳地希望你不去用它。就像如今的银行一样(译注:应该是指银行存款)。这被称为 overcommiting\[4\](译注:指允许申请超过可用空间的内存),有一些正当的应用有这种需求(例如稀疏数组),这也意味着内存分配不会简单被拒绝。 char *block = malloc(1024 * sizeof(char)); if (block == NULL) { return -ENOMEM; /* sad :( */ } 检查 NULL 返回值是个好习惯,但已经没有过去那么强大了。由于 overcommit 机制的存在,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 位。 点击在新窗口中浏览此图片 https://www.felix021.com/blog/attachment.php?fid=541 (来源: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个不同的变量存放区:进程的数据段(静态存储,或堆内存分配),内存映射段,和栈。我们从这里开始。 # 理解栈上的内存分配 装备箱: * alloca() - 在调用方的栈帧上分配内存 * getrlimit() - 获取/设置 resource limits * sigaltstack() - 设置或获取信号栈上下文 栈相对比较容易理解,毕竟每个人都知道如何在栈上放一个变量,对吧 ?比如: 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,感兴趣的记得关注,等不及的推荐阅读原文。 顺便再贴下之前推送的几篇文章,祝过个充实的五一假期~ * 《踩坑记:go服务内存暴涨》 * 《TCP:学得越多越不懂》 * 《UTF-8:一些好像没什么用的冷知识》 * 《关于RSA的一些趣事》 * 《程序员面试指北:面试官视角》 * * * # 参考链接: [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 Generated by Bo-blog 2.1.0