本文為上海交大 ipads 研究所陳海波老師等人所著的《現代操作系統:原理與實現》的課程實驗(LAB)的學習筆記的第二篇。所有章節的筆記可在此處查看:chcore | 康宇PL's Blog
實驗准備
首先一句 git merge lab2
把 Lab 2 分支合並到當前分支下。
這章中為了方便調試我手動將 CMakeLists.txt 中構建類型從 Release 改為 Debug
set(CMAKE_BUILD_TYPE "Debug") # "Release" or "Debug"
物理內存管理
物理內存布局
問題 1
請簡單解釋,在哪個文件或代碼段中指定了 ChCore 物理內存布局。你可以從兩個方面回答這個問題: 編譯階段和運行時階段。
還是用 ASCII Flow 畫了個圖:
┌─────────────┐◄─ page_end (metadata_end + (npages * PAGE_SIZE))
│ │
│ pages │
│ │
├─────────────┤◄─ metadata_end (img_end + (npages * sizeof(struct page))
│page metadata│
│ │
├─────────────┤◄─ metadata_start (img_end)
│ KERNEL IMG │
├─────────────┤◄─ init_end
│ bootloader │
├─────────────┤◄─ 0x00080000 (img_start, init_start)
│ reserved │
└─────────────┘◄─ 0x00000000
首先 我們在 Lab 1 的練習 4 里分析過 chcore 的鏈接腳本了。腳本里可以知道 reserved、bootloader、KERNEL IMG 這幾個段的信息。這些都是在編譯時可以確定的。
-
bootloader: Lab 1 里我們可以知道 img_start 和 init_start 都被硬編碼為了 0x80000,分別代表 chcore 鏡像的開始地址和 bootloader 的開始地址。整個 .init 段就是 bootloader 所有代碼和全局變量的內存空間。至於 booloader 里的臨時變量,我們在匯編課里學過臨時變量優先放在寄存器里,其次選擇放在函數棧里。放在寄存器里的不占內存;放在函數棧的,因為 bootloader 的函數棧就是一個全局數組,所以本質上還是放在全局變量里。
-
KERNEL IMG:這一塊就是內核所有代碼和全局變量的空間。鏈接腳本里指明了 .init 段之后就是內核的 .text、 .rodata、 .bss 等等的程序段。這些段的末尾就是鏡像的末尾 img_end。為什么內核分這么多個段而 bootloader 只有一個段呢?因為 Lab 1 我們發現 CMakeLists.txt 中將 bootloader 所有的目標文件打包成了一個 init_object 的整體然后放在了 .init 段里。
-
reserved:bootloader 是從 0x00080000 之后放的。從 0x00000000 到 0x00080000 就是保留的區域,不使用。它的作用需要學完虛擬內存整章才能明白:把開頭留出來是為了在方便在訪問空指針時報段錯誤。因為這一段沒有做任何映射,所以訪問必引發異常。
再說 page metadata 和 pages。這兩塊都是運行時才能確定的。給這倆分配空間的過程在 kernel/mm.c 的 mm_init
函數里。
void mm_init(void)
{
vaddr_t free_mem_start = 0;
struct page *page_meta_start = NULL;
u64 npages = 0;
u64 start_vaddr = 0;
// 將 free_mem_start 指定為 img_end 並內存對齊
free_mem_start =
phys_to_virt(ROUND_UP((vaddr_t) (&img_end), PAGE_SIZE));
npages = NPAGES;
start_vaddr = START_VADDR;
// 預留最大頁數 * page 結構體的內存作為 page metadata 的空間
if ((free_mem_start + npages * sizeof(struct page)) > start_vaddr) {
BUG("kernel panic: init_mm metadata is too large!\n");
}
// 物理頁從 page metadata 之后的區域開始分配
page_meta_start = (struct page *)free_mem_start;
// 初始化 slab 和 buddy 這倆物理內存分配器
/* buddy alloctor for managing physical memory */
init_buddy(&global_mem, page_meta_start, start_vaddr, npages);
/* slab alloctor for allocating small memory regions */
init_slab();
map_kernel_space(KBASE + (128UL << 21), 128UL << 21, 128UL << 21);
//check whether kernel space [KABSE + 256 : KBASE + 512] is mapped
kernel_space_check();
}
- page metadata 是每個物理頁的元數據,是一個
struct page
,主要記錄了當前物理頁是否被分配、由哪個物理內存分配器管理、前后的頁是誰等等。 - pages 就是我們要用的物理頁,每個的大小由
PAGE_SIZE
宏定義,為 4K。每個物理頁都有對應的元數據。
伙伴系統
buddy system 核心思想
buddy system 的核心思想是把可供分配物理內存都分割成大小為 2 的整數次冪的塊,並建立多個空閑塊鏈表,將所有相同大小的塊存放在同一個鏈表中。
當申請大小為 x 的塊時,先會將 x 向上取整成 2 的整數次冪的形式。比如申請 15k 就會向上取整為 16k。此時再在管理 16k 大小的塊的鏈表里取一個塊出來。如果鏈表為空,那就繼續向上申請塊,大小為 32k、64k、128k ...... 假如申請到了一個 32k 的塊,而我們只需要 16k,那就把 32k 塊分割成兩個 16k 的大小相等的伙伴塊,一個用作申請的結果,另一個放回 16k 塊的空閑鏈表里。
當回收某個塊時,會檢查它的伙伴塊是否為空閑塊,是的話就把它和伙伴塊合並為一個總大小為當前 2 倍的塊。然后遞歸的重復這一過程,直到伙伴塊不是空閑塊為止。最后把合並好的大塊再插入對應的空閑塊鏈表中。
引用講義中的兩張圖:
物理內存的組織方式
繼續講解伙伴系統前我們先研究下 chcore 里怎么組織物理內存的。
在前面的 mem_init
函數里我們求出了 page metadata 和 pages 的起始地址,以及可供分配的內存總量。由這些信息我們可以定義一個內存池 phys_mem_pool
,在內存池里定義了多個空閑塊鏈表 free_list
。
/* Disjoint physical memory can be represented by several phys_mem_pool. */
struct phys_mem_pool {
u64 pool_start_addr; // 用於分配的內存區域的起始虛擬地址
u64 pool_mem_size; // 內存池總容量
u64 pool_phys_page_num; // 物理頁個數
struct page *page_metadata; // 頁元數據的數組
struct free_list free_lists[BUDDY_MAX_ORDER]; // 多級的空閑頁鏈表
};
為了方便計數每個空閑塊鏈表里維護了空閑塊的個數 nr_free
和它所管理的空閑塊的鏈表 free_list
。(一個 free_list
是結構體名,一個 free_list
是變量名)
struct free_list {
struct list_head free_list;
u64 nr_free;
};
struct list_head {
struct list_head *prev;
struct list_head *next;
};
free_list
並不直接把 page
穿成鏈表,而是把 page
里的 list_head
類型的成員 node
傳成了鏈表。畫個圖就是:
/* `struct page` is the metadata of one physical 4k page. */
struct page {
/* Free list */
struct list_head node;
/* Whether the correspond physical page is free now. */
int allocated;
/* The order of the memory chunck that this page belongs to. */
int order;
/* Used for ChCore slab allocator. */
void *slab;
};
/*
page page page
free_list ┌────┐ ┌────┐ ┌────┐
┌─────────┐ │ │ │ │ │ │
│nr_free │ │ │ │ │ │ │
├─────────┤ ─►├────┼─►├────┼─►├────┼─►
│free_list├───► │node│ │node│ │node│
└─────────┘ ◄─┴────┘◄─┴────┘◄─┴────┘◄─
*/
要將 list_head
轉成 page
可以使用宏 list_entry(ptr, type, field)
, ptr
是指向結構體 type
內成員 field
的指針,通過把 type
里的成員 field
作為偏移量使用,並將 ptr
指針減去這個偏移量,就可以將 ptr
轉換成 type
類型的指針了。
#define list_entry(ptr, type, field) \
container_of(ptr, type, field)
#define container_of(ptr, type, field) \
((type *)((void *)(ptr) - (u64)(&(((type *)(0))->field))))
// 示例
struct page *page = list_entry(pool->free_lists[current_order].free_list.next, struct page, node);
快速找到伙伴塊
接下來分析 buddy.c 中已經實現的 init_buddy
和 get_buddy_chunk
void init_buddy(struct phys_mem_pool *pool, struct page *start_page,
vaddr_t start_addr, u64 page_num)
{
int order;
int page_idx;
struct page *page;
/* 初始化內存池 */
pool->pool_start_addr = start_addr;
pool->page_metadata = start_page;
pool->pool_mem_size = page_num * BUDDY_PAGE_SIZE;
/* This field is for unit test only. */
pool->pool_phys_page_num = page_num;
/* 初始化 free_lists,將每個鏈表清空,並將計數器設為 0 */
for (order = 0; order < BUDDY_MAX_ORDER; ++order) {
pool->free_lists[order].nr_free = 0;
init_list_head(&(pool->free_lists[order].free_list));
}
/* 清空元數據區 */
memset((char *)start_page, 0, page_num * sizeof(struct page));
/* 開始時先將每個 page 都設置為已分配,並將大小 order 標為 0 */
for (page_idx = 0; page_idx < page_num; ++page_idx) {
page = start_page + page_idx;
page->allocated = 1;
page->order = 0;
}
/* 將每個 page 做回收操作,通過 buddy_free_pages 歸類到對應的 free_list 中 */
for (page_idx = 0; page_idx < page_num; ++page_idx) {
page = start_page + page_idx;
buddy_free_pages(pool, page);
}
}
// 找到指定塊的伙伴塊
static struct page *get_buddy_chunk(struct phys_mem_pool *pool,
struct page *chunk)
{
u64 chunk_addr;
u64 buddy_chunk_addr;
int order;
/* 根據 page 獲取對應物理塊的起始地址 */
chunk_addr = (u64) page_to_virt(pool, chunk);
order = chunk->order;
/* 做個異或操作獲得當前伙伴塊 */
#define BUDDY_PAGE_SIZE_ORDER (12)
buddy_chunk_addr = chunk_addr ^
(1UL << (order + BUDDY_PAGE_SIZE_ORDER));
/* Check whether the buddy_chunk_addr belongs to pool. */
if ((buddy_chunk_addr < pool->pool_start_addr) ||
(buddy_chunk_addr >= (pool->pool_start_addr +
pool->pool_mem_size))) {
return NULL;
}
return virt_to_page(pool, (void *)buddy_chunk_addr);
}
page
中的 order
成員代表當前的塊內部包含 \(2^{order}\) 個物理頁,比如一個 32k 大小的塊,它內部包含 8 個物理頁(每個物理頁 4k 大),\(2^3 = 8\),所有 order
為 3 。
伙伴系統每次回收一個塊時都要找到它的伙伴塊。因為每個塊內包含 2 的整數冪個物理頁,每個物理頁的大小也是 2 的整數冪的。所以互為伙伴的兩個塊的物理地址可以通過如下公式獲得:
buddy_chunk_addr = chunk_addr ^ (1UL << (order + BUDDY_PAGE_SIZE_ORDER));
如果要確定兩個伙伴塊誰在左誰在右可以比較下它倆的物理地址高低,低的在左,高的在右。
練習1
實現kernel/mm/buddy.c中 的四個函數:buddy_get_pages(),split_page(),buddy_free_pages(),merge_page()。請參考伙伴塊索引等功能的輔助函數: get_buddy_chunk()。
在着手實現前先思考一下,我們每次對 free_list
增刪元素時都得手動修改下它的 nr_free
,所以增刪元素和增減 nr_free
這兩對操作可以封裝成倆函數。因為每個 page
里都記錄了它的 order
,所以我們只需要指定存放所有 free_list
的內存池就可以根據 order
將 page
插入到對應的 free_list
中。
void page_append(struct phys_mem_pool *pool, struct page *page) {
struct free_list *free_list = &pool->free_lists[page->order];
list_add(&page->node, &free_list->free_list);
free_list->nr_free++;
}
void page_del(struct phys_mem_pool *pool, struct page *page) {
struct free_list *free_list = &pool->free_lists[page->order];
list_del(&page->node);
free_list->nr_free--;
}
merge_page
與 buddy_free_pages
的實現
// 對指定塊向上遞歸的做合並操作
static struct page *merge_page(struct phys_mem_pool *pool, struct page *page)
{
// <lab2>
// 只能對空閑塊做合並操作,禁止對已分配的塊做合並操作
if (page->allocated) {
kwarn("Try to merge an allocated page\n", page);
return NULL;
}
// 合並前先將空閑塊從它所屬的 free_list 中拆出來
page_del(pool, page);
// 遞歸地向上合並空閑塊,循環停止地條件:
// 1) 當前塊的 order 已達到允許的最大值
// 2) 找不到伙伴塊或者無法與伙伴塊合並
while (page->order < BUDDY_MAX_ORDER - 1) {
struct page* buddy_page = get_buddy_chunk(pool, page);
// 只能與等大的、空閑的伙伴塊合並
if (buddy_page == NULL ||
buddy_page->allocated ||
buddy_page->order != page->order) {
break;
}
// 調整下位置,保證 page 為左伙伴, buddy_page 為右伙伴
if(page > buddy_page) {
struct page *tmp = buddy_page;
buddy_page = page;
page = tmp;
}
// 做合並,將 buddy_page 標記為已分配並從 free_list 中刪除
// 將 page 的 order ++
buddy_page->allocated = 1;
page_del(pool, page);
page->order++;
}
// 將合並后的塊插入對應的 free_list 中
page_append(pool, page);
return page;
// </lab2>
}
// 回收指定塊
void buddy_free_pages(struct phys_mem_pool *pool, struct page *page)
{
// <lab2>
// 空閑的塊無法被回收
if (!page->allocated) {
kwarn("Try to free a free page\n");
return;
}
// 將塊標記為空閑,並插入到 free_list 中
page->allocated = 0;
page_append(pool, page);
// 遞歸的向上合並塊
merge_page(pool, page);
// </lab2>
}
split_page
與 buddy_get_pages
的實現
// 遞歸的分割塊,直到分割至指定大小
static struct page *split_page(struct phys_mem_pool *pool, u64 order,
struct page *page)
{
// <lab2>
// 禁止分割已分配的塊
if (page->allocated) {
kwarn("Try to split an allocated page\n");
return 0;
}
// 標記塊為未分配,並從 free_list 中刪除
page->allocated = 0;
page_del(pool, page);
// 遞歸的分割塊,直到 order 變成指定大小
while (page->order > order) {
// 先縮小 order,再找到對應的伙伴塊
// 操作后原先的 page 就變成了當前 page 和 buddy_page
page->order--;
struct page *buddy_page = get_buddy_chunk(pool, page);
// 把 buddy_page 插入 free_list 中
if (buddy_page != NULL) {
buddy_page->allocated = 0;
buddy_page->order = page->order;
page_append(pool, buddy_page);
}
}
return page;
// </lab2>
}
// 申請指定 order 的塊
struct page *buddy_get_pages(struct phys_mem_pool *pool, u64 order)
{
// <lab2>
// 找到一個非空的,最夠大的 free_list
int current_order = order;
while (current_order < BUDDY_MAX_ORDER && pool->free_lists[current_order].nr_free <= 0)
current_order++;
// 申請的 order 太大或者沒有足夠大的塊能分配
if (current_order >= BUDDY_MAX_ORDER) {
kwarn("Try to allocate an buddy chunk greater than BUDDY_MAX_ORDER");
return NULL;
}
// 得到指定 free_list 的表頭塊
struct page *page = list_entry(pool->free_lists[current_order].free_list.next, struct page, node);
if (page == NULL){
kdebug("buddy get a NULL page\n");
return NULL;
}
// 分割塊
split_page(pool, order, page);
// 將返回的塊標記為已分配
page->allocated = 1;
return page;
// </lab2>
}
然后就是漫長的調試過程了。我本以為一兩小時就能寫完,但沒想到最后總共花了 7 小時才完成了 buddy 的內容,下面提幾點我踩的坑:
因為對鏈表的操作涉及到指針操作,稍不留神就會觸發段錯誤。定位段錯誤可以使用 gdb 調試程序,執行到段錯誤的位置會自動停止,此時可以用 bt
指令看一下函數棧來定位下位置。
因為實驗指南里給的測試命令是在 docker 里運行的,所以可能會出現找不到 gdb 的情形,這時候就得在 docker 里手動 apt 或者 yum 裝個 gdb 了。但其實如果你依賴配置好的話, test_buddy
在本地就能編譯運行,不需要 make docker
,不過我還是建議新手用 docker,畢竟環境統一。
test_buddy
這個測試程序是輸出到 stdout 的,為了輔助測試可以在 buddy.c 和 test_buddy.c 引用 stdio.h 寫幾句 printf
,只要記得在做后面的實驗前刪掉 include <stdio.h>
就行。比如我為了調試在 test_buddy.c 里的每條 mu_check
前都輸出了要檢查的變量。在這過程中我踩的一個坑就是 nr_free
是無符號數,減到 0 后再減會直接溢出為最大值。
虛擬內存管理
內核與用戶地址空間分離
為了保護進程間的隔離性,每個進程都有自己的用戶態頁表,所有進程又使用同一份內核態頁表。操作系統在上下文切換時會進行頁表的切換。但大部分的內存都由內核使用(各種內核數據結構、各種內核模塊的管理等等)。
AArch64 中提供了 TTBR0_EL1 和 TTBR1_EL1 兩個頁表基地址寄存器。
TTBR0_EL1 供用戶態使用,進程上下文切換時會刷新。
TTBR1_EL1 供內核態使用,進程上下文切換時不會刷新。
這倆寄存器負責的虛擬地址范圍可以通過 TCR_EL1 寄存器指定。一種方法是根據虛擬地址第 63 位的值決定,為 0 的話用 TTBR0_EL1,否則用 TTBR1_EL1。
問題2
AArch64 采用了兩個頁表基地址寄存器,相較於 x86-64 架構中只有一個頁表基地址寄存器,這樣的好處是什么?
分離用戶態和內核態的寄存器使得在系統調用過程時不需要切換頁表,因此也避免了 TLB 刷新的開銷。
x86-64 體系僅提供一個頁表基地址寄存器 CR3。但內核不使用單獨的頁表,而是把自己映射到應用程序的高地址部分,以此也避免了系統調用過程中造成的頁表切換。
虛擬地址的組成
chcore 中使用四級頁表實現虛擬地址到物理地址的映射
每一級頁表中的條目結構如下:
想要理解表條目、塊條目、頁條目的區別,得看一源碼:
/* table format */
typedef union {
struct {
u64 is_valid:1, is_table:1, ignored1:10, next_table_addr:36, reserved:4, ignored2:7, PXNTable:1, // Privileged Execute-never for next level
XNTable:1, // Execute-never for next level
APTable:2, // Access permissions for next level
NSTable:1;
} table;
struct {
u64 is_valid:1, is_table:1, attr_index:3, // Memory attributes index
NS:1, // Non-secure
AP:2, // Data access permissions
SH:2, // Shareability
AF:1, // Accesss flag
nG:1, // Not global bit
reserved1:4, nT:1, reserved2:13, pfn:18, reserved3:2, GP:1, reserved4:1, DBM:1, // Dirty bit modifier
Contiguous:1, PXN:1, // Privileged execute-never
UXN:1, // Execute never
soft_reserved:4, PBHA:4; // Page based hardware attributes
} l1_block;
struct {
u64 is_valid:1, is_table:1, attr_index:3, // Memory attributes index
NS:1, // Non-secure
AP:2, // Data access permissions
SH:2, // Shareability
AF:1, // Accesss flag
nG:1, // Not global bit
reserved1:4, nT:1, reserved2:4, pfn:27, reserved3:2, GP:1, reserved4:1, DBM:1, // Dirty bit modifier
Contiguous:1, PXN:1, // Privileged execute-never
UXN:1, // Execute never
soft_reserved:4, PBHA:4; // Page based hardware attributes
} l2_block;
struct {
u64 is_valid:1, is_page:1, attr_index:3, // Memory attributes index
NS:1, // Non-secure
AP:2, // Data access permissions
SH:2, // Shareability
AF:1, // Accesss flag
nG:1, // Not global bit
pfn:36, reserved:3, DBM:1, // Dirty bit modifier
Contiguous:1, PXN:1, // Privileged execute-never
UXN:1, // Execute never
soft_reserved:4, PBHA:4, // Page based hardware attributes
ignored:1;
} l3_page;
u64 pte;
} pte_t;
#define PTE_DESCRIPTOR_INVALID (0)
/* page_table_page type */
typedef struct {
pte_t ent[PTP_ENTRIES];
} ptp_t;
雖然這里面的標志位很多,但目前我們只要關心每個條目里存儲的地址是啥作用就行。
表條目即 table
結構體,存儲着下一級頁表的表頭物理地址。
頁條目即 l3_page
結構體,存儲着頁框的物理地址。
塊條目即 l1_block
和 l2_block
,可以視作粒度更大的頁,Lab 2 中作為選作的挑戰。
每個頁表都是一個 ptp_t
結構體,內部存儲着一個 pte_t
條目數組。
虛擬地址的翻譯
進行地址翻譯時首先指定 L0 頁表的基址,使用頁表基址和 L0 索引定位到 L0 頁表里的一個表條目,從中取出 L1 頁表的基地址。遞歸進行這一過程,直到從 L3 頁表里取出頁框的基地址 pfn
為止。用該基地址與頁偏移組合得到物理地址。
組合的具體方法是把 (pfn << PAGE_SHIFT) + GET_VA_OFFSET_L3(vir_addr)
。其中 PAGE_SHFIT
為 12,\(2^{12} == 4096\) ,恰好為一個頁的大小。
為了更深入的理解地址翻譯的流程,我們分析一下 kernel/mm/page_table.c 里的 get_next_ptp
函數。前面我們說過每個頁表都是一個 ptp_t
結構體,內部存儲着一個 pte_t
條目數組。get_next_ptp
函數的作用就是在指定當前頁表的 ptp_t
、頁表等級、待尋址的虛擬地址這幾個參數下求出下一級頁表(對於 L3 頁表來說求的就是頁框)的虛擬地址和存儲着這些元數據的 pte_t
。
/*
* Find next page table page for the "va".
* 根據當前頁表虛擬地址和指定虛擬地址求出下一級的頁表或者物理頁的虛擬地址
*
* cur_ptp: current page table page,當前的頁表虛擬地址
* level: current ptp level,當前頁表等級(0、1、2、3)
*
* next_ptp: returns "next_ptp",下一級頁表的地址
* pte : returns "pte" (points to next_ptp) in "cur_ptp"
* 當前頁表里存儲着下一級頁表的那個頁表條項的地址
*
* alloc: if true, allocate a ptp when missing
*
*/
static int get_next_ptp(ptp_t * cur_ptp, u32 level, vaddr_t va,
ptp_t ** next_ptp, pte_t ** pte, bool alloc)
{
u32 index = 0;
pte_t *entry;
// 頁表不存在,返回錯誤碼
if (cur_ptp == NULL)
return -ENOMAPPING;
// 從虛擬地址里提取出指定等級的頁表的索引(條目在上一級頁表中的偏移值)
switch (level) {
case 0:
index = GET_L0_INDEX(va);
break;
case 1:
index = GET_L1_INDEX(va);
break;
case 2:
index = GET_L2_INDEX(va);
break;
case 3:
index = GET_L3_INDEX(va);
break;
default:
BUG_ON(1);
}
// 求出頁表里條目的地址
entry = &(cur_ptp->ent[index]);
if (IS_PTE_INVALID(entry->pte)) {
if (alloc == false) {
return -ENOMAPPING;
} else {
/* alloc a new page table page */
ptp_t *new_ptp;
paddr_t new_ptp_paddr;
pte_t new_pte_val;
/* 分配一個物理頁以此創建新的頁條目 */
new_ptp = get_pages(0);
BUG_ON(new_ptp == NULL);
memset((void *)new_ptp, 0, PAGE_SIZE);
/* 頁條目內部記錄的地址都是物理地址 */
new_ptp_paddr = virt_to_phys((vaddr_t) new_ptp);
new_pte_val.pte = 0;
new_pte_val.table.is_valid = 1;
new_pte_val.table.is_table = 1;
// 做偏移運算,只保留基地址
// 因為 next_table_addr 定義為了 36 位,所以超出部分會自動截斷
new_pte_val.table.next_table_addr
= new_ptp_paddr >> PAGE_SHIFT;
/* 因為 pte_t 是個 union,所以 new_pte_val.pte 的內容
和 new_pte_val.table相同 */
/* same effect as: cur_ptp->ent[index] = new_pte_val; */
entry->pte = new_pte_val.pte;
}
}
// 等價於求 entry 對應的 table.next_table_addr,然后轉成虛擬地址
*next_ptp = (ptp_t *) GET_NEXT_PTP(entry);
*pte = entry;
if (IS_PTE_TABLE(entry->pte))
return NORMAL_PTP;
else
return BLOCK_PTP;
}
這里引出了幾個問題:
下一級頁表或者頁框不存在時會怎樣?根據指定的 alloc
參數,要么給你分配個頁作為下一級頁表,要么直接返回錯誤碼 -ENOMAPPING
代表虛擬地址沒有映射到物理地址上。
怎么區分 table
和 l3_page
?get_next_ptp
不做區分。觀察下函數的流程可以發現只對 pte_t
中的 table.is_valid
、table.is_page
、table.next_table_addr
三個字段做了修改。而觀察下 pte_t
的定義可知,這三個字段的長度和位置都恰好與 l3_page.is_valid
、l3_page.is_page
、l3_page.pfn
三個字段一一對應。所以 get_next_ptp
具有了一定的通用性,既可以分配頁表也可以分配頁框。只不過其他的標志位需要我們手動更改。
條目里存的表基址都是物理地址,而啟用了虛擬內存后所有的地址都被解釋為虛擬地址,我怎么把表基址的虛擬地址求出來?回一下 Lab 1 里我們對鏈接腳本的研究,內核的物理地址加上一個偏移量就能得到虛擬地址。所以在內核態我們不用費心的走頁表也能完成地址翻譯。那還用頁表干啥呢?是為了服務用戶態進程,內核態進程都共用一套虛擬地址空間,自然不需要區分。但用戶態的進程彼此間是隔離的,為了保證隔離性、提高內存利用率、方便程序員實現我們提供了基於頁表的虛擬內存機制。
問題3
1) 請問在頁表條目中填寫的下一級頁表的地址是物理地址還是虛擬地址?
上面已經分析過了,無論是指向頁表還是頁框,條目里的地址都是物理地址。
2) 在 ChCore 中檢索當前頁表條目的時候,使用的頁表基地址是虛擬地址還是物理地址?
頁表地址是虛擬地址。上面說了因為內核態虛擬地址與物理地址間只差一個偏移量,所以不需要復雜的頁表機制。但用戶態進程需要這套東西。
問題4
1)如果我們有 4G 物理內存,管理內存需要多少空間開銷? 這個開銷是如何降低的?
假如只有一級頁表。4G 內存,每頁 4K,則總共有 \(10^6\) 個頁,同樣需要這么多個頁條目。每個條目 4 字節,總共需要 4M 內存,只占千分之一。
為了節約開銷,考慮到 4G 內存里大部分情況下都不會完全用完,所以我們使用了多級頁表,只對那些使用了的物理頁創建對應的頁條目,以此節約了不少開銷。弊端就是層數多了尋址會慢一點,內存占用率滿了時頁表占的內存相較於一級頁表也會稍大一點。
2)總結一下 x86-64 和 AArch64 地址翻譯機制的區別,AArch64 MMU 架構設計的優點是什么?
我能想到的點就是四級頁表和兩個頁表基址寄存器了。
頁表管理
練習2
在文件 kernel/mm/page_table.c 中,實現map_range_in_pgtbl(),unmap_range_in_pgtbl() 和query_in_pgtbl() 。可以調用輔助函數:set_pte_flags(), get_next_ptp(), flush_tlb()
第一步是在參透 get_next_ptp
的基礎之上實現 query_in_pgtbl
。實現虛擬地址到物理地址的翻譯。
主要思路時多次使用 get_next_ptp
的到頁框的物理基址,然后判斷下合法性。合法的話求出對應的物理地址,不合法的話返回 -ENOMAPPING
/*
* Translate a va to pa, and get its pte for the flags
*/
/*
* query_in_pgtbl: translate virtual address to physical
* address and return the corresponding page table entry
*
* pgtbl @ ptr for the first level page table(pgd) virtual address
* va @ query virtual address
* pa @ return physical address
* entry @ return page table entry
*
* Hint: check the return value of get_next_ptp, if ret == BLOCK_PTP
* return the pa and block entry immediately
*/
int query_in_pgtbl(vaddr_t * pgtbl, vaddr_t va, paddr_t * pa, pte_t ** entry)
{
// <lab2>
ptp_t *cur_ptp = (ptp_t *)pgtbl;
ptp_t *next_ptp = NULL;
pte_t *next_pte = NULL;
int level = 0;
int err = 0;
// get page pte
while(level <= 3 &&
(err = get_next_ptp(cur_ptp, level, va, &next_ptp, &next_pte, false)) == NORMAL_PTP){
cur_ptp = next_ptp;
level++;
}
if(err == NORMAL_PTP){
// TODO: add hugepage support
if(level != 4) {
kwarn("query_in_pgtbl: level = %d < 4\n", level);
return -ENOMAPPING;
}
// page invalid
if(! next_pte->l3_page.is_valid || ! next_pte->l3_page.is_page){
return -ENOMAPPING;
}
// get phys addr
*pa = virt_to_phys((vaddr_t)next_ptp) + GET_VA_OFFSET_L3(va);
return 0;
}
// get an error
else if(err < 0) {
return err;
}
// should never be here
else {
kwarn("query_in_pgtbl: should never be here\n");
return err;
}
// </lab2>
return 0;
}
接着是 map_range_in_pgtbl
和 unmap_range_in_pgtbl
這一對操作。
map_range_in_pgtbl
我們可以借助 get_next_ptp
的 alloc
可參數來自動的完成新頁表的創建,但只能創建指向 L1、L2、L3 的頁表項。指向物理頁框的頁表項需要我們手動設置,如果用get_next_ptp
它會自動分配好一個頁框,就沒法按我們的要求指定物理地址了。手動設置的部分參考 get_next_ptp
,主要是設置好 is_valid
、is_page
、pfn
這幾個字段。然后用 set_pte_flags
設置下標識位。
與之對應的是在 unmap_range_in_pgtbl
我們要解除掉映射關系,這一步就是直接把 pte
所有位清零即可。
/*
* map_range_in_pgtbl: map the virtual address [va:va+size] to
* physical address[pa:pa+size] in given pgtbl
*
* pgtbl @ ptr for the first level page table(pgd) virtual address
* va @ start virtual address
* pa @ start physical address
* len @ mapping size
* flags @ corresponding attribution bit
*
* Hint: In this function you should first invoke the get_next_ptp()
* to get the each level page table entries. Read type pte_t carefully
* and it is convenient for you to call set_pte_flags to set the page
* permission bit. Don't forget to call flush_tlb at the end of this function
*/
int map_range_in_pgtbl(vaddr_t * pgtbl, vaddr_t va, paddr_t pa,
size_t len, vmr_prop_t flags)
{
// <lab2>
for(const vaddr_t end_va = va + len; va < end_va; va += PAGE_SIZE, pa += PAGE_SIZE) {
ptp_t *cur_ptp = (ptp_t *)pgtbl;
ptp_t *next_ptp = NULL;
pte_t *next_pte = NULL;
int level = 0;
int err = 0;
// get and create L3 ptp
while(level <= 2 &&
(err = get_next_ptp(cur_ptp, level, va, &next_ptp, &next_pte, true)) == NORMAL_PTP){
cur_ptp = next_ptp;
level++;
}
// map phys addr
u32 index = GET_L3_INDEX(va);
next_pte = &(cur_ptp->ent[index]);
next_pte->l3_page.is_valid = 1;
next_pte->l3_page.is_page = 1;
next_pte->l3_page.pfn = pa >> PAGE_SHIFT;
set_pte_flags(next_pte, flags, KERNEL_PTE);
}
flush_tlb();
// </lab2>
return 0;
}
/*
* unmap_range_in_pgtble: unmap the virtual address [va:va+len]
*
* pgtbl @ ptr for the first level page table(pgd) virtual address
* va @ start virtual address
* len @ unmapping size
*
* Hint: invoke get_next_ptp to get each level page table, don't
* forget the corner case that the virtual address is not mapped.
* call flush_tlb() at the end of function
*
*/
int unmap_range_in_pgtbl(vaddr_t * pgtbl, vaddr_t va, size_t len)
{
// <lab2>
for(const vaddr_t end_va = va + len; va < end_va; va += PAGE_SIZE) {
ptp_t *cur_ptp = (ptp_t *)pgtbl;
ptp_t *next_ptp = NULL;
pte_t *next_pte = NULL;
int level = 0;
int err = 0;
// get L3 ptp
while(level <= 2 &&
(err = get_next_ptp(cur_ptp, level, va, &next_ptp, &next_pte, false)) == NORMAL_PTP){
cur_ptp = next_ptp;
level++;
}
if(err == NORMAL_PTP && level == 3 && cur_ptp != NULL) {
// unmap page
u32 index = GET_L3_INDEX(va);
next_pte = &(cur_ptp->ent[index]);
next_pte->pte = 0;
}
}
flush_tlb();
// </lab2>
return 0;
}
之后又是喜聞樂見的調試環節,果真如講義所說需要注意諸多邊界條件。我就是因為把 va ~ va + len
當成一個閉區間吃了不少苦頭。如果沒有頭緒就去研究下 tests/mm/page_table/test_aarch64_page_table.c,自己加點輸出中間變量的代碼試試。
做完這一部分的內容又花費了 7 個小時。
內核地址空間
ChCore 用一個宏 KBASE
將地址空間分為用戶態和內核態兩部分。為了保證隔離性,ChCore 使用頁表中的權限位保證用戶態進程只能訪問用戶態地址。
問題5
在 AArch64 MMU 架構中,使用了兩個 TTBR 寄存器,ChCore 使用一個 TTBR 寄存器映射內核地址空間,另一個寄存器映射用戶態的地址空間,那么是否還需要通過設置頁表位的屬性來隔離內核態和用戶態的地址空間?
這里存疑,我個人覺得標識位是個冗余的存在。
在 bootloader 的 init_boot_pt
函數中已經完成了設備空間和內核空間的映射。我們這一節只需要調用下前面寫好的 map_range_in_pgtbl
完成內核空洞部分的映射就行。
問題6
1)ChCore 為什么要使用塊條目組織內核內存? 哪些虛擬地址空間在 Boot 階段必須映射,哪些虛擬地址空間可以在內核啟動后延遲?
用塊(2M)不用頁(4K)的原因自然是因為內核內存訪問的頻率比較高,使用大頁 TLB miss 出現的次數比使用普通頁更少。
內存模塊要用的地址應該在 boot 階段完成映射,進程模塊、文件系統模塊等在虛擬內存啟動之后才啟動的可以在內核啟動后延遲。
2)為什么用戶程序不能讀寫內核內存? 保護內核內存的具體機制是什么?
訪問內存時會用程序狀態寄存器里的標識位和頁表項的標識位做合法性檢查,非法的話會觸發一個保護異常。
練習3
完善kernel/mm/mm.c中的map_kernel_space()函數,實現對內核空間的映射,並且可以通過kernel_space_check()的檢查。
就是單純的函數調用,注意下標識位就行。
void map_kernel_space(vaddr_t va, paddr_t pa, size_t len)
{
// <lab2>
vaddr_t *ttbr1 = (vaddr_t *)get_ttbr1();
map_range_in_pgtbl(ttbr1, va, pa, len, KERNEL_PT);
// </lab2>
}
這部分比較簡單,大概 1 個小時就整完了。
后記
做的挺爽,再來一個。
追更
網友 Syx-e 對練習 3 提出了問題,這里就留給后來的讀者們實驗吧。
2樓 2021-10-13 19:59 Syx-e
練習3要求映射的這段空間應該映射到2M塊上而不是4KB的頁上,雖然可以過,但可以嘗試一下。