x86彙編語言筆記(全)(長文警告)

x86彙編語言

最近系統的學了下彙編語言,下面是學習筆記,用的書是清華大學出版社出版的彙編語言第三版,作者王爽(最經典的那版)。

文章目錄

基礎知識

彙編語言指令組成
  • 彙編指令:機器碼的助記符,有對應的機器碼。
  • 僞指令:沒有對應的機器碼,編譯器執行,機器不執行。
  • 其他符號:如±*/有編譯器識別,無對應機器碼。
CPU與外部器件交互需要
  • 存儲單元地址(地址信息)
  • 器件選擇,讀寫命令(控制信息)
  • 數據(數據信息)
總線

總線就是一根根導線的集合,分爲

  • 地址總線,越寬(數量越多)代表可以尋址的範圍越大
  • 數據總線,越寬代表一次性讀寫的數據越多(8根1字節)
  • 控制總線,越寬代表對器件控制操作越多
小結

彙編指令和機器指令一一對應

每一種cpu都有自己的彙編指令集

在存儲器中指令和數據都是二進制,沒有任何區別

CPU可以直接使用的信息存放在存儲器中(內存)

接口卡

CPU無法直接控制顯示器,鍵盤等的外圍設備,但CPU通過直接控制這些外圍設備在主板上的接口卡來控制這些設備。

存儲器

隨機存儲器(RAM):帶電存儲,關機丟失,可讀可寫

  • 用於存放CPU使用的絕大部分程序和數據,主隨機存儲器由裝在主板上的RAM和擴展插槽的RAM組成。
  • 其他接口卡上也可能有自己的RAM

只讀存儲器(ROM):關機不丟,只能讀取

  • 主板上的ROM裝有系統的BIOS(基本輸入輸出系統)。

  • 其他接口卡上也可能有自己的ROM,一般裝着相應的BIOS。

(P10圖)

內存地址空間

以上這些內存都和CPU總線相連,CPU都通過控制總線向他們發出內存讀寫命令。所以CPU都把他們當內存對待,看做一個一個由若干存儲單元組成的邏輯存儲器,即內存地址空間(一個假想的邏輯存儲器P11圖)。

內存地址空間中的各個不同的地址段代表不同的存儲設備,內存地址空間大小收到CPU地址總線長度限制。

寄存器

內部總線

之前討論的總線是CPU控制外部設備使用的總線,是將CPU和外部部件連接的。而CPU內部由寄存器,運算器,控制器等組成,由內部總線相連,內部總線負責連接CPU內部的部件。

通用寄存器

8086CPU寄存器都是16位的,一共14個,分別是AX,BX,CX,DX,SI,DI,SP,BP,IP,CS,SS,DS,ES,PSW。其中AX,BX,CX,DX四個寄存器通常存放一般性的數據,稱爲通用寄存器。

而且爲了兼容上一代的8位寄存器,這四個寄存器可以拆開成兩個8位的寄存器來使用。稱爲AH,AL,BH,BL,CH,CL,DH,DL。低八位(編號0-7)構成L寄存器,高八位構成H寄存器。

8086CPU可以處理以下兩種數據

  • 字節byte,8位
  • 字word,連個字節,16位。分別稱爲高位字節和低位字節。
簡單的彙編指令
指令 操作 高級語言
mov ax,18 將18存入AX寄存器 AX=18
add ax,8 將AX寄存器中的數加8 AX=AX+8
mov ax,bx 將BX中的數據存入AX AX=BX
add ax,bx 將AX中的數據和BX中的數據相加存入AX AX=AX+BX

彙編指令或寄存器名稱不區分大小寫。

注:AX寄存器當做兩個8位寄存器al和ah使用的時候,CPU就把他們當做兩個8位寄存器使用,而不會看成是一個16未分開,即如果al進行加法運算C5+93=158,即add al,93,al會變成58,ax則是0058而不是0158。

CPU位結構

16位結構的CPU指的是運算器一次最多處理16位數據,寄存器寬度16,寄存器和運算器之間通路也是16位。

CPU表示物理地址

如果物理總線寬度超過寄存器寬度,CPU尋址方法是兩個寄存器輸出一個地址,當地址總線寬度20的時候,P21圖。一個寄存器輸出短地址,另一個輸出偏移地址。然後通過地址加法器合併爲一個20位的地址,然後通過內部總線送給控制電路,控制電路通過地址總線送給內存。

公式:物理地址=段地址x16+偏移地址(這裏的x16其實就是左移四位,P21圖)

雖然這麼表示,但內存並沒有被分爲一段一段的,是CPU劃分的段。段地址x16稱爲基礎地址,所以我們可以根據需求把任意的基礎地址加上不超過一個寄存器表示的最長(64KB)的偏移地址來表示地址。而且一個實際地址往往可以有各種不同的方法表示,通常我們表示21F60H這個地址通過下面方法:

  • 2000:1F60
  • 2000H段中的1F60單元中
段寄存器與指令指針寄存器

8086CPU有四個段寄存器:CS,DS,SS,ES

除此之外,IP寄存器稱爲指令指針寄存器,所以任意時刻可以讀取從CSx16+IP單元開始,讀取一條指令執行。也就是說,CPU將IP指向的內容當做指令執行。

P26圖,CPU執行一段指令。另外,8086CPU開機時CS被置爲FFFFH,IP被置爲0000H,也就是說剛開機的第一條指令從FFFF0H開始讀取執行。

CPU將CS:IP指向的內存中的內容當做指令,一條指令被執行了,那一定被CS:IP指向過。

修改CS,IP

CS和IP寄存器不可以使用傳送指令mov來改變,而能改變CS,IP內容的指令是轉移指令。

jmp指令用法:

  • jmp 段地址:偏移地址 同時修改CS和IP的值 如jmp 2AE3:3 結果CS=2AE3H IP=0003H
  • jmp 某一合法寄存器 只修改IP的值 如jmp ax,將IP的值置爲AX中的值(AX不變)
小結

8086CPU有四個段寄存器,CS是用來存放指令的段地址的段寄存器

IP用來存放指令的偏移地址

CS:IP指向的內容在任意時刻會被當做指令執行

使用轉移指令修改CS和IP的內容

實驗

Debug命令:

  • R:查看,改變CPU寄存器內容
    • 直接-r查看寄存器內容
    • -r 寄存器名,改變寄存器內容
  • D:查看內存中內容
    • -d直接查看
    • -d 段地址:偏移地址 查看固定地址開始的內容
    • -d 段地址:偏移地址 結尾偏移地址 查看指定範圍內存
  • E:改寫內存中內容
    • -e 起始地址 數據 數據 數據 …
    • 提問方式修改 -e 段地址:偏移地址 從這個地址開始一個一個改,空格下一個,回車結束
    • 也可以寫入字符 ‘a’
  • U:將內存中的機器指令翻譯成彙編指令
    • -u 段地址:偏移地址
  • T:執行一條機器指令
    • -t 執行cs:ip指向的命令
  • A:以彙編指令格式在內存中寫入一條機器指令
    • -a 段地址:偏移地址 從這個地址開始一行一行的寫入彙編語句

寄存器(內存訪問)

內存到寄存器的儲存

寄存器是16位的,可以存放一個字即兩個字節,而內存中的一個存儲單元是一字節。所以一個寄存器可以存兩個存儲單元的內容,高地址存儲單元存在高位字節中,低地址存儲單元存在低位字節中。

字單元:存放一個字型數據的兩個地址連續的內存單元。

DS寄存器

與CS類似,DS寄存器存放的是要從內存中讀取的數據的段地址。我們想要使用mov指令從內存10000H(1000:0)中的數據送給AL時,如下:

mov al,[0]

後面的[0]指的是內存的偏移地址是0,CPU會自動從DS寄存器中提取段地址,所以應該首先將段地址1000H寫入DS寄存器中。但卻不能直接使用mov ds,1000指令,只能從其他寄存器中轉傳入DS寄存器。所以完整命令如下:

mov bx,1000
mov ds,bx
mov al,[0]

當然,從AL寄存器中將數據送入內存只要反過來使用mov就可以了,mov [0],al

如果需要傳輸字型數,只要使用對應的16位寄存器就可以了,傳輸的是以相應地址開始的一個字型數據(連續兩個字節)。如mov [0],cx。

mov,add,sub

mov常見語法:

mov 寄存器,數據       mov ax,8
mov 寄存器,寄存器		mov ax,bx
mov 寄存器,內存單元    mov ax,[0]
mov 內存單元,寄存器    mov [0],ax
mov 段寄存器,寄存器    mov ds,ax
mov 寄存器,段寄存器    mov ax,ds

add,sub常見語法:

add 寄存器,數據        add ax,8
add 寄存器,寄存器      add ax,bx
add 寄存器,內存單元    add ax,[0]
add 內存單元,寄存器    add [0],ax
sub和add一樣

注意,add,sub不可以操作段寄存器。

棧是一種後進先出的存儲空間,從棧頂出棧入棧。LIFO(last in first out)

入棧指令:push ax ax中的數據送入棧頂

出棧指令:pop ax 棧頂送入ax

入棧和出棧指令都是以字爲單位的。P58圖

棧寄存器SS,SP與push,pop

CPU通過SS寄存器和SP寄存器來知道棧的範圍,段寄存器SS存放的是棧頂的段地址,SP寄存器存放的是棧頂的偏移地址。所以,任意時刻SS:SP指向棧頂元素。

