汇编语言程序设计学习笔记
本篇文章已整理至我的 VuePress 文档站,后续更新将于文档进行。
课堂速记
本笔记使用教材《汇编语言(第三版)》,王爽,清华大学出版社
引入 进制转换
十六转十进制
就用乘法,每一位乘以 16^0, 16^1, 16^2 … 然后加在一起。
举个例子,ff bf 是几?答:65471
十六转二进制
更简单了,只需把每一位,变成二进制的四位数,然后拼在一起。
看个例子就懂了,ff bf 是二进制的几?答:1111 1111 1011 1111
十进制转十六
稍微复杂些,用短除法。每次除以 16,把余数从下到上拼起来,就得到了 16 进制的数。
来一起试试,65471 是十六进制的几?答:ff bf
二进制转十六
又容易了,只需切成 4 个 4 个的小段,把每段对应的字母 / 数字拼在一起,就可以了。
比如这个例子,11 1111 1011 1111 是几?答:3f bf
十进制转二进制
除二取余,倒序排列,高位补零。
二进制转十进制
同十六转十进制,但 16 改成 2
巧算法
如何快速把 2^n 的十进制数,转换为二进制?
只需把 n 除以 4,得到 j 余 i。把 i 变成 2^i 做为第一位,其余的就是,j 是几就跟几个零。
第二章 寄存器
8086 访问地址
一个段的最大大小为 2^16=65536=64K,此为偏移地址的最大表示大小 FFFFH。
CS:IP
代码段的段地址存放在 CS 中,指令指针寄存器 IP 指示代码段中指令的偏移地址,处理器利用 CS:IP 取得下一条要执行的指令。
第三章 寄存器(内存访问)
内存中字的存储
(1) 20H (2) 4E20H
字节型数据<字型数据。一个字型数据 (如 1234H) 存放在内存中,由 2 个连续的地址的内存单元组成。高地址内存单元存放字型数据的高位字节,低地址内存单元存放字型数据的低位字节。
mov 的大小就是 al 的大小
此处 “一般的寄存器” 就是上例中的 bx
例 1
例 2
1200+7C0A+4532+A963(舍弃溢出)
堆栈
8086CPU 入栈出栈都以字为单位,不能 push/pop 一个 al
先进后出
SS:SP
入栈出栈时 SP 会先进行 - 2/+2 的操作,push 时 SP 向上(低位)移动 - 2。然后将数据送入 SS:SP 指向的内存单元处。
第四章
源程序中的 “程序”
汇编源程序:
- 伪指令(编译器处理)
- 汇编指令(编译为机器码)
程序:源程序中最终由计算机执行、处理的指令或数据。
汇编程序 & 伪指令
codesg:标号,放在 segment 的前面,作为一个段的名称,这个段的名称最终将被编译、连接程序处理为一个段的段地址。
编译 & 连接
当源程序很大时,可以将它分为多个源程序文件来编译,每个源程序编译成为目标文件后,再用连接程序将它们连接到一起,生成一个可执行文件;
程序中调用了某个库文件中的子程序,需要将这个库文件和该程序生成的目标文件连接到一起,生成一个可执行文件;
一个源程序编译后,得到了存有机器码的目标文件,目标文件中的有些内容还不能直接用来生成可执行文件,连接程序将这些内容处理为最终的可执行信息。
所以,在只有一个源程序文件,而又不需要调用某个库中的子程序的情况下,也必须用连接程序对目标文件进行处理,生成可执行文件。
谁将可执行文件中的程序装载进入内存并使它运行?
在 DOS 中,可执行文件中的程序 P1 若要运行,必须有一个正在运行的程序 P2 将 P1 从可执行文件中加载入内存,将 CPU 的控制权交给它,P1 才能得以运行;当 P1 运行完毕后,应该将 CPU 的控制权交还给使它得以运行的程序 P2。
(1)我们在 DOS 中直接执行 1.exe 时,是正在运行的 command 将 1.exe 中的程序加载入内存。
(2)command 设置 CPU 的 CS:IP 指向程序的第一条指令(即程序的入口),从而使程序得以运行。
(3)程序运行结束后,返回到 command 中,CPU 继续运行 command
EXE 文件中的程序的加载过程 DS
程序加载后,ds 中存放着程序所在内存区的段地址,这个内存区的偏移地址为 0,则程序所在的内存区的地址为:ds:0;
这个内存区的前 256 个字节中存放的是 PSP,dos 用来和程序进行通信。
所以,我们从 ds 中可以得到 PSP 的段地址 SA,PSP 的偏移地址为 0,则物理地址为 SAX16+0。
因为 PSP 占 256(100H)字节,所以程序的物理地址是:
SA×16+0+256= SA×16+16×16= (SA+16)×16+0
可用段地址和偏移地址表示为:SA+10:0
程序执行过程的跟踪
用 R 命令查看各个寄存器的设置情况
用 U 命令查看其他指令
使用 P 命令执行 int 21
使用 Q 命令退出 Debug
第六章 包含多个段的程序
在代码段中使用数据
程序解读见书 P124。mov ax,4c00h
代表终止。
程序 6.2
end start 除了通知编译器程序结束外,还可以通知编译器程序的入口在什么地方。
在代码段中使用栈
程序 6.3
30h 是 48 字节,正好对应 dw 分配的 16 个字型数据,用于栈的空间。
将数据、代码、栈放入不同的段
程序 6.4
cs 是自动装载的,不用在代码段中指定 cs 的指向。
为什么
mov bx,0
可以将 ds:bx 指向 data 段中的第一个单元?因为 0 被认为是 ds,[0]
为什么
mov cx,5
表示循环 5 次?cs 用来控制循环次数,每次执行 loop 指令时,都会检查 cs 的值是否为 0
第七章 更灵活的定位内存地址的方法
ASCII 码
程序 7.1
inc 加 1,而不是加 2,因为一个 ASCII 码占一个字节
[bx+idata] 寄存器相对寻址
[bx+idata] 表示一个内存单元,它的偏移地址为 (bx)+idata(bx 中的数值加上 idata)
mov ax,[bx+200]
数学化描述:(ax)=((ds)*16+(bx)+200)
SI 和 DI 基址变址寻址
类似于 bx,但是不能分成两个 8 位寄存器
段寄存器、两个内存单元之间、两个段之间都不能直接 mov,需要用寄存器中转
SI 元变址寄存器
DI 目的变址寄存器
相对基址变址寻址([bx+si+idata] 和 [bx+di+idata])
问题 7.1、7.3、7.4、7.5 的分析很实用
第八章 数据处理的两个基本问题
用 reg 表示一个寄存器,sreg 表示段寄存器
bx,si,di,bp
bp 用于在堆栈段上寻址,bs 默认用于数据段寻址。都是基址 (base)
“两个 i 不相见,两个 b 不相见”
错误指令:
mov ax,[bx+bp]
mov ax,[si+di]
只要在 […] 中使用寄存器 bp,且指令中没有显性地给出段地址,段地址就默认在 ss 中。
寻址方式
P164 表 8.2
指令要处理的数据有多长?
word ptr
和 byte ptr
来显式的指定内存单元的长度
P166: mov word ptr [1000H],1
-> 0100FF
div 指令 书 P169
div byte ptr ds:[0]
(al) = (ax) / ((ds) * 16 + 0) 的商
(ah) = (ax) / ((ds) * 16 + 0) 的余数
div word ptr es:[0]
(ax) = [(dx) * 10000H + (ax)] / ((es) * 16 + 0) 的商
(dx) = [(dx) * 10000H + (ax)] / ((es) * 16 + 0) 的余数
低商高余
例题
mov ax,data
mov ds,ax
mov ax,ds[0]
mov dx,ds[2]
div word ptr ds:[4]
第九章 转移指令的原理
offset
P175、176
问题 9.1 为什么要加
cs:
?
不加冒号复制的是默认 ds 段
jmp
讲得太快
第十一章
PF 标志(Parity)
表示奇偶性,1 的个数为奇数时 PF=0,为偶数个时 PF 为 1
SF 标志(Sign)
结果为负那么 SF=1,结果非负数则 SF=0;
ZF 标志(Zero)
结果为 0 那么 ZF=1, 结果不为 0 则 ZF=0;
CF 标志(Carry)
mov al,97H
sub al,98H
执行后:(al)=FFH, CF=1, CF 记录了向更高位的借位值
10010111
10011000
(-1)11111111
FFH
OF 标志(Overflow)
1 | mov al,98 |
执行后 CF=0, OF=1
对于无符号数运算,没有进位,CF=0;对于有符号数运算,发生了溢出(数值位向符号位进了一位,虽然污染了符号位,但此时数还是八位。如果进到第九位,则产生了进位),OF=1
adc 指令
1 | mov ax,1 |
adc 利用了 CF,执行时相当于计算 (ax)+3+CF=2+3+0=5
计算 1EF000H+201000H,结果放在 ax(高 16 位)和 bx(低 16 位)中
1 | mov ax,001EH |
sbb 指令
adc 的减法版
cmp 指令
不保存结果的减法比较,仅仅根据结果设置标志位
cmp 比较大小不能仅靠 SF,因为可能溢出,还需要借助 OF
SF=1, OF=0,说明没有溢出,逻辑上结果正负 = 实际上结果正负,即 ah<bh
SF=1, OF=1,溢出会污染符号位,正负性颠倒,即 ah>bh
SF=0, OF=1,跟刚才逻辑一样,ah<bh
SF=0, OF=0,ah>bh;若 ZF=0,则 ah=bh
任意一个为 1,另一个为 0 时,前者<后者
je 指令等
有符号位的是 jl(小于)、jg(大于)
DF 标志和串传送指令
df=0 每次操作后 si、di 递增
df=1 每次操作后 si、di 递减
movsb 将 ds:si 指向的内存单元的一个字节送入 es:di 中,然后根据 df 位的值,将 si 和 di 递增或递减
movsw
rep
第十二章 内中断
中断向量表 四个单元
do0
第十三章 int 指令
int n,n 是中断类型码,功能是引发中断,相当于引发一个 n 号中断的中断过程,执行过程:
标志寄存器入栈,IF=0,TF=0;CS、IP 入栈;(IP)=(n*4), (CS)=(n*4+2)
13.5 BIOS 和 DOS 中断例程的安装过程
编程时可以用 int 指令调用 BIOS 和 DOS 提供的中断例程
1 | //不重要 |
第十四章
shl 和 shr 指令
逻辑移位指令。将一个寄存器或内存单元中的数据向左移位,最后移出的一位写入 CF 中,最低为用 0 来补充。移动位数大于 1 时,必须把移动位数放在 cl 中。
shl 左移,shr 右移。会影响到符号位。(算术右移不会影响符号位)
期末考试复习
选择,可能有判断题
写一些指令,进行纠正
2 道编程题
宏不作要求
一直到系统调用,比较重要
《汇编语言》第三版阅读笔记
上课没有时间详细记笔记,复习阶段参考了 sanmianti/AssemblyLanguageTest,感谢。
第一章 基础知识
汇编课程研究重点放在如何利用硬件系统的编程结构和指令集有效灵活的控制系统进行工作。
1.1 机器语言
机器语言是机器指令的集合。电子计算机的机器指令是一列二进制数字。计算机将之转换为一列高低电平,以使计算机的电子器件受到驱动,进行运算。
每一种微处理器都有自己的机器指令集,也就是机器语言。
1.2 汇编语言的产生
机器语言难以辩别和记忆,基于此人们发明了汇编语言。
寄存器 简单的讲是 CPU 中(内部)可以存储数据的器件。
编译器 能够将汇编指令转换为机器指令的翻译程序。
1.3 汇编语言的组成
汇编语言主要由以下 3 类指令组成:
(1) 汇编指令:机器码的助记符,有对应的机器码。
(2) 伪指令:没有对应的机器码,由编译器执行,计算机并不执行。
(3) 其他符号:如 +、-、*、/ 等,由编译器识别,没有对应的机器码。
1.4 存储器
1.5 指令和数据
指令和数据是应用上的概念。在内存或磁盘上,指令和数据没有任何区别,都是二进制信息。
1.6 存储单元
计算机内的最小信息单位是 bit,即一个二进制位。
计算机内的基本存储单元是 Byte,即一个字节。一个字节等于 8 个二进制位。
1KB = 1024B, 1MB = 1024KB, 1GB = 1024MB, 1TB = 1024GB
1.7 CPU 对存储器的读写
CPU 通过地址总线给出数据存储位置。
CPU 通过控制总线给出数据存储方向。
CUP 通过数据总线进行数据传输。
1.8 地址总线
一个 CPU 有 N 根地址线,则可以说这个 CPU 的地址总线宽度为 N。这样的 CPU 最多可以寻找 2 的 N 次方个内存单元。
1.9 数据总线
数据总线的宽度决定了 CPU 和外界的数据传送速度。
1.10 控制总线
CPU 对外部器件的控制是通过控制总线来进行的。控制总线是一些不同控制线的集合。
1.1~1.10 小结
(1) 汇编指令是机器指令的助记符,同机器指令一一对应。
(2) 每一种 CPU 都有自己的汇编指令集。
(3) CPU 可以直接使用的信息在存储器中存放。
(4) 在存储器中指令和数据没有任何区别,都是二进制信息。
(5) 存储单元从零开始顺序编号。
(6) 一个存储单元可以存储 8 个 bit,即 8 位二进制数。
(7) 1Byte = 8bit, 1KB = 1024B = 2^10B, 1MB = 1024KB = 2^20B, 1GB = 1024MB = 2^30B。2^10 = 1024, 2^16 = 65536。
(8) 每一个 CPU 芯片都有许多管脚,这些管脚和总线相连。也可以说,这些管脚引出总线。一个 CPU 可以引出 3 中总线的宽度标志了这个 CPU 的不同方面的性能:
地址总线的宽度决定了 CPU 的寻址能力;
数据总线的宽度决定了 CPU 与其他器件进行数据传送时一次数据传送量;
控制总线的宽度决定了 CPU 对系统中其他器件的控制能力。
1.11 内存地址空间(概述)
内存地址空间就是 CPU 可以通过地址总线寻址到的内存单元集合。
1.12 主板
1.13 接口卡
CPU 通过总线向接口卡发送命令,接口卡根据 CPU 的命令控制外设工作。如:网卡、显卡、声卡等。
1.14 各类存储器芯片
RAM: 随机存储器,可读可写,但必须带电存储,断电后存储内容消失。
ROM: 只读存储器,只能读出,不能写入。断电后存储内容不消失。
BIOS: Basic Input/Output System,基本输入输出系统。BIOS 是由主板和各类接口卡(如显卡、网卡等)厂商提供的软件系统。 可以通过它利用该硬件设备进行最基本的输入输出。在主板和某些接口卡上茶油存储相应 BIOS 的 ROM。例如,主板上的 ROM 中存储着主板的 BIOS (通常称为系统 BIOS);显卡上的 ROM 存储着显卡的 BIOS;如果网卡上装有 ROM,那其中就可以存储网卡的 BIOS。
1.15 内存地址空间
最终运行程序的是 CPU,我们用汇编语言编程的时候,必须要从 CPU 的角度考虑问题。对 CPU 来讲,系统中所有存储器中的存储单元都处于一个统一的逻辑存储器中,它的容量受 CPU 寻址能力的限制。这个逻辑存储器即是我们所说的内存地址空间。
第二章 寄存器
一个典型的 CPU 由运算器、控制器、寄存器等器件构成:
- 运算器进行信息处理;
- 寄存器进行信息存储;
- 控制器控制各种器件进行工作;
- 内部总线连接各种器件,在它们之间进行数据的传送。
寄存器是 CPU 内部的存储器件。
8086CPU 内部有 14 个寄存器,分别是:AX、BX、CX、DX、SI、DI、SP、BP、IP、CS、SS、DS、ES、PSW。这 14 个寄存器都是 16 位的。
2.1 通用寄存器
AX、BX、CX、DX 这这四个寄存器通常用来存放一般性数据,被称为通用寄存器。
为了保证向前兼容,8086CPU 的 AX、BX、CX、DX 这 4 个寄存器可以分为两个独立使用的 8 位寄存器来用:
- AX 可以分为 AH 和 AL;
- BX 可以分为 BH 和 BL;
- CX 可以分为 CH 和 CL;
- DX 可以分为 DH 和 DL。
2.2 字在寄存器中的存储
对于 8086CPU 来说,一个字由两个字节组成,这两个字节分别称之为高位字节和低位字节,并且存储于寄存器中的高 8 位和低 8 位。
2.3 几条汇编指令
mov ax, 001AH—— 转移指令
add ax, bx—— 求和指令
2.4 物理地址
所有内存单元构成的存储空间是一个一维线性空间,每一个内存单元在这个空间都有唯一的地址,我们将这个唯一的地址称为物理地址。
2.5 16 位结构的 CPU
8086CPU 是 16 位结构的 CPU,这也就是说,在 8086 内部,能够一次性处理、传输、暂时存储的信息的最大长度是 16 位的。内存单元的地址在送上地址总线之前,必须在 CPU 中处理、传输、暂时存放,对于 16 位 CPU,能一次性处理、传输、暂时存储 16 位的地址。
但 8086CPU 有 20 根地址总线,那么 16 位的 8086CPU 是如何给出 20 位的地址总线的呢?
2.6 8086CPU 给出物理地址的方法
8086CPU 地址总线长度大于字长,导致程序物理地址无法一次性传递给 CPU。为此,8086CPU 采用一种在内部用两个 16 位地址合成的方法来形成一个 20 位的物理地址。
当 8086CPU 要读写内存时:
(1)CPU 中的相关部件提供两个 16 位的地址,一个称为段地址,另一个称为偏移地址;
(2)段地址和偏移地址通过内部总线送入一个称为地址加法器的部件;
(3)地址加法器将两个 16 位地址合成为一个 20 位的物理地址;
(4)地址加法器通过内部总线将 20 位物理地址送入输入输出控制电路;
(5)输入输出控制电路将 20 位物理地址送上地址总线;
(6)20 位物理地址被地址总线送到存储器。
地址加法器采用物理地址 = 段地址 X16 + 偏移地址的方法用段地址和偏移地址合成物理地址。
2.7 “段地址 X16 + 偏移地址 = 物理地址” 的本质含义
“段地址 X16 + 偏移地址 = 物理地址” 的本质含义是:CPU 在访问内存时,用一个基础地址(段地址 X16)和一个相对基础地址的偏移地址相加,给出内存单元的物理地址。
举个例子:
假如说隔壁部门的同事张三来找你询问李四的工位?你发现李四的工位不好直接描述,既不是在角落也不是在中间。这时你发现李四旁边坐着的是经理,所以你告诉张三说李四就是经理左边第二位的那个人。这就是生活中使用 “基础地址 + 偏移地址 = 物理地址” 的例子。
还有比方说,大家描述学校水房的位置,一般会说在几号餐厅后面,或某宿舍楼旁边。
2.8 段的概念
CPU 访问内存单元时,必须向内存提供内存单元的物理地址。8086CPU 在内部用段地址和偏移地址移位相加的方法形成最终的物理地址。
CPU 可以用不同的段地址和偏移地址形成同一个物理地址。
可以根据需要,将地址连续、起始地址为 16 的倍数的一组内存单元定义为一个段。
2.9 段寄存器 *
8086CPU 内部有四个段寄存器:CS、DS、SS、ES。用于存储指定内存单元的段地址。
2.10 CS 和 IP
8086PC 机中,任意时刻,CPU 将 CS:IP 指向的内容当作指令执行。其中 CS 为代码段寄存器,IP 为指令指针寄存器。
8086CPU 执行指令过程如下:
(1) 从 CS:IP 指向的内存单元读取指令,读取的指令进入指令缓冲器;
(2) IP = IP + 所读指令的长度,从而指向下一条指令;
(3) 执行指令。转到步骤(1),重复这个过程。
2.11 修改 CS、IP 的指令
能够改变 CS、IP 内容的指令被统称为转移指令。如 jump 指令。
2.12 代码段
我们可以将长度为 N (N <= 64KB) 的一组代码,存在一组地址连续、起始地址为 16 的倍数的内存单元中。这段地质连续的内存空间就称之为代码段。简单来说也就是存放代码的段。
2.9~2.12 小结
(1) 段地址在 8086CPU 的段寄存器中存放。当 8086CPU 要访问内存时,由段寄存器提供内存单元的段地址。8086CPU 有 4 个段寄存器,其中 CS 用来存放指令的段地址。
(2) CS 存放指令的段地址,IP 存放指令的偏移地址。8086 机中,任意时刻,CPU 将 CS:IP 指向的内容当做指令指向。
(3) 8086CPU 工作过程:略
(4) 8086CPU 提供转移指令修改 CS、IP 的内容。
第三章 寄存器(内存访问)
本章从内存访问的角度学习相关寄存器。
3.1 内存中字的存储
字单元:存放一个字型数据(16 位)的内存单元,由两个地址连续的内存单元组成。高地址内存单元存放字型数据的高位字节,低地址单元存放字型数据的低位字节。
这种存储方式也被称为小端存储,Intel 系列的处理器一般都是小端存储。
3.2 DS 和 [Address]
上一章我们学习了 CS 段寄存器,用于存放代码段段地址。这里我们再引入另外一个段寄存器 DS,用于存放数据段段地址。
需要特别注意的是,8086CPU 不支持将数据直接送入段寄存器。 包括所有的段寄存器 CS、DS、SS、ES 都不支持将数据从内存直接送入。内存中的数据必须先送入其他中间寄存器,然后在从中间寄存器送入段寄存器。(此处描述有误:栈操作”pop 段寄存器” 实际上就是将数据从内存中直接送入段寄存器,此处应该更正为无法通过 move 指令将数据从内存中直接送入段寄存器)
“[address]” 表示一个内存单元,中括号中的 address 表示内存单元的偏移地址。默认情况下,8686CPU 取 DS 中的数据作为该内存单元的段地址。
3.3 字的传送
使用 move 指令一次可以传送一个字。move 指令可以将数据从内存送入寄存器,也可以将数据从寄存器送入内存,也可以将数据从寄存器送入寄存器。但 move 指令不支持内存到内存的传送。
3.4 mov、add、sub 指令
add 指令和 sub 指令与 mov 指令用法类似,他们都有两个操作对象。这两个操作对象可以是如下格式:
寄存器, 数据
寄存器, 寄存器
寄存器, 内存单元
内存单元, 寄存器
有两点需要注意:
(1) mov、add、sub 指令的两个操作对象不能同时为内存单元。
(2) 段寄存器只能接收 mov 指令传送数据,不可以进行算术运算。如 add ds, ax 指令是违法的。(此处描述不够严谨,实际上段寄存器也可以接收来自操作栈的 pop 指令传递的数据)
3.5 数据段
数据段是一段长度为 N (N <= 64KB)、地址连续、其实地址为 16 的倍数的内存单元。我们用段寄存器 DS 存放数据段的段地址。
3.1~3.5 小结
(1) 字在内存中存储时,要用两个地址连续的内存单元来存放,字的低位字节存放在低地址单元中,高位字节存放在高地址单元中。
(2) 用 mov 指令访问内存单元,可以在 mov 指令中只给出单元的偏移地址,此时,段地址默认在 DS 寄存器中。
(3)[address] 表示一个偏移地址为 address 的内存单元。
(4) 在内存和寄存器之间传送数据时,高地址单元和高 8 位寄存器、低地址单元和低 8 位寄存器相对应。
(5) mov、add、sub 是具有两个操作对象的指令。jmp 是具有一个操作对象的指令。
(6) 可以根据自己的推测,在 debug 中实验指令的新格式。
3.6 栈
栈就是一种先进后出的数据结构。LIFO (Last In First Out)。
3.7 CPU 提供的栈机制
8086CPU 对栈提供两个基本操作指令:PUSH(入栈)和 POP(出栈)。 PUSH 是将数据送入栈中,POP 是将数据移出栈中。
前面我们已经学习了 CS 和 DS 两个段寄存器。并且知道 CS:IP 指向的内存单元被当做指令,DS:[address] 指向的内存单元被当做数据。这里我们引入另外一个段寄存器 SS,SS 中保存的是栈顶元素的段地址,此外使用 SP 保存栈顶元素的偏移地址。故在任意时刻 SS:SP 都指向栈顶元素。
PUSH AX 的操作详情:
(1)SP=SP-2,SS:SP 指向当前栈顶前面的单元,以当前栈顶前面的单元为新的栈顶;
(2)将 ax 中的内容送入 SS:SP 指向的内存单元,SS:SP 此时指向新的栈顶。
POP AX 的操作详情:
(1)将 SS:SP 指向的内存单元处的数据送入 ax 中;
(2)SP=SP+2,SS:SP 指向当前栈顶下面的单元,以当前栈顶下面的单元为新的栈顶。
3.8 栈顶超界问题
当栈满的时候进行 PUSH 操作或者栈空的时候使用 POP 操作,都将引发栈顶超界问题。
8086CPU 并未对栈顶超界做任何处理,程序员在编程的时候应当避免使得栈顶超界的情况发生。
3.9 push、pop 指令
push 指令和 pop 指令支持如下形式:
1 | push 寄存器 |
push、pop 实际上就是一种内存传送指令,可以在寄存器和内存之间传送数据,与 mov 指令不同的是,push 和 pop 指令访问的内存单元的地址不是在指令中给出的,而是由 SS:SP 指出的。同时,push 和 pop 还要改变 sp 中的值。CPU 执行 mov 指令仅需一步,CPU 执行 push 和 pop 指令需要两步:传送数据和修改 sp 的值。
需要注意的是,push、pop 等栈操作指令,修改的只是 SP,也就是说,栈顶的变化范围最大为:0~FFFFH。
栈的综述
(1)8086CPU 提供了栈操作机制,方案如下。
在 SS、SP 中存放栈顶的段地址和偏移地址;
提供入栈和出栈指令,它们根据 SS:SP 指示的地址,按照栈的方式访问内存单元。
(2)push 指令的执行步骤:1、 SP=SP-2; 2、向 SS:SP 指向的字单元中送入数据。
(3)pop 指令的执行步骤:1、从 SS:SP 指向的字单元中读取数据;2、SP=SP+2。
(4)任意时刻,SS:SP 指向栈顶元素。
(5)8086CPU 只记录栈顶,栈空间的大小我们要自己管理。
(6)用栈来暂存以后要恢复的寄存器的内容时,寄存器出栈的顺序要和入栈的顺序相反。
(7)push、pop 实际上是一种内存传送指令,注意它们的灵活应用。
栈是一种非常重要的机制,一定要深入理解,灵活掌握。 (P67)
3.10 栈段
与代码段、数据段类似,我们在编程时,可以根据需要,将一组内存单元定义为一个段。我们可以将长度为 N (N<=64KB) 的一组地址连续、起始地址为 16 的倍数的内存单元,当做栈空间来用。只需要使用 SS:SP 指向它们。
一个栈最大为 64KB,即偏移地址所能指向的最大范围。当一个大小为 64KB 的栈,其 SP=0 时则表示该栈为空或者栈满。
段的综述
我们可以将一段内存定义为一个段,用一个段地址指示段,用偏移地址访问段内的单元(通过偏移地址的移动来访问段内的单元)。这完全是我们自己的安排。我们可以用一个段存放数据,将它定义为 “数据段”;
我们可以用一个段存放代码,将它定义为 “代码段”;
我们可以用一个段当做栈,将它定义为 “栈段”;我们可以这样安排,但若要让 CPU 按照我们的安排来访问这些段,就要:
对于数据段,将它的段地址放在 DS 中,用 mov、add、sub 等访问内存单元的指令时,CPU 就将我们定义的数据段中的内容当做数据来访问;
对于代码段,将它的段地址放在 CS 中,将段中第一条指令的偏移地址放在 IP 中,这样 CPU 就将执行我们定义的代码段中的指令; 对于栈段,将它的段地址放在 SS 中,将栈顶单元的偏移地址放在 SP 中,这样 CPU 在需要进行栈操作的时候,比如执行 push、pop 指令等,就将我们定义的栈段当做栈空间来用。
可见,不管我们如何安排,CPU 将内存中的某段内容当做代码,是因为 CS:IP 指向了那里;CPU 将某段内存当做栈,是因为 SS:SP 指向了那里。我们一定要清楚,什么是我们的安排,以及如何让 CPU 按我们的安排行事。要非常清楚 CPU 的工作原理,才能在控制 CPU 按照我们安排运行的时候做到游刃有余。
比如我们将 10000H~1001FH 安排为代码段,并在里面存储如下代码:
1 | mov ax, 1000H |
设置 CS=1000H,IP=0,这段代码将得到执行。可以看到,在这段代码中,我们又将 10000H
1001FH 安排为栈段和数据段。10000H1001FH 这段内存,即是代码段,又是栈段和数据段。一段内存,可以即是代码的存储空间,又是数据的存储空间,还可以是栈空间,也可以什么也不是。关键在于 CPU 中寄存器的设置,即 CS、IP,SS、SP,DS 的指向。
(p69)
第四章 第一个程序
4.1 一个源程序从写出到执行的过程
一个汇编语言程序从写出到最终执行主要经历三步:
第一步:编写汇编语言程序;
第二步:对源程序进行编译连接;
第三步:执行可执行文件中的程序。
对源程序进行编译连接生成可在操作系统中直接运行的可执行文件。可执行文件包含两部分内容。
- 程序(从源程序中的汇编指令翻译过来的机器码)和数据(源程序中定义的数据)
- 相关的描述信息(比如,程序有多大、要占用多少内存空间等)
4.2 源程序
一段简单的汇编语言源程序:
1 | assume cs:codesg |
1. 伪指令
在汇编语言源程序中,包括两种指令,一种是汇编指令,一种是伪指令。汇编指令是有对应的机器码的指令,可以被编译为机器指令,最终为 CPU 所执行。而伪指令没有对应的机器指令,最终不被 CPU 所执行。伪指令是由编译器来执行的指令,编译器根据伪指令来进行相应的编译工作。
下面介绍上面程序中所出现的几个伪指令:
(1) segment 和 ends
segment 和 ends 是一对成对使用的伪指令。功能是定义一个段。使用格式为:
1 | 段名 segment |
一个汇编语言程序是由多个段组成的,这些段被用来存放代码(代码段)、数据(数据段)或当做栈空间(栈段)来使用。
(2)end
end 是一个汇编语言的结束标记,编译器在编译汇编程序的过程中,如果碰到了伪指令 end,就结束对源程序的编译。
(3)assume
这条伪指令的含义为 “假设”。它假设某一段寄存器和程序中的某一个用 segment…ends 定义的段相关联。即将定义的段的段地址存放在段寄存器中。
2. 源程序中的 “程序”
我们将源程序文件中的所有的内容称之为源程序,将源程序中最终由计算机执行、处理的指令或数据,称为程序。
3. 标号
汇编源程序中,除了汇编指令和伪指令外,还有一些标号,比如 “codesg”。一个标号指代了一个地址。比如 codesg 在 segment 的前面,作为一个段的名称,这个段的名称最终将被编译、连接程序处理为一个段的段地址。
4. 程序的结构
源程序就是由一些段组成的。我们可以在这些段中存放代码、数据、或将某个段当做栈空间。
5. 程序返回
一个程序结束后,将 CPU 控制权交还给使它得以运行的程序,我们称这个过程为程序返回。 程序返回指令:
1 | mov ax, 4c00H |
当前我们不必理解这两天语句的含义。只要记住使用这两条指令可以实现程序返回。
段结束、程序结束、程序返回的区别
目的 | 相关指令 | 指令性质 | 指令执行者 |
---|---|---|---|
通知编译器一个段结束 | 段名 ends | 伪指令 | 编译时,由编译器执行 |
通知编译器程序结束 | end | 伪指令 | 编译时,由编译器执行 |
程序返回 | mov ax,4c00H int 21H | 汇编指令 | 执行时,由于 CPU 执行 |
6. 语法错误和逻辑错误
一般来说,程序在编译时被编译器发现的错误是语法错误,如 mov ss, 1234 。
在程序编译后,在运行时发生的错误是逻辑错误,如除零操作。
4.3 编辑源程序
源程序文件以.asm 作为后缀。
4.4 编译
我们使用微软的 masm5.0 汇编编译器进行编译,文件名为 masm.exe。我们以 c:\1.asm 为例说明编译源程序的方法步骤。
(1)进入 DOS 方式,运行 masm.exe。
1 | ... |
(2)输入要编译的源程序文件名,按 enter 键。
1 | Source filename [.ASM]: c:\1.asm |
输入要编译出的目标文件名,如果不输入则默认使用源程序名。
(3)确定了目标文件名称后,继续按 Enter 键(两次),忽略中间文件的生成。
最终完成对源程序的编译,生成目标文件。目标文件以.obj 作为后缀。
4.5 连接
在对源程序进行编译生成目标文件后,我们需要对目标文件进行连接,从而得到可执行文件。
这里我们使用微软的 Overlay Linker3.60 连接器,文件名为 link.exe。我们以 4.4 中生成的目标文件 c:\masm\1.obj 为例说明连接操作步骤。
(1)进入 DOS 方式,运行 link.exe。
1 | ... |
(2)输入目标文件名,按 enter 键。
1 | ... |
键入生成可执行文件的名称。如果不输入则默认使用源程序名。
(3)确定了可执行文件名后,按 Enter 键(两次),忽略镜像文件的生成,忽略库文件的连接。
经过以上三步后,最终会生成以.exe 结尾的可执行文件。
连接的作用
(1)当源程序很大时,可以将它分为多个源程序文件来单独编译,然后将生成的目标文件连接在一起,节约程序编译时间。
(2)程序中调用了某个库文件的子程序,需要将这个库文件和该程序生成的目标文件连接在一起,生成一个可执行文件。
(3)一个源程序编译后,达到了存有机器码的目标文件,目标文件中的有些内容还不能直接用来生成可执行文件,连接程序将这些内容处理为最终的可执行信息。所以,在只有一个源程序文件,而又不需要调用某个库中的子程序的情况下,也必须用连接程序对目标文件进行处理,生成可执行
文件。
4.6 以简化的方式进行编译和连接
一键编译:
1 | masm c:\1.asm; |
一键连接:
1 | link c:\1.obj; |
4.7 1.exe 的执行
进入 dos 环境,直接键入.exe 可执行文件的文件名即可执行。
4.8 谁将可执行文件中的程序装载进入内存并使它运行
(1)在 DOS 中直接执行 1.exe 时,是正在运行的 command,将 1.exe 中的程序加载入内存;
(2)command 设置 CPU 的 CS:IP 指向程序的第一条指令(即程序入口),从而使程序得以运行。
(3)程序运行结束后,返回到 command 中,CPU 继续运行 command。
操作系统的外壳
操作系统是由多个功能模块组成的庞大、复杂的软件系统。任何通用的操作系统,都要提供一个称为 shell (外壳) 的程序,用户(操作人员)使用这个程序来操作计算机系统进行工作。
DOS 中有一个程序 command.exe,这个程序在 DOS 中称为命令解释器,也就是 DOS 系统的 shell。
DOS 启动时,先完成其他重要的初始化工作,然后运行 command.exe,command.exe 运行后,执行完其他的相关任务后,在屏幕上显示出由当前盘符和当前路径组成的提示符,比如:“c:\” 或 “c:\windows” 等,然后等待用户的输入。
用户可以输入要执行的命令,比如,cd、dir、type 等,这些命令由于 command 执行,command 执行完这些命令后,再次显示当前盘符和当前路径组成的提示符,等待用户输入。
如果用户要执行一个程序,则输入该程序可执行文件的名称,command 首先根据文件名找到可执行文件,然后将这个可执行文件中的程序加载入内存,设置 CS:IP 指向程序的入口。此后,command 暂停运行,CPU 运行程序。程序运行结束后,返回到 command 中,command 再次显示由当前盘符和当前路径组成的提示符,等待用户输入。
在 DOS 中,command 处理各种输入:命令或要执行的程序的文件名。我们就是通过 command 来进行工作的。
shell : 操作人员和 OS 之间的 API。
汇编程序从写出到执行的过程
到此,完成了一个汇编程序从写出到执行的全部过程。我们经历了这样一个历程: 编程(Edit) → 1.asm → 编译(masm) → 1.obj → 连接(link) → 1.exe → 加载(command) → 内存中的程序 → 运行(CPU)
4.9 程序执行过程的跟踪
DOS 系统中.exe 文件中程序的加载过程
(1)找到一段起始地址为 SA:0000(即起始地址的偏移地址为 0)的容量足够的空闲内存区;
(2)在这段内存区的前 256 个字节中,创建一个称为程序段前缀(PSP)的数据区,DOS 要利用 PSP 来和被加载程序进行通信;
(3)从这段内存区的 256 字节处开始(在 PSP 后面),将程序装入,程序的地址被设为 SA+10H:0;(空闲内存区从 SA:0 开始,0~255 字节为 PSP,从 256 字节处开始存放程序,为了更好地区分 PSP 和程序,DOS 一般将它们划分到不同的段中,所以有了这样的地址安排:
空闲内存区:SA:00
PSP 区:SA:0
程序区:SA+10:0
注意:PSP 和程序区虽然物理地址连续,却有着不同的段地址
)
(4)将该内存区的段地址存取 ds 中,初始化其它相关寄存器后,设置 CS:IP 指向程序入口。
程序加载进内存后,cx 中存放的是程序的长度,ds 存放着程序所在内存区的段地址,cs 存放可执行程序的段地址,ip 存放着可执行程序的偏移地址。
Debug 常用命令
我们使用 Debug 对程序的执行过程进行跟踪。
用 T 命令单步执行程序的每一条执行。
用 P 命令执行程序结束语句 int 21。
用 Q 命令退出 debug。
第五章 [BX] 和 loop 指令
1. [bx] 和内存单元的描述
[bx] 表示一个内存单元,该内存单元的段地址位于 ds 中,偏移地址位于 bx 中。
该内存单元的完整地址为: ds*16 + bx。
2. loop
循环指令。指令格式为:loop 标号
该指令分两步执行。
第一步,计算 cx = cx -1
第二步,判断 cx 中的值,不为零则跳转至标号出执行程序,如果为零则向下执行。
3. 我们定义的描述性的符号:“()”
“()” 表示一个内存单元或寄存器的内容。也即是存储器中存储的值。
“()” 中的元素可以有 3 中类型:寄存器名、段寄存器名、内存单元的物理地址(一个 20 位数据)。
4. 约定符号 idata 表示常量
在以后的学习中我们约定 idata 表示一个常量。
5.1 [BX]
[bx] 表示一个内存单元。
mov ax, [bx] 代码的含义:将 ds:bx 所指向内存单元的内容放入 ax 寄存器中。即:(ax)=((ds*16)+(bx))
mov [bx], ax 代码的含义:将 ax 中的内容放入 ds:bx 所指向的内存单元中。即:((ds*16)+(bx))=(ax)
5.2 Loop 指令
首先 loop 指令的格式是:loop 标号。该指令分两步执行:
第一步, 计算 cx = cx - 1;
第二步,判断 cx 中的值,不为零则跳转至标号处执行程序,如果为零则向下执行。
一般使用 loop 指令实现循环功能。格式如下:
1 | mov cx, n |
以上代码会循环执行 n 次。(n >= 0)
5.3 在 Debug 中跟踪用 loop 指令实现的循环程序
使用 Debug 调试程序时,有几条经常用到的指令。
T 指令,单步执行指令。
g 指令 , 跳至断点,从当前 IP 执行至指定 IP 处。”g 0012” 表示程序由当前位置执行至 DS:0012 处。
p 指令,循环执行指令,p 指令用于执行完当前次数。
5.4 Debug 和汇编编译器 masm 对指令的不同处理
Debug 和汇编编译器 masm 对形如 “mov ax, [0]” 这类指令的处理是不同的。debug 将”[0]” 看做是一个内存单元,该内存单元的地址是 ds*6 + 0。而编译器直接将 “[0]” 看做立即数 0。因此有如下约定。
(1)在汇编源程序中,如果指令访问一个内存单元,则在指令中必须用”[…]” 来表示内存单元,如果在 “[…]” 里用一个常量 idata 直接给出内存单元的偏移地址,就要在 “[]” 的前面显式的给出段地址所在的段寄存器。
(2)如果在 “[]” 里面用寄存器,比如 bx, 间接给出内存单元的偏移地址,则段地址默认在 ds 中。当然,也可以显式的给出段地址所在的段寄存器。
以上两点概括来说就是,如果内存单元的偏移地址使用立即数给出,则必须显式指明其段地址所在的段寄存器。
5.5 loop 和 [bx] 的联合应用
通过 loop 和 [bx] 联合应用实现对连续内存单元的操作实例:
1 | ... |
以上代码通过循环实现了对内存单元 DS:0000H~DS:0032H 内容的操作。
5.6 段前缀
如果内存单元的偏移地址由 bx 给出,如 “mov ax, [bx]”,则段地址默认位于 ds 中。我们也可以在访问内存单元的指令中显式的给出内存单元段地址所在的段寄存器。比如:
(1)mov ax, ds:[bx]
(2)mov ax, cs:[bx]
(3)mov ax, ss:[bx]
(4)mov ax, cs:[bx]
(5)mov ax, ss:[0]
(6)mov ax, cs:[0]
这些出现在访问内存单元的指令中,用于显式的指明内存单元的段地址的 “ds:”、“cs:”、“ss:”、“es:”,在汇编语言中称为段前缀。
5.7 一段安全的空间
(1)我们需要直接向一段内存汇总写入内容;
(2)这段内存空间不应当存放系统或其他程序的数据或代码,否则写入操作很可能引发错误;
(3)DOS 方式下,一般情况,0:200~0:2ff 空间中没有系统或其他程序的数据或代码;
(4)以后,我们需要直接向一段内存中写入内容时,就使用 0:200~0:2ff 这段空间。
5.8 段前缀的使用
当需要操作的内存空间跨段时,显式的使用段前缀给出内存单元的段地址,可以避免在循环中对 ds 的重复设置。
也即是说一个内存单元的段地址不仅仅可以由 ds 给出,也可以通过 cs、ss、es 给出。
第六章 包含多个段的程序
程序取得所需空间的方法有两种,一是在加载程序的时候为程序分配,再就是程序在执行的过程向系统分配。在本课程中,我们只讨论第一种方法。
6.1 在代码段中使用数据
下面一段代码用于计算 8 个数据的累加和,结果放在 ax 寄存器中:
1 | assume cs:code |
分析这段代码,我们使用 dw 定义了 8 个字型数据,并且使用 “end 标号” 的形式指明了程序的入口。
6.2 在代码段中使用栈
1 | assume cs:codesg |
6.3 将数据、代码、栈放入不同的段
1 | assume cs:code, ds:data, ss:stack |
下面对以上代码进行说明。
(1)定义多个段的方法
定义数据段、栈段与定义代码段的方法没有区别,只是对于不同的段,要有不同的段名。
(2)对段地址的引用
在程序中,段名就相当于一个标号,它代表了段地址。例如程序中 “data” 段中的数据 “0abch” 的地址就是:data:6。要将它送入 bx 中,代码如下:
1 | mov ax, data |
(3)“代码段”、“数据段”、“栈段” 完全是我们的安排
我们通过 “end 标号” 的形式来声明程序的入口地址,这个入口信息被写入可执行文件中的描述信息中。可执行文件中的程序被加载入内存后,CPU 的 CS:IP 就会被设置指向这个入口。
我们通过如下代码来指定程序的栈段:
1 | mov ax, stack |
通过如下代码来指定数据段:
1 | mov ax, data |
总而言之,CPU 到底如何处理我们定义的段中的内容,是当作指令执行,当作数据访问,还是当作栈空间,完全靠程序中具体的汇编指令,和汇编指令对 CS:IP、SS:SP、DS 等寄存器的设置来决定的。
第七章 更灵活的定位内存地址的方法
本章主要讲不同的寻址方式。
7.1 and 和 or 指令
and 表示逻辑与。or 表示逻辑或。
7.2 关于 ASCII 码
ASCII 码:American Standard Code for Information Interchange,美国信息交换标准代码。用 8 位(一个字节)二进制数表示一个字符。起初定义了 128 个字符,后来扩展至 256 个。
当我们再键盘上按下字母 a 键,屏幕上显示 a 字母,这其中经历了哪些过程?
- a 被 ASCII 编码为数字 61H 存储在指定内存空间内。
- 文本编辑器软件从内存中取出 61H, 将其送入显卡显存中。
- 显卡根据 ASCII 编码将 61H 反译为字母 a,同时显卡驱动显示器,将字母 a 的图像画在屏幕上。
通过以上 3 步,我们就看到了字母 a 被显示在屏幕上。
7.3 以字符形式给出的数据
在汇编程序中,使用引号‘’括起来的内容被识别为字符,编译器将把它转换为对应的 ASCII 码。
7.4 大小写转换的问题
在 ASCII 码中,小写字母的对应范围为:61H - 7AH。大写字母的对应范围为:41H - 5AH。可见同一个字母的大写形式的 ASCII 码比小写形式的 ASCII 码小 20H。
仔细观察大小写字母所对应的 ASCII 吗二进制形式,可以发现如下规律:大写字母从右数第 6 位(从 1 开始计算)全为 0,小写字母从右数第 6 位全为 1。
综上我们可以总结出大小写转换的两种方式:
字母大小写转换方式 1:
- 大写字母加上 20h 可转换为小写字母。
- 小写字母减去 20h 可转换为大写字母。
字母大小写转换方式 2:
- 字母转大写:逻辑与 11011111B。
- 字母转小写:逻辑或 00100000B。
7.5 [bx+idata]
这是一种” 变量 + 常量” 的寻址方式。
[bx+idata] 表示一个内存单元,它的偏移地址为 (bx)+idata。
指令:
1 | mov ax, [bx+200] |
表示将一个内存单元的内容送入 ax,这个内存单元的长度为 2 个字节(字单元),存放一个字,偏移地址为 bx 中的数值加上 200,段地址在 ds 中。
该指令的常用格式有:
1 | mov ax, [bx+200] |
7.6 用 [bx+idata] 的方式进行数组的处理
我们可以将地址连续的多个数据当做数组处理。例如定义如下数据:
1 | datasg segment |
我们可以把如上两个字符串当做两个数组,一个数组下标从 0 开始,一个数组下标从 5 开始。在程序中使用 [bx+0] 和 [bx+5] 的方式定位两个字符串的首地址。从而可以在一个循环当中同时处理两组数据。
回忆我们在高级语言中用到的数组取值方式 (如 c 或 java):a [index]。可以看出这就是汇编语言中 [bx+idata] 形式的变种。a 与 idata 相对应,是一常量,表示了数组的首地址。而下标 index 与 bx 对应,是一变量,表示数组下标。
7.7 SI 和 DI
SI 是 Source Index 的缩写。DI 是 Destination Index 的缩写。它俩的功能与 bx 相近,但 SI 和 DI 不能够分成两个 8 位寄存器来使用。下面三组指令实现了相同的功能:
1 | ;(1) |
下面的三组指令也实现了相同的功能:
1 | ;(1) |
7.8 [bx+si] 和 [bx+di]
这是一种 “变量 + 变量” 的寻址方式。
[bx+si] 和 [bx+di] 含义相似,都是表示一个内存单元。该内存单元的段地址位于 ds 中,偏移地址为 bx 的值加上 si 的值(或 bx 的值加上 di 的值)。
该指令的常用格式有:
1 | mov ax, [bx+si] |
7.9 [bx+si+idata] 和 [bx+di+idata]
这是一种” 变量 + 变量 + 常量” 的寻址方式。
常用指令格式:
1 | mov ax, [bx+200+si] |
7.10 不同的寻址方式的灵活应用
总结一下前面讲到的几种定位内存地址的方法(寻址方式):
(1)[idata] 用一个常量来表示地址,可用于直接定位一个内存单元;
(2)[bx] 用一个变量来表示内存地址,可用于间接定位一个内存单元;
(3)[bx+idata] 用一个变量和常量表示地址,可在一个起始地址的基础上用变量间接定位一个内存单元;
(4)[bx+si] 用两个变量表示地址;
(5)[bx+si+idata] 用两个变量和一个常量表示地址。
下一章中,我们将对寻址方式的问题进行更深入的探讨,之所以如此重视这个问题,是因为寻址方式的适当应用,使我们可以以更合理的结构来看待所要处理的数据。而为所要处理的看似杂乱的数据设计一种清晰的数据结构是程序设计的一个关键问题(个人认为这段话说的很有道理,特记录于此)
第八章 数据处理的两个基本问题
本章旨在进一步加强对不同寻址方式的理解及运用。
计算机是进行数据处理、运算的机器,这其中包含两个基本的问题:
(1)处理的数据在什么地方?
(2)要处理的数据有多长?
携带着这两个问题,我们开启第八章的学习之路。
8.1 bx、si、di 和 bp
首先看下这四个寄存器的含义:
bx, Base,Pointer to base addresss (data)。一般用于存储数据段的基址(首地址)。
si,Source Index,Source string/index pointer。一般用于存储源数组数据索引(下标)。
di,Destination Index,estination string/index pointer。一般用于存储目标数组数据索引(下标)。
bp,Base Pointer,Pointer to base address (stack)。一般用于存储栈的基址。
然后在使用过程中有几处需要注意的地方:
(1)在 8086CPU 中,只有这 4 个寄存器可以用在 “[….]” 中来进行内存单元的寻址。其他寄存器是不可以的,例如 “mov bx, [ax]” 就是错误的用法。
(2)在 […] 中,这四个寄存器可以单个出现,或只能以 4 种组合出现:bx 和 si、bx 和 di、bp 和 si、bp 和 di。为了方便记忆可以将 si 和 di 看做一组,将 bx 和 bp 看做一组。组间可以自由组合,组内不能组合。(脑补为人类不可以近亲繁殖。)
(3)只要在 […] 中使用寄存器 bp,而指令中没有显性的给出段地址,则段地址就默认在 ss 中。
8.2 机器指令处理的数据在什么地方
这是我们在开头抛出的两个问题中的第一个。
在指令执行前,所要处理的数据可以在 3 个地方:CPU 内部、内存、端口(端口暂时不用知道是什么东西)。
我们知道了存储数据的部件,但如果具体找到这些部件存储的数据位置?下一节将解答我们的疑问。
8.3 汇编语言中数据位置的表达
在汇编语言中如何表达数据的位置?
汇编语言中用 3 个概念来表达数据的位置。
(1)立即数
对于直接包含在机器指令中的数据(执行前在 CPU 的指令缓冲器中),汇编语言中称为立即数(idata), 在汇编指令中直接给出。
例如:
1 | mov ax, 1 |
(2)寄存器
指令要处理的数据在寄存器中,在汇编指令中给出相应的寄存器名。例如:
1 | mov ax, bx |
(3)段地址(SA)和偏移地址(EA)
指令要处理的数据在内存中,在汇编语言中可以用 [X] 的格式给出 EA,SA 在某个段寄存器中。
存放段地址的寄存器可以是默认的,也可以显性给出。例如:
1 | ;段寄存器默认存储在DS中 |
8.4 寻址方式
这一节我们总结一下所学到过的寻址方式。列表如下:
寻址方式 | 含义 | 名称 | 常用格式举例 | 备注 |
---|---|---|---|---|
[idata] |
EA=idata; SA=(ds) |
直接寻址 | [idata] |
偏移地址 = 立即数 |
[bx] |
EA=(bx); SA=(ds) |
寄存器间接寻址 | [bx] |
偏移地址 = 变量 |
[bx+idata] |
EA=(bx)+idata; SA=(ds) |
寄存器相对寻址 | 用于结构体:[bx].idata ; 用于数组: idata[si] ,idata[di] ; 用于二维数组: [bx][idata] |
偏移地址 = 变量 + 立即数 |
[bx+si] |
EA=(bx)+(si); SA=(ds) |
基址变址寻址 | 用于二维数组:[bx][si] |
偏移地址 = 变量 + 变量 |
[bx+si+idata] |
EA=(bx)+(si)+idata; SA=(ds) |
相对基址变址寻址 | 用于表格(结构)中的数组项:[bx].idata[si] ; 用于二维数组: idata[bx][si] |
偏移地址 = 变量 + 变量 + 立即数 |
注意在 8.1 节指出的特殊情况,只要在 […] 中使用寄存器 bp,而指令中没有显性的给出段地址,段地址就默认在 ss 中。
8.5 指令要处理的数据有多长
这是我们在开头抛出的两个问题中的第二个。
8086CPU 的指令,可以处理两种尺寸的数据,byte 和 word。所以在机器指令中要指明,指令进行的是字操作还是字节操作。对于这个问题,汇编语言中用以下方法处理。
(1)通过寄存器名指定要处理的数据的尺寸。如果寄存器名是字型寄存器(如 ax、bx 等),则说明指令进行的是字操作。如果寄存器名是字节型寄存器(如 al、ah、bl 等),则说明指令进行的是字节操作。
(2)在没有寄存器名存在的情况下,用操作符 X ptr 指明内存单元的长度,X 在汇编指令中可以为 word 或 byte。这种情形适用于没有寄存器参与的内存单元访问指令中。例如:
1 | ;下面的指令,用word ptr指明了指令中访问的内存单元是一个字单元 |
(3)其他方法。有些指令默认了访问的是字单元还是字节单元,比如,push [1000H] 就不用指明访问的是字单元还是字节单元,因为 push 指令只会进行字操作。
8.6 寻址方式的综合应用
8086CPU 提供的如 [bx+si+idata] 的寻址方式为结构化数据的处理提供了方便。使得我们可以在编程的时候,从结构化的角度去看待所要处理的数据。正常情况下,一个结构化的数据包含了多个数据项,而数据项的类型又不相同,有的是字型数据,有的是字节型数据,有的是数组(字符串)。一般来说,我们可以用 [bx+idata+si] 的方式来访问结构体中的数据。用 bx 定位整个结构体,用 idata 定位结构体中的某一个数据项,用 si 定位数组项中的元素。为此,汇编语言提供了更为贴切的书写方式,如 [bx].idata、[bx].idata [si]。
8.7 div 指令
div 是除法指令。在使用的过程中应注意以下问题:
(1)除数:有 8 位和 16 位两种,在一个 reg (寄存器) 或内存单元中。
(2)被除数:默认放在 AX 或 DX 和 AX 中,如果除数为 8 位,被除数则为 16 位,默认在 AX 中存放;如果除数为 16 位,被除数则为 32 位,在 DX 和 AX 中存放,DX 存放高 16 位,AX 存放低 16 位。
(3)结果:如果除数为 8 位,则 AL 存储除法操作的商,AH 存储除法操作的余数;如果除数为 16 位,则 AX 存储除法操作的商,DX 存储除法操作的余数。
div 使用格式如下:
div reg
div 内存单元
8.8 伪指令 dd
dd 用来定义 dword(双字)型数据。
8.9 dup
dup (duplication 的缩写) 用来重复开辟内存空间。
dup 指令要和 db、dw、dd 等数据定义伪指令配合使用,使用格式如下:
db 重复次数 dup (重复的字节型数据)
dw 重复次数 dup (重复的字型数据)
dd 重复次数 dup (重复的双字型数据)
例如,如下代码表示定义了 9 个字节:
1 | db 3 dup (0,1,2) |
第九章 转移指令的原理
本章主要讲如何控制 CPU 执行指令的顺序。
可以修改 IP,或同时修改 CS 和 IP 的指令统称为转移指令。概括的降,转移指令就是可以控制 CPU 执行内存中某处代码的指令。
8086CPU 的转移指令有以下几类。
- 只修改 IP 时,称为段内转移,比如:jum ax。
- 同时修改 CS 和 IP 时,称为段间转移,比如:jmp 1000:0。
由于转移指令对 IP 的修改范围不同,段内转移又分为:短转移和近转移。
- 短转移 IP 的修改范围为 - 128~127。
- 近转移 IP 的修改范围为 - 32768~32767。
8086CPU 的转移指令分为以下几类。
- 无条件转移指令(如 jmp)
- 条件转移指令
- 循环指令(如 loop)
- 过程
- 中断
这些转移指令的前提条件可能不同,但转移的基本原理是相同的。
9.1 操作符 offset
操作符 offset 在汇编语言中是由编译器处理的符号,它的功能是取得标号的偏移地址。
9.2 jmp 指令
jmp 为无条件转移指令,可以只修改 IP,也可以同时修改 CS 和 IP。
jmp 指令要给出两种信息:
(1)转移的目的地址
(2)转移的距离(段间转移、段内短转移、段内近转移)
下面几节将对 jmp 指令进行详细的介绍。
9.3 依据位移进行转移的 jmp 指令
jmp short 标号
实现的是段内短转移,执行后: (IP) = (IP)+ 8 位位移。
(1)8 位位移 = 标号处的地址 - jmp 指令后第一个字节的地址;
(2)short 指明此处的位移为 8 位位移;
(3)8 位位移的范围为 - 128~127,用补码表示;
(4)8 位位移由编译程序在编译时算出。
jmp near ptr 标号
实现的是段内近转移,执行后:(IP) = (IP) + 16 位位移。
(1)16 位位移 = 标号处的地址 - jmp 指令后第一个字节的地址;
(2)near ptr 指明此处的位移为 16 位位移,进行的是段内近转移;
(3)16 位位移的范围为 - 32768~32767,用补码表示;
(4)16 位位移由编译程序在编译时算出。
9.4 转移的目的地址在指令中的 jmp 指令
jmp far ptr 标号
实现的是段间转移,又称为远转移。功能如下:
(CS)= 标号所在段的段地址;(IP)= 标号所在段中的偏移地址。
far ptr 指明了指令用标号的段地址和偏移地址修改 CS 和 IP。
该指令与上节学习的段内转移明显不同的是:
段内转移机器指令携带的是位移,段间转移机器指令携带的是目的地址。
9.5 转移地址在寄存器中的 jmp 指令
jmp 16 位的 reg
该指令实现的功能为:(IP)= (16 位的 reg)
9.6 转移地址在内存中的 jmp 指令
jmp word ptr 内存单元地址(段内转移)
功能:从内存单元地址处开始存放一个字,是转移的目的偏移地址。
内存单元地址可以用之前学过的任一寻址方式给出。
jmp dword ptr 内存单元地址(段间转移)
功能:从内存单元地址处开始存放着两个字,高地址处的字是转移的目的段地址,低地址处是转移的目的的偏移地址。
9.7 jcxz 指令
jcxz 标号
功能:如果(cx)=0,则转移到标号处执行。如果(cx)≠ 0,则程序继续向下执行。
jcxz 指令为有条件转递指令,所有的有条件转移指令都是短转移,在对应的机器码中包含转移的位移,而不是目的地址。对 IP 的修改范围都为:-128~127。
9.8 loop 指令
loop 标号
功能:(cx)=(cx)-1,如果(cx)≠ 0,则转移到标号处执行。
9.9 根据位移进行转移的意义
方便了程序段在内存中的浮动装配。
9.10 编译器对转移位移超界的检测
根据位移进行转移的指令,它们的转移范围受到转移位移的限制,如果在源程序中出现了转移范围超界的问题,在编译的时候,编译器将报错。
第十章 CALL 和 RET 指令
call 和 ret 都是转移指令,它们都修改 IP,或同时修改 CS 和 IP。它们经常被共同用来实现子程序的设计。
10.1 ret 和 retf
这个两个指令可以理解为高级语言中的 return 关键字,表示程序返回。
ret 用栈中的数据,修改 IP 的内容,从而实现近转移;
retf 指令用栈中的数据,修改 CS 和 IP 的内容,从而实现远转移。
CPU 执行 ret 指令时,进行下面两步操作:
(1)(IP) = ((SS)*16+(SP))
(2)(sp) = (sp)+2
以上步骤相当于进行:
pop IP
CPU 执行 retf 指令时,进行下面 4 步操作:
(1)(IP) = ((SS)*16+(SP))
(2)(sp) = (sp)+2
(3)(CS) = ((SS)*16+(SP))
(4)(sp) = (sp)+2
以上步骤相当于进行:
pop IP
pop CS
10.2 call 指令
call 指令可以理解为高级语言中的方法(函数)调用功能。
CPU 指令 call 指令时,进行两步操作:
(1)将当前的 IP 或 CS 和 IP 压入栈中。 (保存现场)
(2)转移。
call 指令不能实现短转移,除此之外,call 指令实现转移的方法和 jmp 指令的原理相同。
10.3 依据位移进行转移的 call 指令
指令格式:
call 标号
CPU 执行该指令时相当于进行:
push IP
jmp near ptr 标号
10.4 转移的目的地址在指令中的 call 指令
指令格式:
call far ptr 标号
CPU 执行该指令时相当于进行:
push CS
push IP
jmp far ptr 标号
该指令编译的机器指令中包含了转移的目的地址。包括段地址 CS 的值及偏移地址 IP 的值。
10.5 转移地址在寄存器中的 call 指令
指令格式:
call 16 位 reg
CPU 执行该指令时相当于进行:
push IP
jmp 16 位 reg
10.6 转移地址在内存中的 call 指令
转移地址在内存中的 call 指令有两种格式。
(1)第一种指令格式:
call word ptr 内存单元地址
CPU 执行该指令时相当于进行:
push IP
jmp word ptr 内存单元地址
(2)第二种指令格式:
call dword ptr 内存单元地址
CPU 执行该指令时相当于进行:
push CS
push IP
jmp dword ptr 内存单元地址
10.7 call 和 ret 的配合使用
call 和 ret 的配合使用可以用来实现子程序的机制。call 指令在转去执行子程序之前,会将当前指令下一条指令的位置保持在栈中,当子程序执行 ret 或 retf 指令后,会用栈中的数据设置 ip 或 cs 和 ip 的值,从而转到 call 指令后面的代码处继续执行。
10.8 mul 指令
(1)两个相乘的数:练歌相乘的数,要么都是 8 位,要么都是 16 位。如果是 8 位,一个默认放在 AL 中,另一个放在 8 位 reg 或内存字节单元中;如果是 16 位,一个默认放在 AX 中,另一个放在 16 位 reg 或内存字单元中。
(2)结果:如果是 8 位乘法,结果默认放在 AX 中;如果是 16 位乘法,结果高位默认在 DX 中存放,低位在 AX 中存放。
10.9 模块化程序设计
现实问题比较复杂,对现实问题进行分析时,把它转化为相互联系、不同层次的子问题,是必须的解决方法。
在高级语言中的函数或者方法就是这种思想的体现。在汇编语言中我们将高级语言中的方法或函数称之为子程序。
10.10 参数和结果传递的问题
当我们设计子程序时面临两个问题:
(1)参数存放的位置?
(2)计算结果存放的位置?
实际上,我们可以将参数及结果存放于任何可以存储数据的地方。一般情况下,我们可以将参数存储在寄存器中,也可以存储在普通内存单元中。更一般的做法我们将其存储在栈中进行传递。
10.11 寄存器冲突的问题
寄存器数量是有限的,子程序中使用的寄存器,很可能在主程序中也要使用,造成了寄存器使用上的冲突。解决这个问题的简捷方法是,在子程序的开始将子程序中所有用到的寄存器中的内容都保存起来,在子程序返回前再恢复。 可以用栈来保存寄存器中的内容。
栈是临时保存数据的一个比较理想的数据结构。
第十一章 标志寄存器
标志寄存器 (Flag Register) 是我们 8086CPU14 个寄存器中最为复杂的一个。其他 13 个寄存器一般用于存放数据,整个寄存器具有一个含义。而 flag 寄存器是按位起作用的。
这一章中我们主要学习 CF、PF、ZF、SF、OF、DF 等标记位,以及其相关部分指令。
11.1 ZF 标志
Zero Flag,零标记位。用于记录相关指令执行后,其结果是否为 0。如果结果为 0,则 ZF=1,如果结果非 0,则 ZF=0。
需要特别注意的是:
在 8086 的指令集中,有的指令的执行是影响标志寄存器的,比如,add、sub、mul、div、inc、or、and 等,它们大都是运算指令(进行逻辑或算术运算);有的指令的执行对标志寄存器没有影响,比如 mov、push、pop 等,它们大都是传送指令。
11.2 PF 标志
Parity Flag,奇偶标记位。它用于记录相关指令执行后,其结果的所有 bit 位中 1 的个数是否为偶数。如果 1 的个数为偶数,则 pf=1,如果为奇数,则 pf=0。
11.3 SF 标志
Sign Flag,符号标记位。它用于记录相关指令执行后,其结果是否为负。如果结果为负,则 SF=1,如果结果非负,则 SF=0。
计算机中通常用补码来表示有符号数,补码在形式上与普通的无符号二进制数据并无差异。也即是说,给定的一个二进制数,我们既可以把它当做有符号数的补码形式,也可以当做一个无符号数。对于计算机来说,无论是无符号数还是有符号数的补码形式,在计算方式上并无差异(补码的符号位同样参与运算)。
SF 标志,就是 CPU 对有符号数运算结果的一种记录,它记录数据的正负。在我们将数据当做有符号数来运算的时候,可以通过它来得知结果的正负。如果我们将数据当做无符号数来运算,SF 的值则没有意义,虽然相关指令影响了它的值。
11.4 CF 标志
Carry Flag,进位标志位。一般情况下,在进行无符号数运算的时候,它记录了运算结果的最高有效位向更高位的进位值,或从更高位的借位值。
11.5 OF 标志
Overflow Flag,溢出标志位。在进行有符号数运算的时候,如果计算结果超出了机器所能表示的范围则发生溢出,此时 OF=1。否则,OF=0。
注意区分 CF 和 OF 的区别:CF 是对无符号数运算有意义的标志位,OF 是对有符号数运算有意义的标志位。
11.6 adc 指令
adc 是带进位加法指令,它利用了 CF 位上记录的进位值。
指令格式:
adc 操作对象 1,操作对象 2
功能:操作对象 1 = 操作对象 1 + 操作对象 2+CF
比如指令 adc ax,bx 实现的功能是:(ax)= (ax)+ (bx)+ CF
既然我们已经有了 add 指令,那为什么还要设计 adc 指令呢?
设想一下,之前我们使用 add 指令做加法运算的时候,相加结果都是 16 位以内,如果和大于 16 位就会产生误差。adc 指令目的就是对任意大的数据进行加法运算。自习观察加法运算可以得到如下规律:
任意大的加法运算都可以分解为多步进行,低位相加,高位相加再加上低位相加产生的进位值,直至所有位都相加完毕。
使用 adc 指令结合上述规律就可以实现对任意大的数据进行加法运算。
11.7 sbb 指令
sbb 是带借位减法指令,它利用了 CF 位上记录的错位值。
指令格式:
sbb 操作对象 1,操作对象 2
功能:操作对象 1 = 操作对象 1 - 操作对象 2-CF。
sbb 指令和 adc 指令是基于同样的思想设计的两条指令,在应用思路上和 adc 指令类似。
11.8 cmp 指令
cmp 是比较指令,cmp 的功能相当于减法指令,只是不保存结果。cmp 指令执行后,将对标志寄存器产生影响。
指令格式:
cmp 操作对象 1,操作对象 2
功能:计算操作对象 1 - 操作对象 2 但并不保存结果,仅仅根据计算结果对标志寄存器进行设置。
利用 cmp ax, bx 指令对两个无符号数 ax 和 bx 进行比较,如果执行后:
zf = 1,说明 (ax) = (bx)
zf = 0,说明 (ax) ≠ (bx)
cf = 1,说明 (ax) < (bx)
cf = 0,说明 (ax) ≥ (bx)
cf = 0 并且 zf = 0,说明 (ax) > (bx)
cf = 1 或 zf = 1,说明 (ax) ≤ (bx)
利用 cmp ah,bh 指令对两个有符号数 ah 和 bh 进行比较,由于有符号数的比较较为复杂,主要是考虑到溢出的特殊情景,我们分类讨论:
(1) 如果 sf = 1 并且 of = 0
of = 0 说明没有溢出,并且 sf = 1 说明逻辑上真正的结果为负数。所以 (ah) < (bh)。
(2) 如果 sf = 1 并且 of = 1
of = 1 说明存在溢出,针对补码求和来说,如果结果非 0 并且产生溢出,正确的逻辑结果符号与实际的结果符号必然相反。 sf = 1 说明实际结果为负,那么正确的逻辑结果应该为正。所以 (ah) > (bh)。
(3) 如果 sf = 0 并且 of = 1
of = 1 说明存在溢出,针对补码求和来说,如果结果非 0 并且产生溢出,正确的逻辑结果符号与实际的结果符号必然相反。 sf = 0 说明实际运算结果必然不小于 0,因为存在溢出所以实际运算结果必不等于 0,所以实际运算结果必然大于 0,进而推导出正确的逻辑运算结果必然小于 0。所以 (ah) < (bh)。
(4) 如果 sf = 0 并且 of = 0
of = 0 说明没有溢出,并且 sf = 0,说明逻辑上真正的结果为非负数。所以 (ah) ≥ (bh)。
(5) 如果 zf = 1
这种情形比较简单。此时 (ah) = (bh)。
11.9 检测比较结果的条件转移指令
“转移” 指的是它能够修改 IP,而 “条件” 指的是它可以根据某种条件,决定是否修改 IP。比如,jcxz 就是一个条件转移指令,它可以检测 cx 中的数值,如果 (cx) = 0,就修改 IP,否则什么也不做。所有条件转移指令的位移都是 [-128, 127](即它们都是短转移)。
jcxz 是根据寄存器 cx 的值来判断是否转移,除此之外还存在其他条件转移指令,大多数条件转移指令都检测标志寄存器相关标志位,根据检测的结果来决定是否修改 IP。
下表列出了常用的根据无符号数的比较结果进行转移的条件转移指令:
指令 | 含义 | 检测的相关标志位 | 备注 |
---|---|---|---|
je | 等于则转移 | zf = 1 | e 表示 equal |
jne | 不等于则转移 | zf = 0 | ne 表示 not eauql |
jb | 低于则转移 | cf = 1 | b 表示 below |
jnb | 不低于则转移 | cf = 0 | nb 表示 not blow |
ja | 高于则转移 | cf = 0 且 zf = 0 | a 表示 above |
jna | 不高于则转移 | cf = 1 或 zf = 1 | na 表示 not above |
注意,条件转移指令通常与 cmp 指令配合使用。
11.10 DF 标志和串传送指令
Direction Flag,方向标志位。在串传送指令中,控制每次操作后 si、di 的增减。
df = 0 ,每次操作后 si、di 递增;
df = 1 ,每次操作后 si、di 递减。
下面,我们学习几个常见的串传送指令。(写到这里,突然想吃羊肉串了~~)
movsb 指令
格式:
movsb
功能:将 ds:si 指向的内存单元中的字节送入 es:di 中,并根据标志寄存器 df 的值,将 si 和 di 递增或递减。
movsw 指令
与 movsb 指令类似,只不过 movsw 指令传送的是一个字单元。
rep 指令
本人将其翻译为重复指令(repetition)。movsb 和 movsw 进行的是串传送操作中的一个步骤,一般来说,movsb 和 movsw 都配合 rep 配合使用,格式如下:
rep movsb
功能:根据 cx 的值来决定是否重复执行 movsb 操作。使用汇编语法来描述就是 >
s: movsb
loop s
cld 指令和 std 指令
cld 指令:将标志寄存器的 df 位置 0;
std 指令:将标志寄存器的 df 位置 1。
为了方便记忆,可以将 cld 理解为 clear direction 的缩写,将 std 理解为 set direction 的缩写。
11.11 pushf 和 popf
pushf 的功能是将标志寄存器的值压栈,而 popf 是从栈中弹出数据,送入标志寄存器中。
pushf 和 popf 为直接访问标志寄存器提供了一种方法。
11.12 标志寄存器在 Debug 中的表示
在 Debug 中,我们使用 r 命令查看寄存器详情,第二行最后几个双字符字母即是标志寄存器中各标志位的值。
第十二章 内中断
什么是中断?如果你学习过高级编程语言,可以将中断理解为异常的特殊处理过程,就像 Java 里面的 Exception。
任何一个通用的 CPU,都具备一种能力,可以在执行完当前正在执行的指令之后,检测从 CPU 外部发送过来的或内部产生的一种特殊信息,并且可以立即对所收到的信息进行处理。这种特殊信息,我们可以称其为:中断信息。中断的意思是指,CPU 不在接着 (刚执行完的指令) 向下执行,而是转去处理这个特殊信息。
中断信息可以来自 CPU 内部和外部,这一章,我们主要讨论来自 CPU 内部的中断信息,我们称之为内中断。
12.1 内中断的产生
8086CPU 使用单元字节大小的数字来标识中断类型。
CPU 内部可能产生多种多样的中断,那么应该如何来标识是哪种中断呢,或者说我们如何确定中断源?
8086CPU 用称为中断类型码的数据来标识中断信息的来源。中断类型码为一个字节型数据,可以表示 256 种中断类型。以后,我们将产生中断信息的事件,即中断信息的来源,称之为中断源。
12.2 中断处理程序
处理中断信息的程序被称为中断处理程序。
12.3 中断向量表
中断发生后,CPU 要根据中断类型码去执行对应的中断处理程序?但如何根据 8 位的中断类型码得到中断处理程序的地址呢?
实际上,8086CPU 用 8 位的中断类型码通过中断向量表找到相应的中断处理程序的入口地址。中断向量表就是中断处理程序入口地址的列表,列表的下标索引(从 0 开始)即是中断类型码的值。中断向量表实际上是中断类型码与中断处理程序入口地址之间的一种映射关系。可以理解为高级编程语言中的 Map 集合。
8086CPU 中断向量表指定放在内存 0 处。每个表项占用 4 个字节,高位字存放段地址,低位字存放偏移地址。
12.4 中断过程
用中断类型码找到中断向量,并用它设置 CS 和 IP 的值,这个工作是由 CPU 的硬件自动完成的。CPU 硬件完成这个工作的过程被称为中断过程。中断过程完成后,CPU 就会开始执行中断处理程序。中断过程可以理解为中断环境的初始化。那么在 CPU 进行中断过程中需要准备哪些工作呢?概括来说,主要进行以下六步准备工作:
(1)(从中断信息中) 取得中断类型码;
(2)标志寄存器的值入栈 (因为在中断过程中要改变标志寄存器的值,所以先将其保存在栈中);
(3)设置标志寄存器的第 8 位 TF 和第 9 位 IF 的值为 0 (这一步的目的后面将介绍);
(4)CS 的内容入栈;
(5)IP 的内容入栈;
(6)从内存地址为中断类型码 4 和中断类型码 4+2 的两个字单元中读取中断处理程序的入口地址设置 IP 和 CS。
12.5 中断处理程序和 iret 指令
中断处理程序必须一直存储在指定内存中,以应对随时可能发生的中断事件。
中断处理程序的编写方法和子程序比较相似,下面是常规步骤:
(1)保存用到的寄存器;
(2)处理中断;
(3)恢复用到的寄存器;
(4)用 ret 指令返回。
iret 指令的功能用汇编语法描述为:
1 | pop IP |
在中断过程中,注意标志寄存器入栈和出栈的次序。入栈顺序是标志寄存器、CS、IP,出栈顺序与此相反。
12.6 除法错误中断的处理
除法错误将引发 0 号中断。至于为何是 0 号中断,我估摸着除法中断时人们最容易想到也最容易遇到的中断了吧。
12.7 编程处理 0 号中断
我们的需求是重新编写一个 0 号中断处理程序,它的功能是在屏幕中间显示 “overflow!”,然后返回到操作系统。
为了满足以上需求,需要做一下几件事情:
(1)编写可以显示 “overflow” 的中断处理程序:do0;
(2)将 do0 送入内存 0000:0200 处;
(3)将 do0 的入口地址 0000:0200 存储在中断向量表 0 号表项中。
程序的框架如下:
1 | assume cs:code |
下面摘抄书中比较精辟的一段总结:
我们如何让一个内存单元成为栈顶?将它的地址放入 SS、SP 中;
我们如何让一个内存单元中的信息被 CPU 当做指令来执行?将它的地址放入 CS、IP 中;
我们如何让一个内存单元成为要处理的数据?将它的段地址放在 DS 中;(书中无这句话,个人根据理解补充)
那么,我们如何让一段程序成为 N 号中断的中断处理程序呢?将它的入口地址放入中断向量表的 N 好表项中。
12.8 安装
所谓安装就是将中断处理程序 (do0) 送到指定内存处。
我们可以使用 movsb 指令,将 do0 的代码送入 0:200 处。复习一下 movsb 的用法:movsb 是串传送指令,其功能是将 ds:si 指向的内存单元中的字节送入 es:di 中,并根据标志寄存器 df 的值,将 si 和 di 递增或递减。movsb 指令往往与 rep 指令配合使用来实现批量字符串的传送。
安装程序的框架如下所示:
1 | assume cs:code |
以上步骤的难点在于如何确认中断处理程序 do0 的长度?最笨的方法是计算 do0 中每句代码的长度,然后累加,但这样做太麻烦了,不仅要知道每行代码所占的字节数,代码稍有改动那就令人抓狂。书中作者给出一个非常简便的计算方式,利用编译器来帮助我们计算 do0 的长度。之前我们学过 offset 指令,他的功能是取得标号的偏移地址,我们在 do0 后面在添加一个标号 do0end,使用 offset do0end - offset do0 即可计算出 do0 的长度。
解决了字符传送以及确认 do0 长度这两个拦路虎后,我们就可以看一下较为完整的安装程序代码了:
1 | assume cs:code |
这里补充一点,像”+”、”-“、”*“、”/“、”offset” 这类指令都是伪指令,并不是标准的汇编指令,它是由编译器识别并由编译器翻译为对应的汇编指令。
12.9 do0
do0 程序即是我们的 0 号中断处理程序。其主要目的是显示字符串”overflow!”。
主要程序代码如下所示:
1 | do0: jum short do0start |
这部分代码需要注意的地方是,我们在子程序 do0 开始处定义了字符串”overflow!”,但它并不是可以执行的代码,所以在”overflow!” 之前加上一条 jmp 指令,转移到正式的 do0 程序。
12.10 设置中断向量
设置中断向量,也即是将中断处理程序 do0 在内存中的入口地址存放在中断向量表 0 号表项中。0 号表项的地址为 0:0,其中 0:0 字单元存放中断处理程序入口地址的偏移地址,0:2 字单元存放中断处理程序入口地址的段地址。程序如下:
1 | mov ax, 0 |
12.11 单步中断
基本上,在 CPU 执行完一条指令之后,如果检测到标志寄存器的 TF 位为 1,则产生单步中断,引发中断过程。单步中断的中断类型码为 1。在一开始我们说 CPU 在执行中断处理程序之前要先将标志寄存器 TF 位置 0,这就是为了防止 CPU 在执行 1 号类型中断 (单步中断) 时无限递归执行中断。
CPU 提供单步中断功能的出发点是,为单步跟踪程序的执行过程,提供了实现机制。
12.12 响应中断的特殊情况
一般情况下,CPU 在执行完当前指令后,如果检测到中断信息,就响应中断,引发中断过程。可是,在有些情况下,CPU 执行完当前指令后,即便是发生中断,也不会响应。例如针对 ss 修改执行后,下一条指令 (一般是修改 sp) 也会紧接着执行,中间即使发生中断,CPU 也不会去响应。这样做的主要原因是,ss:sp 联合指向栈顶,而对它们的设置应该连续完成。如果在执行完设置 ss 的指令后,CPU 响应中断,引发中断过程,要在栈中压入标志寄存器、CS 和 IP 的值,而 ss 改变,sp 并未改变,ss:sp 指向的不是正确的栈顶,将引起错误。
这种理念在高级编程语言中的具体体现是 “原子操作”,即一组操作要么不执行,要么就一次执行完毕,不会存在中间状态。