
ROS节点跑着跑着突然没了终端上丢下一行process has died [pid 20083, exit code -11, cmd /home/...]然后就什么都不剩——这大概是每个做机器人开发的人都遇到过的场景。exit code -11 对应的就是段错误也就是进程收到了 SIGSEGV 信号被内核直接干掉。这篇文章不讲空泛的理论只聊一件事当 Linux 下的 ROS 程序因为段错误崩溃时怎么用 GDB 配合 core dump 把现场还原出来找到那行真正闯祸的代码。内容适合已经能跑通 ROS 节点、但一遇到崩溃就只会重启程序的开发者也适合想把调试这套东西系统补齐的嵌入式和控制方向的朋友。1. 从一条崩溃日志读懂 ROS 节点挂掉的现场崩溃日志看着简单其实信息密度不低。很多人扫一眼看到 died 就重启了等于把最宝贵的线索扔了。这一章先把这行日志拆开看后面所有排查动作都建立在对它的正确理解上。1.1 exit code -11 不是错误码是信号编号绝大多数人第一次看到exit code -11会以为程序返回了 -11 这个错误码。不是的。进程正常退出时返回的是 0 到 255 之间的值负数只有一个含义进程被信号杀掉了。ROS 的 roslaunch 在回收子进程状态时发现子进程是被信号终止的就会把信号编号取负显示出来所以 -11 就是信号 11也就是 SIGSEGV。信号 11 在 Linux 里的名字叫段错误它的触发条件是进程访问了一块没有权限或者根本不存在映射的内存。典型场景有对空指针解引用、数组下标越界写到别的页、释放过的指针又被用了一次、栈被递归撑爆。这几类问题在 C 写的 ROS 节点里都属于高发区尤其是那些自己手写指针管理、又混着回调多线程的模块。顺带提一句如果日志里出现的是 -6那是 SIGABRT多半来自 assert 失败或者 glibc 检测到堆结构被破坏-4 是 SIGILL一般是指令集不匹配。把信号编号和原因对上号能让排查方向立刻收窄一大截。1.2 从 process has died 这行字里抠线索再仔细看那行日志的其余部分。pid 20083是崩溃进程的进程号看起来没什么用但如果系统开了 systemd-coredump后面就是靠这个 pid 去取 core 文件的。cmd /home/...是启动命令行它能告诉你崩溃的是哪个可执行文件路径是不是你预期的那一个——这一步经常能发现跑错二进制的低级问题比如工作空间里有两份同名节点source 了旧的那份环境。还有一个容易被忽略的点roslaunch 输出的这行日志有时会在前面带一段节点自己的 stdout 尾部内容。如果崩溃前最后打印的是某条数据解析日志、某个话题收到消息的记录那基本可以判断崩溃发生在哪个回调附近这对后面 GDB 里选线程、看栈很有帮助。我自己的习惯是只要看到这行日志第一反应不是重启而是先把终端滚回去把崩溃前 30 行输出整段复制到临时文件里。等 core 文件分析到一半卡住的时候回头再看这段日志往往能豁然开朗。1.3 段错误和普通异常的排查思路差别在哪ROS 里异常和段错误是两种完全不同的东西。异常是程序自己抛出来的栈会正常展开catch 住就能打印详细信息甚至在 ROS 里还能通过日志把what()打出来。段错误没这个待遇它是内核在硬件层面发现越权访问后强杀的栈是被截断的你看到的最后一帧就是闯祸的那一帧。这就决定了排查手段不同异常可以靠日志和 try-catch 定位段错误基本只能靠调试器还原现场。所以遇到段错误别指望加几行 ROS_INFO 就能找到问题该上 GDB 就得上该配 core dump 就得配。很多新手在这个环节反复加日志加了半天啥也没多出来就是因为方向从一开始就选错了。2. 让 core 文件真正落地几个开关必须提前打开GDB 分析的前提是有东西可分析。core dump 就是进程崩溃那一刻的内存快照包含寄存器、栈、堆、映射表。问题是 Linux 默认往往不生成它所以很多人第一步就卡在根本找不到 core 文件。2.1 为什么你在目录里死活找不到 corecore dump 是否生成由两件事决定当前进程的资源限制和内核的 core 文件命名规则。资源限制这一项很多发行版默认设成 0意思是不允许生成 core 文件。你可以直接看ulimit -c如果输出 0那就是关着的。临时打开ulimit -c unlimited但这里有个大坑ulimit是 shell 级别的属性只对当前 shell 及其派生的子进程生效。你在这个终端里ulimit -c unlimited然后另外开一个终端跑节点那节点还是受它自己那个 shell 的限制core 照样不生成。所以必须在同一个终端里先设限制、再启动 ROS。另一个坑是 sudo。用sudo启动的进程它的资源限制来自 root 的环境跟你当前用户设的无关。跨用户调试时这一点特别容易让人怀疑人生。2.2 ulimit 之外core_pattern 决定文件去哪了资源限制打开之后还有第二道关卡内核参数kernel.core_pattern。它规定了 core 文件写到哪、叫什么名字。查看方式cat /proc/sys/kernel/core_pattern如果输出的是一个以竖线开头的字符串比如|/usr/share/apport/apport ...或者|/lib/systemd/systemd-coredump ...说明 core 文件被一个用户态程序接管了不会以普通文件的形式出现在当前目录而是被塞进系统统一管理的目录里。这就是为什么很多人明明开了 ulimit还是找不到 core 文件。想让它老老实实落在当前目录可以临时改成echo core.%e.%p | sudo tee /proc/sys/kernel/core_pattern%e是可执行文件名%p是 pid这样每个崩溃都会生成一个名字明确的文件不会互相覆盖。这个修改重启后失效需要长期生效就写进/etc/sysctl.conf里加一行kernel.core_patterncore.%e.%p再执行sudo sysctl -p。提示改了 core_pattern 之后最好做个验证随手运行一个会崩的小程序确认目录里真的出现了 core 文件再回去跑 ROS 节点。不然等崩溃发生了才发现没配好就得重新折腾一次复现。2.3 systemd 接管后的另一条路coredumpctlUbuntu 20.04 以后的系统默认用 systemd-coredump 接管 core 文件这其实是好事。你不用改任何内核参数崩溃之后直接coredumpctl list它会列出一串历史崩溃记录包含时间和 pid。找到你那个 pid 20083然后coredumpctl gdb 20083GDB 会自动加载对应的可执行文件和 core直接把你送到崩溃现场。全自动不需要手动回忆二进制路径。这个命令我认为是近几年 Linux 调试体验提升最明显的一处强烈建议先试它行不通再回去折腾 core_pattern。coredumpctl info 20083还能看摘要信息包括崩溃时的信号、命令行、以及部分栈回溯有时候光看这个就能定位到大致模块。2.4 一个容易忽略的前置条件二进制得带调试符号core 文件里存的是地址GDB 要把它翻译成函数名、行号靠的是可执行文件里的调试信息。如果编译时没加-g你看到的栈就全是??和十六进制地址等于拿着一堆乱码破案。ROS 的 catkin 工作空间里默认编译类型如果是 Release 或者没开调试信息就会遇到这种情况。解决办法是在CMakeLists.txt里明确加上set(CMAKE_BUILD_TYPE Debug) add_compile_options(-g -O0)或者在catkin_make时指定catkin_make -DCMAKE_BUILD_TYPEDebug-O0的好处是变量不会被优化掉你在 GDB 里print局部变量时能看到真实值。开了-O2之后编译器会把很多变量优化进寄存器甚至直接消掉打印出来是已被优化的提示排查体验会差很多。生产环境当然是 Release但复现阶段请务必切回 Debug。3. GDB 上场从崩溃栈一路挖到根因现在 core 有了符号也有了接下来是把现场读出来。GDB 的命令不算多但用对时机比背命令更重要。3.1 加载 core 之后最先敲的几条命令手动加载的方式是gdb /home/user/catkin_ws/devel/lib/your_pkg/your_node core.20083进去之后第一件事就是看调用栈btbt打印的是崩溃瞬间的函数调用链最上面那几帧是最后的动作。如果bt显示的第一帧不是你的代码而是 libc 或者某个第三方库那说明问题出在库内部但触发点往往在更下层的你的代码里这时候要看完整栈bt fullbt full会连带打印每一帧的局部变量信息量大得多尤其是看到某个指针是0x0或者某个size是负数的时候基本就锁定了。接着看是谁调用的、参数是什么frame 2 info args info localsframe N切换到第 N 帧info args看函数入参info locals看局部变量。我通常从崩溃帧往上逐层看找到第一帧参数明显不对的地方那就是问题的起点。如果崩溃发生在别的线程还得先看线程列表info threads thread apply all btthread apply all bt会把所有线程的栈都打出来多线程崩溃时这是必敲的一条。哪个线程在等着锁、哪个线程在跑回调一目了然。3.2 段错误的四张典型面孔根据我这些年修过的节点段错误基本逃不出这几类对着栈的形状就能大致分类。崩溃形态栈的典型特征常见成因修复方向空指针解引用崩溃帧参数为 0x0消息指针未判空、回调收到空 shared_ptr加判空改用引用或值传递数组/容器越界崩溃在 memcpy、operator[] 附近下标巨大或为负点云尺寸不匹配、索引计算错误检查 size用 at() 或范围判断悬垂指针崩溃位置和释放位置距离很远对象内存已被覆盖异步回调捕获了已析构对象用 shared_ptr 加 weak_ptr注意生命周期栈溢出栈帧极多、递归很深递归解析、回调自触发改迭代加深度上限拿空指针举例ROS 里订阅话题的回调经常写成void cb(const Msg::ConstPtr msg)如果上游发了一帧空消息某些序列化实现会给出空指针。你在回调里直接msg-field就炸了。这种问题在 GDB 里表现为崩溃帧的msg变量是0x0一看就明白。越界则更隐蔽一些尤其涉及 PCL 点云或者图像数据的width * height * channels计算时一个 int 溢出就能让下标变成垃圾值然后写到不该写的地址。GDB 里看到访问地址是个很奇怪的巨大数值就要往这个方向想。3.3 ROS 节点里最容易出事的几个位置结合 ROS 的架构我总结了几处高发地带你可以对照自己的节点看看有没有踩。第一是回调函数里的共享状态。ROS 默认单线程 spinner但如果用了AsyncSpinner或者MultiThreadedSpinner多个回调会并发进来共同读写同一个成员变量而你没加锁数据竞争就会导致内存被写坏最终以段错误的形式爆出来。这种崩溃往往看起来很随机换个时间跑又不崩了特征非常明显。第二是TF 和坐标变换。lookupTransform抛异常是常规操作但有些人为了图快直接拿裸指针去操作变换矩阵或者在回调里缓存了tf::Transform对象的地址等真正的数据更新了指针指向的内容已经被覆盖。第三是消息自定义库的 ABI 混用。这个坑很典型工作空间里有两套版本的同一个消息包编译节点时链接的是 A 版运行时加载的是 B 版结构体布局有细微差别读字段时就错位了轻则数据不对重则直接段错误。表现是代码看起来完全没问题但一跑就崩。排查方法是ldd看链接的库路径确认LD_LIBRARY_PATH和环境 source 的顺序对不对。第四是第三方驱动库。相机驱动、雷达驱动、串口库这些往往带着自己的线程和缓冲区。如果它们的回调在你的对象析构之后才被调用就是标准的悬垂指针场景。这类问题建议在析构函数里显式停掉驱动、断开连接别指望析构顺序。3.4 多线程崩溃时怎么锁定真凶多线程段错误最头疼的地方在于崩的那个线程往往是无辜的真凶是另一个线程写坏了它的数据。这时候thread apply all bt只能看到谁在崩看不到谁在改。我的做法是分两步。第一步先用info threads看所有线程状态找到处于运行态或者持锁状态的线程重点看它们的栈。第二步是在可疑的共享变量上做文章用 GDB 的硬件观察点watch -l your_shared_var程序再跑起来一旦这个变量被改写GDB 立刻中断并告诉你改它的线程和代码位置。这个手段对定位数据竞争特别好用缺点是观察点数量有限一般 4 个得挑最关键的下。如果怀疑是锁的问题可以看thread apply all bt然后找有没有多个线程卡在pthread_mutex_lock上那可能是死锁或者锁顺序不当。死锁本身不会产生段错误但会导致节点卡住不响应很多人口中的崩溃其实是死锁被看门狗杀掉了得区分开。4. 抓不到 core 时的迂回战术直接挂调试器理想情况下 core dump 一条龙搞定。但现实里总有奇怪的环境让你拿不到 core比如容器内、远程设备、或者复现概率极低。这时候换思路直接在进程运行时挂调试器。4.1 gdb --args 手动把节点拉起来最直接的方式gdb --args /home/user/catkin_ws/devel/lib/your_pkg/your_node __name:my_node注意后面那些__name:、_param:value之类的 ROS 参数照样能传它们就是普通命令行参数。进去以后敲run节点就正常跑起来了参数、话题、服务全部照旧。崩溃的那一刻 GDB 会立刻接管你直接bt就行连 core 文件都不需要。这个方式的代价是节点跑在 GDB 里性能会打折扣实时性要求高的控制节点可能有影响。所以更适合离线复现和功能验证不适合长期在线运行。4.2 在 roslaunch 里塞 launch-prefix如果节点必须由 roslaunch 启动别手动去改命令行直接用launch-prefixnode pkgyour_pkg typeyour_node namemy_node launch-prefixgdb -ex run --args outputscreen/launch-prefix会在原有命令前面拼上这一串效果等同于手工在 GDB 里运行。-ex run表示启动后自动执行 run 命令省得你还要手动敲。整个 launch 文件的其他节点照常工作只有这个节点被调试器接管。如果崩溃时你想要交互式操作可以把xterm加上launch-prefixxterm -e gdb -ex run --args这样会弹出一个独立终端跑 GDB崩溃后你能在里面慢慢敲命令主终端不被占住。远程开发时这个组合很好用。注意launch-prefix里的命令必须能在 PATH 里找到某些精简系统没装 gdb或者装在了非标准路径这时写全路径就行。4.3 编译期武器ASan 和 Valgrind 怎么选有些段错误太隐蔽core dump 里的栈看起来很干净查不出所以然。这时候需要把编译器武装起来。AddressSanitizerASan是最推荐的一招。只要在编译选项里加上catkin_make -DCMAKE_BUILD_TYPEDebug -DCMAKE_CXX_FLAGS-fsanitizeaddress -g -O1然后正常跑节点一旦发生越界、use-after-free、栈溢出ASan 会在崩溃点直接打印出完整报告包含出错地址、访问类型、以及这块内存是在哪里分配、哪里释放的。信息量比裸 core dump 大得多而且不需要你手动分析。ASan 的代价是内存占用会涨几倍速度慢一些某些第三方库还可能产生误报。所以它是复现阶段的神器不能长期挂在生产编译里。Valgrind是另一个选择不需要重新编译valgrind --toolmemcheck --leak-checkfull your_node它通过动态插桩监控所有内存操作能抓到未初始化读取、非法访问、内存泄漏。缺点是慢通常是十倍以上的性能损失跑 ROS 节点时会明显感觉到消息延迟。适合短时间、小数据量的复现不适合实时控制链路。我的选择逻辑是能复现的段错误优先 ASan因为它快且报告清晰找不到复现路径的内存问题比如运行几小时才崩一次用 Valgrind 长时间挂着抓。5. 常见问题速查与踩坑记录前面讲的是方法论这一章全是实操里磨出来的东西。很多问题文档里不会写但你一定会遇到。5.1 排查速查表现象最可能的原因处理动作找不到 core 文件ulimit 为 0 或被 core_pattern 接管同终端设 ulimit或改用 coredumpctlcore 生成在意外目录core_pattern 含路径模板检查/proc/sys/kernel/core_patternbt 全是 ??二进制缺调试符号重新用 Debug 编译变量显示已优化编译开了 -O2切 -O0 重新编译sudo 跑的进程无 coreroot 的 ulimit 限制单独给 root 设限制或改 systemd 配置崩溃随机、无法复现多线程数据竞争用观察点或 ASan 定位GDB 里 run 又崩不出来环境变量不一致在 GDB 里set environment补齐换了台机器就正常库版本或 ABI 不一致用 ldd 对比依赖统一环境5.2 几个我亲自踩过的坑第一个坑是ulimit 设了但没生效。当时我在一个终端设了 unlimited然后在另一个已经打开的终端跑节点怎么都不出 core。折腾了半小时才反应过来是 shell 会话的问题。后来我养成习惯启动任何要调试的节点之前都在同一个终端里先ulimit -c unlimited再敲命令形成肌肉记忆。第二个坑是core 文件被同名覆盖。早期我用默认的core命名连续崩几次之后发现只有最后一个文件前面几次的现场全没了。改成core.%e.%p之后再也没有这个问题而且文件名一眼能看出是哪个进程崩的。第三个坑是远程设备上磁盘写满。设备上跑了一个高频话题崩溃时 core 文件几百兆直接把根分区写满系统跟着一起挂。后来我学乖了先把 core_pattern 指到外挂存储或者大分区上或者在limits.conf里限制 core 大小core项设一个上限值避免一个 dump 把设备搞死。第四个坑是用 strip 过的二进制去分析 core。发布版本为了让体积小往往 strip 掉符号。等线上崩溃了想分析bt出来一片??只能重新拿带符号的同版本二进制去匹配。所以我现在会在发布时同时保留一份xxx.debug的符号文件用 build-id 对上就行这一步省下来的时间远比存储成本值。5.3 环境差异带来的假象同样的代码在开发机和机器人上表现不一致是调试里最气人的情况。常见差异点有几个。一是编译器版本。开发机上是 GCC 9机器人上是 GCC 7某些标准库的实现细节不同未定义行为在两边的表现可能完全不同——一边正常跑一边段错误。这种问题不要试图改代码绕过正确做法是把开发环境对齐到目标平台用容器或者同版本工具链编译。二是ROS 版本。Noetic 和 Humble 之间的 API 有大量变化尤其是消息、参数、节点接口。跨版本移植时如果混用了两套头文件结构体布局不一致同样会段错误。编译器的警告在这里特别有用-Wall -Wextra一定要开很多类型不匹配它会直接报警。三是架构差异。x86 上一切正常上到 ARM 板子就崩除了字节序之外更常见的是对齐要求不同。有些代码用reinterpret_cast强行把一段字节当结构体读在 x86 上不对齐也能跑到 ARM 上直接触发硬件异常。这类段错误的栈通常指向一个看起来很无辜的读取操作看到就要往对齐方向查。我自己现在的做法是任何要上实机的代码先在目标架构上跑一遍 ASan 版本把能抓的问题提前抓掉。多花半小时编译省的是现场排查的一整天。最后分享一个实际体会段错误排查这件事工具只占三成另外七成是现场保护的意识。看到崩溃先别急着重启先确认 core 有没有落地、符号有没有保留、崩溃前的日志有没有存下来——这三件事做到位绝大多数段错误都能在一次调试里定位到具体的行。真正让人抓狂的从来不是问题难而是现场被自己亲手毁掉了只能靠反复复现去猜。