Linux崩溃定位:用maps与反汇编从RIP还原现场
凌晨一点线上服务崩了。打开日志只有孤零零一行Segmentation fault没有 core 文件没有 GDB 权限甚至连复现都很困难。这时候光靠日志里的十六进制数字去猜崩溃原因基本就是大海捞针。但如果你手头还有一份进程退出前的/proc/pid/maps以及对应的可执行文件或动态库那就有得玩。用maps加反汇编定位崩溃地址是我这些年处理线上事故比较顺手的一套土办法。它不依赖 core dump、不依赖调试符号、不需要碰线上服务本体只要能把崩溃瞬间的指令地址RIP和内存映射快照留下来就能从汇编层把崩溃点挖出来。这套技能特别适合 Linux C/C 服务端开发者、后台运维排查人员和做嵌入式交叉编译的朋友。下面我把整个思路、换算细节和实操步骤完整写一遍。1. 先搞清楚这套土办法解决什么问题1.1 常规调试三板斧为什么经常失灵很多人遇到崩溃第一反应是gdb挂上去。但实际生产环境里gdb 要么没安装要么权限受限要么进程已经被守护进程自动拉起现场早没了。第二反应是等core dump可多数服务器默认ulimit -c 0而且磁盘空间紧张时 core 根本不落盘。第三个就是到处加日志打点崩溃面前往往什么都打不出来尤其是栈已经损坏的情况。这三个常规手段失效时你就剩一条路靠崩溃地址本身去反推。Linux 下每个进程的内存映射都记录在/proc/pid/maps里它精确到每一段虚拟地址对应哪个文件、哪个偏移相当于进程地址空间的地图。拿到地图再拿着崩溃时的指令地址就能顺着地图找到对应的二进制文件。有了二进制文件反汇编就能还原那几行机器指令。机器指令虽然冷冰冰但配合源码和调用约定足够定位到具体函数甚至具体一行。1.2 maps 反汇编定位的适用场景这套方法最适合下面几种情况线上服务崩溃但系统日志里只留下了一条segfault at ... ip ...没有生成 core。守护进程崩溃后立刻重启你根本没机会 attach。问题出现在第三方闭源库或者发布包被 strip 过GDB 显示不了有用的函数名。偶发性内存踩踏跑了几天才崩一次没法稳定复现。1.3 工作台清单开始之前确认你手头有这几样东西崩溃瞬间的指令地址RIP也可能是 PC、IP叫法不同。崩溃进程对应的/proc/pid/maps输出。崩溃地址所在模块的二进制文件比如可执行文件或.so。GNU binutils 工具集objdump、addr2line、readelf、nm一般已自带没有就apt install binutils。如果你自己写的服务处于早期阶段还可以主动在代码里挂一个信号处理函数把崩溃现场完整保存下来。这点我在后面会给出可直接抄的代码。2. 读懂/proc/pid/maps崩溃地址的“地图”2.1 maps 每一列到底在讲什么maps文件的每一行描述一段连续的虚拟内存区域格式是这样地址范围 权限 偏移 设备 inode 路径 55e0b0a45000-55e0b0a46000 r-xp 00001000 08:01 1234567 /usr/bin/demo各字段含义如下字段含义55e0...-55e0...虚拟地址区间左闭右开遵循[start, end)r-xp权限r读w写x执行p私有s共享00001000该映射在文件中的起始偏移量单位字节08:01所在块设备号1234567文件 inode 号用来精确对应同一个文件末尾路径文件路径方括号如[heap]、[stack]表示匿名内存区域对于定位崩溃地址最该看两样地址区间和末尾路径。崩溃时如果 RIP 落在r-xp区段说明它正在执行某个文件里的代码。落在[stack]就非常反常多半是栈被改成了可执行不太常见的配置。有一点要留意同一个.so文件在 maps 里会有好几行分别是只读段、代码段、数据段。看起来地址区间是断开的但文件其实是同一个。所以判断“它属于谁”要看路径和 inode而不是只看第一行。2.2 崩溃现场保全一个能安全打印 RIP/RSP 的信号处理函数没有 core 的情况下最主动的做法是自己接管SIGSEGV、SIGBUS、SIGILL这类致命信号。下面这个信号处理函数会把 RIP、RSP、出错地址以及当时的/proc/self/maps全部打到 stderr 里。代码是按“异步信号安全”的思路写的没有调用printf这类可能重新进入 libc 的函数。#define _GNU_SOURCE #include signal.h #include ucontext.h #include unistd.h #include fcntl.h #include stdlib.h static void segv_handler(int sig, siginfo_t *si, void *uctx) { ucontext_t *uc (ucontext_t *)uctx; unsigned long rip uc-uc_mcontext.gregs[REG_RIP]; unsigned long rsp uc-uc_mcontext.gregs[REG_RSP]; dprintf(2, \nsignal%d, si_addr%p, RIP0x%lx, RSP0x%lx\n, sig, si-si_addr, rip, rsp); int fd open(/proc/self/maps, O_RDONLY); if (fd 0) { char buf[512]; ssize_t n; while ((n read(fd, buf, sizeof buf)) 0) { write(2, buf, (size_t)n); } close(fd); } signal(sig, SIG_DFL); raise(sig); } void register_crash_handler(void) { struct sigaction sa {0}; sa.sa_sigaction segv_handler; sa.sa_flags SA_SIGINFO | SA_NODEFER; sigaction(SIGSEGV, sa, NULL); sigaction(SIGBUS, sa, NULL); sigaction(SIGILL, sa, NULL); }建议在main()一进来就调用register_crash_handler()。这样即使崩溃发生在子线程handler 也会在那个线程栈上执行拿到的栈指针就是当前现场。dprintf其实不是严格意义上的异步信号安全函数这是演示代码实际生产环境如果担心死锁可以把它换成手写十六进制拼接加write。更重要的是最后一个动作恢复默认信号处理器再raise这样系统还是能正常生成 core。也就是说有 handler 收集现场同时 core 也能保留一份。2.3 从地址反查模块三分钟内找到归属拿到崩溃地址和 maps 之后定位归属就是一行grep的事。假设打印出来是这样的signal11, si_addr0x7ffd12345678, RIP0x7f8b3a5b7e2a, RSP0x7ffd12345670 ... 7f8b3a596000-7f8b3a5bc000 r-xp 00234000 08:01 7891011 /usr/lib/libfoo.so.1RIP0x7f8b3a5b7e2a落在0x7f8b3a596000到0x7f8b3a5bc000之间所以它属于/usr/lib/libfoo.so.1的代码段。到这里问题就从“一个无法理解的十六进制数字”收敛到了“某个动态库的文件内偏移”。如果进程已经退出/proc/pid/maps读不到了那就去翻内核日志dmesg | tail -n 50很多发行版会输出类似segfault at 7ffd12345678 ip 00007f8b3a5b7e2a sp 00007ffd12345670 error 4 in libfoo.so.1[7f8b3a59600024000]这里的ip和in libfoo.so.1已经把关键信息告诉我们了。普通用户可能没权限读dmesg那就要靠程序自己的 handler 把现场打出来。3. 地址换算与反汇编核心姿势3.1 模块内偏移不是崩溃地址本身很多人拿到崩溃地址直接addr2line -e libfoo.so.1 0x7f8b3a5b7e2a结果全是问号。为什么因为addr2line需要的是“模块内部虚拟地址”也就是 ELF 文件里对应的 VMA而不是进程运行时被 ASLR 随机化之后的地址。加载到内存后地址整体平移了一大截必须先做一次减基址的换算。换算公式很简单elf_vma rip - vm_start map_offsetrip崩溃时的指令地址。vm_startmaps 中该行地址区间的起始值。map_offsetmaps 中该行的文件偏移量。列个例子。还是上面的 maps 行7f8b3a596000-7f8b3a5bc000 r-xp 00234000 ... rip 0x7f8b3a5b7e2a vm_start 0x7f8b3a596000 map_offset 0x234000代入elf_vma 0x7f8b3a5b7e2a - 0x7f8b3a596000 0x234000 0x24240 0x234000 0x236240 // 实际值取决于真实映射这一步的直觉是rip - vm_start得到的是映射段内的相对偏移map_offset则告诉这段映射从文件的哪个位置开始。两者相加就还原成了文件视角的地址。3.2 addr2line 一条命令定位源码行有了elf_vma就可以用addr2line映射回源码位置addr2line -e /usr/lib/libfoo.so.1 -f -C 0x236240-f显示函数名-C自动 demangle C 符号。如果这个.so带调试符号或者.text里有符号就能直接得到some_function /path/to/source.c:128如果输出??说明这个位置没有符号信息。可以先试readelf -S看看有没有.debug_*段再考虑走反汇编分析的路。3.3 objdump 手撕汇编无符号时的最后手段没有源码符号的时候objdump是我们的底牌。先把目标模块反汇编出来别直接objdump -d 整个文件文件大的话输出会非常吓人。用地址范围控制输出objdump -d --start-address0x236200 --stop-address0x236280 /usr/lib/libfoo.so.1输出大约是这个样子0000000000023620 未知函数: 23620: 55 push %rbp 23621: 48 89 e5 mov %rsp,%rbp 23624: 48 83 ec 10 sub $0x10,%rsp 23628: 89 7d fc mov %edi,-0x4(%rbp) ...看到这里汇编就能告诉我们崩溃时的执行流。是访问了(%rax)但rax指向了垃圾地址是call的目标非法还是movaps地址没对齐每一种指令都对应一类经典的崩溃原因。3.4 PIE、共享库、ASLR 叠加时的换算细节elf_vma这个换算里有个隐藏变量ELF 文件本身的p_vaddr和p_offset差。大多数动态库的代码段p_vaddr等于p_offset上个公式可以直接用。但有些文件可能首段有额外偏移比如内核模块、静态链接产物、个别自定义工具链生成的 ELF。稳妥的办法是用readelf -l看 LOAD 段readelf -l ./libfoo.so.1 | grep -A1 LOAD重点关注第一个R E属性的 LOAD 段它的p_offset和p_vaddr如果不一致需要再加一次纠正objdump 地址 file_offset - p_offset p_vaddr这里的file_offset就是我们公式算出来的elf_vma。不过这属于少数情况日常调试大部分库都是等偏移第一次算不出来再考虑这个也不迟。另外一个常见坑是 PIE 可执行文件。现在多数 Linux 发行版默认开启 PIE主程序加载地址是0x55...开头objdump 显示的文件内部虚拟地址通常从0x0或0x1000开始和运行时地址差很远。所以即使定义了-no-pie的直觉也不要把运行时地址直接丢给addr2line。先换算再喂地址流程永远一致。4. 完整实战从崩溃日志到源码行4.1 造一个能“崩”的 demo为了演示我写一个简单的数组越界读取的程序。故意不检查下标越界访问时可能踩到未映射页直接段错误。#include stdio.h #include stdlib.h static int parse_and_get(int *arr, int idx) { return arr[idx]; // 第8行越界点 } int main(int argc, char **argv) { int values[4] {10, 20, 30, 40}; int idx 8; if (argc 1) { idx atoi(argv[1]); } int v parse_and_get(values, idx); printf(v%d\n, v); return 0; }编译注意用-g -O0保留调试信息同时为了演示方便用-no-pie固定加载地址。真实生产程序是 PIE 也不要紧流程完全一样。gcc -g -O0 -fno-pie -no-pie -o crash_demo crash_demo.c4.2 跑崩采集现场数据运行并触发崩溃./crash_demo 8得到类似输出Segmentation fault (core dumped)如果程序里挂了第三节的 handler会输出signal11, si_addr0x7ffd12345678, RIP0x4005f3, RSP0x7ffd12345670 00400000-00401000 r-xp 00000000 08:01 9876543 /tmp/crash_demo 00601000-00602000 r--p 00001000 08:01 9876543 /tmp/crash_demo 00602000-00603000 rw-p 00002000 08:01 9876543 /tmp/crash_demo没有 handler 的话也可以靠dmesg拿到同一份信息。下面我用RIP0x4005f3继续。4.3 换算并用 addr2line 锁定代码行RIP 落在这一行00400000-00401000 r-xp 00000000 08:01 9876543 /tmp/crash_demovm_start 0x400000map_offset 0x0rip 0x4005f3这个文件是-no-pie所以公式算出来elf_vma 0x4005f3 - 0x400000 0x0 0x5f3注意对非 PIE 文件addr2line传完整虚拟地址0x4005f3也是可以的但如果换成 PIE 文件头脑里必须维持“先算模块内地址”的流程。这里为了统一演示我直接用0x4005f3喂给addr2lineaddr2line -e /tmp/crash_demo -f -C 0x4005f3输出parse_and_get crash_demo.c:8一下子定位到了第 8 行return arr[idx];。到这里问题范围从整个程序缩小到了函数和行基本能判断是下标越界。4.4 汇编逐条核对看崩溃指令到底在干嘛光有行号还不够再看一眼汇编确认到底是读取越界地址导致的。用 objdump 控制地址范围objdump -d --start-address0x4005d0 --stop-address0x400610 /tmp/crash_demo输出大概是00000000004005c0 parse_and_get: 4005c0: 55 push %rbp 4005c1: 48 89 e5 mov %rsp,%rbp 4005c4: 48 89 7d f8 mov %rdi,-0x8(%rbp) 4005c8: 89 75 f4 mov %esi,-0xc(%rbp) 4005cb: 8b 45 f4 mov -0xc(%rbp),%eax 4005ce: 48 98 cltq 4005d0: 8b 44 85 f8 mov -0x8(%rbp,%rax,4),%eax 4005d4: 5d pop %rbp 4005d5: c3 ret崩溃点在4005d0这条mov -0x8(%rbp,%rax,4),%eax。它从rbp-8 rax*4地址去取 4 字节。rax来自下标8那实际地址是rbp-832已经跑到函数栈帧之外很可能落在未映射页上。这和addr2line的结论完全吻合。把范围和源码一一对应起来你就能很自信地在事故报告中写崩溃发生在parse_and_get()对数组下标未做边界检查时RIP 命中的指令访问了非法栈地址。5. 符号被 strip 之后怎么在汇编里捞人5.1 符号表不是全灭了.dynsym发布版二进制经常被strip掉.symtab所以objdump -d里看不到函数名addr2line返回问号。但二进制的动态符号表.dynsym通常还会保留因为动态链接器还要靠它解析外部符号。这意味着一部分导出函数仍然能搜到名字只是它们没有调试行号。用nm -D --defined-only /usr/lib/libfoo.so.1 objdump -T /usr/lib/libfoo.so.1 | grep -i FUNCobjdump -T输出里的地址是 ELF VMA 或运行时地址它显示的是文件内符号地址和addr2line的输入体系一致。比如0000000000023620 w DF .text 000000000000015 some_function如果我们的elf_vma落在0x23620到0x23720之间就能确定它属于some_function。这个函数边界信息在没有任何调试符号的情况下已经足够把崩溃话题从“神秘地址”提升到“某个 export 函数”。5.2 用 objdump -T / nm -D 圈函数边界把动态符号导出后按地址排序再结合objdump -d的反汇编画出一张“符号名 ↔ 地址范围”的草图。具体操作很简单nm -D --defined-only libfoo.so.1 | sort symbols.txt哪个符号的起始地址不超过elf_vma且下一个符号的起始地址大于elf_vma那崩溃就归它管。符号稀疏的部分可能夹着 static 函数就只能靠反汇编往下找。5.3 没导出符号时的汇编逆推技巧static 函数在 strip 之后彻底失去符号名但汇编序言是有规律的。看到这样一串55 push %rbp 48 89 e5 mov %rsp,%rbp 48 83 ec 20 sub $0x20,%rsp基本可以判定这是一个函数入口。往这个地址之前找往往能看到一个call指令或者前一个函数的ret。以ret作为边界往上回退到最近的call目标就能大致推算函数起始位置。更稳的做法是在崩溃 handler 里同时记录返回地址调用者的地址也就是栈上rsp指向的返回地址。这个返回地址必然位于某个函数的call指令后一条指令位置顺着它反查可以确定是谁调用了崩溃函数。我个人会在 handler 里把栈顶的 16 个 8 字节内容一并 dump 出来现场恢复度能高不少。6. 常见问题与排查技巧实录6.1 崩溃地址在 maps 里找不到行如果 RIP 不在任何 maps 区间内可能原因有三个地址被改过了比如函数指针被踩坏后跳飞、RIP 指向了不存在的特权地址、或者映射信息保存不完整。这种情况先查si_addr和error标志再重新走一遍输入确认没有拿错线程的 maps 或拿错崩溃时刻的日志。6.2 addr2line 定位结果全是问号优先查这些现象排查方向输出??该二进制没有调试符号或elf_vma换算出错函数名是??但行号有.symtab被 strip只剩.debug_line行号完全对不上线上二进制和本地源版本不一致重新拿线上文件遇到 PIE 或共享库时最容易出错的就是没减基址直接拿运行时地址去addr2line。先跑一遍elf_vma公式再喂地址。6.3 线上文件和本地文件对不上生产环境跑的程序本地不一定有一模一样的构建。这时addr2line可能定位到完全错误的行。解决办法是用 ELF 的 Build ID 做强校验readelf -n /usr/lib/libfoo.so.1 | grep Build ID再把崩溃现场用/proc/pid/maps里记录的文件路径原样拷贝回来。只要有 Build ID 一致的文件即使代码版本差距再大汇编层面的地址映射也是可信的。6.4 别忘了保存现场“四件套”我现在的习惯是不管有没有 core先让服务把这些东西完整吐出来signal、si_addr、RIP、RSP 等寄存器现场/proc/self/maps完整内容进程优先级、cwd、环境变量各模块的 Build ID这四样东西放在一起哪怕没有任何调试工具也能在事后慢慢还原崩溃链。很多看起来“无法复现”的偶发崩溃攒上几次现场数据模式自然会浮出来。最后再分享一个实用小技巧如果服务器上连addr2line和objdump都没有但又不能随便装包可以用gdb的批处理模式代替一部分反汇编功能gdb -batch -ex file ./libfoo.so.1 -ex x/20i 0x236240只要二进制的符号表没彻底被 stripx/20i也能给出地址附近的指令序列。工具选择很多但核心思路永远是那三步拿到崩溃地址用 maps 换算成模块内地址再反汇编去还原现场。这套流程救过我很多次没有 core 的紧急事故希望你也能在关键时刻派上用场。