最近一頭扎進了 Linux 核心的學習中,對於我這樣一個沒什麼 C 語言基礎的新生代 Java 農民工來說實在太痛苦了。Linux 核心的學習,需要的基礎知識太多太多了:C 語言、組合語言、資料結構與演算法、作業系統原理、計算機組成原理、計算機體系結構。在囫圇吞棗補完一些計算機基礎知識後,還是在一開始就被一個小小的 offsetof 巨集搞暈了。
offsetof 巨集
先來看看offsetof
巨集是什麼,這是定義在 <linux/stddef.h>
中的一個巨集,用來計算一個 struct
結構體中某個成員相對於結構體首地址的偏移量。這是一個很有用的巨集,因為 Linux 核心的資料結構大量用了嵌入式的結構體(什麼是嵌入式結構體,可以參考 <linux/list.h> 的巧妙設計,這個以後再講)。
// offsetof 巨集的定義
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
當看到這個東西完全傻眼了,size_t 是啥東東,((TYPE *)0) 又是啥東東,這個 0 又是什麼鬼?特別看到後面是訪問一個成員,我去,這不是 Java Farmer 眼中的 NPE 嗎?因為這個巨集展開後沒見任何一個結構體的例項。-_-! 於是上網搜尋一番。
size_t
基本知道了就是代表一個整數型別,只是為了程式的可移植、效率等原因定義成這樣,具體解釋可以看《為什麼size_t重要?》這篇文章。
至於 &((TYPE *)0)->MEMBER) 這段程式碼,簡單來說就是取 TYPE 型別的結構體里名字為 MEMBER 的成員的地址,是相對 0 的地址(0 就是 TYPE 結構體的首地址)。C 語言裡指標就是個無符號整數,所有 0 也可以轉成一個 TYPE 型別的指標,那麼不寫 0 行嗎?答案是肯定的,但算偏移量需要後面再減去首地址值,例如((size_t) (&((TYPE *)1000)->MEMBER)-1000)
,這樣也行,但是,這就有點多此一舉了。
另外,很重要的一點:這樣算偏移地址僅僅是從邏輯計算上來寫計算的表示式,實際上程式執行時是不會發生任何計算,而是編譯器直接就能取到這個地址偏移量,因而也不會有任何的訪存操作。下面從一個例子可以證明:
1、先寫個 C 測試程式
#include <stdio.h>
// 定義一個取偏移量的巨集
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
// 定義一個結構體
struct my_struct {
int a,b,c;
struct my_struct *next; // 後面我要算這個成員的偏移量
};
// main 函式很簡單就是輸出偏移量的值
void main() {
printf("offsetof next=%d\n", offsetof(struct my_struct, next));
}
編譯,執行,最後輸出的結果是:offsetof next=16
,為什麼是16?next 前面有三個 int
型別的成員,各佔 4 位元組,那 next 應該是從 12 開始,其實這要看編譯的是 64 位還是 32 位,因為筆者的機器是 64 位的 Redhat,而 gcc 編譯選項沒加 -m32,所以編譯出來的程式自然是 64 位的了,因此 next 指標是 8 個位元組,要 8 位元組對齊的話,自然不能從 12 開始,要從 16 開始。整個結構體的長度是 24 位元組(即 sizeof(struct my_struct) = 24
)。
2、第二部再將上面的 C 程式碼編譯成彙編看看,指令是怎麼執行的
/**
* 以 . 開頭的行我們不用管它,都是些編譯器生成的東西,只看彙編指令即可
**/
.file "mymain.c"
.section .rodata
.LC0:
.string "offsetof next=%d\n"
.text
.globl main
.type main, @function
main:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl $16, %esi /* printf 的第二個引數,看,這裡沒有任何計算,編譯時就知道偏移量是 16,直接存到 esi 暫存器作為 printf 函式的實參 */
movl $.LC0, %edi /* printf 的第一個引數,就是上面的字串常量 */
movl $0, %eax
call printf /* 呼叫 printf 函式,要說明的是,在 x86-64 結構體系中,有 6 個暫存器是可以用於傳參的(這裡用了 esi 和 edi),多於 6 的其餘就壓棧,也就是上面 rsp 所指的棧頂 */
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size main, .-main
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
.section .note.GNU-stack,"",@progbits
好,到這裡,從上面彙編指令可以看到,offsetof 巨集展開後就是一個 16 這個值,編譯器直接就優化算好了,所有彙編指令僅僅是為了呼叫 printf 函式所做的壓棧保護現場,傳參,彈棧恢復現場這些指令。當然,上面說的是在 x86 結構體系下的指令結構。
小結
Linux 是一個非常龐大的系統,幾乎涵蓋了所有計算機基礎知識。學習 Linux 核心是非常艱鉅的,不但需要非常牢固的計算機基礎,還需要想象力,大局觀。學習了一個月,總結幾點經驗:
1、基礎知識要時時溫習,“溫故知新”。每次看都會有不同的感悟和理解。像這篇學習筆記就是對基礎知識的溫故,從彙編指令角度看編譯器對巨集展開做的工作。永遠不要相信網上一些什麼視訊教程說的不需要什麼基礎,學習 Linux 核心需要的基礎知識太多了,而且這些視訊也不必要浪費時間看,浪費錢買,都是一些二手知識;也永遠不要相信什麼“一文讀懂xxx”這類的文章,同樣是一些二手知識,是不是發現看這些文章很容易就忘了?掌握知識從來沒有捷徑。
基礎、基礎,基礎才是最重要的,計算機技術發展了這麼多年,以及近些年來火起來的什麼大資料,AI,其實都不是什麼新東西,本質還是那些計算機基礎知識原理和數學。
基礎知識脫節,沒可能入門 Linux 核心,不要說入門,入窗戶都不可能。所以想學 Linux 核心,從基礎知識開始,無論基礎有多差,只要肯下功夫,不成問題,這些基礎知識包括:
-
計算機組成原理:站在抽象的層次理解計算機的工作原理,CPU 如何取指執行(這個可以說是現代計算機工作的本質),記憶體如何工作,快取記憶體如何工作,中斷的原理,外設如何協同並行工作等等;
-
C 語言:這個不用說了,肯定最重要的,C 語言玩得溜,可以省大量時間;
-
資料結構及演算法:Linux 裡可以說是各種資料結構和演算法的大雜燴,你能想到的裡面都有,同樣這個玩得溜,可以省大量時間;
-
組合語言(計算機體系結構):彙編其實很簡單,沒什麼好學的,這是要與某一個結構體系緊密結合(基本都 x86 最熟吧),不用強記(記也記不住),只要混個臉熟就好,需要用的時候查手冊即可,主要是結構體系的原理,快取記憶體、快取一致,流水線原理;
-
作業系統原理:理論指導實踐,有了理論,才容易形成藍圖。而學習 Linux 核心只是實踐。
2、大局觀,抓主線,雖然 Linux 核心程式碼將近 800MB,其實大部分不怎麼需要看。網上很多教程,其實都不怎麼好,要麼泛泛而談,要麼講些過時的(很多將0.11版的核心,個人覺得沒啥價值,純屬浪費時間),要麼一下子就從某一結構體系講起,初學者很容易被繞暈,還有些直接就從怎麼自己寫一個作業系統開始,我們要學的是 Linux 核心,一開始講這些個人覺得沒學會走路就學飛;不可否認,講這些教程的人也許很牛,但個人認為不是一個好老師。所以:
-
我們學 Linux 的目的是什麼,不同的人有不同的需求,像 Java 過來的新生代農民工,應該著重學習 Linux 核心的設計哲學,例如 kernel 是如何能像我們 Java 物件導向一樣,與各種結構體系(arch)完美適配的,設計的哲學,這些都是網上那些視訊沒講的。再進一步就是細緻到程式管理、記憶體管理、磁碟這些怎麼管理,學會這些,那些老喜歡被問的什麼 kafka 原理啊、零拷貝啊這些簡直就是小菜。作為 Javaer,工作的環境就是 Linux 核心,因此,Linux 太重要了,能學多深就學多深。
-
要多想象,根據上面的基礎知識,想象,愛因斯坦也說過,想象力比知識跟重要。所以,我們在學習 Linux 核心時要多想象,猜測,帶著問題去學,驗證;
-
Linux 是一個巨複雜的系統,Javaer 更應該學習的是如何應對複雜系統的方法;
-
上面三點個人才覺得是一個工程師最有價值的地方,這些工程師才是工匠。
3、多動手,搭建環境學習原始碼,多編寫程式碼驗證,特別是從 Java 轉過來的。“紙上得來終覺淺,絕知此事要躬行”。
4、由於筆者也是剛剛才開始學 Linux 核心不久,水平有限,有不正確的地方多多交流,不勝感激。