栈破坏下crash的分析方法

本文深入探讨了由栈破坏引起的函数调用回溯结构破坏,分析了此类crash的成因、原理及调试方法。通过实例代码展示了如何手动恢复函数调用栈,提供了一种在栈破坏场景下定位问题的有效手段。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在众多的coredump中,有一类crash调试起来是最麻烦的,那就是“栈被破坏”导致的函数调用回溯结构破坏引发的coredump。本文,主要讲讲这一类crash的成因、原理以及调试方法。

1. SMTC(show me the code)

首先,让我们来看一段代码

#include <stdio.h>
#include <string.h>
void fun(int n)
{
    printf("The %d step begin.\n", n);
    int a[10];
    for (int i = 0; i< n; i++) {
        a[i] = i;
    }
    if (n < 20) {
        fun(n +1);
    }
    printf("The %d step end\n", n);
}
int main(void)
{
	fun(8);
	return 0;
}

这段代码的关键在于fun函数会有递归调用,而在参数大于10的时候会导致写入的空间超过了栈上的“合法”内存。我们先来看下这段代码的输出

The 8 step begin.
The 9 step begin.
The 10 step begin.
The 11 step begin.
The 12 step begin.
The 13 step begin.
The 14 step begin.
The 15 step begin.
The 16 step begin.
The 17 step begin.
The 18 step begin.
The 19 step begin.
The 20 step begin.
The 20 step end
Segmentation fault (core dumped)

对于输出,我们做简要的解释:

  1. 所有的栈上地址都是合法(此处的合法,指的是操作系统允许写入)的,也就是说即使写入到a[19]这样的非预期地址,也不会crash
  2. 由于栈的增长方向是高地址向低地址,而“写坏”的是“高地址”,也就是说已经申请过的地址。
  3. 栈上与函数调用最相关的数据信息是rip&&esp,而直接导致crash的原因是因为rip被“写坏”,导致执行对应的指令出现问题。

2.原因详细解释

首先,我们来回顾一下函数调用过程中的stack数据分布与变化(link)。接着看上文的程序输出就明白:在fun函数调用的末尾,需要执行ret指令,也就是说会将地址为rbp+8对应的栈上数据放入rip寄存器中,然后执行rip对应的这一条这令,这里现在是存放的是一个0~19的数字,不是一个合法的指令地址,于是产生了crash。

stack func call list

也就是说,正常情况下,ebp数据结合栈上的数据实际上构成了一个单向链表,链表头是当前执行的函数,往链表尾部,是对应在各个层次的调用者函数。stack上对应的函数调用链表如上图。看到这里我们可以得出结论:

1.stack crash 的本质是rbp-rip的数据错乱导致。而具体crash的位置,取决于rbprip对应的数据。另一方面,栈上的数据错乱也不一定导致crash,有可能仅仅是把应该写入变量a的数据写到了变量b。
2.stack crash时,函数的执行已经脱离了出问题的函数。也就是说,A调用B,B函数中产生了栈上空间的错误写入,但是crash往往发生在A函数之中,因为只有B函数对应的汇编代码的最后一句retq执行完毕之后,才会发生crash,此时,程序的控制权在函数A之中。
3.stack crash时,函数调用栈已经被破坏。但是被破坏的是调用栈的头部。这也是唯一值得欣慰的信息了,函数调用栈尾部的信息依然完好无损。而我们可以据此,推测出函数调用的蛛丝马迹。

3.手动恢复函数调用栈

需要指出的是,被破坏的函数调用栈部分已经无法得到恢复了。此处我们能恢复的,仅仅是没有被破坏的部分。恢复函数栈的原理也很简单,那就是根据栈空间中的内存内容,找到那个“链表”即可。

继续使用上文我们对应的coredump文件,我们可以看到,由于函数调用最近的RBP对应的栈上内容已经被破坏,此时我们已经无法用bt指令得到正确的函数栈了。

Missing separate debuginfos, use: debuginfo-install glibc-2.17-105.el7.x86_64
(gdb) bt
#0  0x0000000f0000000e in ?? ()
#1  0x0000001100000010 in ?? ()
#2  0x0000001300000012 in ?? ()
#3  0x0000000100000000 in ?? ()
#4  0x0000000300000002 in ?? ()
#5  0x0000000500000004 in ?? ()

我们知道,函数调用栈在回溯过程中会执行两条关键的指令move %rbp %rsp; pop %rbp。而回溯行为对应的retq指令是在这两条指令之后执行的,此时rsp的值仍然是有效的。所以我们可以根据ESP的值打印出目前栈空间的数据。具体命令x/256xg 0x7ffd79ef9e40(rsp对应的值)和结果如下

