Skip to content

目标文件与符号:用 nm/readelf 透视 .o 里的 T/t/U/D/B ​

引言:.quad .LC0 那个坑,到底谁填的 ​

第 5 章咱们看汇编时留了个尾巴:msg 指针那条 .quad .LC0——.LC0 是那个字符串的临时标号,编译成 .o 之后,这个标号要变成一个真实的内存地址。可是单看一个 foo.o,它根本不知道自己最后会被加载到内存的哪个位置,那这个地址是谁、什么时候填上的?

答案藏在“目标文件(object file,也就是 .o)”这个中间产物里。这一章咱们拿 nm、readelf 把 .o 撬开,看清它内部的符号表(它定义了什么、它需要谁)和重定位表(哪些位置的地址还没定、要链接器来填)。搞懂这个,您就能回答三个工程里高频出现的问题:“这个符号到底定义了没”、“为什么报 undefined reference”、“static 函数为什么外面调不到”。顺带把第 5 章那条 .quad .LC0 的谜底揭开。

先把那条“标准 vs 实现”的分界线再强调一遍(和第 5 章一脉相承):符号表、.o 格式、T/U/D/B 这些字母,全是 ELF 工具链的实现层,ISO C 一个字没提。C 标准里对应的概念叫链接性(linkage)——外部链接(external,全局可见)、内部链接(internal,static 限文件内)、无链接(局部变量,§6.2.2)。nm 的大小写字母(T vs t)就是这套语言概念在 ELF 层的落地映射。咱们既讲语言概念,也讲工具现实。

目标文件是什么:一份“有什么 / 缺什么”的清单 ​

gcc -c 产出的 .o 叫可重定位目标文件(relocatable object file)。“可重定位”是关键词:它的代码和数据里,所有地址都还是“相对于自己”的临时值,没定最终位置,因为它还不知道自己会和谁链接、被加载到哪。每个 .o 随身带两张表,咱们一张张看。一张是符号表(symbol table),一份清单,写着这个 .o 定义了哪些符号(函数、全局变量)、引用了哪些但自己没定义的符号(要别人提供);另一张是重定位表(relocation table),代码里凡是引用了“地址还没定”的地方,它都登记一条“这里需要链接器填一个地址”。

链接器(ld,通常由 gcc 在背后调)干的事,就是把一堆 .o 的符号表对一遍:咱们这个 .o 缺的 visible_fn,那个 .o 正好定义了,好,把真实地址填进所有引用它的位置。填不齐就报 undefined reference,撞车(同名强符号定义两次)就报 multiple definition。

咱们造一对最简单的多文件工程来观察:

C
/* foo.c —— 提供定义 */
int counter = 7;        /* 已初始化全局 */
int tally;              /* 零初始化全局 */
static int helper(int); /* 前向声明:static 函数 */
int visible_fn(int x) { /* 全局函数 */
    return helper(x) + counter;
}
static int helper(int x) { /* static 函数:只在 foo.c 内可见 */
    return x * 2;
}
C
/* main.c —— 使用定义 */
#include <stdio.h>
int visible_fn(int); /* 声明:本文件没定义,要用别人的 */
int main(void) {
    printf("r=%d\n", visible_fn(5));
    return 0;
}

咱们分别编成 .o,先看 foo.o 的符号:

终端
$ gcc -std=c11 -Wall -c foo.c -o foo.o
$ gcc -std=c11 -Wall -c main.c -o main.o
$ nm foo.o
0000000000000000 D counter
000000000000001f t helper
0000000000000000 B tally
0000000000000000 T visible_fn

咱们看 nm 输出:最后一列是符号名,倒数第二列那个大写字母是符号类型。这一组输出把最常见的几种全凑齐了:

字母含义这里对应
T定义在 .text 段的全局函数visible_fn
t定义在 .text 段的局部函数(static)helper
D定义在 .data 段的全局已初始化变量counter
B定义在 .bss 段的全局零初始化变量tally
U未定义(自己没定义,要链接器找别人要)见下面的 main.o

记忆窍门:大写 = 全局(external linkage,外部链接);小写 = 局部(internal linkage,文件内 static)。对应 ISO C §6.2.2 的链接性:全局符号是外部链接、static 是内部链接。在咱们看来,同一个字母大小写之分,就是“链接器能不能跨文件看见它”之分。

这里最值得咱们停一下的是 helper,它是小写 t,因为加了 static。这意味着一个分水岭:static 函数/全局是 .o 里的局部符号(小写字母),链接器跨文件时看不到它。咱们要在 main.c 里声明 extern int helper(int); 然后调它,链接器翻遍所有 .o 都找不到一个全局的 helper,报 undefined reference to 'helper',尽管 foo.o 里明明有个叫 helper 的函数,但它是 static、对链接器隐形。static 的本意就是“这个文件私有”,跨文件要用就得去掉 static。

UND:自己没有、等链接器填的符号 ​

现在咱们看 main.o。它只声明了 visible_fn、调了 printf,但都没自己定义:

终端
$ nm main.o
0000000000000000 T main
                 U printf
                 U visible_fn

T main 是它定义的;U printf 和 U visible_fn 是 U(undefined),意思是“这个 .o 自己没有,请链接器帮它找”。咱们注意 U 行没有地址(左边空着),因为还没定。

咱们再用 readelf -s 看同一份符号表的更详细视图,能看到一列 UND:

终端
$ readelf -s main.o | grep -iE 'FUNC|OBJECT|UND|main|visible|printf'
   4: 0000000000000000    45 FUNC    GLOBAL DEFAULT    1 main
   5: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND visible_fn
   6: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT  UND printf

