☰
ELF符号表解析:从运行地址反推函数名的完整指南
2026/10/7 2:33:41 网站建设 项目流程

跑崩的时候,人手一份ELF文件,日志里给你一个地址,比如0x5555554012ab。一开始我也对着十六进制发呆,不知道这串数字到底落在哪个函数里。后来发现ELF里其实早就把答案写好了,只是你还没搞懂“运行地址”和“符号表”之间的换算关系。今天这篇就是围绕这个核心问题:给你一个ELF文件,再给你一个运行时地址,怎么反推出函数名,甚至定位到文件里的行号。

我默认读者是搞Linux服务端、嵌入式、Android native、或者时不时要跟崩溃日志打交道的开发者。你不需要对ELF格式了如指掌,但至少听说过addr2line和nm。这篇会把原理、常用工具、以及一个可以抄作业的Python解析脚本都讲透,往后遇到“有地址没函数名”的情况,不用再抓瞎。

1. 为什么需要由运行地址定位函数名

1.1 三个我真实踩过的场景

第一个场景是线上crash日志。后台打印出一行调用栈,寄存器PC、LR都是裸地址,比如pc=0x0000007f8f0a1234、lr=0x0000007f8f0a5678。如果没有符号化,这行日志等于废话,只能知道崩在哪个模块,不知道崩在哪个函数。第二个场景是性能剖析。perf script输出的一大堆地址,你总得把它们翻译成函数名才能分析热点。第三个场景更常见——手头只有一个生产环境拉回来的ELF文件,想离线分析某个溢出或非法访问,对方只给了入口地址。这三种情况本质都一样:要把“运行地址”映射回“ELF符号表里记录的虚拟地址”。

有人可能会说,直接看映射表不就行了。但现实里ELF文件不是随时都带着符号,共享库被加载时的基址也不是固定值,加上PIE、ASLR、strip这些因素,“所见地址”和“符号地址”往往不是同一个数。不搞清楚这一点,用工具也是瞎猜。

1.2 符号表本质上是一张“地址到名字”的映射

编译器在生成ELF文件时,会给每个函数发一个“虚拟地址”,这个地址记录在符号表里。比如一个函数foo(),符号表会记录它的名字、起始地址st_value、占用大小st_size。当运行地址落在[st_value, st_value+st_size)区间内,就可以说这个地址属于foo()。

这个思路跟查字典一样:先拿到ELF里所有函数符号,按地址排序,然后拿目标地址去做区间匹配。难点并不在匹配算法,而在“目标地址”和“符号地址”的基准可能不一样。我在后面会反复强调这一点。

2. ELF文件里地址与符号的关系

2.1 虚拟地址、加载地址、文件偏移别搞混

很多新手第一次就把vaddr(虚拟地址)和offset(文件偏移)当成一回事。ELF里有好几种地址概念,最关键的是:

  • readelf -S里每个节区有一个Address,这是节区在内存中的虚拟地址;
  • readelf -l里每个segment有p_vaddr和p_offset,分别是内存虚拟地址和文件中的偏移;
  • 程序运行时,ELF被加载到内存,实际访问地址是“加载基址 + 相对偏移”。

举个生活化例子:p_vaddr就像地图上标注的坐标,而运行地址是你站在GPS定位出来的实际位置。如果地图没被平移,两者一样;但PIE或共享库被ASLR随机化之后,整个地图被平移了一段距离,你必须知道平移量才能对上号。

2.2 符号表有两张,作用完全不同

ELF里常见两张符号表:.symtab和.dynsym。.symtab是静态符号表,保存所有本地和全局符号,包括static函数、局部变量,信息很全,但通常会被strip删掉。.dynsym是动态符号表,保留导出和导入的符号,供动态链接器使用,strip之后一般还在。

函数名定位首选.symtab,没有.symtab就只能用.dynsym。但.dynsym通常只包含动态导出函数,static 函数、局部符号在里面找不到。很多生产环境里的.so文件都strip过,所以只能解析出一部分函数名,这一点要有预期。

2.3 重定位和运行地址的关系

如果你搜索过ELF重定位相关的资料,会看到一句像relocations in generic elf这样的表述。它的意思是,在ELF通用重定位模型里,符号的最终运行地址不是编译时写死的,而是由链接器和动态链接器依据重定位表修正出来的。对于可执行文件,链接器在链接时已经把绝对地址填好;对于PIE和共享库,动态链接器在加载时根据加载基址重定位。

