suxxsfe

一言(ヒトコト)

汇编学习笔记-2

3.第一个程序

3.1一个源程序如何从写出到执行

  • 程序员用编辑器写出汇编代码,称之为源程序
  • 对源程序进行编译,行成目标文件
  • 对目标文件链接,行成可执行文件,而可执行文件包含这两种信息:程序(从源程序翻译来的机器码)和数据(源程序中定义的数据);相关描述信息(比如程序有多大,以及要占多少内存等)
  • 执行可执行文件

后面来一一讲解源程序、编译、链接等

3.2源程序

之前说过,汇编代码由汇编指令、伪指令、其它符号组成,不过其它符号在这里暂且用不到
看下面这段代码(这里,包括下面所以代码都是Intel汇编语法):

assume cs:codesg
codesg segment
	mov ax,2
	add ax,ax
	add ax,ax
	
	mov ax,4c00H
	int 21H
codesg ends
end

中间部分,从 mov ax,2int 21H 都是汇编指令,用分号表示注释开始,这前面已经说过很多了,下面主要说伪指令

  • codesg segment ..... codesg endssegmentends 是成对使用的伪指令,它们的作用是定义一个段前者说明段的开始,后者说明段的结束。它们在汇编语言编写程序中是必不可少的
    在这里,定义的是一个代码段。codesg 是这个段的段名,当然可以用其它字符。
    一个有意义的汇编代码至少有一个段,这个段用来存放代码
  • end:表示汇编代码的结束,编译器在编译时遇见这个指令,就停止编译。如果不加它,编译器不知道代码在哪结束
  • assume:中文是“假设”,它假设(也是说明了)某个寄存器和某个定义的段相关联(更具体的作用在后面)。
    比如例子中,用 cs:codesg 说明了段寄存器和 codesg 的联系,用 cs 指向它的段地址,说明了这一个段是代码段,用来存放代码(cpu也会根据 cs 的指向去执行其中的指令)

程序:源程序中最终由计算机执行、处理的指令、数据
程序最先以汇编指令的形式存在源程序中,通过编译、链接,转变为机器码,再加上描述信息,一起存在可执行文件中

标号

一个标号代表一个地址
比如上面例子中的 codesg,它放在 segment 前面,作为一个段的名称,这个段被编译、链接程序,处理为一个段的段地址

程序的结构

还是根据上面那个例子来说
首先,我们要写汇编指令,就要定义一个代码段,现在把这个段命名为 codesg

codesg segment
.....
codesg ends  

然后,编些汇编指令,也就是填充上面的省略号的位置

codesg segment
	mov ax,2
	add ax,ax
	add ax,ax
	
	mov ax,4c00H
	int 21H
codesg ends

然后,要用伪指令 end 为编译器指出程序在哪结束

codesg segment
	mov ax,2
	add ax,ax
	add ax,ax
	
	mov ax,4c00H
	int 21H
codesg ends
end

最后,我们要把 codesg 当作代码段使用,就要用 assume 把它和 cs 联系起来

assume cs:codesg
codesg segment
	mov ax,2
	add ax,ax
	add ax,ax
	
	mov ax,4c00H
	int 21H
codesg ends
end

就完成了

程序的返回

这个和程序如何被加载进内存来执行有关,下面在单任务操作系统 dos 的基础下说明
一个程序 P2 在可执行文件中,必须有一个正在执行的程序 P1,把 P2 从可执行文件中加载进内存中,cpu 的控制权交给了 P2,P2 开始运行,此时 P1 暂停
P2 运行结束,cpu 的控制权交还给 P1,P1 继续运行。
比如我在 cmd 窗口中运行一个程序,那么 cmd 把这个程序载入内存,它开始运行。当它运行结束,就又把 cpu 控制权交还给 cmd,cmd 继续运行
那么这个交还的过程,叫程序返回

mov ax,4c00H
int 21H

程序最后的这两句话,就是实现了这个过程
至于这两句话的原理,等后面再说

3.3编译、链接

需要:masm.exe,link.exe,ml.exe

前两个分别用于编译、链接,大概这样 masm 1.asm 来编译文件 1.asm,后面那个 ml.exe 是一个指令同时包含了编译、链接
如果这样调用程序,会在编译或链接时给出好几个选项,不过在现在来说并没什么用,可以用 masm 1.asm; 来直接将所有选项选默认
编译时如果发现程序编写有语法错误会输出错误信息

编译和链接的作用
编译的作用:把我们编些的汇编指令、伪指令等,转化为机器码
链接的作用:

  • 当源程序很大时,可以把它分为多个源程序文件来编辑、编译,编译成多个目标文件,然后再用链接程序将它们链接在一起,行成一个可执行文件
  • 程序调用了某个库的子程序,需要将这个库文件和该程序的目标文件链接在一起,行成一个可执行文件
  • 编译后,目标文件中存有机器码,但这其中的一些信息还不能直接用来生成可执行文件,链接程序将这些内容处理位最终的可执行信息(这就是比较复杂了)
    所以说,就算一个源程序没有分成多个源程序文件,也没有调用库,也必须经过链接

3.4使用debug来跟踪、调试程序

命令:debug 1.exe 来进入

主要的命令和之前说的一样,但在执行到最后一句指令(就是那个 int 21H)时,要用 p 命令而不是 t
如果用 p,会显示:program terminated normally
否则,如果继续用 t 的话,ip 会跳转到其它地方去,如果那样继续执行的话,可能会读取到并非代码的数据来执行,就导致了卡住死机(可以去试一下,一般没啥问题,大不了关了重进)

但是在刚进入程序时,如果查看一下寄存器的值,发现即使没有定义数据段,ds 和 cs 的值也不一样,会相差 10H,由于它们是段寄存器,所以实际物理地址就差了 100H,也就是 256 个字节
原因如下图,至于这个 PSP,它主要是被 dos 用来与这个加载进来的内存进行通讯,长度 256字节,但具体是啥不重要:

4 [bx] 与 loop

一些约定:

  • 用 () 来表示某个寄存器内的值,比如 \((ax)\) 表示的是 ax 里的值
  • idata 表示常量,或者之前说过的立即数,比如 mov ax,[idata] 就代表了 mov ax,[0]mov ax,[1]

4.1用 [bx] 来描述内存单元以及引出的一些问题

我们之前有过 mov ax,[0] 之类的代码,意思是 \((ax)=((ds)\cdot *16+0)\),当然指的是字型数据
如果直接在 debug 中往内存填入代码来执行,是没问题的
但如果把这种指令写在文件里,然后编译链接,再进 debug 单步调试,会发现实际执行的代码是 mov ax,0
也就是,编译器把 [0] 直接处理为了 0

那么,当我们使用 masm 来编译代码时,就需要用到 [bx] 来描述内存单元了,应该使用指令 mov ax,[bx],意思是 \((ax)=((ds)\cdot 16+(bx))\)
注意这个 [bx] 就只能是 bx,并不是代表了寄存器,用 ax,cx,dx 等,或段寄存器,会在编译时报错
比如上面那个 mov ax,[0],就应该写成:

mov bx,0
mov ax,[bx]

debug 和汇编编译器 masm 对指令的不同处理,以及显式的给出段地址的方法

这时就不得不提出这个问题了

比如之前应该提到过,对于数字,在 debug 中式默认十六进制的,而 masm 编译器是默认进制,所以 masm 写十六进制数是要加上 H,而 debug 中不能

这只是其中之一,还有就是,上面说了,如果你用 [idata] 来访问一个内存单元,会直接被编译为 idata,忽略了那个中括号
如果想访问,就要 [bx],但这样还要将 idata 先送入 bx,显然有些麻烦
所以还有一种方法,就是 ds:[idata],来显式的给出了段地址(不显式的给出就是默认 ds),也就是 ds
当然,这个 ds 也可以是 ss,es 等,只要是段寄存器就行,但不能是通用寄存器或立即数

如果不是 [idata],而是 [bx],当然也可以显式给出段地址,如 ds:[bx]

4.2 loop指令

loop 通常用来实现循环,和 cx 配合
格式是:loop 标号,执行分为两步:

  • \((cx)=(cx)-1\)
  • 判断 \((cx)\) 是否为 \(0\),如果不是,那就跳转到标号继续执行,如果是,继续向下执行

通过 debug 来查看 loop 跳转的原理

我们编写下面这一段程序(实际上,就是我们后面要将的一个例子的程序,先不用管他要干啥):

assume cs:codesg
codesg segment
start:
	mov ax,0ffffH;在汇编源程序中,数组不能以字母开头,所以开头添加一个 0
	mov ds,ax
	mov bx,0
	mov ax,0
	mov dx,0
	mov cx,12
S:
	mov al,[bx]
	add dx,ax
	inc bx
	loop S
	
	mov ax,4c00H
	int 21H
codesg ends
end start

然后进入 debug,用 u 查看 cs:ip 指向内存的代码,如图:

看画红箭头的地方,原本的 loop S 变成了 loop 0011,然后再看 0011 这个内存单元,发现它存的代码是 mov al,[bx]
这,也正是我们跳转后要执行的第一条指令(或者说要跳转到的地方),那么也就可以知道,loop 指令就是通过把 ip 改为相应的值(这里是 0011H),来实现跳转
loop 执行后,cs:ip 就指向了 076a:0011,也就从跳转到的地方继续执行了
不过要是再看一下机器码的话,机器码中也没有把这个 \(11\) 体现出来。其实机器码中的是转移的距离(补码形式,就是那个 F9,另外 E2 是 loop 的机器码),后面会详细说

4.3段前缀

我们在 “debug 和汇编编译器 masm 对指令的不同处理” 那里讲过,访问内存时,可以显式的给出内存单元的段地址
就像 mov ax,ds:[idata]mov ax,es:[bx]
这里的 ds,es 这些段寄存器,就是段前缀

4.4一段安全的空间

在8086模式下,随意向一段内存写入数据时危险的,因为那段内存可能存放着重要的系统数据或代码
如果你尝试写入这些内存,dosbox模式下应该是会卡住不动,8086下就是弹出报错窗口

操作系统管理计算机的所有资源,当然也就包括内存,所以我们在编程时,要使用操作系统分配给我们的空间,而不是随意指定内存空间
但是,我们学习汇编语言就是要深入底层,理解计算机工作的原理,尽量面向硬件编程,不理会操作系统,所以:

我们似乎面临一种选择,是在操作系统中安全、规矩的编程,还是自由、直接的用汇编语言去操作真实的硬件,去了解那些早已被层层系统软件掩盖的真相?在大部分情况下,我们选择后者,除非我们在学习操作系统本身的内容

在纯 dos 下,可以不去理会 dos,因为 dos(运行在 cpu 实模式下)并没有能力对硬件全面、严格的管理
而在 win,unix 中,它们运行在 cpu 保护模式下,不理会操作系统是不可能的,因为硬件已经被cpu提供的保护模式全面且严格的管理了

所以在后面的学习中,我们既想直接对硬件操作,又不想被操作系统所干涉,所以需要一段安全的空间
一般来说,0:200-0:2ff 这 \(256\) 个字节是安全的。也可以用debug查看一下这段空间,如果都是 \(0\) 的话,说明它们没有被使用

4.5一点实例

虽然比较简单,但还是写一些比较好,可以编译完以后进debug跟踪

计算 \(ffff:0-ffff:b\)\(12\) 个单元中的字节型数据和,结果存在 dx 中

分析:

  • 由于每个字节型数据是 \(8\) 位,最大 \(FFH\),那么 \(12\) 个最大就是 \(BF4H\),没有超过 dx 的存储上限
  • 利用循环,循环 12 次,所以在开始要 \((cx)=12\)
  • 利用 bx 访问内存,开始 \((bx)=0\),然后循环中每次访问过后 \((bx)=(bx)+1\)
  • 由于内存单元是字节型,dx 是字型,不能直接相加,需要用一个 8 位寄存器中转。这里用 al,先把内存单元的数送入 al,在把 \((dx)=(dx)+(ax)\),这样做之前要确认 \((ah)\) 是 0
assume cs:codesg
codesg segment
start:
	mov ax,0ffffH
	mov ds,ax
	mov bx,0
	mov ax,0
	mov dx,0
	mov cx,12
S:
	mov al,[bx]
	add dx,ax
	inc bx
	loop S
	
	mov ax,4c00H
	int 21H
codesg ends
end start

还有一个:将内存单元 ffff:0-ffff:b 中的数据复制到 0:200-0:20b 中

显然 0:200-0:20b 等价于 20:0-20:b,这样转换是为了让两个内存区间的偏移地址一样
这样,就可以用 [bx] 代表它们的偏移地址,然后用 loop 来实现了
但段地址不同,当然可以每次分别把 ds 赋为 ffff 和 20 来进行操作,但不如使用段前缀的知识,\((ds)=ffff,(es)=20\),这样,就通过这两个段前缀的表示来进行赋值了
第二种实现方法比第一种执行的命令条数更少,就让程序更加优化了

注意 mov 内存单元,内存单元 这种指令并不合法,所以需要一个 al 来中转一下

assume cs:codesg
codesg segment
start:
	mov ax,0ffffH
	mov ds,ax
	mov ax,0020H
	mov es,ax
	mov cx,12
S:
	mov al,ds:[bx]
	mov es:[bx],al
	inc bx
	loop S
	
	mov ax,4c00H
	int 21H
codesg ends
end start

5 包含多个段的程序

之前说过一段 \(256\) 个字节的安全空间,但如果我们程序需要的内存超过 \(256\) 字节,就需要向操作系统申请
向操作系统申请的空间都是合法、安全的,有两种方法:一是加载程序时让操作系统为程序分配内存,二是程序执行时申请。这里,只讨论第一种

5.1在代码段中使用数据和栈

如果需要用到数据,但是不分成多个段来声明,可以将数据放到代码段里
考虑这样一个问题,将给定的八个数,将它们倒序存放
要先把数据定义出来,因为是倒序,所以需要一个栈来中转,先把数据都入栈,然后出栈就是倒序了,看代码,应该出了一开始定义数据什么的其它不难理解:

assume cs:codesg
codesg segment
	dw 0123H,0456H,0789H,0abcH,0defH,0fedH,0cbaH,0987H
	dw 0,0,0,0,0,0,0,0
	
start:
	mov ax,cs
	mov ss,ax
	mov sp,32;数据和栈分别占用16字节,所以 ss:sp 指向 cs:32
	mov bx,0
	mov cx,8
S1:
	push cs:[bx]
	add bx,2
	loop S1
	
	mov bx,0
	mov cx,8
S2:
	pop cs:[bx]
	add bx,2
	loop S2
	
	mov ax,4c00H
	int 21H
codesg ends
end start

先解释一下,dw 就是 define word,定义字型数据,相应的,就也有 db,define byte,定义字节型数据
第一行定义的 8 个字型数据,就是要倒序存放数据,而第二行的 8 个 0,就是用来当栈空间使用

还有一些定义数据的方法,比如用 ? 可以只是开辟一个空间,而不指定初始值,比如 db ?,?,?,? 就是定义了四个字节型数据,初始值任意
还有 DUP 的用法,就是 num DUP(XXX),num 是重复次数,XXX 是一个表达式,比如 5 DUP(1,2,3) 就是定义五个连续的 1,2,3
定义字符串就直接用单引号或双引号括起来就行了,每个字符一字节,和定义数差不多

那么我们定义出来的这些栈空间或数据,地址在哪?或者说应该如何访问?
因为它们定义在代码段中,所以可以进debug查看一下代码段中内存的数据

发现它们定义在代码中,codesg 的一开头,所以自然也就在内存里代码段的开头 32 个字节
那栈段的段地址也就可以是代码段的段地址,而栈顶应该指向栈空间中最高地址加一,所以需要:

mov ax,cs
mov ss,ax
mov sp,32;数据和栈分别占用16字节,所以 ss:sp 指向 cs:32

而代码段中一开头不是代码,是数据和栈空间,也就不能让 cpu 从程序开头开始执行指令,这个时候就体现出这个 start 标号的作用了
因为后面有一句 end start,这个 end 伪指令的作用不仅是告诉编译器编译的结束,还有告诉编译器程序的入口在哪
我们 start 标号后面第一个指令是 mov ax,cs,那当编译器通过 end 知道了程序的入口在 start 标号处时,就把它当作程序第一条指令,并把相应的信息(转化为一个入口地址)写入可执行文件的描述信息里,这样程序被载入内存后,cpu 通过描述信息,将 cs:ip 指向相应的值
比如在这里,入口地址应该是 \(20H\),ip 就会被 cpu 设为 \(20H\)

所以说,我们想让 cpu 从代码段中的某一个位置开始执行指令时,就使用 end 标号,用这个标号来指出程序的入口
如果没有这个标号,cpu就会从程序开头开始执行,如果那里有数据,就把数据当成了机器码来执行,就发生了错误

就像这样,如果去掉标号来进debug跟踪,会发现程序载入后,cs:ip 指向的代码并不是我们想要的,但再看它对应的内存数据,却是我们定义的数据和栈空间

5.2使用多个段

