Merikanto

一簫一劍平生意,負盡狂名十五年

Assembly - Structures & How

目前很多的汇编语言教材大多都是上来先讲一大堆CPU、总线、寄存器、标志位 … 再讲汇编语言程序设计。这种字典式的编写方法对入门是很不利的,因为在不知道这些东西都是用来干什么的情况下,全部记忆往往很难。然而这些概念在编程中还不得不用到,于是又得重新往前翻书,这就陷入了一个循环。

实际上,汇编语言的学习完全可以和高级语言一样。只不过因为汇编语言是根据CPU的工作原理进行操作,所以一切代码都要从CPU和内存的角度考虑问题。理解了指令在内存层面的执行过程,编程就水到渠成了。

Reference:

汇编语言程序设计

为何不能直接写 MOV DS,DATA


An Example

先从最简单的开始:给定两个数a和b,让CPU做一次加法,结果储存在c中。输出c。

用C语言编写这个程序:

1
2
3
4
5
6
7
8
9
int a=3;
int b=4;
int c;

int main() {
c=a+b;
printf("%d",c);
return 0;
}

注意:如果写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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
DATA SEGMENT	; Data
A DW 03H
B DW 04H
C DW ? ; ? 表示声明时不赋值。相当于 int c;
SEGMENT ENDS

STACK SEGMENT ; Stack
SEGMENT ENDS

CODE SEGMENT ; Code
ASSUME:CS:CODE,DS:DATA,SS:SEGMENT
; 注意:ASSUME是一个伪代码,它只是告诉了编译器各个段与段寄存器的对应关系,并未存入各段的地址。
START: ; 指定程序的入口位置(这个位置当然要在代码段中),并命名为START。
MOV AX,DATA
MOV DS,AX ; 这两行的意思是,以通用寄存器AX为中介,将数据段DATA的起始地址(用句柄DATA表示)送入数据段寄存器DS中。
; 在需要使用数据段的程序中,这一步是必须的,否则CPU无法确定数据段的位置。
;(由于电路结构的原因,DS不支持直接寻址。不能直接写 MOV DS,DATA。)

LEA SI,A ; 注意:与DATA SEGMENT中DATA的含义不同,数据段中A DW 03H中的A仅表示变量名,不表示变量的地址。
; 类似于int a=3,a只是名称,取地址要用 &a。在汇编中,取地址用LEA BX/SI/DI,A的格式。
; 注:通常用BX、SI、DI这三个寄存器存数据的偏移地址。

MOV AX,[SI] ; 将 [内存数据段中以SI为偏移地址的内容] 送入通用寄存器 AX中。这里AX也可以换成BX,CX等。
INC SI ; SI的值加1。即SI++。
INC SI ; 因为A是双字节数据,所以要来2次才能指向下一个数据B的偏移地址。当然,这两条也可用 LEA SI,B替代。
MOV BX,[SI] ; 将 [内存数据段中以SI为偏移地址的内容] ——就是B,送入通用寄存器 BX中。
ADD BX,AX ; BX与AX相加,结果存放在BX中。当然也可以写ADD AX,BX。
; 但之所以不存放在AX中,是因为一会输出要用到AH,避免冲突。
LEA DI,C
MOV [DI],BX ; 这两条是将存放在BX中的结果写回数据段中C所在位置
MOV DX,BX ; 准备在屏幕上输出结果。屏幕输出的是寄存器DX中的内容。
ADD DX,30H ; 由于屏幕上输出的是文字,因此必须将数字+30H转换为对应的ASCII码
MOV AH,02H ; 将控制DOS系统输出数值的代码02H装入AH。这步可理解为printf()中的"%d"
INT 21H ; INT = Interrupt,中断,执行DOS命令。执行后返回程序。
MOV AH,4CH ; 4CH是程序结束,返回DOS系统的命令。将此命令装入AH,等待执行。
INT 21H ; 中断,DOS系统执行4CH命令。程序结束。这两步类似于 "return 0"
SEGMENT ENDS

END START ; 在程序的尾部,告诉编译器程序的入口位置在标号START处

输出结果为7。