第21章 檔案﹙二)列出檔案

小木偶在前一章講了有關以檔案代碼方式的處理檔案時,常用的 DOS 服務程式;當然不能光說不練,所以小木偶在這一章寫了一個範例,LISTFILE.ASM。此程式可以把目前目錄底下的所有檔案列出來,包含具有唯讀、隱藏、系統屬性的檔案。先看看下圖組譯、連結及執行後的畫面,最下面兩個黃色框框是 LISTFILE.EXE 執行後的結果,分成兩欄: 上圖最後一個指令,將螢幕切換成 40×25 彩色文字模式,這時 DOS 會清除螢幕,然後我們再次執行 LISTFILE.EXE,如下圖只有一欄:


原理

要列出一個目錄內的所有檔案,必須先呼叫 AH=4EH/INT 21H(此 DOS 服務程式也稱為「find first matching file」),如果成功,DOS 會清除進位旗標(carry flag,簡稱 CF),並把第一個符合的檔案名稱連同檔案屬性、大小、最後修改的日期與時間,填入磁碟傳輸區(DTA)內。如果失敗,DOS 會設定進位旗標,當然在 DTA 內就不會有資料。

這裡所謂的「符合的檔案名稱」是指符合 DS:DX 位址上的字串,此處的字串是檔案的名稱,可以包含「?」與「*」兩個萬用字元,因此可能有很多檔案符合。設定進位旗標的意思是使進位旗標變為一;清除進位旗標的意思是使進位旗標變為零。進位旗標是旗標暫存器的第零位元,旗標暫存器是在 x86 CPU 裡面的一個暫存器。

接著要找第二個以及更多的檔案,要在呼叫 AH=4EH/INT 21H 且呼叫成功之後,再呼叫 AH=4FH/INT 21H(此 DOS 服務程式也稱為「find next matching file」)。AH=4FH/INT 21H 如果失敗,DOS 就會設定進位旗標,可以檢查錯誤碼 AX 是否為 12H,表示已沒有符合的檔案,到此整個搜尋過程結束;如果成功找到下個符合的檔案,也會清除進位旗標,同時更新 DTA 變為新找到符合檔案名稱的該檔案資料,等處理完這些資料須再呼叫 AH=4FH/INT 21H,檢查進位旗標,並重複這個步驟直到失敗為止。這些流程,可以參考右圖:

LISTFILE.ASM 原始檔

根據上面的思路,小木偶寫出以下的程式:

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
;List File能列出當前目錄的檔案資訊,如檔名、檔案大小、最後修改日期與時間
;組譯:ml listfile.asm
.MODEL  SMALL,C
.386
.STACK
;*******************************************************************************
.DATA
files   DB      "*.*",0         ;尋找符合檔案,即所有檔案
buffer  DB      40 DUP (?),"$"  ;包含檔名、檔案大小、最後修改日期與時間的字串
dta     DB      43 DUP (?)      ;DISK TRANSFER AREA
pause   DB      "Press any key to continue . . .$"
cr_lf   DB      0dh,0ah,"$"
n_files DB      0               ;已印出多少個檔案了
max_f   DB      46              ;假設為80x25模式,螢幕畫面最多顯示46個檔案
;*******************************************************************************
.CODE
;-------------------------------------------------------------------------------
;改良式除法,division,能儘量避免除法溢位
;輸入:DX:AX-被除數
;   CX-除數
;輸出:DX:AX-商
;   CX-餘數
division        PROC
        LOCAL   temp:WORD       ;暫時存放原先被除數的低字組
        LOCAL   quotient:WORD   ;暫時存放商的高字組
        mov     temp,ax         ;保存原先被除數的低字組
        mov     ax,dx
        sub     dx,dx
        div     cx              ;第一次除法的餘數為第二次除法的高字組
        mov     quotient,ax     ;第一次除法後的商為最後商的高字組,先保存起來
        mov     ax,temp         ;取回原先被除數的低字組當做第二次除法的低字組
        div     cx
        mov     cx,dx           ;第二次除法的餘數就是最後的餘數
        mov     dx,quotient
        ret
division        ENDP
;-------------------------------------------------------------------------------
;把AX內的十六進位數變成兩位數的十進位數字串,BX所指的位址是存放個位數,[BX-1]存
;放十位數。用於處理月份、日、時、分。
two_digits      PROC
        xor     dx,dx
        mov     cx,10
        call    division
        add     cl,"0"
        add     al,"0"
        mov     [bx],cl
        mov     [bx-1],al
        sub     bx,2            ;BX-2預備存放「.」或「:」
        ret