这意味着符号表里的st_value是一个“相对虚拟地址”,不是最终运行地址。我们要做的就是从运行地址里减去加载基址,得到相对地址,再到符号表里去匹配。这句话是整个定位过程的命门。

3. 实操:用现成工具快速反查

3.1 先判断ELF是ET_EXEC还是ET_DYN

拿到ELF后,第一件事不是急着敲命令,而是看文件类型:

readelf -h ./app

如果Type是EXEC,说明是非PIE可执行文件,加载地址一般固定,符号表里的st_value可以直接拿来和运行地址比对。如果Type是DYN,说明是PIE可执行文件或共享库,加载地址随机,运行地址需要减去基址才等于符号表里的相对地址。

怎么拿基址?常见方式有:

  • 看/proc/<pid>/maps,找到对应ELF映射的起始地址;
  • Android上可以用dl_iterate_phdr,拿dlpi_addr;
  • 如果日志已经告诉你“加载基址”,直接用。

拿到基址之后:

符号表相对地址 = 运行地址 - 加载基址

后面所有工具都拿这个相对地址去查。

3.2 主力工具:addr2line

addr2line是定位函数名的第一选择。命令格式简单:

addr2line -e ./libfoo.so -f -C 0x1234

参数说明:

  • -e指定ELF文件;
  • -f输出函数名;
  • -C把C++名字还原成可读形式(demangle);
  • -i输出内联函数调用链,如果有调试信息,会依次列出内联的每一层。

假设我从日志里拿到运行地址0x7f8f0a1234,从maps里查到libfoo.so的加载基址是0x7f8f0a0000,那么相对地址是0x1234。执行:

addr2line -e ./libfoo.so -f -C 0x1234

输出类似:

foo(int) /path/to/foo.cpp:42

这行告诉你地址落在foo(int)函数里,还告诉你对应源码行是foo.cpp:42。实测下来,只要ELF文件还带.symtab和.debug_*调试信息,这个方法几乎百发百中。

3.3 辅助工具:nm、readelf、objdump

addr2line没输出或者怀疑结果不对时,我会叠加nm、readelf、objdump交叉验证。

nm按地址排序查看符号:

nm -n ./libfoo.so

输出:

0000000000001234 T foo(int) 00000000000012a0 t local_helper()

大写T表示全局text符号,小写t表示局部text符号。看到目标地址落在哪两个符号之间,基本能确定函数名。-n按地址排序,方便人眼扫描。

readelf -sW可以看符号表的原始结构,比nm更细:

readelf -sW ./libfoo.so

objdump -d则用来反汇编确认。比如addr2line显示foo,但你不确定是不是内联或者同地址多符号,就反汇编附近的指令,看有没有调用关系。实际排查时,我经常用addr2line拿到函数名,再用objdump看上下文,判断崩溃点到底在函数头还是函数尾。

3.4 命中不到符号时怎么补救

遇到地址查不到符号,别急着认定工具不行。先检查:

  • 相对地址计算错误。最常见,运行地址忘了减加载基址。
  • ELF被strip过,.symtab没了。试试nm -D或readelf -sW看.dynsym里有没有。
  • 地址不在代码段。比如落在PLT、GOT、堆栈特殊区域,这不是普通函数符号。
  • 函数被内联。addr2line -i可以展开内联链,或者看外层调用者。

如果.symtab确实被删了,但是有动态符号表,可以先用addr2line -e ./libfoo.so -f -C查动态符号。虽然没有行号,但至少能告诉你“这个地址靠近哪个导出函数”。更深的方案是保留构建时的-g调试包,或者用.eh_frame做CFI回溯,这个属于进阶话题,后面我再细说。

4. 进阶:写个小工具自己解析ELF符号表

4.1 为什么值得自己解析

工具虽好,但有些环境里没有addr2line,或者你不想挨个敲命令,想批量处理几百个地址。这时候手写一个ELF符号表解析脚本就很有价值。另外,解析一遍ELF之后,你对“地址到底存在哪里”会有更直观的理解。以后遇到冷门平台,缺少标准工具,也能自己撑起来。

4.2 从ELF头到节区表

真正的ELF解析看起来唬人,其实只需要处理几个固定结构。下面我以64位小端ELF为例,写一个能跑的Python脚本。

ELF Header 里需要几个关键字段:

  • e_shoff:节区表偏移,在文件偏移0x28,8字节;
  • e_shentsize:每个节区头大小,在0x3a,2字节;
  • e_shnum:节区数量,在0x3c,2字节;
  • e_shstrndx:节区名字符串表的索引,在0x3e,2字节。

每个Section Header关键字段:

  • sh_name:节名在字符串表中的偏移,0字节,4字节;
  • sh_type:节类型,4字节,SHT_SYMTAB=2,SHT_DYNSYM=11;
  • sh_offset:节数据在文件中的偏移,0x18,8字节;
  • sh_size:节大小,0x20,8字节;
  • sh_entsize:每个条目的字节数,0x38,8字节。

拿到这些,就能定位到.symtab和.dynsym。符号表里每个Elf64_Sym重点看:

  • st_name:符号名在字符串表中的偏移,0字节;
  • st_value:符号值,通常是函数的虚拟地址,8字节,偏移8;
  • st_size:符号大小,8字节,偏移16。
import struct def parse_elf_symbols(path): with open(path, 'rb') as f: data = f.read() if data[:4] != b'\x7fELF': raise ValueError('not an ELF file') # 只处理ELF64 little-endian if data[4] != 2 or data[5] != 1: raise ValueError('need ELF64 little-endian') e_shoff = struct.unpack_from('<Q', data, 0x28)[0] e_shentsize = struct.unpack_from('<H', data, 0x3a)[0] e_shnum = struct.unpack_from('<H', data, 0x3c)[0] e_shstrndx = struct.unpack_from('<H', data, 0x3e)[0] # 先解析节头数组 sections = [] for i in range(e_shnum): off = e_shoff + i * e_shentsize sh_name = struct.unpack_from('<I', data, off)[0] sh_type = struct.unpack_from('<I', data, off + 0x04)[0] sh_offset = struct.unpack_from('<Q', data, off + 0x18)[0] sh_size = struct.unpack_from('<Q', data, off + 0x20)[0] sh_entsize = struct.unpack_from('<Q', data, off + 0x38)[0] sections.append((sh_name, sh_type, sh_offset, sh_size, sh_entsize)) # 解析节名字符串表 shstr_name, _, shstr_off, shstr_size, _ = sections[e_shstrndx] shstrtab = data[shstr_off:shstr_off + shstr_size] def get_section_name(idx): start = sections[idx][0] end = shstrtab.find(b'\x00', start) return shstrtab[start:end].decode('utf-8', errors='replace') symbols = [] for idx, (sh_name, sh_type, sh_offset, sh_size, sh_entsize) in enumerate(sections): if sh_type not in (2, 11): # SHT_SYMTAB / SHT_DYNSYM continue section_name = get_section_name(idx) # 符号名称字符串表来自 sh_link 指向的节 # 这个脚本为简洁略过 sh_link 的解析,实际可用 shstrtab # 更严谨的做法需要解析 sh_link 指节的 strtab strtab_section_idx = struct.unpack_from('<I', data, e_shoff + idx * e_shentsize + 0x28)[0] strtab_off = sections[strtab_section_idx][2] strtab_data = data[strtab_off:strtab_off + sections[strtab_section_idx][3]] count = sh_size // sh_entsize for j in range(count): sym_off = sh_offset + j * sh_entsize st_name = struct.unpack_from('<I', data, sym_off)[0] st_value = struct.unpack_from('<Q', data, sym_off + 0x08)[0] st_size = struct.unpack_from('<Q', data, sym_off + 0x10)[0] name_end = strtab_data.find(b'\x00', st_name) sym_name = strtab_data[st_name:name_end].decode('utf-8', errors='replace') if st_value != 0 and st_size != 0: symbols.append((st_value, st_size, sym_name)) symbols.sort(key=lambda x: x[0]) return symbols if __name__ == '__main__': syms = parse_elf_symbols('./libfoo.so') for addr, size, name in syms: print(hex(addr), hex(size), name)

这段代码能用,但有个细节必须提醒:.symtab和.dynsym的符号名字符串表不是同一个,正确做法是通过该节的sh_link字段找到对应字符串表节,脚本里已经使用了sh_link,可以放心。真实项目里我会建议直接用pyelftools库,更稳定,但自己解析一遍能帮你建立体感。

4.3 解析出来后怎么用区间匹配

拿到符号列表后,目标就清晰了:输入一个相对地址,在排好序的(st_value, st_size, name)列表里找到[st_value, st_value + st_size)包含它的那个符号。

由于符号列表已经按地址排好序,线性扫描也能用,但地址多的时候建议二分。Python的bisect正好合适:

