羽夏筆記—— AT&T 與 GCC

2022-05-24 18:01:46

寫在前面

  本文是本人根據《AT&T 組合語言與 GCC 內嵌組合簡介》進一步整理,修改了一些錯誤,並刪除我並不能復現程式碼相關的部分。該文章一是我對 AT&T 的學習記錄,二是對大家學習 AT&T 有更好的幫助。如對該博文有好的建議,歡迎反饋。碼字不易,如果本篇文章有幫助你的,如有閒錢,可以打賞支援我的創作。如想轉載,請把我的轉載資訊附在文章後面,並宣告我的個人資訊和本人部落格地址即可,但必須事先通知我

概述

  在此之前,我建議你學習Intel組合,否則有些你可能不太懂。因為我是根據有Intel組合編寫的基礎的人的快速轉型,方便學習Linux原始碼,因為裡面有一些奇奇怪怪的內聯組合的寫法,不會的話會看的滿頭霧水。下面我開始介紹AT&T組合語言語法與Intel組合語法的差別:

區別

大小寫

  Intel格式的指令一般使用大寫字母,而AT&T格式的一般使用小寫字母。在使用Intel格式的指令內斂組合的時候我並沒有發現嚴格大寫(除了我見過 Ghidra 嚴格區分大小寫)。

Intel AT&T
MOV EAX,EBX movl %ebx,%eax

運算元賦值方向

  在Intel語法中,第一個表示目的運算元,第二個表示源運算元,賦值方向從右向左。AT&T語法第一個為源運算元,第二個為目的運算元,方向從左到右。寫高階語言的程式碼寫多了,AT&T語法看起來比較難受。

Intel AT&T
MOV EAX,EBX movl %ebx,%eax

字首

  在Intel語法中暫存器和立即數不需要字首;AT&T中暫存器需要加字首%,立即數需要加字首$。感覺AT&T語法有點花裡胡哨,可讀性相對於Intel語法低。

Intel AT&T
MOV EAX,1 movl $1,%eax

  符號常數直接參照,不需要加字首,如:movl value , %ebxvalue為一常數;在符號前加字首$表示參照符號地址, 如movl $value, %ebx,是將value的地址放到ebx中。
  是不是有點看不太懂,我們來繼續舉個例子:

Intel AT&T
MOV ESI, [0x4000] movl 0x4000, %esi

  匯流排鎖定字首lock:匯流排鎖定操作。lock字首在Linux核心程式碼中使用很多,特別是SMP程式碼中。當匯流排鎖定後其它CPU不能存取鎖定地址處的記憶體單元。
  對於AT&T語法,遠端跳轉指令和子過程呼叫指令的操作碼使用字首l,分別為ljmplcall,與之相應的返回指令lret,如下是幾個例子:

Intel AT&T
CALL FAR SECTION:OFFSET lcall \(secion:\)offset
JMP FAR SECTION:OFFSET ljmp \(secion:\)offset
RET FAR SATCK_ADJUST lret $stack_adjust

間接定址語法

  Intel中基地址使用[],而在AT&T中使用()。另外處理複雜運算元的語法也不同,IntelSegreg:[base+index*scale+disp],而在AT&T中為%segreg:disp(base,index,sale),其中segregindexscaledisp都是可選的,在指定index而沒有顯式指定scale的情況下使用預設值 1scaledisp不需要加字首&。看不懂沒關係,我們來幾個例子:

Intel AT&T
LEA RCX, [R13+RDX*8+0x10] leaq 0x10(%r13, %rdx, 8), %rcx

字尾

  AT&T語法中大部分指令操作碼的最後一個字母表示運算元大小,b表示byte(一個位元組);w表示word(2 個位元組);l表示long(4 個位元組)。Intel中處理記憶體運算元時也有類似的語法如:BYTE PTRWORD PTRDWORD PTR

