Linux 保護模式記憶體架構介紹
周宏霖
一,前言
在 X86的保護模式下,Linux
作業系統的記憶體定址可以擁有0--4GB的空間,相對於User-Space(Ring 3)的程式碼,屬於Kernel-Space(Ring
0)的作業系統核心程式可以存取執行整個0--4GB的虛擬記憶體空間,而使用者的程式只能存取屬於User-Mode的0—3GB記憶體空間. 如下圖(一)所示,為保護模式下Linux記憶體的架構圖,這張圖清楚的區分了核心的記憶體空間與屬於使用者的記憶體範圍.

圖(一),User-Kernel
Mode記憶體架構
如下圖(二)所示,保護模式下的Linux核心,藉由使用者程序各自獨立的記憶體空間,實現了Preemptive
多工環境. 讓在Linux環境下運作的使用者程序擁有各自獨立的記憶體空間與CPU執行時間,即使任一個使用者程序發生錯誤,也不會影響到系統整體的運作. 由於各使用者程序擁有各自獨立的記憶體空間,所以彼此所共同參考到的函式庫或是檔案都可以透過虛擬記憶體對應到同一塊實體記憶體,只有當記憶體內容改變時,才會藉由Copy-On-Write的機制,使用更多的記憶體空間來紀錄已改變的資料內容.

圖(二),User-Kernel
Mode Process
有了User-Mode與Kernel-Mode基本的概念之後,接下來就開始這次文章的介紹,筆者將以Linux 2.4.x的核心為例,為各位逐步說明Linux下的保護模式記憶體架構.
二,容我再多提一下保護模式
在文章主菜上場前,筆者先帶入保護模式下GDT(Global Descriptor Table,全域描述器表),IDT(Interrupt Descriptor Table,中斷描述器表)與LDT(Local Descriptor Table,區域節區描述器表)的概念,基本上,藉由了解這三個描述器表功能,各位就可以對於保護模式下的多工與記憶體架構有一定程度的認識了.
提到保護模式,首先最重要的概念就是不同程式碼可以位於不同特權等級的觀念,如下圖(三)所示,基本上在x86的保護模式架構下可以分為4個等級(Ring0-Ring3),其中在Linux的環境下使用者的程式屬於Ring3,Linux核心與驅動程式屬於Ring0. 在基於x86保護模式的基礎下, 使用者的程式相對於核心的程式而言是比較不可靠的,也就是說屬於Ring 3的程式並不能存取屬於Ring0的記憶體空間,並且屬於Ring3的程式也無法直接去執行屬於Ring0 的程式碼範圍. 因為屬於Ring 3的使用者程式,很可能是其他使用者自行撰寫的惡意程式, 透過保護模式的特權等級架構,可以讓相對不可安全的程式碼,無法接觸位於核心較為可靠的程式碼.
而位於Ring0的程式碼,由於屬於最高的特權等級,所以可以隨意的存取Ring3程式的記憶體空間, 並且可以接觸Ring0-Ring3所有特權等級的記憶體空間與程式碼. Linux目前作業系統中僅使用了Ring3與Ring0兩個特權等級,目前常用的Windows 9x/ME/NT/2000/XP 也是使用Ring3與Ring0兩個特權等級,分別代表了User-Mode與Kernel-Mode.

