在 Linux 程式碼中,經常可以看到在 C 程式碼中,嵌入部分彙編程式碼,這些程式碼要麼是與硬體體系相關的,要麼是對效能有關鍵影響的。
在很久以前,我特別懼怕內嵌彙編程式碼,直到後來把彙編部分的短板補上之後,才徹底終結這種心理。
也許你在工作中,幾乎不會涉及到內嵌彙編程式碼的工作,但是一旦進入到系統的底層,或者需要對時間關鍵場景進行優化,這個時候你的知識儲備就發揮重要作用了!
這篇文章,我們就來詳細聊一聊在 C 語言中,如何通過 asm 關鍵字來嵌入組合語言程式碼,文中的 8 個示例程式碼從簡單到複雜,逐步深入地介紹內聯彙編的關鍵語法規則。
希望這篇文章能夠成為你進階高手路上的墊腳石!
PS:
示例程式碼中使用的是 Linux 系統中 AT&T 彙編語法;
文章中的 8 個示例程式碼,可以在公眾號後臺回覆【426】,即可收到下載地址;
一、基本 asm 格式
gcc 編譯器支援 2 種形式的內聯 asm 程式碼:
基本 asm 格式:不支援運算元;
擴充套件 asm 格式:支援運算元;
1. 語法規則
asm [volatile] ("彙編指令")
所有指令,必須用雙引號包裹起來;
超過一條指令,必須用\n分隔符進行分割,為了排版,一般會加上\t;
多條彙編指令,可以寫在一行,也可以寫在多行;
關鍵字 asm 可以使用 asm 來替換;
volatile 是可選的,編譯器有可能對彙編程式碼進行優化,使用 volatile 關鍵字之後,告訴編譯器不要優化手寫的內聯彙編程式碼。
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,沒有什麼意義。
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 函式的內部,作為區域性變數來試一下。
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 格式
1. 指令格式
asm [volatile] ("彙編指令" : "輸出運算元列表" : "輸入運算元列表" : "改動的暫存器")
格式說明
彙編指令:與基本asm格式相同;
輸出運算元列表:彙編程式碼如何把處理結果傳遞到 C 程式碼中;
輸入運算元列表:C 程式碼如何把資料傳遞給內聯彙編程式碼;
改動的暫存器:告訴編譯器,在內聯彙編程式碼中,我們使用了哪些暫存器;
“改動的暫存器”可以省略,此時最後一個冒號可以不要,但是前面的冒號必須保留,即使輸出/輸入運算元列表為空。
關於“改動的暫存器”再解釋一下:gcc 在編譯 C 程式碼的時候,需要使用一系列暫存器;我們手寫的內聯彙編程式碼中,也使用了一些暫存器。
為了通知編譯器,讓它知道: 在內聯彙編程式碼中有哪些暫存器被我們使用者使用了,可以在這裡列舉出來,這樣的話,gcc 就會避免使用這些列舉出的暫存器
2. 輸出和輸入運算元列表的格式
在系統中,儲存變數的地方就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個修飾符:
+:被修飾的運算元可以讀取,可以寫入;
=:被修飾的運算元只能寫入;
%:被修飾的運算元可以和下一個運算元互換;
&:在行內函數完成之前,可以刪除或者重新使用被修飾的運算元;
語言描述比較抽象,直接看例子!
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 個地方需要注意一下啊:
在內聯彙編程式碼中,沒有宣告“改動的暫存器”列表,也就是說可以省略掉(前面的冒號也不需要);
擴充套件asm格式中,暫存器前面必須寫 2 個%;
程式碼解釋:
"b"(data1),"c"(data2) ==> 把變數 data1 複製到暫存器 %ebx,變數 data2 複製到暫存器 %ecx。這樣,內聯彙編程式碼中,就可以通過這兩個暫存器來操作這兩個數了;
"=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 通過棧空間(-20(%ebp)),複製到暫存器 %eax,再複製到暫存器 %ebx;
把數字 2 通過棧空間(-16(%ebp)),複製到暫存器 %edx,再複製到暫存器 %ecx;
這 2 個操作正是對應了內聯彙編程式碼中的“輸入運算元列表”部分:"b"(data1),"c"(data2)
。
在內聯彙編程式碼之後(#NO_APP 之後),把 %eax 暫存器中的值複製到棧中的 -12(%ebp) 位置,這個位置正是區域性變數 data3 所在的位置,這樣就完成了輸出操作。
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 編號,一直編號到輸入運算元列表中的所有暫存器。
還是看例子比較直接!
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;
}
程式碼說明:
輸出運算元列表"=r"(data3):約束使用字元 r, 也就是說不指定暫存器,由編譯器來選擇使用哪個暫存器來儲存結果,最後複製到區域性變數 data3中;
輸入運算元列表"r"(data1),"r"(data2):約束字元r, 不指定暫存器,由編譯器來選擇使用哪 2 個暫存器來接收區域性變數 data1 和 data2;
輸出運算元列表中只需要一個暫存器,因此在內聯彙編程式碼中的 %0 就代表這個暫存器(即:從 0 開始計數);
輸入運算元列表中有 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 格式還允許給這些佔位符重新命名,也就是給每一個暫存器起一個別名,然後在內聯彙編程式碼中使用別名來操作暫存器。
還是看程式碼!
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;
}
程式碼說明:
輸出運算元列表:給暫存器(gcc 編譯器選擇的)取了一個別名 v3;
輸入運算元列表:給暫存器(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。
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;
}
程式碼說明:
輸出運算元列表 "=m"(data3):直接使用變數 data3 的記憶體地址;
輸入運算元列表 "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 個示例,我們把內聯彙編程式碼中的關鍵語法規則進行了講解,有了這個基礎,就可以在內聯彙編程式碼中編寫更加複雜的指令了。
希望以上內容對您能有所幫助!謝謝!
文章中的 8 個示例程式碼,可以在公眾號後臺回覆【426】,即可收到下載地址。
讓知識流動起來,越分享,越幸運!
星標公眾號,能更快找到我!
1. C語言指標-從底層原理到花式技巧,用圖文和程式碼幫你講解透徹
2. 原來gdb的底層除錯原理這麼簡單
3. 一步步分析-如何用C實現物件導向程式設計
4. 圖文分析:如何利用Google的protobuf,來思考、設計、實現自己的RPC框架
5. 都說軟體架構要分層、分模組,具體應該怎麼做(一)
6. 都說軟體架構要分層、分模組,具體應該怎麼做(二)