前置知识:

  1. 指针
  2. 动态内存管理

C语言内存模型简介

考虑到不同实现的差异,这里主要描述的是C程序的内存模型,而不是具体操作系统(具体实现)的内存模型(例如ELF的内存分区).

C语言的内存模型大致分为如下几块:栈区,堆区,全局/静态存储区,常量区,代码区.
需要注意的是: 由特定操作系统/编译器的实现不同,具体实际的内存分区也是有所不同的,以下讲解的分区,是基于C语言层面的内容,而和真正环境下载入内存中的程序结构有所差异. 在编写C语言代码的时候,我们一般只需要关心这几个层面的分区即可.

栈区

我们知道,一个C程序有着许许多多的函数,而函数中有许多局部变量,这些局部变量就存储在栈区中.

当一个函数被调用时,程序就会为这个函数创建一个栈帧(初学者无需太关注,了解即可),这个栈帧中的内存区域就用于存储各个局部变量/函数参数等.
而当这个函数返回时,即"退出"这个函数,此时这个栈帧就会被释放掉,对应地,里面的局部变量也会被释放掉.这就是为什么"一个函数的局部变量只能在该函数内部访问"的原因.

当然,一个函数可能有一些形参(如果不知道这是什么请复习"函数"一章),这些形参的处理方式和普通的局部变量有所不同. 但是总之,他们在使用上和局部变量没什么两样,他们同样是属于某个函数栈帧,也就是说,他们也是在栈区中分配的.

此外,栈区的大小也是有限的,根据编译器的不同而有所不同,当然也可以在编译的时候进行指定. 例如Linux中(64位)默认栈的大小为10MB等.

堆区

根据"15_1"一章的学习,我们知道C语言中所谓的"动态内存",实际上就是堆内存,他们是在堆区中分配的.

堆区与栈区不同,他的内存非常大,总大小为机器的虚拟内存大小. 其中的内存需要手动进行申请,并且在使用后手动释放,如果未手动释放,则在程序结束后由操作系统回收.
堆内存一旦分配,在整个程序运行期间都是有效的,而不会像栈内存一样在函数执行结束即销毁.

全局/静态存储区

我们后面会学习到static关键字,该关键字可以在函数中声明静态局部变量.
与普通的局部变量不同,静态局部变量不会随着函数执行结束而销毁,而是仍然保留其值,在下次调用这个函数时仍然生效,不会重新分配.

静态存储区内的变量在程序编译阶段已经分配好内存空间并初始化,主要存放静态变量和全局变量.

常量区

这里会存储一些程序中使用到的常量,字面量const变量.

  • 所谓常量,即是一些整数,浮点数,字符,例如1,3.14,a这些,它们都是常量.

  • 所谓字面量,大多数为字符串字面量,例如"hello world"等,这些直接写入到程序中的字符串就是字符串字面量.
    而另一种字面量,则是复合字面量,简单地说就是一个指定类型的无名对象,例如一个无名的结构体对象.(关于这些细节详见Cppreference文档)

  • const变量,如前所述,则为由const修饰的变量,其值是不可变的,因此往往视为"常量".

它们被存储于常量区,C程序可以直接访问并使用它们.

必须注意的是,这种存储并不是绝对的,例如简单的整数会直接硬编码到汇编指令中,而根本没必要单独存储;再例如简单const变量,编译器往往会将其直接进行优化,硬编码到程序指令中,而不会单独进行存储.

此外,所谓的常量区是取决于具体实现的,例如在Linux系统中,常量可能会被存储在只读内存段.rodata中;而在Windows中,常量则存储于.rdata中. 当然,很多简单的整数,字符常量往往会直接硬编码到指令中.

代码区

代码区中存放的就是我们的程序代码,编译器编译好的二进制程序指令就存放在此处. 当我们的程序开始执行时,程序会从代码区的一个特定位置开始,按照代码逻辑顺序逐步执行,并且根据逻辑需要去访问特定的内存数据.

学习过函数指针后,我们知道,C语言的函数是可以寻址的,实际上,函数指针指向的就是内存中的一段代码,他们就是存储在代码区的. 在实际的操作系统中,代码区往往被实现为.text段.

C语言的对象

C语言中,对象指的是执行环境中数据存储的一个区域, 其内容可以表示.
指的是对象的内容转译为特定类型时的含义.

每个对象拥有如下的信息;

  • 大小: 一个对象的大小即为其占用的字节数, 可以使用sizeof确定
  • 对齐要求: C11起可以使用_Alignof确定
  • 存储期: 包含有自动, 静态, 分配, 线程局域
  • 生存期: 等于存储期或临时
  • 有效类型: 即该对象以何种类型解释为合法, 例如变量的类型.
  • 值: 其值可以为不确定的
  • 可选项: 表示该对象的标识符

一个对象由声明, 分配函数, 字符串字面量, 复合字面量返回拥有数组类型的结构体或联合体的非左值表达式创建

作用域

C语言的每个标识符都只能在一些可能不连续的部分可见(即可使用),这些部分被称为其作用域.
注: 在作用域内,标识符仅在不同命名空间中, 才可以指代多于一个实体.

C语言拥有4种作用域:

  • 块作用域
  • 文件作用域
  • 函数作用域
  • 函数原型作用域

块作用域

