C调用汇编

 1.80X86 32位汇编基础以及寄存器设定

 2.栈帧与C函数调用

 3.函数调用的汇编级解释以及栈图

 4.stdcall和cdcel

------------------------------------------------------

1. 80X86 32位CPU的编程模型(programming model)

80X86有16个通用寄存器register。从某种程度上来说介绍80X86的CPU编程模型,就是介绍这16个寄存器。另外,介绍的是32位汇编,请把80X86的16位汇编先忘记掉,这么短的文章不可能介绍完汇编,而是给出汇编最基本的东西和最简单的抽象。

eax ebx ecx edx

这4个寄存器是通用寄存器。用来暂存数据的地方。

esi edi(extension source index, extension destination index.)

它们也可以用来暂存数据。更一般的是伴随串指令使用。

esp ebp(extension stack pointer, extension base pointer)

栈指针寄存器和基址指针寄存器。关于栈和过程调用,最重要的寄存器就是这两个了!

eip(extension instruction pointer, or program count)

指令指针寄存器!这就是“顺序存储控制”的核心!又称程序计数器!

eflag

 标志寄存器。算术、逻辑及相关指令运算会影响该标志寄存器中的位。这个寄存器很重要也很麻烦。

以上!就是32位汇编(又称平坦地址模式汇编)会使用到的所有寄存器,一共十个,都是32位的。啊不是说十六个寄存器吗?

对,还有6个寄存器,分别名为:

 cs ;代码段寄存器code segment

 ds ;数据段寄存器data segment

 ss ;栈寄存器stack segment

 es fs gs; 附加段寄存器

这6个寄存器都是16位寄存器。即使是现今的80686 32位系统中,它们仍然是16位的。这些段寄存器在8086中用来对内存地址进行段指定。有8086 16位汇编知识的同学都知道怎么回事,...还是解释一下吧,8086是16位CPU,而地址线是20位。20根地址线表明能寻址的空间是2^20也就是1M(1024 * 1024).16位不够表达1M的地址空间,因此由“段*16+偏移”得到内存地址值。

但是在32位系统中,这些段寄存器已经不怎么使用了。总之32位汇编不需要关注这些寄存器,因为32位系统CPU和各寄存器是32位,地址线也是32位,一个32位值足够表达32位寻址空间。...实际上这些段寄存器在32位系统中是同一个值,用来指向某个索引表,但这是本文不需要在此关注的东西。

以上,16个寄存器介绍完毕!接下来介绍简洁的编程模型抽象!

由于是简单而本质的抽象,因此我们不考虑分页机制、MMU(memory management unit)之类的。正是如此,它们本来对于我们就是透明的。

 
所以内存就被考虑为一个从编号(地址)0开始、以编号(地址)0xffff ffff结束的字节序列。每一个字节都被顺序地编号。编号就是字节的地址。
 在32位FLAT模式汇编中,本来就是如此。

在程序加载入内存后,程序的指令和数据都按某种方式存放在内存里面。要访问和执行他们,只需要知道他们的地址就可以了。
 
最重要的东西登场,它就是eip,指令指针寄存器,或称程序计数器。eip中的值程序员无法修改(嗯,可是汇编程序员呢?汇编程序员也无法修改它的值吗?废话,汇编程序员也是程序员啊!),它的值就是下一条即将执行的指令的地址。就是说eip永远指向下一条指令。
 
然后就是esp,它指向栈的栈顶。当向栈压入数据或从栈弹出数据时,esp的值不断变化,但无论如何变化,它都指向栈顶。
 
最后就是ebp,它用来把栈中的某个地址作为基址(基本地址,这样理解就是了),它用来标识栈中的某个固定位置,因此可以通过它访问这个固定位置附近的数据。
 
80X86的栈是向下增长的。也就是说,当向栈压入4个字节的数据时,esp = esp - 4; 当从栈中弹出4个字节时,esp = esp + 4。
 
以上!多么幸福的事情啊,32位汇编只需要在意这3个寄存器就可以了!(标志寄存器也挺重要的啊!但是跟本文要陈述的东西没太大关系,略)。

 

解释下上图代表什么意思...纯粹照顾完全的新手。
 首先,那一排格子代表内存空间中的一小段,每个格子代表4个字节。右边的十六位数值代表方格的地址。格子中间的“...”代表格子的内容。
 图中地址是从下往上增长的。
 esp永远指向栈顶。一开始它指向地址为0x0063 fff4的字节。然后向栈压入4个字节。
 对80X86来说,指令就是push ...;
 数据压入后,esp指向0x0063 fff0。这是新的栈顶。
 
