存储类、链接和内存管理

区分作用域、链接与存储期,掌握C11存储类和类型限定符,并安全使用malloc/calloc/realloc/free

学习目标

  • 能分别判断一个声明的作用域、链接和存储期,并解释 static、extern、_Thread_local 与 register 改变了哪一轴
  • 能比较 const、volatile、restrict、_Atomic 的契约,说明 volatile 为什么不能替代原子同步
  • 能在计算分配字节数前检查乘法溢出,并写出 malloc/calloc/realloc/free 失败与清理路径

机制总览

存储类、链接和内存管理:机制路径

  1. 1

    为什么变量「活多久」和「谁能看见」要分开想?

    写程序时,你声明一个名字,编译器要回答两件事:这块内存 放在哪 (栈?堆?静态区?),以及这个名字 谁能用 (只在当前 里?整个文件?整个程序?)。

  2. 2

    存储类:不要与作用域、链接、存储期混成一件事

    C11 的存储类说明符包括 typedef 、 extern 、 static 、 Thread local 、 auto 、 register 。

  3. 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 的存储类说明符包括 typedefexternstatic_Thread_localautoregister 不能单独回答“名字在哪可见、对象何时存在、不同声明是否同一实体”三个问题:

  • 作用域看声明位置:块、文件、函数原型或 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);
}

若在函数块内声明,名字仍是块作用域,但对象的生存期贯穿整个程序。calls 不会在每次调用时归零;未显式初始化的静态存储期对象会先按规则零初始化。

文件作用域的 static(见下文链接)还限制名字只在本 .c 可见。

extern:声明「别处有定义」

/* file_a.c */
int shared = 100;      /* 定义 + 外部链接 */
 
/* file_b.c */
extern int shared;     /* 声明:链接到 file_a 的那份 */

常用于头文件里声明全局变量/函数,在某一 .c 里写一次定义。若只有 extern 声明、全程序找不到定义,链接阶段报 undefined reference

register:建议放寄存器

不保证寄存器分配,现代优化器会自行决定。它真正可观察的限制是不能对该对象应用 &;因此通常没有必要主动使用。

_Thread_local:每个线程一份对象

C11 的 只能用于对象声明。文件作用域可写 _Thread_local int error_code;;块作用域使用时还必须与 staticextern 组合。线程存储期不等于原子访问:每线程独立对象通常无需共享同步,但若把其地址交给其他线程,仍要遵守对象生存期和并发访问规则。

链接:谁能跨文件看见这个名字

回答:两个 .c 文件能不能指到同一个全局变量或函数。

两个源文件如何「看见」彼此的名字file_a.cstatic int counter = 0;内部链接 — 仅 file_a.c 可见int shared = 100;外部链接 — 整个程序共享一份extern int shared;声明:别处有定义(可写在头文件)file_b.cextern int shared;引用 file_a 中的 shared/* counter 不可见 */写 counter 会链接错误:undefined链接器合并各 .o:外部链接符号全局唯一;内部链接符号各文件各一份、互不影响shared → 一份定义,多处 extern 引用 ✓同一符号counter 不出本文件
文件作用域的 static 变量/函数具有内部链接,其他 .c 看不见;全局定义默认外部链接,可用 extern 跨文件共享。头文件里放 extern 声明,.c 里放一次定义。

规则速记:

  • 函数内变量:无链接(只在块内)
  • 文件内 static 全局/函数:内部链接——其他 .c 看不见,各文件可各有一份同名 static 而不冲突
  • 普通全局定义(无 static):外部链接——全程序一份,用 extern 声明引用

头文件惯用法:extern int foo;.hint foo = 0; 只在一个 .c 里定义,避免重复定义链接错误。

类型限定符:const、volatile、restrict 与 _Atomic

存储类描述对象生存与链接, 则描述如何访问对象:

const:禁止经该左值修改

void print_values(size_t count, const int *values);

const int * 表示不能经 values 修改所指 int。若原对象本来不是 const,它仍可能通过其他非 const 左值改变;若对象定义时就是 const,强制去掉限定并修改它会产生未定义行为。const 是类型检查契约,不等于“编译期常量”,也不自动提供线程安全。

volatile:每次访问都有可观察意义

