1. 程式人生 > 實用技巧 >轉:c中嵌入彙編

轉:c中嵌入彙編

在閱讀Linux核心原始碼或對程式碼做效能優化時,經常會有在C語言中嵌入一段彙編程式碼的需求,這種嵌入彙編在CS術語上叫做inline assembly。本文的筆記試圖說明Inline Assembly的基本語法規則和用法(建議英文閱讀能力較強的同學直接閱讀本文參考資料中推薦的技術文章_)。

注意:由於gcc採用AT&T風格的彙編語法(與Intel Syntax相對應,二者的區別參見這裡),因此,本文涉及到的彙編程式碼均以AT&T Syntax為準。

  1. 基本語法規則

內聯彙編(或稱嵌入彙編)的基本語法模板比較簡單,如下所示(為使結構更清晰,這裡特意做了換行,其實完全可以全部寫到單行中):

asm [ volatile ] (
assembler template
[ : output operands ] /* optional/
[ : input operands ] /
optional/
[ : list of clobbered registers ] /
optional */
);

備註:本文遵從linux系統的統一風格,以[ ]來表示其對應的內容為可選項。

由程式碼模板可以看到,基本語法規則由5部分組成,下面分別進行說明。

1)關鍵字asm和volatile

asm為gcc關鍵字,表示接下來要嵌入彙編程式碼。為避免keyword asm與程式中其它部分產生命名衝突,gcc還支援__asm__關鍵字,與asm的作用等價。

volatile為可選關鍵字,表示不需要gcc對下面的彙編程式碼做任何優化。同樣出於避免命名衝突的原因,__volatile__也是gcc支援的與volatile等效的關鍵字。

BTW: C語言中也經常用到volatile關鍵字來修飾變數(不熟悉的同學,請參考這裡)

2)assembler template

這部分即我們要嵌入的彙編命令,由於我們是在C語言中內聯彙編程式碼,故需用雙引號""將命令括起來,以便gcc以字串形式將這些命令傳給彙編器AS。例如可以寫成這樣:“movl %eax, %ebx”

有時候,彙編命令可能有多個,則通常分多行寫,每行的命令都用雙引號括起來,命令後緊跟"\n\t"之類的分隔符(當然,也可以只用1對雙引號將多行命令括起來,從語法來說,兩種寫法均有效,我們可自行決定用哪種格式來寫)。示例程式碼如下所示:

asmvolatile( “movl %eax, %ebx\n\t”
“movl %ecx, 2(%edx, %ebx, $8)\n\t”
“movb %ah, (%ebx)”
);

還有時候,根據程式上下文,嵌入的彙編程式碼中可能會出現一些類似於魔數(Magic Number )的運算元,比如下面的程式碼:

int a=10, b;
asm ("movl %1, %%eax;  /* NOTICE: 下面會說明此處用%%eax引用暫存器eax的原因
      movl %%eax, %0;"
      :"=r"(b)          /* output 該欄位的語法後面會詳細說明,此處可無視,下同 */
      :"r"(a)          /* input  */
      :"%eax"          /* clobbered register */
    ); 

我們看到,movl指令的運算元(operand)中,出現了%1、%0,這往往讓新手摸不著頭腦。其實只要知道下面的規則就不會產生疑惑了:

在內聯彙編中,運算元通常用數字來引用,具體的編號規則為:若命令共涉及n個運算元,則第1個輸出運算元(the first output operand)被編號為0,第2個output operand編號為1,依次類推,最後1個輸入運算元(the last input operand)則被編號為n-1。

具體到上面的示例程式碼中,根據上下文,涉及到2個運算元變數a、b,這段彙編程式碼的作用是將a的值賦給b,可見,a是input operand,而b是output operand,那麼根據運算元的引用規則,不難推出,a應該用%1來引用,b應該用%0來引用。

還需要說明的是:當命令中同時出現暫存器和以%num來引用的運算元時,會以%%reg來引用暫存器(如上例中的%%eax),以便幫助gcc來區分暫存器和由C語言提供的運算元。

3)output operands

該欄位為可選項,用以指明輸出運算元,典型的格式為:
“=a” (out_var)

其中,"=a"指定output operand的應遵守的約束(constraint),out_var為存放指令結果的變數,通常是個C語言變數。本例中,“=”是output operand欄位特有的約束,表示該運算元是隻寫的(write-only);“a”表示先將命令執行結果輸出至%eax,然後再由暫存器%eax更新位於記憶體中的out_var。關於常用的約束規則,本文後面會給出說明。

若輸出有多個,則典型格式示例如下:

asm ( "cpuid"
      : "=a" (out_var1), "=b" (out_var2), "=c" (out_var3)
      : "a" (op)
    );

可見,我們可以為每個output operand指定其約束。

4)input operands

該欄位為可選項,用以指明輸入運算元,其典型格式為:
“constraints” (in_var)

其中,constraints可以是gcc支援的各種約束方式,in_var通常為C語言提供的輸入變數。

與output operands類似,當有多個input時,典型格式為:
“constraints1” (in_var1), “constraints2” (in_var2), “constraints3” (in_var3), …

當然,input operands + output operands的總數通常是有限制的,考慮到每種指令集體系結構對其涉及到的指令支援的最多運算元通常也有限制,此處的運算元限制也不難理解。此處具體的上限為max(10, max_in_instruction),其中max_in_instruction為ISA中擁有最多運算元的那條指令包含的運算元數目。

需要明確的是,在指明input operands的情況下,即使指令不會產生output operands,其:也需要給出。例如asm (“sidt %0\n” : :“m”(loc)); 該指令即使沒有具體的output operands也要將:寫全,因為有後面跟著: input operands欄位。

5)list of clobbered registers

該欄位為可選項,用於列出指令中涉及到的且沒出現在output operands欄位及input operands欄位的那些暫存器。若暫存器被列入clobber-list,則等於是告訴gcc,這些暫存器可能會被內聯彙編命令改寫。因此,執行內聯彙編的過程中,這些暫存器就不會被gcc分配給其它程序或命令使用。

  1. 常用約束(commonly used constraints)

前面介紹output operands和input operands欄位過程中,我們已經知道這些operands通常需要指明各自的constraints,以便更明確地完成我們期望的功能(試想,如果不明確指定約束而由gcc自行決定的話,一旦程式碼執行結果不符合預期,除錯將變得很困難)。

下面開始介紹一些常用的約束項。

1)暫存器運算元約束(register operand constraint, r)

當運算元被指定為這類約束時,表明彙編指令執行時,運算元被將儲存在指定的通用暫存器(General Purpose Registers, GPR)中。例如:

asm (“movl %%eax, %0\n” : “=r”(out_val));

該指令的作用是將%eax的值返回給%0所引用的C語言變數out_val,根據"=r"約束可知具體的操作流程為:先將%eax值複製給任一GPR,最終由該暫存器將值寫入%0所代表的變數中。"r"約束指明gcc可以先將%eax值存入任一可用的暫存器,然後由該暫存器負責更新記憶體變數。

通常還可以明確指定作為“中轉”的暫存器,約束引數與暫存器的對應關係為:

a : %eax, %ax, %al

b : %ebx, %bx, %bl

c : %ecx, %cx, %cl

d : %edx, %dx, %dl

S : %esi, %si

D : %edi, %di

例如,如果想指定用%ebx作為中轉暫存器,則命令為:asm (“movl %%eax, %0\n” : “=b”(out_val));

2)記憶體運算元約束(Memory operand constraint, m)

當我們不想通過暫存器中轉,而是直接操作記憶體時,可以用"m"來約束。例如:

asm volatile ( “lock; decl %0” : “=m” (counter) : “m” (counter));

該指令實現原子減一操作,輸入、輸出運算元均直接來自記憶體(也正因如此,才能保證操作的原子性)。

3)關聯約束(matching constraint)

在有些情況下,如果命令的輸入、輸出均為同一個變數,則可以在內聯彙編中指定以matching constraint方式分配暫存器,此時,input operand和output operand共用同一個“中轉”暫存器。例如:

asm (“incl %0” :"=a"(var):“0”(var));

該指令對變數var執行incl操作,由於輸入、輸出均為同一變數,因此可用"0"來指定都用%eax作為中轉暫存器。注意"0"約束脩飾的是input operands。

4)其它約束

除上面介紹的3中常用約束外,還有一些其它的約束引數(如"o"、“V”、“i”、"g"等),感興趣的同學可以參考這裡。

  1. 例項剖析

前面介紹了很多理論性的規則,這裡通過分析一個例項來加深對inline assembly的理解。

下面的程式碼是Linux核心i386版本中的syscall0定義:

#define _syscall0(type, name)          \
type name(void)                        \
{                                      \
    long __res;                        \
    __asm__ volatile ( "int $0x80"    \
      : "=a" (__res)                  \
      : "0" (__NR_##name));            \
    __syscall_return(type, __res);    \
}

對於系統呼叫fork來說,上述巨集展開為:

pid_t fork(void)
{
    long __res;                      
    __asm__ volatile ( "int $0x80"    
    : "=a" (__res)                  
    : "0" (__NR_fork));          
    __syscall_return(pid_t, __res);    
}

根據前面對inline assembly語法及使用方法的說明,我們不難理解這段程式碼的含義。將這段內聯彙編翻譯更可讀的偽碼形式為:

pid_t fork(void)
{
    long __res;                      
    %eax = __NR_fork  /* __NR_fork為核心分配給系統呼叫fork的呼叫號 */
    int $0x80          /* 觸發中斷,核心根據%eax的值可知,引起中斷的是fork system call */
    __res = %eax      /* 中斷返回值保持在%eax中 */
    __syscall_return(pid_t, __res);    
}
程式碼說明程式碼說明
a 使用暫存器eax m 使用記憶體地址
b 使用暫存器ebx o 使用記憶體地址並可以加偏移值
c 使用暫存器ecx I 使用常數0-31
d 使用暫存器edx J 使用常數0-63
S 使用esi K 使用常數0-255
D 使用edi L 使用常數0-65535
q 使用動態分配位元組可定址暫存器(eax,ebx,ecx或edx) M 使用常數0-3
r 使用任意動態分配的暫存器 N 使用1位元組常數(0-255)
g 使用通用有效的地址即可(eax,ebx,ecx,edx或記憶體變數) O 使用常數0-31
A 使用eax與edx聯合(64位) = 輸出運算元,輸出值將替換前值
+ 表示運算元可讀可寫 & 早期彙編的運算元。表示在使用完運算元之前,內容會被修改

我遇到得問題是解析如下指令:

#define read_csr(reg) ({ unsigned long __tmp; \
  asm volatile ("csrr %0, " #reg : "=r"(__tmp)); \
  __tmp; })

以後有時間做分析

【參考資料】

    1. GCC-Inline-Assembly-HOWTO
    2. Inline assembly for x86 in Linux
    3. 《程式設計師的自我修養—連結、裝載與庫》,第12章
    4. Using Assembly Language in Linux
      ————————————————
      版權宣告:本文為CSDN博主「如果可以不需要長大」的原創文章,遵循 CC 4.0 BY-SA 版權協議,轉載請附上原文出處連結及本宣告。
      原文連結