在匯編語言中,程序都是由指令流構成的,而指令一般是由操作符和操作數組成的,操作符是CPU用來完成某項功能的操作,而操作數是操作符所處理加工的對象。比如:add eax, 42,add是執行一個加法運算的操作符,是把eax中的值和常數42相加,并保存到eax中,而指令中的操作數,如常數42和存放在eax寄存器中的數值。
在高級語言中,數據本身帶有類型信息,比如C語言中,6是一個整型(int)的值,'6’是字符型(char)的值,而"6"就是字符串類型的值,再比如變量:int a,那么在內存地址“&a”中存放的數據是有符號int整型數據。而在匯編語言中,如果脫離開指令上下文環境單獨看待它們,它們僅僅是一個二進制的數據,并沒有具體的意義,也就是它們沒有類型信息,一個數據只有出現在指令中,作為操作數進行操作時才知道它所表示的意義,同一個數據用在不同形式的指令中,可能表示不同的類型。
注,本文以x86-64處理器的匯編語言說明,匯編指令的格式是Intel風格,下面內容均為個人觀點。
1、如何表述數據?
既然在匯編語言中,數據自身沒有類型信息,那么,如何來表述它們呢?描述數據有三個要素:位置、長度和內容。
位置,就是表示數據存放在哪兒,以及怎么才能找到它?數據只能存放在三個地方:指令中、寄存器中和內存中。
長度,就是數據它占有多大的存放空間。比如數據可以占用1字節、2字節、4字節、8字節、16字節等2的次冪大小的空間,如果是存放在內存中,也可以是3字節、5字節、6字節、乃至n字節大小的空間,比如表示串操作的數組、字符串等數據。
內容,就是指它所表示的是什么。同一個數據可能表示一個字符、整型數、浮點數、地址等,取決于數據用作什么指令的操作數,也就是數據表示什么是根據所處的上下文環境決定的,所表示的是什么和操作它的指令息息相關。
顯然,長度和內容對應了高級語言中的類型,比如在C/C++中聲明short data,則表示data長度占用2個字節,表示的是范圍在-37768~37767之間的整型數,data的位置取決于定義的場合:可以在棧中、堆中,數據區、常量區等。在匯編語言中,如果數據存放在內存中的話,比如[rax],寄存器rax中存放64位的地址,rax可以對應高級語言的void*指針,只是一個位置地址,如果沒有相關的前綴信息比如dword ptr,是不知道所指向的數據的長度,也不知道表示的是什么。
下面分別看一下它們在匯編語言中是如何表示的。
2、數據位置-放在什么地方?
在匯編語言中,數據就存在于三個地方:
1、位于指令中
是一個常量,也叫立即數,一般會被編碼到指令中,成為指令的一部分。 它只能用作指令的源操作數,比如指令mov eax, 42,源數據42位于指令中。
下面是這條指令編譯完之后的二進制指令流,就是這條mov指令匯編完之后的二進制數據b82a000000,在執行時,CPU要把這個二進制指令流放在執行引擎中執行,在譯碼時就能從中得到數據42。
我們知道,十進制數字42對應的16進制是0x2a,而b8表示把4字節的二進制數據存入寄存器eax,指令的其余部分2a000000就是數字42,是個4字節長度的數據。
立即數一般來源自高級程序中的const int、constexpr int、整數字面量等,但高級程序中的常量也不一定都會當作立即數位于指令中,有時候也會位于只讀數據區,存放于內存中。
2、位于寄存器中
數據存放在通用寄存器中,它可以用作源操作數和目的操作數。比如:
add eax, edx
加法操作add有兩個操作數,數據分別存放在寄存器eax和edx中,CPU在執行時,執行引擎會從這兩個寄存器中得到數據。
3、位于內存中
指定的是數據在內存中的位置,可以用作源操作數或目的操作數,但不能同時用作源和目的操作數。所在內存位置最常見的表示形式是:[64位寄存器±偏移量],比如[rax+4],寄存器rax的值表示一個內存地址,假設是0x8000000000,把它加上4之后,也就是在0x80000000004內存位置。
3、數據長度-占多大的地方?
數據的長度信息表示,和它存放在哪兒有關,在不同的地方有不同的長度表示方式。
1、立即數
長度取決于字面形式源操作數和目的操作數的長度,一般都是1、2、4、8字節的長度。例如,對于0x42這個立即數,如果目的數是單字節長度,它在指令中是單字節長度,如果目的數是2字節長度,它在指令中的長度是2字節,如果目的數是4、8字節長度,它在指令中的長度是4字節;對于0x4200這個立即數,如果目的數是2字節長度,它在指令中的長度是2字節,如果目的數是4、8字節長度,它在指令中的長度是4字節;對于0x4200000000這個立即數,在指令中只能是8字節長度。
2、寄存器
長度取決于寄存器的大小,就以累加寄存器ax、eax、rax系列以及SIMD寄存器為例:
寄存器 1 | 字節 | bit | 長度可能對應的C/C++類型 |
---|---|---|---|
al | 1 | 8 | char、unit8_t、int8_t |
ax | 2 | 16 | short、uint_16、int_16 |
eax | 4 | 32 | int、uint32_t、int32_t、float |
rax | 8 | 64 | long long、uint64_t, int64_t、double |
rdx:rax組合 | 16 | 128 | struct {int64_t, int64_t}、int64_t[2]、double[2] |
ymm | 16 | 128 | int8_t[16]、int16_t[8]、int32_t[4]、int64_t[2]、float[4]、double[2] |
ymm | 32 | 256 | int8_t[32]、int16_t[16]、int32_t[8]、int64_t[4]、float[8]、double[4] |
zmm | 64 | 512 | int8_t[64]、int16_t[32]、int32_t[16]、int64_t[8]、float[16]、double[8] |
即,如果一個數據存放在寄存器eax中,它的長度就是4字節32bit,顯然可能是來自高級語言的int、uint32_t、int32_t、float、struct{short, short}, char[4]等類型的數據。
3、內存數據
內存可以存放更多的數據,而且存放數據的地方也沒有額外的信息來表示它的長度,在指令中需要專門的標識符來表示長度,在不同風格形式的匯編語言中有不同的表示形式。
在Intel風格的匯編語言中,數據的長度信息需要專門指定前綴,如:單字節長度的內存數據使用byte ptr前綴來指示,4字節長度的內存數據使用dword ptr前綴來表示,例如 mov eax,dword ptr [rax],表示把寄存器rax中的值作為地址,從內存中讀取4字節的數據放到寄存器eax中,即數據是[rax]指向的內存位置處連續4字節的長度。
而ATT風格的匯編語言中,長度信息是由操作符使用后綴來指示的,比如:movl (%rax), %eax,使用movl操作符來表示寄存器rax指向的內存位置的4字節數據,把它存入寄存器eax。其它的比如movb、movw、movq等用于不同長度的數據,b、w、l、q后綴分別表示1字節、2字節、4字節和8字節的內存數據的長度。
下面列舉各種數據的長度的前綴,以及對應C/C++的類型:
前綴 | 字節 | bit | 長度可能對應的C/C++類型 |
---|---|---|---|
byte ptr | 1 | 8 | char、unit8_t、int8_t |
word ptr | 2 | 16 | short、uint_16、int_16 |
dword ptr | 4 | 32 | int、uint32_t、int32_t、float |
qword ptr | 8 | 64 | long long、uint64_t, int64_t、double |
xmm ptr | 16 | 128 | int8_t[16]、int16_t[8]、int32_t[4]、int64_t[2]、float[4]、double[2] |
ymm ptr | 32 | 256 | int8_t[32]、int16_t[16]、int32_t[8]、int64_t[4]、float[8]、double[4] |
zmm ptr | 64 | 512 | int8_t[64]、int16_t[32]、int32_t[16]、int64_t[8]、float[16]、double[8] |
此外,匯編中還有串操作(對應于C/C++語言中的數組類型),這些數據全部存放在內存中,串操作數據的長度,需要由專門的寄存器存放,存放在寄存器cx/ecx中。
4、數據內容- 存放的是什么?
數據所表示的類型,取決于用做什么指令的操作數。指令運算時的它眼中的操作數可以是整型、浮點型、指針、位域、BCD整型、串型(string)和組合類型。
比如,以下面的指令形式為例:
#操作符# eax, dword ptr [rbp-24];
源操作數: dword ptr [rbp-24];
位置:rbp-24指向的內存位置
長度:dword,雙字,即4字節長度
類型:取決于操作符的類型,如果是傳輸型,如mov,不確定,如果是算術運算符,則是整型,如果是邏輯運算型,則是無符號整型
目的操作數:eax
位置:寄存器eax
長度:eax位長是32位,即4字節長度
類型:取決于操作符的類型
下面舉一個例子:
假設源數據是qword ptr [rsp],此形式能確定數據的兩個要素,位置:rsp指向的內存地址,長度:8字節。下面是它可能出現的場景及類型,假設long、double是64位長度:
MOV rax, qword ptr [rsp]
什么類型都可能,取決于后續以rax為操作數的操作
ADD rax, qword ptr [rsp]
因為ADD操作符用于整型加法運算,因此類型可能是long或unsigned long
MUL rax, qword ptr [rsp]
因為MUL操作符用于無符號整型乘法運算,因此類型明確是unsigned long
IMUL rax, qword ptr [rsp]
因為MUL操作符用于無符號整型乘法運算,因此類型明確是signed long型
MOVSS xmm3, qword ptr [rsp] ;是float類型
MOVSD xmm3, qword ptr [rsp] ;是double類型
MOV rax, qword ptr [rsp]
什么類型都可能,取決于后續以rax為操作數的操作
MOVD xmm0, qword ptr[rax]
后續操作MOVD:qword ptr[rax],rax用作地址,它是指向一個8字節的數據的指針,仍不知指向什么類型。
MOVSD xmm3, xmm0
此時確定是double類型,即rsp是一個二維指針,即對應C/C++的double **ptr類型;
paddw mm1, qword ptr [rsp]; 指向組合整數,等同于C的short[4]數組
paddb mm1, qword ptr [rsp]; 指向組合整數,等同于C的char[8]數組
jmp qword ptr [rsp] ; 是地址,可能是指針,包括函數指針類型
jcc qword ptr [rsp] ; 是地址,可能是指針,包括函數指針類型
call qword ptr [rsp] ; 是函數指針
可見,同一個數據在不同形式的指令中,可能表示不同的類型,比如,假設rsp寄存器指向的內存處,長度8字節的內容是0x8123456781234567。比如,在下面的指令中,它會表示不同的類型:
MUL rax, qword ptr [rsp] ; 是uint64_t類型,值是9305357565928097127
IMUL rax, qword ptr [rsp] ; 是int64_t類型,值是-9141386507781454489
MOV rax, qword ptr [rsp] 和 MOV mm0, qword ptr[rax]; 是指針,指向0x8123456781234567內存位置
MOV mm0, qword ptr[rax] ; 是char數組,值是{0x81, 0x23, 0x45, 0x67, 0x81, 0x23, 0x45, 0x67}
MOVSD xmm0, qword ptr [rsp] ; 是double類型,值是-3.5127004713770933e-303,幾乎是0.0。
5、如何訪問數據
知道數據在哪兒,也知道長度,如何取獲取數據呢?尤其是在內存中的數據。是由不同的尋址方式來獲取數據,在x86-64處理器中共有下面幾種方式:
1、立即數尋址
數據是立即數常數,存在于指令中。比如指令:mov eax, 42,它的指令二進制編碼是:b82a000000,其中4字節長度的2a000000,就是立即數42。
C/C++的代碼中,這些數據一般對應字面量或者const、constexpr修飾的常量數據。
2、寄存器尋址
數據在寄存器中,這種尋址方式性能很高,因為寄存器都在CPU執行單元內部,訪問它們很快。
在C/C++代碼中,函數傳遞參數、返回結果、循環變量、使用register修飾的變量等都是存放在寄存器中,此外,表達式運算產生的中間結果一般也放在寄存器中。
3、內存直接尋址
數據在內存中,直接使用地址的絕對值訪問它們,如mov eax, dword ptr [0x7fff324ce00c],從內存位置0x7fff324ce00c處讀取4字節的數據。
在C/C++代碼中,全局變量、靜態變量、函數入口地址可能會用這種尋址方式,但是因為指令編碼較長,可能會用下面的內存間接尋址方式,尤其是x86-64處理器的rip相對尋址方式。
4、內存間接尋址
這一類尋址方式最為豐富。
4.1、寄存器間接
形式:[寄存器],如[rax]
在C/C++代碼中,指針使用*操作符解引用、通過this指針訪問虛函數表地址時,都是這種形式的尋址。
4.2、基址+偏移量
形式:[寄存器±立即數偏移量],如[rbp-16]
也可以把寄存器間接尋址看作是這種尋址方式當偏移量為0時的特例,如[rax]即為[rax+0]
在C/C++中,訪問局部變量、數組元素或者結構成員時常見這種尋址方式,常用于基址的寄存器多是rax、rbx、rbp、rsp,使用它們指令編碼較短,在C++中,如果出現rdi作為基址寄存器,很可能是通過this指針在訪問對象的數據成員。其中寄存器rbp用作訪問函數棧幀的基址、rsp用作棧頂基址,差不多是所有編譯器的標準用法了。
4.3、基址+變址
形式:[基址寄存器±變址寄存器] ,如[rbx+rsi]
用作基址寄存器常見有rbx、rbp,變址寄存器常見有rsi、rdi,當然所有寄存器都可以用作它們。
在C/C++中,使用下標變量來訪問char、uint8_t等單字節數據組成的數組時,就是這種尋址方式,其中數組起始位置是基址寄存器,而下標是變址寄存器。
4.4、基址+變址因子
形式:[基址寄存器±變址寄存器立即數因子] ,立即數因子只能是2的次冪,如[rbx+rsi*4]
在C/C++中,使用下標變量來訪問short、int、float等多字節數據組成的數組時,就是這種尋址方式,其中數組起始位置是基址寄存器,而下標是變址寄存器,立即數因子就是數組元素的長度,比如對于數組:int ar[],立即數因子就是sizeof(int)=4。
4.5、基址+變址因子+偏移量
形式:[基址寄存器±變址寄存器立即數因子±立即數偏移量] ,立即數因子只能是2的次冪,如[rbx+rsi*4]。
在C/C++中,使用下標變量來訪問結構數組中某個結構中的數據成員,就是這種尋址方式,其中數組起始位置是基址寄存器,而下標是變址寄存器,因子就是結構的長度,偏移量是數據在結構中的偏移量。
4.6、基址+變址+偏移量
形式:[基址寄存器±變址寄存器±立即數偏移量] ,如[rbx+rsi+8]
在C/C++中,使用下標變量來訪問結構數組中某個結構的數據成員,如果沒有使用4.5的尋址方式(因為變址因子只能使用1、2、4、8等有限的幾個值),就使用兩條指令來尋址,先計算出變址*因子的值放在變址寄存器中,其中數組起始位置是基址寄存器,而下標位置就是變址寄存器,偏移量是數據在結構中的偏移量。
比如:
// size是16字節
struct pointer {int x; // 偏移量0int64_t z; // 偏移量8
};long foo(pointer a[], int x) {return a[x].z;
}
可以這樣尋址:
foo(pointer*, int):movsx rsi, esisal rsi, 4 ; 單獨計算變址的值mov rax, QWORD PTR [rsi+8+rdi]ret
也可以:
foo(pointer*, int):movsx rsi, esiadd rsi, rsi ; 翻倍,這樣rsi*8就是rsi*16mov eax, DWORD PTR [rdi+rsi*8+8]ret 0
4.7、rip間接尋址
形式:[rip±立即數偏移量],比如[rip - 0x33538]
現代操作系統中,進程的內存布局模型都是flat平坦模式,即所有的代碼和數據都不再分段管理,而是在同一個地址空間內統一編址,這樣就使用指令指針寄存器rip來訪問數據,在指令訪問內存數據時,所執行的位置處(即RIP的值)加上和目標地址的距離——偏移量,就能訪問到目標地址處的內存數據。在C、C++訪問全局變量、static變量、動態加載的函數和全局變量時,都可以使用這種方式來尋址。
最后
雖然在前面我把數據分成了三部分進行說明,實際上它們三者是同時密切聯系在一起的。在一條指令中,同時表示了這三部分的信息,比如下面的指令:
add edx, DWORD PTR [rdi+4+rsi*8]
1、源數據位于內存中,是一個“基址+變址*因子+偏移量”的尋址方式,位置是經過基址寄存器rdi和變址寄存器rsi運算后的內存地址,數據長度是4字節(dword ptr指示),而地址數據存放在寄存器rdi中,它的長度是8字節,相當于rdi對應C/C++中的指針類型;
2、目的數據位于寄存器edx中,它的長度是4字節
3、指令操作符是add,是算數加法操作,因此源數據和目的數據都是整型數,但是有符號還是無符號數仍不知道。
差不多是類似于下面形式的C/C++代碼結構:
struct strct {int x;int z;
};.....
strct ar[M];
int index;
int sum;
...
sum += ar[index].z;
其中ar是一個結構數組,rdi=&ar,rsi=index,edx=sum。