弹出数据跟上面的过程相反。esp中的值会增加。

关于80X86 32位CPU汇编模型就讲上面这些了。之所以讲这么少,因为这就是最基本的和最本质的内容,讲多了反而把重点搞没了。
 总结就是记住3个寄存器。eip, esp, ebp。记住他们的意义就可以了。

------------------------------------------------------

2.栈帧与C函数调用

关于计算机,最重要的三个抽象是什么?答案是虚拟地址空间、进程、文件。
 
一个进程就是一个运行中的程序,或者被加载到内存中的程序。现代操作系统使进程看上去独占了所有的系统资源,但实际上系统中运行着多个进程。
 
所以从一个进程的视角看去,它独占了系统中的所有内存资源和CPU资源。对于32位系统虚拟地址空间被抽象为编号0~0xffff ffff的字节序列,它是平坦的,线性的,被系统抽象了的,所以叫它平坦地址或线性地址、虚拟地址。

 

对于Linux来说,保留高1G为系统使用。0-3G空间被应用程序也就是进程独占。
 
对于一个被加载了的程序也就是进程,其在内存中的分布为:

 栈
 共享内存段
 自由存储区(堆)
 BSS段
 数据段
 只读数据段
代码段

栈向下增长。
 
每一个函数调用,都是一个栈帧。
 以下代码:
 int add(int x, int y)
 {
    int z;
    z = x + y;
    return z;
 } int main(int argc, char* argv[])
 {
   add(3, 5);
   return 0;
 }


   那么main函数是一个栈帧,add是一个栈帧。

 当程序运行时,main函数栈帧先被建立,这个栈帧在高地址。然后调用add函数。此时add函数栈帧被建立,在低地址。当程序执行流进入add函数时,add函数内的局部变量在add函数栈帧中被建立。然后add返回。当add函数返回,此时add函数栈帧被销毁,同时add函数内的局部变量也被销毁。所以,C编程原则告诉我们:永远不要返回一个指向局部对象的指针。也就是说如下代码是错误的:
 
int* getNumber(void)
{
   int a = 3;
   return &a;
}

 那么运行时的栈是什么样子的呢?它是一个随着运行,不断增长(进入新的函数调用)和缩短(函数返回)的动态影像。
 
OK,关于C栈帧就说到这里,完毕。
------------------------------------------------------------------

3.函数调用的汇编级解释以及栈图

先来一段汇编代码。很简单,有注释。
请注意。不同的汇编编译器使用不同的文法。MASM、NASM、gcc后端汇编编译器,它们的文法几乎完全不一样。尤其是gcc后端,他妹的那文法那个汗。
这里使用的是MASM.学习汇编的话用MASM还是NASM都没关系,学了之后用什么都一样,因为那只是文法方面的东西。指令助记符一般也不会有太多改变。如果真的写汇编代码的话,我想我倾向于使用NASM.


汇编语句分为指令(instruction)、指示性语句(directive)、和宏(macro).
只有指令是真正的机器代码。指示性语句是编译器处理的东西。宏是一堆指令性语句或指示性语句。
以下代码使用MASM。
.386                        ;386系统
.MODEL FLAT                 ;32位平坦地址模式
 
Exit PROTO NEAR32 stdcall, dwPara:DWORD ;退出函数原型
                                        ;Exit是函数名,dwPara是函数参数
 
.STACK 4096                 ;保留4096字节栈空间
 
.DATA                       ;数据段,定义全局变量
   number1 DWORD 11111111h  ;定义变量number1,大小4字节
   number2 DWORD 22222222h  ;定义变量number2, 大小4字节
 
.CODE                 ;程序代码

Init PROTO NEAR32     ;定义函数Init
 
   mov number1, 0     ;假设该指令地址为0x0040 0000
   mov number2, 0
   ret                ;函数Init返回
 
Init ENDP             ;函数Init结束
 
_start:               ;相当于main函数
   call Init          ;调用函数Init,此指令地址为0x0040 000f
   ......             ;该处指令地址为0x0040 0014

   INVOKE Exit, 0     ;调用Exit退出
 
PUBLIC _start         ;公开入口点
 