import bisect def find_symbol(symbols, addr): values = [s[0] for s in symbols] idx = bisect.bisect_right(values, addr) - 1 if idx >= 0: start, size, name = symbols[idx] if start <= addr < start + size: return name return None

这里有个容易出错的地方:函数入口地址是st_value,而崩溃地址经常落在函数中间的指令上,所以必须是“区间包含”,而不是等号匹配。有些工具连PLT、trampoline也会建符号,恰好在边界上时,bisect_right配合后端判断能减少误判。

4.4 遇到strip和inline时的处理思路

如果ELF被strip过,.symtab没了,脚本只能解析.dynsym,那么大量内部函数查不出来。这时的常用招数是:

  • 保留未strip的build版本文件,部署时用strip后的文件,分析时用带符号版;
  • 开启编译器的-fno-inline或生成.debug_info,然后用addr2line -i还原内联调用链;
  • 利用.eh_frame里的寄存器规则,从崩溃地址反推栈上调用链,这是更底层的方案,但能绕过符号表缺失。

内联函数在符号表里根本没有独立符号,所以只靠符号表无法看到被内联的小函数。addr2line -i之所以能输出,是因为它读了调试信息。换句话说,定位函数名,符号表是下限;定位到准确行号和内联链,调试信息才是上限。

5. 常见问题与排查经验

5.1 常见问题速查表

我在实际调试里整理了下面这张表,遇到“查不到函数名”时先对照一下。

现象常见原因解决办法
地址查出来差一点忘了减加载基址用/proc/pid/maps拿到基址再减
addr2line只输出??ELF被strip,无符号表换-D动态符号,或找带调试版本的ELF
找到函数名但没有行号只有.dynsym或调试信息被裁掉保留构建时的-g产物
地址落在两个符号边界可能是PLT跳板反汇编看是不是jmp指令
地址带低位1ARM/Thumb指令集标志先addr & ~1再查
同名函数或模板函数混乱C++名字修饰或重载用-Cdemangle,再结合偏移判断

5.2 几个容易踩的坑

第一个坑是ARM平台。ARM处理器的Thumb指令集会把地址最低位当状态位,比如0x1235其实表示0x1234处的Thumb指令。你在addr2line里直接传0x1235,有经验的工具会自动处理,但自己写脚本时必须先去掉最低位,否则区间匹配永远差1。

第二个坑是共享库的加载基址不等于readelf -l里的p_vaddr。很多情况下动态链接器把ELF映射到内存时,起始地址会比p_vaddr大一个固定偏移。Android里的dlpi_addr、Linux里的maps第一行起始地址,都要当成“装载基址”用,不是简单拿p_vaddr。我一度认为基址等于第一个PT_LOAD的p_vaddr,结果调试共享库时每个地址都偏了一截,后来才发现要计算load_bias = dlpi_addr - first_pt_load.p_vaddr。

第三个坑是弱符号和同名符号。静态库里同一个函数有多个版本,符号表里可能有多条相同或相近地址的记录。我的习惯是:如果目标地址命中了多个符号,优先选.symtab里的全局符号,再看st_size更大的那个。

5.3 如何批量定位一堆地址

线上拿到的地址往往不是一个,而是几十上百个。手动敲addr2line不现实,我一般把地址列表先减掉统一基址,然后写循环调用addr2line:

while read addr; do echo "--- $addr ---" addr2line -e ./libfoo.so -f -C "$addr" done < addrs.txt

或者干脆直接用前面那个Python脚本,把所有符号加载进内存,最后一次性做区间匹配。实测几万个符号、几百个地址,毫秒级就算完。如果还想让结果更可靠,可以同地址再跑一次objdump -d看上下文,防止内联导致误判。

个人经验:定位ELF函数名这件事,真正难的不是命令,而是“你到底在拿哪个地址减哪个基址”。只要把这个换算关系搞顺,工具基本都是锦上添花。还有一点,遇到大项目我会保留一份带符号的构建产物,成本不高,但排线时省的时间远超存储开销。

最后再分享一个我常用来验证的小办法:拿到一个地址后,先用nm -n看到相邻两个符号,大体确定函数区间;再用addr2line -f -C确认函数名;最后用objdump -d看两条指令,判断是不是调用点或跳板。三层交叉对照下来,基本不会有查错的情况。这个方法在Android native、Linux服务端、嵌入式环境里都适用,熟悉之后十分钟就能定位一次崩溃。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询