清风日常

Stay curious. Stay gentle.

听一会儿

4 首收藏
LOVE 2000 封面

LOVE 2000

遠野ひかる

0:004:23
首页/C Primer Plus/正文

C Primer Plus(第6版)· 第12章:存储类别、链接和内存管理

变量的类型说明“怎样解释数据”,存储规则则说明“怎样找到数据,以及数据会存在多久”。本章把单个函数中的变量扩展到多次调用、多个源文件和运行时分配的内存。

文章目录 52 个章节

第12章:存储类别、链接和内存管理

变量的类型说明“怎样解释数据”,存储规则则说明“怎样找到数据,以及数据会存在多久”。本章把单个函数中的变量扩展到多次调用、多个源文件和运行时分配的内存。

理解这些规则时,要把三个问题分开:作用域决定名字在哪里可见,链接决定不同声明是否指向同一个实体,存储期决定对象存在多久。

1. 对象、标识符与访问方式

c
int value = 10;
int *p = &value;

value 对应一个存放整数的对象;p 对应另一个存放地址的对象。

表达式 含义
value 用标识符指定整数对象
p 用标识符指定指针对象
*p 通过指针指定那个整数对象
&value 得到整数对象的地址
&p 得到指针对象自身的地址

一个对象可以有多种访问方式,匿名的动态分配对象也不一定有自己的变量名。

指定对象的表达式可以是左值,但左值不一定可修改。例如,通过 const int * 解引用得到的表达式是左值,却不能通过它修改整数。

不要把“名字已经不可见”直接等同于“对象已经不存在”,也不要因为一个指针变量还存在,就认为它指向的对象仍然存在。

2. 作用域:在源码的什么位置可以使用名字

2.1 四类作用域

类别 适用对象与范围
块作用域 块中声明的局部标识符,通常从声明处到所在块结束
文件作用域 函数之外的声明,从声明处到翻译单元结束
函数原型作用域 原型中的形参名,到该原型声明结束
函数作用域 goto 使用的标签,范围覆盖整个函数

函数定义的形参具有块作用域。不要把“函数里的局部变量”误称为具有函数作用域;标准中的函数作用域主要用于标签。

2.2 内层同名声明会隐藏外层声明

c
#include <stdio.h>

int main(void)
{
    int value = 10;

    {
        int value = 20;
        printf("inner: %d\n", value);
    }

    printf("outer: %d\n", value);
    return 0;
}

输出:

text
inner: 20
outer: 10

两个 value 对应不同对象。内层声明不会把外层对象改成新类型,也不会因为隐藏它就销毁它。

c
for (int i = 0; i < 3; ++i) {
    /* i 在这里可见。 */
}
// 这个循环声明的 i 在这里不可见。

尽量避免不必要的同名隐藏。就近声明变量能缩小可见范围,但让多层变量故意同名通常只会增加阅读成本。

2.3 文件作用域不是从文件第一行开始

c
void first(void);

int total = 0;

void second(void)
{
    ++total;
}

total 的文件作用域从其声明处开始。位于声明之前的代码不会自动获得这个名字,需要事先有适当声明。

2.4 翻译单元不等于一个物理文件

一个 .c 文件经过预处理,连同它包含的头文件内容,构成一个翻译单元。

text
main.c + 它包含的头文件 → 一个翻译单元
stats.c + 它包含的头文件 → 另一个翻译单元

理解文件作用域和内部链接时,更准确的范围是翻译单元,而不只是编辑器里看到的那个 .c 文件。

3. 链接:不同声明怎样指向同一个实体

3.1 三种链接属性

链接属性 含义 常见例子
无链接 这个声明中的名字不通过链接与其他声明共享实体 普通局部变量、局部静态变量
内部链接 同一翻译单元中的相关声明指向同一实体 文件作用域的 static 对象或函数
外部链接 不同翻译单元中的相关声明也可以指向同一实体 通常的文件作用域对象、公开函数

