從簡單到複雜,搞懂內聯彙編代碼

作者 | 道哥       責編 | 歐陽姝黎

在 Linux 代碼中,經常可以看到在 C 代碼中,嵌入部分彙編代碼,這些代碼要麼是與硬件體系相關的,要麼是對性能有關鍵影響的。

在很久以前,我特別懼怕內嵌彙編代碼,直到後來把彙編部分的短板補上之後,才徹底終結這種心理。

也許你在工作中,幾乎不會涉及到內嵌彙編代碼的工作,但是一旦進入到系統的底層,或者需要對時間關鍵場景進行優化,這個時候你的知識儲備就發揮重要作用了!

這篇文章,我們就來詳細聊一聊在 C 語言中,如何通過 asm 關鍵字來嵌入彙編語言代碼,文中的 8 個示例代碼從簡單到複雜,逐步深入地介紹內聯彙編的關鍵語法規則。

希望這篇文章能夠成爲你進階高手路上的墊腳石!

PS:

  1. 示例代碼中使用的是 Linux 系統中 AT&T 彙編語法;

  2. 文章中的 8 個示例代碼,可以在公衆號後臺回覆【426】,即可收到下載地址;

基本 asm 格式


gcc 編譯器支持 2 種形式的內聯 asm 代碼:

  1. 基本 asm 格式:不支持操作數;

  2. 擴展 asm 格式:支持操作數;

1.1 語法規則

asm [volatile] ("彙編指令")

  1. 所有指令,必須用雙引號包裹起來;

  2. 超過一條指令,必須用 \ n 分隔符進行分割,爲了排版,一般會加上 \ t;

  3. 多條彙編指令,可以寫在一行,也可以寫在多行;

  4. 關鍵字 asm 可以使用 asm 來替換;

  5. volatile 是可選的,編譯器有可能對彙編代碼進行優化,使用 volatile 關鍵字之後,告訴編譯器不要優化手寫的內聯彙編代碼。

1.2 test1.c 插入空指令

#include <stdio.h>
int main()
{
    asm ("nop");
    printf("hello\n");
    asm ("nop\n\tnop\n\t"
	 "nop");
    return 0;
}

注意:C 語言中會自動把兩個連續****的字符串字面量拼接成一個,所以 "nop\n\tnop\n\t" "nop" 這兩個字符串會自動拼接成一個字符串。

生成彙編代碼指令:

gcc -m32 -S -o test1.s test1.c

test1.s 中內容如下 (只貼出了內聯彙編代碼相關部分的代碼):

#APP
# 5 "test1.c" 1
	nop
# 0 "" 2
#NO_APP
	// 這裏是 printf 語句生成的代碼。
#APP
# 7 "test1.c" 1
	nop
	nop
	nop
# 0 "" 2
#NO_APP

