1. 崩溃定位这件事为什么值得单独拎出来讲设备调试干久了你会发现写代码的时间远没有查崩溃的时间多。尤其是嵌入式设备、后台服务、桌面客户端这类跑在真实环境里的程序一旦挂掉留给你的往往只有一行冷冰冰的提示或者干脆什么都没有设备直接重启了。这时候如果手里没有一套成体系的定位方法就只能靠猜改一版烧一版一天下来问题还在原地打转。我这些年调过的设备五花八门从跑 Linux 的工控板到 Windows 上的采集软件从 C 写的老服务到 Python 写的小工具崩溃的形态千奇百怪但定位的路子其实高度收敛。崩溃定位三步走这个说法我很认同它把一件看起来玄学的事情拆成了可执行的动作先拿到现场再读懂现场最后还原现场。这三步分别对应三个关键词——core、gdb、日志。core 是崩溃瞬间的内存快照gdb 是解剖这份快照的手术刀日志则是把崩溃前后发生的事串起来的时间线。三者配合绝大多数崩溃都能在半小时内锁定到具体函数甚至具体那一行。这篇内容适合谁看如果你正在调设备、写底层代码、维护跑在生产环境里的服务或者你只是被一个“信号11”折腾得睡不着觉那这篇就是写给你的。我不打算讲太多教科书上的定义而是按我实际排查问题的顺序把每一步该做什么、为什么这么做、容易在哪里翻车一条条摊开来说。哪怕你之前没碰过 gdb跟着走一遍也能上手。2. 第一步拿到现场别让崩溃证据白白溜走2.1 先搞清楚“现场”到底是什么程序崩溃的瞬间操作系统其实掌握着全部信息哪个线程在执行、执行到哪条指令、各个寄存器的值、调用栈长什么样、堆和栈里存了什么。问题是这些信息默认只存在于内存里进程一死就全没了。core 文件就是把这些信息在进程终止前转存到磁盘上的产物本质上是一份内存镜像加上寄存器上下文。有了它你就能在事后用调试器把进程“复活”到崩溃那一刻慢慢看。很多人调设备时最大的失误就是根本没让系统生成 core。设备一崩进程没了日志里只有一句“Segmentation fault”然后就没有然后了。所以第一步的核心任务只有一个确保崩溃时能留下 core 文件。这件事在不同系统上的配置方式不一样但思路是一致的——告诉系统“进程异常终止时把内存转储下来存到我能找到的地方”。2.2 Linux 下打开 core 生成的完整操作Linux 默认的 core 行为经常是关闭的尤其是发行版为了省磁盘空间把 core 大小限制设成了 0。你可以先用一条命令确认当前状态ulimit -c如果输出是0说明当前 shell 下不会生成 core。临时打开可以执行ulimit -c unlimited但要注意这个设置只对当前 shell 及其子进程生效而且重启就没了。设备上的服务通常是通过 init 系统或 systemd 拉起来的你得在服务启动的环境里设置而不是在你登录的终端里设。用 systemd 的话可以在 service 文件里加一行[Service] LimitCOREinfinity改完记得systemctl daemon-reload再重启服务。另外 core 文件的命名和存放位置由/proc/sys/kernel/core_pattern决定默认可能只是简单的core会生成在进程的工作目录下很容易被覆盖或者找不到。我一般会改成带进程名、PID 和时间戳的格式echo /var/crash/core.%e.%p.%t /proc/sys/kernel/core_pattern这样每个 core 文件都是唯一的排查时不会搞混。%e是进程名%p是 PID%t是崩溃时间戳。如果设备重启频繁最好把 core 存到独立分区或者挂载的存储上避免把根分区写满导致设备起不来。注意有些嵌入式环境用的是 busyboxcore_pattern的写法可能不支持所有占位符改之前先cat一下确认当前值改完再cat一次确认生效。2.3 Windows 上的对应做法Windows 没有 core 这个概念对应的是 dump 文件。任务管理器里右键进程可以“创建转储文件”但崩溃是瞬时的你来不及手动操作。正确做法是配置系统的崩溃转储策略或者用工具常驻监控。最省事的是改注册表里的LocalDumps项指定某个进程崩溃时自动写 dumpHKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps在这个路径下建一个以进程名命名的子项里面设置DumpFolder和DumpType。DumpType设成 2 表示完整转储信息最全但文件大设成 1 是迷你转储够看调用栈。设备上磁盘紧张的话用迷你转储配合符号文件一样能定位到函数。2.4 一个容易被忽略的坑符号和调试信息core 或 dump 拿到手只是第一步如果编译程序时把调试信息剥掉了你打开 core 看到的调用栈全是地址等于白拿。所以发布给设备的版本最好保留一份带调试信息的二进制或者至少把符号表单独存下来。GCC 编译时加-g保留调试信息发布时用strip剥掉生成正式版本但把带-g的那份按版本号归档。崩溃发生后用带调试信息的二进制去配 core就能看到函数名和行号。我踩过最深的坑就是设备上跑的是 strip 过的版本core 拿回来对着源码怎么都对不上最后只能重新编译一版带-g的靠地址偏移硬算浪费了大半天。从那以后每个发布版本我都会把对应的调试文件单独存一份命名带上版本号和编译时间。3. 第二步读懂现场用 gdb 把崩溃点挖出来3.1 gdb 加载 core 的标准姿势拿到 core 文件后用 gdb 加载的命令格式是固定的gdb /path/to/program /path/to/core第一个参数是崩溃时运行的那个可执行文件第二个是 core 文件。顺序不能反反了 gdb 会认不出来。加载成功后gdb 会打印一堆信息包括崩溃信号、出错的地址、当前的函数调用栈。这时候你最先要看的不是别的就是信号类型。信号 11 就是 SIGSEGV段错误最常见的一类崩溃通常是访问了非法内存地址——空指针、野指针、越界访问都会触发它。信号 6 是 SIGABRT一般是程序自己调了abort()常见于断言失败或者 C 里未捕获的异常。信号 4 是 SIGILL非法指令可能是代码跑到了不该跑的地方或者二进制和 CPU 架构不匹配。信号 8 是 SIGFPE算术异常除零是典型代表。看到信号类型心里对问题方向就有个大概了。3.2 第一眼要看的三样东西加载完 core我习惯按固定顺序看三样东西这个顺序能最快逼近真相。第一样是bt也就是 backtrace调用栈。它告诉你崩溃时程序是怎么一层层调用到出错位置的。看调用栈要从下往上看最下面是入口最上面是崩溃点。如果栈顶是某个库函数比如memcpy、strlen那问题多半出在调用它的那一层——传进去的指针有问题。如果栈顶就是你自己的函数那直接看那一行。第二样是info registers看寄存器。重点看指令指针寄存器x86 上是ripARM 上是pc和栈指针。指令指针告诉你崩溃时 CPU 在执行哪条指令配合x/i $pc可以把那条指令反汇编出来。如果指令是访问内存的再看它用的地址寄存器往往能直接看出访问了哪个非法地址。第三样是frame切换加info locals看崩溃那一帧的局部变量。空指针崩溃时你经常能在这里看到某个指针变量的值是0x0或者一个明显不合理的地址比如0xdeadbeef。这一步是把“崩溃在哪个函数”细化到“哪个变量出了问题”。3.3 调用栈被破坏时怎么办有时候bt打出来的栈是断的或者明显不对比如只有一两层就没了或者函数名全是问号。这通常意味着栈被踩了——缓冲区溢出把返回地址覆盖了或者栈指针被改坏了。这种情况光看bt没用得换个思路。一个办法是看崩溃地址附近的指令用x/20i $pc-40反汇编崩溃点前后的代码结合源码判断程序原本想干什么。另一个办法是检查栈内存用x/40x $sp把栈顶一段内存打出来看看有没有明显的字符串或者被覆盖的痕迹。如果怀疑是某个数组越界可以在崩溃帧里print那个数组的地址和长度再x一下它前后的内存看有没有踩到相邻变量。栈被破坏的崩溃最难查因为它破坏了现场本身。我的经验是这类问题往往不是崩溃点本身造成的而是更早的某个操作埋下的雷。这时候日志就派上用场了得靠日志把崩溃前一段时间的行为还原出来找到那个越界的写操作。3.4 gdb 常用命令速查调 core 的时候下面这些命令基本覆盖了九成场景我整理成表方便对照命令作用使用场景bt打印调用栈加载 core 后第一件事bt full打印调用栈及每帧局部变量需要看各层变量值时frame N切换到第 N 帧栈顶是库函数要看调用层info locals打印当前帧局部变量定位具体是哪个变量出错info registers打印寄存器看指令指针和出错地址x/i $pc反汇编当前指令确认崩溃时执行的是什么x/s ADDR按字符串查看内存看指针指向的内容print VAR打印变量值确认指针是否为空thread apply all bt打印所有线程栈多线程程序崩溃多线程程序崩溃时thread apply all bt特别有用。有时候崩溃的线程只是受害者真正搞破坏的是另一个线程。把所有线程的栈都打出来对比一下哪个线程正在操作共享数据往往能找到竞争条件的线索。4. 第三步还原现场用日志把时间线拼起来4.1 日志不是越多越好而是要有关键信息core 和 gdb 解决的是“崩溃那一刻发生了什么”但很多崩溃的根因在崩溃之前很久就埋下了。比如内存被慢慢写坏直到某次访问才触发段错误比如某个资源没释放累积到一定程度才出问题。这时候光看崩溃瞬间是不够的你需要日志把崩溃前的行为串起来。但日志这东西写少了不够用写多了淹死人。我见过不少项目日志打得密密麻麻真出问题时翻半天找不到重点。有效的崩溃排查日志应该包含几类关键信息时间戳、线程 ID、关键函数的进出、重要变量的值、资源申请和释放的记录。时间戳精度要到毫秒否则多线程下根本对不齐线程 ID 能帮你区分是哪个执行流出的问题关键函数的进出日志能还原调用路径。4.2 崩溃前的日志该怎么看拿到一份崩溃日志不要从头读那样效率太低。正确的做法是从崩溃点往前倒推。先找到日志里最后几条记录那通常就是崩溃前最后的动作。然后往前看找有没有异常的值、失败的返回值、超时的操作。我一般会重点关注这几类记录返回值为错误码或者 NULL 的调用资源申请失败内存分配、文件打开、锁获取超时或者重试数值异常比如长度为 0 却要拷贝、索引为负数这些往往是崩溃的前兆。举个例子如果日志里在崩溃前几秒有一条“malloc 返回 NULL”而代码里没检查就直接用了那崩溃原因基本就锁定了。4.3 把 core 和日志对照起来看单独看 core 或者单独看日志都不如两者对照着看。core 告诉你崩溃在哪一行日志告诉你崩溃前程序在干什么。把崩溃的函数名和行号拿到日志里搜看看这个函数之前被调用了几次、每次的参数是什么往往能发现规律。我调过一个采集设备的崩溃core 显示崩在一个数组写入操作上索引越界。但看代码索引是有边界检查的理论上不会越界。后来把日志翻出来发现崩溃前这个函数被并发调用了两次两个线程同时改了索引变量导致检查通过之后索引又被另一个线程改大了。这就是典型的竞争条件光看 core 只能看到越界结合日志的线程 ID 才能看出并发问题。4.4 日志分级与落盘策略设备上的日志不能一直往磁盘写写满了设备就挂了。所以日志要分级并且要有落盘策略。我的习惯是分四级ERROR 必须落盘且长期保留WARN 落盘但定期清理INFO 只在调试时打开DEBUG 平时关闭。崩溃排查主要靠 ERROR 和 WARN 级别的日志。落盘策略上用环形缓冲或者按大小滚动保证最新的日志一定在。有些设备断电频繁日志还在内存缓冲里没来得及写盘就丢了这种情况可以用带电池的 RTC 或者掉电中断里强制刷盘。如果设备支持把日志通过网络实时发到远端是最稳的本地丢了远端还有。5. 三步走之外几个能救命的实操心得5.1 复现是定位的加速器core 和日志能定位大部分崩溃但有些问题只有在特定条件下才出现比如特定的输入、特定的时序、特定的硬件状态。这时候如果能稳定复现定位效率会成倍提升。复现的关键是把崩溃时的环境变量、输入数据、操作序列都记录下来。我习惯在设备上放一个“复现脚本”把崩溃前的操作步骤写成可重复执行的命令下次直接跑脚本就能重现。复现不了也别死磕先把 core 和日志分析透很多时候分析完就知道触发条件了再针对性构造复现。5.2 别忽视编译器和优化级别的影响同一个 bug在-O0下可能表现正常在-O2下就崩了。这是因为优化会改变指令顺序、复用寄存器、内联函数把原本隐藏的问题暴露出来。如果你在调试版本上复现不了试试用发布版本的优化级别编译一版带-g的往往就能复现。反过来如果发布版本崩了但调试版本不崩也要考虑是不是优化引入的问题比如未定义行为被优化器“合理”地放大了。5.3 常见崩溃类型与排查方向对照信号典型原因排查重点SIGSEGV (11)空指针、野指针、越界看崩溃帧的指针变量和数组索引SIGABRT (6)断言失败、未捕获异常看日志里 abort 前的最后输出SIGFPE (8)除零、整数溢出看算术运算的操作数SIGILL (4)非法指令、架构不匹配确认二进制与 CPU 架构一致SIGBUS (7)内存对齐、映射文件访问看是否访问了未对齐地址这张表我贴在工位上好多年了遇到崩溃先对号入座能省不少瞎猜的时间。5.4 把定位过程本身也记录下来最后分享一个习惯每次定位完一个崩溃我都会把过程简单记一笔——什么现象、怎么拿到的 core、gdb 里看到什么、日志里发现什么、根因是什么、怎么修的。攒多了之后你会发现很多崩溃是同一类问题的不同表现下次遇到类似的直接翻记录就能快速定位。这份记录比任何文档都值钱因为它是你自己踩过的坑。设备调试这行崩溃定位是绕不过去的硬功夫。三步走这个框架之所以好用是因为它把一件复杂的事拆成了三个明确的动作每个动作都有对应的工具和方法。core 拿到手gdb 读明白日志串起来剩下的就是耐心和经验的积累了。