Ch 36 中斷


中斷

中斷簡介

眾所皆知,電腦是用來處理資料的,我們將許多資料輸入電腦,然後經由電腦中 CPU 的強大運算功能,獲得我們所要的結果。然而在電腦工作時,有時仍然會有許多重要的資料必須立即接收處理,否則會造成資料的遺失,例如,電腦正工作時,使用者突然按下鍵盤上的按鍵,這時電腦得優先處理使用者的按鍵,否則資料就流失了。

一般電腦線路設計之初就會考慮這個問題,大致可以用兩種方式解決這個問題。第一種是技巧是採用輪詢 ( polling ) 的方式,電腦每隔一段時間查詢各個周邊設備是否有資料,假如使用這種方式的話,周邊設備也需要設計一個額外的緩衝區,把資料存在該處,等電腦查詢時再交由電腦取出處理。第二種技巧是中斷,當電腦的周邊設備有資料產生時,周邊設備會通知 CPU,而 CPU 會先擱下目前的工作並把當時的狀態存起來,然後處理這些資料,待處理完成後,再繼續被打斷的工作。IBM PC 及其相容電腦採取中斷的方式處理突如其來的資料。事實上,在日常生活中,也有很多這樣的例子。例如在吃飯時,突然電話鈴聲響起,我們就會先放下筷子,接聽電話,等通話完畢後,再繼續吃飯。接聽電話這個動作,便像是中斷一般。

在 IBM PC 及其相容電腦能處理 256 個中斷,從第零號到 0FFH。這些中斷處理來自各種不同的周邊設備或者是來自軟體的要求,在 IBM PC 及其相容電腦的中斷可分為兩種:

  1. 軟體中斷 ( software interrupt ):由應用程式執行到 INT 指令所產生的中斷。這類中斷幾乎都是作業系統、BIOS 等提供的服務程式,用來執行應用程式所要求的功能,例如在螢幕上印出文字等等。這類中斷最為常用的是 MS-DOS 所提供的 INT 21H,其他還有 BIOS 所提供的 INT 16H、INT 10H 等等。
  2. 硬體中斷 ( hardware interrupt ):由各種周邊設備的硬體所產生的中斷,例如鍵盤、磁碟機、計時器……,甚至 CPU 自己也會產硬體中斷。( 詳細情形,請參考註二 )

不管是軟體中斷或硬體中斷,它們本身其實都是一個個的程式,稱為中斷服務程式 ( interrupt service routine,ISR ),用來處理特定的功用,那麼 CPU 是如何得知各個中斷服務程式在那一個位址,好讓 CPU 在處理中斷時跳躍至該位址呢?原來每次一開機時,BIOS、DOS 或其他驅動程式等,會在記憶體位址 0000:0000 到 0000:03FF ( 以 16 進位表示 ) 之間的 1KB 堙A設定中斷向量表 ( interrupt vector table ),底下是小木偶電腦以 MS-DOS 開機後,用 SYMDEB 觀察 0000:0000 處的記憶體內容:

-d 0:0 L30
0000:0000  57 92 19 00 F4 06 70 00-16 00 E7 0D F4 06 70 00  W...t.p...g.t.p.
0000:0010  F4 06 70 00 54 FF 00 F0-BF 00 00 F0 67 00 00 F0  t.p.T..p?..pg..p
0000:0020  3C 00 E7 0D 45 00 E7 0D-57 00 E7 0D 6F 00 E7 0D  <.g.E.g.W.g.o.g.
-

不同的電腦,因 BIOS、DOS 版本和載入驅動程式不同,在這 1KB 的中斷向量表堛漱漁e不盡相同,不過只要是 IBM PC 及其相容電腦的中斷向量表必存於 0000:03FF 處而且每個中斷都是用一個雙字組 ( 即兩個字組或四個位元組 ) 來表示:
  第零號中斷即由 0000:0000 到 0000:0003,其內容為 0019:9257,
  INT 1 由 0000:0004 到 0000:0007,其內容為 0070:06F4,
  INT 2 由 0000:0008 到 0000:000C,其內容為 0DE7:0016,
  ……,
在中斷向量表中,因為每一個中斷都需要四個位元組的空間,因此我們很容易找到第 n 號中斷 ( INT n ) 是在記憶體中的那一個位址:

