C语言编程: 函数调用中堆栈的个人理解
接下来将通过下面几个问题解析函数调用中对堆栈理解:
(1)函数调用过程中堆栈在内存中存放的结构如何?
(2)汇编语言中call,ret,leave等具体操作时如何?
(3)linux中任务的堆栈,数据存放是如何?
1. 函数调用过程中堆栈在内存中存放的结构如何?
计算机,嵌入式设备,智能设备等其实都是有软件和硬件两部分组成,具体实现也许复杂,但整体的结构也就如此。软件运行在硬件上,告诉硬件该干什么。操作系统软件是在启动过程中经过BIOS,bootloarder等(如果有这些过程的话)从磁盘加载到内存中,而自定义软件则是编写存放到磁盘中,只有通过加载才会到内存中运行。
首先我们来看一下什么是堆、栈还有堆栈,我们经常说堆栈其实它是等同于栈的概念。
可以通俗意义上这样理解堆,堆是一段非常大的内存空间,供不同的程序员从其中取出一段供自己使用,使用之后要由程序员自己释放,如果不释放的话,这部分存储空间将不能被其他程序使用。堆的存储空间是不连续的,因为会因为不同时间,不同大小的堆空间的申请导致其不连续性。堆的生长是从低地址向高地址增长的。
对栈的理解是,栈是一段存储空间,供系统或者操作系统使用,对程序员来说一般是不可见的,除非从一开始由程序员自己通过汇编等自己构建栈,栈会由系统管理单元自己申请释放。栈是从高地址向低地址生长的,既栈底在高地址,栈顶低地址。
其次我们看一下应用程序的加载,应用程序被加载进内存后,由操作系统为其分配堆栈,程序的入口函数会是main函数。不过main函数也不是第一个被调用的函数,我们通过简单的例子讲解。
#include
#include string.h>
int function(int arg)
{
return arg;
}
int main(void)
{
int i = 10;
int j;
j = function(i);
printf("%dn",j);
return 0;
}
用gcc -S main.c 生成汇编文件main.s, 其中function的汇编代码如下:
function:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp)
movl -4(%rbp), %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
看以看到当函数被调用时,首先会把调用函数的栈底压栈到自己函数的栈中(pushq %rbp),然后将原来函数栈顶rsp作为当前函数的栈底(movq %rsp, %rbp)。函数运行完成时,会将压入栈中的rbp重新出栈到rbp中(popq %rbp)。当前function汇编函数没有显示出栈顶的变化(rsp的变化),我们可以通过main函数来看栈顶的变化,汇编代码如下:
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $10, -4(%rbp)
movl -4(%rbp), %eax
movl %eax, %edi
call function
movl %eax, -8(%rbp)
movl -8(%rbp), %eax
movl %eax, %esi
movl $.LC0, %edi
movl $0, %eax
call printf
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
从上面的汇编代码可以看到首先也是压栈和设置新栈底的过程,从此可以看出main函数也是被调用的函数,而不是第一个调用函数。代码中的黄色部分是当前栈顶变化,从使用的subq可以知道,栈顶的地址要小于栈底的地址,所以栈是从高地址向低地址生长。
接下来可能有点绕,慢慢读,将用语言描述函数调用过程,调用函数会将被调用函数的实参从右往左的顺序压入调用函数的栈中,通过call指令调用被调用函数,首先将return address(也就是call指令的后一条指令的地址)压入调用函数栈中,这时rsp寄存器中存储的地址是存放return address内存地址的下一地址值,这时调用函数的栈结构形成,然后就会进入被调用函数的作用域中。被调用函数首先将调用函数的rbp压入被调用函数栈中(其实这个地址就是rsp寄存器中存储的地址),接下来将会将这个地址作为被调用函数的rbp地址,才会有movq %rsp, %rbp指令设置被调用函数的栈底。如上所描述的构成了函数调用的堆栈结构如下图所示。
2. 汇编语言中call,ret,leave等具体操作时如何?
push:将数据压入栈中,具体操作是rsp先减,然后将数据压入sp所指的内存地址中。rsp寄存器总是指向栈顶,但不是空单元。
pop:将数据从栈中弹出,然后rsp加操作,确保rsp寄存器指向栈顶,不是空单元。
call:将下一条指令的地址压入当前调用函数的栈中(将PC指令压入栈中,因为在从内存中取出call指令时,PC指令已经自动增加),然后改变PC指令的为call的function的地址,程序指针跳转到新function。
ret:当指令指到ret指令行时,说明一个函数已经结束了,这时候rsp已经从被调用函数的栈指到了调用函数构建的返回地址位置。ret是将rsp所指栈顶地址中的内容赋值给PC,接下来将执行call function的下一条指令。
leave:相当于mov %esp, %ebp, pop ebp。头一条指令其实是把ebp所指的被调用函数的栈底作为新的栈顶,pop指令时相当于把被调用函数的栈底弹出,rsp指向返回地址。
int:通过其后加中断号,实现软件引发中断,linux操作系统中系统调用多有此实现,其他实时操作系统中在操作系统移植时,会有tick心脏函数也有此实现。
其他的汇编指令在此就不多讲了,因为汇编指令众多,硬件cpu寄存器也因硬件不同而不同,此节就讲了函数构建进入和离开函数时用到的几个汇编指令,这几条指令和栈变化有关。自己构建汇编函数,或者是在读linux操作系统的系统调用时会对其理解有帮助。硬件寄存器中rsp,和rbp用于指示栈顶和栈底。
3. linux中任务的堆栈,数据存放是如何?
linux的任务堆栈分为两种:内核态堆栈和用户态堆栈。接下来简单介绍一下这两个堆栈,如果以后有机会将详细介绍这两个堆栈。
1. 内核态堆栈
linux操作系统分为内核态和用户态。用户态代码访问代码和数据收到诸多限制,用户态主要是为程序员编写程序使用,处于用户态的代码不可以随便访问linux内核态的数据,这主要就是设置用户态的权限,安全考虑。但是用户态可以通过系统调用接口,中断,异常等访问指定内核态的内容。内核态主要是用于操作系统内核运行以及管理,可以无限制的访问内存地址和数据,权限比较大。
linux操作系统的进程是动态的,有生命周期,进程的运行和普通的程序运行一样,需要堆栈的帮助,如果在内核存储区域内为其提前分配堆栈的话,既浪费内核内存(任务地址大约3G的空间),也不能灵活的构建任务,所以linux操作系统在创建新的任务时,为其分配了8k的存储区域用于存放进程内核态的堆栈和线程描述符。线程描述符位于分配的存储区域的低地址区域,大小固定,而内核态堆栈则从存储区域的高地址开始向低地址延伸。如果之前版本为内核态堆栈和线程描述符分配4k的存储空间时,则需要为中断和异常分配额外的栈供其使用,防止任务堆栈溢出。
2. 用户态堆栈
对于32位的linux操作系统,每个任务都会有4G的寻址空间,其中0-3G为用户寻址空间,3G-4G为内核寻址空间。每个任务的创建都会有0-3G的用户寻址空间,但是3G-4G的内核寻址空间是属于所有任务共享的。这些地址都属于线性地址,需要通过地址映射转换成物理地址。为了实现每个任务在访问0-3G的用户空间时不至于混淆地址,每个任务的内存管理单元都会有一个属于自身的页目录pgd,在任务创建之初会创建新的pgd,任务会通过地址映射为0-3G空间映射物理地址。用户态的堆栈就在这0-3G的用户寻址空间中分配,和之前的main函数以及function函数构建堆栈一样,但是具体映射到哪个物理地址,还需要内存管理单元去做映射操作。总之,linux任务用户态的堆栈和普通应用程序一样,由操作系统分配和释放,对程序员来说不可见,不过因为操作系统的原因,任务用户程序寻址有限制。
这次就分享到这里了,如果有喜欢本篇文章的可以加上小编的学习加流群一起交流哦!