阅读正文前,让我们先回答下面的问题来热热身吧:
- 本地代码的指令中,表示其功能的英语缩写称为什么
- 汇编语言的源代码转换成本地代码的方式称为什么?
- 本地代码转换成汇编语言的源代码的方式称为什么?
- 汇编语言的源文件的扩展名,通常是什么格式?
- 汇编语言程序中的段定义指的是什么?
- 汇编语言的跳转指令,是在何种情况下使用的?
答案:
- 助记符
- 汇编
- 反汇编
- .asm
- 构成程序的命令和数据的集合组
- 将程序流程跳转到其他地址时需要用到该指令
1 汇编语言和本地代码是一一对应的
在加法运算的本地代码中加上add(addition的缩写)、在比较运算的本地代码中加上cmp(compare的缩写)等。这些缩写称为助记符,使用助记符的编程语言称为汇编语言。这样,通过查看汇编语言编写的源代码,就可以了解程序的本质了。因为这和查看本地代码的源代码,是同一级别的。
不过,即使是用汇编语言编写的源代码,最终也必须要转换成本地代码才能运行。负责转换工作的程序称为汇编器,转换这一处理本身称为汇编。在将源代码转换成本地代码这个功能方面,汇编器和编译器是同样的。
用汇编语言编写的源代码,和本地代码是一一对应的。因而,本地代码也可以反过来转换成汇编语言的源代码。持有该功能的逆变换程序称为反汇编程序,逆变换这一处理本身称为反汇编(图10-1)
哪怕是用C语言编写的源代码,编译后也会转换成特定CPU用的本地代码。而将其反汇编的话,就可以得到汇编语言的源代码,并对其内容进行调查。不过,本地代码变换成C语言源代码的反编译,则要比反汇编困难。这是因为,C语言的源代码同本地代码不是一 一对应的,因此完全还原到原始的源代码是不太可能的。
2 通过编译器输出汇编语言的源代码
除了将本地代码进行反汇编这一方法外,通过其他方式也可以获取汇编语言的源代码。大部分C语言编译器,都可以把利用C语言编写的源代码转换成汇编语言的源代码,而不是本地代码。利用该功能,就可以对C语言的源代码和汇编语言的源代码进行比较研究。笔者在学生时代的报告中,使用的便是该功能。Borland C++中,通过在编译器的选项中指定“-S”,就可以生成汇编语言的源代码了。大家也可以实际尝试一下。
用Windows的记事本等文本编辑器编写如代码清单10-1所示的C语言源代码,并将其命名为Sample4.c进行保存。
C语言源文件的扩展名,通常用“.c”来表示。该程序是由返回参数的两个整数值之和的AddNum函数和调用AddNum函数的MyFunc函数构成的。因为没有包含程序运行起始位置的main函数部分,这种情况下直接编译是无法运行的。大家只需把它看成是学习汇编语言的一个示例即可。
①通过解析可执行文件得到源代码的方式称为“反汇编”或“反编译”,也称为“反向工程”。
②AddNum函数仅仅返回两个参数值的相加结果。在实际的编程中,这种函数是不需要的。为了说明函数调用的机制,这里特意使用了这种简单的函数。
③在命令提示符上运行的程序中,main函数位于程序运行起始位置。而在Windows上运行的程序中,WinMain函数位于程序运行起始位置。程序运行起始位置也称为“入口点”。
由Windows开始菜单启动命令提示符,把当前目录变更到Sample4.c保存的文件夹后,输入下面的命令并按下Enter键。bcc32是启动Borland C++编译器的命令。“-c”选项指的是,仅进行编译而不进行链接^。“-S”选项被用来指定生成汇编语言的源代码。
bcc32 -c -S Sample4.c
作为编译的结果,当前目录下会生成一个名为Sample4.asm的汇编语言源代码。汇编语言源文件的扩展名,通常用“.asm”来表示。下面就让我们使用记事本来看一下Sample4.asm的内容。可以发现,C语言的源代码和转换成汇编语言的源代码是交叉显示的。而这也为我们对两者进行比较学习提供了绝好的教材。在该汇编语言代码中,分号(;)以后是注释。由于C语言的源代码变成了注释,因此就可以直接对Sample4.asm进行汇编并将其转换成本地代码了(代码清单10-2)。
①当前目录指的是当前正在打开的目录(文件夹)。在命令提示符下对C语言的源文件进行编译时,该文件所在的目录必须是当前目录,所以有时候就需要变换当前目录。变换当前目录时,只需在命令提示符中的“CD”后面空上一个半角空格,然后加上需要跳转的目录,再按下回车即可。例如,如果要将\Test指定为当前目录的话,只需输入CD\Test然后按下回车键即可。CD是Change Dirctory的略称。
②链接是指把多个目标文件结合成1个可执行文件。
3 不会转换成本地代码的伪指令
汇编语言的源代码,是由转换成本地代码的指令(后面讲述的操作码)和针对汇编器的伪指令构成的。伪指令负责把程序的构造及汇编的方法指示给汇编器(转换程序)。不过伪指令本身是无法汇编转换成本地代码的。这里我们把代码清单10-2中用到的伪指令部分摘出,如代码清单10-3所示。
由伪指令 segment和ends围起来的部分,是给构成程序的命令和数据的集合体加上一个名字而得到的,称为段定义。段定义的英文表达segment具有“区域”的意思。在程序中,段定义指的是命令和数据等程序的集合体的意思。一个程序由多个段定义构成。
①段定义(segment)是用来区分或者划定范围区域的意思。汇编语言的segment伪指令表示段定义的起始,ends伪指令表示段定义的结束。段定义是一个连续的内存空间。
②group指的是将源代码中不同的段定义在本地代码程序中整合为一个。
4 汇编语言语法是"操作码+操作数"
在汇编语言中,1行表示对CPU的一个指令。汇编语言指令的语法结构是操作码+操作数(也存在只有操作码没有操作数的指令)。
操作码表示的是指令动作,操作数表示的是指令对象。操作码和操作数罗列在一起的语法,就是一个英文的指令文本。操作码是动词,操作数相当于宾语。例如,用汇编语言来分析“Give me money”这个英文指令的话,Give就是操作码,me和money就是操作数。汇编语言中存在多个操作数的情况下,要用逗号把它们分割开来,就像Give me,money这样。
能够使用何种形式的操作码,是由CPU的种类决定的。表10-1对代码清单10-2中用到的操作码的功能进行了整理,大家可以看一下。这些都是32位x86系列CPU用的操作码。操作数中指定了寄存器名、内存地址、常数等。
在汇编语言中,类似于mov这样的指令称为“操作码”(opcode),作为指令对象的内存地址及寄存器称为“操作数”(operand)。被转换成CPU可以直接解析运行的二进制的操作码和操作数,就是本地代码。
本地代码加载到内存后才能运行。内存中存储着构成本地代码的指令和数据。程序运行时,CPU会从内存中把指令和数据读出,然后再将其存储在CPU内部的寄存器中进行处理(图10-2)。
寄存器是CPU中的存储区域。不过,寄存器并不仅仅具有存储指令和数据的功能,也有运算功能。x86系列CPU的寄存器的主要种类和角色如表10-2所示。寄存器的名称会通过汇编语言的源代码指定给操作数。内存中的存储区域是用地址编号来区分的。CPU内的寄存器是用eax及ebx这些名称来区分的。此外,CPU内部也有程序员无法直接操作的寄存器。例如,表示运算结果正负及溢出状态的标志寄存器及操作系统专用的寄存器等,都无法通过程序员编写的程序直接进行操作。
5 最常用的移动指令
指令中最常使用的是对寄存器和内存进行数据存储的mov指令。mov指令的两个操作数,分别用来指定数据的存储地和读出源。操作数中可以指定寄存器、常数、标签(附加在地址前),以及用方括号(【】)围起来的这些内容。如果指定了没有用方括号围起来的内容,就表示对该值进行处理;如果指定了用方括号围起来的内容,方括号中的值则会被解释为内存地址,然后就会对该内存地址对应的值进行读写操作。接下来就让我们来看一下下面代码中用到的mov指令部分:
mov ebp,esp
mov eax,dword ptr [ebp+8]
mov ebp,esp中,esp寄存器中的值被直接存储在了ebp寄存器中。esp寄存器的值是100时ebp寄存器的值也是100。而在mov eax,dword ptr [ebp+8]的情况下,ebp寄存器的值加8后得到的值会被解释为内存地址。如果ebp寄存器的值是100的话,那么eax寄存器中存储的就是100+8=108地址的数据。dword ptr (double word pointer)表示的是从指定内存地址读出4字节的数据。像这样,有时也会在汇编语言的操作数前附带dword ptr这样的修饰语。
6 对栈进行push和pop
程序运行时,会在内存上申请分配一个称为栈的数据空间。栈(stack)有“干草堆积如山”的意思。就如该名称所表示的那样,数据在存储时是从内存的下层(大的地址编号)逐渐往上层(小的地址编号)累积,读出时则是按照从上往下的顺利进行(图10-3)的。
栈是存储临时数据的区域,它的特点是通过push指令和pop指令进行数据的存储和读出。往栈中存储数据称为“入栈”,从栈中读出数据称为“出栈”。32位×86系列的CPU中,进行1次push或pop,即可处理32位(4字节)的数据。
push指令和pop指令中只有一个操作数。该操作数表示的是“push的是什么及pop的是什么”,而不需要指定“对哪一个地址编号的内存进行push或pop”。这是因为,对栈进行读写的内存地址是由esp寄存器(栈指针)进行管理的。push指令和pop指令运行后,esp寄存器的值会自动进行更新(push指令是-4,pop命令是+4),因而程序员就没有必要指定内存地址了。
7 函数调用机制
上面这个图是函数调用的汇编语言代码:
(1)、(2)、(7)、(8)的处理适用于C语言中所有的函数,我们会在后面展示AddNum函数处理内容时进行说明。这里希望大家先关注一下(3)~(6)部分,这对了解函数调用的机制至关重要。
(3)和(4)表示的是将传递给AddNum函数的参数通过push入栈。在C语言的源代码中,虽然记述为函数AddNum(123,456),但1 在函数的入口处把寄存器ebp的值入栈保存(代码清单10-4(1)),在函数的出口处出栈(代码清单10-4(7)),这是C语言编译器的规定。这样做是为了确保函数调用前后ebp寄存器的值不发生变化。入栈时则会按照456、123这样的顺序,也就是位于后面的数值先入栈。这是C语言的规定。
(5)的call指令,把程序流程跳转到了操作数中指定的AddNum函数所在的内存地址处。在汇编语言中,函数名表示的是函数所在的内存地址。AddNum函数处理完毕后,程序流程必须要返回到编号(6)这一行。call指令运行后,call指令的下一行((6)这一行)的内存地址(调用函数完毕后要返回的内存地址)会自动地push入栈。该值会在AddNum函数处理的最后通过ret指令pop出栈,然后程序流程就会返回到(6)这一行。
(6)部分会把栈中存储的两个参数(456和123)进行销毁处理,也就是在第5章提到的栈清理处理。虽然通过使用两次pop指令也可以实现,不过采用esp寄存器加8的方式会更有效率(处理1次即可)。对栈进行数值的输入输出时,数值的单位是4字节。因此,通过在负责栈地址管理的esp寄存器中加上4的2倍8,就可以达到和运行两次pop命令同样的效果。虽然内存中的数据实际上还残留着,但只要把esp寄存器的值更新为数据存储地址前面的数据位置,该数据也就相当于被销毁了。
代码清单10-1中列出的C语言源代码中,有一个处理是在变量c中存储AddNum函数的返回值,不过在汇编语言的源代码中,并没有与此对应的处理。这是因为编译器有最优化功能。最优化功能是编译器在本地代码上费尽功夫实现的,其目的是让编译后的程序运行速度更快、文件更小。在代码清单10-1中,由于存储着AddNum函数返回值的变量c在后面没有被用到,因此编译器就会认为“该处理没有意义”,进而也就没有生成与之对应的汇编语言代码。在编译代码清单10-1的代码时,应该会出现“警告W8004 Sample4.c 11:'c'的赋值未被使用(函数MyFunc)”这样的警告消息。
8 函数内部的处理
接下来,让我们透过执行AddNum函数的源代码部分,来看一下参数的接收、返回值的返回等机制(代码清单10-5)。
ebp寄存器的值在(1)中入栈,在(5)中出栈。这主要是为了把函数中用到的ebp寄存器的内容,恢复到函数调用前的状态。在进入函数处理之前,无法确定ebp寄存器用到了什么地方,但由于函数内部也会用到ebp寄存器,所以就暂时将该值保存了起来。CPU拥有的寄存器是有数量限制的。在函数调用前,调用源有可能已经在使用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寄存器返回,这也是规定。不过,和ebp寄存器不同的是,eax寄存器的值不用还原到原始状态。至此,我们进行了很多细节的说明,其实就是希望大家了解“函数的参数是通过栈来传递,返回值是通过寄存器来返回的”这一点。
(6)中ret指令运行后,函数返回目的地的内存地址会自动出栈,据此,程序流程就会跳转返回到代码清单10-4的(6)(Call_AddNum的下一行)。这时,AddNum函数入口和出口处栈的状态变化,就如图10-5所示。将图10-4和图10-5按照(a)(b)(c)(d)(e)(f)的顺序来看的话,函数调用处理时栈的状态变化就会很清楚了。由于(a)状态时处理跳转到AddNum函数,因此(a)和(b)是同样的。同理,在(d)状态时,处理跳转到了调用源,因此(d)和(e)是同样的。在(f)状态时则进行了清理处理。栈的最高位的数据地址,是一直存储在esp寄存器中的。
9 始终确保全局变量用的内存空间
在C语言中,在函数外部定义的变量称为全局变量,在函数内部定义的变量称为局部变量。全局变量可以参阅源代码的任意部分,而局部变量只能在定义该变量的函数内进行参阅。
10 临时确保局部变量用的内存空间
函数内部利用的栈,在函数处理完毕后会恢复到初始状态,因此局部变量的值也就被销毁了,而寄存器也可能会被用于其他目的。因此,局部变量只是在函数处理运行期间临时存储在寄存器和栈上。
在代码清单10-6中定义了10个局部变量。这是为了表示存储局部变量的不仅仅是栈,还有寄存器。为确保cl~c10所需的领域,寄存器空闲时就使用寄存器,寄存器空间不足的话就使用栈。
关于往全局变量中代入局部变量的数值这一内容,这里不再进行说明。这时可能有读者会产生疑问,既然不进行说明,那为什么代码清单10-6中没有省略掉该部分呢?这是为了避免编译器的最优化功能。如果仅进行定义局部变量并代入数值这一处理的话,编译器的最优化功能就会启动,届时编译器就会认为某些代码没有意义,从而导致汇编语言的源代码无法生成。
11 循环处理的实现方法
接下来,让我们继续解析汇编语言的源代码,看一下for循环及if条件分支等C语言程序的流程控制是如何实现的。代码清单10-8是将局部变量i作为循环计数器 连续进行10次循环的C语言源代码。在for语句中,调用了不做任何处理的MySub函数。这里我们把代码清单10-8转换成汇编语言,然后仅把相当于for处理的部分摘出来,如代码清单10-9所示。
C语言的for语句是通过在括号中指定循环计数器的初始值(i=0)、循环的继续条件(i < 10)、循环计数器的更新(i++)这3种形式来进行循环处理的。与此相对,在汇编语言的源代码中,循环是通过比较指令(基军)和跳转指令(jl)来实现的。
下面就让我们按照代码清单10-9的内容的顺序来进行说明。MyFunc函数中用到的局部变量只有i,变量i申请分配了ebx寄存器的内存空间。for语句的括号中的i =0;被转换成了xor ebx,ebx这一处理。xor指令会对左起第一个操作数和右起第二个操作数进行XOR运算,然后把结果存储在第一个操作数中。由于这里把第一个操作数和第二个操作数都指定为了ebx,因此就变成了对相同数值进行XOR运算。也就是说,不管当前ebx寄存器的值是什么,结果肯定都是0一个。虽然用mov指令的mov ebx,0也会得到同样的结果,但与mov指令相比,xor指令的处理速度更快。这里,编译器的最优化功能也会启动。
ebx寄存器的值初始化后,会通过call指令调用MySub函数(_MySub)。从MySub函数返回后,则会通过inc指令对ebx寄存器的值做加1处理。该处理就相当于for语句的i++,++是当前数值加1的意思。
下一行的基军指令是用来对第一个操作数和第二个操作数的数值进行比较的指令。cmp ebx,10就相当于C语言的i<10这一处理,意思是把ebx寄存器的数值同10进行比较。汇编语言中比较指令的结果,会存储在中央处理器的标志寄存器中。不过,标志寄存器的值,程序是无法直接参考的。那么,程序是怎么来判断比较结果的呢?
实际上,汇编语言中有多个跳转指令,这些跳转指令会根据标志寄存器的值来判定是否需要跳转。例如,最后一行的jl,是jump onless than(小于的话就跳转)的意思。也就是说,jl short @4的意思就是,前面运行的比较指令的结果若"小"的话就跳转到@4这个标签。
代码清单10-10是按照代码清单10-9中汇编语言源代码的处理顺序重写的C语言源代码(由于C语言中无法使用@字符开头的标签,因此这里用了L4这个标签名),也是对程序实际运行过程的一个直接描述。不过看来看去还是觉得使用for语句的代码清单10-8的源代码更智能些。人们经常说"汇编语言是对中央处理器的实际运行进行直接描述的低级编程语言,C语言是用与人类的感觉相近的表现来描述的高级编程语言",此时,想必大家都能深切体会这句话的意思了吧。此外,代码清单10-10的第一行中的i^=i,意思是对i和i进行XOR运算,并把结果代入i。为了和汇编语言的源代码进行同样的处理,这里把将变量i的值清0这一处理,通过对变量i和变量i进行XOR运算来实现了。借助i^=i,i的值就变成了0。
12 条件分支的实现方法
下面让我们来看一下条件分支的实现方法。条件分支的实现方法同循环处理的实现方法类似,使用的也是基军指令和跳转指令,这一点估计大家也预料到了。
没错,条件分支就是利用这些指令来实现的。不过,为了以防万一,我们来确认一下。代码清单10-11是,根据变量a的值来调用不同函数(MySub1函数、MySub2函数、MySub3函数)的C语言源代码。为了实现条件分支,这里使用了if语句。示例中被调用的各个函数,都不进行任何处理。将代码清单10-11的MyFunc函数处理转换成汇编语言源代码后,结果就如代码清单10-12所示。
代码清单10-12中用到了三种跳转指令,分别是比较结果小时跳转的jle (jump on less or equal)、大时跳转的jge (jump on greater orequal)、不管结果怎样都无条件跳转的jmp。在这些跳转指令之前还有用来比较的cmp指令,比较结果被保存在了标志寄存器中。这里我们添加了注释,大家不妨顺着程序的流程看一下。虽然同C语言源代码的处理流程不完全相同,不过大家应该知道处理结果是相同的。此外,还有一点需要注意的是,eax寄存器表示的是变量a。
13 了解程序运行方式的必要性
通过对C语言源代码和汇编语言源代码进行比较,想必大家对"程序是怎样跑起来的"又有了更深的理解。而且,从汇编语言源代码中获得的知识,在某些情况下对查找bug的原因也是有帮助的。
让我们来看个示例。代码清单10-13是更新全局变量counter的值的C语言程序。MyFunc1函数和MyFunc2函数的处理内容,都是把全局变量counter的值放大到2倍。counter *=2 ;指的是把counter的数值乘以2,然后再把所得结果赋值到counter的意思。这里,假设我们利用 多线程处理一个,同时调用了一次MyFunc1函数和MyFunc2函数。这时,全局变量counter的数值,理应变成100×2×2=400。然而,某些时候结果也可能会是200。至于为什么会出现该bug,如果没有调查过汇编语言的源代码,也就是说如果对程序的实际运行方式不了解的话,是很难找到其原因的。
将代码清单10-13的counter *=2;部分转换成汇编语言源代码后,结果就如代码清单10-14所示。这里希望大家注意的是,C语言源代码中counter *=2;这一个指令的部分,在汇编语言源代码,也就是实际运行的程序中,分成了3个指令。如果只是看counter *=2;的话,就会以为counter的数值被直接扩大为了原来的2倍。然而,实际上执行的却是"把counter的数值读入EAX寄存器""将EAX寄存器的数值变成原来的2倍""把EAX寄存器的数值写入counter"这3个处理。
在多线程处理中,用汇编语言记述的代码每运行1行,处理都有可能切换到其他线程(函数)中。因而,假设MyFunc1函数在读出counter的数值100后,还未来得及将它的2倍值200写入counter时,正巧MyFunc2函数读出了counter的数值100,那么结果就会导致counter的数值变成了200(图10-8)。
为了避免该bug,我们可以采用以函数或C语言源代码的行为单位来禁止线程切换的 锁定方法。通过锁定,在特定范围内的处理完成之前,处理不会被切换到其他函数中。至于为什么要锁定MyFunc1函数和MyFunc2函数,大家如果不了解汇编语言源代码的话想必是不明白的吧。
现在基本上没有人用汇编语言来编写程序了。因为C语言等高级编程语言用1行就可以完成的处理,使用汇编语言的话有时就需要多行,效率很低。不过,汇编语言的经验还是很重要的。因为借助汇编语言,我们可以更好地了解计算机的机制。特别是对专业程序员来说,至少要有一次使用汇编语言的经验。
下面让我们以开车为例进行说明。没有汇编语言经验的程序员,就相当于只知道汽车的驾驶方法而不了解汽车结构的驾驶员。对这样的驾驶员来说,如果汽车出现了故障或奇怪的现象,他们就无法自己找到原因。不了解汽车结构的话,开车的时候还可能会浪费油。这样的话,作为职业驾驶员是不合格的。与此相对,有汇编语言经验的程序员,也就相当于了解计算机和程序机制的驾驶员,他们不仅能自己解决问题,还能在驾驶过程中省油。
本章的内容确实有些绕,但是对了解计算机和程序的实际运行方式来说,体验汇编语言是最有效的。如果大家会使用C语言的话,希望大家对C语言的各种语法所对应的汇编语言都一一确认一下。最好能编写一些简短的程序来进行反复的测试。笔者自身也是通过进行这些尝试才使自己的编程技能有了大幅提高的。
标签:10,函数,汇编语言,程序,第十章,指令,寄存器,源代码 From: https://www.cnblogs.com/Chenyaxuan/p/17134020.html