指令push ax執行過程:

  1. SP=SP-2,SP指針向前移動兩格代表新棧頂
  2. AX中的數據送入SS:SP目前指向的內存字單元,P59圖

所以棧頂在低地址,棧底在高地址。初始狀態下,SP指向棧底的下一個單元。

反之pop ax執行過程相反。

8086CPU並不會自己檢測push是否會超棧頂,pop是否會超棧底。

push和pop可以加寄存器,段寄存器,內存單元(直接偏移地址[address])

指定棧空間通常通過指定SS來進行,如:

指定10000H~1000FH爲棧空間
mov ax,1000
mov ss,ax
mov sp 0010

注:將一個寄存器清零 sub ax,ax 兩個字節,mov ax,0 三個字節

注:若設定一個棧段爲10000H~1FFFFH,棧空的時候SP=0(要知道入棧操作先SP-2,然後再送入棧)

實驗

Debug中的t命令一次執行一條指令,但如果執行的指令修改了ss段寄存器,下一條命令也會緊跟着執行(中斷機制)。

簡單編程

一個彙編語言程序
  1. 編寫
  2. 編譯(masm5.0)
  3. 連接
一些僞指令功能
assume cs:codesg

codesg segment

mov ax,0123
mov bx,0456
add ax,bx
add ax,ax

mov ax,4c00
int 21

codesg ends

end

涉及到的一些知識:

  • XXX segment···XXXends
    • segment和ends成對出現,代表一個段的開始和結束。
    • 一個彙編程序可以有多個段,代碼,數據和棧等,至少要有一個段。
  • end
    • end代表一個彙編程序結束,遇到end編譯器停止編譯。
  • assume
    • assume 假設,假設某一個段寄存器和程序中的一個段關聯。
    • 可以理解爲將這個段寄存器指向程序段的段地址
  • 標號(codesg)
    • 一個標號代表一個地址
  • 程序返回mov ax,4c00 int 21
    • 暫時記住這兩條指令代表程序返回

編譯和連接方法,P83。

注:編譯器只能發現語法錯誤而無法發現邏輯錯誤。

CPU執行一個程序,需要有另一個程序將它加載進內存(即將CS:IP指向它),一般情況下我們通過DOS執行這個.exe,所以是DOS程序將它加載進入內存。當這個程序運行結束,再返回DOS程序繼續執行。如果是DOS調用Debug調用.exe,那麼先返回Debug再返回DOS。

DOS加載一個.exe時,先在內存中找到一段內存,起始段地址SA,然後分配256字節的PSP區域,用來和被加載程序通信。在之後的段地址SA+10就是程序開始的段地址。CS:IP指向它,DS=SA。

注:在Debug中,最後的int 21指令要使用P命令執行。

[BX]和loop指令

內存單元的描述

內存單元可以使用[數字]表示,當然也可以使用[寄存器]表示,如[bx],mov ax,[bx],mov al,[bx]

爲了表示方便,使用()來表示一個內存單元或寄存器中的內容,如(ax),(20000H),或((dx)*16+(bx))表示ds:bx中的內容,但不可寫爲(1000:0),((dx):0H)。而(X)中的內容由具體寄存器名或運算來決定。

我們使用idata來表示常亮。所以以下語句可以這麼寫:mov ax,[idata] mov ax,idata。

loop指令

loop指令格式:loop 標號。

loop指令通常用來實現循環功能,當執行loop指令時,CPU進行兩步操作:

  1. (cx)=(cx)-1
  2. (cx)不爲零則跳至標號處執行程序。

所以CX中存放的是循環次數,一個簡單的例子如下(計算2^12):

assume cs:code
code segment

mov ax,2

mov cx,11
s:add ax,ax
loop s

mov ax,4c00h
int 21h

code ends
end

所以使用loop注意三點:

  1. 先設置cx的值 mov cx,循環次數
  2. 設置標號與執行循環的程序段 s:執行程序段
  3. 在程序段最後寫loop loop

注:在彙編語言中,數據不能以字母開頭,所以大於9fffH的數據,要在開頭加0,如0A000H

注:debug中G命令 g 0012表示CPU從當前CS:IP開始一直執行到0012處暫停。P命令可以將loop部分一次執行完畢,直到(CX)=0,或使用g loop的下一條命令。

Debug和masm編譯器對指令的不同處理

mov ax,[0]這條指令在Debug和masm中有着不同的解釋,Debug是將DS:0內存中的數據送給AX,而masm中則是mov ax,0,即將0送入AX。

解決方法1:先將偏移地址送入BX,然後再使用mov ax,[bx]

解決方法2:直接顯式給出地址,如mov al,ds:[0] (相應的段寄存器還有CS,SS,ES這些在彙編語言中可以稱爲“段前綴”)當然,這種寫法通過編譯器之後會變成Debug中的mov al,[0]

注:inc bx bx值加一

安全的編程空間

在之前沒有提到的一個問題,如果在寫程序之前不看一眼要操作的內存,就直接開始使用的話,萬一改寫了內存中重要的系統數據,可能會引起系統崩潰。所以我們一般在一個安全的內存空間中操作。一般操作系統和合法程序都不會使用0:200~0:2ff這256字節的空間,所以我們可以在這裏操作。

學習彙編語言的目的就是直接和硬件對話,而不理會操作系統,這在DOS(實模式)下是可以做到的,但在windows或Unix這種運行與CPU保護模式的操作系統上卻是不可能的,因爲這種操作系統已經將CPU全面嚴格的管理了。

段前綴的使用

將ffff:0ffff:b中的數據轉存入0:2000:20b中:

assume cs:code
code segment

mov ax,0ffffh
mov ds,ax

mov ax,0020h
mov es,ax

mov bx,0

mov cx,12
s:mov dl,[bx]
mov es:[bx],dl
inc bx
loop s

mov ax,4c00h
int 21h

code ends
end

[bx]直接使用的時候默認段前綴是ds,但要使用其他的段前綴,如es就要在前面加上。

程序的段

數據段

一般一個程序想要使用內存空間,有兩種方法,在程序加載的時候系統分配或在需要使用的時候向系統申請,我們先考慮第一種情況。所以我們應事先將所需的數據存入內存中的某一段中,但我們又不可以隨意的指定內存地址,以下面的求8個數據累加和的代碼爲例:

assume cs:code
code segment

dw 0123h,0456h,0789h,0abch,0defh,0fedh,0cbah,0987h

mov bx,0
mov ax,0

mov cx,8
s:add ax,cs:[bx]
add bx,2
loop s

mov ax,4c00h
int 21h

code ends
end

代碼第一行的dw是定義字類型數據,define word的意思。這裏定義了8個字類型數據,佔16字節。由於是在程序最開始定義的dw,所以數據段的偏移地址爲0,也就是說第一個數據0123h的地址是CS:[0]第二個0456h的地址是CS:[2]以此類推。

所以這個程序加載之後CS:IP指向的是數據段的第一個數據,我們要是想成功執行,需要把IP置10,指向第一條指令mov bx,0,所以我們想要直接執行(不在Debug中調整IP)的話,需要指定程序開始的地方:

···
dw 0123h,0456h,0789h,0abch,0defh,0fedh,0cbah,0987h

start:mov bx,0
···
code ends
end start

在第一條指令前加start,後面的end變成end start,end除了通知編譯器程序在哪裏結束之外,也可以通知程序的入口在哪,也就是第一條語句,在這裏編譯器就知道了mov bx,0是程序的第一條指令。也就是說,我們想要CPU從何處開始執行程序,只要在源程序中使用end 標號指定就好了。

所以有如下框架:

assume cs:code
code segment
···數據···
start:
···代碼···
code ends
end start
棧段

看下面一段使8個數逆序存放的代碼:

assume cs:codesg
codesg segment

dw 0123h,0456h,0789h,0abch,0defh,0fedh,0cbah,0987h
dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0

start:mov ax,cs
mov ss,ax
mov sp,30h

mov bx,0
mov cx,8
s:push cs:[bx]
add bx,2
loop s

mov bx,0
mov cx,8
s0:pop cs:[bx]
add bx,2
loop s0

mov ax,4c00h
int 21h

codesg ends
end start

在定義了8個字型數據之後,又定義了16個取值爲0的字型數據,用作棧空間。所以dw這個定義不僅僅用來定義數據,也可以用來開闢內存空間留給之後的程序使用。

數據,代碼,棧的程序段

在8086CPU中,一個段的長度最大爲64KB,所以如果我們將數據或棧空間定義的比較大,就不能像前面一樣編程了。我們需要將代碼,數據,棧放入不同的段中:

assume cs:code,ds:data,ss:stack
data segment
dw 0123h,0456h,0789h,0abch,0defh,0fedh,0cbah,0987h
data ends

stack segment
dw 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0
srack ends

code segment
start:mov ax,stack
mov ss,ax
mov sp,20h

mov ax,data
mov ds,ax

mov bx,0

mov cx,8
s:push [bx]
add bx,2
loop s

mov bx,0

mov cx,8
s0:pop [bx]
add bx,2
loop s0

mov ax,4c00h
int 21h

code ends
end start

我們可以這樣在寫代碼時就將程序分爲幾個段,這段代碼中,mov ax,data的意思是將data段的段地址送入ax寄存器。但我們不可以使用mov ds,data這樣是錯誤的,因爲在這裏data被編譯器視爲一個數值。

