0%

这里我们接下来使用TurboC2来尝试编写可执行程序,它是一个可以在DOS十六位上运行的C语言编辑器,我们使用C语言来进一步的对8086进行学习

有关Turbo2C的安装到网上找教程即可

使用寄存器

在汇编中使用寄存器,需要指定寄存器名,在C语言中也是如此

我们可以tc2.0支持以下寄存器名

image.png

根据寄存器名称可以理解对应的寄存器关系

我们进行以下思考

  1. 用TurboC编译出来的可执行程序和用masm编译出来的程序有什么区别?

首先我们用turboC写出以下程序然后进行编译

image.png
image.png

我们注意到当我们查看debug时看到的汇编代码和我们写的C语言程序并不一样

此时我们思考下一个问题:

  1. main函数的代码在什么段中,我们怎么找到它?

在这里我将答案写在了我的源程序中,我们可以看到 printf("%x",main)

这一条指令的作用是答应出main函数在代码段中的偏移地址: 0x01FA

这里还需要注意,为什么可以用main来找到其在代码段中的偏移地址。这是因为在这里,main是一个标号,并不是一个变量。我们可以通过printf("%x",&main)来验证,如果main是变量,那么此时打印出来的是main变量的存储地址,然而实际上main是一个直接指向代码段地址的标号

所以我们可以定位到我们的程序,并看到我们写的程序逻辑:

image.png
  1. 那么程序DEBUG时我们,一开时看到的内容是什么?

我们可以判断添加这一部分内容的肯定时编译连接程序,所以其作用,可能与程序执行前后的饿现场保护,系统调度有关系。那么,这多出来的部分应该是固定的,与我们编写的程序无关。所以在 上面拿到的偏移地址,对于所有的程序都是一样的。

  1. 我们在程序中看到main函数后有ret指令,因此我们可以设想:C语言将函数实现为汇编中的子程序。但是如何验证?

我们编写一个有函数调用过程的C程序即可

image.png

我们通过调试打开可以看到

image.png

在这里看可以看到函数的调用过程,实际上就是子程序的调用

使用内存空间

首先要明确内存空间的使用,对于寄存器而言,我们需要给出寄存器的名称,寄存器的名称中也包含了他们的类型信息。而对于内存空间我们同样也需要给出内存地址(准确的说是内存空间首地址)和空间存储数据的类型。

现在我们对一些C语言的指令进行分析:

1
*(char *)0x2000 = 'a';

这里我们的第一个*是访问内存空间地址的意思,而(char *)则是指明这是一个存储char型数据的内存空间地址

当然我们也可以直接使用给出段地址和偏移地址,比如我们要向一个地址为 2000:0存储一个字节的内存空间写入字符a

1
*(char far *)0x20000000='a';

“far”指明内存空间的地址是段地址和偏移地址,而0x20000000中的0x2000给出了段地址,0000给出了偏移地址

当然这种对内存空间进行直接访问的方式是不安全的,我们可能无意间修改了别的程序的代码或者数据,从而引起错误

(1)首先编写一个程序,看看C语言的内存空间使用,在汇编中是以什么形式呈现?

image.png

我们可以看到汇编中的完成方式(由此可以感受到汇编与C的相似性)

image.png

(2)现在我们尝试在C语言中写一个程序来实现打印字符”Hello”

简单粗暴的方法

image.png

(3)那么我们现在进一步的思考,C语言将全局的变量存放在哪里?将局部变量又存放在哪里?每个函数开头的push bp mov bp sp又有什么意义?分析以下代码思考一下

1
2
3
4
5
6
7
8
9
10
11
12
int a1,a2,a3;
void f(void);
main(){
int b1,b2,b3;
a1=0xa1;a2=0xa2;a3=0xa3;
b1=0xb1;b2=0xb2;b3=0xb3;
}
void f(void){
int c1,c2,c3;
a1=0x0fa1;a2=0x0fa2;a3=0x0fa3;
c1=0xc1;c2=0xc2;c3=0xc3;
}
image.png

我们看到 SUB SP,+06将栈顶下移了6个字节用来存放局部变量,为什么是存放局部变量而不是全局变量呢,在存储的过程中,我们可以看到对于全局变量,是使用直接定址的方法进行存储在程序的数据段中,而对于局部变量则是以栈底的相对位置进行访问。由此可以看出,局部变量以栈的形式存储在函数的同一个栈中。

在这里我们便可以理解push bp mov bp sp的意义,通俗来讲。这是因为全局变量被存储于数据段中,而局部变量被存储于栈段中,和函数功能存放在一起,而这段指令则是用于创建一个新的函数栈帧。

我在下一篇博客中会详细讲解这个过程。

(4)此时我们进一步思考,函数的返回值被存放在哪里?分析下面的程序

1
2
3
4
5
6
7
8
9
10
int f(void);
int a,b,ab;
main(){
int c;
c=f();
}
int f(void){
ab = a+b;
return ab;
}
image.png

我们很容易理解前面的逻辑,其中 [01A6],[01A8],[01AA]都是全局变量的位置,但是此时我们注意到 MOV AX,[01AA]观察可以得到,在这里函数的返回值通过寄存器的方式返回。

(5)理解内存的创建与释放?分析以下函数

1
2
3
4
5
6
7
8
9
#define Buffer ((char *)*(int far *)0x2000000)
main(){
Buffer = (char *)malloc(20);
Buffer[10]=0;
while(Buffer[10]!=8){
Buffer[Buffer[10]]='a'+Buffer[10];
Buffer[10]++;
}
}

气死了,这个实验不知道为什么做的很不成功,先是没办法正常分配内存,然后再是拿不到正常的返回值,算了算了

不用main函数编程

现在我们讨论一个问题,如果一个C程序中它没有使用main函数编程,那它是否能被编译并正常运行呢?

现在我们准备两个程序

1
2
3
4
f(){
*(char far *)(0xb8000000+160*10+80)='a';
*(char far *)(0xb8000000+160*10+81)=2;
}
1
2
3
4
main(){
*(char far *)(0xb8000000+160*10+80)='a';
*(char far *)(0xb8000000+160*10+81)=2;
}
image.png

我们在对F.exe进行编译后出现了如下报错,且编译失败,故我们用link对F.obj进行编译

接下来我们分别运行M.exe和F.exe,运行结果如下:

image.png

M运行后正常显示并正常返回,但是F出现了一些情况。虽然a的显示是正常的,但是F运行之后,程序卡死,无法返回正常的操作

image.png

这是为什么呢,我们观察两个程序的大小

image.png

发现M程序的大小远大于F程序,说明M程序中包含了跟多的指令和信息,我们对其分别进行反汇编,发现相较于M程序,F程序只是一个孤零零的子程序只有入口却没有返回。而M程序则是一个完整的程序,且在01FA之前,有着完整的程序

image.png

而且相较于F程序,M程序的子程序结尾多了 RET PUSH BP MOV BP,SP这一部分的作用是用于函数返回,恢复原栈帧用的

现在我们可以好好分析一下二者的区别:

  • 首先main函数被作为了一个子程序,且在编译时被添加了很多代码
  • f函数则是作为一个子程序被直接调用却没有返回

因此,问题出在main函数被编译连接的过程中,我们回想f函数编译失败的报错 Linker Error:Undefined symbol _main in module C0S我们可以猜测,在连接的过程中,连接器把main.obj与C0S.obj连接在了一起,得到我们的main.exe函数。此时我们可以进一步的推断,01FA地址以前的程序都是来自COS.obj中。接下来我们对此进行验证:

我们在lib文件夹下面找到C0S.obj文件并将其编译,然后对执行程序进行反编译

image.png

我们发现这部分代码和01FA以前的代码很像啊,几乎一样,所以我们可以认定main函数以前的代码都与C0S是有关系的

从上面我们可以看出,tc.exe把c0s.obj和用户.obj文件一同进行连接,生成.exe文件。按照这个方法生成.exe文件中的程序的运行过程如下:

  • c0s.obj中的程序先运行,进行相关的初始化,比如,申请资源,设置DS,SS等寄存器
  • c0s.obj中的程序调用main函数,从此用户程序开始运作
  • 用户程序从main函数中返回到c0s.obj的程序中
  • c0s.obj程序接着运行,进行相关资源的释放,环境恢复等问题;
  • c0s.obj的程序调用DOS的int 21h例程的4ch号功能,程序返回

所以看来C语言程序必须从main函数开始,是C语言的规定,这个规定不是在编译时保证的,也不是连接的时候保证的,而是用下面的机制保证的:

  • 首先,C开发系统提供了用户写的应用程序正确运行所必须的初始化和程序返回等相关程序,这些程序被存放在相关的.obj程序中
  • 其次,需要将这些文件和用户.obj文件一起连接,才能生成可正确运行的.exe文件
  • 基于这个机制,我们只需要改写c0s.obj,让它调用其他函数,编程时就可以不写main函数了

现在我们自己写一个简单的c0s.obj程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
assume cs:code
data segment
db 128 dup(0)
data ends
code segment
start:
mov ax,data
mov ds,ax
mov ss,ax
mov sp,128

call s

mov ax,4c00h
int 21h
s:

code ends
end start

我们尝试将它和f.obj连接在一起看看能不能生成可正确执行的可执行程序

image.png

OK ,经过不懈的努力我们也是成功连接出了一个可正确执行的可执行程序。

这里需要补充一下连接多个目标文件的用法 link file1.obj file2.obj...;

函数如何接受不定数量的参数

给定参数的函数参数传递

我们通过一个简单的程序来研究两个问题,main函数时如何给showchar传递参数的?showchar是如何接受参数的?

1
2
3
4
5
6
7
8
void showchar(char a,int b);
main(){
showchar('a',2);
}
void showchar(char a,int b){
*(char far *)(0xb800+160*10+80) = a;
*(char far *)(0xb800+160*10+81) = b;
}

我们先编译成可执行程序后,反汇编其代码:

image.png

我们可以看到一个下面这样的栈结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
内存地址 (低地址)
+-----------------+
| | <- SP 指向这里 (0000)
+-----------------+
| | <- 0001
+-----------------+
| BP (低位) | <- 0002
+-----------------+
| BP (高位) | <- 0003 (PUSH BP)
+-----------------+
| AX (AL) | <- 0004
+-----------------+
| AX (AH) | <- 0005 (PUSH AX)
+-----------------+
| AX (AL) | <- 0006
+-----------------+
| AX (AH) | <- 0007 (栈底)(PUSH AX)
+-----------------+
内存地址 (高地址)

我们可以看到在函数调用传入参数时,是以栈底为基础相对位移对传入的参数进行访问。也就是说,依次向AL中传入的数值便是我们的参数。

