存储类、链接和内存管理
区分作用域、链接与存储期,掌握C11存储类和类型限定符,并安全使用malloc/calloc/realloc/free
学习目标
- 能分别判断一个声明的作用域、链接和存储期,并解释 static、extern、_Thread_local 与 register 改变了哪一轴
- 能比较 const、volatile、restrict、_Atomic 的契约,说明 volatile 为什么不能替代原子同步
- 能在计算分配字节数前检查乘法溢出,并写出 malloc/calloc/realloc/free 失败与清理路径
机制总览
存储类、链接和内存管理:机制路径
- 1
为什么变量「活多久」和「谁能看见」要分开想?
写程序时,你声明一个名字,编译器要回答两件事:这块内存 放在哪 (栈?堆?静态区?),以及这个名字 谁能用 (只在当前 里?整个文件?整个程序?)。
- 2
存储类:不要与作用域、链接、存储期混成一件事
C11 的存储类说明符包括 typedef 、 extern 、 static 、 Thread local 、 auto 、 register 。
- 3
链接:谁能跨文件看见这个名字
头文件惯用法: extern int foo; 放 .h ; int foo = 0; 只在一个 .c 里定义,避免重复定义链接错误。
章级决策实验
存储类、链接和内存管理:机制与证据
切换《存储类、链接和内存管理》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 为什么变量「活多久」和「谁能看见」要分开想?
写程序时,你声明一个名字,编译器要回答两件事:这块内存 放在哪 (栈?堆?静态区?),以及这个名字 谁能用 (只在当前 里?整个文件?整个程序?)。
可核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「为什么变量「活多久」和「谁能看见」要分开想?」的实际行为。
学完《存储类、链接和内存管理》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
存储类、链接和内存管理:失效与核验
为什么变量「活多久」和「谁能看见」要分开想?
典型失效
若只记语法而忽略「为什么变量「活多久」和「谁能看见」要分开想?」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「为什么变量「活多久」和「谁能看见」要分开想?」的实际行为。
存储类:不要与作用域、链接、存储期混成一件事
典型失效
若只记语法而忽略「存储类:不要与作用域、链接、存储期混成一件事」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「存储类:不要与作用域、链接、存储期混成一件事」的实际行为。
链接:谁能跨文件看见这个名字
典型失效
若只记语法而忽略「链接:谁能跨文件看见这个名字」的类型范围、求值顺序或资源边界,输入变化后可能出现未定义行为或错误结果。
核验证据
用 -Wall -Wextra -pedantic 构建本节示例,再以正常值、边界值和失败输入核对「链接:谁能跨文件看见这个名字」的实际行为。
为什么变量「活多久」和「谁能看见」要分开想?
写程序时,你声明一个名字,编译器要回答两件事:这块内存放在哪(栈?堆?静态区?),以及这个名字谁能用(只在当前 {} 里?整个文件?整个程序?)。
想象公司工位:临时工(auto)下班就清桌子;档案室(static)的文件整年都在,换班的人还能翻到上次记录;跨部门共享的台账(extern)全公司只认一份。若你借了仓库空间(malloc)却从不归还,仓库迟早堆满——那就是 内存泄漏。
这一章把 C 的存储类、链接规则,以及栈/堆/静态区的内存模型讲清楚。搞懂这些,多文件项目、全局状态、动态数组才会可控。
存储类:不要与作用域、链接、存储期混成一件事
C11 的存储类说明符包括 typedef、extern、static、_Thread_local、auto、register。↡声明说明符中的一类关键字,主要参与决定对象的存储期和标识符链接;作用域仍由声明出现的语法位置决定。 不能单独回答“名字在哪可见、对象何时存在、不同声明是否同一实体”三个问题:
- 作用域看声明位置:块、文件、函数原型或 label 的函数作用域
- 链接看声明是否与其他作用域或翻译单元中的声明表示同一实体
- 存储期看对象生存时间:自动、静态、线程或分配存储期
函数体内不写存储类说明符的普通局部对象具有自动存储期。typedef 声明类型别名而不声明对象,本章重点放在影响对象生存期和链接的其余说明符。
auto:块内自动变量
void demo(void) {
int count; /* 等价于 auto int count; */
count = 0;
}
/* count 已销毁,内存可被复用 */↡进入关联块时开始一次对象实例的生存期,正常离开块后结束;名字具有块作用域且无链接。实现常用调用栈或寄存器承载。 只在声明点之后的包含块内可见。每次递归进入同一块也会产生独立实例;实现可以使用机器栈、寄存器或完全优化掉对象。未初始化自动对象具有不确定值,在赋值前读取可能产生未定义行为。
static:活过整个程序
void counter(void) {
static int calls = 0;
calls++;
printf("第 %d 次调用\n", calls);
}↡对象具有静态存储期;块内 static 名字仍只有块作用域且无链接,初始化在程序启动前完成一次。 若在函数块内声明,名字仍是块作用域,但对象的生存期贯穿整个程序。calls 不会在每次调用时归零;未显式初始化的静态存储期对象会先按规则零初始化。
文件作用域的 static(见下文链接)还限制名字只在本 .c 可见。
extern:声明「别处有定义」
/* file_a.c */
int shared = 100; /* 定义 + 外部链接 */
/* file_b.c */
extern int shared; /* 声明:链接到 file_a 的那份 */↡告诉编译器该标识符的定义在别的翻译单元;本身不分配存储(除非同时是定义)。 常用于头文件里声明全局变量/函数,在某一 .c 里写一次定义。若只有 extern 声明、全程序找不到定义,链接阶段报 undefined reference。
register:建议放寄存器
↡用于块作用域对象或函数形参的存储类说明符;对象具有自动存储期,不能对其使用一元取地址运算符。是否放进CPU寄存器由实现决定。 不保证寄存器分配,现代优化器会自行决定。它真正可观察的限制是不能对该对象应用 &;因此通常没有必要主动使用。
_Thread_local:每个线程一份对象
C11 的 ↡让对象具有线程存储期:每个线程拥有独立实例,实例生存覆盖该线程执行期间。可与 static 或 extern 组合以表达链接。 只能用于对象声明。文件作用域可写 _Thread_local int error_code;;块作用域使用时还必须与 static 或 extern 组合。线程存储期不等于原子访问:每线程独立对象通常无需共享同步,但若把其地址交给其他线程,仍要遵守对象生存期和并发访问规则。
链接:谁能跨文件看见这个名字
↡标识符能否在多个源文件间共享同一份实体。内部链接仅本翻译单元;外部链接全程序唯一。 回答:两个 .c 文件能不能指到同一个全局变量或函数。
规则速记:
- 函数内变量:无链接(只在块内)
- 文件内
static全局/函数:内部链接——其他.c看不见,各文件可各有一份同名 static 而不冲突 - 普通全局定义(无 static):外部链接——全程序一份,用 extern 声明引用
头文件惯用法:extern int foo; 放 .h;int foo = 0; 只在一个 .c 里定义,避免重复定义链接错误。
类型限定符:const、volatile、restrict 与 _Atomic
存储类描述对象生存与链接,↡附着在类型上的 const、volatile、restrict、_Atomic 等限定,用于约束允许的访问方式或并发语义。 则描述如何访问对象:
const:禁止经该左值修改
void print_values(size_t count, const int *values);const int * 表示不能经 values 修改所指 int。若原对象本来不是 const,它仍可能通过其他非 const 左值改变;若对象定义时就是 const,强制去掉限定并修改它会产生未定义行为。const 是类型检查契约,不等于“编译期常量”,也不自动提供线程安全。
volatile:每次访问都有可观察意义
↡告诉实现对该限定左值的访问属于可观察行为,常用于实现定义的内存映射I/O或 volatile sig_atomic_t 信号通信;不提供线程同步。 要求实现按抽象机规则保留相关访问,适合硬件寄存器等实现定义场景。它不能修复多线程数据竞争,也不能建立 happens-before;多个线程同时读写普通 volatile 对象仍可能是未定义行为。
restrict:受约束的别名承诺
void add_arrays(size_t count,
int *restrict out,
const int *restrict left,
const int *restrict right);↡用于指针派生类型的限定符,在相应执行块期间承诺受访问对象只通过符合restrict关联规则的指针表达式访问;违反承诺会产生未定义行为。 让实现可以假设 out、left、right 所访问数组不以违约方式重叠,从而进行向量化等优化。它不是运行时检查;调用者若传入不满足契约的重叠区域,后果不是“变慢”而是行为未定义。
_Atomic:标准原子对象与操作
#include <stdatomic.h>
atomic_uint completed = 0;
void mark_complete(void) {
atomic_fetch_add_explicit(&completed, 1u, memory_order_relaxed);
}↡C11 原子类型限定符;通过 stdatomic.h 的操作访问可获得不可分割更新,并按所选 memory_order 参与线程间排序。 或 atomic_uint 创建原子对象。示例计数器只要求更新不丢失,使用 relaxed 顺序;若该变量还发布其他数据,则必须按通信协议选择 acquire/release 等顺序。原子性解决特定对象的竞争,不自动让一组复合操作成为事务。
典型进程内存布局:实现模型,不是 C 标准分区
托管操作系统和常见 ABI 往往把进程虚拟地址空间组织成下面这些区域;嵌入式、分段架构或优化后的程序可以完全不同。可移植 C 代码应依据存储期和分配 API 推理,不能依赖段名、地址顺序或栈增长方向。
| 区域 | 典型内容 | 谁分配/释放 |
|---|---|---|
| 代码段 | 机器指令 | 加载时固定 |
| .data | 有初值的全局/static | 程序整个生命期 |
| .bss | 未初始化全局/static(零初始化) | 程序整个生命期 |
| 动态分配区(常称堆) | malloc 族返回的块 | 程序调用 free |
| 调用栈(若 ABI 使用) | 部分自动对象、保存的调用状态 | 实现随调用进入/返回管理 |
↡申请指定字节数的未初始化存储,成功返回满足基本对齐要求的 void*,失败返回空指针;需包含 stdlib.h。 返回具有分配存储期的块;对应释放是 ↡结束 malloc/calloc/realloc 所得动态块的分配存储期;参数可为空指针,否则必须是尚未释放的当前块起始指针。。这里习惯称“堆”,但 C 标准按分配函数和存储期定义行为,不要求某种堆数据结构。
malloc / free 生命周期
动态数组、运行时才知道长度的字符串,往往离不开堆。标准用法是「申请 → 使用 → 释放」三步,漏掉最后一步就泄漏。
第一步:malloc 申请
先验证 n 不超过 SIZE_MAX / sizeof *p,再调用 malloc。乘法若先溢出,malloc 可能成功返回一个远小于预期的块,仅检查空指针也救不了越界。
最小完整示例:
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
int main(void) {
size_t n = 5, i;
int *arr;
if (n > SIZE_MAX / sizeof *arr) {
fputs("size overflow\n", stderr);
return 1;
}
arr = malloc(n * sizeof *arr);
if (arr == NULL) {
fprintf(stderr, "malloc failed\n");
return 1;
}
for (i = 0; i < n; i++)
arr[i] = i * 10;
for (i = 0; i < n; i++)
printf("%d ", arr[i]);
printf("\n");
free(arr);
arr = NULL;
return 0;
}sizeof *arr 比 sizeof(int) 更安全:若以后改指针类型,字节数自动跟着变。
calloc 与 realloc:初始化和扩容不是同一件事
calloc(count, size) 为 count 个元素申请存储,并把所有字节初始化为零;这不应被泛化为“所有类型都获得语义上的零值”,例如空指针的对象表示不要求全零位。realloc 则尝试改变现有块大小,可能原地扩展,也可能搬到新地址:
#include <stdint.h>
#include <stdlib.h>
int grow_ints(int **buffer, size_t new_count) {
int *resized;
if (new_count == 0 || new_count > SIZE_MAX / sizeof **buffer)
return 0;
resized = realloc(*buffer, new_count * sizeof **buffer);
if (resized == NULL)
return 0; /* 原来的 *buffer 仍有效 */
*buffer = resized;
return 1;
}不要直接写 buffer = realloc(buffer, bytes):失败返回 NULL 会覆盖唯一旧指针并泄漏原块。成功后旧指针值可能已经失效,必须使用返回的新指针。扩大的新增区域未初始化;若需要零值,要由程序显式初始化。示例拒绝零长度,避开不同标准版本和实现对 realloc(p, 0) 的特殊处理。
代码详解:static 计数与跨文件共享
场景一:static 局部变量做计数器
#include <stdio.h>
void visit(void) {
static int count = 0;
count++;
printf("visit #%d\n", count);
}
int main(void) {
visit(); /* visit #1 */
visit(); /* visit #2 */
visit(); /* visit #3 */
return 0;
}count 具有静态存储期,只初始化一次,三次调用递增;具体放入哪个目标文件段由实现决定。
场景二:文件私有 helper(内部链接)
/* utils.c */
static int helper(int x) { return x * 2; }
int public_api(int x) { return helper(x) + 1; }helper 带文件作用域 static,其他 .c 即使写 extern int helper(int); 也链接不到——封装实现细节。
场景三:动态读入未知长度
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
int main(void) {
size_t n, i;
int *data;
printf("多少个整数?");
if (scanf("%zu", &n) != 1 || n == 0 ||
n > SIZE_MAX / sizeof *data)
return 1;
data = malloc(n * sizeof *data);
if (data == NULL)
return 1;
for (i = 0; i < n; i++) {
if (scanf("%d", data + i) != 1) {
free(data);
return 1;
}
}
/* ... 使用 data ... */
free(data);
return 0;
}编译期不知道 n 时,支持 VLA 的实现可以使用块作用域变长数组,但 C11 允许实现不提供 VLA,且大对象可能超出实现的自动存储资源。动态分配同样受 SIZE_MAX、实现上限和可用内存约束,并非“大小几乎不受限制”;选择依据应是容量上限、所需生存期和清理责任。
容易踩的坑
小结
- 作用域、链接、存储期是三条独立轴;存储类说明符只参与其中部分规则
- static 可赋予静态存储期或内部链接;extern 引用外部定义;_Thread_local 让每线程拥有独立实例
- const 管修改路径,volatile 管可观察访问,restrict 是别名承诺,_Atomic 才提供标准原子操作
- .data、.bss、调用栈和堆是常见实现模型,不是可移植 C 可以依赖的地址布局
- 动态分配先查字节数溢出,再检查返回值;realloc 用临时指针;每个块只释放一次且释放后所有别名失效
练习
问题 1(问答型) 块内 static int x = 0; 与块内 int x = 0; 每次进入块时,x 的值分别会怎样?
问题 2(改代码型) 下面程序泄漏了内存,指出问题并改正。
#include <stdlib.h>
void process(int n) {
int *buf = malloc(n * sizeof(int));
if (buf == NULL) return;
/* 使用 buf ... */
return; /* 忘记释放 */
}问题 3(综合实现型) 写程序:用 malloc 创建长度为 10 的 int 数组,填入 0..9,打印后释放。必须检查 malloc 是否失败。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 存储类(storage class)
声明说明符中的 typedef、extern、static、_Thread_local、auto、register,主要参与决定对象存储期和标识符链接;作用域由声明位置决定。详见 StorageClassDiagram。
- auto 变量
块内普通局部对象的默认类别,具有自动存储期、块作用域和无链接。实现可以使用调用栈、寄存器或优化消除对象。
- static 变量
块内 static 对象具有静态存储期但名字仍是块作用域;文件作用域 static 标识符具有内部链接。
- extern
声明标识符在其他翻译单元有定义;不重复分配存储(纯声明时)。
- register
用于自动存储期对象的存储类说明符,不保证物理寄存器分配;不能对该对象使用一元取地址运算符。
- _Thread_local
C11 线程存储期说明符,每个线程拥有独立对象实例。块作用域使用时须与 static 或 extern 组合;线程局部不等于原子同步。
- 链接(linkage)
标识符能否跨 .c 文件共享。internal 仅本文件;external 全程序唯一。详见 LinkageDiagram。
- 内部链接
static 文件作用域变量/函数;符号不导出给其他 .o,各文件可各有同名 static。
- 外部链接
普通全局定义;全程序一份,其他文件用 extern 引用。
- 栈(stack)
常见 ABI 用来保存部分自动对象和调用状态的区域。C 标准不要求自动对象一定入栈,也不规定栈存在或增长方向。
- 堆(heap)
对 malloc/calloc/realloc 所管理动态区域的常用实现称呼。C 标准依据分配存储期和 API 定义行为,不规定具体堆结构。
- BSS 段
常见目标文件格式用于存放部分零初始化静态对象的段名,不是 C 抽象机概念。静态存储期对象的零初始化规则不依赖 BSS 是否存在。
- malloc
申请指定字节数的未初始化存储,成功返回适当对齐的 void 指针,失败返回空指针。元素数乘以元素大小必须先防溢出。
- free
结束 malloc 族动态块的存储期。free(NULL) 无操作;其他参数必须是尚未释放的当前块起始指针,释放后所有别名都不得访问该块。
- 类型限定符(type qualifier)
const、volatile、restrict、_Atomic 等附着于类型的限定,分别表达修改、可观察访问、别名和原子并发语义。
- volatile
让经该限定左值的访问成为可观察行为,常用于实现定义的设备或信号场景;不提供原子性,也不能修复线程数据竞争。
- restrict
指针别名契约,允许实现基于受约束访问路径进行优化。调用者违反关联规则会产生未定义行为,并非运行时检查失败。
- _Atomic
C11 原子类型限定符;配合 stdatomic.h 操作提供不可分割访问,并按 memory_order 参与线程间排序。
- 内存泄漏
堆块无法再被程序访问却未 free,可用堆内存持续减少。见 MallocFreeDiagram step 4。
原版目录概念补充核对
以下条目补齐官方目录中容易被示例主线掩盖的概念。它们不重复罗列目录,而是明确每项概念的机制、适用边界和验收证据。
自动变量:机制、边界与证据
在《存储类、链接和内存管理》的官方单元 cpr-12 中,自动变量连接本章第 2 组知识约束。学习时要同时说明它接受什么输入、改变什么状态、在何种边界失效;再以本章示例的编译诊断、固定输入输出或失败用例复核结论,不能只记术语名称。