INT n 在位址 0000:(4n) 處

INT 與 IRET 指令的過程

每一中斷的雙字組中,較高位址的字組是表示該中斷的區段位址,低位址表示偏移位址,這些位址是發生中斷後,CPU 會經由一個長程呼叫而到該位址繼續執行。假如要再講得更詳細些,當發生第 n 號中斷時,INT n 指令會使 CPU 依序執行下面動作:

  1. 把旗標暫存器推入堆疊
  2. 禁止其他中斷發生
  3. 清除陷阱旗標
  4. 把 CS 暫存器推入堆疊
  5. 把 INT n 的下一指令位址推入堆疊
  6. 由 0000:(4n) 位址取出中斷服務程式所在位址,並執行長程跳躍指令,至該處繼續執行

由上面看來,其實 INT 指令跟 CALL 指令很相像,所不同的是 INT 指令會把旗標暫存器推入堆疊,並且會清除中斷旗標及陷阱旗標 ( 禁止其他中斷發生也禁止單步追蹤 ),但是 CALL 不會把旗標暫存器推入堆疊,也不會清除中斷旗標及陷阱旗標。在中斷服務程式之中,該做那些必要的事呢?首先是要恢復允許其他中斷發生,原因是系統計時器會呼叫 INT 8H,因此不趕緊恢復可以開啟中斷會造成計時不準確,甚至引起當機。第二是用 PUSHA 指令在堆疊中保存各暫存器之數值,以便中斷服務程式結束返回原程式時,仍保有原來的狀態,當然了,如果某些暫存器是必須當作返回值供原程式參考的暫存器又另當別論了。第三是即將返回原程式時,用 POPA 指令自堆疊取回各暫存器之值,同時要注意,中斷服務程式無法把旗標當成返回值傳回給原來的程式。

而自中斷返回原程式時,就必須自堆疊取回原程式的下一指令位址,同時還要在堆疊取回旗標暫存器,因此中斷結束時不用 RET 指令而改用 IRET 指令。IRET 指令使 CPU 執行下面兩件事:

  1. 由堆疊中彈出一雙字組,並把控制權交到該雙字組所指位址
  2. 由堆疊彈出旗標暫存器

底下小木偶舉一個可能是系統中最簡單的中斷,INT 1CH,來說明 INT、IRET 的過程。INT 1CH 其實是一個空的中斷,它就只有一個指令 IRET,亦即發生此中斷後,立即返回。INT 1CH 其實是被 INT 8H 所呼叫的一個中斷,它是預留給常駐程式使用。INT 1CH 的程式位址可在中斷向量表 0000:0070 處找到 ( 1CH*4=70H ),我們用 SYMDEB 觀察並做實驗,底下黃色的文字是小木偶輸入的指令,白色的雙字組就是 INT 1CH 程式的位址:

-d 0:70 L10 [Enter]  →檢查 INT 1CH 位址
0000:0070  53 FF 00 F0 A4 F0 00 F0-22 05 00 00 E6 2F 00 C0  S..p$p.p"...f/.@
-u f000:ff53 [Enter]           →看看 INT 1CH 的程式碼
F000:FF53 CF             IRET     →INT 1CH 的程式碼就只有一條,IRET
F000:FF54 E933EA         JMP    E98A
F000:FF57 0000           ADD    [BX+SI],AL
F000:FF59 284329         SUB    [BP+DI+29],AL
F000:FF5C 3230           XOR    DH,[BX+SI]
F000:FF5E 3030           XOR    [BX+SI],DH
F000:FF60 41             INC    CX
F000:FF61 4D             DEC    BP
-a [Enter]     →編寫一程式,僅執行 INT 1CH
1F4C:0100 int 1c [Enter]
1F4C:0102 [Enter]
-rip 100 [Enter] →設定 IP 暫存器之值
-r [Enter]     →先觀察各暫存器之值
AX=0000  BX=0000  CX=0000  DX=0000  SP=DFA3  BP=0000  SI=0000  DI=0000
DS=1F4C  ES=1F4C  SS=1F4C  CS=1F4C  IP=0100   NV UP EI PL NZ NA PO NC
1F4C:0100 CD1C           INT    1C
-t [Enter]     →追蹤該程式,已進入 INT 1CH 堶惜F
AX=0000  BX=0000  CX=0000  DX=0000  SP=DF9D  BP=0000  SI=0000  DI=0000
DS=1F4C  ES=1F4C  SS=1F4C  CS=F000  IP=FF53   NV UP DI PL NZ NA PO NC
F000:FF53 CF             IRET
-d ss:df9d L20 [Enter] →觀察堆疊
1F4C:DF90  00 00 00 00 00 00 00 00-00 00 00 00 00 02 01 4C  ...............L
1F4C:DFA0  1F 02 F2 00 00 00 00 00-00 00 00 00 00 00 00 00  ..r.............
-t [Enter]
AX=0000  BX=0000  CX=0000  DX=0000  SP=DFA3  BP=0000  SI=0000  DI=0000
DS=1F4C  ES=1F4C  SS=1F4C  CS=1F4C  IP=0100   NV UP EI PL NZ NA PO NC
1F4C:0102 8ED8           MOV    DS,AX
-

在上面的實驗堙A當執行 INT 1C 後,旗標暫存器、INT 1C 下一位址 ( 返回位址之 CS:IP ) 都會被推入堆疊,這一點可以經由觀察堆疊堛滷“帢o知 ( 藍色是旗標暫存器,墨綠色部份是返回位址,旗標暫存器的值是 F202H,請參考 註一 ),除此之外,INT 指令也會把旗標暫存器的中斷旗標設為 DI ( 紅色部份 )。一進入 INT 1C 後,程式位址變成存於中斷向量表的位址了 ( 白色部份 ),INT 1C 什麼事也沒做就結束中斷,所以只有一條指令 IRET,再度追蹤,發現果然又回到 1F4C:0102 處,同時恢復旗標暫存器成為進入中斷前的狀況。

修改中斷向量表

為什麼要修改中斷向量表呢?因為 IBM 及微軟並沒有使用所有的中斷,換句話說,仍有許多中斷是未使用的,可以供使用者或其他廠商使用,例如 INT 33H 現在被公認是使用滑鼠的服務程式。除此之外,還有兩個理由,一個是 IBM 或微軟提供的中斷不好用,想修改它;或是為了撰寫常駐程式 ( TSR,terminate and stay resident )。想修改中斷向量表,有兩種方法,第一種是使用 DOS 提供的中斷服務程式;第二種方法是直接修改 0000:0000 到 0000:03FF 處的記憶體。

取得中斷位址 ( AH=35H/INT 21H ) 與設定中斷位址 ( AH=25H/INT 21H )

先說第一種方法,MS-DOS 的中斷服務程式 INT 21H 提供了兩個功能,一個用來取得某中斷位址 ( 用 AH=35H/INT 21H ),一個用來設定某中斷位址 ( 用 AH=25H/INT 21H )。小木偶整理如下表:

取得中斷位址設定中斷位址
AL=欲取得位址的中斷編號
AH=35H
執行 INT 21H
返回後:
ES:BX=中斷位址
AL=欲設定位址的中斷編號
AH=25H
DS:DX=新的中斷程式位址
執行 INT 21H
返回後:

直接修改中斷向量表

前面已經提過,每一個中斷程式的位址都記錄在記憶體 0000:03FF 位址處,而每一個中斷程式的位址都佔據一個雙字組,因此很容易就能計算出該中斷位址,進而修改它,方法雖然簡單,但是卻得小心。假如您要修改 INT 9H 中斷位址,可不能這樣做:

        mov     es,0                                ;預先使用 .386 才可直接更改 ES
        mov     word ptr es:[24h],offset new_int9h  ;設定新的 INT 9H 偏移位址
        mov     word ptr es:[26h],seg new_int9h     ;設定新的 INT 9H 區段位址

原因是,事實上,有些中斷是硬體自行產生,如果當上面修改中斷位址的程式已執行完第二個 mov 指令,但第三個 mov 指令尚未執行時,使用者剛好按下某一鍵,這時便會發生 INT 9H,但是這時的中斷向量表的 INT 9H 的位址其實是錯的,系統便會當機。因此改成下面的樣子:

        mov     es,0
        cli                                         ;禁止中斷
        mov     word ptr es:[24h],offset new_int9h
        mov     word ptr es:[26h],seg new_int9h
        sti                                         ;可以發生中斷