要求实现按抽象机规则保留相关访问,适合硬件寄存器等实现定义场景。它不能修复多线程数据竞争,也不能建立 happens-before;多个线程同时读写普通 volatile 对象仍可能是未定义行为。

restrict:受约束的别名承诺

void add_arrays(size_t count,
                int *restrict out,
                const int *restrict left,
                const int *restrict right);

让实现可以假设 out、left、right 所访问数组不以违约方式重叠,从而进行向量化等优化。它不是运行时检查;调用者若传入不满足契约的重叠区域,后果不是“变慢”而是行为未定义。

_Atomic:标准原子对象与操作

#include <stdatomic.h>
 
atomic_uint completed = 0;
 
void mark_complete(void) {
    atomic_fetch_add_explicit(&completed, 1u, memory_order_relaxed);
}

atomic_uint 创建原子对象。示例计数器只要求更新不丢失,使用 relaxed 顺序;若该变量还发布其他数据,则必须按通信协议选择 acquire/release 等顺序。原子性解决特定对象的竞争,不自动让一组复合操作成为事务。

典型进程内存布局:实现模型,不是 C 标准分区

托管操作系统和常见 ABI 往往把进程虚拟地址空间组织成下面这些区域;嵌入式、分段架构或优化后的程序可以完全不同。可移植 C 代码应依据存储期和分配 API 推理,不能依赖段名、地址顺序或栈增长方向。

典型进程虚拟内存(低地址在上)代码段 (.text)机器指令、只读main() 函数体已初始化静态/全局 (.data)有显式初值int g = 42;BSS 段 (.bss)未初始化静态/全局 → 0static int count;堆 Heap ↑ 向上增长malloc / calloc / freeint *p = malloc(n);栈 Stack ↓ 向下增长auto 局部变量、函数帧int local; 参数、返回地址malloc向高地址函数调用向低地址谁放哪?· 字符串字面量 → 只读数据· static / 全局 → .data 或 .bss· 局部 auto → 栈· malloc → 堆栈空间有限;大块或生命周期跨函数的内存应放堆,并记得 free。
C 程序运行时,代码与静态数据在固定区域;堆与栈相向增长。理解布局有助于解释栈溢出、内存泄漏和 static 变量为何「记住」上次值。
区域典型内容谁分配/释放
代码段机器指令加载时固定
.data有初值的全局/static程序整个生命期
.bss未初始化全局/static(零初始化)程序整个生命期
动态分配区(常称堆)malloc 族返回的块程序调用 free
调用栈(若 ABI 使用)部分自动对象、保存的调用状态实现随调用进入/返回管理

返回具有分配存储期的块;对应释放是 。这里习惯称“堆”,但 C 标准按分配函数和存储期定义行为,不要求某种堆数据结构。

malloc / free 生命周期

动态数组、运行时才知道长度的字符串,往往离不开堆。标准用法是「申请 → 使用 → 释放」三步,漏掉最后一步就泄漏。

int *p = malloc(n * sizeof(int));堆 Heap泄漏块p① malloc堆上划 n×sizeof(int)返回 void* → 赋给 p② 使用p[i] = ...读写堆内存③ free(p)归还堆块之后勿再解引用 p④ 泄漏只 malloc 不 free堆越用越少④ 丢失唯一指针却不 free → 内存泄漏,长跑程序会耗尽堆。
动态内存三件套:malloc 申请、使用、free 释放。谁分配谁释放;成对出现,否则泄漏。
分步1 / 4

第一步:malloc 申请

int *p = malloc(n * sizeof(int));堆 Heapn × intp① malloc堆上划 n×sizeof(int)返回 void* → 赋给 p② 使用p[i] = ...读写堆内存③ free(p)归还堆块之后勿再解引用 p④ 泄漏只 malloc 不 free堆越用越少① malloc 成功时返回可用指针;失败返回 NULL,必须检查。
动态内存三件套:malloc 申请、使用、free 释放。谁分配谁释放;成对出现,否则泄漏。

先验证 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 *arrsizeof(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 组知识约束。学习时要同时说明它接受什么输入、改变什么状态、在何种边界失效;再以本章示例的编译诊断、固定输入输出或失败用例复核结论,不能只记术语名称。

讨论

评论区加载中…