Ch 15 控制台程式

Windows 作業系統能成為大多數人使用的作業系統,不可諱言的,圖形界面的操作方便及親和力是重要的原因。雖然現在大部分的 Win32 程式都已有漂亮的圖形界面,但是我們仍然可以使用或撰寫以文字導向操作方式的程式。例如,在 Windows XP 中提供『命令提示字元』執行以文字畫面輸出或輸入的程式,就像以前的 DOS 程式一樣。

這些程式雖然沒有漂亮的圖形界面,但是小而快速是其優點,所以仍然有許多人還在使用著這些程式,像是 ping、ipconfig、ftp 等程式都是常常被使用的。這些程式仍然是 32 位元的程式,它們也都屬於 PE 格式,也能執行多執行緒,它們除了沒有華麗的視窗外,和一般的 Win32 程式一樣,這些程式稱為『控制台程式』( console program,或稱『主控台程式』),讀者須知,他們和以前的 DOS 程式是不同的。這一章將介紹如何撰寫控制台程式。


原理

在 16 位元的 DOS 系統裏,螢幕與鍵盤被視為標準輸出輸入設備,它們是在 DOS 啟動時,由 DOS 開啟,DOS 把它們視為檔案,其檔案代碼早已分配且固定不變 ( 參閱組合語言第 15 章 )。當我們的程式要對 DOS 控制下的螢幕鍵盤做輸出或輸入時,可對這些『檔案代碼』做輸出或輸入動作。例如,對標準輸出設備 ( 檔案代碼為 01H ) 寫入,意即在螢幕上輸出文字。

Win32 系統不一樣的地方在標準輸出輸入代碼不固定,因此在做輸入或輸出之前要先取得它們的代碼 ( handle ),這項工作可以用 GetStdHandle API 來做,GetStdHandle 的原型是

HANDLE GetStdHandle(
  DWORD nStdHandle  // input, output, or error device
);

這個 API 根據 nStdHandle 的不同,在 EAX 裏傳回標準輸出、輸入或錯誤代碼,nStdHandle 可以是下面三個數值的其中之一:

  1. STD_INPUT_HANDLE:取得標準輸入代碼。
  2. STD_OUTPUT_HANDLE:取得標準輸出代碼。
  3. STD_ERROR_HANDLE:取得標準錯誤代碼。

如果要輸入資料可以用 ReadFile 或 ReadConsole。ReadConsole API 的原型是:

BOOL ReadConsole(
  HANDLE  hConsoleInput,        // handle to a console input buffer
  LPVOID  lpBuffer,             // address of buffer to receive data
  DWORD   nNumberOfCharsToRead, // number of characters to read
  LPDWORD lpNumberOfCharsRead,  // address of number of characters read
  LPVOID  lpReserved            // reserved
);

hConsoleInput 是之前用 GetStdHandle API 的傳回值,此傳回值當然得用 STD_INPUT_HANDLE 為參數呼叫 GetStdHandle 所得之結果。lpBuffer 是指緩衝區位址,使用者輸入的資料會存在這個緩衝區裏,同時系統還會在資料的最後加上換行及歸位碼 ( 0ah,0dh )。nNumberOfCharsToRead 是指緩衝區容量。lpNumberofCharsRead 是一個指標,這個指標應該指向一個雙字組 ( 32 位元長 ) 變數,當 ReadConsole 結束時會在這個雙字組變數裏存放實際有幾個位元組被放入緩衝區裏,此位元組數包含了換行及歸位兩位元組。。如果使用者輸入的位元組數 ( 包含 0dh、0ah ) 比 nMumberOfCharsToRead 大,那麼超過的部份,雖然會顯示在螢幕上,但是卻不會存放在緩衝區裏。

如果要在控制台上輸出文字,則用 WriteConsole,其原型為:

BOOL WriteConsole(
  HANDLE  hConsoleOutput,        // handle to a console screen buffer
  CONST   VOID *lpBuffer,        // pointer to buffer to write from
  DWORD   nNumberOfCharsToWrite, // number of characters to write
  LPDWORD lpNumberOfCharsWritten,// pointer to number of characters written
  LPVOID  lpReserved             // reserved
);

WriteConsole 的參數和 ReadConsole 類似,小木偶就不多做介紹了。底下是一個簡單的控制台程式:

        .586
        .model  flat,stdcall
        option  casemap:none

include         windows.inc
include         kernel32.inc
include         user32.inc
includelib      kernel32.lib
includelib      user32.lib

BufferSize      equ     10h
;***********************************************************
                .data
hOutput         HANDLE  ?
hInput          HANDLE  ?
dWrittee        DWORD   ?
dRead           DWORD   ?
sName           BYTE    '請輸入您的姓名:'
sBuffer         BYTE    BufferSize dup (0)
sHowAreYou      BYTE    ',您好嗎?'
;***********************************************************
        .code
start:  invoke  GetStdHandle,STD_OUTPUT_HANDLE
        mov     hOutput,eax
        invoke  GetStdHandle,STD_INPUT_HANDLE
        mov     hInput,eax

        invoke  WriteConsole,hOutput,offset sName,16,offset dWritten,NULL
        invoke  ReadConsole,hInput,offset sBuffer,BufferSize-2,offset dRead,NULL
        mov     edi,offset sBuffer
        mov     esi,offset sHowAreYou
        mov     al,0dh
        mov     ecx,dRead
        repne   scasb
        jne     @f              ;沒找到0dh,表示輸入字元比BufferSize大
        dec     edi             ;找到了0dh,表示輸入字元比BufferSize小
        jcxz    tail            ;若CX=0,表示0dh恰好是緩衝區的最後一字元
        dec     dRead