在這裏將數據命名爲data,代碼命名爲code,棧命名爲stack只是爲了方便閱讀,CPU並不能理解,和start,s,s0一樣,只在源程序中使用。而assume cs:code,ds:data,ss:stack這段代碼也並不能讓CPU的cs,ds,ss指向對應的段,因爲assume是僞指令,CPU並不認識,它是由編譯器執行的。源程序中end start語句指明瞭程序的入口,在這個程序被加載後,CS:IP被指向start處,開始執行第一條語句,這樣CPU纔會將code段當做代碼執行。而當CPU執行

mov ax,stack
mov ss,ax
mov sp,20h

這三條語句後纔會將stack段當做棧空間開使用。也就是說,CPU如何區分哪個段的功能,全靠我們使用匯編指令對ds,ss,cs寄存器的內容設置來指定。

靈活定位內存地址

and和or指令

and:邏輯與指令,按位與運算,如:

mov al,01100011B
and al,00111011B

執行結果是al=00100011B,所以我們想要把某一位置零的時候可以使用and指令。

or:邏輯或指令,按位或運算,如:

mov al,01100011B
or al,00111011B

執行結果是al=01111011B,or指令可以將相應位置1。

ASCII碼和字符形式的數據

在彙編語言中我們可以使用’···'的方式指明數據是以字符形式給出的,編譯器會自動將它們轉化爲ASCII碼。例如:

assume cs:code,ds:data
data segment
db 'unIX'
db 'foRK'
data ends
code segment
start:mov al,'a'
mov bl,'b'
mov ax,4c00h
int 21h
code ends
end start

db和dw類似,只不過定義的是字節型數據,然後通過’unIX’相繼在接下來四個字節中寫下75H,6EH,49H,58H即unIX的ASCII值。同理,mov al,'a’也是將’a’的ASCII值61H送入al寄存器。

使用and和or指令改變一串字符串字母的大小寫,將第一串全變爲大寫,第二串全變爲小寫:

首先分析ASCII碼:

大寫	十六進制	二進制			小寫	十六進制	二進制
 A		41		01000001		a		61	   01100001
 B		42		01000010		b		62	   01100010
 C 		43		01000011		c		63	   01100011

可見,只有第5位(從右往左數,從0開始計數)在大寫和小寫的二進制中是不一樣的,所以我們只要把所有字母的二進制第五位置零,那就是大寫,置1就是小寫。代碼如下:

assume cs:codesg,ds:datasg

datasg segment
db 'BaSiC'
db 'iNfOrMaTiOn'
datasg ends

codesg segment
start:mov ax,datasg
mov ds,ax
mov bx,0

mov cx,5
s:mov al,[bx]
and al,11011111B
mov [bx],al
inc bx
loop s

mov bx,5

mov cx,11
s0:mov al,[bx]
or al,00100000B
mov [bx],al
inc bx
loop s0

mov ax,4c00h
int 21h

codesg ends
end start
[bx+idata]的內存表示方法與數組處理

除了使用[bx]來表示一個內存單元外,我們還可以使用[bx+idata]來表示一個內存單元,他表示的意思是偏移地址爲(bx)+idata(bx中的數值加idata)的內存單元。當然也可寫爲[idata+bx],除此之外還可寫爲,200[bx],[bx].200。

既然有了這種表示方法,我們就可以使用這種方法來操作數組,剛纔將兩個字符串改變大小寫的代碼的循環部分可以如下優化:

···
s:mov al,[bx]
and al,11011111B
mov [bx],al
mov al,[5+bx]
or al,00100000B
mov [5+bx],al
inc bx
loop s
···

當然也可寫爲0[bx]和5[bx],注意這種寫法和C語言中數組的相似之處:C語言中數組表示爲a[i],彙編語言中表示爲5[bx]。

SI和DI寄存器

SI和DI功能和BX相似,但不可以拆分爲兩個8位寄存器。也就是說下面代碼等價:

mov bx|si|di,0
mov ax,[bx|si|di]
mov ax,[bx|si|di+123]

所以在這裏可以使用更方便的方式:[bx+si]和[bx+di],這兩個式子表示偏移地址爲(bx)+(si)的內存單元,使用方法如:mov ax,[bx+si]等價於mov ax,[bx][si]。

當然,有了這些表示方法,自然就有[bx+si+idata]和[bx+di+idata],相似的,也可以寫成

mov ax,[bx+200+si]
mov ax,[200+bx+si]
mov ax,200[bx][si]
mov ax,[bx].200[si]
mov ax,[bx][si].200

那我們總結一下這些內存尋址方法:

  • [idata]用一個常量表示偏移地址,直接定位一個內存單元
  • [bx]用一個變量表示偏移地址,定位一個內存單元
  • [bx+idata]用一個常量和一個變量表示偏移地址,可在一個起始地址的基礎上間接定位一個內存單元
  • [bx+si]用兩個變量表示偏移地址
  • [bx+si+idata]用兩個變量和一個常量表示偏移地址

使用雙循環,使用一個寄存器暫存cs的值,如:

···
mov cx,4
s0:mov dx,cx
mov si,0

mov cx,3
s:mov al,[bx+si]
and al,11011111b
mov [bx+si],al
inc si
loop s

add bx,16
mov cx,dx
loop s0
···

假如循環比較複雜,沒有多餘的寄存器可用,我們可以使用內存暫存cx或其他數據:

···
dw 0
···
mov cx,4
s0:mov ds:[40H],cx
mov si,0

mov cx,3
s:mov al,[bx+si]
and al,11011111b
mov [bx+si],al
inc si
loop s

add bx,16
mov cx,ds:[40H]
loop s0
···

這麼使用的話注意需要在數據段聲明用來暫存的內存,好在程序加載時分配出來。當然,在需要暫存的地方,還是建議使用棧:

···
dw 0,0,0,0,0,0,0,0
···
mov ax,stacksg
mov ss,ax
mov sp,16
···
mov cx,4
s0:push cx
mov si,0

mov cx,3
s:mov al,[bx+si]
and al,11011111b
mov [bx+si],al
inc si
loop s

add bx,16
pop cx
loop s0
···

數據處理的兩個基本問題

兩個基本問題
  1. 處理的數據在什麼地方
  2. 要處理的數據有多長

接下來的討論中,使用reg來表示一個寄存器,使用sreg來表示一個段寄存器。所以:

  • reg:ax,bx,cx,dx,ah,al,bh,bl,ch,cl,dh,dl,sp,bp,si,di
  • sreg:ds,ss,cs,es
bx,si,di和bp

在8086CPU中,只有這四個寄存器可以使用[···]來進行內存尋址,可以單個出現,或以下面組合出現(常數可以隨意出現在這些表示方法中):

  • bx+si/di
  • bp+si/di

注:如果使用了bp來尋址,而沒有顯式的表明段地址,默認使用ss段寄存器,如:

mov ax,[bp]              ;(ax)=((ss)*16+(bp))
mov ax,[bp+idata]        ;(ax)=((ss)*16+(bp)+idata)
mov ax,[bp+si]           ;(ax)=((ss)*16+(bp)+(si)+idata)
數據的位置

絕大部分機器指令都是用來處理數據的,基本可分爲讀取,寫入,運算。在機器指令這個層面上,並不關心數據是什麼,而關心指令執行前數據的位置。一般數據會在三個地方,CPU內部,內存,端口。

彙編語言中使用三個概念來表示數據的位置:

  • 立即數(idata)
    • 對於直接包含在機器指令中的數據,在彙編語言中稱爲立即數
    • 例:mov ax,1 add bx,2000h
  • 寄存器
    • 指令要處理的數據在寄存器中,在彙編指令中給出相應寄存器名
    • 例:mov ax,bx mov ds,ax
  • 段地址(SA)和偏移地址(EA)
    • 指令要處理的數據在內存中,在指令中使用[X]方式給出,SA在某個段寄存器中
    • 例:mov ax,[0] mov ax,[di]

總結一下尋址方式:

尋址方式 含義 名稱
[idata] EA=idata;SA=(DS) 直接尋址
[bx|si|di|bp] EA=(bx|si|di|bp);SA=(DS) 寄存器間接尋址
[bx|si|di|bp+idata] EA=(bx|si|di|bp+idata);SA=(DS) 寄存器相對尋址
[bx|bp+si|di] EA=(bx|bp+si|di);SA=(DS|SS) 基址變址尋址
[bx|bp+si|di+idata] EA=(bx|bp+si|di+idata);SA=(DS|SS) 相對基址變址尋址
數據的長度

8086CPU中可以指定兩種尺寸的數據,byte和word,所以在使用數據的時候要指明數據尺寸。

  • 在有寄存器參與的時候使用寄存器的種類區分
    • 字:mov ax,1
    • 字節:mov al,1
  • 在沒有寄存器參與的時候,使用X ptr指明內存單元長度,X是word或byte
    • 字:mov word ptr ds:[0],1 add word ptr [bx],2
    • 字節:mov byte ptr ds:[0],1 add byte ptr [bx],2
  • 其他默認指明處理類型的指令
    • push [1000H],push默認只進行字操作

靈活使用尋址方式的例子,修改下面內存空間中的數據:

段seg:60

起始地址 內容
00 ‘DEC’
03 ‘Ken Oslen’
0C 137
0E 40
10 ‘PDP’
···
mov ax,seg
mov ds,ax
mov bx,60h

mov word ptr [bx].0ch,38    ;第三字段改爲38

add word ptr [bx].0eh,70    ;第四字段改爲70

mov si,0
mov byte ptr [bx].10h[si],'v'   ;修改最後一個字段的三個字符
inc si
mov byte ptr [bx].10h[si],'A'
inc si
mov byte ptr [bx].10h[si],'X'
···