(gdb) info reg rsp
rsp            0x7ffd79ef9e40	0x7ffd79ef9e40
(gdb) x/256xg 0x7ffd79ef9e40
0x7ffd79ef9e40:	0x0000001100000010	0x0000001300000012
0x7ffd79ef9e50:	0x0000000100000000	0x0000000300000002
0x7ffd79ef9e60:	0x0000000500000004	0x0000000700000006
0x7ffd79ef9e70:	0x0000000900000008	0x000000130000000a
......
0x7ffd79efa070:	0x00007fbe1d989d58	0x0000000c00000005
0x7ffd79efa080:	0x0000000100000000	0x0000000300000002
0x7ffd79efa090:	0x0000000500000004	0x0000000700000006
0x7ffd79efa0a0:	0x0000000900000008	0x0000000c0000000a
0x7ffd79efa0b0:	0x00007ffd79efa100	0x0000000000400583
0x7ffd79efa0c0:	0x00007fbe1d989d58	0x0000000b00000005
0x7ffd79efa0d0:	0x0000000100000000	0x0000000300000002
0x7ffd79efa0e0:	0x0000000500000004	0x0000000700000006
0x7ffd79efa0f0:	0x0000000900000008	0x0000000b0000000a
0x7ffd79efa100:	0x00007ffd79efa150	0x0000000000400583
0x7ffd79efa110:	0x00007fbe1dcfce80	0x0000000a00000000
0x7ffd79efa120:	0x0000000100000000	0x0000000300000002
0x7ffd79efa130:	0x0000000500000004	0x0000000700000006
0x7ffd79efa140:	0x0000000900000008	0x0000000a0040032a
0x7ffd79efa150:	0x00007ffd79efa1a0	0x0000000000400583

这里,函数调用关系比较长,我们以栈开始部分的数据来说明。使用x/256xg 0x7ffd79ef9e40+0x100获取栈跳过rsp开始被写坏的部分数据,得到如下rbp对应的list

broken_stack

好了,此时,我们已经找到了这个list,那么如果通过这个list找到函数调用关系呢?

通过rbpList恢复函数调用关系

通过《从汇编语言看函数调用》这篇文章,我们已经知道,栈上和rbp相邻的位置,就是对应的rip的值。而知道了rip的值,就能知道对应的代码位置。具体操作如下。

通过上图,我们根据这个list得到对应的rip-list对应的地址(rbp + 8对应的内容)依次全部为 0x0000000000400583 >> 0x0000000000400583 > ... > 0x00000000004005a7, 如下图:

ripList

有了rip地址,接下来只需要找出该地址对应的代码位置即可。这里,我们可以使用addr2line工具来分析代码段地址对应的源代码位置,结果如下。

[ykhuang@ykhuang-temp ~]$ addr2line -e test 0x0000000000400583
/home/ykhuang/test.cpp:13
[ykhuang@ykhuang-temp ~]$ addr2line -e test 0x00000000004005a7
/home/ykhuang/test.cpp:19

我们可以看到,这两个地址分别对应源代码的13和19行。 这里分别对应的printfreturn 0对应的位置。实际上出问题的位置发生在这两行代码的上一行,因为rip对应的意义是下一条指令的地址. 至此,我们已经得到了部分函数调用关系。实际debug的过程中,这也几乎是我们能从一个crash的堆栈上能够获取的全部信息了。有了这部分信息,可以让我们迅速定位问题。当然,结合实际的代码,我们可以从stack中靠近rsp被写坏的数据是什么,来反推和代码的对应关系。

4.总结

“栈破坏”导致的crash虽然难以排查,但是我们还是能根据栈上仅存的信息,尽可能缩小“问题”代码所在的位置。这其中的原来就是函数调用过程中函数栈的建立和销毁过程。当然,除此之外,你需要熟悉一些基本的gdb指令(查看内存、反汇编、查看对应寄存器的值等),也需要了解一些汇编指令的实际含义。其实,对于这种crash,还有另外的方式能够保存函数调用栈,我们以后再展开讨论。在实际的生产中,由于crash文件比较大,对crash现场的保存往往采用保存函数调用堆栈的方式。但是这种情况下,函数堆栈是无意义的,所以保存一些栈上数据,有利于我们更快定位问题,毕竟stack空间本来就不大。

博客源地址:栈破坏下crash的分析方法
更多相关内容:优孚-探索编程与技术的本源

