1.写在前面
上一篇博客,我大概介绍了一些高级的寻址方式,以及数据处理的一些问题,今天的博客,我主要带大家介绍一下,如何写出一些结构化的程序。在了解结构化的程序的之前,我们先来了解下转移指令吧。
2.本篇博客的概述
3.转移指令的原理
可以修改IP,或同事修改CS和IP的指令统称为转移指令。
转移行为可以分为以下的几类:
- 只修改IP时,称为段内转移,比如:jmp ax
- 同时修改CS和IP时,称为段间转移,比如:jmp 1000:0
由于转移指令对IP的修改范围不同,段内转移又分为:短转移和近转移
- 短转移IP的修改范围为-128~127
- 近转移IP的修改范围为-32768~32767
转移指令可分为以下几类:
- 无条件转移指令(如:jmp)
- 条件转移指令
- 循环指令(如:loop)
- 过程
- 中断
3.1操作符offset
操作符offset在汇编语言中是由编译器处理的符号,它的功能是取得标号的偏移地址。比如下面的程序:
assume cs:codesg
codesg segment
start: mov ax,offset start ;相当于mov ax,0
s:mov ax,offset s ;相当于mov ax,3
codesg ends
end start
在上面的程序中,offset操作符取得了标号start和s的偏移地址0和3
3.2jmp指令
jmp为无条件转移指令,可以只修改IP,也可以同时修改CS和IP
jmp指令要给出两种信息:
- 转移的目的地址
- 转移的距离(段间转移、段内短转移、段内近转移)
3.3依据位移进行转移的jmp指令
Jmp short 标号(转到标号处执行指令)
这种格式的指令实现的段内短转移,它对IP的修改范围为-128~127,也就是说,它向前转移时可以最多越过128个字节,向后转移可以最多越过127个字节。jmp 指令中的short符合,说明指令进行的是短转移。jmp指令中的标号是代码段中的标号,指明了指令要转移的目的地,转移指令结束后,CS:IP应该指向标号处的指令。
assume cs:codesg
codesg segment
start:mov ax,0
jmp short s
add ax,1
s:inc ax
codesg ends
end start
上面的代码执行到jmp short s 后,越过了add ax,1,直接执行标号s出的inc ax,也就是说程序只进行了一次ax 加1的操作。这个我们需要了解这个转移时怎么做的,这个时候我们就需要编译和连接上面的程序。然后看到的机器码如下:
可以看出 jmp short s中的s标识inc ax 指令的偏移地址8,并将jmp short s表示为jmp 0008,表示转移到cs:0008处。这一切似乎合理,可是当我们查看jmp short s或是jmp 0008所对应的机器码,却发现了一些问题。对应的机器码是EB03可以发现机器码中没有转移的目的地址,这意味着,CPU在执行EB 03的时候,并不知道转移的目的的地址。那么CPU根据什么来进行转移呢?
于是我们将上面的程序修改成如下:
assume cs:codesg
codesg segment
start:mov ax,0
mov bx ,0
jmp short s
add ax,1
s:inc ax
codesg ends
end start
然后我们再进行编译和连接,再次查看对应的机器码如下:
我们发现还是EB03,这就说明一个问题CPU在执行jmp指令的时候并不需要转移的目的地址。但是两个程序的转移的目的地址是不一样的,一个是cs:0008,另外一个是cs:000B。这个时候需要我们回忆一下CPU的执行指令的过程:
- 从CS:IP指向的内存单元读取指令,读取的指令进入指令缓冲器;
- (IP)=(IP)+所读取的指令的长度,从而指向下一条指令;
- 执行指令,转到1,重复这个过程。
按照这个步骤,我们分析前面的代码的执行的逻辑
- (CS)=076A,(Ip)=000H,CS:IP 指向EB 03(jmp short s 的机器码)
- 读取指令码EB03进入指令缓冲器
- (IP)=(IP)+所读取指令的长度=(Ip)+2=0008H,CS:IP指向add ax,1;
- CPU执行的指令缓冲器中的指令EB 03
- 指令EB03执行后,(IP)=000BH,CS:IP 指向inc ax。
从上面的执行的流程来看,CPU执行的EB 03 是一条修改IP的转移指令,执行完(IP)=000BH,CS:IP指向inc ax,CS:0008处的add ax,1没有被执行。那是根据什么来修改IP呢?很明显是根据03。注意要转移的目的地址是CS:000B,而CPU执行EB03时,当前的(IP)=0008H,如果将当前的IP值加3,使(IP)=000BH,CS:IP就可指向目标指令。所以这儿的03是位移。那是怎么计算的呢?于是我们得出下面的图:
于是有如下的结论:
jmp short 标号的更能为:(IP)=(IP)+8位位移
- 8位位移=标号处的地址-jmp指令后的第一个字节的地址
- short指明此处的位移为8位位移
- 8位位移的范围为-128~127,用补码表示
- 8位位移有编译程序的在编译时算出。
还有一种 jmp near ptr 标号,它实现的是段内近转移。它的功能为:(IP)=(IP)+16位位移。
- 16位位移=标号处的地址-jmp指令后的第一个字节的地址;
- Near ptr 指明此处的位移为16位位移,进行的是段内近转移
- 16位位移的范围为-32768~32767,用补码表示
- 16位位移由编译程序在编译时算出。
3.4转移的目的地址在指令中的jmp指令
Jmp far ptr 标号 实现的是段间转移,又称为远转移,功能如下:
(CS)=标号所在的段的段地址;(IP)=标号在段中的偏移地址。
far ptr 指明了指令用标号的段地址和偏移地址修改CS和IP。
先看下面的程序:
assume cs:codesg
codesg segment
start: mov ax,0
mov bx,0
jmp far ptr s
db 256 dup (0)
s: add ax,1
inc ax
codesg ends
end start
上面的程序我们先编译和连接,然后看下对应的机器码如下:
可以发现我们的机器指令中存储的转移的段地址:076A和偏移地址:010B
3.5转移地址在内存中的jmp指令
主要有两种格式,具体的如下:
-
Jmp word ptr 内存单元地址(段内转移)
功能:从内存单元地址处开始存放着一个字,是转移的目的的偏移地址。
内存单元地址可用是寻址方式的任意格式给出。比如以下的指令
mov ax,0123H mov ds:[0],ax jmp word ptr ds:[0]
执行后,(IP)=0123H。
-
jmp dword ptr 内存单元地址(段间转移)
功能:从内存单元地址处开始存放着两个字,高地址处的字是转移的目的段地址,低地址是转移的目的的偏移地址。
(CS)=(内存单元地址+2)
(IP)=(内存单元地址)
内存单元地址可用寻址方式的任意格式给出。
比如,下面的指令:
mov ax,01234H mov ds:[0],ax mov word ptr ds:[2],0 jmp dword ptr ds:[0]
执行后,(CS)=0,(IP)=0123H,CS:IP指向0000:0123
3.7jcxz指令
jcxz指令为有条件的转移指令,所有的有条件的转移指令都是短转移,在对应的机器码中包含转移的位移,而不是目的地址。对IP的修改范围都为-128~127。
指令格式:jcxz标号(如果(CX)=0,转移到标号处执行。)
操作:当(CX)=0时候,(IP)=(IP)+8位位移
8位位移=标号处的地址-jcxx指令后的第一个字节的地址
8位位移的范围为-128~127,用补码表示;
8位位移由编译程序在编译时算出。
用C语言可以表示为:if((CX)==0)jmp short 标号;
3.8loop指令
loop指令为循环指令,所有的循环指令都是短转移,在对应的机器码中包含转移的位移,而不是目的地址。对IP的修改范围都为:-128~127
指令格式:loop 标号((CX)=(CX)-1,如果(CX)!= 0,转移到标号处执行)
操作:
- (cx)=(cx)-1
- 如果(cx)!=0,(Ip)=(Ip)+8位位移
8位位移=标号处的地址-loop指令后的第一个字节的地址
8位位移的范围为-128~127,用补码表示;
8位位移由编译程序在编译时算出。
用C语言可以表示为:(CX)–; if((CX)!=0) jmp short 标号
3.9根据位移进行转移的意义
前面我们讲到:
jmp short 标号
jmp near ptr 标号
jcxz 标号
loop 标号
等几种汇编指令,它们对IP的修改是根据转移的目的地址和转移起始地址之间的位移来进行的。在它们对应的机器码中不包含转移的目的地址,而包含的是到目的地址的位移。这种设计,方便了程序段在内存中的浮动装配。这儿不太懂,于是在网上找到如下的答案:
看不懂,后面再研究吧。
3.10编译器对转移位移超界的检测
注意,根据位移进行转移的指令,它们的转移范围受到转移位移的限制,如果在源程序中出现了转移范围超界的问题,在编译的时候,编译器就会报错。
比如,下面的程序将引起编译错误
assume cs:code
code segment
start : jmp short s
db 128 dup (0)
s:mov ax,0ffffh
code ends
end start
我们来试下,具体的情况如下:
可以看到编译的时候直接出错了。
4.Call和RET指令
call和ret指令都是转移指令,它们都修改IP,或同时修改CS和IP。它们经常被共同来实现子程序的设计。
4.1ret 和retf
ret指令用栈中的数据,修改IP的内容,从而实现近转移
retf指令用占中的数据,修改CS和IP的内容,从而实现转移。
CPU执行ret指令时,进行下面两步操作:
- (IP)=((SS)*16+(SP))
- (SP)=(SP)+2
相当于pop IP
CPU执行retf指令时,进行下面4步操作:
- (IP)=((SS)*16+(SP))
- (SP)=(SP)+2
- (CS)=((SS)*16+(SP))
- (SP)=(SP)+2
相当于 pop IP pop CS
4.2call指令
CPU执行Call指令时,进行两步操作:
- 将当前的IP或CS和IP压入栈中;
- 转移
4.3依据位移进行转移的Call指令
call标号(将当前的IP压栈后,转到标号处执行指令)
-
(sp)= (sp)-2
((ss)*16+(sp))=(IP)
-
(IP)=(IP)+16位位移
16位位移=标号处的地址-Call指令后的第一个字节的地址;
16位位移的范围为-32768~32767,用补码表示;
16位位移由编译程序在编译时算出。
相当于进行:push IP jmp near ptr 标号
4.4转移的目的地址在指令中的Call指令
前面的讲的Call指令,其对应的机器指令中并没有转移的目的地址,而是相对于当前IP的转移位移。
call far ptr 标号实现的是短剑转移。
CPU执行此种格式的Call指令时,进行如下的操作:
-
(sp)=(sp)-2
((ss)*16+(sp))=(CS)
(sp)=(sp)-2
((ss)*16+(sp))=(IP)
-
(CS)=标号所在段的段地址
(Ip)=标号在段中的偏移地址
CPU执行 call far ptr 标号 ,相当于 push CS push IP jmp far ptr 标号
4.5转移地址在寄存器中Call指令
指令格式:call 16位reg
功能:
(sp)=(sp)-2
((ss)*16+(sp))=(IP)
(IP)=(16位reg)
CPU 执行 call 16位reg,相当于进行 push IP jmp 16位reg
4.6转移地址在内存中的Call指令
转移地址在内存中的Call指令有两种格式:
-
call word ptr 内存单元地址
CPU执行 call word ptr 内存单元地址,相当于进行:
Push Ip jmpword ptr 内存单元地址
-
call dword ptr 内存单元地址
CPU执行 call dword ptr 内存单元地址,相当于进行:
push Cs push Ip jmp dword ptr 内存单元地址
4.7Call和ret的配合使用
首先我们先来看一个程序,bx中的值是多少?
assume cs:code
code segment
start: mov ax,1
mov cx,3
call s
mov bx,ax
mov ax 4c00h
int 21h
s: add ax,ax
loop s
ret
code ends
end start
分析:
- CPU将call s指令的机器码读入,IP指向了Call s 后的指令mov bx,ax,然后CPU执行Call s指令,将当前的IP值(指令 mov bx,ax的偏移地址)压栈,并将IP的值改变为标号s出的偏移地址;
- CPU从标号s处开始执行指令,loop循环完毕后(ax)=8;
- CPU 将ret 指令的机器码读入,IP指向了ret指令后的内存单元,然后CPU执行ret指令,从栈中弹出一个值(即Call s先前压入的mov bx,ax指令的偏移地址)送入IP中,则CS:IP指向指令mov bx,ax;
- CPU从mov bx,ax开始执行指令,直至完成。
我们在来看下面的程序:
assume cs:code
stack segment
db 8 dup(0) ;1000:0000 00 00 00 00 00 00 00 00
db 8 dup(0) ;1000:0008 00 00 00 00 00 00 00 00
stack ends
code segment
start:mov ax,stack ;1001:0000 B8 00 10
mov ss,ax ;1001:0003 8E D0
mov sp,16 ;1001:0005 BC 10 00
mov ax,1000 ;1001:0008 B8 E8 03
call s ;1001:000B E8 05 00
mov ax,4c00h ;1001:000E B8 00 4c
int 21h ;1001:0011 CD 21
s:add ax ax ;1001:0013 03 C0
ret ;1001:0015 C3
code ends
end start
我们来分析一下程序的执行的过程:
- 前3条指令执行后,栈的情况如下:
-
call指令读入后,(IP)=000EH,CPU指令缓冲器中的代码为:E8 05 00;CPU执行 E8 05 00,首先,栈中的情况变为:
然后,(IP)=(IP)+0005=0013H。
-
CPU从cs:0013H处开始执行
-
ret指令读入后:(IP)=0016H,CPU指令缓冲器中代码为:C3。CPU执行C3,相当于进行 pop IP ,执行后,栈中情况为:
(IP)=00EH
-
CPU回到cs:000EH处继续执行
从上面的例子我们发现,可以写一个具有一定功能的程序段,我们称其为子程序。call指令指令转去执行子程序之前,Call指令后面的指令的地址将存储在栈中,所以可在子程序的后面使用ret紫菱,用占中的数据设置IP的值,从而转到call指令后面的代码处继续执行。这样我们就得到如下的框架:
assume cs:code
code segment
main: ...
...
call sub1 ;调用子程序sub1
...
...
mov ax,4c00h
int 21h
sub1: ... ;子程序sub1开始
...
call sub2 ;调用子程序sub2
...
...
ret ;子程序返回
sub2: ... ;子程序sub2开始
...
...
ret ;子程序返回
code ends
end main
4.8mul指令
mul是乘法指令,使用mul做乘法的时候,需要注意以下两点
- 两个相乘的数:两个相乘的数,要么都是8位,要么都是16位。如果是8位,一个默认放在AL中,另一个放在8位reg或内存字单元中;如果是16位,一个默认在AX中,另一个放在16位reg或内存单元中。
- 结果:如果是8位乘法,结果默认放在AX中;如果是16位乘法,结果高位默认在DX中存放,低位在AX中放。
我们直接看下面的两个例子吧:
计算100*10,100和10小于255,可以做8位乘法,程序如下
mov al,100
mov bl,10
mul bl
结果:(ax)=1000(03E8H)
计算100*10000,100小于255,可10000大于255,所以必须做16位乘法,程序如下:
mov ax,100
mov bx,10000
mul bx
结果:(ax)=4240H,(dx)=000FH (F4240H=1000000)
4.9模块化程序设计
在实际编程中,程序的模块化是必不可少的。因为现实的问题比较复杂,对现实问题进行分析时,把它转化成为相互联系、不同层次的子问题,是必须的解决办法。
4.10参数和结果传递的问题
子程序一般都要根据提供的参数处理一定的事务,处理后,将结果提供给调用者。这儿需要的考虑的问题就是如何存储子程序需要的参数和产生的返回值。
比如,设计一个子程序,可以根据提供的N,来计算N的3次方。
这里面就有两个问题:
- 将参数N存储在什么地方?
- 计算得到的数值,存储在什么地方?
这儿我们可以将参数放在bx中;因为子程序中要计算N*N*N,可以使用多个mul指令,为了方便,可将结果放到dx和ax中。子程序如下:
cube:mov ax,bx
mul bx
mul bx
ret
用寄存器来存储参数和结果是最常使用的方法。对于存放参数的寄存器和存放结果的寄存器,调用者和子程序的读写操作恰恰相反:调用者将参数送入参数寄存器,从结果寄存器中取到返回值;子程序从参数寄存器中取到参数,将返回值送入结果寄存器。
编程,计算data段中第一组数据的3次方,结果保存在后面一组dword单元中
assume cs:code,ds:data
data segment
dw 1,2,3,4,5,6,7,8
dd 0,0,0,0,0,0,0,0
data ends
code segment
start:mov ax,data
mov ds,ax
mov si,0 ;ds:si指向第一组Word单元
mov di,16 ;ds:di指向第二组dword单元
mvo cx,8
s: mov bx,[si]
call cube
mov [di],ax
mov [di].2,dx
add si,2 ;ds:si指向下一个Word单元
add di,4 ;ds:di指向下一个dword单元
loop s
mov ax,4c00h
int 21h
cube: mov ax,bx
mul bx
mul bx
ret
code ends
end start
4.11批量数据传递
前面的程序,只传递了一个参数,如果有多个参数,该怎么存放呢?在这个时候,我们将批量的数据存放到内存中,然后将它们所在内存空间的首地址放在寄存器中,传递给需要的子程序。对于具有批量数据的返回结果,也可用同样的方法。
例子:将一个全是字母的字符串转换为大写。这个子程序需要知道两件事,字符串的内容和字符串的长度。因为字符串中的字母可能很多,所以不便将整个字符串中的所有字母都直接传递给子程序。但是,可以将字符串在内存中的首地址放在寄存器中传递给子程序。因为子程序中要用到循环,我们可以用loop指令,而循环的次数恰恰就是字符串的长度。于是我们写出如下的程序:
assume cs:code,ds:data
data segment
db 'conversation'
data ends
code segment
start: mov ax,data
mov ds,ax
mov si,0 ;ds:si指向字符串(批量数据)所在空间的首地址
mov cx,12 ;cx存放字符串的长度
call captial
mov ax,4c00h
int 21h
capital: and byte ptr [si],11011111b
inc si
loop capital
ret
code ends
end start
4.12寄存器冲突的问题
设计一个子程序,功能:将一个全是字母,以0结尾的字符串,转化为大写。
程序要处理的字符串以0作为结尾符,这个字符串可以如下定义:
db 'conversation',0
这个字符串的内容后面一定要有一个0,标记字符串的结束。子程序可以一次读取每个字符进行检测,如果不是0,就进行大写的转化,如果是0,就结束处理。于是写出如下的子程序
capital: mov cl,[si]
mov ch,0
jcxz ok ;如果(cx)=0,结束:如果不是0,处理
and byte ptr [si],11011111b ;将ds:si所指单元中的字母转化为大写
inc si ;ds:si指向下一个单元
jmp short cpaital
ok:ret
再来看下这个子程序的应用吧
assume cs:code,ds:data
data segment
db 'conversion',0
data ends
code segment
start: mov ax,data
mov ds,ax
mov si,0
call captial
mov ax,4c00h
int 21h
capital: mov cl,[si]
mov ch,0
jcxz ok ;如果(cx)=0,结束:如果不是0,处理
and byte ptr [si],11011111b ;将ds:si所指单元中的字母转化为大写
inc si ;ds:si指向下一个单元
jmp short cpaital
ok:ret
code ends
end start
再来看一个例子:
assume cs:code,ds:data
data segment
db 'word',0
db 'unix',0
db 'wind',0
db 'good',0
data ends
code segment
start: mov ax,data
mov ds,ax
mov bx,0
mov cx,4
s: mov si,bx
call capital
add bx,5
loop s
mov ax,4c00h
int 21h
capital: mov cl,[si]
mov ch,0
jcxz ok ;如果(cx)=0,结束:如果不是0,处理
and byte ptr [si],11011111b ;将ds:si所指单元中的字母转化为大写
inc si ;ds:si指向下一个单元
jmp short cpaital
ok:ret
code ends
end start
上面的代码思想上完全正确的,但是细节上确有写错误,就是Cx的使用,主程序要使用Cx记录循环的次数,可是子程序中也使用了cx,在执行子程序的时候,cx中保存的循环计数值被改变,使得主程序的循环出错。
由此可知:子程序中的使用的寄存器,很有可能在主程序中也要使用,造成了寄存器使用上的冲突。如何解决?
- 在编写调用子程序的程序时,注意看看子程序中有没有用到会产生冲突的寄存器,如果有,调用者使用别的寄存器。
- 在编写子程序的时候,不要使用会产生冲突的寄存器。
但是上面的两个方案似乎都是不可行。我们希望:
- 编写调用子程序的程序的时候不必关心子程序到底使用了哪些寄存器。
- 编写子程序的时候不必关心调用者使用了哪些寄存器;
- 不会发生寄存器冲突。
最简单的方法:在子程序的开始将子程序中所有用到的寄存器中内容都保存起来,在子程序返回前再恢复。可以用栈来保存寄存器中的内容。于是有了如下的子程序框架
子程序开始:子程序中使用寄存器入栈
子程序内容
子程序中使用的寄存器出栈
返回(ret retf)
我们改进下原来的子程序capital的设计:
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
ret
这儿需要注意寄存器入栈和出栈的顺序。
5.写在最后
这篇博客主要介绍了汇编语言中的一些转移指令,同时学习如何写出模块化的程序。