函数调用栈

article/2025/9/1 14:50:22

函数调用栈

我们在编程中写的函数,会被编译器编译为机器指令,写入可执行文件,程序执行的时候,会把这个可执行文件加载到内存,在虚拟地址空间中的代码段存放。

如果在一个函数中调用另一个函数,编译器就会对应生成一条call指令,当程序执行到这条call指令时,就会跳到对应的函数入口处开始执行,而每一个函数的最后,都有一条ret指令,负责在函数结束后跳回到调用处继续执行。

image-20220210201148148

栈区

函数执行的时候需要有足够的内存空间来存放局部变量,参数,返回值等数据,这些数据存在上图中的栈中。

栈就是先入后出,先入栈的在底部。

在虚拟地址空间的栈区,上面的是高地址,下面是低地址,放了一些数据,栈底通常称为栈基,栈顶又叫栈指针

具体的栈帧布局是:

  • 调用者栈基地址(也就是谁调用了这个函数)
  • 局部变量
  • 调用函数的返回值
  • 参数

通过栈指针加上偏移来定位到每个参数和返回值。

比如栈指针+8字节处,就是栈指针的上一格,通过这种方式来进行偏移。

image-20220210202009209

还记得我们之前说当在A函数中调用B函数时,会在A函数中插入一条call指令,当执行到call指令的时候,会去B函数开始处运行。

那么call指令做的事情就是:

  1. 首先把A函数中下一条指令的地址入栈(栈基地址,当B函数执行完之后,可以再通过这个地址回到A函数的调用处继续执行A函数。)
  2. 跳转到被调用函数的入口处执行(也就是被调用函数的栈帧,而所有的函数栈帧布局都遵循统一的结构约定。)

image-20220210204301454

栈具体的入栈策略

程序执行时,CPU通过特定的寄存器来存运行时的栈基和栈指针,也有指令指针寄存器用来存储下一条要执行的指令地址。

执行指令的过程有两种,第一种是逐步扩张:

  • 如果要执行入栈3这条指令,CPU读取之后,会先把指令指针移向下一条指令,然后栈指针向下移动,入栈数字3。
  • 然后再执行入栈4这条指令,CPU读取之后,再把指令指针移向下一条指令,然后栈指针向下移动,入栈数字4。
  • 一直往复。

image-20220210210301866

Go语言中的是第二种——一次性分配,它会直接将栈指针移动到所需最大栈空间的位置,然后通过右边这种相对寻址的方式,来把对应的值入栈。

image-20220210214502183

Go语言选择使用一次性分配的策略是有原因的,拿下图来讲,下面三个goroutine,初始分配的栈空间只有那么大,如果要逐步扩张的话,如果g2执行到最后了,但是接下来要执行的函数又要用掉很多的空间,如果函数栈是逐步扩张的,执行时就可能会发生栈访问越界。

函数栈帧的大小可以在编译时期确定, 对于栈消耗大的函数,Go编译器会在函数头部插入检测代码,如果发现需要进行栈增长,则会另外分配一段足够大的空间,然后把原来的内容移过来,并释放原来的空间。

image-20220210214938132

call和ret的细节

首先我们可以看到,下面是栈区代码段

当代码段执行到对应的指令时,就会给栈中添加对应的元素,最终再把栈全部出栈。

假如说,我们是在函数A中的a1处调用函数B(函数B开始位置为b1)。

首先,在最开始的时候,寄存器在栈中的情况是这样的:

image-20220211215127647

ip寄存器中存的是下一条要运行的指令,那么当我们的代码段运行到a1的call指令时,会做两件事:

首先会入栈返回地址a2,然后栈指针sp向下一格,然后给ip寄存器b1的指令地址,接下来要去B函数的开始处运行。

call指令就结束了。

image-20220211215327924

