C语言内存管理 原创
@toc
C语言内存管理
内存是能够与CPU直接交换数据的内部存储器,程序己身及其访问的数据、运算结果都在内存之中。
1. 内存布局
粗略来讲,一个进程的内存布局通常如下所示:
用户空间的地址虽然从0开始,但通常情况下,开头的一段内存地址系统会保留,不会分配给用户使用。因此可以通过判断地址是否为0,来确定对象是否有效。
通过如下代码,简要分析程序运行过程中,内存分布情况。
#include <stdio.h>
#include <stdlib.h>
int globalVariable;
const int constGloablVariable = 0;
void func()
{
int localVariable;
printf("func address: %p\n", func);
printf("localVariable address: %p\n", &localVariable);
}
int main(int argc, char *argv[], char **envp)
{
printf("main address: %p\n", main);
printf("globalVariable address: %p\n", &globalVariable);
printf("constGloablVariable address: %p\n", &constGloablVariable);
static int staticVariable;
printf("staticVariable address: %p\n", &staticVariable);
const char *stringLiteral = "String Literal";
printf("stringLiteral address: %p\n", &stringLiteral);
printf("stringLiteral: %p\n", stringLiteral);
int *heapObject = malloc(sizeof(int));
printf("heapObject address: %p\n", &heapObject);
printf("heapObject: %p\n", heapObject);
func();
}
编译运行后,可能的输出:
main address: 0x40116b
globalVariable address: 0x40403c
constGloablVariable address: 0x402008
staticVariable address: 0x404040
stringLiteral address: 0x7fffebb4f208
stringLiteral: 0x4020a5
heapObject address: 0x7fffebb4f200
heapObject: 0x13f66b0
func address: 0x401136
localVariable address: 0x7fffebb4f1cc
1.1 栈
栈保存了一个函数调用所需的信息,一般包含如下内容:
- 函数参数及返回地址
- 非静态局部变量及运算过程中生成的其它变量
- 运行环境(上下文),包括调用前、后需要保存及恢复的内容
每次函数调用都会在栈空间生成具有以上信息的数据帧,称为堆栈帧或活动记录,并在函数返回时销毁。这就是栈空间内存不需要手动释放的本质。而堆空间对象显然不具备此种特质,因此需要手动释放。
栈空间通常只有几兆字节,因此要尽量避免在栈空间分配过多以及较大的对象。并且递归可能会导致栈内存溢出。
1.2 堆
栈对象会随着函数的返回销毁,如果只使用栈,当需要在不同的函数之间传递数据时,就会显得有些促狭。因此,需要一块由自己做主的地盘,这就是堆。
堆对象都是不具名的,只能通过指针访问。在上面的代码中,heapObject保存了一个堆对象的地址,而heapObject本身是栈对象。亦即heapObject不是堆对象的名称,堆对象不具名,只有解引用后的*heapObject才是堆对象(C++引用表面上可以为堆对象具名,实则底层实现依然是位于栈空间的指针对象)。哪怕栈对象heapObject因为函数返回而释放,堆对象*heapObject依然存在。只需要将heapObject的值传递下去就可以随时随地访问。
1.3 静态存储
静态存储区可读可写,通常保存全局变量以及静态变量这类伴随着程序整个生命周期的可读写对象。
1.4 只读存储
只读存储区只读,通常保存程序代码及全局常量。并且保存程序代码的代码段可执行。字符串字面量就位于只读存储区域。
1.5 内存映射
内存映射是一种将外部存储器数据映射到内部存储器的方法,从而可以通过直接访问内存地址来访问外部存储器数据。
内存映射通常通过缺页中断来实现。当访问内存映射内存时,因为无法找到与之实际对应的物理内存,此时将产生一个缺页中断。处理这个中断的函数就会根据映射关系访问外部存储器,完成操作。
2. 作用域
C语言中出现的每个标识符,都可能仅在一些不连续的部分可见,这些部分既其作用域。C语言具有如下几种作用域:
- 块作用域
块作用域指所有复合语句,包括
- 函数体
void fun() {
/* 块作用域 */
}
- 以及位于if
if (/* 块作用域 */) {
/* 块作用域 */
}
if (/* 块作用域 */)
/* 块作用域 */;
- switch
switch(/* 块作用域 */) {
case 常量: {
/* 块作用域 */
break;
}
}
- for
for (/* 块作用域 */) {
/* 块作用域 */
}
for (/* 块作用域 */)
/* 块作用域 */;
- while
while (/* 块作用域 */) {
/* 块作用域 */
}
while (/* 块作用域 */)
/* 块作用域 */;
- do-while
do {
/* 块作用域 */
} while (/* 块作用域 */);
do /* 块作用域 */;
while (/* 块作用域 */);
语句中的任何表达式、声明或语句,或
- 函数定义内参数列表
void fun(/* 块作用域 */) {
/* 块作用域 */
}
中声明的标识符的作用域。标识符作用域于声明点开始,于其声明的作用域末尾结束。
2 函数作用域
声明于函数内部的标号,函数中的所有位置都在其作用域内。
void fun()
{
goto label;
{
goto label;
}
if (1)
goto label;
/*
······
*/
label:;
}
3 函数原型作用域
非函数定义的函数声明参数列表中的标识符,其作用域在函数声明末尾结束。
int func(int n, // n在作用域中并指代第一参数
int a[n]);
当声明中有多个或嵌套声明器,则作用域在最近的外围函数声明器的结尾结束。
void f( // 函数名f在文件作用域
long double f, // 此处声明的标识符f在函数原型作用域,隐藏文件作用域的f
char (**a)[10 * sizeof f] // f指代函数原型作用域声明的f
);
4 文件作用域
文件作用域指任何块或参数列表之外的区域声明的标识符的作用域,于其声明点开始,于编译单元末尾结束。
int i; // i的作用域开始,具有文件作用域
static int g(int a) { return a; } // g的作用域开始,具有文件作用域
int main(void)
{
i = g(2); // i和g在作用域中
}
一般一个源文件为一个编译单元,头文件在预编译阶段会嵌入其引用位置。
同一作用域的同一标识符只能被声明一次。
int func(int a)
{
int a; // 错误,重复声明
int b;
char b; // 错误。重复声明
}
2.1 嵌套作用域
作用域可以嵌套,若同一标识符在内外几层作用域中被重复声明,则在内层作用域内,内层作用域声明覆盖外层作用域声明。
int a; // 名称 a 的文件作用域始于此
void f(void)
{
int a = 1; // 名称 a 的块作用域始于此;隐藏文件作用域的 a
{
int a = 2; // 内层 a 的作用域始于此,隐藏外层 a
printf("%d\n", a); // 内层 a 在作用域中,打印 2
} // 内层 a 的块作用域终于此
printf("%d\n", a); // 外层 a 在作用域中,打印 1
} // 外层 a 的作用域终于此
void g(int a); // 名称 a 拥有函数原型作用域;隐藏文件作用域的 a
2.2 声明点
- 结构体、联合体及枚举标签(类型名称)的作用域,在声明该标签的类型指定符中的标签出现后立即出现。
struct Node {
struct Node *next;
};
- 枚举常量的作用域在枚举列表中其定义的枚举项后立即出现。
enum {
Zero,
Ling = Zero
};
- 其它标识符的作用域,在其声明器之后、初始化器之前开始。
int x = 2;
{
int x[x];
/* 括号内的x在数组x声明器内,不在声明器后,故为整型x。
虽然不是未定义行为,但是尽量避免写这种容易着打的代码。 */
}
int y = 2;
{
int y = y;
/* 后面的y在声明器之后,前面的y在初始化器(= y),故此作
用域的y值不确定,相当于以不确定的自身初始化自身。 */
}
- 特别的是,当声明的类型没有标识符时,如果之后存在标识符(如下所示的object、Type),则其作用域在类型名中标识符出现的地方之后开始。反之则无。
struct {
// 因为没有标识符,此处无法指代该类型
} object; // 类型作用域开始
typedef struct {
// 因为没有标识符,此处无法指代该类型
} Type; // 类型作用域开始
struct {
}; // 无作用域
作用域只与标识符的访问范围有关,与对象的生存期无关。离开作用域后,对象不一定会释放。
3. 生存期(生命周期)
生存期指对象存在、拥有常地址、保存其最后一次所赋值,及变长数组(VLA)保持大小的程序执行部分。
声明自动(auto)、静态(static)及线程本地存储(Thread Local Storage,TLS)的对象,其生存期等于其存储期。
具有分配存储期的对象,其生存期始于包括重分配(realloc等)在内的分配(malloc等)函数的返回,终于解分配(free等)或重分配函数的调用。
在生存期外访问对象属于未定义行为,其值不确定,可能引发段错误。
int *foo(void) {
int a = 17; // a拥有自动存储期
return &a;
} // a的生存期结束
int main(void) {
int *p = foo(); // p指向生存期结束后的对象(“悬垂指针”)
int n = *p; // 未定义行为
int length = 10;
{
int a[10]; // a的生存期开始
int vla[length]; // vla的生存期开始
} // vla的生存期结束
void *m = malloc(20); // *m的生存期开始
void *n = realloc(m, 30); // *m的生存期结束,*n的生存期开始
free(n); // *n的生存期结束
} // p,n,length,a,m,n的生存期结束
当非左值表达式返回的是包含数组的结构体或联合体对象时,该对象拥有临时生存期。其生存期于引用该对象的表达式求值时开始,于
- 下一序列点(C11之前),或
- 包含它的完整表达式或完整声明器结束时(C11开始)
结束。同时,对临时生存期对象的修改,属于未定义行为。
struct T { double a[4]; }; // 包含数组的结构体
struct T f(void) { return (struct T){3.15}; } // 函数表达式属于非左值表达式
double g1(double* x) { return *x; }
void g2(double* x) { *x = 1.0; }
int main(void)
{
double d = g1(f().a); // C11之前,未定义行为,生存期结束于g1调用前
// C11及之后,完整表达式,d = 3.15
g2(f().a); // 未定义行为,修改临时生存期对象
}
其中序列点指:
- 函数调用前
- 操作数求值后
- 完整声明末
- 完整表达式尾
- 函数返回时
4. 存储期
C语言的存储期分为四种,存储期在事实上限制着对象的生命期。
4.1 自动存储期
进入声明对象所在块时分配存储,以任何方式(goto、return、到达结尾)退出块时解分配存储。对于变长数组则在声明时分配存储,在推出作用域时解分配存储。若递归进入块,则对每个递归进行重新分配。所有的函数参数、非静态存储期对象,以及用于块作用域的复合字面量拥有自动存储期。
4.2 静态存储期
存储期是整个程序的执行周期,只在主函数(main)之前初始化。所有静态对象、带链接的非线程存储期对象拥有静态存储期。
4.3 线程存储期
存储期是所在线程的执行周期,在启动线程时初始化。每个线程都独立拥有于其它线程不同的独立对象。跨线程访问具有线程存储期的对象,其行为由实现定义。声明_Thread_local的对象拥有线程存储期。
4.4 分配存储期
通过动态内存分配函数分配和解分配的对象拥有分配存储期。
5 内存对齐
内存对齐的本质是因为处理器是按块(大小为2^n^字节)读写内存。
假设在内存模型为ILP32的环境下,内存要求4字节对齐。有一个int类型的变量,其地址为0x0005,那么处理器需要访问两次内存才能完成对这个变量的访问。先读取0x0004块,再读取0x0008块,再将他们拼接起来,取0x0005开始的4个字节。整个过程如果内存地址是4字节对齐的,那么只需要访问一次内存。
而在某些环境下,内存不对齐甚至可能导致程序崩溃(总线错误,SIGBUS)。
因此,可以认为内存对齐的目的是为了使一个对象尽量处于一个内存块中,加快内存访问速度。
本文部分描述引用cppreference
这个不错,很详细