END                   ;程序结束
这里说明一下。程序一旦加载,所有的指令、全局变量都被载入内存并有了确切的内存地址(程序加载前,或者说程序没有运行时,只是硬盘上的一个可执行文件对吧。程序运行前有一个系统加载动作,这个加载由操作系统完成)。这个我的另一篇BLOG《程序员的基本概念》里面略提过。清楚加载细节的是操作系统开发者,同时涉及到编译器和链接器。要更明白这个问题请参照《Linker and Loader》。
 
 
那么程序加载。栈初始化了。数据区域在内存中开辟出来了,全局变量被给予确切地址(这里是虚拟地址,因为这是一个进程,它的地址只管在虚拟地址空间中给就可以了,虚拟地址到物理地址的映射由操作系统和MMU完成)。代码段(也就是要执行的指令)也被放入内存中并给予确切地址。eip指向代码段的开始,并开始执行程序...
这里说明一下。程序一旦加载,所有的指令、全局变量都被载入内存并有了确切的内存地址(程序加载前,或者说程序没有运行时,只是硬盘上的一个可执行文件对吧。程序运行前有一个系统加载动作,这个加载由操作系统完成)。这个我的另一篇BLOG《程序员的基本概念》里面略提过。清楚加载细节的是操作系统开发者,同时涉及到编译器和链接器。要更明白这个问题请参照《Linker and Loader》。
 
那么程序加载。栈初始化了。数据区域在内存中开辟出来了,全局变量被给予确切地址(这里是虚拟地址,因为这是一个进程,它的地址只管在虚拟地址空间中给就可以了,虚拟地址到物理地址的映射由操作系统和MMU完成)。代码段(也就是要执行的指令)也被放入内存中并给予确切地址。eip指向代码段的开始,并开始执行程序...

所以eip只管指向某个内存地址,这个内存地址存储着程序员编写的指令,然后CPU把指令取出来执行就是了。所以计算机叫做“顺序存储控制机”。对不起我啰嗦了。
 
好的。我们假设了,在程序加载后,esp被初始化为0x0063 00f8,并假设了mov number1, 0这个指令的地址在0x0040 0000,根据这个假设的地址和每个指令码的长度(这些指令都放在代码段,而且一个一个指令就是挨着放的),推断出call指令的地址是0x0040 000f,call指令的下一条指令的地址是0x0040 0014(因为这个call指令的长度占用5个字节,0x0040 000f + 5 = 0x0040 0014)。这里不算我对指令长度的计算错误,总之假设我的地址计算是正确的。

 OK开始了。程序已经加载。那么开始程序执行。eip首先指向call指令,因为_start开始那里就是call指令。嗯,eip就是一个32位寄存器,这个寄存器里面的值永远是即将执行的指令的内存地址,这时eip里面的值是0x0040 000f。

call指令执行!该指令首先将下一条指令的地址压入栈,也就是说,call指令的第一个动作是将0x0040 0014(call指令的下一条指令地址)压入栈。esp此时变化,其值变为0x0063 00f4。为什么?因为esp被初始化为0x0063 00f8,一个地址4个字节入栈之后,esp = esp - 4。然后call指令转去调用Init过程代码。eip变化为0x0040 0000,为什么?因为Init过程的第一个指令地址就是0x0040 0000.这个过程是由CPU自动完成的,也就是说,call指令,让CPU自动完成这一系列动作。

然后Init过程执行到ret指令。
ret指令干什么?它将栈内数据弹出,并用该数据填充eip。栈内数据是什么?就是0x0040 0014,它就是call指令的下一条指令的地址!同时esp = esp + 4.也就是说,ret指令执行后,eip值变为0x0040 0014, esp的值变回0x0063 00f8.这个过程由CPU自动完成。ret指令让CPU自动完成这一系列动作。

整理:执行call,call指令首先将下一条指令地址入栈,然后跑去执行过程代码;过程代码中执行ret,ret首先从栈中将下一条指令地址弹回eip,这样程序就开始执行call指令后的指令。一句话:eip始终指向下一条指令地址。

以上!就是汇编函数调用和返回的过程。就是一个call和一个ret。eip在这个执行过程中通过栈来保存。


接下来,让我们开始考察C语言的过程调用和返回,也就是C语言函数的参数压栈和参数访问过程。

 

图中每个格子是一个字节,图中画的内存地址是向上增长,栈是向下增长的。左边是caller(调用者)栈,右边是callee(被调用者)栈(是同一个栈,分别是压参前、call指令执行后的状态。caller和callee的视图)。

