Lecture 2: Memory Organization, Dynamic Memory Allocation, and Undefined Behavior¶
本页统计信息
-
本页约 2848 个字, 44 行代码, 预计阅读时间 10 分钟。
-
本页总阅读量次
1. 内存中的数据表示¶
我们已经了解程序及其数据在内存中只是一堆字节,但现在让我们更详细地了解数据在内存中的表示方式。
为什么要了解这个? 了解 C/C++ 程序的不同部分在内存中的存储位置可以帮助我们了解程序如何获取和管理内存,某些编程语言会自动执行这些操作,而其他编程语言,尤其是系统编程语言,如 C 和 C++,会强制您作为 程序员做一些这样的内存管理。 这为您提供了很大程度的控制,并允许避免昂贵的隐藏内存分配和复制成本。
我们通过下面这段代码来研究内存中的数据如何表示:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include "hexdump.h"
char global_ch = 'A';
const char const_global_ch = 'B';
void f() {
char local_ch = 'C';
hexdump(&global_ch, 1);
hexdump(&const_global_ch, 1);
hexdump(&local_ch, 1);
}
int main() {
f();
}
这段代码定义了好几个不同的变量,包括全局变量global_ch
,全局常量const_global_ch
和局部变量local_ch
- 对于全局变量来说,整个程序都可以访问这个变量
- 对于全局常量来说,const关键字向编译器表明,这个量的值不能改变
- 对于局部变量来说,它只有在它定义的范围内才是一个有效的变量,上面这个例子中,local_ch
的有效范围就是函数f内
这段代码中的hexdump是来自于另一个文件的函数,它的作用是打印出给定地址对应的内存位置的若干个字节(上面的代码里都是一个),而这段代码的运行结果如下:
00601038 41 // |A|
004009a4 42 // |B|
7ffd4977e80f 43 // |C|
这个输出结果中,左边是变量的地址,右边是该地址位置一字节的内容(十六进制表示),我们发现他们经过换算后就是ABC三个字符。 我们要关注的其实是左边的这几个地址,我们发现虽然三个量都是定义在一个程序里的,但是他们的地址好像差别很大,这是因为,变量在内存中的位置是由编译器和操作系统共同决定的,但对象所在的一般区域由其生命周期决定。
这里的对象object和面向对象编程中的对象的含义不同,这里的对象指的就是组成某个value的若干个字节。它可以是代码也可以是变量。
编译器在分配内存的时候,需要考虑变量/常量的生命周期,对于不同生命周期的变量采用不同的分配方式,以保证不需要的内存可以及时回收并重复利用。 - 全局变量 global_ch 和 const_global_ch 需要在程序的整个运行时都存在,因为程序可以在代码的任何位置引用它们,这称为静态生命周期。 - 局部变量 local_ch 需要一直存在直到它超出有效的范围,这发生在执行到达 f() 的右大括号时。 超出范围后,程序中没有代码可以引用该变量,因此可以重用它的内存。 这称为自动生命周期。 局部变量和函数参数具有自动生命周期。 但是,如果一个函数需要创建一个对象,其大小在程序开始时未知(因此它不能是全局的)并且还需要在函数结束后继续存在怎么办? 在这种常见情况下,静态生命周期和自动生命周期都不合适,我们需要动态的内存分配手段。
2. 动态内存分配¶
如果我们要在函数中创建一个变量,并且在函数返回之后也保持它的生命值,我们要怎么操作呢?一个很简单的办法是把它定义成全局变量,但是如果我们不知道这个变量有多大(比如要定义一个长度未知的数组),这时候又该怎么办呢?如果我们定义了一个全局变量,那么在定义的时候它占用的内存大小就已经确定了,所以当我们不知道变量有多大的时候该怎么办呢?
C语言中提供了一种动态生命周期(dynamic lifetime)的特性,对于具有动态生命周期的对象,我们必须显式创建和销毁该对象。也就是说,必须为该对象留出内存,并确保在不再需要该对象时再次释放该对象。
C语言的标准库中提供了一个malloc函数用来动态分配一段内存,它需要接受一个参数来代表需要分配的字节量,比如char* allocated_ch = (char*)malloc(1);
就可以为allocated_ch分配1个字节的内存并让allocated_ch指向对应的位置。括号中的 char* 是返回到我们期望类型的指针的强制转换(指向 char 的指针),可以用来告诉编译器,我们想要什么类型的指针。
malloc函数本身不知道你要分配什么样的对象:它只是要求操作系统提供一些字节数,并给你第一个字节的地址。它的返回类型是 void,这意味着“指向未指定内容的指针”。如果我们只是保持这样并尝试将 void 分配给 char* 类型的变量,编译器会报警。
动态生命周期对象的最大好处是,我们可以在运行时决定它们需要多大,并且它们可以比创建它们的函数活得更久。动态生存期对象的最大缺点是程序员有责任释放分配的内存,这可以通过调用 free函数并将object的地址作为参数来实现。
在Java这样的语言中,动态分配新的内存可以通过new关键字来分配,也不需要自己进行内存管理,在变量的生命周期结束之后,Java的虚拟机会执行垃圾回收机制自动回收不需要的内存。但是C和C++没有这些功能,而是将回收内存的决定权交给了程序员自己。
但是C和C++的这种特性也给程序员的编程水平带来了更高的要求,错误地使用动态生命周期是 C/C++ 程序中问题、错误和安全漏洞的一个非常常见的来源:内存泄漏、双重释放、释放后使用等严重问题。
3. 内存段¶
具有不同生命周期的对象会被分到内存中的不同区域。程序代码、全局变量和常量全局变量都存储在静态段中,因为它们在编译时都具有静态生存期和已知大小。而局部变量存储在自动段中,因为他们具有自动的生命周期,可以在生命周期结束后自动完成释放。 其他对动态类型的对象所包含的内存区域会变化。例如,当函数相互调用时,它们会创建越来越多的具有自动生存期的局部变量;由于程序调用 malloc为具有动态生存期的对象保留内存,因此这些对象需要更多的内存,它们所在的区域就是动态段。 一般来说,全局变量会位于低地址段,而局部变量会位于高地址段,动态分配的对象的地址则在他们之间。这种变量安置的方法的设计思路是,允许两种类型的内存段增长,而不会有相互妨碍的风险。特别是,随着更多局部变量进入,自动生存期段向下增长,而动态生存期段向上增长。如果两者相遇了,那就说明内存不足了。 动态段的大小会随着程序请求内存并再次释放内存而变化。此外,动态段中使用的内存不一定是连续的。考虑一个程序,它分配四个字符,c1 到 c4,然后释放第三个字符:假设字符从地址 0x1a00050 开始,内存将如下所示: c2 和 c4 之间的差距之所以出现,是因为 c3 的内存已被程序释放,但尚未被重用。换句话说,动态段中可以有“漏洞”(这称为碎片)。 这些不同的内存段有着各自的命名: - 静态生命周期段中保存程序的部分称为文本段(Text Segment),只读全局变量(RODATA)和可修改全局变量(数据和BSS) - 自动生命周期段称为栈。之所以如此命名,是因为它像一叠纸一样增长和收缩:当你调用一个函数时,它的自动生存期对象被添加到现有的对象左侧,当函数返回时,程序会再次放弃它们的内存。 - 动态生存期段称为堆,它通常根据内存地址向上增长。但与堆栈不同的是,它可以有“漏洞”:如果程序员销毁了内存中位于其他两个对象之间的对象,则中间可以有未使用的内存。
在Java里面,使用new关键字分配的对象也在堆中,不过Java的所有对象都具有自动生命周期,因为JVM中引入了引用计数,对没有引用的变量自动进行垃圾回收,只不过这也是有代价的,相比C/C++,Java的性能会更慢。
总的来说,这不同的内存段的区别可以用这张表概括:
4. 未初始化的内存¶
malloc向操作系统申请了一段内存之后,返回指向新分配内存的第一个字节的指针,那么这一段内存对应的内容是什么呢? 我们可以用下面这段代码作为例子:
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include "hexdump.h"
int main() {
char* allocated_st1 = malloc(100);
char* allocated_st2 = malloc(100);
sprintf(allocated_st1, "C programming is cool");
sprintf(allocated_st2, "Strings are sequences of bytes");
hexdump(allocated_st1, 100);
hexdump(allocated_st2, 100);
free(allocated_st1);
free(allocated_st2);
//char* allocated_again_st = malloc(100);
//sprintf(allocated_again_st, "C programming is cool");
//hexdump(allocated_again_st, 100);
}
这段代码做了这样几件事:
- 两次分配 100 字节的动态内存;
- 然后将两个字符串写入此内存并使用 hexdump() 打印它们;
- 然后使用 free释放内存,因此它现在未分配;
- 最后,程序再次请求 100 个字节,也许是另一个字符串,并打印该内存的内容(注释中的内容) 在这个过程中,动态分配的内存在分配好的时候可能是什么情况呢?事实上,任何情况都有可能,这些分配好的内存所对应的值可能是一些随机值,因为它们是未初始化的内存,这在C语言中属于未定义(undefined)的,所以发生什么情况都是有可能的。
很多时候,malloc之后的内存如果被free了,原本的内容还会被保留,如果之后访问到了这一段内存,可能还能找到原来的信息。在上面这个例子中,我们可以从输出结果中,看到了一些先前字符串的片段,这其实可能存在信息泄漏的风险。为了防止这种情况的出现,需要在调用free之前先覆盖掉原本内存里的内容。