汇编语言和本地代码

计算机CPU只能运行本地代码(机器语言)程序,用C语言等高级语言编写的代码,需要经过编译器编译后,转换为本地代码才能够被CPU解释执行。

但是本地代码的可读性非常差,所以需要使用一种能够直接读懂的语言类替换本地代码,那就是在各本地代码中,附带上表示其功能的英文缩写,比如在加法运算的本地代码加上add(addition)的缩写、在比较运算符的本地代码中加上cmp(compare)的缩写等,这些通过缩写来表示具体本地代码指令的标志称为助记符,使用助记符的语言称为汇编语言。这样,通过阅读汇编语言,也能够了解本地代码的含义了。

不过,即使是使用汇编语言编写的源代码,最终也必须要转换为本地代码才能够运行,负责做这项工作的程序称为编译器,转换的这个过程称为汇编。在将源代码转换为本地代码这个功能方面,汇编器和编译器是同样的。

用汇编语言编写的源代码和本地代码是一一对应的。因而,本地代码也可以反过来转换成汇编语言编写的代码。把本地代码转换为汇编代码的这一过程称为反汇编,执行反汇编的程序称为反汇编程序。

本地代码和汇编语言一对一的转换:

 哪怕是C语言编写的源代码,编译后也会转换成特定CPU用的本地代码。反将其反汇编的话,就可以得到汇编语言的源代码,并对其内容进行调查。不过,本地代码变成C语言源代码的反编译,要比本地代码转换成汇编代码的反汇编要困难,这是因为,C语言代码和本地代码不是一对一的关系。

1,通过编译器输出汇编语言的源代码

本地代码可以经过反汇编转换称为汇编代码,C语言编写的源代码也能够通过编译器编译为汇编代码。

首先需要下载 Borland C++ 5.5 编译器,下载完毕,需要进行配置,配置完成,下面开始编译过程。

首先在Windows上用记事本等文件编辑器编写如下代码

编写完成后,将文件名保存在Sample4.c,C语言源文件的扩展名,通常用 .C 来表示,上面程序是提供两个输入参数并返回它们之和。

在Windows操作系统下打开命令提示符,切换到保存Sample4.c的文件夹下,然后在命令提示符中输入 bcc32 -c -S Sample4.c

bcc32是启动Borland C++ 的命令,-c的选项是指仅进行编译而不进行链接,-S 选项被用来置顶生成汇编语言的源代码

作为编译的结果,当前目录下回生成一个名为Sample4.asm 的汇编语言源代码。汇编语言源代码的扩展名,通常用.asm来表示,下面就让我们用编辑器打开看一下Sample4.asm中内容

 这样,编译器就成功的把C语言转换成为了汇编代码了。

2,不会转换成本地代码的伪指令

 汇编语言的源代码,是由转换成本地代码的指令(后面讲述的操作码)和针对汇编器的伪指令构成的。伪指令负责把程序的构造移机汇编的方法指示给汇编器(转换程序)。不过伪指令是无法汇编转换成为本地代码的。下面是上面程序截取的伪指令:

 由伪指令 segment 和 ends 围起来的部分,是给构成程序的命令和数据的集合体上加一个名字而得到的,称为段定义。段定义的英文表达具有区域的意思,在这个程序中,段定义指的是命令和数据等程序的集合体的意思,一个程序由多个段定义构成。

上面代码的开始位置,定义了3个名称分别为 _TEXT、_DATA、_BSS的段定义,_TEXT的指定的段定义,_DATA是被初始化(有初始值)的数据的段定义,_BSS是尚未初始化的数据的段定义。这种定义的名称是由Borland C++ 定义的,是由Borland C++编译器自动分配的,所以程序段定义的顺序就成为了_TEXT、_DATA、_BSS,这样也确保了内存的连续性

段定义(Segment)是用来区分或者划分范围区域的意思。汇编语言的segment伪指令表示段定义的起始,ends伪指令表示段定义的结束。段定义是一段连续的内存空间。

而group这个伪指令表示的事将_BSS和_DATA这两个段定义汇总名为DGROUP的组

 

围起_AddNum 和_MyFun的_TEXT segment 和_TEXT ends,表示_AddNum 和 _MyFun是属于_TEXT 这一段定义的。

 

因此,即使在源代码中指令和数据是混杂编写的,经过编译和汇编后,也会转换成为规整的本地代码。

_AddNum proc 和 _AddNum endp 围起来的部分,以及_MyFunc proc 和 _MyFunc endp围起来的部分,分别表示AddNum函数和MyFunc函数的范围。

 