two_digits      ENDP
;-------------------------------------------------------------------------------
;製作像「CHTMSDNX.ISO2537519104 2020.02.21 15:10」的字串,分別是
;    檔名    檔案大小  年份 月 日 時 分
;輸入:buffer-用於儲存此字串
;   dta-檔案資料來源
print   PROC
        LOCAL   digits:WORD     ;位數,例如198是三位數,用於換算成十進位時的位數
        cld
;初始buffer字串,全部填入空白字元
        mov     al," "
        mov     cx,SIZEOF buffer-1      ;扣去「$」,故要減去一
        lea     edi,buffer
        rep     stosb
;把檔名存入buffer的第零個∼第11個位元組內,長度12個位元組
        mov     di,OFFSET buffer
        mov     si,OFFSET dta+30
        mov     cx,13           ;ASCIZ檔名共13位元組
f_name: lodsb
        or      al,al
        jz      f_size
        stosb
        loop    f_name
;把檔案大小存入buffer的第12個∼第21個位元組,長度10個位元組
f_size: mov     si,OFFSET dta+26
        mov     bx,OFFSET buffer+21
        mov     digits,10
        lodsw
        mov     dx,[si]         ;DX:AX=檔案大小,單位是位元組
.WHILE digits
        mov     cx,10           ;除數=CX
        call    division
        add     cl,"0"          ;餘數=CX
        mov     [bx],cl
.BREAK .IF ax==0
        dec     bx
        dec     digits
.ENDW
;把檔案最後修改日期存入buffer的第23個∼第32個位元組
        mov     si,OFFSET dta+22
        mov     ax,[si+2]
        and     ax,11111b
        mov     bx,OFFSET buffer+32
        call    two_digits      ;處理日
        mov     BYTE PTR [bx],"."
        dec     bx
        mov     ax,[si+2]
        shr     ax,5
        and     ax,1111b
        call    two_digits      ;處理月份
        mov     BYTE PTR [bx],"."
        dec     bx
        mov     ax,[si+2]
        shr     ax,9
        add     ax,1980
        mov     digits,4        ;西元年份有四位數
        xor     dx,dx
.WHILE digits
        mov     cx,10
        call    division
        add     cl,"0"
        mov     [bx],cl
        dec     bx
        dec     digits
.ENDW
;把檔案最後修改時間存入buffer的第34個∼第38個位元組
        mov     ax,[si]
        shr     ax,5
        and     ax,111111b
        mov     bx,OFFSET buffer+38
        call    two_digits
        mov     BYTE PTR [bx],":"
        mov     ax,[si]
        dec     bx
        shr     ax,11
        call    two_digits
        ret
print   ENDP
;-------------------------------------------------------------------------------
        .STARTUP
        mov     ax,ds
        mov     es,ax
;呼叫AH=1Ah/INT 21h,設定DTA
        mov     ah,1ah
        mov     dx,OFFSET dta
        int     21h
;呼叫AH=0FH/INT 10H,檢查螢幕是在80x25模式還是40x25模式
        mov     ah,0fh
        int     10h
        cmp     ah,40
        jnz     search
        mov     max_f,23
;呼叫AH=4Eh/INT 21h,尋找第一個符合的檔案
search: mov     ah,4eh
        mov     dx,OFFSET files
        mov     cx,27h          ;尋找具有唯讀、隱藏、系統、保存屬性的檔案
        int     21h             ;尋找第一個符合「*.*」的檔案,其資料存入dta
        jc      ok
nxt:    call    print           ;把要印在螢幕上的字串內容計算出來
        mov     dx,OFFSET buffer
        mov     ah,9
        int     21h
        inc     n_files
        mov     cl,max_f
        cmp     n_files,cl      ;檢查是否已經填滿個螢幕了
        jb      go_on           ;若沒有,跳至go_on:處繼續;若已填滿,繼續下個指令
        mov     dx,OFFSET pause ;印出「Press any key to continue . . .」
        mov     ah,9
        int     21h
        mov     ah,0
        mov     n_files,ah
        int     16h             ;等使用者按下任意鍵
        mov     dx,OFFSET cr_lf
        mov     ah,9
        int     21h             ;換行
go_on:  mov     ah,4fh          ;尋找下一個符合「*.*」的檔案
        int     21h
        jnc     nxt             ;若有找到,跳至nxt:處;若找不到,結束程式
