资讯详情

exit code 139与SIGSEGV段错误定位指南

📅 2026/11/3 20:36:22 | 华诺云谱 👁 阅读
exit code 139与SIGSEGV段错误定位指南
标题里出现Process finished with exit code 139 (interrupted by signal 11: SIGSEGV)这行字绝大多数人的第一反应是去翻最近的代码改动翻半小时又一无所获因为这段报错根本没告诉你错在哪一行。它的名字叫段错误segmentation faultexit code 139 只是它在进程退出状态上的一个投影真正干活的信号是 SIGSEGV编号 11。它意味着进程访问了一块自己没有权限访问、或者压根没有被映射到地址空间的虚拟内存内核在硬件异常处理路径上直接把进程终止掉了。这玩意儿的杀伤力在于崩溃点和真正的错误点经常隔着几万行代码、几十毫秒甚至几个线程你看到的是受害者凶手早就跑了。这篇内容面向所有写 C、C、Rust unsafe 块、Go 的 cgo、Python 的 C 扩展、Java 的 JNI 层、以及做音视频和图像处理的工程人员——只要你碰过裸内存早晚会碰到它。我会把 139 这个数字怎么来的、内核在什么时机发信号、六类高发场景的成因、core dump 和 AddressSanitizer 这套工具链该怎么排兵布阵、以及一次完整的现场还原过程全部拆开讲附上我这些年踩出来的排查顺序和几张速查表能让你在下一次看到它时少走两小时弯路。1. 先把这个报错看明白exit code 139 到底在说什么1.1 139 这个数字是 128 加 11 算出来的在一套常见的类 Unix 系统上shell 拿到的$?遵循一个约定如果进程是正常退出的返回值就是它自己exit()传入的数值0 到 255如果进程是被信号杀死的返回值等于 128 加上信号编号。SIGSEGV 在 x86 和 ARM 架构上的编号就是 11128 加 11 等于 139。这条规则解释了很多看起来很奇怪的数字134 是 128 加 6对应 SIGABRT通常是断言失败或者 C 抛出了没人接的异常136 是 128 加 8对应 SIGFPE整数除以零或者浮点异常137 是 128 加 9对应 SIGKILL最常见的原因是容器里内存超限被系统强制干掉143 是 128 加 15对应 SIGTERM被人正常地停掉了。把这几个数字记下来有个非常实际的好处你在一堆日志里翻的时候139 和 137 的处理方向完全相反137 你要去查内存配额和 OOM139 你要去查指针。搞混了方向查一天也是白查。顺便说一句IDE 里那行Process finished with exit code是运行器把 shell 的退出码原样转述了一遍interrupted by signal 11是它额外补的一句人话翻译这行文字本身不包含任何栈信息别盯着它看。1.2 内核到底在什么时刻发出这个信号很多人误以为 SIGSEGV 是程序执行了非法操作被某个检查程序抓到了其实更准确的理解是CPU 在访问内存时先做地址翻译翻译过程由 MMU 配合页表完成一旦发现目标虚拟地址在当前页表中找不到有效映射或者找到了但权限位不允许这次访问比如往只读页写、从不可执行页取指令CPU 就抛出一个缺页异常陷入内核。内核的缺页处理程序会去查这个地址落在进程的哪一个虚拟内存区域VMA里如果能补页就补上程序继续跑这就是正常的按需调页如果查不到对应区域或者权限压根不匹配、补不了内核就会给这个进程投递一个 SIGSEGV如果进程没有注册处理函数默认动作就是终止并尽可能生成 core dump。所以严格来说段错误不是你写错了代码这个层面的事而是你的访问请求在内核的地址空间账本上对不上号。这个视角很有用它告诉你排查的第一步应该是搞清楚那个非法地址是什么、它该属于谁而不是先去猜代码逻辑哪里错了。内核还会在信号里附带一个si_addr字段记录触发异常的地址以及一个si_code说明原因SEGV_MAPERR表示地址没被映射SEGV_ACCERR表示映射存在但权限不够。这两个值在后面的定位里非常关键MAPERR大概率是空指针或者野指针ACCERR大概率是栈溢出撞上了保护页或者往只读段写了数据。1.3 为什么脚本语言很少遇到本地语言却天天撞上托管运行时把这件事挡在了你够不着的地方。你在 Python 里取列表第 100 个元素而列表只有 3 个解释器会抛 IndexError在 Java 里访问 null 对象JVM 抛 NullPointerException在 Go 里越界运行时 panic 并打印一长串 goroutine 栈。这些语言在每次访问前插了检查或者干脆不允许你拿到裸地址。代价是性能开销收益是你几乎不可能靠一个下标就把整个进程送走。而 C 和 C 把指针原封不动地交给了你p[i]编译出来就是一条基址加偏移的访存指令编译器默认信任你的下标是合法的运行时没有任何人替你验证。Rust 在安全代码里做了借用检查但一旦你写unsafe块、调用外部 C 库、或者用transmute做类型转换这道保护就没了。这块的取舍没有对错音视频解码、图像处理、数据库引擎这些场景需要精确控制内存布局和缓存行为必须有裸指针代价就是你得自己当那个检查员。我见过太多从脚本语言转过来的同学第一次遇到 139 时的困惑——我什么都没干啊。其实你干了只是编译器没吭声。2. 六类高发场景按我的经验命中率大概八成2.1 空指针以及那些看起来不空的指针最经典的一种指针值是 0 或者接近 0 的一个小偏移比如访问一个结构体的成员实际地址是0x0 偏移量比如0x18这个地址在内核看来就是没映射直接SEGV_MAPERR。但真正坑人的不是明晃晃的 NULL而是那些看起来有值、实际上已经失效的指针。结构体被memset清零之后忘记重新赋值函数返回 NULL 但调用方没检查C 里delete之后没置空或者std::move之后又去读源对象对象构造顺序不对基类构造函数里调了虚函数。还有一种我很常见的某个配置读取函数在配置项缺失时返回了一个默认构造的对象引用调用方一路传下去直到某一层真的去解引用它。定位这类问题的技巧是先看si_addr如果落在 0 到 4096 这个页范围内基本可以锁定空指针加偏移再结合反汇编看是哪条访存指令、偏移量是多少倒推回是哪个成员字段。这个倒推过程比读源码快得多因为编译器已经帮你把字段偏移算好了。2.2 越界读写和堆块元数据被踩坏这类最典型的表现是崩溃点离作案现场十万八千里。你写了一个循环上界多算了一个往缓冲区末尾后写了一个字节这个字节恰好落在下一个堆块的头部把记录块大小的字段改了。程序当时一点事没有继续跑了几万行、分配释放了几十次直到某次free或者malloc去读那个已经烂掉的头部才在内部分支里崩掉。这时候栈上全是内存分配器的内部函数你顺着bt看上去一片陌生。图像处理、音频解码这类逐行逐块搬运数据的场景是重灾区因为行跨距stride计算错一次影响的是整片内存。举个例子一张宽度 1920、每像素 3 字节、每行末尾补齐到 64 字节边界的图正确的行跨距是((1920 * 3 63) / 64) * 64 5760如果你按1920 * 3 5760算刚好相等没事但如果宽度是 1919正确结果是((1919*363)/64)*64 5824你按 5757 算每行就少挪 67 字节第二行开始所有数据全部错位写到最后一行的最后几十个字节就会越界。这种错误在单色小图上测不出来一上真实素材就炸非常典型。2.3 释放后使用与重复释放悬挂指针的破坏力比空指针大得多因为空指针必然崩溃你立刻就知道悬挂指针指向的内存可能还没被别人覆盖读出来是旧数据程序正常跑完等你上生产环境、内存压力大了、那块内存被复用了才以最诡异的方式崩掉。多线程场景下更麻烦A 线程刚判断指针非空B 线程就把它释放了这个窗口期可能只有几纳秒。还有一种不太被注意的是重复释放两次free同一块内存第二次进去的时候分配器会读到损坏的块信息通常在分配器自己的链表操作里崩栈上看不到你自己的任何函数。我建议大家养一个习惯释放之后立刻把指针赋成 NULL虽然挡不住跨线程的问题但能干掉相当一部分单线程误用容器类的对象优先用智能指针或者带所有权的封装别到处传裸指针。另外如果你们项目里有大量shared_ptr留个心眼——循环引用不会导致崩溃但会导致泄漏而weak_ptr提升之后忘记检查是否为空就直接变成空指针解引用了。2.4 栈溢出递归太深、栈上大数组、线程栈太小栈溢出报出来的信号有时是 SIGSEGV有时是 SIGBUS看具体踩到哪里。原理是栈空间用完了往下长的时候撞上了内核预留的保护页访问触发SEGV_ACCERR。判断特征很明显si_addr的值和当前栈指针rsp非常接近差不了几 KB。三种常见成因第一种是递归层数失控比如解析一棵畸形嵌套的 JSON 或者 XML深度几万层每层栈帧几百字节几 MB 的栈瞬间就没了第二种是在栈上开了大数组char buf[4 * 1024 * 1024]这种主线程默认栈一般 8MB函数里再来两三个就满了第三种是多线程程序里新建的线程栈设得太小或者用了默认值但那个函数调用链很深。栈大小的账很好算主线程看ulimit -s常见是 8192 KB用线程库创建线程时不指定栈大小多数实现默认也是 8MB但这个默认值可以配有些嵌入式方案会设成 128KB 甚至更小。你拿不准的时候可以临时用调试器看一眼栈帧数量乘以平均帧大小估个数量级。真遇到深递归优先改成显式栈的迭代写法实在改不动就调大线程栈但那只是延缓不是解决。2.5 指针截断、对齐要求和类型双关这一类在 64 位普及的今天依然时不时冒出来。最阴险的是把指针塞进int或者long之外的窄类型里地址高 32 位被截掉运行时解引用一个残缺地址直接MAPERR。编译器的-Wint-conversion、-Wpointer-to-int-cast这类警告一定要当真别一股脑加-w关掉。第二类是对齐某些架构对未对齐的多字节访问零容忍比如在 4 字节边界上放一个double去读在 x86 上可能只是慢一点换到别的架构上直接 SIGBUS 或者 SIGSEGV而这种代码在自己的开发机上跑一万遍都没事一上目标设备就崩排查起来极其痛苦。第三类是类型双关用reinterpret_cast把一个char*强转成一个结构体指针去读如果这块缓冲区实际长度不够、或者布局和编译器实际排出的一致有填充、有对齐读出来的字段全是垃圾后续用它当长度或者下标就变成随机的越界写。我个人的原则是凡是涉及强转指针的地方旁边写一行注释说明布局依据并且用静态断言把sizeof和关键成员的偏移固定住。2.6 跨语言边界和第三方二进制库这类问题排查成本最高因为栈上全是别人的代码。典型场景C 扩展传给宿主语言的回调签名不一致头文件和实际链接的动态库版本对不上结构体布局变了但调用方还按老布局传参静态库和主程序用了不同的编译选项比如一个开了结构体紧凑对齐、一个没开JNI 里把局部引用缓存到全局函数返回后引用失效cgo 里把 Go 的指针传给 C 长期持有Go 的垃圾回收器把对象挪走了或者回收了。这类问题的判断线索是崩溃稳定发生在跨越语言边界之后的第一次内存访问或者稳定发生在某个第三方库的某个函数里而且换一个版本就好了。定位手段上先确认头文件和库的版本、编译参数、ABI 是否一致比直接读汇编高效得多。我踩过一次坑某个第三方库的头文件里结构体末尾有个条件编译的成员我们编译时没定义那个宏库编译时定义了结果每次写最后一个字段都在踩别人的内存现象是随机的最后靠比对双方的编译命令才找到。3. 工具链怎么排兵布阵别一上来就上最重的3.1 第一步永远是保住现场core dump 的开启与落盘位置段错误最宝贵的东西是现场而现场默认往往不保存。你需要确认ulimit -c是不是 0如果是 0进程崩了只留一行报错什么都查不到。临时开启用ulimit -c unlimited但要注意这个设置只对当前 shell 及其子进程有效服务跑在别的会话里就得改它的启动环境。core 文件落在哪里由内核参数决定很多发行版默认把它交给系统级的崩溃收集服务处理你需要在对应目录下找也可以手动把它改成落到当前目录文件名带上进程号和信号编号。容器里更要注意很多基础镜像默认把 core 大小设成 0而且容器内的文件系统是临时的崩完就没了得挂载一个持久目录专门接 core。还有一点容易被忽略如果进程是被 SIGKILL 干掉的就是前面说的 137那是不会产生 core 的只有能捕获的信号才会。另外如果程序自己装了信号处理函数处理完之后调用_exit而不是重新抛出也会导致 core 生成不了。我一般的做法是在测试环境和预发环境全量开 core生产环境按需开并且配好轮转不然磁盘会被撑爆。3.2 调试器读栈我常用的那几个命令拿到 core 之后调试器是第一步不要急着上重武器。加载方式很简单把可执行文件和 core 文件一起喂给调试器它会停到崩溃的那条指令上。我常用的动作有这么几个先bt看调用栈如果栈很长或者有内联用bt full把每一帧的局部变量也带出来用thread apply all bt一次性把全部线程的栈打出来多线程程序里九成的答案是别的线程干了什么info registers看寄存器重点看栈指针和指令指针如果崩溃在一条访存指令上基址寄存器里往往就是那个非法地址p $_siginfo把信号附带的信息打出来能直接看到si_addr和si_codeinfo proc mappings或者读进程的内存映射文件确认那个地址到底该不该有映射实在不行用disassemble看当前函数反汇编对照源码找偏移。这些命令组合起来绝大多数单线程崩溃五分钟内能定位到具体行。有一点要提醒编译时一定要带调试符号至少-g优化等级别开太高-O2会把变量优化掉、把函数内联掉栈看着就断了如果只能在-O2下复现那就用-g -O2一起上接受一部分变量看不到。3.3 AddressSanitizer把事后验尸变成当场抓获调试器解决的是已经崩了崩在哪AddressSanitizer 解决的是还没崩但你已经写错了。它的原理是在编译期给每次内存访问插桩在每块分配的内存周围放红区访问红区立刻报错同时记录分配和释放的调用栈。好处是能抓住那些写越界但没崩的隐患而且报错信息里直接给出这块内存是在哪个函数里分配的、在哪一行被释放的、现在在哪一行被非法访问三份栈一摆案情立刻清楚。用法是在编译和链接时都加上相关开关同时保留帧指针和调试符号不然栈回溯会缺帧。运行时它会带来大概两倍左右的时间开销和三倍左右的内存占用这个代价在测试环境完全可以接受在生产环境就别开了。它还有一个很实用的能力是检测内存泄漏程序退出时把没释放的块和分配栈打出来。我自己的习惯是本地开发和 CI 的单元测试默认开这套检测跑一遍全量用例成本是几分钟收益是每次合并前都能拦住新引入的越界。局限也要知道它抓不到所有问题比如某些跨共享库的边界、被优化掉的访问、以及纯粹的野指针指向一块已经完全不属于你的、但恰好还有映射的地址。3.4 valgrind、系统调用跟踪和系统日志的补位当程序没法重新编译比如拿到的是别人的二进制包或者问题只在特定环境复现就用 valgrind 这类动态分析工具。它不需要重编直接在模拟的 CPU 上跑能报出未初始化读、越界、非法释放、泄漏配合追踪来源的选项还能告诉你这个未初始化的值是从哪儿来的。代价是慢慢一个数量级甚至更多所以只适合小规模复现不适合跑完整业务流。系统调用跟踪工具在另一条线上有用如果崩溃发生在程序启动早期压根没到你的代码那多半是动态链接阶段出了问题跟踪一下加载过程能看到它在找哪个库、找了哪个路径、加载失败还是符号解析失败这种情况报的是加载器错误而不是 139但配套使用能省不少事。系统日志里通常有一条内核打印的段错误记录包含进程名、出错地址、指令指针和栈指针格式紧凑但信息量不小尤其在容器里没开 core 的时候这条日志就是唯一的现场。手段是否需要重新编译能否抓到未崩溃的越界运行开销典型适用场景core dump 调试器否但要有符号否崩溃时才有开销已复现的线上崩溃地址检测插桩是能约 2 倍时间、3 倍内存本地开发、CI 单元测试动态二进制分析工具否能10 倍以上无法重编的第三方二进制系统调用跟踪否否中等启动期崩溃、动态库加载问题内核日志否否无容器内无 core 时的兜底4. 一次完整定位过程的复现4.1 场景描述与最小复现的构造说一个我处理过的模拟案例。某个图像处理 Demo功能是把一批图片按块并行做滤波输入分辨率不固定输出写到一个预分配的大缓冲区。现象是小图正常超过一定宽度就必崩报的就是 139而且崩的位置每次都不太一样有时在滤波函数里有时在写文件的时候。这个崩的位置飘忽是个强信号说明是内存被踩坏而不是逻辑分支错误。第一步不是读代码是构造最小复现固定一张刚好越过临界宽度的图关掉多线程改成单线程顺序处理看还崩不崩。结果单线程也崩但崩得更晚从第三块开始——这就说明问题在块与块之间的地址计算上跟并发无关。第二步把缓冲区长度打印出来和实际写入的最大下标比一比立刻发现写入下标比分配长度多了几十个字节。整个过程没用到任何重型工具靠的是缩小变量、对齐现象这个最朴素的办法我强烈建议你在打开调试器之前先做这一步很多问题到这儿就结束了。4.2 参数计算行跨距和缓冲区长度到底该多少这个案例的根因是一处跨距计算的边界处理。缓冲区按宽度乘以高度乘以通道数分配但每行末尾为了对齐做了补齐实际每行占用字节数是跨距不是宽度乘通道数。而代码里在算最后一行末尾位置时用的是补之前的宽度乘通道数导致最后一块的写入起点比正确位置靠前了一点点写出去的尾部就溢出了。正确的算法是先把单行字节数算出来向上取整到对齐边界再乘以总行数。这里要特别注意乘法溢出的问题宽度、高度、通道数三个数相乘如果都用 32 位有符号整型一张超大图很容易越过 21 亿这个坎结果是负数或者回绕成一个很小的正数malloc一个很小的块然后按真实尺寸往里写那必然是段错误。我的做法是在算尺寸之前先做一次范围检查用无符号 64 位中转超过阈值直接报错返回不要指望后面的代码能兜住。另外malloc(0)的行为是实现相关的某些实现返回非空指针但不可写分配长度算出来是 0 的时候要单独处理。4.3 打开现场先看全部线程再读寄存器复现稳定之后开 core加载进调试器。第一步先把所有线程的栈打印一遍确认崩溃线程是哪一路其他线程当时卡在哪里。这个案例里只有一个线程崩其余线程都在等条件变量说明不是并发踩踏。第二步看崩溃线程的栈帧不长顶层是内存分配器内部的函数往下两帧才是业务代码的写入函数这符合堆被踩坏、在分配时爆发的预期。第三步读寄存器指令指针停在一条访存指令上基址寄存器里的地址落在堆区靠后的位置用内存映射命令确认这个地址确实在堆的范围内但已经超过了这块分配的长度si_code报的是权限/映射相关的那一项。到这一步结论已经很清楚了越界写而且越界幅度不大说明是边界条件处理错误不是指针完全飞掉。第四步反汇编当前函数找到那条指令对应的源码行再看该行用到的下标变量问题一目了然。4.4 修复、加固与回归验证修复本身只有三行把最后一块的起点按跨距重新算加上边界判断把三个尺寸参数改成 64 位无符号并在入口处做溢出校验。但改完不算完得做三件事才算收工。第一件是用地址检测插桩重新编译把全量测试用例跑一遍确认没有新的越界报出来——很多时候你修了一处旁边还埋着两处平时不崩而已。第二件是把导致崩溃的那个宽度、宽度减一、宽度加一三个用例补进回归测试这种边界值是长期资产不补上过两个月又会有人踩。第三件是加一条断言在写入前检查目标偏移加写入长度不超过分配长度断言在发布版本可以关掉但在开发和预发必须开着让下一次同类错误在案发点就炸出来而不是等到几千行之后。做完这三件事这个模块的稳定性会有一个台阶式的提升。5. 常见问题速查与踩坑记录5.1 现象到原因的速查表现象特征高度怀疑的方向优先验证手段崩溃地址接近 0空指针或空指针加成员偏移看si_addr反汇编确认偏移对应哪个字段崩溃地址接近栈指针栈溢出、递归过深、栈上大对象看栈帧数量、检查递归终止条件、查线程栈配置崩在分配器内部函数堆块头被越界写踩坏用插桩工具跑一遍重点查边界下标崩溃位置随机漂移悬挂指针、释放后使用全量开检测插桩重点查释放路径换机器才崩对齐、指令集、字节序、ABI 差异检查编译选项和内存访问的对齐要求只在多线程下崩数据竞争、生命周期管理错误线程检测工具检查共享对象的释放时机只在特定输入崩输入驱动的越界或解析深度用最小输入二分法缩小范围5.2 报了 139 但其实不是内存访问问题的几种情况第一种是递归析构或者超长调用链把栈耗尽表现是崩溃但代码里没有任何指针操作这类本质还是栈溢出只是触发者不是野指针。第二种是运行库版本错配程序在启动阶段或者初始化阶段就崩栈上全是加载器和运行库的帧这时候要看的是动态库搜索路径和版本不是你的代码。第三种是硬件层面的问题内存条有故障某一位随机翻转表现是同一个二进制在别的机器上跑得好好的这台机器随机崩这种问题靠代码查不出来得跑内存检测。第四种是即时编译生成的代码很多虚拟机和某些脚本引擎会在运行时生成机器码再执行生成逻辑有 bug 时崩在动态生成的代码区栈上没有符号只能拿到一坨地址。第五种是内存超限被系统干掉但那通常报 137 而不是 139如果你看到 139说明进程还活着走到了非法访问这一步两者别混。分辨这几类有个通用办法看崩溃是不是稳定复现、是不是跟机器绑定、是不是跟输入绑定这三个维度交叉一下方向基本就定了。5.3 结构性预防让它别在线上第一次出现事后排查再漂亮也不如让它别上线。我现在的项目里固化了几件事。编译期把警告当错误处理指针相关的警告全部打开-Wall -Wextra起步团队里有人嫌吵就单独讨论别整个关掉。CI 里必须有一步用地址检测插桩跑全量单元测试跑不过不许合并这一步拦下的问题比任何代码评审都多。所有涉及内存布局的结构体加静态断言固定大小和关键字段偏移防止某天有人加了个成员没同步更新序列化逻辑。所有从外部拿到的长度、尺寸、索引进入核心逻辑前先做一次范围校验宁可在这里返回错误也不要往下传。对外接口里的缓冲区优先用带长度信息的封装类型别传裸指针加隐式长度。还有一条偏经验性的给每个分配点写清楚归属关系谁分配、谁释放、生命周期到哪儿结束写下来之后你会发现很多悬挂指针在设计阶段就能看出来。这些做法加起来的成本不高但能把线上段错误的概率压下一个数量级。5.4 我自己踩出来的几条硬经验第一条别相信以前一直这么写没出过事内存越界是概率问题输入变了、内存压力变了、编译器版本变了都可能让潜伏三年的问题突然爆发。第二条崩溃点飘忽的时候先关并发再关优化最后才动代码缩小变量比读代码快得多。第三条拿到 core 之后先看内存映射再看栈因为映射关系能立刻告诉你是地址不存在还是地址存在但没权限这两种方向的排查路径完全不同。第四条FindBugs 之外善用编译器的静态分析很多空指针和越界在编译期就能报出来只是默认没开。第五条修完一定要留回归用例段错误这东西最大的特点是会回来你这次花三小时定位的那一行边界下次换个新同事还会再写一遍。第六条遇到怎么都查不出来的把整个场景录成一个脚本用同样的编译器、同样的参数、同样的输入在干净环境里跑环境差异导致的问题占了我遇到的总量的相当一部分。这些都是被折腾出来的肌肉记忆写在这里希望你能少被折腾几次。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