天天看點

X86 尋址方式、AT&T 彙編語言相關知識、AT&T 與 Intel 彙編語言的比較、gcc 嵌入式彙編

注:本分類下文章大多整理自《深入分析linux核心源代碼》一書,另有參考其他一些資料如《linux核心完全剖析》、《linux c 程式設計一站式學習》等,隻是為了更好地理清系統程式設計和網絡程式設計中的一些概念性問題,并沒有深入地閱讀分析源碼,我也是草草翻過這本書,請有興趣的朋友自己參考相關資料。此書出版較早,分析的版本為2.4.16,故出現的一些概念可能跟最新版本核心不同。

此書已經開源,閱讀位址 http://www.kerneltravel.net

注解:不同平台有不同的instruction set 即指令集,比如x86, PowerPC, ARM等平台的指令集是不同的。而彙編一直存在兩種不同的文法,在intel的官方文檔中使用intel文法,Windows也使用intel文法,而UNIX 系統的彙編器一直使用AT&T文法,下文會比較兩種文法的差別。

一、X86 尋址方式

x86的通用寄存器有8個。這些寄存器在大多數指令中是可以任意選用的,比如movl 指令可以把一個立即數傳送到eax 中,也可傳送到ebx 中。但也有一些指令規定隻能用其中某個寄存器做某種用途,例如除法指令idivl 要求被除數在eax 寄存器中,edx 寄存器必須是0,而除數可以在任意寄存器中,計算結果的商數儲存在eax 寄存器中(覆寫原來的被除數),餘數儲存在edx 寄存器中。也就是說,通用寄存器對于某些特殊指令來說也不是通用的。

介紹x86常用的幾種尋址方式(Addressing Mode)。記憶體尋址在指令中可以表示成如下的通用格式:

ADDRESS_OR_OFFSET(%BASE_OR_OFFSET,%INDEX,MULTIPLIER)

它所表示的位址可以這樣計算出來:

FINAL ADDRESS = ADDRESS_OR_OFFSET + BASE_OR_OFFSET + MULTIPLIER * INDEX

注:實際上 final address 也隻是邏輯位址中的32位偏移量部分,需要使用段選擇符找到段描述符,進而得到段基位址,兩者相加才是線性位址,但在Linux實作中段基位址都為0,故偏移量可以直接當作線性位址,再經過分頁轉換就是真正的實體位址,也就是說final address 是程式中通路的位址。具體參見 《80386分段分頁機制》

其中ADDRESS_OR_OFFSET 和 MULTIPLIER 必須是常數, BASE_OR_OFFSET 和 INDEX 必須是寄存器。

在有些尋址方式中會省略這4項中的某些項,相當于這些項是0。

直接尋址(Direct Addressing Mode)。隻使用ADDRESS_OR_OFFSET尋址,例如movl ADDRESS, %eax 把ADDRESS位址處的32位數傳送到eax 寄存器。

變址尋址(Indexed Addressing Mode) 。movl data_items(,%edi,4), %eax 就屬于這種尋址方式,用于通路數組元素比較友善。

間接尋址(Indirect Addressing Mode)。隻使用BASE_OR_OFFSET尋址,例如movl (%eax), %ebx ,把eax 寄存器的值看作位址,把記憶體中這個位址處的32位數傳送到ebx 寄存器。注意和movl %eax, %ebx 區分開。

基址尋址(Base Pointer Addressing Mode)。隻使用ADDRESS_OR_OFFSET和BASE_OR_OFFSET尋址,例如movl 4(%eax), %ebx ,用于通路結構體成員比較友善,例如一個結構體的基位址儲存在eax 寄存器中,其中一個成員在結構體内的偏移量是4位元組,要把這個成員讀上來就可以用這條指令。

立即數尋址(Immediate Mode)。就是指令中有一個操作數是立即數,例如movl $12,%eax 中的$12, 這其實跟尋址沒什麼關系,但也算作一種尋址方式。

