背景
主要基于Linux,介绍X86-64和ARM64的用户栈结构。断断续续的学了很多和栈相关的知识,今天打算整理用户栈相关的知识,废话少说,下面进入正题。
栈的定义和类别
栈有时也称堆栈,是一种受限的线性表,只能在线性表的一端按序进行插入(进栈)和删除(出栈),因此先进栈的数据会后出栈。为了便于描述,我们习惯将在线性表进行插入和删除的一端称为栈顶,另一端称为栈底。栈顶会随着插入和删除而发生变化,栈底则保持不变。
其实,栈在计算机中就是一块连续的存储区域(至少虚拟地址是连续的),只不过在这块连续的存储区域写入和删除数据按照先进后出的规则进行,在计算机中使用两个指针就可以完全描述一个栈,bp(base pointer)指向栈底,sp(stack pointer)指向栈顶,如下图所示。
上面主要讲了栈的定义,在上面栈的定义中至少有两个地方没有说清楚,一是往栈中增加数据时,栈是往高地址增加还是往低地址增加;二是栈顶指针SP指向的地方是否存放数据。向高地址增长的栈称为递增栈(Ascendant Stack),向低地址增长的栈称为递减栈(Decendant Stack)。SP指向栈顶元素(即SP指向的地方存放数据)的栈为满栈(Full Stack),SP指向下一个栈顶元素位置(即SP指向的地方不存放数据)的栈为空栈(Empty Stack)。很显然一个栈不能同时为递增栈和递减栈,也不能同时为满栈和空栈。因此,存在4种类型的栈,即空增栈(Empty Ascendant Stack,EA)、空减栈(Empty Descendant Stack,ED)、满增栈(Full Ascendant Stack,FA)和满减栈(Full Descendant Stack,FD)。
空增栈(Empty Ascendant Stack,EA)
在对空增栈中压入数据时,先把数据放入SP所指的位置处,然后SP=SP+1。对这种栈的压入操作,相当于C语言的 memory[SP++]=data;或者相当于ARM64的汇编语言str x1,[SP],#8。出栈操作相当于C语言的data=memory[--SP];或者ARM64的汇编语言ldr x1,[SP,#-8]!。
空减栈(Empty Descendant Stack,ED)
在对空减栈中压入数据时,先把数据放入SP所指的位置处,然后SP=SP-1。对这种栈的压入操作,相当于C语言的 memory[SP--]=data;或者相当于ARM64的汇编语言str x1,[SP],#-8。出栈操作相当于C语言的data=memory[++SP];或者ARM64的汇编语言ldr x1,[SP,#8]!。
满增栈(Full Ascendant Stack,FA)
在对满增栈中压入数据时,先对SP操作腾出位置SP=SP+1,然后数据放入SP指向的位置。对这种栈的压入操作,相当于C语言的 memory[++SP]=data;或者相当于ARM64的汇编语言str x1,[SP,#8]!。出栈操作相当于C语言的data=memory[SP--];或者ARM64的汇编语言ldr x1,[SP],#-8。
满减栈(Full Descendant Stack,FD)
在对满减栈中压入数据时,先对SP操作腾出位置SP=SP-1,然后数据放入SP指向的位置。对这种栈的压入操作,相当于C语言的 memory[--SP]=data;或者相当于ARM64的汇编语言str x1,[SP,#-8]!。出栈操作相当于C语言的data=memory[SP++];或者ARM64的汇编语言ldr x1,[SP],#8。或者X86-64的汇编指令push r1。x86-64的汇编指令push和pop操作栈是按照满减栈的规则进行。默认情况下,ARM64也使用满减栈的规则操作栈。
栈的生命周期
栈的生命周期是和进程的生命周期保持一致的,进程在则栈在,进程亡则栈亡。因此,不妨从进程的生命周期探讨栈的生命周期。一个用户进程从无到开始运行,需要经过几个重要的步骤:
Linux首先创建一个task_struct用于管理进程的方方面面。这里只是有了进程的“草图”,进程还没有被创建。
建立进程的虚拟地址空间,也即建立页表,建立虚拟地址到物理地址的映射,到这时一个用户进程所需的基本元素已经具备,一个进程被创建完成,在创建进程的过程中,进程的内核栈也被创建,内核栈不在本文的说明范围内。
接下来就需要可执行文件本身的参与,读取可执行文件头,解析文件头,文件头的前几个字节会指出当前文件是何种类型,如果是#!/bin/sh或 #!/bin/python 则该文件是脚本文件,有负责脚本文件的加载程序,本文只关注可执行文件。建立虚拟地址和可执行文件之间的映射。
初始化进程环境,其中比较重要的一项便是初始化用户栈
跳转到可执行文件的入口,执行可执行文件,运行到用户程序main函数,这其中主要右libc对栈进行管理。
main()函数通过切换栈帧调用其它子函数,子函数也能通过切换栈帧调用其子函数。
mian()函数返回,整个进程结束,释放栈占的内存,栈消失。
结合上面所述以及下图所示,栈的生命周期可以分为4个部分:
Linux Kernel创建用户栈,为栈分配内存空间,处理传递给用户的参数,将参数压入栈中,压入指向参数的argv,计算出argc并将其压栈。
libc的_start函数将 Linux Kernel创建的栈和libc库函数接上头,由体系结构相关的汇编语言编写,核心作用是将栈顶地址赋值给SP,还将Linux设置的栈传递、参数传递以及一些库函数的函数指针传递给C语言编写的函数__libc_start_mian。_start函数只是起到一个过渡作用,根据CPU的体系结构将Linux Kernel初始化好的栈传递给后续的C语言编写的函数。
libc的__libc_start_mian函数是一个C语言写的函数,运行到该函数时用户栈的结构已经是编译器设计的了,同时由于_start函数已经设置好了SP的值,各种压栈、出找操作都在不断调整SP的值。该函数的功能主要有,main调用前的初始化工作;调用main;main函数返回后的清尾工作。
编译器设计main函数及其调用的子函数的栈。
用户栈在系统中的位置
对于Linux内核而言,将整个内存空间划分为两个部分,Kernel Space 和User Space,前者用于支撑Linux Kenrel本身的运行所需空间,后者就是用于支持用户程序所需的运行空间。用户栈就是位于用户空间,一般位于用户空间的最高部分,向低地址处增长。