基於 Bochs 的操作系統內核實現

簡介

Bochs 簡介

Bochs(讀音Box)是一個開源的模擬器(Emulator),它可以完全模擬x86/x64的硬件以及一些外圍設備。與VirtualBox / VMware等虛擬機(Virtual Machine)產品不同,它的設計目標在於模擬一臺真正的硬件,並不追求執行速度的高效,而追求模擬環境的真實,同時帶有強大的調試功能,比如觀察寄存器、對實地址/虛擬地址下斷點、裝載符號表等等。對於操作系統內核的開發者而言,是一隻不可多得的強力工具,通過簡單的設置,即可大大地降低內核開發與調試的困難。

作爲開源軟件,我們可以很方便地獲取它:

安裝

在Ubuntu操作系統下,可以通過apt-get來安裝:

sudo apt-get install bochs

若要利用Bochs的調試功能,則需要自己編譯安裝:

wget http://sourceforge.net/projects/bochs/files/bochs/2.5.1/bochs-2.5.1.tar.gz/download -O bochs.tar.gz
tar -xvfz bochs.tar.gz
cd bochs-2.5.1
./configure --enable-debugger --enable-debugger-gui --enable-disasm --with-x --with-term
make
sudo cp ./bochs /usr/bin/bochs-dbg

配置

Bochs 提供了許多配置選項,在項目中,我們可以靈活的選擇/設置自己所需的功能,比如模擬器的內存大小、軟/硬盤鏡像以及引導方式等等。而這些配置選項都統一在一個.bochsrc文件中,樣例如下:

.bochsrc:

# BIOS與VGA鏡像
romimage: file=/usr/share/bochs/BIOS-bochs-latest
vgaromimage: file=/usr/share/bochs/VGABIOS-lgpl-latest
# 內存大小
megs: 128
# 軟盤鏡像
floppya: 1_44=bin/kernel.img, status=inserted
# 硬盤鏡像
ata0-master: type=disk, path="bin/rootfs.img", mode=flat, cylinders=2, heads=16, spt=63
# 引導方式(軟盤)
boot: a
# 日誌輸出 
log: .bochsout
panic: action=ask
error: action=report
info: action=report
debug: action=ignore
# 雜項
vga_update_interval: 300000
keyboard_serial_delay: 250
keyboard_paste_delay: 100000
mouse: enabled=0
private_colormap: enabled=0
fullscreen: enabled=0
screenmode: name="sample"
keyboard_mapping: enabled=0, map=
keyboard_type: at
# 符號表(調試用)
debug_symbols: file=main.sym
# 鍵盤類型
keyboard_type: at

在啓動bochs時,使用命令:

bochs -q -f .bochsrc

內置調試器

bochs內置了強大且方便的調試功能。主要命令如下:

  • b,vb,lb 分別爲物理地址、虛擬地址、邏輯地址設置斷點
  • c 持續執行,直到遇到斷點或者錯誤
  • n 下一步執行
  • step 單步執行
  • r 顯示當前寄存器的值
  • sreg 顯示當前的段寄存器的值
  • info gdtinfo idtinfo tssinfo tab 分別顯示當前的GDT、IDT、TSS、頁表信息
  • print-stack 打印當前棧頂的值
  • help 顯示幫助

fleurix 簡介

fleurix 是一個簡單的單內核(Monolithic Kernel)操作系統實現,它的功能精簡但不失完整,代碼簡短(七千行C,二百多行彙編)且易於閱讀,可作爲操作系統課程教學中的樣例系統。在設計時選擇採用了類UNIX的系統調用接口,因此在開發過程中可以獲取豐富的文檔供參考,也可以作爲學習UNIX操作系統實現的一個參考材料。

fleurix 在編寫時儘量使用最簡單的方案。它假定CPU爲單核心、內存固定爲128mb,前者可以簡化內核同步機制的實現,後者可以簡化內存管理的實現。從技術角度來看,這些假定並不合理,但可以有效地降低剛開始開發時的複雜度。待開發進入軌道,也不難回頭解決。 此外,你也可以在源碼中發現許多窮舉算法——在數據量較小的前提下,窮舉並不是太糟的解決方案。

特性

  • minix v1的文件系統。原理簡單,而且可以利用linux下的mkfs.minix,fsck.minix等工具。
  • fork()/exec()/exit()等系統。可執行文件格式爲a.out,實現了寫時複製與請求調頁。
  • 信號。
  • 一個純分頁的內存管理系統,每個進程4gb的地址空間,共享128mb的內核地址空間。至少比Linux0.11中的段頁式內存管理方式更加靈活。
  • 一個簡單的kmalloc()
  • 一個簡單的終端。

編譯運行

git clone [email protected]:Fleurer/fleurix.git
cd fleurix
rake

調試

# 需要自行編譯安裝帶調試功能的bochs-dbg,安裝步驟參見前文。
cd fleurix
rake debug

設計與實現

編譯與鏈接

fleurix的內核鏡像爲裸的二進制文件,結構大體如下:

(補圖)

Rakefile

對於項目中的一些日常性質操作,比如:

  • 編譯bootloader,生成引導鏡像
  • 編譯並鏈接內核,生成內核鏡像
  • 生成符號表
  • 初始化根文件系統,生成硬盤鏡像
  • 編譯整個項目,並運行bochs進行調試

它們需要的命令比較多,而且存在依賴關係,此任務必須在確保彼任務執行完畢併成功之後纔可以執行。對此,比較通用的解決方案便是make,它可以自動分析任務之間的依賴關係再依次執行,從而簡化日常操作的腳本編寫。但是make的語法比較晦澀,對於沒有任何基礎的初學者來講,上手起來並不容易。爲此fleurix選擇了rake,它相當於make的ruby實現,可以使用ruby語言的語法來編寫make腳本,好處是易於上手,而代價是不如make的語法簡潔。

fleurix中常用的rake命令有:

  • rake或者rake bochs,構建整個項目並運行bochs
  • rake build,構建整個項目到/bin目錄
  • rake debug,構建整個項目並運行bochs的調試器
  • rake clean,將/bin目錄清空
  • rake nm,生成符號表
  • rake todo,列出代碼中遺留的待解決事項
  • rake werr,打開gcc的-Werror選項進行編譯,方便排除代碼中的warning
  • rake rootfs,構建根文件系統
  • rake fsck,對根文件系統執行fsck,檢查結構是否正確

ldscript

內核開發與應用程序開發的不同之一便在於開發者需要對二進制鏡像的結構有所瞭解,在必要時必須進行一些重定位。比如內核的入口爲0x100000,爲此需要將入口的代碼(bin/entry.o)安排到內核鏡像的最前方。而這便可以通過ldscript來完成,如下:

tool/main.ld:

ENTRY(kmain)
SECTIONS {
    __bios__ = 0xa0000; # 綁定BIOS保留內存的地址到__bios__ 
    vgamem = 0xb8000; # 綁定vga緩衝區的地址到符號vgamem
    .text 0x100000 : { # 內核二進制鏡像中的.text段(Section),從0x100000開始
        __kbegin__ = .; # 內核鏡像的開始地址
        __code__ = .;
        bin/entry.o(.text) bin/main.o(.text) *(.text); # 將bin/entry.o中的.text段安排到內核鏡像的最前方
        . = ALIGN(4096); # .text段按4kb對齊
    }
    .data : { 
        __data__ = .;
        *(.rodata);
        *(.data);
        . = ALIGN(4096);
    }
    .bss : {
        __bss__ = .;
        *(.bss);
        . = ALIGN(4096);
    }
    __kend__ = .; # 內核鏡像的結束地址
}

