前言

为进行基础回炉,接下来一段时间我将持续更新汇编和操作系统相关知识,希望通过屏蔽底层细节能让大家明白每节所阐述内容。当我们写下如下C代码时背后究竟发生了什么呢?

#include <stdio.h>
int main()
{
int a = , b = ;
int func(int a, int b);
int c = func(a, b);
printf("%d\n%d\n%d\n",a, b, c);
} int func(int a, int b)
{
int c = ;
return a + b + c;
}

接下来我们gcc编译器通过如下命令

gcc -S fileName.c

将其转换为如下AT&T语法的汇编代码(看不懂的童鞋可自行忽略,接下来我会屏蔽细节,从头开始分析如下汇编代码的本质)

_main:
LFB13:
.cfi_startproc
pushl %ebp
movl %esp, %ebp
andl $-, %esp
subl $, %esp
call ___main
movl $, (%esp)
movl $, (%esp)
movl (%esp), %eax
movl %eax, (%esp)
movl (%esp), %eax
movl %eax, (%esp)
call _func
movl %eax, (%esp)
movl (%esp), %eax
movl %eax, (%esp)
movl (%esp), %eax
movl %eax, (%esp)
movl (%esp), %eax
movl %eax, (%esp)
movl $LC0, (%esp)
call _printf
movl $, %eax
leave
.cfi_restore
.cfi_def_cfa ,
ret
.cfi_endproc
LFE13:
.globl _func
.def _func; .scl ; .type ; .endef
_func:
LFB14:
.cfi_startproc
pushl %ebp
movl %esp, %ebp
subl $, %esp
movl $, -(%ebp)
movl (%ebp), %edx
movl (%ebp), %eax
addl %eax, %edx
movl -(%ebp), %eax
addl %edx, %eax
leave
.cfi_restore
.cfi_def_cfa ,
ret
.cfi_endproc
LFE14:
.ident "GCC: (MinGW.org GCC Build-20200227-1) 9.2.0"
.def _printf; .scl ; .type ; .endef

CPU提供了基于栈的数据结构,当我们利用push和pop指令时说明会将寄存器上某一块地址作为栈来使用,但是当我们执行push或者pop指令时怎么知道哪一个单元是栈顶呢?此时将涉及到两个寄存器,段寄存器SS和寄存器SP,栈顶的段地址存放在SS中,而偏移地址存放在SP中,通过SS:SP即(段地址/基础地址 + 偏移地址 = 物理地址),因为堆栈是向下增长,所以当我们进行比如push ax(操作数和结果数据的累加器)即将ax压入栈时,会进行如下两步操作:(1)SP = SP - 2,SS:SP指向当前栈顶前面的单元,以当前栈顶前面的单元作为新的栈顶(画外音:SP就是堆栈指针)(2)将ax中的内容送入SS:SP指向的内存单元处,SS:SP指向新栈顶。

那么CPU提供基于堆栈的数据结构可以用来做什么呢?堆栈的主要用途在于过程调用,一个堆栈将由一个或多个堆栈帧组成,每个堆栈帧(也称作活动记录)对应于对尚未以返回终止的函数或过程的调用,堆栈帧本质就是函数或者方法。我们知道对于函数或者方法有参数、局部变量、返回值。所以对于堆栈帧由函数参数、指向前一个堆栈帧的反向指针、局部变量组成。有了上述基础知识铺垫,接下来我们来分析在主函数中对函数调用如何利用汇编代码实现

int c = func(a, b);

int func(int a, int b)
{
int c = ;
return a + b + c;
}

参数

当调用func时,我们需要通过push指令将参数压入堆栈,此时在堆栈中入栈顺序如下

push b
push a
call func

当每个参数被推到堆栈上时,由于堆栈会向下生长,所以将堆栈指针寄存器减4个字节(在32位模式下),并将该参数复制到堆栈指针寄存器所指向的存储位置。注意:指令会隐式将返回地址压入堆栈。

栈帧

接下来进入被调用函数即进入栈帧,如果我们想要访问参数,可以像如下访问(注意:sp为早期处理器堆栈指针,如下esp为intel x86堆栈指针,只是名称不同而已)