总结得到C语言中参数的传递是通过栈来实现的。在函数调用前,将参数放入AX中,进入调用函数后,先把参数中的值出栈到AX中。这样就完成了函数间参数值的传递工作。其次我们还需要注意:在参数入栈中首先入栈的是后面的参数,即入栈时为倒序入栈,这是因为栈先进后出的特性

不定参数个数的函数传参

我们编写一个不定参数个数的函数后进行分析

1
2
3
4
5
6
7
8
9
10
11
void showchar(int,int,...);
main(){
showchar(8,2,'a','b','c','d','e','f','g','h')
}
void showchar(int n,int color,...){
int a;
for(a=0;a!=n;a++){
*(char far *)(0xb8000000+160*10+80+a+a)=*(int *)(_BP+8+a+a),
*(char far *)(0xb8000000+160*10+81+a+a)=color;
}
}

这里我用AI画了一个栈段图,可以更形象的理解这个调用的过程

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
+-------------------+
| 返回地址 | <- _BP + 0
+-------------------+
| 旧的 _BP 值 | <- _BP + 2
+-------------------+
| 第一个参数 n | <- _BP + 4
+-------------------+
| 第二个参数 color| <- _BP + 6
+-------------------+
| 第三个参数 'a' | <- _BP + 8
+-------------------+
| 第四个参数 'b' | <- _BP + 10
+-------------------+
| 第五个参数 'c' | <- _BP + 12
+-------------------+
| 第六个参数 'd' | <- _BP + 14
+-------------------+
| 第七个参数 'e' | <- _BP + 16
+-------------------+
| 第八个参数 'f' | <- _BP + 18
+-------------------+
| 第九个参数 'g' | <- _BP + 20
+-------------------+
| 第十个参数 'h' | <- _BP + 22
+-------------------+

因此就不过多赘述了,这便是函数传递参数的原理

写一个printf函数

知道了传递参数的原理,我们写一个简单的print函数来结束对于TurboC 的简单学习

功能:实现一个支持%c,%d的printf函数

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void print(char *str,...);

main(){
print("%c %c %c %c",'a','b','c','d');
}

void print(char *str,...){
int color = 2;
int x = 2;
int i = 0;
int j = 0;
int data = 0;
int buffer[100];
int bit = 0;
char ch = str[i++];

while(ch){
if(ch == '%'){
ch = str[i++];
if(ch == 'c'){
*(char far *)(0xb8000000+160*10+80+x) = *(int *)(_BP+6+j);
*(char far *)(0xb8000001+160*10+80+x) = color;
x = x+2;
j++;
}
if(ch == 'd'){
bit = 0;
data = *(int *)(_BP+6+j);
j++;
if(data == 0){
*(char far *)(0xb8000000+160*10+80+x)='0';
*(char far *)(0xb8000001+160*10+80+x)=color;
x = x+2;
}else{
while(data !=0){
buffer[bit] = data%10;
data = data / 10;
bit++;
}
bit--;
for(;bit>=0;bit--){
*(char far *)(0xb8000000+160*10+80 + x) = buffer[bit]+'0';
*(char far *)(0xb8000001+160*10+80 + x) = color;
x = x+2;
}
}
}
}else{
*(char far *)(0xb8000000+160*10+80+x)=ch;
*(char far *)(0xb8000001+160*10+80+x)=color;
x = x+2;
}
ch = str[i++];
}
}

至此对于8086的简单学习到这里结束了

这几天一直在玩,要抓紧学完,去学别的东西哦

直接定址表

学习如何有效的组织数据,以及相关的编程技术

描述了单元长度的标号

我们先以一个程序为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
assume cs:code 
code segment
a: db 1,2,3,4,5,6,7,8
b: dw 0
start:
mov si,offset a
mov bx,offset b
s:
mov al,cs:[si]
mov ah,0
add cs:[bx],ax
inc si
loop s
mov ax,4c00H
int 21h
code ends
end start

这里的 a b start s code都是标号。这些标号仅仅表示了内存单元的地址

但是我们还有一种标号,这种标号不仅表示内存单元的地址,同时还表示了内存单元的长度

上面的程序可以写成下面的形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
assume cs:code 
code segment
a db 1,2,3,4,5,6,7,8
b dw 0
start:
mov si,offset a
mov bx,offset b
s:
mov al,cs:[si]
mov ah,0
add cs:[bx],ax
inc si
loop s
mov ax,4c00H
int 21h
code ends
end start

在code段中使用的标号a,b后面没有 :,他们是同时描述内存地址和长度的标号,例如:

  • 标号a,描述了地址code:0,和从这个地址开始,以后的内存单元都是字节单元
  • 标号b,描述了地址code:8,和从这个地址开始,以后的内存单元都是字单元

可见,使用这种包含单元长度的标号,可以使我们以简洁的形式访问内存中的数据。我们将这种标号称为 数据标号

在其他段中使用数据标号

指定段寄存器

在其他段中,也可以使用数据标号来描述存储数据的单元的地址和长度

不过要注意,在后面加有”:“的地址标号,只能在代码段中使用,不能在其他段中使用

比如我们以一个累加程序为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
assume cs:code,ds:data
data segment
a db 1,2,3,4,5,6,7,8
b dw 0
data ends
code segment
start:
mov ax,data
mov ds,ax
mov si,0
mov bx,8
s:
mov al,a[si]
mov ah,0
add b,ax
inc si
loop s
mov ax,4c00H
int 21h
code ends
end start

注意,如果想在代码段中直接用数据标号访问数据,需要用都安寄存器和标号所在的段进行关联,否则编译器无法却确定标号的段地址在哪一个寄存器里。

这里并不是说,如果用assume指令将段寄存器和某个段相联系,段寄存器中就真的回存放该段的地址。我们在程序中,仍然需要指令对段寄存器进行设置

将标号当作数据定义

可以将标号当作数据来定义,此时,编译器将标号所表示的地址当作数据的值。比如:

1
2
3
4
5
data segment
a db 1,2,3,4,5,6,7,8
b dw 0
c dw a,b
data ends

数据标号c存储的两个字型数据为标号a,b的偏移地址。相当于:

c dw offset a, offset b

再比如:

1
2
3
4
5
data segment
a db 1,2,3,4,5,6,7,8
b dw 0
c dd a,b
data ends

数据标号c处存储的两个双字型为标号a的偏移地址和段地址,标号b的偏移地址和段地址。相当于:

c dw offset a,seg a,offset b,seg b

这里的seg操作符,功能为取得某一标号的段地址

直接定址表

我们注意到数值015和字符”0”“F”之间并没有直接的映射关系,所以我们需要再他们之间建立新的映射关系

数值09和字符”0”“9”之间的映射关系最明显,有 数值 + 30H = 对应字符的ASCII值

数值1015和字符”A”“F”之间的映射关系则是,数值 + 37H = 对应字符的ASCII值

具体的做法是,建立一张表,表中依次存储字符”0”“F”,我们可以通过数值015直接查找到对应的字符

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
40
41
42
43
44
45
46
47
48
49
50
51
assume cs:code
stack segment
dw 16 dup(0)
stack ends
code segment
start:
mov ax,stack
mov ss,ax
mov sp,16
mov ax,3AH
call show

mov ax,4c00H
int 21h

showbyte:
jmp short show
table db '0123456789ABCDEF' ;字符表

show:
push bx
push es

mov ah,al
shr ah,1
shr ah,1
shr ah,1
shr ah,1 ;取高四位
and al,00001111b ;取低四位

mov bl,ah
mov bh,0
mov ah,table[bx]

mov bx,0b800H
mov es,bx
mov es:[160*12+40*2],ah

mov bl,al
mov bh,0
mov al,table[bx]

mov es:[160*12+40*2+2],al

pop es
pop bx

ret

code ends
end start

这张表定义后可以实现映射操作,当我们向ax中传输一个数值时,它会返回这个数值到显示器上

这种映射关系一般用作以下几种用途:

  • 为了算法的清晰和简洁
  • 为了加快运算速度
  • 为了使程序易于填充

接下来编写一个子程序sin (x),  x ∈ {0, 30, 60, 90, 120, 150, 180}并在屏幕中央显示结果,我们可以用麦克劳林公式进行计算sin(x),不过为了加快运算速度,在这里我们使用映射来加快这个过程。我们可以写出以下程序:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
assume cs:code
stack segment
dw 16 dup(0)
stack ends
code segment
start:
mov ax,stack
mov ss,ax
mov sp,16
mov ax,120
call show

mov ax,4c00H
int 21h

showsin:
jmp short show
table dw ag0,ag30,ag60,ag90,ag120,ag150,ag180 ;字符串偏移地址
ag0 db '0',0
ag30 db '0.5',0
ag60 db '0.866',0
ag90 db '1',0
ag120 db '0.866',0
ag150 db '0.5',0
ag180 db '0',0
show:
push bx
push es
push si
mov bx,0b800H
mov es,bx
;用角度/30作为相对于table的偏移值,取得对应的字符串的偏移地址,放在bx中
mov ah,0
mov bl,30
div bl
mov bl,al
mov bh,0
add bx,bx ;注意每个标号实际上使双字节的
mov bx,table[bx]
;显示
mov si,160*12+40*2
shows:
mov ah,cs:[bx] ;此时bx存储了表中的偏移地址
cmp ah,0
je showret
mov es:[si],ah
inc bx
add si,2
jmp short shows
showret:
pop si
pop es
pop bx
ret


code ends
end start

上面这种可以通过依据数据,直接计算出所要找的元素的位置的表,我们称之为直接定址表

程序入口的直接定址表

我们可以在直接定址表中存储子程序的地址,从而方便的实现不同子程序的调用。

我们编写以下程序

image.png

得知需求之后,分析一下功能的实现:

  1. 清屏:讲显存中当前屏幕中的字符设置为空格符
  2. 设置前景色:设置显存中当前屏幕中处于奇地址的属性字节的第0,1,2位
  3. 设置背景色:设置显存中当前屏幕中处于奇地址的属性字节的第4,5,6位
  4. 向上滚动一行:依次将第n+1行的内容复制到第n行;最后一行为空

我们可以写出以下程序:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
assume cs:code
stack segment
dw 32 dup(0)
stack ends
code segment
start:
mov ax,stack
mov ss,ax
mov sp,32

mov ah,3
mov al,1
call setscreen

mov ax,4c00h
int 21h
setscreen:
jmp short set
table dw sub1,sub2,sub3,sub4
set:
push bx
cmp ah,3
ja sret
mov bl,ah
mov bh,0
add bx,bx

call word ptr table[bx]

sret:
pop bx
ret

