Appearance
符号表(Symbol Table)—— 格式、结构与调试用法
符号表(Symbol Table)记录每个函数和全局变量的名字→地址映射。
gdb的bt显示函数名、perf火焰图显示调用链、nm列出符号、链接器ld解析undefined symbol——它们底层读的都是同一张符号表。本文把符号表的内部结构、两张表的区别、与调试信息(DWARF, Debugging With Arbitrary Record Formats)的关系一起讲透。
更新时间:2026-08-06
一、术语前置
| 缩写 | 英文原文 | 含义 |
|---|---|---|
| ELF | Executable and Linkable Format | 可执行可链接格式,Linux 下二进制文件的统一格式 |
| DWARF | Debugging With Arbitrary Record Formats | 标准调试信息格式,-g 编译产物,记录变量/行号/类型等 |
.symtab | Symbol Table | 完整符号表,含所有函数/变量,strip 后消失 |
.strtab | String Table | 符号名字符串表,.symtab 中每个符号的 st_name 是 .strtab 的索引 |
.dynsym | Dynamic Symbol Table | 动态符号表,只含动态链接所需的符号,strip 删不掉 |
.dynstr | Dynamic String Table | 动态符号名字符串表 |
| STB | Symbol Binding | 符号绑定属性(LOCAL / GLOBAL / WEAK) |
| STT | Symbol Type | 符号类型(FUNC / OBJECT / SECTION / FILE / NOTYPE) |
| STV | Symbol Visibility | 符号可见性(DEFAULT / HIDDEN / PROTECTED / INTERNAL) |
二、符号表是什么——在 ELF 中的位置
回顾 elf-format.md 的 Section/Segment 双视角:
.symtab(Symbol Table,完整符号表) 和.strtab(String Table,符号名字符串表) 是 ELF 的两个 Section,它们的Address=0x0、不带A(alloc,分配)标志,不属于任何 LOAD 段,运行时内核不加载进内存——只在文件里躺着,供ld(链接器)、gdb(GNU 调试器)、nm、perf等工具读取。.dynsym(Dynamic Symbol Table,动态符号表) 和.dynstr(Dynamic String Table,动态符号名字符串表) 是另一对符号表,专为动态链接服务——它们带A标志,会被加载进内存,所以strip删不掉。

正文论述:同一份 ELF 文件里并存两套符号表,服务于不同阶段:
.symtab + .strtab | .dynsym + .dynstr | |
|---|---|---|
| 中文名 | 完整符号表 | 动态符号表 |
| 用途 | 链接期符号解析 + 调试期符号查找 | 运行时动态链接 |
| 谁在用 | ld(链接器)、gdb、perf、nm、readelf -s | ld.so(动态链接器)、nm -D |
| 是否加载进内存 | 否(Address=0x0) | 是(带 A 标志) |
| 内容 | 所有符号(含 static 函数、FILE 符号) | 仅导出/导入的动态符号 |
strip 后 | 被删除 | 保留 |
一句话总结:.symtab 是完整的"户口本"(所有符号都在),.dynsym 是精简的"对外通讯录"(只管动态链接需要的进出符号)。
三、符号表条目的内部结构(Elf64_Sym)
符号表中的每一条记录是一个定长结构体。x86-64 上 sizeof(Elf64_Sym) = 24 字节,readelf -S 的 EntSize 就是 24。用 readelf -s 看到的一行行输出,就是从这些结构体解码出来的。
3.1 Elf64_Sym 结构体定义
cpp
// 来自 <elf.h>,glibc 提供的标准定义
typedef struct {
uint32_t st_name; // 符号名在 .strtab 中的字节偏移
unsigned char st_info; // 高 4 位=绑定(bind),低 4 位=类型(type)
unsigned char st_other; // 可见性(visibility)
uint16_t st_shndx; // 所在 Section 的索引(指向 Section Header Table)
Elf64_Addr st_value; // 地址或偏移
Elf64_Xword st_size; // 符号大小(函数=代码长度, 变量=sizeof)
} Elf64_Sym; // sizeof = 24 字节
// 字节布局(Little-Endian):
// +0 +1 +2 +3 +4 +5 +6 +7 +8..+15 +16..+23
// [ st_name ] [st_info] [st_other] [ st_shndx ] [ st_value ] [ st_size ]
// 4 bytes 1 byte 1 byte 2 bytes 8 bytes 8 bytes| 字段 | 大小 | 含义 | 示例 |
|---|---|---|---|
st_name | 4 字节 | 符号名在 .strtab 中的字节偏移(不是字符串本身) | 0x1a3 → 去 .strtab 偏移 0x1a3 处读到 "do_work" |
st_info | 1 字节 | 高 4 位 = 绑定(bind),低 4 位 = 类型(type) | 0x12 = STB_GLOBAL | STT_FUNC |
st_other | 1 字节 | 可见性(visibility),控制动态链接时的符号导出 | 0x00 = STV_DEFAULT |
st_shndx | 2 字节 | 符号所在的 Section 索引(指向 Section Header Table 的第几项) | 0x0e = 属于 .text 段 |
st_value | 8 字节 | 地址或偏移——可执行文件中是虚拟地址;.o 文件中是段内偏移;未定义符号时为 0 | 0x400e10 |
st_size | 8 字节 | 符号的字节大小——函数是代码长度,变量是 sizeof | 0x000000a4 = 164 字节 |
0 号条目:
.symtab的第一条(index 0)始终是全零的保留条目,st_name=0指向.strtab的首字节\0,st_shndx=SHN_UNDEF。nm和readelf -s都不显示它。

3.2 st_info 解码:绑定 + 类型
st_info 用一个字节编码两层信息,这是理解符号表最关键的部分。
高 4 位——符号绑定(STB,Symbol Binding),决定符号的链接作用域:
| 宏 | 值 | 含义 | nm 显示 |
|---|---|---|---|
STB_LOCAL | 0 | 局部符号:仅本目标文件可见,链接时不同文件的同名 LOCAL 符号互不冲突 | 小写字母(t、d) |
STB_GLOBAL | 1 | 全局符号:对外导出,整个程序唯一,链接时不同文件出现同名 GLOBAL 定义 → 重定义错误 | 大写字母(T、D) |
STB_WEAK | 2 | 弱符号:类似全局,但允许被同名强符号覆盖——库提供默认实现,用户可写同名函数覆写 | W/w/V |
C 语言的默认规则:函数和全局变量默认是
STB_GLOBAL;static修饰后变成STB_LOCAL。
低 4 位——符号类型(STT,Symbol Type),说明符号是什么:
| 宏 | 值 | 含义 | 示例 |
|---|---|---|---|
STT_NOTYPE | 0 | 未指定类型 | 动态符号表中常有 |
STT_OBJECT | 1 | 数据对象(变量) | int g_tick; |
STT_FUNC | 2 | 函数 | void do_work() |
STT_SECTION | 3 | 关联到某个 Section | .text 节符号 |
STT_FILE | 4 | 源文件名 | main.c,nm 输出中的文件标记 |
STT_COMMON | 5 | 未初始化的公共块(历史遗留) |
解码公式:st_info = (bind << 4) | type。例如 0x12 → (1 << 4) | 2 = GLOBAL + FUNC。
cpp
// <elf.h> 提供的解码宏(glibc 标准定义)
#define ELF64_ST_BIND(info) ((info) >> 4) // 高 4 位 → STB_*
#define ELF64_ST_TYPE(info) ((info) & 0x0f) // 低 4 位 → STT_*
#define ELF64_ST_INFO(bind, type) (((bind) << 4) + ((type) & 0xf)) // 合成
// 使用示例
uint8_t info = sym->st_info;
if (ELF64_ST_BIND(info) == STB_GLOBAL && ELF64_ST_TYPE(info) == STT_FUNC) {
// 这是一个全局函数
}3.3 st_other:可见性
| 宏 | 值 | 含义 | 影响 |
|---|---|---|---|
STV_DEFAULT | 0 | 默认可见性 | 符号正常导出,外部可见 |
STV_HIDDEN | 2 | 隐藏 | 动态链接时符号不出现在 .dynsym 中,仅本 .so 内部可见 |
STV_PROTECTED | 3 | 保护 | 外部可见,但外部不能覆写(preemption 禁止),访问走内部 GOT |
STV_INTERNAL | 1 | 内部(类同 HIDDEN) | 特定工具链使用 |
STV_HIDDEN是-fvisibility=hidden编译选项背后的机制——把未显式标记导出的符号设为 HIDDEN,缩小.dynsym、加快动态链接、减少符号冲突。
3.4 st_shndx:符号所在的 Section 索引
从 readelf -S 输出的 Section Header Table 的第几号(从 0 开始)就是该符号所在的 Section。
特殊值:
| 值 | 宏 | 含义 |
|---|---|---|
0 | SHN_UNDEF | 未定义——该符号引用外部定义(nm 显示 U) |
0xfff1 | SHN_ABS | 绝对地址——不受重定位影响(nm 显示 A) |
0xfff2 | SHN_COMMON | COMMON 块(未初始化全局变量) |
3.5 数据结构全景类图

上图揭示了符号表的核心设计:
.symtab是一个Elf64_Sym的定长数组,每个条目通过st_name(整型偏移)去.strtab取字符串,通过st_shndx(整型索引)去 Section Header Table 找所属节。
3.6 手动遍历符号表(代码示例)
理解 Elf64_Sym 结构体后,遍历符号表的代码非常直观——本质就是读定长数组 + 解码 st_info + 用 st_name 偏移查 .strtab:
cpp
#include <elf.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <cstdio>
#include <cstring>
// ── 遍历 .symtab 并打印所有函数 ────────────────────────
void dump_symtab_functions(const char* path) {
int fd = open(path, O_RDONLY);
struct stat st;
fstat(fd, &st);
// 1. mmap 整个 ELF 文件
uint8_t* base = (uint8_t*)mmap(NULL, st.st_size, PROT_READ,
MAP_PRIVATE, fd, 0);
Elf64_Ehdr* ehdr = (Elf64_Ehdr*)base;
// 2. 定位 Section Header Table
Elf64_Shdr* shdrs = (Elf64_Shdr*)(base + ehdr->e_shoff);
// 3. 遍历所有 Section,找到 .symtab 和 .strtab
Elf64_Sym* symtab = nullptr;
size_t nsyms = 0;
const char* strtab = nullptr;
for (int i = 0; i < ehdr->e_shnum; i++) {
const char* sname = (const char*)(base
+ shdrs[ehdr->e_shstrndx].sh_offset) + shdrs[i].sh_name;
if (shdrs[i].sh_type == SHT_SYMTAB) {
symtab = (Elf64_Sym*)(base + shdrs[i].sh_offset);
nsyms = shdrs[i].sh_size / sizeof(Elf64_Sym);
}
if (strcmp(sname, ".strtab") == 0) {
strtab = (const char*)(base + shdrs[i].sh_offset);
}
}
// 4. 遍历符号表,过滤出函数并打印
if (!symtab || !strtab) { printf("no .symtab\n"); return; }
for (size_t i = 1; i < nsyms; i++) { // 跳过 index 0(全零占位)
uint8_t bind = ELF64_ST_BIND(symtab[i].st_info);
uint8_t type = ELF64_ST_TYPE(symtab[i].st_info);
if (type == STT_FUNC && symtab[i].st_size > 0) {
const char* name = strtab + symtab[i].st_name;
printf("[%3zu] 0x%016lx %4lu bytes %s %s\n",
i,
symtab[i].st_value,
symtab[i].st_size,
bind == STB_LOCAL ? "LOCAL" : "GLOBAL",
name);
}
}
munmap(base, st.st_size);
close(fd);
}
// 输出示例:
// [ 75] 0x0000000000001189 205 bytes LOCAL main
// [ 76] 0x0000000000001256 237 bytes GLOBAL busy_kernel_cpu这就是
nm和readelf -s的底层逻辑:mmap ELF → 遍历 Section Header 找 .symtab/.strtab → 遍历 Elf64_Sym 数组 → 解码 st_info → 用 st_name 偏移读字符串。50 行代码即可实现一个最小符号表查看器。
四、从真实输出看符号表
4.1 readelf -s 输出
bash
$ readelf -s ./cpu_demo | head -20
Symbol table '.symtab' contains 442 entries:
Num: Value Size Type Bind Vis Ndx Name
0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND
1: 0000000000000238 0 SECTION LOCAL DEFAULT 1 .interp
...
75: 0000000000001189 205 FUNC LOCAL DEFAULT 14 main
76: 0000000000001256 237 FUNC GLOBAL DEFAULT 14 busy_kernel_cpu
77: 0000000000000000 0 FILE LOCAL DEFAULT ABS main.cpp
102: 0000000000004050 4 OBJECT GLOBAL DEFAULT 25 g_tick逐列对照 Elf64_Sym 字段:
| 输出列 | 对应字段 | main 的值 | 含义 |
|---|---|---|---|
Num | (nm 索引号) | 75 | .symtab 中第 75 个条目(0 号是全零占位符) |
Value | st_value | 0x1189 | 函数入口地址 |
Size | st_size | 205 | main() 有 205 字节指令 |
Type | st_info 低 4 位 | FUNC(2) | 这是一个函数 |
Bind | st_info 高 4 位 | LOCAL(0) | 局部符号(static 修饰或内部函数) |
Vis | st_other | DEFAULT(0) | 默认可见性 |
Ndx | st_shndx | 14 | 在 Section Header Table 第 14 号,即 .text 段 |
Name | st_name → .strtab | main | 符号名 |
4.2 各 Bind/Type 组合的典型例子(对照 nm 输出)
bash
$ nm -C ./cpu_demo | head -20
0000000000004050 B g_tick ← B = GLOBAL + OBJECT,在 .bss(未初始化全局量)
0000000000001189 t main ← t = LOCAL + FUNC(static 或内部函数)
0000000000001256 T busy_kernel_cpu ← T = GLOBAL + FUNC(导出函数)
U strcpy@GLIBC ← U = UNDEF(需要外部提供)
0000000000004054 D initialized_var ← D = GLOBAL + OBJECT,在 .data(已初始化全局量)
0000000000002004 R _IO_stdin_used ← R = GLOBAL + OBJECT,在 .rodata(只读数据)五、调试工具链如何使用符号表
这一节回答核心问题:各种调试工具"读符号表的哪个部分""读来做什么""没符号会怎样"。
5.1 工具 → 符号表的使用全景