发现像之前那样把代码、数据、栈都放到一个段里,会造成程序比较混乱。而且,如果程序较大,一个段也不够用(最大 \(64KB\)
那么就需要定义多个段了,这里给出实现上面那个问题,并通过定义多个段来实现的代码

assume cs:code,ds:data,ss:stack

data segment
	dw 0123H,0456H,0789H,0abcH,0defH,0fedH,0cbaH,0987H
data ends
stack segment stack
	dw 0,0,0,0,0,0,0,0
stack ends
code segment
start:
	mov ax,stack
	mov ss,ax
	mov sp,16;栈占用16字节,所以 ss:sp 指向 ss:16
	mov ax,data
	mov ds,ax;
	
	mov bx,0
	mov cx,8
S1:
	push [bx]
	add bx,2
	loop S1
	
	mov bx,0
	mov cx,8
S2:
	pop [bx]
	add bx,2
	loop S2
	
	mov ax,4c00H
	int 21H
code ends
end start

如何定义段:观察一下这段代码,发现定义其它段的方式和定义代码段相似,都是先把对应的段寄存器 assume 到相应段名上,然后用 XXX segmentXXX ends 就行了
至于这句 stack segment stack,前一个 stack 是栈段的名字,然后再后面加上一个 stack,也就是后买那个,是告诉编译器这是一个栈段
因为编译器不会因为你 assume ss:stack 就认为你定义了栈段,必须这样声明,如果不这样,在链接时会报 warning:no stack segment


后来加入的补充
其实不定义栈段,系统也会为你分配一个比较小的栈空间,当然在我们现在写的栈段中也是够用的,就是你不用定义栈也可以使用 push pop
但如果你定义了栈段,因为我们跟踪程序在 debug 中进行,我们的程序和 debug 就公用了一个栈,因此,如果你看的仔细,会发现栈顶指针指向的那段内存会被修改为一些奇怪数据,那应该就是 debug 用的(应该是这样),因此当定义的栈空间过小时,你往栈里放的数据可能被 debug 修改,发生错误
参考博客:https://www.cnblogs.com/pluse/p/10198677.html#4647784
https://blog.csdn.net/sinat_42483341/article/details/88665331

还有一点,如果你把栈空间定义到了代码段里,如果 debug 访问时发生了越界(定义的太小),会修改掉其它代码导致错误
可能和第三章中一个实验也有些关系(不过迷惑的是我在本机上做并没有出现书中说的情况):

当然以上仅为发现问题后结合其它博客一点推测,如果以后发现了问题会来修改


如何获取段的段地址,并访问段中数据:段名,其实是相当于一个标号,而标号在编译后会变成一个地址(之前说 loop 跳转的原理时说过),那么 mov ax,data 就相当于把 data 标号的地址(其实就是 data 段的段地址),送入了 ax 中
又因为编译后变成了地址,也就是一个立即数,所以也就不能写 mov ds,data 这种指令

代码、数据、栈段是我们“安排”的:我们安排 “code”,“data”,“stack” 这三个段分别来存放代码,数据,栈,那如何让 cpu 知道这种安排?

首先要知道,“code”,“data”,“stack” 只是这三个段的“名称”,也就是一个标号
cpu和编译器都不懂这些名称的含义,所以不会因为你这样命名,就去遵循你的这种安排,把这些段命名成 hahaha,xixixi 这种名字也都是一样的

assume cs:code,ds:data,ss:stack,这句伪指令,将三个寄存器和三个段相联系。但这是在编译阶段执行的,将定义的段和相应的寄存器联系起来,但是cpu并不会因此就将相关段寄存器指向相应段的段地址
assume 具体的作用:大概就是和逻辑地址相关的吧,但逻辑地址还不怎么了解;同时,如果你在数据或栈段中定义了带有长度的数据标号(数据、栈段只能定义这种标号,不能定义一般的标号,至于这种数据标号是啥在后面会说),想在代码段中访问,就需要 assume 了
https://www.zhihu.com/question/411008597/answer/1372976533

那么,就需要我们在代码中手动用这些段的标号,来送入相关寄存器,毕竟内存中的内容是当作数据还是指令,完全是根据汇编指令,和什么寄存器里的值指向它

mov ax,stack
mov ss,ax
mov sp,16;这里和之前不同,栈自己在一个段里,占 16 字节,所以它的栈顶指针应该是 16

另外,各种段在内存中的顺序,其实和代码中定义的顺序是一样的,在下面的实例中也会看到这点

5.3实例

将 a,b 两个段中的数据相加,存在 c 段相应位置

分析:我们需要三个段寄存器来指向三个段,这里用的是 ds,ss,es,其实栈段的寄存器拿来存不是栈空间的段地址也当然是可以的
然后 bx 当偏移地址,loop 循环就行了
其实因为刚才说内存中段的顺序和代码中相同,所以用一个段寄存器,然后通过不同偏移地址也是可以的,不过比较麻烦

assume cs:code;,ds:a,ss:b,es:c

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

code segment
start:
	mov ax,a
	mov ds,ax
	mov ax,b
	mov ss,ax
	mov ax,c
	mov es,ax
	mov cx,8
	mov bx,0
	
S:
	mov al,[bx]
	add al,ss:[bx]
	mov es:[bx],al;不能直接从两个内存单元间 mov
	inc bx
	loop S
	
	mov ax,4c00H
	int 21H
code ends
end start

程序在debug中运行结束后,查看内存中的值:

前三行分别是 a,b,c 段中的内容,说明刚才说的顺序是对的
然后发现,我们这里定义的 8 个字节型数据,占用 8 字节,而一个段的起始地址必须是 16 的倍数,所以下一个段必须必须在上一个段向后16个字节,才会再有一个16倍数的起始地址可用,所以这也就使得一个段最小长度是 16 字节
而我们只用了8字节,剩下的8字节就自然都是 0 了

当然,通过查看寄存器中的值,也可以知道 ds,ss,es,cs 分别相差了 1,也就是这四个段的实际物理地址相差 16 字节

6 更灵活的定位内存地址以及数据处理相关

6.1 and 与 or 指令

and 是按位与,or 是按位或
与之前的 mov 相类似,and ax,5 是将 (ax) 和 5 按位与,结果存入 ax 中
第一个操作符,可以是通用寄存器或内存单元,第二个可以是立即数,通用寄存器,内存单元
特别的,不能使用 and 内存单元,立即数and 内存单元,内存单元
or 也是类似

6.2 [bx+idata]

之前有过用 [bx] 进行寻址的方式,比如 mov ax,[bx] 就是 \((ax)=((ds)\cdot 16+(bx))\)
另一种方式,可以使用 [bx+idata],例如 mov ax,[bx+2] 就是 \((ax)=((ds)\cdot 16+(bx)+2)\)mov ax,2[bx]mov ax,[bx].2mov ax,[bx][2] 这几种语法也可以达到相同的效果

那这样寻址有什么用?可以用这种方式进行数组的处理
比如有数组,从 ds:0 作为起始地址开始定义,那么可以用 [bx+0] 并不断增加 bx 的值来访问每一位
又比如两个数组,分别从 ds:0 和 ds:10 作为其实地址来定义,每次依次同时访问这两个数组中下标相同的两数,就可以用 [bx+0] 和 [bx+10] 来进行
然后用另一种语法也许会看的更明确,就是 0[bx] 和 10[bx]
这样作为数组来访问,放在 c 语言里就是 a[i] 和 b[i]。汇编里的起始地址的偏移地址,就是 c 里的数组名;其实 c 里的数组名也就是一个地址,实际地址就是 a+i(至于加的到底是不是精确是 i,根据数据类型来,也许是 i 的若干倍),如果写成 i[a] 这种形式也能正常执行(但是汇编里不能写 bx[3] 这样的形式)

6.3 si 和 di

si 和 di 在 8086cpu 中也是和 bx 用途相近的两个寄存器,但它们不能分成两个 8 位寄存器使用
可以使用 mov ax,[di/si]mov ax,[di/si +idata] 这种形式,也就是和 bx 一样

si,di,bx 一起使用的一些用法

  • [bx+si/di]:(ax)=((ds)*16+(si/di)),也可以使用 [bx][si/di],但 si 和 di 不能一起使用,例如 mov ax,[di+si] 是不正确的
    这种可以用来处理二维数组
  • [bx+si/di+idata]:(ax)=((ds)*16+(si/di)+idata),这种方式的语法也比较多,以 si 为例,例如 idata[bx+si] , idata[bx][si] , [bx][si].idata , [bx].idata[si] , [bx][si][idata] 等,可以自己写一些编译一下,编译成功一般就是可以用
    注意这里 idata 可以不止有一个,比如:mov ax,5[bx+3][si+4][3].6,这种奇怪语法其实是可以编译成功的,然后去 debug 里看一眼,机器码对应过来是 mov ax,[bx+si+15H],加的那个常数也就是十进制下 21,是我们输入的几个常数的和,只不过这样写也没啥意义罢了

6.4 bp

还有一个寄存器,也就是 bp
他经常和 bx,si,di 搭配使用

  • [bx/bp/si/di],就是以一个寄存器为偏移地址进行寻址,中括号里的寄存器只能是这四个,其它的都是不正确的
  • 这四个也可以有四种两两搭配的方法,[bx+si],[bx+di],[bp+si],[bp+di],也就是 bx,bp 以及 si,di 不能在一起出现
  • 上面说的几种方式也都可以再加一个 idata
  • 寻址中,只要涉及到了 bp,那么如果不显式的给出段地址,默认的段地址就是 ss(因为经常使用 bp 和其它搭配来访问栈空间)

6.5 寻址方式、数据位置的表达

8086cpu 的寻址方式我们基本已经都接触过了,于是这里给出一张图涵盖了这些方式:

对于那个“结构中的数组项”,就是比如可以用 bx 定位一个结构体,然后用一个常数来指出结构体中的一个数组的起始地址(相对这个结构体的起始地址),然后用 si 定位数组里的每个数

再说数据位置的表达,先要知道cpu要处理的数据存放在三个位置:cpu内部,内存,端口
其中第三个端口目前还没有涉及

  • 立即数,这类数据是立即寻址,信息直接包含在指令中,指令执行前,存放在指令缓冲器中
  • 寄存器,当我们在汇编的指令中使用一个寄存器,那cpu要处理的数据就存放在寄存器里
  • 内存,就是cpu用段地址和偏移地址,来访问内存读取数据

6.6 cpu处理数据的长度

8086cpu,能处理长度为字节和字的数据
有这几种方式,告诉cpu当前要处理的数据有多长

  • 通过寄存器名,比如 ax 是 16 位,al 是 8 位
  • 有些指令,默认了访问的数据是 16 还是 8 位,比如栈操作的 push 和 pop
  • 还有一种方式,使用 X ptr,这个 X 可以是 byte 或 word
    例如,inc word ptr ds:[0]:把 ds:0 处字型数据自增;mov byte ptr [bx]:把 ds:bx 处字节型数据 mov 成 1

数据的位置,要处理的数据的长度是数据处理的两个基本问题

6.7 div 指令

用于做除法
做除法时,如果除数是 8 位,那被除数必须是 16 位;如果除数 16 位,被除数要 32 位。原因应该是,因为除法由乘法模拟,两个 8 位相乘就是最高 16 位,所以被除数应为 16 位(应该是这样吧,具体不太清楚)

  • 如果是 16 位除以 8 位,被除数存在 ax 中,除数存在 X 中,调用 div X,商会被存到 al 中,余数存到 ah 中。其中 X 必须是 8 位寄存器,或用 byte ptr 声明的内存单元
  • 如果是 32 位除以 16 位,被除数的高位在 dx 中,低位在 ax 中,除数存在 X 中,调用 div X,商会被存在 ax 中,余数在 dx 中。其中 X 必须是 16 位寄存器,或用 word ptr 声明的内存单元

也就是,不能把立即数或段寄存器作为 div 的参数

下面这个例子,就把数据段里前两个数的商,存到了第三个数里

assume cs:codesg,ds:datasg
datasg segment
	dd 100001;dd(define double word) 定义双字,32 位
	dw 100
	dw 0
datasg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,ds:[0]
	mov dx,ds:[2]
	div word ptr ds:[4]
	mov ds:[6],ax
	
	mov ax,4c00H
	int 21H
codesg ends
end start

6.8 实例

懒得打题目了,直接截个图


data 里可以看作 3 个数组,由于前两个每个元素长度相同,所以可以用一个 bx 索引,第一个年份的是 \((bx)+0\),总收入的是 \((bx)+54H\),可以自己算一下这个长度。bx 每次加四
第三个人数的,由于长度不同,不能和前面两个一样用 bx+idata 索引,再开一个 si,每次加二
然后 table 可以看作每个存有一个长度为 16 的数组的结构体,我是用 bp+idata 索引

assume cs:codesg,ds:datasg,es:table
datasg 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'
	
	dd 16,22,382,1356,2390,8000,16000,24486,50065,97479,140417,197514 ;公司总收入
	dd 345980,590827,803530,1183000,1843000,2759000,3753000,4649000,5937000
	
	dw 3,7,9,13,28,38,130,220,476,778,1001,1442,2258,2793,4037,5635,8226 ;公司人数
	dw 11542,14430,15257,17800
datasg ends
table segment
	db 21 DUP ('year summ ne ?? ');共 16 位:四位年份,四位收入,两位人数,两位人均收入
table ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,table
	mov es,ax
	mov bx,0
	mov bp,0
	mov si,0
	
	mov cx,21
S0:
	mov ax,[bx];存放年份,一次传一个字
	mov es:[bp+0],ax
	mov ax,[bx+2H]
	mov es:[bp+2H],ax
	
	mov ax,[bx+54H];存年收入,第一个年收入从 ds:54H 开始
	mov es:[bp+5H],ax
	mov dx,[bx+56H]
	mov es:[bp+7H],dx;存入 dx 方便后面的除法(这里是高位)
	
	mov ax,[si+0A8H];存人数,第一个人数从 ds:0A8H 开始
	mov es:[bp+0AH],ax
	
	mov ax,[bx+54H];做除法,算人均收入
	div word ptr ds:[si+0A8H]
	mov es:[bp+0DH],ax
	
	add bx,4
	add si,2;si 和 di 每次增加的不一样,所以要单独开一个 si
	add bp,16
loop S0
	
	mov ax,4c00H
	int 21H
codesg ends
end start

7 转移指令

可以修改 ip,或同时修改 cs 和 ip 的指令是转移指令。之前说过的 loop 就是其中之一
只修改 ip 的是段内转移(又分为段内短转移和近转移),同时修改 cs 和 ip 的是段间转移
8086cpu 的转移指令可以分成这几类

  • 无条件转移指令
  • 条件转移指令
  • 循环指令
  • 过程
  • 中断

其中后两个目前还不会提到

7.1 伪指令 offset

它由编译器处理,用处是取一个标号的偏移地址

code segment
start:
      mov ax,offset start
code ends

这段代码,就等价于 mov ax,0

7.2 jmp

是一个无条件跳转指令,可以只修改 ip,也可以同时修改 cs 和 ip
不同的转移方式有不同的格式

利用 jmp 段内转移

段内短转移:jmp short 标号,转到标号处继续执行代码
对 ip 的修改为 \([-128,127]\),也就是用一个 8 位数字表示,标号应该在这个范围内

段内近转移:jmp near ptr 标号,转到标号处继续执行代码,near ptr 指明了这是位移 16 位的转移
对 ip 的修改为 \([-2^{15},2^{15}-1]=[-32768,32767]\),用一个 16 为数字表示,标号应该在这个范围内

其实也可以用 jmp 标号,用来段内转移,具体编译器如何编译他看下面

利用 jmp 段间转移

jmp far ptr 标号,转移后 cs 变成标号所在段的段地址,ip 变成标号的偏移地址
far ptr 指明了段间转移,也就是利用标号同时修改 cs 和 ip

转移地址在内存或寄存器中

使用寄存器:jmp 16 位寄存器,将 ip 的值变为这个寄存器的值

使用内存:

  • jmp word ptr 内存单元,实现段内转移,将对应内存单元的字型数据(16 位)当作偏移地址,送入 ip
  • jmp dword ptr 内存单元,实现段间转移,把内存单元低地址的字型数据,送入 ip;高地址的字型数据,送入 cs

7.3 jmp 指令的原理以及编译过程

向后转移

当使用 jmp short S

assume cs:code
code segment
start:
	mov ax,0
	jmp short S
	mov ax,1
	add ax,1
S:
	mov ax,0

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

进入 debug 查看代码:

发现 jmp short S 变成了 jmp 000B,然后再看 \(000B\) 处的代码,发现正是 mov ax,0
但是再看 jmp 000B 对应的机器码,却是 EB06
首先,其中 EB 是 jmp short 的机器码,那么可以确定 06 就是和 \(000B\) 有关系了
通过加入、删除一些代码可以找到一个规律,就是 06 其实是 ip 要跳转的距离,jmp 那个语句起始地址是 cs:3,然后长度两个字节,再往后跳转 6 个字节,那么就是 \((cs):(3+2+6)=(cs):B\),当然也就是跳转后 mov ax,0 的地址了
所以我们知道了,jmp short 标号 的机器码为 EB+跳转距离,注意这个距离是用补码来表示(向前跳转时,距离为负)

再看一个段内近转移的,代码如下

assume cs:code
code segment
start:
	mov ax,0
	jmp near ptr S
	db 138 DUP(0)
	add ax,1
S:
	mov ax,0

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

debug 中:


发现这次 jmp 的机器码变成了三字节,其中 E9 就是 jmp near ptr 的机器码
那么剩下三个字节,就是 008D(注意读取顺序),跳转后是 cs:(2+4+8D)=cs:93

这样,两种段内转移的指令,其实是通过跳转的距离来进行转移

而对于 jmp far ptr S,机器码格式为 EA 偏移地址 段地址,共占 5 字节,就懒得再写代码进 debug 看了
所以说,段间转移靠的不是距离,而是具体的地址

下面再来说向后转移的指令编译的过程

编译器中有一个地址计数器 AC,每读到一个字节的代码 AC 的值就加一(特别的,一些定义数据等的伪指令加的数有所不同)

它肯定会先读到 jmp 指令,此时记录 AC 的值为 \(A_j\),那么编译器把所有的 jmp ... S 都先当作短转移的格式读取,还要根据情况做这样几个事:

  • 对于 jmp short S,生成 EB(它的机器码)和一个 nop(nop 就是什么都不做,占一个字节,但有一定的执行时间),也就是预留了一个字节
  • 对于 jmp Sjmp near ptr S,生成 EB 和两个 nop
  • 对于 jmp far ptr S,生成 EB 和四个 nop

然后继续向后编译,直到遇到了 S,记录此时 AC 的值是 \(A_S\),那么转移的距离就是 \(dis=A_S-A_j\),还是分几种情况

\(dis\le 127\),把所有几种格式都当作 jmp short ptr,在前面预留的位置填充它的机器码,也就是 EB dis
那么,此时 jmp Sjmp near ptr S 会在 EB dis 后面有一个 nop,jmp far ptr S 会有三个,比如下面这段代码

assume cs:code
code segment
start:
	mov ax,0
	jmp far ptr S
	add ax,1
S:
	mov ax,0

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

到了 debug 里是这样的:

\(127<dis\le 32767\),此时 jmp short S 会报错,jmp Sjmp near ptr S 会在之前的位置填充 E9 dis
jmp far ptr S 也会填充段间远转移对应的机器码

向前转移

向前转移的机器码和向后转一的差不多,就是一个补码的问题,不再说了

主要说编译过程
由于是会先读到标号,所以当它读到一个标号 S,那么就记下 AC 当前的值 \(A_S\),然后后面再读到 jmp 这个标号时,记录下那时 AC 的值为 \(A_j\)。这跳转的距离 \(dis=A_S-A_j\),是要给负数

  • \(dis\ge -128\),所有的 jmp 格式都被当作段内短转移来编译机器码
  • \(dis\ge -32768\)jmp short S 报错,其它的两种按照对应的机器码来

还有一个问题,为什么两种段内转移要用转移的距离而不是目标地址?

这样做,是为了方便程序在内存中浮动装配
就是只依靠它们相对的位置来进行转移,而不用管实际的内存地址(或者说绝对的位置),那么它们处在内存中的不同位置就都能正常执行(不用管内存地址是多少)

7.4 jcxz 和 loop

这两个都是条件跳转指令,而且跳转的条件和 cx 有关

jcxz 对 ip 的修改是 \([-128,127]\),格式 jcxz 标号
如果 (cx)=0,则跳转,否则继续向下执行
机器码是 E3,还有一个字节的转移距离(jcxz 也是按照距离来转移)
可以理解为:if((cx)==0) jmp short S

loop 之前已经了解过了,如果 \((cx)\neq 0\),那么 \((cx)=(cx)-1\),并跳转,否则向下执行
对 ip 的修改 \([-128,127]\)
机器码 E2,一个字节的转移距离

例如下面这个程序,就利用了 jcxz 指令,来找到 \(2000H\) 段中第一个值为零的字节型数据,并把它的偏移地址存到 dx(通过简单的修改也可以用 loop 完成)

assume cs:code
code segment
start:
	mov ax,2000H
	mov ds,ax
	mov bx,0
	mov cx,0
S:
	mov cl,[bx]
	jcxz OK
	inc bx
	jmp short S
OK:
	mov dx,bx
	
	mov ax,4c00H
	int 21H
code ends
end start

7.5 一个奇怪的程序

看这样一个代码:

assume cs:codesg
codesg segment
	mov ax,4c00H
	int 21H
	
start:
	mov ax,0
S:
	nop;*
	nop;
	mov di,offset S
	mov si,offset S2
	mov ax,cs:[si]
	mov cs:[di],ax
	
S0:
	jmp short S
S1:
	mov ax,0
	int 21H
	mov ax,0
S2:
	jmp short S1;**
	nop

codesg ends
end start

一上来就是程序返回的指令,但它确实是可以正常返回的
一步步分析:

  • S 后的一些语句,就是将 S2 标号后一个字节的代码复制到 S 后面来
  • 然后执行到 S0,跳转回 S
  • S 中的指令此时实际上就是 S2 中的,那么它是跳转到 S1 吗?并不是,因为 jmp short S1 的机器码和向前移动的距离有关,从 * 那里向前跳转的距离,应该是 ** 那里跳转到 S1 的距离,算下来,就是从 * 跳转到了程序返回的语句

7.6 通过修改显存来进行彩色输出

可能是一个比较有意思的实例

dos 中的 \(80\times 25\) 显示缓冲区,在内存中由 B8000H 到 BFFFFH 共 32KB 构成。向他写入数据,会立刻在屏幕上显示
显示器 25 行,80 列,每个字符 256 中属性,再加上 ASCII 码,一共占两个字节。那么一屏占 4000 字节
显示缓冲区分为八页,每页 4KB,一般情况在显示器上显示第一页,也就是内存地址 B8000H 到 B8F9FH

在第一页上,偏移地址 0 到 9F 是第一行的 160 字节,A0 到 13F 是第二行的,以此类推
在第一行上,偏移地址 0 和 1 是第一个字符的,2 和 3 是第二个的,以此类推
在每个字符的两个字节内存中,低位存放 ASCII 码,高位存属性

关于属性,下面是二进制形式下每一位表示的意义:

其中,闪烁要在全屏 dos 下查看,暂且不用(其实后来发现在 dosbox 中也是可以的)
可以根据 RGB 的有无来调整颜色,比如这段代码在屏幕上的第一行第一列输出一个红色的 A(执行前一定要 cls 一下!不然可能会出现问题,我这是行数出错,在这里坑了好久。。。)

assume cs:code
code segment
start:
	mov ax,0B800H
	mov ds,ax
	mov byte ptr ds:[0],'A'
	mov byte ptr ds:[1],00000100B

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

下面做这样一件事:在屏幕中间输出三行 'welcome to masm!',分别用三种不同属性(在代码注释里)
首先要确定第一行第一个那个 w 在内存里的位置,因为有三行,且水平居中,那么它上面有 11 整行,同理,它左边有 32 个字符
那么它的偏移地址就是 \(11\times 160+32\times 2=720H\),实际地址就是 B8720H
把它化成段地址,然后每次加 160(十进制)就行,具体看代码

;分别在屏幕中间,显式绿色、绿底红字、白底蓝字的 'welcome to masm!'
assume cs:codesg,ds:datasg,ss:stacksg
datasg segment
	db 'welcome to masm!' ;16 字节
	db 00000010B,00100100B,01110001B ;分别是三种样式的属性
datasg ends
stacksg segment stack
	dw 16 DUP(0)
stacksg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov si,16;指向第一个属性的数字
	mov ax,0B872H;ax 始终指向当前行第一个字母在显存中的段地址(把它当作段的起始)
	
	mov cx,3
S0:
	xor bx,bx;自己异或自己来清零
	mov es,ax
	xor bp,bp
	
	push ax
	push cx
	mov cx,10H
	S1:
		mov al,[bx]
		mov es:[bp],al
		mov al,[si]
		mov es:[bp+1],al
		add bp,2
		inc bx
	loop S1
	pop cx
	pop ax
	
	inc si
	add ax,0AH
loop S0
	
    mov ax,4c00h
    int 21h
codesg ends
end start

执行效果,可以数出来确实是在中间:

8 call 和 ret

8.1 ret 和 retf

从栈中取数据,更改 ip 或 ip 和 cs

  • 执行 ret\((ip)=((ss)\cdot 16+(sp)),(sp)=(sp+2)\),相当于 pop ip
  • 执行 retf\((ip)=((ss)\cdot 16+(sp)),(sp)=(sp+2),(cs)=((ss)\cdot 16+(sp)),(sp)=(sp+2)\),相当于 pop ip,pop cs

8.2 call

将 ip 或 ip 和 cs 压栈,并转移

  • 执行 call 标号\((sp)=(sp-2),((ss)\cdot 16+(sp))=(ip),(ip)=(ip)+16\text{位位移}\),这里的跳转也是根据位移进行,相当于 push ip,jmp near ptr 标号
  • 执行 call far ptr 标号\((sp)=(sp-2),((ss)\cdot 16+(sp))=(cs),(sp)=(sp-2),((ss)\cdot 16+(sp))=(ip)\),然后 cs 和 ip 分别再更改位标号的段地址、偏移地址,根据具体地址进行,相当于 push cs,push ip,jmp far ptr 标号
  • 执行 call 16 位寄存器:先把 ip 压栈,然后 16 位寄存器的值送入 ip
  • 执行 call word ptr 内存单元:ip 压栈,对应内存单元的字型数据的值送入 ip
  • 执行 call dword ptr 内存单元:先压栈 cs,再压栈 ip,然后把内存单元的双字型数据的高位送入 cs,低位送入 ip

后面三条懒得再写数学化的表达式了

8.3 call 和 ret 配合使用

这两个指令从执行方式来看就比较像是要配合起来使用的,一般用它们来进行子程序,或者说函数的调用
我用一个标号来表示一个函数的开始,然后标号后面写这个函数的语句,等语句执行完,就 ret 回去
然后想调用这个函数的时候就用 call 加那个标号
调用的时候,调用前(执行过 call 语句后的,每执行一条语句 ip 都要加上指令长度)的 ip 被压栈,然后跳转到函数内执行,等执行完了,就到 ret 了,栈中原来的 ip 就被弹出来,ip 被修改,回到 call 语句的下一个语句来继续执行

要注意两个地方,下面应用的时候还会再说

  • 就是函数内的 push 和 pop 个数相同,或者通过其它方式来保证进入函数时,调用 ret 时,栈顶都是原来的 ip
  • 如果函数内要修改一些寄存器或内存的值,而这些值在函数外(调用函数的地方)也会用到,那么如果修改了就造成了错误,应该先把这些都压到栈里,然后 ret 之前再弹出来。其实也不用考虑在函数外会不会用到,那样既麻烦还不一定能复用,因为在这里调用时函数外没用到某个寄存器,在其它地方再调用可能就用到了,所以只要把函数里要用的寄存器都压栈即可

8.4 mul

用来做乘法,两种调用方式

  • 两个 8 位相乘,结果得到一个 16 位的数,一个乘数存在 al 中,调用 mul X,这个 X 就是另一个乘数,在内存单元字节型数据或 8 位寄存器中。结果存在 ax 中
  • 两个 16 位相乘,结果得到一个 32 位的数,一个乘数存在 ax 中,调用 mul X,X 就是另一个乘数,在内存单元字型数据或 16 位寄存器中。结果的高位存在 dx 中,低位存在 ax 中。其实除法哪里 32 位被除数也是高位 dx,低位 ax

8.5 参数和结果的传递

一种最容易想到的方法就是约定好参数和结果分别在哪个寄存器中,比如我们实现一个计算一个数的立方的程序,约定参数在 bx 中,结果在 dx:ax 中(这样表示高位在 dx,低位在 ax)

assume cs:codesg,ds:datasg
datasg segment
	dw 1,2,3,4,5,6,7,8
	dd 0,0,0,0,0,0,0,0
datasg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	xor si,si
	mov di,16
	
	mov cx,8
S:
	mov bx,[si]
	call cube
	mov [di],ax
	mov [di+2],dx
	add si,2
	add di,4
loop S

	mov ax,4c00H
	int 21H
	
cube:;(dx:ax)=(bx)^3
	mov ax,bx
	mul bx
	mul bx
ret

codesg ends
end start

那如果要传递的参数和结果个数很多呢?
此时用寄存器一个个存就不现实了,那可以用内存来传递,把参数或结果存在一段内存里,然后传递这段内存的首地址、长度等信息

8.6 实例

编些一些函数来体会一下这个过程

显示字符串

在指定的行列,用指定的颜色,显示一串以零结尾的字符串(ASCII 码是零,不是字符是零)
参数:dh 行号,dl 列号,分别都是从零开始。cl 颜色,字符串从 ds:bx 开始
返回:无

我们以 \(B800H\) 作为段地址,然后 \((dh)\cdot 160+(dl)\cdot 2\) 作为第一个字符的偏移地址(乘 \(160\) 是每行的字节数,乘 \(2\) 是这一列的每个字符的字节数)
然后每次更改显存内存并更改 bx 和显存偏移地址即可
如何判断当前是不是零了?就每次把当前字符放入 cx,然后 \((cx)=(cx)+1\),再 loop 即可,比较容易想到

assume cs:codesg,ds:datasg,ss:stacksg
datasg segment
	db 'Welcome to masm!',0
datasg ends
stacksg segment stack
	dw 32 DUP(0)
stacksg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov dh,8
	mov dl,78
	mov cl,2
	call show_str

	mov ax,4c00H
	int 21H
	
show_str:;dh 行号,dl 列号,cl 颜色,从 ds:bx 开始,输出字符串
	push ax
	push bx
	push cx
	push dx
	push bp
	push es
	
	mov ax,0B800H
	mov es,ax;段地址
	mov ax,0A0H
	mul dh;前面有 dh 行,每行 0AH
	add dl,dl;这一行前面 dl 列,每列 2H
	mov dh,0
	add ax,dx;
	mov bp,ax;此时 bp 即第一个字符的偏移地址
	mov dl,cl;转存 cl,因为判断是否跳出要用到 cx
	
	do:
		mov al,[bx]
		mov es:[bp],al
		mov es:[bp+1],dl
		inc bx
		add bp,2
		mov cl,[bx]
		inc cl
	loop do
	
	pop es
	pop bp
	pop dx
	pop cx
	pop bx
	pop ax
ret
	
codesg ends
end start

解决除法溢出问题

比如一个 32 位除以一个 16 位,结果应为 16 位,但有时除数较小可能会导致结果大于 16 位的最大值,发生错误
那么实现一个 32 位除以 16 位,结果 32 位的函数
参数:dx:ax 为被除数,cx 除数
返回:dx:ax 商,cx 余数

先看商:

\[\lfloor\dfrac{(dx)\cdot 10000H+(ax)}{(cx)}\rfloor \]

\[10000H\cdot \lfloor\dfrac{(dx)}{(cx)}\rfloor+\lfloor\dfrac{10000H\cdot ((dx) \bmod (cx))+(ax)}{(cx)}\rfloor \]

首先式子的正确性比较显然吧,那么看这样是不是每一步就都不会溢出了
第一个式子(加号左边),两个 16 位相除,可以把他们都当成 32 位除,这样解决了溢出,至于乘 \(10000H\),就直接把没乘它的结果加到最终结果的高位里就行了
然后第二个式子,考虑取整符号里面的,由于分子 \(10000H\cdot ((dx) \bmod (cx))+(ax)\le 10000H\cdot ((cx)-1)+(ax)\)
那么整个分数小于等于 \(10000H+\dfrac{(ax)-10000H}{(cx)}<10000H\),所以也不会溢出

代码实现比较简单了,我是直接把这三个寄存器里的数先存内存,避免更改它们的值带来的麻烦

assume cs:codesg,ds:datasg,ss:stacksg
datasg segment
	dw 32 DUP(0)
datasg ends
stacksg segment stack
	dw 32 DUP(0)
stacksg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov dx,0FH
	mov ax,4240H
	mov cx,0AH
	call divdw

	mov ax,4c00H
	int 21H
	
divdw:
	push bx
	push ds:[0]
	push ds:[4]
	push ds:[8]
	
	mov ds:[0],ax
	mov ds:[4],cx
	mov ds:[8],dx
	
	;计算 (dx)/(cx),商存在 bx,余数在 dx
	mov ax,ds:[8]
	mov dx,0
	div word ptr ds:[4];(dx)/(cx),为防止溢出用 word
	mov bx,ax
	
	;计算剩余部分
	mov ax,ds:[0]
	div word ptr ds:[4];目前剩余部分分子已经符合高位在 dx,低位在 ax,直接除
	mov cx,dx;余数放进 cx
	mov dx,bx;第一部分的结果就是总结果的高位,放入 dx
	;ax 已经是低位
	
	pop ds:[8]
	pop ds:[4]
	pop ds:[0]
	pop bx
ret

codesg ends
end start

数值显示

将一个数以十进制形式显示到屏幕上

那么此时我们需要一个二进制转十进制的程序
参数:ax,要转的数
返回:从 ds:si 开始,返回一个字符串

就每次 ax 除以 \(10\),余数存起来,然后判一下是不是已经 \((ax)=0\) 就行了
但这样存完以后是逆序的,要再转换顺序,就一个循环执行字符串长度除以二下取整次,每次用 si 和 di 分别指向字符串两端,往中间靠近,并交换
还要判断是不是字符串长度为 \(1\)

然后再调用之前的显示字符串函数

assume cs:codesg,ds:datasg,ss:stacksg
datasg segment
	db 16 DUP(0)
datasg ends
stacksg segment stack
	dw 32 DUP(0)
stacksg ends

codesg segment
start:
	mov ax,datasg
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov ax,12666
	xor si,si
	call dtoc
	
	xor bx,bx
	mov dh,8
	mov dl,3
	mov cl,2
	call show_str

	mov ax,4c00H
	int 21H

dtoc:;数据在 ax,转为十进制字符,存的位置从 ds:si 开始
	push ax
	push bx
	push cx
	push dx
	push si
	push di

	do_dtoc:
		xor dx,dx;为了防止除法溢出,用 32 位除以 16 位
		mov cx,10
		div cx
		add dx,30H;转为字符
		mov [si],dx
		inc si
		mov cx,ax
		inc cx
	loop do_dtoc
	
	;因为这样计算是逆序的,所以还要转换顺序
	mov ax,si
	mov bx,2
	div bl
	mov ah,0;把商的位置置零
	mov cx,ax;执行次数
	jcxz cx_is_0
	mov di,si
	dec di
	xor si,si
	order:
		mov al,[si]
		mov ah,[di]
		mov [si],ah
		mov [di],al
		inc si
		dec di
	loop order
	cx_is_0:
	
	pop di
	pop si
	pop dx
	pop cx
	pop bx
	pop ax
ret

show_str:
	push ax
	push bx
	push cx
	push dx
	push bp
	push es
	
	mov ax,0B800H
	mov es,ax;段地址
	mov ax,0A0H
	mul dh;前面有 dh 行,每行 0AH
	add dl,dl;这一行前面 dl 列,每列 2H
	mov dh,0
	add ax,dx;
	mov bp,ax;此时 bp 即第一个字符的偏移地址
	
	xor ch,ch
	xor bx,bx
	mov dl,cl;转存 cl,因为判断是否跳出要用到 cx
	
	do:
		mov al,[bx]
		mov es:[bp],al
		mov es:[bp+1],dl
		inc bx
		add bp,2
		mov cl,[bx]
		inc cl
	loop do
	
	pop es
	pop bp
	pop dx
	pop cx
	pop bx
	pop ax
ret
	
codesg ends
end start

将之前某个实例中那个公司的各种信息按照格式输出

还记得之前那个实例吗,那个是存到内存里,现在是输出,输出成这样:

因为年份是字符串,就以为一位往显存里写
然后总收入和人数就调用进制转换和字符串显示的函数,因为是 32 位,所以进制转换也要变成 32 位的
再调用防止溢出的除法函数,算出人均收入,同样输出
其实思路很简单,主要就是细节问题

一定注意寄存器冲突的问题!进入函数时保存所有要更改的寄存器,如果没有进入函数,但一个本来有用途的寄存器此时要用作其它用途,也要先把它的值保存下来,想清楚每个寄存器在什么时候是表示什么!

我写了半个下午加半个晚上,大部分时间都耗在差寄存器冲突带来的错上了。。。。
以及跳转上的一些问题也要注意

assume cs:codesg,es:datasg,ds:datasg2,ss:stacksg
datasg 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'
	
	dd 16,22,382,1356,2390,8000,16000,24486,50065,97479,140417,197514 ;公司总收入
	dd 345980,590827,803530,1183000,1843000,2759000,3753000,4649000,5937000
	
	dw 3,7,9,13,28,38,130,220,476,778,1001,1442,2258,2793,4037,5635,8226 ;公司人数
	dw 11542,14430,15257,17800	
datasg ends
datasg2 segment
	db 16 DUP(0)
datasg2 ends
stacksg segment stack
	dw 32 DUP(0)
stacksg ends

codesg segment
start:
	mov ax,datasg2
	mov ds,ax
	mov ax,stacksg
	mov ss,ax
	mov sp,34
	mov ax,datasg
	mov es,ax
	xor si,si;年份和总收入
	mov di,0A8H;第一个人数
	mov bx,288H;第一个屏幕上要输出的字符偏移地址,第四行第四列
	mov bp,3;bp 为当前行号
	
	mov cx,21
main:
	push cx
	push bx
	push si
	inc bp
	
	mov cx,es:[si];输出年份,年份是字符串所以手动输出
	mov dx,es:[si+2]
	push es
	mov ax,0B800H
	mov es,ax
	mov ax,111B;颜色
	mov es:[bx],cl
	mov es:[bx+1],al
	mov es:[bx+2],ch
	mov es:[bx+3],al
	mov es:[bx+4],dl
	mov es:[bx+5],al
	mov es:[bx+6],dh
	mov es:[bx+7],al
	pop es
	
	mov ax,es:[si+54H];输出总收入
	mov dx,es:[si+2+54H]
	xor si,si;转换进制从 ds:si 开始存结果
	call dtoc
	mov dx,bp
	mov dh,dl
	mov dl,22;22 列
	mov cx,111B
	xor bx,bx;显示字符从 ds:bx 开始
	call show_str
	
	mov ax,es:[di];人数
	xor dx,dx
	xor si,si
	call dtoc
	mov dx,bp
	mov dh,dl
	mov dl,40
	mov cx,111B
	xor bx,bx
	call show_str
	
	
	pop si;计算、输出人均收入,还原 si
	push si
	mov dx,es:[si+54H+2]
	mov ax,es:[si+54H]
	mov cx,es:[di]
	call divdw
	xor si,si
	call dtoc
	mov dx,bp
	mov dh,dl
	mov dl,58
	mov cx,111B
	xor bx,bx
	call show_str
	
	pop si
	pop bx
	pop cx
	add bx,160
	add si,4
	add di,2
	
	dec cx
	jcxz done
	jmp near ptr main
done:

	mov ax,4c00H
	int 21H

divdw:;被除数高位 dx,低位 ax,除数 cx,返回时商高位 dx,低位 ax,余数 cx
	push bx
	push ds:[0]
	push ds:[4]
	push ds:[8]
	
	mov ds:[0],ax
	mov ds:[4],cx
	mov ds:[8],dx
	
	;计算 (dx)/(cx),商存在 bx,余数在 dx
	mov ax,ds:[8]
	mov dx,0
	div word ptr ds:[4];(dx)/(cx),为防止溢出用 word
	mov bx,ax
	
	;计算剩余部分
	mov ax,ds:[0]
	div word ptr ds:[4];目前剩余部分分子已经符合高位在 dx,低位在 ax,直接除
	mov cx,dx;余数放进 cx
	mov dx,bx;第一部分的结果就是总结果的高位,放入 dx
	;ax 已经是低位

	pop ds:[8]
	pop ds:[4]
	pop ds:[0]
	pop bx
ret

dtoc:;高位 dx,低位 ax,转为十进制字符,存的位置从 ds:si 开始
	push ax
	push bx
	push cx
	push dx
	push si
	push di

	do_dtoc:
		mov cx,10
		call divdw
		add cx,30H;转为字符
		mov [si],cx
		inc si
		mov cx,ax
		inc cx
	loop do_dtoc
	
	;因为这样计算是逆序的,所以还要转换顺序
	mov ax,si
	mov bx,2
	div bl
	mov ah,0;把商的位置置零
	mov cx,ax;执行次数
	jcxz cx_is_0
	mov di,si
	dec di
	xor si,si
	order:
		mov al,[si]
		mov ah,[di]
		mov [si],ah
		mov [di],al
		inc si
		dec di
	loop order
	cx_is_0:
	
	pop di
	pop si
	pop dx
	pop cx
	pop bx
	pop ax
ret

show_str:;dh 行号,dl 列号,cl 颜色,从 ds:bx 开始,输出字符串
	push ax
	push bx
	push cx
	push dx
	push bp
	push es

	mov ax,0B800H
	mov es,ax;段地址
	mov ax,0A0H
	mul dh;前面有 dh 行,每行 0AH
	add dl,dl;这一行前面 dl 列,每列 2H
	mov dh,0
	add ax,dx;
	mov bp,ax;此时 bp 即第一个字符的偏移地址
	
	xor ch,ch
	xor bx,bx
	mov dl,cl;转存 cl,因为判断是否跳出要用到 cx
	
	do:
		mov al,[bx]
		mov es:[bp],al
		mov es:[bp+1],dl
		inc bx
		add bp,2
		mov cl,[bx]
		inc cl
	loop do
	
	pop es
	pop bp
	pop dx
	pop cx
	pop bx
	pop ax
ret

codesg ends
end start

7 标志寄存器

标志寄存器用来存储计算的某些结果,为 cpu 的执行提供依据或控制其行为
与其它寄存器不同,它是每个二进制位代表一个意义(其它都是整个寄存器代表一个意义)
每一位的意义如下,空白说明在 8086cpu 中这一位无意义

7.1 ZF

第 6 位,零标志位
如果上一条指令执行的结果为 \(0\),则 \(ZF=1\),否则 \(ZF=0\)

关于“上一步的结果”:8086cpu 中有一些指令会产生结果,比如 add,sub,mul,div,inc,dec,or,and(其中 inc 和 dec 只有可能影响 AF,OF,PF,SF,ZF 标志位,但不影响 CF 标志位);还有一些比如 mov,push,pop 不影响标志位
这一点在后面也会用上

7.2 PF

第 2 位,奇偶标志位
如果上一条指令执行的结果的二进制中有偶数个 \(1\),则 \(PF=1\),否则 \(PF=0\)

7.3 SF

第 7 位,符号标志位
如果上一条指令执行结果为负\(SF=1\),如果非负\(SF=0\)

和符号有关,就是要用到补码了,先去学补码再看下面内容会更名白一些
一个数据以二进制保存在计算机中,它既可以代表直接转换成十进制的数(无符号),也可以用补码来转换(有符号)
也就是说,cpu 执行一条指令的时候,已经有了两种含义(当成有符号执行和当成无符号执行),结果也有两种含义(有符号和无符号),虽然它们在计算机中的表达是一样的,把它当成有符号还是无符号是我们的“看待”

所以说,cpu 在执行一条有结果的指令时,必然影响到 SF 的值(当然是当作有符号运算来进行影响),而我们需不需要这个影响就另说了:比如我们对这个运算的“看待”就是无符号运算,那么 SF 受到的影响就是无用的,但 cpu 对 SF 的影响还是会有,只是我们此时不需要罢了

7.4 CF

第 0 位,进位标志位
两个 N 位数字运算时,有可能发生溢出,CF 记录的就是溢出的这一位(第 N 位)

当减法出现借位时,CF 也会记录借位值。比如一个八位减法 \(97H-98H\),发生借位,变成 \(197H-98H\),然后 \(CF=1\)

其实可以发现,一般来说这个 CF 也是对于无符号数的,但是如果我们把一个运算看作有符号的运算,cpu 执行指令对 CF 的影响仍然是存在的

7.5 OF

第 11 位,溢出标志位
溢出一般是对于有符号数来说的,就是如果运算过程中结果超过了机器所能表示的范围称为溢出
比如对于两个 8 位数的运算,\(98+99=197\),这个 \(197\) 就超过了 8 位数的表示范围 \([-128,127]\),发生了溢出
这样结果变成十六进制就是 \(0C5H\),又因为是有符号运算,所以它应该被按照补码的规则看作 \(-59\),发生了错误
这时就要用到 OF 了,如果上一个指令的结果发生了溢出,\(OF=1\),否则为零

注意:OF 是对有符号数有意义的标志位,而 CF 是对无符号运算有意义的
但即使一个标志位对当前的运算无意义,它也会被影响(cpu 不知道当前是有符号还是无符号)

7.6 在 debug 中查看标志寄存器

r 命令查看寄存器值时右下角会有一些字符:

7.7 adc 与 sbb

adc X,Y 就是 \(X=X+Y+CF\)
比如 adc ax,bx,意义是 \((ax)=(ax)+(bx)+CF\)

那么这样一种指令的意义何在?比如当我们执行 add al,bl 后,\((al)=(al)+(bl)\),但这样以后 al 可能发生进位,那么会对应的记录到 CF 中,此时再调用 adc ah,bh,就会在把 bh 的值加到 ah 上的同时,把 CF 也加到 ah 上
那么如果之前 al 进位,也就是 \(CF=1\),多了一个 \(100H\),加到 ah 上就是加一,也就是加 CF 的值(当然没进位 \(CF=0\) 也不会有问题)
所以 adc 的意义其实是使得更大数据的加法可以被支持,通过把 CF 的值加到高位上来解决低位出现进位的问题

比如下面这个程序,我们计算了 \(1EF000H+201000H\),并将结果存进了 ax:bx

;calc 1EF000H+201000H,result in ax:bx
assume cs:code
code segment
start:
	mov ax,001EH
	mov bx,0F00H
	add bx,1000H
	adc ax,0020H

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

同样,也可以实现下面这样的一个函数,来利用 adc 进行两个 128 位数据的相加

;两个 128 位数字相加,ds:si 指向第一个数,8 个字
;ds:di 指向第二个数,结果存在第一个数的位置
add128:
	push ax
	push cx
	push si
	push di
	
	sub ax,ax;将 CF 设零
	mov cx,8
	S:
		mov ax,[si]
		adc ax,[di]
		inc si
		inc si
		inc di
		inc di;用 inc 而不是 add 来防止改变 CF
	loop S
	
	pop di
	pop si
	pop cx
	pop ax
ret

再来说 abbabb X,Y 就是 \(X=X-Y-CF\)
比如 sbb ax,bx,意义是 \((ax)=(ax)-(bx)-CF\)
现在类比上一个看这个指令,意义也很明确了,实现带借位的减法

这两个指令也体现出了 CF 存在的意义

7.8 cmp

比较指令,对标志寄存器的影响相当于减法指令,但是它不会改变参与减法运算的两个寄存器或内存单元的值(就是说只改变标志寄存器,不保存结果)
比如执行指令 cmp ax,ax,执行后标志寄存器:\(ZF=1,PF=1,SF=0,CF=0,OF=0\),但 ax 以前是多少还是多少
如果执行 cmp ax,bx,则:

如果 cmp 是对无符号数进行比较,那么上面的几条也可以倒推

但如果是有符号数,就稍微复杂一些了
首先前两条相等和不相等,当然还是一样
如果 \((ax)<(bx)\),则会引起 \(SF=1\),但是 \(SF=1\) 却不一定可以说明 \((ax)<(bx)\)
比如有符号 8 位减法:\(22H-0A0H=34-(-96)=82H=-126\text{(补码转换为原码)},CF=1\),但是 \(34>-96\)
什么情况下会出现这种问题?\(SF=1\) 并不完全等价于结果为负数(结果为负数我们一定能说明那个小于关系),因为就像上面那个例子,运算中发生了溢出,因此出现了这种情况,所以再经过一些简单分析,就可以得到:

  • \(SF=1,OF=0\),没有溢出,此时 \(SF=1\) 就等价于结果为负,所以 \((ax)<(bx)\)
  • \(SF=1,OF=1\),发生溢出,溢出导致了 \(SF=1\),也就是 cpu“以为”结果为负,那么实际上应该是结果为正,那么 \((ax)>(bx)\)
  • \(SF=0,OF=0\),没溢出,\((ax)\ge (bx)\),注意由于 \(SF=0\) 这里是大于等于
  • \(SF=0,OF=1\),溢出了,\((ax)<(bx)\),这后面两个都是同理

这里感觉比较容易迷惑,主要就是关注有符号数溢出,在原码上的表示超出范围,对应到补码上就是改变了符号

7.9 基于标志寄存器的跳转指令

其实就是通过上面讲述的 cmp 结果,对于无符号数,有这几种:

其中各个字母缩写的含义:not,equal,below,above,可能会帮助记忆
其实这个图稍微有一些歧义,要知道中间那一竖栏只是一个辅助的描述,比如如果你只执行一个 je 并不会直接起到中间竖栏的作用,而只是通过 ZF 的值来进行转移,只有当在 je 之前执行一个 cmp,它才会起到“等于则跳转”的效果
也就是,这些指令都可以单独使用,根据标志寄存器跳转,但一般都是通过和 cmp 搭配使用来起到根据两数大小来跳转的作用
就好像 callret 一般搭配使用,但也可以单独拿出一个来用

然后,对于有符号数,原理上是一样的,只是检测的标志位不同,整理出了下面这一个和无符号数跳转指令的对应关系(以下同一个指令两种助记符用斜杠隔开,其实可以发现它们是有规律的)

无符号 有符号 何时跳转
je / jz je / jz 等于
jne / jnz jne / jnz 不等于
jb / jnae jl / jnge 低于
jnb / jae jnl / jge 不低于(大于等于)
ja / jnbe jg / jnle 高于
jna / jbe jng / jle 不高于(小于等于)

例子

可以用如下程序检测 ds:si 开始的一些数据中有几个 8

assume cs:code,ds:datasg;看 datasg 里有多少数等于 8,结果存 ax
datasg segment
	db 8,11,8,1,8,5,63,38
datasg ends
code segment
start:
	mov ax,datasg
	mov ds,ax
	xor bx,bx
	xor ax,ax
	
	mov cx,8
S:
	cmp byte ptr [bx],8
	jne next
	inc ax
	next:
	inc bx
loop S

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

同理,也可以统计有多少个大于,小于,不等于 8

7.10 DF 和串传送指令

第 10 位,方向表示位,串处理指令中,控制每次 di 和 si 的加减,\(DF=0\),就加,否则就减

串传送指令:movsbmovsw
相当于每次把 es:si 处的数据送入 ds:di 中,每次的长度分别是 byte 和 word
然后每次传送完以后变更 si 和 di,就依靠 DF 的值

它可以和 rep 配合使用,rep movsb 就相当于:

S: movsb
loop S

所以要提前设置 cx
cldstd 分别将 DF 置为 \(0\)\(1\)

下面就是一个例子

assume cs:code,ds:datasg;把 datasg 第一个 16 字节传送到第二个 16 字节
datasg segment
	db 'Welcome to masm!'
	db 16 DUP(0)
datasg ends
code segment
start:
	mov ax,datasg
	mov ds,ax
	mov es,ax
	xor si,si
	mov di,16
	mov cx,8
	
	cld
	rep movsw
	
    mov ax,4c00h
    int 21h
code ends
end start

7.11 pushf 和 popf

分别是把标志寄存器的值入栈、出栈。这也是一种可以直接访问标志寄存器的方法

7.12 实例

写一个大小写转换的子程序,小写转大写,但是转换的字符串不一定都是字母,要提前判断

assume cs:code,ds:datasg
datasg segment
	db "Beginner's All-purpose Symbolic Instruction Code.",0
datasg ends
code segment
start:
	mov ax,datasg
	mov ds,ax
	xor si,si
	call letterc
	
    mov ax,4c00h
    int 21h
	
letterc:;ds:si 指向的以 0 结尾的字符串中小写字母转成大写
	push ax
	push cx
	push si
	do:
		mov al,[si]
		cmp al,'a'
		jb no
		cmp al,'z'
		ja no
		and al,11011111B
		mov [si],al
		no:
		inc si
		mov cx,[si]
		inc cx
	loop do
	pop si
	pop cx
	pop ax
ret

code ends
end start
posted @ 2020-08-31 18:42  suxxsfe  阅读(925)  评论(0编辑  收藏  举报