;清屏
sub1:
push bx
push cx
push es
mov bx,0b800h
mov es,bx
mov bx,0
mov cx,2000
sub1s:
mov byte ptr es:[bx],' '
add bx,2
loop sub1s
pop es
pop cx
pop bx
ret
;设置前景色
sub2:
push bx
push cx
push es
mov bx,0b800h
mov es,bx
mov bx,1
mov cx,2000
sub2s:
and byte ptr es:[bx],11111000b
or es:[bx],al
add bx,2
loop sub2s
pop es
pop cx
pop bx
ret
;设置背景色
sub3:
push bx
push cx
push es
mov cl,4
shl al,cl
mov bx,0b800h
mov es,bx
mov bx,1
mov cx,2000
sub3s:
and byte ptr es:[bx],10001111b
or es:[bx],al
add bx,2
loop sub3s
pop es
pop cx
pop bx
ret
;上移一行
sub4:
push cx
push si
push di
push es
push ds
mov si,0b800h
mov es,si
mov ds,si
mov si,160 ;ds:si指向第n+1行
mov di,0 ;es:di指向第n行
cld
mov cx,24
sub4s:
push cx
mov cx,160
rep movsb
pop cx
loop sub4s
mov cx,80
mov si,0
sub4s1:
mov byte ptr [160*24+si],' '
add si,2
loop sub4s1
pop ds
pop es
pop di
pop si
pop cx
ret
code ends
end start

根据上面的程序需求即可修改对应的参数

最后的挑战

字符串打印

image.png

程序如下,利用栈和int 16h模拟了键盘的输入和撤回:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
assume cs:code
stack segment
dw 128 dup(0)
stack ends
code segment
start:
mov ax,stack
mov ss,ax
mov sp,256
call clean
mov dh,12
mov dl,25
call getstr

mov ax,4c00h
int 21h

charstack:
jmp short charstart
table dw charpush,charpop,charshow
top dw 0 ;栈顶
charstart:
push bx
push dx
push di
push es

cmp ah,2
ja sret
mov bl,ah
mov bh,0
add bx,bx ;表项是二字节的,所以偏移位置要左移一位
jmp word ptr table[bx]
charpush:
mov bx,top
mov [si][bx],al
inc top
jmp sret
charpop:
cmp top,0
je sret
dec top
mov bx,top
mov al,[si][bx]
jmp sret
charshow:
mov bx,0b800h
mov es,bx
mov al,160
mov ah,0
mul dh ;行号
mov di,ax
add dl,dl
mov dh,0 ;列号
add di,dx
mov bx,0
charshows:
cmp bx,top
jne noempty
mov byte ptr es:[di],' '
jmp sret
noempty:
mov al,[si][bx]
mov es:[di],al
mov byte ptr es:[di+2],' '
inc bx
add di,2
jmp charshows
sret:
pop es
pop di
pop dx
pop bx
ret
; bye:
; mov ax,4c00h
; int 21h
getstr:
push ax
getstrs:
mov ah,0
int 16h
; cmp al,1
; je bye
cmp al,20h
jb nochar ;字符码小于20h,说明不是字符
mov ah,0
call charstack ;字符入栈
mov ah,2
call charstack ;显示字符
jmp getstrs
nochar:
cmp ah,0eh ;BS的扫描码
je backspace
cmp ah,1ch ;Enter的扫描码
je enter
jmp getstrs
backspace:
mov ah,1
call charstack ;字符出栈
mov ah,2
call charstack ;显示字符
jmp getstrs
enter:
mov al,0
mov ah,0
call charstack ;0入栈
mov ah,2
call charstack ;显示字符
pop ax
ret
;清屏
clean:
push bx
push cx
push es
mov bx,0b800h
mov es,bx
mov bx,0
mov cx,2000
cleans:
mov byte ptr es:[bx],' '
add bx,2
loop cleans
pop es
pop cx
pop bx
ret
code ends
end start

马上过年了,这几天要么在帮忙,要么在玩。今天是年三十,上午没什么事,我要好好学一下。

外中断

外中断信息

有一种中断信息,来自于CPU的外部,当CPU外部有需要处理的事情发生的时候。比如说,外设的输入到达,芯片会向CPU发出相应的中断信息。CPU在执行完当前的指令后,可以检测发送过来的中断信息,引发中断过程,处理外设的输入

在PC中,外中断源一共有以下两类:

  1. 可屏蔽中断

    可屏蔽中断是CPU可以不响应的外中断。CPU是否可以响应可屏蔽中断,要看标志位寄存器IF的位的设置。当CPU检测到可屏蔽中断信息是,如果IF = 1,则CPU在执行完当前指令后响应中断,引发中断;如果IF= 0,则不响应可屏蔽

    可屏蔽中断程序的所引发的中断过程,基本和内中断的中断过程相同。因为可屏蔽中断信息来自于CPU外部,中断类型码通过数据总线送入CPU;而内中断的中断类型码在CPU内产生的

    现在,我们可以解释中断过程中将IF置为0的原因了。将IF置为0的原因就是,在进入中断和程序之后,禁止其他的可屏蔽中断。如果在中断过程中需要处理可屏蔽中断,可以用指令将IF置1。。8086CPU提供的设置IF的指令如下:

    • sti,设置IF = 1
    • cli,设置IF = 0
  2. 不可屏蔽中断

    不可屏蔽中断时CPU必须响应的外中断。当CPU检测到不可屏蔽中断信息时,则在执行完当前命令后,立即响应,并引发中断过程

    对于8086CPU,不可屏蔽中断的中断类型码固定为2,所以中断过程中,不需要取中断类型码,而是直接触发。不可屏蔽中断的中断过程为:

    • 标志寄存器入栈,IF=0,TF=0
    • CS,IP入栈
    • (IP) = (8),(CS)= (0AH)

几乎所有的外设引发的外中断,都是可屏蔽中断。当外设有需要处理的事件时(比如说键盘输入)发生时,相关芯片向CPU打出可屏蔽中断信息。不可屏蔽中断时在系统中有必须处理的紧急情况发生时用来通知CPU的中断信息

PC机的键盘处理过程

通过键盘的响应过程。我们可以感受一下外设输入的过程

键盘输入

键盘上的键相当于开关,键盘中有一个芯片。当按下一个键时,芯片就会产生一个扫描码,扫描码说明了按下的键在键盘上的位置。扫描码被送入主板上的相关接口芯片的寄存器中,该寄存器的端口地址为60h。松开按下的键时,也产生一个扫描码,扫描码说明了松开的键在键盘上的位置。松开时,产生的扫描码也送到端口60h中

  • 通码:按下一个键产生,第7位为0
  • 断码:松开一个键产生,第7位为1

一个扫描码的长度为一个字节,所以有 断码 = 通码 + 80H

引发9号中断

当键盘的输入到达60H端口时,相关的芯片就会向CPU发出中断类型码为9的可屏蔽中断信息。CPU检测到该中断信息后,如果IF=1,则响应中断,引发中断过程,转去执行int 9 中断例程

执行 int 9中断例程

BIOS提供了int 9中断例程,用来进行基本的键盘输入处理,其工作如下:

  1. 读出60h端口中的扫描码
  2. 如果是字符键的扫描码,将该扫描码和它所对应的(ASCII码)送入内存中的BIOS键盘缓冲区;如果是控制键(Ctrl)和切换键(CapsLock)的扫描码,则将其转变为状态字节(用二进制位记录控制键和切换状态的字节)写入内存中存储状态字节的单元
  3. BIOS键盘缓冲区在系统启动后,用来存放int 9中断例程中所接收的键盘输入。该内存区可以存储15个键盘输入,因为 int 9中断例程除了接受扫描码外,还要产生对应的字符码。所以在BIOS键盘缓冲区,一个键盘输入用一个字单元存放(高位存放扫描码,低位存放字符码)

关于状态码如下:

image.png

INT 9例程

编写int 9例程

我们可以编写新的键盘中断例程,进行一些特殊工作。不过设计到部分的硬件操作细节,不过我们可以通过使用已经编写好的int 9例程覆盖这些操作

比如现在我们需要:在屏幕中间依次显示“a”~“z”,并可以让人看清。在显示的过程中,按下Esc键后改变颜色

我们可以先写出循环打印的程序

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
40
41
42
43
44
assume cs:code
stack segment
db 128 dup(0)
stack ends

code segment
start:
mov ax,stack
mov ss,ax
mov sp,128

mov ax,0b800H
mov es,ax
mov ah,'a'
s:
;显示a-z
mov es:[160*12+40*2],ah
call delay ;因为我们的CPU执行速度太快了,所以需要延迟输出
inc ah
cmp ah,'z'
jna s

mov ax,4c00h
int 21h

delay:
;延迟函数
push ax
push dx
mov dx,0FH ;循环F0000h次
mov ax,0
s1:
sub ax,1
sbb dx,0
cmp ax,0
jne s1
cmp dx,0
jne s1
pop dx
pop ax
ret

code ends
end start

接着我们使用int 9中断例程去实现变色

键盘输入到达60h端口后,会引发int 9 中断例程。我们可以编写int 9例程,功能如下:

  • 从60H端口中读出键盘的输入
  • 调用BIOS的int 9中断例程,处理其他硬件细节
  • 判断是否位Esc的扫描码,如果是,改变颜色后返回;如果不是则直接返回

不过如何调用原int 9也是一个问题,在这里我们可以将其入口地址作为我们的函数入口,然后用以下方法模拟调用:

  1. 标志寄存器入栈
  2. IF=0,TF=0
  3. call dword ptr ds:[0]

对于(1),可以使用 pushf实现

对于(2),可以用下面的指令实现

1
2
3
4
5
pushf
pop ax
and ah,11111100B ;IF和TF为标志寄存器的第九位和第八位
push ax
popf

则综上的int模拟过程为:

1
2
3
4
5
6
7
pushf
pushf
pop ax
and ah,11111100B
push ax
popf ;IF=0,TF=0
call dword ptr ds:[0] ;将CS,IP入栈

知道原理之后我们可以实现:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
assume cs:code

stack segment
db 128 dup(0)
stack ends

data segment
dw 0,0 ;为原int 9中断例程的调用分配空间
data ends
code segment
start:
;栈的初始化
mov ax,stack
mov ss,ax
mov sp,128
;拷贝原int 9的中断例程
mov ax,data
mov ds,ax
mov ax,0
mov es,ax
push es:[9*4]
pop ds:[0]
push es:[9*4+2]
pop ds:[2] ;将入口地址保存在当时ds:0~2单元中
mov word ptr es:[9*4],offset int9
mov es:[9*4+2],cs
;显存初始化
mov ax,0b800H
mov es,ax
mov ah,'a'
s:
;显示a-z
mov es:[160*12+40*2],ah
call delay ;因为我们的CPU执行速度太快了,所以需要延迟输出
inc ah
cmp ah,'z'
jna s
;恢复例程
mov ax,0
mov es,ax
push ds:[0]
pop es:[9*4]
push ds:[2]
pop es:[9*4+2]

mov ax,4c00h
int 21h

delay:
;延迟函数
push ax
push dx
mov dx,0Fh ;循环F0000h次
mov ax,0
s1:
sub ax,1
sbb dx,0
cmp ax,0
jne s1
cmp dx,0
jne s1
pop dx
pop ax
ret
;------------新的int 9的中断例程-----------------
int9:
push ax
push bx
push es

in al,60h ;从60h读出键盘的输入

pushf
pushf
pop bx
and bh,11111100B
push bx
popf
call dword ptr ds:[0] ;对int指令进行模拟,调用原来的int 9中断例程

cmp al,1
jne int9ret

mov ax,0b800H
mov es,ax
inc byte ptr es:[160*12+40*2+1] ;将属性值加1,改变颜色
int9ret:
pop es
pop bx
pop ax
iret
code ends
end start

安装int 9例程

刚刚是简介使用了原int 9中断例程的功能,现在我安装一个新的int 9中断例程,使得原int9中断例程的功能得到拓展

功能:在DOS下,按F1键后改变当前屏幕的显示颜色,其他键照常处理

在开始之前需要解决几个问题,首先

  1. 改变屏幕的显示颜色

改变从B800H开始的4000个字节中的所有奇地址元素中的内容,当前屏幕的显示颜色即发生改变

1
2
3
4
5
6
7
8
	mov ax,08b00H
mov es,ax
mov bx,1
mov cx,2000
s:
inc byte ptr es:[bx]
add bx,2
loop s
  1. 其他的都是使用原int9例程处理即可,这里我们还需要调用原int9中断例程,所以需要保存原int9例程的入口地址:

由于安装程序在程序返回之后地址将丢失,所以我们将其保存在0:200后,而我们重写的int9例程,我们也将保存在0:204之后

现在准备就绪,开始编写

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
assume cs:code
stack segment
db 128 dup (0)
stack ends

code segment
start:
mov ax,stack
mov ss,ax
mov sp,128

push cs
pop ds

mov ax,0
mov es,ax
;安装程序
mov si,offset int9 ;设置ds:si指向源地址
mov di,204h ;设置es:di指向目的地址
mov cx,offset int9end-offset int9
cld
rep movsb

push es:[9*4]
pop es:[200H]
push es:[9*4+2]
pop es:[202H]

cli
mov word ptr es:[9*4],204h
mov word ptr es:[9*4+2],0
sti

mov ax,4c00H
int 21h

int9:
push ax
push bx
push cx
push es

in al,60h
pushf
call dword ptr cs:[200h] ;此时cs=0

cmp al,3bh ;F1的扫描码为3bH
jne int9ret

mov ax,0b800H
mov es,ax
mov bx,1
mov cx,2000
s:
inc byte ptr es:[bx]
add bx,2
loop s
int9ret:
pop es
pop cx
pop bx
pop ax
iret
int9end:
nop
code ends
end start

实验十五

image.png

很简单,只要在上一个代码的基础上做修改即可

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
assume cs:code
stack segment
db 128 dup (0)
stack ends

code segment
start:
mov ax,stack
mov ss,ax
mov sp,128

push cs
pop ds

mov ax,0
mov es,ax
;安装程序
mov si,offset int9 ;设置ds:si指向源地址
mov di,204h ;设置es:di指向目的地址
mov cx,offset int9end-offset int9
cld
rep movsb

push es:[9*4]
pop es:[200H]
push es:[9*4+2]
pop es:[202H]

cli
mov word ptr es:[9*4],204h
mov word ptr es:[9*4+2],0
sti

mov ax,4c00H
int 21h

int9:
push ax
push bx
push cx
push es

in al,60h
pushf
call dword ptr cs:[200h] ;此时cs=0

cmp al,9EH ;判断断码
jne int9ret

mov ax,0b800H
mov es,ax
mov bx,0
mov cx,2000
s:
mov byte ptr es:[bx],'A' ;全屏输出
add bx,2
loop s
int9ret:
pop es
pop cx
pop bx
pop ax
iret
int9end:
nop
code ends
end start

昨天学的那个太难了,再加上下午一直在玩,今天要赶赶进度

int 指令

当CPU执行 int n指令时,相当于引发一个n号中断的过程,其执行流程如下:

  • 取中断类型码n
  • 标志寄存器入栈,IF = 0,TF = 0
  • CS,IP入栈
  • (IP) = (n*4) (CS) = (n*4+2)

可以在程序中使用int指令,调用任何一个中断的中断处理程序

比如我们使用这段程序:

1
2
3
4
5
6
7
8
9
assume cs:code
code segment
start:
mov ax,0b800h
mov es,ax
mov byte ptr es:[12*160+40*2],'!'
int 0
code ends
end start

当我们运行这段指令之后我执行0号处理程序,然后回到系统

由此,我们可以看出int 和 call指令相似,都是调用一段程序

我们可以实现编译一些子程序,作为中断处理程序,然后用int进行调用,我们把这个称为中断例程

编写供应用程序调用的中断例程

书本里面给了两个例题

image.png

我们在下面给出安装程序

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
assume cs:code
code segment
start:
mov ax,cs
mov ds,ax
mov si,offset sqr ;指向源地址
mov ax,0
mov es,ax
mov di,200H
mov cx,offset sqrend - offset sqr
cld
rep movsb

mov ax,0
mov es,ax
mov word ptr es:[7ch*4],200H
mov word ptr es:[7ch*4+2],0

mov ax,4c00H
int 21h

sqr:
mul ax
iret ;中断例程的最后使用,退栈CS:IP和标志寄存器
sqrend:
nop

code ends
end start
image.png

我们在下面给出安装程序

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
40
assume cs:code
code segment
start:
mov ax,cs
mov ds,ax
mov si,offset capital ;指向源地址
mov ax,0
mov es,ax
mov di,200H
mov cx,offset capitalend - offset capital
cld
rep movsb

mov ax,0
mov es,ax
mov word ptr es:[7ch*4],的
mov word ptr es:[7ch*4+2],0

mov ax,4c00H
int 21h

capital:
push cx
push si
change:
mov cl,[si]
mov ch,0
jcxz ok
and byte ptr [si],11011111b
inc si
jmp short change
ok:
pop si
pop cx
iret
capitalend:
nop

code ends
end start

因为中断例程用到了si,cx所以我们需要用栈保存

对int,iret和栈的深入理解

我们通过一个例子,来实现int,iret,栈的深入了解

image.png

为了具备loop的功能,7cH中断例程需要具备以下功能:

  • dec cx
  • 如果(cx)!=0,转到s标号执行,否则向下执行

可是怎么实现到目的地的转移到呢?

  • 我们应该设(CS)为s的段地址,(IP)为s的偏移地址
  • 在中断例程开始之后,我们会将s标号的段地址和se的偏移地址压入栈中.此时,我们可以用之前存放在bx中的偏移位移,来得到标号s的偏移地址
  • 接着利用iret指令,我们将栈中的se的偏移地址加上bx中的转移位移,则栈中的偏移地址即变成了s的偏移地址.我们再使用iret指令,用栈中的内容设置CS,IP从而实现了跳转

由此我们可以写出中断例程:

1
2
3
4
5
6
7
8
9
lp:
push bp
mov bp,sp
dec cx
jcxz lpret
add [bp+2],bx
lpret:
pop bp
iret

这需要说明一下,因为我们要访问栈,所以使用了bp.在程序开始前,先将bp入栈保存,结束时再出栈恢复.当要修改栈中se的偏移地址时,栈中的结构是: 栈顶处是bp原来的数值,下面是se的偏移地址,在下面是s的段地址,再下面是标志寄存器此时bp中为栈顶的偏移地址,所以((ss)*16+(bp)+2)处为se的偏移地址,再加上bx中的偏移位移就变成了s的偏移地址.最后再用iret出栈返回,CS:IP此时为标号s的指令

BIOS与DOS

BIOS和DOS提供的中断例程

在系统的ROM中存放了一套程序,称之为BIOS(基本输入输出系统),BIOS中主要包含以下内容:

  • 硬件系统的检测和初始化程序
  • 外部中断和内部中断的中断例程
  • 用于对硬件设备进行I/O操作的中断例程
  • 其他和硬件系统相关的中断例程

操作系统DOS也提供了中断例程,从操作系统的角度来看,DOS的中断例程就是操作系统像程序员提供的一种编程资源

BIOS和DOS中断例程的安装过程

BIOS和DOS 的中断例程是怎么安装到内存中的呢?

  • 开机后,CPU加电.初始化(CS)=0FFFFH,(IP)=0,自动从FFFF:0单元开始执行程序.在FFFF:0单元有一条跳转指令,CPU执行这个命令之后转去执行BIOS中的硬件系统检测和初始化程序
  • 初始化程序将建立BIOS所支持的中断向量,只需要将BIOS提供的中断例程入口登记在中断向量表中.在这里需要注意,对于BIOS所提供的中断例程,只需要将入口地址登记在中断向量表中,因为他们是被固化在ROM中的程序,一直在内存中存在
  • 硬件系统检测和初始化完成之后,调用 int 19h进行操作系统的引导.从此将计算机交由操作系统控制
  • DOS启动后,除完成其他工作之外,还将它所提供的中断例程装入内存中,并建立相应的中断向量

BIOS中断例程的应用

我们可以使用int 10H中断例程,其包含了多个和屏幕输出相关的子程序.

这里可以看出,一个供程序员调用的中断例程,其中包含了多个子程序,中断例程内部用传递进来的参数来决定执行哪一个子程序.在BIOS和DOS中提供的中断例程,都是用ah来传递内部的子程序的编号

展示以下 int 10h中断例程的设置光标位置功能:

1
2
3
4
5
6
mov ah,2	;置光标
mov bh,0 ;第0页
mov dh,5 ;dh中放行号
mov dl,12 ;dl中放列号
int 10h
;上面操作的含义是:设置光标到第0页,第5行,第12列

关于页号的含义可以看看下方的图片:

image.png

我们继续试试int 10h中断例程的在光标位置显示字符功能

1
2
3
4
5
6
7
mov ah,9	;在光标位置显示字符
mov al,'a' ;字符
mov bl,7 ;颜色属性
mov bh,0 ;第0页
mov cx,3 ;字符重复个数
int 10H
;含义:在屏幕的第5行12列显示3个红底高亮闪烁绿色的"a"

我们编写一个完整的程序来查看效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
assume cs:code
code segment
start:
mov ah,2
mov bh,0
mov dh,5
mov dl,12
int 10h

mov ah,9
mov al,'a'
mov bl,11001010b
mov bh,0
mov cx,3
int 10h

mov ax,4c00H
int 21h

code ends
end start

非常成功(好耶!!!)

DOS中断例程的应用

int 21H是DOS提供的中断例程,其中包含了DOS提供给程序员编程时调用的子程序

比如我们常用的:

1
2
3
mov ah,4ch	;程序返回
mov al,0 ;程序返回值
int 21h

我们来试试另外的用法:在光标位置显示字符串的功能