圖(三),保護模式 Ring 概念
不過在Linux的保護模式架構下, Ring3的程式必須要依靠Linux核心所提供的系統呼叫才能順利的執行各式的服務,例如:開檔讀檔,收送網路封包,都會需要透過位於核心的檔案系統驅動程式或是網路協定驅動程式才得以完成,也因此位於Ring 0的Linux核心記憶體空間就有必要提供一些機制讓屬於Ring3的程式可以呼叫Ring0提供的服務.
因此,在Linux的架構下提供了一個可以讓Ring3程式呼叫的中斷80h,也就是在保護模式中的中斷閘道(Interrupt Gate), 透過觸發一個中斷可以導致特權等級由Ring 3轉移到Ring0,進而使得Ring3呼叫核心服務的參數可以透過中斷的暫存器被帶入到屬於Ring0的服務函式中. 透過這樣的方式,位於Ring0 的80h號中斷服務常式就可以根據使用者所填入的參數(由CPU暫存器ah來區分)來提供所需完成的對應服務. 如此一來,在Linux環境下的使用者程式就可以透過80h號中斷所提供的各式系統呼叫來建構出各式各樣的函式庫(例如:Glibc的libc.so與ld-linux.so),再來提供給使用者呼叫使用了.
保護模式中,定義了一些屬於作業系統等級(Ring0)才能執行的指令,這些屬於特權的指令集,如果由位於Ring 3的程式來執行就會產生保護模式下的例外錯誤, 例如:屬於Ring 3指令集的sidt與sgdt 作用在於讀出IDT與GDT表的位址與大小,但是儲存IDT與GDT表的指令為LIDT和LGDT 就是屬於Ring0的指令,一旦使用者程式執行這些特權等級指令,就會引發相關例外錯誤產生.
這部分的保護模式觀念就介紹到此,接下來就讓我們進一步的討論本文所涉及到的GDT,IDT與LDT描述器表的意義與架構,其它所沒提到的保護模式概念,各位可以自行參閱相關書籍,如果以後有機會或許我也可以針對特定的部分再加以介紹補充.
GDT介紹
在X86的保護模式下,可以擁有唯一一個GDT(Global Descriptor Table, 全域描述器表)用來紀錄屬於系統全域的記憶體區段描述資料,如下圖(四)所示,我們可以透過SGDT指令來取得6 bytes 的GDT資料,前2bytes為整個GDT表的大小,後4bytes為GDT表所存在的記憶體位址.

圖(四) ),x86 GDT架構
每個GDT的Entry都可以用來代表一個保護模式下的記憶體節區(Segment),所以每一個Entry主要都會定義了記憶體節區的大小(可以由0—4GB)以及所起始的基底位址,以下筆者針對幾個主要的欄位加以說明
(1)
Segment
Limit(20bits)è定義節區的大小,會先確認欄位G(Granularity)的值
如果G=0èSegment Limit的單位為 1Bytes,所以Segment Limit為2^20-1=1MB-1如果G=1èSegment Limit的單位為 4kBytes,所以Segment Limit為4k*2^20-1=4GB-1
(2)Base
Address(32bits)è定義節區的起始位址,可以由0—2^32-1(4GB-1)
(3)DPL(2bits)(Descriptor
Privilege Level)è定義節區所屬特權等級,可以由0—3(Ring0—Ring3)
其它的欄位筆者在此就不多述,各位可以自行查閱相關技術文件即可得知.
IDT介紹
保護模式下的中斷向量表起始位址,已經不是過去在DOS環境下(真實模式)的0:0,而是在處理器由真實模式切換到保護模式時會重新Reset 8259chip,把中斷向量表定義到新的IDT起始位址,並且把IRQ所對應的中斷重新配置,而這個起始位址就會紀錄在處理器的IDTR暫存器.每當重新觸發一個中斷時,就會根據IDTR所紀錄的記憶體位址來尋找中斷服務的進入點,進而執行中斷服務常式.
如下圖(五)所示,我們可以透過SIDT指令來取得6 bytes的IDT資料 ,前2bytes為整個IDT表的大小,後4bytes為IDT表所存在的記憶體位址.