接下来就要运行四步函数都要做的事:

  • 第一步是先把栈指针sp移动到足够大的位置——s7上。
  • 第二步是存储一下之前栈基bp寄存器的值,这样可以在运行完之后,还能回到原来的栈基地址。
  • 第三步是把s5存入栈基地址。
  • 接下来就要做函数剩下的指令了——参数,代码等,并一一入栈。

image-20220211220131455

在函数B运行到最后——ret指令之前,编译器还会插入两条指令:

  • 恢复调用者栈基。最开始我们分配了多少空间,此时就释放多少空间,修改bp寄存器为之前入栈的s1,bp继续指向s1处。
  • 然后就到ret指令了,它首先会弹出call指令压栈的返回地址a2,sp赋值为s3。然后跳转到这个返回地址a2,把ip寄存器赋值为a2。 接下来可以从a2这里继续执行了。

image-20220211220405112

简单来说,call指令会分配栈帧,ret指令又会释放栈帧,恢复到call之前的样子。通过这些指令的配合,就能实现函数的层层嵌套了。

image-20220211220920122

函数传参和返回值

首先看一个例子,下面这个例子是交换两个局部变量的值,可以看到,结果并没有改变:

image-20220211221812987

上面那个函数在栈中的分配如下:

  • 首先分配局部变量的空间,然后把局部变量存进去。
  • 然后分配被调用函数的参数,从右至左分配。先入栈第二个参数,再入栈第一个参数。传参是值拷贝,所以把两个参数的值压入栈。
  • 接下来是call指令存入的返回地址。也就是fmt.Println这一行代码所对应的指令
  • 再接下来就是swap函数栈帧了

image-20220211222902684

当swap函数执行到a,b=b,a时,就会修改参数对应的值,但是调用者的局部变量a和b在上面,交换的并不是它们,所以最终结果显示没有交换成功。

image-20220211223513405

我们再修改一下:

还是和上一次的一样,只是我们把指针作为参数,函数参数还是值类型,所以会拷贝两个地址的值。

再swap函数中,会将对应地址的值进行交换,修改的是调用者的局部变量ab,所以最终修改成功。

image-20220211223653267

通常,返回值是通过寄存器传递的,但是Go语言支持多返回值,所以在栈上分配返回值更合适。

接下来我们看一个有返回值的例子:

  1. 一次性分配main函数栈帧,sp直接到达对应的位置。

  2. 把局部变量压入栈

  3. 压入栈函数返回值(默认为0)——因为栈是先入后出的缘故,所以一个函数的执行步骤要从后往前的压入栈。

  4. 把函数参数压入栈

  5. 保存调用者函数main的栈基地址以方便最后回到main函数。

  6. 接下来进入函数incr的函数栈帧。 首先初始化局部变量b,默认为0,

  7. 然后执行a++指令,把局部变量a的值加1.

  8. 运行到b=a的指令,把参数a赋给局部变量b。

  9. 接下来就是返回值和defer函数的问题。 在Go语言中,是先给返回值赋值,然后再执行defer函数。

image-20220211224655112

  1. 把局部变量b的值,拷贝到返回值空间。
  2. 执行注册的defer函数,在defer函数中,参数a再次自增1,局部变量b也加1。但是需要注意的是它让局部变量b自增1,不代表就把返回值自增1,因为在defer之前,已经给返回值赋值过了,可以看下图,b的值是2,但是返回值还是1.

image-20220211225218764

  1. 把返回值给局部变量b
  2. 输出a,b——0,1

image-20220211225300156

接下来我们看这个例子,用的是命名返回值:

  1. main函数的栈情况还是如下图右边所示。

image-20220211225605954

  1. 接下来会运动到incr函数的a++指令,然后把参数a的值加1.
  2. return a指令赋值a变量的值给返回值局部变量b,此时b=a=1.
  3. 运行defer函数,a++,b++,a和b都是2.
  4. 此时返回值的位置为2,所以会把main函数中的局部变量b赋值为2.
  5. 打印0,2