编译后在函数名前附带上下划线_,是Borland C++的规定。在C语言中编写的AddNum函数,在内部是以_AddNum这个名称处理的。伪指令proc 和endp围起来的部分,表示的是过程(procedure)的范围。在汇编语言中,这种相当于C语言的函数的形式称为过程。

末尾的end伪指令,表示的是源代码的结束。

3,汇编语言的语法是 操作码+操作数 

在汇编语言中,一行表示一对CPU的一个指令。汇编语言指令的语法结构是 操作码+操作数,也存在只有操作码没有操作数的指令。

操作码表示的是指令动作,操作数表示的是指令对象。操作码和操作数一起使用就是一个英文指令。比如从英语语法来分析的话,操作码是动词,操作数是宾语。比如这个句子 Give me money这个英文指令的话,Give就是操作码,me 和 money就是操作数。汇编语言中存在多个操作数的情况,要用逗号把他们分割,就说是Give me,money这样。

能够使用何种形式的操作码,是由CPU的种类决定的,下面对操作码的功能进行了整理。

部分操作码及其功能:

 本地代码需要加载到内存后才能运行,内存中存储着构成本地代码的指令和数据。程序运行时,CPU会从内存中把数据和指令读出来,然后放在CPU内部的寄存器中进行处理。

CPU和内存的关系:

 寄存器是CPU中的存储区域,寄存器除了具有临时存储和计算的功能之外,还具有运算功能,x86系列的主要种类和角色如下图所示,x86系列CPU的主要寄存器:

 1,指令解析

1,最常用的mov指令

指令中最常用的是岁寄存器和内存进行数据存储的mov指令,mov指令的两个操作数,分别用来指定数据的存储地和读出源。操作数中可以指定寄存器、常数、标签(附加在地址前),以及用方括号([])围起来的这些内容。如果指定了没有用([])方括号围起来的内容,就表示对该值进行处理;如果指定了用方括号围起来的内容,方括号的值则会被解释为内存地址,然后就会对该内存地址对应的值进行读写操作。让我们对上面的代码片段进行说明:

mov ebp,esp中,esp寄存器中的值被直接存储在了ebp中,也就是说,如果esp寄存器的值是100的话那么ebp寄存器的值也是100。

 而在mov eax,dword ptr[ebp+8] 这条指令中,ebp寄存器的值+8后会被解析称为内存地址。如果ebp寄存器的值100的话,那么eax寄存器的值就是100+8的地址的值。dword ptr 也叫做double word pointer 简单解释一下就是从指定的内存地址中读出4字节的数据。

2,对栈进行push和pop

程序运行时,会在内存上申请分配一个称为栈的数据空间。栈(stack)的特性是后入先出,数据在存储时是从内存的下层(大的地址编号)逐渐往上层(小的地址编号)累积,读出时则是按照从上往下进行读取的。

 栈是存储临时数据的区域,他的特点是通过push指令和pop指令进行数据的存储和读出。向栈中存储数据称为入栈,从栈中读取数据称为出栈,32位x86系列的CPU中,进行1次push或者pop,即可处理32位(4字节)的数据。

2,函数的调用机制

用C语言编写的代码为例,首先,让我们从MyFunc函数调用AddNum函数的汇编语言部分开始,来对函数的调用机制进行说明。栈在函数的调用中发挥了巨大的作用,下面是经过处理后的MyFunc函数的汇编处理内容

代码解释中的(1)、(2) 、(7)、(8)的处理适用于C语言中的所有函数,我们会在后面展示AddNum函数处理内容时进行说明。这里先关注(3)~(6)这一部分,这对了解函数调用机制至关重要。

(3)和(4)表示的是将传递为AddNum函数的参数通过push入栈。在C语言源代码中,虽然记述为函数AddNum(123,456),但入栈时则会先按照456,123这样的顺序。也就是位于后面的数值先入栈。这是C语言的规定。(5)表示的call指令,会把程序流程跳转到AddNum函数指令的地址处。在汇编语言中,函数名表示的就是函数所在的内存地址。AddNum函数处理完毕后,程序流程必须要返回到编号(6)这一行。call指令运行后,call指令的下一行(也就是指的是(6)这一行)的内存地址(调用函数完毕后要返回的内存地址)会自动的push入栈。该值会在AddNum函数处理的最后通过ret指令pop出栈,然后程序会返回到(6)这一行。

