最近部門新入職了幾個小鮮肉,打算給他們分享下一些C/C++編譯的基礎(chǔ)知識,于是整理了一些資料寫了這篇博客.由于已經(jīng)有差不多一年沒有寫c++了,可能會有一些不太正確的地方,希望哪位同學(xué)看到能夠幫忙指出,免得誤人子弟.
首先需要聲明的是,我用的是Ubuntu系統(tǒng),也是基于Linux去講的,當(dāng)然大家如果是用的Mac系統(tǒng),其實可以無縫切換,用幾乎完全一樣的命令去跑.但是如果是Windows的同學(xué),可能就不太適用了.
不過其實我還是鼓勵大家用Linux系統(tǒng)或者M(jìn)ac系統(tǒng)去編譯C/C++程序.因為大多數(shù)流行庫都是在linux下面寫的,使用Linux或者M(jìn)ac交叉編譯出安卓的可用程序都比較方便.
為什么要學(xué)C/C++編譯
很多的安卓程序員可能都會用Android Studio寫一些簡單的C/C++代碼,然后通過jni去調(diào)用,但是對C/C++是如何編譯的其實并沒有什么概念.有人可能會問,為什么安卓程序員會需要了解C/C++是如何編譯的呢?我一直都認(rèn)為,要成為一個真正的高級安卓應(yīng)用開發(fā)工程師,安卓源碼和C/C++是兩座繞不過的大山.安卓源碼自然不必多說,而C/C++流行了幾十年,存在著許多優(yōu)秀的開源項目,我們在處理一些特定的需求的時候,可能會需要使用到它們.如腳本語言Lua,計算機(jī)視覺庫OpenCV,音視頻編解碼庫ffmpeg,谷歌的gRPC,國產(chǎn)游戲引擎Cocos2dx...有些庫提供了完整的安卓接口,有些提供了部分安卓接口,有些則沒有.在做一些高級功能時,我們常常需要使用源碼,通過裁剪和交叉編譯,才能編譯出可以在安卓上使用的so庫.總之,安卓做深做精總避不開C/C++交叉編譯.
C/C++編譯器
類似java編譯器javac可以將java代碼編譯成class文件,C/C++也有g(shù)cc、g++干旧、clang等多種編譯器可以用于編譯C/C++代碼.這里我們用gcc來舉例.
gcc原名為GNU C 語言編譯器(GNU C Compiler),因為它原本只能處理C語言.但GCC很快地擴(kuò)展,變得可處理C++雨女。后來又?jǐn)U展能夠支持更多編程語言,如Fortran、Pascal致开、Objective-C弦撩、Java文虏、Ada瞒瘸、Go以及各類處理器架構(gòu)上的匯編語言等,所以改名GNU編譯器套件(GNU Compiler Collection).
我這篇文章的例子都是Ubuntu上編譯的.使用Ubuntu系統(tǒng)的同學(xué)可以使用下面命令安裝gcc:
sudo apt-get install gcc
如果是CentOS使用yum去安裝:
yum install gcc
Mac系統(tǒng)的話可以用HomeBrew來安裝,HomeBrew的安裝方法我就不說了,大家可以自己搜索:
brew install gcc
而使用Windows的同學(xué),需要自己搜索下MinGw是如何安裝的,MinGw 是 Minimal GNU on Windows 的縮寫.
使用gcc其實只需要一個命令就能將一個c文件編譯成可運行程序了:
gcc test.c -o test
通過上面這條命令可以將test.c編譯成可運行程序test.但是其實C/C++的編譯是經(jīng)過了好幾個步驟的,我這邊先給大家大概的講一講.
C/C++的編譯流程
C/C++的編譯可以分為下面幾個步驟:
預(yù)處理
相信學(xué)過C/C++的同學(xué)都知道"宏"這個東西,它在編譯的時候會被展開替換成實際的代碼,這個展開的步驟就是在預(yù)處理的時候進(jìn)行的.當(dāng)然,預(yù)處理并不僅僅只是做宏的展開,它還做了類似頭文件插入坷备、刪除注釋等操作.
預(yù)處理之后的產(chǎn)品依然還是C/C++代碼,它在代碼的邏輯上和輸入的C/C++源代碼是完全一樣的.
我們來舉一個簡單的例子,寫一個test.h文件和一個test.c文件:
//test.h
#ifndef TEST_H
#define TEST_H
#define A 1
#define B 2
/**
* add 方法的聲明
*/
int add(int a, int b);
#endif
//test.c
#include "test.h"
/**
* add 方法定義
*/
int add(int a, int b) {
return a + b;
}
int main(int argc,char* argv[]) {
add(A, B);
return 0;
}
然后可以通過下面這個gcc命令預(yù)處理test.c文件,并且把預(yù)處理結(jié)果寫到test.i:
gcc -E test.c -o test.i
然后就能看到預(yù)處理之后的test.c到底長什么樣子了:
# 1 "test.c"
# 1 "<built-in>"
# 1 "<command-line>"
# 1 "/usr/include/stdc-predef.h" 1 3 4
# 1 "<command-line>" 2
# 1 "test.c"
# 1 "test.h" 1
# 11 "test.h"
int add(int a, int b);
# 2 "test.c" 2
int add(int a, int b){
return a + b;
}
int main(int argc,char* argv[]) {
add(1, 2);
return 0;
}
可以看到這里它把test.h的內(nèi)容(add方法的聲明)插入到了test.c的代碼中,然后將A、B兩個宏展開成了1和2,將注釋去掉了,還在頭部加上了一些信息.
但是光看代碼邏輯,和之前我們寫的代碼是完全一樣的.
匯編
可能大家都聽過匯編語言這個東西,但是年輕一點的同學(xué)不一定真正見過.簡單來說匯編語言是將機(jī)器語言符號化了的語言,是機(jī)器不能直接識別的低級語言.我們可以通過下面的命令,將預(yù)處理后的代碼編譯成匯編語言:
gcc -S test.i -o test.s
然后就能看到生成的test.s文件了,里面就是我們寫的c語言代碼翻譯而成的匯編代碼:
.file "test.c"
.text
.globl add
.type add, @function
add:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl -4(%rbp), %edx
movl -8(%rbp), %eax
addl %edx, %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size add, .-add
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl %edi, -4(%rbp)
movq %rsi, -16(%rbp)
movl $2, %esi
movl $1, %edi
call add
movl $0, %eax
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609"
.section .note.GNU-stack,"",@progbits
匯編
匯編這一步是將匯編代碼編譯成機(jī)器語言:
gcc -c test.s -o test.o
生成的test.o文件里面就是機(jī)器代碼了,我們可以通過nm命令來列出test.o里面的符號:
nm test.o
得到的結(jié)果如下:
0000000000000000 T add
0000000000000014 T main
鏈接
由于我們的例子代碼比較簡單只有一個test.h和test.h,所以只生成了一個.o文件,其實一般的程序都是由多個模塊組合成的.鏈接這一步就是將多個模塊的代碼組合成一個可執(zhí)行程序.我們可以用gcc命令將多個.o文件或者靜態(tài)庫情臭、動態(tài)庫鏈接成一個可執(zhí)行文件:
gcc test.o -o test
得到的就是可執(zhí)行文件test了,可以直接用下面命令運行
./test
當(dāng)然是沒有任何輸出的,因為我們就沒有做任何的打印
編譯so庫
當(dāng)然,在安卓中我們一般不會直接使用C/C++編譯出來的可運行文件.用的更多的應(yīng)該是so庫.那要如何編譯so庫呢?
首先我們需要將test.c中的main函數(shù)去掉,因為so庫中是不會帶有main函數(shù)的:
#include "test.h"
/**
* add 方法定義
*/
int add(int a, int b){
return a + b;
}
然后可以使用下面命令將test.c編譯成test.so:
gcc -shared test.c -o test.so
其實也就是多了個-shared參數(shù),指定編譯的結(jié)果為動態(tài)鏈接庫.
這里是直接將.c文件編譯成so,當(dāng)然也能像之前的例子一樣先編譯出.o文件再通過鏈接生成so文件.
當(dāng)然一般編譯動態(tài)鏈接庫,我們還會帶上-fPIC參數(shù).
fPIC (Position-Independent Code)告訴編譯器產(chǎn)生與位置無關(guān)代碼,即產(chǎn)生的代碼中沒有絕對地址,全部使用相對地址.故而代碼可以被加載器加載到內(nèi)存的任意位置,都可以正確的執(zhí)行.不加fPIC編譯出來的so,是要再加載時根據(jù)加載到的位置再次重定位的.因為它里面的代碼并不是位置無關(guān)代碼.如果被多個應(yīng)用程序共同使用,那么它們必須每個程序維護(hù)一份.so的代碼副本了.因為.so被每個程序加載的位置都不同,顯然這些重定位后的代碼也不同,當(dāng)然不能共享.
交叉編譯
通過上面的例子,我們知道了一個C/C++程序是怎么從源代碼一步步編譯成可運行程序或者so庫的.但是我們編譯出來的程序或者so庫只能在相同系統(tǒng)的電腦上使用.
例如我使用的電腦是Linux系統(tǒng)的,那它編譯出來的程序也就只能在Linux上運行,不能在安卓或者Windows上運行.
當(dāng)然正常情況下不會有人專門去到android系統(tǒng)下編譯出程序來給安卓去用.一般我們都是在PC上編譯出安卓可用的程序,在給到安卓去跑的.這種是在一個平臺上生成另一個平臺上的可執(zhí)行代碼的編譯方式就叫做交叉編譯.
交叉編譯有是三個比較重要的概念要先說明一下:
- build : 當(dāng)前你使用的計算機(jī)
- host : 你的目的是編譯出來的程序可以在host上運行
- target : 普通程序沒有這個概念省撑。對于想編譯出編譯器的人來說此屬性決定了新編譯器編譯出的程序可以運行在哪
如果我們想要交叉編譯出安卓可運行的程序或者庫的話就不能直接使用gcc去編譯了.而需要使用Android NDK提供了的一套交叉編譯工具鏈.
我們首先要下載Android NDK,然后配置好環(huán)境變量NDK_ROOT指向NDK的根目錄.
然后可以通過下面命令安裝交叉編譯工具鏈:
$NDK_ROOT/build/tools/make-standalone-toolchain.sh \
--platform=android-19 \
--install-dir=$HOME/Android/standalone-toolchains/android-toolchain-arm \
--toolchain=arm-linux-androideabi-4.9 \
--stl=gnustl
然后我們就能在HOME/Android/目錄下看到安裝好的工具鏈了.進(jìn)到HOME/Android/standalone-toolchains/android-toolchain-arm/bin/目錄下我們可以看到有arm-linux-androideabi-gcc這個程序.
它就是gcc的安卓交叉編譯版本.我們將之前使用gcc去編譯的例子全部換成使用它去編譯就能編譯出運行在安卓上的程序了:
如下面命令生成的so庫就能在安卓上通過jni調(diào)用了:
$HOME/Android/standalone-toolchains/android-toolchain-arm/bin/arm-linux-androideabi-gcc -shared -fPIC test.c -o test.so
我們會將定義下面幾個環(huán)境變量,將$HOME/Android/standalone-toolchains/放到PATH變量中,這樣就可以直接使用arm-linux-androideabi-gcc命令,而不需要輸入它的全路徑去使用了:
export TOOLCHAIN_HOME=$HOME/Android/standalone-toolchains/android-toolchain-arm
export TOOLCHAIN_SYSROOT=$TOOLCHAIN_HOME/sysroot
export PATH=$PATH:$TOOLCHAIN_HOME/bin
設(shè)定好之后可以直接用下面命令去編譯:
arm-linux-androideabi-gcc -shared -fPIC test.c -o test.so
不同CPU架構(gòu)的編譯方式
當(dāng)然安卓也有很多不同的CPU架構(gòu),不同CPU架構(gòu)的程序也是不一定兼容的,相信大家之前在使用Android Studio去編譯so的時候也有看到編譯出來的庫有很多個版本像armeabi赌蔑、armeabi-v7a、mips竟秫、x86等.
那這些不同CPU架構(gòu)的程序又要如何編譯了.
我們可以在$NDK_ROOT/toolchains目錄下看到者幾個目錄:
arm-linux-androideabi-4.9
aarch64-linux-android-4.9
mipsel-linux-android-4.9
mips64el-linux-android-4.9
x86-4.9
x86_64-4.9
這就是不同CPU架構(gòu)的交叉編譯工具鏈了.還記得我們安裝工具鏈的命令嗎?
$NDK_ROOT/build/tools/make-standalone-toolchain.sh \
--platform=android-19 \
--install-dir=$HOME/Android/standalone-toolchains/android-toolchain-arm \
--toolchain=arm-linux-androideabi-4.9 \
--stl=gnust
toolchain參數(shù)就能指定使用哪個工具鏈,然后就能使用該工具鏈去編譯該架構(gòu)版本的程序了.
但是,我們看到這下面并沒有armeabi-v7a的工具鏈,那armeabi-v7a的程序要如何編譯呢?
其實armeabi-v7a的程序也是用arm-linux-androideabi-4.9去編譯的,只不過在編譯的時候可以帶上-march=armv7-a:
arm-linux-androideabi-gcc -march=armv7-a -shared -fPIC test.c -o test.so
官方文檔
我這邊其實只是簡要的介紹了下NDK的基本用法而已,更多的用法大家可以到官方文檔上查找.
Makefile
理解完C/C++編譯的原理之后,還有個十分重要的東西還要了解,這個東西就是Makefile.
我們前面的例子都是直接用gcc或著各個交叉編譯的版本的gcc去編譯C/C++代碼的.在代碼量不多的時候這么做還是可行的,但是如果軟件一旦復(fù)雜一些,代碼量一多,那么編譯的命令就會十分的復(fù)雜,而且還需要考慮到多個模塊之間的依賴關(guān)系.
Makefile就是一個幫助我們解決這些問題的工具.它的基本原理十分簡單,先讓我們看看它最最基本的用法:
目標(biāo)文件 : 依賴文件
命令1
命令2
命令3
...
還是舉我們的例子代碼,首先創(chuàng)建一個文件,名字叫Makefile,然后寫上:
test.so : test.c test.h
arm-linux-androideabi-gcc -march=armv7-a -shared -fPIC test.c -o test.so
然后就可以用make命令去編譯了.make命令會找到當(dāng)前目錄下的Makefile,然后比較目標(biāo)文件文件和依賴文件的修改時間,如果依賴文件的修改時間比較晚,或者干脆就還沒有目標(biāo)文件.就會執(zhí)行命令.
如我們的例子,如果還沒有test.so,或者test.c惯雳、test.h的修改時間比test.so要晚,那么就會執(zhí)行arm-linux-androideabi-gcc -march=armv7-a -shared -fPIC test.c -o test.so,然后生成test.so文件.
而如果是目標(biāo)文件比較新,就不會執(zhí)行,它會告訴你目標(biāo)文件已經(jīng)是最新的了:
make: 'test.so' is up to date.
沒有依賴的目標(biāo)文件
然后可能有同學(xué)還有見過make clean,make install,make uninstall...這些命令,它們又是怎么一回事呢?
這里以make clean舉例,我們在Makefile中加入目標(biāo)文件clean:
test.so : test.c test.h
arm-linux-androideabi-gcc -march=armv7-a -shared -fPIC test.c -o test.so
clean :
rm test.so
現(xiàn)在除了test.so這個目標(biāo)文件之后,還多了個目標(biāo)文件clean,它下面的命令是tm test.so.而且特殊的是clean這個目標(biāo)文件,它沒有任何的依賴文件.
然后我們就能使用make clean命令了,因為clean文件不存在,所以就會執(zhí)行下面的rm test.so.所以就會將test.so刪除了.
剛剛我們說的時候clean存在的時候會執(zhí)行命令,那如果我們自己創(chuàng)建了個文件名字叫做clean又會發(fā)生什么事情?
make: 'clean' is up to date.
由于沒有依賴文件,所以不用比較時間,它會直接告訴你clean文件已經(jīng)是最新的了,而不會執(zhí)行命令.
那要如果規(guī)避這個問題呢?例如當(dāng)前目錄下的確需要有個clean文件,但是我又需要make clean這個功能.方法很簡單,只需要加上".PHONY : clean"就可以了:
test.so : test.c test.h
arm-linux-androideabi-gcc -march=armv7-a -shared -fPIC test.c -o test.so
clean :
rm test.so
.PHONY : clean
Makefile自動生成工具
Makefile,這里我也只是簡單代過,其實它還有許多強(qiáng)大的功能,感興趣的同學(xué)可以自行搜索.
但是如果我們的項目都是手動去寫makefile的話也會十分的麻煩,那有沒有辦法可以根據(jù)我們的代碼,自動生成makefile呢?
答案肯定是有的.比如現(xiàn)在安卓使用的CMake還有經(jīng)典的AutoMake工具.
相信大家在用JNI的時候肯定都有配過CMakeLists.txt這個文件,CMake就是通過讀取這個文件的配置去生成代碼的.
而一些比較早期的庫如ffmpeg,就是用automake去生成Makefile的,我之前寫過四篇博客專門將如何使用AutoMake,如果感興趣可以去看看.
- automake學(xué)習(xí)筆記 - helloworld
- automake學(xué)習(xí)筆記 - 模塊化編譯
- automake學(xué)習(xí)筆記 - 安裝與發(fā)布
- automake學(xué)習(xí)筆記 - 交叉編譯
自動生成工具這塊內(nèi)容比較多我就不詳細(xì)講了,它們其實并不是很難,大家自行找資料學(xué)習(xí)就好.