最近一頭扎進了 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 內核不久,水平有限,有不正確的地方多多交流,不勝感激。