文章由个人翻译和整理自Holbert School的系列文章与CS301、Brown University的x64 Register教程,链接位于文末
关于进程在虚拟内存的布局,一张经典的解释图是:
在一段完整的汇编程序中,我们首先要关注的是其实是图中的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
int main(void)
{
int a;
a = 972;
printf("a = %d\n", a);
return (0);
}
对应汇编是:
000000000040052d :
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
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 :
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。其作用是标定栈顶,其值会不断变化。因为虚拟内存中栈的地址是向下生长的,因此入栈操作会使它存储的值看起来不断变小
command-line arguments and env var
。注意,这些东西其实也是stack中的内容,并不是栈之外的内容,因此叫"previous values"push rbp
指令将寄存器的值rbp
压入堆栈。因为它“推”到堆栈上,所以现在的值rsp
是新堆栈顶部的内存地址。堆栈和寄存器如上图所示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
。计算机实际上并不知道我们在代码中使用的变量的名称,它只是指堆栈上的内存地址。
这是此操作后堆栈和寄存器的状态:
我们现在查看函数的末尾,我们会发现:
400555: c9 leave
该指令leave
分为两步:设置rsp
为rbp
,然后将栈顶弹出到rbp
.
因为我们rbp
在进入函数时将之前的值压入堆栈,rbp
所以现在设置为之前的值rbp
堆栈和寄存器rbp
的rsp
状态恢复到与我们进入main
函数时相同的状态。
当变量自动从堆栈中释放时,它们并没有完全“销毁”。它们的值仍在内存中,这个空间可能会被其他函数使用
这就是为什么在编写代码时初始化变量很重要,正如Effective C++
中的条款04所说: 在使用对象之前请确定它已经初始化。 因为否则,它们将在程序运行时获取堆栈上的任何值
考虑如下代码:
#include
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 :
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
400568: c9 leave
400569: c3 ret
000000000040056a :
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
40058c: c9 leave
40058d: c3 ret
000000000040058e :
40058e: 55 push rbp
40058f: 48 89 e5 mov rbp,rsp
400592: e8 96 ff ff ff call 40052d
400597: e8 ce ff ff ff call 40056a
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
审视上面那段有点长的汇编代码,可以发现它每个函数(或者说栈帧)都有一个ret。
在其中的main部分
,用到了call,现在来审视call与ret
400592: e8 96 ff ff ff call 40052d
call 40052d
,但是func1
执行结束之后怎么退出调用回到原处?call
语句时,它会把返回地址(或者说当前地址)
推入栈顶。
ret
从堆栈中弹出返回地址并跳转到那里。当函数被调用时,程序call
在跳转到被调用函数的第一条指令之前使用指令来压入返回地址。
这就是程序能够调用函数然后从所述函数返回调用函数以执行其下一条指令的方式。
如下图所示,调用call时,先把要返回的地址压入栈
然后调用func1形成栈帧(stack frame)
现在回过头来看本文开头的汇编
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
寄存器一个很妙的理解是: 与软件中的变量类比,相当于一种"硬件变量"
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 位寄存器独立访问。寄存器名称如下
再来看,上面几段汇编的含义已经已经很简单并且可以彻底理解了
Hack the Virtual Memory: drawing the VM diagram
Hack the virtual memory: the stack, registers and assembly code
CS301–Registers in x86 Assembly
x64 Cheat Sheet – Brown University