(轉)Linux 中的彙編語言

Linux 中的彙編語言
 在閱讀 Linux 源代碼時,你可能碰到一些彙編語言片段,有些彙編語言出現在以.S
爲擴展名的彙編文件中,在這種文件中,整個程序全部由彙編語言組成。有些彙編命令出
現在以.c 爲擴展名的 C 文件中,在這種文件中,既有 C 語言,也有彙編語言,我們把出
現在 C 代碼中的彙編語言叫所“嵌入式”彙編。不管這些彙編代碼出現在哪裏,它在一定
程度上都成爲閱讀源代碼的攔路虎。
 儘管 C 語言已經成爲編寫操作系統的主要語言,但是,在操作系統與硬件打交道的過
程中,在需要頻繁調用的函數中以及某些特殊的場合中,C 語言顯得力不從心,這時,
繁瑣但又高效的彙編語言必須粉墨登場。因此,在瞭解一些硬件的基礎上,必須對相關的
彙編語言知識也所有了解。
 讀者可能有過在 DOS 操作系統下編寫彙編程序的經歷,也具備一定的彙編知識。但是,
在 Linux 的源代碼中,你可能看到了與 Intel 的彙編語言格式不一樣的形式,這就是
AT&T 的 386 彙編語言。
一、AT&T 與 Intel 彙編語言的比較
 我們知道,Linux 是 Unix 家族的一員,儘管 Linux 的歷史不長,但與其相關的很多事
情都發源於 Unix。就 Linux 所使用的 386 彙編語言而言,它也是起源於 Unix。Unix 最初是
爲 PDP-11 開發的,曾先後被移植到 VAX 及 68000 系列的處理器上,這些處理器上的彙編
語言都採用的是 AT&T 的指令格式。當 Unix 被移植到 i386 時,自然也就採用了 AT&T 的匯
編語言格式,而不是 Intel 的格式。儘管這兩種彙編語言在語法上有一定的差異,但所基
於的硬件知識是相同的,因此,如果你非常熟悉 Intel 的語法格式,那麼你也可以很容
易地把它“移植“到 AT&T 來。下面我們通過對照 Intel 與 AT&T 的語法格式,以便於你把
過去的知識能很快地“移植”過來。 1.前綴
 在 Intel 的語法中,寄存器和和立即數都沒有前綴。但是在 AT&T 中,寄存器前冠以
“%”,而立即數前冠以“$”。在 Intel 的語法中,十六進制和二進制立即數後綴分別
冠以“h”和“b”,而在 AT&T 中,十六進制立即數前冠以“0x”,表 2.2 給出幾個相應
的例子。
表 2.2 Intel 與 AT&T 前綴的區別
Intel 語法 AT&T 語法
 mov eax,8 movl $8,%eax
 mov ebx,0ffffh movl $0xffff,%ebx
 int 80h int $0x80
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 給出其語
法及幾個相應的例子。
表 2.3 內存操作數的語法及舉例
Intel 語法 AT&T 語法
指令 foo,segreg:[base+index*scale+disp] 指令 %segreg:disp(base,index,scale),foo
mov eax,[ebx+20h] Movl0x20(%ebx),%eax
add eax,[ebx+ecx*2h Addl (%ebx,%ecx,0x2),%eax
lea eax,[ebx+ecx] Leal (%ebx,%ecx),%eax
sub eax,[ebx+ecx*4h-20h] Subl -0x20(%ebx,%ecx,0x4),%eax
 從表中可以看出,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 給出幾個相應的例子。
表 2.4 操作碼的後綴舉例Intel 語法 AT&T 語法
 Mov al,bl movb %bl,%al
 Mov ax,bx movw %bx,%ax
 Mov eax,ebx movl %ebx,%eax
 Mov eax, dword ptr [ebx] movl (%ebx),%eax
二、 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 關鍵詞來標識,當你編寫彙編語言程序時,
至少需要有以下三種節:
.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 是讀/寫節。
 
3.彙編程序指令(Assembler Directive)
 上面介紹的.section 就是彙編程序指令的一種,GNU 彙編程序提供了很多這樣的指令
(directiv),這種指令都是以句點(.)爲開頭,後跟指令名(小寫字母),在此,我
們只介紹在內核源代碼中出現的幾個指令(以 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 爲可選項。如果第二個逗號和 value 值不存在,則假定 value 爲 0。如
果第一個逗號和 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():
#define read_cr0() ({ \
 unsigned int __dummy; \
 __asm__( \
 "movl %%cr0,%0\n\t" \
 :"=r" (__dummy)); \
 __dummy; \
 })
