第一章 汇编指令与C语言
一、 上机建立第一个工程
用C/C++编写的正确代码经过编译器生成的汇编代码的汇编指令的用法一定是正确的。
1. 用visual studio 创建工程
1) 用创建工程或自己手动的方式来创建一个.c文件。
2) 此时创建的程序为:
#inlcude “stdafx.h”、
int _tmian(int argc,_TCHAR* argv[])
{
return 0;
}
2. 用visual studio 查看汇编代码
1) 在“return 0;”一句上按下F9设置一个断点,按下F5调试程序,当程序停止在这一行的时候,打开菜单“debug”下的“windows”子菜单,选择“disassembly”,将显示相对应的汇编代码。
2) #include “stdafx.h”
int _tmain(int argc,_TCHAR* argv[])
{
00411360 push ebp
00411361 mov ebp, esp
00411363 sub esp,0C0h
00411369 push ebx
0041136A push esi
0041136B push edi
0041136C lea edi,[ebp – 0C0h]
00411372 mov ecx,30h
00411377 mov eax,0CCCCCCCCh
0041137C rep stos dword ptr es:[edi]
return 0;
0041137E xor eax,eax
}
00411380 pop edi
00411381 pop esi
00411382 pop ebx
00411383 mov esp,ebp
00411385 pop ebp
00411386 ret
二、 简要复习常见的汇编指令
1. 堆栈相关指令
1) 需要对指令详细了解时,可以查询Intel发布的指令手册。
2) push:把一个32位的操作数压入堆栈中。这个操作导致esp被减4。esp被形象地称为栈顶。我们认为顶部是地址小的区域,那么,压入堆栈的数据越多,这个堆栈也就越堆越高,esp也就越来越小。在32位平台上,esp每次减少4字节。
3) pop:相反,esp被加4,一个数据出栈。pop的参数一般是一个寄存器,栈顶的数据被弹出到这个寄存器中。
4) 一般不会把sub、add这样的算术指令,以及call、ret这样的跳转指令归入堆栈相关指令中。但实际上在函数参数传递过程中,sub和add最常用来操作堆栈。call和ret对堆栈也有影响。所以这里做特殊处理。
5) ret:返回。相当于跳转回调用函数的地方。
6) call:调用函数。
7) 某些指令会“自动”地操作堆栈,这就是call和jmp的不同之处。call指令会把它的下一条指令的地址压入堆栈中,然后跳转到它调用的函数开头处,而单纯的jmp是不会这样做的。而ret会自动地弹出返回地址。
8) call的本质相当于push+jmp,而ret的本质相当于pop+jmp
9) 在操作堆栈时,sub和add可以快速的回收和分配栈空间,不需要使用push和pop一个一个的来处理。它们常用于对C语言函数中保存在栈内的局部变量的栈空间的分配和回收。
2. 数据传送指令
1) xor:异或。常用xor eax,eax的操作来代替mov eax,0,这样做的好处是速度更快,占用的字节数更少。
2) lea:取得地址(第二个参数)后放入到前面的寄存器(第一个参数)中。但实际上,有时候lea用来做和mov同样的事情,比如赋值。如:lea edi,[ebp – 0cch] ,方括弧表示存储器,也就是ebp-0cch这个地址所指的存储器内容。但是lea要求取[ebp – 0cch]的地址,那么地址就是ebp – 0cch,这个地址将被放入到edi中。换句话说,这等同于:mov edi,ebp – 0cch。但是这样的mov指令是错误的,因为mov指令不支持后一个操作数写成寄存器减去数字。但是lea支持,所以可以用lea来代替它。
3) mov ecx,30
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
4) stos:串存储指令。它的功能是将eax中的数据放入到edi所指的地址中。同时,edi会增加4字节数。rep使指令重复执行ecx中填写的次数。方括弧表示存储器,这个地址实际上就是edi的内容所指向的地址。这里的stos其实对应的是stosd,其他还有stosb、stosw,分别对应于处理 4、1、2个字节。这里对堆栈中30h*4(0c0h)个字节初始化为0cch(也就是int 3指令的机器码),这样发生意外时执行堆栈里面的内容会引发调试中断。
3. 跳转与比较指令
1) jmp:无条件跳转。
2) jg:大于的时候跳转。
3) jl:小于的时候跳转。
4) jge:大于等于的时候跳转。
5) cmp:比较指令。 往往是jg、jl、jge之类的条件跳转指令的执行条件。
三、 C函数的参数传递过程
1. 函数和堆栈的关系密切,这是因为:C语言程序通过堆栈把参数从函数外部送入到函数内部。此外,在堆栈中划分区域来容纳函数的内部变量。
2. 对于C程序默认的调用方式,堆栈总是调用方把参数反序(从右到左)地压入堆栈中,被调用方把堆栈复原。这些参数对齐到机器字长,16位、32位、64位CPU下分别对齐到2、4、8个字节。这种调用是C编译器默认的C方式。
3. 函数调用规则:在一个编写高级语言的程序员的观念中,函数(或者没有返回值的过程)是必不可少的基础单元。C语言的程序完全由函数构成,所有的代码都在某一个函数中。
Pascal区分函数和过程,但是本质依然是类似的。对计算机硬件而言,这种区分毫无必要,因为CPU只关心一条一条的指令,并不关心它们是以怎样的结构组织的。
call指令和ret指令只是为了调用的方便而已,绝不是函数存在的绝对证据。即使我们仅仅使用jmp并自己操作堆栈,也一样可以实现函数的功能。因此,一种高级语言如何实现函数调用,并没有法律的约束,所以出现了各种不同的函数调用规则。
但是毫无疑问,如果一个第三方提供的函数要能被使用,那么必须有约定的函数调用规则。
函数调用规则指的是调用者和被调用者函数间传递参数及返回参数的方法,在Windows上,常见的又Pascal方式、WINAPI方式 (_stdcall)、C方式(_cdecl)。
_cdecl C调用规则:
1) 参数从右到左进入堆栈;
2) 在函数返回后,调用者要负责清除堆栈,所以这种调用常会生成较大的可执行程序。
_stdcall又称为WINAPI,其调用规则:
1) 参数从右到左进入堆栈;
2) 被调用者得函数在返回前自行清理堆栈,所以生成的代码比cdecl小。
Pascal调用规则:
Pascal调用规则主要用在Win16函数库中,现在基本不用。
1) 参数从左到右进入堆栈;
2) 被调用的函数在返回前自行清理堆栈;
3) 不支持可变参数的函数调用。
此外,在Windows内核中还常见有快速调用方式(_fastcall);在C++编译的代码中有this call方式。
4. 在用C语言所写的程序中,堆栈用于传递函数参数。例如一个简单的函数如下:
void myfunction(int a,int b)
{
int c = a + b ;
}
这是标准的C函数调用方式。其过程是:
1) 调用者把参数反序地压入堆栈中。
2) 调用函数。
3) 调用者把堆栈清理复原。
这就是C编译器默认的_cdecl方式,而Windows API一般采用的_stdcall则是被调用者恢复堆栈(可变参数函数调用除外)。
5. 对于返回值,都是写入eax中,然后返回的。在Windows中,不管哪种调用方式都是返回值放在eax中,然后返回。外部从eax中得到返回值。
6. _cdecl方式下被调用函数需要做以下一些事情:
1) 保存ebp。ebp总是被我们用来保存这个函数执行之前的esp的值。执行完毕之后,我们用ebp恢复esp;同时,调用此函数的上层函数也用ebp做同样的事情。所以先把ebp压入堆栈,返回之前弹出,避免ebp被我们改动。
2) 保存esp到ebp中。
上面两步的代码如下;
; 保存ebp,并把esp放入ebp中,此时ebp和esp同
;都是这次函数调用时的栈顶
push esp
mov ebp,esp
3) 在堆栈中腾出一个区域用来保存局部变量,这就是常说的所谓局部变量是保存在栈空间中的。方法是:把esp减少一个数值,这样就等于压入了一堆变量。要恢复时,只要把esp恢复成ebp中保存的数据就可以了。
4) 保存ebx、esi、edi到堆栈中,函数调用完后恢复。
对应的代码如下:
;把esp往下移动一个范围,等于在堆栈中放出一片新的空间用来存局部变量
sub esp,0cch
push ebx ;下面保存三个寄存器:ebx、esi、edi
push esi
push edi
5) 把局部变量区域初始化成全0cccccccch。0cch实际是int 3指令的机器码,这是一个断点中断指令。因为局部变量不可能被执行,如果执行了,必然程序有错,这时发生中断来提示开发者。这是VC编译Debug版本的特有操作。相关代码如下:
lea edi,[ebp – 0cch] ;本来是要mov edi,ebp-0cch,但是mov不支持ebp-0cch
;这样的参数
;所以对ebp-occh取内存,而lea把内容的地址,也就 ;是ebp-0cch加载到edi中。目的是把保存局部变量的 ;区域(从ebp-0cch开始的区域)初始化成全部cccccccch
mov ecx,33h
mov eax,0cccccccch
rep stos dword ptr [edi] ;串写入
6) 然后做函数里应该做的事情。参数的获取是ebp+12字节为第二个参数,ebp+8为第一个参数(注意倒序压入),依次增加。最后ebp+4字节处是要返回的地址。
7) 恢复ebx、esi、edi、esp、ebp,最后返回。代码如下:
pop edi ;恢复edi、esi、ebx
pop esi
pop ebx
mov esp,ebp ;恢复原来的ebp和esp,让上一个调用的函数正常使用
pop ebp
ret
为了简单起见,这个函数没有返回值。如果要返回值,函数应该在返回之前,把返回值放入eax中。外部通过eax得到返回值。
7. 完整的反汇编代码如下:
void myfunction(int a,int b)
{
push ebp ;保存ebp,并把esp放入ebp中。此时ebp与esp同
mov ebp,esp ;都是这次函数调用时的栈顶
sub esp,0cch ;把esp往上移动一个范围,等于在堆栈中放出一片新的空间用来存
;储局部变量
push ebx ;下面保存三个寄存器:ebx、esi、edi
push esi
push edi
lea edi,[ebp-0cch] ;本来是要“mov edi,ebp-0cch”,但是mov不支持“-”操作, ;所以对ebp-0cch取内容,而lea把内容的地址,也就是ebp-0cch ;加载到edi中。目的是把保存局部变量的区域(从ebp-0cch开
始的区域)初始化成全部0cccccccch
mov ecx,33h
mov eax,0cccccccch
rep stos dword ptr [edi] ;写入0cch指令(中断)
int c = a + b;
mov eax,dword ptr [a] ;简单的想加操作。这里从堆栈中取得从外部
;送入的参数。那么,a和b到底是怎么取得
;的呢
add eax,dword ptr [b] ;通过ida反汇编可以看到,其实这两条指令是
;mov eax,[ebp+8],add eax,[ebp+0cch]
;参数是通过ebp从堆栈中取得的。这里看到
;的是VC调试器的显示结果,为了阅读方便,
;直接加上了参数名。
mov dword ptr [c],eax
}
pop edi ;恢复edi、esi、ebx
pop esi
pop ebx
mov esp,ebp ;恢复原来的ebp和esp,让上一个调用的函数
;正常使用
pop ebp
ret
主程序中对这个函数的调用方式是:
mov eax,dword ptr [b] ;把b、a两个参数压入堆栈
push eax
mov ecx,dword ptr [a]
push ecx
call myfunction ;调用函数myfunction
add esp,8 ;恢复堆栈
重点观察那些涉及call、ret、push和pop,操作ebp和esp的指令,就能看到C语言函数的调用过程。