Linux 原始碼下載路徑位於 https://mirrors.edge.kernel.org/pub/linux/kernel/,這篇部落格所需要的 0.01 版本原始碼通過點選鏈接 https://mirrors.edge.kernel.org/pub/linux/kernel/Historic/linux-0.01.tar.gz 可下載。
Linux 0.01 版本內核是一個 80386 平臺版本的內核,所以需要瞭解 x86 平臺的一些彙編知識以及 AT&T 彙編嵌入程式設計,不過沒關係,我們可以邊閱讀程式碼邊學習,遇到不懂的就暫停存檔,然後就搜尋學習,最後返回繼續讀檔學習,這也是我學習 Linux 內核的基本思路。由於我的工作是在 ARM64 平臺下,所以當之後的版本有了 ARM64 架構之後,將轉移陣地到 ARM64 上面,影響應該不大。Linux 0.01 處理的實體記憶體大小是 8MB。
分段和分頁的很多理論知識在網上可以找到很多,這裏不再贅述這部分內容,我們聚焦的點是在 Linux 0.01 版本中如何實現分段和分頁。在保護模式下程式使用的是邏輯地址,邏輯地址對映到實體地址要經過線性地址,這部分的對映是分段的內容,而分頁是將線性地址對映到實體地址。在 x86 中,分段是必須的,分頁是可選的,Linux 中分頁功能是開啓的。
如上圖所示,在 x86 平臺上,邏輯地址由兩部分組成:16 位的段選擇符和 32 位的偏移值,總共 48 位,線性地址和實體地址則都是 32 位。分段機制 機製和分頁機制 機製都是通過查表實現的,這部分內容可以參考https://blog.csdn.net/leoufung/article/details/86770794 這篇文章,說的很詳細。我們在這裏將要說一下原理。
分段有四個術語:段選擇符、偏移值、段描述符和段描述符表。段選擇符和偏移值構成了邏輯地址,段描述符表是一個數組,段描述符是構成段描述符表的一個個 entry,每個段描述符佔 8 個位元組,作用是提供一個段的基地址、長度以及保護屬性資訊,這裏我們主要關注的是段的基地址這個資訊,這個基地址指的是一個段位元組 0 線上性空間的位置。段描述符表有兩種:全域性描述符表(GDT)和區域性描述符表(LDT)。在 Linux 中,每個任務都有一個 LDT。下面 下麪是邏輯地址轉線性地址的參考圖:
剛纔說段描述符表有兩種,但具體選擇哪種,依靠段選擇符中的一個位元位是 0 還是 1 來決定,這裏不細說。總結一下就是邏輯地址劃分爲兩部分:段選擇符和偏移值,通過段選擇符定位到在段描述符表中的索引,拿到段描述符表中的內容,提取出段基地址,然後加上偏移值得到的線性地址就是該邏輯地址線上性空間對映的地址了。
前提知識:每一頁 4K。分頁的做法和分段很類似,在 Linux 0.01 版本中,使用的是二級分頁,有一個頁目錄和一個頁表。具體做法如下:
這邊不詳細展開說了,如果上圖看不懂,可評論或搜尋其他資料。
我們探索的原始碼位於內核根目錄下的 mm 目錄,該目錄下有兩個檔案 memory.c 和 page.s,其中 memory.c 檔案時我們關注的重點,裏面的程式實現了對實體記憶體的管理。
在 Linux 0.01 版本內核中能處理的最大實體記憶體是 8MB,include/linux/config.h
中定義了該記憶體大小:
/* #define LASU_HD */
#define LINUS_HD
/*
* Amount of ram memory (in bytes, 640k-1M not discounted). Currently 8Mb.
* Don't make this bigger without making sure that there are enough page
* directory entries (boot/head.s)
*/
#if defined(LINUS_HD)
#define HIGH_MEMORY (0x800000)
#elif defined(LASU_HD)
#define HIGH_MEMORY (0x400000)
#else
#error "must define hd"
#endif
/* End of buffer memory. Must be 0xA0000, or > 0x100000, 4096-byte aligned */
#if (HIGH_MEMORY>=0x600000)
#define BUFFER_END 0x200000
#else
#define BUFFER_END 0xA0000
#endif
這段程式碼中有兩個宏 LASU_HD
和 LINUS_HD
,不清楚這兩個宏是啥,知道的請不吝賜教。不過程式碼中預設定義了宏 LINUS_HD
,我們就按這個宏來走接下來的流程就好。因此 HIGH_MEMORY
是 8MB
,BUFFER_END
是 2MB
。BUFFER_END
是緩衝區末端。
在 memory.c
檔案中定義了 LOW_MEM
如下:
#if (BUFFER_END < 0x100000)
#define LOW_MEM 0x100000
#else
#define LOW_MEM BUFFER_END
#endif
因此,LOW_MEM
位於 2MB
處。所以整個記憶體模型如下:
記憶體管理的區域是主記憶體區,分配和釋放頁面都是在主記憶體區。
Linux 0.01 版本內核通過一個數組來管理主記憶體區,這個陣列名爲 mem_map
。
/* these are not to be changed - thay are calculated from the above */
#define PAGING_MEMORY (HIGH_MEMORY - LOW_MEM)
#define PAGING_PAGES (PAGING_MEMORY/4096)
#define MAP_NR(addr) (((addr)-LOW_MEM)>>12)
static unsigned short mem_map [ PAGING_PAGES ] = {0,};
這段程式碼的思想是將主記憶體區分爲 4K 爲一段來管理,mem_map
的一個 entry 項代表一個 4K 的頁,當其值爲 0 時,表示該頁沒有被佔用,大於 0 表示被佔用,大於 1 表示頁面被共用。MAP_NR
求給定地址 addr
所在頁面在 mem_map
陣列的索引。
通過 get_free_page
函數在主記憶體區申請一頁空閒記憶體,申請成功返回實體記憶體的頁基址,若無空閒記憶體頁則返回 0。
/*
* Get physical address of first (actually last :-) free page, and mark it
* used. If no free pages left, return 0.
*/
unsigned long get_free_page(void)
{
register unsigned long __res asm("ax"); ------------------------------- (1)
__asm__("std ; repne ; scasw\n\t" ------------------------------- (2)
"jne 1f\n\t" ------------------------------- (3)
"movw $1,2(%%edi)\n\t" ------------------------------- (4)
"sall $12,%%ecx\n\t" ------------------------------- (5)
"movl %%ecx,%%edx\n\t" ------------------------------- (6)
"addl %2,%%edx\n\t" ------------------------------- (7)
"movl $1024,%%ecx\n\t" ------------------------------- (8)
"leal 4092(%%edx),%%edi\n\t"
"rep ; stosl\n\t"
"movl %%edx,%%eax\n" ------------------------------- (9)
"1:"
:"=a" (__res)
:"0" (0),"i" (LOW_MEM),"c" (PAGING_PAGES),
"D" (mem_map+PAGING_PAGES-1)
:"di","cx","dx");
return __res;
}
__res
變數,register
關鍵字指示編譯器將 __res
變數放在暫存器中,後面的 asm("ax")
指定暫存器爲 ax
暫存器,這裏使用暫存器變數是因爲在嵌入彙編語言中想要將彙編指令的輸出直接寫到指定的暫存器中的話,使用暫存器變數會很方便。暫存器變數的一些知識參考 《Linux 內核完全註釋》一書如下:__asm__
表示這是嵌入彙編,關於這部分知識可以自己在網上搜尋學習下格式。\n\t
是爲了編譯器預處理出來的彙編程式對齊好看,可以方便檢視偵錯彙編程式。重點是 std ; repne ; scasw
,這裏有三個指令,std
指令使方向標誌位 DF
置位,即 DF=1
.這個指令用於串操作指令中。先瞭解下串操作指令,在串操作指令中,源運算元和目的運算元分別使用暫存器 (e)si
和 (e)di
進行間接定址,每執行一次串操作,源指針 (e)si
和目的指針 (e)di
將自動進行修改:±1、±2、±4,其對應的分別是位元組操作、字操作和雙字操作。這裏的 ±1、±2、±4 是加還是減就是取決於 DF
方向標誌位,當 DF=1
時爲減,當 DF=0
時爲加(與 std
指令相對的 cld
指令)。REPNE
指令一般用來掃描字串,它用來重複後一個指令,這裏是 scasw
指令,REPNE
的重複條件是 ZF=0 且 ecx > 0
,每回圈執行一次,ecx
的值自動減 1,所以如果當它後面的指令掃描字串時,當它掃到字串與需要檢測的字串相等時或者掃描了 ecx
次之後停止掃描,後一個條件好理解,因爲掃描了 ecx
次之後,ecx
的值已經爲 0,已經不滿足重複條件了;而掃描到相等則會置 ZF=1
,也會使條件不滿足。最後一個指令 scasw
,最後那個 w
表示字,兩個位元組,這個指令減 ax
暫存器和 di
暫存器的值對比,每比較一次,di
暫存器依賴 DF
的值自動增加或減小,增加或減小的值如果指令是 scasw
則爲 2,如果是 scasb
則爲 1。ax
暫存器的初始賦值在第 21 行,"0" (0)
中 "0"
(或者」「)表示使用輸出運算元中同一位置的暫存器,即暫存器 ax
,(0)
表示賦值爲 0;di
暫存器的初始賦值在第 22 行,"D" (mem_map+PAGING_PAGES-1)
中的 "D"
表示參照暫存器 edi
,後面的 (mem_map+PAGING_PAGES-1)
表示賦值爲 mem_map
陣列的最後一個索引,所以查詢空閒頁面是從最後一頁開始查詢的。jne 1f
表示 ZF=0
則跳轉到 1
標籤,ZF=0
表示沒有找到空閒頁,跳轉到 1
標籤,結束嵌入彙編,然後直接返回 __res
,也就是 0。1 => [2 + edi]
,edi
暫存器加 2 是因爲執行完 scasw
指令之後 edi
會減 2,加 2 回到找到的空閒頁的地址,這裏找到空閒頁,將對應的 mem_map
的值置爲 1。ecx
左移 12 位,ecx
的值會隨着 repne
指令變化,找到空閒頁之後,ecx
爲空閒頁的 mem_map
陣列索引,左移 12 位即是空閒頁的相對於 LOW_MEM
的頁面基址。ecx
移到 edx
。edx
加上 LOW_MEM
則爲空閒頁面的實際實體地址。eax
的值爲空閒頁面的實體地址,也就是設定了 __res
的值,最後返回 __res
。