链接不是运行时的指针连接,也不等于可见范围。另一个源文件中的对象具有外部链接,不代表当前文件不写声明就能直接使用它。

3.2 局部名字不一定都无链接

普通块内变量通常无链接,但块内也可以出现对已有对象的 extern 声明:

c
int total = 0;

void add_one(void)
{
    extern int total;
    ++total;
}

块内的这个声明仍然引用文件作用域的 total,没有创建一个新的自动变量。

如果去掉 extern,改成块内的 int total;,才是在创建并隐藏外层名字的局部对象。

4. 存储期:对象存在多久

存储期 生存期的大致范围 典型对象
自动存储期 对应块的一次执行期间;VLA 从声明执行时开始 普通局部变量、形参、局部 VLA
静态存储期 程序整个执行期间 文件作用域对象、局部静态对象、字符串字面量
线程存储期 所属线程的执行期间 C11 的 _Thread_local 对象
分配存储期 成功分配之后,直到相应释放 malloc、calloc 等取得的内存

这些概念描述语言规则,不要求实现一定把自动对象放在硬件栈、把动态对象放在某个固定地址区间。

4.1 自动对象每次调用有各自的实例

函数每次被调用,自动局部对象会重新参与本次执行。递归调用时,各层调用的普通局部变量也是不同对象。

退出块后,不得再通过保存的地址访问已经结束生存期的自动对象:

c
// 不要从函数返回指向普通局部变量或局部数组的指针。

4.2 静态存储期不表示只读

c
static int count = 0;

static 不会禁止修改 count。它的作用取决于声明位置;只读访问由 const 等类型规则表达。

4.3 线程对象与全局共享对象不同

c
_Thread_local int thread_count = 0;

在支持相应 C11 特性的实现中,每个线程拥有自己的实例,而不是所有线程共用同一个计数器。

线程存储期与链接仍是不同维度。_Thread_local 还可以按适用规则和 static、extern 组合;本章只需掌握“每线程各有一份”的区别,不必由此展开完整并发编程。

5. 五种常见存储类别放在一起比较

以下以本章采用的 C99/C11 规则为背景:

常见类别 典型声明位置与写法 作用域 链接 存储期
普通自动变量 块内 int n; 块 无 自动
寄存器变量 块内 register int n; 块 无 自动
局部静态变量 块内 static int n; 块 无 静态
文件内私有对象 文件作用域 static int n; 文件 内部 静态
可跨文件共享对象 文件作用域 int n; 或带初始化的定义 文件 外部 静态

extern 常用来补充对已有对象的声明,不能只看到关键字就认定它在创建一种新的独立对象。

C99/C11 把 auto、register、static、extern、_Thread_local 和 typedef 归入存储类别说明符。其中 typedef 用于定义类型别名,本身不分配对象内存。这种语法分类不表示这些关键字都在完成同一种内存操作。

5.1 auto 是普通自动变量的显式说明

c
void task(void)
{
    auto int value = 10;
}

在这里,auto 通常可以省略。本章语境中的 auto 是存储类别说明符,不是 C++ 中常见的类型推导用法。

5.2 register 是请求,不是硬件保证

c
void task(void)
{
    register int index = 0;
    // &index 在本章采用的规则下不允许。
}

它请求实现考虑更快的访问方式,但编译器可以忽略请求。即使最终没有放进 CPU 寄存器,仍然不能对该变量使用地址运算符。

程序的正确性和效率不应依赖“写了 register 就一定进入寄存器”。

5.3 自动变量不会默认清零

普通自动对象没有初始化时,值不确定,不能直接读取。它们可以用运行时表达式初始化:

c
int first = 3;
int second = first * 2;

静态存储期对象没有显式初始化时会被零初始化:整数得到零、算术类型得到相应零值、指针得到空指针。不要把这简单说成“所有类型的所有位都必定清零”;语言的零初始化与字节模式是不同层面的规则。

6. 局部静态变量:函数调用之间保留状态

c
#include <stdio.h>

void report(void);