tail:   dec     dRead
@@:     mov     ecx,10          ;sHowAreYou 字串長度
        mov     edx,ecx
        rep     movsb
        add     edx,dRead
        invoke  WriteConsole,hOutput,offset sBuffer,edx,offset dWritten,NULL
        invoke  ExitProcess,NULL
;***********************************************************
        end     start

把它存成 CONSOLE1.ASM 後再以下面步驟組譯:

  1. 開啟 Windows XP 的命令提示字元,並切換到 CONSOLE1.ASM 所在子目錄。此處假設 CONSOLE1.ASM 在 E:\HomePage\SOURCE 子目錄裏,因此輸入底下指令:
    C:\>cd e:\homepage\source [Enter]
    C:\>e: [Enter]
    E:\HomePage\SOURCE>
  2. 設定路徑,使其指向 ML.EXE 及 LINK.EXE 所在子目錄。例如小木偶的電腦中,組譯器及連結器在 C:\masm32\bin 子目錄裏,在命令提示字元中輸入:
    E:\HomePage\SOURCE>path C:\masm32\bin;%path% [Enter]
  3. 設定連結時產生控制台程式,這一行指令牽涉到 ML.EXE 的選擇參數,須注意大小寫,否則會產生錯誤。此外,注意製作系統參數用『/SUBSYSTEM:CONSOLE』,小木偶有好幾次都是因為這個參數下錯了,而無法連結成功。在命令提示字元中輸入:
    E:\HomePage\SOURCE>set ml=/coff /link /SUBSYSTEM:CONSOLE [Enter]
  4. 最後組譯及連結原始檔:
    E:\HomePage\SOURCE>ml console1.asm
    就大功告成了。

改變控制台外觀

控制台的螢幕緩衝區

或許各位讀者曾經注意到,XP 的『命令提示字元』右側有一捲軸,可以向上捲動觀察不在螢幕上的資料,的確,這點和 Windows 95/98/98SE/Me 的 DOS 模式或 DOS 系統是不同的。在 XP 裏,作業系統會為『命令提示字元』分配一塊資料區域,稱為『控制台螢幕緩衝區』( console screen buffer ),這塊區域存放著曾經或正顯示在命令提示字元視窗的資料,而『命令提示字元』的視窗所能見到的只是其中的一部份。就像我們由房間向窗戶往外望去,只能看到美麗風景的一小部份一樣,假如窗戶可向上移或下移,那麼就可能可以看見其他部份的風景了。只不過在電腦上,並不是視窗移動,而是用捲軸把要顯示的資料移進視窗的範圍內。

在 XP 系統中,也可以不用『命令提示字元』來做為文字導向界面,另有一家 JP Software 所出品的 4NT 也是常見的文字導向界面,假設您以 4NT 為文字導向界面的話,就不稱為『命令提示字元視窗』了,應該稱為『4NT 視窗』。不管是『命令提示字元視窗』或是『4NT 視窗』,都只顯示『控制台螢幕緩衝區』的一部份文字而已。

XP 利用 CONSOLE_SCREEN_BUFFER_INFO 結構體記錄控制台螢幕緩衝區的資料,底下是它的各欄位的說明:

CONSOLE_SCREEN_BUFFER_INFO  STRUCT
dwSize                      COORD       ?       ;控制台螢幕緩衝區大小
dwCursorPosition            COORD       ?       ;游標位置
wAttributes                 WORD        ?       ;屬性
srWindow                    SMALL_RECT  ?       ;控制台視窗位置在螢幕緩衝區那兒
dwMaximumWindowSize         COORD       ?       ;控制台視窗可能最大範圍
CONSOLE_SCREEN_BUFFER_INFO  ENDS

顧名思義,dwSize 是控制台螢幕緩衝區的大小,以行與列來表示,記錄於 COORD 結構體,其定義為:

COORD   STRUCT
x       WORD      ?
y       WORD      ?
COORD   ENDS

x 表示橫座標有幾個字元,y 則表示縱座標有幾個字元。x 亦可看成一列 ( 由左而右 ) 有幾個字元,y 也可以看成共有幾列。一般而言,開啟『命令提示字元』後如果沒有特別更改,每列可顯示 80 個字元,共有 300 列。

dwCursorPosition 表示目前游標在那一個位置,以 COORD 結構體表示,此處的 x、y 由零開始。所以一般 x 介於 0 到 79 之間。

wAttributes 是字元屬性 ( 亦即顏色 ),可以是 FOREGROUND_BLUE、FOREGROUND_GREEN、FOREGROUND_RED、FOREGROUND_INTENSITY、BACKGROUND_BLUE、BACKGROUND_GREEN、BACKGROUND_RED 和 BACKGROUND_INTENSITY 等保留字的組合。其實他們和在 DOS 文字模式中的字元屬性定義相同,文字的顏色,藍、綠、紅及強度以一個字組的最低四個位元 ( 第 0~3 位元 ) 來表示;文字的背景也是以藍、綠、紅及強度,以該字組的第 4~7 位元表示。這些數值所代表的顏色,可以參考組合語言第 19 章,以低亮度白色顯示於黑色背景為例,此字組值為 7。( 白色是藍、綠、紅三色組成,黃色是藍、綠兩色組成……)

