首页 / 资讯中心 / 文章详情

ELF地址反查函数名:从崩溃日志到addr2line的完整实战指南

ELF地址反查函数名:从崩溃日志到addr2line的完整实战指南 ★ FEATURED ARTICLE
程序崩了日志里只有一行demo_app[0x55f0a1f11a14]。没有完整调用栈没有函数名甚至连模块里的符号都看不出来。这种时候我脑子里冒出来的第一个动作从来不是去翻代码而是把这串运行地址喂给对应的ELF文件让它输出具体的函数名。从一个ELF文件出发逆向定位运行地址对应的函数名听起来像是调试器的精细活实际操作起来就是几个命令加一点偏移换算的功夫但里面的坑真不少尤其当你处理的是PIE、动态库、strip过的发布包时换算错一位就是白干。这篇东西不搞理论堆砌我会从实际日志长什么样讲起带你走一遍完整的“地址→函数名”流程把符号表、调试信息、ASLR偏移、strip兜底这些点全串起来。适合服务端开发、嵌入式工程师、性能分析爱好者以及所有被“一长串十六进制”折磨过的人。1. 先搞清楚什么日志会给你一串需要还原的运行地址1.1 崩溃日志里“地址”的几种长相不同运行时打出来的地址格式不一样但核心信息就两类绝对运行地址、或者“基址偏移”。先说最常见的 glibc backtrace 格式./demo_app(0x13a14)[0x559e7a7b3a14] /lib/libdemo.so.1(0x2a3c)[0x7f1e2c3ba3c]中括号里是整个进程地址空间中的绝对地址小括号里的0x13a14是相对模块加载基址的偏移。注意glibc已经帮你算好了偏移所以括号里的偏移量可以直接拿来用这能省掉后面一大半换算工作。另一种格式来自某些系统库和信号处理器的自绘输出Crashed thread: 0 demo_app 0x0000559e7a7b3a14 (in demo_app)这种只给绝对地址不给偏移你得自己从/proc/pid/maps里找加载基址做换算。还有一种是 Android/ARM 常见的pc 00013a14 /data/app/demo_app/lib/arm64/libdemo.sopc后面直接跟的是相对于起始地址的偏移。看到这种格式反而要开心拿到ELF就能直接查。这些格式虽然长相不同本质都是在问同一个问题**这个地址落在代码的哪个函数里**要回答它得先知道地址对应的ELF文件在内存里的真实排布以及ELF内部存了哪些可供定位的符号信息。1.2 哪些场景真正需要这条“地址→函数名”链路我整理了下自己实际碰到过的场景供你对照线上崩溃分析崩溃发生在用户侧或灰度机器没有现成的coredump只有日志里打出来的一串地址。这是触发频率最高的场景。coredump事后分析有core文件但机器上没有对应的符号文件需要拿到目标机ELF后离线定位。性能采样归因perf record采样后得到一堆采样地址火焰图的横轴坐标全是十六进制不解析成函数名根本没法人看。动态追踪用uprobe/ftrace跟踪某个内核事件事件输出中出现用户态进程的IP指令指针地址需要反查。写自己的信号处理器想在SIGSEGV处理函数里打印出可读的函数调用栈而不是干巴巴的地址。无论是哪种前提都一样你要有一份和目标运行版本完全对应的ELF文件。文件对不上后面一切工具都是白搭这算是这条链路上的第一铁律。2. ELF里能让地址变成函数名的信息藏在哪两部分定位函数名这件事靠的是ELF文件内部的两处“地图”信息符号表symbol table和调试信息debug info。很多人把这两个概念混在一起其实它们解决的是不同层次的问题。2.1 符号表名字、地址、大小的三列账本符号表是ELF结构里的一个section通常叫.symtab或.dynsym。用readelf -sW可以把它打出来大概是下面这个样子Symbol table .symtab contains 1234 entries: Num: Value Size Type Bind Vis Ndx Name 42: 0000000000001230 24 FUNC GLOBAL DEFAULT 2 parse_config 47: 0000000000002100 128 FUNC LOCAL DEFAULT 2 parse_option 51: 0000000000005ba0 200 FUNC GLOBAL DEFAULT 2 process_request每行符号记录了三件关键事情Value是符号的地址链接期虚拟地址Size是函数体长度Name是函数名。有了这三列你就可以把地址“夹”在某条符号记录的Value和ValueSize之间从而判断它落在哪个函数的代码范围内。但符号表有个先天的限制它不知道行号。你查到process_request可你想知道的是它内部第42行崩了符号表答不上来。而且.symtab在strip之后就没了发布包大多不留。2.2 调试信息真正让你定位到“行号”的宝藏如果ELF里带着调试信息情况就完全不一样了。.debug_line这个section里存着一张行表line table记录“地址区间→源文件:行号”的映射。编译器带着-g选项编译时会把这个表格生成出来反汇编时你能看到每条指令对应源码的哪一行靠的就是它。调试信息里还有.debug_info、.debug_abbrev、.debug_str这些section它们一起描述类型、变量的完整结构。对“地址→函数名”来说行表是核心其它是为更高阶的调试能力服务的。这也是为什么有些ELF能查函数名但查不到行号多半是只保留了符号表丢了调试信息。2.3 先给你的ELF做个体检五大命令一次说清拿到ELF后我习惯先跑一组命令摸清底细再决定后面用什么工具file demo_app # 看架构、PIE还是非PIE readelf -S demo_app | grep -E debug|symtab # 看有没有符号表和调试section readelf -sW demo_app # 看符号表内容 readelf -l demo_app | grep LOAD # 看程序头的段布局 nm -n demo_app # 按地址排序的所有符号其中file的输出尤其值得留意。如果显示ELF 64-bit LSB shared object, x86-64说明这是个PIE位置无关可执行文件如果显示ELF 64-bit LSB executable通常是非PIE的传统可执行文件。这两种文件的地址换算逻辑完全不同后面第4章会专门讲。符号表和调试信息的关系用下面这张表可以看得很清楚对比项符号表(.symtab)调试信息(.debug_line等)主要作用地址→符号名地址→符号名行号变量类型生成条件链接器默认生成编译加-g选项strip之后消失消失占体积较小较大典型命令nm, readelf -saddr2line的查行号能力依赖它实际情况里两者往往同时存在。但你要是手里只有一份生产环境的strip包符号表和调试信息都别想了只能走第5章讲的动态符号表路线。3. 首选武器addr2line从地址到函数名和行号的最短路径3.1 单地址查询的基本姿势有符号信息时addr2line是效率最高的工具没有之一。看这条addr2line -e demo_app -f -C -p 0x13a14对应输出可能是process_request at src/handler.c:42这个输出不是变魔法它内部做的事情就是刚才说过的两步先用.symtab找地址落在哪个函数再用.debug_line的行表把函数内偏移精确到源码行号。这也解释了为什么没有调试信息时行号会变成??。需要特别注意一个常见误区**addr2line接收的是ELF里的链接期虚拟地址vaddr/偏移不是进程运行时的绝对地址。**除非日志里已经给你0x13a14这种偏移格式否则直接拿运行地址填进去大概率返回垃圾结果。地址换算那套逻辑下一章展开。3.2 参数速查被我反复用的那几个我用得最顺的参数组合是-e -f -C -p -i逐个说一下参数作用踩过坑才知道的价值-e elf指定要查的ELF文件版本不对时先排查这个-f显示函数名不加只有一个行号等于没查-C自动demangle C符号不加会输出_ZN12processCtx5ParseEv这种-ppretty模式一行输出批量跑日志时省一半篇幅-i显示内联函数链强优化代码里没有它就是一头雾水-a显示你输入的地址批量输出排查“对不上号”时很有用-s只显示basename路径长路径日志看着清爽C项目强烈建议-p -f -C一起上。我自己第一次解析一个C崩溃栈时忘了加-C看到一长串_ZN6Server15ProcessRequestEiP8Request愣了半天加一个-C秒变Server::ProcessRequest(int, Request*)体验天差地别。3.3 批量解析整份backtrace一句话脚本崩溃日志里的backtrace往往几十行不可能一行行敲命令。我常用下面这个脚本把日志中的十六进制地址一把梭grep -oE 0x[0-9a-f] backtrace.txt | while read addr; do echo -n $addr - addr2line -e demo_app -f -C -p $addr done跑完会得到一份“地址→函数名”清清楚楚的映射表。如果日志给的是glibc那种带(0x13a14)的偏移格式可以直接提取括号里的偏移注意补上0x前缀再喂给addr2linegrep -oE \(0x[0-9a-f]\) backtrace.txt | tr -d () | while read off; do echo -n $off - addr2line -e demo_app -f -C -p 0x${off#0x} done这里的小细节是有些老版本addr2line对不带0x前缀的输入不识别哪怕你给的是纯十六进制字符串。所以脚本里宁可多补一个前缀也别让它空手而归。3.4 一个完整的解析示例假设你抓到这样一段日志./app(0x13a14)[0x55b5c1a11a14] ./app(0x11040)[0x55b5c1a0f040] /lib/libc.so.6(0x45dff)[0x7f4e3d1d4dff]你已经确认app是这个架构、这个版本的最终发布二进制并且还留着带符号的版本。执行addr2line -e ./app -f -C -p 0x13a14 addr2line -e ./app -f -C -p 0x11040得到process_request at src/handler.c:42 send_response at src/io_loop.c:118第三条是libc的地址那就得拿系统库的对应符号文件去解析或者用下一章的maps换算后交给系统仓库里的debug包。整个过程也就一分钟的事。4. 三个地址的换算为什么不能拿日志里的数字直接查4.1 三个“地址”分别是什么翻阅大量“地址定位失败”的求助帖后我发现问题几乎都出在同一个地方把链接地址、加载地址、运行地址三个概念搅在了一起。先把概念钉死链接地址vaddr链接器生成ELF时就写入符号表和程序头的虚拟地址。它是“设计图纸”上的位置不随运行变化。加载地址加载基址/load_bias程序或共享库被操作系统装入进程地址空间后真正映射到的起始地址。受ASLR影响每次启动都可能不同。运行地址运行时绝对地址在跑的进程里某条指令的真实地址也就是日志中括号里的那一大串数字。它们的关系极其简单运行地址 加载基址 偏移日志里的小括号偏移本质就是“目标指令在ELF里的链接地址相对第一个LOAD段链接基址的差值”。所以拿到这个偏移就不用再关心加载基址直接拿偏移去addr2line里查就行。这就是glibc背戳格式最友好的地方。4.2 PIE和ASLR为什么同一段代码每次地址都不同现在Linux发行版默认开启PIE。PIE产物的特点链接时基址是0所有函数的链接地址都特别小类似0x13a14、0x11040加载时再随机映射到一个高位地址。如果你只是用file看到shared object说明它就是PIE那么日志里如果是绝对地址而非偏移就一定要先做减法不能直接拿给addr2line。非PIE的传统可执行文件老式ELF executable链接基址通常固定为0x400000左右加载地址基本等于链接地址这时候才能“拿那个地址直接查”。可现在默认都开PIE我遇到拿绝对地址直接查然后查了个寂寞的情况九成都是忘了这件事。另外注意ASLR只随机化加载基址不改变文件内各函数的相对偏移。这就意味着你完全不需要知道基址是多少只要手里有带符号的ELF任何一次崩溃的偏移都是可复现的。这背后是内核对ELF的页对齐映射机制把编译产物当作一个整体搬进内存只是搬家不改内部布局。4.3 拿到加载基址/proc/ /maps 实战日志只给绝对地址、没给偏移时你需要从当时的进程状态里把加载基址捞出来。标准动作是翻/proc/pid/mapscat /proc/12345/maps | grep demo_app输出示意559e7a7a0000-559e7a7c0000 r-xp 00000000 08:01 2334455 /opt/app/bin/demo_app 559e7a7c0000-559e7a7d0000 r--p 00020000 08:01 2334455 /opt/app/bin/demo_app 559e7a7d0000-559e7a7d2000 rw-p 00030000 08:01 2334455 /opt/app/bin/demo_app图中第一列是进程内地址范围第二列是权限第三列是文件内偏移最后一列是映射的文件。换算的关键你的目标地址在哪个区间里就用哪个区间的起始地址当作加载基址。假如日志里的绝对运行地址是0x559e7a7b3a14它落在第一行559e7a7a0000-559e7a7c0000里偏移就是0x559e7a7b3a14 - 0x559e7a7a0000 0x13a14然后正常交给addr2lineaddr2line -e demo_app -f -C -p 0x13a14如果你要更严格地算加载基址可以结合ELF程序头的LOAD段信息加载基址 映射起始地址 - LOAD段的p_vaddr。对第一段LOOD来说PIE的p_vaddr为0所以映射起始地址就是加载基址直接用起始地址减即可对非PIE的传统可执行p_vaddr通常为0x400000映射起始地址也恰好在0x400000附近于是运行地址基本等于链接地址。多数场景下选对包含目标地址的maps行再用“运行地址 - 该行起始地址”这个公式就够用了。还有个容易翻车的细节maps必须来自崩溃时刻的进程。进程退出了、maps文件没了日志里又没有偏移那绝对地址就再也还原不出来了。这也是为什么我一直强调线上崩溃日志里必须带上(0x偏移)这种格式或者建立快照自动保存maps否则崩溃分析直接少一条腿。5. strip之后也有的救动态符号表与工程化兜底5.1 动态符号表是最后一张底牌发布包为了省体积几乎都会被strip。strip会把.symtab和调试信息全删掉但有一个section通常还在.dynsym动态符号表。它是给动态链接器在运行时解析符号用的删了就没人能加载这个库所以动不得。查看方式readelf -sW --dyn-syms demo_app | head -50输出里能看到所有对外导出的全局函数比如Symbol table .dynsym contains 89 entries: Num: Value Size Type Bind Vis Ndx Name 5: 0000000000001230 96 FUNC GLOBAL DEFAULT 12 md5_update 7: 0000000000002100 128 FUNC GLOBAL DEFAULT 12 process_request局限也很明显它只包含导出符号以及链接器需要解析的全局符号。static函数、被-fvisibilityhidden藏起来的内部实现在.dynsym里是看不到的。所以用动态符号表定位只能给你一个“大概在哪个导出函数附近”具体行号想都不用想。5.2 怎么找“离得最近的函数”动态符号表里没有行表定位策略退化为找到地址区间包含目标地址的那个导出符号。把符号按地址排序然后沿着地址往上找第一个“符号地址 ≤ 目标地址”的条目就行。用nm可以直接按地址排序把符号列出来nm -D --numeric-sort demo_app再用个小脚本把目标地址插进符号区间TARGET0x13a14 nm -D --numeric-sort demo_app | awk -v t$TARGET $1 ~ /^[0-9a-f]$/ $2 T { addr strtonum(0x $1) if (addr t) { last_name$3; last_addraddr; last_sizestrtonum($2)?0:0 } } END { print closest symbol:, last_name } 实话实说这个脚本胜在轻量但精度有限。更可靠的算法是用二分查找在符号数组里找“上界”这不难但要自己写点代码。我偷懒时经常借助gdb见下节。5.3 gdb也能干这活而且比脚本稳strip的二进制同样能被gdb解析地址靠的就是动态符号表加运行时的段信息。命令如下gdb -q -batch demo_app -ex info symbol 0x13a14输出类似process_request 42 in section .text 42表示在某函数内偏移42字节这已经够你对照反汇编做进一步判断了。甚至可以直接用gdb反汇编那一小段gdb -q -batch demo_app -ex x/8i 0x13a14看一眼附近几条指令经常能确认到底是哪个分支、哪次调用炸的。这类“粗定位反汇编”的组合拳是strip包场景下最现实的方案。5.4 正确的工程姿势发布时不要真的丢掉符号生产环境为了安全省体积要strip我完全理解但strip需要可控地做而不是把符号文件当垃圾扔了。我建议的规范流程是编译期完整保留-g调试信息生成带全部符号的完整二进制。用objcopy把调试信息单独抽成一个文件objcopy --only-keep-debug demo_app demo_app.debug发布用strip后的瘦身版objcopy --strip-debug demo_app demo_app.strippedcrash日志里记录ELF的build-idreadelf -n demo_app可查把.debug文件按build-id归档事后用带调试信息的版本做addr2line。这条链路走通后即使线上跑的是精简二进制你手里的.debug文件依然是完整的“设计图纸”一切原有的符号和行号查询能力全部保留。build-id匹配尤其值得做它比文件名靠谱的多——同名文件不同版本这种事我踩的次数太多了。6. 综合避坑内联、Thumb、PLT这些附加题6.1 地址落在内联函数里别被外层函数误导-O2以上编译时编译器经常把一个小函数内联进调用方。崩溃地址会落在调用方函数的代码区间内但程序计数器其实指向的是被内联进来的小函数。addr2line的-i参数就是为这个场景设计的它会输出一条内联链addr2line -e demo_app -f -C -p -i 0x13a14输出可能是inline_checks at src/validate.c:204 process_request at src/handler.c:156第一行才是真正发生问题的内联函数第二行是它的外层调用者。不加-i你只能看到第二行很容易误判“崩在handler.c第156行”而实际上问题出在validate.c里的某个条件检查。6.2 ARM/Thumb地址最低位的陷阱跑ARM平台时如果目标地址带最低位为1比如0x13a15别急着报错。那是Thumb指令集的产物——ARM处理器用地址最低位表示当前处于Thumb模式PC值会带这个标志位。查询前先把它屏蔽掉addr0x13a15 addr2line -e demo_app -f -C -p $(( addr ~1 ))忘了这步地址会错位一个字节函数名可能对得上也可能错行号大概率全乱。这问题在x86上不存在但在ARM嵌入式交叉开发里出镜率很高。6.3 地址落在PLT里查出来是库函数的跳板当调用 shared 库的外部函数时main模块里会有一小段PLTProcedure Linkage Table代码。如果崩溃地址恰好落在PLT那几字节上addr2line会告诉你“在.plt里”完全看不出是哪个外部函数。这时候需要结合反汇编看这几点在调什么objdump -d demo_app | grep -A5 .*plt看到callq 0x7f...或jmp *0x...(%rip)再配合objdump -R demo_app | grep 地址找重定位条目很快能定位到实际调用的库函数。说白了PLT场景下要查的不是“代码在哪”而是“跳转目标是谁”。6.4 延伸自己写一个地址解析器也不难如果你在交叉编译环境下遇不到addr2line或者想把这套逻辑集成进自己的工具链解析思路其实很清晰读ELF的section header找到.symtab和.debug_line然后按地址值遍历符号表找符号区间覆盖目标地址的那项。用Python的pyelftools可以快速实现一个最小版本from elftools.elf.elffile import ELFFile def find_symbol(elf_path, addr): with open(elf_path, rb) as f: elf ELFFile(f) symtab elf.get_section_by_name(.symtab) if not symtab: print(no .symtab, try .dynsym) symtab elf.get_section_by_name(.dynsym) for sym in symtab.iter_symbols(): start sym[st_value] size sym[st_size] if start and start addr start size: return sym.name return unknown这段代码的边界条件很有意思符号的st_size为0时要跳过否则会匹配到一堆段边界符号目标地址要转成ELF里的vaddr不能直接用运行地址多个符号同地址时偏好选函数类型的。真正要做得健壮比这个示例多考虑十来个边界情况但核心循环就是“地址区间包含”这一个判断。6.5 踩坑速查表把上面积累的经验浓缩成一张表排查时直接对照现象常见原因解决动作函数名对行号全??有符号表但无调试信息编译加-g重新生成符号直接拿绝对地址查结果没一个对的忘了PIE/ASLR偏移换算先减加载基址用偏移查询C符号全是_ZN乱码没做demangle加-C参数查到外层函数但和崩溃点对不上内联展开加-i显示内联链ARM地址对不上Thumb最低位标志地址 ~1函数名是.plt或??落在PLT或strip包反汇编查重定位或换debug文件崩溃现场没留maps日志只有绝对地址没有保存进程地址快照日志模板里强制带上偏移或maps这张表基本覆盖了我在实际处理“运行地址定位函数名”时遇到的九成麻烦。最后说一个我养成的习惯每次发版除了二进制我一定会把.debug文件、当时的/proc/pid/maps样本、以及readelf -n的build-id一起归档进发布包管理目录。你永远不知道线上什么时候会给你甩来一个只有十六进制地址的崩溃日志而提前把符号资产归档好是唯一能保证那时你还可以从容解析的先手棋。遇到跨版本问题先查build-id对不对再谈解析这能帮你省下至少一个下午。
阅读完成 · 觉得有帮助?
咨询建站