int main(void)
{
    for (int i = 0; i < 3; ++i) {
        report();
    }
    return 0;
}

void report(void)
{
    int fresh = 1;
    static int saved = 1;

    printf("fresh = %d, saved = %d\n", fresh, saved);
    ++fresh;
    ++saved;
}

输出:

text
fresh = 1, saved = 1
fresh = 1, saved = 2
fresh = 1, saved = 3

fresh 每次调用重新初始化,saved 始终是同一个对象。saved 的初始化不会在每次进入函数时重新执行。

局部静态对象的名字仍然只在相应块内可见。函数返回后对象继续存在,不意味着其他函数自动获得了这个名字。

适合保存调用次数、局部持久状态;但它会引入调用之间的依赖。递归调用不会为静态局部对象额外创建独立副本,多线程访问也需要另外考虑同步。

静态存储期对象的初始化不能随意调用函数获取运行时值:

c
int initial_value(void);

void task(void)
{
    // static int saved = initial_value(); // 不是合法的 C 静态初始化。
}

确实需要运行时初始化时,要另行设计执行逻辑,而不是套用其他语言的局部静态初始化规则。

7. 文件作用域的 static:把实现细节留在模块内

c
static unsigned count = 0;

static void record_one(void)
{
    ++count;
}

unsigned current_count(void)
{
    record_one();
    return count;
}

count 与 record_one 都具有内部链接。其他翻译单元不能通过同名外部声明引用它们,可以定义自己的同名内部实体。

current_count 默认具有外部链接,适合作为模块公开接口。

普通文件作用域对象(未使用 _Thread_local)本来就有静态存储期;在这里添加 static 的关键区别是把链接改为内部链接。不要把它解释成“只有加上 static 才能活到程序结束”。

函数本身不属于普通数据对象,不宜把局部变量的存储期规则直接套到函数上。对函数使用 static,主要是在控制链接。

8. extern、定义与多文件共享

8.1 声明提供信息,定义提供实体

c
extern int total; // 通常是引用声明,不在这里定义对象。
int total = 0;    // 定义对象并初始化。

一个对象可以有多次相容声明,但需要按语言和链接规则提供定义。跨源文件共享变量时,清楚采用“一处定义,其他地方声明”的组织方式。

extern 不是“去其他文件找”的机械指令,它也可以引用同一文件中已有的对象。

文件作用域带初始化的 extern 声明仍然是定义:

c
extern int total = 7; // 文件作用域下,这仍然定义了对象。

因此,不能在多个 .c 文件中都写这一句,指望它们只是重复声明。

8.2 不带初始化的文件作用域声明有特殊规则

c
int total;

在文件作用域,这通常是暂定定义。若同一翻译单元中没有其他完整定义,到翻译单元末尾会按规则形成零初始化的定义。

不要把普通的 int total; 放进被多个 .c 包含的头文件,以为它只是一个共享声明。头文件中应使用 extern int total;,实际定义放在一个 .c 中。

8.3 完整的多文件示例

头文件 counter.h:

c
#ifndef COUNTER_H
#define COUNTER_H

extern unsigned total;
void record_event(void);

#endif

实现文件 counter.c:

c
#include "counter.h"

unsigned total = 0;
static unsigned batch = 0;

void record_event(void)
{
    ++batch;
    total += batch;
}

调用文件 main.c:

c
#include <stdio.h>
#include "counter.h"

int main(void)
{
    record_event();
    record_event();
    printf("total: %u\n", total);
    return 0;
}

在这些文件所在目录编译:

sh
cc -std=c11 -Wall -Wextra main.c counter.c -o counter_demo

输出:

text
total: 3

main.c 与 counter.c 使用的是同一个 total。batch 由模块私有,调用者只能通过函数影响它。

这个示例为了说明共享变量而公开 total。实际设计中,也可以把它设为内部对象,再用读取函数公开结果,进一步限制外部修改。

8.4 extern 不会自动改变已有内部链接

c
static int private_count = 0;

