根据 C 语言最新的 ISO 9899:2024 (C23) 标准,我们来系统地梳理程序中 标识符 (identifier) 的 作用域 (scope)、链接 (linkage) 和 名字空间 (name space),以及 变量 (object or variable) 的 存储期 (storage duration) 这几个核心概念。
虽然 C 程序最终必须被翻译成机器指令才能执行,但在理解语言本身时,我们有必要想象存在一台能直接“执行”C 程序全部文本(包括各种声明和预处理指令语义)的 抽象机器 (abstract machine)。C 语言标准正是从抽象机的角度定义了程序的语义。作为程序员,自觉运用这个视角是正确编程的思想基础。
1. 标识符的作用域
在 C 程序中,标识符 用来标识各种实体 (entity),例如:
- 变量、函数
- 结构体/共用体/枚举的标签 (tag) 和成员
- 由
typedef 定义的类型
- 语句标号
可以说,标识符就是实体的名字。标识符与实体的关联通过 声明 (declaration) 来确立。变量和函数需要占用内存来存储数值和指令代码,这是它们与其他实体的主要区别。
枚举类型的成员称为 枚举常量 (enumeration constant),它类似于宏定义的宏名,但必须是整型常量表达式。一个枚举类型定义了一个整型枚举常量的集合,枚举常量标识符本质上就是一个整型常量,主要用于提高代码可读性。
同一个标识符在程序的不同位置可以标识不同的实体。对于标识符所标识的每一个实体,该标识符只在程序文本的一个特定区域内是 可见的 (visible) 或 可引用的 (can be used),这个文本区域就称为该标识符的 作用域。
同一标识符标识的不同实体,必须具有不同的作用域或属于不同的名字空间,以此来消除引用歧义,否则会导致语义错误。
作用域分为四类:函数作用域 (function scope)、文件作用域 (file scope)、块作用域 (block scope) 和 函数原型作用域 (function prototype scope)。在讨论作用域时,“函数原型”特指不带函数体的函数声明。
- 函数作用域:只有
语句标号名 (label name) 具有函数作用域。一个标号可以在其所属函数的任何地方被 goto 语句引用。标号通过“标号名后接冒号和一条语句”这种独特的语法格式来隐式声明。
- 文件作用域:如果一个标识符(准确地说是其声明子元或类型指定子元)出现在所有块结构(用
{ } 包裹的代码块)和参数列表之外,那么它就具有文件作用域。其作用域始于声明点,结束于其所在的 翻译单元(可以简单理解为预处理后的源文件)末尾。
- 块作用域:如果一个标识符出现在任何一个块结构或函数定义的参数列表之内,那么它就具有块作用域。其作用域始于声明点,结束于其直属块的末尾。
- 函数原型作用域:如果一个标识符位于函数原型(非函数定义的一部分)的参数列表之内,那么它就具有函数原型作用域。其作用域始于声明点,结束于函数声明子元之尾,即声明末尾的分号之前。
如果同一标识符标识同一名字空间的两个不同实体,它们的作用域可以 交叠 (overlap)(例如文件作用域包含块作用域)。但如果交叠,内层作用域必须严格结束于外层作用域之前。这样,在内层作用域中,该标识符引用的是在内层声明的实体,而外层声明的实体则被暂时 隐藏 (hidden)。
两个标识符的作用域当且仅当在同一点结束时,才被视为具有相同的作用域。
2. 标识符的链接
在不同的作用域或同一作用域内多次声明的同一标识符,可以通过一个链接过程来引用同一个变量或函数。链接的本质就是地址解析和确定的过程。不同标识符之间不存在链接。
链接有三种:外链 (external linkage)、内链 (internal linkage) 和 无链 (no linkage)。一个标识符声明只能附带一种链接。
- 外链:在构成整个程序的所有翻译单元和函数库范围内,每次声明该标识符都会引用同一个变量或函数(即全局唯一)。
- 内链:在一个翻译单元范围内,每次声明该标识符都会引用同一个变量或函数(即文件内唯一)。
- 无链:每次声明该标识符都会引用一个独一无二的实体(不限于变量或函数)。
链接的确定规则:
- 在具有文件作用域的变量或函数标识符的声明中,如果包含关键字
static,则该标识符具有内链。
- 对于用关键字
extern 声明的标识符,如果在其作用域内存在一个可见的(未被隐藏的)先前声明,则其链接属性与先前声明相同(若先前声明为内链或外链)。如果不存在可见的先前声明,或先前声明指定为无链,则该标识符具有外链。
- 如果函数标识符的声明不含任何存储类指定子元(如
static, extern),则其链接属性的认定方式与声明包含 extern 时相同。
- 如果变量标识符的声明具有文件作用域且不含关键字
static,则该标识符具有外链。
下列标识符无链接:
- 既不是变量也不是函数的标识符(如标签、标号)。
- 函数参数标识符。
- 具有块作用域且其声明不含关键字
extern 的变量标识符。
所有库函数的标识符均具有外链。
重要提示: 在一个翻译单元内,如果同一个标识符同时以内链和外链的形式出现,其行为是未定义的 (undefined),编程时必须避免这种情况。
3. 标识符的名字空间
如果在翻译单元内的某一点,同一个标识符有多个针对不同实体的声明同时可见,那么这些引用会由上下文语法结构特征自动区分开来。这就自然地形成了不同的名字空间:
- 语句标号名:由其“后接冒号和语句”的语法特征区分,构成一个独立的名字空间。
- 结构体、共用体和枚举的标签:分别由关键字
struct、union 或 enum 区分出来,它们共同形成一个名字空间。
- 结构体或共用体的成员:每一个结构体或共用体的全体成员构成一个独立的名字空间,由包含操作符
. 或 -> 的成员访问表达式的独特语法区分。
- 普通标识符:除上述以外的所有其他标识符(包括枚举常量标识符)归为一类,构成一个名字空间。
标识符的名字空间是消除引用歧义的另一个重要机制。
4. 变量的存储期
存储期决定了变量的 生命期 (lifetime)。这里我们主要讨论最基础的两种:静态存储期 (static storage duration) 和 自动存储期 (automatic storage duration)。
变量的生命期是程序运行过程中的一个时间段,在此期间确保变量拥有内存空间。在生命期内,变量存在、拥有恒定的内存地址、并持有最近一次存储的值。在变量生命期之外引用它是未定义行为。
存储期的确定规则:
- 如果一个变量标识符的声明具有外链或内链,或者其声明包含关键字
static,则该变量具有静态存储期(可称为静态变量)。静态变量的生命期为整个程序运行全程,且在程序开始执行前只初始化一次。
- 如果一个变量标识符被声明为无链,且其声明不包含关键字
static,则该变量具有自动存储期(可称为自动变量)。
- 对于固定长度数组或非数组型的自动变量,其生命期从进入其直属块开始,到该块以任何方式执行结束为止。如果因递归进入同一块,每次都会生成新的变量实例。自动变量的初值是不确定的 (indeterminate)。如果指定了初始化,则每当执行流到达其声明时才会进行初始化(有可能被
goto 跳过)。
- 对于可变长数组型的自动变量,其生命期从变量声明点开始,到程序执行离开所声明的作用域为止。每次递归进入都会生成新的实例,且其初值总是不确定的。
5. 外部定义
标识符的 定义 (definition) 是一种特殊的 声明 (declaration):
- 变量定义:是导致变量内存得到分配的变量标识符声明。
- 函数定义:是包含函数体
{ ... } 的函数标识符声明。
因此,定义一定是声明,但声明不一定是定义。编译器根据函数定义生成指令代码,根据变量定义分配内存。
一个翻译单元是由一组 外部声明 (external declaration) 的序列构成的。“外部”是指这些声明出现在所有函数之外(因此具有文件作用域)。外部定义 (external definition) 就是充当函数或变量定义的外部声明。
定义的唯一性规则:
- 一个内链标识符在一个翻译单元内只能有一个外部定义。
- 一个外链标识符在整个程序范围内只能有一个外部定义。
- 一个无链变量标识符的每一次声明都会引用一个独一无二的变量,因此该声明必定是定义。
外部变量定义的特殊情况:
- 如果一个具有文件作用域的变量声明带有初始值 (initializer),那么它是一个正式的外部定义。
- 如果一个具有文件作用域的变量声明未带初始值,也未指定
extern 存储类,那么它只是一个 临时定义 (tentative definition)。
- 如果一个翻译单元包含一个标识符的一个或多个临时定义,但没有其正式的外部定义,那么这些临时定义将共同被视为一个初始值为“空”的正式外部定义(数值变量初始化为0,指针变量初始化为
NULL)。
6. 综合程序示例
下面这个由多个文件组成的程序,将帮助我们具体理解上述所有概念。
文件:stdio.h (片段)
int printf(const char *format, ...); // 库函数原型声明
文件:header.h
extern int i1;
void f1(int p);
int f3(int p);
文件:source1.c
#include <stdio.h>
#include "header.h"
int main(void) {
int i;
struct i1 {
int i;
int i1;
};
typedef struct i1 Struct1;
Struct1 i2 = { 8, 9 };
enum i { X = 10, Y, Z };
enum i y = Y;
i = 5;
{
int i = 6;
int i1 = 7;
f1(i + i1);
goto i1;
i = i1;
}
i1:
printf("%d\n", i + i1 + i2.i + y);
return 0;
}
文件:source2.c
#include "header.h"
int i1 = 1;
static int i2 = 2;
static int f2(int p) {
int i1 = 3;
return p + i1 + i2;
}
void f1(int p) {
int i2 = 4;
i1 += p + i2 + f2(i2) + f3(p);
}
文件:source3.c
#include "header.h"
int f3(int p) {
return p + i1++;
}
程序执行流程与概念分析:
-
预备与链接:在程序开始执行前,静态变量被初始化。source2.c 中的 int i1 = 1; 是 i1 的唯一定义(外链,静态存储期)。source1.c 和 source3.c 中的 extern int i1; 是对此外部变量的声明。f1 和 f3 的定义与声明同理。static int i2 = 2; 是内链变量,仅在 source2.c 内可见。static int f2(...) 是内链函数,也仅在 source2.c 内可调用。
-
作用域与隐藏:
- 在
main 函数中,int i;(外层)和子块内的 int i = 6;(内层)都是普通标识符。内层 i 的作用域严格嵌套在外层 i 的作用域内,因此在子块中,外层 i 被隐藏。
- 外部变量
i1(来自 header.h)和子块内的 int i1 = 7; 同理,在子块内,全局的 i1 被隐藏。
- 在
f1 函数中,局部变量 int i2 = 4; 隐藏了同文件内的静态变量 static int i2 = 2;。
- 在
f2 函数中,局部变量 int i1 = 3; 隐藏了外部变量 i1。
-
名字空间:
main 函数中的 struct i1 { ... };(结构体标签)、int i1 = 7;(普通标识符)、i1:(语句标号)以及结构体成员 int i1; 分属四个不同的名字空间,因此即使同名也不会冲突。
- 同样,
int i;(变量)、int i;(结构体成员)、enum i { ... };(枚举标签)也分属不同名字空间。
-
程序执行与结果:
main 函数调用 f1(13)。
f1 执行 i1 += 13 + 4 + f2(4) + f3(13);。
f2(4) 返回 4 + 3 + 2 = 9(注意:f2 内的 i1 是局部变量 3,i2 是静态变量 2)。
f3(13) 返回 13 + i1++。此时 i1 是全局变量,初值为1,所以返回 14,并随后将 i1 递增为2。
f1 中的计算变为:i1 = 2 + (13 + 4 + 9 + 14) = 42。
- 控制流通过
goto 跳转到 i1 标号处,继续执行 main 函数。
- 此时打印
i + i1 + i2.i + y。这里的 i 是外层的 5,i1 是全局变量 42,i2.i 是结构体成员 8,y 是枚举变量 11。总和为 5 + 42 + 8 + 11 = 66。
运行程序,在命令行中得到如下输出:

7. 常见用法总结
掌握以下习惯用法,足以应对大多数编程场景:
// 以下各行示例语义彼此无关
int i1 = 0; // 外部定义,所有翻译单元共享,外链,静态变量,全程生命期
static int i2 = 0; // 外部定义,单个翻译单元共享,内链,静态变量,全程生命期
extern int i3; // 外部声明(非定义),链接取决于已有声明,变量实体不在本文件
int f1(int p); // 函数原型声明,外链
int f2(int p) // 函数定义,外链
{
static int i4 = 0; // 局部静态变量,本函数独享,无链接,静态存储期,全程生命期
int i5 = 0; // 局部自动变量,初值确定,无链接,自动存储期,块生命期
int i6; // 局部自动变量,初值不确定,无链接,自动存储期,块生命期
// ......
}
static int f3(int p); // 函数原型声明,内链
static int f4(int p) // 函数定义,内链
{
// ......
}
8. 核心要点与最佳实践
- 声明唯一:任何实体应尽量只有一份声明,避免不必要的重复和潜在的混乱。
- 慎用同名:尽量不使用同名标识符,以免造成引用歧义。
- 解决冲突:如果必须使用同名标识符,应确保它们属于不同名字空间,或作用域无交叠,或作用域严格嵌套(外层被内层隐藏),以实现无歧义引用。
- 显式初始化:为外部变量定义显式指定初值,即使是
0 或 NULL,尽量避免依赖临时定义。
- 合理选择链接:
- 变量:需要整个程序共享用
外链;仅在单个源文件内共享用内链;仅在单个函数内使用用无链+static(静态局部变量)。
- 函数:需要整个程序共享用
外链;仅在单个源文件内使用用内链。
- 理解递归:函数递归调用时,每次都会生成新的自动变量(包括形参)实例。
- 关注声明属性:对于每个声明,要清楚其标识符、实体、作用域、链接、名字空间。如果是变量,还要清楚其存储期和生命期。
- 区分初值:静态变量的初值是确定的,且在程序执行前只初始化一次;自动变量的初值是不确定的,除非显式初始化。
- 区分声明与定义:对于变量和函数,要清晰区分声明和定义。
- 理解声明本质:声明的语义是为标识符指定解释(标识何种实体)和属性(存储类、类型、初值等)。
透彻理解作用域、链接、名字空间和存储期,是编写正确、清晰、可维护的C程序,尤其是多文件项目的基石。希望本文的梳理和示例能帮助你更好地掌握这些核心概念。对于更深入的编译和链接过程细节,可以继续在云栈社区探索相关专题。