寄存器尋址(Register Addressing Mode)。就是指令中有一個操作數是寄存器, 例如movl $12, %eax 中的%eax ,這跟記憶體尋址沒什麼關系,但也算作一種尋址方式。在彙程式設計式中寄存器用助記符來表示,在機器指令中則要用幾個Bit表示寄存器的編号,這幾個Bit也可以看作寄存器的位址,但是和記憶體位址不在一個位址空間。

二、AT&T 與 Intel 彙編語言的比較

1.字首

在Intel 的文法中,寄存器和和立即數都沒有字首。但是在AT&T 中,寄存器前冠以“%”,而立即數前冠以“$”。在Intel 的文法中,十六進制和二進制立即數字尾分别冠以“h”和“b”,而在AT&T 中,十六進制立即數前冠以“0x”,如表2.2 所示給出幾個相應的例子。

2.操作數的方向

Intel 與AT&T 操作數的方向正好相反。在Intel 文法中,第一個操作數是目的操作數,第二個操作數是源操作數。而在AT&T 中,第一個數是源操作數,第二個數是目的操作數。由此可以看出,AT&T 的文法符合人們通常的閱讀習慣。

例如:在Intel 中,mov eax,[ecx]

在AT&T 中,movl (%ecx),%eax

X86 尋址方式、AT&T 彙編語言相關知識、AT&T 與 Intel 彙編語言的比較、gcc 嵌入式彙編

3.記憶體單元操作數

從上面的例子可以看出,記憶體操作數也有所不同。在Intel 的文法中,基寄存器用“[]”括起來,而在AT&T 中,用“()”括起來。

例如: 在Intel 中,mov eax,[ebx+5]

在AT&T,movl 5(%ebx),%eax

4.間接尋址方式

與Intel 的文法比較,AT&T 間接尋址方式可能更晦澀難懂一些。Intel 的指令格式是segreg:[base+index*scale+disp],而AT&T 的格式是%segreg:disp(base,index,scale)。其中index/scale/disp/segreg 全部是可選的,完全可以簡化掉。如果沒有指定scale 而指定了index,則scale 的預設值為1。segreg 段寄存器依賴于指令以及應用程式是運作在實模式還是保護模式下,在實模式下,它依賴于指令,而在保護模式下,segreg 是多餘的。在AT&T 中,當立即數用在scale/disp 中時,不應當在其前冠以“$”字首,表2.3 給出其文法及幾個相應的例子。

X86 尋址方式、AT&T 彙編語言相關知識、AT&T 與 Intel 彙編語言的比較、gcc 嵌入式彙編

從表中可以看出,AT&T 的文法比較晦澀難懂,因為[base+index*scale+disp]一眼就可以看出其含義,而disp(base,index,scale)則不可能做到這點。這種尋址方式常常用在通路資料結構數組中某個特定元素内的一個字段,其中,base 為數組的起始位址,scale 為每個數組元素的大小,index 為下标。如果數組元素還是一個結構,則disp 為具體字段在結構中的位移。

5.操作碼的字尾

在上面的例子中你可能已注意到,在AT&T 的操作碼後面有一個字尾,其含義就是指出操作碼的大小。“l”表示長整數(32 位),“w”表示字(16 位),“b”表示位元組(8 位)。而在Intel 的文法中,則要在記憶體單元操作數的前面加上byte ptr、word ptr 和dword ptr,“dword”對應“long”。表2.4 給出了幾個相應的例子。

X86 尋址方式、AT&T 彙編語言相關知識、AT&T 與 Intel 彙編語言的比較、gcc 嵌入式彙編

三、AT&T 彙編語言相關知識

在Linux 源代碼中,以.S 為擴充名的檔案是“純”彙編語言的檔案。這裡,我們結合具體的例子再介紹一些AT&T 彙編語言的相關知識。

1.GNU 彙程式設計式GAS(GNU Assembly)和連接配接程式

當你編寫了一個程式後,就需要對其進行彙編(assembly)和連接配接。在Linux 下有兩種方式,一種是使用彙程式設計式GAS 和連接配接程式ld,一種是使用gcc。我們先來看一下GAS 和ld:

