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

文章由个人翻译和整理自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 <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指的是rbprsp; 这些是特殊用途的寄存器。rbp是基指针,指向当前栈帧的基点,rsp是栈指针,指向当前栈帧的顶部

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

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

虚拟内存中stack部分的初始状态:
在这里插入图片描述

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

在这里插入图片描述

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

在这里插入图片描述

  • mov rbp, rsp将堆栈指针的值复制rsp到基指针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 - 4972[rbp - 4]是我们的局部变量a计算机实际上并不知道我们在代码中使用的变量的名称,它只是指堆栈上的内存地址

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

在这里插入图片描述


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

  400555:       c9                      leave  

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

在这里插入图片描述

在这里插入图片描述

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

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

堆栈和寄存器rbprsp状态恢复到与我们进入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时,先把要返回的地址压入栈

在这里插入图片描述

然后调用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

  • 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 位寄存器独立访问。寄存器名称如下

在这里插入图片描述
以rax寄存器为例,包含结构如下

在这里插入图片描述

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

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

参考

  • 5
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值