大家好,我是杂烩君。分享一个很久之前遇到的段错误问题定位实录。
你有没有遇到过这种情况——程序跑了几天好好的,换个位置就段错误死机,屏蔽两行三角函数的代码就正常,打开就必崩?
更离谱的是,问题根源和那两行代码毫无关系。
这个bug让我排查了几天,过程一波三折,最终的真凶竟然是一个"隔山打牛"式的数组越界。下面完整复盘整个排查过程,希望对你有所帮助。
本文你将看到:
为什么打印法定位段错误可能把你带偏
远程GDB如何快速锁定崩溃行
如何在Linux下找到static变量的内存邻居
一个隐蔽的数组越界如何"隔山打牛"导致段错误
-fdata-sections编译参数的妙用
背景
某个进程负责数据解析处理和算法融合,数据来源是GPS模块,我负责这个程序的开发维护及与算法对接。
机器之前一直正常在跑,但后来做了一些特殊测试,发现机器走到某个位置之后基本上必会出现段错误。因为与位置相关的就是数据,所以刚开始我怀疑可能是数据解析出了问题。
但之前解析经过长时间测试没什么问题,特殊位置也测过,暂时排除了数据解析的嫌疑。
整个排查过程如下图所示:
定位问题
遇到死机问题,当然得先定位,才能分析和解决。定位段错误的方法有很多,我依次尝试了以下两种。
1、log打印定位
最直接的方式——把所有打印调试信息打开,看程序挂在哪里。
打印法基本能定位到大多数问题,但不要对结果抱有太大希望。针对本次bug,打印法的结果反而给我带来了迷惑:
-
- 锁定到了某个算法函数里的
两行三角函数算式
- 屏蔽掉这两行,程序正常;打开就必崩这让我的注意力完全集中在了这个地方
但反反复复看了好多次,没发现这两行算式有什么不妥,前后两层函数也没有异常。后来证明,这里确实不是问题的根源,但我却在这浪费了很多时间。
经验教训: 打印法有时只能看到表象。对于藏得深的bug,表象往往和根因相隔甚远。分析时要保持头脑清醒,遇到不合理的地方要不断推敲、不断推翻。
2、远程GDB调试
远程GDB是嵌入式系统上非常实用的调试手段,使用目标机端的gdbserver和主机端的gdb调试器协同工作,再搭配VSCode可以很方便地进行调试。
远程GDB的原理是:
有一小段驻留在目标机上的代码,称为调试桩(调试代理)。它负责执行主机调试器发送过来的调试命令(读写内存、读写寄存器、设置断点、运行程序等),并向主机报告目标机上的异常事件。
启动远程调试,全速运行,当段错误发生时可以精确知道崩溃的代码行号。本次bug正是用这种方法快速定位出来的。
除此之外,还有strace工具跟踪、gdb调试core文件等方法。
分析、解决问题
fd值异常——谁篡改了它?
通过远程GDB,快速定位到段错误出现在一个串口读函数里的这一行:
FD_SET(fd, &fs_read);
打印发现,出现段错误时fd的值是一个很大的数,显然不对。
在Linux中,一个进程默认可以打开的文件数为1024个,fd的合法范围为0~1023(可通过ulimit修改上限)。
我们代码里的fd是一个静态全局变量,它为何突然变成了异常值?分析下来有两种可能:
串口操作不当:没有正确打开/关闭,或多个线程、进程操作了同一个串口
fd的值被非法篡改了
排查后发现代码中串口操作都比较规范,所以第二种可能性更大。于是开始着手确认fd在内存中的相邻变量。
map文件与static变量的困境
查看内存布局,最直接的方法是通过map文件。在CMakeLists.txt中生成map文件:
set(CMAKE_EXE_LINKER_FLAGS "-Wl,-Map=output.map")
但问题来了——Linux下的map文件默认不显示static变量的地址信息。
为了验证猜想,我先把fd前面的static去掉,此时确实可以在map文件中找到它的地址。但去掉static后再测试,段错误竟然消失了!
这反而更加印证了fd被篡改的猜测:去掉static后,fd存放的内存区域发生了变化,越界写入的位置不再是fd,所以暂时不崩了。
所以,要找到根因必须把static加回去,再找fd的内存邻居。可static变量的地址又不在map文件里,怎么办?
当时没什么好办法,网上也查了,相关资料很少。只能把整个工程的static变量的地址逐个打印出来——纯体力活。
还好工程不算复杂,核心文件就那么几个,但查找过程也耗费了不少时间。
真凶浮出水面——数组越界
最后锁定了某个源文件,依次把其中的static变量的值和地址打印出来。终于,找到了fd的前一个变量——一个int类型的cnt计数变量:
查到cnt的地址正好是fd的前一个地址时,别提多开心了。但检查代码发现,cnt除了忘了清零之外,没有其它异常操作。不清零顶多上溢,似乎不会影响到后面的fd。
这时候又陷入了沉思——莫非方向又错了?
下班后饿着肚子想了一个多小时,突然灵光一闪:程序才运行一小会,cnt竟然已经是一个十位数?再往前多看几个相邻变量,值也全是十位数的大数!
一切豁然开朗——这不是单个变量的问题,而是一块连续内存被批量篡改了,一定是某处数组越界写操作!
顺着这个思路检查代码,果然,在一个算法函数里发现了真凶。内存布局如下所示:
一个含有5个元素的float数组arr,因为逻辑设计不当,实际对arr[5]、arr[6]、arr[7]、arr[8]都进行了写操作。而**arr[8]的地址正好是fd的地址**,写入操作直接篡改了fd的值,导致段错误死机。
到这一步一切都说通了:之前打印调试时屏蔽掉的那两行三角函数算式,与这个越界的算法函数有调用关系——一层套一层,屏蔽掉算式后越界路径不被触发,所以表面上"正常"了。
让static变量现身map文件的方法
后来,复盘发现更快速的方式。Linux下static变量地址输出到map文件的方法——在CMakeLists.txt中加入:
set(CMAKE_C_FLAGS "-fdata-sections")
set(CMAKE_CXX_FLAGS "-fdata-sections")
-fdata-sections会让编译器把每个变量放到独立的section中,这样链接器生成map文件时就会包含static变量的地址信息。
如果当时能用这种方法,查找fd的相邻变量就轻松多了,省去大量体力活。强烈建议大家在嵌入式Linux项目中默认加上这个编译参数。
总结
这次bug从定位、分析到找到根因,耗费了几天的时间。回顾整个过程,有几点深刻的体会:
1. 表象≠根因
通过屏蔽/打开某些代码来定位问题时,看到的可能只是bug的表象。本次屏蔽三角函数算式后"不崩了",其实只是切断了越界的触发路径,并没有解决根本问题。要多思考"为什么",从根本上解决。
2. 选对工具事半功倍
打印法虽然常用,但遇到内存踩踏类bug时容易把人带偏。远程GDB能精确定位崩溃点,map文件能帮助分析内存布局,都是更高效的手段。
3. 别忘了现代化工具
事后复盘,如果当时编译时加上-fsanitize=address,AddressSanitizer会直接报出数组越界的精确位置和调用栈,可能10分钟就能定位到根因。这是对付内存类bug的"大杀器"。
这个bug是在非 AI 时代遇到的。如果放到现在,最好的定位工具可能就是AI,可以与AI结对分析,可以让AI去分析日志、分析可能原因、检查可疑的代码/模块看代码是否存在缺陷,这个问题我觉得借助AI,应该是可以很快速定位出来的。另外,这类有明显代码缺陷的代码,在代码入库的之前,AI review分分钟就检测出来了。
除此之外,还有如下排查软错误的工具(建议收藏):
| 工具/方法 | 适用场景 | 优势 |
|---|---|---|
| log打印法 | 快速初筛 | 简单直接,零成本 |
| 远程GDB (gdbserver) | 精确定位崩溃行 | 可看调用栈和变量值 |
| core dump + gdb | 事后分析 | 不需要复现,离线分析 |
| strace | 系统调用级跟踪 | 排查文件/网络/权限问题 |
| AddressSanitizer | 内存越界/溢出/UAF | 编译期插桩,精确到行 |
map文件 + -fdata-sections |
分析变量内存布局 | 查看static变量地址 |
虽然这次"浪费"了不少时间,但这些经验是实打实的积累。下次再遇到类似的bug,就能快速挖出来了。
Linux下的嵌入式开发坑很多,多掌握一些调试工具和方法,关键时刻能救命。
361