static 与 extern

一个 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” 两套术语,历史原因):

  1. 改变存储期(当用于块作用域时):从 automatic → static,变量不再随栈帧销毁
  2. 改变链接(当用于文件作用域时):从 external → internal,符号不再导出

四种场景,逐一拆解

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
int   g_shared = 42;          /* ① 文件作用域, 无 static: 外部链接 */
static int g_hidden = 7;      /* ② 文件作用域, 有 static: 内部链接 */

void set_hidden(int v) { g_hidden = v; }
int  get_hidden(void)  { return g_hidden; }

void counter_demo(void) {
    static int calls = 0;     /* ③ 块作用域 static: 无链接 + 静态存储期 */
    ++calls;
}

extern int g_extern_decl;     /* ④ 仅声明, 定义在别的翻译单元 */

文件作用域、无 static:外部链接

g_shared 是一个全局定义,在整个程序里必须唯一。任何其他 .c 文件 只要写 extern int g_shared; 就能引用它。

链接时,如果两个文件都定义了 int shared;,会直接报错:

1
2
3
$ gcc dup1.c dup2.c -o dup
/usr/bin/ld: /tmp/ccZQqxSH.o:(.data+0x0): multiple definition of `shared';
             /tmp/ccmuPxDG.o:(.data+0x0): first defined here

这就是符号冲突——链接器发现两个 GLOBAL 符号同名。

文件作用域、有 static:内部链接

g_hidden 只在当前文件内有效。在别的文件里声明 extern int g_hidden; 并引用它,链接期会找不到符号(因为它根本没被导出)。

反过来,两个不同文件各自 static int shared = 1; 和 static int shared = 2; 完全不冲突——它们是两个不同的符号:

1
2
3
4
$ gcc p1.c p2.c -o ss2        # 链接成功
$ nm ss2 | grep shared
0000000000004010 d shared     # 来自 p1.c
0000000000004014 d shared     # 来自 p2.c  ← 两个同名但地址不同的 LOCAL 符号

nm 输出的小写 d 表示 local data,正是"内部链接"的直接证据。

块作用域 static:作用域不变,存储期改变

1
2
3
4
void counter_demo(void) {
    static int calls = 0;
    printf("%p\n", (void*)&calls);
}

连续调用两次,打印出的地址完全相同——变量不在栈上,而在进程的 静态存储区。反汇编会看到它被 nm 命名为 calls.0(编译器加的后缀, 避免与其它符号混淆),并且是小写 b(local BSS):

1
2
3
4
$ nm st | grep calls
0000000000004014 b calls.0
$ objdump -t st | grep calls
0000000000004014 l     O .bss    0000000000000004  calls.0

extern仅声明

extern 的本质是:“我在这里提到这个名字,但我不定义它”。

  • extern int x; → 声明,期望链接期在别处找到 x 的定义
  • extern int x = 10; → 这是定义(带初始化器就不再是纯声明了)
  • 函数声明里 extern 可省略(void f(void); 等价于 extern void f(void);)

一个容易忽略的 C99 陷阱——tentative definition(悬浮定义):

1
2
3
4
5
/* file A */
extern int tentative;   /* 声明 */

/* file B */
int tentative;          /* 未初始化, 也是定义 → 放进 .bss */

链接后 tentative 正常存在(B 类符号)。但如果两边都写 int tentative; (都没初始化),C99 会把它们合并成同一个 COMMON 符号(-fcommon 时代的 行为);GCC 10 起默认改为 -fno-common,于是直接报 multiple definition。 这也是很多老代码升级 GCC 后突然链接失败的原因。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
        高地址
┌─────────────────────────────────────┐
│                                     │
│          内核空间 (kernel)           │  ← 用户程序访问不到
│                                     │
├─────────────────────────────────────┤ 0x7fff... (栈顶附近)
│                                     │
│            栈 (stack)                │  ← 局部变量、函数参数
│           ↓ 向下增长 ↓               │    自动存储期 (auto)
│                                     │
│         ...  空闲区  ...             │
│                                     │
│           ↑ 向上增长 ↑               │
│            堆 (heap)                 │  ← malloc / new 分配的内存
│                                     │
├─────────────────────────────────────┤
│      .bss  (未初始化数据段)          │  ← 初值为0/无初值的全局、静态变量
│                                     │    启动时清零
├─────────────────────────────────────┤
│      .data (已初始化数据段)          │  ← 有非零初值的全局、静态变量
├─────────────────────────────────────┤
│      .rodata (只读数据段)            │  ← 字符串字面量 "abc"、const 常量
├─────────────────────────────────────┤
│      .text (代码段)                  │  ← 机器指令 (程序代码)
│                                     │    只读、可共享
└─────────────────────────────────────┘
低地址

static 不只是"隐藏":优化器的视角

static 带来的最大实际收益,除了封装,还有优化自由度。

因为 static 函数/变量在本翻译单元内定义一定可见,编译器可以放心:

  1. 内联:static 函数必然在本 TU 有定义,可以激进内联
  2. 不产生调用开销:-O0 下 static 函数也是直接 call,不走 PLT
  3. 删除未使用的定义:static 且没人引用 → 直接丢弃,不留符号
  4. 跨函数记忆:static 局部变量让编译器知道这个值在函数调用间保持

对比一下:

1
2
static int add(int a, int b) { return a + b; }
int use(int x) { return add(x, 1); }
1
2
3
4
5
6
7
$ gcc -O0 -c ol.c -o ol0.o && objdump -d ol0.o | grep call
  31:    call   0 <add>            # -O0: 直接 call, 无 PLT

$ gcc -O2 -c ol.c -o ol2.o && objdump -d ol2.o
<use>:
   4:    lea    0x1(%rdi),%eax     #等于 eax = x + 1==> -O2: 内联, add 根本没生成
   7:    ret

而 extern 函数(跨 TU 可见、可能被别处重定义)在共享库场景下必须走PLT(过程链接表),多一次间接跳转。

PLT(Procedure Linkage Table) 是动态链接用的跳板。当程序调用一个定义在共享库(.so / .dll)里的外部函数时,编译器/链接器在编译期不知道它的真实地址(地址要等程序加载时才由动态链接器确定),所以不能直接 call,而是:

1
call <printf@plt>   → 跳到 PLT 表项 → 再跳到真正的 printf

上面的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 三种写法同台:

1
2
3
4
5
6
/* obj2.c */
extern int g;       /* ① extern 声明: 只提到名字, 定义在别的翻译单元 */
int        l = 1;   /* ② 无 static 无 extern: 外部链接(定义) */
static int s = 2;   /* ③ static: 内部链接 */

void f(void) { g++; l++; s++; }
1
2
$ gcc -O0 -c obj2.c -o obj2.o
$ objdump -dr obj2.o
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
0000000000000000 <f>:
   0:    f3 0f 1e fa              endbr64
   4:    55                       push   %rbp
   5:    48 89 e5                 mov    %rsp,%rbp
   8:    8b 05 00 00 00 00        mov    0x0(%rip),%eax        # e <f+0xe>
            a: R_X86_64_PC32    g-0x4        ← ① 引用 extern 声明的 g (GLOBAL, 但未定义)
   e:    83 c0 01                 add    $0x1,%eax
  11:    89 05 00 00 00 00        mov    %eax,0x0(%rip)        # 17 <f+0x17>
            13: R_X86_64_PC32    g-0x4
  17:    8b 05 00 00 00 00        mov    0x0(%rip),%eax        # 1d <f+0x1d>
            19: R_X86_64_PC32    l-0x4        ← ② 引用本文件定义的 l (GLOBAL)
  1d:    83 c0 01                 add    $0x1,%eax
  20:    89 05 00 00 00 00        mov    %eax,0x0(%rip)        # 26 <f+0x26>
            22: R_X86_64_PC32    l-0x4
  26:    8b 05 00 00 00 00        mov    0x0(%rip),%eax        # 2c <f+0x2c>
            28: R_X86_64_PC32    .data       ← ③ 引用 s (LOCAL, 重定位只给节名)
  2c:    83 c0 01                 add    $0x1,%eax
  2f:    89 05 00 00 00 00        mov    %eax,0x0(%rip)        # 35 <f+0x35>
            31: R_X86_64_PC32    .data
  35:    90                       nop
  36:    5d                       pop    %rbp
  37:    c3                       ret

这里就是 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 一目了然:

1
2
3
4
5
6
$ readelf -sW obj2.o
   Num:    Value          Size Type    Bind   Vis      Ndx Name
     4: 0000000000000004     4 OBJECT  LOCAL  DEFAULT    3 s         ← static: LOCAL
     5: 0000000000000000     4 OBJECT  GLOBAL DEFAULT    3 l         ← 普通全局: GLOBAL (Ndx=3, 在 .data)
     6: 0000000000000000    56 FUNC    GLOBAL DEFAULT    1 f
     7: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND g         ← extern 声明: GLOBAL + UND

Bind 列是链接属性,Ndx 列告诉我们"定义在哪":

  • LOCAL = 内部链接 = 源码里的 static
  • GLOBAL = 外部链接 = 源码里不加 static(写不写 extern 都一样)
  • GLOBAL + Ndx = UND = 只有声明没有定义——这就是 extern int g; 在目标文件里的形态:符号已登记为 GLOBAL,但地址空着, 等链接期从别的目标文件里解析。如果最终没人提供定义, 链接器报 undefined reference to 'g'。

对照 nm 也是同一信息的速记版:

1
2
3
4
5
$ nm obj2.o
0000000000000000 T f     ← 外部链接函数
                 U g     ← extern 声明: 未定义, 等链接期解析
0000000000000000 D l     ← 外部链接数据
0000000000000004 d s     ← 小写: static

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 符号 —

实测对照:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ cat all.c
int   gv = 1;        static int sv = 2;
int   bu;            static int sb;
void  gf(void){}     static void sf(void){}
const int cv = 3;

$ nm all.o
0000000000000000 B bu      ← 外部链接, bss
0000000000000000 R cv      ← 外部链接, rodata
0000000000000000 T gf      ← 外部链接, text
0000000000000000 D gv      ← 外部链接, data
0000000000000004 b sb      ← 小写! static, bss
000000000000000b t sf      ← 小写! static, text
0000000000000004 d sv      ← 小写! static, data

一眼区分:大写 = 导出给链接器看,小写 = 只在本文件用。

extern 引用 vs static 引用:PLT 的差别

函数调用更能体现差异。看同一段代码里两种调用:

1
2
3
void ext(void);        /* 外部链接, 定义在别处 */
static void intl(void){}  /* 内部链接 */
void use(void){ ext(); intl(); }
1
2
3
4
5
$ gcc -O0 -c pltx.c -o pltx.o && objdump -dr pltx.o
<use>:
  14:    call   18 <use+0xd>
            14: R_X86_64_PLT32    ext-0x4     ← extern: PLT32 重定位
  18:    call   0 <intl>                       ← static: 直接 call, 无重定位
  • extern 函数 → R_X86_64_PLT32 重定位,链接期可能经过 PLT (共享库场景必须间接跳转,因为地址要到运行时才能确定)
  • static 函数 → 直接 call,编译器当场知道目标在本文件 .text 的哪个偏移,连重定位都不需要(或只需相对节内偏移)

链接成最终可执行文件后,对比更明显(把 loop.c 与一个调用 printf 的 main 放在一起链接成 mm):

1
2
3
$ objdump -d mm --no-show-raw-insn
  117d:    call   1149 <get>            ← 本程序内定义的函数, 直接 call
  1195:    call   1050 <printf@plt>      ← 外部库函数, 走 PLT

这就是 static 带来的性能优势的汇编实证:少一次间接跳转, 而且给链接器/优化器更多自由度。

链接后:符号属性的最终形态

把 main2.c(有 static int s)和 ext2.c(有 int ext_val)链接后:

1
2
3
4
5
$ gcc -O0 main2.c ext2.c -o exe2
$ readelf -sW exe2 | grep -E "s$|ext_val|main"
    12: 0000000000004010     4 OBJECT  LOCAL  DEFAULT   25 s        ← 保持 LOCAL
    20: 0000000000004014     4 OBJECT  GLOBAL DEFAULT   25 ext_val   ← 保持 GLOBAL
    34: 0000000000001149    49 FUNC    GLOBAL DEFAULT   16 main

s 和 ext_val 都在 .data 节(第 25 节)、都在同一地址范围, 存储位置毫无区别——唯一的差别就是 Bind 列的 LOCAL vs GLOBAL。

反汇编看访问方式:

1
2
  1151:    mov    0x2eb9(%rip),%edx        # 4010 <s>
  1157:    mov    0x2eb7(%rip),%eax        # 4014 <ext_val>

两条 mov 指令结构完全一样(都是 RIP 相对寻址), 链接后地址都已回填。static/extern 不影响指令怎么访问变量, 只影响这个符号名在链接期"叫不叫得应"。

ELF 段布局全景:变量最终住在哪

1
2
3
4
5
6
$ readelf -S exe2
  [16] .text             PROGBITS   0000000000001060   ← 代码
  [18] .rodata           PROGBITS   0000000000002000   ← const / 字符串
  [24] .got              PROGBITS   0000000000003fb8   ← 全局偏移表 (PIC)
  [25] .data             PROGBITS   0000000000004000   ← 有初值的全局/static
  [26] .bss              NOBITS     0000000000004018   ← 零初始化的全局/static

初值决定段,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 字节,但可执行文件本身一个字节都不增加:

1
2
3
$ size bss.o
text   data    bss    dec
 144      4   4004   4152      ← bss 4004 字节, 但文件里 NOBITS

字符串字面量与 .rodata 的合并

顺便看一个编译器优化细节——相同字符串字面量会被合并到同一地址:

1
2
const char *a = "unique-string-xyz";
const char *b = "unique-string-xyz";
1
2
3
4
$ ./strt
0x62715db5200e 0x62715db5200e 1    ← a == b 为真, 指向同一份
$ objdump -s -j .rodata strt
 2010 69717565 2d737472 696e672d 78797a00  unique-string-xyz\0

GCC 默认 -fmerge-constants,把相同的只读串合并。这与 static 无关, 但展示了 .rodata(只读段)的典型行为——把 const 放这里就能享受 这类合并,同时受到写保护。


进阶:extern 的几个隐藏用法

static 与共享库:谁能进 .dynsym

.dynsym(动态符号表)决定外部能不能通过 dlsym/动态链接拿到这个名字; .symtab 只是本文件的完整清单。static 符号永远不进 .dynsym, 因此无法被 dlsym 找到,也不会被 LD_PRELOAD 之类的机制拦截:

1
2
3
4
/* lib.c */
static int  hidden_var = 42;       /* 内部链接变量 */
static void hidden_fn(void){}      /* 内部链接函数 */
int getlib(void){ hidden_fn(); return hidden_var; }
1
2
3
4
5
6
$ gcc -O0 -fPIC -shared lib.c -o lib0.so
$ nm lib0.so | grep hidden
00000000000010f9 t hidden_fn        ← 小写 t, LOCAL, 在 .symtab
0000000000004008 d hidden_var       ← 小写 d, LOCAL, 在 .symtab
$ readelf --dyn-syms lib0.so | grep hidden
(无输出)                           ← 两个都不在 .dynsym, 外部拿不到

函数和 static 变量在这里表现一致:LOCAL 符号不进 .dynsym。

反过来说,不加 static 的名字默认 GLOBAL DEFAULT,会进 .dynsym:

1
2
3
4
$ printf 'int shown_var = 1;\n' > libv.c
$ gcc -O0 -fPIC -shared libv.c -o libv.so
$ readelf --dyn-syms libv.so | grep shown_var
     5: 0000000000004008     4 OBJECT  GLOBAL DEFAULT   19 shown_var   ← 进了

一个容易踩的细节:默认导出与否看的是"这个名字最终是不是 LOCAL"。 普通的全局变量/函数默认 GLOBAL(进 .dynsym);若加了 static、或用 -fvisibility=hidden 编译,就变成 LOCAL/隐藏,不进 .dynsym:

1
2
3
$ gcc -O0 -fPIC -fvisibility=hidden -shared libv.c -o libvh.so
$ readelf --dyn-syms libvh.so | grep shown_var
(无输出)                           ← 被隐藏了, 外部同样拿不到

extern "C" 在 C++ 里的作用

C++ 有名字修饰(name mangling),函数 use() 编译后变成 _Z3usev。 extern "C" 强制按 C 的规则命名,从而:

  • 让 C 和 C++ 文件能互相链接
  • 让 static/extern 的链接语义在 C++ 中保持一致
1
2
3
$ nm anoncpp.o
0000000000000000 T _Z3usev                              ← C++ 修饰名
0000000000000000 b _ZN12_GLOBAL__N_17anon_nsE          ← 匿名命名空间 = static

C++ 的 namespace { ... }(匿名命名空间)在语义上等价于 static—— 都是内部链接,编译器用 _GLOBAL__N_ 前缀标记。

头文件里写 static 变量的陷阱

1
2
/* bad.h */
int in_header = 1;        /* 定义! 每个 include 它的 .c 都定义一份 */

两个 .c 都 include 后链接,直接报 multiple definition。 正确做法:头文件里只放声明(extern int in_header;), 定义放在某一个 .c 里;或者如果确实每个文件要各一份,用 static int in_header;(但要知道它们是不同变量):

1
2
3
$ nm trap2 | grep in_header
0000000000004010 d in_header      ← u1.c 的那份
0000000000004014 d in_header      ← u2.c 的那份 (地址不同, 互不影响!)

这是 static 变量在头文件里最隐蔽的坑:看似共享,实则每个文件各一份, 改了一个不会影响另一个。

static inline:头文件函数的标准姿势

1
2
/* helper.h */
static inline int helper(int x) { return x * 2; }

每个 include 它的 .c 都会生成自己的一份(LOCAL 符号):

1
2
3
4
$ nm a1.o | grep helper
0000000000000000 t helper      ← 小写 t
$ nm a2.o | grep helper
0000000000000000 t helper      ← 另一份, 独立

好处:无链接冲突、可被内联、编译器可自由丢弃未使用版本。 代价:可能产生多份代码(编译器内联后通常就消失了)。


一张图总结

  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

核心要点回顾:

  1. static 改变的是链接(文件作用域)或存储期(块作用域),不改变作用域本身
  2. extern 是"跨翻译单元引用"的契约,它让名字在链接期可解析
  3. 编译器视角:static = LOCAL = 本文件可见 = 可内联、无 PLT、可丢弃
  4. 链接器视角:Bind 列决定符号怎么合并,LOCAL 天然不冲突
  5. 汇编实证:
    • 重定位表里 LOCAL 符号只写节名(.data),GLOBAL 符号写名字(g)
    • nm 大小写直接暴露链接属性
    • static 函数直接 call,extern 函数走 R_X86_64_PLT32/PLT
    • 段归属由初值决定,链接属性由 static/extern 决定,二者正交

附:本文所有实验的复现命令

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# 1. 符号表对比 (extern 声明 vs 普通全局 vs static)
cat > obj2.c <<'EOF'
extern int g;       /* ① 声明, 定义在别处 */
int        l = 1;   /* ② 外部链接(定义) */
static int s = 2;   /* ③ 内部链接 */
void f(void) { g++; l++; s++; }
EOF
gcc -O0 -c obj2.c -o obj2.o
objdump -dr obj2.o        # 重定位表: g / l 写符号名, s 只写节名 .data
readelf -sW obj2.o        # Bind: s=LOCAL, l=GLOBAL, g=GLOBAL+UND(无定义)

# 1b. 把 g 链接上 (定义方也不用写 extern)
printf 'int g = 100;\nint main(void){ return 0; }\n' > defg.c
gcc -O0 obj2.c defg.c -o prog && echo LINK-OK
# 去掉 defg.c 单独链接 → undefined reference to `g`

# 2. nm 大小写速查
nm obj2.o                 # D=大写(GLOBAL), d=小写(LOCAL), U=未定义(extern)

# 3. 跨文件冲突测试
# 两个文件都 int shared; → 链接报 multiple definition
# 两个文件都 static int shared; → 链接成功, 两个 LOCAL 符号

# 4. static 函数无 PLT
gcc -O0 -c pltx.c -o pltx.o && objdump -dr pltx.o
# static 函数: call 直接跳; extern 函数: R_X86_64_PLT32 重定位

# 5. 段归属 (初值决定, 与 static/extern 无关)
readelf -S exe2            # .data / .bss / .rodata 地址范围
size bss.o                 # .bss 不占文件空间 (NOBITS)

# 6. 共享库验证 static 不导出
gcc -O0 -fPIC -shared lib.c -o lib.so
nm lib.so                  # static: 小写 t/d, 在 .symtab
readelf --dyn-syms lib.so  # 不在 .dynsym, 外部拿不到
# 对照: 不加 static 的全局变量会进 .dynsym; -fvisibility=hidden 则不进
Licensed under CC BY-NC-SA 4.0
使用 Hugo 构建
主题 Stack 由 Jimmy 设计