第十章——通过汇编语言了解程序的实际构成(一)

原创
2020/10/14 19:20
阅读数 12

思考问题

    1. 本地代码的指令中,表示其功能的英语缩写成为什么
      助记符
      解析:汇编语言是通过助记符来记述程序的
    1. 汇编语言的源代码转换成本地代码的方式称为什么
      汇编
      解析:使用汇编器这个工具来进行汇编
    1. 本地代码转换成汇编语言的源代码的方式称为什么
      反汇编
      解析:通过反汇编,得到人们可以理解的代码
    1. 汇编语言的源文件的扩展名,通常是什么格式
      .asm
      解析:.asm是assembler(汇编器)的略写
    1. 汇编语言程序中的段定义指的是什么
      段定义是指构成程序的命令和数据的集合组
      解析:在高级编程语言的源代码中,即使指令和数据在编写时是分散的,编译后也会在段定义中集合汇总起来。
    1. 汇编语言的跳转指令,是在何种情况下使用的
      将程序流程跳转到其他地址时需要用到该指令
      解析:在汇编语言中,通过跳转指令,可以实现循环和条件分支

章节重点

  • CPU解释运行的本地代码和汇编语言的一对一关系、
  • 汇编语言的源代码中包含的用来指示汇编器的伪命令
  • 栈的push/pop以及调用函数的机制
  • 局部变量和全局变量的不同
  • 循环等流程控制的实现方式

在研究对象方面,本书选取了Pentium等x86系列CPU用的汇编语言,编程工具则使用了Borland C++。

章节结构

  • 汇编语言和本地代码是一一对应的
  • 通过编译器输出汇编语言的源代码(获取汇编语言的源代码的方式)
  • 不会转换成本地代码的伪指令(汇编语言的源代码的构成)
  • 汇编语言的语法是“操作码 +(修饰语)+ 操作数”
  • 最常用的mov指令
  • 对栈进行push和pop
  • 函数调用机制
  • 函数内部的处理
  • 始终确保全局变量用的内存空间
  • 临时确保局部变量用的内存空间
  • 循环处理的实现方法
  • 条件分支的实现方法
  • 了解程序运行方式的必要性

汇编语言和本地代码是一一对应的

助记符:表示其功能的英语单词缩写
汇编语言:使用助记符的编程语言
汇编:将汇编语言编写的源代码转换成本地代码(即机器语言代码)
汇编器:将汇编语言编写的源代码转成本地代码的程序
反汇编:将本地代码(即机器语言代码)转换成汇编语言代码
反汇编程序:将本地代码转换成汇编语言的源代码

用汇编语言编写的源代码和本地代码是一一对应的,但C语言的源代码同本地代码不是一一对应的,C语言的源代码编译后生成的是特定CPU用的本地代码。)。因此本地代码变换成C语言源代码的反编译,比反汇编困难。

通过编译器输出汇编语言的源代码(获取汇编语言的源代码的方式)

获取汇编语言的源代码方式:本地代码进行反汇编
除此之外,通过其他方式也可以获取。
大部分C语言编译器,都可以利用C语言编写的源代码转成汇编语言的源代码,而不是本地代码。

如在BorlandC++中,通过编译器的选项中指定“-S”,就可以生成汇编语言的源代码了。

// 返回两个参数之和的函数
int AddNum(int a,int b)
{
	return a + b;
}

// 调用AddNum函数的函数
void MyFunc()
{
	int c;
	c = AddNum(123,456);
}

不会转换成本地代码的伪指令(汇编语言的源代码的构成)

汇编语言的源代码,是由转换成本地代码的指令(即操作码)和针对汇编器的伪指令构成的。
伪指令:负责把程序的构造以及汇编的方法指示给汇编器(转换程序)。但伪指令本身是无法汇编转换成本地代码的

下面通过一段汇编语言的源代码来分析伪指令

段定义:由伪指令segment和ends围起来的部分,是给构成程序的命令和数据的集合体加上一个名字而得到的。
在程序中,段定义指的是命令和数据等程序的集合体的意思。一个程序由多个段定义构成。段定义是一个连续的内存空间。

_TEXT segment dword public use32 'CODE' 
_TEXT ends
_DATA segment dword public use32 'DATA'
_DATA ends
_BSS segment dword public use32 'BSS'
_BSS ends
DGROUP group _BSS,_DATA

_TEXT segment dword public use32 'CODE'

_AddNum                 proc     near
_AddNum                 endp 
_MyFunc                 proc    near
_MyFunc                 endp

_TEXT  ends
           end