正文论述:上图把每个工具"读哪个节、拿到什么信息"画清:
| 工具 | 读的节 | 拿到的信息 | 没符号时的表现 |
|---|---|---|---|
gdb bt | .symtab → 函数名;.debug_* → 源码行/变量 | 完整的函数名 + 源码行号调用栈 | 只剩地址 0x400e10(问号栈) |
perf report | .symtab → 函数名 | 火焰图的函数名、调用链 | 显示为十六进制地址 [unknown] |
nm | .symtab(默认)/ .dynsym(-D) | 符号名称、地址、类型 | 输出 "no symbols" |
addr2line -e app 0x1234 | .debug_* → 行号;.symtab → 函数名 | func_name / file.c:42 | 输出 ??:0 |
ld.so | .dynsym + .dynstr | 运行时查找共享库函数 | 程序无法启动(undefined symbol) |
5.2 gdb:从符号到回溯
gdb 加载一个二进制后,先读 .symtab 建立"地址→函数名"映射,再读 .debug_*(DWARF)建立"地址→源码行→变量名→变量位置"映射。bt(backtrace)命令的完整链路:
- 从栈帧中读出返回地址(RIP)
- 用
.symtab把地址映射为函数名(main、do_work) - 如有 DWARF,再用
.debug_line把地址映射为源码文件名和行号(main.cpp:42) - 如有 DWARF,再用
.debug_info读出局部变量名和值(n=123)
strip 后:.symtab 和 .debug_* 都没了 → bt 只剩十六进制地址,栈回溯"失明"。
5.3 perf:从采样地址到火焰图
perf record 按频率(如 99Hz)采样 CPU 当前的指令指针(Instruction Pointer,IP)。采样完后 perf report / perf script 要把这些地址翻译成函数名:
- 读 ELF 的
.symtab,建一张"地址→符号名"哈希表 - 每个采样地址反查这张表,填上函数名
- 如果有 DWARF(
--call-graph dwarf),还能拿到调用链形成火焰图
strip 后:火焰图上全是 [unknown] 和十六进制地址——采样数据本身是对的,但人读不懂。这也是为什么 profiling 场景下二进制绝不能 strip。
5.4 addr2line:地址反查源码行
addr2line 的工作流完全依赖 DWARF 的 .debug_line 节(地址→行号映射表)和 .debug_info(编译单元信息),.symtab 只用于 -f 参数(输出函数名)。没有 DWARF 就无法工作,即使符号表完好。
六、符号表与 DWARF 的关系
符号表和 DWARF 是两套不同层次的信息,经常被混淆:
| 维度 | .symtab 符号表 | .debug_* DWARF |
|---|---|---|
| 记录什么 | 函数/变量名 → 地址 | 地址 → 源码行/变量位置/类型 |
| 数据量 | 相对小(几百 KB) | 大(常是代码体积的数倍) |
| 来源 | 汇编器自动生成 | -g 编译选项产生 |
| 主要用户 | 链接器 ld、nm、perf 建函数名映射 | gdb 源码调试、addr2line |
| 能否回答"函数在哪" | 能(函数入口地址) | 能(逐行的精确地址) |
| 能否回答"变量叫什么" | 能(全局变量) | 能(全局+局部变量+参数) |
| 能否回答"变量是什么类型" | 不能 | 能 |
| 能否回答"当前在源码第几行" | 不能 | 能(.debug_line) |
strip 后 | 被删除 | 被删除 |