前面曾提及,『命令提示字元』視窗所能顯示的僅為控制台螢幕緩衝區的一部份,而 srWindow 就是用來表示所顯示的部份在那一位置。srWindow 是一 SMALL_RECT 結構體,其欄位說明如下:

SMALL_RECT  STRUCT
Left        WORD    ?
Top         WORD    ?
Right       WORD    ?
Bottom      WORD    ?
SMALL_RECT  ENDS

Left、Top 是『命令提示字元』視窗左上角在控制台螢幕緩衝區的位置;Right、Bottom 則是視窗右下角在控制台螢幕緩衝區的位置,四者皆從 0 開始算起。在 MSDN 中,Left、Top、Right、Bottom 這四個欄位都以 SHORT 表示,其實 SHORT 這種資料型態就佔有一個字組 ( WORD,即 16 位元長 ) 的大小。

最後一個參數,dwMaximumWindowSize,表示『命令提示字元』視窗的最大值,這個最大值是依據控制台螢幕緩衝區的大小、字型的大小及螢幕的大小來決定的。您應當可以想像,若是字形越大、螢幕越小,那麼『命令提示字元』視窗所能顯示的範圍就會越小,dwMaximumWindowSize 也會越小。底下是一張螢幕緩衝區和命令提示字元視窗的關係圖,圖中淡藍色的長方形所框起來的是控制台螢幕緩衝區,而以『命令提示字元』為標題的視窗就是命令提示字元視窗,此圖中的 dwCursorPosition,即游標位置,之值為 00460004h;srWindow 結構體的各欄位,亦即控制台視窗位置,Left、Top、Right、Bottom 分別為 0000、002Eh、004Fh、0046h。

螢幕緩衝區和命令提示字元視窗

欲取得控制台螢幕緩衝區的資料,可以用 GetConsoleScreenBufferInfo API,其原型如下:

BOOL GetConsoleScreenBufferInfo(
  HANDLE                        hConsoleOutput,             // handle to console screen buffer
  PCONSOLE_SCREEN_BUFFER_INFO   lpConsoleScreenBufferInfo   // address of screen buffer info.
);

呼叫 GetConsoleScreenBufferInfo 之前,得傳入一個指向 CONSOLE_SCREEN_BUFFER_INFO 結構體的位址,如果執行成功,GetConsoleScreenBufferInfo 會傳回 TURE,並將此結構體的各欄位填入目前之數值。

如果要設定『命令提示字元』視窗大小及位置,則用 SetConsoleWindowInfo,其參數為:

BOOL SetConsoleWindowInfo(
  HANDLE hConsoleOutput,                // handle to console screen buffer
  BOOL   bAbsolute,                     // coordinate type flag
  CONST  SMALL_RECT *lpConsoleWindow    // address of new window rectangle
);

此 API 的第三個參數為一 SMALL_RECT 結構體位址,這個結構體指定了要設定的『命令提示字元』視窗範圍,SMALL_RECT 結構體內包含了視窗的左上角及右下角位置。第二個參數,bAbsolute,可以為 TRUE 或 FALSE,前者表示在 SMALL_RECT 所存的視窗大小是由螢幕緩衝區的絕對位置 ( 即第零列、第零行 ) 開始算起;若為後者,則表示由現在視窗位置開始算起。若 SMALL_RECT 所存位置不合邏輯 ( 例如負數、左邊位置比右邊大等等 ),則會產生錯誤,傳回 FALSE 值。

SetConsoleWindowInfo 僅僅表面上改變了『命令提示字元』視窗大小並把視窗移到控制台螢幕緩衝區的某個位置,實際上使用者仍然可以操作右邊的捲動軸移動視窗與螢幕緩衝區的相對位置,如果想要讓捲動軸也消失,就得設定控制台螢幕緩衝區的大小,可以用 SetConsoleScreenBufferSize 來達到這目的。SetConsoleScreenBufferSize 的原型是:

BOOL SetConsoleScreenBufferSize(
  HANDLE  hConsoleOutput,   // handle to console screen buffer
  COORD   dwSize            // new size in character rows and cols.
);

dwSize 是新設定的控制台螢幕緩衝區大小,要注意控制台螢幕緩衝區大小的行與列由一開始,並不是由零開始。所以,如果程式設計師想要把命令提示字元視窗設為 80 行寬、10 列高,並使捲動軸消失,必須呼叫兩項 API:

srct    SMALL_RECT      <0,0,79,9>
    invoke  SetConsoleWindowInfo,hOutput,TRUE,offset srct
    invoke  SetConsoleScreenBufferSize,hOutput,0a0050h    ;低位元是行,高位元是列,50h=80d,0ah=10d

SetConsoleScreenBufferSize 設定成功會傳回 TRUE,否則傳回 FALSE。設定失敗的原因,大部分是『命令提示字元』視窗大小比控制台螢幕緩衝區還大,這時可呼叫 GetLastError 而得到 ERROR_INVALID_PARAMETER。

控制台游標

在控制台中,也像在 DOS 一樣,可以設定游標位置,一但設定好游標位置之後,所輸出到控制台的文字便會由此位置顯示出來。設定游標位置的 Win32 API 是 SetConsoleCursorPosition,其原型為:

