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