ok:     mov     ax,4c00h
        int     21h
        .EXIT   0
;*******************************************************************************
END

解說

排版方式

不管彩色螢幕或單色螢幕都有許多種顯示模式,但最常用的是底下兩種文字模式:①80×25 與②40×25,因為每個 ASCII 字元佔用一個位元組,所以小木偶打算每個檔案的資料,包含檔名、檔案大小、最後修改的日期與時間,都限制在 40 個字元,也就是說每個檔案的資料有 40 個位元組的空間,不多也不少,其分配方式如下圖,而執行結果就在本章一開始的圖片:

這樣安排還有個好處。小木偶用 AH=09H/INT 21H 顯示檔案資料,這個 DOS 服務程式有兩個特性:①如果要顯示的字串太長,在顯示完一列之後,其餘的部分會在下一列顯示,相當於換行;②假如上次已經在螢幕上顯示到最右側的那一行,那麼下次顯示時,也會到下一列顯示。因為此利用這兩個特性,只要每次顯示 40 個字元,不需要額外程式碼就能排版得整整齊齊的。差別只在於,80×25 模式會顯示兩欄,40×25 模式的只會顯示一欄。

決定一個螢幕最多顯示多少個檔案

雖然排版方式並不會隨 80 行還是 40 行顯示模式改變,但是如果一個目錄裡的檔案太多,必須在顯示 23 列時讓它暫停顯示,等使用者觀察完再繼續。

如果是 80 行模式,23 列會顯示 46 個檔案,而 40 行模式就只能顯示 23 個檔案。小木偶在 LISTFILE.ASM 的第 14 行定義變數 max_f,記錄每個畫面最多顯示幾個檔案,此處先假設使用者是在 80×25 模式之中執行,故先假設為 46。到了程式第 137∼142 行檢查顯示模式之後,再確定 max_f 是 46 還是 23,前者是 80×25 模式,後者是 40×25 模式。

此外,小木偶在程式第 13 行定義一個變數 n_files,記錄已經處理過多少的檔案了。當然 n_files 必須從零開始,每處理過一個檔案,就會使 n_files 增加一 ( 程式第 152 行 ),然後與 max_f 比較,如果 n_files 比較小,就繼續呼叫 AH=4FH/INT 21H 尋找下一個符合的檔案;如果相等,就執行第 156∼164 行之間的程式,包含①印出「Press any key to continue . . .」字串,②等使用者按鍵,③使 n_files 歸零,④換行四件事。

獲得顯示模式:AH=0FH/INT 10H

一般獲得顯示模式的方法是呼叫 AH=0FH/INT 10H,它的用法是

獲得現在的顯示模式 ( get current video mode )
輸入:AH=0FH
執行:INT 10H
返回:AH=每一列的字元個數
   AL=顯示模式 ( 常見的有七種,與第十六章的「設定顯示模式」中的顯示模式相同 )
   BH=目前的顯示頁

呼叫 AH=0FH/INT 10H 之後,AH 暫存器內便會傳回來每一列有多少字元,只有兩種結果 40 或 80。

BH 為目前的顯示頁。視訊記憶體可劃分為幾塊區域,這幾塊區域可視為許多顯示頁,每次顯示在螢幕上的只是其中一頁。利用這個特性,我們可以在 CPU 空閒之餘事先計算待會要顯示在螢幕上的資料,存入下一個顯示頁,等到要顯示時進行顯示頁切換即可,非常快速。例如 IBP PC 配置的是 CGA 顯示卡,上面的視訊記憶體有 16KB,而在 80×25 文字模式下,一個螢幕的畫面需用去 80×25×2=4KB,故可分為四個顯示頁,平常顯示第 0 頁,但程式可以先計算好第 1 頁,等要顯示時切換即可。

但在 LISTFILE.ASM 用不到顯示頁,只要檢查 AH 中每一列的字元個數,在第 139 行檢查 AH 是否為 40,如果是的話,就把 max_f 變為 23;如果不是,就維持原來在第 14 行的預設值,46。

磁碟傳輸區 ( DTA ) 與 程式前置區 ( PSP )

