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
- 用两位16进制数表示一个字节8位
- 机器语言与汇编语言是一一对应的
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语言代码,生动呈现了对应的汇编代码。在汇编代码中,我们讲解了寄存器、汇编指令操作码、内存地址等一系列关键概念。
此外,我们还深入探究了函数调用的原理,包括栈内存和栈帧的概念、栈帧相关的数据(如返回地址、函数参数、局部变量)等。通过了解这些内容,读者可以更充分地了解程序在运行时是如何存储和处理数据的,如何通过不断的函数调用来构建整个程序的流程等。
关注微信公众号:“小虎哥的技术博客”,让我们一起成为更优秀的程序员❤️!
文章和代码仓库:
gitee(推荐):gitee.com/cunzaizhe/x…
github:github.com/tigerleeli/…