程序员需要了解的硬核知识之汇编语言(一)

之前的系列文章从 CPU 和内存方面简单介绍了一下汇编语言,但是还没有系统的了解一下汇编语言,汇编语言作为第二代计算机语言,会用一些容易理解和记忆的字母,单词来代替一个特定的指令,作为高级编程语言的基础,有必要系统的了解一下汇编语言,那么本篇文章希望大家跟我一起来了解一下汇编语言。

汇编语言和本地代码

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

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

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

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

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

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

我们上面提到本地代码可以经过反汇编转换成为汇编代码,但是只有这一种转换方式吗?显然不是,C 语言编写的源代码也能够通过编译器编译称为汇编代码,下面就来尝试一下。

首先需要先做一些准备,需要先下载 Borland C++ 5.5 编译器,为了方便,我这边直接下载好了读者直接从我的百度网盘提取即可 (链接:https://pan.baidu.com/s/19LqVICpn5GcV88thD2AnlA 密码:hz1u)

下载完毕,需要进行配置,下面是配置说明 (https://wenku.baidu.com/view/22e2f418650e52ea551898ad.html),教程很完整跟着配置就可以,下面开始我们的编译过程

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

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

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

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

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

bcc32 -c -S Sample4.c

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

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

	.386p
	ifdef ??version
	if    ??version GT 500H
	.mmx
	endif
	endif
	model flat
	ifndef	??version
	?debug	macro
	endm
	endif
	?debug	S "Sample4.c"
	?debug	T "Sample4.c"
_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
?live1@0:
   ;	
   ;	int AddNum(int a,int b){
   ;	
	push      ebp
	mov       ebp,esp
   ;	
   ;	
   ;	    return a + b;
   ;	
@1:
	mov       eax,dword ptr [ebp+8]
	add       eax,dword ptr [ebp+12]
   ;	
   ;	}
   ;	
@3:
@2:
	pop       ebp
	ret 
_AddNum	endp
_MyFunc	proc	near
?live1@48:
   ;	
   ;	void MyFunc(){
   ;	
	push      ebp
	mov       ebp,esp
   ;	
   ;	    int c;
   ;	    c = AddNum(123,456);
   ;	
@4:
	push      456
	push      123
	call      _AddNum
	add       esp,8
   ;	
   ;	}
   ;	
@5:
	pop       ebp
	ret 
_MyFunc	endp
_TEXT	ends
	public	_AddNum
	public	_MyFunc
	?debug	D "Sample4.c" 20343 45835
	end

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

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

第一次看到汇编代码的读者可能感觉起来比较难,不过实际上其实比较简单,而且可能比 C 语言还要简单,为了便于阅读汇编代码的源代码,需要注意几个要点

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

_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

_AddNum	proc	near
_AddNum	endp

_MyFunc	proc	near
_MyFunc	endp

_TEXT	ends
	end

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

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

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

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

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

DGROUP	group	_BSS,_DATA

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

_TEXT	segment dword public use32 'CODE'
_TEXT	ends

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

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

_AddNum	proc	near
_AddNum	endp

_MyFunc	proc	near
_MyFunc	endp

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

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

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

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

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

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

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

如果 CPU 和内存的关系你还不是很了解的话,请阅读作者的另一篇文章 程序员需要了解的硬核知识之CPU 详细了解。

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

指令解析

下面就对 CPU 中的指令进行分析

最常用的 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 的地址的值。dword ptr 也叫做 double word pointer 简单解释一下就是从指定的内存地址中读出4字节的数据

对栈进行 push 和 pop

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

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

函数的调用机制

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

_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				ebp			; 读出栈中的数值存入 esp 寄存器中				 (7)
	ret 							; 结束 MyFunc 函数,返回到调用源					(8)
_MyFunc 		endp

代码解释中的(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 这一函数前后栈内存的变化

函数的内部处理

上面我们用汇编代码分析了一下 Sample4.c 整个过程的代码,现在我们着重分析一下 AddNum 函数的源代码部分,分析一下参数的接收、返回值和返回等机制

_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 寄存器的内容,恢复到函数调用前的状态。

(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 函数入口和出口处栈的状态变化,就如下图所示

这是程序员需要了解的硬核知识之汇编语言(一) 第一篇文章,下一篇文章我们会着重讨论局部变量和全局变量以及循环控制语句的汇编语言,防止断更,请关注我

posted @ 2019-11-27 13:04  程序员cxuan  阅读(1801)  评论(1编辑  收藏  举报