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

s1mba發表於2013-09-16

注:本分類下文章大多整理自《深入分析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


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 給出其語法及幾個相應的例子。

從表中可以看出,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 給出了幾個相應的例子。


三、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
相當於
.long 0
.long 0
.long 0

(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():

 C++ Code 
1
2
3
4
5
6
7
 
#define read_cr0() ({ \
unsigned int __dummy; \
__asm__( \
"movl %%cr0,%0\n\t" \
:"=r" (__dummy)); \
__dummy; \
})

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 給出了幾個主要的約束字母及其含義。


輸入部分(Input):輸入部分與輸出部分相似,但沒有“=”。如果輸入部分一個操作數所要求使用的暫存器,與前面輸出部分某個約束所要求的是同一個暫存器,那就把對應操作數的編號(如“1”,“2”等)放在約束條件中,在後面的例子中,我們會看到這種情況。
修改部分(modify):這部分常常以“memory”為約束條件,以表示操作完成後記憶體中的內容已有改變,如果原來某個暫存器的內容來自記憶體,那麼現在記憶體中這個單元的內容已經改變。

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

 C++ Code 
1
 
#define __cli() __asm__ __volatile__("cli": : :"memory")

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

(1)簡單應用

 C++ Code 
1
2
3
 
#define __save_flags(x) __asm__ __volatile__("pushfl ; popl %0":"=g"(x): /* no input*/)
#define __restore_flags(x) __asm__ __volatile__("pushl %0 ; popfl"/* no output */:"g"(x):"memory""cc")

第1 個巨集是儲存標誌暫存器的值,第2 個巨集是恢復標誌暫存器的值。第1 個巨集中的pushfl指令是把標誌暫存器的值壓棧。而popl 是把棧頂的值(剛壓入棧的flags)彈出到x 變數中,這個變數可以存放在一個暫存器或記憶體中。這樣,你可以很容易地讀懂第2 個巨集。

(2)較複雜應用

 C++ Code 
1
2
3
4
5
6
7
 
static inline unsigned long get_limit(unsigned long segment)
{
    unsigned long __limit;
    __asm__("lsll %1,%0"
            :"=r" (__limit) :"r" (segment));
    return __limit + 1;
}

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

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

 C++ Code 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
 
static inline int strcmp(const char *cs, const char *ct)
{
    int d0, d1;
    register int __res;
    __asm__ __volatile__(
        "1:\tlodsb\n\t"
        "scasb\n\t"
        "jne 2f\n\t"
        "testb %%al,%%al\n\t"
        "jne 1b\n\t"
        "xorl %%eax,%%eax\n\t"
        "jmp 3f\n"
        "2:\tsbbl %%eax,%%eax\n\t"
        "orb $1,%%al\n"
        "3:"
        :"=a"(__res), "=&S" (d0), "=&D"(d1)
        :"1"(cs), "2"(ct));
    return __res;
}

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

 C++ Code 
1
2
3
4
5
6
7
8
9
10
 
1:  lodsb    //裝入串運算元,即從[esi]傳送到al 暫存器,然後esi 指向串中下一個元素
scasb   //掃描串運算元,即從al 中減去[edi],不保留結果,只改變標誌
jne 2f   //如果兩個字元不相等,則轉到標號2
testb %al %al
jne 1b
xorl %eax %eax
jmp 3f
2:  sbbl %eax %eax
orb $1 %al
3:

其中3f 表示往前(forword)找到第一個
標號為3 的那一行,相應地,1b 表示往後找。其中嵌入式彙編程式碼中輸出和輸入部分的結合情況為:
• 返回值__res,放在al 暫存器中,與%0 相結合;
• 區域性變數d0,與%1 相結合,也與輸入部分的cs 引數相對應,也存放在暫存器ESI中,即ESI 中存放源字串的起始地址。
• 區域性變數d1,與%2 相結合,也與輸入部分的ct 引數相對應,也存放在暫存器EDI中,即EDI 中存放目的字串的起始地址。



相關文章