Ch 12 字串

在撰寫程式時常常會遇到資料轉移、比較等狀況發生,80X86 家族的 CPU 埵陷X個指令是專門處理『字串』的,在這裡所謂的字串,是指在記憶體內連續的位元組或字組,並不一定是 ASCII 字元,也有可能是一段二進位數。


概論

80X86 指令集中字串處理的指令有搬移、掃描、比較三種,另外再加上由記憶體載入至暫存器與由暫存器存入記憶體兩種。

MOVSB、MOVSW 和 REP 指令

先說搬移字串。搬移字串指令有兩種,分別是 MOVSB 和 MOVSW,先說 MOVSB。MOVSB 的英文是 move string byte,意思是搬移一個位元組,它是把 DS:SI 所指位址的一個位元組搬移到 ES:DI 所指的位址上,搬移後原來的內容不變,但是原來 ES:DI 所指的內容會被覆蓋而且在搬移之後 SI 和 DI 會自動地址向下一個要搬移的位址。

一般而言,通常程式設計師一般並不會只搬一個位元組,通常都會重複許多次,如果要重複的話,就得把重複次數 ( 也就是字串長度 ) 先記錄在 CX 暫存器,並且在 MOVSB 之前加上 REP 指令,REP 是重複 (repeat) 的意思。這種寫法很是奇怪,一般而言組合語言原始檔的每一行都只有一個指令,但 REP MOVSB 卻可以在同一行寫兩個指令,當然您分開寫也是一樣的。

讓小木偶用 DEBUG 來觀察 REP MOVSB 執行情形:

C:\WINDOWS>debug [Enter]
-a [Enter]
1C6C:0100 mov cx,10 [Enter]
1C6C:0103 mov si,200 [Enter]
1C6C:0106 mov di,300 [Enter]
1C6C:0109 rep movsb [Enter]
1C6C:010B [Enter]
-a 200 [Enter]
1C6C:0200 db "I learn assembly" [Enter]
1C6C:0210  [Enter]

上面的程式片段是把位於 1C6C:0200 的『I learn assembly』字串移搬到 1C6C:0300 處,此字串共 16 個字元,所以 CX 存入 10H。現在來追蹤看看。

-t [Enter]

AX=0000  BX=0000  CX=0010  DX=0000  SP=FFEE  BP=0000  SI=0200  DI=0000
DS=1C6C  ES=1C6C  SS=1C6C  CS=1C6C  IP=0106   NV UP EI PL NZ NA PO NC
1C6C:0103 BE0002        MOV     SI,0200
-t [Enter]

AX=0000  BX=0000  CX=0010  DX=0000  SP=FFEE  BP=0000  SI=0200  DI=0000
DS=1C6C  ES=1C6C  SS=1C6C  CS=1C6C  IP=0106   NV UP EI PL NZ NA PO NC
1C6C:0106 BF0003        MOV     DI,0300
-t [Enter]

AX=0000  BX=0000  CX=0010  DX=0000  SP=FFEE  BP=0000  SI=0200  DI=0300
DS=1C6C  ES=1C6C  SS=1C6C  CS=1C6C  IP=0109   NV UP EI PL NZ NA PO NC
1C6C:0109 F3            REPZ
1C6C:010A A4            MOVSB
-d 300 L10 [Enter]

在還未搬移之前,先看看 1C6C:0300 處的內容,再追蹤。此處您會看到我們輸入 rep 指令,但是 DEBUG 卻顯示 REPZ,事實上這兩個是一樣的。

1C6C:0300  E8 A3 F6 74 08 49 46 FE-06 D7 DC EB EF E8 C3 F9   ...t.IF.........
-t [Enter]

AX=0000  BX=0000  CX=000F  DX=0000  SP=FFEE  BP=0000  SI=0201  DI=0301
DS=1C6C  ES=1C6C  SS=1C6C  CS=1C6C  IP=0109   NV UP EI PL NZ NA PO NC
1C6C:0109 F3            REPZ
1C6C:010A A4            MOVSB
-d 300 L10 [Enter]
1C6C:0300  49 A3 F6 74 08 49 46 FE-06 D7 DC EB EF E8 C3 F9   I..t.IF.........

在搬移一次之後,再看看 1C6C:0300 處的內容,發現上面已經和原來不一樣了 (紅色部份)。這是因為 movsb 已經把第零個位元組搬到 1C6C:0300 處,而覆蓋了原來的內容。而 CX 也減少一,SI、DI 也各增加一而指向下一個位址。好!再追蹤看看。

-t [Enter]

AX=0000  BX=0000  CX=000E  DX=0000  SP=FFEE  BP=0000  SI=0202  DI=0302
DS=1C6C  ES=1C6C  SS=1C6C  CS=1C6C  IP=0109   NV UP EI PL NZ NA PO NC
1C6C:0109 F3            REPZ
1C6C:010A A4            MOVSB