上面的做法可以適用在大部分的情形下,但是 CLI 指令無法禁止 NMI 的發生,因此可能還是會產生當機的危險,正確的程式應該是像下面的程式:

new_key dw      offset new_int9h,seg new_int9h
        ……
        mov     es,0
        mov     di,24h
        mov     si,offset new_key
        cld
        cli
        movsd
        sti

上面的程式把新的 INT 9H 的偏移位址和區段位址設定成一個兩字組的陣列,然後以 MOVSD 指令做搬移,MOVSD 僅為一個指令,即使是 NMI 也無法在指令正執行尚未完成時發生,這樣就可避免中斷的干擾了。不過為了省卻計算中斷服務程式在中斷向量表的位址,大部分的情形還是用 AH=25H/INT 21H 來修改中斷向量表。


改善 INT 0

前一章曾提到當 CPU 做除法運算,如果發生除以零或是商數太大,會產生無意義或溢位錯誤,CPU 會自動引發 INT 0,在螢幕上印出『Divide overflow』,然後終止程式。這種處理錯誤的方法並不完善,你想假使秘書小姐已輸入許多資料,並做運算,萬一有一筆資料發生除以零的錯誤,而她沒有事先儲存,結果程式就結束了,她所輸入的資料不就泡湯了嗎?如果有這樣不處理 INT 0 的商業軟體,想必沒人會去買。本章前面已經介紹了一些中斷的概念,如果對 INT 0 了解 ( 參考註二 ),就可以做簡單的修改。

底下的程式,NEW_INT0.ASM,是對 INT 0 做簡單的修改。這個程式做三個 8 位元的除法運算:4096÷0、4096÷10、4096÷32,很明顯的第一個是除以零,結果為無意義,第二個除法的商數超過 255,顯然會造成溢位。假如 INT 0 不曾修改,那做完第一個除法後,NEW_INT0 便會終止並返回 DOS。但是在 NEW_INT0.ASM 中,小木偶修改了 INT 0,因此除以零僅會印出『Divide by Zero!』字串,程式還能繼續執行下去,而且溢位也不是問題,NEW_INT0.ASM 仍能算出正確的商數和餘數,並且印出來。請看執行的結果:

E:\HomePage\SOURCE>new_int0 [Enter]
4096/0=Divide by Zero!
4096/10=409...6
4096/32=128

E:\HomePage\SOURCE>

完整的 NEW_INT0.ASM 原始碼如下,把它存成 NEW_INT0.ASM,經組譯、連結並轉換成 NEW_INT0.COM 可執行檔。

;新的 INT 0-此程式可以檢查除以零的錯誤,而不會終止程式
        page    ,132
;***********************************************************
        .386
code    segment use16
        assume  cs:code,ds:code
        org     100h
;-----------------------------------------------------------
start:  jmp     begin
old_int0        dw      ?,?
message         db      'Divide by Zero!'
n               dw      1000h           ;1000h=4096d
d1              db      0
d2              db      10
d3              db      20h
buffer          db      30h dup (' ')
message_len     equ     offset n-offset message
;-----------------------------------------------------------
new_int0        proc    near
        sti
        mov     bx,sp
        add     word ptr ss:[bx],div_len
        mov     bx,0ffffh
        iret
new_int0        endp
;-----------------------------------------------------------
hex2dec proc    near
;把EDX內所存的十六進位有號數變成十進位有號數,存於DI所指的位址
        jmp     short h2d0
tmp     db      10 dup (' ')
h2d0:   test    edx,80000000h   ;檢查是否為負數
        jz      h2d1
        mov     al,'-'          ;若為負數,則取其2的補數,並
        neg     edx             ;於DI所指位址存入負號
        stosb
h2d1:   push    di
        mov     ebx,10          ;使EDX每次除以10,所得的餘數
        mov     eax,edx         ;分別為十進位的個位數、十位數、
        mov     di,offset tmp+9 ;百位數……,把這些餘數暫時存於
        mov     cx,bx           ;tmp字串中(由高位址開始存)
h2d2:   sub     edx,edx
        div     ebx
        add     dl,'0'
        mov     [di],dl
        or      eax,eax
        jz      h2d3
        dec     di
        loop    h2d2
h2d3:   mov     si,di           ;把tmp字串中的每一位數,存入
        pop     di              ;DI所指位址
        sub     bx,cx
        mov     cx,bx
        inc     cx
        rep     movsb
        ret
