組合語言——更多功能
轉移指令及其原理
可以修改IP,或同時修改cs和IP的指令統稱為轉移指令。概括地講,轉移指令就是可以控制CPU執行記憶體中某處程式碼的指令。
8086CPU的轉移行為有以下幾類:
-
只修改IP時,稱為段內轉移,比如:jmp ax
-
同時修改cs和IP時,稱為段間轉移,比如:jmp 1000:0,由於轉移指令對IP的修改範圍不同,段內轉移又分為:短轉移和近轉移。
-
短轉移IP的修改範圍為-128~127。
-
近轉移IP的修改範圍為-32768~32767。
8086CPU的轉移指令分為以下幾類
- 無條件轉移指令(如:jmp)
- 條件轉移指令
- 迴圈指令(如:loop)
- 過程
- 中斷
這些轉移指令轉移的前提條件可能不同,但轉移的基本原理是相同的。我們在這一章主要通過深入學習無條件轉移指令jmp來理解CPU執行轉移指令的基本原理。
操作符 offset和空操作指令nop
操作符offset
在組合語言中是由編譯器處理的符號 , 它的功能是取得標號的偏移地址。比如下面的程式:
assume cs:codesg
codesg segment
start: mov ax,offset start ;等於mov ax,0
s: mov ax,offset s ;等於mov ax,3
codesg ends
end start
空操作指令nop
,只佔用一個byte的空間,作為指令不會被執行。
jmp指令
jmp指令是無條件跳轉指令,可以只修改ip,也可以cs和ip都修改
jmp
指令要給出兩種資訊:
- 轉移的目的地址
- 轉移的距離(段間轉移、段內短轉移、段內近轉移)
不同的給出目的地址的方法,和不同的轉移位置,對應有不同格式的jmp
指令,下面分情況討論。
依據位移進行轉移的jmp指令
jmp short 標號(轉到標號處執行指令)
這種格式的jmp指令實現的是段內短轉移,它對IP的修改範圍為-128~127,也就是說,它向前轉移時可以最多越過128個位元組,向後轉移可以最多越過127個位元組。jmp指令中的"short"符號,說明指令進行的是短轉移。jmp指令中的“標號”是程式碼段中的標號,指明瞭指令要轉移的目的地,轉移指令結束後,CS:IP應該指向標號處的指令。
assume cs:code
code segment
start: mov ax,0
jmp short s
add ax,1
s:inc ax
mov ax,4c00h
int 21h
code ends
end
上面的程式碼執行完之後,ax中的值為1,因為jmp short
指令跳過了add ax,1
。
在其他的用立即數操作的指令中,如mov ax,ds:[0123h]
,在debug中可以看到對應的機器碼為B82301
,可以發現運算元就在機器碼中。
在debug中檢視jmp short s
發現對應程式碼為jmp 0008
,但機器碼確是EB03
,可以發現沒有和0008直接有關。那麼他是如何實現修改IP的呢?
;debug 機器碼
076C:0000 B80000 mov ax,0
076C:0003 EB03 jmp short s
076C:0005 050100 add ax,1
076C:0008 40 inc ax
076C:0009 B8004C mov ax,4c00h
076C:000C CD21 int 21h
可以很容易發現0005和0008之間隔了3,回想一下CPU如何處理指令,載入到緩衝區,IP+(指令長度),然後執行指令。不妨做個猜想,CPU讀取到jmp short s
之後,IP=IP+3=5,IP指向了add ax,1
,執行jmp short s
後變為0008,實際上是通過對當前IP的加減操作,IP=IP+3。為了驗證,我們在原來程式碼的基礎上,jmp short s
後再加一條nop空指令
來佔據一個byte。再次debug可以發現jmp short s
的機器碼為EB04
,證明我們的猜想是對的。
實際上,jmp short 標號
的功能為:(IP)=(IP)+8位位移。
- 8位位移=標號處的地址-jmp指令後的第一個位元組的地址;
- short指明此處的位移為8位位移;
- 8位位移的範圍為-128~127,用補碼錶示
- 8位位移由編譯程式在編譯時算出。
還有一種和jmp short 標號
功能相近的指令格式,jmpnearptr標號,它實現的是段內近轉移。jmp near ptr 標號
的功能為:(IP)=(IP)+16位位移。
- 16位位移=標號處的地址-jmp指令後的第一個位元組的地址;
- nearptr指明此處的位移為16位位移,進行的是段內近轉移;
- 16位位移的範圍為-32768~32767,用補碼錶示;
- 16位位移由編譯程式在編譯時算出。
轉移的目的地址在指令中的jmp 指令
前面說到的jmp指令對應的機器指令中並沒有轉移的目的地址,而是相對於其當前IP的轉移位移。jmp far ptr 標號
實現的是段間轉移,又稱為遠轉移。
assume cs:code
code segment
start: mov ax,0
jmp far ptr s
db 256 dup(0)
s:inc ax
mov ax,4c00h
int 21h
code ends
end
上面這段程式碼用debug檢視機器碼可以看到jmp far ptr s
的機器碼為EA08016C07
,執行這條指令後IP為0108
,CS為076C
。
轉移地址在暫存器中的jmp指令
可以在檢視之前的講解
轉移地址在記憶體中的jmp指令
轉移地址在記憶體中的jmp指令有兩種格式:
- jmp word ptr 記憶體單元地址(段內轉移)
功能:從記憶體單元地址處開始存放著一個字,是轉移的目的偏移地址。記憶體單元地址可用定址方式的任一格式給出。比如,下面的指令:
mov ax,0123H
mov ds:[0],ax
jmp word ptr ds:[0]
執行後,(IP)=0123H。
下面的指令也可以達到相同的效果:
mov ax,0123H
mov [bx],ax
jmp word ptr [bx]
- jmp dword ptr 記憶體單元地址(段間轉移)
功能:從記憶體單元地址處開始存放著兩個字,高地址處的字是轉移的目的段地址,低地址處是轉移的目的偏移地址。
(CS)=(記憶體單元地址+2)
(IP)=(記憶體單元地址)
記憶體單元地址可用定址方式的任一格式給出。比如,下面的指令:
mov ax,0123H
mov ds:[0],ax
mov word ptr ds:[2],0
jmp dword ptr ds:[0]
執行後,(CS)=0,(IP)=0123H,CS:IP指向0000:0123
下面的指令也可以達到相同的效果:
mov ax,0123H
mov [bx],ax
mov word ptr [bx+2],0
jmp dword ptr [bx]
jcxz指令
jcxz指令為有條件轉移指令,所有的有條件轉移指令都是短轉移,在對應的機器碼中包含轉移的位移,而不是目的地址。對IP的修改範圍都為:-128~127。
指令格式:jcxz 標號
(如果cx==0,轉移到標號處執行。操作:當(cx)=0時,(IP)=(IP)+8位位移;
8位位移=標號處的地址-jcxz指令後的第一個位元組的地址;
8位位移的範圍為-128~127,用補碼錶示;
8位位移由編譯程式在編譯時算出。
當cx!=0時,什麼也不做(程式向下執行)。
loop指令
雖然在前面經常用,但還是說一下,loop指令也是短轉移指令,機器碼中的數字是位移,而不是目的地址。
編譯器對轉移位移超界的檢測
編譯器會對超界的位移報錯,程式無法通過編譯,例如下面這段程式在編譯時會報錯error A2053 jump out of range by 129 byte(s)
。因為jmp short
最多向後127個,但是卻有256的差距,所以越界了129byte。
assume cs:code
code segment
start: mov ax,0
jmp short ptr s
db 256 dup(0)
s:inc ax
mov ax,4c00h
int 21h
code ends
end
CALL和RET指令
call和ret指令都是轉移指令,這它們都修改IP或同時修改CS和IP。他們共同用來實現子程式的設計。
ret和retf
ret
用棧中的資料,修改IP,實現近轉移
retf
用棧中的資料,修改IP和CS,實現遠轉移
CPU執行ret指令時,進行下面兩步操作:
IP = ss*16+sp
sp=sp+2
CPU執行retf指令時,進行下面4步操作:
IP=((ss)*l6+(sp))
sp=(sp)+2
CS=((ss)*l6+(sp))
sp=(sp)+2
用不正確彙編指令表示出來就是這個
------RET-------
POP IP
------RETF-------
POP IP
POP CS
CALL指令
CPU執行CALL指令時,會把當前的IP或CS和IP壓入棧中,隨後轉移。
CALL指令除了不能短轉移(short 位移)之外和jmp指令很相似,不同的用法在下面分別講述
依據位移進行轉移的call指令
call 標號
(將當前的IP壓棧後,轉到標號處執行指令)
CPU執行此種格式的call指令時,進行如下的操作:
sp=sp-2
ss*16+sp=IP
IP=IP+16位位移
16位位移=標號處的地址-call指令後的第一個位元組的地址;
16位位移的範圍為-32768~32767,用補碼錶示;
16位位移由編譯程式在編譯時算出。
從上面的描述中,可以看出,如果我們用匯編語法來解釋此種格式的call指令,則:
CPU執行"call標號”時,相當於進行:
push IP
jmp near ptr 標號
轉移的目的地址在指令中的call指令
上一個call指令的用法,對應的機器碼只有當前IP的偏移值,沒有指定的目的地址,CALL far ptr 標號
實現的是段間轉移。
CPU執行此種格式的call指令時,進行如下的操作。
sp=sp-2
ss16+sp=CS
sp=sp-2
ss16+sp=IP
CS=標號所在段的段地址
IP=標號在段中的偏移地址
從上面的描述中可以看出,如果我們用匯編語法來解釋此種格式的call指令,則:
CPU執行"callfarptr標號”時,相當於進行:
push CS
push IP
jmp far ptr 標號
轉移的目的地址在暫存器和記憶體中的call指令
大致用法與jmp指令並無不同。
CALL指令和RET指令配合使用
在開始的時候我們說到call和ret是用來實現子程式的,那麼該如何使用呢?
call是用來儲存當前即將執行到指令的偏移地址並且轉到標號處執行別的程式,ret可以用來恢復IP。通常我們用以下結構實現程式執行到一半去執行別的程式再回來接著執行剩餘的指令
.
.
.
many instructions
.
.
.
call sub
.
.
.
many instructions
.
.
.
sub:
.
.
.
many instructions
.
.
.
ret ;返回原程式繼續執行
引數和結果傳遞的問題
子程式一般都要根據提供的引數處理一定的事務,處理後,將結果(返回值)提供給呼叫者。其實,我們討論引數和返回值傳遞的問題,實際上就是在探討,應該如何儲存子程式需要的引數和產生的返回值。
比如,設計一個子程式,可以根據提供的N,來計算N的3次方。這裡面就有兩個問題:
(1)將引數N儲存在什麼地方?
(2)計算得到的數值,儲存在什麼地方?
很顯然,可以用暫存器來儲存,可以將引數放到bx中;因為子程式中要計算
N X N X N
,可以使用多個mul指令,為了方便,可將結果放到dx和ax中。子程式如下。
;說明:計算N的3次方
;引數:(bx)=N
;結果:(dx:ax)=N3
cube: mov ax,bx
mul bx
mul bx
ret
mul是乘法指令,用法與div指令相似,提供8位乘法和16位乘法,8位乘法是,一個乘數預設在AL,另一個可以在8位暫存器或者記憶體中,結果高位在AH,低位在AL,16位乘法是,AX中儲存一個乘數,另一個可以在16位暫存器或者記憶體中,結果高位在DX,低位在AX。
批量資料的傳遞
在上面的例子中,只有一個引數,如果有很多的引數,暫存器跟不夠用,該怎麼辦呢?
通常把資料儲存在記憶體中,然後將它們所在記憶體空間的首地址放在暫存器裡,再把暫存器傳給子程式,返回是也是如此。
暫存器的衝突
在之前的實現雙重迴圈的時候,會出現cx暫存器的衝突,在構建子程式的時候,同樣也會遇到這樣的問題,會出現暫存器的衝突,解決方法是在子程式的開始,將使用到的暫存器儲存到堆疊,在程式返回的時候再復原。
標誌暫存器
CPU內部的暫存器中,有一種特殊的暫存器(對於不同的處理機,個數和結構都可能不同)具有以下3種作用。
- 用來儲存相關指令的某些執行結果;
- 用來為CPU執行相關指令提供行為依據;
- 用來控制CPU的相關工作方式。
這種特殊的暫存器在8086CPU中,被稱為標誌暫存器。8086CPU的標誌暫存器有16位,其中儲存的資訊通常被稱為程式狀態字(PSW)。我們已經使用過8086CPU的ax、bx、ex、dx、si、di、bp、sp、IP、cs、ss、ds、es等13個暫存器了,本章中的標誌暫存器(以下簡稱為flag)是我們要學習的最後一個暫存器。
flag和其他暫存器不一樣,其他暫存器是用來存放資料的,都是整個暫存器具有一個含義。而flag暫存器是按位起作用的,也就是說,它的每一位都有專門的含義,記錄特定的資訊。
下面是8086flag的結構
|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|
在這一章中,我們學習標誌暫存器中的CF、PF、ZF、SF、OF、DF標誌位,以及一些與其相關的典型指令。
ZF
flag的第6位是ZF,零標誌位。它記錄相關指令執行後,其結果是否為0。如果結果為0,那麼zf=l;如果結果不為0,那麼zf=0。
如下面的程式,執行之後結果為0,zf為1:
mov ax,2
sub ax,2
注意,在8086CPU的指令集中,有的指令的執行是影響標誌暫存器的,比如,add、sub、mul、div、inc、or、and等,它們大都是運算指令(進行邏輯或算術運算);有的指令的執行對標誌暫存器沒有影響,比如,mov、push、pop等,它們大都是傳送指令。在使用一條指令的時候,要注意這條指令的全部功能,其中包括,執行結果對標誌暫存器的哪些標誌位造成影響。
PF
flag的第2位是PF,奇偶標誌位。它記錄相關指令執行後,其結果的所有bit位中1
的個數是否為偶數。如果1的個數為偶數,pf=l,如果為奇數,那麼pf=O。比如,指令:
mov al,1
add al,10
執行後,結果為00001011B,其中有3(奇數)個1,則pf=0;
mov al,1
or al,2
執行後,結果為00000011B,其中有2(偶數)個1,則pf=1;
SF
flag的第7位是SF,符號標誌位。它記錄相關指令執行後,其結果是否為負。如果結果為負,sf=1;如果非負,sf=0。
計算機中通常用補碼來表示有符號資料。計算機中的一個資料可以看作是有符號數,也可以看成是無符號數。比如:
00000001B,可以看作為無符號數1,或有符號數+1;
10000001B,可以看作為無符號數129,也可以看作有符號數-127。
這也就是說,對於同一個二進位制資料,計算機可以將它當作無符號資料來運算,也可以當作有符號資料來運算。比如:
mov al,10000001B
add al,1
結果:(al)=10000010B。
可以將add指令進行的運算當作無符號數的運算,那麼add指令相當於計算129+1,結果為130(10000010B);也可以將add指令進行的運算當作有符號數的運算,那麼
add指令相當於計算-127+1,結果為-126(10000010B)。
不管我們如何看待,CPU在執行add等指令的時候,就已經包含了兩種含義,也將得到用同一種資訊來記錄的兩種結果。關鍵在於我們的程式需要哪一種結果。
SF標誌,就是CPU對有符號數運算結果的一種記錄,它記錄資料的正負。在我們將資料當作有符號數來運算的時候,可以通過它來得知結果的正負。如果我們將資料當作無符號數來運算,SF的值則沒有意義,雖然相關的指令影響了它的值。
這也就是說,CPU在執行add等指令時,是必然要影響到SF標誌位的值的。至於我們需不需要這種影響,那就看我們如何看待指令所進行的運算了。
sf為什麼值,代表了假如我們進行了有符號數的計算,結果是否為負數。
CF
flag的第0位是CF,進位標誌位。一般情況下,在進行無符號數運算的時候,它記錄了運算結果的最高有效位向更高位的進位值,或從更高位的借位值。
對於位數為N的無符號數來說,其對應的二進位制資訊的最高位,即第N-1位,就是它的最高有效位,而假想存在的第N位,就是相對於最高有效位的更高位。
我們知道,當兩個資料相加的時候,有可能產生從最高有效位向更高位的進位。比如,兩個8位資料:98H+98H,將產生進位。由於這個進位值在8位數中無法儲存,我們在前面的課程中,就只是簡單地說這個進位值丟失了。其實CPU在運算的時候,並不丟棄這個進位值,而是記錄在一個特殊的暫存器的某一位上。8086CPU就用flag的CF位來記錄這個進位值。比如,下面的指令:
mov al,98h
add al,al
執行後結果為30H,CF為1。
而當兩個資料做減法的時候,有可能向更高位借位。比如,兩個8位資料:97H-98H,將產生借位,借位後,相當於計算197H-98H。而falg的CF位也可以用來記錄這個借位值。
mov al,98h
sub al,98h
執行後結果為FFH,CF為1。
OF
我們先來談談溢位的間題。在進行有符號數運算的時候,如結果超過了機器所能表示的範圍稱為溢位。
那麼,什麼是機器所能表示的範圍呢?
比如說,指令運算的結果用8位暫存器或記憶體單元來存放,比如,addal,3,那麼對於8位的有符號資料,機器所能表示的範圍就是—128127。同理,對於16位有符號資料,機器所能表示的範圍是-32768~32767,如果運算結果超出了機器所能表達的範圍,將產生溢位。
注意,這裡所講的溢位,只是對有符號數運算而言。下面我們看兩個溢位的例子。
mov al,98
add al,99
執行後將產生溢位。因為add al,99
進行的有符號數運算是:
(al)=(al)+99=98+99=197。
而結果197超出了機器所能表示的8位有符號數的範圍:-128~127。
add指令運算的結果是(al)=0C5H
,因為進行的是有符號數運算,所以
有符號數,而0C5H
是有符號數-59
的補碼。指令進行的是有符號數運算,則98+99=-59這樣的結果讓人無法接受,造成這種情況的原因,就是實際的結果197,在8位暫存器al中存放不下。
由於在進行有符號數運算時,可能發生溢位而造成結果的錯誤。則CPU需要對指令執行後是否產生溢位進行記錄。
flag的第11位是OF,溢位標誌位。一般情況下,OF記錄了有符號數運算的結果是否發生了溢位。如果發生溢位,OF=1,如果沒有,OF=0。
一定要注意CF和OF的區別:CF是對無符號數運算有意義的標誌位,而OF是對有符號數運算有意義的標誌位。比如:
mov al,98
add al,99
add指令執行後:CF=0,0F=1。前面我們講過,CPU在執行add等指令的時候,就包含了兩種含義:無符號數運算和有符號數運算。對於無符號數運算,CPU用CF位來記錄是否產生了進位;對於有符號數運算,CPU用0F位來記錄是否產生了溢位,當然,還要用SF位來記錄結果的符號。對於無符號數運算,98+99沒有進位,CF=0;對於有符號數運算,98+99發生溢位,0F=1。
mov al,0F0H
add al,88H
add指令執行後:CF=1,0F=1。對千無符號數運算,0F0H+88H有進位,CF=1;對於有符號數運算,0F0H+88H發生溢位,0F=1。
mov al,0F0H
add al,78H
add指令執行後:CF=1,0F=0。對於無符號運算,0F0H+78H有進位,CF=1;對於有符號數運算,0F0H+78H不發生溢位,0F=0。
我們可以看出,CF和0F所表示的進位和溢位,是分別對無符號數和有符號數運算而言的,它們之間沒有任何關係。
adc指令
adc指令是帶進位加法的指令,利用了CF中儲存的進位資訊。
例如:
mov ax,2
mov bx,1
sub bx,ax
adc ax,1
結果ax=4,因為ax=ax+bx+cf。
可以看出,adc指令比add指令多加了一個CF位的值。為什麼要加上CF的值呢?
在進行大的數字計算的時候,可能無法直接相加,如32位數的相加,可以拆成2個16位相加,但是低位的相加,進位需要儲存下來,在高位計算的時候用adc指令補進去。
sbb指令
sbb是帶借位減法指令,它利用了CF位上記錄的借位值。
指令格式:sbb操作物件1,操作物件2
功能:操作物件l=操作物件l—操作物件2—CF
比如指令sbb ax,bx
實現的功能是:(ax)=(ax)-(bx)-CF
sbb和adc是基於同樣的思想設計的兩條指令,在應用思路上和adc類似。在這裡,我們就不再進行過多的討論。通過學習這兩條指令,我們可以進一步領會一下標誌暫存器CF位的作用和意義。
cmp指令
cmp是比較指令,cmp的功能相當於減法指令,只是不儲存結果。cmp指令執行後,將對標誌暫存器產生影響。其他相關指令通過識別這些被影響的標誌暫存器位來得知比較結果。
cmp指令格式:cmp 操作物件1,操作物件2
功能:計算操作物件1-操作物件2,但並不儲存結果,僅僅根據計算結果對標誌暫存器進行設定。
比如,指令cmp ax,ax,做(ax)-(ax)的運算,結果為0,但並不在ax中儲存,僅影響
flag的相關各位。指令執行後:zf=1,pf=1,sf=0,cf=0,of=0。
對於無符號數比較,我們通過cmp指令執行後,相關標誌位的值就可以看出比較的結果。
if ax =bx, then ZF=1
if ax!=bx, then ZF=0
if ax >bx, then ZF=0, CF=0
if ax <bx, then CF=1
if ax<=bx, then CF=1 or ZF=1
if ax>=bx, then CF=0
但是對有符號數這麼比較是有漏洞的,對於有符號數,判斷是否相等可以直接用ZF標誌位。對於有符號數計算,通過判斷SF標誌符號,可以知道結果表示為有符號數的正負,但是這裡同樣有個漏洞,例如:
mov al,22h
mov bl,0a0h
cmp al,bl
34-(-96)=130得到的結果是82H,-126的補碼是82H(10000010B),SF標誌位的值為1,這樣的情況不能直接判斷是大於還是小於,思考一下,為什麼出現上述的情況會無法只用SF判斷,原因是因為溢位了(130>127),因為溢位導致了無法正確判斷,在沒有發生溢位時(OF=0),可以直接判斷,所以:
if OF=1 and SF=1 ax>bx
if OF=0 and SF=1 ax<bx
if OF=1 and SF=0 ax<bx
if OF=0 and SF=0 ax>bx
檢測比較結果的條件轉移指令
在前面的時候,我們有說到關於條件的跳轉指令都是有關CX暫存器的,現在來說一下關於標誌暫存器的,根據cmp指令修改標誌位後,檢查指定標誌位確定是否進行跳轉,兩者配合使用,類似於call和ret指令。
因為cmp分為無符號數字比較和有符號數字比較,所以,跳轉指令也分為對無符號數比較的跳轉指令和有符號數字比較的跳轉指令
;無符號跳轉
je ;含義:相等跳轉
jne ;含義:不相等跳轉
jb ;含義:小於跳轉
jnb ;含義:不小於跳轉
ja ;含義:大於跳轉
jna ;含義:不大於跳轉
DF標誌和串傳送指令
falg的第10位是DF,方向標誌位。在串處理指令中,控制每次操作後si、中的增減。
df=0每次操作後si、di遞增;
df=1每次操作後si、di遞減。
我們來看下面的一個串傳送指令。
格式:movsb
功能:執行movsb指令相當於進行下面幾步操作。
(1)((es)X16+(di))=((ds)Xl6+(si))
(2)如果df=0則:(si)=(si)+1,(di)=(di)+1
(3)如果df=1則:(si)=(si)-1,(di)=(di)-1
該指令實現了記憶體中資料段中的資料複製到另一處位置,可以從一個指定位置開始,如果df為0則正向進行,否則反向進行。
當然也支援字傳輸:
格式:movsw
功能:執行movsw指令相當於進行下面幾步操作。
(1)((es)X16+(di))=((ds)Xl6+(si))
(2)如果df=0則:(si)=(si)+2,(di)=(di)+2
(3)如果df=1則:(si)=(si)-2,(di)=(di)-2
movsb和movsw進行的是串傳送操作中的一個步驟,一般來說,movsb和movsw都和rep配合使用,格式如下:
rep movsb
用匯編語法來描述rep movsb
的功能就是:
s: movsb
loop s
可見,rep的作用是根據cx的值,重複執行後面的串傳送指令。由於每執行一次
movsb指令si和小都會遞增或遞減指向後一個單元或前一個單,rep movsb
就可以迴圈實現(cx)個字元的傳送。對於movsw也是同理。
對標誌暫存器的儲存和恢復
對於一半的暫存器可以直接 push 暫存器名
實現把暫存器的值儲存在堆疊中,對標誌暫存器,使用pushf
指令可以把標誌暫存器壓入堆疊,popf
從堆疊中彈出一個字給標誌暫存器