嵌入式C語言自我修養 10:內聯函數探究

10.1 屬性聲明:noinline & always_inline

這一節,接着講 attribute 屬性聲明,attribute可以說是 GNU C 最大的特色。我們接下來繼續講一下跟內聯函數相關的兩個屬性:noinline 和 always_inline。這兩個屬性的用途是告訴編譯器:編譯時,對我們指定的函數內聯展開或不展開。它們的使用方法如下。

static  inline __attribute__((noinline)) int func();
static  inline __attribute__((always_inline)) int func();

內聯函數使用 inline 聲明即可,有時候還會用 static 和 extern 修飾。使用 inline 聲明一個內聯函數,和使用關鍵字 register 聲明一個變量一樣,只是建議編譯器在編譯時內聯展開。使用關鍵字 register 修飾變量時,只是建議編譯器在給變量分配存儲空間時,將這個變量放到寄存器裏,這樣,程序的運行效率會更高。那編譯器會不會放呢?編譯器就要根據寄存器資源緊不緊張,這個變量用得頻不頻繁來做權衡。

同樣,當一個函數使用 inline 關鍵字修飾,編譯器在編譯時一定會內聯展開嗎?未必。編譯器也會根據實際情況,比如函數體大小、函數體內是否有循環結構、是否有指針、是否有遞歸、函數調用是否頻繁來做決定。比如 GCC 編譯器,一般是不會對內聯函數展開的,只有當編譯優化選項開到 -O2 以上,纔會考慮是否內聯展開。當我們使用 noinline 和 always_inline 對一個內聯函數作了屬性聲明後,編譯器的編譯行爲就變得確定了。使用 noinline 聲明,就是告訴編譯器,不要展開;使用 always_inline 屬性聲明,就是告訴編譯器,要內聯展開。

什麼是內聯展開呢?我們不得不說一下內聯函數的基礎知識。

10.2 什麼是內聯函數

函數調用開銷

說起內聯函數,又不得不說函數調用開銷。一個函數在執行過程中,如果需要調用其它函數,一般會執行下面這個過程。

  • 保存當前函數現場
  • 跳到調用函數執行
  • 恢復當前函數現場
  • 繼續執行當前函數

比如一個 ARM 程序,在一個函數 f1() 中,我們對一些數據進行處理,運算結果暫時保存在 R0 寄存器中。接着要調用另外一個函數 f2(),調用結束後,接着返回到 f1() 函數中繼續處理數據。如果我們在 f2() 函數中使用到 R0 這個寄存器(用於保存函數的返回值),此時就會改變 R0 寄存器中的值,那麼就篡改了 f1() 函數中的暫存運算結果。當我們返回到 f1() 函數中繼續進行運算時,結果肯定不正確。

那怎麼辦呢?很簡單,在跳到 f2() 執行之前,先把 R0 寄存器的值保存到堆棧中,f() 函數執行結束後,再將堆棧中的值恢復到 R0 寄存器中,這樣 f1() 函數就可以接着繼續執行了,就跟什麼事情都沒發生過一樣。

這種方法證明是 OK 的,現代計算機系統,無論是什麼架構和指令集,都是採用這種方法。雖然麻煩了點,但至少能解決問題,無非就是多花點代價,需要不斷地保存現場、恢復現場,這就是函數調用帶來的開銷。

內聯函數的好處

對於一般的函數調用,這種方法是沒有問題的。但對於一些極端情況,比如說一個函數很小,函數體內只有一行代碼,而且被大量頻繁的調用。如果每次調用,都不斷地保存現場,執行時卻發現函數只有一行代碼,又要恢復現場,往往造成函數開銷比較大,性價比不高。這就跟你去五星級飯店訂個餐位吃飯一樣,VIP 包間、刀叉餐具、空調、服務人員都準備好了,你到了之後只要了一碗麪條,吃完之後抹嘴走人,而且一天三頓你都這麼幹,你說服務員煩不煩?

函數調用也是如此。有些函數很小,而且調用頻繁,調用開銷大,算下來性價比不高。我們就可以將這個函數聲明爲內聯函數。編譯器在編譯過程中遇到內聯函數時,像宏一樣,將內聯函數直接在調用處展開。這樣做的好處就是減少了函數調用開銷,直接執行內聯函數展開的代碼,不用再保存現場、恢復現場。

10.3 內聯函數與宏

看到這裏,可能就有人納悶了,內聯函數既然跟宏的功能差不多,那爲什麼不直接定義一個宏,而去定義一個內聯函數呢?

存在即合理,內聯函數既然在 C 語言中廣泛應用,自然有它存在的道理。相對於宏,內聯函數有以下幾個優勢。

  • 參數類型檢查。內聯函數雖然具有宏的展開特性,但其本質仍是函數,編譯過程中,編譯器仍可以對其進行參數檢查,而宏就不具備這個功能。
  • 便於調試。函數支持的調試功能有斷點、單步……,內聯函數也同樣可以。
  • 返回值。內聯函數有返回值,返回一個結果給調用者。這個優勢是相對於 ANSI C 說的。不過現在宏也可以有返回值和類型了,比如前面我們使用語句表達式定義的宏。
  • 接口封裝。有些內聯函數可以用來封裝一個接口,而宏不具備這個特性。

10.4 編譯器對內聯函數的處理

前面也講過,我們雖然可以通過 inline 關鍵字,將一個函數聲明爲內聯函數,但編譯器不一定會對這個內聯函數展開處理。編譯器也要進行評估,權衡展開和不展開的利弊。