Intel AT&T
mov al, bl movb %bl,%al
mov ax,bx movw %bx,%ax
mov eax, dword ptr [ebx] movl (%ebx), %eax

  在AT&T組合指令中,運算元擴充套件指令有兩個字尾,一個指定源運算元的字長,另一個指定目標運算元的字長。AT&T的符號擴充套件指令的為movs,零擴充套件指令為movz(相應的Intel指令為movsxmovzx)。因此,movsbl %al,%edx表示對暫存器al中的位元組資料進行位元組到長字的符號擴充套件,計算結果存放在暫存器edx中。下面是一些允許的運算元擴充套件字尾:

  • bl: 位元組 -> 長字
  • bw: 位元組 -> 字
  • wl: 字 -> 長字

  跳轉指令標號後的字尾表示跳轉方向,f表示向前forwardb表示向後backward,比如:

    jmp 1f 
1:  jmp 1f 
1: 

指令

  Intel組合與AT&T組合指令基本相同,差別僅在語法上,具體請查閱手冊。

GCC 內聯組合概述

  核心程式碼絕大部分使用C語言編寫,只有一小部分使用組合語言編寫,例如與特定體系結構相關的程式碼和對效能影響很大的程式碼。GCC提供了內嵌組合的功能,可以在C程式碼中直接內嵌組合語言語句,大大方便了程式設計。簡單的內嵌組合很容易理解,例如:

__asm__ __volatile__("hlt"); 

  __asm__表示後面的程式碼為內嵌組合,asm__asm__的別名;__volatile__表示編譯器不要優化程式碼,後面的指令保留原樣,volatile是它的別名;括號裡面是組合指令。
  在內嵌組合中,可以將C語言表示式指定為組合指令的運算元,而且不用去管如何將C語言表示式的值讀入哪個暫存器,以及如何將計算結果寫回C變數,你只要告訴程式中C語言表示式與組合指令運算元之間的對應關係即可,GCC會自動插入程式碼完成必要的操作。
  使用內嵌組合,要先編寫組合指令模板,然後將C語言表示式與指令的運算元相關聯,並告訴GCC對這些操作有哪些限制條件。例如在下面的組合語句:

__asm__ __violate__ ("movl %1,%0" : "=r" (result) : "m" (input));

  movl %1,%0是指令模板;%0%1代表指令的運算元,稱為預留位置,內嵌組合靠它們將C語言表示式與指令運算元相對應。指令模板後面用小括號括起來的是C語言表示式,本例中只有resultinput,他們按照出現的順序分別與指令運算元%0%1對應。注意對應順序:第一個C表示式對應%0;第二個表示式對應%1,依次類推。運算元至多有 10 個,分別用%0-%9表示。在每個運算元前面有一個用引號括起來的字串,字串的內容是對該運算元的限制或者要求。result前面的限制字串是=r,其中=表示result是輸出運算元,r表示需要將result與某個通用暫存器相關聯,先將運算元的值讀入暫存器,然後在指令中使用相應暫存器,而不是result本身,當然指令執行完後需要將暫存器中的值存入變數result,從表面上看好像是指令直接對result進行操作,實際上GCC做了隱式處理,這樣我們可以少寫一些指令。input前面的r表示該表示式需要先放入某個暫存器,然後在指令中使用該暫存器參加運算。
  具體細節我們還會在後面進行介紹,下面我們先進行測試:

int input,result; 

int main()
{
    input = 1; 
    __asm__ __volatile__ ("movl %1,%0" : "=r" (result) : "r" (input)); 

    return 0;
}

  我們使用gcc -c wingsummer.c -S將原始碼轉成組合:

    .file    "wingsummer.c"
    .text
    .comm    input,4,4
    .comm    result,4,4
    .globl    main
    .type    main, @function
main:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $1, input(%rip)
    movl    input(%rip), %eax
#APP
# 6 "wingsummer.c" 1
    movl %eax,%eax
# 0 "" 2
#NO_APP
    movl    %eax, result(%rip)
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size    main, .-main
    .ident    "GCC: (Uos 8.3.0.3-3+rebuild) 8.3.0"
    .section    .note.GNU-stack,"",@progbits

  我們重點關注的是:

    movl    $1, input(%rip)
    movl    input(%rip), %eax
