GNU C 允許在 C 代碼中嵌入匯編代碼,這種特性被稱為內聯匯編。使用內聯匯編可以同時發揮 C 和匯編的強大能力。
本文介紹 GCC 的內聯匯編拓展,Clang 編譯器兼容大部分 GCC 語言拓展,因此 GNU C 的內聯匯編特性大部分在 Clang 中工作正常。
本文實驗環境如下:
Linux Friday 5.8.17-300.fc33.x86_64 #1 SMP Thu Oct 29 15:55:40 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
gcc (GCC) 10.2.1 20201016 (Red Hat 10.2.1-6)
使用 64 位 AT&T 風格 x86 匯編,為了和編譯器自動生成的注釋區分開,我添加的注釋使用##
風格。
基本內聯匯編
基本內聯匯編是 GCC 對內聯匯編最簡陋的支持,它實際上已經沒有任何使用價值了,介紹它只是為了說明使用內聯匯編的基本原理和問題。
基本內聯匯編的語法如下:
asm asm_qualifiers ( AssembleInstructions )
asm_qulifiers
包括以下兩個修飾符:
- volatile: 指示編譯器不要對 asm 代碼段進行優化
- inline: 指示編譯器盡可能小的假設 asm 指令的大小
這兩個修飾符的意義先不用深究,本文會逐步介紹它們的作用。
asm
不是 ISO C 中的關鍵字,如果我們開啟了 -std=c99 等啟用 ISO C 的編譯選項,代碼將無法成功編譯。然而,內聯匯編對於許多 ISO C 程序是必須的,GCC 通過 _asm_ 給程序員開了個后門。使用 __asm__ 替代 asm 可以讓程序作為 ISO C 程序成功編譯。volatile 和 inline 也有加 __ 的版本。
AssembleInstructions
是我們手寫的匯編指令。基本內聯匯編的例子如下:
__asm__ __valatile__(
"movq %rax, %rdi \n\t"
"movq %rbx, %rsi \n\t"
);
編譯器不解析 asm 塊中的指令,直接把它們插入到生成的匯編代碼中,剩下的任務有匯編器完成。這個過程有些類似於宏。為了避免我們手寫的匯編代碼擠在一起,導致指令解析錯誤,通常在每一條指令后面都加上\n\t
獲得合適的格式。
編譯器不解析 asm 塊中的指令的一個推論是:GCC 對我們插入的指令毫不知情。這相當於我們人為地干涉了 GCC 自動的代碼生成,如果我們處理不當,很可能導致最終生成的代碼是錯誤的。考慮以下代碼段:
#include <stdio.h>
int
main()
{
unsigned long long sum = 0;
for (size_t i = 1; i <= 10; ++i)
{
sum += i;
}
printf("sum: %llu\n", sum);
return 0;
}
--------------------------------------
output
--------------------------------------
sum: 55
這段代碼很簡單,只是簡單的整數求和。反匯編結果如下:
.file "basic-asm.c"
.text
.section .rodata
.LC0:
.string "sum: %llu\n"
.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 ## 分配局部變量
subq $16, %rsp
movq $0, -8(%rbp) ## sum
movq $1, -16(%rbp) ## i
jmp .L2
.L3: ## for body
movq -16(%rbp), %rax ## sum += i
addq %rax, -8(%rbp)
addq $1, -16(%rbp) ## ++i
.L2:
cmpq $10, -16(%rbp) ## for 條件判斷
jbe .L3
movq -8(%rbp), %rax ## 傳遞參數給 printf
movq %rax, %rsi ## x86-64 通常可以使用 6 個寄存器傳遞參數
movl $.LC0, %edi ## 從做往右依次為 %rdi, %rsi, %rdx, %rcx, %r8, %r9
movl $0, %eax ## 更多的參數通過堆棧傳遞
call printf
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 10.2.1 20201016 (Red Hat 10.2.1-6)"
.section .note.GNU-stack,"",@progbits
可以看到在 for body 中,變量i
被分配到-16(%rbp)
中,我們在sum += i
前插入這段代碼來驗證基本內聯匯編的處理過程。
__asm__ __volatile__(
"movq $100, -16(%rbp)\n\t"
);
反匯編結果如下:
.file "basic-asm.c"
.text
.section .rodata
.LC0:
.string "sum: %llu\n"
.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
subq $16, %rsp
movq $0, -8(%rbp)
movq $1, -16(%rbp)
jmp .L2
.L3:
#APP ## 可以看到編譯器直接將我們的指令插入到了匯編文件中
# 9 "basic-asm.c" 1
movq $100, -16(%rbp)
# 0 "" 2
#NO_APP
movq -16(%rbp), %rax
addq %rax, -8(%rbp)
addq $1, -16(%rbp)
.L2:
cmpq $10, -16(%rbp)
jbe .L3
movq -8(%rbp), %rax
movq %rax, %rsi
movl $.LC0, %edi
movl $0, %eax
call printf
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 10.2.1 20201016 (Red Hat 10.2.1-6)"
.section .note.GNU-stack,"",@progbits
我們通過基本內聯匯編將變量i
的值修改為100
,因此程序會直接退出for
循環,運行結果為:
sum = 100
基本內聯匯編中沒有程序員和編譯器的交流,程序員不知道編譯器將生成怎樣的代碼,編譯器也不知道程序員希望它怎樣生成代碼為內聯匯編的結果幾乎不可控制,因此內聯匯編沒有任何實用價值。
拓展內聯匯編
從上面基本內聯匯編的介紹可以發現,生成正確的代碼需要程序員和編譯器的通力合作,只有充分的交流才能確保結果的正確。拓展內聯匯編很好的實現了程序員和編譯器的交流,程序員不再打亂編譯器的代碼生成,而是提供充分信息來輔助、微調編譯器的代碼生成。
基本原理和思路
在編譯器生成代碼的過程是一個動態的過程,變量可能被分配到寄存器(如 rax)中,也可能被分配到內存中;一個整型字面值可能是 32 位立即數,也可能是 64 位大立即數;可能使用 rax 寄存器,也可能使用 rbx 寄存器。程序員任何擅自的篡改都會導致生成錯誤的代碼。
拓展內聯匯編從程序員處獲取信息,並根據獲取的信息調整自己生成代碼的行為。比如,程序員要求將某個變量分配到 rax 寄存器中,編譯器就會將該變量分配在 rax 中,並調整其他部分的代碼,使程序員的要求不影響正確代碼的生成。
因此,使用拓展內聯匯編的基本思路就是:提供盡可能多的信息給編譯器。程序員提供的信息越多,出錯的概率就越小。除了提供信息,程序員還應該清楚地明白 GCC 對內聯匯編做的假設和限制。
語法結構
拓展內聯匯編的語法結構如下:
asm asm-qualifiers ( AssemblerTemplate
: OutputOperands
[ : InputOperands
[ : Clobbers ] ])
asm asm-qualifiers ( AssemblerTemplate
:
: InputOperands
: Clobbers
: GotoLabels)
asm
、asm-qualifiers
和基本內聯匯編基本相同。基本內聯匯編提供了在匯編中跳轉到 C Label 的能力,因此asm_qualifiers
中增加了 goto。goto 修飾符只能用於第二種形式中。
AssemblerTemplate
是程序員手寫的匯編指令,但是增加了幾種更方便的表示方法。
可以將拓展內聯匯編 asm 塊看成一個黑盒,我們給一些變量、表達式作為輸入,指定一些變量作為輸出,指明我們指令的副作用,運行后這個黑盒會按照我們的要求將結果輸出到輸出變量中。
OutputOperands
表示輸出變量,InputOperands
表示輸入變量,Clobbers
表示副作用(asm 塊中可能修改的寄存器、內存)等。
拓展內聯匯編語法結構比較復雜,沒法一下講清楚,先給出一個例子一覽全貌。
// 測試 val 的第 bit 位是否為 1
int
bittest(unsigned long long val, unsigned long long bit)
{
int ret;
__asm__ (
"movl $0, %0 \n\t" // %0 代表 ret(第 0 個輸入/輸出)
"btq %2, %1 \n\t" // %1 代表 val(第 1 個輸入/輸出),%2 代表 bit。btq 指令將 val 的第 bit 位存入 CF 中
"jnc %=f \n\t" // 若 CF 標記為 1,將 ret 設置為 1
"movl $1, %0 \n\t"
"%=: movl $0, %0 \n\t"
: "=&rm" (ret) // ret 為輸出變量。該變量可以被分配到通用寄存器或內存中中。不允許該輸出變量與輸入重疊。
: "r" (val), "r" (bit) // val 和 bit 是輸入變量,分配到任意通用寄存器中
: "cc", "memory" // asm 塊可能讀取、修改條件寄存器和內存
);
return ret;
}
這個例子使用到了拓展內聯匯編的絕大多數功能。
匯編方言
GCC 支持多種匯編方言,x86 匯編默認使用 AT&T 語法,但也支持 Intel 語法。GCC 生成的匯編指令可以通過編譯選項 -masm=dialect 切換。如果使用 Intel 語法,那么 asm 塊中的 AT&T 語法就無法正確編譯,反之亦然。可以通過{ dialect 0 | dialect 1 | dialect 2 ... }
來兼容多種方言。這里使用bt
指令(bit test)來說明使用方法。
"bt{l %[Offset],%[Base] | %[Base],%[Offset]}; jc %l2"
編譯器根據編譯選項 -masm 展開后為:
"btl %[Offset],%[Base] ; jc %l2" /* att dialect */
"bt %[Base],%[Offset]; jc %l2" /* intel dialect */
`%l2代表 C Label。
特殊字符串
內聯匯編中使用%N
表示第N
個輸入/輸出(從 0 開始數),使用{
、}
和|
表示不同方言。AT&T 語法中寄存器要加%
前綴,因此%
需要被跳脫。拓展內聯匯編中,%
要寫成%%
,如%%rax
。
GCC 還特別提供了%=
生成在所有 asm 塊中唯一的數字,這個功能用於生成不重復的 local label 供跳轉指令使用。我們最開始的bittest()
就使用了這個功能。
介紹到這里,實際上就說完了AssemblerTemplate
的全部內容,開始介紹輸出列表、輸入列表、修改列表的細節。
輸出列表
語法結構如下:
[ [asmSymbolicName] ] constraint (cvariablename)
-
asmSymboicName
我們可以給
cvariablename
起一個只能在該 asm 中使用的別名,並通過%[asmSymbolicName]
訪問它。比如: [value] "=m" (i)
,可以在 asm 塊中通過%[value]
訪問它。 -
constraint(限制)和 modifier(修飾語)
constraint 在拓展匯編中至關重要,它和 modifier 是拓展內聯匯編和基本內聯匯編的根本差異之處。它們的作用都是給編譯器提供信息,不同之處在於:constraint 提供輸入/輸出變量位置的信息(如分配到寄存器還是內存中),modifier 只能用於輸出,提供輸出變量的行為信息(如只讀/讀寫,是否可以在指令中交換次序)。
-
cvariablename
cviriablename 是一個 C 變量,因為它是 asm 的輸出變量,必須要可以被修改,因此必須是左值。
因為 modifier 只能用於輸出變量上,因此只先介紹 modifier。
constraint 用來表示輸入/輸入變量的位置,既有通用的(如任意通用寄存器,不同平台對應不同寄存器),也有特定平台的拓展(如 x86 中的 a,對應寄存器 (r|e)ax),使用時查閱 GCC 手冊即可。本文只介紹幾個常用的通用、x86、RISC-V constraint。
modifier 有以下四個:
-
=
: 操作對象是只寫的。這意味着操作對象中的值可以被丟棄,並且寫入新數據。 -
+
: 操作對象是讀寫的。這以為着可以在 asm 中合法的讀取操作對象,並且 C 變量在進入 asm 塊時就已經加載到對應的位置中。 -
&
: 指示該操作對象一定不能和輸入重疊。 -
%
: 表示該操作對象可以交換次序。這個 modifer 我不是很理解,似乎沒有大的用處。
&
比較難以理解,單獨解釋。GCC 假設匯編代碼在產生輸出前會消耗掉輸入,可能會將不相關的輸出/輸入分配到同一個寄存器中。實際上輸入和輸出的次序不一定滿足 GCC 的假設,這時就會出錯。舉兩個例子說明這個問題。
細心的讀者應該會注意到在testbit()
函數中,輸出ret
被分配到寄存器或內存中,constraint 中使用了&
。假如我們刪除掉&
會怎么樣呢?
// 測試程序
// bittest()刪除 &
int
main()
{
if (bittest(1, 0))
printf("0\n");
else
printf("1\n");
return 0;
}
編譯后運行結果為 1。這很顯然是錯的。反匯編bittest()
,關鍵部分代碼如下:
movq %rdi, -24(%rbp) ## 第一個參數(val)
movq %rsi, -32(%rbp) ## 第二個參數(bit)
movq -24(%rbp), %rax ## 變量 val 分配到 rax 中
movq -32(%rbp), %rdx
#APP
# 23 "bt.c" 1
movl $0, %eax ## 變量 ret 也被分配到 rax 中
btq %rdx, %rax
jnc 15f ## 15 是 %= 生成的
movl $1, %eax
15:
# 0 "" 2
#NO_APP
可以發現,錯誤的根源在於我們指示 GCC 將變量ret
和val
分配到通用寄存器中,GCC 假設輸入在產生輸出前就被消耗(輸入/輸入分配到同一個寄存器中不會出錯),因此將ret
和val
都分配到了寄存器 rax 中。在執行 bt 指令前,我們將返回值ret
設置成0
,覆蓋了val
,導致錯誤。
還有一種可能的輸入/輸出重疊的情況:輸出 A 被分配到寄存器中,輸出 B 被分配到內存中,訪問內存 B 時錯誤地使用了輸出 A 被分配到的寄存器。訪問內存中的 B 很可能需要使用到寄存器(如內存尋址),GCC 這時將訪問 B 過程中使用到的寄存器視為輸入,根據“輸入在產生輸出之前就被消耗掉了”的假設,GCC 很可能會在訪問 B 的過程中使用 A 對應的寄存器(假設在訪問完 B 后才寫入 A,這時情況正常)。實際情況可能不符合 GCC 的假設,用戶可能在訪問 B 之前寫入 A,在訪問 B 時使用的寄存器中的值(這個值錯誤地變成了 A 的值)可能是錯誤的。
陷阱:
- GCC不保證在進入 asm 塊時,輸出變量已被加載到 constraint 指定的位置中。如果需要 GCC 在進入 asm 塊時將變量加載到 constraint 指定的位置中,請使用
+
。 - constraint 是指定變量在 asm 塊中的位置,而不是在函數中的位置。變量
val
的 constraint 為 r 說明它在進入/退出 asm 塊時被分配到通用寄存器中,但在進入 asm 塊前它的位置是不確定的。如果要控制 asm 塊外變量被分配的位置,可以使用 GNU C 的寄存器變量拓展。
輸入列表
語法結構如下:
[ [asmSymbolicName] ] constraint (cexpression)
asmSymbolicName
和constraint
和輸出列表一樣。
輸入列表中不可以使用=
和+
這兩個 constraint。
因為輸入是只讀的,因此不要求輸入是左值,任何 C 表達式都可以作為輸入。
GCC 假設輸入是只讀的,在退出 asm 塊時輸入的值不被改變。我們不能通過修改列表來告知 GCC 我們將修改一個輸入。如果我們確實需要修改輸入,有兩種辦法:
- 使用可讀寫的輸出替換輸入。
- 將輸入綁定到一個不使用的輸出上。
第一種方法的原理顯而易見,加上+
限制的輸出在進入 asm 塊時就被分配到對應的位置中,除了可以寫外,跟輸入變量沒有區別。
第二種方法是變通方法,當我們將輸入綁定(放入同一個位置)到一個不使用的輸出時,我們修改輸入就相當於生成輸出,繞開了 GCC 不修改輸入的規定。使用這種方法要小心 GCC 發現輸出變量未使用,將 asm 優化掉,需要添加 volatile 修飾符。
我個人建議使用第一種方法,雖然第一種方法在語意上不太合適,但能夠實現我們的目的,並且比較好理解。
修改列表
在使用內聯匯編時,我們寫的匯編代碼可能會產生一些副作用,GCC 必須清楚地知道這些副作用才能調整自己的行為,生成正確的代碼。
舉一個可能導致生成錯誤代碼的例子。我們使用字符串復制指令movsb
將一段內存復制到另一個地址,movsb
會讀取、修改寄存器 rsi 和 rdi 的值,如果我們不告訴 GCC 我們寫的匯編代碼有“修改 rsi 和 rdi”的副作用,GCC 會認為 rsi 和 rdi 沒有被修改,生成錯誤的代碼。
在使用內聯匯編時我們必須提供給 GCC 盡可能多的信息,匯編代碼可能有哪些副作用(修改了哪些寄存器,是否訪問內存)是使用內聯匯編時需要始終考慮的問題。
修改列表(Clobeerrs)的語法結構如下:
: "Clobber" (cexpression)
Clobber
有以下幾個:
cc
: 條件(標准)寄存器。如 x86 的 EFLAGS 寄存器。memory
: 讀/寫內存。為了確保讀取到正確的值,GCC 可能會在進入 asm 塊前將某些寄存器寫入內存中,也可能在必要的時候將內存中存儲的寄存器值重新加載到寄存器中。- 寄存器名:如 x86 平台的 rax 等,直接寫原名即可。
constraint
這里介紹幾個常用的 constraint:
r
: 通用寄存器i
: 在匯編時或運行時可以確定的立即數n
: 可以直接確定的立即數,如整形字面量g
: 任意通用寄存器、內存、立即數
這些是 GCC 提供的通用 constraint,在不同處理器上有不同的實現。比如 x86 上的通用寄存器是 rax、r8 等,在 RISC-V 上是 x0 到 x31。
有些指令,如 x86 常用的mov
指令,兩個操作數既可以都是寄存器、也可以一個是寄存器一個是內存地址。這時就有三種組合,我們可以將 constraint 可以分為多個候選組合傳遞給 GCC,如:
: "m,r,r" (output)
: "r,m,r" (input)
constraint 通過,
分組,並且一一對應。上面的代碼段相當於以下三個輸出/輸入列表組合在一起:
: "m" (output)
: "r" (input)
------------------
: "r" (output)
: "r" (input)
------------------
: "r" (output)
: "m" (input)
一個輸入/輸出可以有多個constraint,GCC 會自動選擇其中最好的一個。如:"rm" (output)
表示output
既可以分配到通用寄存器中,也可以分配到內存中,由 GCC 自己選擇。
多 constraint 和分組的 constraint 是兩碼事。還拿 x86 上的mov
指令舉例,mov
指令不允許兩個操作數都是內存地址,因此我們不能寫出這樣的輸出/輸入列表:
: "rm" (output)
: "rm" (input)
這個列表表示output
和input
都可以分配到內存或通用寄存器中,可能出現兩變量同時被分配到內存中的情況,這是 mov 指令就會出錯。
goto 列表
GCC 提供了在內聯匯編中使用 C Label 的功能,但這個功能有限制,這能在 asm 塊沒有輸出時使用。C Label 在內聯匯編中直接當成匯編的 label 使用即可,唯一要注意的是在內聯匯編中 C label 的命名。
在內聯匯編中使用%lN
來訪問 C label,因為%l
在內聯匯編中已經有了特殊的意義(x86 平台的修飾符,表示寄存器的低位子寄存器,如 rax 中的 eax),因此 GCC 將 C label 對應的N
設置為輸入輸出總數加 goto 列表中 C label 的位置。
asm goto (
"btl %1, %0\n\t"
"jc %l2"
: /* No outputs. */
: "r" (p1), "r" (p2)
: "cc"
: carry);
return 0;
carry:
return 1;
標簽carry
之前有兩個輸入,carry
在 goto 列表的第 0 位,因此使用%l2
引用carry
。
雜項
標記寄存器的使用
在某些平台,比如 x86,存在標記寄存器。GCC 允許將標記寄存器中的某個標准輸出到 C 變量中,這個變量必須是標量(整形、指針等)或者是布爾類型。當 GCC 支持這個特性時,會與定義宏__GCC_ASM_FLAG_OUTPUTS__
。
標記輸出約束為@cccond
,其中cond
為指令集定義的標准條件,在 x86 平台上即條件跳轉的后綴。
因為訪問的是標記寄存器中的標記(很可能是一個比特),因此不能在 asm 塊中通過%0
等形式顯示訪問,也不能給多個約束。
使用標記寄存器,可以簡化前面的testbit()
:
int
bittest(unsigned long long val, unsigned long long bit)
{
int ret;
__asm__ __volatile__(
"btq %2, %1 \n\t"
: "=@ccc" (ret)
: "r" (val), "r" (bit)
: "cc", "memory"
);
return ret;
}
asm 的大小
為了生成正確的代碼,一些平台需要 GCC 跟蹤每一條指令的長度。但是內聯匯編由編譯器完成,指令的長度卻只有匯編器知道。
GCC 使用比較保守的辦法,假設每一條指令的長度都是該平台支持的最長指令長度。asm 塊中所有語句分割符(如;
等)和換行符都作為指令結束的標准。
通常,這種辦法都是正確的,但在遇到匯編器的偽指令和宏時可能會產生意想不到的錯誤。匯編器的偽指令和宏最終會被匯編器轉換為多條指令,但是在編譯器眼中它只是一條指令,從而產生誤判,生成錯誤的代碼。
因此,盡量不要在 asm 塊中使用偽指令和宏。
X86 特定
x86 平台有些些專門的 constraint ,如:
a
: ax 寄存器,在 32 位處理器上是 eax,在 64 位處理器上是 raxb
(bx),c
(cx),d
(dx),S
(si),D
(di): 類似與a
q
: 整數寄存器。32 位上是a
、b
、c
、d
,64 位增加了 r8 ~ r15 8 個寄存器
x86 中一個大寄存器可以重疊地分為多個小寄存器,比如 rax 第 32 位可以作為 eax 單獨使用,eax 低 16 位又可以作為 ax 單獨使用,ax 高 8 位可以做為 ah 單獨使用、低 8 位可以作為 al 單獨使用。針對這種情況,GCC 在 x86 平台專門提供了一些修飾符來調整生成的匯編代碼中寄存器、內存地址等的格式。
uint16_t num;
asm volatile ("xchg %h0, %b0" : "+a" (num) );
這段代碼將num
分配到 ax 寄存器中,在64 位處理器上是 rax,32 位處理器上是 eax,但程序員只需要訪問它的子寄存器 ah 和 al。h
表示訪問 ah(bh、ch 等),b
表示訪問 al(bl、cl 等)。因此內聯匯編指令插入到匯編代碼中時變成了xchg ah, al
,而不是原始的xchg rax, rax
或xchg eax, eax
。
完整的 GCC x86 修飾符可以在手冊中找到。
RISC-V 特定
GCC 對 RISC-V 平台提供了以下額外的 constrait。
f
: 浮點寄存器(如果存在的花)I
: 12 比特立即數J
: 整數 0K
: 用於 CSR 訪問指令的 5 比特的無符號立即數A
: 存儲在通用寄存器中的地址
GCC 沒有提供對 RISC-V 特定寄存器的 constrait,如果我們需要將變量分配到特定的寄存器,只能通過分配寄存器變量的方式曲線救國。
寄存器變量
寄存器變量是 ISO C 的特性,語法為:
register type cvariable
如:
register size_t i;
for (i = 0; i < 100; ++i)
/* do something */
ISO C 中的寄存器變量特性只是“建議”將某個變量分配到寄存器中,最終是否分配到寄存器中由編譯器決定,並且沒有提供指定寄存器的語法,分配到哪個寄存器也由編譯器決定。
GCC 拓展了 ISO C 中寄存器變量的特性,提供了指定寄存器的語法,只要分配的寄存器合法就會分配成功。
語法結構如下:
register type cvariable asm ("register")
如:
register unsigned long long i asm ("rax"); // x86
該代碼段將變量i
分配到寄存器 rax 中。
因為變量在寄存器中,因此寄存器變量的使用有以下限制:
- 全局寄存器變量不能有初始值不能初始化。可執行文件無法給寄存器提供初值。
- 不能使用 volatile 等修飾符。
- 不能取地址。
寄存器變量僅僅是指示編譯器將變量放置在特定的寄存器中,不意味這在該變量的整個生命周期中該變量都獨占該寄存器,該寄存器很可能會被分配為別的變量使用。程序員只可以假設在聲明時變量在指定的寄存器中,之后的語句中不能假設該變量仍在該寄存器中,生成的任何指令都可能修改該寄存器的值。
既可以全局變量也可以是局部變量。由於上面提到的限制,將全局變量聲明為寄存器變量幾乎總是錯誤的做法,很可能破壞 C ABI,對性能也未必有大的提升,僅在極有限的場景下使用全局寄存器變量,因此不解釋全局寄存器變量。
當 GCC 沒有提供將變量分配到特定寄存器中的 constraint 時,我們將該變量聲明為局部寄存器變量,並將其分配到特定的寄存器中。然后緊貼着寫內聯匯編,分配到寄存器中就使用r
constrait。
以下代碼封裝了 RISC-V ecall。
void
sbi_console_putchar(int ch)
{
register int a0 asm ("x10") = ch; // 變量 a0 分配到寄存器 x10 中
register uint64_t a6 asm ("x16") = 0; // 變量 a6 分配到寄存器 x16 中
register uint64_t a7 asm ("x17") = 1; // 變量 a7 分配到寄存器 x17 中
__asm__ __volatile__ (
"ecall \n\t"
: /* empty output list */
: "r" (a0), "r" (a6), "r" (a7)
: "memory"
);
}
陷阱:
- 小心在定義了寄存器變量后,使用寄存器變量前,某些語句修改的寄存器的值
- 局部寄存器變量只能配合內聯匯編使用,或者按照標准 C ABI 在函數之間傳遞。其他所有用法都是未定義的,工作正常僅僅是運氣。
總結
准則:
- 盡可能不要使用宏和偽指令
- 使用
=
constraint 時不要假設在進入 asm 塊時,變量已被分配到寄存器中 - 不要修改輸入變量,除非它和輸出相關聯
- 盡可能考慮全面,盡可能提供多的信息
- 小心輸出輸入重疊,使用
&
constraint 解決這個問題 - 較寬泛的 constraint 可以給 GCC 更大自由,生成更好的代碼,但程序員要考慮的事情也變多了
- 小心打字錯誤,如將
$1
打成1
,這可能導致段錯誤 - 小寫指令的操作對象類型錯誤,這可能導致段錯誤。如 x86 的
cmov
指令要求源是寄存器或內存位置,目的操作對象是寄存器。
參考
- Machine Modes: GCC 中的機器模式概念,和 x86 修飾符有關。
- Using the GNU Compiler Collection (GCC): GCC 官方文檔。
- New asm flags feature for x86 in GCC 6: 解釋了 GCC 6 引入的在內聯匯編中以標記寄存器為輸出的的新功能。