什么是寄存器
通俗的理解:
寄存器就是你的口袋。身上只有那么几个,只装最常用或者马上要用的东西。
内存就是你的背包。有时候拿点什么放到口袋里,有时候从口袋里拿出点东西放在背包里。
辅存就是你家里的抽屉。可以放很多东西,但存取不方便。
寄存器的种类
操作系统有32位和64位之分,因此寄存器的位数也不一样:
32位和62位的差异:
- 32位有8个通用寄存器,64位有16个通用寄存器
- 32位的8个寄存器名称都是r开头;64位的16个寄存器名称都是e开头
寄存器的逻辑结构
以RAX为例,寄存器的逻辑结构如下图所示:
RAX寄存器是一个64位寄存器,那么它可以存储一个64位的数据。RAX寄存器是在64位处理器中工作的,它的上一代处理器是32位处理器,而32位处理器的上一代是16位处理器。为了保证兼容,使之前的程序的可以在64位处理器上运行,使得RAX寄存器包含了32位、16位、8位寄存器。
RAX寄存器可分为一个可独立使用的32位寄存器:EAX(32位);32位寄存器还可以分为一个16位的AX寄存器;AX还可以分为两个独立使用的8位寄存器:AH(8位~15位为高8位)、AL(0位~7位为低8位)。
寄存器的用途
每个寄存器都有自己的用途,下面是32位和64位寄存器的用途说明:
32位寄存器:
- EAX:累加寄存器(Accumulator),操作数和结果数据累加器,保存返回值
- EBX:基地址寄存器(Base),DS段的数据指针,在内存寻址时存放基地址
- ECX:计数寄存器(Counter),字符串和循环操作的计数器
- EDX:数据寄存器(Data),保存乘法形成的部分结果或者除法之前部分被除数
- ESI:字符串操作的源(Source Index)指针,SS段的数据指针
- EDI:字符串操作的目标(Destination Index)指针,ES段的数据指针
- EBP:基址指针(Base pointer),存放栈底指针
- ESP:堆栈(Stack pointer)指针,存放栈顶指针
此外,CPU内还有6个段寄存器、1个指令寄存器:
CPU内部的段寄存器:
- CS——代码段寄存器(Code Segment Register),其值为代码段的段值;
- DS——数据段寄存器(Data Segment Register),其值为数据段的段值;
- ES——附加段寄存器(Extra Segment Register),其值为附加数据段的段值;
- SS——堆栈段寄存器(Stack Segment Register),其值为堆栈段的段值;
- FS——附加段寄存器(Extra Segment Register),其值为附加数据段的段值;
- GS——附加段寄存器(Extra Segment Register),其值为附加数据段的段值。
指令寄存器:
- EIP:(Instruction Pointer)是存放下一条指令的内存地址
64位寄存器:
- RAX:返回值
- RBX:被调用者保存
- RCX:第四个参数
- RDX:第三个参数
- RSI:第四个参数
- RDI:第三个参数
- RBP:存放栈底指针
- RSP:存放栈顶指针
- R8:第5个参数
- R9:第6个参数
- R10~R15:被调用者保存
- R8~R15属于普通寄存器,支持拆分,但是拆分的寄存器在命名规则上与特殊功能寄存器有所不同。32位拆分寄存器以
D
作为后缀(DWORD),16位寄存器以W
作为后缀(WORD),8位则以B
作为后缀(BYTE)。
常用的汇编指令
1.NOP :交换指令,操作数为90,什么也不干
2.PUSH x:将x压入栈,esp先减再压数据
3.POP x: 将栈顶数据弹出到x中,esp递增
4.CALL x:将call指令下一条指令压入栈中,改变eip的值为x
5.RET: 将栈顶数据弹出到eip 若为RET X 表示esp再增加x字节
6.MOV 目的, 源:将源的值传给目的
7.LEA x1,x2:将x2的地址载入到x1
8.ADD x1,x2:x1=x1+x2,指令的计算结果将影响eflags寄存器
9.SUB x1,x2:x1=x1-x2,同add,不能同时是内存操作数
10. jmp x: 将eip的值改为x。 jmp -2意味着jmp指令的无限循环
11. jcc x:当条件满足时跳转到x
12. cmp x1, x2: 用于比较两个操作数的大小
13. test x1, x2:通过计算两个操作数的逻辑与位运算实现比较操作,根据结果设置eflags
14. AND x1,x2: x1 = x1&x2
15. OR x1,x2: x1 = x1|x2
16. XOR x1,x2: x1 = x1^x2 常用于清零,效率高xor eax eax
17. not x1: x1 = ~x1
18. SHL x1,x2:x1=x1<<x2逻辑左移,右边补0, SAL:算术左移
19. SHR x1,x2:x1=x1>>x2 逻辑右移,左边补0, SAR:算术右移,左边补符号
20. IMUL/MUL: imul实现有符号数的乘法运算,mul是无符号乘法
imul r/m32 eax=eax*r/m*32
imul reg,r/m reg=reg*r/m32
imul reg,r/m32,immediate reg=r/m32*immediate
21. IDIV/DIV: div用于实现无符号数的除法运算
idiv ecx: eax / ecx 商放在eax,余数放在edx
div ax, r/m8 ; ax 除以 r/m8, al是商,ah是余数
div eax,r/m32; eax除以r/m32,eax是商,edx是余数
若被除数是32位数,在指令执行前edx被置为0
22. REP STOS; rep是前缀,表示重复执行某项操作
23. REP MOVS:重复执行movs操作
24. LEAVE: 用于子函数推出时清理栈的操作,ret和leave选择取决于编译器。 等价于mov esp, ebp; pop ebp
函数调用栈的过程
什么是栈帧
首先,什么是栈帧?引用百度百科:C语言中,每个栈帧对应着一个未运行完的函数。栈帧中保存了该函数的返回地址和局部变量。从这句话中,可以提炼以下几点信息:
- 栈帧是一块因函数运行而临时开辟的空间。
- 每调用一次函数便会创建一个独立栈帧。
- 栈帧中存放的是函数中的必要信息,如局部变量、函数传参、返回值等。
- 当函数运行完毕栈帧将会销毁。
ESP,EBP,EAX寄存器
栈帧中有2个重要的寄存器:esp和ebp。可以把esp和ebp看作是两个指针,ebp指向当前栈帧栈底,esp指向函数栈栈顶。
代码演示
演示代码:
#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);
return 0;
}
这段代码,我们在VS2022编译器上调试,调试进入Add函数后,我们就可以观察到函数的调用堆栈:
我们可以从中清晰的观察到,main 函数调用之前,是由 invoke_main 函数来调用 main 函数.
在 invoke_main 函数之前的函数调用我们暂时就不考虑了。
那我们可以确定,invoke_main 函数应该会有自己的栈帧,main 函数和 Add 函数也会维护自己的栈帧,每个函数栈帧都由 ebp 和 esp 来维护栈帧空间。
那接下来我们从 main 函数的栈帧创建开始讲解。
main函数反汇编代码
我们在main函数内设置好断点,然后按F5启动调试。然后调出反汇编代码:
int main()
{
00E016E0 push ebp
00E016E1 mov ebp,esp
00E016E3 sub esp,0E4h
00E016E9 push ebx
00E016EA push esi
00E016EB push edi
00E016EC lea edi,[ebp+FFFFFF1Ch]
00E016F2 mov ecx,39h
00E016F7 mov eax,0CCCCCCCCh
00E016FC rep stos dword ptr es:[edi]
int a = 10;
00E016FE mov dword ptr [ebp-8],0Ah
int b = 20;
00E01705 mov dword ptr [ebp-14h],14h
int c = 0;
00E0170C mov dword ptr [ebp-20h],0
c = Add(a, b);
00E01713 mov eax,dword ptr [ebp-14h]
00E01716 push eax
00E01717 mov ecx,dword ptr [ebp-8]
00E0171A push ecx
00E0171B call 00E010F0
00E01720 add esp,8
00E01723 mov dword ptr [ebp-20h],eax
return 0;
00E01726 xor eax,eax
}
由于main函数是由 invoke_main 函数调用的,所以我们这里先画出 invoke_main 函数的函数栈帧:
第一步:push ebp。把ebp压入栈中,esp-4
第二步:mov ebp, esp。把esp的值存入ebp(使ebp和esp同时指向栈顶)
第三步:sub esp, 0E4h。esp减去0E4h的大小,即esp下移0E4h。为main函数开辟栈空间
第四步~第六步:分别将ebx、esi、edi压栈,esp-4,esp-4,esp-4
- push ebx
- push esi
- push edi
第七步~第十步:
- lea edi,[ebp+FFFFFF1Ch]
- mov ecx,39h
- mov eax,0CCCCCCCCh
- rep stos dword ptr es:[edi]
- 先把 ebp+FFFFFF1Ch (等于ebp+0E4h)放到edi中
- 把 39h 放到 ecx中
- 把 0xCCCCCCCC 放在 eax 中
- 将 ebp+FFFFFF1Ch 到 ebp 这一段的内存的每个字节都初始化为 0xcccccccc
将这四步执行完后,发现main函数栈对应的这块内存全部被初始化为0xcccccccc:
所以,当我们打印未初始化的内存内容,会输出 “烫”这么一个奇怪的字,0xcccccccc的汉字编码就是“烫”。
那么现在main函数的函数栈帧就创建完毕了,接下来就是main函数中代码的执行。
第十一步:mov dword ptr [ebp-8],0Ah。将0Ah(10)存储到ebp-8的地址处,ebp-8的位置就是变量a。
第十二步:mov dword ptr [ebp-14h],14h。 将14h(20)存储到ebp-14h的地址处,ebp-14h的位置其实就是b变量:
第十三步:mov dword ptr [ebp-20h],0。将0存储到ebp-20h的地址处,ebp-20h的位置其实就是c变量:
第十四步~第十七步:调用Add函数(传参):Add(a, b)
- mov eax,dword ptr [ebp-14h]
- push eax
- mov ecx,dword ptr [ebp-8]
- push ecx
- 传递b(从右往左扫描)将ebp-14h处放的20放在eax寄存器中
- 将eax的值压栈,esp-4
- 传递a,将ebp-8处的10放在ecx寄存器中
- 将ecx的值压栈,esp-4
第十八步:调用Add函数:
call指令是要执行函数调用的,它会执行2个动作:
- 在执行call指令之前会把call指令的下一条add指令的地址进行压栈操作。这个操作是为了在函数调用结束后回到call指令的下一条指令add的地方,继续往后执行。我们可以看到call指令的下一条指令add的地址是:00E01720
- 保存下一步要执行的指令的地址。即:使eip寄存器的值为00E010F0
第十九步:这个时候我们按键盘上的F11进入Add函数:
Add函数的反汇编代码
再按一下F11,就可以跳转到Add函数。Add函数的反汇编代码如下:
int Add(int x, int y)
{
00E01690 push ebp
00E01691 mov ebp,esp
00E01693 sub esp,0CCh
00E01699 push ebx
00E0169A push esi
00E0169B push edi
00E0169C lea edi,[ebp+FFFFFF34h]
00E016A2 mov ecx,33h
00E016A7 mov eax,0CCCCCCCCh
00E016AC rep stos dword ptr es:[edi]
int z = 0;
00E016AE mov dword ptr [ebp-8],0
z = x + y;
00E016B5 mov eax,dword ptr [ebp+8]
00E016B8 add eax,dword ptr [ebp+0Ch]
00E016BB mov dword ptr [ebp-8],eax
return z;
00E016BE mov eax,dword ptr [ebp-8]
}
00E016C1 pop edi
00E016C2 pop esi
00E016C3 pop ebx
00E016C4 mov esp,ebp
00E016C6 pop ebp
00E016C7 ret
第一步~第六步:为Add函数创建一块栈帧空间
- push ebp
- mov ebp,esp //让ebp指向esp
- sub esp,0CCh
- push ebx
- push esi
- push edi
为Add函数中创建栈帧的方法和main函数中是相似的,在栈帧空间的大小上略有差异而已。
- 将main函数的ebp压栈,esp-4
- 计算新的ebp,esp
- 将ebx,esi,edi寄存器的值保存
第七~十步:初始化add函数的栈帧空间
- lea edi,[ebp+FFFFFF34h]
- mov ecx,33h
- mov eax,0CCCCCCCCh
- rep stos dword ptr es:[edi]
第十一步:将0存储到ebp-8的地址处,ebp-8的位置就是变量z。
mov dword ptr [ebp-8],0
第十二、三步:计算z的值
- mov eax,dword ptr [ebp+8]
- add eax,dword ptr [ebp+0Ch]
- 将ebp+8地址处存放的值存入eax寄存器中,即把形参a存入eax
- 将ebp+12地址处的数字加到eax寄存器中,即把形参b加入eax
以上两步算出a+b的值,并把其存入了寄存器eax中。
第十二、三步:将eax的结果保存到ebp-8的地址处,其实就是放到z中
- mov dword ptr [ebp-8],eax
第十四步:将ebp-8地址处存放的值放入eax中,其实就是把z的值存储到eax寄存器中,这里是想通过eax寄存器带回计算的结果,做函数的返回值。
- mov eax,dword ptr [ebp-8]
做完这一步我们的函数调用就将结束了,应该开始返回了。
函数栈帧的销毁
当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁。
那具体是怎么销毁的呢?我们来看一下反汇编代码。
第一步~第三步:
- 在栈顶弹出一个值,存放到edi中,esp+4
- 在栈顶弹出一个值,存放到esi中,esp+4
- 在栈顶弹出一个值,存放到ebx中,esp+4
第四步:让esp指向ebp,此时ebp和esp指向同一处
- mov esp,ebp
第五步:弹出栈顶的值存放到ebp,栈顶此时的值恰好就是之前main函数的ebp。
- pop ebp
esp+4,此时恢复的main函数的栈帧维护,esp指向main函数栈帧的栈顶,ebp指向main函数栈帧的栈底:
第六步:ret指令会将栈顶数据弹出给eip,然后esp+4。
然后直接跳转到call指令的下一条指令的地址00E01720处,回到main函数,继续往下执行:
第一步:esp直接+8,相当于跳过了main函数中压栈的a和b
- add esp,8
第二步:把eax的值赋给ebp-20h地址所指向的空间,其实就是存储到main函数的c变量中,而此时eax中就是Add函数中计算的x和y的和,可以看出来,本次函数的返回值是由eax寄存器带回来的。程序是在函数调用返回之后,在eax中去读取返回值的。
- mov dword ptr [ebp-20h],eax
参考文献: