Ch 05 副程式


在撰寫程式時,有許多部分會重複,像是在螢幕上印出字來,或是輸入文字等等,如果每次都再寫一遍相當麻煩而且增加程式大小,於是我們可以事先將這重複的部分寫好,要用到時就呼叫它,執行完畢時,再返回原來的地方繼續執行,這觀念就誕生了副程式(SubRoutine)。

在許多程式語言中都有副程式的觀念,像 BASIC、Pascal、C/C++ 等等。組合語言也不例外,組合語言中的副程式呼叫方式是用 CALL 指令,而返回原處是用 RET 指令( RETURNE 之縮寫)。但在這一章裡,我還想介紹程式是如何返回原位置繼續執行,這牽涉到『堆疊』的觀念。


改寫印出 BL 暫存器值的程式

我們在上一章裡介紹如何印出一個八位元的數字,其中有一些部分是重複的,我們就將這個程式重複的部份用副程式的觀念改寫。先看看原程式:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
;***************************************
code    segment
        assume  cs:code,ds:code
        org     100h
 
;---------------------------------------
start:  mov     bl,2bh
;以下九行印出 BL 內較高的 4 個位元,例如 BL=2B 則在螢幕印出『2』
        mov     cl,4    ;將 4 存於 CL
        mov     dl,bl   ;將 BL 之內容存於 DL 中以方便印出
        shr     dl,cl   ;把 BL 較高之 4 位元變成 DL 中較低之 4 位元
        add     dl,30h  ;加上 30H
        cmp     dl,'9'  ;比較看看是否超過 39H
        jbe     ok_1    ;沒超過直接印出
        add     dl,7    ;若超過再加上 7
ok_1:   mov     ah,2
        int     21h     ;印出
 
;以下 8 行印出 BL 較低的 4 個位元,例如 BL=2B 則在螢幕印出『B』
        mov     dl,bl   ;將 BL 之值存入 DL
        and     dl,0fh  ;取得 DL 之較低的 4 個位元
        add     dl,30h  ;加上 30H
        cmp     dl,'9'  ;比較看看是否超過 9
        jbe     ok_2    ;沒超過直接印出
        add     dl,7    ;若超過再加上 7
ok_2:   mov     ah,2
        int     21h     ;印出
        mov     ax,4c00h;結束程式
        int     21h
;---------------------------------------
code    ends
;***************************************
        end     start

白色部分就是重複的部分,現在把他獨立成一段副程式並取名為 print,而整個程式改寫如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
;***************************************
code    segment
        assume  cs:code,ds:code
        org     100h
;---------------------------------------
start:  mov     bl,2bh
;以下四行印出 BL 內較高的 4 個位元,例如 BL=2B 則在螢幕印出『2』
        mov     cl,4    ;將 4 存於 CL
        mov     dl,bl   ;將 BL 之內容存於 DL 中以方便印出
        shr     dl,cl   ;把 BL 較高之 4 位元變成 DL 中較低之 4 位元
        call    print   ;呼叫 print 副程式
;以下三行印出 BL 較低的 4 個位元,例如 BL=2B 則在螢幕印出『B』
        mov     dl,bl   ;將 BL 之值存入 DL
        and     dl,0fh  ;取得 DL 之較低的 4 個位元
        call    print   ;呼叫 print 副程式
 
        mov     ax,4c00h;結束程式
        int     21h
;---------------------------------------
;print 副程式
;輸入:DL-由 0 到 F 的十六進位數
;輸出:在螢幕上印出 DL 內的 ASCII 碼
print   proc    near
        add     dl,30h  ;加上 30H
        cmp     dl,'9'  ;比較看看是否超過 39H
        jbe     ok      ;沒超過直接印出
        add     dl,7    ;若超過再加上 7
ok:     mov     ah,2
        int     21h     ;印出
        ret
print   endp
;----------------------------------------
code    ends
;****************************************
        end     start