image-20220211225444411

当函数A中调用函数B和函数C时,栈的寻址策略

  1. 首先分配A函数的局部变量空间。

  2. 因为后面有两个函数要执行,又因为Go是一次性分配空间的,所以会分配最大的参数和返回值空间,函数B比函数C的空间要大,就以函数B所需要的空间标准来分配,如下图r2~p1这么大的空间。

  3. 接下来把函数B的参数和返回值压入栈,进入函数B的栈帧。

image-20220211230158564

  1. 当函数B执行完毕之后,会释放这两片空间。把函数C的参数和返回值压入栈,但是此时空间还是那么大,r1p1是存在这片空间的上面,还是下面,还是中间?

image-20220211230543070

最终的答案就是,会把r1p1分配到最下面,和函数C的栈指针挨着,这样虽然上面会空出来一块,但是被调用者通过栈指针相对寻址自己的参数和返回值时会比较方便。

image-20220211230624897


http://chatgpt.dhexx.cn/article/ti0ZfMd0.shtml

相关文章

函数调用和使用

1.函数是什么 函数(Function)能实现的功能从简单到复杂,各式各样,但其本质是相通的:“喂”给函数一些数据,它就能内部消化,给你“吐”出你想要的东西。 2.定义和调用函数 2.1 定义函数 #…

C语言——函数的调用

函数的调用 传值调用 函数的形参和实参分别占有不同的内存块,对形参的修改不会影响实参。 传址调用 1.传址调用是把函数外部创建的变量的内存地址传递给函数参数的一种调用函数的方式 2.这种传参方式可以让函数和函数外边的变量建立起真正的联系,也就…

C语言之函数调用

C语言之函数调用 “温故而知新,可以为师矣”! 让我们开启函数的道路吧! 今天主要讲函数的调用方式! 在讲之前,先回顾一下实际参数和形式参数的区别; 1.在定义函数时函数名后面括号中的变量名称为“形式参数…

C语言函数的调用

函数调用(Function Call),就是使用已经定义好的函数。函数调用的一般形式为: functionName(param1, param2, param3 ...);functionName 是函数名称,param1, param2, param3 …是实参列表。实参可以是常数、变量、表达…

Windows编程-001

如果建立的是Win32控制台工程(入口函数是main函数)的话,WinMain函数不能作为入口函数,如果想要解决这个问题的话,可以打开项目属性->链接器->系统->子系统,把子系统对应的“控制台”改为“窗口”。…

windows全系1

windows操作系统专贴(一定有你想要的) 2006年12月01日 22:25 windows 98 简体中文零售版第三版 语言:简体中文 类型:操作系统 大小:180MB 环境:9x/Me/NT/2000/XP/.Net/ 授权:零售版 软件介绍:这个版本是SE的改进版,比前…

对不起,说句粗话——这个太屌了,windows1.0安装程序(附下载)

今天逛一个软件论坛发现的,只有几百K。遥想当今我刚接触windows的版本是3.1,当时记得很清楚哦,进入windows要从dos命令行进入。现在一转眼,变成进入伪dos是运行栏里敲cmd了。 唉,想当年尿尿还能疵2米多远的时候&#…

Windows入门(一)

本人正在学习Windows编程操作,所以进行一些记录,希望对刚入门的个位有所帮助。 目录 1.什么是win32编程 2. 一个简单的win32程序 2.1 创建一个空项目 2.2 入口函数 2.3 注册窗口 2.3.1 窗口回调函数 2.4 创建窗口 2.5 显示窗口 2.6 更新窗口 3.…

win11网页版

网页版地址 点它 https://win11.blueedge.me/ 其github库地址其github库地址https://github.com/blueedgetechno/win11React/

windows10/11子系统安装ubuntu22.04