GAS 把彙編語言源檔案(.o)轉換為目标檔案(.o),其基本文法如下:

as filename.s -o filename.o

一旦建立了一個目标檔案,就需要把它連接配接并執行,連接配接一個目标檔案的基本文法為:

ld filename.o -o filename

這裡 filename.o 是目标檔案名,而filename 是輸出(可執行) 檔案。

GAS 使用的是AT&T 的文法而不是Intel 的文法,這就再次說明了AT&T 文法是UNIX 世界的标準,你必須熟悉它。如果要使用GNC 的C 編譯器gcc,就可以一步完成彙編和連接配接,例如:

gcc -o example example.S

這裡,example.S 是你的彙程式設計式,輸出檔案(可執行檔案)名為example。其中,擴充名必須為大寫的S,這是因為,大寫的S 可以使gcc 自動識别彙程式設計式中的C 預處理指令,像#include、#define、#ifdef、#endif 等,也就是說,使用gcc 進行編譯,你可以在彙程式設計式中使用C 的預處理指令。

2.AT&T 中的節(Section)

在AT&T 的文法中,一個節由.section 關鍵詞來辨別,當你編寫彙編語言程式時,至少需要有以下3 種節。

section .data:這種節包含程式已初始化的資料,也就是說,包含具有初值的那些變量,例如:

hello: .string "Hello world!\n"

hello_len : .long 13

.section .bss:這個節包含程式還未初始化的資料,也就是說,包含沒有初值的那些變量。當作業系統裝入這個程式時将把這些變量都置為0,例如:

name : .fill 30 # 用來請求使用者輸入名字

name_len : .long 0 # 名字的長度(尚未定義)

當這個程式被裝入時,name 和name_len 都被置為0。如果你在.bss 節不小心給一個變量賦了初值,這個值也會丢失,并且變量的值仍為0。使用.bss 比使用.data 的優勢在于,.bss 節不占用磁盤的空間。在磁盤上,一個長整數就足以存放.bss 節。當程式被裝入到記憶體時,作業系統也隻配置設定給這個節4 個位元組的記憶體大小。

注意,編譯程式把.data 和.bss 在4 位元組上對齊(align),例如,.data 總共有34 位元組,那麼編譯程式把它對齊在36 位元組上,也就是說,實際給它36 位元組的空間。

section .text :這個節包含程式的代碼,它是隻讀節,而.data 和.bss 是讀/寫節。

注:真正在編譯程式的時候,section會被合并為segment,如.text和 .rodata 合并為Text Segment,當然有多個源程式一起編譯的話它們的Text Segment 也将最終合并在一起。

3.彙程式設計式指令(Assembler Directive)

上面介紹的.section 就是彙程式設計式指令的一種,GNU 彙程式設計式提供了很多這樣的指令(directive),這種指令都是以句點(.)為開頭,後跟指令名(小寫字母),在此,我們隻介紹在核心源代碼中出現的幾個指令(以arch/i386/kernel/head.S 中的代碼為例)。

(1).ascii "string"...

.ascii 表示零個或多個(用逗号隔開)字元串,并把每個字元串(結尾不自動加“0“位元組)中的字元放在連續的位址單元。

還有一個與.ascii 類似的.asciz,z 代表“0”,即每個字元串結尾自動加一個“0”位元組,例如:

int_msg:

.asciz "Unknown interrupt\n"

(2).byte 表達式

.byte 表示零或多個表達式(用逗号隔開),每個表達式被放在下一個位元組單元。

(3).fill 表達式

形式:.fill repeat , size , value

其中,repeat、size 和value 都是常量表達式。Fill 的含義是反複拷貝size 個位元組。repeat 可以大于等于0。size 也可以大于等于0,但不能超過8,如果超過8,也隻取8。把repeat 個位元組以8 個為一組,每組的最高4 個位元組内容為0,最低4 位元組内容置為value。size 和 value 為可選項。如果第2 個逗号和value 值不存在,則假定value 為0。如果第1 個逗号和size 不存在,則假定size 為1。