仔細觀察看看,原程式重複的部分已經變成 print 副程式了 ( 第 20∼31 行 ),要呼叫副程式時,用 call 指令,call 後面就接著副程式名稱,就是 print,意思就是程式執行到此會跳到副程式,print,所在之處繼續執行,而不是執行下一行。

來看看 print 副程式的樣子。在 print 副程式中最前面和最後面分別多了 proc 和 endp 兩行。在組合語言中,宣告副程式就是用 proc 這個假指令,它告訴組譯器這兒開始有個副程式,而副程式的名稱就在前面,而副程式的內容一直到 endp 為止,endp 就是告訴組譯器這個副程式結束了,因此副程式就夾在 proc 和 endp 這兩個假指令之間。

proc 後面有個 near,它告訴組譯器這個副程式是近程呼叫,近程呼叫是說呼叫副程式的程式 ( 也就是主程式 ) 和被呼叫者 ( 就是副程式 ) 是在同一區段內,也就是說兩者都在 64KB 內。還有一種叫遠程呼叫,就是被呼叫的副程式在另外一個區段,這時用 proc far 表示。

好了,現在副程式內部已大致完成了,剩下一個新的 8088/8086 指令 RET。由字面上猜測你可能已經知道它是 return 的簡寫,這個指令是用來返回原呼叫副程式的程式之下一行,使程式能繼續執行。以上述程式為例,當 CPU 執行完 SHR DL,CL 後,就執行 CALL PRINT,此時 CPU 會跳到 PRINT 副程式中開始執行 ADD DL,30H,一直到遇到 RET 指令就會返回剛才呼叫處的下一行繼續執行,如下圖紅色箭頭所示:

呼叫副程式流程圖
在執行完 mov dl,bl 和 and dl,0fh 兩個指令後,程式又呼叫 print 副程式(依黃色箭頭所示),待此副程式執行完畢後又跳回原程式結束。

或許你會問,endp 和 RET 都是結束副程式,為何要有兩個呢?原來 endp 是假指令,它僅僅指示組譯器副程式到此為止了;而 RET 是 8086/8088 的指令集,CPU 看到 RET 指令就會跳到原來呼叫地方的下一行。除了『真』『假』指令的差別外,有時一個副程式可以有好幾個出口回到主程式中,這時就在各出口用 RET 來返回主程式。


堆疊簡介

副程式可以簡化程式設計,每當需要用到副程式時,都可以呼叫它,這堬ㄔ秅F一個問題,副程式結束之後,CPU 如何知道要返回那一個地方繼續執行?這個問題,電腦設計者早已想好了。原來程式在呼叫副程式時,會先將 CALL 的下一行指令位址存入一個特別的地方,等到副程式結束時 ( 也就是執行 RET 指令時 ),RET 會到這個地方將該位址取出,然後再跳到該位址繼續執行,而這個儲存位址的地方叫『堆疊』( stack )。

你可以把堆疊想像成餐廳裡,服務生堆起來的餐盤。每次服務生將餐盤洗乾淨就將盤子堆在上面,如果洗好一個餐盤,又會堆到原餐盤的上面,等到要用時就由最上面的餐盤開始拿出來使用。注意!疊起來的餐盤有先進後出的特性,亦即越先堆起來的餐盤,越後用到。電腦中的堆疊也具這種先進後出的特性,系統程式會畫分一塊記憶體作為堆疊使用,並用 SS:SP 這組暫存器指向堆疊。

一開始時堆疊如果是空的話,SS:SP 就指向最高位址。在 COM 檔中,所有區段都在 64K 內,所以堆疊的最高位址為 0FFFFh,但最高的一個字組 ( WORD,就是兩個位元組的長度,所以此處『最高的一個字組』是佔用記憶體位址 0FFFFh 和 0FFFEh 兩個位元組 ) 保留給系統使用,所以當一載入 COM 檔時,SP 之值為 0FFFEh。您可以用 DEBUG 載入任何一個 COM 可執行檔,觀察 SP 之值。

