我是如何學習寫一個作業系統(八):記憶體管理和段頁機制
前言
多程序和記憶體管理是緊密相連的兩個模組,因為執行程序也就是從記憶體中取指執行,建立程序首先要將程式和資料裝入記憶體。將使用者原程式變成可在記憶體中執行的程式,而這就涉及到了記憶體管理。
記憶體的裝入
絕對裝入。
在編譯時,如果知道程式將駐留在記憶體的某個位置,編譯程式將產生絕對地址的目的碼。絕對裝入程式按照裝入模組的地址,將程式和資料裝入記憶體。裝入模組被裝入記憶體後,由於程式中的邏輯地址與實際地址完全相同,故不需對程式和資料的地址進行修改。
可重定位裝入。
在多道程式環境下,多個目標模組的起始地址通常都是從0開始,程式中的其他地址都是相對於起始地址的,此時應採用可重定位裝入方式。根據記憶體的當前情況,將裝入模組裝入到記憶體的適當位置。裝入時對目標程式中指令和資料的修改過程稱為重定位,地址變換通常是裝入時一次完成,所以成為靜態重定位。
動態執行時裝入
也成為動態重定位,程式在記憶體中如果發生移動,就需要採用動態的裝入方式。
動態執行時的裝入程式在把裝入模組裝入記憶體後,並不立即把裝入模組中的相對地址轉換為絕對地址,而是把這種地址轉換推遲到程式真正要執行時才進行。因此,裝入記憶體後的所有地址均為相對地址,這種方式需要一個重定位暫存器的支援。
其特點是可以將程式分配到不連續的儲存區中;在程式執行之前可以只裝入它的部分程式碼即可執行,然後在程式執行期間,根據需要動態申請分配記憶體;便於程式段的共享,可以向用戶提供一個比儲存空間大得多的地址空間。
參考連結
所以裝入記憶體的最好方法應該就是動態執行時的裝入,但是這種方法需要一個方法來進行重定位。這個重定位的資訊就儲存在每個程序的PCB中,也就是儲存這塊記憶體的基地址,所以最後在執行時的地址就是邏輯地址 + 基地址
,而硬體也提供了相應計算的支援,也就是MMU
分段機制
但是在程式設計師眼裡:程式由若干部分(段)組成,每個段有各自的特點、用途:程式碼段只讀,程式碼/資料段不會動態增長...。這樣就引出了對記憶體進行分段
分段
假如使用者程序由主程式、兩個字程式、棧和一段資料組成,於是可以把這個使用者程序劃分為5個段,每段從0開始編址,並採用一段連續的地址空間(段內要求連續,段間不要求連續),其邏輯地址由兩部分組成:段號與段內偏移量,分別記為S、W。
段號為16位,段內偏移量為16位,則一個作業最多可有2的16次方16=65536個段,最大段長64KB。
GDT和LDT
每個程序都有一張邏輯空間與主存空間對映的段表,其中每一段表項對應程序的一個段,段表項紀錄路該段在記憶體中的起始地址和段的長度。在配置了段表後,執行中的程序可通過查詢段表,找到每個段所對應的記憶體區。段表用於實現從邏輯端段到實體記憶體區的對映。
而這個段表就是之前在保護模式提到的GDT和LDT
一個處理器只有一個GDT。LDT(區域性描述表),一個程序一個LDT,實際上是GTD的一個“子表”。
LDT和GDT從本質上說是相同的,只是LDT巢狀在GDT之中。
有一個專門的暫存器LDTR來記錄區域性描述符表的起始位置,記錄的是在GDT中的一個段選擇子。所以本質上LDT是一個段描述符,這個描述符就儲存在GDT中,對應這個表述符也會有一個選擇子。
記憶體分割槽和分頁
在用了分段機制後,那麼就需要對記憶體進行分割槽,讓各個段載入到相應的記憶體分割槽中
記憶體分配演算法
在程序裝入或換入主存時。如果記憶體中有多個足夠大的空閒塊,作業系統必須確定分配那個記憶體塊給程序使用,通常有這幾種演算法
首次適應演算法:空閒分割槽以地址遞增的次序連結。分配記憶體時順序查詢,找到大小能滿足要求的第一個空閒分割槽。
- 最佳適應演算法:空閒分割槽按容量遞增形成分割槽鏈,找到第一個能滿足要求的空閒分割槽。
- 最壞適應演算法:有稱最大適應演算法,空閒分割槽以容量遞減次序連結。找到第一個能滿足要求的空閒分割槽,也就是挑選最大的分割槽。
臨近適應演算法:又稱迴圈首次適應演算法,由首次適應演算法演變而成。不同之處是分配記憶體時從此查詢結束的位置開始繼續查詢。
記憶體分頁
引入記憶體分頁就是為了解決在進行記憶體分割槽時導致的記憶體效率問題
分頁就是把真正的記憶體空間劃分為大小相等且固定的塊,塊相對較小,作為記憶體的基本單位。每個程序也以塊為單位進行劃分,程序在執行時,以塊為單位逐個申請主存中的塊空間。所以這時候對真正的實體記憶體地址的對映就不能再用分段機制的那套了
就引入了頁表概念:系統為每個程序建立一張頁表,記錄頁面在記憶體中對應的物理塊號,所以對地址的轉換變成了對頁表的轉換
在系統中通常設定一個頁表暫存器PTR,存放頁表在記憶體的初值和頁表長度。
地址分為頁號和頁內偏移量兩部分,再用頁號去檢索頁表。。
將頁表始址與頁號和頁表項長度的乘積相加,便得到該表項在頁表中的位置,於是可從中得到該頁的物理塊號。
但是頁表還是有兩個問題:
- 頁表佔用的記憶體大
- 頁表需要頻繁的進行地址訪問,所以訪存速度必須非常快
多級頁表
為了解決頁表佔用的記憶體太大,就引入了多級頁表
頁目錄有2的十次方個4位元組的表項,這些表項指向對應的二級表,線性地址的最高10位作為頁目錄用來尋找二級表的索引
二級頁表裡的表項含有相關頁面的20位物理基地址,二級頁表使用線性地址中間10位來作為尋找表項的索引
- 程序訪問某個邏輯地址
- 由線性地址中的頁號,以及外層頁表暫存器(CR3)中的外層頁表始址,找到二級頁表的始址
- 由二級頁表的始址,加上線性地址中的外層頁內地址,找到對應的二級頁表中的頁表項
- 由頁表項中的物理塊號,加上線性地址中的頁內地址,找到對實體地址
快表
為了解決訪存速度,就有了快表
在系統中通常設定一個頁表暫存器PTR,存放頁表在記憶體的初值和頁表長度。
CPU給出有效地址後,由硬體進行地址轉換,並將頁號送入快取記憶體暫存器,並將此頁號與快表中的所有頁號同時進行比較。
如果有找到匹配的頁號,說明索要訪問的頁表項在快表中,則可以直接從中讀出該頁對應的頁框號。
如果沒有找到,則需要訪問主存中的頁表,在讀出頁表項後,應同時將其存入快表中,以供後面可能的再次訪問。但是如果快表已滿,就必須按照一定的演算法對其中舊的頁表項進行替換。
段頁結合(虛擬記憶體)
既然有了段和頁,程式設計師希望能用段,計算機設計希望用頁,那麼就需要將二者結合
所以邏輯地址和實體地址的轉換:
首先將給定一個邏輯地址,
利用其段式記憶體管理單元,也就是GDT中的斷描述符,先將為個邏輯地址轉換成一個線性地址,
再利用頁式記憶體管理單元查表,轉換為實體地址。
虛擬記憶體的管理
在實際的操作上,很有可能當前可用的實體記憶體遠小於分配的虛擬記憶體(4G),這時候就需要請求掉頁功能和頁面置換功能,也就是在進行地址轉換的時候找不到對應頁,就啟動頁錯誤處理程式來完成調頁
這樣在頁表項中增加了四個段:
狀態位P:用於指示該頁是否已調入記憶體,共程式訪問時參考。
訪問欄位A:用於記錄本頁在一段時間內被訪問的次數,或記錄本頁最近已有多長時間未被訪問,供置換演算法換出頁面時參考。
修改位M:表示該頁在調入記憶體後是否被修改過。
外存地址:用於指出該也在外存上的地址,通常是物理塊號,供調入該頁時參考。
所以現在在查詢實體地址的時候就變成了:
先檢索快表
若找到要訪問的頁,邊修改頁表中的訪問位,然後利用頁表項中給出的物理塊號和頁內地址形成實體地址。
若為找到該頁的頁表項,應到記憶體中去查詢頁表,在對比頁表項中的狀態位P,看該頁是否已調入記憶體,未調入則產生缺頁中斷,請求從外存把該頁調入記憶體。
頁面置換演算法
既然有頁面的換入換出,那自然就會有相應的不同的演算法
最佳置換演算法所選擇的被淘汰頁面將是以後永不使用的,或者是在最長時間內不再被訪問的頁面,這樣可以保證獲得最低的缺頁率。但由於人們目前無法預知程序在記憶體下的若干頁面中那個是未來最長時間內不再被訪問的,但是這種演算法無法實現。
LRU演算法
選擇最近最長時間未訪問過的頁面予以淘汰,他認為過去一段時間內未訪問過的頁面,在最近的將來可能也不會被訪問。該演算法為每個頁面設定一個訪問欄位,來記錄頁面自上次被訪問以來所經歷的時間,淘汰頁面時選擇現有頁面中值最大的予以淘汰。
LRU演算法一般有兩種實現:
- 時間戳
每次地址訪問都修改時間戳,淘汰頁的時候只需要選擇次數最少的即可
但是需維護一個全域性時鐘,需找到最小值,實現代價較大
- 頁碼棧
在每次地址訪問的時候都修改棧,這樣在淘汰的時候,只需要將棧底換出
但是和上面用時間戳的方法一樣,實現的代價都非常大
CLOCK演算法
給每個頁幀關聯一個使用位。當該頁第一次裝入記憶體或者被重新訪問到時,將使用位置為1。每次需要替換時,查詢使用位被置為0的第一個幀進行替換。在掃描過程中,如果碰到使用位為1的幀,將使用位置為0,在繼續掃描。如果所謂幀的使用位都為0,則替換第一個幀
CLOCK演算法的效能比較接近LRU,而通過增加使用的位數目,可是使得CLOCK演算法更加高效。在使用位的基礎上再增加一個修改位,則得到改進型的CLOCK置換演算法。這樣,每一幀都出於以下四種情況之一。
- 最近未被訪問,也未被修改(u=0,m=0)。
- 最近被訪問,但未被修改(u=1,m=0)。
- 最近未被訪問,但被修改(u=0,m=1)。
- 最近被訪問,被修改(u=1,m=1)。
演算法執行如下操作步驟:
- 從指標的當前位置開始,掃描幀緩衝區。在這次掃描過程中,對使用位不作任何修改,選擇遇到的第一個幀(u=0,m=0)用於替換。
- 如果第1步失敗,則重新掃描,查詢(u=0,m=1)的幀。選額遇到的第一個這樣的幀用於替換。在這個掃面過程中,對每個跳過的幀,把它的使用位設定成0.
- 如果第2步失敗,指標將回到它的最初位置,並且集合中所有幀的使用位均為0.重複第一步,並且如果有必要重複第2步。這樣將可以找到供替換的幀。
改進型的CLOCK演算法優於簡單的CLOCK演算法之處在於替換時首選沒有變化的頁。由於修改過的頁在被替換之前必須寫回,因而這樣做會節省時間。
Linux 0.11的故事
所有有關管理記憶體都是為了服務程序而誕生的,所以先來看一下Linux 0.11裡從建立程序開始的故事
fork
- 首先通過系統呼叫的中斷來建立程序,fork()->sys_fork->copy_process
- copy_process的主要作用就是為子程序建立TSS描述符、分配記憶體和檔案設定等等
- copy_mem這裡僅為新程序設定自己的頁目錄表項和頁表項,而沒有實際為新程序分配實體記憶體頁面。此時新程序與其父程序共享所有記憶體頁面。
int copy_process(int nr,long ebp,long edi,long esi,long gs,long none,
long ebx,long ecx,long edx,
long fs,long es,long ds,
long eip,long cs,long eflags,long esp,long ss)
{
struct task_struct *p;
int i;
struct file *f;
p = (struct task_struct *) get_free_page();
if (!p)
return -EAGAIN;
task[nr] = p;
*p = *current; /* NOTE! this doesn't copy the supervisor stack */
p->state = TASK_UNINTERRUPTIBLE;
p->pid = last_pid;
p->father = current->pid;
p->counter = p->priority;
p->signal = 0;
p->alarm = 0;
p->leader = 0; /* process leadership doesn't inherit */
p->utime = p->stime = 0;
p->cutime = p->cstime = 0;
p->start_time = jiffies;
p->tss.back_link = 0;
p->tss.esp0 = PAGE_SIZE + (long) p;
p->tss.ss0 = 0x10;
p->tss.eip = eip;
p->tss.eflags = eflags;
p->tss.eax = 0;
p->tss.ecx = ecx;
p->tss.edx = edx;
p->tss.ebx = ebx;
p->tss.esp = esp;
p->tss.ebp = ebp;
p->tss.esi = esi;
p->tss.edi = edi;
p->tss.es = es & 0xffff;
p->tss.cs = cs & 0xffff;
p->tss.ss = ss & 0xffff;
p->tss.ds = ds & 0xffff;
p->tss.fs = fs & 0xffff;
p->tss.gs = gs & 0xffff;
p->tss.ldt = _LDT(nr);
p->tss.trace_bitmap = 0x80000000;
if (last_task_used_math == current)
__asm__("clts ; fnsave %0"::"m" (p->tss.i387));
if (copy_mem(nr,p)) {
task[nr] = NULL;
free_page((long) p);
return -EAGAIN;
}
for (i=0; i<NR_OPEN;i++)
if ((f=p->filp[i]))
f->f_count++;
if (current->pwd)
current->pwd->i_count++;
if (current->root)
current->root->i_count++;
if (current->executable)
current->executable->i_count++;
set_tss_desc(gdt+(nr<<1)+FIRST_TSS_ENTRY,&(p->tss));
set_ldt_desc(gdt+(nr<<1)+FIRST_LDT_ENTRY,&(p->ldt));
p->state = TASK_RUNNING; /* do this last, just in case */
return last_pid;
}
int copy_mem(int nr,struct task_struct * p)
{
unsigned long old_data_base,new_data_base,data_limit;
unsigned long old_code_base,new_code_base,code_limit;
code_limit=get_limit(0x0f);
data_limit=get_limit(0x17);
old_code_base = get_base(current->ldt[1]);
old_data_base = get_base(current->ldt[2]);
if (old_data_base != old_code_base)
panic("We don't support separate I&D");
if (data_limit < code_limit)
panic("Bad data_limit");
new_data_base = new_code_base = nr * 0x4000000;
p->start_code = new_code_base;
set_base(p->ldt[1],new_code_base);
set_base(p->ldt[2],new_data_base);
if (copy_page_tables(old_data_base,new_data_base,data_limit)) {
printk("free_page_tables: from copy_mem\n");
free_page_tables(new_data_base,data_limit);
return -ENOMEM;
}
return 0;
}
page
- copy_page_tables就是複製頁目錄表項和頁表項,從而被複制的頁目錄和頁表對應的原實體記憶體頁面區被兩套頁表對映而共享使用。複製時,需申請新頁面來存放新頁表,原實體記憶體區將被共享。此後兩個程序(父程序和其子程序)將共享記憶體區,直到有一個程序執行操作時,核心才會為寫操作程序分配新的記憶體頁(寫時複製機制)。
int copy_page_tables(unsigned long from,unsigned long to,long size)
{
unsigned long * from_page_table;
unsigned long * to_page_table;
unsigned long this_page;
unsigned long * from_dir, * to_dir;
unsigned long nr;
if ((from&0x3fffff) || (to&0x3fffff))
panic("copy_page_tables called with wrong alignment");
from_dir = (unsigned long *) ((from>>20) & 0xffc); /* _pg_dir = 0 */
to_dir = (unsigned long *) ((to>>20) & 0xffc);
size = ((unsigned) (size+0x3fffff)) >> 22;
for( ; size-->0 ; from_dir++,to_dir++) {
if (1 & *to_dir)
panic("copy_page_tables: already exist");
if (!(1 & *from_dir))
continue;
from_page_table = (unsigned long *) (0xfffff000 & *from_dir);
if (!(to_page_table = (unsigned long *) get_free_page()))
return -1; /* Out of memory, see freeing */
*to_dir = ((unsigned long) to_page_table) | 7;
nr = (from==0)?0xA0:1024;
for ( ; nr-- > 0 ; from_page_table++,to_page_table++) {
this_page = *from_page_table;
if (!(1 & this_page))
continue;
this_page &= ~2;
*to_page_table = this_page;
if (this_page > LOW_MEM) {
*from_page_table = this_page;
this_page -= LOW_MEM;
this_page >>= 12;
mem_map[this_page]++;
}
}
}
invalidate();
return 0;
}
no_page
如果找不到相應的頁,也就是要執行換入和換出了,在此之前CPU會先觸發缺頁異常
- 缺頁異常中斷的處理,會呼叫do_no_page
page_fault:
xchgl %eax,(%esp) # 取出錯碼到eax
pushl %ecx
pushl %edx
push %ds
push %es
push %fs
movl $0x10,%edx # 置核心資料段選擇符
mov %dx,%ds
mov %dx,%es
mov %dx,%fs
movl %cr2,%edx # 取引起頁面異常的線性地址
pushl %edx # 將該線性地址和出錯碼壓入棧中,作為將呼叫函式的引數
pushl %eax
testl $1,%eax # 測試頁存在標誌P(為0),如果不是缺頁引起的異常則跳轉
jne 1f
call do_no_page # 呼叫缺頁處理函式
jmp 2f
1: call do_wp_page # 呼叫防寫處理函式
2: addl $8,%esp # 丟棄壓入棧的兩個引數,彈出棧中暫存器並退出中斷。
pop %fs
pop %es
pop %ds
popl %edx
popl %ecx
popl %eax
iret
- 該函式首先嚐試與已載入的相同檔案進行頁面共享,或者只是由於程序動態申請記憶體頁面而只需對映一頁實體記憶體即可。若共享操作不成功,那麼只能從相應檔案中讀入所缺的資料頁面到指定線性地址處。
void do_no_page(unsigned long error_code,unsigned long address)
{
int nr[4];
unsigned long tmp;
unsigned long page;
int block,i;
address &= 0xfffff000;
tmp = address - current->start_code;
if (!current->executable || tmp >= current->end_data) {
get_empty_page(address);
return;
}
if (share_page(tmp))
return;
if (!(page = get_free_page()))
oom();
/* remember that 1 block is used for header */
block = 1 + tmp/BLOCK_SIZE;
for (i=0 ; i<4 ; block++,i++)
nr[i] = bmap(current->executable,block);
bread_page(page,current->executable->i_dev,nr);
i = tmp + 4096 - current->end_data;
tmp = page + 4096;
while (i-- > 0) {
tmp--;
*(char *)tmp = 0;
}
if (put_page(page,address))
return;
free_page(page);
oom();
}
小結
這一篇的篇幅很長,因為把有關記憶體管理的東西都寫在一起了,主要有三個關鍵點:
分段
對記憶體的分段引申的GDT和IDT來進行實體地址的定址
分頁
再由於分段引出的記憶體分割槽,為了提高效率而引入的分頁機制,重點就是用頁式記憶體管理單元查表
- 段頁結合
所以為了將段和頁結合就需要一個機制來轉化邏輯地址和實體地址,也就分為兩步走利用其段式記憶體管理單元,也就是GDT中的斷描述符,先將為個邏輯地址轉換成一個線性地址,
再利用頁式記憶體管理單元查表,轉換為實體地址。