#APP
# 6 "wingsummer.c" 1
    movl %eax,%eax
# 0 "" 2
#NO_APP
    movl    %eax, result(%rip)

  上面的組合是GCC自動增加的程式碼,GCC根據限定字串決定如何處理C表示式,本例兩個表示式都被指定為r型,所以先使用指令movl input, %eax,將input讀入暫存器%eaxGCC也指定一個暫存器與輸出變數result相關,本例也是%eax,等得到操作結果後再使用指令movl %eax, result,將暫存器的值寫回C變數result中。從上面的組合程式碼我們可以看出與resultinput相關連的暫存器都是%eaxGCC使用%eax替換內嵌組合指令模板中的%0%1movl %eax,%eax,顯然這一句可以不要。但是沒有優化,所以這一句沒有被去掉。由此可見,C表示式或者變數與暫存器的關係由GCC自動處理,我們只需使用限制字串指導GCC如何處理即可。限制字元必須與指令對運算元的要求相匹配,否則產生的組合程式碼將會有錯,讀者可以將上例中的兩個r,都改為mm 表示運算元放在記憶體,而不是暫存器中),編譯後得到的結果是movl input, result,很明顯這是一條非法指令,因此限制字串必須與指令對運算元的要求匹配。例如指令movl允許暫存器到暫存器,立即數到暫存器等,但是不允許記憶體到記憶體的操作,因此兩個運算元
不能同時使用m作為限定字元。

GCC 內聯組合詳解

  內嵌組合語法如下:

__asm__(組合語句模板: 輸出部分: 輸入部分: 破壞描述部分) 

  正如上面所示,一共四個部分:組合語句模板,輸出部分,輸入部分,破壞描述部分。各部分使用:格開,組合語句模板必不可少,其他三部分可選,如果使用了後面的部分,而前面部分為空,
也需要用:格開,相應部分內容為空,這個和C的普通的for語句點寫法差不多。例如:

__asm__ __volatile__("cli": : :"memory") 

組合語句模板

  組合語句模板由組合語句序列組成,語句之間使用;\n\n\t分開。指令中的運算元可以使用預留位置參照C語言變數,運算元預留位置最多 10 個。指令中使用預留位置表示的運算元,總被視為long型(4 個位元組),但對其施加的操作根據指令可以是字或者位元組,當把運算元當作字或者位元組使用時,預設為低字或者低位元組。對位元組操作可以顯式的指明是低位元組還是次位元組。方法是在%和序號之間插入一個字母,b代表低位元組,h代表高位元組,例如:%h1

輸出部分

  輸出部分描述輸出運算元,不同的運算元描述符之間用逗號格開,每個運算元描述符由限定字串和C語言變陣列成。每個輸出運算元的限定字串必須包含=表示他是一個輸出運算元。比如:

__asm__ __volatile__("pushfl ; popl %0 ; cli":"=g" (x) ) 

  描述符字串表示對該變數的限制條件,這樣GCC就可以根據這些條件決定如何分配暫存器,如何產生必要的程式碼處理指令運算元與C表示式或C變數之間的聯絡。

輸入部分

  輸入部分描述輸入運算元,不同的運算元描述符之間使用逗號格開,每個運算元描述符由限定字串和C語言表示式或者C語言變陣列成。下面給幾個例子:

__asm__ __volatile__ ("lidt %0" : : "m" (real_mode_idt)); 
//bitops.h
static __inline__ void __set_bit(int nr, volatile void * addr) 
{ 
    __asm__( 
        "btsl %1,%0" 
        :"=m" (ADDR) 
        :"Ir" (nr)); 
} 

  最後一個例子展示的功能是將*addr的第nr位設為 1。第一個預留位置%0C語言變數ADDR對應,第二個預留位置%1C語言變數nr對應。因此改組合語句程式碼與虛擬碼btsl nr, ADDR等價,該指令的兩個運算元不能全是記憶體變數,因此將nr的限定字串指定為Ir,將nr與立即數或者暫存器相關聯,這樣兩個運算元中只有ADDR為記憶體變數。

限制字元

限制字元列表

  限制字元有很多種,有些是與特定體系結構相關,此處我們以x86為例,僅列出常用的限定字元和i386中可能用到的一些常用的限定符。它們的作用是指示編譯器如何處理其後的C語言變數與指令運算元之間的關係,例如是將變數放在暫存器中還是放在記憶體中等,下表列出了常用的限定字母:

  • 通用暫存器
限定符 描述
a 將輸入變數放入eax。如果被佔用了,GCC會在這段組合程式碼的起始處插入一條語句pushl %eax,將eax內容儲存到堆疊,然後在這段程式碼結束處再增加一條語句popl %eax,恢復 eax 的內容
b 將輸入變數放入ebx
c 將輸入變數放入ecx
d 將輸入變數放入edx
s 將輸入變數放入esi
d 將輸入變數放入edi
q 將輸入變數放入eaxebxecxedx中的一個
r 將輸入變數放入通用暫存器,也就是eaxebxecxedxesiedi中的一個
A eaxedx合成一個 64 位的暫存器
  • 記憶體
限定符 描述
m 記憶體變數
o 運算元為記憶體變數,但是其定址方式是偏移量型別,也即是基址定址,或者是基址加變址定址
V 運算元為記憶體變數,但定址方式不是偏移量型別
運算元為記憶體變數,但定址方式為自動增量
p 運算元是一個合法的記憶體地址(指標)
  • 暫存器或記憶體
限定符 描述
g 將輸入變數放入eaxebxecxedx中的一個或者作為記憶體變數
X 運算元可以是任何型別
  • 立即數
限定符 描述
I 0-31 之間的立即數(用於 32 位移位指令)
J 0-63 之間的立即數(用於 64 位移位指令)
N 0-255 之間的立即數(用於 out 指令)
i 立即數
n 立即數,有些系統不支援除字以外的立即數,這些系統應該使用n而不是i
  • 運算元型別
限定符 描述
= 運算元在指令中是隻寫的(輸出運算元)
+ 運算元在指令中是讀寫型別的(輸入輸出運算元)
  • 浮點數
限定符 描述
f 浮點暫存器
t 第一個浮點暫存器
u 第二個浮點暫存器
G 標準的 80387 浮點常數
  • 匹配
限定符 描述
0-9 表示用它限制的運算元與某個指定的運算元匹配,也即該運算元就是指定的那個運算元,例如用0去描述%1運算元,那麼%1參照的其實就是%0運算元,注意作為限定符字母的0-9與指令中的%0-%9的區別,前者描述運算元,後者代表運算元。
  • 其他
限定符 描述
& 該輸出運算元不能使用過和輸入運算元相同的暫存器
% 該運算元可以和下一個運算元交換位置
# 部分註釋,從該字元到其後的逗號之間所有字母被忽略
* 表示如果選用暫存器,則其後的字母被忽略

  現在繼續看上面的例子,"=m" (ADDR)表示ADDR為記憶體變數,而且是輸出變數;"Ir" (nr)表示nr0-31之間的立即數或者一個暫存器運算元。

匹配限制符

  I386指令集中許多指令的運算元是讀寫型的(讀寫型運算元指先讀取原來的值然後參加運算,最後將結果寫回運算元),例如addl %1,%0,它的作用是將運算元%0與運算元%1的和存入運算元%0,因此運算元%0是讀寫型運算元。老版本的GCC對這種型別運算元的支援不是很好,它將運算元嚴格分為輸入和輸出兩種,分別放在輸入部分和輸出部分,而沒有一個單獨部分描述讀寫型運算元,因此在GCC中讀寫型的運算元需要在輸入和輸出部分分別描述,靠匹配限制符將兩者關聯到一起。注意僅在輸入和輸出部分使用相同的C變數,但是不用匹配限制符,產生的程式碼很可能不對,後面會分析原因。看一下下面的程式碼就知道為什麼要將讀寫型運算元,分別在輸入和輸出部分加以描述(求input+result的和,然後存入result):