void record(void)
{
    extern int private_count;
    ++private_count;
}

这里块内的声明引用已可见的内部对象,不会使它突然变成可以跨翻译单元共享的外部对象。

8.5 函数的默认链接

c
int public_task(void);
extern int another_task(void);
static int helper(void);

通常的函数声明默认具有外部链接,extern 可以显式表达这一点;static 用来声明当前翻译单元私有的函数。

头文件放公开函数原型,内部辅助函数留在实现文件中。不要通过包含另一个 .c 文件来代替正常的多文件编译。

9. 随机数:用持续存在的状态生成序列

9.1 rand 与 srand 分工不同

c
#include <stdlib.h>

srand(123);
int value = rand();

rand 返回 0 到 RAND_MAX 之间的整数,RAND_MAX 定义在 <stdlib.h> 中,标准保证它至少为 32767。

srand 设置种子,影响随后生成的序列。同一实现中使用相同种子,并执行相同的调用过程,会得到相同序列;不同实现不保证产生同一组具体数字。

在没有显式调用 srand 时,序列按种子为 1 的情形开始。伪随机的含义是序列由内部状态和算法决定,不是每次调用都凭空产生不可预测的数字。

9.2 为什么生成器需要静态状态

text
当前状态 → 算法变换 → 新状态 → 本次输出
                         ↓
                    留给下一次调用

如果把状态作为每次都重置的自动局部变量,函数就无法自然地延续序列。

可以把状态放在文件作用域的内部对象中,让设置种子的函数和生成数字的函数共享它;其他文件只通过公开接口操作。这是 static 与模块封装的一种典型结合。

也可以把状态交给调用者持有,通过参数传入,从而同时维护多组独立序列。需要什么范围的共享,就选择相应的状态归属。

9.3 完整示例:验证固定种子的可重复性

c
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int first[5];

    srand(123);
    for (int i = 0; i < 5; ++i) {
        first[i] = rand();
    }

    srand(123);
    for (int i = 0; i < 5; ++i) {
        if (rand() != first[i]) {
            return EXIT_FAILURE;
        }
    }

    puts("same seed, same sequence");
    return EXIT_SUCCESS;
}

固定种子方便复现调试。想让不同运行通常使用不同种子,可以在程序初始化时调用:

c
#include <stdlib.h>
#include <time.h>

srand((unsigned int)time(NULL));

通常只在初始化时设种子,不要每掷一次骰子就重新设种子。时间变化不够快时,反复用时间设种子可能反复生成相同结果。

9.4 将结果映射到骰子点数

c
int roll_die(int sides)
{
    if (sides < 2 || sides > RAND_MAX) {
        return 0;
    }

    return rand() % sides + 1;
}

先求余得到 0 到 sides - 1,再加 1。这是简单模拟的常见写法,调用者需要识别无效参数时返回的 0。

它不保证严格均匀:即使原始输出均匀,当取值总数不能被面数整除时,取模也会引入偏差。统计质量要求更高时,需要选择适当的生成器与映射算法。rand 也不适合生成密码或安全令牌。

若连续掷多个骰子,还要保证累加结果不会超出目标整数类型范围。验证面数、次数和总和范围,是调用接口的一部分。

10. 动态分配:把对象的生存期交给程序管理

10.1 malloc 接收的是字节数

c
#include <stdlib.h>

double *data = malloc(count * sizeof *data);

count 是元素个数,sizeof *data 是一个元素的大小,乘积才是所需字节数。

malloc 返回 void *,在 C 中可以隐式转换为相应对象指针,不要求额外强制转换。正确包含 <stdlib.h> 比手写或猜测函数声明更重要。

sizeof *data 不会在这里读取一个 double 值,它取得所指类型的大小。把类型大小写成这种形式,修改指针类型时也不容易漏改分配表达式。

malloc 分配的内容没有被初始化。分配成功后,需要先赋值再读取。

10.2 先验证数量和乘法,再检查分配结果

c
#include <stdint.h>
#include <stdlib.h>