圖(五),x86 IDT架構
同樣的筆者針對幾個本文會提到的欄位加以說明
(1)Segment
Selector(16bits)è定義中斷服務常式所在的節區選擇器,根據選擇器的值,會對應到GDT表的欄位.
(2)Offset(32bits)è定義中斷服務常式在指定節區內進入點的相對位址(通常都會根據GDT欄位中的BaseAddress+IDT欄位中的Offset來找出中斷服務常式的進入點).
(3)DPL(2bits)(Descriptor
Privilege Level)è定義中斷所屬特權等級,可以由0—3(Ring0—Ring3),例如:由核心提供給Ring3程式碼使用的系統呼叫,就是透過DPL為3的80h中斷來完成,其它的中斷DPL為0, 使用者程式則無法直接使用.
在x86保護模式下前32個中斷(00h—1fh)都被Intel定義為系統例外與預留的中斷服務,因此現在的保護模式作業系統都會把中斷服務定義在20h以後的中斷編號(包括IRQ所對應的中斷).
LDT介紹
在X86保護模式下,如果要實現多工的環境LDT會是一個極為重要的系統機制,透過LDT我們可以充分運用X86系統中Ring0—Ring3的特權等級架構. 如果沒有LDT的協助,X86的保護模式下將只能提供單一特權等級的多工環境, 也就是每個使用者程式都會與系統核心同屬於Ring 0特權等級.
如下圖(六)所示,如果我們不透過LDT來建立保護模式的環境,只以GDT與IDT來產生保護模式機制的話, 我們會建立一個只有單一特權等級的保護模式環境,也就是說,在這樣的架構下,所有的使用者程式與Linux核心都會屬於同一個特權等級,也就是Ring0. 如此的架構是不安全的,因為每個使用者程式都有可能透過記憶體的覆蓋破壞了核心的正常執行. 不過目前這樣的架構也適用於Real-Time作業系統或是軟體架構較為精簡的執行環境中. 因為可以避免User-Mode與Kerenl-Mode彼此資料交換與多工切換上的執行成本.

圖(六),不使用LDT與TSS的單一特權等級多工架構圖
如果說,我們希望使用者程式與Linux核心分屬於不同的特權等級,進而提供更為完整與強固的保護模式,我們就會需要使用LDT來架構這樣的保護模式環境.
透過LDT的協助. 每一個使用者的程式都會有一個獨立的節區描述機制,如此一來針對每個節區描述範圍就可以提供不同的特權等級. 如下圖(七)所示, 每個LDT都會透過一個GDT欄位來紀錄,也因此當使用者程式使用了LDT時,就會由處理器自動透過GDT把所對應到的LDT欄位中的Code Segment與Data Segment載入到系統中,如此一來每個使用者程式都可以根據自己所屬的特權等級來執行(也就是Ring 3),也不容易危害到核心Ring 0程式碼的運作安全.

