嵌入式C語言自我修養 11:有一種函數,叫內建函數

11.1 什麼是內建函數

內建函數,顧名思義,就是編譯器內部實現的函數。這些函數跟關鍵字一樣,可以直接使用,無須像標準庫函數那樣,要 #include 對應的頭文件才能使用。

內建函數的函數命名,通常以 __builtin 開頭。這些函數主要在編譯器內部使用,主要是爲編譯器服務的。內建函數的主要用途如下。

  • 用來處理變長參數列表;
  • 用來處理程序運行異常;
  • 程序的編譯優化、性能優化;
  • 查看函數運行中的底層信息、堆棧信息等;
  • C 標準庫函數的內建版本。

因爲內建函數是編譯器內部定義,主要由編譯器相關的工具和程序調用,所以這些函數並沒有文檔說明,而且變動而頻繁。對於程序開發者來說,不建議使用這些函數。

但有些函數,對於我們瞭解程序運行的底層信息、編譯優化很有幫助,而且在 Linux 內核中也經常使用這些函數,所以還是很有必要去了解 Linux 內核中常用的一些內建函數。

11.2 常用內建函數

__builtinreturnaddress(LEVEL)

這個函數用來返回當前函數或調用者的返回地址。函數的參數 LEVEl 表示函數調用鏈中的不同層次的函數,各個值代表的意義如下。

  • 0:返回當前函數的返回地址;
  • 1:返回當前函數調用者的返回地址;
  • 2:返回當前函數調用者的調用者的返回地址;
  • ……

我們接下來寫一個測試程序。

void f(void)
    {
        int *p;
        p = __builtin_return_address(0);
        printf("f    return address: %p\n",p);
        p = __builtin_return_address(1);;
        printf("func return address: %p\n",p);
        p = __builtin_return_address(2);;
        printf("main return address: %p\n",p);
        printf("\n");
    }
    void func(void)
    {
        int *p;
        p = __builtin_return_address(0);
        printf("func return address: %p\n",p);
        p = __builtin_return_address(1);;
        printf("main return address: %p\n",p);
        printf("\n");
        f();
    }

    int main(void)
    {
        int *p;
        p = __builtin_return_address(0);
        printf("main return address: %p\n",p);
        printf("\n"); 
        func();
        printf("goodbye!\n");
        return 0;
    }

C 語言函數在調用過程中,會將當前函數的返回地址、寄存器等現場信息保存在堆棧中,然後纔會跳到被調用函數中去執行。當被調用函數執行結束後,根據保存在堆棧中的返回地址,就可以直接返回到原來的函數中繼續執行。

在這個程序中,main() 函數調用 func() 函數,在 main() 函數跳轉到 func() 函數執行之前,會將程序正在運行的當前語句的下一條語句(如下代碼所示)的地址保存到堆棧中,然後纔去執行 func(); 這條語句,跳到 func() 函數去執行。func() 執行完畢後,如何返回到 main() 函數呢?很簡單,將保存到堆棧中的返回地址賦值給 PC 指針,就可以直接返回到 main() 函數,繼續往下執行了。

printf("goodbye!\n");

每一層函數調用,都會將當前函數的下一條指令地址,即返回地址壓入堆棧保存。各層函數調用就構成 了一個函數調用鏈。在各層函數內部,我們使用內建函數就可以打印這個調用鏈上各個函數的返回地址。程序的運行結果如下。

main return address:0040124B

func return address:004013C3
main return address:0040124B

f    return address:00401385
func return address:004013C3
main return address:0040124B

__builtinframeaddress(LEVEL)

在函數調用過程中,還有一個“棧幀”的概念。函數每調用一次,都會將當前函數的現場(返回地址、寄存器等)保存在棧中,每一層函數調用都會將各自的現場信息都保存在各自的棧中。這個棧也就是當前函數的棧幀,每一個棧幀有起始地址和結束地址,表示當前函數的堆棧信息。多層函數調用就會有多個棧幀,每個棧幀裏會保存上一層棧幀的起始地址,這樣各個棧幀就形成了一個調用鏈。很多調試器、GDB、包括我們的這個內建函數,其實都是通過回溯函數棧幀調用鏈來獲取函數底層的各種信息的。比如,返回地址 i、調用關係等。在 ARM 系統中,使用 FP 和 SP 這兩個寄存器,分別指向當前函數棧幀的起始地址和結束地址。當函數繼續調用或者返回,這兩個寄存器的值也會發生變化,總是指向當前函數棧幀的起始地址和結束地址。

