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