/* 
_TEXT 表示段定义的名称,是指令的段定义
_DATA 表示段定义的名称,是被初始化(有初始值)的数据的段定义
_BSS 表示段定义的名称,是尚未初始化的数据的段定义。
类似这种段定义的名称及划分方法是BorlandC++的规定。
是由Borland C++的编译器自动分配的,  
因而程序段定义的配置顺序就成了_TEXT、_DATA、_BSS,  
这样也确保了内存的连续性。

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伪指令,表示的是源代码的结束。

*/

汇编语言的语法是“操作码 +(修饰语)+ 操作数”

在汇编语言中,一行表示对CPU的一个指令。
汇编语言的语法是“操作码 +(修饰语)+ 操作数”(也存在只有操作码没有操作数的指令)
操作码:表示的是指令动作。
操作数:表示的是指令对象(如内存地址以及寄存器等)
修饰语:放再操作数之前(可以有也可以没有)

CPU种类不同,能够使用的操作码形式也不同。

如32位x86系列CPU用的操作码。

操作码 操作数 功能
mov A,B 把B的值赋给A
and A,B 把A同B的值相加,并赋给A
push A 把A的值存储在栈中
pop A 从栈中读取值,并将其赋给A
call A 调用函数A
ret 将处理返回到函数的调用源

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

寄存器是CPU中的存储区域。
寄存器具有存储指令和数据的功能,也有运算功能
寄存器的名称会通过汇编语言的源代码指定给操作数。
内存中的存储区域使用地址编号来区分的。
CPU内的寄存器使用eax以及ebx等名称来区分。
此外CPU内部有些寄存器是程序员无法直接操作的,


表示运算结果正负以及溢出状态的标志寄存器
以及操作系统专用的寄存器等

x86系列CPU的主要寄存器

寄存器名 中文名称 主要功能
eax 累加寄存器 运算
ebx 基址寄存器 存储内存地址
ecx 计数寄存器 计算循环次数
edx 数据计数器 存储数据
esi 源基址寄存器 存储数据发送源的内存地址
edi 目标基址寄存器 存储数据发送目标的内存地址
ebp 扩展基址指针寄存器 存储数据存储领域基点的内存地址
esp 扩展栈指针寄存器 存储栈中最高位数据的内存地址

最常用的mov指令

mov指令:对寄存器和内存进行数据存储的操作指令
mov指令后面有两个操作数,分别用来指定数据的存储地读出源
操作数中可以指定寄存器、常熟、标签(附加在地址前),以及用房阔([])围起来的内容。

  • 如果制定了没有用方括号围起来的内容,就表示对该值
  • 如果指定了方括号围起来的内容,方括号中的值则会被解释为内存地址
    然后就会对该内存地址对应的值进行读写操作。
mov ebp,esp
mov  eax,dword ptr [ebp+8]

/* ebp esp,eax都是寄存器
   其中ebp表示  扩展基址指针寄存器	功能:存储数据存储领域基点的内存地址
   esp表示  扩展栈指针寄存器	    功能:存储栈中最高位数据的内存地址
   eax表示 累加寄存器     功能:运算  
   根据mov语法,第1行表示从esp寄存器中的值并将其存储到ebp寄存器中。
   
   第二行,根据mov的语法  
   eax寄存器位为存储地;dword ptr [ebp+8]为读出源  
   dword ptr是修饰语,类似于限定范围的定语,表示的是从指定内存地址读出4字节。   
   [ebp + 8]用[]方括号围起来,则方括号中的值( = ebp寄存器中的值 +8)被解释称内存地址
   因此dword ptr [ebp + 8]表示的是从[ebp + 8]这样的内存地址中读出4个字节大小的数据 
   


*/

对栈进行push和pop

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

栈是存储临时数据的区域,他的特点是通过push指令和pop指令进行数据的存储和读出。
往栈中存储数据称为“入栈”,从栈中读出数据称为“出栈”。

push指令和push指令中只有一个操作数。
32位x86系列的CPU中,进行1次push或pop,即可处理32位(4字节)的数据。

该操作数表示的是“push的是什么以及pop的是什么”,而不需要指定“对哪一个地址编号的内存进行push或pop”。
这是因为,对栈进行读写的内存地址时esp寄存器(栈指针)进行管理的。
push和pop指令运行后,esp寄存器的值会自动进行更新(push指令是-4,pop指令是+4,一次处理),因而就没有必要指定内存地址了。

push指令运行后,操作数中指定的值就被自动push入栈,pop指令运行后,最后存储在栈中的值就会被pop到指定的操作数中出栈。这种数据的存储顺序称为LIFO(Last In First Out)方式。

函数调用机制

  • C语言编译器规定,

