jni學習筆記:動態鏈接庫與靜態鏈接庫的基本使用流程簡記

背景

最近做了一段時間的項目中涉及到一些ffmpeg視頻編解碼的應用和OpenCV算法在Android的使用,其中免不了需要使用jni在java層調用相關算法的內容,尤其當業務邏輯複雜時還需要cpp層調用java層的函數。在此也總結了一些jni使用上的方法以及一些常見的問題。本文我們將總結一些基礎知識。

我們知道,Android集成許多第三方庫的時候,需要導入許多動態鏈接庫也就是.so文件,而我們只要在java層load一下庫的名稱,就可以調用其中的jni函數。下面我們將總結一些Linux下庫的生成與加載的知識,以便我們更深入地理解jni編寫與運作的流程,在編寫jni相關代碼時減少不必要的彎路。

##靜態連接庫的生成與使用
我們先來個簡單的例子,看一下靜態庫的生成與使用。
我們先定義一個fun.c的文件,稍後我們會將其生成爲一個靜態庫libfun.a,然後main.c的主函數在鏈接時會調用這個libfun.a中的fun函數。
###實驗1
main.c

#include <stdio.h>

int main(){
	fun();
}

fun.c

#include <stdio.h>
#include "fun.h"

void fun(){
	printf("I am fun!");
}

fun.h

#ifndef FUN_H
#define FUN_H
void fun();
#endif

這就是我們用到的三個最簡單的文件,現在我們要將先根據fun.c編譯出libfun.a文件,之後再將main.c編譯生成main.o文件,最後進行鏈接,生成最終可執行的文件。下面是我們的Makefile文件。

CC=gcc
AR=ar
LD=ld

.PHONY:clean

all:main

main:main.c libfun
	${CC} -c main.c
	#${CC} -o main.out main.o -L. -lfun
	${LD} -o main.out /usr/lib/crt1.o main.o -L. -lfun -lc
libfun:fun.c
	${CC} -c fun.c
	${AR} cr libfun.a fun.o

clean:
	rm *.o *.a *.out

下面簡要解釋一下這段Makefile的含義。
完成最終的all標籤我們需要依賴main標籤,main標籤需要依賴於main.c和libfun標籤。在libfun中,我們使用 ${CC} -c fun.c命令編譯fun.c,最終生成目標文件fun.o,fun.o中包含了不完整的段信息,是沒有辦法進行直接執行的。接下來我們使用ar命令,將fun.o文件打包成靜態鏈接庫libfun.a。

有了libfun.a,我們就可以執行main標籤中的命令了。還是通過-c選項,先對main.c文件進行編譯,生成目標文件main.o。接下來的兩種辦法都可以讓我們生成最終的可執行文件。

${CC} -o main.out main.o -L. -lfun 這條命令的意思是使用cc提供的工具通過main.o 與libfun.a庫進行鏈接,其中-L用來指定庫文件的路徑。命令最終生成可以執行文件main.out。

${LD} -o main.out /usr/lib/crt1.o main.o -L. -lfun -lc則是我們手動使用鏈接命令進行連接,手動鏈接時我們首先需要手動鏈接crt1.o這個目標文件,它是Glibc的一個輔助運行庫,用來幫助我們找到程序的入口,由於我們調用了printf函數,這個函數是標準c函數,所以我們還要把它也鏈接進來,也就是libc庫鏈接進來,即-lc。正常情況下我們直接使用cc工具鏈接即可,而手動使用ld命令進行鏈接則更有助於我們理解例子程序的生成過程。

運行這段makefile,我們就可以得到 可以獨立運行 的main.out,下面是我們的運行結果。

這裏寫圖片描述

運行main.out即可調用鏈接庫中的fun函數打印"I am fun!"。

接下來我們再思考一個問題,假如靜態庫中有多個函數,而我們只是使用了其中的一些函數,那麼整個靜態庫都會被編譯到可執行文件中嗎?
###實驗2
main.c

#include <stdio.h>
#include "fun.h"

int main(){
	fun();
}

fun.c

#include <stdio.h>
#include "fun.h"

void fun(){
	printf("I am fun!");
}

void fun_extra(){
	printf("I am extra");
}

fun.h

#ifndef FUN_H
#define FUN_H

void fun();
void fun_extra();

#endif

