计算机组成原理 —— 函数栈帧的创建切换
我们之前学习了指令的一些相关内容,我们今天来看利用这些指令,如何实现函数栈帧的创建切换:
虚拟地址空间
在这之前我们要稍微复习一下虚拟地址空间:
不得不拿出我之前在Linux中画的图了:
我们有关程序调用的运行的信息会放在栈区:
栈区(Stack)是进程虚拟地址空间的一个重要组成部分,主要用于存储函数调用期间的信息以及函数内的局部变量。栈区的特点是按照后进先出(LIFO, Last In First Out)的原则工作,这意味着最后压入栈的数据将是第一个被弹出的数据。
以下是栈区中通常存放的内容:
- 局部变量:
- 函数内部声明的局部变量通常会存储在栈中。这些变量在其作用域内有效,在函数返回时会被销毁。
- 函数调用帧(Process Activity Record):
- 每次函数调用时,都会创建一个新的栈帧来存储该函数的相关信息,包括:
- 参数值:传递给函数的参数。
- 返回地址:函数调用完成后应返回的代码位置。
- 局部变量:函数内部声明的变量。
- 临时数据:函数执行过程中产生的临时数据。
- 保存的寄存器状态:为了防止函数调用期间破坏寄存器的值,一些寄存器会在进入函数之前被保存在栈上。
- 返回值:
- 在某些架构中,函数的返回值也可能暂时存储在栈上,尤其是在返回值较大或不适合寄存器的情况下。
- 函数调用开销:
- 函数调用所需的额外开销,比如栈帧指针和基址指针的设置。
栈区的主要特点有:- 栈区的空间相对较小,因为它的大小通常是固定的或有限制的。
- 栈区的内存分配和释放非常快速,是由编译器自动完成的。
- 栈区通常是从高地址向低地址方向增长(向下生长栈)。
由于栈区的内存是自动管理和回收的,开发者不需要显式地分配和释放内存,这使得栈区非常适合存储那些生命周期短暂且大小已知的对象。然而,如果在栈上分配过大的数据结构或递归调用层次过深,则可能导致栈溢出(Stack Overflow),这是一种常见的编程错误。
常见寄存器
在 Intel x86 架构下,有一些常用的寄存器用于执行各种操作。这些寄存器分为通用寄存器、段寄存器、指令指针寄存器以及其他特殊用途的寄存器。以下是 x86 架构中的一些主要寄存器及其用途:
通用寄存器
这些寄存器通常用于存储数据和中间结果,也可以作为索引或基址寄存器。
32位寄存器
- eax (accumulator):累加器寄存器,通常用于算术运算的结果。
- ebx (base):基址寄存器,用于指向数据结构。
- ecx (counter):计数寄存器,通常用于循环计数。
- edx (data):数据寄存器,经常用于乘法和除法运算。
- esi (source index):源索引寄存器,用于指向源数据。
- edi (destination index):目标索引寄存器,用于指向目标数据。
- esp (stack pointer):堆栈指针寄存器,指向当前堆栈顶部。
- ebp (base pointer):基址指针寄存器,用于指向当前函数调用帧的底部。
处了edi和esi之外,剩下的寄存器大家要熟悉。
16位寄存器
- ax, bx, cx, dx, si, di, sp, bp:32位寄存器的低16位部分。
- ah, bh, ch, dh:32位寄存器的高8位部分。
64位寄存器
在 x86-64 架构中,除了上述寄存器扩展到了 64 位之外,还引入了一些新的寄存器:
- rax, rbx, rcx, rdx, rsi, rdi, rsp, rbp:32位寄存器的64位版本。
- r8 - r15:额外的通用寄存器。
段寄存器
这些寄存器用于存储段选择符,通常与指针寄存器一起使用来计算内存地址。
- cs (code segment):代码段寄存器,指向当前执行的代码段。
- ds (data segment):数据段寄存器,指向数据段。
- ss (stack segment):堆栈段寄存器,指向堆栈段。
- es (extra segment):附加段寄存器,可以指向任何段。
- fs (extra segment):附加段寄存器,可以指向任何段。
- gs (extra segment):附加段寄存器,可以指向任何段。
指令指针寄存器
- eip (instruction pointer):指令指针寄存器,指向当前正在执行的指令地址。
- rip (64-bit instruction pointer):在 x86-64 架构中,这是 64 位的指令指针寄存器。
标志寄存器
- eflags (flags register):包含各种标志位,如 CF(进位标志)、PF(奇偶标志)、AF(辅助进位标志)、ZF(零标志)、SF(符号标志)、TF(陷阱标志)、IF(中断标志)、DF(方向标志)、OF(溢出标志)。
其他特殊寄存器
- cr0 - cr4 (control registers):控制寄存器,用于控制 CPU 的各种特性。
- dr0 - dr7 (debug registers):调试寄存器,用于支持调试功能。
- mmx0 - mmx7, xmm0 - xmm15, ymm0 - ymm15:多媒体寄存器,用于 SIMD 操作。
这些寄存器在编写汇编语言程序时非常重要,因为它们直接影响着程序的行为和性能。了解这些寄存器的功能可以帮助你更有效地编写和优化程序。
Intel的汇编指令
Intel x86 架构下的汇编语言指令集非常丰富,涵盖了各种类型的指令,包括算术运算、逻辑运算、控制转移、输入输出操作、内存访问等等。下面是一些基本的 Intel x86 汇编指令的例子及其说明:
数据传送指令
- mov (move): 将数据从一个寄存器或内存位置移动到另一个寄存器或内存位置。
mov eax, 5
:将立即数 5 移动到寄存器eax
。mov ebx, [esi]
:将esi
指向的内存位置的数据移动到寄存器ebx
。mov [edi], eax
:将寄存器eax
中的数据移动到edi
指向的内存位置。
算术指令
- add (addition): 加法。
add eax, ebx
:将ebx
加到eax
上。
- sub (subtraction): 减法。
sub eax, ebx
:从eax
中减去ebx
。
- mul (multiply): 乘法。
mul ecx
:将eax
和ecx
相乘,结果的低 32 位存入eax
,高 32 位存入edx
。
- div (divide): 除法。
div edx
:用eax
(低 32 位)和edx
(高 32 位)组成的 64 位数除以edx
,商存入eax
,余数存入edx
。
逻辑指令
- and: 逻辑与。
and eax, ebx
:将eax
和ebx
进行按位与运算。
- or: 逻辑或。
or eax, ebx
:将eax
和ebx
进行按位或运算。
- xor: 逻辑异或。
xor eax, ebx
:将eax
和ebx
进行按位异或运算。
- not: 逻辑非。
not eax
:对eax
中的每一位取反。
控制转移指令
- jmp (jump): 无条件跳转。
jmp label
:跳转到标签label
所指定的位置。
- jcc (conditional jump): 条件跳转。
je label
:如果 ZF(零标志)为 1,则跳转到label
。jne label
:如果 ZF(零标志)不为 1,则跳转到label
。jg label
:如果 CF(进位标志)和 ZF 都为 0,则跳转到label
。jl label
:如果 SF(符号标志)和 OF(溢出标志)不同,则跳转到label
。
内存访问指令
- lea (load effective address): 计算有效地址并将其加载到寄存器中。
lea eax, [ebx + ecx * 4]
:计算ebx + ecx * 4
的有效地址,并将其存入eax
。
- push / pop: 堆栈操作。
push eax
:将eax
的值压入堆栈。pop eax
:从堆栈弹出一个值并将其存入eax
。
其他常用指令
- cmp (compare): 比较两个值,并设置标志位。
cmp eax, ebx
:比较eax
和ebx
的值,并根据结果设置标志位。
- inc / dec: 对寄存器中的值进行加一或减一操作。
inc eax
:eax
加 1。dec ebx
:ebx
减 1。
了解一些汇编指令可以帮助我们理解创建栈帧的过程。
函数栈帧创建
这里我们用VS2019来观察函数栈帧创建的过程,我们写这样的一段代码:
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
int P(int x1,int x2)
{
return x1 + x2;
}
int main()
{
int a = 19, b = 90;
int result = 0;
result = P(19,90);
return 0;
}
我们在main这打个断点,转到反汇编:
我们先看前三行代码,我们画个图来表示:
首先我们push一下ebp,然后我们把esp中的值给了ebp:
在图中,我们可以这么表示:
接下来进行了sub操作,将esp中的值减少了228:
这时候esp到ebp所指的区域就是main函数的函数栈帧:
函数栈帧的切换
我们一直调试,调试到call这里:
这里的call指令表明我们要转移到另一个新的函数中去运行了,这个时候我们就要切换函数栈帧,这个时候,首先,我们原来的函数栈帧不能丢,这个时候,会把ebp指向的地址压入栈中:
这个时候,我们点击逐语句,进入到P函数内部:
我们之前画图的时候已经将ebp的值压入了,这个时候esp中的值会赋值给ebp:
之后又是sub,esp减:
之后esp到ebp这段区域就是P的函数栈帧:
如果之后我想切换回main函数的栈帧,我们首先将esp指回ebp,拿出里面的16251460,让ebp往这个地址去跳,然后esp-4就行了(按字节编址的)。
但是哦,我们切换回原来的main函数栈帧,我们肯定是想在原来的代码下继续往下执行,我们都知道我们有一个程序计数器PC(这里会称作IP计数器),所以我们在切换函数之前,会把原来的IP数压到栈中,方便我们知道在上一个函数栈帧里的代码运行到哪里了:
函数返回:
函数栈帧的传参
传参的话可以参考这一张图片:
全局变量和调用参数会占一个函数栈帧的两头,如果我们先访问参数,直接ebp+4,ebp+8就可以访问到:
我们也可以自己调试一下验证一下参数的顺序:
我们发现先压的90,后压的19,跟我们预料的一样。