学习目标: winfows10/11 安装wsl内核 基于wsl内核安装Ubuntu系统 基于ubuntu系统安装docker环境 学习内容: 系统下安装wsl2下载ubuntu安装包windows11 安装ubuntu 22.04系统ubuntu 22.04 安装dockerdocker 启动、测试 windows下安装wsl2内核 1、如果未…

快速教你在虚拟机上完美安装Windows1.0

想必不用我说大家用虚拟机安装Windows1.0时都是这样的: 今天我就来教大家如何正确安装Windows1.0 因为在1985年(Windows1.0诞生的年代)的鼠标驱动已经不匹配我们的电脑了,所以我们需要先解决鼠标这个问题。 第一步: 打开UltraISO,在里面打开我们Windows1.0第一个镜像(…

Windows:

服务主机:本地系统(网络受限) CPU利用率高,磁盘利用率高 解决方法: 方法一:禁用SuperFetch服务 计算机:—右键“管理”—SuperFetch—停止。或属性—-禁用。 开机就占用50%的内存(共8G) 关闭家庭组  家…

Windows1.0到Windows10三十年进化史,你还记得自己最初使用的系统吗?

从1985年Windows 1.0正式诞生到2015年Windows 10诞生,微软花了三十年的时间,从像素化桌面到现在扁平化的界面。让我们来看一下Windows 1.0到Windows10三十年来的变化。 1、1985年11月20日,微软发布了第一版的Windows操作系统——Windows1.0。…

【Docker】 Windows10运行Windows镜像时常见错误

项目场景:【Docker】 Windows10运行Windows镜像常见错误 很多项目要求在Windows系统下运行,但开发环境的安装相对复杂,并且难以重新配置到新设备,甚至会出现多个项目的运行环境相互冲突和干扰的情况,这时候配置好一个镜像后就可以一劳永逸解决很多问题,直接用docker拉取Window…

Windows 1.0

Windows 1.0 是微软于1985年11月发布的第一款基于dos的pc图形操作平台.要安装Windows 1.0 先安装好ms-dos2.11.对于ms-dos3以上的,只能看见一个类似资源管理器的程序.因为现在很找到一个很破旧的电脑,所以只能通过虚拟机来实现.推荐使用VMware虚拟机.先进入dos2.11,如下图所示 …

重温经典:Windows1.0系统体验和尝试自己编写Windows1.0系统

相关说明 如果你觉得文章含有侵权部分,可以联系CSDN私聊,我会适当修改。 未经允许,不得转载,如需转载,请CSDN私聊。 Windows Windows,意思是窗口,窗户。当然,他也是一个操作系统的…

[笔记]深入解析Windows操作系统《一》概念和工具

文章目录 前言1.1 Windows操作系统的版本1.2 基础概念和术语Windows API关于 .NetWin32 API 历史服务、函数、例程进程、线程和作业进程tlist /t 查看进程树任务管理查看进程Process Explorer查看进程的细节 线程纤程与用户模式调度器线程 虚拟内存内核模式和用户模式终端服务及…

Windows操作系统各版本的历史 Windows系统历史版本简介

30年间Windows系统有哪些版本?还记得你第一次了解到Windows操作系统存在的时候是哪一年吗?这些操作系统又有哪些特点呢?隐约知道计算机变得越来越小了吗……现在笔者将通过收集的资料,为各位细细解说曾经的操作系统。 30年间Wind…

[字符串]ASCII码表

数字,字母位置 数字0~9对应的ASCII码(十进制)袭为“48”~“57”大写字母A~Z对应的ASCII码(十进制)为“65”~“90”小写字母a~z对应的百ASCII码(十进制)为"97"~“122”表

ASCII 码不同的两种空格:32 和160

背景 在 Linux 下手写一个命令和拷贝 html 页面文本域中的命令执行结果完全不同,后者的命令无法被 Linux 识别。 问题截图 肉眼根本看不出来这两行有什么区别,但执行结果就是有问题。 问题定位 编写测试代码,执行两个字符串的 equals 方法…