可以看到,內聯彙編代碼被兩個註釋 (#APP ... #NO_APP) 包裹起來。在源碼中嵌入了兩個彙編代碼,因此可以看到 gcc 編譯器生成的彙編代碼中包含了這兩部分代碼。

這 2 部分嵌入的彙編代碼都是空指令 nop,沒有什麼意義。

1.3 test2.c 操作全局變量

在 C 代碼中嵌入彙編指令,目的是用來計算,或者執行一定的功能,下面我們就來看一下,如何在內聯彙編指令中,操作全局變量

#include <stdio.h>

int a = 1;
int b = 2;
int c;

int main()
{
    asm volatile ("movl a, %eax\n\t"
        "addl b, %eax\n\t"
        "movl %eax, c");
    printf("c = %d \n", c);
    return 0;
}

關於彙編指令中編譯器的基本知識:

eax, ebx 都是 x86 平臺中的寄存器 (32 位),在基本 asm 格式中,寄存器的前面必須加上百分號 %。

32 位的寄存器 eax 可以當做 16 位來使用 (ax),或者當做 8 位來使用 (ah, al),本文只會按照 32 位來使用。

代碼說明:

movl a, %eax  // 把變量a的值複製到 %eax 寄存器中;

addl b, %eax   // 把變量 b 的值 與 %eax 寄存器中的值 (a) 相加,結果放在 %eax 寄存器中;

movl %eax, c   // 把 %eax 寄存器中的值複製到變量 c 中;

生成彙編代碼指令:

gcc -m32 -S -o test2.s test2.c

test2.s 內容如下 (只貼出與內聯彙編代碼相關部分):

#APP
# 9 "test2.c" 1
	movl a, %eax
	addl b, %eax
	movl %eax, c
# 0 "" 2
#NO_APP

可以看到,在內聯彙編代碼中,可以直接使用全局變量 a, b 的名稱來操作。執行 test2,可以得到正確的結果。

思考一個問題:爲什麼在彙編代碼中,可以使用變量 a, b, c?

查看 test2.s 中內聯彙編代碼之前的部分,可以看到:

	.file	"test2.c"
	.globl	a
	.data
	.align 4
	.type	a, @object
	.size	a, 4
a:
	.long	1
	.globl	b
	.align 4
	.type	b, @object
	.size	b, 4
b:
	.long	2
	.comm	c,4,4

變量 a, b 被 .globl 修飾,c 被 .comm 修飾,相當於是把它們導出爲全局的,所以可以在彙編代碼中使用。

那麼問題來了:如果是一個局部變量,在彙編代代碼中就不會用 .globl 導出,此時在內聯彙編指令中,還可以直接使用嗎?

眼見爲實,我們把這 3 個變量放到 main 函數的內部,作爲局部變量來試一下。

1.4 test3.c 嘗試操作局部變量

#include <stdio.h>
int main()
{
    int a = 1;
    int b = 2;
    int c;

    asm("movl a, %eax\n\t"
        "addl b, %eax\n\t"
        "movl %eax, c");
    printf("c = %d \n", c);
    return 0;
}

生成彙編代碼指令:

gcc -m32 -S -o test3.s test3.c

在 test3.s 中可以看到沒有 a, b, c 的導出符號,a 和 b 沒有其他地方使用,因此直接把他們的數值複製到棧空間中了:

movl	$1, -20(%ebp)
movl	$2, -16(%ebp)

我們來嘗試編譯成可執行程序:

$ gcc -m32 -o test3 test3.c
/tmp/ccuY0TOB.o: In function `main':
test3.c:(.text+0x20): undefined reference to `a'
test3.c:(.text+0x26): undefined reference to `b'
test3.c:(.text+0x2b): undefined reference to `c'
collect2: error: ld returned 1 exit status

編譯報錯:**找不到對 a,b,c 的引用!**那該怎麼辦,才能使用局部變量呢?擴展 asm 格式!

擴展 asm 格式

2.1 指令格式

asm [volatile] ("彙編指令" : "輸出操作數列表" : "輸入操作數列表" : "改動的寄存器")

格式說明

  1. 彙編指令:與基本 asm 格式相同;

  2. 輸出操作數列表:彙編代碼如何把處理結果傳遞到 C 代碼中;

  3. 輸入操作數列表:C 代碼如何把數據傳遞給內聯彙編代碼;

  4. 改動的寄存器:告訴編譯器,在內聯彙編代碼中,我們使用了哪些寄存器;

  5. “改動的寄存器” 可以省略,此時最後一個冒號可以不要,但是前面的冒號必須保留,即使輸出 / 輸入操作數列表爲空。

關於 “改動的寄存器” 再解釋一下:gcc 在編譯 C 代碼的時候,需要使用一系列寄存器;我們手寫的內聯彙編代碼中,也使用了一些寄存器。

爲了通知編譯器,讓它知道: 在內聯彙編代碼中有哪些寄存器被我們用戶使用了,可以在這裏列舉出來,這樣的話,gcc 就會避免使用這些列舉出的寄存器

2.2 輸出和輸入操作數列表的格式

在系統中,存儲變量的地方就 2 個:寄存器和內存。因此,告訴內聯彙編代碼輸出和輸入操作數,其實就是告訴它:

  1. 向哪些寄存器或內存地址輸出結果;

  2. 從哪些寄存器或內存地址讀取輸入數據;

這個過程也要滿足一定的格式:

"[輸出修飾符]約束"(寄存器或內存地址)

(1)約束

就是通過不同的字符,來告訴編譯器使用哪些寄存器,或者內存地址。包括下面這些字符:

a: 使用 eax/ax/al 寄存器;

b: 使用 ebx/bx/bl 寄存器;

c: 使用 ecx/cx/cl 寄存器;

d: 使用 edx/dx/dl 寄存器;

r: 使用任何可用的通用寄存器;

m: 使用變量的內存位置;

先記住這幾個就夠用了,其他的約束選項還有:D, S, q, A, f, t, u 等等,需要的時候再查看文檔。

(2)輸出修飾符

顧名思義,它使用來修飾輸出的,對輸出寄存器或內存地址提供額外的說明,包括下面 4 個修飾符:

  1. +:被修飾的操作數可以讀取,可以寫入;

  2. =:被修飾的操作數只能寫入;

  3. %:被修飾的操作數可以和下一個操作數互換;

  4. &:在內聯函數完成之前,可以刪除或者重新使用被修飾的操作數;

語言描述比較抽象,直接看例子!

2.3 test4.c 通過寄存器操作局部變量

#include <stdio.h>

int main()
{
    int data1 = 1;
    int data2 = 2;
    int data3;

    asm("movl %%ebx, %%eax\n\t"
        "addl %%ecx, %%eax"
        : "=a"(data3)
        : "b"(data1),"c"(data2));

    printf("data3 = %d \n", data3);
    return 0;
}

有 2 個地方需要注意一下啊:

  1. 在內聯彙編代碼中,沒有聲明 “改動的寄存器” 列表,也就是說可以省略掉(前面的冒號也不需要);

  2. 擴展 asm 格式中,寄存器前面必須寫 2 個 %;

代碼解釋:

  1. "b"(data1),"c"(data2) ==> 把變量 data1 複製到寄存器 %ebx,變量 data2 複製到寄存器 %ecx。這樣,內聯彙編代碼中,就可以通過這兩個寄存器來操作這兩個數了;

  2. "=a"(data3) ==> 把處理結果放在寄存器 %eax 中,然後複製給變量 data3。前面的修飾符等號意思是:會寫入往 %eax 中寫入數據,不會從中讀取數據;

通過上面的這種格式,內聯彙編代碼中,就可以使用指定的寄存器來操作局部變量了,稍後將會看到局部變量是如何從經過棧空間,複製到寄存器中的。

生成彙編代碼指令:

gcc -m32 -S -o test4.s test4.c

彙編代碼 test4.s 如下:

	movl	$1, -20(%ebp)
	movl	$2, -16(%ebp)
	movl	-20(%ebp), %eax
	movl	-16(%ebp), %edx
	movl	%eax, %ebx
	movl	%edx, %ecx
#APP
# 10 "test4.c" 1
	movl %ebx, %eax
	addl %ecx, %eax
# 0 "" 2
#NO_APP
    movl	%eax, -12(%ebp)

可以看到,在進入手寫的內聯彙編代碼之前:

  1. 把數字 1 通過棧空間 (-20(%ebp)),複製到寄存器 %eax,再複製到寄存器 %ebx;

  2. 把數字 2 通過棧空間 (-16(%ebp)),複製到寄存器 %edx,再複製到寄存器 %ecx;

這 2 個操作正是對應了內聯彙編代碼中的 “輸入操作數列表” 部分:"b"(data1),"c"(data2)。

在內聯彙編代碼之後 (#NO_APP 之後),把 %eax 寄存器中的值複製到棧中的 -12(%ebp) 位置,這個位置正是局部變量 data3 所在的位置,這樣就完成了輸出操作。

2.4 test5.c 聲明改動的寄存器

在 test4.c 中,我們沒有聲明改動的寄存器,所以編譯器可以任意選擇使用哪些寄存器。從生成的彙編代碼 test4.s 中可以看到,gcc 使用了  %edx 寄存器。

那麼我們來測試一下:告訴 gcc 不要使用 %edx 寄存器。

#include <stdio.h>
int main()
{
    int data1 = 1;
    int data2 = 2;
    int data3;

    asm("movl %%ebx, %%eax\n\t"
        "addl %%ecx, %%eax"
        : "=a"(data3)
        : "b"(data1),"c"(data2)
        : "%edx");

    printf("data3 = %d \n", data3);
    return 0;
}

代碼中,asm 指令最後部分 "%edx" ,就是用來告訴 gcc 編譯器:在內聯彙編代碼中,我們會使用到 %edx 寄存器,你就不要用它了。

生成彙編代碼指令:

gcc -m32 -S -o test5.s test5.c

來看一下生成的彙編代碼 test5.s:

    movl	$1, -20(%ebp)
	movl	$2, -16(%ebp)
	movl	-20(%ebp), %eax
	movl	-16(%ebp), %ecx
	movl	%eax, %ebx
#APP
# 10 "test5.c" 1
	movl %ebx, %eax
	addl %ecx, %eax
# 0 "" 2
#NO_APP
	movl	%eax, -12(%ebp)

可以看到,在內聯彙編代碼之前,gcc 沒有選擇使用寄存器 %edx。

使用佔位符來代替寄存器名稱

在上面的示例中,只使用了 2 個寄存器來操作 2 個局部變量,如果操作數有很多,那麼在內聯彙編代碼中去寫每個寄存器的名稱,就顯得很不方便。

因此,擴展 asm 格式爲我們提供了另一種偷懶的方法,來使用輸出和輸入操作數列表中的寄存器:佔位符!

佔位符有點類似於批處理腳本中,利用 1,2... 來引用輸入參數一樣,內聯彙編代碼中的佔位符,從輸出操作數列表中的寄存器開始從 0 編號,一直編號到輸入操作數列表中的所有寄存器。

還是看例子比較直接!

3.1 test6.c 使用佔位符代替寄存器

#include <stdio.h>
int main()
{
    int data1 = 1;
    int data2 = 2;
    int data3;

    asm("addl %1, %2\n\t"
        "movl %2, %0"
        : "=r"(data3)
        : "r"(data1),"r"(data2));

    printf("data3 = %d \n", data3);
    return 0;
}

代碼說明:

  1. 輸出操作數列表 "=r"(data3):約束使用字符 r, 也就是說不指定寄存器,由編譯器來選擇使用哪個寄存器來存儲結果,最後複製到局部變量 data3 中;

  2. 輸入操作數列表 "r"(data1),"r"(data2):約束字符 r, 不指定寄存器,由編譯器來選擇使用哪 2 個寄存器來接收局部變量 data1 和 data2;

  3. 輸出操作數列表中只需要一個寄存器,因此在內聯彙編代碼中的 %0 就代表這個寄存器 (即:從 0 開始計數);

  4. 輸入操作數列表中有 2 個寄存器,因此在內聯彙編代碼中的 %1 和 %2 就代表這 2 個寄存器 (即:從輸出操作數列表的最後一個寄存器開始順序計數);

生成彙編代碼指令:

gcc -m32 -S -o test6.s test6.c

彙編代碼如下 test6.s:

	movl	$1, -20(%ebp)
	movl	$2, -16(%ebp)
	movl	-20(%ebp), %eax
	movl	-16(%ebp), %edx
#APP
# 10 "test6.c" 1
	addl %eax, %edx
	movl %edx, %eax
# 0 "" 2
#NO_APP
	movl	%eax, -12(%ebp)

可以看到,gcc 編譯器選擇了 %eax 來存儲局部變量 data1,%edx 來存儲局部變量 data2 ,然後操作結果也存儲在 %eax 寄存器中。

是不是感覺這樣操作就方便多了?不用我們來指定使用哪些寄存器,直接交給編譯器來選擇。

在內聯彙編代碼中,使用 %0、%1 、%2 這樣的佔位符來使用寄存器。

別急,如果您覺得使用編號還是麻煩,容易出錯,還有另一個更方便的操作:擴展 asm 格式還允許給這些佔位符重命名,也就是給每一個寄存器起一個別名,然後在內聯彙編代碼中使用別名來操作寄存器。

還是看代碼!

3.2 test7.c 給寄存器起別名

#include <stdio.h>
int main()
{
    int data1 = 1;
    int data2 = 2;
    int data3;

    asm("addl %[v1], %[v2]\n\t"
        "movl %[v2], %[v3]"
        : [v3]"=r"(data3)
        : [v1]"r"(data1),[v2]"r"(data2));

    printf("data3 = %d \n", data3);
    return 0;
}

代碼說明:

  1. 輸出操作數列表:給寄存器 (gcc 編譯器選擇的) 取了一個別名 v3;

  2. 輸入操作數列表:給寄存器 (gcc 編譯器選擇的) 取了一個別名 v1 和 v2;

起立別名之後,在內聯彙編代碼中就可以直接使用這些別名 (%[v1], %[v2],  %[v3]) 來操作數據了。

生成彙編代碼指令:

gcc -m32 -S -o test7.s test7.c

再來看一下生成的彙編代碼 test7.s:

	movl	$1, -20(%ebp)
	movl	$2, -16(%ebp)
	movl	-20(%ebp), %eax
	movl	-16(%ebp), %edx
#APP
# 10 "test7.c" 1
	addl %eax, %edx
	movl %edx, %eax
# 0 "" 2
#NO_APP
	movl	%eax, -12(%ebp)

這部分的彙編代碼與 test6.s 中完全一樣!

使用內存位置

在以上的示例中,輸出操作數列表和輸入操作數列表部分,使用的都是寄存器 (約束字符:a, b, c, d, r 等等)。

我們可以指定使用哪個寄存器,也可以交給編譯器來選擇使用哪些寄存器,通過寄存器來操作數據,速度會更快一些。

如果我們願意的話,也可以直接使用變量的內存地址來操作變量,此時就需要使用約束字符 m。

4.1 test8.c 使用內存地址來操作數據

#include <stdio.h>
int main()
{
    int data1 = 1;
    int data2 = 2;
    int data3;

    asm("movl %1, %%eax\n\t"
        "addl %2, %%eax\n\t"
        "movl %%eax, %0"
        : "=m"(data3)
        : "m"(data1),"m"(data2));

    printf("data3 = %d \n", data3);
    return 0;
}

代碼說明:

  1. 輸出操作數列表 "=m"(data3):直接使用變量 data3 的內存地址;

  2. 輸入操作數列表 "m"(data1),"m"(data2):直接使用變量 data1, data2 的內存地址;

在內聯彙編代碼中,因爲需要進行相加計算,因此需要使用一個寄存器 (%eax),計算這個環節是肯定需要寄存器的。

在操作那些內存地址中的數據時,使用的仍然是按順序編號的佔位符。

生成彙編代碼指令:

gcc -m32 -S -o test8.s test8.c

生成的彙編代碼如下 test8.s:

	movl	$1, -24(%ebp)
	movl	$2, -20(%ebp)
#APP
# 10 "test8.c" 1
	movl -24(%ebp), %eax
	addl -20(%ebp), %eax
	movl %eax, -16(%ebp)
# 0 "" 2
#NO_APP
	movl	-16(%ebp), %eax

可以看到:在進入內聯彙編代碼之前,把 data1 和 data2 的值放在了棧中,然後直接把棧中的數據與寄存器 %eax 進行操作,最後再把操作結果 (%eax),複製到棧中 data3 的位置 (-16(%ebp))。

總結

通過以上 8 個示例,我們把內聯彙編代碼中的關鍵語法規則進行了講解,有了這個基礎,就可以在內聯彙編代碼中編寫更加複雜的指令了。

希望以上內容對您能有所幫助!謝謝!

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/o_urIb46wCGIp56uHXctQw