Cover

细究内存管理(一)

Wenxuan SHI /
December 18, 2021
18 min read
细究内存管理
This article is part of a series.

《细究内存管理》系列文章之首。这一系列的博客文章将从原理出发探究内存管理的本质,并且介绍如何综合常见的手段分析与预防内存安全性漏洞。这篇文章里,我主要介绍了堆、栈模式与内存管理的关系。

0x00 前言

大三下学期,我在操作系统课程的期末项目里综合二进制插桩、动态库注入和内核缺页陷阱制作了内存检查工具。在制作工具的过程中我和朋友们不断讨论,最终形成了一套对于内存管理机制的解读。

但是为什么这篇文章拖到了大四下学期才发布呢?

内存管理的命题非常庞大,涉及到了计算机理论的方方面面。就算是概括着观览一遍,也能写成好几本书。这个系列的文章只是围绕我的个人兴趣,探讨几个比较有趣的点。

  • 内存与函数的绑定模型。栈和堆有什么区别?内存泄露是怎么出现的?
  • 程序内部的内存安全性。什么是垃圾回收?所有权和生命周期是什么概念?
  • 程序外部的内存安全性。为什么需要内存隔离?虚拟化、安全世界、TEE 是如何帮助提升安全性的?
  • 计算与数据的交互范式。如果当初不采用“函数调用”的模型,计算机该如何设计?

我也会穿插介绍插桩、动态库注入、内核陷阱等等我们在项目中用到的技术。

真是一张大饼。

根据统计,70% 的软件漏洞来自于内存安全性问题。作为热身,我们先从其中最著名的内存泄露问题开始聊起。

0x01 内存泄漏

内存泄露指一块无用内存没有被及时释放。例如程序员用了一个结构体struct Message {user_id, ctx}来存储用户向服务器发送的指令。服务器在经过解析、计算、响应后,用户拿到了满意的结果,结束套接字连接,关机睡大觉去了。这是结构体实例就失去了存在的意义,应该被及时释放。如果它一天后、或一个月后、乃至一年后、甚至到机房停电服务器宕机时都一直没有被释放,那么我们说程序存在内存泄露的问题。

但是我们不可能笃定内存泄露正在发生。这是因为内存泄露本质上不能被运行时动态检测。

  • 在程序运行时,我怎么知道将来某段数据会不会被使用?万一哪天就得用它了呢?
  • 在程序运行时,我怎么知道这段数据会不会在下一秒被另一个函数给释放了?

为了进一步了解内存泄露出现的本质,我们需要先讨论操作系统中栈和堆的概念。

0x02 内存泄露的根源:堆内存的分配与释放

假设你要用 C 语言处理 1000 万个矩阵,每个矩阵的大小不定。这些矩阵无法被预处理,你只能硬着头皮把它们全部加载进内存。更糟糕的是,由于计算格外的复杂,在得到结果之前,它们必须一直保留在内存里,时刻准备好被读取。你可以选择数据的存储方式:

  • ❌ 局部数组 LocalList[]。尽管运行时在栈上创建局部动态数组是 C99 支持的行为,但是你很难把它们持续的保存下来。
  • ❌ 全局数组 GlobalList[]。如果使用一个被初始化的全局数组,它会存储在二进制程序中的数据段 (data section) 中,你会编译出一个巨大无比的可执行文件。如果使用一个未被初始化的全局数组,操作系统会帮你在运行之前申请好内存摆在那里。但是,使用全局数组必须要预先规定好数据的大小,所以它不适合存储我们的矩阵数据。
  • ✅ 动态数组 HeapList[]。在程序的运行过程中测量每个矩阵的大小,在堆上动态分配内存存储它们。这听起来是不错的解决方案。

我们最终会选择动态数组,在堆上进行数据的存放。其原因是堆空间可以在运行期间动态分配,非常灵活。这也是程序员青睐“堆”的原因。事实上,如果我们用面向对象的语言(例如 C++)写这个程序,你会发现 new 操作符正是在堆上开辟空间存放实例。

虽然动态分配、灵活多变的堆内存听上去很不错,它也带来了困扰行业(尤其是巨头公司)几十年之久的问题,那就是内存泄露。

知识回顾:栈模式

程序的执行过程可以看做函数之间的相互调用。函数调用遵循“栈”的原则,也就是“后进先出”的原则:A 函数调用 B 函数,B 函数调用 C 函数,C 函数把计算结果返回给 B 函数,B 再处理一遍,最终告诉 A 函数。每个函数都对应一块内存用于存放数据,它们连在一起就是我们所谓的“栈内存空间”。

一个函数调用了返回指令后 (return),它使用的栈空间就会自动释放。这是因为编译器确定了每个函数所占据的栈空间大小,并且自动向程序里插入了对栈帧 (sp) 寄存器的操纵指令。在运行期间,栈帧会自动移动,以开辟和释放栈内存。

栈帧esp在调用和返回时移动
栈帧esp在调用和返回时移动

然而栈结构给复杂程序的编写带来了相当大的困难——在函数返回之前,栈帧都不能移动。例如,如果 D 函数想要使用 A 对应的内存空间(以下简称 A 内存)内部的第四条数据,它该怎么获得呢?

方法一: A 将数据作为参数传递给 B,B 再将数据作为参数传递给 C,C 也如此传递给 D。

方法二: 程序员计算好 A、B、C、D 三块内存的大小,并且通过 esp-(A+B+C+D)+4 的地址拿到数据。

方法三: 复制栈帧寄存器到另一个指针,模拟函数返回的过程进行“栈展开”操作,向前跳跃 3 次,最后根据偏移量拿到数据。

这三个方法一个比一个离谱……

