简介:面向Android原生开发学习者的so文件解析示例工程,聚焦Java层与C/C++动态库的交互理解,适合需要认识.so结构、JNI声明及工程组织方式的初中级开发者参考。压缩包共30个文件,以class、java、h、so四类为主,另有Eclipse工程配置;其中java与h文件可对照JNI接口声明和native方法实现,so文件呈现动态库产物形态,class与配置保留可导入的项目结构,整体约58KB。已有3147人学习下载,说明该主题在Android原生开发与so文件分析入门场景中具有一定关注度。工程将源码、头文件、动态库和项目配置集中组织,便于按模块查看Java调用C/C++的接口关系、so的存放方式及构建产物组成,为后续分析符号、反汇编与调试动态库提供可对照的样本。
1. 从一份 3MB 的 .so 说起:为什么我坚持先读 ELF 头
解析 so 文件这件事,我第一次做的时候以为半小时能收工:某跨平台系统的 native 层崩了,日志里只有一行#00 pc 000000000004a1c8,so 是 release 出的、符号被剥干净,我拿着反汇编器硬啃了三天没定位到崩溃点。后来把 ELF 头、程序头、动态段一张张打出来,才发现那个偏移落在.plt的一个跳转桩上,真正的函数在另一个 so 里,地址是运行时才填进去的。
这篇讲的就是这套顺序:头 → 段 → 符号 → 重定位,每一步看什么、为什么这么看、看错了会掉进哪个坑。带的是能直接跑的命令和脚本,做 NDK 开发的、正在查 native 崩溃的、想给 so 瘦身的、刚接触二进制的,都能照着走一遍;熟手可以只挑第四、五章看判断依据。
2. 拆开 .so 的第一刀:ELF 头、节表与程序头怎么读
拿到一个陌生的 so,我不会先开反汇编器。反汇编器一上来就让你选架构、选基址,选错了后面全白干,而且它默认把整个文件当代码段线性扫描,遇到数据区就出垃圾指令。正确的入口是文件头,那几十个字节把所有后续偏移的读法都定死了。
2.1 前 16 个字节的 e_ident,能挡掉一半的错判
ELF 文件的前 16 字节是e_ident,格式固定,跟架构无关。头 4 字节是魔数7f 45 4c 46(\x7fELF),第 5 字节EI_CLASS是位宽:1 表示 32 位,2 表示 64 位;第 6 字节EI_DATA是字节序:1 小端、2 大端;第 7 字节EI_VERSION通常是 1;第 8 字节EI_OSABI标 ABI 体系,0 是 System V、3 是 Linux;第 9 字节EI_ABIVERSION是 ABI 版本。
这 16 个字节为什么值得单独拎出来讲?因为后面所有多字节字段(e_phoff、e_shoff、p_vaddr)的读取方式都由EI_CLASS和EI_DATA决定。我曾经把一个 32 位 arm 的 so 当成 64 位读,程序头偏移直接从正确值翻了一倍,读出来的p_type是乱码,我还以为是文件被加壳破坏了,折腾了半宿。血泪经验是:解析器第一行就判魔数,第二行判位宽和字节序,不匹配直接抛异常退出,别硬往下读。
e_ident之后是一串定长字段,我按重要性排:
| 字段 | 典型取值 | 用来判断什么 |
|---|---|---|
e_type | 3 = ET_DYN | 共享库和 PIE 可执行文件都是 3,光看它分不出来 |
e_machine | 40 = EM_ARM、183 = EM_AARCH64、62 = EM_X86_64、3 = EM_386 | 架构是否和崩溃日志、设备匹配 |
e_entry | 常见 0 | so 一般不设入口,由DT_INIT/DT_INIT_ARRAY接管 |
e_phoff/e_phnum | 程序头表偏移与条目数 | 运行时视图的入口,解析的第一站 |
e_shoff/e_shnum | 节表偏移与条目数 | 可能为 0,说明节表被抹掉了 |
e_flags | ARM 上有硬浮点、EABI 版本标志 | 判断 ABI 是否兼容 |
e_type那条要展开说:ET_DYN 既可能是共享库,也可能是开了 PIE 的可执行文件。区分办法是不看头,看有没有PT_INTERP段,或者动态段里有没有DF_1_PIE标志位。这个判断在做「这个文件到底是可执行还是库」的时候很有用,光凭后缀名.so三个字母是靠不住的。
看头最省事的命令还是 binutils 自带的那几条,不用自己写代码:
# 一次性看清位宽、字节序、架构、类型、程序头/节表位置 readelf -h libtarget.so # 顺手确认架构,file 的输出对 ARM 硬浮点和软浮点区分得更细 file libtarget.soreadelf -h的输出里,我会重点盯Machine、Class、Flags三行。Flags里如果出现hard-float ABI,说明这个 so 用了硬浮点调用约定,跟软浮点的库混链接会在运行期直接崩,而且崩的位置往往离真正的调用点很远,日志上一串地址完全不讲道理。
2.2 节表和程序头:加载器只认后者,别搞反
ELF 有两套视图,这是初学者最容易混淆的地方。节表(section header table)是链接视图,给编译器和链接器看的,里面是.text、.data、.dynsym、.symtab、.comment这些按功能划分的节;程序头表(program header table)是执行视图,给动态加载器看的,里面是PT_LOAD、PT_DYNAMIC、PT_GNU_RELRO、PT_TLS这些按内存映射划分的段。
关键在于:运行的时候加载器只读程序头,压根不看节表。所以一份 so 完全可以把节表整个抹掉,照样能被正常加载和执行。有些加固方案和极限瘦身就是这么干的,readelf -S直接报Section Headers: none,新手第一反应是「文件损坏了」,其实文件完好得很。
这就定下了解析顺序的原则:先读程序头,再读节表,且节表允许不存在。反过来写解析器的,遇到这类样本必然翻车。我一般会这么看:
# 程序头:看有几个 PT_LOAD、虚拟地址区间、对齐、有没有 RELRO readelf -l libtarget.so # 动态段:依赖、SONAME、初始化数组、各种标志位 readelf -d libtarget.soreadelf -l的输出里,PT_LOAD一般有两到三个:代码段(R E)、只读数据段(R)、可读写数据段(RW)。这里有个硬性约束值得记:每个PT_LOAD的p_vaddr必须和p_offset对p_align同余,也就是p_vaddr % p_align == p_offset % p_align。这是加载器做 mmap 的前提。如果一份 so 被人手工改过或者被工具处理坏了,这条同余关系经常第一个破,用它可以快速判断文件完整性,比拿哈希去比对靠谱得多。
还有个实战判断:PT_GNU_RELRO段存在,说明链接时开了 RELRO,.got里那些在重定位完成后不该再改的项会被 mprotect 成只读。这个段的位置和大小,直接决定了你后面做 GOT 改写类分析时哪些地址是能动的、哪些一动就段错误。
2.3 三十行把骨架打出来,先看形状再看细节
我不建议一上手就啃二进制格式手册,先写个最小可用的骨架打印器,把文件形状跑出来,再针对性地补细节。用 pyelftools 比自己拿struct解要省事,它把EI_CLASS带来的 32/64 位差异都包掉了。
from elftools.elf.elffile import ELFFile from elftools.common.exceptions import ELFError def dump_skeleton(path): try: with open(path, 'rb') as fp: elf = ELFFile(fp) # 1) 身份三件套:位宽/字节序/架构,决定后面所有字段的读法 print(f"[ident] class={elf.elfclass} " f"little_endian={elf.little_endian} " f"machine={elf['e_machine']} type={elf['e_type']}") # 2) 程序头:运行时视图,加载器唯一认的东西,必须最先看 for seg in elf.iter_segments(): print(f"[ph] {seg['p_type']:<18} " f"vaddr=0x{seg['p_vaddr']:08x} " f"filesz=0x{seg['p_filesz']:06x} " f"flags={seg['p_flags']}") # 3) 节表:可能整个不存在,用 e_shnum 先探一下,别硬迭代 if elf['e_shnum'] == 0: print("[sh] section header table is absent, skip") return for sec in elf.iter_sections(): print(f"[sh] {sec.name:<20} " f"addr=0x{sec['sh_addr']:08x} " f"size=0x{sec['sh_size']:06x}") except ELFError as e: # 魔数不对、文件被截断,都会走到这里 print(f"not a valid ELF: {e}") dump_skeleton("libtarget.so")代码里三个位置的注释对应三个判断点。第一处elfclass和little_endian打印出来,你就能确认这台设备上跑的是不是 arm64 小端;第二处迭代程序头时,如果发现只有两个PT_LOAD且没有PT_DYNAMIC,那基本可以断定这不是个正常链接出来的共享库,可能是被处理过的壳或者干脆是个数据文件改了后缀;第三处判断e_shnum == 0再迭代,是因为 pyelftools 在节表缺失时迭代会抛异常,而现实中这种样本真不少。
跑完这段,你会拿到一张「文件形状图」。别急着往下钻符号表,先对着这张图问三个问题:它有.dynsym吗?有PT_GNU_RELRO吗?代码段和数据段的虚拟地址是不是连续排布的?这三个答案基本决定了后面该往哪个方向查。翻车最多的场景,是拿着一个节表被抹掉的样本,还死磕readelf -S,最后得出「文件已损坏」的错误结论,然后把整条排查路线带偏。
还有个容易忽略的字段:e_entry。so 的这个值通常是 0,但如果你发现它非 0,说明这份文件可能同时被当成可执行文件用,或者被某个工具改写过头。我遇到过一次e_entry指向PT_LOAD之外的地址,加载直接失败,报错信息只有一句「cannot load library」,什么细节都没有,全靠对比e_entry和程序头区间才定位到是被一个体积优化脚本改坏了。
3. 符号与依赖链:dynsym、dynstr 和 DT_NEEDED 的解析顺序
形状看完,第二步是问「这个 so 对外承诺了什么、又向谁要了什么」。这两个问题分别对应动态符号表和动态段里的依赖条目,是解析 so 里信息密度最高的两块,也是我每次排查 native 问题时打开次数最多的两块。
3.1 为什么 strip 之后还能找到函数名
很多人以为 release 版本 strip 过就彻底没符号了,其实不然。ELF 里有两套符号表:.symtab是完整符号表,给调试和静态链接用的,包含本地符号、静态函数、源文件名;.dynsym是动态符号表,给动态链接器在运行期做符号解析用的,只保留全局可见的那部分。strip 删的是.symtab和.strtab,.dynsym和.dynstr是必须保留的,因为动态链接器要靠它按名字找函数。
所以nm libtarget.so报no symbols是正常的,换nm -D libtarget.so就能看到导出符号。这个区别看起来是常识,但我见过不止一个人因为看到前半句的输出就放弃了整条分析路线。
.dynsym里每一项是Elf64_Sym,关键字段四个:
| 字段 | 含义 | 实战用法 |
|---|---|---|
st_name | 在.dynstr里的偏移 | 拼出符号名 |
st_info | 高 4 位绑定、低 4 位类型 | 绑定 GLOBAL/WEAK/LOCAL,类型 FUNC/OBJECT/IFUNC |
st_shndx | 所属节索引 | 等于SHN_UNDEF(0) 表示「导入」,否则是「导出」 |
st_value | 符号地址 | 配合加载基址换算崩溃地址 |
st_shndx == 0这条特别好用。一个 so 的导入符号列表,就是它对外部世界的全部要求;导出符号列表,就是它对外承诺的全部能力。两边一拉,这个库的角色就清楚了。我一般还会顺手看st_info里的 WEAK 绑定:弱符号在解析不到的时候会被填 0,运行期调用必崩,而且崩溃地址就是 0 附近,日志非常难看——遇到pc 0000000000000000这种,第一反应就该去查是不是某个弱符号没解析到。
还有一个在移动端更常见的细节:.gnu.hash和.hash。动态链接器按名字查符号不是线性扫,是靠哈希表。有些裁剪工具会把.hash删掉只留.gnu.hash,某些老版本的加载器不认,结果就是 so 能加载但符号解析失败。判断方法很简单,readelf -S里看这两个节哪个在。
3.2 DT_NEEDED 与 SONAME:依赖链怎么排、dlopen 为什么失败
动态段(PT_DYNAMIC指向的那块)是一串d_tag/d_val键值对,我用得最多的几个:
DT_NEEDED:这个库依赖的其他库的名字,注意存的是 SONAME 字符串,不是路径DT_SONAME:这个库自己的名字,别人链接它时记的就是这个DT_RPATH/DT_RUNPATH:查找路径,RUNPATH优先于RPATH,且不影响间接依赖DT_INIT/DT_INIT_ARRAY:加载时自动执行的初始化函数DT_FLAGS/DT_FLAGS_1:BIND_NOW、NOW、TEXTREL、PIE这些行为标志
DT_INIT_ARRAY是我分析陌生 so 时的第一站。原因很实在:导出函数列表可能有一百个,你根本不知道哪个是主逻辑,但初始化数组在加载时是无条件跑的,它挂上去的函数八成就是这个库真正的入口。顺藤摸瓜比在导出表里瞎猜效率高一个数量级。
DT_NEEDED则直接对应最常见的dlopen failed系列报错。这类报错信息很短,但原因可以分成三类,排查顺序最好固定下来:
第一类是找不到。DT_NEEDED里写的是 SONAME,加载器按命名空间和搜索路径去找实际文件,路径不对、命名空间隔离、文件没打包进去,都会落到这一类。
第二类是架构不匹配。找到了文件但位宽或架构对不上,报错里通常带 32-bit / 64-bit 字样。这类反而好查,readelf -h一看e_machine就清楚了。
第三类是依赖缺失或符号未定义。文件本身没问题,但它依赖的下一层库缺了,或者某个符号在整条链上都找不到。这时候要顺着DT_NEEDED一层层往下拉,别只盯着报错的那个文件看。
# 只看动态段里的依赖和标志位,输出短,适合贴进排查记录 readelf -d libtarget.so | grep -E 'NEEDED|SONAME|RPATH|RUNPATH|FLAGS' # 导出符号(带地址),strip 过的库也能看 nm -D --defined-only libtarget.so | head -50 # 未定义的导入符号,也就是它向外部要什么 nm -D -u libtarget.so | head -50nm -D --defined-only的输出里,符号名后面带@的是带版本符号(Linux 上常见,移动端少见)。版本符号会导致「明明有这个函数却报 undefined」的怪现象,因为链接器按版本号匹配,名字对上版本对不上照样失败。这个坑我在一个跨平台系统的 Linux 侧踩过一次,现象是链接期就报错,跟运行期完全是两回事。
3.3 手写一遍符号表解析,把清单拉出来
命令行工具够用,但要批量处理几十个 so、还要交叉比对依赖关系,就得自己写。下面这段把导出、导入、依赖三样一次拉出来,输出可以直接重定向成表格。
from elftools.elf.elffile import ELFFile from elftools.elf.dynamic import DynamicSection def dump_symbols(path): with open(path, 'rb') as fp: elf = ELFFile(fp) # 1) 依赖与自身 SONAME:从动态段里捞 needed, soname = [], "" for sec in elf.iter_sections(): if not isinstance(sec, DynamicSection): continue for tag in sec.iter_tags(): if tag.entry.d_tag == 'DT_NEEDED': needed.append(tag.needed) elif tag.entry.d_tag == 'DT_SONAME': soname = tag.soname print(f"[soname] {soname or '-'}") print(f"[needed] {', '.join(needed) or '-'}") # 2) 动态符号表:st_shndx == 0 是导入,反之为导出 dynsym = elf.get_section_by_name('.dynsym') if dynsym is None: print("[warn] .dynsym missing, this lib is not dynamically linkable") return exports, imports = [], [] for sym in dynsym.iter_symbols(): if not sym.name: continue # st_info 拆绑定和类型,type=2 是 FUNC,1 是 OBJECT kind = sym['st_info']['type'] if sym['st_shndx'] == 'SHN_UNDEF': imports.append(f"{sym.name}({kind})") else: exports.append(f"{sym.name}@0x{sym['st_value']:x}({kind})") print(f"[exports] {len(exports)}") for s in exports[:20]: print(" ", s) print(f"[imports] {len(imports)}") for s in imports[:20]: print(" ", s) dump_symbols("libtarget.so")代码里两个关键点:一是遍历动态段用isinstance判断节类型,而不是按名字硬匹配.dynamic,因为节表被抹掉的样本里名字可能取不到;二是sym['st_shndx'] == 'SHN_UNDEF'用的是 pyelftools 的字符串常量写法,不同版本对整数 0 的处理略有差异,用字符串最保险。
跑完你会得到三样东西:这个库自己叫什么、它要哪些库、它导出了多少东西。导出数量这个数字本身就有信息量——一个正经的业务 so 导出几十个接口很常见,但如果导出上百个还夹杂着大量JNI_OnLoad之外的Java_开头符号,说明没有做符号可见性收敛,体积和加载开销都白送了。这时候配合-fvisibility=hidden重新编,能砍掉相当一部分动态符号表的体积,属于我常做的常规优化。
导入列表则要看有没有异常项。比如一个纯计算库却导入了网络相关符号,那就得问一句它到底在后台干什么。这不是怀疑一切,而是解析 so 的基本素养:符号表是二进制的自白书,它不会骗人,只是很多信息藏在你不看的节里。
4. 重定位表里的线索:.rela.dyn 与 .rela.plt 到底改了什么
符号表告诉你「有哪些东西」,重定位表告诉你「运行时这些东西被放到了哪里」。native 崩溃定位八成要用到重定位信息,因为崩溃日志里的地址是运行时绝对地址,你要把它翻译回函数名,中间隔着的就是加载基址和重定位。
4.1 重定位项的三个字段,以及 r_info 怎么拆
64 位 ELF 用的是Elf64_Rela,三个字段:
| 字段 | 类型 | 含义 |
|---|---|---|
r_offset | 64 位 | 要改写的目标地址,相对 so 基址的偏移 |
r_info | 64 位 | 高 32 位是符号索引,低 32 位是重定位类型 |
r_addend | 64 位 | 加数,RELATIVE类型下就是目标偏移本身 |
REL和RELA的区别是前者没有r_addend,加数要从被改写位置的原值里读。x86-64 用RELA,ARM 的 32 位版本历史上用REL居多,arm64 用RELA。解析器要按e_machine分情况,不能一把梭。
r_info的拆分是纯位运算:
# 64 位 ELF 的 r_info:高 32 位符号索引,低 32 位类型 sym_index = r_info >> 32 reloc_type = r_info & 0xFFFFFFFF常见类型号记几个就够用了。arm64 上:R_AARCH64_ABS64= 257、R_AARCH64_GLOB_DAT= 1025、R_AARCH64_JUMP_SLOT= 1026、R_AARCH64_RELATIVE= 1027。x86-64 上:R_X86_64_64= 1、R_X86_64_GLOB_DAT= 6、R_X86_64_JUMP_SLOT= 7、R_X86_64_RELATIVE= 8。
其中RELATIVE是最值得关注的一类,它不带符号索引,含义是「把 基址 + addend 写到 r_offset 处」。所有静态初始化过的函数指针、vtable 条目、全局对象地址,走的都是它。你在分析一张虚表被谁改写了、某个回调指针指向哪里,答案就在RELATIVE项里。
JUMP_SLOT对应 PLT 那一套,GLOB_DAT对应全局数据符号。三者加起来基本覆盖了一个正常 so 的全部重定位条目。数量上有个经验值:一个中等规模的业务 so,RELATIVE通常占七八成,JUMP_SLOT和GLOB_DAT分剩下的。如果你发现JUMP_SLOT数量异常多,那可能是链接时没开延迟绑定,或者代码里大量跨库调用。
4.2 PLT/GOT 与懒绑定:为什么崩溃地址落在桩上
这是理解 native 崩溃的关键一环。跨库函数调用在编译后不会直接生成「调那个绝对地址」的指令,而是调本库.plt里的一个桩,桩再去.got.plt里取真实地址跳过去。第一次调用时.got.plt里存的还是「回跳到解析器」的地址,由解析器找到真实函数后回填,这叫懒绑定。
所以崩溃日志里#00 pc落在一个JUMP_SLOT对应的偏移上,完全不奇怪——那说明崩在桩上,真实函数在另一个库。我最早那次三天没定位到问题,就是卡在这里:反汇编器把.plt当普通代码反出来,看着像一堆无意义的跳转,其实是通往别的 so 的入口。
懒绑定可以通过链接选项关掉。-z now(或者DT_FLAGS里的DF_BIND_NOW、DT_FLAGS_1里的DF_1_NOW)会让加载器在加载阶段一次性解析完所有符号,.got.plt之后被 mprotect 成只读。近几年的 NDK 工具链默认就倾向于now,因为懒绑定需要可写可执行的页面,落地到安全策略上不划算。判断方法是在readelf -d的输出里找BIND_NOW和FLAGS_1:
# 看绑定策略和 RELRO 是否开启 readelf -d libtarget.so | grep -E 'BIND_NOW|FLAGS|RELRO' # 看重定位条目,注意可能被压缩,见第五章的坑 readelf -r libtarget.so | head -40开了now之后有一个副作用:所有符号在加载阶段就解析完,任何一个弱符号缺失都会在加载时直接失败,而不是等到第一次调用才崩。这其实是好事,故障暴露得更早、更好查。反之,如果你的库是老工具链编的、还是懒绑定,那「加载成功但运行到某个分支才崩」就是常态,定位时要格外注意调用栈里那些来自其他库的帧。
4.3 用重定位反查崩溃:把绝对地址压回函数名
完整的还原链路是这样的:崩溃日志给绝对地址 → 减去 so 的加载基址得到文件内偏移 → 用偏移在符号表和重定位表里定位 → 落到具体函数。
第一步的加载基址怎么拿?日志里通常有/proc/self/maps的片段,或者崩溃上报里带了模块加载信息,格式是模块名 + 起始地址-结束地址 + 权限 + 偏移 + 路径。取起始地址减掉文件里的p_vaddr(一般是 0),就是基址。
第二步纯算术,但有个细节:arm64 上崩溃地址可能是返回地址,也就是调用点之后的那条指令,需要减 4 再查,否则会落到下一个函数的开头,查出来的名字对不上,会让你怀疑人生。
# 假设 so 基址 0x7f8a200000,崩溃地址 0x7f8a2004a1c8 # 先算文件内偏移:0x4a1c8 # 有符号的 so 直接还原 aarch64-linux-android-addr2line -C -f -e libtarget.so 0x4a1c8 # 没有符号时,查这个偏移落在哪个符号的地址区间里 readelf --dyn-syms -W libtarget.so | awk '{print $2, $8}' | sort | head -40 # 如果偏移落在 PLT 区间,反查对应的重定位项,看它指向哪个外部符号 readelf -r -W libtarget.so | grep -i 'JUMP_SLOT' | head -20三条命令对应三种情况,我一般按顺序试。addr2line能出结果就直接收工;出??说明这个 so 没有行号信息,退到第二条用动态符号的地址区间做范围匹配;如果偏移落在.plt段范围内,第二条也会失效,那就走第三条,用重定位表反查这个桩对应的是哪个外部函数。
这套流程跑顺了,一个 native 崩溃从看到日志到定位函数,十几分钟能出结果。反过来,如果跳过重定位这一步,只拿反汇编器硬看,那.plt那一堆桩就是黑匣子,你永远不知道它跳去了哪。
5. 解析 .so 的五个翻车现场:从现象到修法的排查清单
前面几章讲的都是「正确路径」,这一章讲「错误路径长什么样」。下面五条都是我实际踩过的,按踩到的频率从高到低排,每条按现象、原因、解决三段写。
5.1 readelf -S 报 no sections,就以为文件损坏
现象:readelf -S libtarget.so输出There are no sections in this file,或者Section Headers: none。同时readelf -h一切正常,库也能被正常加载。
原因:节表被有意抹掉了。节表是链接视图,运行期加载器根本不读,所以抹掉它不影响功能,还能顺带抹掉.symtab、.comment这些泄漏信息的节。部分加固方案和极限体积优化都会这么干。
解决:改用程序头和动态段解析,把readelf -l、readelf -d当主入口,符号信息从.dynsym拿。自己写解析器时,e_shnum == 0必须作为正常分支处理,不能当成异常。我第一次遇到时把整条排查路线带偏了两天,最后是靠readelf -d输出正常才反应过来文件没坏。
5.2 反汇编出来的地址和崩溃日志完全对不上
现象:崩溃日志里是0x4a1c8,反汇编器里同一位置是段无关的乱码,或者干脆落在数据区。
原因:三种可能。一是没减加载基址,把运行时绝对地址直接当文件偏移查;二是架构选错,arm64 的 so 用 32 位模式反汇编;三是这个 so 的重定位被压缩了,链接器把重定位项打成了 APS2 之类的紧凑格式,存在DT_ANDROID_REL/DT_ANDROID_RELA里,readelf -r输出为空或只有几条,你根据「没有重定位」得出的所有推论都是错的。
解决:先把基址减掉,再确认架构,最后看动态段里有没有ANDROID_REL类条目。有的话,要么换支持解压的工具,要么在链接阶段用--pack-dyn-relocs=none重新出一版用于分析。这一步别省,我见过有人对着压缩过的重定位表硬推,推出一个完全错误的调用链。
5.3 nm 说 no symbols,就断定没法分析
现象:nm libtarget.so输出no symbols,于是认为这个库没法做函数级定位。
原因:nm默认读.symtab,strip 之后这个节没了。但.dynsym是动态链接必需的,一定还在。
解决:加-D读动态符号表。如果要看未定义符号加-u,要按地址排序加-n。顺带说一句,objdump -T也能拉动态符号表,输出格式更适合做地址区间匹配。
5.4 addr2line 全是 ??,换工具也没用
现象:addr2line对每个偏移都返回??:0,换llvm-symbolizer一样。
原因:这个 so 是 strip 过的,没有.debug_line和.debug_info。行号信息只在带调试符号的产物里,release 包里的那个 so 天然没有。
解决:回到构建产物目录找未 strip 的版本,通常在中间产物目录下,按 ABI 分目录存放。找不到就重新构建一版带-g的,用同一份源码、同样的编译选项,只改调试信息开关,这样地址偏移能对上。注意编译选项别乱改,优化等级一变,函数内联和指令排布全变,地址就对不上了,这是很多人「明明有符号却还是对不上」的真实原因。
5.5 库能加载但一调用就崩,报错指向 0 地址
现象:dlopen成功,调用某个接口时段错误,崩溃地址在 0 附近。
原因:弱符号没解析到。WEAK 绑定的符号在运行期找不到实现时会被填 0,不报错,等到真调用才炸。
解决:查这个符号的绑定类型,如果是 WEAK 且st_shndx是SHN_UNDEF,说明它期待别人提供实现。顺着DT_NEEDED往下找哪个库应该提供它,或者确认加载顺序对不对。这类问题的隐蔽性在于加载阶段完全静默,日志里一点痕迹都没有,只能靠提前把弱符号清单拉出来逐个核。
6. 一次跑通的解析脚本:从骨架到结论的完整复现
把前面几章串起来,一个能日常用的解析流程是四步,每步的输出直接喂给下一步:
| 步骤 | 输入 | 输出 | 判断什么 |
|---|---|---|---|
| 头解析 | 文件前 64 字节 | 位宽、字节序、架构、段表位置 | 能不能继续读,架构对不对 |
| 段解析 | 程序头 + 动态段 | 依赖列表、初始化数组、标志位 | 它要什么、加载时会跑什么 |
| 符号解析 | .dynsym+.dynstr | 导出清单、导入清单、弱符号清单 | 它提供什么、对外部有什么要求 |
| 重定位解析 | .rela.dyn+.rela.plt | 地址映射表 | 崩溃偏移对应哪个函数 |
第四步的输出建议直接落成一份「偏移 → 符号」的映射文件,崩溃日志进来先查表,查不到再退回范围匹配。这份表可以缓存,同一个版本号复用,不用每次重跑。
有个小技巧值得单独说:把readelf --dyn-syms -W的输出按地址排序后,用二分查找做范围匹配,比线性扫快得多,几百个符号的量级下差别不明显,但批量处理几十个 so 时很省时间。匹配时的边界要留神,符号的st_size为 0 的情况在汇编写的函数里很常见,这时候范围要取到下一个符号的起始地址为止。
回过头看,我最早栽的那三天,问题不在工具不够强,而在顺序错了——上来就反汇编,等于跳过了所有能给出结构性判断的信息,直接扎进最脏的细节里。从那以后我每次碰陌生的 so,都强制走一遍「头 → 段 → 符号 → 重定位」这四步,哪怕只花五分钟,也比在反汇编窗口里瞎滚半小时强。希望这套顺序能帮你少走一次那三天的弯路。
本文还有配套的精品资源,点击获取