作者:姜亞華(@二如公子 ),《精通 Linux 核心——智慧裝置開發核心技術》的作者,一直從事與 Linux 內核和 Linux 程式設計相關的工作,研究核心程式碼十多年,對多數模組的細節如數家珍。曾負責華為手機 Touch、Sensor 的驅動和軟體最佳化(包括 Mate、榮耀等系列),以及 Intel 安卓平臺 Camera 和 Sensor 的驅動開發(包括 Baytrail、Cherrytrail、Cherrytrail CR、Sofia 等)。現負責 DMA、Interrupt、Semaphore 等模組的最佳化與驗證(包括 Vega、Navi 系列和多款 APU 產品)。
往期回顧:
雲端計算時代,容器底層 cgroup 如何實現資源分組?
雲端計算時代,容器底層 cgroup 的程式碼實現分析
雲端計算時代,容器底層 cgroup 如何使用
在往期的文章中,姜亞華老師給大家分享了核心中的重要功能 —— 容器底層 cgroup 的相關知識,不少網友表示核心實在太高深,程式碼也較難理解。本期內容我們將站在非核心開發者的角度,給大家介紹應用和系統工程師如何梳理 Linux 核心程式碼。希望大家讀完之後能有所收穫,也希望更多的開發者能夠關注到核心開發領域,畢竟連祖師爺 Linus 都表示核心維護者要後繼無人了呀!
測試環境版本資訊:
Ubuntu (lsb_release -a) | Distributor ID: Ubuntu Description: Ubuntu 19.10 Release: 19.10 |
Linux (uname -a) | Linux yahua 5.5.5 #1 SMP … x86_64 x86_64 x86_64 GNU/Linux |
Java | Openjdk jdk14 |
玩內核的人怎麼也懂 Java?這主要得益於我學校的 Java 課程和畢業那會在華為做 Android 手機的經歷,幾個模組從 APP/Framework/Service/HAL/Driver 掃過一遍,自然對 Java 有所瞭解。
每次提起 Java,我都會想到一段有趣的經歷。剛畢業到部門報到第一個星期,部門領導(在華為算是 Manager)安排我們熟悉 Android。我花了幾天寫了個 Android 遊戲,有些類似連連看那種。開週會的時候,領導看到我的演示後,一臉不悅,質疑我的直接領導(在華為叫 PL,Project Leader)沒有給我們講明白部門的方向。
emm,我當時確實沒明白所謂的熟悉 Android 是該幹啥,後來 PL 說,是要熟悉 xxx 模組,APP 只是其中一部分。話說如果當時得到的是肯定,也許我現在就是一枚 Java 工程師了(哈哈手動狗頭)。
世界上最遠的距離,是咱倆坐隔壁,我在看底層協議,而你在研究 spring……如果想拉近咱倆的距離,先下載 openjdk 原始碼(openjdk),然後下載 glibc(glibc),再下載核心原始碼(kernel)。
Java 程式到 JVM,這個大家肯定比我熟悉,就不班門弄斧了。
我們就從 JVM 的入口為例,分析 JVM 到內核的流程,入口就是 main 函式了(java.base/share/native/launcher/main.c):
JNIEXPORT int
main(int argc, char **argv)
{
//中間省略一萬行引數處理程式碼
return JLI_Launch(margc, margv,
jargc, (const char**) jargv,
0, NULL,
VERSION_STRING,
DOT_VERSION,
(const_progname != NULL) ? const_progname : *margv,
(const_launcher != NULL) ? const_launcher : *margv,
jargc > 0,
const_cpwildcard, const_javaw, 0);
}
JLI_Launch 做了三件我們關心的事。
首先,呼叫 CreateExecutionEnvironment 查詢設定環境變數,比如 JVM 的路徑(下面的變數 jvmpath),以我的平臺為例,就是 /usr/lib/jvm/java-14-openjdk-amd64/lib/server/libjvm.so,window 平臺可能就是 libjvm.dll。
其次,呼叫 LoadJavaVM 載入 JVM,就是 libjvm.so 檔案,然後找到建立 JVM 的函式賦值給 InvocationFunctions 的對應欄位:
jboolean LoadJavaVM(const char *jvmpath, InvocationFunctions *ifn)
{
void *libjvm;
//省略出錯處理
libjvm = dlopen(jvmpath, RTLD_NOW + RTLD_GLOBAL);
ifn->CreateJavaVM = (CreateJavaVM_t)
dlsym(libjvm, "JNI_CreateJavaVM");
ifn->GetDefaultJavaVMInitArgs = (GetDefaultJavaVMInitArgs_t)
dlsym(libjvm, "JNI_GetDefaultJavaVMInitArgs");
ifn->GetCreatedJavaVMs = (GetCreatedJavaVMs_t)
dlsym(libjvm, "JNI_GetCreatedJavaVMs");
return JNI_TRUE;
}
dlopen 和 dlsym 涉及動態連結,簡單理解就是 libjvm.so 包含 JNI_CreateJavaVM、JNI_GetDefaultJavaVMInitArgs 和 JNI_GetCreatedJavaVMs 的定義,動態連結完成後,ifn->CreateJavaVM、ifn->GetDefaultJavaVMInitArgs 和 ifn->GetCreatedJavaVMs 就是這些函式的地址。
不妨確認下 libjvm.so 有這三個函式。
objdump -D /usr/lib/jvm/java-14-openjdk-amd64/lib/server/libjvm.so | grep -E
"CreateJavaVM|GetDefaultJavaVMInitArgs|GetCreatedJavaVMs" | grep ":$"
00000000008fa9d0 <JNI_GetDefaultJavaVMInitArgs@@SUNWprivate_1.1>:
00000000008faa20 <JNI_GetCreatedJavaVMs@@SUNWprivate_1.1>:
00000000009098e0 <JNI_CreateJavaVM@@SUNWprivate_1.1>:
openjdk 原始碼裡有這些實現的(hotspot/share/prims/下),有興趣的同學可以繼續鑽研。
最後,呼叫 JVMInit 初始化 JVM,load Java 程式。
JVMInit 呼叫 ContinueInNewThread,後者呼叫 CallJavaMainInNewThread。插一句,我是真的不喜歡按照函式呼叫的方式講述問題,a 呼叫 b,b 又呼叫 c,簡直是在浪費篇幅,但是有些地方跨度太大又怕引起誤會(尤其對初學者而言)。相信我,注水,是真沒有,我不需要經驗+3 哈哈。
CallJavaMainInNewThread 的主要邏輯如下:
int CallJavaMainInNewThread(jlong stack_size, void* args) {
int rslt;
pthread_t tid;
pthread_attr_t attr;
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_JOINABLE);
if (stack_size > 0) {
pthread_attr_setstacksize(&attr, stack_size);
}
pthread_attr_setguardsize(&attr, 0); // no pthread guard page on java threads
if (pthread_create(&tid, &attr, ThreadJavaMain, args) == 0) {
void* tmp;
pthread_join(tid, &tmp);
rslt = (int)(intptr_t)tmp;
}
else {
rslt = JavaMain(args);
}
pthread_attr_destroy(&attr);
return rslt;
}
看到 pthread_create 了吧,破案了,Java 的執行緒就是透過 pthread 實現的。此處就可以進入核心了,但是我們還是先繼續看看 JVM。ThreadJavaMain 直接呼叫了 JavaMain,所以這裡的邏輯就是,如果建立執行緒成功,就由新執行緒執行 JavaMain,否則就知道在當前程序執行JavaMain。
JavaMain 是我們關注的重點,核心邏輯如下:
int JavaMain(void* _args)
{
JavaMainArgs *args = (JavaMainArgs *)_args;
int argc = args->argc;
char **argv = args->argv;
int mode = args->mode;
char *what = args->what;
InvocationFunctions ifn = args->ifn;
JavaVM *vm = 0;
JNIEnv *env = 0;
jclass mainClass = NULL;
jclass appClass = NULL; // actual application class being launched
jmethodID mainID;
jobjectArray mainArgs;
int ret = 0;
jlong start, end;
/* Initialize the virtual machine */
if (!InitializeJVM(&vm, &env, &ifn)) { //1
JLI_ReportErrorMessage(JVM_ERROR1);
exit(1);
}
mainClass = LoadMainClass(env, mode, what); //2
CHECK_EXCEPTION_NULL_LEAVE(mainClass);
mainArgs = CreateApplicationArgs(env, argv, argc);
CHECK_EXCEPTION_NULL_LEAVE(mainArgs);
mainID = (*env)->GetStaticMethodID(env, mainClass, "main",
"([Ljava/lang/String;)V"); //3
CHECK_EXCEPTION_NULL_LEAVE(mainID);
/* Invoke main method. */
(*env)->CallStaticVoidMethod(env, mainClass, mainID, mainArgs); //4
ret = (*env)->ExceptionOccurred(env) == NULL ? 0 : 1;
LEAVE();
}
第 1 步,呼叫 InitializeJVM 初始化 JVM。InitializeJVM 會呼叫 ifn->CreateJavaVM,也就是libjvm.so 中的 JNI_CreateJavaVM。
第 2 步,LoadMainClass,最終呼叫的是 JVM_FindClassFromBootLoader,也是透過動態連結找到函式(定義在 hotspot/share/prims/ 下),然後呼叫它。
第 3 和第 4 步,Java 的同學應該知道,這就是呼叫 main 函式。
有點跑題了……我們繼續以 pthread_create 為例看看核心吧。
其實,pthread_create 離核心還有一小段距離,就是 glibc(nptl/pthread_create.c)。建立執行緒最終是透過 clone 系統呼叫實現的,我們不關心 glibc 的細節(否則又跑偏了),就看看它跟直接 clone 的不同。
以下關於執行緒的討論從書裡摘抄過來。
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
| CLONE_SIGHAND | CLONE_THREAD
| CLONE_SETTLS | CLONE_PARENT_SETTID
| CLONE_CHILD_CLEARTID
| 0);
__clone (&start_thread, stackaddr, clone_flags, pd, &pd->tid, tp, &pd->tid);
各個標誌的說明如下表(這句話不是摘抄的。。。)。
標誌 | 描述 |
CLONE_VM | 與當前程序共享VM |
CLONE_FS | 共享檔案系統資訊 |
CLONE_FILES | 共享開啟的檔案 |
CLONE_PARENT | 與當前程序共有同樣的父程序 |
CLONE_THREAD | 與當前程序同屬一個執行緒組,也意味著建立的是執行緒 |
CLONE_SYSVSEM | 共享sem_undo_list |
…… | …… |
與當前程序共享 VM、共享檔案系統資訊、共享開啟的檔案……看到這些我們就懂了,所謂的執行緒是這麼回事。
Linux 實際上並沒有從本質上將程序和執行緒分開,執行緒又被稱為輕量級程序(Low Weight Process, LWP),區別就在於執行緒與建立它的程序(執行緒)共享記憶體、檔案等資源。
完整的段落如下(雙引號擴起來的幾個段落),有興趣的同學可以詳細閱讀:
“ fork 傳遞至 _do_fork 的 clone_flags 引數是固定的,所以它只能用來建立程序,核心提供了另一個系統呼叫 clone,clone 最終也呼叫 _do_fork 實現,與 fork 不同的是使用者可以根據需要確定 clone_flags,我們可以使用它建立執行緒,如下(不同平臺下 clone 的引數可能不同):
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
int __user *, parent_tidptr, int, tls_val, int __user *, child_tidptr)
{
return _do_fork(clone_flags, newsp, 0, parent_tidptr, child_tidptr);
}
Linux 將執行緒當作輕量級程序,但執行緒的特性並不是由 Linux 隨意決定的,應該儘量與其他作業系統相容,為此它遵循 POSIX 標準對執行緒的要求。所以,要建立執行緒,傳遞給 clone 系統呼叫的引數也應該是基本固定的。
建立執行緒的引數比較複雜,慶幸的是 pthread(POSIX thread)為我們提供了函式,呼叫pthread_create 即可,函式原型(使用者空間)如下。
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
第一個引數 thread 是一個輸出引數,執行緒建立成功後,執行緒的 id 存入其中,第二個引數用來定製新執行緒的屬性。新執行緒建立成功會執行 start_routine 指向的函式,傳遞至該函式的引數就是arg。
pthread_create 究竟如何呼叫 clone 的呢,大致如下:
//來源: glibc
const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM
| CLONE_SIGHAND | CLONE_THREAD
| CLONE_SETTLS | CLONE_PARENT_SETTID
| CLONE_CHILD_CLEARTID
| 0);
__clone (&start_thread, stackaddr, clone_flags, pd, &pd->tid, tp, &pd->tid);
clone_flags 置位的標誌較多,前幾個標誌表示執行緒與當前程序(有可能也是執行緒)共享資源,CLONE_THREAD 意味著新執行緒和當前程序並不是父子關係。
clone 系統呼叫最終也透過 _do_fork 實現,所以它與建立程序的 fork 的區別僅限於因引數不同而導致的差異,有以下兩個疑問需要解釋。
首先,vfork 置位了 CLONE_VM 標誌,導致新程序對區域性變數的修改會影響當前程序。那麼同樣置位了 CLONE_VM 的 clone,也存在這個隱患嗎?答案是沒有,因為新執行緒指定了自己的使用者棧,由 stackaddr 指定。copy_thread 函式的 sp 引數就是 stackaddr,childregs->sp = sp 修改了新執行緒的 pt_regs,所以新執行緒在使用者空間執行的時候,使用的棧與當前程序的不同,不會造成幹擾。那為什麼 vfork 不這麼做,請參考 vfork 的設計意圖。
其次,fork 返回了兩次,clone 也是一樣,但它們都是返回到系統呼叫後開始執行,pthread_create 如何讓新執行緒執行 start_routine 的?start_routine 是由 start_thread 函式間接執行的,所以我們只需要清楚 start_thread 是如何被呼叫的。start_thread 並沒有傳遞給 clone 系統呼叫,所以它的呼叫與核心無關,答案就在 __clone 函式中。
為了徹底明白新程序是如何使用它的使用者棧和 start_thread 的呼叫過程,有必要分析 __clone 函式了,即使它是平臺相關的,而且還是由組合語言寫的。
/*i386*/
ENTRY (__clone)
movl $-EINVAL,%eax
movl FUNC(%esp),%ecx /* no NULL function pointers */
testl %ecx,%ecx
jz SYSCALL_ERROR_LABEL
movl STACK(%esp),%ecx /* no NULL stack pointers */ //1
testl %ecx,%ecx
jz SYSCALL_ERROR_LABEL
andl $0xfffffff0, %ecx /*對齊*/ //2
subl $28,%ecx
movl ARG(%esp),%eax /* no negative argument counts */
movl %eax,12(%ecx)
movl FUNC(%esp),%eax
movl %eax,8(%ecx)
movl $0,4(%ecx)
pushl %ebx //3
pushl %esi
pushl %edi
movl TLS+12(%esp),%esi //4
movl PTID+12(%esp),%edx
movl FLAGS+12(%esp),%ebx
movl CTID+12(%esp),%edi
movl $SYS_ify(clone),%eax
movl %ebx, (%ecx) //5
int $0x80 //6
popl %edi //7
popl %esi
popl %ebx
test %eax,%eax //8
jl SYSCALL_ERROR_LABEL
jz L(thread_start)
ret //9
L(thread_start): //10
movl %esi,%ebp /* terminate the stack frame */
testl $CLONE_VM, %edi
je L(newpid)
L(haspid):
call *%ebx
/*…*/
以 __clone (&start_thread, stackaddr, clone_flags, pd, &pd->tid, tp, &pd->tid) 為例,
FUNC(%esp) 對應 &start_thread,
STACK(%esp) 對應 stackaddr,
ARG(%esp) 對應 pd(新程序傳遞給 start_thread 的引數)。
如此看來,Java → JVM → glibc → 核心,好像也沒有多遠。
往期回顧:
雲端計算時代,容器底層 cgroup 如何實現資源分組?
雲端計算時代,容器底層 cgroup 的程式碼實現分析
雲端計算時代,容器底層 cgroup 如何使用
[admin
]