分享

通过汇编语言来深入理解程序

 新用户0118F7lQ 2023-09-17

1. C语言、汇编语言、机器语言

1.1 C语言代码

C语言属于高级语言,高级语言无法直接被计算机运行,需要转换为机器语言才能被计算机识别运行。

但是机器语言都是二进制数,为了方便阅读,我们通过标识符来标识机器语言,这些标识符就是汇编语言,汇编语言与机器语言是一一对应的。

下面是简单的C语言程序,就是调用add方法实现1020的相加。test.c

#include <stdio.h>

int add(int x,int y){
    return x + y;
}

int main()
{
    int res = add(10,20);
    return 0;
}

1.2 汇编代码

通过如下命令可以获取该C语言程序编译后的汇编语言,如下:

gcc -S test.c -o test.s
add:
        push    rbp
        mov     rbp, rsp
        mov     DWORD PTR [rbp-4], edi
        mov     DWORD PTR [rbp-8], esi
        mov     edx, DWORD PTR [rbp-4]
        mov     eax, DWORD PTR [rbp-8]
        add     eax, edx
        pop     rbp
        ret
main:
        push    rbp
        mov     rbp, rsp
        sub     rsp, 16
        mov     esi, 20
        mov     edi, 10
        call    add
        mov     DWORD PTR [rbp-4], eax
        mov     eax, 0
        leave
        ret
  • 这里为了方便阅读,去掉了很多不必要的注释。

1.3 机器语言

该汇编语言对应的机器语言如下:

55
48 89 e5
89 7d fc
89 75 f8
8b 55 fc
8b 45 f8
01 d0
5d
c3

55
48 89 e5
48 83 ec 10
be 14 00 00 00
bf 0a 00 00 00
e8 d5 ff ff ff
89 45 fc
b8 00 00 00 00
c9
c3
  • 用两位16进制数表示一个字节8位
  • 机器语言与汇编语言是一一对应的

2. 汇编语言操作码

常用的汇编语言操作码:

操作码操作数功能
movA,B把B的值赋给A
addA,B把A和B的值相加,并将结果赋给A
pushA把A的值存储在栈中
popA从栈中读取出值,并将其赋给A
callA调用函数A
ret将处理返回到函数的调用源

程序是指令与数据的集合,程序是在内存中被CPU解析执行的。

CPU操作的对象就是寄存器。

3. 寄存器类型

寄存器全称名称作用
eaxAccumulator Register累加寄存器运算
ebxBase Register基址寄存器存储内存地址
ecxCounter Register计数寄存器计算循环次数
esiSource Index Register源基址寄存器存储数据发送源的内存地址
ediDestination Index Register目标基址寄存器存储数据发送目标的内存地址
rbpRegister Base Pointer基址指针寄存器存储数据存储领域基点的内存地址
rspRegister Stack Pointer栈指针寄存器存储栈中最高位数据的内存地址
ebpExtended Base Pointer Register扩展基址指针寄存器存储数据存储领域基点的内存地址
espExtended Stack Pointer Register扩展栈指针寄存器存储栈中最高位数据的内存地址

4. 汇编代码分析

4.1 一行C代码对应多行汇编代码

图片
  • 看颜色对应。

先整体看一下汇编代码,有一个大概的印象。