您有沒有發現,在搬移完之前,IP 都指向 REP MOVSB 指令 ( 即 REP MOVSB 所在位址 )。要追蹤這麼多次,太麻煩了,乾脆直接執行到搬移字串到結束。

-g 10b [Enter]

AX=0000  BX=0000  CX=0000  DX=0000  SP=FFEE  BP=0000  SI=0210  DI=0310
DS=1C6C  ES=1C6C  SS=1C6C  CS=1C6C  IP=010B   NV UP EI PL NZ NA PO NC
1C6C:010B 06            PUSH    ES
-d 200 L10 [Enter]
1C6C:0200  49 20 6C 65 61 72 6E 20-61 73 73 65 6D 62 6C 79   I learn assembly
-d 300 L 10 [Enter]
1C6C:0300  49 20 6C 65 61 72 6E 20-61 73 73 65 6D 62 6C 79   I learn assembly

搬移結束後,1C6C:0200 和 1C6C:0300 處的內容均相同,所以 MOVSB 事實上是把原來字串複製到要搬移之處,而原字串是原封不動的。

MOVSW 的作用方式都和 MOVSB 相同,所不同的是 MOVSW 每次搬移一個字組,所以每次搬運完 SI、DI 會增加 2,而 CX 仍然減少一。

CLD 和 STD 指令

此外,還有一點,小木偶在上面沒有提到。事實上我們也可以使每搬移一次之後,使 SI、DI 遞減,也就是往低位址搬移。方法是由『方向旗標』控制( 有關方向旗標請參考附錄二旗標暫存器 )。

當方向旗標清除時 (即方向旗標為零),搬移方向是向高位址處,SI、DI 會遞增,同時您可以看到在 DEBUG 顯示旗標處會有『UP』的字樣,表示向高位址搬移。這是大部分的情況。

當方向旗標設定時 (即方向旗標為一),搬移方向是向低位址處,SI、DI 會遞減。到在 DEBUG 顯示旗標處會有『DN』的字樣。

最後,方向旗標清除的指令是 CLD,意思是 clear direction flag;設定的指令是 STD,意思是 set direction flag。

CMPSB 和 CMPSW 指令

這兩個指令使用方法和 MOVSB、MOVSW 相同,而它的作用是將一個字串和另一處的字串比較。如果只有單獨的一個 CMPSB 或 CMPSW 時,CPU 只比較一個位元組或一個字組;當 CMPSB 或 CMPSW 前加上 REP 時,可以比較一個字串。您也可以用 REPE ( 表示 repeat while equal,如果兩字相等則重複 ) 來代替 REP,也可以用 REPZ ( 表示 repeat while zero,如果零旗標為 ZR,則重複 ) 來代替,換句話說 REP、REPE 和 REPZ 是相同的。

那您可能會問,如何才知道兩個字串相等?這時您就得檢查『零旗標』了,如果零旗標被設為一 (DEBUG 顯示 ZR),表示兩字串相等,此時兩字串會比較完畢所以 CX 也會一直減少至零。如果零旗標被設為零 (DEBUG 顯示 NZ),表示兩字串不相等,cmps 指令僅僅比較到第一個不相等的字元就停止了,所以 CX 不會為零,SI、DI 會指到第一個不相等的位元組或字組之後的位址。

與 MOVS 指令相同的是,CMPS 指令也可以用方向旗標來指定向高位址比較或向低位址比較。

SCASB 和 SCASW 指令

這是 scan string 的意思,中文是掃描字串,它的作用是在一個字串中找到特定的位元組或字組。而這特定的位元組或字組放在 AL 或 AX 暫存器中,被掃描字串的長度位於 CX,字串位址位於 ES:DI 所指的位址。同樣也可以用方向旗標來指定往高位址或低位址掃描。

同樣的 SCASB 或 SCASW 也可以用 REPE 來搭配使用。但是最常用的還是和 REPNE 搭配,它的意思是 repeat while not equal,意思是如果不相等則重複,試想當你在一個英文句子中,尋找英文字母『a』有沒有出現,直覺的方法是不是先看第一個字母,如果不是再看第二個字母。此處最常與 SCASB 搭配的 REPNE 也是如此,如果前面的字不相等,才找後面的字,所以用 REPNE ,而少用 REPE。您也可以用 REPNZ 來代替 REPNE。

LODSB 和 LODSW

LODSB 這個 80X86 指令是把 DS:SI 所指位址的記憶體內容載入一個位元組到 AL 堙A同樣視方向旗標而定,會使得 SI 暫存器增加一或遞少一。LODSB 之意思是 load string byte,但是它卻很少配合 REP 指令,因為通常我們用它是因為要處理該字串堛漕C一個位元組,待處理完才能再次載入,所以兩次載入之間常還有其他指令,並不像 MOVS、CMPS、SCAS 這三個指令可以用一個指令就解決了。

LODSW 是載入 DS:SI 所指位址的內容一個字組到 AX 暫存器,同樣的 SI 會視方向旗標增加二或遞少二,這是因為一個字組佔兩個位元組。

STOSB 和 STOSW

這兩個指令和 LODSB、LODSW 類似,所不同的是這兩個指令是 AL 或 AX 的內容移到 ES:DI 所指的記憶體位址。DI 會視方向旗標增加或遞少。


印出 ASCII 字元的位元圖

字元圖案

在 BIOS 埵酗@段記憶體空間 ( F000:FA6E 開始 ) 是存放 ASCII 字元由 0 開始到 127 共 128 個字元的圖案,每一個文字都可以看成許多『點』組成,這些點構成字元的圖案。如下圖是一個英文字『A』

ASCII 字元,A,的位元圖

最上面一行,由右而左共有 8 個點,這 8 個點構成一個位元組,其中有些點是紅色的,表示這個點必須在螢光幕上印出來,而相對應的位元為一:有些點是黑色表示這是背景,表示這個點不用印出來,相對應的位元為零。以英文字母『A』來說,第零個位元組應該就是 00 11 00 00,換算成十六進位就是 30H,同理其餘位元組分別是 78H、0CCH、0CCH、0FCH、0CCH、0CCH、0。這八個位元組就構成『A』的圖樣,其他的 ASCII 字元也都是類似。所以如果 DOS 要顯示英文字母,就到 BIOS 這個圖案表去尋找該字元的圖案位元組,然後用程式將它依樣畫葫蘆印在螢光幕上。

當然在螢光幕上每一個點都很小,所以您看不到鋸齒狀,也感覺不到點的樣子,在這堣p木偶想將這些字元圖案放大顯示在螢光幕上,這個程式稱為『CHAR_GRA.ASM』,執行結果如下:

H:\HomePage\SOURCE>char_grp [Enter]

按任意鍵(Esc鍵離開):    按下 A
  AA
 AAAA
AA  AA
AA  AA
AAAAAA
AA  AA
AA  AA


按任意鍵(Esc鍵離開):    按下 1
  11
 111
  11
  11
  11
  11
111111


按任意鍵(Esc鍵離開):    按下 Esc 鍵

H:\HomePage\SOURCE>

原始程式

原始程式如下:

total_len       equ     8*128   ;01 
;***************************************
message segment                 ;03 資料段開始
message0        db      0dh,0ah,'按任意鍵(Esc鍵離開):$'
char_graph      db      total_len dup (?)
message ends                    ;06 資料段結束
;***************************************
code    segment                 ;08 程式碼區段開始
        assume  cs:code,ds:message
;---------------------------------------
main    proc    far             ;11 指程式開始
start:  push    ds              ;12 將返回DOS資訊存入堆疊
        sub     ax,ax
        push    ax

        mov     bx,0f000h       ;16
        mov     cx,total_len
        mov     ds,bx           ;18 使DS指向BIOS段位址
        mov     si,0fa6eh       ;19 使SI指向BIOS中ASCII位元圖之偏移位址
        mov     ax,message
        mov     di,offset char_graph
        mov     es,ax   ;22 使ES指向本程式的資料段
        rep     movsb   ;23 搬移

        mov     ds,ax   ;25 使DS指向本程式的資料段
nxt_char:
        mov     dx,offset message0
        mov     ah,9
        int     21h     ;29
        call    crlf
input:  mov     ah,0    ;31 輸入按鍵
        int     16h
        cmp     al,1bh
        je      exit
        cmp     al,07fh
        ja      input   ;36

        mov     dh,al   ;38 保存該鍵的ASCII於DH
        mov     si,offset char_graph
        cbw             ;40 計算該ASCII之偏移位址
        mov     cl,3
        shl     ax,cl
        add     si,ax   ;43 並存於SI
        cld             ;44 使LODSB往高位址處取得資料
        mov     ch,8    ;45 每個ASCII字元圖以8位元組表示
nxt_byte:
        lodsb           ;47 取得該ASCII字元的其中一個位元組
        mov     cl,8    ;48 每個位元組有8位元
nxt_bit:
        mov     dl,dh   ;50 決定是要印出空白還是該字元
        shl     al,1    ;51 決定方法是該位元為0則印空白
        jc      print   ;52 反之印出該ASCII字元
        mov     dl,' '  ;53
print:  mov     ah,2    ;54
        push    ax      ;55 為避免AL值改變,故存於堆疊
        int     21h     ;56 印出
        pop     ax      ;57 取回AL值
        dec     cl
        jnz     nxt_bit ;59 是否印下一位元

        call    crlf    ;61 否,則印出換行及歸位字元
        dec     ch
        jnz     nxt_byte;63 是否印下一位元組
        jmp     nxt_char;64 否,則跳到輸入按鍵

exit:   ret             ;66 返回DOS
main    endp            ;67
;---------------------------------------
crlf    proc    near    ;69
        push    ax
        mov     ah,2
        mov     dl,0dh
        int     21h
        mov     dl,0ah
        int     21h
        pop     ax
        ret
crlf    endp            ;78
;---------------------------------------
code    ends            ;80
;***************************************
stack   segment stack   ;82 堆疊段
        dw      80 dup (?)
stack   ends            ;84
;***************************************
        end     start   ;86 指定程式進入點

程式解說

這個程式一開始,就是找到 BIOS 存放字元圖案的地方,把這些圖案資料移到本程式的資料段。此時來源字串由 DS:SI 指定,應該指到 F000:FA6E 的位址,而目的字串應指到本程式的資料段,也就是 message:char_graph 的位址。至於字串長度可以由從 0 到 127 共 128 個字元,每個字元佔 8 個位元組,因此總共佔用 8*128 個位元組。( 程式第 16 行到第 23 行)

第 25 行在執行完了搬移後,才將 DS 指向我們的資料區。接下來第 26 行到第 36 行是印出提示字串及輸入按鍵。第 38 行是保存按鍵的 ASCII 字元,等程式計算出來如何列印時,在銀幕上列印時需要用到。

第 39 行到第 43 行是計算該按鍵所代表的 ASCII 字元的 8 個位元組在那堙C可以想像,每一個字元都由 8 個位元組的圖案來表示,而這些圖案是放在 char_graph 開始的位址,所以 ASCII 為零的字元就是在 char_graph 位址上,ASCII 為一的字元 ( 空心的笑臉圖案,參考附錄四 ) 就是在 char_graph 之後的第 8 個位址上,ASCII 為 2 的字元 ( 實心的笑臉圖案 ) 就是在 char_graph 之後的第 16 個位址上……,當使用者按下一個鍵時,該鍵的 ASCII 字元在 AL 暫存器,將其改成字組變成 AX ( 程式第 40 行 ),那該 ASCII 所在位址就應該是

8*AX + char_graph

程式第 41 行到第 43 行就是計算這一個算式,並將結果存於 SI 暫存器。

程式第 45 行到第 64 行是印出該字元的圖案來。因為一個字有 8 個位元組來表示其圖案,而每個位元組有 8 個位元來表示 8 個點,所以小木偶用兩個迴圈來解決。第一個迴圈比較大,由第 46 行到第 63 行,這 8 個位元組的個數存於 CH 中,每處理完一個位元組,CH 就減一 (第 62 行),這個迴圈一開始就取得代表該 ASCII 字元圖案的一個位元組,取得方式就是用 LODSB 指令 ( 第47行 ),此指令會自動使 SI 加一,然後將取得的資料放在 AL 中,交由第二個迴圈處理。因每一個位元組有 8 個位元所以進入第二個迴圈之前,先把 8 存入 CL ( 第48行 )。

第二個迴圈比較小,它包含在第一個迴圈堙A像這樣的處理方式,稱之為『巢狀』。在這個迴圈堙A先把 DH 內的數值拷貝到 DL 中,DH 的數值是使用者所按下鍵的 ASCII 字元,拷貝到 DL 中是要使印出在螢幕上。第二步是向左邊移一個位元到『進位旗標』,X86 指令集中有一個指令 SHL 恰好可以做這件事 ( 第51行 ),接下來就是檢查進位旗標 ( 第52行 ),如果剛才 SHL 後的位元為一,那進位旗標會被設定,應該印出 ASCII 字元;反之,若為零,進位旗標會被清除,應該印出空白 ( 第53行 )。接下來第 54 行到第 57 行是印出空白或 ASCII 字元的程式,第 59 行是檢查是否已將 8 個位元都處理完畢。

若處理完畢則又返回第一個迴圈,先印出換行字元 ( 第61行 ),再檢查是否已處理完 8 個位元組。這兩個迴圈的結構如下圖:

巢狀迴圈說明

最後第 66 行是一個返回 DOS 的指令,但因為 main 副程式對 DOS 而言是遠程呼叫,因此這個 ret 指令會取出兩個字組,也就是程式第 12、14 行所推入的堆疊資料,而將控制權交還給 DOS,當然,如果您用 AH=4CH/INT 21H 來結束本程式也可以。


回到首頁到第十一章到第十三章