這段代碼中地址的使用類似c++中結構體的使用。[bx].idata.[si],就類似與c++中的dec.cp[i]。dec是結構體,cp是結構體中的字符串成員,[i]表示第幾個字符。

div指令

div是除法指令,需要注意以下三點:

  • 除數:8位或16位,在一個reg或內存單元中
  • 被除數:默認在AX或DX中,如果除數8位,被除數則爲16位,放在AX中;如果除數16位,則被除數32位,在DX和AX中,DX存放高16位,AX放低16位。
  • 結果,除數8位,結果(商)存放在AL中,AH存放餘數;如果除數16位,則AX存放商,DX存放餘數

格式:div reg或div 內存單元,所以div byte ptr ds:[0]表示:

(al)=(ax)/((ds)*16+0)的商;
(ah)=(ax)/((ds)*16+0)的餘數;

div word ptr es:[0]表示:

(al)=[(dx)*10000H+(ax)]/((es)*16+0)的商
(ah)=[(dx)*10000H+(ax)]/((es)*16+0)的餘數

例:計算100001/100,因爲100001(186A1H)大於65535,則需要存放在ax和dx兩個寄存器,那麼除數100只能存放在一個16位的寄存器中,實現代碼:

mov dx,1
mov ax,86A1H
mov bx,100
div bx

執行之後(ax)=03E8H(1000),(dx)=1。

僞指令dd

dd是一個僞指令,類似dw,但dd是用來定義dword(double word,雙字),如:

dd 1  ;2字,4字節
dw 1  ;1字,2字節
db 1  ;1字節

將data段中第一個數據除以第二個數據,商存入第三個數據:

···
data segment
dd 100001
dw 100
dw 0
data ends
···
mov ax,data
mov ds,ax
mov ax,ds:[0]
mov dx,ds:[2]
div word ptr ds:[4]
mov ds:[6],ax
···

總結一下div相關:

  • div後面跟的是除數
  • 被除數位數是除數兩倍
  • 被除數存在ax中或ax+dx(ax低,dx高)
  • 商在ax或al中,餘數在ah或dx中(高餘數,低商)
dup

dup是一個操作符,由編譯器識別,和db,dw,dd配合使用,如:

db 3 dup (0)表示定義了三個值是0的字節,等價於db 0,0,0

db 3 dup (1,2,3)等價於db 1,2,3,1,2,3,1,2,3 共九個字節

db 3 dup (‘abc’,‘ABC’)等價於db ‘abcABCabcABCabcABC’

綜上,db|dw|dd 重複次數 dup (重複內容)

轉移指令原理

轉移指令

可以修改IP或同時修改CS,IP的系統指令稱爲轉移指令,可分爲以下幾類:

  • 轉移行爲:
    • 只修改IP,稱爲段內轉移,如jmp ax
    • 同時修改CS和IP,稱爲段間轉移,如jmp 1000:0
  • 修改範圍(段內轉移):
    • 短轉移:修改IP範圍-128~127
    • 近轉移:修改IP範圍-32768~32767
  • 轉移指令分類:
    • 無條件轉移:jmp
    • 條件轉移
    • 循環指令
    • 過程
    • 中斷
offset操作符

offset是由編譯器處理的符號,它能去的標號的偏移地址,如:

start:mov ax,offset start
s:mov ax,offset s

這裏就是將start和s的偏移地址分別送給ax,也就是0和3

jmp指令

jmp是無條件轉移指令,可以只修改IP也可以同時修改CS和IP,只要給出兩種信息,要轉移的目的地址和專一的距離。

依據位移的jmp指令:jmp short 標號(轉到標號處執行指令)。這個指令實現的是段內短轉移,對IP修改範圍是-128~127,指令結束後CS:IP指向標號的地址,如:

0BBD:0000   start:mov ax,0  (B80000)
0BBD:0003   jmp short s   (EB03)
0BBD:0005   add ax,1    (050100)
0BBD:0008   s:inc ax    (40)

執行之後ax值爲1,因爲跳過了add指令。

還應注意的是,jmp short短轉移指令並不會在機器碼中直接寫明需要轉移的地址(0BBD:0008),jmp的機器碼是EB03並沒有包含轉移的地址,這裏的轉移距離是相對計算而出的地址,來看下面的執行過程:

  1. (CS)=0BBDH,(IP)=0006H,CS:IP指向EB03(jmp short s)
  2. 讀取指令EB03進入指令緩衝器
  3. (IP)=(IP)+指令長度,即(IP)=(IP)+2=0008H,之後CS:IP指向add ax,1
  4. CPU指向指令緩衝器中的指令EB03
  5. 執行之後(IP)=000BH,指向inc ax

在jmp short s的機器碼中,包含的並不是轉移的地址,而是轉移的位移,這裏的位移是相對計算出來的,用8位一字節來表示,所以表示範圍是-128~127,用補碼錶示。計算方法如是,8位位移=標號處地址-jmp下一條指令的地址。當然還有一種類似的指令是jmp near ptr 標號,是近轉移,原理一樣,只是表示位移的是字類型16位,表示範圍-32768~32767。

jmp+地址遠轉移

jmp far ptr 標號實現的是段間轉移,也就是遠轉移,它的機器碼中指明瞭轉移的目的地址的CS和IP的值,如下面例子:

0BBD:0000   start:mov ax,0    (B80000)
0BBD:0003   mov bx,0    (BB0000)
0BBD:0006   jmp far ptr s    (EA0B01BD0B)
0BBD:000B   db 256 dup (0)    
0BBD:010B   s:add ax,1    
0BBD:010X   inc ax

可以看出,jmp的機器碼中明確指明瞭跳轉位置s的地址0BBD:010B,在低位的是IP的值,高位的是CS的值。

jmp+寄存器|內存轉移

jmp+寄存器:jmp 16位reg,實現的是(IP)=(16位reg),之前討論過,直接修改IP的值爲寄存器中的值。

jmp+內存:jmp加內存使用的時候有兩種用法:

  • jmp word ptr 內存單元地址(段內轉移)
    • 從內存單元地址處開始存放一個座位轉移目的的偏移地址的字
    • 內存單元支持任何尋址方式
    • 如jmp word ptr ds:[0],執行後(IP)=0123H(ds:[0]中的值是123H)
  • jmp dword ptr 內存單元地址(段間轉移)
    • 從內存單元地址處開始存放兩個字,高位存放段地址,低位偏移地址作爲轉移的目的地址
    • (CS)=(內存單元地址+2),(IP)=(內存單元地址),支持任一種尋址方式
    • 如jmp dword ptr [bx]跳轉到0:123H
jcxz指令

jcxz指令爲條件轉移指令,所有的條件轉移指令都是短轉移,轉移範圍是-128~127。使用格式是jcxz 標號,功能是如果(cx)=0則跳轉到標號處執行;如果(cx)!=0,那麼什麼也不做繼續執行代碼。

loop指令

loop爲循環指令,所有的循環指令都是短轉移,轉移範圍是-128~127。使用格式是loop 標號,功能是如果(cx)!=0那麼跳轉到標號處執行;如果(cx)=0那麼什麼也不做繼續執行程序。

根據位移進行轉移的指令總結

下面幾條指令是根據位移進行轉移(相對計算轉移位置,而不是直接提供轉移目的的IP和CS的值)

  • jmp short 標號
  • jmp near ptr 標號
  • jcxz 標號
  • loop 標號

這些指令之所以是間接計算標號的位置,是爲了方便在代碼中浮動裝配,使得循環體或這些指令的代碼段在任何位置都可以執行(不要超跳轉範圍)。而編譯器會對跳轉的範圍進行檢測,如果跳轉超過了範圍,編譯器會報錯。

注:jmp 2100:0是debug使用的彙編指令,編譯器並不認識。

call和ret指令

ret和retf

ret和call都是轉移指令,都是修改IP的值,或同時修改CS和IP。

ret指令用棧中的數據修改IP,實現的是近轉移;retf指令用棧中的數據修改CS和IP的值,實現遠轉移。格式:直接用 ret。

ret執行步驟:

  1. (IP)=((SS)*16+(SP))
  2. (SP)=(SP)+2

retf執行步驟:

  1. (IP)=((SS)*16+(SP))
  2. (SP)=(SP)+2
  3. (CS)=((SS)*16+(SP))
  4. (SP)=(SP)+2

所以ret指令相當於 pop ip,執行retf指令相當於執行pop ip,pop cs。

call指令

call指令也是一個轉移指令,執行格式:call 目標(具體使用接下來說明),call的執行步驟:

  1. 將當前的IP或CS和IP入棧
  2. 轉移

call不能實現短轉移,但它實現轉移的原理和jmp相同。

根據位移轉移:call 標號,近轉移,16位轉移範圍,也是使用相對的轉移地址。

執行步驟:

  1. (SP)=(SP)-2
  2. ((SS)*16+(SP))=(IP)
  3. (IP)=(IP)+16

所以執行這條命令相當於執行push ip,jmp near ptr 標號。

直接使用地址進行(遠)轉移:call far ptr 標號,執行步驟:

  1. (SP)=(SP)-2
  2. ((SS)*16+(SP))=(CS)
  3. (SP)=(SP)-2
  4. ((SS)*16+(SP))=(IP)
  5. (CS)=標號所在的段的段地址
  6. (IP)=標號的偏移地址

所以執行call far ptr 標號相當於執行push cs,push ip,jmp far ptr 標號