咱们读一下:UND(undefined)就是 nm 里的 U。main 那行 FUNC GLOBAL 说它是个 45 字节的全局函数,落在第 1 节(.text)。visible_fn/printf 都是 UND,等着被填。

那“填”具体填在哪?咱们看重定位表 readelf -r:

终端
$ readelf -r main.o
Relocation section '.rela.text' at 0x1c0 contains 3 entries:
  Offset          Type           Sym. Name + Addend
00000000000a     R_X86_64_PLT32  visible_fn - 4
000000000013     R_X86_64_PC32   .rodata - 4
000000000022     R_X86_64_PLT32  printf - 4

这张表告诉咱们:main.o 的 .text 段里有三处“地址还没定、要链接器填”:

  • 偏移 0x0a:一条调用 visible_fn 的指令,类型 R_X86_64_PLT32(PC 相对、走 PLT 的函数调用重定位)。
  • 偏移 0x13:引用 .rodata(那个格式串 "r=%d\n"),类型 R_X86_64_PC32(PC 相对的地址引用)。
  • 偏移 0x22:一条调用 printf 的指令,同样 R_X86_64_PLT32。

第 5 章那条 .quad .LC0 之谜,答案就在这里:.LC0 在 .o 里留了一条重定位条目,链接器扫到它、算出字符串的真实地址,回填进那条 .quad。重定位类型(PLT32/PC32/绝对重定位等)决定“怎么算、填几字节”,是 ABI 细节,本章咱们只要记住“有 U 就一定伴随重定位条目,等链接器回填”即可。

链接:看着 U 变成真实地址 ​

咱们把两个 .o 链接成可执行,跑一下:

终端
$ gcc foo.o main.o -o prog && ./prog
r=17

r=17 是对的:visible_fn(5) = helper(5) + counter = 10 + 7 = 17。现在咱们看链接之后符号表发生了什么:

终端
$ nm prog | grep -iE 'visible_fn| main$| counter| tally| helper| printf'
0000000000004018 D counter
0000000000001158 t helper
0000000000001166 T main
                 U printf@GLIBC_2.2.5
0000000000004020 B tally
0000000000001139 T visible_fn

咱们对比链接前后,最关键的变化在这两处。一处是 counter、tally、main、visible_fn、helper 现在都拿到了真实的虚拟地址(0x4018、0x4020、0x1166、0x1139、0x1158):main.o 里那个 U visible_fn 没了,链接器在 foo.o 找到了定义,把地址回填进 main 里所有调用 visible_fn 的地方,这就是"U 被解析"的全过程。另一处是 printf 仍然是 U,准确说是 U printf@GLIBC_2.2.5。为什么?因为 printf 不在任何 .o 里,它在 glibc 动态库中。静态链接阶段不填它,而是留一个带版本号(@GLIBC_2.2.5)的标记,等程序启动时由动态链接器去 libc.so 里找、再填地址。

这个 printf@GLIBC 留着 U 的现象,正是静态链接 vs 动态链接的分水岭,也是第 8 章(动态库与 dlopen)的入口。咱们先记住这个画面,后面会接上。

符号这层还有个 C++ 对照值得咱们记下,别拿 C++ 的经验套纯 C。C++ 有 name mangling(int foo(int) 编译后符号叫 _Z3fooi),所以 C++ 工程里 nm 看到的是一堆乱码符号;纯 C 的符号不 mangle(visible_fn 就是 visible_fn)。混语言时要用 extern "C"(C++ 那边)让符号退回 C 的平坦命名,否则链接器对不上名。另外,符号可见性 -fvisibility=hidden 会把本来全局的符号变成局部(影响动态库导出哪些符号),第 8 章细讲。

小结 ​

到这,.o 这个黑盒就被咱们撬开了:.o 是可重定位的,里面地址都是临时的,靠随身带的符号表加重定位表等链接器回填。读它靠 nm 的那几个字母,T/t 是 text 函数(大写全局、小写 static),D/d 是 data,B/b 是 bss,U 是未定义,规律是大写对应外部链接(全局)、小写对应内部链接(static),这正是 ISO C §6.2.2 链接性在工具层的映射。U 的意思是"自己没有、等链接器找”,在 readelf -s 里显示 UND、必然伴随 readelf -r 里的重定位条目。而 static 符号是小写的局部符号,链接器跨文件看不见,所以跨文件调 static 函数会吃 undefined reference。链接会把 U 填成真实地址,但来自动态库的符号(比如 printf@GLIBC)仍然留着 U、等运行期由动态链接器解析,这正是下一章的引子。最后别忘了,纯 C 不做 name mangling,别拿 C++ 那套 mangled 符号的经验往 C 上套。

下一章咱们正式打开“链接”这一步:亲手制造并诊断 undefined reference 和 multiple definition,搞懂链接器按命令行顺序找符号的规则,再把几个 .o 打包成静态库 .a。

参考资源 ​

  • ELF 格式规范(/usr/include/elf.h、Oracle Linker and Libraries Guide):符号表 .symtab、重定位条目 .rela.text 的字段定义
  • man nm / man readelf / man objdump(符号类型字母、重定位类型的完整清单)
  • ISO/IEC 9899:2011 §6.2.2(标识符的链接性:external / internal / no linkage)
  • x86-64 psABI:R_X86_64_PLT32 / R_X86_64_PC32 等重定位类型的语义

87bb5f2 · 87bb5f2 · 2026-09-21