double *data = NULL;

if (count == 0 || count > SIZE_MAX / sizeof *data) {
    /* 拒绝零长度或会使字节数乘法溢出的请求。 */
} else {
    data = malloc(count * sizeof *data);
    if (data == NULL) {
        /* 分配失败,不能解引用 data。 */
    }
}

size_t 的乘法可能发生无符号回绕,使一个很大的请求变成很小的分配。只检查 malloc 是否返回空指针,不足以发现之前已经算错的字节数。

对于零长度请求,分配函数具有特殊规则。初学时可以让自己的接口明确拒绝零长度,避免拿返回地址当成存在可访问元素的保证。

若数量来自有符号整数输入,还应先拒绝负数,再转换为 size_t。负数直接参与无符号大小计算,可能变成一个极大的请求。

10.3 指针对象与分配对象有不同生存期

text
函数调用期间:
局部指针 data ──→ 动态分配的数据块

函数返回后:
局部指针可以消失,数据块不会因此自动释放。

动态数据可以在函数中分配,通过返回值交给调用者,最后在调用者中释放。反过来,局部指针还在,并不意味着它所指的数据没有被别处释放。

sizeof data 只得到指针大小,不是动态分配的字节数。元素个数必须由程序另行保存并传递。

11. 完整示例:创建数组并转交释放责任

下面的函数约定:拒绝零元素请求;失败返回空指针;成功返回包含 count 个元素的数组,由调用者负责释放。

c
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

int *make_array(size_t count, int initial);

int main(void)
{
    size_t count = 5;
    int *data = make_array(count, 3);

    if (data == NULL) {
        fputs("allocation failed\n", stderr);
        return EXIT_FAILURE;
    }

    int total = 0;
    for (size_t i = 0; i < count; ++i) {
        total += data[i];
    }

    printf("count: %zu, total: %d\n", count, total);

    free(data);
    data = NULL;
    return EXIT_SUCCESS;
}

int *make_array(size_t count, int initial)
{
    int *data;

    if (count == 0 || count > SIZE_MAX / sizeof *data) {
        return NULL;
    }

    data = malloc(count * sizeof *data);
    if (data == NULL) {
        return NULL;
    }

    for (size_t i = 0; i < count; ++i) {
        data[i] = initial;
    }

    return data;
}

输出:

text
count: 5, total: 15

返回时消失的是函数的局部指针变量,返回的地址值已经交给调用者。动态分配对象仍存在,直到调用者执行 free。

这与返回普通局部数组的地址完全不同。返回一个地址是否合法,要看被指向对象的生存期,而不只是看返回值类型是否为指针。

12. 释放、别名与内存泄漏

12.1 free 归还的是一整块分配

c
free(data);
data = NULL;

free 不需要额外传入元素个数。对于非空参数,必须传入尚未释放的、由兼容分配函数返回的分配起始地址。

c
int *data = malloc(10 * sizeof *data);

// free(data + 1); // 错误:不是分配块的起始地址。
// free(local);   // 如果 local 是普通局部数组,也不能这样释放。

free(NULL) 没有效果,可以用于简化清理逻辑。释放一块内存后,不能再次释放同一块,也不能继续访问其中对象。

12.2 设一个指针为空,不会清理所有别名

c
int *owner = malloc(sizeof *owner);
if (owner != NULL) {
    int *alias = owner;
    *alias = 7;

    free(owner);
    owner = NULL;

    // alias 没有因此变成 NULL,也不能再用于访问已释放对象。
}

释放后,不应再读取、解引用或拿旧别名继续进行地址计算。把 owner 设为空指针,有助于阻止经由这个变量误用内存,却不能使其他旧别名恢复有效。

清晰的设计应说明:谁拥有释放责任,谁只是临时借用,以及借用何时结束。

12.3 内存泄漏是丢失了释放机会

text
分配成功 → 保存地址 → 完成使用 → 释放
                     ↓
              地址丢失且没有释放
                     ↓
                  内存泄漏