這種形式看起來比較陌生,這是因爲這不是標準 C 所定義的形式,而是 gcc 對 C 語
言的擴充。其中__dummy 爲 C 函數所定義的變量;關鍵詞__asm__表示彙編代碼的開始。括弧中第一個引號中爲彙編指令 movl,緊接着有一個冒號,這種形式閱讀起來比較複雜。
一般而言,嵌入式彙編語言片段比單純的彙編語言代碼要複雜得多,因爲這裏存在
怎樣分配和使用寄存器,以及把 C 代碼中的變量應該存放在哪個寄存器中。爲了達到這個
目的,就必須對一般的 C 語言進行擴充,增加對編譯器的指導作用,因此,嵌入式彙編
看起來晦澀而難以讀懂。
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 給出幾個主要的約束字母及其含義:
 表 2.5 主要的約束字母及其含義
 字母 含義
 m, v,o 表示內存單元
 R 表示任何通用寄存器
 Q 表示寄存器 eax, ebx, ecx,edx 之一 
 I, h 表示直接操作數
 E, F 表示浮點數
 G 表示“任意”
 a, b.c d 表示要求使用寄存器 eax/ax/al, ebx/bx/bl, ecx/cx/cl 或 edx/dx/dl
 S, D 表示要求使用寄存器 esi 或 edi
 I 表示常數(0~31)
· 輸入部分(Input):輸入部分與輸出部分相似,但沒有“=”。如果輸入部分一個
操作數所要求使用的寄存器,與前面輸出部分某個約束所要求的是同一個寄存器,
那就把對應操作數的編號(如“1”,“2”等)放在約束條件中,在後面的例子中
我們會看到這種情況。
· 修改部分(modify):這部分常常以“memory”爲約束條件,以表示操作完成後內存
中的內容已有改變,如果原來某個寄存器的內容來自內存,那麼現在內存中這個單
元的內容已經改變。
注意,指令部分爲必選項,而輸入部分、輸出部分及修改部分爲可選項,當輸入部分
存在,而輸出部分不存在時,分號“:“要保留,當“memory”存在時,三個分號
都要保留,例如 system.h 中的宏定義__cli(): #define __cli() __asm__ __volatile__("cli": : :"memory")
2. Linux 源代碼中嵌入式彙編舉例
 Linux 源代碼中,在 arch 目錄下的.h 和.c 文件中,很多文件都涉及嵌入式彙編,下面
以 system.h 中的 C 函數爲例,說明嵌入式彙編的應用。
(1)簡單應用
#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")
第一個宏是保存標誌寄存器的值,第二個宏是恢復標誌寄存器的值。第一個宏中的
pushfl 指令是把標誌寄存器的值壓棧。而 popl 是把棧頂的值(剛壓入棧的 flags)彈出到
x 變量中,這個變量可以存放在一個寄存器或內存中。這樣,你可以很容易地讀懂第二個
宏。
(2) 較複雜應用
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 中。
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 把嵌入式彙編代碼翻譯成一般的彙編代碼時能夠保證換行和留有一定的空格。例
如,上面的嵌入式彙編會被翻譯成:
1: lodsb //裝入串操作數,即從[esi]傳送到 al 寄存器,然後 esi 指向串
中下一個元素
 scasb //掃描串操作數,即從 al 中減去 es:[edi],不保留結果,只
改變標誌
 jne2f //如果兩個字符不相等,則轉到標號 2 
 testb %al %al 
 jne 1b
 xorl %eax %eax
 jmp 3f
2: sbbl %eax %eax
 orb $1 %al
3:
 這段代碼看起來非常熟悉,讀起來也不困難。其中 1f 表示往前(forword)找到第
一個標號爲 1 的那一行,相應地,1b 表示往後找。其中嵌入式彙編代碼中輸出和輸入部分
的結合情況爲:
· 返回值__res,放在 al 寄存器中,與%0 相結合;
· 局部變量 d0,與%1 相結合,也與輸入部分的 cs 參數相對應,也存放在寄存器
ESI 中,即 ESI 中存放源字符串的起始地址。
· 局部變量 d1, 與%2 相結合,也與輸入部分的 ct 參數相對應,也存放在寄存
器 EDI 中,即 EDI 中存放目的字符串的起始地址。
通過對這段代碼的分析我們應當體會到,萬變不利其本,嵌入式彙編與一般彙編的區別僅僅是形式,本質依然不變。因此,全面掌握 Intel 386 彙編指令乃突破閱讀低層代碼之
根本。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章