上篇文章:ARM Linux 系统稳定性分析入门及渐进 2 – Kernel Lockup
下篇文章:ARM Linux 系统稳定性分析入门及渐进 4 – 栈分类
1.1 栈溢出
堆和栈的空间必须由程序员静态的分配,但计算 堆heap 和 栈stack的空间 大小却不是一件简单的事情,即便是对于最小的嵌入式系统。栈一般静态分配,并且后进先出,开发者静态的指定栈内存空间,一般栈向下生长,即从高地址到低地址,如果栈空间不足,发生下溢,则栈之下的内存空间被写入。
导致栈溢出的常见的情况有以下几种:
(1) 局部数组过大
当系统栈设置比较小时,会导致栈溢出。当程序确实需要大数组时,可以设置为静态变量或全局变量。
(2) 递归调用层次太多
递归函数在运行时会执行压栈操作,当压栈次数太多时,也会导致堆栈溢出。
(3) 指针或者数组越界
比方说错误的指针、不加边界检查的数组访问等会造成这种情况。
这两种栈区溢出都会产生 死机 或者 代码跑飞 的风险。当栈区溢出时,栈指针指向非法区域,这个区域有可能是 全局变量区,有可能是别的 task 的栈区。
以函数中数据越界为例:
函数 func_a 调用 func_b,通过汇编我们知道在执行func_b的指令之前首先会为 func_b进行栈帧分配,一般都是进行 SP 指针减去一个立即数据
1.1.1 栈结构
每个进程都会有自己的栈空间,而进程中的各个函数也会维护自己本身的一个栈的区域,这个区域就是栈帧。那么一个函数的栈帧的区域是如何来界定的呢?当然,首先会普及ARM的几个特殊寄存器功能
- R11:frame pointer,FP寄存器
- R12:IP寄存器,用于暂存SP
- R13:stack pointer,SP寄存器
- R14:link register,LR寄存器
- R15:PC寄存器
而在 ARM上,函数的栈帧是由 SP寄存器 和 FP寄存器 来界定的,参见图:
上图描述的是 main 函数调用 func1 函数的栈帧情况,从图可知,当 main 函数
调用 func1
函数时,func1
函数会先将 PC、LR、SP、FP
四个寄存器压到栈上边,其中 SP
和 FP
的值分别指向 main 函数栈帧的两个边界,LR
的值保存的是 func1
调用结束之后的返回值,PC
值表示的是当前执行到的指令地址,放置的是进入 func1
后的指令地址。紧接着就会在栈上分配一片区域,用于放置局部变量等。
如果 func1
中还调用了 func2
子函数,那么也会为 func2
创建一个栈帧,并且func2
的 SP
和 FP
会指向 func1
栈帧的两个边界。这样当函数返回的时候,参数进行出栈,也能找到 Caller 函数,这个也就是 backtrace 的原理。
1.1.2 汇编实例
反汇编分析某段代码,如下图所示:
- 红色部分,表明进入到函数时先将几个特殊的寄存器压栈;
- 黄色部分,
sub sp, sp, #16
,表明开辟一个4 x 32bit
大小的栈区域; - 蓝色部分,将传入的参数压栈,在 ARM ATPCS 中规定,寄存器
R0-R3
用来传参; - 绿色部分,调用子函数。
并不是所有函数调用都需要先 push {fp, ip, lr, pc}
,当子函数调用过程中,并不会去改变这些值的时候,就不需要压栈,说白了,压栈的目的就是为了在使用完的时候能恢复原来的状态。
1.1.3 数组越界栈回踩
从上面的 1.1.1 节内容我们知道,ARM 架构上一般栈都是向下增长的,如果在 函数 func1
中定义了一个大小为 N 的数组 int test[N]
,在 for
循环中根据数组下标 i
向数组 test
中写入数据 N
个0xfffff
。
int fun1(int a, int b, int c, int loop)
{
...
int test[N], i;
for (int i = 0; i < loop; i++)
*(test + i) = 0xffff;
...
如果由于某种原因 loop
的值大于 N
的值,这样就会出现在 func1
栈空间向数组 &test[N]
之后的地址处写入数据, 从栈帧分配可以看到,func1
的栈帧高地址处存放的是 FP, SP, LR, PC
,如果 LR
的值被修改为 0xffff
,那么在 func1
函数返回 main
时将会出现致命错误,因为 func1
返回时,系统会从 LR
地址处取指令给 PC
,而此时 func1
栈帧中保存的 LR
的值已经被修改为 0xffff
了。
1.1.4 栈保护区
栈保护区是一块分配在栈之下的一块内存空间(假设栈stack是向下生长的),如图 2 所示,这样当栈 stack下溢时,就能在保护区留下痕迹 trace。通过软件的方法来检查保护区是否还完整(即填充保护区相同的数据,如0xff
的数据,然后检测保护区是否被写入)来确定栈下溢情况。有些公司在 task
创建后,stack
会被全部初始化为 0xEFEFEFEF。
1.1.5 检测栈下溢
把栈空间填充成固定的值可以检测栈空间的下溢,如在程序开始前填充 0xCD
。当程序终止时,栈内存可以从栈底端搜索模式直到 0xCD
没找到。这样就能得到从栈顶端的栈空间大小。
上篇文章:ARM Linux 系统稳定性分析入门及渐进 2 – Kernel Lockup
下篇文章:ARM Linux 系统稳定性分析入门及渐进 4 – 栈分类