在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 彙編指令乃突破閱讀低層代碼之根本。