文章目錄
- 0 前言
- 1 從C語言編譯說起
- 2 重復定義錯誤(ODR violation)和條件編譯
- 3 內聯函數inline和static inline
- 4 總結
0 前言
??最近在研究ARM內核代碼時,看到core_cm3.h中有大量的內聯函數,為此查閱了很多資料,也和朋友討論了很久,最后對C語言多文件編程有了一點不一樣的體會,對此前很多習以為常的東西也知道了這么做的原因。特寫此文以作總結。
1 從C語言編譯說起
??在使用gcc或者g++編譯時,直接傳入c文件即可得到執行程序,看似非常簡單,但實際有很多步驟,包括:預處理(Preprocess),編譯(Compile),匯編(Assemble),鏈接(Link)四個步驟。其中,所謂預處理,即對帶有#
的語句進行處理,如#include, #define
以及條件編譯語句#if, #ifdef, #ifndef
等(當然,這一步也會進行刪除注釋等操作);而編譯,即是將c語言編譯成匯編語言;匯編,是基于匯編語言生成機器碼;鏈接,則是鏈接具有函數引用關系的不同c文件。
參考鏈接
??以上這些過程中有一些注意點:
#include
實際上就是將這個文件的內容復制過來,所以預處理之后,得到的仍然是c格式的文本文件,但體積會比原來的文件大很多。- 既然如此,豈不是理論上可以包含任何文件?是的,include某個c文件其實也是允許的,只要復制過來不會報錯就行。
- 那為什么還要建立同名的c和h文件呢?直接一個c文件不行嗎?這其實是考慮到C語言不能重復定義函數和變量的特性,以及庫文件加調用接口的這種應用場景。一般都是h文件放聲明,c文件放定義。至于重復定義錯誤的相關介紹,參考后面章節。
- 為什么需要鏈接?簡單來說,只要寫的代碼中引用了其他文件中定義的函數,就需要鏈接。這里需要理解一個專業名詞:編譯實體,即每一個c文件都是一個編譯實體,是最小的編譯單位。各個編譯實體在上述前三個階段都是獨立的,互不影響,而最后的鏈接階段就是將不同編譯實體給“拼接”到一塊,組成一個完整的執行程序。
2 重復定義錯誤(ODR violation)和條件編譯
??相信使用過ADC模塊的都遇到過重復定義的問題,即在h文件定義一個轉換值的變量,int ADC_value = 0;
然后在main.c
中包含這個h文件之后直接使用變量ADC_value
,這樣就會報重復定義的錯誤。
??所謂條件編譯,即在h文件中用這么一段代碼括起來:
#ifndef __FILE_H_
#define __FILE_H_// 中間是頭文件的內容#endif
對于這個東西的作用,網上絕大多數的描述都是防止重復包含。確實,從條件編譯的邏輯來看可以實現這個功能,但很多人可能會將這個當作上述描述的重復定義錯誤的解決辦法,這顯然是不對的。
??首先需要明確,函數或變量的聲明,是可以重復include的,如果h文件中只有聲明,那完全可以多次include的,那為什么現在的庫文件中h文件中都會有上述的條件編譯代碼呢?確實是防止重復包含,但最終目的不是避免報錯,而是加快編譯速度。
參考鏈接
??舉個例子,有一個庫(func.c, func.h
)包含了stdio.h
,然后main.c
包含了func.h
,但是出于編寫習慣,main.c
中也會包含stdio.h
,也就是說,最后在編譯main.c時就包含了兩次stdio.h文件,如果stdio.h文件中沒有條件編譯,那么它就會被包含兩次,雖然不會報錯,但會影響編譯的速度,而且這種庫數量越多,影響越大。
??那重復定義到底是怎么回事呢?如果在h文件中定義全局變量,那么包含該h文件的c文件也就定義了一個全局變量(因為include是完全復制),編譯器在編譯該c文件時,這個變量就會被存放在全局/靜態區。同理,假如該h文件也被其他c文件包含,那么其他包含該h文件的c文件也會這么干,因為不同c文件在預處理,編譯和匯編這三個階段(生成目標文件階段)是獨立的。到這各個c文件都可以被正常編譯,不會報錯,但是在最后鏈接階段時,編譯器就會發現全局/靜態區存在相同的變量定義,由此報錯。
??總結來說,防止重復包含是在前三個階段,是同一個編譯單元編譯時的考慮;而重復定義,是不同編譯實體之間在第四個階段鏈接過程中的問題。因此,防止重復包含并不能解決重復定義的問題。
??所以,對于全局變量,建議采用的方式就是頭文件中只聲明(extern int a;
),定義放在同名的c文件中,這樣即使有不同的編譯實體包含了該頭文件,也只是包含了聲明,沒有變量定義,這樣在鏈接階段就不會出現重復定義的問題。
3 內聯函數inline和static inline
??inline這個關鍵詞比較復雜,它在不同C語言版本,不同編譯器,c和c++中的含義都不盡相同,所以在使用前一定要了解編譯環境。
??所謂內聯函數,指調用時沒有普通函數調用時的堆棧壓入和彈出的步驟,而是將函數展開,直接執行內部的代碼。內聯函數的好處在于減少了函數出入棧的操作,代碼執行效率更高,但同樣也有缺點,那就是每調用一次,都需要復制一遍函數的代碼,空間成本更高,所以內聯函數一般只適用于比較簡短的代碼。
??另外,inline關鍵詞只能建議該函數內聯調用,但最終是否調用仍然取決于編譯器,所以就有可能會內聯失敗。對于這個問題,在c++中,一般編譯器會將該函數自動轉換成普通函數,且只保留一份定義,然后正常調用,從而保證不會出錯。比如,在func.h
文件中定義一個內聯函數,但由于函數內容太長或者其他原因,內聯失敗了,那么編譯器可能會自動創建一個func.c
文件(原來沒有,不一定是這個名字),然后在這個文件中生成該內聯函數的定義,原func.h
文件中的定義就只有聲明的作用,從而轉換成普通函數。
參考鏈接
??但是,以上是c++的處理方式,可以保證內聯的函數有且僅有一份定義,但這并不適用于C語言。先來看一個vscode中的例子:
test.c
#include "stdio.h"inline void func()
{printf("Hello World!\n");
}int main(void)
{func();return 0;
}
點擊編譯運行,發現會報錯:undefined reference to 'func' ... error: ld returned 1 exit status
但是如果將文件改成cpp,同樣的代碼就不會報錯。這看起來好像是C語言編譯器的問題?在這篇博客中,介紹了一種辦法,就是在函數前再加上static
關鍵詞進行修飾,這也就是后面要提到的static inline
聯合使用的問題,暫且按下不表。
??為解決這個問題,嘗試在編譯時開啟優化,執行gcc -O1 test.c -o run_c; ./run_c
,結果發現竟然正常輸出了Hello World!換成O2,O3,也都正常。(默認為O0,不開啟優化)
由此可知,如果有inline函數,必須要考慮編譯優化等級的問題。
static inline
??再來看看這個static inline,在介紹之前,首先介紹一下static關鍵詞。
??對于static關鍵詞,在我之前的一篇博客中有詳細介紹。簡單來說,修飾變量時,表示該變量為靜態變量,存放在靜態區,比如函數中如果使用了靜態變量,那么它在內存中的地址就是固定的,全局變量加不加static
修飾其實差別不大;修飾函數時,表示該函數只能被當前文件訪問,不能被其他文件訪問,常用于庫的內部函數,不開放對外接口。
??對于修飾函數的情況,static可以起到隔絕作用域的功能。比如,在兩個c文件中定義同名函數,且都用static修飾,如下所示:
這樣是可以正常編譯運行的。兩個test函數雖然一樣,但由于被限制在各自的文件中,所以不會造成沖突。
??那為什么inline還要加上static呢?如前所述,inline關鍵詞只能建議編譯器將該函數內聯展開,如果成功,那么即使內聯函數定義所在的文件被多次包含,也可以正常編譯運行,舉個例子:
這個例子中,io.h
中定義了一個內聯函數,另外有兩個c文件包含了該h文件,且調用了該內聯函數,可以正常編譯運行。
??但考慮到inline可能會失敗,而且C語言的編譯器在這方面又沒有c++那么智能,可以自動實現只保留一份定義,避免ODR Violation。那么就需要加上static
,這樣每次調用都相當于是內部函數,只在該編譯實體下可調用,且允許不同編譯實體中存在重復定義,這樣就能正常編譯運行了。總結來說,static起到的是一個安全保障功能。
參考鏈接
4 總結
??本文從研究內聯函數出發,分析了C語言多文件編程的具體流程,并基于次對內聯函數的含義和性質、inline和static兩個關鍵詞及其組合等內容進行了詳細的介紹,對于閱讀ARM內核代碼有一定的幫助。