int input,result; 

int main()
{
    result = 0;
    input = 1; 

    __asm__ __volatile__ ("addl %1,%0" : "=r" (result) : "r" (input)); 
    return 0;
}

  看一下生成的組合程式碼:

    .file    "wingsummer.c"
    .text
    .comm    input,4,4
    .comm    result,4,4
    .globl    main
    .type    main, @function
main:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $0, result(%rip)
    movl    $1, input(%rip)
    movl    input(%rip), %eax
#APP
# 8 "wingsummer.c" 1
    addl %eax,%eax
# 0 "" 2
#NO_APP
    movl    %eax, result(%rip)
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size    main, .-main
    .ident    "GCC: (Uos 8.3.0.3-3+rebuild) 8.3.0"
    .section    .note.GNU-stack,"",@progbits

  從上面我們明顯的看出,這根本和預想的根本不一樣,最終的結果是result = input + input,這顯然不是我們想要的。
  綜上可以總結出如下幾點:

  1. 使用r限制的輸入變數,GCC先分配一個暫存器,然後將值讀入暫存器,最後用該暫存器替換預留位置;
  2. 使用r限制的輸出變數,GCC會分配一個暫存器,然後用該暫存器替換預留位置,但是在使用該暫存器之前並不將變數值先讀入暫存器,GCC認為所有輸出變數以前的值都沒有用處,不讀入暫存器,最後GCC插入程式碼,將暫存器的值寫回變數;
  3. 輸入變數使用的暫存器在最後一處使用它的指令之後,就可以挪做其他用處,因為已經不再使用。

  因為第二條,上面的內嵌組合指令不能奏效,因此需要在執行addl之前把result的值讀入暫存器,也許再將result放入輸入部分就可以了,修改如下:

int input,result; 

int main()
{
    result = 0;
    input = 1; 

    __asm__ __volatile__ ("addl %2,%0":"=r"(result):"r"(result),"m"(input)); 
    return 0;
}

  看上去上面的程式碼可以正常工作,因為我們知道%0%1都和result相關,應該使用同一個暫存器,但是GCC並不去判斷%0%1是否和同一個C表示式或變數相關聯(這樣易於產生與內嵌組合相應的組合程式碼),因此%0%1使用的暫存器可能不同。
  我們來生成一下組合:

    .file    "wingsummer.c"
    .text
    .comm    input,4,4
    .comm    result,4,4
    .globl    main
    .type    main, @function
main:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $0, result(%rip)
    movl    $1, input(%rip)
    movl    result(%rip), %eax
#APP
# 8 "wingsummer.c" 1
    addl input(%rip),%eax
# 0 "" 2
#NO_APP
    movl    %eax, result(%rip)
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size    main, .-main
    .ident    "GCC: (Uos 8.3.0.3-3+rebuild) 8.3.0"
    .section    .note.GNU-stack,"",@progbits

  可以看出,這次組合程式碼生成正確,正如我們想要的。使用匹配限制符後,GCC知道應將對應的運算元放在同一個位置(同一個暫存器或者同一個記憶體變數)。使用匹配限制字元的程式碼如下:

int input,result; 

int main()
{
    result = 0;
    input = 1; 

    __asm__ __volatile__ ("addl %2,%0":"=r"(result):"0"(result),"m"(input));
    return 0;
}

  輸入部分中的result用匹配限制符0限制,表示%1%0代表同一個變數,輸入部分說明該變數的輸入功能,輸出部分說明該變數的輸出功能,兩者結合表示result是讀寫型。因為%0%1表示同一個C變數,所以放在相同的位置,無論是暫存器還是記憶體。如下是反組合:

    .file    "wingsummer.c"
    .text
    .comm    input,4,4
    .comm    result,4,4
    .globl    main
    .type    main, @function
main:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $0, result(%rip)
    movl    $1, input(%rip)
    movl    result(%rip), %eax
#APP
# 8 "wingsummer.c" 1
    addl input(%rip),%eax
# 0 "" 2
#NO_APP
    movl    %eax, result(%rip)
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size    main, .-main
    .ident    "GCC: (Uos 8.3.0.3-3+rebuild) 8.3.0"
    .section    .note.GNU-stack,"",@progbits

  至此你應該明白了匹配限制符的意義和用法。在新版本的GCC中增加了一個限制字元「+」,它表示運算元是讀寫型的,GCC知道應將變數值先讀入暫存器,然後計算,最後寫回變數,而無需在輸入部分再去描述該變數:

int input,result; 

int main()
{
    result = 0;
    input = 1; 

    __asm__ __volatile__ ("addl %1,%0":"+r"(result):"m"(input));
    return 0;
}

  組合如下:

    .file    "wingsummer.c"
    .text
    .comm    input,4,4
    .comm    result,4,4
    .globl    main
    .type    main, @function
main:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $0, result(%rip)
    movl    $1, input(%rip)
    movl    result(%rip), %eax
#APP
# 8 "wingsummer.c" 1
    addl input(%rip),%eax
# 0 "" 2
#NO_APP
    movl    %eax, result(%rip)
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size    main, .-main
    .ident    "GCC: (Uos 8.3.0.3-3+rebuild) 8.3.0"
    .section    .note.GNU-stack,"",@progbits

& 限制符

  限制符&在核心中使用的比較多,它表示輸入和輸出運算元不能使用相同的暫存器,這樣可以避免很多錯誤。舉一個例子,下面程式碼的作用是將函數foo的返回值存入變數ret中:

int foo()
{
    return 1;
}

int main()
{
    __asm__ ( 「call foo\n\tmovl %%ebx,%1」, : 」=a」(ret) : 」r」(bar) );
    return 0;
}

  它的反組合如下:

    .file    "wingsummer.c"
    .text
    .comm    ret,4,4
    .comm    bar,4,4
    .globl    foo
    .type    foo, @function
foo:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $1, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size    foo, .-foo
    .globl    main
    .type    main, @function
main:
.LFB1:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    bar(%rip), %eax
#APP
# 10 "wingsummer.c" 1
    call foo
    movl %ebx,%eax
# 0 "" 2
#NO_APP
    movl    %eax, ret(%rip)
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE1:
    .size    main, .-main
    .ident    "GCC: (Uos 8.3.0.3-3+rebuild) 8.3.0"
    .section    .note.GNU-stack,"",@progbits

  我們知道函數的int型返回值存放在%eax中,但是gcc編譯的結果是輸入和輸出同時使用了暫存器%eax,這顯然不對。避免這種情況的方法是使用&限定符,這樣bar就不會再使用%eax暫存器,因為已被ret指定使用。

int ret,bar;

int foo()
{
    return 1;
}

int main()
{
    __asm__ ( "call foo\n\tmovl %%ebx,%1" : "=&a"(ret) : "r"(bar) );
    return 0;
}

  組合如下:

    .file    "wingsummer.c"
    .text
    .comm    ret,4,4
    .comm    bar,4,4
    .globl    foo
    .type    foo, @function
foo:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $1, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size    foo, .-foo
    .globl    main
    .type    main, @function
main:
.LFB1:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    bar(%rip), %edx
#APP
# 10 "wingsummer.c" 1
    call foo
    movl %ebx,%edx
# 0 "" 2
#NO_APP
    movl    %eax, ret(%rip)
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE1:
    .size    main, .-main
    .ident    "GCC: (Uos 8.3.0.3-3+rebuild) 8.3.0"
    .section    .note.GNU-stack,"",@progbits

破壞描述部分

