ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

嵌入式Linux段错误排查:数组越界一个字节引发的崩溃

嵌入式Linux段错误排查:数组越界一个字节引发的崩溃 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[10000e000]这里最有价值的信息是末尾的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_buf0x00037a20128 字节g_frame_ctrl0x00037aa088 字节g_tx_buf0x00037af8256 字节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 目标板如果工具链支持可以直接给业务模块追加-fsanitizeaddress编译选项arm-linux-gnueabihf-gcc -fsanitizeaddress -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附近耗掉好几个晚上。现在每次写循环我都会下意识扫一眼索引上限和数组容量是否匹配毕竟这个习惯背后是好几天的排查时间换来的。如果你的项目里还没有类似的内存保护机制不妨趁下一个版本迭代的时候把它加上这比任何排错技巧都更省时间。
返回列表