一个 C 程序是怎么从若干个
.c文件,变成一个(或多个)ELF 可执行文件的?
static 和 extern 控制的,正是这件事的两个层面:
| 层面 | 关键字 | 问题 |
|---|---|---|
| 编译期(单个翻译单元内) | static |
这个名字在当前文件里怎么找?作用域多大? |
| 链接期(多个翻译单元合并时) | extern |
这个名字跨文件怎么对上?谁能看见谁? |
所以本文的主线是:
flowchart LR
A["源码"] --> B["预处理"]
B --> C["编译<br/>生成 .o(带符号表 + 重定位表)"]
C --> D["链接<br/>符号解析 + 地址回填"]
D --> E["ELF 可执行文件"]
S["<b>static</b> 在这里决定<br/>名字是不是 LOCAL"] -.-> C
X["<b>extern</b> 在这里决定<br/>名字能不能跨文件对上"] -.-> D
style S fill:#B8860B,stroke:#8B6508,color:#fff
style X fill:#B8860B,stroke:#8B6508,color:#fff
作用域(scope)与链接(linkage)
这是理解 static/extern 最大的拦路虎。C 标准里它们是两个独立概念:
- 作用域 (scope):名字在源码的哪个范围内可见(编译期解析用)
- 块作用域、函数作用域、文件作用域、原型作用域
- 链接 (linkage):这个名字在链接期指向哪个实体(链接期解析用)
- 外部链接 (external linkage)、内部链接 (internal linkage)、无链接 (no linkage)
一个名字可能"在文件里到处可见"(文件作用域),但"链接期只认自己这一份"
(内部链接)——这正是 static 文件作用域变量的状态。
链接
| 链接类型 | 谁能定义 | 谁能引用 | 对应写法 |
|---|---|---|---|
| 外部链接 (external) | 全程序唯一一份 | 所有翻译单元 | 不加 static 的文件作用域变量/函数 |
| 内部链接 (internal) | 每个翻译单元各一份 | 仅本翻译单元 | 文件作用域加 static |
| 无链接 (none) | 每次进入作用域 | 仅本作用域 | 局部变量、static 局部变量 |
关键:static 本身不改变作用域,它改变的是链接。
static int x;在文件作用域 → 作用域仍是整个文件,但链接从 external 变成 internal- 函数内的
static int y;→ 作用域仍是块作用域(只在函数内可见),但有了 静态存储期(lifetime 是整个程序)
也就是说 static 一词身兼两职(C 标准确实用了 “static storage duration” 和
“internal linkage” 两套术语,历史原因):
- 改变存储期(当用于块作用域时):从 automatic → static,变量不再随栈帧销毁
- 改变链接(当用于文件作用域时):从 external → internal,符号不再导出
四种场景,逐一拆解
|
|
文件作用域、无 static:外部链接
g_shared 是一个全局定义,在整个程序里必须唯一。任何其他 .c 文件
只要写 extern int g_shared; 就能引用它。
链接时,如果两个文件都定义了 int shared;,会直接报错:
|
|
这就是符号冲突——链接器发现两个 GLOBAL 符号同名。
文件作用域、有 static:内部链接
g_hidden 只在当前文件内有效。在别的文件里声明 extern int g_hidden;
并引用它,链接期会找不到符号(因为它根本没被导出)。
反过来,两个不同文件各自 static int shared = 1; 和 static int shared = 2;
完全不冲突——它们是两个不同的符号:
|
|
nm 输出的小写 d 表示 local data,正是"内部链接"的直接证据。
块作用域 static:作用域不变,存储期改变
|
|
连续调用两次,打印出的地址完全相同——变量不在栈上,而在进程的
静态存储区。反汇编会看到它被 nm 命名为 calls.0(编译器加的后缀,
避免与其它符号混淆),并且是小写 b(local BSS):
|
|
extern仅声明
extern 的本质是:“我在这里提到这个名字,但我不定义它”。
extern int x;→ 声明,期望链接期在别处找到x的定义extern int x = 10;→ 这是定义(带初始化器就不再是纯声明了)- 函数声明里
extern可省略(void f(void);等价于extern void f(void);)
一个容易忽略的 C99 陷阱——tentative definition(悬浮定义):
|
|
链接后 tentative 正常存在(B 类符号)。但如果两边都写 int tentative;
(都没初始化),C99 会把它们合并成同一个 COMMON 符号(-fcommon 时代的
行为);GCC 10 起默认改为 -fno-common,于是直接报 multiple definition。
这也是很多老代码升级 GCC 后突然链接失败的原因。
|
|
static 不只是"隐藏":优化器的视角
static 带来的最大实际收益,除了封装,还有优化自由度。
因为 static 函数/变量在本翻译单元内定义一定可见,编译器可以放心:
- 内联:
static函数必然在本 TU 有定义,可以激进内联 - 不产生调用开销:
-O0下static函数也是直接call,不走 PLT - 删除未使用的定义:
static且没人引用 → 直接丢弃,不留符号 - 跨函数记忆:
static局部变量让编译器知道这个值在函数调用间保持
对比一下:
|
|
|
|
而 extern 函数(跨 TU 可见、可能被别处重定义)在共享库场景下必须走PLT(过程链接表),多一次间接跳转。
PLT(Procedure Linkage Table) 是动态链接用的跳板。当程序调用一个定义在共享库(.so / .dll)里的外部函数时,编译器/链接器在编译期不知道它的真实地址(地址要等程序加载时才由动态链接器确定),所以不能直接 call,而是:
|
|
上面的add 是 static(internal linkage),编译器在编译这个 .o 时就完全知道它的地址,是文件内的本地符号。
汇编角度:符号、重定位与链接
static/extern 如何体现在目标文件(.o) 和 最终 ELF 里。
目标文件里有什么
编译器把每个 .c 编译成一个可重定位目标文件(relocatable object),
里面至少包含:
| 节 (Section) | 内容 |
|---|---|
.text |
机器码 |
.data |
已初始化的全局/静态变量 |
.bss |
未初始化的全局/静态变量(NOBITS,不占文件空间) |
.rodata |
只读数据(const、字符串字面量) |
.symtab |
符号表:每个名字的地址、大小、绑定(LOCAL/GLOBAL)、类型 |
.rela.text |
重定位表:机器码里哪些位置需要链接期回填地址 |
看一个例子——同一个函数里让 extern 声明、普通全局、static 三种写法同台:
|
|
|
|
|
|
这里就是 static 和 extern 在汇编层面的第一个可见差异!
注意重定位记录的符号名有三种状态:
- 引用
g(extern声明,定义在别的.c)→ 重定位条目写着符号名g, 但g在本目标文件里没有定义——链接器要拿着这个名字去其它目标文件里找, 找不到就报undefined reference to 'g' - 引用
l(本文件定义)→ 同样写着符号名l,链接器只需在本文件或 全局符号表里解析(跨文件时也可能被别处覆盖) - 引用
s(内部链接)→ 重定位条目只写着.data(节名),因为s是本文件的 LOCAL 符号,编译器当场就知道它在.data节的偏移, 留给链接器的只是"节基址 + 已知偏移"的回填
也就是说:重定位条目里出现符号名(g、l)= GLOBAL = 需要链接期按名字解析;
出现节名(.data)= LOCAL = 本文件内已知偏移。extern 声明与普通全局
在这一层写法相同,真正的差别在符号表的 Ndx 列(见下)。
这行代码的 RIP 相对寻址 mov 0x0(%rip), %eax 中的 0x0 是占位符,
链接期由重定位表回填为真实偏移。三种引用的机器码字节完全一样,
差别只在重定位表记录的解析方式——这正是"链接期符号解析"的物证。
再看符号表,LOCAL/GLOBAL/UND 一目了然:
|
|
Bind 列是链接属性,Ndx 列告诉我们"定义在哪":
LOCAL= 内部链接 = 源码里的staticGLOBAL= 外部链接 = 源码里不加static(写不写extern都一样)GLOBAL+Ndx = UND= 只有声明没有定义——这就是extern int g;在目标文件里的形态:符号已登记为 GLOBAL,但地址空着, 等链接期从别的目标文件里解析。如果最终没人提供定义, 链接器报undefined reference to 'g'。
对照 nm 也是同一信息的速记版:
|
|
nm 速查表:大小写就是链接属性
nm 是最快的符号检查工具,大小写字母直接暴露链接属性:
| 字母 | 含义 | 大写(GLOBAL) | 小写(LOCAL) |
|---|---|---|---|
T/t |
代码段 (.text) | 外部链接函数 | static 函数 |
D/d |
已初始化数据 (.data) | 外部链接变量 | static 变量 |
B/b |
未初始化数据 (.bss) | 外部链接未初始化 | static 未初始化 |
R/r |
只读数据 (.rodata) | const 全局 |
static const |
U |
未定义(UND) | 引用了别处的 extern 符号 |
— |
实测对照:
|
|
一眼区分:大写 = 导出给链接器看,小写 = 只在本文件用。
extern 引用 vs static 引用:PLT 的差别
函数调用更能体现差异。看同一段代码里两种调用:
|
|
|
|
extern函数 →R_X86_64_PLT32重定位,链接期可能经过 PLT (共享库场景必须间接跳转,因为地址要到运行时才能确定)static函数 → 直接call,编译器当场知道目标在本文件.text的哪个偏移,连重定位都不需要(或只需相对节内偏移)
链接成最终可执行文件后,对比更明显(把 loop.c 与一个调用 printf 的
main 放在一起链接成 mm):
|
|
这就是 static 带来的性能优势的汇编实证:少一次间接跳转,
而且给链接器/优化器更多自由度。
链接后:符号属性的最终形态
把 main2.c(有 static int s)和 ext2.c(有 int ext_val)链接后:
|
|
s 和 ext_val 都在 .data 节(第 25 节)、都在同一地址范围,
存储位置毫无区别——唯一的差别就是 Bind 列的 LOCAL vs GLOBAL。
反汇编看访问方式:
|
|
两条 mov 指令结构完全一样(都是 RIP 相对寻址),
链接后地址都已回填。static/extern 不影响指令怎么访问变量,
只影响这个符号名在链接期"叫不叫得应"。
ELF 段布局全景:变量最终住在哪
|
|
初值决定段,static/extern 决定链接属性,两者正交:
| 写法 | 段 | 链接属性 |
|---|---|---|
int x = 1; |
.data |
GLOBAL |
int x; |
.bss |
GLOBAL |
static int x = 1; |
.data |
LOCAL |
static int x; |
.bss |
LOCAL |
const int x = 1; |
.rodata |
GLOBAL |
static const int x = 1; |
.rodata |
LOCAL |
注意 .bss 是 NOBITS 类型——它不占文件空间,只在内存里分配
(内核加载 ELF 时统一清零)。一个 static int arr[1000]; 会让 .bss
“膨胀” 4000 字节,但可执行文件本身一个字节都不增加:
|
|
字符串字面量与 .rodata 的合并
顺便看一个编译器优化细节——相同字符串字面量会被合并到同一地址:
|
|
|
|
GCC 默认 -fmerge-constants,把相同的只读串合并。这与 static 无关,
但展示了 .rodata(只读段)的典型行为——把 const 放这里就能享受
这类合并,同时受到写保护。
进阶:extern 的几个隐藏用法
static 与共享库:谁能进 .dynsym
.dynsym(动态符号表)决定外部能不能通过 dlsym/动态链接拿到这个名字;
.symtab 只是本文件的完整清单。static 符号永远不进 .dynsym,
因此无法被 dlsym 找到,也不会被 LD_PRELOAD 之类的机制拦截:
|
|
|
|
函数和 static 变量在这里表现一致:LOCAL 符号不进 .dynsym。
反过来说,不加 static 的名字默认 GLOBAL DEFAULT,会进 .dynsym:
|
|
一个容易踩的细节:默认导出与否看的是"这个名字最终是不是 LOCAL"。
普通的全局变量/函数默认 GLOBAL(进 .dynsym);若加了 static、或用
-fvisibility=hidden 编译,就变成 LOCAL/隐藏,不进 .dynsym:
|
|
extern "C" 在 C++ 里的作用
C++ 有名字修饰(name mangling),函数 use() 编译后变成 _Z3usev。
extern "C" 强制按 C 的规则命名,从而:
- 让 C 和 C++ 文件能互相链接
- 让
static/extern的链接语义在 C++ 中保持一致
|
|
C++ 的 namespace { ... }(匿名命名空间)在语义上等价于 static——
都是内部链接,编译器用 _GLOBAL__N_ 前缀标记。
头文件里写 static 变量的陷阱
|
|
两个 .c 都 include 后链接,直接报 multiple definition。
正确做法:头文件里只放声明(extern int in_header;),
定义放在某一个 .c 里;或者如果确实每个文件要各一份,用
static int in_header;(但要知道它们是不同变量):
|
|
这是 static 变量在头文件里最隐蔽的坑:看似共享,实则每个文件各一份,
改了一个不会影响另一个。
static inline:头文件函数的标准姿势
|
|
每个 include 它的 .c 都会生成自己的一份(LOCAL 符号):
|
|
好处:无链接冲突、可被内联、编译器可自由丢弃未使用版本。 代价:可能产生多份代码(编译器内联后通常就消失了)。
一张图总结
flowchart TD
A["源码中的名字"] --> B{"有 static 吗?"}
B -- "文件作用域 + static" --> C["内部链接<br>Bind = LOCAL<br>nm 小写符号<br>重定位只认节名"]
B -- "无 static" --> D["外部链接<br>Bind = GLOBAL<br>nm 大写符号<br>重定位认符号名"]
B -- "块作用域 + static" --> E["无链接 + 静态存储期<br>作用域限于块内<br>但值跨调用保留"]
C --> F["链接期: 本文件内解析<br>不进 .dynsym, 不走 PLT<br>可被激进优化/内联"]
D --> G["链接期: 全局符号表解析<br>跨文件共享, 冲突则报错<br>共享库中走 PLT"]
E --> H["编译期: 生成 .data/.bss 符号<br>地址固定, 跨调用不变"]
F --> I["ELF: LOCAL 符号"]
G --> J["ELF: GLOBAL 符号"]
H --> I
核心要点回顾:
static改变的是链接(文件作用域)或存储期(块作用域),不改变作用域本身extern是"跨翻译单元引用"的契约,它让名字在链接期可解析- 编译器视角:
static= LOCAL = 本文件可见 = 可内联、无 PLT、可丢弃 - 链接器视角:
Bind列决定符号怎么合并,LOCAL天然不冲突 - 汇编实证:
- 重定位表里 LOCAL 符号只写节名(
.data),GLOBAL 符号写名字(g) nm大小写直接暴露链接属性static函数直接call,extern函数走R_X86_64_PLT32/PLT- 段归属由初值决定,链接属性由 static/extern 决定,二者正交
- 重定位表里 LOCAL 符号只写节名(
附:本文所有实验的复现命令
|
|