常见原因包括:

  • 函数退出前忘记释放临时分配。
  • 把唯一保存地址的指针改指向别处。
  • 错误分支直接返回,跳过已成功分配对象的清理。

不使用某块内存,不等于它已经可以被分配器重新使用。释放责任要覆盖成功路径与失败路径。

13. calloc、VLA 与动态数组的区别

13.1 calloc 把整块内存的位清零

c
int *counts = calloc(count, sizeof *counts);

calloc 接收元素数量与元素大小,分配后把所有位设为零,失败返回空指针,结果同样由 free 释放。

项目 malloc calloc
主要参数 总字节数 元素数量、单个元素字节数
初始内容 未初始化 所有位清零
释放方式 free free

对于整数计数数组,清零通常正好符合需求。但不要把“所有位为零”无条件等同于所有类型的语言零值:浮点数与空指针表示并不在所有实现上都必须是全零位模式。

例如,若需要一个初始化为空指针的指针数组,逐个赋值 NULL 能直接表达语言层面的意图。

13.2 VLA 由块的执行管理,动态内存由程序管理

比较项 局部 VLA 分配得到的数组
大小确定时间 声明执行时 调用分配函数时
创建后是否自动变长 否 否
何时释放 退出相应块时自动结束 程序调用 free
能否把有效数据交给调用者长期使用 不能返回局部 VLA 地址后继续使用 可以转交指针和释放责任
sizeof 实际对象 可得到运行时数组大小 指针本身不记录分配大小
失败处理 没有类似 malloc 的空指针返回通道 检查返回空指针

不能对 VLA 调用 free。两种方式都允许运行时决定元素数量,但它们的存储期与管理责任不同。

13.3 动态分配连续二维数组

固定列数时,可以使用指向一行的指针:

c
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    enum { COLS = 3 };
    size_t rows = 2;
    int (*matrix)[COLS];

    if (rows == 0 || rows > SIZE_MAX / sizeof *matrix) {
        return EXIT_FAILURE;
    }

    matrix = malloc(rows * sizeof *matrix);
    if (matrix == NULL) {
        return EXIT_FAILURE;
    }

    int total = 0;
    for (size_t r = 0; r < rows; ++r) {
        for (size_t c = 0; c < COLS; ++c) {
            matrix[r][c] = (int)(r * COLS + c + 1);
            total += matrix[r][c];
        }
    }

    printf("last: %d, total: %d\n", matrix[1][2], total);
    free(matrix);
    return EXIT_SUCCESS;
}

输出为 last: 6, total: 21。sizeof *matrix 是一整行的大小,所以分配的是 rows 行。

支持变长数组类型时,也可以用运行时列数声明 int (*matrix)[cols]。创建这种类型前,必须先确保列数为正,行大小与总大小计算也都能表示。

这种连续布局使用 int (*)[COLS],不是 int **。若创建的是“指针数组加各行独立分配”的布局,就需要分别管理指针数组和各行对象。

14. 多层分配:每一层都要有对应清理

若每个字符串长度不同,可以先分配一个指针数组,再为每个字符串分配恰好容纳它的空间:

text
words ──→ [指针0] ──→ "red\0"
          [指针1] ──→ "green\0"
          [指针2] ──→ "blue\0"

这里总共有四块分配:一个指针数组,加三个字符数组。只调用 free(words) 不会自动释放三个字符串。

下面的程序展示正常释放以及分配到一半失败时的回收方式:

c
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

char **copy_words(const char *const source[], size_t count);
void free_words(char *words[], size_t count);

int main(void)
{
    const char *const source[] = {"red", "green", "blue"};
    size_t count = sizeof source / sizeof source[0];
    char **words = copy_words(source, count);

    if (words == NULL) {
        return EXIT_FAILURE;
    }

    words[0][0] = 'R';
    for (size_t i = 0; i < count; ++i) {
        puts(words[i]);
    }

    free_words(words, count);
    return EXIT_SUCCESS;
}