例如,在Linux 初始化的過程中,對全局描述符表GDT 進行設定的最後一句為:

.fill NR_CPUS*4,8,0 /* space for TSS's and LDT's */

因為每個描述符正好占8 個位元組,是以,.fill 給每個CPU 留有存放4 個描述符的位置。

(4).globl symbol

.globl 使得連接配接程式(ld)能夠看到symbl。如果你的局部程式中定義了symbl,那麼,與這個局部程式連接配接的其他局部程式也能存取symbl,例如:

.globl SYMBOL_NAME(idt)

.globl SYMBOL_NAME(gdt)

定義idt 和gdt 為全局符号。

(5).quad bignums

.quad 表示零個或多個bignums(用逗号分隔),對于每個bignum,其預設值是8 位元組整數。如果bignum 超過8 位元組,則列印一個警告資訊;并隻取bignum 最低8 位元組。

例如,對全局描述符表的填充就用到這個指令:

.quad 0x00cf9a000000ffff /* 0x10 kernel 4GB code at 0x00000000 */

.quad 0x00cf92000000ffff /* 0x18 kernel 4GB data at 0x00000000 */

.quad 0x00cffa000000ffff /* 0x23 user 4GB code at 0x00000000 */

.quad 0x00cff2000000ffff /* 0x2b user 4GB data at 0x00000000 */

(6).rept count

把.rept 指令與.endr 指令之間的行重複count 次,例如

.rept 3

.long 0

.endr

相當于

(7).space size , fill

這個指令保留size 個位元組的空間,每個位元組的值為fill。size 和fill 都是常量表達式。如果逗号和fill 被省略,則假定fill 為0,例如在arch/i386/bootl/setup.S 中有一句:

.space 1024

表示保留1024 位元組的空間,并且每個位元組的值為0。

(8).word expressions

這個表達式表示任意一節中的一個或多個表達式(用逗号分開),表達式的值占兩個位元組,例如:

gdt_descr:

.word GDT_ENTRIES*8-1

表示變量gdt_descr 的值為GDT_ENTRIES*8-1

(9).long expressions

這與.word 類似

(10).org new-lc , fill

把目前節的位置計數器提前到new-lc(New Location Counter)。new-lc 或者是一個常量表達式,或者是一個與目前子節處于同一節的表達式。也就是說,你不能用.org 橫跨節:如果new-lc 是個錯誤的值,則.org 被忽略。.org 隻能增加位置計數器的值,或者讓其保持不變;但絕不能用.org 來讓位置計數器倒退。

注意,位置計數器的起始值是相對于一個節的開始的,而不是子節的開始。當位置計數器被提升後,中間位置的位元組被填充值fill(這也是一個常量表達式)。如果逗号和fill 都省略,則fill 的預設值為0。

例如:.org 0x2000

ENTRY(pg0)

表示把位置計數器置為0x2000,這個位置存放的就是臨時頁表pg0。

四、gcc 嵌入式彙編

在Linux 的源代碼中,有很多C 語言的函數中嵌入一段彙編語言程式段,這就是gcc 提供的“asm”功能,例如在include/asm-i386/system.h 中定義的,讀控制寄存器CR0 的一個宏read_cr0():

1.嵌入式彙編的一般形式

__asm__ __volatile__("<asm routine>" : output : input : modify);

其中,__asm__表示彙編代碼的開始,其後可以跟__volatile__(這是可選項),其含義是避免“asm”指令被删除、移動或組合;然後就是小括弧,括弧中的内容是我們介紹的重點。

• "<asm routine>"為彙編指令部分,例如,"movl %%cr0,%0\n\t"。數字前加字首“%“,如%1,%2 等表示使用寄存器的樣闆操作數。可以使用的操作數總數取決于具體CPU 中通用寄存器的數量,如Intel 可以有8 個。指令中有幾個操作數,就說明有幾個變量需要與寄存器結合,由gcc 在編譯時根據後面輸出部分和輸入部分的限制條件進行相應的處理。由于這些樣闆操作數的字首使用了“%”,是以,在用到具體的寄存器時就在前面加兩個“%”,如%%cr0。

