8086汇编语言学习(八) 8086子程序

1.8086过程跳转指令

  作为一门通用的编程语言,需要具有对代码逻辑进行抽象封装的能力。这一抽象元素,在有的语言中被称为函数方法或者过程,而在8086汇编中被称为子程序。子程序和子程序组合能够构造出更复杂的子程序,如此往复以至无穷。子程序的存在,使得开发人员可以使用不同层次的抽象,构建出越来越复杂的系统。

  8086汇编子程序的调用、返回本质上依然是程序指令的跳转。过程跳转和无条件跳转的不同之处在于,跳转的子程序执行完毕后,还需要能够正确的返回子程序执行完成后的第一条指令上,执行之后的程序。

  子程序可以调用子程序,互相之间理论上可以无限制的嵌套。程序跳转时,可以将当前的CS:IP值压入栈中,当子程序执行完毕后再将栈中的CS:IP弹出。栈的先进后出的特性使得栈这一结构可以很好的完成任务。

  虽然使用无条件跳转指令和显式的CS:IP压栈出栈也能实现子程序的调用和返回,但8086汇编为此提供了专门的跳转指令,这被成为过程跳转指令。过程跳转指令通过将CS:IP的压栈/出栈和之后的跳转合而为一,降低了使用子程序时的复杂度。

  8086汇编的子程序跳转指令可以分为两类,一是子程序调用指令,二是子程序返回指令

子程序调用指令

  子程序调用指令call,执行时有两步操作,将IP或者CS/IP压入当前栈中,随后进行对应跳转。call指令主要有以下几种格式:

  call [标号]:其相当于push IP;jmp near ptr [标号]。是段内转移,位移的值由编译器在编译时根据标号位置动态指定,偏移的IP范围也如jmp near一致(-32678~32767)

  call far ptr [标号]:其相当于 push CS;push IP;jmp far ptr [标号]

  call [16位寄存器]:相当于push IP;jmp near [16位寄存器]

  call word ptr [内存单元地址]: 相当于 push IP; jmp word ptr [内存单元地址]

  call dword ptr [内存单元地址]: 相当于push IP; jmp dword ptr [内存单元地址]

子程序返回指令

  有了子程序调用指令,在跳转前先将CS/IP的值压入栈中,并跳转。与之相对的子程序返回指令则是一个逆向的操作,先将栈中的CS/IP弹出,覆盖还原调用者在调用子程序跳转前的CS/IP值,再进行跳转,这样便能够正确的返回子程序执行完毕后调用者对应的指令处。

  ret指令: 其相当于pop IP;弹出栈中的一个数据,用于复原IP的值,从而实现近转移。

  ret n指令:类似ret,在ret的基础上进行了栈顶指针sp的偏移(例如 ret 4),相当于pop IP;add sp,n

  retf指令: 其相当于pop IP; pop CS;(和call far ptr的入栈顺序正好相反)弹出栈中的两个数据,分别用于复原CS、IP的值,从而实现远转移。 

  retf n指令:类似retf,在retf的基础上进行了栈顶指针sp的偏移(例如 retf 4),相当于pop IP;pop CS;add sp,n 。

call和ret组合使用

  子程序的调用和返回跳转指令通常是配对使用的,call近转移和ret配对,而call远转移则和retf配对。

下面是使用call/ret构造子程序的基础模版:

assume cs:code
code segment
main: ..
      ..
      call sub1; 调用sub1子程序
      ..
      ..
      mov ax,4c00h
      int 21h
sub1: ..
      ..
      call sub2; 调用sub2子程序
      ..
      ..
      ret; sub1子程序返回
sub2: ..
      ..
      ..
      ret; sub2子程序返回
code ends
end main

2.子程序与调用者之间参数/返回值传递的问题

  参数返回值传递的问题解决方法其实质是如何通过某一媒介,使得调用者和子程序都能访问到其中的数据。这一媒介主要有三种:寄存器、通用内存以及栈。

通过寄存器传递参数返回值

  下面是一个计算N的三次方的子程序,其通过寄存器来进行参数和返回值传递。

;说明:计算N的三次方
;参数:(bx)=N
;返回值: (dx:ax)=N^3
cube:mov ax,bx
     mul bx; mul bx可以简单理解为ax = ax * bx
     mul bx
     ret

  使用寄存器传递参数/返回值时,调用者需要将参数送入子程序指定的参数寄存器中,并在执行完毕后从指定的结果寄存器中获取返回值。相对的,子程序从参数寄存器中取出参数,将返回值送入结果寄存器中。

通过通用内存传递参数返回值

  使用寄存器传递参数/返回值虽然简单,但存在一个致命缺陷:寄存器的数量是有限的,当子程序所需要传递的参数达到4、5个甚至十几个,几十个时(虽然不推荐传递过多参数,但理论上大多数编程语言是不限制参数个数的),使用寄存器传递参数/返回值就变得不可行了。可以考虑使用一片连续的内存来传递参数。

  下面是一个将ascll码字母转为大写的子程序。

;说明:将ascll字母转为大写
;参数: 将(ds:si)指向的内存单元中的字母转为大写
capital:
  and byte ptr [si],11011111b; 利用字母大小写ascll码的规律进行大小写转换 inc si; si指向下一个内存单元 loop capital ret

完整的示例程序:

data segment
    db 'helloworld'
data ends

code segment
start:
    mov ax,data
    mov ds,ax
    mov si,0
    mov cx,10; 'helloworld'的长度
    call capital
    mov ax,4c00h
    int 21h
capital:
    and byte ptr [si],11011111b; 利用字母大小写ascll码的规律进行大小写转换
    inc si; si指向下一个内存单元
    loop capital
    ret 
code ends
end start