char **copy_words(const char *const source[], size_t count)
{
    char **words;

    if (count == 0 || count > SIZE_MAX / sizeof *words) {
        return NULL;
    }

    words = malloc(count * sizeof *words);
    if (words == NULL) {
        return NULL;
    }

    size_t made = 0;
    for (; made < count; ++made) {
        size_t bytes = strlen(source[made]) + 1;
        words[made] = malloc(bytes);

        if (words[made] == NULL) {
            free_words(words, made);
            return NULL;
        }

        strcpy(words[made], source[made]);
    }

    return words;
}

void free_words(char *words[], size_t count)
{
    if (words == NULL) {
        return;
    }

    for (size_t i = 0; i < count; ++i) {
        free(words[i]);
    }
    free(words);
}

输出:

text
Red
green
blue

source 中每一项都必须指向有效字符串。copy_words 复制字符内容,因此得到的字符数组可以独立修改。

made 只记录已经成功分配并复制的项。失败时,仅释放这些已完成对象,再释放外层指针数组;不读取后面尚未初始化的指针。

成功后,整个返回结果的释放责任交给调用者。free_words 要接收正确的已拥有字符串数量,它不是可以随意用于普通二维字符数组的通用释放函数。

15. 类型限定符:约束访问,表达优化与并发要求

同一层类型上重复使用相同限定符,不会叠加出额外一层限制。例如,类型别名已经包含 const 时,再加同层 const 不会得到一种“更只读”的对象。

15.1 const 限制的是相应访问或对象修改

c
int value = 1;
const int *reader = &value;
int *const fixed = &value;
声明 指针能否改变 能否通过指针修改整数
const int *reader 能 不能
int *const fixed 不能 能
const int *const both 不能 不能

如果原对象没有被定义成 const,它仍可以通过其他合法的非 const 访问方式修改。真正定义为 const 的对象,不能通过去掉限定符的指针绕过限制后修改。

函数接收普通整数形参时,本来就得到一个值的副本。给这种形参加 const,限制的是函数内对副本的赋值,不会额外保护调用方原整数。

指针形参则不同:const int *data 可以表达“通过这个形参不修改调用方的整数”。

15.2 跨文件共享只读对象

一处定义,头文件提供引用声明:

c
// constants.h 中
extern const double conversion_rate;

// constants.c 中
const double conversion_rate = 2.54;

标准 C 中,文件作用域的普通 const 定义不会仅仅因为 const 就自动得到内部链接。这一点与 C++ 的常见规则不同。

也可以在头文件中写:

c
static const double conversion_rate = 2.54;

这会让各翻译单元分别拥有一个内部对象,通常不是所有文件共用同一个对象。小型常量可能适用,大型只读数组则要考虑重复存储。

指针数组还有两层只读问题:

c
static const char *const labels[] = {"yes", "no"};

第一个 const 限制所指字符,第二个限制数组中的指针元素。只有 const char *labels[] 时,字符通过这些指针只读,但指针本身仍可改指向。

15.3 volatile 表示访问具有外部可观察要求

c
volatile unsigned status;
const volatile unsigned *device_status;

volatile 常用于特定环境中的设备寄存器等对象,提醒实现按照相应规则保留访问,不能随意假设两次读取一定得到相同结果。

const volatile 可以表达:程序通过这个访问方式不写入,但对象值仍可能被外部机制改变。

volatile 不会自动使复合操作成为原子操作,也不提供普通线程之间所需的同步。不能仅把共享计数器声明为 volatile,就认为 ++counter 在并发下已经正确。

15.4 restrict 是关于访问别名的承诺

c
void add_values(double *restrict target,
                const double *restrict source, size_t count)
{
    for (size_t i = 0; i < count; ++i) {
        target[i] += source[i];
    }
}

这个示例要求本次处理的源范围与目标范围互不重叠。编译器可以利用这种承诺,减少对别名修改的顾虑。

restrict 不等于“整个程序只能有一个指针保存这个地址”。它约束的是特定执行范围中,相关对象通过哪些访问路径被读取和修改。规则不能简单套用为指针数量限制。