[esp + ]   - return address
[esp + ] - parameter 'a'
[esp + ] - parameter 'b'

然后我们开始为局部变量c分配空间,但是如果我们还是利用esp来指向函数局部变量将会出现问题,因为esp作为堆栈指针,若在其过程中执行push(推送)或者pop(弹出)操作时,esp堆栈指针将会发生变化,此时将导致esp无法真正引用其中任何变量即通过esp表示的局部变量的偏移地址不再有效,偏移量由编译器所计算并在指令中为其硬编码,所以在执行程序期间很难对其进行更改。

为了解决这个问题,我们引入帧指针寄存器(bp),当被调用函数或方法开始执行时,我们将其设置为堆栈帧的地址,如果代码将局部变量称为相对于帧指针的偏移量而不是相对于堆栈指针的偏移量,则程序可以使用堆栈指针而不会使对自动变量的访问复杂化,然后,我们将堆栈帧中的某些内容称为offset($ fp)而不是offset($ sp)。

上述帧指针寄存器从严格意义上来说称作为堆栈基指针寄存器(bp:base pointer),我们希望将堆栈基指针寄存器设置为当前帧,而不是先前的函数,因此,我们将旧的保存在堆栈上(这将修改堆栈上参数的偏移量),然后将当前的堆栈指针寄存器复制到堆栈基指针寄存器。

push ebp        ; 保存之前的堆栈基指针寄存器
mov ebp, esp ; ebp = esp

局部变量

局部变量存在堆栈中,所以接下来我们通过esp为局部变量分配内存单元空间,如下:

sub esp, bytes ; bytes为局部变量所需的字节大小

如上意思则是,sub为单词(subtraction)相减缩写,堆栈向下增长(根据处理器不同可能方向有所不同,但通常是向下增长比如x86-64),若局部变量为3个(int)即双字,则字节大小为12,则堆栈指帧向上减去12即esp-12(注:这种说法不是很准确,涉及到具体细节,可暂且这样理解)。 如上所述最终将完成堆栈帧调用,最终我们将所有内容放在一起,则是如下这般

[ebp + ]  - parameter 'b'
[ebp + ] - parameter 'a'
[ebp + ] - return address
[ebp + ] - saved stackbase-pointer register

当调用函数或方法完毕后,对堆栈帧必须进行清理即进行内存释放和恢复先前堆栈帧指针寄存器继续往下执行,如下:

mov esp, ebp   ; 释放局部变量内存空间
pop ebp ; 恢复先前的堆栈帧指针寄存器

如上只是从整体上去对堆栈帧调用的大概说明,我们来看看局部变量和参数基于ebp的偏移量是为正值还是负值

void func()
{
  int a, b, c;
  a = 1;
  b = 2;
  c = 3;
} 执行:
push ebp
mov ebp, esp 高地址
|
|<--------------  ebp = esp 
|

低地址 执行:
sub esp, 12 高地址
|
|<--------------  ebp
|
|<--------------  esp
|

低地址 执行:
mov [ebp-4], 1
mov [ebp-8], 2
mov [ebp-12], 3 高地址


| <--------------  ebp
|1
|2
|3
| <--------------- esp
低地址

如上所述在进入函数后,旧的ebp值将被压入堆栈,并将ebp设置为esp的值,然后esp递减(因为堆栈在内存中向下增长),以便为函数的局部变量和临时变量分配空间。从那一刻起,在函数执行期间,函数的参数位于堆栈上,因为它们在函数调用之前被压入,所以与ebp的偏移量为正值,而局部变量位于与ebp的偏移量为负值的位置,因为它们是在函数输入之后分配在堆栈上(如上图分析)。到这里我们将开始所写的函数最终在堆栈中的内存位置是怎样的呢?图解如下:

最后我们将上述通过AT&T语法转换的汇编代码转换为intel语法汇编代码可能会更好理解一点

gcc -S -masm=intel .c

二者只不过是对应指令所使用符号有所不同而已,比如操作数为立即数时,AT&T语法将添加$符号,而intel语法不会,对上述函数调用进行详细解释,如下