我們可以通過內建函數 __builtinframeaddress(LEVEL),查看函數的棧幀地址。

  • 0:查看當前函數的棧幀地址
  • 1:查看當前函數調用者的棧幀地址
  • ……

寫一個程序,打印當前函數的棧幀地址。

void func(void)
{
    int *p;
    p = __builtin_frame_address(0);
    printf("func frame:%p\n",p);
    p = __builtin_frame_address(1);
    printf("main frame:%p\n",p);
}

int main(void)
{
    int *p;
    p = __builtin_frame_address(0);
    printf("main frame:%p\n",p);
    printf("\n");
    func();
    return 0;
}

程序運行結果如下。

main frame:0028FF48

func frame:0028FF28
main frame:0028FF48

11.3 C 標準庫的內建函數

在 GNU C 編譯器內部,實現了一些和 C 標準庫函數類似的內建函數。這些函數跟 C 標準庫函數功能相似,函數名也相同,只是在前面加了一個前綴 __builtin。如果你不想使用 C 庫函數,也可以加個前綴,直接使用對應的內建函數。

常見的標準庫函數如下:

  • 內存相關的函數:memcpy 、memset、memcmp
  • 數學函數:log、cos、abs、exp
  • 字符串處理函數:strcat、strcmp、strcpy、strlen
  • 打印函數:printf、scanf、putchar、puts

接下來我們寫個小程序,使用與 C 標準庫對應的內建函數。

int main(void)
{    
    char a[100];
    __builtin_memcpy(a,"hello world!",20);
    __builtin_puts(a);

    return 0;
}

程序運行結果如下。

hello world!

通過運行結果我們看到,使用與 C 標準庫對應的內建函數,同樣也能實現字符串的複製和打印,實現 C 標準庫函數的功能。

11.4 內建函數:__builtinconstantp(n)

編譯器內部還有一些內建函數,主要用來編譯優化、性能優化,如 __builtinconstantp(n) 函數。該函數主要用來判斷參數 n 在編譯時是否爲常量,是常量的話,函數返回1;否則函數返回0。該函數常用於宏定義中,用於編譯優化。一個宏定義,根據宏的參數是常量還是變量,可能實現的方法不一樣。在內核中經常看到這樣的宏。

#define _dma_cache_sync(addr, sz, dir)        \
do {                            \
    if (__builtin_constant_p(dir))          \
        __inline_dma_cache_sync(addr, sz, dir); \
    else                        \
        __arc_dma_cache_sync(addr, sz, dir);    \
}                            \
while (0);

很多計算或者操作在參數爲常數時可能有更優化的實現,在這個宏定義中,我們實現了兩個版本。根據參數是否爲常數,我們可以靈活選用不同的版本。

11.5 內建函數:__builtin_expect(exp,c)

內建函數 __builtin_expect 也常常用來編譯優化。這個函數有兩個參數,返回值就是其中一個參數,仍是 exp。這個函數的意義主要就是告訴編譯器:參數 exp 的值爲 c 的可能性很大。然後編譯器可能就會根據這個提示信息,做一些分支預測上的代碼優化。

參數 c 跟這個函數的返回值無關,無論 c 爲何值,函數的返回值都是 exp。

int main(void)
{    
    int a;
    a = __builtin_expect(3,1);
    printf("a = %d\n",a);

    a = __builtin_expect(3,10);
    printf("a = %d\n",a);

    a = __builtin_expect(3,100);
    printf("a = %d\n",a);
    return 0;
}

程序運行結果如下。

a = 3
a = 3
a = 3

這個函數的主要用途就是編譯器的分支預測優化。現代 CPU 內部,都有 cache 這個緩存器件。CPU 的運行速度很高,而外部 RAM 的速度相對來說就低了不少,所以當 CPU 從內存 RAM 讀寫數據時就會有一定的性能瓶頸。爲了提高程序執行效率,CPU 都會通過 cache 這個 CPU 內部緩衝區來緩存一定的指令或數據。CPU 讀寫內存 RAM 中的數據時,會先到 cache 裏面去看看能不能找到。找到的話就直接進行讀寫;找不到的話,cache 會重新緩存一部分內存數據進來。CPU 讀寫 cache 的速度遠遠大於內存 RAM,所以通過這種方式,可以提高系統的性能。

