第六章 C语言的内存模型
提起 C 语言,很多人的第一反应就是“指针“——它既是 C 语言最强大的武器,也是让无数初学者头疼的难题。你可能已经听说过指针的大名,甚至隐隐有些畏惧。但请放心:指针并没有那么神秘,它本质上操作的不过是内存地址而已。要真正理解指针,你首先需要理解一个更基础的问题——变量到底是“放“在哪里的。
回顾此前各章的学习,我们一直在和变量打交道:声明一个 int x,给变量赋值,把变量传给函数……当时我们反复提到“存储“这个词——变量是用来存储数据的容器。但你有没有想过这些更底层的问题:
- 变量究竟存储在计算机的什么地方?
- 为什么有的变量出了花括号就消失,有的却能一直存在?
scanf("%d", &x)里的&符号到底在做什么?- 既然所有变量都存在内存里,那它们之间不会互相打架吗?
这些问题的答案,都指向同一个核心主题——C 语言的内存模型。
理解内存模型,是你从“会写 C 代码“走向“真正理解 C 语言“的关键一步。本章将依次讲解:
- 内存地址直觉:把地址理解为一排编号格子——这是理解一切的基础
- 物理内存:程序运行的物理空间——以及操作系统面临的难题
- 虚拟内存:密室逃脱店的故事——操作系统如何用“逻辑编号映射“的思路解决冲突
- 典型进程内存布局:栈、动态分配区、静态数据和代码映射——理解常见实现,同时区分语言标准与平台细节
- 局部变量与全局变量:比较作用域、链接和存储期,而不是死记某个物理地址
static关键字:如何改变对象的存储期或标识符的链接属性
当你学完这一章,指针就不再是凭空出现的魔法——它只不过是一个存着地址的变量,而你已经知道了地址通向哪里。
一、内存概念
1. 内存地址——送外卖得知道门牌号
从第一章开始,你就在用 & 这个符号了。scanf("%d", &x) 里的 &x——它的意思是“取 x 的地址“。你可能记住这条规则用到了现在,但心里始终有个疑问:地址,到底是什么?
打个比方:你给客人送外卖,得知道客人住在哪——几号楼、几单元、几零几。这个门牌号,就是客人家的地址。没有地址,外卖送不到。
CPU 访问对象时也需要定位信息。在 C 语言抽象机中,指针可以表示对象或函数的位置;在常见机器上,它通常对应进程虚拟地址空间中的地址表示。门牌号是有用的直觉,但不要把 C 指针简单等同于未经转换的物理内存编号。
2. 物理内存——程序赖以运行的硬件
上一节说的那些位置,物理上就存在于电脑主板上的内存条里。
什么是物理内存。 你买电脑或手机时,配置单上一定见过这样的写法:笔记本 “16GB + 512GB”,手机 “12GB + 256GB”。前面的 16GB、12GB 就是物理内存(Physical Memory),后面的 512GB、256GB 是硬盘。
这里所说的物理内存通常指系统 RAM(Random Access Memory,随机存取存储器)。运行中的代码和数据会通过缓存、内存控制器和虚拟内存映射等机制被处理器访问。磁盘或闪存主要负责持久化存储,程序通常通过操作系统 I/O 或内存映射来访问其中的数据。
物理内存有四个关键特点:
- 延迟低、带宽高:RAM 通常比持久化存储更适合作为程序运行时工作区,但具体差距随硬件而变
- 容量相对有限:个人设备的 RAM 通常少于其持久化存储;具体容量没有固定范围
- 掉电就没了:关机之后,内存里的数据全部清空——因此代码要“保存到文件“,文件存在硬盘上,断电不丢
- 由系统统一管理:多个进程共享机器的内存资源,但通常通过各自的虚拟地址空间进行隔离
💡 内存(RAM) vs 硬盘(Storage)
内存(RAM) 硬盘 / 闪存 作用 程序运行时的临时工作空间 长期保存文件、照片、软件 速度 极快(CPU 可直接读写) 较慢(需要 I/O 操作) 断电后 数据全部消失 数据依然保留 容量 通常相对较小 通常相对较大
物理内存带来的管理难题。 这么多程序共用一条物理内存——如果不加管理,程序 A 往某个格子写了数据,程序 B 不知道,也往同一个格子写,程序 A 的数据就被踩掉了。一个程序崩溃时乱写的垃圾数据可能搞坏另一个程序的内存,恶意程序甚至可以翻看公共格子偷走你的密码。
操作系统必须解决这个问题:怎么让多个程序安全、高效地共用同一块物理内存?
以密室逃脱店为例来理解
假如你开了一家密室逃脱店。你租了一层楼——就这么大,多一平米都没有。
你的密室主题很火,每天来好几批客人。最简单的想法:整层打通,做成一个大密室,所有客人都进这一个房间。
你很快发现了问题:
- 一批客人翻箱倒柜——打开柜子拿线索 A,顺手把线索 B 塞进另一个抽屉。这批人走了,下一批进来,看到的不是初始状态,是上一批留下的烂摊子。 道具位置全乱了,抽屉里多了一堆不该出现的东西
- 两批客人同时挤在里面——你翻你的柜子,我翻我的抽屉。A 队刚找到的关键道具被 B 队当成垃圾扔了。一个队把柜子弄坏了,另一个队要用的线索恰好锁在那个柜子里
同一个空间,所有人直接共享 = 互相干扰、互相破坏。一次只接待一批?后面的客人全堵门口排队。
这跟操作系统面对的问题,结构上一模一样:
| 密室逃脱店 | 计算机 |
|---|---|
| 一层楼的物理空间,所有客人共用 | 物理内存——所有程序共用一条内存条 |
| 一批客人 | 一个程序 |
| 客人翻道具、解谜、改布局 | 程序读写内存格子里的数据 |
| 大房间里互相踩踏 | 程序直接操作物理内存 → 踩数据、崩溃传播、隐私泄露 |
不加管理,直接让程序操作物理内存,就像让所有客人挤在一间密室里——谁也玩不好。
操作系统的答案和密室老板一样:给每组客人一套独立的编号体系。 让每个程序只看自己的道具编号,背后由一张总平面图负责把编号翻译到实际的物理位置——这就是虚拟内存。
3. 虚拟内存——每个程序都有自己的独立空间
什么是虚拟内存
虚拟内存(Virtual Memory)是现代操作系统常见的一种内存管理机制。它让每个进程面对一套自己的虚拟地址空间。这个空间在编号上覆盖一个很大的范围,但并非其中每个地址都有效:零地址附近通常故意不映射,中间也可能存在空洞,代码、共享库和动态内存的位置还可能受 ASLR 影响。
对普通程序来说,它主要操作虚拟地址,不需要知道数据当前对应哪一页物理内存。有效的虚拟地址区间可以在程序看来连续,即使其背后的物理页并不连续;没有建立映射的虚拟地址则不能访问。
虚拟内存如何工作
程序读写数据时,给出的是一个虚拟地址。但数据实际存放在物理内存条上的某个物理地址。虚拟地址到物理地址的翻译,由 CPU 内部的一个专用硬件自动完成——MMU(Memory Management Unit,内存管理单元)。
MMU 翻译的依据是操作系统维护的一张页表(Page Table)。页表记录了“程序 A 的虚拟地址 X → 物理地址 Y“这样的映射关系。翻译过程如下:
- 程序给出虚拟地址:“我要读写 100 号”
- MMU 查页表,把 100 翻译成物理地址 38492
- CPU 去物理内存条上真正读写 38492 号位置
- 程序从头到尾不知道 38492 的存在
几个关键术语:
- 虚拟地址(Virtual Address):程序看到的地址编号
- 物理地址(Physical Address):内存条上的真实位置编号
- MMU:CPU 内部的硬件翻译器,自动完成地址转换
- 页表(Page Table):操作系统维护的映射表,记录每个虚拟地址对应的物理地址
虚拟内存的设计目的
虚拟内存要达成两个核心目标:
- 隔离:不同进程默认拥有不同的地址映射,同一个虚拟地址在两个进程中通常对应不同的物理位置。操作系统也可以显式建立共享内存映射,所以“隔离”是默认机制,不是绝对禁止共享。
- 简化:程序使用统一的虚拟地址模型,不必自行协调物理内存位置。具体布局由操作系统、加载器、ABI 和编译工具链共同决定,并不保证“代码固定在低地址、栈固定在高地址”。
💡 交换(Swap)
支持交换或分页文件的操作系统,可以把部分暂时不用的内存页写入持久化存储,腾出 RAM 给活跃页面。虚拟地址通常保持不变,但再次访问可能触发缺页并产生明显延迟。具体是否启用、如何实现由操作系统配置决定。
虚拟地址空间的大小
32 位指针若使用完整的 32 位地址表示,理论上可区分 2³² 个字节地址,但单个进程真正可用的范围还会受操作系统划分和 ABI 限制。64 位架构的理论编号空间更大,实际硬件和操作系统通常只实现其中一部分。可移植 C 程序不应依赖某个固定的虚拟地址空间大小。
以密室逃脱店为例来理解
还记得密室逃脱店老板的困境吗?他的解决方案——给每组客人一套独立的道具编号体系——就是虚拟内存的核心思路。
老板给每个房间放进了完全相同的一套密室主题。同样外观的道具、同样的线索摆放方式、同样的陈设布局。关键设计是:每个房间里的道具都有本地编号——“1号抽屉”“2号柜子”“3号暗门”……每个房间的编号体系完全一致,但所有这些编号都是逻辑上的,只在这间房里有效。
现在分别从两个视角来看:
玩家视角——程序的视角:
- 每批客人走进房间,看到标准初始状态——道具在原位,线索没被动过
- 客人按线索操作时只看本地编号:“打开 3 号暗门”——他不知道也不需要知道,这条指令最终落在大楼的哪面墙、哪块地砖上
- 隔壁有没有客人弄坏道具、有没有客人提前走了——完全无关
- 通关离开后,老板重置房间状态,给下一批客人使用(内存回收)
老板视角——操作系统的视角:
- 老板手里有一张楼层总平面图(页表)。A 房的“3 号暗门“实际对应大楼北侧第 5 块区域,B 房的“3 号暗门“实际对应大楼南侧第 11 块区域。同样编号,物理位置完全不同
- 客人说“开 3 号暗门“ → 老板查平面图找到真实位置 → 告诉客人去哪操作(MMU 翻译)
- 客人来了 → 给一套干净编号体系。客人走了 → 回收编号,重置对应区域(内存回收)
- 某间房客人暴力破坏(程序崩溃)→ 没事,不影响其他房间
- 道具太多摆不下 → 暂时不用的搬进地下仓库(swap)
映射回计算机:
| 密室逃脱店 | 计算机 |
|---|---|
| 房间内的本地编号(1号抽屉、2号柜子……) | 虚拟地址——程序看到的编号 |
| 大楼实际的墙、地砖位置 | 物理地址——内存条上的真实位置 |
| 老板手里的楼层总平面图 | 页表 |
| 老板查图,把房间编号翻译成实际位置 | MMU 翻译虚拟地址 → 物理地址 |
| 各组客人互不干扰 | 隔离——不同进程的地址空间相互独立 |
| 客人通关后老板重置房间 | 程序结束,操作系统回收内存 |
| 搬道具进地下仓库 | Swap——交换到硬盘 |
⚠️ 比喻的局限——请务必注意
密室逃脱店用房间来隔离客人,每个房间的物理面积必然小于整层楼。但计算机中的虚拟地址范围可以大于实际安装的 RAM,而且大量地址可以暂时不映射到任何物理页。
这不是比喻的失误,而是现实世界里任何物理比喻都绕不开的限制——你不可能隔出一间比整层楼还大的房间。虚拟内存的本质不是物理切割,而是逻辑映射:你程序里看到的地址永远是逻辑编号,MMU + 页表在背后默默把它翻译成物理内存上的真实位置。记住这一点就好——比喻只是帮你建立直觉,不代表物理世界的约束同样适用于计算机。
串起来:
int score = 95;——它到底在哪?
- 对象大小:
score占sizeof(int)个字节;这个值由实现决定,不固定假设为 4- 程序视角:
&score取得指向该对象的指针,常见实现把它表示为虚拟地址- 平台映射:操作系统与硬件负责把有效虚拟页映射到物理存储,程序不应依赖具体物理位置
一句话:让每个程序以为自己独占整台电脑的内存——这就是虚拟内存的设计初衷。
在具有进程和虚拟内存的常见宿主系统上,每个进程通常拥有自己的虚拟地址空间。裸机和部分嵌入式环境可能没有这种机制,因此它不是 C 语言本身提供的保证。
在常见桌面和服务器系统上,编译器、链接器、加载器与运行库会按照平台 ABI 组织代码和数据。下面介绍的是典型实现中的进程内存布局,它很有助于理解程序运行,但不是 C 语言标准强制要求的物理分区。
4. C 程序的典型内存布局——四类常见区域
为什么要分区域?因为你程序里的数据,性格各不相同:
- 临时的数据:函数里算个中间结果,算完就不需要了。这类数据应该自动清理,不能一直占着空间
- 持久的数据:记录”程序总共被打开了几次”的计数器,从启动到退出都不能丢
- 大小未知的数据:用户输入了一行文字,多长事先不知道。编译时没法预留——你不可能猜”用户大概输入 100 个字”然后留 100 个格子。必须运行时按需分配
- 代码本身:编译好的机器指令,运行时不应该被意外修改——否则程序逻辑就乱了
如果把这些性格迥异的数据全混在一起管理,要么浪费空间,要么互相干扰。不同的需求,推动了不同的分区。
说明:下面的四区模型是常见操作系统(Windows、Linux、macOS 等)上 C 程序的典型内存布局。它不是 C 语言标准强制规定的,但理解它对于学习指针、动态内存和后续章节至关重要——就像你不需要知道 CPU 内部的晶体管怎么排布,但需要知道 CPU 有运算器和控制器一样。
4.1 全景图:四个区域的位置关系
在动手写代码之前,先在脑子里建立一张”地图”。一个典型 C 程序的虚拟地址空间,从高地址到低地址,四类区域依次排列:
- 高地址段 → 栈区(Stack):向低地址方向增长。存放局部变量、函数形参、返回地址。容量有限,自动管理,速度极快。
- 中间段 → 未映射区域:栈和堆之间的”安全距离”,防止两者互相撞上。
- 中间段 → 堆区(Heap):向高地址方向增长。存放
malloc申请的动态内存。空间很大,手动管理,速度较慢。 - 低地址段 → 静态存储区(.data / .bss):存放全局变量和
static变量,贯穿整个程序生命周期。 - 低地址段 → 代码区(Text):存放编译后的机器指令,只读。
几个关键观察:
- 栈从高地址向低地址增长,就像一叠从上往下放的盘子
- 堆从低地址向高地址增长,就像仓库里从门口开始往里堆货
- 栈和堆相向而行,中间的未映射区域是”缓冲地带”——如果栈或堆增长得太疯狂,它们会撞到一起,但操作系统通常会在越界时直接终止程序,而不是让它们真的互相覆盖
- 代码区和静态存储区在低地址段,布局在程序启动时就基本确定了
下面这张图用另一种视觉风格展示了同样的布局:
有了这张地图,接下来逐个认识四个区域。
4.2 栈区(Stack)—— 自动管理的”叠盘子”
栈区是你写 C 代码时打交道最多的区域。你到目前为止声明的几乎所有变量——函数里的 int x、float y、char name[20]——都住在栈上。
栈上放了什么?
具体来说,以下这些东西通常都在栈区:
- 局部变量:你在函数体(
{ })内部声明的变量,比如int a = 10; - 函数的形参:函数参数列表里的变量,比如
void func(int x, float y)中的x和y - 返回地址:函数执行完后,CPU 需要知道”回哪儿去”——这个返回地址也保存在栈上
- 寄存器的备份值:函数可能会用到一些 CPU 寄存器,调用之前的值需要暂存起来
一言蔽之:每次函数调用,都会在栈上开辟一块空间,把这次调用需要的所有”家当”——参数、局部变量、返回地址——打包放进去。这块空间叫做栈帧(Stack Frame)。
栈的工作方式:盘子的故事
栈的管理方式,和你在厨房洗碗时叠盘子一模一样:
- 洗完一个盘子,叠在最上面
- 要用盘子时,从最上面取
- 最后放上去的,最先拿下来——这正是”后进先出”(LIFO,Last In First Out)
函数调用完美契合这个模式。来看一个具体例子——三层函数嵌套调用:
#include <stdio.h>
void func_b(int num)
{
int result = num * 2; // func_b 的局部变量
printf("func_b: %d\n", result);
} // func_b 返回 → 它的栈帧被"拿掉"
void func_a(int x, int y)
{
int sum = x + y; // func_a 的局部变量(形参 x, y 也在栈上)
func_b(sum); // 调用 func_b → func_b 的栈帧叠上去
printf("func_a 完成\n");
} // func_a 返回 → 它的栈帧被"拿掉"
int main(void)
{
int a = 10; // main 的局部变量
func_a(a, 20); // 调用 func_a → func_a 的栈帧叠上去
return 0;
} // main 返回 → 它的栈帧被"拿掉"
程序运行时,栈的变化过程如下:
- ① main 开始执行:在栈上创建
main的栈帧,里面有局部变量a = 10 - ② main 调用
func_a(10, 20):在main的栈帧上面叠放func_a的栈帧,里面有形参x = 10, y = 20和局部变量sum = 30 - ③ func_a 调用
func_b(30):在func_a的栈帧上面再叠放func_b的栈帧,里面有形参num = 30和局部变量result = 60 - ④ func_b 返回:
func_b的栈帧从栈顶整个移除,控制权交还给func_a,func_a继续执行 - ⑤ func_a 返回:
func_a的栈帧也被移除,控制权交还给main
每次函数返回,它在栈上的”家当”就自动清空,不需要你写任何代码来释放——这就是”自动管理”的含义。
栈的特点
(1)容量有限。 栈的大小由操作系统和编译器预先确定,通常在 1 MB 到 8 MB 之间(具体数值因平台而异)。这意味着:
- 声明一个超大的局部数组会让栈直接爆掉(栈溢出,Stack Overflow)
- 递归调用如果深不见底,每一层都叠一个新栈帧,叠到一定数量也会溢出
// 危险示例:局部数组太大,可能导致栈溢出
void bad_function(void)
{
int huge[1000000]; // 100 万个 int ≈ 4 MB,很可能超过栈容量
// ...
}
// 危险示例:无限递归
void infinite_recursion(void)
{
infinite_recursion(); // 永远不返回,栈帧不断叠上去,直到溢出
}
栈溢出通常导致程序直接崩溃——这是操作系统在保护你的系统不被一个跑飞的程序搞垮。
(2)速度极快。 栈的操作只需要一条 CPU 指令:移动栈指针(Stack Pointer,一个记录栈顶位置的寄存器)。叠盘子 = 栈指针往下移一格;取盘子 = 栈指针往上移一格。没有搜索、没有查找、没有记账——纯粹的指针加减。这是整个计算机系统中最快的内存管理方式。
(3)自动管理。 你不必手动释放栈上的变量——函数返回时,编译器自动生成的代码会把栈指针移回调用前的位置,”销毁”栈帧里的一切。你只管声明变量,编译器包办生老病死。
4.3 堆区(Heap)—— 按需申请的”储物仓库”
栈很好——又快又自动化——但它有几个根本性的局限。堆区正是为了弥补这些局限而存在的。
为什么光有栈不够?
栈搞不定的三种典型场景:
场景一:编译时不知道要多少空间。 栈上的空间在编译时就确定了——编译器看到 int arr[100] 就预留 100 个 int 的大小。但如果用户运行程序时才告诉你”我要处理 5000 条数据”,编译器事前根本不知道,怎么预留?
int n;
scanf("%d", &n);
int arr[n]; // 可变长数组(VLA)在某些 C 标准下可用,但不推荐
// 而且 VLA 通常也在栈上分配,n 太大就会栈溢出
场景二:数据需要比函数调用活得更久。 栈上的数据,函数一返回就没了。但如果一个函数负责”创建一份数据”,另一个函数稍后”处理这份数据”——这份数据就不能跟着创建它的函数一起死掉。
// 错误示范:返回局部变量的地址(后面学指针时会深入讲)
// 函数返回后,栈帧被销毁,局部变量不复存在
场景三:数据量太大,栈根本装不下。 即使编译时知道大小,如果要处理 100 万条记录(几 MB 甚至几十 MB),栈的容量也远远不够。
这三种场景的共同答案是:堆区。堆区允许你在程序运行时,按需向操作系统申请任意大小的内存,用完之后手动归还。
堆的工作方式:储物仓库的故事
把堆想象成一个大型储物仓库,操作系统是仓库管理员。你需要存放东西时:
- 申请:告诉管理员”我需要一个能放 N 件货物的货架”——这是
malloc(N)做的事 - 拿到货架号:管理员在仓库里找到合适的空位,给你一个货架编号(指针),之后你凭这个编号存取货物
- 存取:拿着货架号去对应位置放东西、取东西
- 退租:用完了,告诉管理员”这个货架我不需要了”——这是
free()做的事
关键区别在于:栈上的盘子,你只管用,服务员自动收;堆上的仓库,你自己申请,也必须在用完后自己退租。 忘了退租?仓库空间被占着不还,这就叫内存泄漏。
来看一段代码,直观感受堆的用法:
#include <stdio.h>
#include <stdlib.h> // malloc 和 free 需要这个头文件
int main(void)
{
int n;
printf("请输入学生人数:");
scanf("%d", &n);
// 在堆上申请 n 个 int 的空间(运行时才知道 n 是多少)
int* scores = (int*)malloc(n * sizeof(int));
// 安全检查:malloc 可能失败(比如内存不够了)
if (scores == NULL)
{
printf("内存分配失败!\n");
return 1;
}
// 像普通数组一样使用堆上的空间
for (int i = 0; i < n; i++)
{
scores[i] = (i + 1) * 10; // 填入数据
printf("学生 %d 的成绩:%d\n", i + 1, scores[i]);
}
// ★ 关键一步:用完之后归还空间
free(scores);
return 0;
}
提示:
malloc和free的详细用法将在第十章中展开讨论。这里你只需要建立两个直觉:(1) 堆是”需要时申请,用完归还”的内存区域;(2)scores这个指针变量本身在栈上,但它指向的数据在堆上——指针只是一张写着货架号的小纸条。
堆的特点
(1)容量大。 堆可以利用几乎整个虚拟地址空间——动辄几 GB,远超栈的几 MB。处理大量数据时,堆是不二之选。当然,实际能分配的量受物理内存 + 交换空间总和限制,不是无限的。
(2)速度较慢。 和栈的”移动指针就搞定”不同,malloc 需要在仓库里找到一个合适的空闲货架,登记为”已占用”,返回货架号;free 则需要把货架标记回”空闲”,还可能合并相邻的空闲区域。这些”查找 + 记账”操作让堆分配比栈分配慢上一到两个数量级。一般情况下不必过度担心这个差距,但在性能敏感的代码中值得留意。
(3)手动管理。 这是堆区别于栈的最关键特征——程序员掌握生杀大权。申请了忘记释放 → 内存泄漏(memory leak),程序占用的内存越来越多;释放了还继续用 → 悬空指针(dangling pointer),行为不可预测,查 bug 查到怀疑人生。这也是为什么很多现代化语言(Java、Python、Go 等)引入了垃圾回收(Garbage Collection,GC)来自动管理堆内存——但在 C 语言中,你既是国王,也是守夜人。
(4)灵活性最高。 申请大小在运行时决定,释放时机由你掌控,数据可以跨函数存活。堆是 C 语言强大表达能力的来源之一——链表、树、图等所有”编译时不知道有多大”的数据结构,都建立在堆上。
4.4 静态存储区 —— 贯穿始终的”铭牌”
栈是临时的便签纸,堆是按需租用的仓库——那有没有一块空间,从程序启动到结束,始终稳稳当当地放在那里?有,这就是静态存储区。
谁住在静态存储区?
- 全局变量:在所有函数之外定义的变量
static局部变量:在函数内部用static修饰的变量(作用域在函数内,但存储位置在这里)- 字符串字面量:代码里直接写的
”Hello”、”请输入”等,通常放在只读数据区,属于静态存储期
静态存储区的内部划分:.data 与 .bss
静态存储区内部其实分两个”片区”,区别在于有没有显式给出初始值:
- .data 段:存放已初始化的全局变量和
static变量,比如int count = 100;。初始值(100)存储在可执行文件中,程序启动时直接加载到内存。 - .bss 段:存放未初始化(或初始化为 0)的全局变量和
static变量,比如int flag;。可执行文件中只记录”这里需要 N 个字节”,程序启动时操作系统直接给一块清零的内存。
用铭牌的比喻来说:
- .data 是”刻好字的铭牌”——初始值清清楚楚地刻在上面,可执行文件中存着这些值
- .bss 是”空白铭牌”——铭牌的位置留好了,但上面什么字都没刻。操作系统启动程序时,直接给一块清零的内存即可,不需要在可执行文件中存一大堆零
为什么 .bss 很重要? 如果你的程序声明了
int big_table[1000000] = {0};,一千万个零不需要存到可执行文件中——可执行文件只需要记录”这块要 400 万字节”,启动时操作系统给一块清零的内存。如果这些零全存进文件,可执行文件的体积会白白增大。
静态存储区的特点
- 生命周期最长:从程序启动到结束,始终存在
- 自动零初始化:未显式初始化的变量自动归零(不像栈上的局部变量,不初始化就是垃圾值)
- 编译时确定大小:编译器在编译时就能算出需要多少空间,不需要运行时动态决定
4.5 代码区(Text)—— 只读的”工作手册”
代码区存放的是你写的 C 代码被编译器翻译成的机器指令。它有三个关键特征:
(1)只读。 现代操作系统把代码区映射为不可写——任何试图修改代码区的操作都会导致程序崩溃。这是出于两个原因:
- 安全:防止恶意代码注入。如果代码区可写,攻击者可能通过缓冲区溢出等手段把恶意指令写进去
- 防错:防止程序中的野指针意外改写代码逻辑。一个乱飞的指针改写了数据段可能只导致计算结果出错,改写了代码段则可能导致完全不可预测的行为
(2)共享。 如果你同时打开了三个终端窗口,每个都运行着同一个程序,操作系统可以让它们共享同一份代码区的物理内存——因为代码只读不写,共用不会出问题。这节省了大量内存。
(3)位置由构建工具链决定。 代码在虚拟地址空间中的具体位置由编译器、链接器和加载器共同确定,并受到地址空间布局随机化(ASLR,Address Space Layout Randomization)等安全机制的影响——每次运行时,代码区的实际地址可能不同,这也是出于安全考虑。
4.6 贯穿示例:一个程序的内存全貌
讲了四个区域,现在用一个完整的程序把它们串起来。请仔细看下面的代码和注释——每一个变量属于哪个区域,都标注清楚了:
#include <stdio.h>
#include <stdlib.h>
// ★ 静态存储区 (.data) —— 显式初始化了,初始值"Hello"存在可执行文件中
char greeting[20] = "Hello, World!";
// ★ 静态存储区 (.bss) —— 未显式初始化,启动时自动清零
int access_count;
// 形参 name 在栈区 | 形参 age 在栈区
void print_info(char* name, int age)
{
// ★ 栈区 —— 局部变量
int year = 2026 - age; // 根据年龄推算出生年份
// ★ 静态存储区 (.bss) —— static 局部变量,只在这个函数里可见
// 但生命周期贯穿整个程序,且自动初始化为 0
static int call_times;
call_times++; // 每次调用 +1
access_count++; // 全局变量,也 +1
printf("%s 今年 %d 岁,出生于 %d 年\n", name, age, year);
printf(" (print_info 被调用了 %d 次,全局访问计数 %d)\n",
call_times, access_count);
}
int main(void)
{
// ★ 栈区 —— main 的局部变量
int student_count;
// ★ 栈区 —— 指针变量 p 本身在栈上
// ★ 堆区 —— p 指向的内存块在堆上(运行时分配)
int* p = (int*)malloc(3 * sizeof(int));
if (p == NULL) return 1;
p[0] = 85;
p[1] = 92;
p[2] = 78;
student_count = 3;
print_info("小明", 20); // 字符串字面量 "小明" → 静态存储区(只读)
print_info("小红", 19);
print_info("小刚", 18);
for (int i = 0; i < student_count; i++)
printf("学生 %d 的成绩:%d\n", i + 1, p[i]);
free(p); // ★ 归还堆上的内存
return 0;
}
梳理一下这个程序的内存分布:
| 代码中的东西 | 所在区域 | 为什么 |
|---|---|---|
greeting[20] | 静态存储区 (.data) | 在所有函数外定义,且显式初始化了 |
access_count | 静态存储区 (.bss) | 在所有函数外定义,未显式初始化 |
print_info 的形参 name, age | 栈区 | 函数参数,每次调用都被”叠”到栈帧上 |
print_info 的 year | 栈区 | 函数内的局部变量 |
print_info 的 call_times | 静态存储区 (.bss) | static 局部变量,未显式初始化 |
main 的 student_count | 栈区 | main 函数内的局部变量 |
main 的指针 p 本身 | 栈区 | main 函数内的局部变量 |
p 指向的 3 个 int | 堆区 | 通过 malloc 申请 |
”小明” 等字符串字面量 | 静态存储区(只读) | 直接写在代码里的字符串常量 |
print_info、main 的机器指令 | 代码区 | 编译后的机器码 |
运行时,栈的变化(只看 main → print_info 部分):
- main 开始执行:栈上有
main的栈帧,包含student_count和指针变量p(p本身在栈上,但它指向的 3 个 int 在堆上) - 调用
print_info("小明", 20):在main的栈帧上方叠放print_info的栈帧,包含形参name、age和局部变量year - print_info 返回:
print_info的栈帧被整个移除,main的栈帧恢复为栈顶
关键观察:函数调用期间,堆上的数据 [85][92][78] 始终存在——它不受栈帧叠放和移除的影响,只有显式调用 free() 才会被回收。
四个区域讲完了。但说实话,在日常写 C 代码时,你直接感知到的主要是其中两个:堆和栈。代码区的指令是编译器生成的,你不用管;静态存储区的全局变量定义了就放那儿,也不需要特别操作。而你每写一个局部变量,都在用栈;每调用一次 malloc,都在用堆。
这两个区域的差异,决定了你应该把什么样的数据放在哪里。下面这张对比表把核心区别一次性说清楚:
| 对比维度 | 栈(Stack) | 堆(Heap) |
|---|---|---|
| 管理方式 | 编译器自动管理,函数进入时分配、返回时释放 | 程序员手动管理,malloc 申请、free 释放 |
| 存放内容 | 局部变量、函数形参、返回地址 | 动态分配的内存块 |
| 容量 | 有限,通常 1~8 MB | 很大,可利用几乎整个虚拟地址空间(数 GB) |
| 速度 | 极快(移动栈指针即可,一条 CPU 指令) | 较慢(需要查找空闲块 + 登记管理信息) |
| 生命周期 | 由函数调用决定,函数返回即销毁 | 由程序员决定,从 malloc 到 free |
| 分配时机 | 编译时确定布局,运行时自动分配 | 运行时按需分配,大小可以在运行时决定 |
| 典型问题 | 栈溢出(递归过深、局部数组过大) | 内存泄漏(忘记 free)、碎片化、悬空指针 |
| 适用场景 | 大小已知、生命周期短的小数据 | 大小未知、需要跨函数存活的大数据 |
选择口诀:
数据小而短命 → 栈(声明一个局部变量就行) 数据大或长寿 → 堆(用
malloc申请,用完记得free)
这个口诀覆盖了 90% 的日常决策。随着学习的深入,你会对”什么时候用堆、什么时候用栈”形成肌肉记忆——但在此之前,这张表和这个口诀就是你的指南针。
补充说明:虚拟地址空间的四区模型是常见桌面/服务器系统的典型实现,不是 C 语言标准的强制规定。但在学习阶段,用它来建立内存直觉是完全合适的——就像学物理时用”电子绕核旋转”的模型,虽然不完全精确,但它能帮你理解和预测大部分现象。后续章节中局部变量、指针、动态内存等概念,都会反复用到本章建立的这张”内存地图”。
接下来,就从你最常打交道的两种变量——局部变量和全局变量——入手,深入理解它们和内存分区的关系。
二、局部变量与全局变量
1. 作用域 —— 决定了你能看到谁
在讲局部变量和全局变量之前,必须先理解一个更基础的概念:作用域(scope)。
C 语言里的一对 { },不仅仅是“函数体的边界“——它定义了一个作用域。在 { } 内部声明的变量,只在这个 { } 内部可见。出了这个 { },外面就看不到里面的变量了。
#include <stdio.h>
void func(void)
{
int a = 10; // a 的作用域就是这个 { } 内部
printf("%d\n", a); // 使用 a,避免示例只声明不用
} // 离开这里后,自动对象 a 的生命周期结束
int main(void)
{
func();
// 这里无法访问 a——a 的作用域只在 func 的 { } 里面
return 0;
}
嵌套的 { } 也遵循同样的规则:内层可以看到外层的变量,外层看不到内层的变量。
#include <stdio.h>
int main(void)
{
int x = 1; // x 的作用域是整个 main 的 { }
{ // 嵌套的代码块
int y = 2; // y 的作用域只在这个内层 { } 里
printf("%d %d", x, y); // ✅ 内层可以访问外层的 x 和自己的 y
}
// printf("%d", y); // 错误示范:y 已超出作用域,取消注释会编译失败
return 0;
}
💡 作用域 vs 生命周期
作用域决定名字在源代码中的可见范围,存储期和执行流程共同决定对象的生命周期。普通自动对象通常在离开相应块时结束生命周期,但
static局部变量即使名字离开作用域,对象仍然存活。第十二章会系统区分这些概念。
2. 局部变量——栈区的居民
回顾你到目前为止写过的所有 C 程序,你声明变量时一定是在某个函数内部写的:
void func(void)
{
int a = 10; // 写在函数 func 的内部
double b = 3.14;
}
像这样定义在函数或嵌套块内部的标识符具有块作用域,通常称为局部变量。离开块后,这个名字不可见;若对象具有自动存储期,其生命周期也会结束,而 static 局部对象则继续存活。
局部自动对象在常见未优化实现中经常位于调用栈,也可能被放进寄存器或被优化掉。下面先用“栈帧”模型建立直觉,再牢记它不是 C 标准规定的唯一实现方式。
#include <stdio.h>
void func(void)
{
int a = 10; // func 被调用 → a、b、c 叠到栈顶
double b = 3.14;
char c = 'X';
printf("%d %.2f %c\n", a, b, c); // 读取三个对象,展示其值
} // func 结束 → a、b、c 从栈顶拿走,全部销毁
int main(void)
{
func();
// 到这里,a、b、c 已经不存在了
// 如果再次调用 func(),会在栈顶重新叠三块全新的变量
return 0;
}
从这个例子可以看出栈上局部变量的几个关键行为:
- 进入块时开始、离开块时结束:这里的自动对象无需手动释放;编译器负责实现其存储管理
- 每次调用对应新的对象实例:连续调用两次
func(),两次调用中的a、b、c不是同一生命周期的对象 - 避免巨型自动数组:调用栈容量有限且因环境而异,过大的自动对象可能导致栈耗尽
3. 全局变量——静态存储区的居民
到目前为止,你声明的所有变量都写在函数内部(局部变量)。但 C 语言允许你在所有函数之外定义变量——这种变量叫做全局变量(global variable)。全局变量不归任何一个函数私有,而是整个程序的“公共财产“。
与局部变量不同,全局变量住在静态存储区。它们就像刻着名字的铭牌,从程序启动的那一刻起就钉在那里,直到程序结束才会被取下。
#include <stdio.h>
int global_counter = 0; // 在所有函数外面定义 → 全局变量,在静态存储区
void count(void)
{
global_counter++; // 任何函数都可以访问它
printf("count = %d\n", global_counter);
}
int main(void)
{
count(); // count = 1
count(); // count = 2
count(); // count = 3
return 0;
}
注意 global_counter 只初始化了一次(= 0),之后每次 count() 调用修改的都是同一个变量,所以它从 1 一直累加到 3。如果它是局部变量,每次调用 count() 都会重新创建一个新的、从 0 开始的变量——这就是存储位置不同带来的行为差异。
文件作用域变量的名字从声明处开始在相应作用域内可见。非 static 的文件作用域定义通常具有外部链接,其他翻译单元可通过兼容的 extern 声明引用;这并不意味着无需声明就能被任意函数直接使用。
4. 局部变量与全局变量对照
| 局部变量 | 全局变量 | |
|---|---|---|
| 定义位置 | 函数 /{ } 内部 | 所有函数之外 |
| 典型实现位置 | 常见于栈帧,也可能在寄存器中 | 常见于数据段或 BSS 类映射 |
| 生命周期 | 自动对象通常随块执行开始和结束 | 贯穿整个程序执行 |
| 名字的可见性 | 从声明处到所在块末尾 | 从声明处到文件作用域末尾;跨文件需链接与声明 |
| 未初始化时 | 值不确定,读取可能是未定义行为 | 按规则零初始化 |
| 管理方式 | 自动管理(编译器) | 自动管理(编译器) |
这张表真正要揭示的是:应先从声明方式判断对象的存储期,再理解常见实现如何安排存储。不要反过来仅凭某次打印出的地址猜测标准语义。
三、static 关键字——改变存储位置的“开关“
通过上一节的对照表,你可能会产生一个疑问:如果我想让一个局部变量也拥有全局变量那样的“长寿命“——在多次函数调用之间保持它的值——但又不想让它变成全局变量被所有函数访问,有没有办法?
C 语言提供了 static 关键字来满足这种需求。用于块作用域对象时,它把对象的存储期改为静态存储期,但名字仍保持块作用域。
1. static 局部变量——住在静态区的“局外人“
在局部变量前面加上 static,会把它从自动存储期改为静态存储期。这带来的关键行为变化是:对象只初始化一次,之后每次函数调用都沿用上一次结束时的值。 至于它在可执行文件和内存中的具体区域,由实现决定。
#include <stdio.h>
void count(void)
{
static int counter = 0; // 静态存储期:只初始化一次,值在调用间保留
counter++;
printf("counter = %d\n", counter);
}
int main(void)
{
count(); // counter = 1
count(); // counter = 2
count(); // counter = 3
return 0;
}
对比普通局部变量和 static 局部变量:
| 普通局部变量 | static 局部变量 | |
|---|---|---|
| 存储期 | 自动存储期(通常在栈帧中实现) | 静态存储期 |
| 初始化时机 | 每次执行到声明时进行初始化 | 程序启动前完成静态初始化 |
| 值在调用之间 | 不保留(每次都是新的) | 保留(沿用上次的值) |
| 作用域 | 定义它的 { } 内 | 定义它的 { } 内(不变) |
| 生命周期 | 进入 { } 时创建,离开时销毁 | 程序启动时创建,程序结束时销毁 |
这里有一个非常重要的概念需要格外注意:变量的作用域(在哪里能访问它)和生命周期(它能活多久)是两个不同且相互独立的概念。static 局部变量就是最典型的例子——它的作用域仅限于函数内部,出了函数就无法直接访问它,但它的生命周期却贯穿整个程序。
此外,和全局变量一样,static 局部变量如果没有显式初始化,也会被自动初始化为 0。
2. static 全局变量——限制可见范围
static 关键字也可以用在全局变量上,但它的作用完全不同:它不是改变存储位置(全局变量本来就在静态存储区),而是限制可见范围。
普通的全局变量可以被程序中的所有文件访问(前提是其他文件用 extern 声明)。但如果给全局变量加上 static,它就变成了文件内部私有——只有当前 .c 文件中的函数能够访问它,其他文件看不到。
// file1.c
static int internal_counter = 0; // 只能在本文件内访问
void increment(void)
{
internal_counter++;
}
// file2.c
// extern int internal_counter; // 错误示范:无法引用 file1.c 中具有内部链接的对象
这个特性对于大型项目的模块化开发非常重要:它让你可以把只在当前文件内部使用的数据隐藏起来,避免被其他文件意外修改。这称为信息隐藏,是编写可靠、可维护的 C 代码的重要实践。
3. static 总结
static 关键字在不同语境下有不同的含义,但核心思想是一致的——限制与持久化:
| 用法 | 作用 | 关键词 |
|---|---|---|
static 局部变量 | 赋予静态存储期,值在调用之间保持 | 持久化 |
static 全局变量 | 可见范围从“所有文件“缩小到“当前文件“ | 限制 |
记忆时应按语境区分:块作用域对象上的 static 改变存储期;文件作用域声明上的 static 赋予内部链接。二者都不等价于“地址固定”,也不应从英文名称反推更多语言保证。