最近一直在和Linux C開發(fā)打交道,開發(fā)過程中會用到交叉編譯和Makefile相關(guān)知識寄狼,但是對這塊真的是沒有了解,所以在網(wǎng)上搜索氨淌,找到一篇不錯(cuò)的博客泊愧。本文大部分摘抄自該博客寫給安卓程序員的C/C++編譯入門(作者:嘉偉咯)。如有侵權(quán)請聯(lián)系刪除盛正。
為什么要學(xué)C/C++編譯
很多的安卓程序員可能都會用Android Studio寫一些簡單的C/C++代碼,然后通過jni去調(diào)用,但是對C/C++是如何編譯的其實(shí)并沒有什么概念.有人可能會問,為什么安卓程序員會需要了解C/C++是如何編譯的呢?
我一直都認(rèn)為,要成為一個(gè)真正的高級安卓應(yīng)用開發(fā)工程師,安卓源碼和C/C++是兩座繞不過的大山.安卓源碼自然不必多說,而C/C++流行了幾十年,存在著許多優(yōu)秀的開源項(xiàng)目,我們在處理一些特定的需求的時(shí)候,可能會需要使用到它們.如腳本語言Lua,計(jì)算機(jī)視覺庫OpenCV,音視頻編解碼庫ffmpeg,谷歌的gRPC,國產(chǎn)游戲引擎Cocos2dx...有些庫提供了完整的安卓接口,有些提供了部分安卓接口,有些則沒有.在做一些高級功能時(shí),我們常常需要使用源碼,通過裁剪和交叉編譯,才能編譯出可以在安卓上使用的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),因?yàn)樗局荒芴幚鞢語言.但GCC很快地?cái)U(kuò)展,變得可處理C++。后來又?jǐn)U展能夠支持更多編程語言,如Fortran续崖、Pascal敲街、Objective-C、Java严望、Ada多艇、Go以及各類處理器架構(gòu)上的匯編語言等,所以改名GNU編譯器套件(GNU Compiler Collection)。
使用gcc其實(shí)只需要一個(gè)命令就能將一個(gè)c文件編譯成可運(yùn)行程序了:
gcc test.c -o test
通過上面這條命令可以將test.c編譯成可運(yùn)行程序test.但是其實(shí)C/C++的編譯是經(jīng)過了好幾個(gè)步驟的,我這邊先給大家大概的講一講像吻。
C/C++的編譯流程
C/C++的編譯可以分為下面幾個(gè)步驟:
預(yù)處理
相信學(xué)過C/C++的同學(xué)都知道"宏"這個(gè)東西,它在編譯的時(shí)候會被展開替換成實(shí)際的代碼,這個(gè)展開的步驟就是在預(yù)處理的時(shí)候進(jìn)行的.當(dāng)然,預(yù)處理并不僅僅只是做宏的展開,它還做了類似頭文件插入峻黍、刪除注釋等操作.
預(yù)處理之后的產(chǎn)品依然還是C/C++代碼,它在代碼的邏輯上和輸入的C/C++源代碼是完全一樣的.
我們來舉一個(gè)簡單的例子,寫一個(gè)test.h文件和一個(gè)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;
}
然后可以通過下面這個(gè)gcc命令預(yù)處理test.c文件,并且把預(yù)處理結(jié)果寫到test.i:
gcc -E test.c -o test.i
然后就能看到預(yù)處理之后的test.c到底長什么樣子了:
可以看到這里它把test.h的內(nèi)容(add方法的聲明)插入到了test.c的代碼中,然后將A、B兩個(gè)宏展開成了1和2,將注釋去掉了,還在頭部加上了一些信息.但是光看代碼邏輯,和之前我們寫的代碼是完全一樣的.
匯編代碼
可能大家都聽過匯編語言這個(gè)東西,但是年輕一點(diǎn)的同學(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
鏈接
由于我們的例子代碼比較簡單只有一個(gè)test.h和test.h,所以只生成了一個(gè).o文件,其實(shí)一般的程序都是由多個(gè)模塊組合成的.鏈接這一步就是將多個(gè)模塊的代碼組合成一個(gè)可執(zhí)行程序.我們可以用gcc命令將多個(gè).o文件或者靜態(tài)庫拨匆、動態(tài)庫鏈接成一個(gè)可執(zhí)行文件:
gcc test.o -o test
得到的就是可執(zhí)行文件test了,可以直接用下面命令運(yùn)行
./test
當(dāng)然是沒有任何輸出的,因?yàn)槲覀兙蜎]有做任何的打印
編譯so庫
在安卓中我們一般不會直接使用C/C++編譯出來的可運(yùn)行文件.用的更多的應(yīng)該是so庫.那要如何編譯so庫呢?
首先我們需要將test.c中的main函數(shù)去掉,因?yàn)閟o庫中是不會帶有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
其實(shí)也就是多了個(gè)-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,是要再加載時(shí)根據(jù)加載到的位置再次重定位的.因?yàn)樗锩娴拇a并不是位置無關(guān)代碼.如果被多個(gè)應(yīng)用程序共同使用,那么它們必須每個(gè)程序維護(hù)一份.so的代碼副本了.因?yàn)?so被每個(gè)程序加載的位置都不同,顯然這些重定位后的代碼也不同,當(dāng)然不能共享.
交叉編譯
通過上面的例子,我們知道了一個(gè)C/C++程序是怎么從源代碼一步步編譯成可運(yùn)行程序或者so庫的.但是我們編譯出來的程序或者so庫只能在相同系統(tǒng)的電腦上使用.
例如我使用的電腦是Linux系統(tǒng)的,那它編譯出來的程序也就只能在Linux上運(yùn)行,不能在安卓或者Windows上運(yùn)行.
當(dāng)然正常情況下不會有人專門去到android系統(tǒng)下編譯出程序來給安卓去用.一般我們都是在PC上編譯出安卓可用的程序,在給到安卓去跑的.這種是在一個(gè)平臺上生成另一個(gè)平臺上的可執(zhí)行代碼的編譯方式就叫做交叉編譯.
交叉編譯有是三個(gè)比較重要的概念要先說明一下:
- build : 當(dāng)前你使用的計(jì)算機(jī)
- host : 你的目的是編譯出來的程序可以在host上運(yùn)行
- target : 普通程序沒有這個(gè)概念姆涩。對于想編譯出編譯器的人來說此屬性決定了新編譯器編譯出的程序可以運(yùn)行在哪
如果我們想要交叉編譯出安卓可運(yùn)行的程序或者庫的話就不能直接使用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這個(gè)程序.
它就是gcc的安卓交叉編譯版本.我們將之前使用gcc去編譯的例子全部換成使用它去編譯就能編譯出運(yùn)行在安卓上的程序了:
如下面命令生成的so庫就能在安卓上通過jni調(diào)用了:
$HOME/Android/standalone-toolchains/android-toolchain-arm/bin/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的時(shí)候也有看到編譯出來的庫有很多個(gè)版本像armeabi、armeabi-v7a惭每、mips骨饿、x86等.
那這些不同CPU架構(gòu)的程序又要如何編譯了.
我們可以在$NDK_ROOT/toolchains目錄下看到者幾個(gè)目錄:
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è)工具鏈,然后就能使用該工具鏈去編譯該架構(gòu)版本的程序了.
但是,我們看到這下面并沒有armeabi-v7a的工具鏈,那armeabi-v7a的程序要如何編譯呢?
其實(shí)armeabi-v7a的程序也是用arm-linux-androideabi-4.9去編譯的,只不過在編譯的時(shí)候可以帶上-march=armv7-a:
arm-linux-androideabi-gcc -march=armv7-a -shared -fPIC test.c -o test.so
Makefile
我們前面的例子都是直接用gcc或著各個(gè)交叉編譯的版本的gcc去編譯C/C++代碼的.在代碼量不多的時(shí)候這么做還是可行的,但是如果軟件一旦復(fù)雜一些,代碼量一多,那么編譯的命令就會十分的復(fù)雜,而且還需要考慮到多個(gè)模塊之間的依賴關(guān)系.
Makefile就是一個(gè)幫助我們解決這些問題的工具.它的基本原理十分簡單,先讓我們看看它最最基本的用法:
target ... : prerequisites ...
command
...
...
target可以是一個(gè)object file(目標(biāo)文件),也可以是一個(gè)執(zhí)行文件,還可以是一個(gè)標(biāo)簽(label)样刷。
prerequisites就是仑扑,要生成那個(gè)target所需要的文件或是目標(biāo)。
command也就是make需要執(zhí)行的命令置鼻。(任意的shell命令)
這是一個(gè)文件的依賴關(guān)系镇饮,也就是說,target這一個(gè)或多個(gè)的目標(biāo)文件依賴于prerequisites中的文件箕母,其生成規(guī)則定義在 command中储藐。說白一點(diǎn)就是說,prerequisites中如果有一個(gè)以上的文件比target文件要新的話嘶是,command所定義的命令就會被執(zhí)行钙勃。這就是makefile的規(guī)則。也就是makefile中最核心的內(nèi)容聂喇。
還是舉我們的例子代碼,首先創(chuàng)建一個(gè)文件,名字叫Makefile,然后寫上:
test.so : test.c test.h
arm-linux-androideabi-gcc -march=armv7-a -shared -fPIC test.c -o test.so
clean :
rm test.so
然后就可以用make命令去編譯了.make命令會找到當(dāng)前目錄下的Makefile,然后比較目標(biāo)文件文件和依賴文件的修改時(shí)間,如果依賴文件的修改時(shí)間比較晚,或者干脆就還沒有目標(biāo)文件.就會執(zhí)行命令.
clean不是一個(gè)文件辖源,它只不過是一個(gè)動作名字,有點(diǎn)像c語言中的lable一樣希太,其冒號后什么也沒有克饶,那么,make就不會自動去找它的依賴性誊辉,也就不會自動執(zhí)行其后所定義的命令矾湃。要執(zhí)行其后的命令(不僅用于clean,其他lable同樣適用)堕澄,就要在make命令后明顯得指出這個(gè)lable的名字邀跃。這樣的方法非常有用,我們可以在一個(gè)makefile中定義不用的編譯或是和編譯無關(guān)的命令蛙紫,比如程序的打包拍屑,程序的備份,等等惊来。
這只是比較簡單的用法丽涩,具體的Makefile知識請查看跟我一起寫Makefile.
CMake
CMake是一種跨平臺編譯工具,比make更為高級裁蚁,使用起來要方便得多矢渊。CMake主要是編寫CMakeLists.txt文件,然后用cmake命令將CMakeLists.txt文件轉(zhuǎn)化為make所需要的makefile文件枉证,最后用make命令編譯源碼生成可執(zhí)行程序或共享庫(so(shared object))矮男。
#1.cmake verson,指定cmake版本
cmake_minimum_required(VERSION 3.2)
#2.project name室谚,指定項(xiàng)目的名稱毡鉴,一般和項(xiàng)目的文件夾名稱對應(yīng)
project(myPro)
#3.head file path崔泵,頭文件目錄
include_directories(include)
#4.添加需要鏈接的庫文件目錄
link_directories(include)
#5.source directory,源文件目錄
aux_source_directory(src DIR_SRCS)
#6.set environment variable猪瞬,設(shè)置環(huán)境變量憎瘸,編譯用到的源文件全部都要放到這里,否則編譯能夠通過陈瘦,但是執(zhí)行的時(shí)候會出現(xiàn)各種問題幌甘,比如"symbol lookup error xxxxx , undefined symbol"
set(TEST_MATH ${DIR_SRCS})
#7.add executable file,添加要編譯的可執(zhí)行文件
add_executable(${PROJECT_NAME} ${TEST_MATH})
#8.add link library痊项,添加可執(zhí)行文件所需要的庫锅风,比如我們用到了libm.so(命名規(guī)則:lib+name+.so),就添加該庫的名稱
target_link_libraries(${PROJECT_NAME} m)