(6)部分会把栈中存储的两个参数(456和123)进行销毁处理。虽然通过两次的pop指令也可以实现,不过采用esp寄存器+8的方式会更有效率(处理1次即可)。对栈进行数值的输入和输出时,数值的单位是4字节。因此,通过在负责栈地址管理的esp寄存器中加上4的2倍8,就可以达到和运行两次pop命令同样的效果。虽然内存中的数据实际上海残留着,但只要把esp寄存器的值更新为数据存储地址前面的数据位置,该数据也就相当于销毁了。

在编译Sample4.c文件时,出现了下图的这条消息:

图中的意思是指c的值在MyFunc定义了但是一直未被使用,这其实是一项编译器优化的功能,由于存储着AddNum函数返回值的变量c在后面没有被用到,因此编译器就认为该变量没有意义,进而也就没有生成与之对应的汇编语言代码。 

下面是调用AddNum这一函数前后占内存的变化:

3,函数的内部处理 

现在我们分析一下AddNum函数的源代码部分,分析一下参数的接收、返回值和返回等机制。

ebp寄存器的值在(1)中入栈,在(5)中出栈,这主要是为了把函数中用到的ebp寄存器的内容,恢复到函数调用前的状态。

 (2)中把负责管理栈地址的esp寄存器赋值到了ebp寄存器中。这是因为,在mov指令中方括号内的参数,是不允许指定esp寄存器的。因此,这里就采用了不直接通过esp,而是用ebp寄存器来读写栈内容的方法。

(3)使用[ebp+8]指定栈中存储的第1个参数123,并将其读出到eax寄存器中。像这样,不使用pop指令,也可以参照栈的内容。而之所以从多个寄存器中选择了eax寄存器,是因为eax是负责运算的累加寄存器。

通过(4)的add指令,把当前eax寄存器的值同第2个参数相加后的结果存储在eax寄存器中。[ebp+12]是用来指定第2个参数456的。在C语言中,函数的返回值必须通过eax寄存器返回,这也是规定。也就是函数的参数是通过栈来传递,返回值是通过寄存器返回的。

(6)中ret指令运行后,函数返回目的地内存地址会自动出栈,据此,程序流程就会跳转返回到(6)(Call _AddNum)的下一行。这时,AddNum函数入口和出口处栈的状态变化,就如下图所示:

4,全局变量和局部变量

在函数外部定义的变量称为全局变量,在函数内部定义的变量称为局部变量,全局变量可以在任意函数中使用,局部变量只能在函数定义局部变量的内部使用。下面,我们就通过汇编语言来看一下全局变量和局部变量的不同之处。

下面定义的C语言代码分别定义了局部变量和全局变量,并且给各变量进行了赋值,我们先看一下源代码部分:

上面的代码挺暴力的,不过能够便于我们分析其汇编源码就好,我们用Borland C++ 编译后的汇编代码如下,编译完成后的源码比较长,这里我们只拿出来一部分作为分析使用

 

 

编译后的程序,会被归类到名为段定义的组。

初始化的全局变量,会汇总到名为_DATA的段定义中

 

没有初始化的全局变量,会汇总到名为_BSS的段定义中

 

被段定义_TEXT围起来的汇编代码则是Borland C++ 的定义

 

我们再分析上面汇编代码之前,先认识一下更多的汇编指令,此表是对上面部分操作码及其功能的接续:

 

 我们首先来看一下_DATA段定义的内容。_a1 label dword 定义了 _a1这个标签。标签表示的是相对于段定义起始位置的位置。由于_a1在_DATA段定义的开头位置,所以相对位置是0。

_a1就相当于是全局变量a1.编译后的函数名和变量名前面会加一个(_),这也是Borland C++ 的规定。dd 1 指的是,申请分配了4字节的内存空间,存储着1这个初始值。dd指的是define double word 表示有两个长度为2的字节领域(word),也就是4字节的意思。

Borland C++中,由于int 类型的长度是4字节,因此汇编器就把int a1 = 1变换成了_a1 label dword 和dd 1。同样,这里也定义了相当于全局变量的a2~a5的标签_a2~_a5,它们各自的初始值2-5也被存储在各自的4字节中。

接下来,我们来说一说 _BSS段定义的内容。这里定义了相当于全局变量b1-b5的标签_b1-_b5。其中db 4dup(?)表示的事申请分配了4字节的领域,但尚未确定(这里用?来表示)的意思。db(define byte)表示有1个长度是1字节的内存空间。因而,db 4dup(?)的情况下,就是4字节的内存空间。

 5,临时确保局部变量使用的内存空间

我们知道,局部变量是临时保存在寄存器和栈中的。函数内部利用栈进行局部变量的存储,函数调用完成后,局部变量值被销毁,但是寄存器可能用于其他目的。所以局部变量只是函数在处理期间临时存储在寄存器和栈中的。

回想上述代码是不是定义了10个局部变量?这是为了表示存储局部变量的不仅仅是栈,还有寄存器。为了确保c1-c10所需的域,寄存器空闲的时候就会使用寄存器,寄存器空间不足的时候就会使用栈。

让我们继续分析一下上面代码的内容。_TEXT段定义表示的是MyFun函数的范围。在MyFun函数中定义的局部变量所需要的内存领域。会被尽可能的分配在寄存器中。大家可能认为使用高性能的寄存器来替代普通的内存是一种资源浪费,但是编译器不这么认为,只要寄存器有空间,编译器就会使用它。由于寄存器的访问速度远高于内存,所以直接访问寄存器能够高效的处理。局部变量使用寄存器,是Borland C++编译器最优化的运行结果。

代码清单中的如下内容表示的是向寄存器中分配局部变量的部分:

仅仅对局部变量进行定义是不够的,只有在给局部变量赋值时,才会被分配到寄存器的内存区域。上述代码相当于就是给5个局部变量c1-c5分别赋值为1-5.eax、edx、ecx、ebx、esi 是x86系列32位CPU寄存器的名称。至于使用哪个寄存器,是由编译器来决定的。

x86系列CPU拥有的寄存器中,程序可以操作的是十几,其中空闲的最多会有几个。因而,局部变量超过寄存器数量的时候,可分配的寄存器就不够用了,这种情况下,编译器就会把栈派上用场,用来存储剩余的局部变量。

在上述代码这一部分,给局部变量c1-c5分配完寄存器后,可用的寄存器数量就不足了。于是,剩下的5个局部变量c6-c10就被分配给了栈的内存空间。如下代码所示:

 

函数入口add esp,-20 指的是,对栈数据存储位置的esp寄存器(栈指针)的值做减20的处理。为了确保内存变量c6-c10在栈中,就需要保留5个int类型的局部变量(4字节*5 = 20字节)所需的空间。mov ebp,esp 这行指令表示的意思是将esp寄存器的值赋值到ebp寄存器。之所以需要这么处理,是为了通过在函数出口处 mov esp ebp这一处理,把esp寄存器的值还原到原始状态,从而对申请分配的栈空间进行释放,这时栈中用到的局部变量就消失了。这也是栈的清理处理。使用寄存器的情况下,局部变量则会在寄存器被用于其他用途时自动消失了,如下图所示:

 

用于局部变量的栈空间的申请分配和释放

 

这五行代码是往栈空间代入数值的部分,由于在向栈申请内存空间前,借助了mov ebp,esp这个处理,esp寄存器的值被保存到了esp寄存器中,因此,通过使用[ebp-4]、[ebp-8]、[ebp-12]、[ebp-16]、[ebp-20]这样的形式,就可以申请分配20字节的栈内存空间切分成5个长度为4字节的空间来使用。例如,mov dword ptr [ebp-4],6 表示的就是,从申请分配的内存空间的下端(ebp寄存器指示到的位置)开始向前4字节的地址([ebp-4])中,存储着6这一4字节数据。

将栈内存空间进行分割:

6,循环控制语句的处理

上面说的都是顺序流程,那么现在就让我们分析一下循环流程的处理,看一下for循环以及if条件分支等C语言程序的流程控制是如何实现的,我们还是以代码以及编译后的结果为例,看一下程序控制流程的处理过程。

上述代码局部变量i作为循环条件,循环调用十次MySub函数,下面是它主要的汇编代码:

C语言中的for语句是通过在括号中指定循环计数器的初始值(i=0)、循环的继续条件(i<10)、循环计数器的更新(i++)这三种形式来进行循环处理的。与此相对的汇编代码就是通过比较指令(cmp)和跳转指令(jl)来实现的。

下面我们来对上述代码进行说明:

MyFunc函数中用到的局部变量只有i,变量i申请分配了ebx寄存器的内存空间。for语句括号中的i=0被转换为 xor ebx,ebx 这一处理,xor指令会对左起第一个操作数和右起第二个操作数进行XOR运算,然后把结果存储在第一个操作数中。由于这里把第一个操作数和第二个操作数都指定为了ebx,因此就变成了相同数值XOR运算。也就是说不管当前寄存器的值是什么,最终的结果都是0,。类似的,我们使用mov ebx,0 也能得到相同的结果,但是xor指令的处理速度更快,而且编译器也会启动最优化功能。

 