//主函数栈帧
_main:
LFB13:
push ebp
mov ebp, esp
and esp, -
sub esp,
call ___main //将立即数2写入【esp+28】
mov DWORD PTR [esp+], //将立即数3写入【esp+24】
mov DWORD PTR [esp+], //将【esp+24】值写入寄存器eax
mov eax, DWORD PTR [esp+] //将寄存器eax中的值(即3)写入【esp+4】
mov DWORD PTR [esp+], eax //将[esp+28]值写入eax寄存器
mov eax, DWORD PTR [esp+] //将寄存器eax中的值(即2)写入【esp+0】
mov DWORD PTR [esp], eax //调用_func函数,此时将返回地址压入栈
call _func //将eax寄存器的值结果(即25)写入【esp+20】
mov DWORD PTR [esp+], eax //将【esp+20】值写入eax寄存器
mov eax, DWORD PTR [esp+] //将寄存器eax中的值写入【esp+12】 = 25
mov DWORD PTR [esp+], eax //将【esp+24】值写入eax寄存器
mov eax, DWORD PTR [esp+] //将寄存器eax中的值写入【esp+8】 = 3
mov DWORD PTR [esp+], eax //将【esp+28】值写入eax寄存器
mov eax, DWORD PTR [esp+] //将寄存器eax中的值写入【esp+4】 = 2
mov DWORD PTR [esp+], eax mov DWORD PTR [esp], OFFSET FLAT:LC0 call _printf mov eax,
leave
ret //被调用函数(_func)栈帧
_func:
LFB14:
push ebp
mov ebp, esp //为函数局部变量分配16个字节空间
sub esp, //将立即数写入偏移栈帧4位的地址上
mov DWORD PTR [ebp-], //将偏移栈帧8位上的地址值(即2)写入edx寄存器
mov edx, DWORD PTR [ebp+] //将偏移栈帧12位上的地址值(即3)写入eax寄存器
mov eax, DWORD PTR [ebp+] //将eax寄存器中的值和edx寄存器中的值相加即(a+b) = 5
add edx, eax //将偏移栈帧地址4位上的地址值(即20)写入寄存器eax
mov eax, DWORD PTR [ebp-] //将eax寄存器值和edx寄存器存储的值相加即(20+c) = 25
add eax, edx //相当于执行(move esp,ebp; pop ebp;)有效清除堆栈帧空间
leave //相当于执行(pop ip),从堆栈中弹出返回地址,并将控制权返回到该位置
ret

上述对汇编代码的详细解释可能对零基础的汇编童鞋理解起来还是有很大困难,接下来我将再一次通过图解方式一步步给大家做出明确的解释,通过对堆栈帧的学习我们能够知道函数或方法调用的具体细节以及高级语言中值类型复制的原理,它的本质是什么呢?接下来我们一起来看看。(注:英特尔架构上的堆栈从高内存增长到低内存,因此堆栈的顶部(最新内容)位于低内存地址中)。

在主函数栈帧如图所示,首先分配局部变量内存空间,然后保存主函数的堆栈帧,最后将2和3分别压入栈,接下来进入调用函数,如下图所示

然后开始调用函数,当执行call指令时会将返回地址压入栈以便执行栈帧上的ret指令时进行返回,将当前堆栈针移动到堆栈针,定义了堆栈帧的开始,从此刻开始进行函数调用内部,如下图

首先我们保存先前的ebp值,并将堆栈帧指针设置为堆栈的顶部(堆栈指针的当前位置),然后我们通过从堆栈指针中减去16个字节来增加堆栈为局部变量分配空间,在此堆栈框架中,包含该函数的本地数据、帧指针ebp的负偏移量(栈的顶部,到较低的内存中)r表示本地变量、ebp的正偏移量将使我们能够读取传入的参数,接下来则是将局部变量c设置为20,完成后,通过leave指令将堆栈指针设置为帧指针的值(ebp),并弹出保存的帧指针值,有效地释放堆栈帧内存空间,此时,堆栈指针指向函数返回地址,执行ret指令时弹出堆栈,并将控制转移到call指令压入栈的返回地址,继续往下执行。

堆栈帧解惑

通过如上图解对比汇编代码分析可以为我们解惑两大问题,我们看到将操作数为立即数的a = 2和 b = 3入栈【esp+28】和【esp+24】的地址上,如下:

//将立即数2写入【esp+28】
mov DWORD PTR [esp+], //将立即数3写入【esp+24】
mov DWORD PTR [esp+],

但是我们会发现接下来会将2和3将通过寄存器eax分别写入到栈为【esp+4】和【esp+0】的地址上,但是最终获取变量a和b的值依然是对应地址【esp+28】和【esp+24】,这就是高级语言中值类型的原理即深度复制(副本):通过寄存器传递(比如eax)将值副本存储到堆栈帧上其他内存单元地址,参数值即从该内存单元获取。

//将【esp+24】值写入寄存器eax
mov eax, DWORD PTR [esp+] //将寄存器eax中的值(即3)写入【esp+4】
mov DWORD PTR [esp+], eax //将[esp+28]值写入eax寄存器
mov eax, DWORD PTR [esp+] //将寄存器eax中的值(即2)写入【esp+0】
mov DWORD PTR [esp], eax 调用完函数后: //将【esp+24】值写入eax寄存器
mov eax, DWORD PTR [esp+] //将寄存器eax中的值写入【esp+8】 = 3
mov DWORD PTR [esp+], eax //将【esp+28】值写入eax寄存器
mov eax, DWORD PTR [esp+] //将寄存器eax中的值写入【esp+4】 = 2
mov DWORD PTR [esp+], eax

将变量a和b复制到栈【esp+0】和【esp+4】地址上,就是将其作为函数或方法的调用参数,即使进行修改操作也不会修改原有变量的值,但是我们会发现在函数中当获取变量a和b的值是通过【ebp+8】和【ebp+12】来获取

//将偏移栈帧8位上的地址值(即2)写入edx寄存器
mov edx, DWORD PTR [ebp+] //将偏移栈帧12位上的地址值(即3)写入eax寄存器
mov eax, DWORD PTR [ebp+]

若是看到上述汇编代码时存在看不懂的情况,结合图解3将一目了然,参数通过基于当前堆栈帧的偏移位移来获取,因为在调用函数时也将返回地址和函数的ebp压入栈,最终将堆栈针指向当前函数的ebp,所以相对于当前函数的堆栈帧而言,变量a和b的地址自然而然就变成了【ebp+8】和【ebp+12】。

总结

经典的书籍针对栈顶的定义实际上是指堆栈所占内存区域中的最低地址,和我们自然习惯有所不同,有些文章若是指向堆栈内存高地址,这种说法是错误的。存在帧指针寄存器(ebp)存在的主要原因在于堆栈指针(sp)的值会发生变化,但是这只是历史遗留问题针对早期的处理器而言,现如今处理器对于sp有些已具备offset(相对寻址)属性,所以对于帧指针寄存器是可选的,不过利用bp在跟踪和调试函数的参数和局部变量更加方便。一个调用堆栈由1个或多个堆栈帧组成,每个堆栈帧对应于对尚未以返回终止的函数或过程的调用。要使用栈帧,线程保留两个指针,一个称为堆栈指针(SP),另一个称为帧指针(FP)。SP始终指向堆栈的顶部,而FP始终指向帧的顶部。此外,该线程还维护一个程序计数器(PC),该计数器指向要执行的下一条指令。栈帧中局部变量为负偏移量,参数为正偏移量。