add: ; 定义名为add的函数
push rbp ; 保存rbp寄存器的值到栈中
mov rbp, rsp ; 将rbp设置为当前栈顶指针rsp的值
mov DWORD PTR [rbp-4], edi ; 将第一个参数edi的值存储到rbp-4位置的4字节内存中
mov DWORD PTR [rbp-8], esi ; 将第二个参数esi的值存储到rbp-8位置的4字节内存中
mov edx, DWORD PTR [rbp-4] ; 从rbp-4位置读取4字节内存中的值到edx寄存器
mov eax, DWORD PTR [rbp-8] ; 从rbp-8位置读取4字节内存中的值到eax寄存器
add eax, edx ; 将eax和edx的值相加,结果存储在eax寄存器中
pop rbp ; 恢复之前保存的rbp寄存器的值
ret ; 返回eax寄存器的值
main: ; 定义名为main的函数
push rbp ; 保存rbp寄存器的值到栈中
mov rbp, rsp ; 将rbp设置为当前栈顶指针rsp的值
sub rsp, 16 ; 在栈顶位置向下移动16字节的空间
mov esi, 20 ; 将20赋值给esi寄存器,作为第二个参数
mov edi, 10 ; 将10赋值给edi寄存器,作为第一个参数
call add ; 调用add函数
mov DWORD PTR [rbp-4], eax ; 将add函数返回的值存储到rbp-4位置的4字节内存中
mov eax, 0 ; 将0赋值给eax寄存器,作为返回值
leave ; 恢复栈顶指针rsp,等价于mov rsp, rbp; pop rbp
ret ; 返回eax寄存器的值
  • 可以看到指令都是对寄存器的操作。

4.2 栈内存

程序在运行时会在内存分配一个称为的内存空间。的特性是后进先出(Last In First Out,LIFO)。

栈像堆碟子一样,一个个堆在上面,取得时候从上面一个个取。

图片

4.3 栈帧分配

在函数调用过程中,栈内存中会产生一个栈帧,栈帧中存储了函数调用所需要的信息,包括函数参数、局部变量、返回地址以及其他的上下文信息等。当函数调用结束后,这个栈帧会被删除,并释放在栈内存上的相应空间。

主函数 mainadd 函数都使用了栈帧分配。让我们来计算一下各个栈帧分配的大小:

main 函数中,分配了一个栈帧,具体步骤如下:

  1. push rbp 将调用者的基址指针 rbp 入栈,占用 8 字节。
  2. mov rbp, rsp 将栈顶指针 rsp 的值赋给 rbp,相当于 rbp 指向当前栈帧的基址,不占用额外的栈空间。
  3. sub rsp, 16 分配 16 字节的空间给当前栈帧的局部变量和参数,预计 4 字节用于保存 add 函数的返回值 eax,因此为局部变量留出 12 字节的空间。
  4. 其他指令的栈操作没有涉及栈帧的空间分配,不占用额外的栈空间。

综上,main 函数的栈帧分配大小为 8 字节(push rbp) + 16 字节(sub rsp, 16)= 24 字节。

add 函数中,也分配了一个栈帧,具体步骤如下:

  1. push rbp 将调用者 main 函数的基址指针 rbp 入栈,占用 8 字节。
  2. mov rbp, rsp 将栈顶指针 rsp 的值赋给 rbp,相当于 rbp 指向当前栈帧的基址,不占用额外的栈空间。
  3. mov DWORD PTR [rbp-4], edimain 函数的第一个参数 edi 存储在当前栈帧的位置 [rbp-4],占用 4 字节。
  4. mov DWORD PTR [rbp-8], esimain 函数的第二个参数 esi 存储在当前栈帧的位置 [rbp-8],占用 4 字节。
  5. 其他指令的栈操作没有涉及栈帧的空间分配,不占用额外的栈空间。

综上,add 函数的栈帧分配大小为 8 字节(push rbp)+ 4 字节(mov DWORD PTR [rbp-4], edi)+ 4 字节(mov DWORD PTR [rbp-8], esi)= 16 字节。