• 輸出部分(output),用以規定對輸出變量(目标操作數)如何與寄存器結合的限制(constraint),輸出部分可以有多個限制,互相以逗号分開。每個限制以“=”開頭,接着用一個字母來表示操作數的類型,然後是關于變量結合的限制。例如,上例中:

:"=r" (__dummy)

“=r”表示相應的目标操作數(指令部分的%0)可以使用任何一個通用寄存器,并且變量__dummy 存放在這個寄存器中,但如果是:

:“=m”(__dummy)

“=m”就表示相應的目标操作數是存放在記憶體單元__dummy 中。

表示限制條件的字母很多,表 2.5 給出了幾個主要的限制字母及其含義。

X86 尋址方式、AT&T 彙編語言相關知識、AT&T 與 Intel 彙編語言的比較、gcc 嵌入式彙編

輸入部分(Input):輸入部分與輸出部分相似,但沒有“=”。如果輸入部分一個操作數所要求使用的寄存器,與前面輸出部分某個限制所要求的是同一個寄存器,那就把對應操作數的編号(如“1”,“2”等)放在限制條件中,在後面的例子中,我們會看到這種情況。

修改部分(modify):這部分常常以“memory”為限制條件,以表示操作完成後記憶體中的内容已有改變,如果原來某個寄存器的内容來自記憶體,那麼現在記憶體中這個單元的内容已經改變。

注意,指令部分為必選項,而輸入部分、輸出部分及修改部分為可選項,當輸入部分存在,而輸出部分不存在時,分号“:”要保留,當“memory”存在時,三個分号都要保留,例如system.h 中的宏定義__cli():

Linux 源代碼中,在arch 目錄下的.h 和.c 檔案中,很多檔案都涉及嵌入式彙編,下面以system.h 中的C 函數為例,說明嵌入式彙編的應用。

(1)簡單應用

第1 個宏是儲存标志寄存器的值,第2 個宏是恢複标志寄存器的值。第1 個宏中的pushfl指令是把标志寄存器的值壓棧。而popl 是把棧頂的值(剛壓入棧的flags)彈出到x 變量中,這個變量可以存放在一個寄存器或記憶體中。這樣,你可以很容易地讀懂第2 個宏。

(2)較複雜應用

這是一個設定段界限的函數,彙編代碼段中的輸出參數為__limit(即%0),輸入參數為segment(即%1)。lsll 是加載段界限的指令,即把segment 段描述符中的段界限字段裝入某個寄存器(這個寄存器與__limit 結合),函數傳回__limit 加1,即段長。

(3)複雜應用

在Linux 核心代碼中,有關字元串操作的函數都是通過嵌入式彙編完成的,因為核心及使用者程式對字元串函數的調用非常頻繁,是以,用彙編代碼實作主要是為了提高效率(當然是以犧牲可讀性和可維護性為代價的)。在此,我們僅列舉一個字元串比較函數strcmp,其代碼在arch/i386/string.h 中。

其中的“\n”是換行符,“\t”是tab 符,在每條指令的結束加這兩個符号,是為了讓gcc 把嵌入式彙編代碼翻譯成一般的彙編代碼時能夠保證換行和留有一定的空格。例如,上面的嵌入式彙編會被翻譯成:

其中3f 表示往前(forword)找到第一個

标号為3 的那一行,相應地,1b 表示往後找。其中嵌入式彙編代碼中輸出和輸入部分的結合情況為:

• 傳回值__res,放在al 寄存器中,與%0 相結合;

• 局部變量d0,與%1 相結合,也與輸入部分的cs 參數相對應,也存放在寄存器ESI中,即ESI 中存放源字元串的起始位址。

• 局部變量d1,與%2 相結合,也與輸入部分的ct 參數相對應,也存放在寄存器EDI中,即EDI 中存放目的字元串的起始位址。