首先,esp是栈顶,直接从caller栈顶看起。也就是,在调用前,esp指向某个内存地址。
在调用函数前将参数压入栈中。

push var1
push var2
这两行代码使 esp - 8. 然后压参完毕,图中即为压参完毕esp.
然后调用函数:

call add

 嗯,之前复习call指令时说什么了?call指令执行时,首先将返回地址压入栈。
也就是将“add esp, 8”这条指令的地址压入栈。如左图所示。

然后call指令执行过程调用,eip指向add函数内第一条指令的地址:

push ebp ;将ebp保存到栈中,同时esp - 4(说过了80X86的栈是向低地址方向增长的).


 此时ebp原值被保存入栈中。参看右图,蓝色部分是ebp原值。
然后:

mov ebp, esp


 此时以ebp为基准的栈建立了。此时ebp和esp都指向栈顶(ebp原值被栈保存起来了哦)。
为什么要这么做?
因为esp是随时变动的,只要有压栈和出栈的操作,esp的值就随着压栈和出栈的操作变化(随着push和pop操作变化,甚或,程序员直接改动esp的值)。
而ebp却不会随着push和pop操作变化。程序员在callee中不会修改ebp的值,而是使用ebp作为基准访问参数。

那么接下来就很好理解了,第二个参数的地址是ebp + 8, 第一个参数的地址是ebp + 12.所以

mov eax, [ebp + 8] ;复制第二个参数值(var2)到eax
mov eax, [ebp + 12] ;加上第一个参数值(var1)

 就不难理解了。

在过程把实现代码处理完毕的最后,pop ebp将ebp原值从栈中弹出恢复。
然后ret返回指令将返回地址弹出并赋给eip(请注意,返回地址弹出后,esp + 4, 这时esp正好指向调用者压参完毕的位置),...
回到调用者的地方并继续执行。
 
那么调用处(caller)的

add esp, 8 ;从栈移除参数

是干什么用的?注释已经说得很清楚了。
调用者将var1和var2压到栈中,由于调用者的压栈,esp被往下移动了8;那么这个esp的原始位置也就是caller的栈顶应该在过程调用后恢复,add esp, 8就是恢复esp的。
 
ok。基本上就是如此了!

 
对于C语言的过程调用,比如,在main函数里面调用add

 int main(int argc, char* argv[])
 {
    ...
    add(x, y);
    ...
 }


 实际上,这里add(x, y)(调用者处)被编译器编译成如下汇编代码:

 push y
 push x
 call add
 add esp, 8
以上,这就是C过程调用的汇编解释。


接下来给出一般过程的入口代码和出口代码。


不难猜测,所有的过程(被调用函数)都有一样的入口代码和出口代码。

所有的C函数,在被编译器编译成汇编代码之后,
函数开始的几行汇编代码总是这样的,所以我们称这它为入口代码(entry code):

push ebp      ;保存基址
mov ebp, esp  ;建立ebp偏移基准
sub esp, n    ;n个字节的局部变量参数
push ...      ;保存过程中会用到的通用寄存器
...
pushf         ;保存标识寄存器,也就是保存标志位

而结尾的几行总是这样的,所以称其为出口代码(exit code):

popf          ;恢复标识寄存器
pop ...       ;恢复寄存器
...
mov esp, ebp  ;恢复callee esp
pop ebp       ;恢复ebp
ret           ;返回
------------------------------------------------------------------

4. stdcall和cdcel

既然已经了解了上述内容,那么调用惯例就很容易理解了。
cdcel和stdcall是约定俗成的调用惯例,它们的区别在于由谁来恢复esp。

cdcel是由调用者恢复esp的调用惯例,
也就是说

push var1
push var2
call add
add esp, 8

这是cdcel调用惯例

而stdcall则是由callee恢复esp的调用惯例
stdcall会在callee里面将ret这样写:

ret 8

意思是返回的同时esp + 8.

这两种调用惯例,stdcall的好处是不用每次都在调用过程后写add esp, 8这样就减小了代码量,减小了目标文件的体积。
而stdcall的缺陷更明显,那就是callee有时候无法推断参数的个数和长度,这样的话esp只能由调用者恢复(比如变参数函数,这种函数callee是无法推断参数个数的,也就无法知道应该在ret后面加多少偏移量)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章