小木偶利用 DOS 服務程式 AH=4EH/INT 21H 或 AH=4FH/INT 21H 取得檔案名稱、檔案大小等資料,這些資料會存放在磁碟傳輸區 ( disk transfer area,縮寫為 DTA ) 中。在程式一開始執行時,DOS 預設的磁碟傳輸區在程式前置區 ( program segment prefix,縮寫為 PSP ) 中,PSP 是一塊 128 個位元組的記憶體,程式一開始執行時,DS、ES 暫存起都指向 PSP。磁碟傳輸區就在 PSP 的偏移位址 0080H 處,這裡同時也是在 DOS 的提示字元後,使用者輸入指令之後,參數存放的地方。可以做底下的實驗,以驗證這個說法。

在 DOS 提示字元下 ( 提示字元就是底下的「E:\DOS\FINDFILE>」) 輸入 ( 本來每輸入一道指令都要按「Enter」鍵,但為了怕誤會,底下都沒寫出「Enter」鍵來 )

E:\DOS\FINDFILE>c:\tools\symdeb listfile.exe /"I love assembly!"
Microsoft (R) Symbolic Debug Utility  Version 4.00
Copyright (C) Microsoft Corp 1984, 1985.  All rights reserved.

Processor is [80286]
-R [Enter]
AX=0000  BX=0000  CX=0186  DX=0000  SP=0400  BP=0000  SI=0000  DI=0000  
DS=20D6  ES=20D6  SS=20FF  CS=20E6  IP=00EC   NV UP EI PL NZ NA PO NC 
20E6:00EC B8F820         MOV	AX,20F8
-D ES:80 L20 [Enter]
20D6:0080  14 20 2F 22 49 20 6C 6F-76 65 20 61 73 73 65 6D  . /"I love assem
20D6:0090  62 6C 79 21 22 0D 00 00-00 00 00 00 00 00 00 00  bly!"...........

上面的例子是用 SYMDEB 載入 LISTFILE.EXE 除錯,雖然 LISTFILE.EXE 不需要參數,但是輸入「 /"I love assembly!"」作為參數未嘗不可。你會看到,DOS 把這些參數原封不動的被 DOS 搬到 PSP:0080,也就是白色字所標示的地方。要說明的是 PSP:0080 這個位址上的位元組是使用者輸入的參數長度,不包含最後的「Enter」( 即 0DH ),單位是位元組。

由以上觀察,可以知道 PSP:0080 處既是指令的參數存放的空間,也是 DTA 所佔用的空間,兩者相衝突,再者為了方便可以直接以 DS 存取 AH=4EH/INT 21H 或 AH=4FH/INT 21H 傳回來的資料,因此小木偶呼叫 AH=1AH/INT 21H 重新設置 DTA,見 LISTFILE.ASM 的第 130∼135 行。

解說 print 副程式

print 副程式是把 DTA 內的資料「印」在 buffer 字串內,這些資料包含三項:①檔名、②檔案大小、③檔案最後修改的日期與時間。

由於檔名長度並不固定,因此如果前一個檔名太長而後一個檔名太短,就會在 buffer 內留下上個檔案的殘存資料。要避免發生這種情況,可以在填入檔名之前先填入空白,見第 59∼63 行。接下來的第 64∼72 行,把檔名由 DTA 的第 30 個位元組開始搬移到 buffer 字串的起頭。檔名的長度,包含主、副檔名、「.」及最後一個 0,最多 13 個位元組,但是只要檢測到 0,就表示檔名已經結束了。

第 73∼87 行把檔案大小存入 buffer 的第 12∼21 個位元組,共十個位元組。DOS 僅支援 FAT12 與 FAT16 檔案系統,單一個檔案最大可達 4GB,有十位數。這段程式的原理可參考第七章,這些數值是由個位數、十位數……往大數填,但是在記憶體位址卻是往低位址填,所以你會看到第 75 行,BX 先指向 buffer 的第 21 個位元組。

第 88∼114 行是計算檔案最後修改的日期,此日期會被填入 buffer 的第23個∼第32個位元組,與檔案大小中間隔一個空白。日期格式是「YYYY.MM.DD」,西元年四位、月份、日各兩位,中間以「.」隔開。跟檔案大小一樣,也是先指向高位址,先處理日的個位數、十位數,再計算月份、西元年份。

第 115∼125 行是計算檔案最後修改的時間,其格式是「HH:MM」,跟上面處理檔案最後修改的日期差不多,小木偶就不再贅述。綜觀這兩項,你會發現月份、日、時、分都是兩位數,因此處理方式類似,差別在於分隔符號。所以小木偶特地為了這四個數值撰寫處理兩位數的副程式,two_digits。