1. 现象:程序跑几分钟后毫无征兆地退出
1.1 第一次复现时,日志停留在最让人迷惑的位置
前阵子调一块嵌入式 Linux 工控板,主程序负责采集外设数据、解析协议帧、定期上报。启动一切正常,业务也能跑,但运行大约五到八分钟后,进程就会退出。由于我提前挂了一个守护进程,程序退出后会被自动拉起,然后过几分钟再次退出,周而复始。起初我还以为是外部看门狗在复位系统,可看门狗复位的话整机都会重启,不会单独让一个用户态进程消失。于是我把日志调到了最细,把所有关键路径都打了点,最终串口上的最后一条日志却停在一句无关紧要的打印上,类似“frame count: 1234”,然后就再也没有后续了。
这种日志停在“正常位置”的崩溃最让人头疼。如果是明确的断言失败、访问空指针,打印一般会停在出错点附近,很容易顺藤摸瓜。可这次没有任何异常打印,程序就像是被什么东西一把掐断了呼吸。
当天我做了几次快速验证:换了一块板子、换了电源、把 SD 卡重新格式化,问题照旧。到了这一步,我基本排除硬件电源波动和存储介质的问题,心里已经清楚,这就是一个典型的嵌入式用户态段错误,只是现场还没抓到。
1.2 内核日志里的段错误线索,错误码怎么读
第一次真正让排查方向明确起来的,其实是一段被很多人忽略的内核日志。程序再次崩溃后,我立刻执行了dmesg | tail -n 30,看到这样一行:
app[213]: segfault at 2a0040 ip 00010850 sp 00e6f8bc error 6 in app[10000+e000]这里最有价值的信息是末尾的error 6。Linux 的段错误错误码是按位定义的,常见组合如下:
| error 值 | 含义 |
|---|---|
| 0 | 读操作,页面不存在 |
| 2 | 写操作,页面不存在 |
| 4 | 读操作,特权级违规 |
| 5 | 读操作,页面不存在且处于用户态 |
| 6 | 写操作,页面不存在且处于用户态 |
| 7 | 写操作,页面不存在且处于用户态,同时触发了保护错误 |
我这次看到的是error 6,说明进程在用户态做了一次写操作,写入的目标地址映射不存在。ip那一列是崩溃时的指令地址,2a0040是要访问的内存地址。我把ip地址随手记下来,当时没觉得它能有什么用,因为我用addr2line去查这个地址的时候,它既不落在任何函数的开头,也不像正常的代码地址,这反而让我更确信程序已经跑到一个被篡改的路径上去了。
从这一刻起,我不再纠结于“为什么日志停在正常位置”,而是开始认真对待“现场”本身。既然内核已经告诉我们访问了非法地址,那当务之急就是把这个非法地址是怎么算出来的追出来。
2. 初查阶段:怀疑对象换了一轮,问题依然存在
2.1 先怀疑内存泄漏:valgrind 在板卡上根本跑不动
遇到内存类问题时,大家第一反应都是内存泄漏。我也不能免俗,立刻想把 valgrind 搬到板子上。结果交叉编译 valgrind 到 ARM 平台本身就折腾了小半天,编译出来的二进制在板子上跑起来之后,业务进程慢得像蜗牛,而且由于业务涉及硬件寄存器操作和外设中断交互,很多路径在 valgrind 的模拟环境下根本走不到,等于白跑。
这里有个经验:valgrind 这类动态二进制插桩工具更适合 x86 环境下的纯逻辑问题,对于嵌入式目标板上的硬件耦合型业务,它的实用性大打折扣。倒不是说完全不能用,而是你得有足够的时间和耐心去筛掉它产生的海量误报。
我挣扎了一会儿之后放弃了 valgrind,改成在关键函数入口出口打印指针值和关键变量的十六进制内容,想靠人工对比找出哪个指针被写坏了。这条路走了一整天,一无所获,因为崩溃点根本不稳定,每次崩溃时的上下文都不一样,日志里看不出统一的规律。
2.2 被“反复退出”误导:一度以为是看门狗或中断冲突
由于进程总是隔几分钟退出,而且守护进程会立刻拉起它,从外部看就像系统在反复重启。我一开始确实怀疑过看门狗,甚至还把驱动里看门狗喂狗线程的优先级调高、调低各测了一轮。后来又怀疑某个中断处理函数和业务线程之间存在竞争,在中断里访问了共享缓冲区导致数据错乱。
为了验证中断竞争,我关掉了两个不相关的外设中断,只保留核心采集中断,程序依旧在几分钟后崩溃。而如果真是中断和线程的竞争问题,大概率会表现为死锁或者硬件异常,而不是干干净净的段错误。到了这一步,我已经确认不是中断的问题。
回头看,这个阶段最大的问题是我在“猜”。每次猜一个原因,就改一次代码验证,改完又得跑几分钟等崩溃,效率极低。而且修改本身会改变内存布局和时序,导致崩溃规律跟着变,进一步扰乱判断。这种无序猜测是排错中最容易走死的路。
2.3 加打印反而更难复现,这里面有门道
另一个让我抓狂的现象是:只要我在代码里多加几行打印,崩溃周期就会明显变长,有时候甚至跑二十分钟都不崩;而一旦我把打印删掉,几分钟内必崩一次。从直觉上看,打印越多越容易暴露问题才对,可结果完全相反。
原因其实不复杂。打印函数本身会占用栈空间,也会影响编译器对局部变量的寄存器分配和栈帧布局。更重要的是,打印产生的系统调用和串口输出改变了程序执行的时间节奏,原本可能被越界写坏的某个变量,恰好因为多打了一行日志,被推到了别的位置,或者暂时没被后续逻辑使用到。
这种“加了打印就不复现”的现象,几乎可以反向确认问题和内存布局强相关。此时我不能再靠打印碰运气了,必须采用能固定崩溃现场的工具手段。
3. 转折:开启 core dump,把崩溃现场固定住
3.1 嵌入式板开 core dump 的三个注意点
在目标板上开启 core dump 的思路很简单,但实际操作有几个坑。第一是ulimit -c必须设置为 unlimited,否则内核不会生成 core 文件;第二是/proc/sys/kernel/core_pattern要指定一个可写的存储路径;第三是嵌入式板子的存储空间往往有限,core 文件可能很大,得做好空间管理。
我当时的做法是把 core 文件重定向到 SD 卡的一个专门目录,并把 core 文件命名加上进程名和 PID,方便区分多次崩溃:
ulimit -c unlimited echo "/home/root/cores/core_%e_%p" > /proc/sys/kernel/core_pattern注意core_pattern一次写入后全局生效,但如果板子重启了,这个配置会丢失。为了保险,我把这几条命令加进了系统的启动脚本里,让它每次开机自动执行。
还需要提醒一点:如果业务进程本身会 fork 子进程,最好检查一下/proc/sys/kernel/core_uses_pid以及其他过滤参数。嵌入式环境里有时候会出现 core 文件生成失败的情况,最常见原因是进程的工作目录不可写,或者core_pattern指定的路径不存在。我在第一次尝试时就因为目录没建好而扑了个空,折腾了半小时才反应过来。
3.2 抓到的 core 文件,用 addr2line 定位到“不可能出错”的函数
开启 core dump 后程序又崩了一次,SD 卡里多出一个十几 MB 的 core 文件。接下来就是熟悉的一套动作:先用交叉编译工具链里的 gdb 简单看一下调用栈,再用addr2line把关键地址翻译成源码行号。
arm-linux-gnueabihf-gdb app core -batch -ex bt arm-linux-gnueabihf-addr2line -e app -f -C 0x00010850结果出来了:崩溃点落在了一个叫parse_frame的函数里,具体是协议解析后处理帧头的某个分支。这个函数是我自己写的,逻辑非常简单,无非是取几个字节、填几个字段、判断一下帧类型。我当时的反应是:不可能,这个函数我看了三遍都没看出毛病。
但 addr2line 不会说谎。它明确告诉我崩溃指令就在那个函数内偏移某处。这让我意识到,问题可能不在parse_frame本身,而是它使用的某个参数或某个全局变量,在被传入之前就已经被写坏了。
3.3 调用栈看不出问题,就反汇编看当时的真实指令
gdb 的 bt 只能看到函数调用链,但嵌入式交叉编译器在 -O2 优化下经常会把栈帧信息压缩得不成样子,bt 的可靠性大打折扣。于是我把目标模块的 .o 文件反汇编出来,直接看崩溃指令附近的机器码。
arm-linux-gnueabihf-objdump -d app > app.asm在parse_frame函数对应的汇编片段里,我找到了崩溃地址附近的内容,看到这样一条指令:
ldr r3, [r2, #4]r2寄存器在这个位置应该保存的是一个结构体指针,后面的[r2, #4]是取该结构体的偏移 4 字节处的字段。问题在于,从寄存器 dump 来看,此时r2的值是0x2a0040,这个地址显然不是任何合理的结构体指针,更像是一个被当成指针使用的普通数据。
这基本实锤了:parse_frame本身没有主动写坏内存,它是被外部调用方传入的一个错误指针坑了。而那个错误指针,可能是从某个数组越界、结构体偏移算错,或者某个被破坏的全局变量里读出来的。到这里,判断维度从“哪个函数崩了”切换到了“谁把数据写坏了”。
4. 真相:越界写了一个字节,毁掉了旁边的结构体
4.1 内存布局:数组和关键结构体的“邻居”关系
为了找出数据被谁写坏,我把目光转向了链接脚本和 map 文件。嵌入式交叉编译生成的.map文件里记录了所有全局变量、静态变量的地址分配。我翻出了app.map,搜索parse_frame依赖的几个全局缓冲区,就看到了一段让我头皮发麻的内存布局:
| 全局符号 | 地址 | 大小 |
|---|---|---|
| rx_buf | 0x00037a20 | 128 字节 |
| g_frame_ctrl | 0x00037aa0 | 88 字节 |
| g_tx_buf | 0x00037af8 | 256 字节 |
rx_buf这个 128 字节的数组,和g_frame_ctrl这个协议控制结构体,在 .bss 段里紧紧挨在一起。两者之间只剩下非常小的填充间隙。如果rx_buf发生越界写入,哪怕只是多写了几个字节,第一个被踩到的就是g_frame_ctrl。
这里要说明一点,全局变量的排列顺序虽然和编译顺序、对齐方式有关,但相邻全局变量在地址上确实有可能紧贴在一起。对于嵌入式这种资源紧张、全局变量司空见惯的场景,数组越界波及“邻居”几乎是必然的,区别只是你什么时候发现而已。
4.2 根因代码:一个边界判断少算了一种情况
顺着g_frame_ctrl的使用路径往上翻,最终在一段从串口缓冲区往rx_buf拷贝数据的代码里找到了真凶。代码大致是这个样子:
static uint8_t rx_buf[128]; void handle_protocol_data(const uint8_t *raw, uint16_t len) { uint16_t idx; for (idx = 0; idx < len; idx++) { rx_buf[idx] = raw[idx]; } /* 后续调用 parse_frame(g_frame_ctrl, rx_buf) */ }看起来平平无奇对不对?问题出在len的来源。handle_protocol_data的调用方解析了一个自定义超长帧格式,其中长度字段是一个uint8_t,也就是最大可以到 255。正常协议约定数据段最多 120 字节,可一旦上游设备在某种异常状态下发了一个长度大于 128 的帧,这个len就超过了rx_buf的容量。
更隐蔽的是,由于协议里长度字段和实际有效数据长度并没有强校验,程序在大多数场景下收到的帧长度都在 100 字节以内,越界情况极少出现,只有碰到那种异常超长帧时才会触发。而触发后多出来的一两个字节,恰好就写进了隔壁的g_frame_ctrl.packet_len字段,把这个字段从正常值改动成一个天文数字。
正因为这种越界是“偶发的、字节数很少的”,它不会立刻让程序崩溃,而是先污染了结构体里的某个字段,等后续代码用这个被污染的长度去执行memcpy或者解析循环时,才真正踩到非法地址,最终产生段错误。这就完美解释了为什么崩溃点飘忽不定,以及为什么parse_frame会拿到一个完全不像样的指针。
4.3 为什么最初 core dump 的崩溃点不在越界代码本身
这个问题其实也是段错误定位中最容易让人绕远路的点。很多人以为,程序崩了就一定是最新执行的几行代码有问题。但实际上,内存越界是一种“延迟触发”型错误。数组越界写入时,如果目标地址落在进程合法的地址空间内(比如相邻的全局变量、栈上的变量),硬件根本不会报错,程序会继续运行。被写坏的数据可能直到几十毫秒后、几秒后,甚至下一次循环才被使用,那时候才触发段错误。
我这次就是因为一开始把“崩溃点”和“写入点”混为一谈,才在parse_frame周边浪费了大量时间。反汇编帮我确认了崩溃指令,map 文件帮我锁定了相邻变量,最后顺着被破坏的字段反查写入路径,才找到真正写入非法的源头。
这种“先污染、后爆发”的特性,也是数组越界问题在嵌入式环境里比空指针更难搞的原因。空指针通常当场崩,现场很好抓;数组越界则像埋了一颗雷,你永远不知道它会在哪段代码里踩响。
5. 复盘:这类问题怎么在以后的项目中少踩
5.1 开发期就把越界问题暴露出来
这次排查之后,我把开发机上的构建脚本做了一次升级,加入了 AddressSanitizer 的支持。对于嵌入式 Linux 目标板,如果工具链支持,可以直接给业务模块追加-fsanitize=address编译选项:
arm-linux-gnueabihf-gcc -fsanitize=address -g -O1 -o app_test app.cASAN 会在每次数组访问时插入边界检查,越界写入会在第一次发生时就立刻报出精确的文件行号和调用栈,不需要等到数据被使用时才崩溃。这样能把定位时间从几天压缩到几分钟。
当然,ASAN 不是没有代价。它的内存开销通常在 2 到 3 倍以上,运行速度也会明显下降。因此我的建议是:日常联调用普通编译,专门安排一次“ASAN 回归跑测”,让测试脚本自动编一版带 sanitizer 的固件,在板子上跑几轮压力测试。反正我是把这一步加进了自动化流程,实测效果非常好。
如果你的工具链不支持 ASAN,还有一个偏方是使用 Electric Fence,它通过重载malloc在堆内存边界放置不可访问的保护页,越界访问会立刻触发段错误。不过它对全局数组没用,只对堆内存有效,适用面窄一些。
5.2 几个很土但有效的编码习惯
工具能帮你快速定位,但更根本的是写代码时把边界当成一等公民。我复盘这次问题后,给自己定了三条规则:
第一条:数组长度必须和业务上限显式挂钩,不能随手写一个128就完事。定义缓冲区时直接用业务限制的宏:
#define MAX_FRAME_DATA_LEN (128) static uint8_t rx_buf[MAX_FRAME_DATA_LEN];这样一来,当协议长度字段的上限变化时,编译器会在有越界嫌疑的地方给出更明显的提示,人肉审查时也能一眼看出容量是否匹配。
第二条:对于从外部输入得到的长度、索引,一律先做边界断言再进数组访问。尽管断言在发布版中可能被NDEBUG关掉,但在联调版本里,它能第一时间告诉你哪个环节的假设被打破了。
if (len > sizeof(rx_buf)) { log_error("frame len overflow: %u > %u", len, sizeof(rx_buf)); return -1; }对于嵌入式这种安全敏感场景,我更推荐显式的if判断配合错误返回,而不是直接assert。因为assert被NDEBUG关掉后,这段校验就彻底消失了,线上版本会继续踩同样的坑。
第三条:结构体尾部可以加一个哨兵字段,用于检测“是否被野指针或越界写穿”。在关键结构体末尾放一个固定魔数,每次使用前检查一下,一旦发现魔数不对,就知道结构体被别人踩了。这个方法在调试大型遗留代码时尤其有用。
5.3 我的排查顺序建议
最后分享一个我自己验证下来比较好用的排查顺序。以后再遇到嵌入式段错误,不管是裸机 HardFault 还是 Linux 用户态 Segfault,我建议按这个顺序来:
第一步,先看内核日志或者故障寄存器,拿到最原始的崩溃地址和错误码,不要凭感觉猜。第二步,尽可能开启 core dump 或者抓取硬件现场寄存器,把崩溃点的汇编和函数调用链固定下来。第三步,用 addr2line、map 文件、反汇编这些工具把崩溃点翻译成源码层面的线索。第四步,才轮到经验判断,去检查数组边界、指针偏移、结构体布局这些老生常谈的雷区。
等这个流程走完之后,往往会发现真相其实很简单,简单到你会怀疑自己之前几天都在干嘛。就像这次,一个循环边界少判断了几种情况,多写了两三个字节,就让我在parse_frame附近耗掉好几个晚上。
现在每次写循环,我都会下意识扫一眼索引上限和数组容量是否匹配,毕竟这个习惯背后是好几天的排查时间换来的。如果你的项目里还没有类似的内存保护机制,不妨趁下一个版本迭代的时候把它加上,这比任何排错技巧都更省时间。