Rakefile中的相關命令如下,在鏈接時選擇tool/main.ld作爲鏈接腳本:

sh "ld #{ofiles * ' '} -o bin/main.elf -e c -T tool/main.ld"

bootloader

bootloader是一段小程序,負責執行一些初始化操作,並將內核裝載到內存,是內核執行的入口,也是內核開發的第一步。

x86體系結構的CPU在設計中爲了保持向前兼容,在PC機電源打開之後,x86平臺的CPU會先進入實模式(Real Mode),並從0xFFF0開始執行BIOS的一些初始化操作。隨後,BIOS將依次檢測啓動設備(軟盤或者硬盤)的第一個扇區(512字節),如果它的第510字節處的值爲0xAA55,則認爲它是一個引導扇區,將它裝載到物理地址0x7C00,並跳轉到0x7C00處開始執行。這便是bootloader的入口地址。

實模式中默認可用的地址總線爲20位,可以尋址1mb的內存,但寄存器只有16位。爲此英特爾公司做出的設計是,在實模式的尋址模式中,令物理地址爲16位段寄存器左移4位加16位邏輯地址的偏移所得的20位地址。若要訪問1mb之後的內存,則必須開啓A20 Line開關,將32位地址總線打開,並進入保護模式(Protect Mode)纔可以。

在實模式中,0~4kb爲中斷向量表保留,640kb~1mb爲顯存與BIOS保留,實際可用的內存只有636kb。考慮到日後內核鏡像的體積有超過1mb的可能,所以將其裝載到物理地址1mb(0x100000)之後連續的一塊內存中可能會更好。但實模式中並不可以訪問1mb以後的內存,若要裝載內核到物理地址1mb,一個解決方案便是在實模式中暫時將其裝載到一個臨時位置,待進入保護模式之後再移動它。

由上總結可知,bootloader所需要做的工作便依次爲:

  • 裝載內核鏡像到一個臨時的地址;
  • 進入保護模式;
  • 移動內核鏡像;
  • 跳轉到內核的入口。

相關代碼可見於 src/boot/boot.S 。

保護模式與GDT

x86的保護模式是對段尋址的增強,除去可以訪問32位的地址空間(4Gb)之外,更有了對保護級別(即ring0/ring1/ring2/ring3)的劃分、對內存區域的限制、以及訪問控制。爲實現這些功能,x86的做法是引入了GDT(Global Descriptor Table)。將每個段(Segments)的屬性對應爲GDT中的一項段描述符(Segment Descriptor),並通過段寄存器(如csdsss)中指明的選擇符進行選擇。GDT是駐留於內存中的一個表,通過lgdt指令裝載到CPU。

在bootloader中進入保護模式的目的僅僅是爲了訪問1mb以後的內存,而且bootloader在完成引導系統之後即被視爲廢棄,因此這裏的GDT只能做臨時使用。其中含有兩個段描述符,它們的選擇符分別爲0x08與0x10,分別用於內核態代碼與數據的訪問。

進入內核之後,fleurix會在gdt_init()中重新設置GDT(見scr/kern/seg.c)。

fleurix是一個純分頁的系統,雖然並不需要段式的內存管理,但依然需要一個GDT,只採用它的內存保護功能,而繞過它的分段功能。在fleurix最終的GDT中,將只保留四個段描述符,它們的內存區域皆爲0~4Gb,選擇符分別爲KERN_CSKERN_DSUSER_CSUSER_DS——前兩者的權限爲ring0,用於內核態代碼與數據的訪問;後兩者的權限爲ring3,分別用於用戶態代碼與數據的訪問——從而實現內核態與用戶態的分離,使後者受到更多限制,將系統“保護”起來。

需要留意的是,除四個段描述符之外,fleurix的GDT中也帶有一個TSS描述符,其選擇符爲(_TSS)。英特爾公司引入TSS機制的動機爲實現硬件的任務切換,每個任務擁有一個TSS,在進程切換時,將當前進程的所有上下文保存在TSS中。比起軟件的任務切換,硬件任務切換的開銷相對比較大,而且沒有調試與優化的餘地。fleurix採用了軟件的任務切換機制,並無用到TSS的任務切換功能,但依然保留一個TSS是爲了保存中斷處理時ss0與esp0兩個寄存器的值,在CPU通過中斷門或者自陷門轉移控制權時,據此獲取內核棧的位置。

裝載內核

在早期開發中爲方便裝載,fleurix內核的二進制鏡像被放置在軟盤鏡像中,自第二個扇區開始,大約爲50kb。

在實模式中,可以通過調用13h號中斷來讀取軟盤扇區,將內核鏡像臨時讀取到物理地址0x10000處。在設置臨時的GDT之後,通過jmp指令進入保護模式,並將內核拷貝至物理地址0x100000(1mb)處。

內核初始化

待bootloader執行完畢之後,內核會首先進入kmain()(見src/kern/main.c),執行一些初始化操作。這些操作依次爲:

  • 清理屏幕(cls(),見src/chr/vga.c),初始化puts()printk()等函數供調試與輸出使用。
  • 重新設置GDT(gdt_init(),見src/kern/seg.c)。
  • 初始化IDT(idt_init(),見src/kern/trap.c)。
  • 初始化內存管理(mm_init(),見src/kern/pm.c)。
  • 初始化進程0(proc0_init(),見src/kern/proc.c)。
  • 初始化高速緩衝(buf_init(),見src/blk/buf.c)。
  • 初始化tty(tty_init(),見src/chr/tty.c)。
  • 初始化硬盤驅動(hd_init(),見src/blk/hd.c)。
  • 初始化內核定時器(timer_init(),見src/kern/timer.c)
  • 初始化鍵盤驅動(keybd_init(),見src/chr/keybd.c)。
  • 開啓中斷(sti(),見src/inc/asm.h)。
  • 初始化進程1(kspawn(&init)),通過do_exec()(見src/kern/exec.c)即進入用戶態。

中斷處理

中斷是CPU中打斷當前程序的控制流以處理外部事件、報告錯誤或者處理異常的一種機制。若詳細分類,仍可將中斷分爲三種:

  • 中斷(Interrupt):由CPU外部產生,CPU處於被動的位置,多用於CPU與外部設備的交互。
  • 自陷(Trap):在CPU本身的執行過程中產生。一般由專門的指令有意產生,比如int $0x80,因此又被稱作"軟件中斷"。
  • 異常(Exception):因CPU執行某指令失敗而產生,如除0、缺頁等等。與自陷的不同在於,CPU會在處理例程結束之後重新執行產生異常的指令。

(注:即,自陷發生時,入棧的返回地址爲下一條指令的地址;而異常發生時,入棧的返回地址爲當前指令的地址)

在保護模式的x86平臺中,中斷通過中斷門(Interrupt Gate)轉移控制權,自陷與異常通過自陷門(Trap Gate)轉移控制權。

每個中斷對應一箇中斷號,系統開發者可以將自己的中斷處理例程綁定到相應的中斷號,表示中斷號與中斷處理例程之間映射關係的結構被稱作中斷向量表(Interupt Vector Table)。在保護模式中的x86平臺,這一結構的實現爲IDT(Interrupt Descriptor Table)。與GDT類似,IDT也是一個駐留於內存中的結構,通過lidt指令裝載到CPU。每個中斷處理例程對應一個門描述符(Gate Descriptor)。在fleurix中初始化IDT的代碼位於idt_init()(見src/trap.c)。