暫存器破壞描述符

  通常編寫程式只使用一種語言:高階語言或者組合語言。高階語言編譯的步驟大致如下:

  • 預處理
  • 編譯
  • 組合
  • 連結

  我們這裡只關心第二步編譯(將C程式碼轉換成組合程式碼):因為所有的程式碼都是用高階語言編寫,編譯器可以識別各種語句的作用,在轉換的過程中所有的暫存器都由編譯器決定如何分配使用,它有能力保證暫存器的使用不會衝突;也可以利用暫存器作為變數的緩衝區,因為暫存器的存取速度比記憶體快很多倍。如果全部使用組合語言則由程式設計師去控制暫存器的使用,只能靠程式設計師去保證暫存器使用的正確性。但是如果兩種語言混用情況就變複雜了,因為內嵌的組合程式碼可以直接使用暫存器,而編譯器在轉換的時候並不去檢查內嵌的組合程式碼使用了哪些暫存器(因為很難檢測組合指令使用了哪些暫存器,例如有些指令隱式修改暫存器,有時內嵌的組合程式碼會呼叫其他子過程,而子過程也會修改暫存器),因此需要一種機制通知編譯器我們使用了哪些暫存器(程式設計師自己知道內嵌組合程式碼中使用了哪些暫存器),否則對這些暫存器的使用就有可能導致錯誤,修改描述部分可以起到這種作用。當然內嵌組合的輸入輸出部分指明的暫存器或者指定為rg型由編譯器去分配的暫存器就不需要在破壞描述部分去描述,因為編譯器已經知道了。
  破壞描述符由逗號格開的字串組成,每個字串描述一種情況,一般是暫存器名,除暫存器外還有memory。例如:%eax%ebxmemory等。下面看個例子就很清楚為什麼需要通知 GCC 內嵌組合程式碼中隱式(稱它為隱式是因為GCC 並不知道)使用的暫存器。
  在內嵌的組合指令中可能會直接參照某些暫存器,我們已經知道AT&T格式的組合語言中,暫存器名以%作為字首,為了在生成的組合程式中保留這個%號,在asm語句中對暫存器的參照必須用%%作為暫存器名稱的字首。原因是%asm內嵌組合語句中的作用與\在 C 語言中的作用相同,因此%%轉換後代表%

int main()
{
    int input, output, temp;

    input = 1;
    __asm__ __volatile__("movl $0, %%eax;\n\t"
                         "movl %%eax, %1;\n\t"
                         "movl %2, %%eax;\n\t"
                         "movl %%eax, %0;\n\t"
                         : "=m"(output), "=m"(temp) /* output */
                         : "r"(input)               /* input */
    );
    return 0;
}

  這段程式碼使用%eax作為臨時暫存器,功能相當於C程式碼temp = 0;output=input;,組合如下:

    .file    "wingsummer.c"
    .text
    .globl    main
    .type    main, @function
main:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    movl    $1, -4(%rbp)
    movl    -4(%rbp), %eax
#APP
# 6 "wingsummer.c" 1
    movl $0, %eax;
    movl %eax, -12(%rbp);
    movl %eax, %eax;
    movl %eax, -8(%rbp);
    
# 0 "" 2
#NO_APP
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size    main, .-main
    .ident    "GCC: (Uos 8.3.0.3-3+rebuild) 8.3.0"
    .section    .note.GNU-stack,"",@progbits

  對應的組合程式碼如下:

    movl $1, -4(%rbp)
    movl -4(%rbp), %eax
# 6 "wingsummer.c" 1
    movl $0, %eax;
    movl %eax, -12(%rbp);
    movl %eax, %eax;
    movl %eax, -8(%rbp);
    
# 0 "" 2
#NO_APP
    movl    $0, %eax

  顯然GCCinput分配的暫存器也是%eax,發生了衝突,output的值始終為0,而不是input