承诺通常不会由运行时自动检查;调用者违反要求,可能导致未定义行为。只有能保证访问关系时才使用它,不能把它当成无条件提升速度的装饰。

memcpy 要求源和目标范围不重叠;memmove 允许重叠。两者的选择体现了接口对访问范围的不同约定。

15.5 _Atomic 用于原子访问

在支持 C11 原子特性的实现中,可以使用 <stdatomic.h>:

c
#include <stdatomic.h>
#include <stdio.h>

int main(void)
{
    _Atomic unsigned counter;
    atomic_init(&counter, 0);

    unsigned before = atomic_fetch_add(&counter, 1);
    unsigned after = atomic_load(&counter);

    printf("before: %u, after: %u\n", before, after);
    return 0;
}

输出为 before: 0, after: 1。

atomic_fetch_add 将读取旧值和加法更新组成一个原子操作。原子操作不会以部分完成的状态被相应并发访问观察到,但这不表示执行期间其他线程完全不能访问这个对象,也不等于自动给整个函数加锁。

多个原子操作组成的算法,仍需要正确设计操作关系与同步。尤其不能把 atomic_load 后再 atomic_store 的两步更新,直接当作一个不可分割的累加操作。

_Atomic、volatile、const 与 restrict 解决的问题不同,不能相互替代。

16. 数组形参方括号中的限定符与 static

C99 允许在数组形参最外层方括号中写限定符。它们作用于调整后的指针形参:

c
void process(int data[const], size_t count);
// 对应形参指针为 int *const data。

void update(int data[restrict], size_t count);
// 对应形参指针为 int *restrict data。

这与下面限制元素访问的写法不同:

c
void show(const int data[], size_t count);
// 对应形参为 const int *data。

方括号里的 static 还有另一种含义:

c
double sum_first_four(const double data[static 4]);

它要求每次调用时,实参提供至少 4 个可访问元素。这是调用约定,不会在函数内部创建静态数组,不代表返回后仍保存实参数组,也不保证编译器为你插入边界检查。

static 出现位置 主要含义
块内对象声明 对象具有静态存储期
文件作用域对象声明 对象具有内部链接,同时本来就有静态存储期
文件作用域函数声明 函数具有内部链接
数组形参的最外层方括号 对本次调用提供的最少元素数量作出要求

理解一个关键字时,要结合完整声明和出现位置,而不是只记一个固定中文解释。

17. 选择存储方式时先考虑责任范围

需求 合适的组织方式
一次函数调用中的临时数据 普通自动变量
一个块执行期间需要运行时数组大小 支持且规模合适时使用 VLA
多次调用之间保留一份局部状态 局部静态对象,并明确共享影响
一个模块内多个函数共同使用状态 文件作用域内部对象,通过公开函数操作
确实需要跨模块共享同一个对象 一处定义,相容的外部声明
数据需要超出创建函数继续存在 动态分配,明确把释放责任交给谁
每个线程分别维护自己的状态 适当的线程存储期对象
多线程共同更新对象 使用适当的原子操作或其他同步机制

多数临时数据使用普通局部变量最容易推理。扩大可见范围、保留跨调用状态或延长数据生存期时,应能说清楚由谁访问、谁修改、谁最终清理。

看到一个指针时,除了检查类型,还应确认它指向的对象是否仍然存在;看到一个共享变量时,除了确认名字可见,还应确认所有声明是否引用同一个对象,以及修改是否符合接口约定。

Aron 的清风日常

作者 Aron · 发布于 2026.08.10

愿这份记录能为你提供一点参考。发现问题,欢迎通过 GitHub 或 邮件交流。

如果这篇记录对你有用,留下一份喜欢。

← 所有文章继续阅读C Primer Plus(第6版)· 附录整理:语法与标准库速查 →
SEARCH MY NOTES

在记录里,找一点答案

搜索标题、标签与全文 · 按 Esc 关闭