1
2
3
;ds:dx指向字符串	;要显示的字符串需要用"$"作结束符
mov ah,9 ;功能号9,表示在光标位置显示字符串
int 21h

我们来试试效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
assume cs:code
data segment
db 'I love you my baby','$' ;$本身并不输出,只是起到边界作用
code segment
start:
mov ah,2
mov bh,0
mov dh,5
mov dl,12
int 10h

mov ax,data
mov ds,ax
mov dx,0
mov ah,9
int 21H

mov ax,4c00H
int 21h

code ends
end start

DOS为程序员提供了许多可以调用的子程序,包含在int 21H中断例程中.可以自行了解

端口

在PC机种,和CPU通过总线相连接的芯片除了各种储存器以外,还有以下3种芯片:

  • 各种接口卡(显卡,网卡)上的接口芯片,它们控制接口卡进行工作
  • 主板上的接口芯片,CPU通过它们对部分外设进行访问
  • 其他芯片,用来存储相关的系统信息,或进行相关的输入输出处理

在这些芯片种,都有一组可以由CPU读写的寄存器,这些寄存器有以下共同点:

  • 都和CPU的总线相连接,当然这种连接都是通过他们所在的芯片进行的
  • CPU对他们进行读写的时候都通过控制线向他们所在的芯片发出端口读写命令

所以,从CPU的角度,将这些寄存器当作端口,对他们进行统一编址,从而建立一个统一的端口地址空间。每一个端口在地址空间都有一个地址

CPU可以直接读写这三个地方的数据:

  • CPU内部的寄存器
  • 内存单元
  • 端口

端口的读写

在访问端口时,CPU通过端口地址来定位端口。因为端口所在的芯片和CPU通过总线相互连接,所以,端口地址和内存地址一样,通过地址总线来传送。在PC中CPU最多可以定位64KB个不同的端口,即端口的地址范围为0~65535

访问端口的命令只有两条 inout,我们分析下面的例子:

1
in al,60H	;从60H号端口读入一个字节

注意,在inout指令中,只能使用ax和al来存放从端口中读入的数据或要发送到端口中的数据

  • 访问8位端口要用al
  • 访问16位端口要用ax

CMOS RAM芯片

我们通过一个芯片的例子来详细的体会一下对端口的访问

这个芯片的特征如下:

  • 包含一个实时钟和一个有128个存储单元的RAM存储器
  • 该芯片靠电池供电。所以,关机后其内部的实时钟仍可以正常工作,RAM中的信息也不会丢失

芯片的作用如下:

  • 128个字节的RAM中,内部实时钟占用0~0DH单元来保存时间信息,其余大部分单元用于保存系统配置信息,供系统启动时BIOS程序读取。BIOS也提供相关的程序,使我们可以在开机的时候配置CMOSRAM中的系统信息
  • 该芯片有两个端口,端口地址为70H和71H,CPU通过这两个端口来读写CMOSRAM。其中70H为地址端口,存放要访问的CMOS的单元地址;71H为数据端口,存放选定的CMOS单元中读取的数据,或要写入其中的数据

也就是说CPU对CMOS的读写要分两步进行。比如,读CMOS的2号单元

  1. 将2送入端口70H
  2. 从端口71H读出2号单元的内容

shl和shr指令

他们两个是逻辑移位指令

他们的使用方法为

1
2
3
4
5
6
shl/shr 二进制数据,见下面的分类
;如果只是移动一位
shl al,1
;如果移动的不止一位,则将移动的次数放在cl中
mov cl,N
shl al,cl

shl

shl是逻辑左移指令,它的功能为:

  • 将一个寄存器或内存单元中的数据向左移位
  • 将最后移除的一位写为CF中
  • 最低位用0补充

将X逻辑左移一位相当于进行: X=X*2

shr

shr是逻辑右移指令,它的功能为:

  • 将一个寄存器或内存单元中的数据项右移位
  • 将最后移除的一位写入CF中
  • 最高位用0补充

将X逻辑右移一位相当于进行:X=X/2

这里举个例子,将10100100B左移三位

1
2
3
原数据:	10100100
左移后: 10100100 CF =1
最低位用0补充:00100000

CMOS RAM中存储的时间信息

这里有一个编程任务

image.png

实现这个功能,我们需要分成两个步骤进行:

  • 从CMOSRAM中的8号单元读出当前月份的BCD码
  • 将用BCD码表示的月份以十进制的形式显示到屏幕上

关键在于怎么将BCD值转换为十进制数对应的ASCII码,这里我们可以用刚刚提到的逻辑位移实现

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
assume cs:code 
code segment
start:
;从CMOS中单读出当前的月份的BCD码
mov al,8
out 70h,al
in al,71h
;分离BCD码
mov ah,al
mov cl,4
shr ah,cl
and al,00001111b
;转换为ASCII码
add ah,30H
add al,30H
;显示
mov bx,0b800H
mov es,bx
mov byte ptr es:[160*12+40*2],ah ;显示十位数码
mov byte ptr es:[160*12+40*2+2],al ;显示个位数码

mov ax,4c00H
int 21H
code ends
end start

实验十四

image.png
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
assume cs:code 
data segment
db '00/00/00 00:00:00','$'
db 9,8,7,4,2,0
data ends
stack segment
dw 8 dup(0)
stack ends
code segment
start:
;初始化
mov ax,stack
mov ss,ax
mov sp,16
mov ax,data
mov ds,ax
mov si,0
mov cx,6
mov bx,12H
;从CMOS中单读出当前的月份的BCD码
s:
mov al,[bx]
out 70h,al
in al,71h
call go
inc bx
loop s

;字符显示
mov ax,data
mov ds,ax
mov dx,0
mov ah,9
int 21H

mov ax,4c00H
int 21H

go:
translation:
;分离BCD码
push cx
mov ah,al
mov cl,4
shr ah,cl
and al,00001111b
;转换为ASCII码
add ah,30H
add al,30H
input:
;存入
mov byte ptr ds:[si],ah ;显示十位数码
mov byte ptr ds:[si+1],al ;显示个位数码
add si,3
pop cx
ret

code ends
end start

今天继续学习汇编语言,争取这个星期之内学完8086CPU

内中断

CPU 具有一种能力,可以在执行当前的命令之后,检测到从CPU外部发送过来的或者内部产生的一种特殊信息,并且可以立即对接受到的信息进行某种处理。这种特殊的信息,我们称之为”中断信息”。中断的意思是要求CPU马上进行某种处理,并向需要进行处理的提供了必备的参数的通知信息

内中断的产生

对于8086CPU,我们有四种情况会产生中断信息:

  • 除法错误,比如除法溢出
  • 单步执行
  • 执行into 指令
  • 执行int 指令

对于不同的信息,我们需要进行不同的处理,那么我们必须知道中断信息的来源,所以中断信息中需要包含识别来源的编码。在8086中我们用称为中断类型码的数据来标识中断信息的来源。中断类型码为一个字节型数据,可以标识256种中断信息的来源。以后我们将中断信息的来源称之为中断源。上述的4种中断源,在8086中的中断类型码如下:

  • 除法错误 –> 0
  • 单步执行 –> 1
  • 执行into –> 4
  • 执行int指令,指令格式为 int n指令中n为字节型立即数,是提供给CPU 的中断类型码

中断处理

中断处理程序

CPU在收到中断信息之后,需要对中断信息进行处理。而我们编写的,用来处理中断信息的程序被称之为中断处理程序。一般对不同的中断信息我们会编写不同的处理程序

当我们收到中断信息后,应该前往中断处理程序进行处理。所以我们需要根据中断信息确定处理程序的入口。我们用下面的方式来进行对中断处理程序的段地址和偏移地址的查找

中断向量表

根据CPU的设计,中断类型码的作用就是用来定位中断处理程序的

我们通过中断向量表完成对中断类型的查找,中断向量表在内存中保存,其中放了256个中段源的处理程序、

image.png

所以只要知道了中断类型码,就可以通过查找中断向量表,找到处理程序的入口

所以现在,找到中断向量表成了首要条件。

中断向量表在内存中存放,对于8086PC机,中断向量表指定存放在内存地址0处。从内存0000:00000000:03FF的1024个单元中存放着中断向量表

中断过程

当CPU收到中断信息后,要对中断信息进行处理,首先将引发中断过程。硬件在完成中断过程之后,CS:IP将指向中断处理程序的入口,CPU开始执行中断处理程序

不过在执行完中断处理程序后,我们还需要返回原来的执行点继续执行下面的指令。

为了解决这个问题,CPU在收到中断类型之后,所引发的中断过程是这样的:

  • 获取中断类型码
  • 将标志寄存器中的值入栈(因为在中断过程中要改变标志寄存器的值,所以先要保存)
  • 设置标志寄存器的第8位TF和第9位IF的值为0
  • CS的内容入栈
  • IP的内容入栈
  • 从内存地址为中断类型码4和中断类型码4+2的两个字单元读取中断处理程序的入口地址,并设置

我们用以下方式更加简洁的描述这个中断的过程:

  • 取得中断类型码N
  • pushf
  • TF = 0,IF = 0
  • push CS
  • push IP
  • (IP) = (N*4)(CS) = (N*4+2)

最后开始执行由程序员编写的中断处理程序

中断处理程序和iret指令

由于CPU随时都可能检测到中断信息,所以CPU随时都可能执行中断处理程序,所以中断处理程序必须一直存储在内存中的某段空间中。而中断处理程序的入口地址,即中断向量,必须存储在中断向量表项中

中断处理程序的编写方法和子程序的比较相似,其步骤如下:

  • 保存用到的寄存器
  • 处理中断
  • 恢复用到的寄存器
  • 用iret指令返回

iret指令的功能用汇编语法描述为:

  • pop IP
  • pop CS
  • popf

iret的出栈顺序和执行中断过程中断的入栈顺序正好相反

除法错误中断的处理

触发触发错误

编程处理0号中断

我们改变一下0号中断处理程序的功能,即重新编写一个0号中断处理程序,他的功能是在屏幕中显示”overflow”,然后返回操作系统

我们分析一下需求:

  1. 引发中断过程
  • 取得中断类型码0
  • 标志寄存器入栈,TF,IF设置为0
  • CSIP入栈
  • (IP) = (0*4) (IP) = (0*4+2)
  1. 中断处理过程(我们将此程序称为do0)
  • 相关处理
  • 向显示缓冲区送字符串”overflow!”
  • 返回DOS
  1. 存放do0到电脑内存空间中

​ 如果存储在其他内存空间中,可能会导致内存内容被覆盖。所以我们将其放在中断向量表中的后面的空余部分,这是因为中断向量表支持256个中断,但是在实际的操作过程中,后面的数据基本不会被使用。所以在中断向量表中,许多单元是空的。我们使用这些程序对我们的中断处理程序进行存放。

  1. 中断处理程序do0的存放