4.4 汇编代码分析

  1. main 函数首先会将当前的基址指针 rbp 压入栈中,并将 rbp 的值存储到栈顶,即执行 push rbpmov rbp, rsp

  2. 接着,main 函数会在当前栈帧的栈顶位置分配一个大小为 16 字节的区域,即执行 sub rsp, 16

  3. 然后,main 函数将第一个参数 20 和第二个参数 10 分别存储到 esiedi 中。

  4. main 函数紧接着调用了 add 函数,即执行 call add。在执行 call 指令时,会将当前指令所在的下一条指令的地址(即 add 函数的入口地址)推入栈中,同时将 rsp 的值减去 8,以便在调用子例程时建立新的栈帧。

  5. add 函数执行时,会在栈顶再建立一个新的栈帧。首先,add 函数会将当前的基址指针 rbp 压入栈中,并将其值存储在栈顶,即执行 push rbpmov rbp, rsp

  6. 接着,add 函数会将第一个参数 edi 和第二个参数 esi 分别存储到 [rbp-4][rbp-8],即执行 mov DWORD PTR [rbp-4], edimov DWORD PTR [rbp-8], esi

  7. add 函数接下来将第二个参数 esi 和第一个参数 edi 分别移动到寄存器 eaxedx 中。

  8. 然后,add 函数执行 add eax, edx,将这两个数相加,并将结果存储到寄存器 eax 中。

  9. 接下来,add 函数执行 pop rbp,将当前的基址指针弹出栈中,并将其值存储到 rbp 中。同时,add 函数通过执行 ret 返回到 call 指令之后的下一条指令执行,并且将结果存储到寄存器 eax 中。

  10. main 函数会将 add 函数的返回值从寄存器 eax 复制到 [rbp-4] 中,即执行 mov DWORD PTR [rbp-4], eax

  11. 然后,main 函数将 eax 寄存器复位为 0,以使其成为函数的返回值,即执行 mov eax, 0

  12. main 函数接着执行 leave,该指令等价于 mov rsp, rbp,然后 pop rbp,用于恢复栈帧的状态。

  13. 最后,main 函数执行 ret,使程序从 main 函数返回到调用该函数的地方,并且将返回值存储在寄存器 eax 中。

4.5 通过寄存器和栈来传递参数

我们再来分析一下上述汇编代码,可以看出函数调用使用「寄存器」「栈」来传递参数。具体来说:

  1. main 函数中,参数的值被存储在 esi 寄存器和 edi 寄存器中。mov esi, 20 将整型值 20 存储在 esi 寄存器中,mov edi, 10 将整型值 10 存储在 edi 寄存器中。

  2. 接着在 main 函数中,通过调用 call add 跳转到 add 函数,并将控制权转移到 add 函数中执行。

  3. add 函数中,首先使用 push rbp 将调用者的基址指针 rbp 入栈,保存 add 函数之前的基址。然后通过 mov DWORD PTR [rbp-4], edi 将第一个参数 10 存储到 [rbp-4] 的位置上,使用 mov DWORD PTR [rbp-8], esi 将第二个参数 20 存储到 [rbp-8] 的位置上。这里通过将参数存储到当前函数的栈帧中,实现了参数的传递。

  4. add 函数中,通过 mov edx, DWORD PTR [rbp-4]mov eax, DWORD PTR [rbp-8] 读取栈帧中存储的两个参数的值,分别赋值给 edxeax 寄存器。

  5. 接下来,通过 add eax, edx 指令将 eaxedx 中的值相加,计算结果存储在 eax 寄存器中,即作为函数的返回值。

  6. 最后,在 add 函数中使用 pop rbp 将之前入栈的基址指针 rbp 出栈,以恢复调用方的栈帧。通过 ret 指令返回到调用 add 函数的位置。

  7. 控制权回到 main 函数后,通过 mov DWORD PTR [rbp-4], eaxadd 函数的返回值存储到 [rbp-4] 的位置上。使用 mov eax, 0eax 寄存器置为 0,再通过 leave 指令清除当前栈帧并恢复调用方的栈帧。最后使用 ret 返回到调用方。

综上所述,函数参数通过寄存器 esiedi 来传递,并且通过将参数存储在当前函数栈帧中的方式,在被调用函数中恢复参数值的情况下进行处理。

5. 总结

本文通过一个简单的C语言代码示例,详细阐述了汇编代码的流程和相关知识点。

具体地,我们通过分析这个C语言代码,生动呈现了对应的汇编代码。在汇编代码中,我们讲解了寄存器、汇编指令操作码、内存地址等一系列关键概念。

此外,我们还深入探究了函数调用的原理,包括栈内存和栈帧的概念、栈帧相关的数据(如返回地址、函数参数、局部变量)等。通过了解这些内容,读者可以更充分地了解程序在运行时是如何存储和处理数据的,如何通过不断的函数调用来构建整个程序的流程等。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多