1. C语言、汇编语言、机器语言
1.1 C语言代码
C语言属于高级语言,高级语言无法直接被计算机运行,需要转换为机器语言才能被计算机识别运行。
但是机器语言都是二进制数,为了方便阅读,我们通过标识符来标识机器语言,这些标识符就是汇编语言,汇编语言与机器语言是一一对应的。
下面是简单的C语言程序,就是调用add
方法实现10
和20
的相加。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
2. 汇编语言操作码
常用的汇编语言操作码:
操作码 | 操作数 | 功能 |
---|
mov | A,B | 把B的值赋给A |
add | A,B | 把A和B的值相加,并将结果赋给A |
push | A | 把A的值存储在栈中 |
pop | A | 从栈中读取出值,并将其赋给A |
call | A | 调用函数A |
ret | 无 | 将处理返回到函数的调用源 |
程序是指令与数据的集合,程序是在内存中被CPU解析执行的。
CPU操作的对象就是寄存器。
3. 寄存器类型
寄存器 | 全称 | 名称 | 作用 |
---|
eax | Accumulator Register | 累加寄存器 | 运算 |
ebx | Base Register | 基址寄存器 | 存储内存地址 |
ecx | Counter Register | 计数寄存器 | 计算循环次数 |
esi | Source Index Register | 源基址寄存器 | 存储数据发送源的内存地址 |
edi | Destination Index Register | 目标基址寄存器 | 存储数据发送目标的内存地址 |
rbp | Register Base Pointer | 基址指针寄存器 | 存储数据存储领域基点的内存地址 |
rsp | Register Stack Pointer | 栈指针寄存器 | 存储栈中最高位数据的内存地址 |
ebp | Extended Base Pointer Register | 扩展基址指针寄存器 | 存储数据存储领域基点的内存地址 |
esp | Extended 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 栈帧分配
在函数调用过程中,栈内存中会产生一个栈帧,栈帧中存储了函数调用所需要的信息,包括函数参数、局部变量、返回地址以及其他的上下文信息等。当函数调用结束后,这个栈帧会被删除,并释放在栈内存上的相应空间。
主函数 main
和 add
函数都使用了栈帧分配。让我们来计算一下各个栈帧分配的大小:
在 main
函数中,分配了一个栈帧,具体步骤如下:
push rbp
将调用者的基址指针 rbp
入栈,占用 8 字节。mov rbp, rsp
将栈顶指针 rsp
的值赋给 rbp
,相当于 rbp
指向当前栈帧的基址,不占用额外的栈空间。sub rsp, 16
分配 16 字节的空间给当前栈帧的局部变量和参数,预计 4 字节用于保存 add
函数的返回值 eax
,因此为局部变量留出 12 字节的空间。- 其他指令的栈操作没有涉及栈帧的空间分配,不占用额外的栈空间。
综上,main
函数的栈帧分配大小为 8 字节(push rbp
) + 16 字节(sub rsp, 16
)= 24 字节。
在 add
函数中,也分配了一个栈帧,具体步骤如下:
push rbp
将调用者 main
函数的基址指针 rbp
入栈,占用 8 字节。mov rbp, rsp
将栈顶指针 rsp
的值赋给 rbp
,相当于 rbp
指向当前栈帧的基址,不占用额外的栈空间。mov DWORD PTR [rbp-4], edi
将 main
函数的第一个参数 edi
存储在当前栈帧的位置 [rbp-4]
,占用 4 字节。mov DWORD PTR [rbp-8], esi
将 main
函数的第二个参数 esi
存储在当前栈帧的位置 [rbp-8]
,占用 4 字节。- 其他指令的栈操作没有涉及栈帧的空间分配,不占用额外的栈空间。
综上,add
函数的栈帧分配大小为 8 字节(push rbp
)+ 4 字节(mov DWORD PTR [rbp-4], edi
)+ 4 字节(mov DWORD PTR [rbp-8], esi
)= 16 字节。
4.4 汇编代码分析
main
函数首先会将当前的基址指针 rbp
压入栈中,并将 rbp
的值存储到栈顶,即执行 push rbp
和 mov rbp, rsp
。
接着,main
函数会在当前栈帧的栈顶位置分配一个大小为 16 字节的区域,即执行 sub rsp, 16
。
然后,main
函数将第一个参数 20 和第二个参数 10 分别存储到 esi
和 edi
中。
main
函数紧接着调用了 add
函数,即执行 call add
。在执行 call
指令时,会将当前指令所在的下一条指令的地址(即 add
函数的入口地址)推入栈中,同时将 rsp
的值减去 8,以便在调用子例程时建立新的栈帧。
当 add
函数执行时,会在栈顶再建立一个新的栈帧。首先,add
函数会将当前的基址指针 rbp
压入栈中,并将其值存储在栈顶,即执行 push rbp
和 mov rbp, rsp
。
接着,add
函数会将第一个参数 edi
和第二个参数 esi
分别存储到 [rbp-4]
和 [rbp-8]
,即执行 mov DWORD PTR [rbp-4], edi
和 mov DWORD PTR [rbp-8], esi
。
add
函数接下来将第二个参数 esi
和第一个参数 edi
分别移动到寄存器 eax
和 edx
中。
然后,add
函数执行 add eax, edx
,将这两个数相加,并将结果存储到寄存器 eax
中。
接下来,add
函数执行 pop rbp
,将当前的基址指针弹出栈中,并将其值存储到 rbp
中。同时,add
函数通过执行 ret
返回到 call
指令之后的下一条指令执行,并且将结果存储到寄存器 eax
中。
main
函数会将 add
函数的返回值从寄存器 eax
复制到 [rbp-4]
中,即执行 mov DWORD PTR [rbp-4], eax
。
然后,main
函数将 eax
寄存器复位为 0,以使其成为函数的返回值,即执行 mov eax, 0
。
main
函数接着执行 leave
,该指令等价于 mov rsp, rbp
,然后 pop rbp
,用于恢复栈帧的状态。
最后,main
函数执行 ret
,使程序从 main
函数返回到调用该函数的地方,并且将返回值存储在寄存器 eax
中。
4.5 通过寄存器和栈来传递参数
我们再来分析一下上述汇编代码,可以看出函数调用使用「寄存器」和「栈」来传递参数。具体来说:
在 main
函数中,参数的值被存储在 esi
寄存器和 edi
寄存器中。mov esi, 20
将整型值 20
存储在 esi
寄存器中,mov edi, 10
将整型值 10
存储在 edi
寄存器中。
接着在 main
函数中,通过调用 call add
跳转到 add
函数,并将控制权转移到 add
函数中执行。
在 add
函数中,首先使用 push rbp
将调用者的基址指针 rbp
入栈,保存 add
函数之前的基址。然后通过 mov DWORD PTR [rbp-4], edi
将第一个参数 10
存储到 [rbp-4]
的位置上,使用 mov DWORD PTR [rbp-8], esi
将第二个参数 20
存储到 [rbp-8]
的位置上。这里通过将参数存储到当前函数的栈帧中,实现了参数的传递。
在 add
函数中,通过 mov edx, DWORD PTR [rbp-4]
和 mov eax, DWORD PTR [rbp-8]
读取栈帧中存储的两个参数的值,分别赋值给 edx
和 eax
寄存器。
接下来,通过 add eax, edx
指令将 eax
和 edx
中的值相加,计算结果存储在 eax
寄存器中,即作为函数的返回值。
最后,在 add
函数中使用 pop rbp
将之前入栈的基址指针 rbp
出栈,以恢复调用方的栈帧。通过 ret
指令返回到调用 add
函数的位置。
控制权回到 main
函数后,通过 mov DWORD PTR [rbp-4], eax
将 add
函数的返回值存储到 [rbp-4]
的位置上。使用 mov eax, 0
将 eax
寄存器置为 0,再通过 leave
指令清除当前栈帧并恢复调用方的栈帧。最后使用 ret
返回到调用方。
综上所述,函数参数通过寄存器 esi
和 edi
来传递,并且通过将参数存储在当前函数栈帧中的方式,在被调用函数中恢复参数值的情况下进行处理。
5. 总结
本文通过一个简单的C语言代码示例,详细阐述了汇编代码的流程和相关知识点。
具体地,我们通过分析这个C语言代码,生动呈现了对应的汇编代码。在汇编代码中,我们讲解了寄存器、汇编指令操作码、内存地址等一系列关键概念。
此外,我们还深入探究了函数调用的原理,包括栈内存和栈帧的概念、栈帧相关的数据(如返回地址、函数参数、局部变量)等。通过了解这些内容,读者可以更充分地了解程序在运行时是如何存储和处理数据的,如何通过不断的函数调用来构建整个程序的流程等。