一句话总结:符号表是"地图索引"(知道每个地方叫什么),DWARF 是"导航细节"(知道每条路在第几页、路上有什么)。火焰图只需索引,源码调试两者都需要。
七、strip 与符号分离——调试视角的全景
7.1 strip 到底删了什么
bash
strip ./app # 删除:.symtab + .strtab + .debug_*
# 保留:.dynsym + .dynstr(动态链接所必需)strip 后:
nm ./app→ "no symbols"nm -D ./app→ 仍有动态符号(printf@GLIBC这类)gdb bt→ 全是?? ()+ 地址perf report→ 全是[unknown]- 程序照常跑——因为内核加载靠的是 Segment(段),不依赖 Section(节)中的符号表
7.2 生产环境两全做法:符号分离
参见 symbol-separation.md 的完整四步流程,本文只概括符号视角:
bash
objcopy --only-keep-debug app app.debug # 抽出 .symtab + .debug_* → app.debug
strip app # 线上二进制删掉符号
objcopy --add-gnu-debuglink=app.debug app # 写入 .gnu_debuglink 节,指向 app.debuggdb 加载被 strip 的二进制时,读到 .gnu_debuglink 就自动找对应的 .debug 文件补全符号,实现"线上小体积 + 事后完整调试"。
八、实操速查
bash
# 看符号表条目数
readelf -S ./app | grep symtab # EntSize=24 说明是 64 位
# 看所有符号(含 LOCAL)
readelf -s ./app | less
# 只看函数,地址排序
nm -C -n ./app | grep ' [tT] '
# 判断是否被 strip
nm ./app # "no symbols" = 被 strip
nm -D ./app | head # 动态符号还在
# 验证 DWARF 是否存在
readelf -S ./app | grep debug
# 地址反查(需 -g 编译)
addr2line -e ./app -fC 0x1256九、与相关文档的关系
| 文档 | 覆盖内容 | 本文的关系 |
|---|---|---|
| elf-format.md | ELF 整体格式、Section/Segment 双视角 | 本文是符号表专题深入 |
| nm.md | nm 命令的用法和输出解读 | 本文讲 nm 输出来自什么数据结构 |
| readelf.md | readelf -s 等命令详解 | 本文是对 -s 输出的逐字段解码 |
| compile-link-load.md | 编译→链接→加载全链路 | 链接阶段符号解析的底层数据结构即本文 |
| symbol-separation.md | 生产环境符号分离实践 | 本文解释分离的"是什么",那篇解释"怎么做" |
| misc.md | strip/addr2line 等命令 | 本文讲这些命令背后的符号层面原理 |
一句话总结:符号表本质是
Elf64_Sym的定长数组 +.strtab的字符串池,每个条目用st_info编码绑定和类型。它同时服务三个场景——链接期(ld 解析引用)、加载期(ld.so 用 .dynsym)、调试期(gdb/perf/addr2line 查名字)。DWARF 在其之上叠加源码行/变量/类型,形成完整的调试信息栈。