hex2dec endp
;-----------------------------------------------------------
division        proc    near
        sub     bh,bh           ;使BX高位元組為零,因為除數僅用 BL
        push    ax              ;呼叫hex2dec時,AX、BX均會被破壞,
        push    bx              ;故先儲存於堆疊
        sub     edx,edx
        mov     dx,ax
        call    hex2dec         ;計算被除數之十進位ASCII字元
        mov     al,'/'
        stosb
        pop     dx
        push    dx
        call    hex2dec         ;計算除數之十進位ASCII字元
        mov     al,'='
        stosb
        pop     bx
        pop     ax
        or      bl,bl
        jz      divi2           ;檢查是否除以零,若是則跳至divsi2
        push    bx
addr_d: div     bl
addr_n: cmp     bx,0ffffh       ;檢查是否商數太大,無法放到AL暫存器
        pop     bx              ;,若太大則除法溢位,跳躍至divi3處理
        je      divi3
div_len equ     offset addr_n-offset addr_d
        mov     dl,al
divi0:  push    ax
        call    hex2dec         ;計算商數的十進位ASCII字元
        pop     ax
        or      ah,ah
        jz      divi1
        mov     dl,ah
        mov     ax,2e2eh
        stosw
        stosb
        call    hex2dec         ;計算餘數的十進位ASCII字元
divi1:  mov     ax,0a0dh
        stosw
        mov     al,'$'
        stosb
        mov     dx,offset buffer
        mov     ah,9
        int     21h
        ret

divi2:  mov     si,offset message
        mov     cx,message_len  ;除以零,則印出『Divide by Zero!』
        rep     movsb
        jmp     divi1

divi3:  sub     cx,cx           ;除法溢位,則改用連續減法代替除法
        mov     bh,0            ;BX=除數,AX=被除數
divi4:  cmp     ax,bx           ;計算完後,CX=商數,AL=餘數
        jbe     divi5           ;若連續減法減至AX比除數小,表示除法已完成
        sub     ax,bx           ;,除法完成後跳躍至divi5,否則繼續減去除數
        inc     cx
        jmp     divi4
divi5:  mov     dx,cx
        mov     ah,al           ;因8位元的DIV指令,所得餘數在AH,故把
        jmp     divi0           ;AH設為AL,再跳躍至divi0
division        endp
;-----------------------------------------------------------
begin:  push    es
        mov     ax,3500h        ;儲存舊的 INT 0 位址
        int     21h
        mov     old_int0,bx
        mov     [old_int0+2],es
        mov     dx,offset new_int0
        mov     ax,2500h
        int     21h
        pop     es

        mov     ax,n
        mov     bl,d1
        mov     di,offset buffer
        call    division        ;計算 100h/0

        mov     ax,n
        mov     bl,d2
        mov     di,offset buffer
        call    division        ;計算 1000h/10=409d...6

        mov     ax,n
        mov     bl,d3
        mov     di,offset buffer
        call    division        ;計算 1000h/20h=128d

        push    ds
        mov     dx,word ptr old_int0
        mov     ds,word ptr [old_int0+2]
        mov     ax,2500h        ;恢復原來的 INT 0
        int     21h
        pop     ds

        mov     ax,4c00h        ;結束程式
        int     21h
;-----------------------------------------------------------
code    ends
;***********************************************************
        end     start

NEW_INT0.ASM 最主要的地方在於 new_int0 副程式,雖然這個副程式僅僅五行,它卻改變了除法錯誤就終止程式的不完美之處。這五行指令做三件事,第一是可重新允許中斷發生。第二件事是改變在堆疊堙A返回原程式的位址。前面提過,當執行 INT 指令時,會依序把旗標暫存器、發生中斷時下一個指令的位址存入堆疊,以保證從中斷程式返回時,能找到原程式發生中斷時的地方繼續執行。但是,對於 INT 0 這個中斷很奇怪,這個中斷是發生在除以零或除法溢位時,如果 DIV 指令除以零或商數太大而溢位,CPU 並不是把 DIV 下一指令的位址推入堆疊,反而是把 DIV 指令所在位址推入堆疊。為什麼會這樣?這一點小木偶也不清楚,如有前輩高人,能提供意見則感激不盡。