PUSH/POP 指令

我們很少使用『mov ax,[sp]』這種指令來存取堆疊,一般我們使用 PUSH 和 POP 指令來存取堆疊。他們的指令語法是:

PUSH    暫存器/記憶體
POP     暫存器/記憶體

PUSH 是用來把後面接著的暫存器或記憶體內的數值存入堆疊,我們可說成『把暫存器或記憶體推入堆疊』。POP 則是由堆疊把數值取回並存在接在後面的暫存器或記憶體。例如執行下面兩個指令

        PUSH    AX
        POP     day

第一個指令會把 AX 之值存入堆疊,第二個指令會由堆疊中取出一個數值存入 day 變數,亦即 day 變成 AX 之數值。

PUSH 指令的執行過程是當有資料存入堆疊時,這筆資料就存在 SP 所指位址更低的一個字組所在位址,也就是 0FFFCh,並且使 SP 之值減 2,變成 0FFFCh,再度使 SP 指向被使用的位址。如果又有資料存入堆疊時,這筆資料便存在 0FFFAh,SP 變成 0FFFAh。( 堆疊每次都必須存入一個字組的長度,字組的英文為 word,一個字組相當於兩個位元組 ( bytes ),即十六個位元 ( bits ) )。或者更簡單的講法是執行 PUSH 指令時,CPU 會使 SP 暫存器減去 2,然後再把後面的運算元存入 SP 所指的堆疊位址。

反之,執行 POP 指令時,先把 SP 所指的堆疊內容取出,存在運算元中,接著使 SP 加 2,釋放出一個字組出來。為什麼是加 2 或減 2 呢?這是因為每次推入或彈出堆疊的資料都是一個字組的資料。( 有關 SS、SP 暫存器請參考附錄二 )

CALL/RET 指令

CALL 指令是用來呼叫副程式的指令,語法是

        CALL    副程式名

當執行 CALL 指令時,CPU 會把 CALL 下一行所在位址推入堆疊,這個位址稱為返回位址,然後把 CALL 後面副程式所在位址拷貝到 IP 暫存器堙A這樣 CPU 就會跳到副程式中執行了。

RET 指令是在副程式中返回主程式的指令,其語法是:

        RET     n
        RETN    n
        RETF    n

n 是位元組數,此位元組數表示自副程式返回時還要再使 SP 加上幾個位元組,常用於高階語言呼叫副程式,見第 31 章。當 CPU 執行 RET 指令時,會自 SP 所指堆疊位址取出一個數值來,並拷貝到 IP 暫存器,如此一來程式便會到跳躍到該數值所代表的位址繼續執行,接著 CPU 還會使 SP 加上 2。假如 RET 之後沒有運算元,n,的話,RET 指令就算結束了;如果還有運算元的話,SP 還要再加上 n,亦即捨棄堆疊堛滬Y干資料。RETN、RETF 見 PROC/ENDP 假指令的說明。

底下用 DEBUG 來觀察改寫後的『印出 BL 暫存器』程式執行情形。先將原始程式存成 PNT_BL.ASM,然後用 CPL.BAT 批次檔來組譯、連結:(黃色部份是你必須輸入的字)

H:\HomePage\SOURCE>cpl pnt_bl [Enter]

H:\HomePage\SOURCE>..\masm50\masm pnt_bl;
Microsoft (R) Macro Assembler Version 5.00
Copyright (C) Microsoft Corp 1981-1985, 1987.  All rights reserved.


  51578 + 418566 Bytes symbol space free

      0 Warning Errors
      0 Severe  Errors

H:\HomePage\SOURCE>..\masm50\link pnt_bl;

Microsoft (R) Personal Computer Linker  Version 2.40
Copyright (C) Microsoft Corp 1983, 1984, 1985.  All rights reserved.

Warning: no stack segment

H:\HomePage\SOURCE>..\masm50\exe2bin pnt_bl pnt_bl.com