內聯函數並不是完美無瑕,也有一些缺點。比如說,會增大程序的體積。如果在一個文件中多次調用內聯函數,多次展開,那整個程序的體積就會變大,在一定程度上,會造成 CPU 的取址效率降低,程序執行效率降低。函數的作用之一就是提高代碼的複用性,我們將常用的一些代碼或代碼塊封裝成函數,進行模塊化編程,而內聯函數往往是降低了函數的複用性。所以編譯器在對內聯函數作展開處理時,除了檢測用戶定義的內聯函數內部是否有指針、循環、遞歸外,還會在函數執行效率和函數調用開銷之間進行權衡。一般來講,判斷對一個內聯函數到底展不展開,從程序員的角度,主要考慮以下幾個因素。

  • 函數體積小
  • 函數體內無指針賦值、遞歸、循環等語句
  • 調用頻繁

當我們認爲一個函數體積小,而且被大量頻繁調用,應該做內聯展開時,就可以使用 static inline 關鍵字修飾它。但編譯器會不會作內聯展開,編譯器也會有自己的權衡。如果你想告訴編譯器一定要展開,或者不作展開,就可以使用 noinline 或 always_inline 對函數作一個屬性聲明。

//inline.c
static inline 
__attribute__((always_inline))  int func(int a)
{
    return a+1;
}

static inline void print_num(int a)
{
    printf("%d\n",a);
}
int main(void)
{
    int i;
    i=func(3);
    print_num(10);
    return 0;
}

在這個程序中,我們分別定義兩個內聯函數 func() 和 print_num(),然後使用 always_inline 對 func() 函數進行屬性聲明。接下來,我們對生成的可執行文件 a.out 作反彙編處理,其彙編代碼如下。

$ arm-linux-gnueabi-gcc -o a.out inline.c
$ arm-linux-gnueabi-objdump -D a.out 
00010438 <print_num>:
   10438:    e92d4800    push    {fp, lr}
   1043c:    e28db004    add fp, sp, #4
   10440:    e24dd008    sub sp, sp, #8
   10444:    e50b0008    str r0, [fp, #-8]
   10448:    e51b1008    ldr r1, [fp, #-8]
   1044c:    e59f000c    ldr r0, [pc, #12]
   10450:    ebffffa2    bl  102e0 <printf@plt>
   10454:    e1a00000    nop ; (mov r0, r0)
   10458:    e24bd004    sub sp, fp, #4
   1045c:    e8bd8800    pop {fp, pc}
   10460:    0001050c    andeq   r0, r1, ip, lsl #10

00010464 <main>:
   10464:    e92d4800    push    {fp, lr}
   10468:    e28db004    add fp, sp, #4
   1046c:    e24dd008    sub sp, sp, #8
   10470:    e3a03003    mov r3, #3
   10474:    e50b3008    str r3, [fp, #-8]
   10478:    e51b3008    ldr r3, [fp, #-8]
   1047c:    e2833001    add r3, r3, #1
   10480:    e50b300c    str r3, [fp, #-12]
   10484:    e3a0000a    mov r0, #10
   10488:    ebffffea    bl  10438 <print_num>
   1048c:    e3a03000    mov r3, #0
   10490:    e1a00003    mov r0, r3
   10494:    e24bd004    sub sp, fp, #4
   10498:    e8bd8800    pop {fp, pc}

通過反彙編代碼可以看到,因爲我們對 func() 函數作了 always_inline 屬性聲明,所以編譯器在編譯過程中,對於 main()函數調用 func(),會直接在調用處展開。

10470:    e3a03003    mov r3, #3
   10474:    e50b3008    str r3, [fp, #-8]
   10478:    e51b3008    ldr r3, [fp, #-8]
   1047c:    e2833001    add r3, r3, #1
   10480:    e50b300c    str r3, [fp, #-12]

而對於 print_num() 函數,雖然我們對其作了內聯聲明,但編譯器並沒有對其作內聯展開,而是當作一個普通函數對待。還有一個注意的細節是,當編譯器對內聯函數作展開處理時,會直接在調用處展開內聯函數的代碼,不再給 func() 函數本身生成單獨的彙編代碼。這是因爲其它調用該函數的位置都作了內聯展開,沒必要再去生成。在這個例子中,我們發現就沒有給 func() 函數本身生成單獨的彙編代碼,編譯器只給 print_num() 函數生成了獨立的彙編代碼。

10.5 思考:內聯函數爲什麼常使用 static 修飾?

在 Linux 內核中,你會看到大量的內聯函數定義在頭文件中,而且常常使用 static 修飾。

爲什麼 inline 函數經常使用 static 修飾呢?這個問題在網上也討論了很久,聽起來各有道理,從 C 語言到 C++,甚至有人還拿出了 Linux 內核作者 Linus 作者關於對 static inline 的解釋:

"static inline" means "we have to have this function, if you use it, but don't inline it, then make a static version of it in this compilation unit". "extern inline" means "I actually have an extern for this function, but if you want to inline it, here's the inline-version".

我的理解是這樣的:內聯函數爲什麼要定義在頭文件中呢?因爲它是一個內聯函數,可以像宏一樣使用,任何想使用這個內聯函數的源文件,不必親自再去定義一遍,直接包含這個頭文件,即可像宏一樣使用。那爲什麼還要用 static 修飾呢?因爲我們使用 inline 定義的內聯函數,編譯器不一定會內聯展開,那麼當多個文件都包含這個內聯函數的定義時,編譯時就有可能報重定義錯誤。而使用 static 修飾,可以將這個函數的作用域侷限在各自本地文件內,避免了重定義錯誤。理解了這兩點,就能夠看懂 Linux 內核頭文件中定義的大部分內聯函數了。至於其它的一些內聯函數定義,基本上沒怎麼遇到過,就不再贅述了。

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

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