Menory safety —— x86 汇编和调用堆栈
编译器、汇编器、链接器、加载器(CALL)
运行 C 程序有四个主要步骤:
(1)编译器将 C 代码转换为汇编指令。
(2)汇编程序将编译器中的汇编指令转换为机器代码(原始位)。
(3)链接器解析对外部库的依赖关系。链接器完成链接外部库后,它会输出可以运行的程序的二进制可执行文件。
(4)当用户运行可执行文件时,加载程序会在内存中设置地址空间,并在可执行文件中运行机器代码指令。
内存布局
运行程序时,地址空间分为四个部分。从最低地址到最高地址,它们是:
- CODE:代码部分包含程序的可执行指令(即代码本身)
-STATIC: static 部分包含常量和静态变量,这些常量和静态变量在程序执行期间永远不会更改,通常在程序启动时分配。 - HEAP:堆存储动态分配的数据。当您在 C 中调用 malloc 时,内存会在堆上分配并提供给您使用,直到您调用 free。堆从较低的地址开始,随着分配的内存增加而“增长”到更高的地址。
- STACK:栈存储与函数调用关联的局部变量和其他信息。堆栈从更高的地址开始,并随着调用更多函数而“增长”。
寄存器
== EIP (指令指针)==:存储当前正在执行的机器指令的地址。在RISC-V中,这个寄存器被称为PC(程序计数器)。
== EBP (基指针)==:存储当前堆栈帧顶部的地址。在RISC系统中,该寄存器称为FP(帧指针)。
== ESP (堆栈指针)==:存储当前堆栈帧底部的地址。在RISC-V中,这个寄存器被称为SP(堆栈指针)。
注意:
(1)当前栈帧的顶部是与当前堆栈帧关联的最高地址,栈帧的底部是与当前堆栈帧关联的最低地址。
(2)健全性检查:这些寄存器通常指向 C 存储器的哪个部分(代码、静态、堆、栈)?
函数调用
具体步骤:
- 将参数推送到栈上。
RISC-V 通过将参数存储在寄存器中来传递参数,而 x86 通过将参数推送到堆栈上来传递参数。
请注意,当我们将参数推送到堆栈上时,esp 会递减。参数以相反的顺序推送到堆栈上。 - 将旧的 eip (rip) 推到栈上。
我们即将更改 eip 寄存器中的值,因此我们需要将其当前值保存在栈上,然后再用新值覆盖它。当我们在堆栈上推送此值时,它被称为旧 eip 或 rip(返回指令指针)。 - 移动 eip。
将 eip 更改为指向被调用方函数的指令。 - 将旧的 ebp (sfp) 推送到栈上。
请注意,esp 已递减。 - ** 将 ebp 向下移动。**
将 ebp 更改为指向新栈帧的顶部。新栈帧的顶部是 esp 当前指向的位置,因为我们即将在 esp 下方为新堆栈帧分配新空间。 - ** 向下移动 esp。**
通过递减 esp 来为新的堆栈帧分配新空间。
编译器查看函数的复杂性,以确定 esp 应该递减多远。例如,只有几个局部变量的函数不需要堆栈上太多空间,因此 esp 只会递减几个字节。另一方面,如果一个函数将一个大数组声明为局部变量,则 esp 将需要减少很多以适应堆栈上的数组。 - 执行函数。
局部变量和任何其他必要的数据现在可以保存在新的栈帧中。
此外,由于 == ebp == 始终指向栈帧的顶部,因此我们可以将其用作参考点来== 查找栈上的其他变量 ==。例如,参数将从存储在 ebp 中的地址加上 8 开始。 - 向上移动esp。
一旦函数准备好返回,我们就会递增 esp 以指向堆栈帧 (ebp) 的顶部。这有效地擦除了栈帧,因为栈帧现在位于 esp 下方(esp 下方堆栈上的任何内容都是未定义的。 - 恢复旧的 ebp (sfp)。
堆栈上的下一个值是 sfp,即我们开始执行函数之前 ebp 的旧值。我们将 sfp 从堆栈中弹出,并将其存储回 ebp 寄存器中。这会将 ebp 返回到调用函数之前的旧值。 - 恢复旧的eip(rip)。
堆栈上的下一个值是 rip,即我们开始执行函数之前 eip 的旧值。我们将 rip 从堆栈中弹出并将其存储回 eip 寄存器中。这会将 eip 返回到调用函数之前的旧值。 - **从堆栈中删除参数。**由于函数调用已经结束,我们不再需要存储参数。我们可以通过递增 esp 来删除它们(回想一下,esp 下面的堆栈上的任何内容都是未定义的)。