H:\HomePage\SOURCE>
到此,已經組譯、連結並轉換成 COM 檔成功了,現在用 DEBUG 載入看看:( 黃色部份是你必須輸入的字 )
H:\HomePage\SOURCE>..\masm50\debug pnt_bl.com [Enter]
-u [Enter]
12A7:0100 B32B          MOV     BL,2B
12A7:0102 B104          MOV     CL,04
12A7:0104 8AD3          MOV     DL,BL
12A7:0106 D2EA          SHR     DL,CL
12A7:0108 E80D00        CALL    0118   ===>呼叫副程式處
12A7:010B 8AD3          MOV     DL,BL
12A7:010D 80E20F        AND     DL,0F
12A7:0110 E80500        CALL    0118   ===>呼叫副程式處
12A7:0113 B8004C        MOV     AX,4C00
12A7:0116 CD21          INT     21
12A7:0118 80C230        ADD     DL,30
12A7:011B 80FA39        CMP     DL,39
12A7:011E 7603          JBE     0123
-g 106 [Enter]

AX=0000  BX=002B  CX=0004  DX=002B  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=12A7  ES=12A7  SS=12A7  CS=12A7  IP=0106   NV UP EI PL NZ NA PO NC
12A7:0106 D2EA          SHR     DL,CL
我們執行此程式,一直到第一次呼叫副程式前的指令停下來,也就是到 SHR DL,CL 停下來好讓我們觀察結果。再執行一次 t 指令:
-t [Enter]

AX=0000  BX=002B  CX=0004  DX=0002  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=12A7  ES=12A7  SS=12A7  CS=12A7  IP=0108   NV UP EI PL NZ AC PO CY
12A7:0108 E80D00        CALL    0118
現在先觀察堆疊的內容,再執行 CALL 0118 指令。因為 SP 為 FFFE,故輸入『d SS:FFF0 L10』觀察這 16 個位元組的資料:
-d SS:FFF0 L10 [Enter]
12A7:FFF0  0F 06 15 1C 00 00 00 00-08 01 A7 12 0A 0C 00 00   ................
-t [Enter]   ===>執行 CALL 指令

AX=0000  BX=002B  CX=0004  DX=0002  SP=FFFC  BP=0000  SI=0000  DI=0000
DS=12A7  ES=12A7  SS=12A7  CS=12A7  IP=0118   NV UP EI PL NZ AC PO CY
12A7:0118 80C230        ADD     DL,30
觀察堆疊內容,並比較白色部分。
-d SS:FFF0 L10 [Enter]
12A7:FFF0  0F 06 00 00 00 00 18 01-A7 12 0A 0C 0B 01 00 00
你應該會發現,在執行 CALL 0118 之前,SP=FFFE,而此時堆疊中只有一筆資料,就是 0000,這是系統使用的。執行 CALL 0118 之後,SP=FFFC,堆疊中多了一筆資料,010B,再回到前面看看 CALL 0118 之後的指令是 MOV DL,BL,其位址恰好也是 010B。換句話說 CALL 指令會將要返回的位址(橘色),存入堆疊中,再跳到副程式位址(紅色)繼續執行。好,我們看看副程式的內容,再連續追蹤:
-u 118 [Enter]
12A7:0118 80C230        ADD     DL,30
12A7:011B 80FA39        CMP     DL,39
12A7:011E 7603          JBE     0123
12A7:0120 80C207        ADD     DL,07
12A7:0123 B402          MOV     AH,02
12A7:0125 CD21          INT     21
12A7:0127 C3            RET
12A7:0128 0000          ADD     [BX+SI],AL
12A7:012A 0000          ADD     [BX+SI],AL
-t [Enter]

AX=0000  BX=002B  CX=0004  DX=0032  SP=FFFC  BP=0000  SI=0000  DI=0000
DS=12A7  ES=12A7  SS=12A7  CS=12A7  IP=011B   NV UP EI PL NZ NA PO NC
12A7:011B 80FA39        CMP     DL,39
-t [Enter]