块作用域是任何在复合语句, 包含函数体(或出现于if,switch,for,while或do-while语句中(C99起))的任何表达式, 声明或语句, 或在函数定义内的参数列表中声明的标识符的作用域.

在声明点开始,在声明于其中的块或语句的结尾结束.
例如:

1
2
3
4
5
6
7
8
9
10
11
void f(int n) {                           // 函数参数 'n' 的作用域开始
// 函数体开始
++n; // 'n' 在作用域中并指代函数参数
// int n = 2; // 错误:不能在同一作用域重声明标识符
for (int n = 0; n < 10; ++n) { // 循环局域的 'n' 的作用域开始
printf("%d\n", n); // 打印 0 1 2 3 4 5 6 7 8 9
} // 循环局域的 'n' 的作用域结束
// 函数参数 'n' 回到作用域
printf("%d\n", n); // 打印参数的值
} // 函数参数 'n' 的作用域结束
int a = n; // 错误:名称 'n' 不在作用域中

如果您学习的是C89标准,那么必须注意下面这个例子:

1
2
3
4
5
6
enum {a, b};
int different(void) {
if (sizeof(enum {b, a}) != sizeof(int))
return a; // a == 1
return b; // C89 中 b == 0,C99 中 b == 1
}

在C99之前,选择和迭代语句不建立其自身的块作用域,因此,上面的代码在C89时b的值为0,即if语句中的枚举类型; 而在C99时b的值为1.

块作用域变量默认无链接(见下)并拥有自动存储期(见下), 需要注意的是VLA局部变量的存储期在进入块时开始,但在见到声明之前,该变量不在作用域中且不能访问.

文件作用域

在任何块或形参列表外声明的任何标识符的作用域为文件作用域, 在声明点开始, 翻译单元尾结束.

例如:

1
2
3
4
5
int i; // i 的作用域开始
static int g(int a) { return a; } // g 的作用域开始(注意 "a" 拥有块作用域)
int main(void) {
i = g(2); // i 和 g 在作用域中
}

文件作用域的标识符默认拥有外部链接静态存储期.

函数作用域

声明于函数内部的标号(且只有标号)在该函数中的所有位置都在函数作用域内.

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
void f()
{
{
goto label; // label 在作用域中,尽管之后才声明
label:;
}
goto label; // 标号忽略块作用域
}
 
void g()
{
goto label; // 错误:g() 中 label 不在作用域中
}

函数原型作用域

非函数定义函数声明的形参列表中引入的名称的作用域为函数原型作用域, 在函数声明器的结尾结束.
例如:

1
int f(int n, int a[n]); // n 在作用域中并指代第一形参

注意: 若声明中有多个或嵌套声明器, 则作用域在最近的外围函数声明器的结尾结束:

1
2
3
4
5
6
7
8
9
void f ( // 函数名 'f' 在文件作用域
long double f, // 标识符 'f' 现在在作用域中,隐藏文件作用域的 'f'
char (**a)[10 * sizeof f] // 'f' 指代第一形参,它在作用域中
);
 
enum{ n = 3 };
int (*(*g)(int n))[n]; // 函数形参 'n' 的作用域在其函数声明符的结尾结束
// 数组声明器中,全局 n 在作用域
// (这声明指向返回 3 个 int 的数组的指针的函数的指针)

此外, 作用域是可以嵌套的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 此处的命名空间为通常标识符。
 
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

注:

存储期

存储期(storage duration)描述(限制)了对象的生存期, C语言有四种存储期:

  • 自动存储期: 当进入对象所声明于其中的时分配其存储, 而当退出该块时(return, goto, 抵达结尾)解分配存储.
    如果该块是递归进入的, 那么每层递归都会进行新的分配.
    一个例外是, C99起的VLA是在声明时分配的, 而非在块入口分配.
  • 静态存储期: 该存储期是整个程序的执行过程, 只有在main函数执行之前进行一次初始化.
    所有声明为static对象和所有带内部和外部链接且不声明为_Thread_local(C23前)的对象都拥有次存储期.
  • 分配存储期: 使用动态分配函数进行分配和解分配的堆内存拥有此存储期.
  • 线程存储期: 该存储期是创建对象的线程的整个执行过程. 在启动线程时初始化存储于对象的值.

最常见的是前三种存储期, 简单来讲, 自动存储期对应了函数/块中的局部变量, 而静态存储期对应了static修饰的变量, 分配存储期则对应了我们使用malloc等函数分配的堆内存空间.

链接

链接指的是标识符(变量或函数)可从其他作用域指代的能力.
C语言有三种链接: 外部链接, 内部链接或无链接.

  • 无链接: 所有的函数形参以及非extern的块作用域变量(包括声明为static的变量)都为无链接.
  • 内部链接: 能够在一个翻译单元的所有作用域指代的标识符具有内部链接, 所有static的文件作用域标识符都为内部链接.
  • 外部链接: 整个程序中任何翻译单元都能指代的标识符具有外部链接, 所有的非static函数, 所有extern变量(除非之前声明为static)和所有的文件作用域的非static变量都为外部链接.

注: 所谓翻译单元由一个源文件及其所包含的头文件构成.

参考资料

——WAHAHA 2024.4.28



上一篇:C语言教程-15_1-动态内存分配
下一篇:C语言教程-16_1-文件与文件操作