1. 程式人生 > 實用技巧 >GCC 內聯彙編

GCC 內聯彙編

目錄
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)

asmasm-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 將變數retval分配到通用暫存器中,GCC 假設輸入在產生輸出前就被消耗(輸入/輸入分配到同一個暫存器中不會出錯),因此將retval都分配到了暫存器 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)

asmSymbolicNameconstraint和輸出列表一樣。

輸入列表中不可以使用=+這兩個 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)

這個列表表示outputinput都可以分配到記憶體或通用暫存器中,可能出現兩變數同時被分配到記憶體中的情況,這是 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 位處理器上是 rax
  • b(bx),c(cx),d(dx),S(si),D(di): 類似與a
  • q: 整數暫存器。32 位上是abcd,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, raxxchg eax, eax

完整的 GCC x86 修飾符可以在手冊中找到。

RISC-V 特定

GCC 對 RISC-V 平臺提供了以下額外的 constrait。

  • f: 浮點暫存器(如果存在的花)
  • I: 12 位元立即數
  • J: 整數 0
  • K: 用於 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 中。

因為變數在暫存器中,因此暫存器變數的使用有以下限制:

  • 不能初始化。C 無法給暫存器提供初值。
  • 不能使用 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指令要求源是暫存器或記憶體位置,目的操作物件是暫存器。

參考