AX=0000  BX=002B  CX=0004  DX=0032  SP=FFFC  BP=0000  SI=0000  DI=0000
DS=12A7  ES=12A7  SS=12A7  CS=12A7  IP=011E   NV UP EI NG NZ AC PE CY
12A7:011E 7603          JBE     0123
-t [Enter]

AX=0000  BX=002B  CX=0004  DX=0032  SP=FFFC  BP=0000  SI=0000  DI=0000
DS=12A7  ES=12A7  SS=12A7  CS=12A7  IP=0123   NV UP EI NG NZ AC PE CY
12A7:0123 B402          MOV     AH,02
-t [Enter]
AX=0200  BX=002B  CX=0004  DX=0032  SP=FFFC  BP=0000  SI=0000  DI=0000
DS=12A7  ES=12A7  SS=12A7  CS=12A7  IP=0125   NV UP EI NG NZ AC PE CY
12A7:0125 CD21          INT     21
這個指令是 DOS 服務程式,應該不至於有錯誤,所以一般而言,不予以追蹤,直接跳過,故用 g :
-g 127 [Enter]
2   ===>這是 DOS 服務程式所印出來的,還記得 AH=2/INT 21H 吧?
AX=0232  BX=002B  CX=0004  DX=0032  SP=FFFC  BP=0000  SI=0000  DI=0000
DS=12A7  ES=12A7  SS=12A7  CS=12A7  IP=0127   NV UP EI NG NZ AC PE CY
12A7:0127 C3            RET
我們先觀察堆疊區域,再執行 RET 指令。
-d SS:FFF0 L10 [Enter]
12A7:FFF0  0F 06 00 00 00 00 18 01-A7 12 0A 0C 0B 01 00 00
-t [Enter]

AX=0232  BX=002B  CX=0004  DX=0032  SP=FFFE  BP=0000  SI=0000  DI=0000
DS=12A7  ES=12A7  SS=12A7  CS=12A7  IP=010B   NV UP EI NG NZ AC PE CY
12A7:010B 8AD3          MOV     DL,BL
-d SS:FFF0 L10 [Enter]
12A7:FFF0  0F 06 00 00 00 00 00 00-18 01 A7 12 0A 0C 00 00
執行 RET 指令之前,如我們預料的,SP 所指之處正是當初存入的欲返回位址 ( 白色 )。執行之後,程式跳到該位址處,而不是繼續執行 RET 之後的 ADD [BX+SI],AL,同時 SP 之值也恢復到原先未呼叫副程式的值 ( 0FFFEh )。

結論

好了,我已經介紹完副程式與堆疊了,不知你覺得如何?照例做個結論,這一章堙A小木偶介紹了

PROC/ENDP 假指令

PROC 並非 80x86 指令而是假指令,它是用來定義副程式,它指出副程式由此開始,必須和 EDNP 搭配使用,否則組譯器會出錯。語法是:

標號名  proc    [near/far]
        ;副程式碼
        ret
標號名  endp

標號名就是這個副程式的名稱,例如當 CPU 執行到

        CALL    my_subroutine

指令時,就會跳到名為 my_subroutne 的副程式執行。

若 PROC 後接 near 表示副程式與主程式在同一區段內,稱為近程呼叫;若接 far,表示副程式與主程式在不同區段內,稱為遠程呼叫。若省略 near 和 far,組譯器能自動判斷副程式與主程式是否在同一區段內。近程呼叫或遠程呼叫會影響返回位址的大小。很明顯的,近程呼叫,只要把下一指令的偏移位址推入堆疊即可而偏移位址只有一個字組的大小;如果是遠程呼叫,就要把區段位址及偏移位址,共兩個字組推入堆疊。這也影響到返回時要自堆疊取出一個或兩個字組,因此分別用 RETN、RETF 來表示。但是如果用 RET,也沒關係,組譯器也能自動判斷。


回到首頁到第四章到第六章