文章目录

虚拟内存布局

关于进程在虚拟内存的布局,一张经典的解释图是:

从虚拟内存的角度理解一段汇编_堆栈

在一段完整的汇编程序中,我们首先要关注的是其实是图中的stack部分,它是一个地址向低位生长的栈

理解一段简单的汇编

想要分析汇编程序,一个很好用的网站是 https://godbolt.org/,它能把程序方便地翻译成汇编

网站中提供的示例是:

// Type your code here, or load an example.
int square(int num) {
return num * num;
}

​x86-64 gcc 11.2​​中汇编为

square(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
imul eax, eax
pop rbp
ret


背景知识:

  • push代表把操作数推入stack (指的是内存中的那个stack)
  • mov destination,source。mov指令效果等同于C++/Java中的赋值语句(从右值到左值) destination = source;
  • imul代表signed integer multiply,有符号整型相乘
  • pop与push对应


光知道这些指令是什么还不够,rbp、rsp、DWORD PTR这些字符都有本身固定的含义,只有理解了它们才能理解这段汇编到底在干嘛


作为对比我们先来看另一段简单的程序,并把它翻译成汇编

#include <stdio.h>

int main(void)
{
int a;

a = 972;
printf("a = %d\n", a);
return (0);
}

对应汇编是:

000000000040052d <main>:
40052d: 55 push rbp
40052e: 48 89 e5 mov rbp,rsp
400531: 48 83 ec 10 sub rsp,0x10
400535: c7 45 fc cc 03 00 00 mov DWORD PTR [rbp-0x4],0x3cc
40053c: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
40053f: 89 c6 mov esi,eax
400541: bf e4 05 40 00 mov edi,0x4005e4
400546: b8 00 00 00 00 mov eax,0x0
40054b: e8 c0 fe ff ff call 400410 <printf@plt>
400550: b8 00 00 00 00 mov eax,0x0
400555: c9 leave
400556: c3 ret
400557: 66 0f 1f 84 00 00 00 nop WORD PTR [rax+rax*1+0x0]
40055e: 00 00

对比这两段汇编,发现有一些东西是没有变化的,这告诉我们这些东西很重要,我们需要理解这些东西


来看前几句

000000000040052d <main>:
40052d: 55 push rbp
40052e: 48 89 e5 mov rbp,rsp
400531: 48 83 ec 10 sub rsp,0x10

函数的第一行​​main​​​指的是​​rbp​​​和 ​​rsp​​​; 这些是特殊用途的寄存器。​​rbp​​​是基指针,指向当前栈帧的基点,​​rsp​​是栈指针,指向当前栈帧的顶部


rbp: Register Base Pointer。其作用是标定一个基址,其值在运行过程变化很少

rsp: Register Stack Pointer。其作用是标定栈顶,其值会不断变化。因为虚拟内存中栈的地址是向下生长的,因此入栈操作会使它存储的值看起来不断变小


虚拟内存中stack部分的初始状态:

从虚拟内存的角度理解一段汇编_寄存器_02

  • 图中的 previous values on the stack : 也就是本文开头那张图中的​​command-line arguments and env var​​。注意,这些东西其实也是stack中的内容,并不是栈之外的内容,因此叫"previous values"

从虚拟内存的角度理解一段汇编_2d_03

  • ​push rbp​​​指令将寄存器的值​​rbp​​​压入堆栈。因为它“推”到堆栈上,所以现在的值​​rsp​​是新堆栈顶部的内存地址。堆栈和寄存器如上图所示

从虚拟内存的角度理解一段汇编_c++_04

  • ​mov rbp, rsp​​将堆栈指针的值复制​​rsp​​到基指针rbp。现在​​rsp​​都指向堆栈的顶部

    从虚拟内存的角度理解一段汇编_c++_05

  • ​sub rsp, 0x10​​创建一个空间来存储局部变量的值。​​rbp​​和之间的空间​​rsp​​就是这个空间。请注意,这个空间足够大,可以存储我们的类型变量​​integer​


sub: subtract。 sub rsp, 0x10 相当于C++\Java中的 rsp = rsp - 16;

还是因为虚拟内存中的stack是向低地址位生长的,因此将栈顶向低地址位滑动


我们刚刚在内存中——在栈上——为我们的局部变量创建了一个空间。这个空间称为栈帧(stack frame)。每个具有局部变量的函数都将使用堆栈帧来存储这些变量


我们函数的第四行汇编代码如下:

400535:       c7 45 fc cc 03 00 00    mov    DWORD PTR [rbp-0x4],0x3cc


word为16bit,DWORD也就是double word,32bit。这正是现代c++中signed int的长度。而PTR就是pointer,代表地址

前面说到mov相当于C++\Java中的赋值,因此这里是一个赋值操作


这一行对应于我们的 C 代码行:

a = 972;

​mov DWORD PTR [rbp-0x4],0x3cc​​​正在将地址处的内存设置​​rbp - 4​​​为​​972​​​。​​[rbp - 4]​​​是我们的局部变量​​a​​。计算机实际上并不知道我们在代码中使用的变量的名称,它只是指堆栈上的内存地址

这是此操作后堆栈和寄存器的状态:

从虚拟内存的角度理解一段汇编_2d_06


我们现在查看函数的末尾,我们会发现:

400555:       c9                      leave

该指令​​leave​​​分为两步:设置​​rsp​​​为​​rbp​​​,然后将栈顶弹出到​​rbp​​.

从虚拟内存的角度理解一段汇编_c++_07

从虚拟内存的角度理解一段汇编_2d_08

因为我们​​rbp​​​在进入函数时将之前的值压入堆栈,​​rbp​​​所以现在设置为之前的值​​rbp​

  • 局部变量被“解除分配”,并且
  • 在我们离开当前函数之前恢复前一个函数的堆栈帧。

堆栈和寄存器​​rbp​​​的​​rsp​​​状态恢复到与我们进入​​main​​函数时相同的状态。


更深入地理解堆栈

当变量自动从堆栈中释放时,它们并没有完全“销毁”。它们的值仍在内存中,这个空间可能会被其他函数使用

这就是为什么在编写代码时初始化变量很重要,正如​​Effective C++​​中的条款04所说: 在使用对象之前请确定它已经初始化。 因为否则,它们将在程序运行时获取堆栈上的任何值

考虑如下代码:

#include <stdio.h>

void func1(void)
{
int a;
int b;
int c;

a = 98;
b = 972;
c = a + b;
printf("a = %d, b = %d, c = %d\n", a, b, c);
}

void func2(void)
{
int a;
int b;
int c;

printf("a = %d, b = %d, c = %d\n", a, b, c);
}

int main(void)
{
func1();
func2();
return (0);
}

输出

a = 98, b = 972, c = 1070
a = 98, b = 972, c = 1070

相同的变量值​​func1​​!这是因为堆栈的工作方式。这两个函数以相同的顺序声明了相同数量、相同类型的变量。它们的堆栈帧完全相同。结束时​​func1​​,其局部变量值所在的内存不会被清除 - 只会​​rsp​​增加。

因此,当我们调用​​func2​​它的堆栈帧时,它与前一个堆栈帧的位置完全相同​​func1​​,并且局部变量的​​func2​​值与​​func1​​我们离开时的局部变量的值相同​​func1​​。


注: 一个函数对应一个栈帧


对应汇编为:

000000000040052d <func1>:
40052d: 55 push rbp
40052e: 48 89 e5 mov rbp,rsp
400531: 48 83 ec 10 sub rsp,0x10
400535: c7 45 f4 62 00 00 00 mov DWORD PTR [rbp-0xc],0x62
40053c: c7 45 f8 cc 03 00 00 mov DWORD PTR [rbp-0x8],0x3cc
400543: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
400546: 8b 55 f4 mov edx,DWORD PTR [rbp-0xc]
400549: 01 d0 add eax,edx
40054b: 89 45 fc mov DWORD PTR [rbp-0x4],eax
40054e: 8b 4d fc mov ecx,DWORD PTR [rbp-0x4]
400551: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
400554: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
400557: 89 c6 mov esi,eax
400559: bf 34 06 40 00 mov edi,0x400634
40055e: b8 00 00 00 00 mov eax,0x0
400563: e8 a8 fe ff ff call 400410 <printf@plt>
400568: c9 leave
400569: c3 ret

000000000040056a <func2>:
40056a: 55 push rbp
40056b: 48 89 e5 mov rbp,rsp
40056e: 48 83 ec 10 sub rsp,0x10
400572: 8b 4d fc mov ecx,DWORD PTR [rbp-0x4]
400575: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
400578: 8b 45 f4 mov eax,DWORD PTR [rbp-0xc]
40057b: 89 c6 mov esi,eax
40057d: bf 34 06 40 00 mov edi,0x400634
400582: b8 00 00 00 00 mov eax,0x0
400587: e8 84 fe ff ff call 400410 <printf@plt>
40058c: c9 leave
40058d: c3 ret

000000000040058e <main>:
40058e: 55 push rbp
40058f: 48 89 e5 mov rbp,rsp
400592: e8 96 ff ff ff call 40052d <func1>
400597: e8 ce ff ff ff call 40056a <func2>
40059c: b8 00 00 00 00 mov eax,0x0
4005a1: 5d pop rbp
4005a2: c3 ret
4005a3: 66 2e 0f 1f 84 00 00 nop WORD PTR cs:[rax+rax*1+0x0]
4005aa: 00 00 00
4005ad: 0f 1f 00 nop DWORD PTR [rax]

如您所见,堆栈帧的形成方式始终是一致的。在我们的两个函数中,堆栈帧的大小是相同的,因为局部变量是相同的。

push   rbp
mov rbp,rsp
sub rsp,0x10

leave两个函数都以语句结尾。

变量a,b和c在两个函数中的引用方式相同:

a位于内存地址rbp - 0xc

b位于内存地址rbp - 0x8

c位于内存地址rbp - 0x4


call 与 ret


审视上面那段有点长的汇编代码,可以发现它每个函数(或者说栈帧)都有一个ret。
在其中的​​main部分​​,用到了call,现在来审视call与ret


  • 函数调用是如何实现的?汇编中有call语句
400592:       e8 96 ff ff ff          call   40052d <func1>
  • call语句标明了要跳转的指令地址,例如​​call 40052d<func1>​​​,但是​​func1​​执行结束之后怎么退出调用回到原处?
  • 原来,在调用​​call​​​语句时,它会把​​返回地址(或者说当前地址)​​推入栈顶。
  • 而ret语句调用时,会从堆栈中弹出栈顶的内容,也就是返回地址,从而正确返回到main中


​ret​​从堆栈中弹出返回地址并跳转到那里。当函数被调用时,程序​​call​​在跳转到被调用函数的第一条指令之前使用指令来压入返回地址。
这就是程序能够调用函数然后从所述函数返回调用函数以执行其下一条指令的方式。


如下图所示,调用call时,先把要返回的地址压入栈

从虚拟内存的角度理解一段汇编_2d_09

然后调用func1形成栈帧(stack frame)

从虚拟内存的角度理解一段汇编_2d_10

其它变量

现在回过头来看本文开头的汇编

square(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
imul eax, eax
pop rbp
ret

里面还有两个字符没搞懂是啥: edi、eax

  • edi、eax和rbp、rsp一样,是寄存器的名字。下面来把x86系列寄存器中的奇怪名称命名搞清楚


寄存器一个很妙的理解是: 与软件中的变量类比,相当于一种"硬件变量"



Like C++ variables, registers are actually available in several sizes:

  • rax is the 64-bit, “long” size register. It was added in 2003 during the transition to 64-bit processors.
  • eax is the 32-bit, “int” size register. It was added in 1985 during the transition to 32-bit processors with the 80386 CPU. I’m in the habit of using this register size, since they also work in 32 bit mode, although I’m trying to use the longer rax registers for everything.
  • ax is the 16-bit, “short” size register. It was added in 1979 with the 8086 CPU, but is used in DOS or BIOS code to this day.
  • al and ah are the 8-bit, “char” size registers. al is the low 8 bits, ah is the high 8 bits. They’re pretty similar to the old 8-bit registers of the 8008 back in 1972.


x64 汇编代码使用 16 个 64 位寄存器。此外,其中一些寄存器的低字节可以作为 32 位、16 位或 8 位寄存器独立访问。寄存器名称如下

从虚拟内存的角度理解一段汇编_c++_11

以rax寄存器为例,包含结构如下

从虚拟内存的角度理解一段汇编_堆栈_12

  • 如上图所示,最初的寄存器为8位。例如上图8位的al
  • 在DOS、8086中,8位的寄存器扩展为16位的ax,分为高8位ah和低8位al
  • 在80386中,进一步扩展为32位的eax,其中e代表extended
  • 64位处理器对应使用64位的rax,其中r代表register

再来看,上面几段汇编的含义已经已经很简单并且可以彻底理解了

参考