目前很多的汇编语言教材大多都是上来先讲一大堆CPU、总线、寄存器、标志位 … 再讲汇编语言程序设计。这种字典式的编写方法对入门是很不利的,因为在不知道这些东西都是用来干什么的情况下,全部记忆往往很难。然而这些概念在编程中还不得不用到,于是又得重新往前翻书,这就陷入了一个循环。
实际上,汇编语言的学习完全可以和高级语言一样。只不过因为汇编语言是根据CPU的工作原理进行操作,所以一切代码都要从CPU和内存的角度考虑问题。理解了指令在内存层面的执行过程,编程就水到渠成了。
Reference:
An Example
先从最简单的开始:给定两个数a和b,让CPU做一次加法,结果储存在c中。输出c。
用C语言编写这个程序:
1 | int a=3; |
注意:如果写int c=3+4,一行就可以搞定。但是这里没这样做,而是先统一声明所有的变量,然后再在进行运算的主函数中执行相加操作。后面可以看到,这种编程习惯是符合二进制数据在内存中的存放规律的。
如果用汇编语言编写,该怎样写呢?
再重复一下题目:给定两个数a和b,让CPU做一次加法,结果储存在c中。输出c。
Principles
要从原理上写这个过程,就要解决以下问题:
- 数据a和b怎么存储
- 怎么做加法
- 怎么储存结果
- 怎么输出结果
下面将分别解决这四个问题。
汇编语言程序结构
首先,我们要知道二进制信号在内存中的存放规律。众所周知,计算机能直接处理的只能是二进制信号,这些信号以高低电平的方式存放在内存中,既可以作为指令,也可以作为程序使用的数据。一块内存区域所存放的二进制信号到底是指令还是数据,是由相应的命令说了算的。
CPU在读取指令/数据时,每读取一条指令/数据,内存位置指针就加1,指向下一条指令/数据的内存地址。这样就产生了一个问题:数据和指令在内存中应该分块,并且要连续存放。否则如果内存位置指针不知道下一个位置是数据还是代码,将会给内存位置指针的寻址带来极大的不便。所以,在汇编程序中,要人工将内存分为:
- 数据段(Data Segment)
- 代码段(Code Segment)
- 堆栈段(Stack Segment)
- 附加段(Extra Segment)
这样划分好以后,我们只需要告诉内存位置指针每个段在内存中的起始地址,内存位置指针就可以顺利寻址了。怎样告诉呢?在CPU中,有一组专门的段寄存器用来存放各个段的起始地址。它们是:DS(用来存放数据段的起始地址),CS(用来存放代码段的起始地址),SS(用来存放堆栈段的起始地址),ES(用来存放附加段的起始地址)。程序员在编程时,需要人工指定这些段寄存器对应于程序中的哪个段。
有了段的概念,我们就可以写出一个汇编程序的基本框架如下:
DATA SEGMENT
: 定义一个叫DATA的段。DATA既是这个段的名称,也指代这个段的地址。但这里并未规定这个段是数据段、代码段还是其他段SEGMENT ENDS
: 表示段结束。ENDS是END SEGMENT的缩写。STACK SEGMENT
: 定义一个叫STACK的段,这个段的地址用STACK表示。SEGMENT ENDS
: 段结束
CODE SEGMENT
: 定义一个叫CODE的段,,这个段的地址用CODE表示。
ASSUME:CS:CODE,DS:DATA,SS:SEGMENT
: 告诉编译器,将代码中写的各段分别对应上各个段寄存器。这句话要放在准备用作代码段的段开头SEGMENT ENDS
: 段结束
好了。回到我们的问题:怎样存储a和b呢?在数据段中声明变量如下:
DATA SEGMENT
A DW 03H
: 定义一个名为A的双字节(即1个字)的数据,DW是Define Word 的缩写。末尾加H表示十六进制。
这相当于C语言中的 int A=3
,只不过int表示的范围远大于 DW
而已。
B DW 04H
: 定义一个名为B的双字节数据。由于B是紧挨着A之后定义的,根据 数据段的连续性,B在数据段的偏移地址就是A在数据段的偏移地址 + A的长度。由于 A是双字节数据,所以A的长度是2个字。
SEGMENT ENDS
CPU的运算方式及运算结果的判定
第二个问题:怎样做一次加法?
CPU只能处理电平信号。学过模电的都知道,有一种东西叫“加法器”,输入2个电压信号,经过运算放大器后,就会得到这两个信号的和。所以CPU做加法的方式就是:把输入的两个二进制信号输入加法器,得到结果。
问题似乎解决了。但是我们突然发现,这样的结果几乎没有任何意义,因为我们无法知道结果的性质。比如,如果结果超出了能容许的最大位数(溢出),会怎么样?CPU没有任何提示。又或者,我们要比较两个数的大小,这就要将两个数相减。然而结果是正是负?我们无从知晓。
为了获知运算结果的性质,在CPU中设置了一个“标志寄存器”,专门用于存放运算结果的各种标志。它们都是用电路实现的。比如:
CF(Carry Flag) 就是用来标志无符号数运算是否产生进位。产生进位时,CF=1,反之CF=0。特别指出,CF标志位的值对有符号数的运算没有意义。
OF(Overflow Flag) 则是用来标志有符号数运算是否产生溢出。产生溢出时,OF=1,反之OF=0。同理,OF标志位的值对无符号数的运算没有意义。
SF(Sign Flag) 用来标志结果的正负。当结果是负(SF)时,SF=1。反之SF=0。
回到我们的问题:怎么做一次加法?或者更一般地,怎样做一次运算?
我们不必关心具体的电路实现细节,只需要执行相应的运算指令,运算完成后,不仅会得到结果,各个标志位的值也可能发生相应的改变,从而有利于我们对结果的判断。例如:
ADD AX,BX
: 把AX和BX中的内容相加,结果存放在AX中。若AX,BX为有符号数,当产生溢出时,OF=1
。 CF的值不确定。当结果为负时,SF=1。
内存与寄存器的关系
内存(RAM)是存放各种数据、指令的地方。根据用途的不同,又可以把它分成不同的段。而寄存器(Register)则是CPU内部临时存放运算结果的地方。与容量较大的内存相比,寄存器的容量极小(每个寄存器只有16位),数量有限(只有少数几个),用途专一(各个寄存器有不同的用途,用来存放不同方面的结果)。例如,前面所述的段寄存器(DS,CS,SS,ES)就是用来存放段的起始地址的。除了段寄存器之外,CPU中还设有通用寄存器(AX,BX,CX,DX …)。它们各自有其专门的用途,在不致于产生冲突的情况下,也可以用来存放数据或运算结果。
通用寄存器的用途简述如下:(通用寄存器容量都是16位的)
1) AX:
- 用来存放数据或运算结果
- AX的高8位AH用于与DOS操作系统通信。向AH中装入DOS系统的指令码并执行,可以利用DOS系统完成一些操作,如在屏幕上输出字符。
2) DX:
- 用来存放数据或运算结果
- 与AH的DOS屏幕输出指令码配合使用,存放准备输出到屏幕上的数据
3) CX:
- 在有循环的程序中,用来存放循环次数。相当于for循环中的计数变量i。
4) BX、SI、DI:
- 用来存放数据或运算结果
- 用来存放数据段中的数据在段中的偏移地址
一般而言,需要运算的数据存放在内存中。CPU在程序的指令下,通过指针确定它们的位置,将它们读入寄存器。进行运算后,再将结果返回到内存预留的结果位置中。
代码
回到我们的问题,在内存的DATA SEGMENT中存放有两个双字节数据A=3和B=4。要将它们读入寄存器进行相加运算,再将结果写入到内存中。为了读入寄存器,首先需要获取A和B在内存数据段中的偏移地址。确定它们的地址后,按地址将它们读入寄存器(这里可以任选两个寄存器),然后执行运算指令。运算完成后,将储存在寄存器中的结果写入到内存 DATA SEGMENT
中事先预留的位置。使用 MOV 目标,源
指令完成源对目标的赋值。代码如下:
1 | DATA ; Data |
输出结果为7。