通过栈传递参数返回值

  使用通用内存可以批量的传递参数,同理也可以使用栈来实现参数/返回值的传递。调用者将所需要传递的参数压入栈中,而子程序则从栈中弹出、取出参数。

使用栈来传递参数比起使用通用内存来说具有几个优点:

  1.通用内存范围过于宽泛,不同的设计者会约定使用不同的内存空间进行参数传递,不利于理解。统一的使用栈进行参数传递能让代码易于理解。

  2.子程序与调用者之间存在着共享寄存器冲突的问题,通常使用栈来缓存子程序与调用者冲突的寄存器内容。

  3.一般高级程序语言的实现中存在着作用域的概念,子程序中的临时局部变量(也包括传入的参数)无法在调用者所处的外部作用域中被访问。出于空间效率的考量,子程序中的临时局部变量应该在当前子程序执行完毕后被销毁。栈这一后进先出的特性很适合这样的场景,在子程序执行时将临时局部变量压入栈中,并在子程序执行完毕后将栈中元素有序弹出复原。

  下面是一个子程序,用于计算两数之差的立方(a-b)^3 (demo中a=3,b=1)

assume cs:code

code segment
start:
; 参数b先压入栈中,参数a后压入栈中
    mov ax,1
    push ax
    mov ax,3
    push ax
    call difcube
    mov ax,4c00h
    int 21h
; difcube 计算两数之差的立方 依赖子程序cube
; 参数a=[sp+4];b=[sp+6] (call指令会将当前IP压入栈中,因此IP=[sp+2],栈中元素占用两个内存单元)
; 返回值 ax = (a-b)^3
difcube:
    push bp
    mov bp,sp
    mov ax,[bp+4]
    sub ax,[bp+6]
    push ax
    call cube
    pop bp
    ret 4; ret时需要将进行sp的偏移(参数个数为2,偏移量为4),将参数弹出栈中,使得程序得以正确的返回
; cube 计算N的立方
; 参数n=[sp+4]
; 返回值 ax = n^3
cube:
    push bp
    mov bp,sp
    mov bx,[bp+4]
    mov ax,bx
    mul bx
    mul bx
    pop bp
    ret 2; ret时需要将进行sp的偏移(参数个数为1,偏移量为2),将参数弹出栈中,使得程序得以正确的返回
code ends
end start

3.子程序与调用者之间寄存器冲突的问题

   子程序与调用者之间寄存器冲突通过一个示例程序来说明。

assume cs:code
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   ; 共有4个字符串需要处理         
s:
    mov si,bx
    call capital
    add bx,5  ; 每个字符串长度为5,bx增加指向下一字符串起始位置
    loop s
    
    mov ax,4c00h
    int 21h      
capital: 
    mov cl,[si]
    mov ch,0
    jcxz ok  ; 当前字符串到达结尾,cl+ch=cx=0
    and byte ptr [si],11011111b ; 当前字母转换为大写
    inc si    ; 指向当前字符串下一个字母
    jmp short capital
ok:
    ret
code ends

end start

  程序的思路大致是对每一字符串(和字符数组不同以0结尾,表示字符串的结束)循环调用capital子程序,并将字符串中的所有字母转为大写。乍看一下并没有什么问题,但由于外部调用者s以及capital都使用了条件跳转指令(loop、jcxz),导致了寄存器cx中的数据冲突。从高级语言作用域的角度来看,一个全局变量被调用者和子程序所共享,互相覆盖。

  要想解决这一问题有几种思路:调用者仔细检查以避免和子程序使用相同的寄存器;将子程序和调用者使用的寄存器解耦,不互相冲突,使得调用者和子程序互相之间都不必关心彼此使用的寄存器。

避免调用者使用子程序依赖的寄存器

  由于寄存器数量是极其有限的,当程序足够复杂时(子程序调用子程序),很难做到完全不冲突。由于必须检查全局共享寄存器的存在,避免冲突导致bug,对开发人员也是一个极大的负担。

调用者和子程序寄存器解耦

  将子程序和调用者之间的寄存器解耦,自然是最好不过的方案了。子程序只需要和调用者在参数/返回值处进行交互,而不必考虑例如cx计数寄存器之类的冲突。

  一个简单的寄存器解耦思路是使用栈。当程序指针进入子程序时,将子程序使用到的寄存器首先压入栈中,并在子程序执行完毕返回之前,按照相反的顺序将其弹出,还原进入子程序前的寄存器。这样,无论子程序使用的寄存器是否和调用者产生冲突,都不会产生冲突;如果子程序的设计者按照上述思路编写了代码,调用者也无需关心寄存器冲突的问题。

  因此,在设计子程序时应该将模版进一步优化,使之能够解决调用者和子程序之间寄存器冲突的问题。

子程序开始:
    子程序所使用的寄存器入栈
    子程序内容
    子程序所使用的寄存器出栈
    子程序返回(ret retf)

  上文使用栈传递参数的例子中,子程序头部和尾部对寄存器BP的入栈/出栈便是使用了这一技巧,从而避免了上下文BP寄存器的冲突。

改进后的程序如下:

assume cs:code
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   ; 共有4个字符串需要处理         
s:
    mov si,bx
    call capital
    add bx,5  ; 每个字符串长度为5,bx增加指向下一字符串起始位置
    loop s
    
    mov ax,4c00h
    int 21h      
capital: 
    push cx
    push si
change:
    mov cl,[si]
    mov ch,0
    jcxz ok  ; 当前字符串到达结尾,cl+ch=cx=0
    and byte ptr [si],11011111b ; 当前字母转换为大写
    inc si    ; 指向当前字符串下一个字母
    jmp short change
ok:
    pop si
    pop cx
    ret
code ends

end start

 

posted on 2020-03-23 19:24  小熊餐馆  阅读(2844)  评论(1编辑  收藏  举报