使用寄存器的值作爲call的跳轉地址:call 16位reg

  1. (SP)=(SP)-2
  2. ((SS)*16+(SP))=(IP)
  3. (IP)=(16爲reg)

相當於執行push ip,jmp 16位reg

使用內存中的值作爲call的跳轉地址:call word ptr 內存單元地址,當然還有call dword ptr 內存單元地址,這樣進行的就是遠轉移。

聯合使用ret和call

聯合使用ret和call實現子程序的框架:

assume cs:code
code segment
main:
···
call sub1
···
mov ax,4c00h
int 21h

sub1:
···
call sub2
···
ret

sub2:
···
ret
code ends
end main
mul指令

mul是乘法指令,使用時應注意,兩個相乘的數,要麼都是8位,要麼都是16位,如果是8位,那麼其中一個默認放在al中,另一個在一個8位reg或字節內存單元中;若是16位,則一個默認在ax中,另一個在16位reg或字內存單元中。如果是8位乘法, 則結果放在ax中,結果是16位;若是16位乘法,結果默認在ax和dx中,dx高位,ax低位,共32位。

格式:mul reg 或 mul 內存單元,支持內存單元的各種尋址方式。

如mul word ptr [bx+si+8]代表:

(ax)=(ax)*((ds)*16+(bx)+(si)+8)低16位
(dx)=(ax)*((ds)*16+(bx)+(si)+8)高16位

例:計算100*10

mov al,100
mov bl,10
mul bl
參數的傳遞和模塊化編程

看下面一段程序:計算data中第一行的數的立方存在第二行

assume cs:code
data segment
dw 1,2,3,4,5,6,7,8
dd 0,0,0,0,0,0,0,0
data ends

code segment
start:mov ax,data
mov ds,ax
mov si,0
mov di,16

mov cs,8
s:mov bx,[si]
call cube
mov [di],ax
mov [di].2,dx
add si,2
add di,4
loop s

mov ax,4c00h
int 21h

cube:mov ax,bx
mul bx
mul bx
ret

code ends
end start
寄存器衝突

觀察下面將data中的數據全轉化爲大寫的代碼:

assume cs:code
data segment
db 'word',0
db 'unix',0
db 'wind',0
db 'good',0
data ends

code segment
start:mov ax,data
mov ds,ax
mov bx,0

mov cx,4
s:mov si,bx
call capital
add bx,5
loop s

mov ax,4c00h
int 21h

capital:mov cl,[si]
mov ch,0
jcxz ok
and byte ptr [si],11011111b
inc si
jmp short capital
ok:ret
code ends
end start

這段代碼有一個問題出在,主函數部分使用cx設置循環次數4次,在循環中調用了子函數,而子函數中有一個判斷語句jcxz也是用了cx,並且在之前修改了cx的值,造成邏輯錯誤。雖然修改的方法有很多,但我們應遵循以下的標準:

  • 編寫調用子程序的程序不必關心子程序使用了什麼寄存器
  • 編寫子程序不用關心調用子程序的程序使用了什麼寄存器
  • 不會發生寄存器衝突

針對這三點,我們可以如下修改代碼:

···
capital:push cx
push si

change:mov cl,[si]
mov ch,0
jcxz ok
and byte ptr [si],11011111b
inc si
jmp short change

ok:pop si
pop cx
ret
···

雖然和上面的程序中沒有衝突的是si,但我們保險起見,在子程序開始時將子程序用到的所有的寄存器的內容存入棧中,在返回之前在出棧回到相應寄存器中。這樣無論調用子程序的程序使用了什麼寄存器,都不會產生寄存器衝突。

標誌寄存器

標誌寄存器

CPU中有一種特殊的寄存器——標誌寄存器(不同CPU中的個數和結構都可能不同),主要有以下三種作用:

  1. 存儲相關指令的某些執行結果
  2. 爲CPU執行相關質量提供行爲依據
  3. 控制CPU相關工作方式

8086CPU中的標誌寄存器有16位,其中存儲的信息通常被稱爲程序狀態字(PSW),標誌寄存器以下簡稱爲flag。標誌位如圖:

15	14	13	12	11	10	9	8	7	6	5	4	3	2	1	0
				OF	DF	IF	TF	SF	ZF		AF		PF		CF

如上圖所示,1,3,5,12,13,14,15位沒有使用,沒有任何意義,而其他幾位都有不同的含義。

ZF標誌

ZF位於flag第6位,零標誌位,功能是記錄相關指令執行後結果是否爲0,如果結果爲0,則ZF=1,否則ZF=0。如:

mov ax,1
sub ax,1

執行後結果爲0,ZF=1。一般情況下,運算指令(如add,sub,mul,div,inc,or,and)影響標誌寄存器,而傳送指令(如mov,push,pop)不影響標誌寄存器。

PF標誌

flag的第2位是PF標誌位,奇偶標誌位,功能是記錄相關指令執行後,其結果的所有bit中1的個數是否爲偶數,若1的個數是偶數,pf=1,如果是奇數,fp=0。如:

mov al,1
add al,10

執行後結果爲00001011b,有3個1,所以PF=0。

SF標誌

flag的第7位是SF標誌位,符號標誌位,它記錄相關指令執行後,結果是否爲負,如果結果爲負,則sf=1,結果爲正,sf=0。計算機中通常用補碼錶示數據,一個數可以看成有符號數或無符號數,如:

00000001B,可以看成無符號1或有符號+1
10000001B,可以看成無符號129或有符號-127

也就是說對於同一個數字,可以當做有符號數運算也可以當做無符號數運算。如:

mov al,10000001b
add al,1

這段代碼結果是(al)=10000010b,可以將add指令進行的運算當做無符號運算,那麼相當於129+1=130,也可以當做有符號運算,相當於-127+1=-126。SF標誌就是在進行有符號運算的時候記錄結果的符號的,當進行無符號運算的時候SF無意義(但還會影響SF,只是對我們來說沒有意義了)。

CF標誌

flag的第0位是CF標誌位,進位標誌位,一般情況下載進行無符號運算時,他記錄了運算結果的最高有效爲向更高爲的進位值,或從更高位的借位值。加入一個無符號數據是8位的,也就是0-7個位,那麼在做加法的時候就可能造成進位到第8位,這時並不是丟棄這個進位,而是記錄在falg的CF位上。如:

mov al,98h
add al,al

執行後al=30h,CF=1。當兩個數據做減法的時候有可能向更高位借位,如97h-98h借位後相當於197h-198h,CF也可以用來記錄借位,如:

mov al,97h
sub al,98h

執行後(al)=FFH,CF=1記錄了向更高位借位的信息。

OF標誌

在進行有符號運算的時候,如果結果超過了機器能表示的範圍稱爲“溢出”。機器能表示的範圍是指如8位寄存器存放或一個內存單元存放,表示範圍就是-128~127,16位同理。如果超出了這個範圍就叫做溢出,如:

mov al,98
add al,99

mov al,0F0H
add al,088H

第一段代碼(al)=(al)+99=98+99=197超過了8位能表示的有符號數的範圍,第二段代碼結果(al)=(al)+(-120)=(-16)+(-12-)=-136也超過了8位有符號的範圍,所以計算的結果是不可信的。如第一段代碼計算之後(al)=0C5H,換成補碼錶示的是-59,98+99=-59很明顯是不正確的結果。

flag的第11位是OF標誌位,溢出標誌位,一般情況下,OF記錄有符號數運算結果是否溢出,如果溢出則OF=1,如果沒有溢出,OF=0。所以CF是對無符號數的標誌,OF是對有符號的標誌。但對於一個運算指令,他們是同時生效的,只不過這個指令究竟是有符號還是無符號,是看實際的操作的。有符號CF無意義,無符號OF無意義。

adc指令

adc是帶進位加法指令,利用了CF標誌位上記錄的進位值。格式:adc 操作對象1,操作對象2。功能:操作對象1=操作對象1+操作對象2+CF。如abc ax,bx實現的是(ax)=(ax)+(bx)+CF,如:

mov ax,2
mov bx,1
sub bx,ax
adc ax,1

注意這段代碼,首先ax中的值是2,bx中的值是1,然後進行(bx)-(ax)的計算,結果是-1造成了無符號的借位,此時CF=1,在進行adc ax,1時,進行的是(ax)+1+CF=2+1+1=4。仔細分析一下就可以發現,如果把整個加法分開,低位先相加,然後高位相加再加上進位CF, 就是一個完整的加法運算,也就是說add ax,dx這個指令可以拆分爲:

add al,bl
adc ah,bh

所以有了adc這個指令我們就可以完成一些更龐大的數據量的加法運算。如計算1EF000H+000H的值:

mov ax,001eh
mov bx,0f000h
add bx,1000h
adc ax,0020h

注:inc和loop指令不影響CF位。

sbb指令

sbb和adc類似,是帶借位的減法,格式:sbb 操作對象1,操作對象2,執行的功能是操作對象1=操作對象1-操作對象2-CF,如:sbb ax,bx即(ax)=(ax)-(bx)-CF。sbb指令影響CF。

cmp指令

cmp是比較指令,cmp的功能相當於減法,只是不保存結果。cmp執行後影響標誌寄存器,其他相關指令通過識別被影響的標誌位來得知結果。格式:cmp 操作對象1,操作對象2,執行功能是計算對操作對象1-操作對象2但不保存結果,僅僅根據結果對標誌位進行設置,如:cmp ax,ax結果爲0,但並不保存在ax中,執行之後zf=1,pf=1,sf=0,cf=0,of=0。若執行cmp ax,bx通過標誌位就可以判斷結果:

若(ax)=(bx)則(ax)-(bx)=0,zf=1
若(ax)!=(bx)則(ax)-(bx)!=0,zf=0
若(ax)<(bx)則(ax)-(bx)產生借位,cf=1
若(ax)>=(bx)則(ax)-(bx)不產生借位,cf=0
若(ax)>(bx)則(ax)-(bx)既不產生借位,結果又不爲0,cf=0且zf=0
若(ax)<=(bx)則(ax)-(bx)既可能借位,結果可能爲0,cf=1或zf=1

但實際上往往會出現溢出,如34-(-96)=82H(82H是-126的補碼),但應該等於130超出了補碼錶示的範圍,所以sf=1。我們可以同時檢驗sf和of兩個來驗證cmp的結果:cmp ah,bh

  • 若sf=1,of=0說明沒有溢出,那麼sf的計算結果正確(ah)<(bh)
  • 若sf=1,of=1說明出現了溢出,那麼sf結果相反(ah)>(bh)
  • 若sf=0,of=1說明有溢出,那麼sf結果相反(ah)<(bh)
  • 若sf=0,of=0說明沒有溢出,那麼結果正確(ah)>=(bh)
檢測比較結果的條件轉移指令

下面幾條指令和cmp一起使用,檢測不同的標誌位來達到不同的條件跳轉效果:

指令 含義 檢測的標誌位
je 等於則轉移 zf=1
jne 不等於轉移 zf=0
jb 小於轉移 cf=1
jnb 不小於轉移 cf=0
ja 大於轉移 cf=0且zf=0
jna 不大於轉移 cf=1或zf=1

指令中的字母含義如下:

  • e:equa;
  • ne:not equal
  • b:below
  • nb:not below
  • a:above
  • na:not above

上面的檢測都是在cmp進行無符號比較時的檢測位,有符號數檢測原理一樣,只是檢測的標誌位不同而已。下面看一個例子,如果(ah)=(bh)則(ah)=(ah)+(ah),否則(ah)=(ah)+(bh)

cmp ah,bh
je s
add ab,bh
jmp short ok
s:add ah,ah
ok:···

這裏注意的是,je檢測的是zf位,而不管之前執行的是什麼指令,只要zf=1就會發生轉移,所以cmp的位置需要仔細的把控,當然是否和cmp配合使用也是取決於編程者,下面例子實現了統計data中數值爲8的字節個數,然後用ax保存:

···
data segment
db 8,11,8,1,8,5,63,38
data ends
···
mov ax,data
mov ds,ax
mov bx,0
mov ax,0
mov cx,8
s:cmp byte ptr [bx],8
jne next
inc ax
next:inc bx
loop s
···
DF標誌位和串傳送指令

flag的第10位是DF標誌位,方向標誌位,在串處理中,每次操作si,di的增減。

  • df=0每次操作後si,di遞增
  • df=1每次操作後si,di遞減

串傳送指令,movsb,這個指令相當於執行:

  1. ((es)*16+(di))=((ds)*16+(si))

  2. 如果df=0:(si)=(si)+1,(di)=(di)+1

    如果df=1:(si)=(si)-1,(di)=(di)-1

可以看出,movsb是將DS:SI指向的內存單元中的字節送入ES:DI中,然後根據DF的值對SI和DI增減1

同理mobsw就是將DS:SI指向的內存單元中的字送入ES:DI中,然後根據DF的值對SI和DI增減2

但一般來說,movsb和movsw都是和rep聯合使用的,格式:rep movsb,這相當於:

s:movsb
loop s

所以rep的作用是根據cx的值重複執行後面的串傳送指令,由於每次執行movsb之後si和di都會自行增減,所以使用rep可以完成(cx)個字節的傳送。movsw也一樣。

由於DF位決定着串傳送的方向,所以這裏有兩條指令用來設置df的值:

cld:df=0
std:df=1

例子:使用串傳送指令將data段中第一個字符串複製到他後面的空間中:

···
data segment
db 'Welcome to masm!'
db 16 dup (0)
data ends

mov ax,data
mov ds,ax
mov si,0
mov es,ax
mov di,16
mov cx,16
cld
rep movsb
···
pushf和popf

pushf的功能是將標誌寄存器的值入棧,popf是出棧標誌寄存器。有了這兩個命令,就可以直接訪問標誌寄存器了,如:

mov ax,0
push ax
popf
標誌寄存器在Debug中的表示

Debug中-r查看寄存器信息,最後有一段表示,下面列出我們已知的寄存器在Debug裏的表示:

標誌 值1的標記 值0的標記
of OV NV
sf NG PL
zf ZR NZ
pf PE PO
cf CY NC
df DN UP

內中斷

內中斷的產生

任何一個通用CPU都擁有執行完當前正在執行的指令後,檢測到從CPU發來的中斷信息,然後立即去處理中斷信息的能力。這裏的中斷信息是指幾個具有先後順序的硬件操作,當CPU出現下面請看時會產生中斷信息,相應的中斷信息類型碼(供CPU區分來源,是字節型,共256種)如下:

  • 除法錯誤,如執行div指令出現除法溢出 0
  • 單步執行 1
  • 執行into指令 4
  • 執行int指令 指令執行的int n後面的n就是一個字節型立即數,即爲中斷類型碼
中斷處理和中斷向量表

CPU接收到中斷信息之後,往往要對中斷信息進行處理,而如何處理使我們編程決定的。而CPU通過中斷向量表來根據中斷類型找到處理程序的入口地址(CS:IP)也稱爲中斷向量。

中斷向量表中存放着不同的中斷類型對應的中斷向量(處理程序的入口地址),中斷向量表存放在內存中,8086PC指定必須放在內存地址0處,從0000:0000到0000:03FF的1024個單元存放中斷向量表,每個表項佔兩個字,四個字節。

CPU會自動根據中斷類型找到對應的中斷向量並設置CS和IP的值,這個過程稱爲中斷過程,步驟如下:

  1. (從中斷信息中)取得中斷類型碼
  2. 標誌寄存器的值入棧(暫存)pushf
  3. 設置標誌寄存器第8位TF和第9位IF的值爲0 TF=0,IF=0
  4. CS內容入棧 push cs
  5. IP內容入棧 push ip
  6. 在中斷向量表中找到對應的CS和IP值並設置 (ip)=(N*4),(cs)=(N*4+2)

這麼做的目的是,在中斷處理之後還要回復CPU的現場(各個寄存器的值),所以先把那些入棧。

中斷處理程序和iret指令

運行中的CPU隨時都可能接收到中斷信息,所以CPU隨時都可能執行中斷程序,執行的步驟:

  1. 保存用到的寄存器
  2. 處理中斷
  3. 回覆用到的寄存器
  4. 用iret返回

iret的指令功能是:pop ip pop cs popf(前面說到了,這三個寄存器的入棧是硬件自動完成的,所以iret是和硬件自動完成的步驟配合使用的)。

以處理0號除法溢出中斷爲例,我們想要編寫除法溢出的中斷處理程序需要解決如下幾步問題:

  1. 編寫程序
  2. 找到一段沒有使用的內存空間
  3. 將程序寫入到內存
  4. 將內存中的程序的入口寫入0號中斷的向量表位置

我們可以採取下面框架來完成這個過程:

···
start do0安裝程序
設置中斷向量表
mov ax,4c00h
int 21h

do0 程序部分
mov ax,4c00h
int 21h
···

可以看出我們分成了兩部分,第一部分稱之爲“安裝”,第二部分是代碼實現。安裝部分的函數實現思路如下:

設置es:di至項目的地址
設置ds:si指向源地址
設置cx爲傳輸長度
設置傳輸方向爲正
rep movsb
設置中斷向量表

實現如下:

start:mov ax,cs
mov ds,ax
mov si,offset do0
mov ax,0
es,ax
mov di,200h
mov cx,offset do0end-fooset do0
cld
rep movsb
···
do0:代碼
do0end:nop

這裏offset do0end-fooset do0的意思是do0到do0end的代碼長度,-是編譯器可以識別並運算的符號,也就是說編譯器可以再編譯時處理表達式,如8-4等。還要注意的是,假如代碼部分要輸出“owerflow!”的話,需要將輸出的內容寫在代碼部分並寫入選擇的內存中,否則如果單單在這個安裝程序開始開闢一段data段的話,是會在程序返回時被釋放。如:

do0:jmp short do0start
db "overflow!"

do0start:
···
do0end:nop
單步中斷

當標誌寄存器的TF標誌位爲1的時候,CPU會在執行一條語句之後將資源入棧,然後去執行單步中斷處理程序,如Debug就是運行在單步中斷的條件下的,它能讓CPU每執行一條指令都暫停,然後我們可以查看CPU的狀態。但CPU可以防止在運行單步中斷處理程序的時候再發生中斷然後又去調用單步中斷處理程序…CPU可以將TF置零,這樣就不會再中斷了。CPU提供這個功能就是爲了單步跟蹤程序的執行。

但需要注意的是,CPU並不會每次接收中斷信息之後立即執行,在某些特定情況下它不會立即響應中斷,如設置ss寄存器的時候如果接收到了中斷信息,就不會響應。因爲我們需要連續設置ss和ip的值,在debug中單步執行的時候也是,mov ss,ax和mov sp,0是在一步之內執行的,所以我們需要靈活使用這個特性,sp緊跟着ss執行,而不要在設置ss和sp之間插入其他指令。

int指令

int指令

