在读《汇编语言》这本书学习汇编语言的时候,在此对每一章的要点进行总结和记录,以便日后复习与查看。
练习环境:Windows 2000 Professional,与书中程序运行环境一致。
第一章 基础知识
汇编语言是一门直接在硬件之上工作的编程语言。由于早期人们使用机器语言(一串二进制数字)进行编程存在不易纠错、晦涩难懂的缺点,所以发明了汇编语言来帮助程序员更高效的编程。汇编语言经编译连接之后可以直接形成由机器指令构成的程序,可以直接被CPU执行。而不同型号的CPU拥有不同的指令集,所以汇编语句对应的机器码可能不尽相同。这本书的汇编语言是基于8086CPU的指令集来进行描述的。
汇编语言包括三个部分:
- 汇编指令(核心部分):机器码的助记符,有对应的机器码
- 伪指令:由编译器执行,没有对应的机器码,计算机本身并不执行
- 其他符号:如
+
、-
、*
、/
等,由编译器识别,没有对应机器码
汇编语言的指令和数据信息存放在存储器(内存)中。在内存中指令和数据并没有任何区别,本质上都是二进制信息。而决定一段二进制信息是指令还是数据,则由CPU工作时决定。存储器由若干个存储单元构成,单个存储单元的最小单位是字节(Byte,1Byte=8Bit)。
CPU想要进行数据的读写(包括存储器及一些外部器件),必须通过总线,与这些器件进行三类信息的交互(主线逻辑上也分为这三类):
- 地址信息:CPU首先通过地址总线将要读写的存储单元地址发出
- 控制信息:随后CPU通过控制总线发送控制命令(内存读/写命令、存储芯片片选命令)
- 数据信息:再通过数据总线将数据送入对应的存储单元/从对应的存储单元读出数据
一个CPU的地址总线有$N$根,那么可寻址的最多单元数为$2^N$,CPU能够寻址到的所有内存单元构成了这个CPU的内存地址空间;数据总线有$N$根,那么一次性可传输的数据位数为$N$;控制总线的宽度则决定了CPU对外部器件的控制能力。
CPU类型 | 地址总线宽度 | 数据总线宽度 | 寻址能力 | 一次可传送的数据量 |
---|---|---|---|---|
8080 | 16 | 8 | 64KB | 1B |
8088 | 20 | 8 | 1MB | 1B |
8086 | 20 | 16 | 1MB | 2B |
80286 | 24 | 16 | 16MB | 2B |
80386 | 32 | 32 | 4GB | 4B |
在一台PC中,装有多个存储器芯片,这些存储器芯片从读写属性上分为RAM和ROM两类。RAM可读可写,但必须带点存储,掉电则内容丢失;ROM只可读不可写,掉电后数据不丢失。从功能上还可以分为三类:
- RAM:这就是我们平常所说的内存,用于存放CPU使用的绝大部分程序和数据
- 装有BIOS的ROM:主板和各接口卡均可以有,通过各自的BIOS可以实现基本的输入和输出
- 各接口卡的RAM:比如显卡上的显存
CPU将上述的所有存储器都当作是内存来看待,所以逻辑上可以将上述的所有存储器映射为一个存储器,也就是CPU的内存地址空间。每个物理存储器在这个逻辑存储器中占有一个地址段,CPU向这个地址段读写数据,就是在向对应的存储器读写数据。所以,我们在基于一个硬件系统编程时,必须要知道这个系统的内存地址空间分配情况。
8086PC的内存地址空间分配情况:
- 00000H~9FFFFH:主存地址空间(RAM)
- A0000H~BFFFFH:显存地址空间
- C0000H~FFFFFH:各类ROM地址空间
第二章 寄存器
计算机组成原理中有提到:一个计算机系统除了I/O设备外,还有三大部件:运算器、控制器、存储器。其实这三大部件在CPU中也是存在的,它们在CPU中通过CPU内部的总线实现连接和信息传递。
- 运算器:进行信息处理和运算
- 控制器:控制各器件进行工作
- 存储器(寄存器):存储信息
而对于汇编语言来说,CPU中主要的部件就是寄存器。因为程序员可以通过汇编指令来读写寄存器,从而实现对CPU的控制。
不同的CPU寄存器的个数和结构也不尽相同。8086CPU中有14个寄存器,分别为:AX
、BX
、CX
、DX
、SI
、DI
、SP
、BP
、IP
、CS
、SS
、DS
、ES
、PSW
。8086CPU中所有的寄存器都是16位(2B)的。
AX
、BX
、CX
、DX
这四个寄存器通常用来存放一般的数据,被称为通用寄存器。由于8086CPU上一代的CPU的寄存器都是8位(1B)的,为了保证兼容性,这四个通用寄存器也都可以分为两个8位的寄存器来使用,高位部分称为xH
,低位部分称为xL
(举例:AX
高位为AH
,低位为AL
)。
8086CPU可以一次性处理一个字节(Byte)或一个字(Word,即两个字节)大小的数据。字型数据存储在寄存器里时,高8位数据存储在寄存器的高位部分,低8位存储在寄存器的低8位部分。
几条汇编指令:
指令 | 语法 | 功能 | 高级语言描述 |
---|---|---|---|
mov | mov a, b | 将b数据放进a中 | a = b |
add | add a, b | 将a和b相加,结果存储在a中 | a = a + b |
sub | sub a, b | 将a和b相减,结果存储在a中 | a = a - b |
注意:上述指令的两个操作对象的数据位数必须一致;当最高位产生进位时,进位值不能存储,将会丢失(但这个进位值并未真正被丢弃)
CPU访问内存单元时要给出内存单元的地址。每个内存单元在CPU的内存地址空间中都有一个唯一的地址,这个地址称为这个单元的物理地址。 由于8086CPU的地址总线有20根,可以传送20位地址,而8086CPU又是16位的结构(运算器一次最多可以处理16位的数据),所以它给出内存单元物理地址的方法是:将两个16位的地址合成为一个20位的物理地址。
相关部件需要提供两个16位的地址:段地址和偏移地址,这两个地址通过地址加法器输出得到一个20位的物理地址,再通过地址总线传送到对应的存储器。
地址加法器的计算方法是:物理地址 = 段地址 × 16 + 偏移地址
(也可以理解为物理地址 = 段地址左移4位 + 偏移地址
)
举例:段地址1230H
,偏移地址00C8H
,合成的物理地址就是1230H × 10H + 00C8H = 123C8H
。(也可表述为1230H:00C8H
)
地址加法器将两个地址合成一个地址的方法,在计算机组成原理中叫做变址寻址,即基准量固定、偏移量可变的寻址方式。段地址相当于在内存地址空间划出了一个内存段,而通过变化偏移地址的大小,可以访问到这个段中的所有内存单元。内存段并不是固定被划分好的,而是根据需要可以进行自由的划分。需要注意,每个段的起始地址必为16的倍数;偏移地址的寻址能力位64KB,所以一个段最大为64KB。
段地址存放在段寄存器里面。8086CPU有4个段寄存器:CS
、SS
、DS
、ES
。
CS
是代码段寄存器,IP
是指令指针寄存器。通过这两个寄存器可以指示当前要读取的指令地址。换句话说,任意时刻,CPU将CS:IP
指向的内容当作指令执行。
CPU读取、执行指令分如下几步:
CS
、IP
中的地址信息送入地址加法器,得到物理地址- 物理地址通过输入输出电路送上地址总线
CS:IP
对应单元开始的指令通过数据总线传送至指令缓冲器- 读取一条指令后,IP中的值根据指令长度自动增加
- 指令被送入执行控制器,指令被执行
- 重复步骤1-5
修改CS
、IP
内容需要使用转移指令jmp
。虽然mov传送指令可以将数据送入寄存器,但是mov指令并不能修改CS
和IP
的值。
jmp 段地址:偏移地址
:将CS
修改为段地址,IP
修改为偏移地址jmp 寄存器/数字
:用寄存器/数字的值修改IP
|
|
实验1 查看CPU和内存,用机器指令和汇编指令编程
Debug是DOS、Windows都提供的实模式(8086模式)程序的调试工具。使用Debug可以查看CPU各种寄存器中的内容、内存的情况以及在机器码级跟踪程序的运行。
在Windows 2000下,Debug程序位于C:/WINNT/system32
下,由于系统已经添加好了环境变量,所以可以直接在任意位置运行:
Debug常见功能:
- **
r
命令:查看、改变CPU寄存器的内容。**直接输入r
可查看寄存器内容并列出CS:IP对应的指令,r 寄存器
可修改对应寄存器的内容,回车后输入要修改的值即可修改。 d
命令:查看内存中的内容,包括单元的数据具体值和对应的字符。d 段地址:偏移地址
可查看以该地址开始的128个单元的内容,d 段地址:偏移地址1 偏移地址2
可查看偏移地址1到偏移地址2的内存内容,d 偏移地址
可查看DS:偏移地址
开始的内存内容,单独输入d
可查看上一次d
命令后续的内存内容。e
命令:以内存单元为单位改写内存中的内容。e 地址 数据...
可以让从该地址开始的后续单元修改为对应的数值/字符,e 地址
可以以提问的方式逐个修改内存单元,回车键结束。u
命令:查看内存中机器码对应的汇编指令。t
命令:单步执行CS:IP指向的指令。a
命令:以汇编指令的形式向内存写入机器指令。g
命令:运行到内存指定位置的代码后暂停。g 地址/断点
运行到地址或断点处暂停。q
命令:退出Debug。
实验任务
- (1) 使用Debug将下面的程序段写入内存,逐条执行,观察每条指令执行后CPU中相关寄存器内容的变化。
汇编指令 | 寄存器内容 |
---|---|
mov ax,4E20H | AX=4E20H,IP=0003H |
add ax,1416H | AX=6236H,IP=0006H |
mov bx,2000H | BX=2000H,IP=0009H |
add ax,bx | AX=8236H,BX=2000H,IP=000BH |
mov bx,ax | AX=8236H,BX=8236H,IP=000DH |
add ax,bx | AX=046CH,BX=8236H,IP=000FH |
mov ax,001AH | AX=001AH,IP=0012H |
mov bx,0026H | BX=0026H,IP=0015H |
add al,bl | AX=0040H,BX=0026H,IP=0017H |
add ah,bl | AX=2640H,BX=0026H,IP=0019H |
add bh,al | AX=2640H,BX=4026H,IP=001BH |
mov ah,0 | AX=0040H,IP=001DH |
add al,bl | AX=0066H,BX=4026H,IP=001FH |
add al,9CH | AX=0002H,IP=0021H |
- (2) 将下面三条指令写入从2000:0开始的内存单元中,利用这三条指令计算$\large{2^8}$。
|
|
执行结果如下:
- (3) 查看内存中的内容:PC机主板上的ROM中写有一个生产日期,在内存
FFF00H~FFFFFH
的某几个单元中,请找到这个生产日期并试图改变它。
查看结果如下:
可以看见主板的生产日期是2019年7月29日,位于FFFF5H~FFFFCH
的位置。尝试去修改的操作是无效的,因为这段内存对应的存储器是ROM,只可读不可写。
- (4) 向内存从
B8100H
开始的单元中填写数据,如:
|
|
观察产生的现象。
可以看见窗口上的显示发生了变化,因为这一段内存对应的是显存。
第三章 寄存器(内存访问)
内存中存储字的方式和在寄存器中存储字的方式类似,也是低位字节放在低地址单元中,高位字节放在高地址单元中。存放字的两个连续单元称为字单元,其低地址单元称为字单元的起始地址。
DS
寄存器用于存储要访问数据的段地址。修改DS
可以使用mov
指令,但是不能直接将数据送入段寄存器或是将段寄存器中的数据直接与其他数据进行处理,而需要通过先将数据送入通用寄存器或内存单元间接实现。
[address]
用于表示ds:address
单元中的数据。汇编程序中会把[address]
单独出现时看作一个常数,所以单独出现的情况仅在Debug程序中才有效。
下面是一段示例代码:
|
|
注:在处理[address]
中的数据时,若要传送的目标或数据的来源为8位寄存器,则传送字节型数据;若为16位寄存器,则传送以[address]
为起始地址的字型数据。
栈是一种具有特殊访问方式的存储空间,其特点是后进先出。CPU中同样拥有栈机制。可以将一段内存空间作为栈来使用,所以字型数据的存储和内存中一致。
汇编语言中提供了push
和pop
两个指令来对栈做最基本的操作:入栈和出栈。push
和pop
操作的对象可以是寄存器、段寄存器和内存单元。
注:8086CPU的入栈和出栈操作都是以字为单位进行的,所以类似push al
这样的命令会报错。
8086CPU使用段寄存器SS
和寄存器SP
来指明栈顶的位置和栈的空间范围。**任意时刻,SS:SP指向栈顶元素。**初始状态下,即栈空时,栈顶指针SP
指向栈底(即栈空间的最高地址单元)的下一个单元。每次入栈时,栈顶指针先上移两位,再将数据送入对应的位置;每次出栈时,先将数据送出,再将指针下移两位。
8086CPU没有额外的寄存器来存储栈的边界单元,所以栈顶超界(栈满时使用push
入栈或栈空时使用pop
出栈)的问题需要我们自己来管理,要根据可能用到的最大栈空间来设计栈的大小。
|
|
段的总结:可以把一段内存定义为一个段,而段的功能则由我们自己决定:一个段可以既是栈段(对应SS
和SP
),又是代码段(对应CS
和IP
),还可以是数据段(对应DS
和IP
),完全取决于我们的安排。
实验2 用机器指令和汇编指令编程
Debug中,d
命令的段地址从DS
寄存器中得到,且e、a、u、d
这些可以带有内存地址的命令中均可以使用段寄存器表示段地址。
在Debug中,当t
命令在执行修改寄存器SS
的指令时,下一条指令也紧接着被执行,原因是触发了中断机制。
|
|
实验任务
- (1) 使用Debug,将下面的程序段写入内存,逐条执行,根据指令执行后的实际运行情况填空。
- (2) 仔细观察图中的实验过程,然后分析:为什么
2000:0~2000:f
中的内容会发生改变?
t
命令是单步执行的,所以在执行的过程中触发了单步中断,而中断需要栈来保护原程序的数据,所以在中断时将数据写入了栈中。
现在还不太懂中断,先贴一张后面的图:
第四章 第一个程序
和其他语言类似,一个汇编源程序从写出到执行要经历编写程序、编译连接得到可执行文件、执行可执行文件三个步骤。
汇编语言编译程序将编写好的汇编源程序进行编译,产生目标文件;随后连接程序将目标文件进行连接,生成可执行文件。可执行文件包含有程序机器码、程序中定义的数据以及一些描述信息,当可执行文件被执行时,系统会将文件中的机器码和数据载入内存,并进行初始化(如将CS:IP
指向程序入口第一条指令),随后让CPU执行。
|
|
上述程序中的三个伪指令:
xxx segment …… xxx ends
:成对使用的伪指令,用于标记一个段。一个源程序中至少要有一个段,通过段的名称来进行标识,段的名称将被编译连接后处理为段地址。end
:汇编程序结束的标记,没有end
,CPU将无法知道程序在何处结束。assume
:意为“假设”,假设程序中的某一个段和某一个段寄存器相关联。
一个程序要想运行,必须要有另一个正在运行的程序P,P将程序载入到内存中,并把CPU的控制权交给要运行的程序,自己暂停运行,于是这个程序就运行起来了;当这个程序运行结束后,需要把CPU的控制权交还给程序P,随后程序P继续运行。这个交还CPU控制权的过程叫做程序返回。上述程序使用mov ax,4c00H
、int 21H
实现程序的返回。
当我们使用命令行来运行程序的时候,这个程序P就是操作系统的Shell(外壳)。每个系统都有自己的Shell,如DOS有command.com
,Windows有cmd.exe
等。
可以使用任意文本编辑器来编写汇编源程序,最终保存为.asm
文件。
可以使用masm5.0汇编编译器(Microsoft Macro Assembler Version 5.00)来对源程序进行编译。在命令行窗口输入masm
后回车,程序会提示输入源程序路径、输出目标文件路径、列表文件路径和交叉引用文件路径(这两个文件是编译的中间结果,不是必须要生成的文件),随后就可以生成目标文件,并提示是否有错误。
接下来可以使用Microsoft Overlay Linker对目标文件进行连接。同样输入输入目标文件路径、输出可执行文件路径、映像文件路径(中间结果,可以不用生成)和库文件路径(无子程序调用,可以不用输入):
编译和连接也可以简化操作:输入masm 源程序文件路径;
则直接在当前目录生成目标文件;输入link 目标文件路径;
则直接在当前目录生成可执行文件。
连接的作用:
- 源程序过大时,可以分为多个源程序编译,编译后连接到一起成为一个可执行文件;
- 如果程序调用了库文件中的子程序,那么需要将库文件和目标文件连接到一起;
- 目标文件的某些内容还不能直接用来生成可执行文件,需要连接程序进一步的处理。
至此,一个汇编源程序从编写到执行的完整过程可以进行如下表示:
同样可以使用Debug程序来对一个程序进行逐步的追踪。使用debug 程序路径
即可通过Debug将程序加载入内存并进行初始化,同时Debug仍然可以对CPU进行控制。
Debug加载程序后,会将程序的长度送入CX
寄存器中。
DOS加载一个可执行程序时,会先找到一段空闲、容量足够的内存区域,并在这个区域的前256个字节中创建一个程序段前缀(PSP)。DOS利用PSP来和被加载的程序进行通信。
于是,假设这段内存区域的起始地址为SA:0
,那么程序区的起始地址为SA+10H:0
。程序初始化时,CS:IP
也会指向SA+10H:0
。
这种加载方式可以直观的体现在Debug中,表现为DS
和CS
寄存器的数值差异。当一个程序被Debug加载时,可以看见初始状态CS
的值比DS
的值要大10H
:
实验3 编程、编译、连接、跟踪
- (1) 将下面的程序保存为
t1.asm
文件,将其生成可执行文件t1.exe
。
|
|
命令:masm t1.asm;
、link t1.obj;
- (2) 用Debug跟踪t1.exe的执行过程,写出每一步执行后,相关寄存器中的内容和栈顶的内容。
汇编指令 | 寄存器内容 | 栈顶内容 |
---|---|---|
mov ax,2000H | AX=2000H | 00B8H |
mov ss,ax | SS=2000H,AX=2000H | 0000H |
mov sp,0 | SP=0000H | 0000H |
add sp,10 | SP=000AH | 0000H |
pop ax | AX=0000H,SP=000CH | 0000H |
pop bx | BX=0000H,SP=000EH | 0000H |
push ax | AX=0000H,SP=000CH | 0000H |
push bx | BX=0000H,SP=000AH | 0000H |
pop ax | AX=0000H,SP=000CH | 0000H |
pop bx | BX=0000H,SP=000EH | 0000H |
mov ax,4c00H | AX=4C00H | 0000H |
- (3) PSP的头两个字节是
CD 20
,用Debug加载t1.exe
,查看PSP的内容。
如下图:
第五章 [BX]
和loop
指令
[BX]
表示使用BX中存放的数据作为一个偏移地址,若单独出现则表示DS:[BX]
中的数据,而也可以加其他的段前缀,表示对应单元的数据,如CS:[BX]
。
举例:mov ax,[bx]
注:[]
中只允许使用通用寄存器中的BX
,是因为[]
中必须是变址(Index,指SI
, DI
)或基址(Base,指BX
, BP
)寄存器,否则编译时会报error A2048:Must be index or base register
错误。
loop
指令的格式是loop 标号
。CPU执行loop
指令时,首先要将寄存器CX
的内容减一,然后判断CX
内容是否为0:若为0则向下执行,反之跳转至标号处执行程序。
也就是说,在loop
指令中CX
寄存器相当于起了一个计数器的作用,代表着这一段程序应该重复执行的次数。
|
|
可以看见,loop
指令中的标号的标识地址要在loop
指令的前面,要循环执行的程序段就放置在标识和loop
指令之间。
使用Debug里逐步跟踪上述程序,可以看见CX
在循环段执行时一直在递减,到了CX
=1时,进入loop
指令CX
减为0,程序继续执行:
若循环次数过多,逐步执行过于麻烦,可以使用g
命令可以直接跳至某个地址开始执行:
在Debug和编译器中,对[常数]
的处理是不同的。Debug中把[常数]
认为是DS:[常数]
对应的单元数据,而编译器中把[常数]
认为是一个常数。
所以,要让编译器认得[常数]
是指一个偏移地址,需要在[常数]
前面显式的给出对应的段前缀,如ds:[0]
。
在8086CPU的模式下,随意向一段内存空间写入数据是非常危险的。向装有重要数据的内存单元写入数据会引发程序错误甚至死机:
为了保证重要数据的安全,我们通常需要寻找空闲的内存空间用于存储数据,或是让操作系统给程序分配空间用于存储数据。在DOS中,0:200~0:2FF
这段内存空间通常是空闲的,直接使用这段内存是安全的。
实验4 [bx]
和loop
的使用
- (1) 编程,向内存
0:200~0:23F
一次传送数据0~63(3FH)
。
|
|
- (2) 编程,向内存
0:200~0:23F
一次传送数据0~63(3FH)
,程序中只能使用9条指令,包括mov ax,4c00h
和int 21h
。
使用同一个寄存器进行自增即可。
|
|
- (3) 下面的程序功能是将mov ax,4c00h之前的指令复制到内存0:200处,补全程序,上机调试,跟踪运行结果。
|
|
第六章 包含多个段的程序
上一章提到了,我们不能够随意的向内存中写入数据,否则很可能会因为覆盖了系统关键数据而导致程序崩溃甚至死机,而应当选择一段安全、空闲的内存空间来存储我们的数据。
在DOS中,0:200~0:2FF
这段内存空间是相对安全的,但是大小只有256个字节,所以当我们需要的空间大于256字节时,就无法使用这段空间,而应当使用汇编指令向操作系统申请空间。在操作系统环境下,合法地通过操作系统取得的空间都是安全的。
在程序中,有两种方式可以向系统取得空间:加载程序时为程序分配和程序执行时向系统申请。(后者此处暂时不讨论)
我们可以通过在程序中定义我们希望处理的数据来获取对应的空间,也就类似于高级语言当中的宏定义。在汇编语言中,使用**dw
、db
和dd
指令来定义一个或一组数据**,其对应的英文全称分别为Define Word、Define Byte、Define DoubleWord,分别用于定义字数据、字节数据和双字数据。
定义了多少个数据,在加载程序时就会为这些数据分配对应的内存空间。数据处于哪段内存空间,取决于数据的定义在程序中的位置。比如下面的代码,我们分别在代码的前面和后面添加数据定义的语句,可以看到数据对应的内存位置发生了变化:
注:使用Debug的u
指令来展示数据时,会把数据认成对应的指令,所以会出现一些与原程序无关的指令,但是查看其对应的字节数据,会发现其实就是被定义的数据。
而上面这种情况会使得程序在运行的时候也把数据当成指令来看待,会发现当数据的定义在代码前面的时候,后面代码的一些指令的意义也发生了改变,所以此时唯有通过改变CS:IP
的位置到正确的指令位置,来使得程序可以正确的运行。但是当程序正常的运行过程中,除去读取指令会改动IP
的值和修改CS
、IP
的指令以外,是不会随意修改CS:IP
的位置的,所以此时必须要给程序指定一个程序入口,让CPU知道,应该从哪里开始执行指令,执行到哪里应该结束。
给程序添加入口的方法是使用一个标识标记程序的入口,并在程序结束的伪指令end
后添加该标识。这里的end
伪指令其实就起到了指明程序入口位置的作用:
|
|
可以给一个汇编源程序添加多个段,用于存放不同的数据或指令。只需要每个段的标识不同即可:
|
|
上述代码中使用了三个段,分别用于存放数据、设置栈和程序代码。由前面的知识,段的标识经过编译之后就是段的起始地址,所以实际上可以直接用段的标识来代表段的起始地址。也就是说,代码中的mov ax,stack
、mov ax,data
实际上就是把两个段的地址传送到AX
寄存器中。
注:把一个段定义为数据段、栈段、代码段完全是我们人为的安排,是为了方便人们阅读而这么定义的,CPU并不知道这些定义的存在。
实验5 编写、调试具有多个段的程序
- (1) 将下面的程序编译、连接,用Debug加载、跟踪,然后回答问题。
|
|
单从代码上来看,这段代码实现的是将数据段的前两个数据入栈再出栈的功能,最终的结果应该是数据段的数据不变。下图是执行结果,可以看见数据也并没有变化:
- CPU执行程序,程序返回前,
data
段的数据为多少?data
段的数据和定义时一样,没有变化:23 01 56 04 89 07 BC 0A EF 0D ED 0F BA 0C 87 09
。 - CPU执行程序,程序返回前,CS=0C3CH,SS=0C3BH,DS=0C3AH。
- 设程序加载后,
code
段的段地址为X,则data
段的段地址为X-2,stack
段的段地址为X-1。
- (2) 将下面的程序编译、连接,用Debug加载、跟踪,然后回答问题。
|
|
这段代码实现的是将数据段的全部数据入栈再出栈的功能,最终的结果同样是数据段的数据不变。
- CPU执行程序,程序返回前,
data
段的数据为多少?data
段的数据和定义时一样,没有变化:23 01 56 04
。 - CPU执行程序,程序返回前,CS=0C3CH,SS=0C3BH,DS=0C3AH。
- 设程序加载后,
code
段的段地址为X,则data
段的段地址为X-2,stack
段的段地址为X-1。 - 对于如下定义的段:
|
|
如果段中的数据占N个字节,则程序加载后,该段实际占有的空间为?
先看看上面程序对两个dw
指令的处理方式,使用Debug加载程序后使用d
指令查看两个段:
可以发现,系统为这两个段中定义的数据各分配了16字节的空间,前四个字节是程序中定义的数据,后面的则用0来填充。
而如果把这两个dw
指令放在一个段里呢?接下来是把两个dw
指令都放在data
段的结果(为了方便展示效果,两条dw
指令均为dw 0123h,0456h
):
可以看见,两条指令的数据直接被连续的存储在一起,而由于仍然没有满16字节,所以剩余的用0来填充。
那么再看看数据大小超过16字节的情况,这次的data
段如下:
|
|
可以看见,由于两组数据没有把第二行填满,所以第二行的空余空间也用0进行了填充。
由此可以发现一个段占有内存空间大小的规律: 当数据大小为16字节的$n$倍时,段实际占有的大小也就是$n\times16$字节;而在数据大小不为16字节的$n$倍时,数据大小除以16字节后得到的整数部分为数据占满的16字节的行数,而剩下的最后一行并没有被占满,剩余的字节被0填充,也就是说,一个段所占空间,即为段数据大小除以16字节所得商向下取整后加1得到的值: $$S=16Bytes\times(\lfloor{\frac{N}{16Bytes}}\rfloor+1),S为段的实际占有空间,N为段中数据所占空间$$
- (3) 将下面的程序编译、连接,用Debug加载、跟踪,然后回答问题。
|
|
这段代码和(2)中代码的区别就在于数据段和栈段的位置放在了代码段的后面,所以DS
,CS
,SS
的寄存器内容会有所改变。
- CPU执行程序,程序返回前,
data
段的数据为多少?data
段的数据和定义时一样,没有变化:23 01 56 04
。 - CPU执行程序,程序返回前,CS=0C3BH,SS=0C3FH,DS=0C3EH。
- 设程序加载后,
code
段的段地址为X,则data
段的段地址为X+3,stack
段的段地址为X+4。
- (4) 如果将(1)、(2)、(3)题中的最后一条伪指令
end start
改为end
(也就是说不指明程序的入口),则那个程序仍然可以正确执行?请说明原因。
之前已经提到,如果不指定程序的入口,那么位于真正代码段之前的数据也会被当作指令看待,因为CS:IP
一开始只会指向整个程序的最开始位置。三段代码,只有(3)的数据段和栈段位于代码段的后面,所以三段代码都可以执行,但是真正正确执行的只有(3),因为前两个由于没有指定程序入口,定义的数据相当于在程序中添加了额外的代码,逻辑上就不一定正确了。
- (5) 编写
code
段中的代码,将a
段和b
段数据依次相加,结果存入c
段中。
|
|
这里有两种解决方案:设置三个段寄存器分别指向a
,b
,c
段,然后设置一个寄存器用于存放偏移地址;设置两个段寄存器,一个固定指向c
,另一个指向可变,而且使用一个临时的寄存器用于存放a+b
的结果。
主要要注意段寄存器的内容不能被直接改变,且mov、add
等指令的两个对象的位数必须一致。
这里我使用了第二种方案:
|
|
- (6) 编写
code
段中代码,用push指令将a
段中前8个字型数据逆序存储到b
段中。
|
|
这个挺简单,把b
段当成栈,直接把a
段数据压入即可。
|
|
第七章 更灵活的定位内存地址的方法
汇编语言中也可以像高级语言中一样,直接使用and
和or
指令来进行逐位的与运算和或运算。用法举例:
|
|
在定义数据的时候,除了给出数据的数值以外,还可以直接通过输入字符串定义数据,其用法是使用''
将字符串包括起来:
|
|
综合上面两个用法,也就对应的可以针对字符串ASCII码的二进制表示,进行与或运算。
书上有个非常好的例子,即不使用条件块,直接通过与或运算进行字母的大小写转换。
可以找到大小写字母分别对应的ASCII码范围:大写41H~5AH(65~90)、小写61H~7AH(97~122)
通过16进制可以清晰的看到:大写字母对应二进制中,高4位的值只有两种情况
0100
和0101
,而小写字母中高4位同样也只有两种情况0110
和0111
,大小写字母的差异在于第6位,所以只需要对第6位进行改变就可以实现改变大小写的操作,而不需要条件块。示例程序:
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
assume cs:code, ds:data data segment db 'Hello' db 'World' data ends code segment start: mov ax, data mov ds, ax mov bx, 0 mov cx, 5 uppercase: mov al, [bx] mov ah, [bx+5] and al, 11011111B and ah, 11011111B mov [bx], al mov [bx+5], ah inc bx loop uppercase mov bx, 0 mov cx, 5 lowercase: mov al, [bx] mov ah, [bx+5] or al, 00100000B or ah, 00100000B mov [bx], al mov [bx+5], ah inc bx loop lowercase mov ax, 4c00h int 21h code ends end start
调试结果如下图:
然后学习了两个新的寄存器:SI
和DI
。查了一下,这两个寄存器属于变址寄存器,它们和BX
的用法差不多,区别在于SI
和DI
只能够当成16位寄存器使用,不能够拆分成两个8位寄存器。
基于这两个寄存器,可以实现更加灵活的地址表示方式:
[bx+常数]
:写法举例[bx+5]
、5[bx]
、[bx].5
,意义相同[bx+si]
或[bx+di]
:还可写成[bx][si]
/[bx][di]
[bx+si/di+常数]
由上可发现其表示方法与高级语言的类似性:a[i]
(高级语言)、5[bx]
(汇编语言),也就是说,这些地址表示方式为高级语言中数组的实现提供了便利。
汇编语言中也可以实现嵌套循环。而循环计数器只有CX
一个,所以进入内存循环时CX
将被修改成内层循环的次数。为了保存外层循环的次数信息,需要在进入内层循环前,提前保存好CX
寄存器的内容,然后再进入内层循环;当程序从内层循环退出后,再将CX
的值还原。
保存CX
的值有三种方法:
- 保存到其他寄存器
- 保存在一段内存空间中
- 保存在栈里
上述三种方法,将CX
保存在栈中是最好的做法。一般来说,在需要暂存数据的时候,都应该使用栈。
实验6 实践课程中的程序
(1) 将课程中所有讲解过的程序上机调试,用Debug跟踪其执行过程,并在过程中进一步理解所讲内容。(略)
(2) 编程,完成问题7.9中的程序。
编程,将
datasg
段中每个单词的前4个字母改为大写字母。1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
assume cs:codesg, ss:stacksg, ds:datasg stacksg segment dw 0,0,0,0,0,0,0,0 stacksg ends datasg segment db '1. display ' db '2. brows ' db '3. replace ' db '4. modify ' datasg ends codesg segment start: ;code codesg ends end start
可以使用更加之前说到的更灵活的寻址方式以及双重循环来实现遍历,然后使用and、or指令来转换大小写,而栈则用来暂存
CX
。下面是codesg
的代码: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
codesg segment start: mov ax, stacksg mov ss, ax mov sp, 16 mov ax, datasg mov ds, ax mov bx, 3 mov cx, 4 s0: push cx mov si, 0 mov cx, 4 s1: mov al, [bx+si] and al, 11011111b mov [bx+si], al inc si loop s1 add bx, 16 pop cx loop s0 mov ax, 4c00h int 21h codesg ends
程序运行截图:
第八章 数据处理的两个基本问题
之前用到了寄存器bx
,si
,di
,其实这三个寄存器外加寄存器bp
都可以进行内存单元的寻址,也只有这四个寄存器可以进行内存单元的寻址,使用其他寄存器进行内存单元寻址都会报错。而且,使用这四个寄存器进行寻址的时候,它们要么单个出现,要么按照一定的组合出现:bx
和si
、bx
和di
、bp
和si
、bp
和di
。使用[bx+bp]
或是[si+di]
也是错误的。
除此之外,bp
还和另外三个寄存器有所不同:若在[]
中使用寄存器bp
,且没有显式的给出段地址,那么段地址默认在**ss
**中。
对之前的知识做一些总结:
在汇编指令中,指令中的数据可以在寄存器、指令缓冲器和内存单元中,如下表:
汇编指令 | 指令执行前数据的位置 | 对应表达 |
---|---|---|
mov bx, [0] | 内存单元ds:[0] | 段地址、偏移地址 |
mov bx, ax | CPU内部的ax 寄存器 | 寄存器 |
mov bx, 1 | CPU内部的指令缓冲器 | 立即数(idata) |
寻址方式总结:
寻址方式 | 名称 | 其他格式 |
---|---|---|
idata | 立即寻址 | N/A |
[idata] | 直接寻址 | N/A |
[bx/bp/si/di] | 寄存器间接寻址 | N/A |
[bx/bp/si/di + idata] | 寄存器相对寻址/基址寻址 | [bx].idata 、idata.[si] 、[bx][idata] |
[bx/bp + si/di] | 基址变址寻址 | [bx][si] |
[bx/bp + si/di + idata] | 相对基址变址寻址 | [bx].idata[si] 、idata[bx][si] |
之前提到可以使用伪指令db
、dw
、dd
来定义数据的长度,其实在某些指令中,也可以显式的指定要处理数据的长度,使用操作符x ptr
,举例如下:
mov word ptr ds:[0], 1
表示指令访问的内存是一个字单元mov byte ptr ds:[0], 1
表示指令访问的内存是一个字节单元
栈操作
push
、pop
指令默认指定了访问字单元,所以再使用x ptr
会报错。
使用div
指令可以进行数据的除法。有以下两种情况:
- 除数为8位,被除数为16位:被除数默认存储在
ax
中,计算得到的商存储在al
,余数存储在ah
; - 除数为16位,被除数为32位:被除数默认存储在
ax
和dx
中,dx
存放高16位,ax
存放低16位。计算得到的商存储在ax
,余数存储在dx
。
对于除数,可以存储在内存单元或寄存器中。要使用div
指令做除法时,只需要在指令中给出除数的位置,然后被除数放在dx
和ax
或ax
中,就可以进行除法。
div
指令必须使用x ptr
运算符。
使用dup
操作符可以进行数据的重复。具体用法:db/dw/dd 重复次数 dup (重复的 字节/字/双字 数据)
举例:定义一个200字节大小的栈段
|
|
实验7 寻址方式在结构化数据访问中的应用
下面的程序中,已经定义好了这些数据:
|
|
编程,将data
段中的数据按如下格式写入到table
段中,并计算21年中的人均收入(取整),结果也按照下面的格式保存在table
段中。
这个题有两个思路:
首先把data
想象成三个数组,table
想象成一个结构体数组,那么第一个思路就是把table
一行一行的填充;第二个思路就是把table
按列来填充。我选择第一个方案,第二个类似,大体上是一样的。
首先观察数据格式,发现年份和收入两个数据的长度是一致的,都是4字节,然后雇员人数的数据长度是2字节。我一开始想着每一种数据给一个寄存器记录位置,但是发现寄存器好像8太够……
于是就用两个循环,先把年份和收入填入,再把雇员人数填入,同时计算平均收入。
三组数据的首地址分别为0000h
、0054h
、00a8h
,所以可以直接用立即数来实现不同数组的读取。
|
|
贴一个截图(后面的数字是以16进制直接存储的,所以不能以ASCII码显示出来):
第九章 转移指令的原理
这一章介绍了几种不同的转移指令及对应的原理。可以修改IP
,或同时修改CS
和IP
的指令统称为转移指令。
操作符offset
用于取得某标号的偏移地址。举例如下:
|
|
下表对这一章中提到的转移指令和对应的功能进行了总结:
指令 | 转移类型 | 修改寄存器 | 转移方式 | 功能 |
---|---|---|---|---|
jmp short 标号 | 段内短转移 | IP | 指令中包含位移量 | (IP)+=8位位移 |
jmp near ptr 标号 | 段内近转移 | IP | 指令中包含位移量 | (IP)+=16位位移 |
jmp far ptr 标号 | 段间转移/远转移 | CS、IP | 指令中包含转移的段地址和偏移地址 | (CS)=标号所在段的段地址(IP)=标号在段中的偏移地址 |
jmp 16位寄存器 | 段内转移 | IP | 指令中包含存有偏移地址的寄存器 | (IP)=寄存器内容 |
jmp word ptr 内存单元地址 | 段内转移 | IP | 指令中包含存有偏移地址的内存单元(字型) | (IP)=内存单元内容 |
jmp dword ptr 内存单元地址 | 段间转移 | CS、IP | 指令中包含存有段地址和偏移地址的内存单元(双字型) | (CS)=内存单元高位内容(IP)=内存单元低位内容 |
jcxz 标号 | 条件转移、短转移 | IP | 指令中包含位移量 | 当CX=0时转移,等价于if (cx == 0) jmp short 标号; |
loop 标号 | 短转移 | IP | 指令中包含位移量 | 自减CX,当CX!=0时转移,等价于cx--; if (cx != 0) jmp short 标号; |
- 短转移和近转移的机器码中仅包含位移量,不包含具体地址;
- 转移指令不得越界,否则将报错;
- 短转移范围:-128 - 127,近转移范围:-32768 - 32767,均用补码表示;
- 所有的循环指令和条件转移指令都是短转移,在机器码中包含转移的位移;
- 短转移/近转移的位移量由编译程序在编译时算出。
实验8 分析一个奇怪的程序
分析下面的程序,在运行前思考:这个程序可以正确返回吗?
|
|
可以正确返回,具体分析如下:
首先将ax
赋值为0,然后进入代码段s
,s
段的作用就是将s2
段中的jmp
指令复制到s
段中的两个nop
的位置上。随后执行s0
段,通过jmp
指令转移到s
段开始执行。
但是jmp short xxx
指令中是没有具体地址的,而是通过位移量来进行转移,那么复制到s
段中的指令其实也不是转移到s1
段,具体转移到哪里要看代码中的位移量,下面是Debug中给出的jmp short s1
对应的机器码:
可以看见对应的机器码为EBF6
,即偏移的位移量为补码F6
,对应数字-10
。
将其放入s
段,从jmp
指令的下一个字节开始向前数10
个字节,刚好位于整个代码段的开始位置,Debug中显示指令为jmp 0000
:
也就是说,执行了s
段的jmp
指令之后,会转移到代码段最开始的mov ax, 4c00h
的位置,刚好是正确返回的标志,所以可以正确返回。
实验9 根据材料编程
编程,在屏幕中间分别显示绿色、绿底红色、白底蓝色的字符串welcome to masm!
,编程所需知识从下面的材料获得:
这个挺简单的,直接上代码:
|
|
程序运行结果如下: