计算机基础- -汇编语言
文章目录
一、汇编语言和本地代码
- 计算机
CPU只能运行本地代码(机器语言) 程序, 用C语言等高级语言编写的代码, 需要经过编译器编 译后,转换为本地代码才能够被CPU解释执行
。 - 但是本地代码的可读性非常差,所以需要使用一种能够直接读懂的语言来替换本地代码
- ,那就是在
各本地代码中,附带上表示其功能的
英文缩写,比如在加法运算的本地代码加上add(addition)写、在比较运算符的本地代码中加上cmp(compare) 的缩写等, 这些通过缩写来表示具体本地代码指 的缩 令的标志称为助记符,使用助记符的
语言称为汇编语言。这样,通过阅读汇编语言,也能够了解本 地代码的含义了。 - 不过,即使是使用
汇编语言
编写的源代码,最终也必须要转换为本地代码
才能够运行,负责做这项工作的程序称为编译器,转换的这个过程称为汇编
。 - 在将源代码转换为本地代码这个功能方面,汇编器和编译器是同样的。
- 用汇编语言编写的源代码和本地代码是一一对应的。因而,本地代码也可以反过来转换成汇编语言编写的代码。把
本地代码转换为汇编代码
的这一过程称为反汇编
,执行反汇编的程序称为反汇编程序。
- 哪怕是C语言编写的源代码, 编译后也会转换成特定CPU用的本地代码。而将其反汇编的话, 就可以得到汇编语言的源代码,并对其内容进行调查。
- 不过,本地代码变成C语言源代码的反编译,要比本地代码转换成汇编代码的反汇编要困难,这是因为,C语言代码和本地代码不是一一对应的关系。
二、通过编译器输出汇编语言的源代码
我们上面提到本地代码可以经过反汇编转换成为汇编代码,但是只有这一种转换方式吗?
显然不是,C语言编写的源代码也能够通过编译器编译称为汇编代码,下面就来尝试一下。
-
编写完成后将其文件名保存为Sample 4.c, C语言源文件的扩展名, 通常用.c
来表示,上面程序是提供两个输入参数并返回它们之和。 -
在Windows操作系统下打开输入命令提示符, 切换到保存Sample 4.c的文件夹下, 然后在命令提示符中
bcc 32-c-S SampLe 4.c
- bcc 32是启动Borland C++的命令,
- -C 的选项是指仅进行编译而不进行链接,
- -S选项被用来指定生成汇编语言的源代码
- 作为编译的结果, 当前目录下会生成一个名为Sample 4.asm的汇编语言源代码。汇编语言源文件的扩展名, 通常用.asm来表示
下面就让我们用编辑器打开看一下Sample 4.asm中的内容:
三、不会转换成本地代码的伪指令
第一次看到汇编代码的读者可能感觉起来比较难,不过实际上其实比较简单,而且可能比C语言还要简单,为了便于阅读汇编代码的源代码,需要注意几个要点
- 汇编语言的源代码,是由转换成本地代码的指令(后面讲述的操作码)和针对汇编器的伪指令构成的。
-
伪指令负责把程序的构造以及汇编的方法指示给汇编器
(转换程序)。不过伪指令是无法汇编转换成为 本地代码的。
下面是上面程序截取的伪指令:
-
由伪指令 segment 和 ends围起来的部分,是给构成程序的命令和数据的集合体上加一个名字而得 到的,称为
段定义
。 -
段定义的英文表达具有区域的意思,在这个程序中,
段定义指的是命令和数据等程序的集合体的意思,一个程序由多个段定义构成。
-
上面代码的开始位置, 定义了3个名称分别为
_TEXT、_DATA、_BSS的段定义
, _TEXT是指定的段定义,_DATA是被初始化(有初始值)的数据的段定义,_BSS是尚未初始化的数据的段定义。 -
这种定义的名称是由Borland C++定义的, 是由Borland C++编译器自动分配的, 所以程序段定义的顺序就成为了_TEXT、_DATA、_BSS, 这样也确保了内存的连续性
段定义(segment) 是用来区分或者划分范围区域的意思。汇编语言的segment伪指令表示段定 义的起始,ends伪指令表示段定义的结束。段定义是一段连续的内存空间
- 而group这个伪指令表示的是将_BSS和_DATA这两个段定义汇总名为D GROUP的组
DGROUP group _BSS, _DATA
- 围起_Add Num和_My Fun的_TEXT segment和_TEXT ends, 表示_Add Num
和_My Fun是属于_TEXT这一段定义的。
_TEXT segment dword public use 32'CODE'
_TEXT ends
- 编译后在函数名前附带上下划线_, 是Borland C++的规定。在C语言中编写的Add Num函数, 在内部是以_Add Num这个名称处理的。
- 伪指令proc和endp围起来的部分, 表示的是过程(procedure)的范围。在汇编语言中,这种相当于C语言的函数的形式称为过程。末尾的end伪指令,表示的是源代码的结束。
四、汇编语言的语法是操作码+操作数
- 在汇编语言中, 一行表示一对CPU的一个指令。
汇编语言指令的语法结构是操作码+操作数
, 也存 在只有操作码没有操作数的指令。 - 操作码表示的是指令动作,操作数表示的是指令对象。操作码和操作数一起使用就是一个英文指令。
- 比 如从英语语法来分析的话, 操作码是动词, 操作数是宾语。
- 比如这个句子Give me money这个英文 指令的话, Give就是操作码,me和money就是操作数。
- 汇编语言中存在多个操作数的情况, 要用逗 号把它们分割, 就像是Give me, money这样。
-
能够使用何种形式的操作码, 是由CPU的种类决定的, 下面对操作码的功能进行了整理。
-
本地代码需要加载到内存后才能运行, 内存中存储着构成本地代码的指令和数据。程序运行时, CPU会从内存中把数据和指令读出来, 然后放在CPU内部的寄存器中进行处理
-
寄存器是CPU中的存储区域, 寄存器除了具有临时存储和计算的功能之外, 还具有运算功能
, x 86系列的主要种类和角色如下图所示
1.指令解析
下面就对CPU中的指令进行分析
最常用的mov指令
- 指令中最常使用的是对寄存器和内存进行数据存储的mov 指令,
mov指令的两个操作数, 分别用来 指定数据的存储地和读出源
。 -
操作数中可以指定寄存器、常数、标签
(附加在地址前),以及用方括号 ([])围起来的这些内容。 - 如果指定了没有用([])方括号围起来的内容,就表示对该值进行处理;如果指定了用方括号围起来的内容,方括号的值则会被解释为内存地址,然后就会对该内存地址对应的值进行读写操作。
让我们对上面的代码片段进行说明
- move bp, esp中, esp寄存器中的值被直接存储在了ebp中, 也就是说, 如果esp寄存器的值是100的话那么ebp寄存器的值也是100。
- 而在 move eax, dword ptr[ebp+8]这条指令中, ebp寄存器的值+8后会被解析称为内存地址。
- 如果ebp寄存器的值是100的话, 那么eax寄存器的值就是100+8的地址的值。
dword
ptr也叫做doubleword pointer,简单解释一下就是从指定的内存地址中读出4字节的数据
对栈进行push和pop
-
程序运行时, 会在内存上申请分配一个称为栈的数据空间
。栈(stack) 的特性是后入先出, 数据在存储时是从内存的下层(大的地址编号)逐渐往上层(小的地址编号)累积,读出时则是按照从上往下进 行读取的
。
- 栈是存储临时数据的区域, 它的特点是
通过push指令和pop指令进行数据的存储和读出
。 - 向栈中存储 数据称为入栈,从栈中读出数据称为 出栈,
32位x 86系列的CPU中, 进行1次push或者pop,即可处理32位(4字节)的数据
。
2.函数的调用机制
- 下面我们一起来分析一下函数的调用机制,我们以上面的C语言编写的代码为例。
- 首先,让我们从MyFunc函数调用Add Num函数的汇编语言部分开始,来对函数的调用机制进行说明。
- 栈在函数的调 用中发挥了巨大的作用,下面是经过处理后的MyFunc函数的汇编处理内容
- 代码解释中的(1)、(2)、(7)、(8)的处理适用于C语言中的所有函数,
(3)-(6)这一部分,这对了解函数调用机制至关重要
。 - (3) 和(4) 表示的是将传递给Add Num函数的参数通过push入栈。
- 在C语言源代码中, 虽然记述为函数Add Num(123, 456) , 但入栈时则会先按照456, 123这样的顺序。也就是
位于后面的数值先入栈,这是C语言的规定(参数传递的先后次序)
。 - (5) 表示的
call指令, 会把程序流程跳转到Add Num函数指令的地址处。在汇编语言中, 函数名表示的就是函数所在的内存地址
。 - Add Num函数处理完毕后, 程序流程必须要返回到编号(6) 这一行。call指令运行后,
call指令的下一行
(也就指的是(6) 这一行)的内存地址
(调用函数完毕后要返回的内存地址)会自动的push入栈
。 - 该值会在Add Num函数处理的最后通过ret指令pop出栈,然后程序会返回到(6)这一行。(6) 部分会把栈中存储的两个参数(456和123) 进行销毁处理。
- 虽然通过两次的pop指令也可以实现,不过采用esp寄存器+8的方式会更有效率(处理1次即可) 。对栈进行数值的输入和输出时, 数值的单位是4字节。
- 因此, 通过在
负责栈地址管理的esp寄存器中加上4的2倍8, 就可以达到和运行两次pop命令同样的效果
。虽然内存中的数据实际上还残留着, 但只要把esp寄存器的值更新为数据存储地址前面的数据位置,该数据也就相当于销毁了。
在编译Sample 4.c文件时, 出现了下图的这条消息
- 图中的意思是指c的值在MyFunc定义了但是一直未被使用, 这其实是一项编译器优化的功能, 由于存储着Add Num函数返回值的变量c在后面没有被用到, 因此编译器就认为该变量没有意义, 进而也就没有生成与之对应的汇编语言代码。
下图是调用Add Num这一函数前后栈内存的变化
3.函数的内部处理
上面我们用汇编代码分析了一下Sample 4.c整个过程的代码, 现在我们着重分析一下Add Num函数的源代码部分,分析一下参数的接收、返回值和返回等机制
- 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_Add Num) 的下一行。
这时, Add Num函数入口和出口处栈的状态变化, 就如下图所示
4.全局变量和局部变量
在熟悉了汇编语言后,接下来我们来了解一下全局变量和局部变量,在函数外部定义的变量称为全局变量,在函数内部定义的变量称为局部变量
全局变量可以在任意函数中使用,局部变量只能在函数定义局部变量的内部使用。
下面定义的C语言代码分别定义了局部变量和全局变量,并且给各变量进行了赋值,我们先看一下源代码部分
- 我们分析其汇编源码就好, 我们用Borland C++编译后的汇编代码如下:
- 我们在分析上面汇编代码之前,先来认识一下更多的汇编指令,此表是对上面部分操作码及其功能的接续
注意:db 4 dup(?) 不要和dd 4混淆了, 前者表示的是4个长度是1字节的内存空间。而db 4表示的则是双字节(=4字节)的内存空间中存储的值是4
5.临时确保局部变量使用的内存空间
- 我们知道,
局部变量是临时保存在寄存器和栈中的
。函数内部利用栈进行局部变量的存储,函数调用完成后,局部变量值被销毁,但是寄存器可能用于其他目的。所以,局部变量只是函数在处理期间临时存 储在寄存器和栈中的
。 - 回想一下上述代码是不是定义了10个局部变量?这是为了
表示存储局部变量的不仅仅是栈,还有寄存器
。 - 为了确保c1-c10所需的域,寄存器空闲的时候就会使用寄存器,寄存器空间不足的时候就会使用栈。
让我们继续来分析上面代码的内容:
- _TEXT段定义表示的是MyFunc函数的范围。在MyFunc函数中定义的局部变量所需要的内存领域。
会被尽可能的分配在寄存器中
。大家可能认为使用高性能的寄存器来替代普通的内存是一种资源浪费,但是编译器不这么认为,只要寄存器有空间,编译器就会使用它。
- 由于寄存器的访问速度远高于内存,所以直接访问寄存器能够高效的处理。局部变量使用寄存器,是Borland C++编译器最优化的运行结果。
代码清单中的如下内容表示的是向寄存器中分配局部变量的部分
- 仅仅对局部变量进行定义是不够的,
只有在给局部变量赋值时,才会被分配到寄存器的内存区域
。 - 上述代码相当于就是给5个局部变量c 1-c 5分别赋值为1-5。eax、edx、ecx、ebx、esi是x 86系列32位CPU寄存器的名称。至于使用哪个寄存器, 是由编译器来决定的。
- x 86系列CPU拥有的寄存器中, 程序可以操作的是十几, 其中空闲的最多会有几个。因而,
局部变量超过寄存器数量的时候,可分配的寄存器就不够用了,这种情况下,编译器就会把栈派上用场,用来存储剩余的局部变量
。 - 在上述代码这一部分,给局部变量c1-c5分配完寄存器后,可用的寄存器数量就不足了。于是,剩下的5个局部变量c6-c10就被分配给了栈的内存空间。
如下面代码所示
- 函数入口add esp, -20指的是,
对栈数据存储位置的esp寄存器(栈指针) 的值做减20的处理。
- 为了确保内存变量c 6-c 10在栈中, 就需要保留5个int类型的局部变量(4字节*5=20字节) 所需的空间。
- mov ebp, esp这行**
指令表示的意思是将esp寄存器的值赋值到ebp寄存器。之所以需要这么处理,是为了通过在函数出口处mov esp ebp这一处理, 把esp寄存器的值还原到原始状态, 从而对请分配的栈空间进行释放
**,这时栈中用到的局部变量就消失了。这也是栈的清理处理。 - 在使用寄存器的情况下,局部变量则会在寄存器被用于其他用途时自动消失
如下图所示。
- 这五行代码
是往栈空间代入数值的部
分,由于在向栈申请内存空间前,借助了
move bp, 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语言程序的流程控制是如何实现的
我们还是以代码以及编译后的结果为例,看一下程序控制流程的处理过程。
- C语言中的for语句是通过在括号中指定
循环计数器的初始值(=0) 、循环的继续条件(i<10) 、循环计数器的更新(i++)
这三种形式来进行循环处理的。与此相对的汇编代码就是通过比较指令(cmp)和跳转指令(jl)来实现
的。
下面我们来对上述代码进行说明
- MyFunc函数中用到的局部变量只有i, 变量i申请分配了ebx寄存器的内存空间。
- for语句
括号中的x or ebx, ebx 这一处理, x or指令会对左起第一个操作数和右起第二个操作数进行i=0被转换为X OR运算, 然后把结果存储在第一个操作数中。
- 由于这里把第一个操作数和第二个操作数都指定为了ebx, 因此就变成了对相同数值的X OR运算。
- 也就是说不管当前寄存器的值是什么, 最终的结果都是0。
- 类似的,我们使用move bx, 0也能得到相同的结果, 但是x or指令的处理速度更快, 而且编译器也会启动最优化功能。
- X OR指的就是异或操作, 它的运算规则是如果
a、b两个值不相同, 则异或结果为1。如果a、b两 个值相同,异或结果为0
。- 例如01010101和01010101进行运算,就会分别对各个数字位进行X OR运算。因为每个 数字位都相同,所以运算结果为0。
-
ebx寄存器的值初始化后, 会通过call指定调用_My Sub函数, 从_My Sub函数返回后, 会执行incebx 指令, 对ebx的值进行+1操作, 这个操作就相当于i++的意思, ++表示的就是当前数值+1。
-
inc下一行的cmp是用来对第一个操作数和第二个操作数的数值进行比较的指令。
-
cmp ebx, 10就相当于C语言中的i<10这一处理, 意思是把ebx寄存器的值与10进行比较。汇编语言中比较指令的结果, 会存储在CPU的标志寄存器中。
-
不过, 标志寄存器的值, 程序是无法直接参考的。那如何判断比较结果呢?
-
汇编语言中有多个跳转指令,这些跳转指令会根据标志寄存器的值来判断是否进行跳转操作,例如最后一行的jl, 它会根据cmp ebx, 10指令所存储在标志寄存器中的值来判断是否跳转,
-
jl 这条指令表示的就是jump on less than(小于的话就跳转) 。发现如果i比10小, 就会跳转到@4所在的指令处继续执行。
7.条件分支的处理方法
- 条件分支的处理方式和循环的处理方式很相似,
使用的也是cmp指令和跳转指令。
下面是用C语言编写的条件分支的代码:
很简单的一个实现了条件判断的C语言代码, 那么我们把它用Borland C++编译之后的结果如下:
- 上面代码用到了三种跳转指令,
分别是jle(jump on lessor equal) 比较结果小时跳转, jge(jump on greater or equal) 比较结果大时跳转, 还有不管结果怎样都会进行跳转的jmp
, 在这些跳转指令之前还有用来比较的指令cmp, 构成了上述汇编代码的主要逻辑形式。
8.了解程序运行逻辑的必要性
- 通过对上述汇编代码和C语言源代码进行比较,想必大家对程序的运行方式有了新的理解,而且,从汇编源代码中获取的知识, 也有助于了解高级语言的特性
- 上面我们了解到的编程方式都是串行处理的,那么串行处理有什么特点呢?
串行处理最大的一个特点就是专心只做一件事情,一件事情做完之后才会去做另外一件事情。计算机是支持多线程的, 多线程的核心就是CPU切换
如下图所示:
我们还是举个实际的例子,让我们来看一段代码:
- 上述代码是更新counter的值的C语言程序, MyFunc 1() 和MyFunc 2) 的处理内容都是把counter的值扩大至原来的二倍, 然后再把counter的值赋值给counter。
- 这里, 我们假设使用多线程处理, 同时调用了一次MyFunc 1和MyFunc 2函数, 这时, 全局变量counter的值, 理应编程10022=400。
如果你开启了多个线程的话, 你会发现counter的数值有时也是200
, 对于为什么出现这种情况,如果你不了解程序的运行方式,是很难找到原因的。
我们将上面的代码转换成汇编语言的代码如下
在多线程程序中,用汇编语言表示的代码每运行一行,处理都有可能切换到其他线程中。
- 因而,假设My Fun 1函数在读出counter数值100后, 还未来得及将它的二倍值200写入counter时, 正巧My Fun 2函数读出了counter的值100, 那么结果就将变为200。
- 为了避免该bug, 我们可以采用以函数或C语言代码的行为单位来禁止线程切换的锁定方法, 或者使用某种线程安全的方式来避免该问题的出现。