int指令也會引發中斷,使用格式是int n,n就是int引發的中斷類型碼,int中斷的執行過程:

  1. 獲取類型碼n
  2. 標誌寄存器入棧,if=0,tf=0
  3. cs,ip入棧
  4. (ip)=(n*4),(cs)=(n*4+2)
  5. 執行n號中斷的程序

所以我們可以使用int指令調用任何一箇中斷的中斷程序,如int 0調用除法溢出中斷。一般情況下,系統將一些具有一定功能的小程序以中斷的方式提供給程序調用,當然也可以自己編寫,可以簡稱爲中斷例程。

編寫中斷例程

如編寫中斷7ch的中斷例程,實現word型數據的平方,返回dx和ax中。求2*3456^2,代碼:

assume cs:code
code segment
start mov ax,3456
int 7ch
add ax,ax
adc dx,dx
mov ax,4c00h
int 21h
code ends
end start

接下來寫7ch的功能和安裝程序,並修改7ch中斷向量表:

assume cs:code
code segment
start:mov ax,cs
mov ds,ax
mov si,offset sqr
mov ax,0
mov es,ax
mov di,200h
mov cx,offset sqrend-offset sqr
cld
rep movsb

mov ax,0
mov es,ax
mov word ptr es:[7ch*4],200h
mov word ptr es:[7ch*4+2],0

mov ax,4c00h
int 21h

sqr:mul ax
iret

sqrend:nop
code ends
end start

編寫7ch中斷實現loop指令,主程序輸出80個“!”:

···
start mov ax,0b800h
mov es,ax
mov di,160*12
mov bx,offset s-offset se
mov cx,80
s:mov byte ptr es:[di],'!'
add di,2
int 7ch
se:nop
···

7ch實現部分:

lp:push bp
mov bp,sp
dec cx
jcxz lpret
add [bp+2],bx
lpret:pop bp
iret

因爲bx裏面是需要專一的偏移地址,而使用bp的時候默認段寄存器是ss,所以add [bp+2],bx就可以實現將棧中的sp的值修改回s處,自行推導一下就ok。

BIOS和DOS提供的中斷例程

系統ROM中存放着一套程序,稱爲BIOS,除此之外還有DOS都提供了一套可以供我們調用的中斷例程,不同歷程有不同的中斷類型碼,並且還能根據傳入的參數不同而實現不同的功能,也就是說同一個類型碼的中斷例程可以實現很多不同功能,如int 10h是BIOS提供的包含了多個和屏幕輸出相關子程序的中斷例程。傳參數如下面例子:

···
mov ah,2 ;置光標
mov bh,0 ;第0頁
mov dh,5 ;dh中放行號
mov dl,12 ;dl中放列號
int 10h

BIOS和DOS安裝歷程的過程是,開機後CPU一加電,自動初始化CS爲0FFFFH,IP爲0,而在這個地方有一個跳轉指令,挑戰到BIOS和系統檢測初始化程序。在BIOS系統檢測初始化程序中會設置中斷向量表中的值。

端口

端口的概念

各種存儲器都要和CPU的地址線,數據線,控制線相連,在CPU看來,總線就是一個由若干個存儲單元構成的邏輯存儲器,稱之爲內存地址空間。除了各種存儲器,通過總線和CPU相連的還有下面三種芯片:

  • 各種接口卡(如網卡顯卡)上的接口芯片,他們控制接口卡工作
  • 主板上的接口芯片,CPU通過它們訪問外部設備
  • 其他芯片,用來存儲相關係統信息,或進行相應的輸入輸出

上面的芯片中都有一種由CPU讀寫的寄存器,它們都和CPU的總線相連(通過各自的芯片),CPU對他們進行讀寫時候都通過控制線向他們所在的芯片發出端口讀寫指令。

所以,對於CPU來說,將這些寄存器都當做端口,對他們進行統一編址,建立了一個端口地址空間,每一個端口擁有一個地址,所以CPU可以直接讀取下面三個地方的數據:

  • CPU內部的寄存器
  • 內存單元
  • 端口
端口的寫

因爲端口所在的芯片和CPU通過總線相連,所以端口地址和內存地址一樣通過地址總線傳送,並且在PC系統中,CPU最多可以定位64KB個不同的端口,所以端口地址範圍是0~65535。

對端口的讀寫不能使用mov,push,pop等內存讀寫指令,端口的讀寫指令只有兩個:in和out分別用於從端口讀取數據和往端口寫入數據。

訪問端口的步驟:

  1. CPU通過地址總線降低至信息60h發出
  2. CPU通過控制線發出端口讀命令,選中端口所在芯片,並通知它要從中讀取數據
  3. 端口所在芯片將目標端口中的數據通過數據線送入CPU

注:在in和out指令中,只能通過ax或al來存放從端口中讀入的數據或要發送到端口中的數據,且訪問8位端口時,用al,訪問16位端口用ax。

對0~255以內的端口進行讀寫時:

in al,20h
out 20h,al

對256~65535的端口進行讀寫時,需要將端口號寫在dx中:

mov dx,3f8h
in al,dx
out dx,al
CMOS RAM芯片

PC中有一個叫做CMOS RAM的芯片,稱爲CMOS,有如下特徵:

  • 包含一個實時鐘和一個有128個存儲單元的RAM存儲器(早期的計算機64個字節)
  • 靠電池供電,關機後內部的實時鐘仍可繼續工作,RAM中的信息不丟失
  • 128個字節的RAM中,內部實時鐘佔用0~0dh單元保存時間信息,其餘大部分單元用於保存系統配置信息,供系統啓動時BIOS程序讀取,BIOS也提供了相關的程序可以讓我們在開機時配置CMOS中的系統信息。
  • 芯片內部有兩個端口70h和71h,CPU通過這兩個端口讀寫CMOS
  • 70h爲地址端口,存放要訪問CMOS單元的地址,71h爲數據端口,存放從選定的單元中讀取的數據,或寫入的數據。

所以可以看出,想要從CMOS中讀取數據,應分兩步,先將單元號送入70h,然後再從71h讀出對應號的數據。

shl和shr指令

shl和shr是邏輯移位指令,shl是邏輯左移,功能爲:

  1. 將一個寄存器或內存單元中的數向左移位
  2. 將最後移出的一位寫入CF
  3. 最低位補0

如:mov al,01001000b shl al,1執行結束後(al)=10010000b,CF=0。

注:如果移動位數大於1,那麼必須將移動位數寫在cl中。

mov al,01010001b
mov cl,3
shl al,cl

執行後(al)=10001000b,最後移出的一位是0,所以CF=0。可以看出左移操作相當於x=x*2。

右移shr同理,最高位用0補充,移出的寫入CF,若移動位數大於1,也要寫在cl中,相當於x=x/2

在CMOS中存放着當前時間的年月日時分秒,分別存在下面的單元內:

0 2 4 7 8 9

每個信息使用一個字節存放,以BCD碼的形式,BCD碼是對0-9這幾個數字使用二進制表示,如:

0 1 2 3 4 5 6 7 8 9
0000 0001 0010 0011 0100 0101 0110 0111 1000 1001

如果要表示一個兩位數如13,就是一個字節高四位是十位1的BCD碼,低四位是個位3的BCD碼,表示爲00010011b。下面程序獲取當前月份:

···
mov al,8
out 70h,al   ;要從8號單元讀取數據,所以先將8號單元送入70h端口
in al,71h    ;從71h端口拿數據

mov ah,al    ;複製一下
mov cl,4     
shr ah,cl    ;ah右移四位,ah裏面的就是月份的十位
and al,00001111b  ;al裏面剩下的就是月份的個位

外中斷

接口芯片和端口

CPU除了需要擁有運算的能力,還要擁有I/O(輸入輸出)能力,我們鍵入一個字母,要能處理,所以我們需要面對的是:外部設備隨時的輸入和CPU何處得到外部設備的輸入。

外部設備擁有自己的芯片連接到主板上,這些芯片內部由若干寄存器,而CPU將這些寄存器當做端口訪問,外設的輸入或CPU向外設輸出都是送給對應的端口然後再由芯片處理送給目標(CPU或外設)。

外中斷

CPU提供外中斷來處理這些如隨時可能出現的來自外設的輸入,在PC系統中,外中斷源有以下兩類:

可屏蔽中斷:CPU可以不響應的外部中斷,CPU是否響應看標誌寄存器IF的設置,如果IF=1,CPU執行完當前指令後響應中斷,如果IF=0,則不響應。可屏蔽中斷的執行步驟和內部中斷類似:

  1. 獲取中斷類型碼n(從外部通過總線輸入)
  2. 標誌寄存器入棧,IF=0,TF=0
  3. CS,IP入棧
  4. (IP)=(n*4),(CS)=(n*4+2)

可見,將IF置零的原因是以免在處理中斷程序的時候再發生中斷。當然我們也可以選擇處理,下面兩個指令可以改變IF的值:sti,設置IF=1,cli,設置IF=0。

不可屏蔽中斷:CPU必須響應的外部中斷,CPU檢測到不可屏蔽中斷後執行完當前指令立即響應中斷。8086CPU中不可屏蔽中斷的中斷類型碼固定位2,所以中斷過程中不需要獲取中斷類型碼,步驟:

  1. 標誌寄存器入棧,IF=0,TF=0
  2. CS,IP入棧
  3. (IP)=(8),(CS)=(0AH)

幾乎所有由外設引發的外中斷都是可屏蔽中斷,如鍵盤輸入,不可屏蔽中斷通常是在系統中又必須處理的緊急情況發生時通知CPU的中斷信息。