我們增加了一個額外的函數void fun_extra(),但是在main函數中我們並沒有對其進行調用,makefile不變,編譯,鏈接,生成可執行文件。現在我們來猜測一下,生成的文件與實驗1有什麼區別呢,直觀上講生成的庫文件肯定變大了,因爲有了新的函數他需要包含更多的信息,那麼最終的可執行文件呢?也會隨着它所鏈接的庫的增大而增大嗎?下面我們來看一下文件信息。
這裏寫圖片描述
最終的main.out會大於上面產生的main.out。但是我們仍然無法確定main.out中是否有libfun.a的全部內容。

我們在做這樣一個實驗,這一次我們不去重新生成libfun.a,而是使用我們當前生成的libfun.a進行鏈接,但是我們在main.c中調用fun_extra(),看其是否能調用成功。
這裏寫圖片描述
如圖,這裏main.out中調用過fun_extra()與沒有調用過fun_extra()生成文件的大小是不變的, 但是main.o文件是變大的,順着這個思路可以推測最終生成文件中整個.a文件的功能是都被包涵進.out中的。

下面我們使用mac的nm工具看看生成文件的符號表,看看這些文件裏到底包含了哪些函數。

我們先看第一種情況,也就是隻調用了動態鏈接庫中fun函數而沒有調用fun_extra函數。
先看一下main.o,我們執行這條操作:nm -px main.o
這裏寫圖片描述
可見.o文件中只有它調用過的函數的符號。
再看看最終生成的main.out ,輸入 nm -px main.out
這裏寫圖片描述
可見,最終經過鏈接生成的文件確實包括了.a文件中的所有所有函數,即便是沒有調用函數。

###去掉沒有調用的代碼
由於我們的.a文件生成時沒有做其他處理,最終得到函數會被分配到同一個段內,這樣就導致了鏈接的時候把沒有用到的函數也一併帶入到最終生成的文件中。也就是說我們在編譯.a文件的時候,需要將每個函數編譯到獨立的段中,下面我們把每個函數放到獨立的代碼段中,然後進行鏈接,看看效果。實驗中發現直接使用ld鏈接最終生成的文件反而大了,此處還需要繼續研究,下面我們直接使用gcc進行鏈接。

我們先對makefile進行修改,改動部分如下

main:main.c libfun
        ${CC} -c main.c
        ${CC} -dead_strip -o main.out main.o -L. -lfun
        #${LD} -dead_strip -o main.out /usr/lib/crt1.o main.o -L. -lfun -lc
libfun:fun.c
        ${CC} -ffunction-sections -c fun.c
        ${AR} cr libfun.a fun.o

編譯libfun.a的時候,我們加入了-ffunction-sections ,將fun.c中函數中每個函數代碼段分開,鏈接時我們使用${CC} -dead_strip -o main.out main.o -L. -lfun, 去除無用的代碼。現在我們再編譯一下新的項目,看看時啥個情況。
這裏寫圖片描述
可見main.out大小確實小了,現在我們看看main.out中有啥。執行nm -px main.out
這裏寫圖片描述
可見,現在沒有用到函數都沒有了。

##動態連接庫的生成與使用
不同於靜態庫直接鏈接到最終的可執行文件,動態庫是在程序運行時動態分配地址。所以動態庫必須與我們最終要執行的文件一起打包才能使用。ndk最終調用c/cpp也是通過調用其生成的動態鏈接庫,也就是我們常見的.so文件。集成過一些推送或者地圖等sdk時,我們都需要將.so文件拷貝到libs目錄中,這些就是動態鏈接庫。

###實驗1
這裏我們簡要看一下動態鏈接庫的生成。
下面這個動態庫中,我們將會使用上一小節中生成的靜態庫的文件,也就是說我們要把靜態庫libfun.a中的內容編譯進動態庫libfunshare.so。然後我們在運行main.out時動態調用libfunshare.so。在實際ndk開發中這種情況也是經常發生的,比如opencv的android官方庫就是爲我們提供了一堆.a文件,我們調用其中的接口生成我們自己的.so文件,ffmpeg中添加編解碼器也可以通過調用編解碼器的.a文件來生成我們最終在jni中調用的.so文件。

fun_share.c文件是我們的動態鏈接庫的c文件,代碼如下

#include <stdio.h>
#include "fun_share.h"
#include "fun.h"
void fun_share(){
	printf("I am from fun_share");
	fun();
}