DIV 指令的位址是由 CS:IP 所組成,CPU 先把旗標暫存器及 CS 暫存器依序推入堆疊,再把 IP 推入堆疊。SP 暫存器先是指向堆疊頂端,每推入一筆字組長度的資料到堆疊,SP 暫存器便減少 2,因此我們要修改的就是 SP 所指的位址,至於要改成多少呢?這和 DIV 的除數有關,除數可以是暫存器或是記憶體變數,但是它們所組成的機械碼長度不同,因此小木偶用 div_len 來表示 DIV 指令所佔機械碼長度,只要把 SS:SP 所指位址內之數值加上 div_len 之值,就是 DIV 指令下一位址。至於 div_len 可以由 DIV 下一指令位址減去 DIV 指令位址而得到,見 division 副程式中的程式片段:

        or      bl,bl
        jz      divi2           ;檢查是否除以零,若是則跳至divsi2
        push    bx
addr_d: div     bl
addr_n: cmp     bx,0ffffh       ;檢查是否商數太大,無法放到AL暫存器
        pop     bx              ;,若太大則除法溢位,跳躍至divi3處理
        je      divi3
div_len equ     offset addr_n-offset addr_d

在新的 INT 0H 中斷中所作的第三件事情是如果發生溢位時,把 BX 設為 0FFFFH 做為返回值,原程式只要檢查 BX 是否為 0FFFFH 就知道是否發生溢位。當然了,如果會進入 INT 0 的程式,顯然就會產生溢位,所以在 INT 0 程式堙A不需要檢查。至於除以零的情形則在 addr_d: 標記上三行的指令,『OR BL,BL』,去檢查除數是否為零。整理一下吧!NEW_INT0.ASM 對於除以零的錯誤,是事先檢查除數是否為零;而對於除法溢位的情形,是修改 INT 0 中斷,使它能運算。讀者應該要知道,在數學是上沒有什麼溢位的事發生,因此什麼除法溢位其實是可以算出答案來的。


註一:在進入 INT 1CH 之前,由 SYMDEB 觀察各旗標是 NV UP EI PL NZ NA PO NC,溢位旗標為 NV 故 NF=0、方向旗標為 UP 故 DF=0、中斷旗標為 EI 故 IF=1、符號旗標為 PL 故 SF=0、零旗標為 NZ 故 ZF=0、輔助進位旗標為 NA 故 AF=0、同位旗標為 PO 故 PF=0、進位旗標為 NC 故 CF=0,對照旗標暫存器的各欄位,請看下圖:

因此得到 ____ 0010 00_0 _0_0。

註二:事實上,硬體中斷可分為兩種,一種是內部中斷,另一種是外部中斷。內部中斷是指由 CPU 自行產生的中斷,例如除以零或除法溢位、除錯程式中的單步追蹤或執行到中斷點。外部中斷則是其他的周邊設備產生的訊號,通知 CPU 發生的中斷,外部中斷又可再細分為可遮罩中斷 ( maskable interrupt ) 和不可遮罩中斷 ( non-maskable interrupt,NMI ) 兩種。可遮罩中斷是指可以被 CPU 指令禁止或允許的中斷,當發生可遮罩中斷時,CPU 會先檢查旗標暫存器中的中斷旗標 ( IF ),當中斷旗標為 1 時,CPU 可接受外來的中斷訊號;反之,若中斷旗標為 0 時則不接受中斷要求。8086 指令集堶惘陪茷令 STI 可以設定中斷旗標變為 1,允許可遮罩中斷執行中斷服務程式;另一個指令,CLI 可以清除中斷旗標變為 0,不允許可遮罩中斷執行中斷服務程式。

不可遮罩中斷 ( NMI ) 則是無法被 CPU 指令禁止或允許的中斷,亦即只要發生了 NMI,不管中斷旗標是否設定或清除,中斷都會被 CPU 執行。只有嚴重的錯誤發生時才會產生 NMI,例如 8087 錯誤、記憶體的同位元錯誤 ( parity error )、I/O 通道錯誤,這些錯誤發生時,若不處理 ( 其實大部分的處理方式也只能送修了 ),所得的資料也是錯誤的,即使 CPU 能處例這些資料,所得結果也是無意義的。

底下小木偶對硬體中斷做一簡單描述。


回到首頁到第三十五章到第三十七章