【GCC編譯優化系列】從KEIL轉戰GCC,一個C庫函數讓你的bin文件增大好十幾KB!
1 寫在前面
KEIL 這個玩意,相信大家都很熟悉,我想很多人上手開發嵌入式、單片機也是采用的這款入門級IDE。回想起我當初剛學習51單片機的時候,也是使用 KEIL-C51 編譯環境來點燈的。后面工作了,開始接觸嵌入式Linux方面的開發,慢慢地使用 KEIL 的機會就越來越少了。
受多年來在Linux環境下開發的重度影響,我現在基本的操作方式都是 Linux 系統下通過samba把代碼共享出來,在Windows下通過samba獲取共享,然后在Windows下編輯代碼,隨后在Linux下編譯。這樣的好處是,你可以隨心所欲地選用你擅長的編輯工具,而不受限于任何一個IDE;同時Linux下對編譯構建的控制、代碼的查找、各種酷炫命令行的酸爽,真的只有誰用誰知道。
當然,不可否認,KEIL 還是有它強大的到底,畢竟基于ARM的開發,很多還是要依賴于 KEIL-MDK 的。KEIL-MDK-for-ARM 在處理ARM平臺的編譯,還是有它的獨到之處,對比其他編譯器,無論是代碼尺寸還是匯編指令效率,都有不錯的優勢。正如網友所說:“畢竟是官網推薦的收費編譯器,能不優秀嗎?”
不過,這次我要帶來的一個問題就是,代碼從 KEIL 編譯環境遷移到 GCC 編譯環境后,生成的固件代碼居然大了 20KB !詳細內容,請看下文分解,通過本文你將可以了解到以下內容:
KEIL編譯環境切換到GCC編譯環境的一般思路和方法。
如何分析GCC編譯器生成的各種文件?
bin文件生成的拆解
常見的編譯選項和鏈接選項
2 問題描述
問題是這樣的,最近我們在使用一款ARM芯片在做開發,它的內核架構是 ARM Cortex-M0,原廠先是提供了 KEIL 編譯環境的基礎例程,由于這方面的例程比較成熟,我們很快就在上面完成了應用部分的開發,調試和測試都沒什么大問題。
后來由于各種商務原因的考慮,我們決定轉戰到GCC編譯環境,這就需要把原本KEIL上面構建的代碼全部遷移到GCC編譯環境。
經過一番操作,總算是使用GCC把代碼編譯跑起來了,但是問題來了,在GCC編譯的固件bin文件,居然比KEIL編譯環境下生成的固件bin文件大了將近 20KB,如下圖所示:
從代碼量來,即便GCC版本與KEIL版本有些代碼做了調整,核心業務邏輯代碼基本沒動呀,怎么會大這么多?要知道,我們這款芯片留給應用部分的Flash容量上限也就 50KB,另外在預留了 50KB 做OTA下載緩存,現在單應用部分就 60KB+ 了,這個肯定是不能接受的。
無奈只能硬著頭皮去找根源,為何代碼差異不大的情況下,編譯出來的固件bin文件大小差異這么大 ?
3 場景復現
為了能夠準確還原項目的真實場景,我分了下面兩個小節來介紹。
3.1 項目遷移
由于我們早前的項目都在KEIL下構建的,團隊內部在GCC方面有些積累,但都是在其他項目上積累,而對已有的KEIL的工程,并沒有現成的腳本來實現 一鍵遷移,所以只能手把手做項目的遷移。
這里面遇到一個最頭疼的問題,就是KEIL的工程文件管理,相信大家肯定也吐槽過它的槽點。這個就是 “KEIL里面的文件管理是自定義目錄的,而這個目錄并不對應真實的文件系統目錄”。這樣設計的好處是,開發人員可以在KEIL里面自定對不同文件進行分類管理,做分層設計之類的,然后把對應的文件添加到指定的類別里面。而這樣設計的最大弊端就是,如果你不熟悉整個工程,你單從KEIL的文件管理那里,很難一下子就找到你想要的文件,同時,即便你在文件系統里面新建或刪除了一個文件,在KEIL里面并不會自動幫你處理,你都需要重新去添加、刪除。這個真的是反人類設計,每用一次,我吐槽一次!
只好借助于KEIL的工程文件了,后的后綴名是 .uvproj,這個文件是一個 XML 格式的文件,它基本就描述了整個KEIL工程配置,包括你需要的那些遷移信息,基本都可以從中找到。
這貨大概長這樣(因文件篇幅原因,我把Group部分折疊了):
項目遷移的過程,我們主要關注里面的四大部分:
頭文件檢索路徑
這部分信息,可以搜索在KEIL的工程文件中搜索 IncludePath,那么就可以看到KEIL工程里配置的頭文件檢索路徑有哪些。如下所示:
所有源碼文件列表
要找源文件,這里所有顯示看KEIL工程文件中的 Group,它對應的就是 KEIL 工程里面展示的一個個文件組,把它展開就可以看到對應組下面的文件列表,字段是 FileName 和 FilePath 。這兩者的區別就是,一個是沒有路徑名,只有文件名,而另一個是帶路徑和文件名的。
有一個比較高效的方法,就是整個文件通篇搜索 FilePath> ,這樣就找到了所有的文件,包括C文件、匯編文件、TXT文件等等。
不過這里需要注意的是,在KEIL工程中,有一些文件是被 排除 的,在上面檢索出來的文件列表中,也需要將這部分文件給排除掉。
編譯選項列表
關于編譯選項這塊,KEIL工程文件中的 Cads 和 Aads 就有說明,不過這里都是一個開關標志,可讀性比較差,建議還是配合著KEIL IDE target配置界面來看。
鏈接選項列表
關于編譯選項這塊,KEIL工程文件中的 LDads 就有說明,與 Cads 和 Aads 類似,可讀性并不是很強。
注:其實當時找編譯選項和鏈接選項的時候,我想過能不能找到像 Makefile 那樣,加個 V=1 就把所有編譯參數、鏈接參數輸出來;但我沒找到KEIL有類似的操作,有知道該方法的,麻煩告知下。
3.2 編譯復現
我們大家的GCC編譯環境是在Linux下構建的,從上一節中取得的各項內容,整合到Linux環境中的Makefile中。
熟悉GCC編譯和Makefile的都應該清楚,上面的各項內容需要這樣整合:
頭文件檢索路徑:這部分內容添加到 CFLAGS 中,使用 -Ixxx 把頭文件路徑加進去。
所有源碼文件列表: 這部分內容添加到 SRC-y 中,使用(與Makefile文件的)相對路徑添加進去。
編譯選項列表: 這部分內容添加到 CFLAGS 中,這里主要包括兩個方面,一個是傳遞GCC編譯器的編譯選項,比如 優化等級參數、編譯特性參數、警告參數 等等;另一個是傳遞給源碼的宏定義,這里需要對宏定義加字母D,比如 -Dxxx 或 -Dxxx=yyy 。
鏈接選項列表:這部分內容添加到 LDFLAGS 中,這里主要是指明鏈接器如何生成最終的可執行文件,常見的內容包括:鏈接腳本文件、生成MAP文件列表、是否啟用段回收優化、是否使用標準庫等等。
除了上面的部分,還有兩個使用GCC編譯比較關鍵的東西是:啟動腳本 和 鏈接腳本,幸運的是這一塊原廠提供了些支持,我們很快就搭起來了。
在Makefile中完成以上內容添加后,再加上一段使用objcopy生成 bin 文件的流程控制,就可以順利執行make拿到基本的應用bin文件。這里需要注意的是,生成的bin文件不見得立馬就可以拿來燒錄,往往還需要結合原廠提供的打包工具,把應用bin文件打包或重組,生成可以被燒錄工具成功燒錄的固件bin文件,不過后續的流程并不在本文的討論范圍。
在上面遷移編譯過程中,肯定多多少少會遇到各式各樣的問題,一般來說,參考我之前的 解決編譯問題的一般思路,基本都可以解決。
當我們順利編譯得到固件bin文件的時候,第一時間也是驚呆了,正如上一節描述的那樣,居然整整比KEIL環境編譯出來的大了 18KB,這可完全交不了差啊!
4 深入分析
既然問題出現了,那么就深入分析下到底是為何固件bin文件大了多?究竟是代碼問題還是GCC編譯器的鍋?
4.1 分析工程代碼
為了大家更好理解我下面的分析過程,我再次捋一下我們的工程情況。
KEIL版本工程,添加了我們的應用部門代碼,編譯出來大概 46KB;
GCC版本工程,原廠提供的基礎demo工程,不含我們的應用部門代碼,編譯出來大概 19KB;
GCC版本工程,從KEIL版本工程移植過來,保留應用代碼,編譯出來大概 64KB;
GCC版本工程,從KEIL版本工程移植過來,刪除應用部分代碼,與原廠的基礎demo功能是對標的,編譯出來大概 37KB。
OK,從上面幾個版本中,我們可以初步排除應用部分代碼引入的bin文件增大,所以落腳點應重點放在工程2和工程4的對比上面。
這時候,BC要發揮作用了,簡要拉出來對比下:
不比不知道,一比嚇一跳!
這不一樣的地方可太多了呀,哪個才是真正的差異啊?
原來,這個項目KEIL版本的工程原本由另一個團隊基于原廠的demo調試過,修復了很多原廠的坑,但你不能說那些標紅的地方都是,只能說都有可能,而且你也不能去找之前的團隊說,為何會這樣,畢竟人家在KEIL下跑得好好的。
雖說有改動,但整體還是延續了原廠demo的實現,只是部分代碼上做了調整和優化,并沒有大改動。
不過,要想從這些差異中一個個對比分析出來,難度可不小,只能再接下往下,換個思路分析看看了。
4.2 分析編譯選項
熟悉GCC的朋友都知道,GCC有好幾個優化級別,不同的優化級別對生成的bin文件大小會不一樣,而在嵌入式工程代碼中,大家用得最多的,我想應該是 Os 優化級別,這個優化級別和-O3有異曲同工之妙,當然兩者的目標不一樣,-O3的目標是寧愿增加目標代碼的大小,也要拼命的提高運行速度,但是這個選項是在-O2的基礎之上,盡量的降低目標代碼的大小,這對于存儲容量很小的設備來說非常重要。
基于這一點認知,我認為有必要檢查對比下兩個工程的編譯選項,結合分析的結論下,兩邊用的都是 Os 優化級別。
未果,繼續分析。
我之前寫過一篇文章:【gcc編譯優化系列】如何(不)回收未發生調用的函數,里面提到了要想縮小最終的固件bin文件,可以啟用GCC的 –gc-sections 選項,即 段回收 機制。
這個選項可以把工程代碼中沒有被調用的函數或全局變量,在鏈接的時候去除掉,達到優化固件bin大小的目的。這個選項在實際使用過程中,需要與編譯選項 -ffunction-sections 和 -fdata-sections 配合使用,感興趣的可以從文章鏈接中找找答案。
但是,這個懷疑,在我仔細對比兩個工程的編譯選項之后,發現其實都啟用了 -ffunction-sections 和 -fdata-sections,同時我還發現了一個我不太認識的編譯選項 -flto。
這個 -flto 簡單查了一下資料說是:在鏈接時優化,可以更大程度地發揮優化效果。具體其他的,沒有調研沒有發言權,況且現在兩個工程都開了這個選項,顯然很大可能不是它引入的,所以暫且跳過。
4.3 分析鏈接選項
到了分析鏈接選項這一步,除了上一小節提及的 –gc-sections 鏈接選項外,還需要關注一個比較常見的鏈接選項 –specs=xxx.specs。
關于這個選項,之前我寫過一篇文章,簡單研究過這個參數,感興趣的可以讀一讀:GCC編譯鏈接時的–specs=kernel.specs鏈接屬性。
簡單來說,這個文件就指明了在鏈接階段,鏈接器按照那個約定的specs文件(規范、模板文件)去執行鏈接;對應的,一般有 kernel.spcs、nosys.specs等等,這些spec文件可以在交叉編譯工具鏈目錄可以找到。
很遺憾,在分析鏈接選項的時候,這個spec文件,兩邊都使用的 nosys.spec,并無差異,所以它也不是問題的關鍵。
補充一句:–specs=nosys.specs 表示使用靜態庫 libnosys.a 。
4.4 分析elf文件
到了分析elf文件這一步,我們可以用以下幾個Linux命令做分析:
size 命令
size 用于查看目標文件、庫或可執行文件中各段及其總和的大小,是 GNU 二進制工具集 GNU Binutils 的一員。
我們來執行一下 size 命令,看下對應的輸出:
recan@ubuntu:~$ size test_app_*.elf text data bss dec hex filename 19174 2 5572 24748 60ac test_app_19KB.elf 35225 2544 5708 43477 a9d5 test_app_37KB.elf
我們可以看到 37KB的這個elf文件在 text 代碼段中,明顯比 19KB 這個elf文件大了很多。由于我們的bin文件就是由elf文件轉換來的,所以對elf文件 求size,在一定程度上就反應了bin文件的大小。
這里再簡單補充一個小知識:bin文件的大小約等于 TEXT + DATA,注意這里是不需要加上 BSS 的, 原因是BSS段在初始化的時候一般都被手動清零了,不需要體現在bin里面。
readelf 命令
readelf 用來顯示一個或者多個 elf 格式的目標文件的信息,可以通過它的選項來控制顯示哪些信息。這里的 elf-file(s) 就表示那些被檢查的文件。可以支持32位,64位的 elf 格式文件,也支持包含 elf 文件的文檔(這里一般指的是使用 ar 命令將一些 elf 文件打包之后生成的例如 lib*.a 之類的“靜態庫”文件)。
簡單來說,它就是用于分析elf的組成的,它有幾個選項比較常用:比如 -h 只查看elf頭部的信息,-a 則查看所有elf文件內容。
為了更好地看到全貌,我采用了 -a 選項,并把結果分別導出到2個文本文件中:
recan@ubuntu:~$ readelf -a test_app_19KB.elf > test_app_19KB.elf.log recan@ubuntu:~$ readelf -a test_app_37KB.elf > test_app_37KB.elf.log
再次上BC,比較一下看看。
看著好像文件不是很少,但打開發現也有個千把行,但這對比看代碼還是簡略了很多。
這里需要對elf文件有一定的了解,內容那么多,我們需要抓重點,重點關注下 FUNC 字樣的函數。
逐步往下翻,我看到了這一段對比,似乎有了一點點思路;
我們是不是可以合理懷疑下 C庫 ?
當然,這里還給不了答案,我們接著往下看。
4.5 分析map文件
關于如何生成map文件,可以參考下 這里。
map文件就是通過編譯器編譯之后,生成的程序、數據及IO空間信息的一種映射文件,里面包含函數大小,入口地址等一些重要信息。從map文件我們可以了解到:
程序各區段的尋址是否正確;
程序各區段的size,即目前存儲器的使用量;
程序中各個symbol的地址;
各個symbol在存儲器中的順序關系(這在調試時很有用);
各個程序文件的存儲用量。
所以,elf的鏡像分布,我們在map文件中是可以看出來的,在一定程度,為何bin文件大了那么多,多少從map文件是可以發現的。
使用BC一對比map文件,一打開的時候,就發現不對勁了:
這樣真的很難不去懷疑C庫了?
4.6 尋找突破口
接下來,開始對常見的C庫函數進行排查,排查的方法是這樣的:
先檢查對應的C庫函數是否在兩個工程中都有使用,如果是,直接跳過;如果某個C庫只在37KB的工程中出現,那么這個函數則需要重點關注。
為了,有效地準確檢索函數 關鍵字,這里我強烈建議大家使用Linux下的命令行 grep 。因為它不僅可以精準地檢索C文件、頭文件、map文件,還可以檢索到靜態庫.a文件、動態庫.so文件、elf文件等等非文本文件。
由于一般我們的嵌入式工程都是啟用高編譯優化級別的,所以不能簡單地搜索代碼是否調用,更為準確的,我認為是檢索elf文件,因為在高優化級別下有些看似調用了的函數在生成elf的時候卻被優化移除了。
下面對常見的C庫進行分類,整體上從用途上分,有以下一些:
內存管理類
這里包括 malloc、free、calloc、realloc 之類的;這幾個函數常見于內存稍微富余的嵌入式工程中,比如那些可以拋 RTOS 的平臺,這些函數需要重點看看。
字符串操作類
這里比較典型的函數是: strlen、strcpy、strcmp、strchr、strstr、memset、memcpy、memcmp、memmove 等等,這些函數太常用了,常用到我基本認為不太可能會問題出在這。
文件操作類
這里就包括關于文件的幾個操作函數:open、lseek、read、write、close 等等;假如你的工程有用到文件系統或者定義了類似Linux的VFS中間層,那么這些函數你需要重點關注。
printf操作類
這類函數也非常常見,主要包括:printf、fprintf、sprintf、dprintf、vprintf、vsprintf、vfprintf、cdprintf 等等。其實大家可以 man 3 printf 查看到更多關于這些函數的信息。
man 3 printf PRINTF(3) Linux Programmer's Manual PRINTF(3) NAME printf, fprintf, dprintf, sprintf, snprintf, vprintf, vfprintf, vdprintf, vsprintf, vsnprintf - formatted output conver‐ sion SYNOPSIS #include
這一類函數,往往在嵌入式里面非常容易出問題,比如最常見的printf函數我們需要重定向到串口輸出,那底層C庫的實現肯定不知道你要從哪個串口輸出,以及怎么輸出,所以這個時候需要上層做些適配。
正是由于這樣的原因,我們排查的過程中,應對這類函數多留一個心眼。
其他類別
這里還包括更多,我就不一一列舉,具體可以參考下面這個頭文件說明列表:
標準c庫函數頭文件列表
5 修復驗證
5.1 問題修復
基于上面的線索分析,基本排查的方向就比較清晰了。因為這個問題需要不斷地試探和驗證,所以我采用的是每找到一個存在可能性的C庫函數,就在源碼工程里面(含編譯輸出的各種文件)grep 一下,找到了位置,再回頭來看看源碼,同時配合這map文件來對比分析。
這樣一套思路操作下來,問題點逐漸暴露了。我發現了這么一個函數:vsprintf !
elf文件和map文件都符合這個特性:37KB工程中有,但19KB工程中沒有 !
立馬靠攏定位對應代碼,看到代碼我開始恍然大悟:
int uart_printf(const char *fmt,...) { int n; va_list ap; va_start(ap, fmt); n = vsprintf(uart_buff, fmt, ap); va_end(ap); uart_putchar(uart_buff); if(n > sizeof(uart_buff)) { uart_putchar("buff full \r\n"); } return n; }
原來這個工程使用 uart_printf 做printf的重定向輸出。
這時候立馬跳出一個問題,為何19KB的工程沒有啊?它沒有重定向輸出,顯然不是!
看了下代碼,原來它自個整了一個 vsprintf :
這里再捋一捋這段代碼的歷史,早期另一個團隊用這份帶vsprintf的工程僅在KEIL環境是編譯,而原廠整的這個帶 local_vsprintf 的工程僅在Linux GCC環境下編譯。
我猜想可能原廠也遇到類似的bin大小的問題,所以在相關代碼附近,依然可以看到 local_vsprintf 的源碼實現。
一切都向著美好的方向進行著,下面開始重點驗證。
5.2 問題驗證
了解了緣由后,立馬調整相關代碼,調整的方式也很簡單,就是把 vsprintf 重新改成 local_svprintf,即可。
之后,我需要重新清理工程,再次編譯生成固件bin文件。還是以基礎demo工程為例,我們的目標是生成與原廠工程生成大小類似的 19KB。
結果一試,果然,生成的大小就是 19KB。
長舒一口氣,看到交差的希望了。
隨后,把之前屏蔽的應用部分代碼打開,一編譯,結果讓我有些驕傲,居然比KEIL的數據還好一些,固件bin大小是 42KB,比KEIL工程的是 46KB 還有小一些。
這個時候我想起了那個 -flto 參數,發現還是有點東西,回頭有空再研究研究。
當然,固件bin大小小了是好事,但功能不能跑偏了呀,于是下載到板子一跑,基本功能都通過了。
完美,收工!
6 經驗總結
KEIL有KEIL的優勢,GCC有GCC的優勢,兩者有時候不可兼得;
KEIL(ARMCC)編譯對ARM芯片有天然的優勢,無論從代碼性能和代碼尺寸都有更佳的表現,畢竟是同一個爹媽生的;
GCC的優勢在于開源,利于折騰;方便你做各種一鍵式(腳本)集成;
標準C庫的函數多種多樣,適當分類后,更容易區分掌握;
在嵌入式領域,謹慎使用諸如printf、vsprintf等原生的標準C庫;
結論往往在不斷實踐、不斷推導的過程中,變得越來越清晰;
GCC的編譯選項,還有知識盲區,值得再深入研究研究,比如-flto;
了解歷史代碼的演變過程,可能會有助于你解決一些看似亂七八糟的問題。
7 參考鏈接
本次復盤分析中,引用了下列相關文檔,若干知識點可以在下面文章中找到答案,感興趣的可以一讀。
【對比參考】KEIL與GCC的編譯環境對比分析
【GCC編譯優化系列】一文帶你了解C代碼到底是如何被編譯的
【經驗科普】實戰分析C工程代碼可能遇到的編譯問題及其解決思路
【Linux編程】如何使用GCC編譯源代碼時輸出map文件?
【GCC編譯優化系列】如何(不)回收未發生調用的函數
【GCC編譯優化系列】GCC編譯鏈接時候–specs=kernel.specs鏈接屬性究竟是個啥?
8 更多分享
架構師李肯
歡迎關注我的github倉庫01workstation ,日常分享一些開發筆記和項目實戰,歡迎指正問題。
同時也非常歡迎關注我的CSDN主頁和專欄:
【CSDN主頁-架構師李肯】
【RT-Thread主頁-架構師李肯】
【C/C++語言編程專欄】
【GCC專欄】
【信息安全專欄】
【RT-Thread開發筆記】
【freeRTOS開發筆記】
有問題的話,可以跟我討論,知無不答,謝謝大家。
ARM gcc Linux
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。