​ 我们将中断处理程序放到0000:0200后,此时0000:0200是我们中断处理程序的入口,我们需要把0号中断向量表的地址设置为该入口的地址

综上我们需要,进行以下的任务:

  • 编写可以显示”overflow!“的中断处理程序
  • 将do0送入内存0000:0200
  • 将do0的入口地址0000:0200存储在中断向量表0项中

程序实现

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
assume cs:code
code segment
start:
;设置es:di指向目标地址
mov ax,0
mov es,ax
mov di,200H
;设置ds:si指向源地址
mov ax,cs
mov ds,ax
mov si,offset do0
;设置cx为源程序长度
mov cx,offset do0end-offset do0;利用编译器计算do0代码字节长度
;设置方向为正
rep movsb

;设置中断向量表
mov ax,0
mov es,ax
mov word ptr es:[0*4],200H
mov word ptr es:[0*4+2],0d

mov ax,4c00H
int 21H

do0:
jmp short do0start
db "overflow!"

do0start:
;显示字符串"overflow!"
;设置ds:si指向字符串
mov ax,cx
mov ds,ax
mov si,142H
;设置es:di指向显存空间的中间位置
mov ax,0b800H
mov es,ax
mov di,12*160+36*2

mov cx,9
s:
mov al,[si]
mov es:[di],al
inc si
add di,2
loop s

mov ax,4c00H
int 21H

do0end:
nop

code ends
end start

首先我们将这段程序进行编译之后成可执行程序,我们运行程序对0号中断处理程序进行修改

然后编写一个有除法溢出错误的程序即可

由于这里的显存内容不断的被刷新,所以会出现看不到警告的问题,但是没关系

单步中断

分析以下单步中断的中断过程

我们知道当CPU检测到TF的值为1时,进行1号中断处理程序,如果此时TF仍然为1,那么在执行中断程序时,会重新进入1号中断处理程序,这样如此往复,会出现各种问题。为了解决这个问题,我们采取以下方法:

  • 取得中断类型码N
  • 标志寄存器入栈,TF=0,IF=0
  • CS,IP入栈
  • (IP)=(N*4),(CS) = (N*4+2)

通过这种方法,我们就可以实现CPU的单步中断功能

响应中断的特殊情况

一般情况下,CPU在执行完当前指令后,如果检测到中断信息,就立即响应中断,引发中断过程。

但是也有特殊情况,在执行完向ss寄存器中传送数据的指令后,即使发生中断,也不会响应。这是因为 ss:sp联合指向栈顶,所以对他们的设置应该联合完成,如果只设置了SS,而没有更新SP,那么此时指向的是一个错误的栈顶。所以CPU在执行设置ss的指令之后不会响应中断,而是向后继续执行一条指令,这样的话为连续设置栈顶提供了一个机会(当然你也可以执行其他指令)。

所以这样就可以解释为什么之前提到的,设置SS之后会继续向后执行一条命令。

今天继续学习汇编语言,感觉有点难哇

标志寄存器

我们前面介绍了13种寄存器分别的作用,现在还剩一种特殊的寄存器,它有以下功能:

  • 用来存储相关指令的某些执行结果
  • 用来为CPU执行相关指令提供行为依据
  • 用来控制CPU的相关工作方式

这种特殊的寄存器被称为 标志寄存器(flag)

它和别的寄存器不同,其他寄存器用来存放数据,具有整个的意义,而flag寄存器是按位起作用的,其每一位都有特定作用

1
2
3
4
  15   14   13   12   11   10   9    8    7    6    5    4    3    2    1    0
+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+
| | | | | OF | DF | IF | TF | SF | ZF | | AF | | PF | | CF |
+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+----+

我们主要学习 CF PF ZF SF OF DF 这几种标志位

常见标志位

ZF标志位

flag的第六位是ZF,零标志位

它记录相关指令执行后数值是否为0,如果是,那么ZF = 1表示肯定;如果不是,那么ZF = 0表示否定

在8086的指令集中,并不是所有的指令都会影响标志位寄存器

以下的指令是由影响的

1
add sub mul div inc or and

以下的没有影响

1
mov push pop

PF标志

flag的第二位是PF,奇偶标志位

它记录相关指令执行后,其结果的所有bit位中1的个数是否为偶数。如果是偶数,PF = 1;如果不是,PF = 0

比如下面的指令执行后,结果为00001011B,其中有3个1,则PF = 0

1
2
mov al,1
add al,10

SF标志位

flag的第七位是SF,符号标志位

它记录相关指令执行后,其结果是否为负。如果是,那么SF = 1;如果不是,SF = 0

这里我们知道,我们进行的计算,既可以看作有符号计算,也可以看作无符号计算;当我们进行无符号计算时,无论如何它对于我们而言都是非负数,但是对于SF而言,它的结果始终是由符号的。

也就是说当我们执行相关命令的时候我们始终是会影响到SF标志位的,至于是否需要这种影响,取决于我们自己

CF标志位

flag的第零位是CF,进位标志位

在进行无符号运算时,它记录了运算结果的最高有效值向更高维的进位值,或从更高位的借位值

当两个数相加时可能向更高位进位CF = 1

1
2
mov al,98H
add al,al

当两个数相减时也有可能向更高位借位

1
2
mov al,97H
sub al,98H ;借位变成197H-98H = FFH

OF标志位

flag的第十一位是OF,溢出标志位

OF用来记载是否发生了溢出,如果发生,OF = 1;如果没有,OF = 0

这里我们需要区分一下进位与溢出:

  • 进位是针对无符号计算,溢出是针对有符号的计算
  • CF用于检测无符号运算溢出
  • OF用于检测有符号运算溢出

更多的指令

adc指令

adc是带进位的加法指令,它利用CF位上的记录的进位制

1
adc 操作对象1,操作对象2

功能:操作对象1 = 操作对象1 + 操作对象2 + CF

我们为什么要加上CF的值呢?我们可以使用其完成低位存在进位的加法,可以分成两步:

  • 低位相加 add al,bl
  • 高位进位相加 adc ah,bh

比如计算1E F000 1000H + 20 1000 1EF0H的值,结果放在ax(最高位),bx(次高位),cx(最低位)

1
2
3
4
5
6
mov ax,001EH
mov bx,F000H
mov cx,1000H
add cx,1EF0H
adc bx,1000H
adc ax,0020H

sbb指令

sbb是带借位的减法指令,它利用了CF位上记录的CF值

1
sbb 操作对象1,操作对象2

功能:操作对象1 = 操作对象1 - 操作对象2 - CF

比如计算 003E 1000H- 0020 2000H的值,结果放在ax,bx

1
2
3
4
mov bx,1000H
mov ax,003EH
sub bx,2000H
sbb bx,0020H

popf和pushf

pushf的功能时将标志寄存器的值压入栈中

popf则是从栈中弹出数据,送入标志寄存器中

比较跳转

cmp指令

cmp指令的操作相当于sub,只不过其结果不被寄存器储存,而是只影响flag中的标志寄存器

我们可以通过cmp ax,bx指令执行后,相关标志位的状态看出比较的结果:

  • 如果(ax) = (bx),则 zf = 1
  • 如果(ax) != (bx),则zf = 0
  • 如果(ax) < (bx),则必将产生借位,cf = 1
  • 如果(ax) >= (bx),则不必借位,cf = 0
  • 如果(ax) > (bx),则不必借位,且结果不为0,cf = 0 and zf = 0
  • 如果(ax) <= (bx),则可能借位,也可能结果为0,cf = 0 or zf = 0

但是在这里我们默认的进行的是无符号计算,但是在实际的比较中我们也会遇到有符号数值的比较

这个时候我们需要结合sf(进位)和of(溢出)的情况进行判断:

  • 当of = 0 时,说明没有溢出,此时 逻辑上真正结果的正负 = 实际结果的正负
  • 当of !=0 时,说明溢出,此时 逻辑上真正结果的正负 != 实际结果的正负

所以我们可以进行有符号整数的判断:

  • 如果 (ax) < (bx),则(sf = 1 and of = 0) or (sf = 0 and of = 1)
  • 如果 (ax) > (bx),则sf = 1 and of = 1
  • 如果 (ax) >= (bx),则sf = 0 and of = 0

检测比较结果的条件转移指令

我们之前使用过jcxz条件跳转指令,但是它是对(cx)进行判断

下面有常用的根据无符号数的比较结果进行转移的条件转移指令:

1
2
3
4
5
6
7
指令			 含义						检测的相关标志位
je 等于则转移(=) zf = 1
jne 不等于则转移(!=) zf = 0
jb 低于则转移(<) cf = 1
jnb 不低于则转移(>=) cf = 0
ja 高于则转移(>) cf = 0 且 zf = 0
jna 不高于则转移(<=) cf = 1 或 zf = 0

通过cmp指令和比较指令还有标志位,可以实现想要的逻辑判断

DF标志位和串传送指令

DF标志位

flag的第十位是DF,方向标志位。在串处理命令中,控制每次操作后si,di的递减

  • df = 0 每次操作后si,di递减
  • df = 1 每次操作后si,di递增

在8086CPU中提供两种方式对df进行修改:

  • cld–> 令df = 0
  • std–> 令df = 1

串传送指令

1
movsb

执行movsb指令相当于进行下面的步骤(将ds:si指向的内存单元中的字节送入es:di中,然后根据df中的值,进行增减)

1
2
3
4
5
6
7
mov es:[di],byte ptr ds:[si]
;如果df=0
inc si
inc di
;如果df=1
dec si
dec di
1
movsw

执行movsw指令相当于进行下面的步骤(将ds:si指向的内存字单元中的字送入es:di中,然后根据df中的值,进行增减)

1
2
3
4
5
6
7
mov es:[di],word ptr ds:[si]
;如果df=0
inc si
inc di
;如果df=1
dec si
dec di
1
rep movsb/movesw

rep指令的含义是根据cx的值,重复执行后面的指令。可以理解为下面的指令:

1
2
s:	movsb/movsw
loop s

当我们使用串传送时,需要为串传送指令提供以下信息:

  • 传送的原始位置
  • 传送的目的位置
  • 传送的长度
  • 传送的方向

DEBUG中的标志寄存器

image.png

图中标识了不同标志寄存器对应的位置

下面我们列出Debug对标志位的表示:

1
2
3
4
5
6
7
标志				值为1的标记				值为0的标记
of OV NV
sf NG PL
zf ZR NZ
pf PE PO
cf CY NC
df DN UP

实验11

image.png
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 "Some day,I'll be enough so you can't hit me.",0
data ends
code segment
begin:
mov si,0
call letterc
mov ax,4c00H
int 21H

letterc:
mov ah,ds:[si]
cmp ah,30H
je return
cmp ah,41H
jnb s1
inc si
jmp letterc

s1:
cmp ah,7AH
jna s2
inc si
jmp letterc