ebx寄存器的值初始化后,会通过call调用_MySub函数,从_MySub函数返回后,会执行inc ebx指令,对ebx的值进行+1 操作,这个操作就相当于i++ 的意思,++表示的就是当前数值 +1。

 这里需要知道 i++ 和 ++i 的区别

i++ 是先赋值,赋值完成后再对i执行+1的操作

++i 是先进行+1 操作,完成后再进行赋值

inc 下一行的cmp 是用来对第一个操作数和第二个操作数的数值进行比较的指令。cmp ebx,10 就相当于C语言中的 i<10 这一处理,意思是把寄存器的值与10 进行比较。汇编语言中比较指令的结果,会存储在CPU的标志寄存器中。不过,标志寄存器的值,程序是无法直接参考的。那如何判断比较结果呢?

汇编语言中有多个跳转指令,这些跳转指令会根据标志寄存器的值来判断是否进行跳转操作,例如最后一行的jl,它会根据cmp ebx,10 指令所存储在标志寄存器中的值来判断是否跳转,jl 这条指令表示的就是jump on less than (小于的话就跳转)。发现如果 i 比 10 小,就会跳转到 @4 所在的指令处基础执行。

那么汇编代码的意思也可以用C语言来改写一下,加深理解:

代码第一行 i ^ = i 指的就是 i 和 j 进行异或运算,也就是XOR运算,MySub() 函数用L4标签来替代,然后进行 i 自增操作,如果 i 的值小于10的话,就会一直循环MySub()函数。 

7,条件分支的处理方法

条件分支的处理方式和循环的处理方式很相似,使用的也是cmp指令和跳转指令。下面是用C语言编写的条件分支的代码:

 很简单的一个实现了条件判断的C语言代码,那么我们把它用Borland C++ 编译之后的结果如下:

上面代码用到了三种跳转指令, 分别是 jle(jump on less or equal)比较结果小时跳转,jge(jump on greater or equal)比较结果大时跳转,还有不管结果怎样都会进行跳转的jmp,在这些跳转指令之前还有用来比较的指令 cmp ,构成了上述汇编代码的主要逻辑形式。

8,了解程序运行逻辑的必要性

通过对上述汇编代码和C语言源代码进行比较,想必大家对程序的运行方式有了新的理解,而且,从汇编源代码中获取的知识,也有助于了解java等高级语言的特性,比如java 中就有native关键字修饰的变量,那么这个变量的底层就是使用C语言编写的,还有一些java中的语法,只有通过汇编代码才能知道其运行逻辑。在某些情况下,对于查找bug的原因也是有帮助的。

编程的方式都是串行处理的,纳闷串行处理的特点是什么?

串行处理最大的一个特点就是专心只做一件事情,一件事情做完之后才会去做另外一件事情。

计算机是支持多线程的,多线程的核心就是CPU切换,如下图所示:

 

 我们还是举个例子,让我们来看一段代码:

上述代码是更新counter 的值的C语言程序,MyFunc1()和MyFunc2()的处理内容都是把counter的值扩大至原来的二倍,然后再把counter的值付给counter。这里,我们假设使用多线程处理,同时调用了一次MyFunc1和MyFunc2函数,这时,全局变量counter的值,理应变成 100*2*2 = 400。如果你开启了多个线程的话,你会发现counter的数值有时也是200,对于为什么出现这种情况,如果你不了解程序的运行方式,是很难找到原因的。

我们将上面的代码转换成汇编语言的代码如下:

 

在多线程程序中,用汇编语言表示的代码每运行一行,处理都有可能切换到其他线程中。因而,假设MyFunc1函数在读出counter数值后,还未来得及将它的二倍值200写入counter时,正巧MyFun2函数读出了counter的值100,那么结果就变成为200。

多线程交互程序处理步骤:

 为了避免该bug,我们可以采用以函数或者C语言代码的行为单位来禁止线程切换的锁定方法,或者使用某种线程安全的方式来避免该问题的出现。

现在基本上没有人用汇编语言来编写程序了,因为C、java等高级语言的效率要比汇编语言快很多。不过,汇编语言的经验还是很重要的,通过借助汇编语言,我们可以更好的了解计算机运行机制。

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值