<think>我们正在讨论C语言中的溢出问题。溢出通常发生在程序调用的空间超过其预设的限定时。在C语言中,局部变量、函数参数和返回地址等都存储在上。当这些数据量过大时,就会导致溢出。溢出的常见原因包括: 1.过大的局部变量:例如在函数内部定义了一个非常大的数组,如`intarr[1000000];`,这可能会消耗大量空间。2.过深的递归调用:递归函数如果没有适当的终止条件或递归深度太大,会不断在上添加新的帧,最终导致溢出。 3.无限递归:递归没有终止条件,导致无限调用,直到空间耗尽。 使用场景示例: -在嵌入式系统中,空间通常较小(可能只有几KB),因此容易发生溢出。-在处理大规模数据时,如果在函数内部定义大数组,容易导致溢出。 -在递归算法中,如果递归深度不可控,也容易导致溢出。如何避免溢出?1.避免在上分配大内存:使用动态内存分配(如`malloc`)在堆上分配大内存,并在使用后释放(`free`)。 2.控制递归深度:使用迭代代替递归,或者使用尾递归优化(但C语言标准不保证尾递归优化)。3.增加空间:在某些系统中可以调整大小,但这种方法可移植性差。 下面是一个溢出的例子(递归导致): ```c#include <stdio.h> voidrecursive_func(intcount){int arr[100]; //每次递归都会在上分配一个数组printf("Depth: %d\n", count);recursive_func(count+1);//无限递归}int main() {recursive_func(1);return0; }``` 这个程序会不断递归调用`recursive_func`,每次调用都会在上分配一个大小为100个整数的数组,最终导致溢出。 而避免溢出的一个改进版本是使用动态分配: ```c#include <stdio.h> #include<stdlib.h>void recursive_func(intcount){//在堆上分配大数组,而不是上int*arr= (int*)malloc(100 *sizeof(int));if (arr ==NULL){//处理分配失败return;}printf("Depth: %d\n", count);//使用数组...free(arr); //释放堆内存recursive_func(count+1);//仍然有无限递归问题,但堆空间通常比大得多,且每次分配后释放,所以不会耗尽堆空间?注意:无限递归仍然会导致溢出,因为每个函数调用都会产生帧。 }intmain(){recursive_func(1);return0;} ```注意:虽然我们避免了在上分配大数组,但无限递归仍然会导致溢出,因为每个函数调用都会在上创建一个帧(包含返回地址等)。因此,避免无限递归或控制递归深度是必要的。 另一种改进是使用迭代代替递归:```c#include<stdio.h>void iterative_func(){int count=1;while(1) {int*arr= (int*)malloc(100 *sizeof(int));if (arr ==NULL){break;}printf("Count: %d\n", count++);free(arr);}} intmain(){iterative_func();return0;} ```这样就不会有溢出的问题,因为函数调用没有嵌套,帧不会累积。但是注意,堆内存可能会耗尽(通过检查`malloc`的返回值)。在嵌入式系统中,空间有限,需要特别注意。例如,引用[^2]中展示了一个递归计算阶乘的函数,它记录了指针的变化,可以用来观察的使用情况。总结:溢出是C语言中常见的问题,主要由于过大的分配或过深的函数调用导致。解决方法是使用堆内存分配大对象,控制递归深度,或改用迭代算法。 §§相关问题§§1.如何检测程序是否发生了溢出?2.在C语言中,如何设置的大小? 3.递归和迭代在使用上有何区别? 4.在嵌入式系统中,如何避免溢出?</think>### C语言溢出详解 溢出是C语言中常见的内存错误,发生在程序使用的空间超过系统分配的大小(通常1-8MB)。以下是溢出的关键要点: #### 一、溢出发生条件 1. **空间耗尽**:当函数调用层级过深或局部变量过大时,指针(SP)超出内存边界 2. **递归失控**:递归函数缺少终止条件或深度过大,如`factorial(10000)`计算阶乘[^2] 3. **大对象分配**:函数内定义大型局部数组,如`int buffer[100000]` 4. **指针越界**:指针操作错误导致指向非法区域 数学表达:设大小为$S$,函数调用帧大小为$f$,最大安全调用深度$d$满足: $$ d \times f \leq S $$ #### 二、典型场景与原因 | 场景类型 | 示例代码 | 风险点 | |-------------------|-----------------------------------|------------------------| | **深度递归** | `int fib(int n){return fib(n-1)+fib(n-2);}` | 指数级帧累积[^2] | | **大局部变量** | `char buf[10*1024*1024];` | 单次分配超限 | | **无限循环调用** | `void f(){g();} void g(){f();}` | 帧持续增长 | | **指针操作错误** | 对指针进行非法算术运算 | 破坏结构 | #### 三、解决方案 1. **改用堆内存**: ```c // 溢出风险 void risky() { int bigArray[1000000]; // 可能溢出 } // 安全方案 void safe() { int* bigArray = malloc(1000000 * sizeof(int)); // ...使用... free(bigArray); } ``` [^1] 2. **限制递归深度**: ```c #define MAX_DEPTH 100 int factorial(int n) { if(n > MAX_DEPTH) return -1; // 深度保护 return (n <= 1) ? 1 : n * factorial(n-1); } ``` 3. **迭代替代递归**: ```c // 递归阶乘 → 迭代改造 int factorial_iter(int n) { int result = 1; for(int i=1; i<=n; i++) result *= i; return result; } ``` 4. **监控指针**(嵌入式场景): ```c uint32_t spState[100]; // 记录指针变化 void func() { uint32_t sp = __get_MSP(); // 获取当前指针 spState[spIndex++] = sp; // 记录状态 } ``` [^2] #### 四、使用场景分析 1. **安全场景**: - 小规模局部变量(`int arr[100]`) - 有限递归(深度<100) - 单线程简单调用 2. **高风险场景**: - 图像/音频处理中的大缓冲区 - 数学计算(深度递归算法) - 嵌入式系统(空间通常仅几KB) - 解析嵌套数据结构(JSON/XML) > 关键建议:当数据规模超过**1KB**时应优先考虑堆分配[^1][^3]
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值