在函数的入口处把寄存器ebp的值入栈保存,在函数的出口处出栈
这样是为了确保函数调用前后ebp寄存器的值不发生变化。

  • C语言规定,函数参数中,位于后面的数值先入栈。
  • 在汇编语言中,函数名表示的是函数所在的内存地址
  • 函数调用使用的是call指令,
    call指令运行后,
    call指令的下一行的内存地址(即调用函数完毕后要返回的内存地址)会自动的push入栈
    该地址会在被调函数(此处是AddNum())处理的最后通过ret指令pop出栈,然后程序流程就会返回到(6)这一行 (思考:是如何实现自动push入栈的)
// 函数调用的汇编语言代码
_MyFunc        proc               near                                                                          编号
push              ebp                ;将ebp寄存器的值存入栈中                                     (1)
mov               ebp,esp      ;将esp寄存器中的值存入ebp寄存器中                  (2)

push              456                ;456入栈                                                                (3)
push              123                ;123入栈                                                                (4)
call                _AddNum      ;调用AddNum函数                                                (5)
add               esp,8             ;esp寄存器的值 + 8                                               (6)
pop               esp                ;读出栈中的数值存入esp寄存器                            (7)
ret                                       ;结束MyFunc函数,返回到调用源                         (8)

_MyFunc       endp

C语言编译器规定,  
在函数的入口处把寄存器ebp的值入栈保存(1)  
在函数的出口处出栈(7)  
这样是为了确保函数调用前后ebp寄存器的值不发生变化。  

(1)(2)(7)(8)的处理适用于C语言中所有的函数。  


(3)(4)(5)(6)部分,对于了解函数调用的机制至关重要  

(3)(4)表示的是将传递给AddNum函数的参数通过push入栈 
C语言规定,函数参数中,位于后面的数值先入栈。  

(5)的call指令,把程序流程跳转到了操作数中指定的AddNum函数所在的内存地址。  
在汇编语言中,函数名表示的是函数所在的内存地址  
AddNum函数处理完毕后,程序流程必须返回到编号(6)这一行。

(6)部分会把栈中存储的两个参数(456和123)进行销毁处理(第5章提到的栈清理处理)  
add esp,8  // 与pop指令有相同功能,但pop需要两次,因为1次pop只能4个字节
通过add esp,8这个指令,使存储着栈数据的esp寄存器前进8位(设定为指向高8位字节地址),来进行数据清理。  
CPU 中,占中堆积的最高位的数据地址是保存在esp(esp是Pentium系列CPU的栈指针名)中。
连续运行两次pop指令,可以消除第一步中两个存储在栈中的4字节数据,
而同样的功能也可以通过esp的数值加8只用一次就可以实现,因而此处add命令更有效率。  
在此处pop和esp的区别:add后,内存中的数据实际上还残留着,  
但只要把esp寄存器的值更新为数据存储地址前面的数据位置,
就相当于改数据被销毁了。


注意:在C语言源代码中,有一个处理是在变量c中存储AddNum函数的返回值,
在汇编语言的源代码中,并没有榆次对应的处理。  
这是因为编译器有最优化功能。  

编译器的最优化功能:是编译器在本地代码上实现的,目的是让编译后的程序运行速度更快、文件更小。
上面由于存储着AddNum函数返回值的变量c在后面没有被用到,一次编译器就会认为“没有意义”,就不会生成与之对应的汇编语言代码。

函数内部的处理

通过执行AddNum函数的源代码部分,观察函数的参数接受、返回值的返回等机制

int AddNum(int a,int b)
{
	return a + b;
}
// 汇编语言的源代码
_AddNum          proc             near
  push                ebp                                               (1)
  mov                 ebp,esp                                         (2)
  mov                 eax,dword ptr [ebp + 8]               (3)
  add                  eax,dword ptr [ebp + 12]             (4)
  pop                  ebp                                               (5)
  ret                                                                         (6)
_AddNum         endp

ebp寄存器的值在(1)中入栈,在(5)中出栈
这主要是为了函数中用到的ebp寄存器的内容,恢复到函数调用前的状态。
在进入函数处理之前,无法确定ebp寄存器用到了什么地方,但由于函数内部也会用到ebp寄存器,所以就暂时将该值保存了起来。

CPU拥有的寄存器是有数量限制的。在函数调用前,调用源有可能已经在使用寄存器了。 因而,存在函数内部利用的寄存器,要尽量返回到函数调用前的状态。 为此,我么就需要将其暂时保存在栈中,然后再在函数处理完毕之前出栈,使其返回到原来的状态。

(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寄存器返回,这也是规定
不过,eax寄存器的值不用还原到原始状态(这与ebp寄存器不同) 函数的参数是通过栈来传递,返回值是通过寄存器来返回的

(6)中ret指令运行后,函数返回目的地的内存地址会自动出栈。
据此,程序流程就会跳转返回到调用函数的下一行(即Call_AddNum的下一行)。

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部