圖(七),使用LDT與TSS的特權等級多工架構圖
最後,在圖(六)與圖(七)都包括了IDT表與中斷觸發的流程,在此也針對保護模式下中斷觸發流程加以說明,首先如果系統觸發了一個中斷(不論是軟體中斷或是透過IRQ的硬體中斷),都會先透過IDTR(Interrupt Descriptor
Table Register)找到指定中斷的進入點,如之前介紹IDT表的圖(五)所示,透過一個IDT的欄位,我們可以取得一個GDT的選擇器與中斷服務常式在這個選擇器記憶體節區空間中的Offset,之後透過一個GDT的欄位,我們可以得到這個選擇器所屬記憶體節區的基底位址(Base Address)與空間大小(Segment Limit,可由0—4GB的範圍),因此有了BaseAddress與Offset我們就可以找到中斷服務常式在保護模式記憶體中的位址,進而順利的執行中斷呼叫.
取得GDT與IDT的範例程式
如下所示,為一個可以用來取得IDT與GDT內容的Kernel Module程式碼,可以透過以下方式編譯
gcc -D__KERNEL__ -DMODULE -Wall -O2 -I/usr/include/linux -I/usr/src/linux/include -c kernel.c -o kernel.o
再執行insmod kernel.o,就可以在console看到顯示結果了
kernel.c
程式碼
#include
<linux/kernel.h>
#include
<linux/module.h>
#include
<linux/delay.h>
#include <linux/string.h>
//
unsigned int bCS,bDS,bES,bSS;
unsigned int
bEIP,bEDI,bESI,bEBP,bESP;
unsigned int GDT,IDT,LDT;
unsigned char
gdt_b[6],idt_b[6],ldt_b[6];
int i,j;
unsigned int *tt;
unsigned char *idt_base_addr;
unsigned char *gdt_base_addr;
unsigned short gdt_limit;
//
int init_module(void)
{
//
printk("\ninit_module\n\n");
//================================================================
//Begin
//================================================================
asm ("movl $0x3879,
%eax\n\t"
/*Selector*/
"movl %CS, bCS\n\t"
"movl %DS, bDS\n\t"
"movl %ES, bES\n\t"
"movl %SS, bSS\n\t"
/*Offset*/
// "movl %%EIP, EIP\n\t"
"movl %EDI, bEDI\n\t"
"movl %ESI, bESI\n\t"
"movl %EBP, bEBP\n\t"
"movl %ESP, bESP\n\t"
/*Register*/
"sgdt gdt_b\n\t"
"sidt idt_b\n\t"
"sldt LDT\n\t"
);
printk("\nCS:%xh DS:%xh ES:%xh
SS:%xh",bCS,bDS,bES,bSS);
printk("\nEDI:%xh ESI:%xh EBP:%xh
ESP:%xh",bEDI,bESI,bEBP,bESP);
printk("\nLDT:%xh",LDT);
printk("\nIDTR__%x_%x_%x_%x_%x_%x\n",0x000000FF&idt_b[0],0x000000FF&idt_b[1],0x000000FF&idt_b[2],0x000000FF&idt_b[3],0x000000FF&idt_b[4],0x000000FF&idt_b[5]);
printk("\nGDTR__%x_%x_%x_%x_%x_%x\n",0x000000FF&gdt_b[0],0x000000FF&gdt_b[1],0x000000FF&gdt_b[2],0x000000FF&gdt_b[3],0x000000FF&gdt_b[4],0x000000FF&gdt_b[5]);
//================================================================
//show GDT
//================================================================
tt=(unsigned int
*)&gdt_b[2];
gdt_base_addr=(unsigned
char *)*tt;
tt=(unsigned
int *)&gdt_b[0];
gdt_limit=(unsigned
short )*tt;
printk("\ngdt_limit:%xh
gdt_base_addr:%xh\n",gdt_limit,(unsigned int)gdt_base_addr);
for(j=0x00;j<=gdt_limit;j+=8)
{
printk("\nSelector:%xh_Limit:%x_%x_%xh_BaseAddress:%x_%x_%x_%xh_Attr:%x_%x",
j,
0x0000000F
& gdt_base_addr[6+j],
0x000000FF
& gdt_base_addr[1+j],0x000000FF & gdt_base_addr[0+j], 0x000000FF
& gdt_base_addr[7+j],0x000000FF & gdt_base_addr[4+j], 0x000000FF
& gdt_base_addr[3+j],0x000000FF & gdt_base_addr[2+j], 0x000000F0
& gdt_base_addr[6+j],0x000000FF & gdt_base_addr[5+j]
);
}
//================================================================
//show IDT
//================================================================
//idt_base_addr=(unsigned
char *)&idt_b[2];
tt=(unsigned
int *)&idt_b[2];
idt_base_addr=(unsigned
char *)*tt;
printk("\nidt_base_addr:%xh\n",(unsigned int)idt_base_addr);
for(i=0x00;i<256;i++)
{
j=i*8;
printk("\nInterrupt:%xh_Offset:%x_%x_%x_%xh_Selector:%x_%xh_Attr:%x_%x",
i,
0x000000FF
& idt_base_addr[7+j],0x000000FF & idt_base_addr[6+j],
0x000000FF
& idt_base_addr[1+j],0x000000FF & idt_base_addr[0+j],
0x000000FF
& idt_base_addr[3+j],0x000000FF & idt_base_addr[2+j],
0x000000FF
& idt_base_addr[5+j],0x000000FF & idt_base_addr[4+j]
&