BOOL SetConsoleCursorPosition(
  HANDLE hConsoleOutput,   // handle to console screen buffer
  COORD  dwCursorPosition

dwCuusorPosition 是一個雙字組整數,存有欲設定的游標位置,較低的 16 位元是 x 座標,較高的 16 位元是 y 座標,這兩個座標都由零開始算。假如所設游標位置不在控制台視窗的範圍內,但仍在控制台螢幕緩衝區範圍內,系統會移動控制台視窗使之到游標新設定的位置;如果所設定游標位置超出控制台螢幕緩衝區範圍,則 SetConsoleCursorPosition 會返回 FALSE。例如想把游標設於第 5 行 ( x=5 ),第 2 列 ( y=2 ),可用下面方式:

        invoke  SetConsoleCursorPosition,hConsoleOutput,20005h

呼叫 GetConsoleCursorInfo 可以獲得游標資料,其原型為:

BOOL GetConsoleCursorInfo(
  HANDLE                hConsoleOutput,         // handle to console screen buffer
  PCONSOLE_CURSOR_INFO  lpConsoleCursorInfo     // address of cursor information
);

呼叫 GetConsoleCursorInfo 時的第二個參數,lpConsoleCursorInfo,是一個位址指標,指向 CONSOLE_CURSOR_INFO 結構體。這個結構體的內容是:

CONSOLE_CURSOR_INFO     STRUCT
dwSize          DWORD   ?
bVisible        DWORD   ?
CONSOLE_CURSOR_INFO     ENDS

dwSize 是指游標大小,以百分比表示,在 1 與 100 之間。bVisible 為 TRUE 時,表示游標可見;反之,表示游標不可見。如欲設定游標資料,可用 SetConsoleCursorInfo,SetConsoleCursorInfo 用法與 GetConsoleCursorInfo 類似,請自行查閱 MSDN。

控制台的文字屬性 ( attribute )

所謂『文字的屬性』可以看成控制台中文字的顏色,如前所提過的,文字是以 FOREGROUND_BLUE、FOREGROUND_GREEN、FOREGROUND_RED、FOREGROUND_INTENSITY、BACKGROUND_BLUE、BACKGROUND_GREEN、BACKGROUND_RED 和 BACKGROUND_INTENSITY 等保留字的組合。如果要改變控制台文字的顏色,可用 SetConsoleTextAttribute API,其原型如下:

BOOL SetConsoleTextAttribute(
  HANDLE hConsoleOutput,    // handle to console screen buffer
  WORD   wAttributes        // text and background colors
);

在呼叫過 SetConsoleTextAttribute 之後,控制台所顯示的顏色均會變成 wAttributes 所指定的顏色,但是在這之前的文字顏色並不會改變。如果只要改變某一個字串的顏色,可用 WriteConsoleOutputAttribute,其原型為:

BOOL WriteConsoleOutputAttribute(
  HANDLE  hConsoleOutput,         // handle to screen buffer
  CONST   WORD *lpAttribute,      // to buffer to write attributes
  DWORD   nLength,                // number of character cells
  COORD   dwWriteCoord,           // coordinates of first cell
  LPDWORD lpNumberOfAttrsWritten  // number of cells written
);

WriteConsoleOutputAttribute 其實是在控制台中的某一個位置寫入一串屬性,lpAttribute 是指向一個由屬性所組成的陣列 ( 可以把陣列看成是在一段連續的記憶體內的資料 ),此陣列的每個屬性佔有一個字組。nLength 是 lpAttribute 所指的屬性陣列的長度,亦即 nLength 是指屬性陣列有幾個屬性。dwWriteCoord 是指由控制台那一個位置開始寫入屬性,lpNumberOfAttrsWritten 是一個位址,指向一個變數,此變數在 WriteConsoleOutputAttribute 返回時,傳回實際上在控制台寫了幾個屬性。WriteConsoleOutputAttribute 通常會和 WriteConsoleOutputCharacter 配合,WriteConsoleOutputAttribute 是在控制台中的某一個位置寫入一串屬性,WriteConsoleOutputCharacter 則是在某一個位置寫入一字串,它的參數和 WriteConsoleOutputAttribute 相似,其原型為:

BOOL WriteConsoleOutputCharacter(
  HANDLE  hConsoleOutput,         // handle to screen buffer
  LPCTSTR lpCharacter,            // buffer to write characters 
  DWORD   nLength,                // number of characters to write
  COORD   dwWriteCoord,           // coordinates of first cell
  LPDWORD lpNumberOfCharsWritten  // number of cells written
);

在控制台程式中讀取鍵盤

ReadConsoleInput 與 INPUT_RECORD

一般在控制台程式中可以用 ReadConsole 來讀取使用者所輸入的按鍵,但是如果要獲得特殊按鍵,例如方向鍵、功能鍵、Shift 鍵等等,就得用 ReadConsoleInput 來讀取鍵盤訊息了。ReadConsoleInput 除了可以讀取鍵盤的輸入外,也可以讀取滑鼠、選單等事件 ( event ) 的輸入,其原型為:

BOOL ReadConsoleInput(
  HANDLE        hConsoleInput,        // handle to a console input buffer
  PINPUT_RECORD lpBuffer,             // address of the buffer for read data
  DWORD         nLength,              // number of records to read
  LPDWORD       lpNumberOfEventsRead  // address of number of records read
);

第一個參數,hConsoleInput,顯然是具有可輸入性質的控制台代碼。第二個參數較為複雜,稍後再說。第三個參數,nLength,是用來指示 ReadConsoleInput 要讀取幾筆輸入的事件。Windows 作業系統會為控制台建立並維護一個輸入緩衝區,當有事件發生時,這些事件都會被系統保存在緩衝區裏等程式提取,而程式可以利用 ReadConsoleInput、ReadConsole 等 API 提出在輸入緩衝區內的事件。當用 ReadConsoleInput 提出緩衝區內的事件時,可以在 nLength 指定要提出的事件的數目,如果 nLength 所指定的事件數比在緩衝區內的事件數大時,ReadConsoleInput 並不會等到滿足了 nLength 所指定的事件數才返回,而是立即返回,並於 lpNumberOfEventsRead 所指位址存放實際讀取的事件數。但是如果呼叫 ReadConsoleInput 之前,在緩衝區裏並沒有任何事件,那麼 ReadConsoleInput 就會一直等待,直到有事件出現才返回。為了避免等待,可以在呼叫 ReadConsoleInput 之前,先呼叫 GetNumberOfConsoleInputEvents 來檢查控制台輸入緩衝區裏是否有輸入事件未被讀取,GetNumberOfConsoleInputEvents 的原型是:

BOOL GetNumberOfConsoleInputEvents(
  HANDLE  hConsoleInput,      // handle to console input buffer
  LPDWORD lpcNumberOfEvents   // address for number of events
);

如果成功,會傳回 TRUE,並於 lpcNumberOfEvents 所指位址存放緩衝區內尚有幾筆事件未被提出。

再回過頭來說 ReadConsoleInput API 的第二個參數,lpBuffer,它是一個指標,指向使用者輸入後的資料存放處。如果所讀取的事件不只一筆,那麼第二筆事件會接在第一筆事件之後,換句話說,lpBuffer 常常是指向一個由許多事件所組成的陣列。這個陣列中的每一筆資料不盡相同,會隨著發生的事件不同,而有不同的形式,但是全部統合在 INPUT_RECORD 結構體內,質言之,我們也可以說 lpBuffer 是指向 INPUT_RECORD 結構體所組成陣列的指標。INPUT_RECORD 結構體的定義是:

INPUT_RECORD    STRUCT
EventType       WORD    ?
Unknown         WORD    ?                                             ;註一
        UNION                   event
        KeyEvent                KEY_EVENT_RECORD                <>
        MouseEvent              MOUSE_EVENT_RECORD              <>    ;註二
        WindowBufferSizeEvent   WINDOW_BUFFER_SIZE_RECORD       <>
        MenuEvent               MENU_EVENT_RECORD               <>
        FocusEvent              FOCUS_EVENT_RECORD              <>
        ENDS
INPUT_RECORD    ENDS

UNION/ENDS 是一個 MASM 6.x 以後所新增的假指令,請參考註三,當一事件發生時,從 UNION 到 ENDS 之間的 KeyEvent、MouseEvent、WindowBufferSizeEvent……五個結構體中,只有一個是有意義的,而發生了那一事件被記錄在 EventType。例如,當使用者按下鍵盤上的某一按鍵,EventType 會被 ReadConsoleInput 設為 KEY_EVENT,而 event 所表示的內容為 KEY_EVENT_RECORD 結構體,這個 KEY_EVENT_RECORD 也會被 ReadConsoleInput 設為適當的數值。其他的 EventType 與相對應的結構體列在下表中:

事  件EventType 種類 數值相對應的結構體
按下或放開某個按鍵KEY_EVENT 1hKEY_EVENT_RECORD
移動滑鼠或按滑鼠按鈕MOUSE_EVENT 2hMOUSE_EVENT_RECORD
更改控制台螢幕緩衝區上的資料WINDOW_BUFFER_SIZE_EVENT 4hWINDOW_BUFFER_SIZE_RECORD
系統內不使用,應予忽略MENU_EVENT 8hMENU_EVENT_RECORD
系統內不使用,應予忽略FOCUS_EVENT 10hFOCUS_EVENT_RECORD

此處僅說明 KEY_EVENT_RECORD 結構體,其餘的請查閱 MSDN。KEY_EVENT_RECORD 結構體被定義成:

KEY_EVENT_RECORD    STRUCT
bKeyDown            BOOL    ?       ;若為 TRUE,表示按鍵被壓下;FALSE 表示按鍵被鬆開
wRepeatCount        WORD    ?
wVirtualKeyCode     WORD    ?       ;虛擬鍵碼
wVirtualScanCode    WORD    ?       ;虛擬掃描碼
    UNION           uChar           ;uChar 表示按鍵的 ASCII 碼或萬國碼
        UnicodeChar WCHAR   ?       ;萬國碼,一個 WCHAR 相當於一個字組
        AsciiChar   CHAR    ?       ;ASCII 碼,一個 CHAR 相當於一個位元組
    ENDS
dwControlKeyState   DWORD   ?       ;控制鍵 ( 如 Alt、Shift、Ctrl、Scroll Lock 等鍵 ) 的狀態
KEY_EVENT_RECORD    ENDS

先說最後面的 dwControlKeyState,它表示控制鍵的狀態,如下表:

dwControlKeyState意義數值
RIGHT_ALT_PRESSED右邊的 Alt 鍵被壓下 1h
LEFT_ALT_PRESSED左邊的 Alt 鍵被壓下 2h
RIGHT_CTRL_PRESSED右邊的 Ctrl 鍵被壓下 4h
LEFT_CTRL_PRESSED左邊的 Ctrl 鍵被壓下 8h
SHIFT_PRESSEDShift 鍵被壓下 10h
NUMLOCK_ONNum Lock 被鎖住為數字狀態 20h
SCROLLLOCK_ONScroll Lock 被鎖住 40h
CAPSLOCK_ONCaps Lock 被鎖住為大寫狀態 80h
ENHANCED_KEY鍵盤右側的數字鍵或方向鍵被按下 100h

bKeyDown 為一雙字組 ( 即 32 位元 ) 長度的欄位,它表示按鍵被按下或鬆開,若為 TRUE,表示按鍵被壓下;FALSE 表示按鍵被鬆開。在一般情形下,例如秘書小姐打字時,當她按下一個鍵後會立即鬆開該鍵,系統會在她按下該鍵時,在控制台輸入緩衝區裏建立一筆 INPUT_RECORD 事件,且記錄該按鍵的資料 bKeyDown 為 TRUE;而當她鬆開此按鍵時,系統也會再一次記錄一筆 INPUT_RECORD 事件,而此時按鍵事件中的 bKeyDown 為 FALSE。至於按下某一鍵不放,會發生什麼事?或又按下某一鍵不放,又再按下另一鍵,會發生什麼事?又或輸入一中文字,會發生什麼事情?小木偶已經寫好一個程式,CONSOLE2.ASM,讀者可以自行組譯後試試看。

CONSOLE2.ASM

        .586
        .model  flat,stdcall
        option  casemap:none

include         windows.inc
include         kernel32.inc
include         user32.inc
includelib      kernel32.lib
includelib      user32.lib

INPUT_RECORD    STRUCT
EventType       WORD    ?
Unknown         WORD    ?
        UNION                   event
        KeyEvent                KEY_EVENT_RECORD                <>
        MouseEvent              MOUSE_EVENT_RECORD              <>
        WindowBufferSizeEvent   WINDOW_BUFFER_SIZE_RECORD       <>
        MenuEvent               MENU_EVENT_RECORD               <>
        FocusEvent              FOCUS_EVENT_RECORD              <> 
        ENDS
INPUT_RECORD    ENDS
nir     equ     sizeof INPUT_RECORD     ;一個INPUT_RECORD結構體所佔位元組數
;*********************************************************************
                .data
hIn             HANDLE          ?
hOut            HANDLE          ?
dRead           DWORD           ?
dWritten        DWORD           ?
idx             DWORD           ?
nIR             WORD            nir             ;一個INPUT_RECORD結構體所佔位元組數
wTime           WORD            0               ;ReadConsoleInput第幾次讀取
sBlank          BYTE            '         '
sBlankSize      equ             $-offset sBlank
szFmt1          BYTE            '第%03d次:',0
szFmt2          BYTE            '%04d',0
szFmt3          BYTE            ' %02X',0
ir              INPUT_RECORD    40h dup ()   ;由40H個INPUT_RECORD結構體所組成的陣列
                                                ;存放由ReadConsoleInput所讀取的事件
buffer          BYTE            400h dup (' ')  ;以ASCII字串格式存放事件處
;*********************************************************************
        .code
;---------------------------------------------------------------------
;deal副程式是把40h個INPUT_RECORD結構體所組成的陣列,以ASCII字串格式存放於buffer處
;每個INPUT_RECORD結構體有nIR個位元組,因此在此副程式以一個迴圈處理這nIR個位元組,
;並以
deal    proc    n_i_r:DWORD     ;n_i_r是ReadConsoleInput讀取的事件數
        LOCAL   pBuffer:DWORD   ;pBuffer是buffer的指標,表示事件填入到那一位址
        LOCAL   counterIR:DWORD ;ReadConsoleInput所讀取資料筆數的計數器
        LOCAL   cIRMember:WORD  ;INPUT_RECORD裏第幾個位元組
        mov     pBuffer,offset buffer
        invoke  wsprintf,offset buffer,offset szFmt1,wTime
        add     pBuffer,eax
        mov     counterIR,0
nxtIR:  cmp     counterIR,0
        jz      @f
        push    esi
        push    edi
        mov     ecx,sBlankSize
        mov     esi,offset sBlank
        add     pBuffer,ecx
        mov     edi,pBuffer
        rep     movsb
        pop     edi
        pop     esi
@@:     invoke  wsprintf,pBuffer,offset szFmt2,counterIR
        add     pBuffer,eax
        mov     cIRMember,nir
        mov     eax,counterIR   ;計算第counterIR個INPUT_RECORD結構
        mul     nIR             ;體在ir陣列的起始位址,並保存於EAX
@@:     push    eax             ;迴圈起始
        movzx   ecx,byte ptr ir[eax]
        invoke  wsprintf,pBuffer,offset szFmt3,ecx
        add     pBuffer,eax
        pop     eax
        inc     eax
        dec     cIRMember
        jnz     @b              ;迴圈結束
        inc     counterIR
        mov     edx,pBuffer
        mov     word ptr [edx],0a0dh
        add     pBuffer,2
        dec     n_i_r
        jnz     nxtIR

        mov     ecx,pBuffer
        mov     edx,offset buffer
        sub     ecx,edx                 ;返回時ECX=要印出的位元組數
        ret
deal    endp
;---------------------------------------------------------------------
start:  invoke  GetStdHandle,STD_OUTPUT_HANDLE
        mov     hOut,eax
        invoke  GetStdHandle,STD_INPUT_HANDLE
        mov     hIn,eax
        invoke  SetConsoleMode,eax,ENABLE_MOUSE_INPUT
get:    invoke  ReadConsoleInput,hIn,offset ir,40h,offset dRead
        inc     wTime
        cmp     dRead,0
        jz      get
        invoke  deal,dRead      ;把ReadConsoleInput讀取到的事件填入buffer字串中
        invoke  WriteConsole,hOut,edx,ecx,offset dWritten,NULL

;檢查使用者是否按下Esc鍵,因每次ReadConsoleInput所讀取的事件可能不只一個,所以
;必須檢查每一個事件,先檢查是否為KEY_EVENT,若是,在檢查是否為Esc鍵
        mov     idx,0
nxt:    mov     eax,idx
        cmp     eax,dRead
        je      get
        inc     idx
        mul     nIR
        cmp     word ptr ir[eax],KEY_EVENT
        jne     nxt
        cmp     word ptr ir[eax+0ah],VK_ESCAPE
        jnz     nxt
        invoke  ExitProcess,NULL
;*********************************************************************
        end     start

將上述原始檔案存成 CONSOLE2.ASM 後,依照以下方式組譯,即可得 CONSOLE2.EXE:

E:\HomePage\SOURCE\CONSOLE>set ML=/coff /link /SUBSYSTEM:CONSOLE

E:\HomePage\SOURCE\>ml console2.asm
Microsoft (R) Macro Assembler Version 6.14.8444
Copyright (C) Microsoft Corp 1981-1997.  All rights reserved.

 Assembling: console2.asm
Microsoft (R) Incremental Linker Version 5.12.8078
Copyright (C) Microsoft Corp 1992-1998. All rights reserved.

/SUBSYSTEM:CONSOLE
"console2.obj"
"/OUT:console2.exe"

E:\HomePage\SOURCE>

用 ReadConsoleInput 觀察在控制台中輸入中文

有了 CONSOLE2.EXE 之後,我們就可以用它來做許多事,例如底下是用它在控制台中以注音輸入法輸入一個中文字『一』,執行 CONSOLE2.EXE 之後,畫面是一片空白,接著按 Ctrl-Space,你會看到第一次讀取時,左邊的 Ctrl 鍵被按下,然後接著第二次讀取是空白鍵被按下,接著第三次是左邊的 Ctrl 鍵被鬆開,到此完成切換『注音輸入法』的動作,螢幕左下角出現『注音 半:』,如下圖:

控制台中輸入中文
接著按下注音『ㄧ』鍵及第一聲鍵,注音『ㄧ』鍵是英文的『U』鍵,第一聲鍵是空白鍵,因此你見到第四次、第五次的按鍵事件。接下來第六次讀取緩衝區時雖然沒有按鍵事件,但是卻是系統自行計算出使用者輸入了一個中文字,因此第六次事件讀取是系統自行產生的,而『一』的 BIG-5 是『A440』,故系統分別傳會兩次 ASCII 碼。


另一個控制台程式的例子

小木偶另外寫了一個程式,CONSOLE3.ASM ,讀者可將滑鼠移至其上,按滑鼠右鍵選另存新檔下載。這是因為 neocities.org 禁止免費用戶上傳 *.ASM 檔,故而掛羊頭賣狗肉把 CONSOLE3.ASM 改名為 CONSOLE3.JPG,讀者可將其名改回來,其執行畫面為:

這個例子有改變控制台視窗大小、顯示文字顏色等等技巧讀者可參考看看。


註一:所有的文獻,包含 MSDN 與網際網路上的資料記載 INPUT_RECORD 結構體的定義是:

INPUT_RECORD    STRUCT
EventType       WORD    ?
        UNION                   event
        KeyEvent                KEY_EVENT_RECORD                <>
        MouseEvent              MOUSE_EVENT_RECORD              <>
        WindowBufferSizeEvent   WINDOW_BUFFER_SIZE_RECORD       <>
        MenuEvent               MENU_EVENT_RECORD               <>
        FocusEvent              FOCUS_EVENT_RECORD              <>
        ENDS
INPUT_RECORD    ENDS

但是小木偶以 OllyDebug 觀察的結果是 EventType 欄位後還有一個字組,但小木偶不知其原因,也不知為什麼所有文獻都沒有此記錄,或許諸位先進賢達知道而能來信告知,小木偶感激不盡。在小木偶電腦中的 WINDOWS.INC 是於民國 90 年十月 22 日發表的的 1.25a 版,這版中並沒有定義 INPUT_RECORD 結構體,必須用 1.30 版以後的 WINDOWS.INC,否則就得於原始檔中自行定義。

註二:一般控制台並不接收滑鼠訊息,必須用 SetConsoleMode 啟動它,SetConsoleMode 的原型是:

BOOL SetConsoleMode(
  HANDLE hConsoleHandle,  // handle to console input or screen buffer
  DWORD  dwMode           // input or output mode to set
);

如果 hConsoleHandle 是標準輸入代碼 的話,dwMode 所代表的意義如下表:

dwMode數值意義
ENABLE_PROCESSED_INPUT1h 此旗標作用時,由系統處理 Ctrl-C 事件,而不放入控制台輸入緩衝區裏,因此 ReadConsole、ReadFile 無法讀出 Ctrl-C 事件。
ENABLE_LINE_INPUT2h 擁有此旗標時,只有收到歸位字元 ( carriage return ) 時,ReadConsole、ReadFile 才會返回;否則只要控制台輸入緩衝區有字元就會返回。如果控制台同時擁有 ENABLE_LINE_INPUT 和 ENABLE_LINE_INPUT 兩旗標時,不只 Ctrl-C 由系統處理,連退位、歸位、換行字元都由系統處理。
ENABLE_ECHO_INPUT4h 擁有此旗標的控制台代碼,會在 ReadConsole、ReadFile 返回時,於控制台螢幕緩衝區顯示字元。此旗標只有在 ENABLE_LINE_INPUT 有作用時才有用。
ENABLE_WINDOW_INPUT8h 擁有此旗標時,ReadConsolInput 能讀取控制台螢幕緩衝區的大小因程式改變的事件,而 ReadConsole、ReadFile 仍會忽視此事件而無法取得。
ENABLE_MOUSE_INPUT10h 當控制台視窗擁有輸入焦點時,滑鼠游標在控制台視窗內移動或按下滑鼠鍵時,系統會把滑鼠事件放入控制台輸入緩衝區裏。但是即使控制台擁有 ENABLE_MOUSE_INPUT 旗標,ReadConsole、ReadFile 仍會忽視滑鼠事件而無法取得,只可用 ReadConsoleInput 取得。

如果 hConsoleHandle 是標準輸出代碼 的話,dwMode 所代表的意義如下表:

dwMode數值意義
ENABLE_PROCESSED_OUTPUT1h 擁有此旗標的控制台會由系統處理退位、跳格、鈴聲、歸位與換行字元。
ENABLE_WRAP_AT_EOL_OUTPUT2h 擁有此旗標時,當 WriteConsole、WriteFile 對控制台輸出字元或擁有 ENABLE_ECHO_INPUT 的控制台以 ReadConsole、ReadFile 回應字元,而且游標到達控制台視窗的最右邊時,游標會自動跳到下一行的開始位置繼續輸出字元。

註三:

UNION/ENDS 假指令

UNION 是定義一種特殊的變數型態或結構體,其語法為:

名稱    UNION
欄位一  資料型態    ?
欄位二  資料型態    ?
……    ……        ……
名稱    ENDS

以 UNION 定義的變數型態或結構體,雖然可能包含數個欄位,但是和 STRUCT ( 有關 STRUCT 請參考組合語言第十九章 ) 不同。這些包含在 UNION 與 ENDS 之間的欄位都是由同一位址開始,換句話說,這些欄位都是重疊的。這個意思是說在 UNION 裏的欄位雖然可能有好幾個,卻只能選擇其中一欄作為資料,但是因為事前不確定,所以不知應該用那一個欄位。舉個例子來說,例如我們常把一個大程式分工給許多人負責,每人僅負責一小部份。假如這個大程式要使用年份作為變數,某些人可能用中華民國 97 年,某些人可能用西元 2008 年,那麼程式可以寫成這樣:

YEAR    UNION
ROC     DB      ?       ;以民國紀元,一個位元組長度就夠了
AD      DW      ?       ;以西曆紀元,要一個字組長度才夠
YEAR    ENDS
;*********************************************************
.data
sale    YEAR    <>       ;發售年份
;*********************************************************
.code
        …………
        mov     sale.ROC,97     ;某人可能這樣寫
        …………
        mov     sale.AD,2008    ;另一人可能這樣寫

組譯器組譯 UNION 時,會以長度最長的欄位最為最後的資料長度。以上面的例子,組譯器會為 sale 變數保留一個字組的空間。UNION 在宣告時,也可以同時就指定其初始值,如下面所示:

sale    YEAR    <97>

UNION 也可以包含在一個結構體內,這時候定義 UNION 時必須像下面這樣,把名稱放在 UNION 之後,而不是之前,例如 INPUT_RECORD 中的 event 就是如此:

結構體名稱      STRUCT
欄位一          資料型態    ?
……            ………
UNION           名稱                           ;這幾行是 UNION 的定義
                欄位A       資料型態    ?       ;
                欄位B       資料型態    ?       ;
                ……        ………      …      ;
ENDS                                          ;此行 UNION 結束
結構體名稱      ENDS

上面這個例子中,在欄位A、欄位B…中,只能選擇其中一個,並與欄位一、欄位二…形成一個結構體。在 MASM32 V7.0 裏的 WINDOWS.INC 定義 LARGE_INTEGER 是這樣子的:

LARGE_INTEGER   STRUCT
    QuadPart    QWORD   ?
LARGE_INTEGER   ENDS

但是在 MASM32 V11 裏的 WINDOWS.INC 定義 LARGE_INTEGER 卻是像下面這樣子的:

LARGE_INTEGER   UNION
STRUCT
        LowPart  DWORD   ?
        HighPart DWORD   ?
ENDS
QuadPart QWORD ?
LARGE_INTEGER   ENDS

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