读懂操作系统(x86)之堆栈帧(过程调用)的更多相关文章

  1. 读懂操作系统(x64)之堆栈帧(过程调用)

    前言 上一节内容我们对在32位操作系统下堆栈帧进行了详细的分析,本节我们继续来看看在64位操作系统下对于过程调用在处理机制上是否会有所不同呢? 堆栈帧 我们给出如下示例代码方便对照汇编代码看,和上一节 ...

  2. 读懂操作系统之缓存原理(cache)(三)

    前言 本节内容计划是讲解TLB与高速缓存的关系,但是在涉及高速缓的前提是我们必须要了解操作系统缓存原理,所以提前先详细了解下缓存原理,我们依然是采取循序渐进的方式来解答缓存原理,若有叙述不当之处,还请 ...

  3. 读懂操作系统之快表(TLB)原理(七)

    前言 前不久.我们详细分析了TLB基本原理,本节我们通过一个简单的示例再次叙述TLB的算法和原理,希望借此示例能加深我们对TLB(又称之为快表,深入理解计算机系统(第三版)又称之为翻译后备缓冲区)的理 ...

  4. 读懂操作系统之虚拟内存TLB与缓存(cache)关系篇(四)

    前言 前面我们讲到通过TLB缓存页表加快地址翻译,通过上一节缓存原理的讲解为本节做铺垫引入TLB和缓存的关系,同时我们来完整梳理下从CPU产生虚拟地址最终映射为物理地址获取数据的整个过程是怎样的,若有 ...

  5. C/C++子函数参数传递,堆栈帧、堆栈参数详解

    本文转载自C/C++子函数参数传递,堆栈帧.堆栈参数详解 导语 因为参数传递和汇编语言有很大联系,之后会出现较多x86汇编代码. 该文会先讲一下x86的堆栈参数传递过程,然后再分析C/C++子函数是怎 ...

  6. 一次CMS GC问题排查过程(理解原理+读懂GC日志)

    这个是之前处理过的一个线上问题,处理过程断断续续,经历了两周多的时间,中间各种尝试,总结如下.这篇文章分三部分: 1.问题的场景和处理过程:2.GC的一些理论东西:3.看懂GC的日志 先说一下问题吧 ...

  7. [转]一次CMS GC问题排查过程(理解原理+读懂GC日志)

    这个是之前处理过的一个线上问题,处理过程断断续续,经历了两周多的时间,中间各种尝试,总结如下.这篇文章分三部分: 1.问题的场景和处理过程:2.GC的一些理论东西:3.看懂GC的日志 先说一下问题吧 ...

  8. 一篇文章教你读懂Makefile

    makefile很重要      什么是makefile?或许很多Winodws的程序员都不知道这个东西,因为那些Windows的IDE都为你做了这个工作,但我觉得要作一个好的和professiona ...

  9. 一文读懂HTTP/2及HTTP/3特性

    摘要: 学习 HTTP/2 与 HTTP/3. 前言 HTTP/2 相比于 HTTP/1,可以说是大幅度提高了网页的性能,只需要升级到该协议就可以减少很多之前需要做的性能优化工作,当然兼容问题以及如何 ...

随机推荐

  1. pytorch torchversion标准化数据

     新旧标准差的关系

  2. 在Windows中使用VirtualBox安装Ubuntu

    VeitualBox官网下载:https://www.virtualbox.org/wiki/Downloads 安装教程:http://dblab.xmu.edu.cn/blog/337-2/ 安装 ...

  3. 手把手编写自己的PHP MVC框架实例教程

    1 什么是MVC MVC模式(Model-View-Controller)是软件工程中的一种软件架构模式. MVC把软件系统分为三个基本部分:模型(Model).视图(View)和控制器(Contro ...

  4. 解决linux(ubuntu18)下无法挂载ntfs磁盘,并读写挂载硬盘

    首先需要有ntfs-3g,没有的话sudo apt-get install ntfs-3g 挂载硬盘: chen@ilaptop:/$ sudo mount -o rw,remount /dev/sd ...

  5. 关于virtualbox配置centos7的网络问题

    连接方式最好选桥接网卡 原文:https://www.cnblogs.com/zergling9999/p/6026006.html

  6. [Batch脚本] if else 的格式

    必须写成一行 ) else (,否则报错. if %abc%=="yes" ( ... ) else ( ... )

  7. HTML入门(HB、DW)

    一.文字内容 <b></b>  <strong></strong>     /*加粗 <i></i>   <em>& ...

  8. Hawkeye部署Github监控系统

    2019独角兽企业重金招聘Python工程师标准>>> step1:python环境安装 #pwd /usr/local/soft #wget https://www.python. ...

  9. Blog Customization

    0 前言 从大二开始写博客,主要为了记录自己学习过程中的问题.尝试使用过CSDN.博客园等公共服务,也用Github pages搭建过自己的博客,但效果都不令人满意.CSDN广告太多,界面乌烟瘴气,而 ...

  10. 升级vue项目中的element-ui的版本

    首先卸载项目中的element-ui 命令为: npm uninstall element-ui / cnpm uninstall element-ui 安装更新最新的element-ui 命令为 n ...