int main()
{
    int input, output, temp;

    input = 1;
    __asm__ __volatile__("movl $0, %%eax;\n\t"
                         "movl %%eax, %1;\n\t"
                         "movl %2, %%eax;\n\t"
                         "movl %%eax, %0;\n\t"
                         : "=m"(output), "=m"(temp) /* output */
                         : "r"(input)               /* input */
                         : "eax"                    /* 描述符 */ 
    );
    return 0;
}

  對應的關鍵組合程式碼如下:

    movl    $1, -4(%rbp)
    movl    -4(%rbp), %edx
#APP
# 6 "wingsummer.c" 1
    movl $0, %eax;
    movl %eax, -12(%rbp);
    movl %edx, %eax;
    movl %eax, -8(%rbp);
    
# 0 "" 2
#NO_APP
    movl    $0, %eax

  通過破壞描述部分,GCC得知%eax已被使用,因此給input分配了%edx。在使用內嵌組合時請記住一點:儘量告訴GCC儘可能多的資訊,以防出錯。如果你使用的指令會改變CPU的條件暫存器cc,需要在修改描述部分增加cc

memory 破壞描述符

  memory比較特殊,可能是內嵌組合中最難懂部分。memory描述符告知GCC

  1. 不要將該段內嵌組合指令與前面的指令重新排序;也就是在執行內嵌組合程式碼之前,它前面的指令都執行完畢。
  2. 不要將變數快取到暫存器,因為這段程式碼可能會用到記憶體變數,而這些記憶體變數會以不可預知的方式發生改變,因此GCC插入必要的程式碼先將快取到暫存器的變數值寫回記憶體,如果後面又存取這些變數,需要重新存取記憶體。

  如果組合指令修改了記憶體,但是 GCC 本身卻察覺不到,因為在輸出部分沒有描述,此時就需要在修改描述部分增加memory,告訴GCC記憶體已經被修改,GCC得知這個資訊後,就會在這段指令之前,插入必要的指令將前面因為優化Cache到暫存器中的變數值先寫回記憶體,如果以後又要使用這些變數再重新讀取。
  這兩條對實現臨界區至關重要,第一條保證不會因為指令的重新排序將臨界區內的程式碼調到臨界區之外,第二條保證在臨界區存取的變數的值,肯定是最新的值,而不是快取在暫存器中的值,否則就會導致奇怪的錯誤。例如下面的程式碼:

int del_timer(struct timer_list *timer)
{
    int ret = 0;
    if (timer->next)
    {
        unsigned long flags;
        struct timer_list *next;
        save_flags(flags);
        cli();
        //臨界區開始
        if ((next = timer->next) != NULL)
        {
            (next->prev = timer->prev)->next = next;
            timer->next = timer->prev = NULL;
            ret = 1;
        }
        //臨界區結束
        restore_flags(flags);
    }
    return ret;
}

  它先判斷timer->next的值,如果是空直接返回,無需進行下面的操作。如果不是空,則進入臨界區進行操作,但是cli()的實現(見下面)沒有使用memorytimer->next的值可能會被快取到暫存器中,後面if ((next = timer->next) != NULL)會從暫存器中讀取timer->next的值,如果在if (timer->next)之後,進入臨界區之前,timer->next的值可能被在外部改變,這時肯定會出現異常情況,而且這種情況很難Debug。但是如果cli使用memory,那麼if ((next = timer->next) != NULL)語句會重新從記憶體讀取timer->next的值,而不會從暫存器中取,這樣就不會出現問題了。2.4 版核心中clisti的程式碼如下:

#define __cli() __asm__ __volatile__("cli": : :"memory") 
#define __sti() __asm__ __volatile__("sti": : :"memory") 

  這就是為什麼指令沒有修改記憶體,但是卻使用memory
改描述符的原因,應從指令的上下文去理解為什麼要這樣做。

小結

  本篇我們介紹了AT&T和如何使用GCC的內聯組合,當然本篇僅僅是拋磚引玉,給熟練使用Intel組合語法的同志們更快的熟悉並會使用AT&T組合語法。不過吐槽一句:有一說一,AT&T可讀性遠遠低於Intel的,寫起來也比較麻煩。