PC鍵盤處理過程

鍵盤上每個按鍵都相當於一個開關,按下就是開關接通,擡起就是開關斷開。鍵盤上有一個芯片對鍵盤中每一個鍵盤的狀態進行掃描,開關按下生成一個掃描碼——通碼,記錄按下的按鍵位置,開關擡起也會產生一個掃描——斷碼,碼記錄鬆開的位置,都是送入60h端口。通碼的第7位爲0,斷碼第7位爲1,也就是說斷碼=通碼+80h。P247表。

當鍵盤輸入送達60h時,相關新品就會向CPU發送中斷類型碼爲9的可屏蔽中斷信息。CPU檢測到該中斷信息之後,如果IF=1,響應中斷,引發中斷過程並執行int9的中斷例程。BIOS中int9的中斷程序用來進行基本的鍵盤輸入處理,步驟如下:

  1. 讀出60h的掃描碼
  2. 如果是字符的掃描碼,將對應的字符的ASCII嗎存入內存中的BIOS鍵盤緩衝區,如果是控制鍵(Ctrl)和切換鍵(CapsLock)掃描碼,則將其轉換爲狀態字(二進制位記錄控制鍵和切換鍵狀態的字節)寫入內存中的存儲狀態字節的單元。
  3. 對鍵盤系統進行相關控制,如向新平發出應答

BIOS中鍵盤緩衝區能存儲15個鍵盤輸入,每個鍵盤輸入兩字節,高位存放掃描碼,低位存放字符。此外,0040:17單元存放鍵盤狀態字節,記錄了控制鍵和切換鍵的狀態,記錄信息如下:

含義
0 右shift,1表示按下
1 左shift,1按下
2 Ctrl,1按下
3 Alt,1按下
4 ScrollLock狀態,1表示指示燈亮
5 NumLock狀態,1表示小鍵盤輸入的是數字
6 CapsLock狀態,1表示大寫字母
7 Insert狀態,1表示處於刪除狀態

可以看書P276的一個改寫int 9的中斷例程。

直接定址表

描述單元長度的標號

我們可以使用下面的標號來表示數據的開始:

···
code segment
a:db 1,2,3,4,5,6,7,8
b:dw 0
···
code ends
···

a,b都是代表對應數據的起始地址,但並不能判斷數據的長度或類型。下面一段程序將a中的8個數累加存入b中:

assume cs:code
code segment
a db 1,2,3,4,5,6,7,8
b dw 0
start mov si,0
mov cx,8
s:mov al,a[si]
mov ah,0
add b,ax
inc si
loop s
mov ax,4c00h
int 21h
code ends
end start

code段中a和b後並沒有":"號,這種寫法同時描述內存地址和單元長度的標號。a描述了地址code:0和從這個地址開始後的內存單元都是字節單元,而b描述了地址code:8和從這個地址開始以後的內存單元都是字單元。所以b相當於CS:[8],a[si]相當於CS:0[si],使用這種標號,我們可以間接地訪問內存數據。

其它段中使用數據標號

剛說的第一種標號即加":"號的標號,只能使用在代碼段中,不能在其他段中使用。如果想要在其它段中(如data段)使用標號可以使用第二種:

assume cs:code,ds:data
data segment
a db 1,2,3,4,5,6,7,8
b dw 0
data ends
···
start mov ax,data
mov ds,ax
mov si,0
mov al,a[si]
···

如果想在代碼段中直接使用數據標號訪問數據,需要使用assume僞指令將標號所在段和一個寄存器聯繫起來,是讓寄存器明白,我們要訪問的數據在ds指向的段中,但編譯器並不會真的將段地址存入ds中,我們做了如下假設之後,編譯器在編譯的時候就會默認ds中已經存放了data的地址,如下面的編譯例子:

mov al,a[si]
編譯爲:mov al,[si+0]

可以看出編譯器默認了a[si]在ds所在的段中。所以我們需要手工指定ds指向data:

mov ax,data
mov ds,ax

也可以這麼使用:

data segment
a db 1,2,3,4,5,6,7,8
b dw 0
c a,b
data ends

c處存放的是a和b的偏移地址,相當於c dw offset a,offset b。同理c dd a,b相當於c dw offset a,seg a,offset b,seg b即存的是a和b的段地址和偏移地址。

直接定址表

使用查表的方法編寫相關程序,如輸出一個字節型數據的16進制形式(子程序):

showbyte jmp short show
table db '0123456789ABCDEF'
show:push bx
push es
mov ah,al
she ah,1
she ah,1
she ah,1
she ah,1 ;右移四位,位移子程序限制使用的寄存器數,只能這麼移
and al,00001111b
mov bl,al
mov bh,0
mov ah,table[bx]  ;高四位作爲相對於table的偏移,取得對應字符
mov bx,0b800h
mov es,bx
mov es:[160*12+40*2],ah
mov bl,al
mov bh,0
mov al,table[bx]
mov es:[160*12+40*2+2],al
pop es
pop bx
ret

可見我們直接使用需要的數值和地址的映射關係來尋找需要的數據。

程序入口地址的直接定址表

可以看書P296的例程,主要思想是,編寫多個子程序實現不同功能,每個子程序有自己的標號,如sub1,sub2···等。將它們存在一個表中:

table dw sub1,sub2,sub3,sub4

然後按照之前的方法使用如:

setscreen:jmp short set
table dw sub1,sub2,sub3,sub4
set:push bx
cmp ah,3
ja sret
mov bl,ah
mov bh,0
add bx,bx
call word ptr table[bx]
sret:pop bx
ret

使用BIOS進行鍵盤輸入和磁盤讀寫

int 9中斷例程對鍵盤輸入的處理

鍵盤處理依次按下A,B,C,D,E,shift_A,A的過程:

我們知道,鍵盤有16字的緩衝區,可以存放15個按鍵的掃描碼和對應的ASCII碼值,如下:

|	|	|	|	|	|	|	|	|	|	|	|	|	|	|	|	|

我們按下A時,引發鍵盤中斷,CPU執行int 9中斷例程,從60h端口讀出A鍵通碼,然後檢測狀態字,看是否有控制鍵或切換鍵按下,發現沒有,將A的掃描碼1eh和對應的ASCII碼’a’61h寫在緩衝區:

|1e61|	|	|	|	|	|	|	|	|	|	|	|	|	|	|	|	|

然後BCDE同理:

|1e61|3062|2e63|2064|1265|	|	|	|	|	|	|	|	|	|	|	|	|	|	|	|	|

在按下shift之後引發鍵盤中斷,int 9程序接受了shift的通碼之後設置0040:17處狀態字第一位爲1,表示左shift按下,接下來按A間,引發中斷,int 9中斷例程從60h端口督導通碼之後檢測狀態字,發現左shift被按下,於是將A的鍵盤掃描碼1eh和’A’的ASCII41h寫入緩衝區:

|1e61|3062|2e63|2064|1265|1e41|	|	|	|	|	|	|	|	|	|	|	|	|	|	|	|

鬆開shift,0040:17第一位變回0,之後又按下A和之前一樣。

int 16h讀取鍵盤緩衝區

int 16h可以供程序員調用,編號爲0的功能是從鍵盤緩衝區讀一個鍵盤輸入,(ah)=掃描碼,(al)=ascii碼。如:

mov ah,0
int 16h
|3062|2e63|2064|1265|1e41|	|	|	|	|	|	|	|	|	|	|	|	|	|	|	|

執行後,緩衝區第一個沒了,然後ah中是1eh,al中是61h。如果緩衝區爲空的時候執行,那麼會循環等待知道緩衝區有數據,所以int 16h的0號功能的步驟是:

  1. 檢測鍵盤緩衝區是否有數據
  2. 沒有則繼續1
  3. 讀取第一個單元的鍵盤輸入
  4. 掃描碼送ah,ascii碼送al
int 13h讀寫磁盤

3.5寸軟盤分爲上下兩面,每面80個磁道,每個磁道18個扇區,每個扇區512字節,共約1.44MB。磁盤的實際訪問時磁盤控制器進行的,我們通過控制磁盤控制器來控制磁盤,只能以扇區爲單位讀寫磁盤,每次需要給出面號,磁道號,和扇區號,面號和磁道號從0開始,扇區號從1開始。BIOS提供int 13h來實現訪問磁盤,讀取0面0道1扇區的內容到0:200的程序:

mov ax,0
mov es,ax
mov bx,200h

mov al,1   ;讀取的扇區數
mov ch,0   ;磁道號
mov cl,1   ;扇區號
mov dl,0   ;驅動器號,0開始,0軟驅A,1軟驅B,磁盤從80h開始,80h硬盤C,81h硬盤D
mov dh,0   ;磁頭號(軟盤面號)
mov ah,2   ;13h的功能號,2表示讀扇區
int 13h

es:bx指向接收數據的內存區。操作成功(ah)=0,(al)=讀入的扇區數,操作失敗(ah)=錯誤代碼。將0:200的數據寫入0面0道1扇區:

mov ax,0
miv es,ax
mov bx,200h

mov al,1   ;讀取的扇區數
mov ch,0   ;磁道號
mov cl,1   ;扇區號
mov dl,0   ;驅動器號
mov dh,0   ;磁頭號(軟盤面號)
mov ah,3   ;13h的功能號,3表示寫扇區
int 13h

es:bx指向寫入磁盤的數據,操作成功(ah)=0,(al)=寫入的扇區數,操作失敗(ah)=錯誤代碼

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章