那 cache 如何緩存內存數據呢?簡單來說,就是依據空間相近原則。比如 CPU 正在執行一條指令,那麼下一個指令週期,CPU 就會大概率執行當前指令的下一條指令。如果此時 cache 將下面幾條指令都緩存到 cache 裏面,下一個指令週期 CPU 就可以直接到 cache 裏取指、翻譯、執行,從而使運算效率大大提高。

但有時候也會出現意外。比如程序在執行過程中遇到函數調用、if 分支、goto 跳轉等程序結構,會跳到其它地址執行,那麼緩存到 cache 中的指令就不是 CPU 要獲取的指令。此時,我們就說 cache 沒有命中,cache 會重新緩存正確的指令代碼給 CPU 讀取,這就是 cache 工作的基本流程。

有了這個理論基礎,我們在編寫程序時,遇到 if/switch 這種選擇分支的程序結構,可以將大概率發生的分支寫在前面,這樣程序運行時,因爲大概率發生,所以大部分時間就不需要跳轉,程序就相當於一個順序結構,從而提高 cache 的命中率。內核中已經實現一些相關的宏,如 likely 和 unlikely,用來提醒程序員優化程序。

11.6 內核中的 likely 和 unlikely

Linux 內核中,使用 __builtin_expect 內建函數,定義了兩個宏。

#define likely(x) __builtin_expect(!!(x),1)
#define unlikely(x) __builtin_expect(!!(x),0)

這兩個宏的主要作用,就是告訴編譯器:某一個分支發生的概率很高,或者說很低,基本不可能發生。編譯器就根據這個提示信息,就會去做一些分值預測的編譯優化。在這兩個宏定義有一個細節,就是對宏的參數 x 做兩次取非操作,這是爲了將參數 x 轉換爲布爾類型,然後與 1 和 0 作比較,告訴編譯器 x 爲真或爲假的可能性很高。

我們接下來舉個例子,讓大家感受下,使用這兩個宏後,編譯器在分支預測上的一些編譯變化。

//expect.c
int main(void)
{
    int a;
    scanf("%d",&a);
    if( a==0)
    {
        printf("%d",1);
        printf("%d",2);
        printf("\n");
    }
    else
    {
        printf("%d",5);
        printf("%d",6);
        printf("\n");
    }
    return 0;
}

在這個程序中,根據我們輸入變量 a 的值,程序會執行不同的分支代碼。我們接着對這個程序反彙編,生成對應的彙編代碼。