這裏的fun函數是上一節生成靜態庫libfun.a中的那個fun函數。最後我們在main函數中調用這個fun_share函數。
main.c

#include <stdio.h>

int main(){
	fun_share();
}

這裏調用動態鏈接庫的fun_share函數。

下面是makefile的代碼,簡要看一下這個demo的生成。

CC=gcc
AR=ar

.PHONY:clean

all:main

main:main.c libfunshare
        ${CC} -c main.c
        #(ldconfig 'pwd')
        ${CC} -o main.out main.o -L. -lfunshare

libfunshare:fun_share.c libfun
        ${CC} -c fun_share.c
        ${CC} -shared -fPIC -o libfunshare.so fun_share.o -L. -lfun

libfun:fun.c
        ${CC} -c fun.c
        ${AR} cr libfun.a fun.o

clean:
        rm *.o *.so *.a *.out

這裏我們着重看一下生成這個.so文件和調用.so文件的地方。

${CC} -shared -fPIC -o libfunshare.so fun_share.o -L. -lfun

這條命令用於動態鏈接庫的生成,-share是告訴gcc生成文件當做動態鏈接庫。-fPIC命了也經常在編譯.so文件時用到,它的意思是說生成不帶絕對地址而帶相對地址的文件(position independent code)。由於動態庫不是在編譯時直接鏈接到位,所以如果加載時需要對其中各段的內容進行重定位,這樣如果有多個進程同時調用這個.so文件,由於每次重定爲前都要重新計算地址,所以這些.so文件的代碼是沒辦法複用的。當然,在編譯動態庫時也可以不加這個選項。

再往後看-L. -lfun是我們上一節中瞭解過的指令,-L指定靜態鏈接庫的位置,-lfun鏈接libfun.a文件。這樣,我們的libfunshare.so文件就生成完畢了。關於.so文件的調用,一般有兩種方式,這裏我們先介紹第一種。

${CC} -o main.out main.o -L. -lfunshare

與鏈接靜態庫一樣,直接指定就可以了,這樣就完成了main.out的生成。執行一下,我們發現動態庫本身的內容和被調用的靜態庫的內容都可以被執行。

###實驗二
下面來看另外一種.so文件的調用方式,這種方式中我們可以在代碼中動態指定調用.so中的哪些方法。
下面先看一下我們的main函數,這裏發生了較大的變化。
main.c

#include <stdio.h>
#include <dlfcn.h>

int main(){
	
	void *handle = dlopen("./libfunshare.so", RTLD_LAZY);
	const char* error = dlerror();
	if(error != NULL){
		printf("load err\n");
		exit(1);
	}	

	void (*fun)();
	fun = dlsym(handle, "fun_share");
	fun();
}

dlfcn.h中的api可以幫助我們動態調用.so中的內容。我們聲明瞭一個函數指針fun,然後用這個函數指針接受dlsym的返回值,這樣就可以fun就稱爲了一個指向.so中fun_share函數的函數指針,執行fun()就可以調用fun_share了。

makefile文件也需要做少許的改動。實驗一我們使用了靜態鏈接。這裏我們將

${CC} -o main.out main.o -L. -lfunshare

替換爲

${CC} -o main.out main.o -rdynamic -ldl

-rdynamic是將符號添加到動態符號表,-ldl說明最後生成的文件需要使用共享庫。以前一直當做固定的寫法這麼寫,但是現在發現去掉這兩個選項生成的文件依然可以正常運行,此處存疑。

##總結
本文通過幾個實驗,總結了靜態鏈接庫(.a)和動態鏈接庫(.so)文件的基本生成與使用的方法。

.a文件我們可以看作是ar文件將很多.o文件壓縮生成的庫,鏈接過程相當於是把其中的各個段鏈接到我們最終生成的文件。我們在編譯.a文件的.o文件時可以指定將函數編譯到單獨的段中,這樣我們在鏈接完畢後可以strip掉沒有調用的函數。

.so文件是運行時動態加載的文件,通過-share選項生成,可以通過選擇-fPIC編譯爲使用相對地址。使用相對地址在加載時可以不改變代碼,使得多個進程可以加載同一個.so文件,但是在有些情況下頁可以不加入這個選項。在調用.so文件的代碼時我們可以在編譯時使用靜態鏈接,也可以在代碼中通過dlfcn.h提供的api直接調用.so文件中的函數,無論使用哪種方法,.so文件在主程序運行時必須存在。

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