三种方法都非常低效,而且稍不留神就会出错。

我们稍微拔高一点看问题:栈结构的观点是“函数拥有内存,内存属于函数”,所以将函数和内存牢牢绑定。程序员在实际编写代码的过程中,会发现通常内存与函数之间的联系没有这么紧密。有时候,一段内存需要在若干函数之间共享。于是,计算机科学家又引入了新的内存管理模式——堆。

👀 函数的副作用
引入堆之后,内存和函数“松绑”。内存不再是函数的附属产物,而是一块独立存在的可交互对象。这是计算机函数被广为诟病的一点——函数 y=f(x) 除了根据输入 x 得出输出 y 以外,竟然产生了副作用!严谨的数学家们不得不把计算机函数表达为 (y,S')=f(x,S) 的形式,其中 S 是一块拥有巨大值域的内存。这使得形式化验证计算机程序变得极其困难。 在图灵发明图灵机的时代,以邱奇为首的美国数学家发明了等价的λ演算,并最终延伸出了函数式编程语言。这是一种只允许栈模式的编程语言。它编写复杂,却拥有其他语言没有的优势。例如,由于不存在中间状态 S,函数式编程语言格外适合流模式、形式化验证和并发执行。 注意,函数式编程语言在实际运行中因为性能原因也使用堆内存。

知识回顾:堆模式

堆的出现消除了函数之间共享数据的难点。堆内存在函数执行时开辟,大小不定。与栈模式的不同,尽管函数可以开辟堆内存,但它并不拥有这块内存,而是只拥有这块内存的指针

正是因为这种特性,一块堆内存可以被很多函数同时使用(换句话说,很多函数都持有指向这块内存的指针)。编译器不敢贸然释放堆上的数据。因此程序员必须要亲自管理堆内存的释放。蹩脚的程序员很可能忘记(或懒得)释放内存,因此导致内存泄露。

其实程序可以找到时机自动释放堆内存?例如在没有指针指向它的时候?

没错,这就是语言运行时利用引用计数进行垃圾回收的原理。其实现代语言的编译器和运行时有很多方法帮助避免内存泄露。后续的系列文章我们会提到!

在软件工程实践中,堆结构带来的影响更大。想象你的同事申请了一段堆内存,但是忘记将它的地址保存下来,或粗心地保存了其它地址。后续使用这段内存的代码可能错误地操作了其他内存单元,造成灾难性的后果。

0x03 绑定模型

📝 课堂笔记
我们刚才仔细地过了一遍堆栈内存管理模式。可以简单的总结一下: - 栈模式将函数与内存绑定,函数拥有内存,两者同生共死。 - 堆模式将函数与内存松绑,函数拥有内存的指针,两者联系微弱。 **使用栈模式管理内存:** - 好处是可以利用函数调用关系自动申请和释放内存 - 坏处是难以共享内存数据 **使用堆模式管理内存:** - 好处是可以方便地在函数间共享数据 - 坏处是必须手动进行内存申请和释放,有可能引发内存泄漏 - 更糟糕的是引入了巨大的值域,破坏了函数的单一职责

感谢这位前排同学的笔记。听课很认真。需要我帮你你写推荐信吗?

堆栈模式各有利弊。它们的本质区别是函数与内存的耦合程度不同(即:函数-内存绑定关系的强弱不同)。我们花费一整篇文章的篇幅,终于得出了这个结论。现在,可以探讨内存管理模式的更多可能了。

绑定模型

栈模式中的函数与内存耦合最强。不妨称它为“强绑定模型”。堆模式中的函数与内存耦合较弱。不妨称它为“弱绑定模型”。那么,除了强、弱这两种绑定模型以外,有没有其他的绑定模型?

尽管从操作系统的层面来看,并不存在除了堆或栈以外的结构(有的操作系统甚至没有堆)。但是高级程序语言可以在编译期或运行期给程序添加更多的约束,从而实现不同的绑定模型。程序员也可以通过遵循各种编程范式,从而间接达到不同绑定模型的效果。

在弱绑定模型中(例如堆),我们只允许函数持有内存的指针,因此决定内存释放的时机非常关键。如果我们稍稍加强耦合关系,例如:允许某一函数(称为 MainActor)持有一块内存,并且可以针对这段内存签发具有不同权限的证书。其余的函数(称为 Actors)只能凭借证书读取或修改这段内存。MainActor 返回时,这块内存被自动销毁。这就是经典的“所有权”模型,又叫做单一可信源 (Single Source of Truth)。你经常能在数据驱动型程序(例如网页、手机 APP)中看到这个模型的实践。

又比如,我们让内存只与特定的函数耦合:内存段在函数返回后移动到垃圾场,在一段时间后被特定函数集中销毁。这就是运行时垃圾回收(GC)机制。Java、Python、Go 等语言都在使用它。

此外还有非常多的绑定模型。我们将在后续的文章中慢慢接触它们。

总结

这篇文章主要探讨程序堆栈模式与内存管理的关系,即函数-内存耦合程度对内存管理带来的影响。在本系列的下一篇文章中,我们将从数据自身的角度,探讨“对象”与“生命周期”的概念。我们还将涉及更多有关内存安全性和垃圾回收机制的内容。

如果你喜欢这篇博客,可以通过 RSS 订阅更新。欢迎关注我的 GitHub 和推特账号,我将分享更多关于计算机系统安全的优质内容。如果你对这篇文章有任何疑问或建议,请在下方留言。再会~

细究内存管理
This article is part of a series.

© LICENSED UNDER CC BY-NC-SA 4.0

Loved this post? Consider following me.

I work on topics related to system security aside with many other interesting things. I would love to have friends who share the same interests.

Wish you happy