s2:
and ah,11011111B
mov ds:[si],ah
inc si
jmp letterc

return:
ret

code ends
end begin

今天继续学习汇编语言

转移指令的学习

jmp指令

使用jmp指令需要给出两种信息:

  • 转移的目标地址
  • 转移的距离(段间转移,段内转移,段内近转移)

根据位移进行的jmp指令

先对两种jmp进行介绍:

1
jmp short 标号

功能为:(IP)=(IP)+ 八位位移(short指明)

  • 8位指令=标号处的地址-jmp指令后的第一个字节的地址
  • 八位位移的范围是-128~127
  • 位移值是在编译程序的过程中计算出来的
1
jmp near ptr 标号

功能为:(IP)=(IP) + 十六位位移(near ptr)

  • 16位指令=标号处的地址-jmp指令后的第一个字节的地址
  • 十六位位移的范围是-32768~32767
  • 位移值是在编译程序的过程中计算出来的

我们可以通过下面的图片理解位移的计算过程:

image.png

指定转移目的地址的jmp指令

1
jmp far ptr 标号

far ptr指明了指令用标号的段地址和偏移地址修改CS和IP

其机器码表现形式为指定目的地址

转移地址在寄存器中的jmp指令

1
jmp (16位reg)

功能:(IP)= (16位reg)

转移地址在内存中的jmp指令

1
jmp word ptr 内存单元地址(段内转移)

功能:从内存单元地址处开始存放一个字,是转移的目的偏移地址

1
jmp dword ptr 内存单元地址(段间转移)

功能:从内存单元地址处开始存放着两个字,高地址存放的字是转移的目的段地址,低地址是转移的目的偏移地址

  • (CS) = (内存单元地址+2)
  • (IP) = (内存单元地址)

jcxz指令

有条件转移指令,所有的有条件转移指令都是短转移,在对应的机器码中包含转移的位移,而不是目的地址,对IP修改范围为-128~127

1
jcxz 标号

功能:当(cx)= 0 时,转移到标号处执行

  • 8位位移=标号处的地址-jcxz指令后的第一个字节的地址
  • 八位位移的范围是-128~127
  • 位移值是在编译程序的过程中计算出来的

可以理解为

1
if((cx)==0) jmp short 标号;

loop 指令

所有的循环指令都是短转移,在对应的机器码中包含转移的位移,而不是目的地址

我们在之前学习过,可以理解为

1
2
(cx)--;
if((cx)!=0)jmp short 标号;

根据位移进行转移的意义

因为程序段在不同的机器中内存情况并不一样,如果指定内存地址进行跳转会发生错误

但如果根据位移进行索引,便可以准确的找到位置

地址间的相对关系是不会改变的

当然如果位移距离超出范围,会造成编译错误

offset

我们可以通过

1
offset 标号

取到标号的偏移地址

实验九

参考链接:王爽《汇编语言》(第三版)实验9解析 - nojacky - 博客园 (cnblogs.com)

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
assume cs:code
data segment
db '!!!HelloWorld!!!'
db 2,36,113
data ends
stack segment
db 16 dup (0)
stack ends
code segment
start:
;指向data单元
mov ax,data
mov ds,ax
;指向显存区域
mov ax,0B800H
mov es,ax
;设置栈段
mov ax,stack
mov ss,ax
mov sp,16
;初始化
mov bx,780H;这个是第十二行的位置
mov si,16

mov cx,3
s:
mov ah,ds:[si]
push cx
push si

mov cx,16
mov si,64;这个确保居中显示
mov di,0

s0:
mov al,ds:[di]
mov es:[si+bx],al
mov es:[si+bx+1],ah

add si,2
add di,1

loop s0

pop si
pop cx

add si,16
add bx,0A0H
loop s

mov ax,4c00h
int 21h

code ends
end start

效果图

4ad49d42c593958a5834364a12d92ea8.png

CALL和RET指令

ret与retf

ret指令用栈中的数据,修改IP的内容,从而实现近转移 retf指令用战中的数据,修改CS和IP的内容,从而实现远转移

CPU执行ret:

1
2
(IP) = ((ss)*16 + (sp))
(sp) = (sp) + 2 //pop IP

CPU执行retf:

1
2
3
4
(IP) = ((ss)*16 + (sp))
(sp) = (sp) + 2 //pop IP
(CS) = ((ss)*16) + (sp)
(sp) = (sp) + 2 //pop CS

call指令

总结一下就是:

  • 将当前的IP或CS和IP压入栈中
  • 转移

依据位移进行的call指令

1
call 标号;将当前的IP压入栈中,转到标号处执行指令

执行过程如下:

1
2
3
(sp) = (sp) -2
((ss)*16 + (sp)) = (IP)
(IP) = (IP) + 16位位移

位移的计算同上

转移到目的地址在call指令中

1
call far ptr 标号

相当于进行

1
2
3
push CS
push IP
jmp far ptr 标号

转移地址在寄存器中的call指令

1
call (16位reg)

相当于进行

1
2
push IP
jmp (16位reg)

转移地址在内存中的call指令

(1)单字节索引

1
call word ptr 内存单元地址

相当于进行:

1
2
push IP
jmp word ptr 内存单元地址

(2)双字节索引

1
call dword ptr 内存单元地址

相当于进行

1
2
3
push CS 
push IP
jmp dword ptr 内存单元地址

使用

在学会ret和call的用法之后,我们可以使用下面的框架来模拟函数的调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
assume cs:code
code segment
main:
...
call sub1
...
mov ax,4c00h
int 21h
sub1:
...
call sub2
...
ret
sub2:
...
ret
code ends
end main

mul指令

使用mul指令时,我们需要注意以下几点:

  • 两个相乘的数要么都是八位,要么都是16位。如果是八位,则一个放在AL中,另一个在8位的reg或内存字节单元中;如果是16位,则一个在AX中,一个在16位的reg或者内存字单元中
  • 如果是八位乘法,结果放在AX中;如果是十六位乘法,结果高位放在DX中,低位放在AX中
1
mul reg/内存单元

实验十:编写子程序

显示字符串

image.png
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
assume cs:code
data segment
db 'Welcome to masm!',0
data ends

code segment
start:
;指向显存
mov ax,0B800H
mov es,ax

mov dh,8
mov dl,3
mov cl,2
mov ax,data
mov ds,ax
mov si,0
call show_str

mov ax,4c00h
int 21h

show_str:
mov bx,8*160
mov di,3*2
mov ah,cl
mov cx,16
s:
mov al,ds:[si]
mov es:[bx+di],al
mov es:[bx+di+1],ah
inc si
add di,2
loop s
ret

code ends
end start

解决除法溢出问题

tips:

公式 X/N = int(H/N)*65536 + [rem(H/N)*65536+L]/N

X:被除数,范围[0,FFFFFFFF] N:除数,范围[0,FFFF] H:X的高16位,范围[0,FFFF] L:X的低16位,范围[0,FFFF] int():取商 rem():取余

参考链接:汇编语言(王爽第三版)实验10:编写子程序 - 筑基2017 - 博客园 (cnblogs.com)

image.png
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
assume cs:code
stack segment
dw 0,0,0,0,0,0,0,0
stack ends
code segment
start:
mov ax,stack
mov ss,ax
mov sp,16

mov ax,4240H
mov dx,000FH
mov cx,0AH
call divdw
mov ax,4c00h
int 21h

divdw:
push ax
mov ax,dx
mov dx,0
div cx
mov bx,ax

pop ax
div cx
mov cx,dx
mov dx,bx
ret

code ends
end start

数值显示

image.png

暂时写不出来

自从大学以来我一直在思考一个问题

是学技术重要还是卷绩点重要?

从学校的角度出发,因为是一个中下游的211,有一定的保研资格但是不多,所以给人一种有希望的感觉。但是僧多肉少,很多人为了保研无所不用其极,或者说是要舍弃很多的东西。对我而言,可能最让我纠结的便是在卷绩点上面所花费的时间。

从我个人的角度出发,我十分喜欢自己正在学习的东西,非常非常喜欢。我等了很久才学到我想学的东西,我很早开始就想学计算机,但是因为教育观念有一定的落后,所以我的家庭和学校并不支持我的想法。到了大学,我有很多时间去做我想做的事情,我把几乎所有的时间投入在技术的学习里面,难以想象的热爱。可是因此,也为我带来了很多烦恼,对我而言,研究生的学历是十分必要的。因为我并不满足于我现在的学历,而且专业需求的是高学历人才,所以要么保研要么考研。就现在来说,肯定是不考虑考研的,因为有保研的名额,而且考研有一定的难度,所以我还是更加倾向于保研。那么问题来了,我并没有时间去学习保研内容相关的,我的绩点岌岌可危。

就目前而言,最为理性的选择无疑是将重心转移到保研上。但是我实在不愿意做出这样的选择,12年以来的应试教育,为什么到了大学就没有选择的权力。我可以做出我想要的选择,可是环境并不会给我想要的结果,这是我内心一直以来的纠结与矛盾。我尝试过两手抓同步走,但是到了最后我才发现自己也是一个很普通的学生。这种心比天高的做法并不适合我。在大学生活的一个学期里,我已经慢慢感受到 了,很多身不由己的瞬间。不能太过于任性,要理性的去判断。

其实当我开始写这段话的时候我的心里已经有了答案,也许我确实应该花更多的时间在学习上面。这是对现实的妥协吧,但是归根到底这也是一种能力不足的体现,也许我更应该锻炼自己。真正优秀的人不会被这些问题困扰,我的心思过于分散,这就是如今局面的原因。反思过后,便是行动。也许人生就是在一次次的妥协里成长。我的高考成绩便是任性的后果,很多事情不想再经历第二遍,不能”只做自己想做”的事情,也许我也应该去做好一些不得不做的事情。

也许以后,慢慢成长,能够放下杂念,也有机会让学习成绩与技术能力并驾齐驱,但是对于现在的我而言却言之过早。不知道向谁倾诉我的烦恼,记录于此,警醒自己。

DEBUG的用法

在 DOSBox 中使用 DEBUG 工具时,可以使用以下命令及其用法,这些命令主要用于调试汇编语言程序、查看和修改寄存器与内存内容、执行代码等。以下是 DEBUG 的所有常用命令及其详细用法:

1. 进入和退出 DEBUG

  • 进入 DEBUG:在 DOSBox 中输入 debug 命令。
  • 退出 DEBUG:使用 q 命令退出 DEBUG 并返回到 DOSBox 命令行。

2. 寄存器操作

  • 查看所有寄存器:输入 r,查看所有寄存器的当前值。
  • 查看和修改单个寄存器:输入 r <寄存器名>,例如 r ax,查看 AX 寄存器的值。输入新值后按回车即可修改。