$ arm-linux-gnueabi-gcc  expect.c
$ arm-linux-gnueabi-objdump -D a.out
 00010558 <main>:
   10558:    e92d4800    push    {fp, lr}
   1055c:    e28db004    add fp, sp, #4
   10560:    e24dd008    sub sp, sp, #8
   10564:    e59f308c    ldr r3, [pc, #140]  
   10568:    e5933000    ldr r3, [r3]
   1056c:    e50b3008    str r3, [fp, #-8]
   10570:    e24b300c    sub r3, fp, #12
   10574:    e1a01003    mov r1, r3
   10578:    e59f007c    ldr r0, [pc, #124]  
   1057c:    ebffffa5    bl  10418 <__isoc99_scanf@plt>
   10580:    e51b300c    ldr r3, [fp, #-12]
   10584:    e3530000    cmp r3, #0
   10588:    1a000008    bne 105b0 <main+0x58>
   1058c:    e3a01001    mov r1, #1
   10590:    e59f0068    ldr r0, [pc, #104]  
   10594:    ebffff90    bl  103dc <printf@plt>
   10598:    e3a01002    mov r1, #2
   1059c:    e59f005c    ldr r0, [pc, #92]
   105a0:    ebffff8d    bl  103dc <printf@plt>
   105a4:    e3a0000a    mov r0, #10
   105a8:    ebffff97    bl  1040c <putchar@plt>
   105ac:    ea000007    b   105d0 <main+0x78>
   105b0:    e3a01005    mov r1, #5
   105b4:    e59f0044    ldr r0, [pc, #68]
   105b8:    ebffff87    bl  103dc <printf@plt>
   105bc:    e3a01006    mov r1, #6
   105c0:    e59f0038    ldr r0, [pc, #56]
   105c4:    ebffff84    bl  103dc <printf@plt>

觀察 main 函數的反彙編代碼,我們看到:彙編代碼的結構就是基於我們的 if/else 分支先後順序,依次生成對應的彙編代碼(看 10588:bne 105b0 跳轉)。我們接着改一下代碼,使用 unlikely 修飾 if 分支,告訴編譯器,這個 if 分支小概率發生,或者說不可能發生。

//expect.c
int main(void)
{
    int a;
    scanf("%d",&a);
    if( unlikely(a==0) )
    {
        printf("%d",1);
        printf("%d",2);
        printf("\n");
    }
    else
    {
        printf("%d",5);
        printf("%d",6);
        printf("\n");
    }
    return 0;
}

對這個程序添加 -O2 優化參數編譯,並對生成的可執行文件 a.out 反彙編。

$ arm-linux-gnueabi-gcc -O2 expect.c
 $ arm-linux-gnueabi-objdump -D a.out
00010438 <main>:
   10438:    e92d4010    push    {r4, lr}
   1043c:    e59f4080    ldr r4, [pc, #128]  
   10440:    e24dd008    sub sp, sp, #8
   10444:    e5943000    ldr r3, [r4]
   10448:    e1a0100d    mov r1, sp
   1044c:    e59f0074    ldr r0, [pc, #116]
   10450:    e58d3004    str r3, [sp, #4]
   10454:    ebfffff1    bl  10420 <__isoc99_scanf@plt>
   10458:    e59d3000    ldr r3, [sp]
   1045c:    e3530000    cmp r3, #0
   10460:    0a000010    beq 104a8 <main+0x70>
   10464:    e3a02005    mov r2, #5
   10468:    e59f105c    ldr r1, [pc, #92]
   1046c:    e3a00001    mov r0, #1
   10470:    ebffffe7    bl  10414 <__printf_chk@plt>
   10474:    e3a02006    mov r2, #6
   10478:    e59f104c    ldr r1, [pc, #76]
   1047c:    e3a00001    mov r0, #1
   10480:    ebffffe3    bl  10414 <__printf_chk@plt>
   10484:    e3a0000a    mov r0, #10
   10488:    ebffffde    bl  10408 <putchar@plt>
   1048c:    e59d2004    ldr r2, [sp, #4]
   10490:    e5943000    ldr r3, [r4]
   10494:    e3a00000    mov r0, #0
   10498:    e1520003    cmp r2, r3
   1049c:    1a000007    bne 104c0 <main+0x88>
   104a0:    e28dd008    add sp, sp, #8
   104a4:    e8bd8010    pop {r4, pc}
   104a8:    e3a02001    mov r2, #1
   104ac:    e59f1018    ldr r1, [pc, #24]
   104b0:    e1a00002    mov r0, r2
   104b4:    ebffffd6    bl  10414 <__printf_chk@plt>
   104b8:    e3a02002    mov r2, #2
   104bc:    eaffffed    b   10478 <main+0x40>

我們對 if 分支條件表達式使用 unlikely 修飾,告訴編譯器這個分支小概率發生。在編譯器開啓優化編譯條件下,通過生成的反彙編代碼(10460:beq 104a8),我們可以看到,編譯器將小概率發生的 if 分支彙編代碼放在了後面,將 else 分支的彙編代碼放在了前面,這樣就確保了程序在執行時,大部分時間都不需要跳轉,直接按順序執行下面大概率發生的分支代碼。

在 Linux 內核中,你會發現很多地方使用 likely 和 unlikely 宏修飾,此時你應該知道它們的用途了吧。

本教程根據 C語言嵌入式Linux高級編程視頻教程 第05期 改編,電子版書籍可加入QQ羣:475504428 下載,更多嵌入式視頻教程,可關注:
微信公衆號:宅學部落(armlinuxfun)
51CTO學院-王利濤老師:http://edu.51cto.com/sd/d344f

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章