在中斷髮生時,CPU會先執行一些權限檢查,若正常,則依據特權級別從TSS中取出相應的ss與esp切換棧到內核棧,並將當前的eflags、cs、eip寄存器壓棧(某些中斷還會額外壓一個error code入棧),隨後依據門描述符中指定的段選擇符(Segment Selector)與目標地址跳轉到中斷處理例程。 保存當前程序的上下文則屬於中斷處理例程的工作。在fleurix中,保存中斷上下文的操作由_hwint_common_stub(見src/kern/entry.S.rb)負責執行,它會將中斷上下文保存到棧上的struct trap結構(見src/inc/idt.h)。

在這裏有三個地方值得留意:

  • 只有部分中斷會壓入error code,這會導致棧結構的不一致。爲了簡化中斷處理例程的接口,fleurix採用的方法是通過代碼生成,在中斷處理例程之初爲不帶有error code的中斷統一壓一個雙字入棧,值爲0,佔據error code在struct trap中的位置。並將中斷調用號壓棧,以方便程序的編寫與調試。
  • fleurix中的中斷處理例程都經過彙編例程_hwint_common_stub,它在保存中斷上下文之後,會調用hwint_common()(見src/kern/trap.c)函數。hwint_common()函數將依據中斷號,再查詢hwint_routines數組找到並調用相應的處理例程。
  • 中斷的發生往往就意味着CPU特權級別的轉換,因此,可以將陷入(或稱"軟件中斷")作爲用戶態進入內核態的入口,從而實現系統調用。在fleurix中系統調用對應的中斷號爲0x80,與linux相同。

I/O

外部設備一般分爲機械部分與電路部分。電路部分又被稱作控制器(Controller)或者適配器(Adapter),負責設備的邏輯與接口。

CPU一般都是通過寄存器的形式來訪問外部設備。外設的寄存器通常包括控制寄存器、狀態寄存器與數據寄存器三類,分別用於發送命令、讀取狀態、讀寫數據。按照訪問外設的寄存器的方式,CPU又主要分爲兩類:

  • 將外設寄存器與內存統一編址(Memory-Mapped):訪問寄存器即一般的內存讀寫,沒有專門用於I/O的指令。
  • 將外設寄存器獨立編址(I/O-Mapped):每個寄存器對應一個端口號(port),通過專門讀/寫的指令訪問外設的寄存器,如inout指令。

x86是後者:採用獨立編址的方式,外設寄存器即I/O端口,並通過in、out等彙編指令進行讀寫。

在fleurix中,提供瞭如下的幾個函數來讀寫端口:

  • inb()outb():按字節讀寫端口
  • inw()outw():按字讀寫端口
  • insb()outsb():對某端口讀/寫一個字節序列
  • insl()outsl():對某端口讀/寫一個雙字的序列

以上函數都是對彙編指令的簡單包裝,定義於src/inc/asm.h

留意它們的源代碼,可以注意到它們都會在最後調用一個io_delay()函數。這是因爲對於一些老式總線的外部設備,讀寫I/O端口的速度若過快就容易出現丟失數據的現象,爲此在每次I/O操作之間插入幾條指令作爲延時,等待慢速外設。

PIT

fleurix通過Intel 8253 PIT(Programmable Interval Timer)定時器定時產生中斷,用於計時與進程調度。

Intel 8253 PIT芯片擁有三個定時器:作爲系統時鐘,定時器1爲歷史遺留中用於定期刷新DRAM,定時器2用於揚聲器。三個定時器分別對應三個數據寄存器0x40、0x41、0x42,以及一個命令寄存器0x43。這裏只需要關心計時器0的功能,用到的寄存器只有0x40與0x43。

定時器0的默認頻率爲1193180HZ,可以通過如下的代碼調整它的頻率:

uint di = 1193180/HZ;
outb(0x43, 0x36);
outb(0x40, (uchar)(di&0xff));
outb(0x40, (uchar)(di>>8));

以上代碼摘自src/kern/timer中的timer_init()。其中HZ常量的值爲100,如此設置,可使PIT在每100毫秒產生一次中斷,觸發中斷處理例程do_timer()。每次時鐘中斷被稱作一個節拍(tick)。

VGA

VGA(Video Graphics Array,視頻圖形陣列)是使用模擬信號的一種視頻傳輸標準,內核可以通過它來控制屏幕上字符或者圖形的顯示。

在默認的文本模式(Text-Mode)下,VGA控制器保留了一塊內存(0x8b000~0x8bfa0)作爲屏幕上字符顯示的緩衝區,若要改變屏幕上字符的顯示,只需要修改這塊內存就好了。它可以被視作如下的一個二維數組,以表示屏幕上顯示的25x80個字符:

/* VGA is a memory mapping interface, you may view it as an 80x25 array
 * which located at 0x8b000 (defined in main.ld).
 * */
extern struct vchar vgamem[25][80];

其中每項的結構如下:

struct vchar {
    char    vc_char:8;
    char    vc_color:4;
    char    vc_bgcolor:4;
};

其中,vc_char表示要顯示的字符內容,vc_colorvc_bgcolor分別表示字符的顏色與字符的背景色。

除了字符的顯示,我們也希望能夠控制光標的位置,這裏需要用到的是0x3D40x3D5兩個端口,相關代碼如下:

/* adjust the position of cursor */
void flush_csr(){
    uint pos = py * 80 + px;
    outb(0x3D4, 14);
    outb(0x3D5, pos >> 8);
    outb(0x3D4, 15);
    outb(0x3D5, pos);
}

VGA內部的寄存器多達300多個,顯然無法一一映射到I/O端口的地址空間。對此VGA控制器的解決方案是,將一個端口作爲內部寄存器的索引:0x3D4,再通過0x3D5端口來設置相應寄存器的值。在這裏用到的兩個內部寄存器的編號爲1415,分別表示光標位置的高8位與低8位。

以上代碼皆可見於src/chr/vga.c

系統調用

系統調用(System Call)即應用程序訪問內核的接口。

在fleurix中,每個系統調用對應一個系統調用號,在調用時,將系統調用號放置於eax,將可能的參數放置於ebxecxedx,最後通過int 0x80從而進入內核並觸發中斷處理例程do_syscall(),繼而依據系統調用號查詢數組sys_routines[]找到對應的處理例程並執行。待執行結束之後,將返回值放置於中斷上下文的eax寄存器中,並在出錯時設置errno

爲方便在應用程序中調用系統調用,fleurix提供了四個宏_SYS0_SYS1_SYS2_SYS4來生成系統調用的C接口。以_SYS3爲例:

#define _SYS3(T0, FN, T1, T2, T3)               \
    T0 FN(T1 p1, T2 p2, T3 p3){                 \
        register int r;                         \
        asm volatile(                           \
            "int $0x80"                         \
            :"=a"(r)                            \
            :"a"(NR_##FN),                      \
            "b"((int)p1),                       \
            "c"((int)p2),                       \
            "d"((int)p3)                        \
        );                                      \
        if (r<0){                               \
            errno = -r;                         \
            return -1;                          \
        }                                       \
        return r;                               \
    }

...
static inline _SYS3(int, write, int, char*, int);
static inline _SYS3(int, read, int, char*, int);
static inline _SYS3(int, lseek, int, int, int);
...

更具體的實例,可見於usr/目錄下的幾個應用程序。

分頁

fleurix應用x86平臺的分頁機制,實現了純頁式的內存管理。與段式內存管理(如DOS)或者段頁式混合的內存管理(如linux0.11)相比,純頁式內存管理(以下簡稱"頁式內存管理")中可用的地址空間更大,也更加靈活。比如寫時複製與請求調頁這樣的機制,在段式內存管理中則屬於不可能實現的。

按照x86平臺的分頁機制,內存被劃分爲4kb或者4mb大小的物理頁(又稱"頁框"),由頁表來表示虛擬頁到物理頁的映射關係。爲節約頁表本身所佔用的內存,x86採用了二級頁表。每個頁表佔4kb,含有1024條頁表項,可以映射4mb的地址空間;頁目錄也同樣4kb,含有1024項,可以映射4gb的地址空間。在進行地址翻譯時,將先查詢頁目錄,找到虛擬地址對應的頁表,再在頁表中查詢得出相應的物理頁,外加頁內的偏移,最終得到物理地址。其中有個例外,便是4mb的大頁,頁目錄中的表項可以不指向一個頁表,而是僅僅表示一個4mb大頁的地址映射,它的便利之處在於映射大塊連續的地址空間,可以做到既方便又高效。

對於CPU來說,每次地址翻譯都到內存中查詢頁表是不可容忍的高開銷,爲此,支持分頁的CPU往往都提供了TLB(Translation Lookaside Buffer,俗稱"快表")作爲頁表的緩存。在這裏開發者需要留意的是,只要更新了頁表,便需要留意保持TLB的同步,不然就會有一些難於調試的問題出現。在bochs的內嵌調試器中,可以通過info tab命令來檢查當前的頁面映射。

(注: 因爲內存局部性原理,TLB一般只需要很小(比如64項)即可達到不錯的效果。)

頁面可以被標記爲只讀(Readonly)或者不存在(Non-Present),也可以設置頁面的保護級別。這一來在讀寫內存時,如果發生不合法的內存讀寫,就會產生一個頁面錯誤(Page Fault),觸發中斷處理例程do_pgfault()(見src/mm/pgfault.c)。這時產生頁面錯誤的地址,將被保存在cr2寄存器中,同時產生一個error code,表示頁面錯誤的類型。待頁面錯誤處理完成,被打斷的程序可以恢復執行,也有可能因爲嚴重的錯誤而中止(收到信號SIGSEGV)。

在fleurix中,每個進程擁有一個獨立的頁目錄,從而實現進程地址空間的隔離;通過4mb的大頁,實現虛擬地址與物理地址的一對一映射直到128mb爲止,作爲內核地址空間;並過頁表項的保護級別,限制用戶態應用程序對內核地址空間的讀寫;通過將頁面標記爲只讀或者不存在,實現寫時複製(Copy On Write)與請求調頁(Demand Paging)。 。

對於x86平臺,值得留意的地方有:

  • cr0寄存器中的Paging位表示分頁機制的開關(mmu_enable(), 見src/inc/asm.h);
  • cr4寄存器中的PSE位表示4mb大頁的開關(在一些較舊的CPU上並沒有PSE的支持);
  • 頁目錄的地址裝載於cr3寄存器(lpgd(),見src/inc/asm.h);
  • 頁面錯誤中的error code可能會有三種flag,即PFE_PPFE_WPFE_U(定義於src/inc/mmu.h),分別表示頁面不存在、頁面只讀及權限不足。
  • 只要重新裝載頁目錄,即爲刷新TLB(flmmu(),見src/mm/pte.c)。

內存分配

fleurix假定用戶的物理內存爲128mb,並將內核永遠地映射於每個地址空間的低端(0~128mb),使得內核地址空間中的虛擬地址與物理地址做到一對一的映射。這一來只要分配了物理頁面,內核就可以直接讀寫它的內容或將它映射到用戶進程。需要留意的是,從技術角度這一假設並不合理:若用戶的物理內存若小於128mb,內核就會崩潰;若用戶的物理內存大於128mb,則無法利用128mb以上的內存。但它可以有效地降低項目開發之初的複雜度,待項目進入軌道,則應優先解決這一問題。

pgalloc()pgfree()爲內核內存分配的基礎例程,分別用於申請/釋放一個物理頁。一個物理頁面可能會被多個進程映射到,因此一個引用計數是必須的;物理頁面可能會比較多,使用窮舉式的分配效率不高。對此,fleurix實現了一個struct page結構(定義於src/inc/page.h),並在內核初始化時,初始化一個數組struct page coremap[NPAGE]與一個隊列struct page pgfreelist(見於src/mm/pm.c中的pm_init()),前者作爲物理頁是否可用的標記,數組的每一項對應一個物理頁,物理頁面的地址就等於數組下標 * 4kb,若對應的struct page結構中的引用計數爲0,則表示物理頁是可用的;後者則將所有可用的物理頁組織到一個鏈表之中,這一來即可將分配/釋放物理頁的操作的時間複雜度降到O(1)。

kmalloc()

fleurix使用了一個簡單且高效的內存分配算法,它將pgalloc()作爲後端,能夠以O(1)的時間分配2次冪對齊的虛擬內存塊,單次內存分配的上限爲4kb。

kmalloc()將固定大小的內存塊(32b、64b、128b...4kb)分別組織爲不同的鏈表。假如待分配的內存塊大小爲n,它會依據n來找到合適的鏈表(通過bkslot(),見於src/mm/malloc.c),其中內存塊的大小爲m(m爲2次冪且n <= m <= 4096),然後檢查鏈表中是否有可用的內存塊。如果有,就將它取出鏈表,直接返回;如果沒有,則通過pgalloc()分配一個物理頁,將它劃分爲4096 / m個內存塊並鏈到對應的鏈表中,重複嘗試分配。

與C標準庫函數free()的不同在於,kfree()需要調用者記住內存塊的大小,用以找到對應的鏈表。這是個不好的設計,使用者若將大小寫錯就會有bug產生。另外值得留意的地方是,除了4kb內存塊的特殊情況,kfree()不能將其它物理頁返還給操作系統,而是留做以後內存分配的保留內存。這是一個不足之處,如果一次性分配比較多的臨時對象,將會造成較大的內存浪費。

kmalloc()kfree()都不會進入睡眠,如果物理內存用盡則會產生一個panic(),這是編寫代碼時爲方便調試而遺留的問題,也是亟需待改進的一個地方。

靜態內存分配

對於內核中常用數據結構(比如struct inodestruct super等)的內存分配,fleurix採用的還是早期UNIX的解決方案:某類對象單獨一個固定長度的數組,通過對象的一個標誌判斷是否可用,如果可用,則爲修改標誌並返回;如果不可用,則根據情況進入睡眠或者返回錯誤。

這一方案的優點在於幾乎沒有任何依賴,在內核開發之初即可在一定程度上滿足內存分配的需求。不足在於每類對象的分配都是代碼的重複,代碼的複用率很低。

改進方案就是採用slab算法,將不同的對象組織在不同的緩存之中,而將內存分配的接口統一起來。

進程

進程即運行中的程序實體。每個進程擁有獨立的地址空間以及一些資源,相互併發執行。在fleurix中,進程爲代碼執行與資源管理的基本單位。

終其一生,進程可能有五種狀態:

  • SSLEEP: 睡眠且不可被信號喚醒,等待一個高優先級的事件;
  • SWAIT: 睡眠,可被信號喚醒,等待一個低優先級的事件;
  • SRUN: 正常執行;
  • SZOMB: 殭屍進程,是在進程因爲某種原因退出執行(主動調用_exit()或者被信號殺死)、在被父進程回收之前的進程狀態。
  • SSTOP: 停止中,在進程創建之初以及進程回收時的進程狀態。

在fleurix中,表示進程的結構爲struct proc,它含有進程的pid(p_pid)、狀態(p_stat)、父進程id(p_ppid)、進程組(p_pgid)、用戶id(p_uid)、組id(p_gid)、地址空間(p_vm)、上下文(p_contxt)、打開的文件(p_ofile)、信號處理例程(p_sigact)、可執行文件的inode(p_inode)等諸多信息,正是fleurix中最爲複雜的結構。

struct proc與這一進程的內核棧同處一個物理頁,前者位於低端固定,後者位於高端向下增長。在這裏不難發現,內核棧的可用空間非常小(小於4kb),因此在內核開發中,應尤其注意不要在棧上放置較大的對象,抑或進行較深的遞歸,不然內核棧若溢出,絕不會像用戶態中那樣出現Segmentation Fault的提示,而會默默地搞亂內核中的數據結構,出現一些難於調試的問題。

爲方便對進程結構的引用,fleurix設置了一個數組即struct proc *proc[NPROC],數組的下標即進程的pid,NPROC則爲系統中進程數量的上限;以及一個指針struct proc *cu,永遠指向當前的進程結構。

進程創建

進程只能通過fork()系統調用創建,它會複製當前進程的地址空間與資源,生成一個一模一樣的子進程。不過,fork()會在父進程中返回0,在子進程中則返回子進程的pid作爲區別,如下:

int pid;
if ((pid = fork()) == 0) {
    printf("I'm the parent process\n");
}
else {
    printf("I'm the child process\n");
}

fork()時,直接複製整個進程地址空間的操作是昂貴的,而且大多數子進程都會在執行之初調用exec()覆蓋掉當前地址空間,之前的複製也就沒有意義了。對此,類UNIX系統大多基於CPU的分頁機制,提供了寫時複製的實現:在複製進程地址空間時,並不直接拷貝地址空間中頁面的內容,而是僅僅複製父進程的頁表,使得父子進程共享相同的物理頁,並將二者的虛擬頁面皆設置爲只讀。隨後若二者任一方試圖修改內存,則申請一個新的物理頁並複製舊頁的內容。這裏需要留意的是,爲控制物理頁的共享,每個物理頁都需要維護一個引用計數,當fork()時引用計數增1,當進程殺死或者發生寫時複製時減1,並在引用計數爲0時釋放這個物理頁。

fork()的主要行爲大致如下:

  • 申請pid與進程結構
  • 設置ppid爲父進程的pid
  • 複製用戶相關的字段,如p_pgrpp_gidp_ruidp_euidp_rgidp_egid
  • 複製調度相關的字段,如p_cpup_nicep_pri
  • 複製父進程的文件描述符(p_ofile),並增加引用計數
  • 複製父進程的信號處理例程(p_sigact)
  • 通過vm_clone()(見於src/mm/vm.c),複製父進程的地址空間(p_vm)
  • 複製父進程的寄存器狀態(p_contxt)
  • 複製父進程的中斷上下文,並設置tf->eax0,使fork()在子進程中返回0。

fleurix在開始運行之初會初始化一個0號進程(proc0_init(),見於src/kern/fork.c),其後的所有進程皆由它fork而來。

程序執行

exec()是fleurix中行爲最爲複雜的系統調用之一。就表面的行爲而言,它會取一個可執行文件的地址與相關參數(argv),並執行它。然而在內部,它所做的工作卻遠比表面上覆雜:

  • 讀取文件的第一個塊,檢查Magic Number(NMAGIC)是否正確
  • 保存參數(argv)到臨時分配的幾個物理頁,其中的每個字符串單獨一頁
  • 清空舊的進程地址空間(vm_clear(),見於src/mm/vm.c),並結合可執行文件的header,初始化新的進程地址空間(vm_renew(),見於src/mm/vm.c)
  • argvargc壓入新地址空間中的棧
  • 釋放臨時存放參數的幾個物理頁
  • 關閉帶有FD_CLOEXEC標識的文件描述符
  • 清理信號處理例程
  • 通過_retu()返回用戶態

這裏值得留意的是,之所以將argv保存到臨時分配的幾個頁面,是因爲argv中的字符串與這個數組本身都是來自舊的地址空間,而舊的地址空間會被銷燬,argv所指向的內存區域,自然也就無法訪問了。

與寫時複製的實現相似,exec()在執行時,並不會立即將可執行文件完全讀入內存。而是通過vm_renew(),將當前進程的虛擬頁面統統設置爲不存在,待進入用戶態開始執行時,每發生一次頁面不存在的錯誤,便讀取一頁可執行文件的內容並映射。這樣的機制被稱作請求調頁(Demand Paging),好處是可以加速程序的啓動,不必等待可執行文件完全讀入內存即可開始程序的執行,在某種意義上,也可以節約內存的使用。缺點是如果程序的體積較小,就不如一次性將可執行文件全部讀入內存的方式高效。

爲簡單起見,fleurix只支持a.out格式作爲可執行文件格式,對應可執行文件中不同的區段(section),進程的地址空間也分爲不同的內存區(VMA,Virutal Memory Area),如正文區(.text)、數據區(.data)、bss區(.bss)、堆區(.heap)與棧區(.stack)。它們的性質各不相同:正文區與數據區內容都來自可執行文件,然而正文區是隻讀的,數據區可讀可寫;bss區、堆區與棧區的內存皆來自動態分配,都可讀可寫,不過bss區的內存都默認爲0,堆區可以通過brk()系統調用來調整它的長度,而棧區可以自動向下增長。對於這些不同需求,fleurix提供了一個結構struct vma,它可以綁定一個inode,並在必要時依據相關的幾個標誌(即VMA_RDONLYVMA_STACKVMA_ZEROVMA_MMAPVMA_PRIVATE)執行不同的操作。具體可見於src/mm/pgfault.c文件中do_no_page()的相關代碼。

進程切換

負責進程切換的函數爲swtch_to(),可見於src/kern/sched.c。內容如下:

void swtch_to(struct proc *to){
    struct proc *from;
    tss.esp0 = (uint)to + PAGE; 
    from = cu;
    cu = to;
    lpgd(to->p_vm.vm_pgd);
    _do_swtch(&(from->p_contxt), &(to->p_contxt));
}

_do_swtch()是一段彙編例程,它負責將當前的上下文保存到from->p_contxt,同時將to->p_contxt中保存的上下文恢復出來,也就是真正發生進程切換的地方。

fleurix採用軟件的進程切換,一切進程切換都發生在內核態。結合swtch_to的源碼,已知進程的上下文有:

  • 內核棧的頂,供中斷處理例程使用;
  • 頁目錄,也就是地址空間;
  • eip
  • esp與所有其它通用寄存器(eaxebxecxedxediesiebp)。

需要留意的是,依據gcc的調用約定,eaxecxedxcaller-saved registers,會在調用_swtch_to()時由調用者自動保存,不需要額外保存,因此內核只需要保存ebxebpediesiesp五個通用寄存器。 另外,因爲進程切換都發生在內核態,cs等段寄存器的內容皆等同於常量,也無需保存。

fleurix將上下文相關的寄存器保存在一個struct jmp_buf結構中,它與C標準庫中的jmp_buf基本相同,甚至可以這樣想:進程切換等價於在切換地址空間之後,爲當前進程的上下文執行setjmp()記錄下來,同時通過longjmp()跳轉到目標進程的上下文。

進程調度

fleurix採用傳統UNIX的優先級調度算法。

src/kern/proc.h中可以見到幾個默認的優先級:PSWPPINODPRIBIOPPIPEPRITTYPWAITPSLEPPUSER。其中除了優先級最小的PUSER專用於CPU調度的基數之外,皆表示某事件的特定優先級。

在進程結構中,調度相關的字段只有三個(取值範圍皆爲-126到127):

  • p_cpu:已執行的時間片計數;
  • p_nice:用戶通過nice()系統調用設置的微調;
  • p_pri:進程的優先值,優先值越小優先級越高。

內核會隨着節拍(tick)增加當前進程的p_cpu,同時每隔一定時間便依據p_cpup_nice重新計算p_pri(可見於src/kern/timer.c中的sched_cpu())。公式大致爲:

p->p_pri = p->p_cpu/16 + PUSER + p->p_nice;

也會調整所有進程的p_cpu,使得進程不至餓死:

p->p_cpu /= 2;

隨着時間的增加,當前進程的優先級會慢慢地低於其它的任何進程。在這時調用swtch(),便可以找出當前優先級最高的進程並切換。

值得留意的是,調用swtch()的時機有兩種:

  1. 從內核態返回用戶態的那一刻,發生進程搶佔;
  2. 進程主動調用,自願放棄控制權,一般是爲了等待資源。

fleurix是非搶佔的內核,一切進程搶佔都發生在內核態返回用戶態的那一刻。這樣的考慮主要出於:

  • 來自PIT的時鐘中斷會定時觸發。
  • 一些中斷處理例程就在執行結束之後,一般都會喚醒一些等待資源的進程。這些進程的優先級都比較高(PRIBIOPINO),在這時切換進程,可以使得相應的資源在第一時間得到處理。

相關代碼可見於src/kern/trap.chwint_common()的結尾處

setpri(cu);
if ((tf->cs & 3)==RING3) {
    swtch();
}

它首先嚐試調整當前進程的優先級,再通過中斷上下文中保存的cs寄存器判斷當前的中斷上下文是否是來自用戶態。只有確定是來自用戶態,才嘗試執行任務切換,這樣可以保證內核態中不會發生搶佔。

進程同步

fleurix的內核是非搶佔的,但中斷處理例程依然有可能打斷內核代碼的執行。要保證代碼的一致性,可以通過cli()sti()來關/開中斷形成一個臨界區。需要留意的是,開/關中斷的方式只在單處理器環境中適用,若支持多處理器,則需要提供自選鎖(spin lock)的實現。

此外一個常見的情景是,在申請資源時若這一資源不可用,就讓這個進程進入睡眠(sleep())以等待資源的釋放,待資源恢復可用時,再喚醒(wakeup())所有等待該資源的進程恢復執行。sleep()wakeup()即爲fleurix的基本同步原語。

sleep()的代碼如下,取自src/kern/sched.c

/* mark a proccess SWAIT, commonly used on waiting a resource.
**/
void sleep(uint chan, int pri){
    if (pri < 0) {
        cli();
        cu->p_chan = chan;
        cu->p_pri  = pri;
        cu->p_stat = SSLEEP; // uninterruptible
        sti();
        swtch();
    }
    else {
        if (issig())
            psig();
        cli();
        cu->p_chan = chan;
        cu->p_pri = pri;
        cu->p_stat = SWAIT; // interruptible
        sti();
        if (issig()) 
            psig();
        swtch();
    }
}

sleep()的第一個參數chan爲"channel"的縮寫,表示等待的事件的標誌符;第二個參數pri表示進程在喚醒那一刻的優先級。依據優先級的分類,睡眠又分爲可中斷(interruptible)與不可中斷(uniterruptible)兩種,意指在睡眠中的進程若收到信號,是否中斷睡眠恢復執行。

wakeup()的代碼如下,取自src/kern/sched.c

void wakeup(uint chan){
    struct proc *p;
    int i;
    for(i=0; i<NPROC; i++){
        if ((p = proc[i]) == NULL) 
            continue;
        if (p->p_chan == chan) {
            setrun(p);
        }
    }
} 

它的內容就是依據參數chan,找到所有因等待此事件而進入睡眠的進程並喚醒。

設備

設備(Device)即外部設備在內核中的基本抽象,主要分爲兩種:

  • 塊設備:將數據儲存在固定大小的塊中,每個塊都有自己的地址,可供驅動程序隨機訪問,如硬盤、光驅等;
  • 字符設備:輸入輸出都是不可以隨機訪問數據流,如鍵盤、打字機等。

在fleurix中,塊設備與字符設備分別對應struct bdevswstruct cdevsw兩個結構(定義於src/inc/conf.h),如下:

struct bdevsw {
    int             (*d_open)(); 
    int             (*d_close)();
    int             (*d_request)(struct buf *bp);
    struct devtab    *d_tab;
};

extern struct bdevsw    bdevsw[NBLKDEV];   

struct cdevsw {
    int             (*d_open)   (ushort dev);
    int             (*d_close)  (ushort dev);
    int             (*d_read)   (ushort dev, char *buf, uint cnt);
    int             (*d_write)  (ushort dev, char *buf, uint cnt);
    int             (*d_sgtty)();
};

extern struct cdevsw    cdevsw[NCHRDEV];

可以看出,兩個結構的主要部分都是函數指針,它們就是設備驅動程序的統一接口了。

類UNIX系統將設備文件(Device File)作爲應用程序訪問設備的接口,使得訪問外部設備與訪問一個普通的文件並無二致。設備文件本身並沒有任何內容,真正發揮作用的只是設備類型與設備號——在讀寫設備文件時,內核會依據它們來調用對應的設備驅動程序(Device Driver),執行真正的讀寫。

在linux下可以通過mknod命令來創建一個設備文件,比如:

mknod /dev/tty0 c 1 0

Buffer Cache

比起訪問內存,訪問外部設備的速度往往要慢許多。爲此,fleurix爲塊設備實現了Buffer Cache,將最近讀過的塊緩存到內存中,從而加快對塊設備的訪問。另外,Buffer Cache也扮演着I/O請求隊列的角色,從中斷中讀取的數據將直接寫入Buffer Cache中。

Buffer Cache相關的結構主要爲struct bufstruct devtab,以及char buffers[NBUF][BUF]bfreelist,其中每個struct buf都對應着buffers[]中的一塊內存,大小與文件系統的虛擬塊相同(1024字節,可見於param.hBLK的定義)。另外,它們主要構成了三個buf對象的鏈表:

  • 空閒列表:表示當前系統中所有可用的buf,用於buf的分配。使用LRU(Last Recently Used)策略,分配一定是在鏈表的頭部取出,釋放則一般都是放回鏈表的尾部。鏈表的頭部爲bfreelistbuf之間由av_prevav_next連接;
  • 緩存列表:每個設備擁有獨立的緩存列表,盛放着設備所有的buf,用於buf緩存的查找。除非被標記爲B_BUSYbuf可以同時存在於空閒列表和緩存列表。鏈表的頭部爲struct devtab,由b_prevb_next連接;
  • 請求隊列:同爲每個設備獨立,表示該設備的I/O請求隊列。位於請求隊列中的buf會存在於設備的緩存列表,但不會存在於空閒列表。鏈表的頭部爲struct devtab,由av_prevav_next連接。

可以認爲,Buffer Cache爲塊設備的讀寫提供了統一的接口,也爲文件系統實現了所需的基礎例程。這些例程主要有:

  • getblk(dev, blknum):分配buf對象,並標記爲B_BUSY
  • brelse(buf):釋放buf對象,將其放回空閒列表;
  • bread(dev, blknum):讀取設備的塊,將buf對象插入設備的請求隊列,隨後進入睡眠等待讀取完畢;
  • bwrite(dev, buf):將buf對象中的內容寫回設備。

以上例程皆可見於src/blk/buf.c

需要留意的是,getblk()這個名字很容易給人一個錯誤的印象,實際上getblk()函數並不會讀取設備的塊(讀取設備塊的函數爲bread()),它用於分配buf結構,並將其標記爲B_BUSY:依據設備號和塊號,查找相應的buf是否存在於緩存中,若存在,就直接返回它;若不存在,則從空閒列表中取出一個可用的buf對象返回。 具體起來,有如下五種情景:

  1. 在設備的緩存列表中找到了對應的buf對象,且正好可用,則返回這一buf並標記爲B_BUSY(通過notavail()函數);
  2. 在設備的緩存列表中找到了對應的buf對象,不過這個buf正忙(B_BUSY),則將這個buf標記爲B_WANTED,令進程睡眠等待它被釋放;
  3. 沒有在設備的緩存列表中找到對應的buf對象,且bfreelist爲空,則將整個bfreelist標記爲B_WANTED,令進程睡眠等待它獲取可用的buf
  4. 沒有在設備的緩存列表中找到對應的buf對象,且bfreelist不爲空,則將頭部的buf取出bfreelist
  5. 若得到的buf對象被標記爲B_DIRTY,則表示它有內容發生變化,需要寫回到設備中。

請求隊列

設備在同一時刻一般只能處理一個請求,且時間較長。因此,合理的做法是將待處理的I/O操作排隊,在發出一個請求之後使進程進入睡眠等待中斷,待中斷髮生時,讀取輸入並再次嘗試發送隊列中的請求,如是循環。

在fleurix中,請求隊列並無專門的數據結構,而是直接利用struct devtab爲隊列的頭部,struct buf作爲隊列的成員,通過av_prevav_next連接起來。

以硬盤爲例,發送I/O請求的例程爲hd_request(),它取一個buf對象作爲參數,只負責將其插入hdtab的請求隊列。而真正依據請求隊列發送I/O請求的例程則爲hd_start(),它取出隊列的頭部,依據buf對象的標誌(B_READ或者B_WRITE)來發送讀請求或者寫請求,待設備在讀取/寫入完畢之後,就會觸發中斷處理例程do_hd_intr(),在這裏將buf對象取出請求隊列,在讀取數據之後,喚醒等待在這一buf對象上的所有進程,並再次調用hd_start(),嘗試處理排隊中的I/O請求。

以上例程皆定義於src/blk/hd.c

文件系統

文件是對I/O的抽象,而文件系統提供了文件的組織方式。fleurix實現了minix v1文件系統,它結構簡單、易於實現,而且在開發環境中也有豐富的工具可供使用,如mkfs.minixfsck.minix等。

超級塊

超級塊表示了文件系統的基本信息,也描述了文件系統的存儲結構。在內核中,有時可以將超級塊視作文件系統的同義詞。

minix v1文件系統主要分爲六個部分,如下圖:

  1. 引導塊,總是位於設備的第一個虛擬塊,爲bootloader所保留;
  2. 超級塊,位於第二個虛擬塊。它保存了一個文件系統的詳細信息,比如inode的數量、zone的最大數量等;
  3. inode位圖,每個位對應一個磁盤上的inode,表示它是否空閒,大小與超級塊中的s_nimap_blk字段相關;
  4. zone位圖,每個位對應一個zone,表示它是否空閒,大小與超級塊中的s_nzmap_blk字段相關。zone是文件系統中虛擬塊的別名,一個zone可能等於1個物理塊,也可能等於2、4、8個物理塊的大小,具體由超級塊中s_log_bz字段指定。在fleurix中,zone等於兩個物理塊的大小(1024字節)。
  5. inode區域,儲存着文件系統中所有的inode,大小與超級塊中的s_max_inode字段相關。
  6. 數據區域,也就是文件系統中所有的虛擬塊,供inode引用。

在fleurix中有兩個數據結構與超級塊相關:struct d_superstruct super,皆定義於src/inc/super.h,分別對應磁盤上與內存中超級塊的表示。後者多了幾個字段來表示掛載信息。

struct buf結構類似,內存中也有一個固定的struct super mnt[NMNT]數組(定義於src/fs/mount.c),用作super對象的分配與緩存。然而更重要的用途,則爲內核中所有文件系統的掛載表。

在類UNIX系統中若要訪問一個文件系統,必先將它掛載(Mount)。在fleurix中,對應的例程爲do_mount(uint dev, struct inode *ip)。它取兩個參數,第一個參數爲文件系統的設備號,第二個參數指向掛載目標的inode。它會首先遍歷mnt[]數組,查找設備號對應的super對象是否已存在,若存在,則直接跳轉至_found;若不存在,則選出一個空閒的super對象,讀取磁盤上的超級塊並跳轉至_found。隨後,它會依據設備號判斷是否爲根文件系統,並增加掛載目標的引用計數。

與之相對,卸載一個文件系統的例程爲do_umount(ushort dev)。它會將設備號對應的super對象寫回到磁盤,隨後釋放它,並減少目標inode的引用計數。

do_mount()do_umount()之外,有關超級塊的例程還有:

  • getsp(),依據設備號,獲取一個已掛載的super對象並上鎖;
  • unlk_sp(),釋放一個super對象的鎖;
  • spload(),讀取磁盤中的超級塊到super對象;
  • spupdate(),將super對象的改動寫回磁盤。

以上例程皆定義於src/fs/super.c

塊分配

minix文件系統採用位圖來表示文件系統中空閒的塊,一個位對應着數據區域的一個邏輯塊,1表示已佔用,0表示可用。每個塊對應着一個塊號,最小爲0,最大爲數據區中塊的數,在文件系統格式化時確定。表示塊號的類型爲unsigned short(16位)。

在fleurix中,通過balloc()(定義於src/fs/alloc.c)來分配塊。它取一個設備號做參數,通過查詢位圖找到並返回文件系統中第一個可用的塊號。若沒有可用的塊,則報告一個錯誤。

與之相對,釋放塊的例程爲bfree()

inode

在類UNIX操作系統中,普通文件、目錄或者文件系統中的其它對象皆由一個統一的數據結構inode來表示。它記錄了文件的類型、用戶信息、訪問權限、修改日期等信息,也記錄了文件中邏輯塊的佈局,但並不包含本文件的名字信息。

每個inode擁有一個唯一的編號,作爲內核訪問inode的憑據。編號從1開始,最大爲文件系統中inode的數量,在文件系統格式化時確定。表示inode編號的類型爲unsigned short(16位)。

同超級塊類似,fleurix中有兩個數據結構與inode相關:struct d_inodestruct inode,皆定義於src/inc/inode.h,分別對應磁盤上與內存中inode的表示。後者增加了引用計數、設備號、inode編號與標誌等信息。

在fleurix中,要訪問一個inode對象,可以通過iget()例程(定義於src/fs/inode.c),它取一個設備號與inode編號做參數,返回一個上鎖的inode對象。大體行爲如下:

  1. 依據設備號與inode編號,遍歷inode[]數組判斷inode對象是否位於緩存;
  2. 若位於緩存且無鎖,則增加引用計數(使i_count增1)並上鎖(通過lock_ino(),定義於src/fs/inode.c)後返回;
  3. 若位於緩存但上鎖,則進入睡眠等待inode對象釋放,重複步驟1;
  4. 若沒有位於緩存,則分配一個空閒的inode對象,讀取磁盤中的inode對象,重複步驟1;
  5. 若沒有位於緩存,且沒有空閒的inode對象,則報告一個錯誤。

與之相對,釋放inode對象的例程爲iput(),它會將i_count減一,當i_count爲0時釋放inode對象。

除了i_count,inode結構還有一個字段i_nlink,用於 表示磁盤上的引用數,也就是硬連接的數量。當新建一個文件時,i_nlink的值爲1,隨後每增加一個硬連接時增1,刪除時減1,當i_nlink爲0時才真正刪除磁盤上的inode。

此外值得注意的是,iput()unlk_ino()雖同爲"釋放一個inode對象",但含義有所不同。準確來講,unlk_ino()的行爲是釋放一個inode對象的鎖,iput()則是根據引用計數來釋放inode對象本身。鎖的目的是限制對象的控制權,保護對象的數據不被破壞,內核必須在系統調用的結束之前及時地釋放鎖,不然將導致死鎖;而引用計數的目的是跟蹤對象的所有權的變化,來管理對象的生存週期。

bmap()

文件是組織虛擬I/O的一種方式,每個文件都可以視作是獨立的一段地址空間。fleurix通過bmap()(定義於src/fs/bmap.c)將文件中的偏移地址翻譯爲設備的物理塊號,而inode在這裏就扮演了翻譯表的角色。

如上圖,minix v1文件系統採用了傳統UNIX文件系統的分組多級中間表,默認只提供7個邏輯塊的映射,若文件增長超過7個塊的大小,則分配一個塊作爲中間表,額外提供512個塊(即NINDBLK,定義於src/inc/param.h,等於BLK / sizeof(unsigned short))的映射。如果文件更長,就採用二級中間表,這樣最大可以支持262663個塊(7+512+512*512)的映射,也就是說,單個文件最大限制約爲256mb(MAX_FILESIZ,定義於src/inc/param.h)。

bmap(struct inode *ip, ushort nr, uchar creat)取三個參數,第一個參數ip指向一個上鎖的inode對象,第二個參數nr表示文件中虛擬塊的偏移,第三個參數creat表示查找過程中是否申請新的塊。當查找失敗時若creat爲0,會返回0表示映射不存在;若creat不爲0,則申請一個塊並繼續查找。值得一提的是,文件系統中的一切塊分配皆發生於設置creat標誌時的bmap()

bmap()主要用於read()write()lseek()等系統調用的實現,也在內核中讀取文件時有所使用。

namei()

前面曾提到,inode結構並沒有保存本文件的名字信息。所有文件的文件名,以及文件目錄之間的層級關係,都保存在目錄類型(S_IFDIR,定義於src/inc/stat.h)的inode中。每個文件系統的第1個inode都是目錄類型。

目錄的數據佈局與普通文件一致,不同在於數據的內容。目錄文件的格式可以視作是struct dirent結構的一個數組,表示了一個目錄中文件名到inode編號的映射關係。struect dirent的聲明爲:

#define NAMELEN 12

struct dirent {
    ushort  d_ino;
    char    d_name[NAMELEN];
    char    __p[18]; /* a padding. each dirent is aligned with a 32 bytes boundary. */
};

每條dirent(目錄項)佔據32字節,其中inode編號爲2字節,文件名爲12字節,保留16字節。可知在minix v1文件系統中,文件名的大小限制爲12個字符。

namei(char *path, uchar creat)所做的工作就是,根據路徑依次查找目錄文件,並在必要時新建inode,最後返回一個上鎖的inode對象或在出錯時返回NULL。此外,內核有時會對父目錄的內容更感興趣(比如通過unlink()來刪除一個文件),對此fleurix也提供了一個namei_parent(char *path, char **name)例程,它可以返回父目錄的inode對象,同時找到指向目標文件名的指針。

在查找過程中需要小心地處理inode對象的鎖和一些異常情況,namei()namei_parent()中有關遍歷目錄的代碼會很複雜,所以將它們分開實現是沒有意義的。爲此,fleurix實現了_namei(char *path, uchar creat, uchar parent, char **name)作爲namei()namei_parent()所共有的基礎例程。它的4個參數的意義分別爲:

  • path:目標文件的路徑,若path爲絕對路徑(以'/'開頭),_namei()將從根目錄開始查找,否則從當前的活動(cu->p_wdir)目錄開始查找;
  • creat:若最後沒有找到對應的文件,則新建一個文件;
  • parent:若不爲0,則返回父目錄的inode對象,同時將目標文件名的地址存入第四個參數name
  • name:當parent不爲0時,保存目標文件名的地址。

_namei()主要用於link()unlink()open()exec()等系統調用的實現,凡是需要訪問文件路徑的地方,就都會調用到它。

遇到的問題

總結

參考文獻

  • 《Linux內核完全註釋》,趙炯 著
  • 《Linux內核完全剖析》,趙炯 著
  • 《萊昂氏UNIX源代碼分析》,John Lions 著
  • 《UNIX操作系統設計》, Maurice J.Bach 著
  • 《操作系統設計與實現》,Andrew S. Tanenbaum、 Albert S. Woodhull 著
  • 《現代操作系統》,Andrew S. Tanenbaum 著
  • 《計算機的心智:操作系統之哲學原理》,鄒恆明 著
  • 《結構化計算機組成》,Andrew S.Tanenbaum 著
  • 《鏈接器和加載器》,John R.Levine 著
  • 《4.4 BSD操作系統的設計與實現》,Marshall Kirk McKusick、Keith Bostic、Michael J.Karels、John S.Quarterman 著
  • 《UNIX Internals》,Uresh Vahalia 著
  • 《Bran's Kernel Development Toturial》,Brandon Friesen 著
  • 《Design and Implementation of the Berkeley Virtual Memory Extensions to the UNIX† Operating System‡》,Ozalp Babao lug、William Joy、Juan Porcar 著
  • 《Virtual Memory Architecture in SunOS》,Robert A. Gingell、Joseph P. Moran、 William A. Shannon 著
  • 《Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 2A: Instruction Set Reference A-M》,英特爾公司 著
  • 《Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 2B: Instruction Set Reference N-Z》,英特爾公司 著
  • 《Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3A: System Programming Guide, Part 1》,英特爾公司 著
  • 《Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 3B: System Programming Guide, Part 2》,英特爾公司 著
  • 《80x86處理器和80x87協處理器大全》,Hummel,R.L. 編著
  • 《UNIX環境高級編程》,W.Richard Stevens 著
  • 《An Introduction to GCC》,Brian J. Gough 著
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章