3. 内存操作

  • 查看内存内容
    • d [起始地址]:从指定地址开始查看内存内容。
    • d [起始地址] [结束地址]:查看指定范围内的内存内容。
  • 修改内存内容
    • e [内存地址]:修改指定地址的内存内容。
    • e [内存地址] '文本':直接输入文本内容。
  • 填充内存内容
    • f [起始地址] [结束地址] [值1] [值2]...:用指定值填充内存区域。

4. 汇编指令操作

  • 输入汇编指令
    • a [地址]:从指定地址开始输入汇编指令。输入完成后按回车退出。
  • 反汇编指令
    • u [地址]:从指定地址开始反汇编指令。
    • u [段地址:偏移地址]:指定段地址和偏移地址进行反汇编。

5. 程序执行

  • 单步执行
    • t:执行当前指令并进入下一步。
    • t [地址]:从指定地址开始单步执行。
  • 连续执行
    • g:从当前地址开始执行程序。
    • g=[地址]:从指定地址开始执行程序,并设置断点。
  • 运行程序至结束:使用 p 命令。

6. 其他功能

  • 计算偏移量
    • h value1 value2:计算两个十六进制值的和。
  • 保存程序到文件
    • p [文件名] [地址]:将内存中的程序保存到文件。

汇编程序中的注意点

关于下面四个指令,在汇编程序中有不同的含义:

1
2
3
4
mov al,[0]
mov al,ds:[0]
mov al,[bx]
mov al,ds:[bx]
  1. (al)= 0

  2. (al)= ((ds)*16 + 0)

  3. (al)= ((ds)*16 + bx)

  4. (al)= ((ds)*16 + bx)

所以总结得到:

  • 如果在”[ ]“里用一个常量idata直接给出内存单元的偏移地址,就要在”[ ]“前面显式的给出段地址所在的寄存器,否则会被解释为常量
  • 如果在”[ ]“里面使用寄存器,则默认段地址为ds,可以不用显式的表现

汇编程序的入口

当我们在代码段设置数据时会遇到一个问题,就是程序的入口被设置在代码段

但是这会导致程序无法执行,因为你定义的字节被反编译后可能是未被定义的或者意义不明的指令

所以我们在运行程序时需要手动调节IP值,那么有什么更好的办法呢?

我们可以在汇编代码时事先定义程序的入口:

1
2
3
4
5
6
7
8
9
10
11
assume cs:code
code segment
...
;数据
...
start:
...
;代码
...
code ends
end start

通过这种格式我们可以在start处定义函数的入口,程序将从此开始运行

那么换个角度思考,我们既然可以指定程序的入口,那么我们就可以对程序进行分段

我们将数据,代码,栈分别放入不同的段中

比如下面这个程序

1
2
3
4
5
6
7
8
9
10
11
assume cs:code,ds:data,ss:stack
data segment
;字型数据
data ends
stack segment
;定于栈空间
stack ends
code segment
start:;代码
code ends
end start

注意要分清什么是伪指令,什么是汇编指令。CPU如何处理我们定义的段空间,完全是靠程序中具体的汇编指令,这里的伪指令只是将其进行了抽象,这样的分段定义,实际上是基于段寄存器的使用。并非我们想象的直接对内存进行分段

汇编程序转换大小写

首先我们要知道ASCII字符中大小写字母之间有什么样的联系

  • 大写字母的十六进制数值比小写字母的十六进制数值小 20H
  • 大写字母和小写字母的区别在于小写字母的第五位是1(因为位数从0开始计算)

循环遍历

所以我们可以写出以下程序:

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
assume cs:code,ds:data
data segment
db 'BaSiC'
db 'iNfOrMaTiOn'
data ends

code segment
start:mov ax,data
mov ds,ax
mov bx,0
mov cx,5

s:mov al,[bx]
and al,11011111B ;小写
mov [bx],al
inc bx
loop s

mov bx,5
mov cx,11

s0:mov al,[bx]
or al,00100000B ;大写
mov [bx],al
inc bx
loop s0

mov ax,4c00h
int 21h
code ends
end start

可以看到我们使用了两个循环,分别将其转换为大小写

数组处理

我们可以用’[bx+idata]’的形式来模拟数组的行为

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
assume cs:code,ds:data
data segment
db 'BaSiC'
db 'MinIX'
data ends
code segment
start:mov ax,data
mov ds,ax
mov bx,0

mov cx,5

s: mov al,0[bx]
and al,11011111B
mov 0[bx],al
mov al,5[bx]
or al,00100000B
mov 5[bx],al
inc bx
loop s

mov ax,4c00h
int 21h
code ends
end start

这里面的 0[bx]5[bx]可以分别理解为C语言中的 a[]b[],只不过这里体现的是偏移地址

在这里我们有几种等价的表达方式:

1
[bx + idata] = idata[bx] = [bx].idata

我们可以用数组的思想去理解

汇编内存寻址的进一步理解

我们依次进行深入:

DS,SS,CS

作为一个段地址,往往用来划分一定的内存区域存放特定的数据

我们可以理解成C语言中,申请了一段空间(空间)

[BX]寄存器间接寻址

通过修改寄存器BX中的值,我们可以进一步索引到段中的某一部分内存的起点

我们可以理解成在这一片空间中划分了一部分作为数组(一维数组)

[BX + idata]寄存器相对寻址

我们以BX确定在段空间的位置后,我们可以用常量去查询指定内存的数值

此时我们可以把常量idata理解成数组的下标(二维数组)

我们可以用以下形式表达:

1
[bx + idata] = idata[bx] = [bx].idata

[BX + SI/DI + idata]相对基址变址寻址

我们先用BX确定一部分空间,再用SI/DI中的地址确定在这段空间中的位置,然后用常量去查询指定内存中的数值

此时我们可以把SI/DI中的数值理解成二维数组的首地址,而常量作为数组下标进行索引(三维数组)

当没有常量时我们这样表达:(基址变址寻址)

1
[bx + si/di] = [bx][si/di]

有常量时我们这样表达:

1
[bx + si/di + idata] = idata[bx][si/di] = [bx].idata[si/di] = [bx][si/di].idata

SI/DI

这两个寄存器是8086CPU中与bx功能相近的寄存器,这两个寄存器不能被分成两个八位的寄存器来使用

通过这些各种各样的表达方式,我们可以根据自己的需求进行各种各样的寻址

新的汇编指令

div指令

使用这个指令时我们需要注意以下几点:

  • 除数:有8位和16位两种,在一个reg或者内存单元中
  • 被除数:默认放在AX或DX和AX中,被除数的位数是除数的两倍,如果被除数是32位,那么DX存放高十六位,AX存放低十六位
  • 结果:如果除数为8位,则AL存储除数操作的商,AH存储除数操作的余数;如果为16位,那么AX存储商,DX存储余数

伪指令 dd

db,dw,dd分别代表三种不同的定义类型

1
2
3
db			;定义字节(byte)类型
dw ;定义字(word)类型
dd ;定义double类型

dup

用来重复定义同一类型的数据

1
2
3
db 重复的次数 dup (重复的字节类型)
dw 重复的次数 dup (重复的字类型)
dd 重复的次数 dup (重复的双字类型)

比如定义200个字类型

1
dw 200 dup (0)

实验七

答案如下:

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
assume cs:code
data segment
db '1975','1976','1977','1978','1979','1980','1981','1982','1983'
db '1984','1985','1986','1987','1988','1989','1990','1991','1992'
db '1993','1994','1995'
;0-84 0-54H

dd 16,22,382,1356,2390,8000,16000,24486,50065,97479,140417,197514
dd 345980,590827,803530,1183000,1843000,2759000,3753000,4649000,5937000
;85-168 55H-A9H

dw 3,7,9,13,28,38,130,220,476,778,1001,1442,2258,2793,4037,5635,8226
dw 11542,14430,15257,17800
;169-210 AAH-D4H

data ends
table segment
db 21 dup ('year summ ne ?? ')
table ends
code segment
start: mov ax,data
mov ds,ax
mov ax,table
mov es,ax

mov bp,0
mov si,0
mov di,0
mov cx,21

s: mov ax,ds:[si]
mov es:[di],ax
mov ax,ds:[si+2]
mov es:[di+2],ax

mov ax,ds:[84+si]
mov es:[di+5],ax
mov ax,ds:[84+si+2]
mov es:[di+5+2],ax

mov ax,ds:[168+bp]
mov es:[di+10],ax

mov ax,ds:[84+si]
mov dx,ds:[84+si+2]
div word ptr ds:[168+bp]
mov es:[di+13],ax

add si,4
add di,16
add bp,2

loop s

mov ax,4c00h
int 21h

code ends
end start



为了进一步的使用对内存单元进行灵活的操作,所以使用[BX]和loop指令对其进行操作

[BX]和loop指令

在开始学习之前,需要对一些符号进行讲解

  1. [bx]和内存单元的描述

    要完整的描述一个内存单元,需要两种信息:

    • 内存单元的地址
    • 内存单元的长度(类型)

    这里[bx]表示一个内存单元,段地址默认为ds,偏移地址存储在bx中

  2. loop

    loop在英文中有循环的含义,所以这个指令肯定和循环有关,我们在后面进行详细的说明

  3. 我们定义的描述性的符号:“( )”

    为了描述上的简洁我们用一个描述性的符号”()“来表示一个寄存器或者一个内存单元中的值

    现在我们可以把[bx]的物理地址表示为((ds)*16+(bx))

    “(X)”所表示的数据有两种类型:1)字节;2)字 数据类型由寄存器名称或者具体的运算决定

  4. 约定符号idata表示常量

    之前我们说在”[…]“里用一个常量0表示内存单元的偏移地址,现在我们可以把所有常量都作为[idata]

[BX]

指令功能:

1
mov ax,[bx]

bx中存放的数据作为一个偏移地址EA,段地址SA默认在ds中,将SA:EA处的数据送入ax中,即(ax) = ((ds)*16+(bx))

1
mov [bx],ax

bx中存放的数据作为一个偏移地址EA,段地址SA默认在ds中,将ax处的数据送入SA:EA处,即((ds)*16+(bx)) = (ax)

Loop指令

loop指令的格式是:loop 标号,CPU执行loop指令的时候,需要进行两步操作:

  • (cx) = (cx) - 1
  • 判断cx中的值,不为0则转至标号处执行程序,如果为0就向下执行

在此我们可以看出cx的值影响了loop指令的执行结果。通常我们用loop来实现循环功能,cx中存放循环次数

这里我们通过一个程序引出关于loop的使用:

1
2
3
4
5
6
7
8
9
10
11
12
assume cs:code
code segment
mov ax,2

mov cx,11
s: add ax,ax
loop s

mov ax,4c00h
int 21h
code ends
end

我们注意到这里使用了标号”s” ,在汇编语言中标号代表一个地址,它实际上标识了一个地址

我突然发现 这样一点一点敲很慢,所以我打算先学完再总结下来

反正这也不是教程 哈哈