mit6.828-lab4 搶佔式多任務調度

lab4 是實現多處理器支持以及搶佔式任務調度,exercize代碼見 這裏

1 多處理器啓動流程

1.1 多處理器支持

爲了支持多處理器,首先需要知道多處理器的配置,這個配置通常是存儲在BIOS裏面。BIOS需要傳遞配置信息給多個處理器,同時需要能復原多處理器及其相關組件,多處理器的BIOS也要擴展功能,增加MP配置。

SMP是指所有處理器都是平等的,包括內存對稱和IO對稱。內存對稱指所有處理器都共享同樣的內存地址空間,訪問相同的內存地址。而IO對稱則是所有處理器共享相同的IO子系統(包括IO端口和中斷控制器),任一處理器可以接收任何源的中斷。雖然處理器都平等的,但是可以分爲BSP(啓動處理器)和AP(應用處理器),BSP負責初始化其他處理器,至於哪個處理器是BSP則是由BIOS配置決定的。

APIC(Advanced Programmable Interrupt Controller)基於分佈式結構,分爲兩個單元,一個是處理器內部的Local APIC單元(LAPIC),另一個是IO APIC單元,它們兩個通過Interrupt Controller Communications (ICC) 總線連接。APIC作用一是減輕了內存總線中關於中斷相關流量,二是可以在多處理器裏面分擔中斷處理的負載。

LAPIC提供了 interprocessor interrupts (IPIs),它允許任意處理器中斷其他處理器或者設置其他處理器,有好幾種類型的IPIs,如INIT IPIs和STARTUP IPIs。每個LAPIC都有一個本地ID寄存器,每個IO APIC都有一個 IO ID寄存器,這個ID是每個APIC單元的物理名稱,它可以用於指定IO中斷和interprocess中斷的目的地址。因爲APIC的分佈式結構,LAPIC和IO APIC可以是獨立的芯片,也可以將LAPIC和CPU集成在一個芯片,如英特爾奔騰處理器(735\90, 815\100),而IO APIC集成在IO芯片,如英特爾82430 PCI-EISA網橋芯片。集成式APIC和分離式APIC編程接口大體是一樣的,不同之處是集成式APIC多了一個STARTUP的IPI。

在SMP系統中,每個CPU都伴隨有一個LAPIC單元,LAPIC用於傳遞和響應系統中斷,LAPIC也爲與它連接的CPU提供了一個唯一ID,在lab4中,我們只用到LAPIC的一些基本功能:

  • 讀取LAPIC標識來告訴我們當前代碼運行在哪個CPU上(見cpunum())。
  • 從BSP發送 STARTUP IPI到AP,用於啓動AP,見(lapic_startup())。
  • 在part C,我們變成LAPIC內置的計時器來觸發時鐘中斷支持搶佔式多任務(見apic_init())。

處理器訪問它的LAPIC使用的是 MMIO,在MMIO裏,一部分內存硬連線到了IO設備的寄存器,因此用於訪問內存的load/store指令可以用於訪問IO設備的寄存器。比如我們在實驗1中用到 0xA0000開始的一段內存作爲VGA顯示緩存。LAPIC所在物理地址開始於0xFE000000(從Intel的文檔和測試看這個地址應該是0xFEE00000),在JOS裏面內核的虛擬地址映射從KERNBASE(0xf00000000)來說,這個地址太高了,於是在JOS裏面在MMIOBASE(0xef800000)地址處留了4MB空間用於MMIO,後面實驗會用到更多的MMIO區域,爲此我們要映射好設備內存到MMIOBASE這塊區域,這個過程有點像boot_alloc,注意映射範圍判斷。接下來完成作業1,見代碼。

  *    MMIOLIM ------>  +------------------------------+ 0xefc00000      --+
  *                     |       Memory-mapped I/O      | RW/--  PTSIZE
  * ULIM, MMIOBASE -->  +------------------------------+ 0xef800000

1.2 AP啓動流程

在啓動AP前,BSP首先要收集多處理器系統的信息,比如CPU數目,CPU的APIC ID和LAPIC單元的MMIO地址。kern/mpconfig.c的mp_init()函數通過讀取駐留在BIOS內存區域中的MP配置表來獲取這些信息。

boot_aps()函數驅動AP啓動進程。AP以實模式啓動,很像boot/boot.S中那樣,boot_aps()將AP entry代碼拷貝到實模式可尋址的一個地址,與bootloader不同的是,我們可以控制AP開始執行代碼的位置,我們將AP entry代碼拷貝到 0x7000(MPENTRY_PADDR),當然其實你拷貝到640KB之下的任何可用的按頁對齊的物理地址都是可以的。

之後,boot_aps()通過向AP的LAPIC發送STARTUP IPIs依次激活AP,並帶上AP要執行的初始入口地址CS:IP(MPENTRY_PADDR)。入口代碼在 kern/mpentry.S,跟boot/boot.S非常相似。在簡單的設置後,它將AP設置爲保護模式,並開啓分頁,然後調用 mp_main()裏面的C設置代碼。boot_aps()會等待AP在其CpuInfo中的cpu_status字段發出CPU_STARTED 標誌,然後繼續喚醒下一個AP。爲此需要將 MPENTRY_PADDR 這一頁內存空出來。

接下來我們要分析下加入多處理器支持後JOS的啓動流程,新加的幾個相關函數是 mp_init(), lapic_init()以及boot_aps()

多處理器配置搜索和初始化

mp_init()主要是搜索多處理器配置信息,要怎麼找呢,首先是按下面的順序找MP Floating Pointer Structure(簡寫爲MPFPS)。

  • 1 去Extended BIOS Data Area (EBDA)的前1KB處
  • 2 去系統base memory的最後1KB找
  • 3 去BIOS的只讀內存空間: 0xE0000 和 0xFFFFF 之間找(代碼裏面用的是 0xF0000 到0xFFFFF位置)。
+------------------+  <- 0x00100000 (1MB)
|     BIOS ROM     |
+------------------+  <- 0x000F0000 (960KB)
|  16-bit devices, |
|  expansion ROMs  |
+------------------+  <- 0x000C0000 (768KB)
|   VGA Display    |
+------------------+  <- 0x000A0000 (640KB)
|                  |
|    Low Memory    |
|                  |
+------------------+  <- 0x00000000

而EBDA的起始位置可以從 BIOS 的 40:0Eh 處找到,也就是 0x40 << 4 + 0x0Eh = 0x40Eh 處找EBDA的起始位置。在測試中,我的測試機裏面顯示該值爲 0x9fc0,故而會先在EBDA的 0x9fc00(左移4位得到物理地址) 到 0xA0000之間找。在BIOS 的 40:13h 處可以找到base memory大小值減1KB的值,這個值是 以KB爲單位的,比如我的測試環境顯示該值爲 0x9fc00,則base memory爲0x9fc00 + 1K = 0xA0000 也就是640KB。由此我們這裏EBDA的前1KB和base memory的最後1KB其實是同一個內存區域。如果前面兩個位置找不到,則會去0xE0000h到0xFFFFFh區域找。在測試環境中在位置3找到了MPFPS,這裏的校驗方式是 mp->signature == "MP" 且mp結構體的所有字段之和爲0

找到了MPFPS後,我們要根據它的配置去找 MP Configuration Table(MPCT),發現 MPFPS中的 physical address值爲 0xf64d0,即表示 MPCT地址在0xf64d0開始的一段BIOS ROM裏面。可以調試發現我們測試機裏面的MPCT的版本爲1.4,LAPIC的基地址爲 0xfee00000,配置項有20個,而這裏的配置項又分爲 Processor, Bus,IO APIC,IO Interrupt Assignment以及Local Interrupt Assignment這五種類型。對於處理器類型,這裏有幾個比較重要的字段,其中有cpu的幾個標識,其中一個BP如果設置爲1,表示這個處理器是啓動處理器BSP。另一個是EN,爲1表示啓用,爲0表示禁用。還有一個LAPIC ID字段,用於標識該處理器裏面的LAPIC的ID,ID是從0開始編號的。JOS裏面最多支持8個CPU,多餘的CPU不會啓用。

在我們測試的時候make qemu CPUS=n,其中的n就是指定的模擬的CPU的數目,指定了幾個我們就能找到幾個CPU的MPCT配置項。爲維護CPU狀態,JOS內核中維護了一個cpus的數組和CpuInfo結構體。

// Per-CPU state
struct CpuInfo {
    uint8_t cpu_id;                 // Local APIC ID; index into cpus[] below
    volatile unsigned cpu_status;   // The status of the CPU
    struct Env *cpu_env;            // The currently-running environment.
    struct Taskstate cpu_ts;        // Used by x86 to find stack for interrupt
};

// Initialized in mpconfig.c
extern struct CpuInfo cpus[NCPU];

找到配置後,接着會設置BSP爲falg爲BP的處理器,並將其狀態設置爲 CPU_STARTED。接下來開始初始化LAPIC。

lapic_init()

因爲LAPIC的起始地址默認是在物理地址0XFEE00000,爲了方便訪問,JOS將這地址通過MMIO映射到了虛擬地址MMIOBASE。映射完成後,我們就可以用lapic這個虛擬地址來訪問和設置LAPIC了。lapic_init()主要對LAPIC的一些寄存器進行設置,包括設置ID,version,以及禁止所有CPU的NMI(LINT1),BSP的LAPIC要以Virtual Wire Mode運行,開啓BSP的LINT0,以用於接收8259A芯片的中斷等。

pic_init()

pic_init()用於初始化8259A芯片的中斷控制器。8259A芯片是一箇中斷管理芯片,中斷來源除了來自硬件本身的NMI中斷以及軟件的INT n指令造成的軟件中斷外,還有來自外部硬件設備的中斷(INTR),這些外部中斷時可以屏蔽的。而這些中斷的管理都是通過PIC(可編程中斷控制器)來控制並決定是否傳遞給CPU,JOS中開啓的INTR中斷號有1和2。

boot_aps()

接下來是啓動AP了。首先通過memmove將mpentry的代碼拷貝到 MPENTRY_PADDR (0x7000)處(其中習題2要將0x7000對應的一頁設置爲已用,不要加入到空閒鏈表),設置好對應該cpu的堆棧棧頂指針,然後調用kern/lapic.c中的lapic_startap()開始啓動AP。

lapic_startap()完成lapic的設置,包括設置warm reset vector指向mpentry代碼起始位置,發送STARTUP IPI以觸發AP開始運行mpentry代碼,並等待AP啓動完成,一個AP啓動完成後再啓動下一個。

那麼mpentry代碼就是在kern/mpentry.S中了,它的作用類似bootloader,最後是跳轉到mp_main()函數執行初始化。mpentry.S 在加載GDT和跳轉時用到了MPBOOTPHYS宏定義,因爲mpentry.S代碼是加載在 KERNBASE之上的,在CPU實模式下是無法尋址到這些高地址的,所以需要轉換爲物理地址。而boot.S代碼不用轉換,是因爲它們本身就加載在實模式可以尋址的0x7c00-0x7dff。後面的流程跟boot.S類似,也是開啓保護模式和分頁。因爲mpentry的代碼加載到了 0x7000,需要在 page_init() 中將這一頁從page空閒鏈表去除,見作業2.

#define MPBOOTPHYS(s) ((s) - mpentry_start + MPENTRY_PADDR)

此時用的頁目錄跟 kern/entry.S 時一樣,用的也是 entry_pgdir,因爲此時的運行指令在低地址,並沒有在 kern_pgdir 建立映射。 最後通過call指令跳轉到mp_main()函數執行,注意下這裏用了間接call,爲什麼不是直接call $mp_main呢? 這裏之所以不直接call,是因爲直接call用的是相對地址,即將 EIP 設置爲 EIP + call後跟的一個相對地址,比如這裏我們的call指令的地址爲0x7050,然後EIP會指向下一條地址0x7055,call地址會被設置爲 0x7050 + 5 + 0xffffa609 = 0x10000165e,地址溢出後變成0x165e,而這個地址內容是0x80050044,可知0x165e處對應的指令是 0x44,也就是 inc %esp,當然這一步不會報錯,接着下一條指令在 0x165f,指令對應的是 00 05 80 44 00 05,即add %al, 0x05004480,則此時訪問地址0x05004480會報錯,因爲此時用的是entry_pgdir,還沒有建立該地址的頁面映射。

## 正確方式
mov $mp_main, %eax; 
call *%eax;

## 錯誤方式
call mp_main
f0105bc8:       e8 09 a6 ff ff          call   f01001d6 <mp_main>

(gdb) x /16x 0x165e
0x165e: 0x80050044  0x90050044  0xa0050044  0xb0050044

mp_main()函數先是加載了kern_pgdir到CR3中,然後調用下面幾個方法,包括前面提過的lapic_init(),以及爲每個CPU初始化進程相關內容和中斷相關內容,最後設置cpu狀態爲 CPU_STARTED 讓 BSP 去啓動下一個CPU。注意到這裏用到了xchg函數來設置cpu狀態,該函數用到xchgl來交換addr存儲的值和newval,並將addr原來存儲的值存到result變量中返回,指令中的lock;用於保證多處理器操作的原子性。

void
mp_main(void)
{
    // We are in high EIP now, safe to switch to kern_pgdir 
    lcr3(PADDR(kern_pgdir));
    lapic_init();
    env_init_percpu();
    trap_init_percpu();
    xchg(&thiscpu->cpu_status, CPU_STARTED); // tell boot_aps() we're up
    for (;;);
}

static inline uint32_t
xchg(volatile uint32_t *addr, uint32_t newval)
{
    uint32_t result;

    // The + in "+m" denotes a read-modify-write operand.
    asm volatile("lock; xchgl %0, %1"
             : "+m" (*addr), "=a" (result)
             : "1" (newval)
             : "cc");
    return result;
}

1.3 CPU初始化

多核CPU需要各自優化,每個CPU都有自己的一些初始化變量,如下:

內核棧

每個cpu都要有一個內核棧,以免互相干擾。percpu_kstacks[NCPU][KSTKSIZE]用於保存棧空間。

TSS和TSS描述符

每個CPU都要有自己的TSS(任務狀態段)和TSS描述符。CPU i的TSS存儲在cpus[i].cpu_ts,而TSS描述符在GDT中的索引是gdt[(GD_TSS0 >> 3) + i],之前實驗用到的全局變量 ts 不再需要了。

當前進程指針

每個CPU都要有自己運行的當前CPU運行的當前進程(Env)的指針curenv,存儲在 cpus[cpunum()].cpu_envthiscpu->cpu_env

系統寄存器

所有寄存器,包括系統寄存器都是每個CPU獨有的。因此lcr3,ltr,lgdt,lidt 這些指令在每個CPU上都要執行一次,其中env_init_per_cpu()trap_init_per_cpu()就是用於這個目的。

具體實現見作業3-4。

1.4 內核鎖

在mp_main中初始化AP後,我們開始spin循環。在AP進一步操作前,我們需要解決多個CPU同時運行內核代碼時的資源競爭問題,因爲多進程同時運行內核代碼,會影響內核中的數據正確性。最簡單的方式是使用big kernel lock(大內核鎖),進程在進入內核時獲取大內核鎖,回到用戶態時釋放鎖。在該模式下,進程可以併發的運行在空閒的CPU上,但是同時只能有一個進程運行在內核態,其他進程想進入內核態必須等待。

大內核鎖在kern/spinlock.h中定義,可以通過locker_kernel()unlock_kernel()來進行加鎖和解鎖。我們需要在下面幾處位置加鎖和釋放鎖。

  • i386_init()中,在BSP喚醒AP前加鎖。
  • mp_main()中,初始化AP後加鎖,並調用sched_yield()在該AP上運行進程。
  • trap()中,進程從用戶態陷入時加鎖。
  • env_run()中,進程切換到用戶態之前釋放鎖。

這樣,我們在BSP啓動AP前,先加了鎖。AP經過mp_main()初始化後,因爲此時BSP持有鎖,所以AP的sched_yield()需要等待,而當BSP執行調度運行進程後,會釋放鎖,此時等待鎖的AP便會獲取到鎖並執行其他進程。

1.5 輪轉調度

輪轉調度(round-robin)在sched_yield()中完成,核心思想就是從進程列表中找到一個狀態爲 ENV_RUNNABLE 的進程運行。注意,不能同時有兩個CPU運行同一個進程,這個可以根據進程狀態進行判斷,已經運行的進程狀態爲 ENV_RUNNING 。如果在列表中找不到ENV_RUNNABLE的進程,而之前運行的進程又處於ENV_RUNNING狀態,則可以繼續運行之前的進程。

修改了kern/init.c運行3個user_yield進程,可以看到輸出如下:

# make qemu CPUS=2
Hello, I am environment 00001000.
Hello, I am environment 00001001.
Back in environment 00001000, iteration 0.
Hello, I am environment 00001002.
Back in environment 00001001, iteration 0.
Back in environment 00001000, iteration 1.
Back in environment 00001002, iteration 0.
Back in environment 00001001, iteration 1.
Back in environment 00001000, iteration 2.
Back in environment 00001002, iteration 1.
Back in environment 00001001, iteration 2.
Back in environment 00001000, iteration 3.
Back in environment 00001002, iteration 2.
Back in environment 00001001, iteration 3.
Back in environment 00001000, iteration 4.
Back in environment 00001002, iteration 3.
All done in environment 00001000.
[00001000] exiting gracefully
[00001000] free env 00001000
Back in environment 00001001, iteration 4.
Back in environment 00001002, iteration 4.
All done in environment 00001001.
All done in environment 00001002.
[00001001] exiting gracefully
[00001001] free env 00001001
[00001002] exiting gracefully
[00001002] free env 00001002

流程如下:

  • BSP先加載3個進程,並設置爲ENV_RUNNBALE狀態。

  • BSP先喚醒AP,由於BSP先在i386_init時持有內核鎖,所以BSP先運行進程1 0x1000,運行進程時 env_run() 切換到用戶態前會釋放內核鎖,此時等待鎖的AP開始運行 sched_yield,這樣 AP 會開始運行進程2 0x1001,開始運行後釋放內核鎖。

  • BSP打印出進程號後調用了sys_yield(),陷入到內核的trap()裏面會加內核鎖,所以等到AP開始運行進程2且打印了進程號後,BSP此時運行進程3。此後兩個CPU輪流調度可運行的三個進程。

1.6 創建進程的系統調用

Unix提供了fork()系統調用創建進程,Unix拷貝了調用進程(父進程)的整個地址空間用於創建新進程(子進程),在用戶空間看來他們的唯一區別就是進程ID不同。在父進程中,fork()返回子進程ID,而在子進程中,fork()返回0。默認情況下父子進程都有自己的私有地址空間,且它們對內存修改互不影響。

在JOS中我們要提供幾個不同的系統調用用於創建進程,這也是Unix早期實現fork()的方式,下一節會討論使用 COW 技術實現的新的fork()。

sys_exofork

這個系統調用創建了一個幾乎空白的新的進程,它沒有任何東西映射到其地址空間的用戶部分,且它不可運行。這個新的進程與父進程有意義的寄存器狀態,在父進程中,它返回子進程的envid,而在子進程中,它返回0。由於sys_exofork初始化將子進程標記爲ENV_NOT_RUNNABLE,因此sys_exofork不會返回到子進程,只有父進程用sys_env_set_status將其狀態設置 ENV_RUNNABLE 後,子進程才能運行。

sys_env_set_status

設置指定的進程狀態爲 ENV_NOT_RUNNABLE 或者 ENV_RUNNABLE,用於標記進程可以開始運行。

sys_page_alloc

用於分配一頁物理內存並將其映射到指定的虛擬地址。不同於page_alloc,sys_page_alloc不僅分配了物理頁,而且要通過page_insert()將分配的物理頁映射到虛擬地址va。

sys_page_map

從一個進程的地址空間拷貝一個頁面映射(注意,不是拷貝頁的內容)到另一個進程的地址空間。其實就是用於將父進程的某個臨時地址空間如UTEMP映射到子進程的新分配的物理頁,方便父進程訪問子進程新分配的內存以拷貝數據。

sys_page_unmap

取消指定進程的指定虛擬地址處的頁面映射以下次重複使用。

所有上面的系統調用都接收進程ID參數,如果傳0表示指當前進程。通過進程ID得到進程env對象可以通過函數 kern/env.c 中的 envidenv() 實現。

在 user/dumbfork.c中有一個類似unix的fork()的實現,它使用了上面這幾個系統調用運行了子進程,子進程拷貝了父進程的地址空間。父子進程交替切換,最後父進程在循環10次後退出,而子進程則是循環20次後退出。

void
duppage(envid_t dstenv, void *addr)
{
    int r;

    // This is NOT what you should do in your fork.
    if ((r = sys_page_alloc(dstenv, addr, PTE_P|PTE_U|PTE_W)) < 0)
        panic("sys_page_alloc: %e", r); 
    if ((r = sys_page_map(dstenv, addr, 0, UTEMP, PTE_P|PTE_U|PTE_W)) < 0)
        panic("sys_page_map: %e", r); 
    memmove(UTEMP, addr, PGSIZE);
    if ((r = sys_page_unmap(0, UTEMP)) < 0)
        panic("sys_page_unmap: %e", r); 
}

user/dumbfork.c 中的dumbfork()具體實現流程是這樣的:

  • 1)先通過 sys_exofork() 系統調用創建一個新的空白進程。
  • 2)然後通過duppage拷貝父進程的地址空間到子進程中。用戶進程地址空間開始位置是UTEXT(0x00800000) ,結束位置是 end。duppage是一頁頁拷貝的,它將父進程的addr開始的一頁物理內存內容拷貝到子進程dstenv的對應的頁中。
    1. 完成父進程到子進程內存數據的拷貝。
    • 3.1)先通過sys_page_alloc爲子進程addr開始的一頁內容分配一個物理頁並完成映射,此時,分配的物理頁還是空的,沒有數據。然後通過 sys_page_map 將子進程va開始的這分配好的物理頁映射到父進程的UTEMP地址處(0x00400000),這麼做的目的就是爲了在父進程中訪問到子進程新分配的物理頁。
    • 3.2)接下來,通過memmove函數將父進程addr處的一頁數據拷貝到了UTEMP中,而因爲前面看到UTEMP已經映射到了子進程的那頁內存,所以最終效果就是將父進程的addr處的一頁內存數據拷貝到子進程的addr對應的那頁內存完成數據的複製。
    • 3.3)最後通過 sys_page_unmap 取消父進程在UTEMP的映射以下次使用,當然還有個重要目的是預防父進程誤操作到子進程的內存數據。

2 寫時複製(Copy On Write)

前面實現fork是直接將父進程的數據拷貝到了子進程,這是Unix系統最初採用的方式,但是這樣有個問題就是會造成資源浪費,很多時候我們fork一個子進程,接着是直接exec替換子進程的內存直接執行另一個程序,子進程在exec之前用到父進程的內存數據很少。

於是後續的Unix版本優化了fork,利用了虛擬內存硬件支持的方式,fork時拷貝的是地址空間而不是物理內存數據,這樣,父子進程各自的地址空間都映射到同樣的內存數據,共享的內存頁會被標記爲只讀。當父子進程有一方要修改共享內存時,此時會報page fault錯誤,此時Unix內核會爲報錯的進程分配一個新的物理頁,並拷共享內存頁的數據到新分配的物理頁中。執行exec時,只需要拷貝堆棧這一個頁面即可。

2.1 用戶程序頁面錯誤處理

爲了實現寫時複製,首先要實現用戶程序頁面錯誤處理功能。基本流程是:

  • 1)用戶進程通過 set_pgfault_handler(handler) 設置頁面錯誤處理函數。
  • 2)函數set_pgfault_handler中爲用戶程序分配異常棧,通過系統調用sys_env_set_pgfault_upcall 設置通用的頁面錯誤處理調用入口。
  • 3)當用戶進程發生頁面錯誤時,陷入內核。內核先判斷該進程是否設置了 env_pgfault_upcall,如果沒有設置,則報錯。如果設置了,則切換用戶進程棧到異常棧,設置異常棧內容,然後設置EIP爲 env_pgfault_upcall 地址,切回用戶態執行 env_pgfault_upcall 函數(即_pgfault_upcall)
  • 4)env_pgfault_upcall作爲頁面錯誤處理函數的入口函數,它在用戶態運行。先調用步驟1中註冊的頁面錯誤處理函數,然後再恢復進程在頁面錯誤之前的棧內容,並切回常規棧,跳轉到頁面錯誤之前的地方繼續運行。

設置用戶級頁面錯誤處理函數

前面提到,新的fork並不直接拷貝內存數據,而是先對共享的內存頁設置一個特殊標記,然後在父子進程的一方寫共享內存發生頁面錯誤時,內核捕獲異常並分配新的頁和拷貝數據。這裏首先要實現的是對用戶級的頁面錯誤的捕獲和處理。

COW只是用戶級頁面錯誤處理的許多可能用途之一。大多數Unix內核最初只映射新進程的堆棧,隨着堆棧消耗增加,訪問尚未映射的堆棧地址會導致頁面錯誤,內核捕獲錯誤後會分配並映射附加的堆棧頁面。典型的Unix內核必須跟蹤進程空間的每個區域發生頁面錯誤時要採取的操作。例如,堆棧區域中的錯誤通常會分配並映射新的物理內存頁面,程序的BSS區域中的錯誤通常會分配一個新頁面,填充零並映射它。而可執行代碼中導致的頁面錯誤將觸發內核從磁盤讀取可執行文件的相應頁面,然後映射它。

爲了處理用戶進程頁面錯誤,用戶進程需要設置一個頁面錯誤處理函數,新增加一個系統調用sys_env_set_pgfault_call來設置Env結構體的 env_pgfault_upcall 字段即可。

用戶進程異常棧和常規棧

而爲了處理用戶級頁面錯誤,JOS採用了一個用戶異常棧UXSTACKTOP(0xeec00000),注意用戶進程的常規棧用的是USTACKTOP(0xeebfe000)。當用戶進程發生頁面錯誤時,內核會切換到異常棧,異常棧大小也是PGSIZE。從用戶常規棧切換到異常棧的過程有點像發生中斷/異常時從用戶態進入內核時的堆棧切換。

當運行在異常棧時,用戶級頁面錯誤處理函數可以調用JOS的常規系統調用去映射新的頁面,以期修復導致頁面錯誤的問題。當用戶級頁面錯誤處理函數處理完成後,再通過一段彙編代碼返回到常規堆棧存儲的發生頁面錯誤的地址處繼續運行。

需要支持用戶級頁面錯誤處理的用戶進程都需要爲它的異常棧分配內存,可以使用前面用過的sys_page_alloc分配內存。

調用用戶頁面錯誤處理函數

修改kern/trap.c中的頁面錯誤處理代碼以支持用戶進程的頁面錯誤處理。如果用戶進程沒有註冊頁面錯誤處理函數,則跟之前一樣返回錯誤即可。而如果設置了頁面錯誤處理函數,則需要在異常棧中壓入下面內容以記錄出錯狀態,這些內容正好構成了一個UTrapframe結構體,方便統一處理,接着設置EIP爲env_pgfault_upcall函數地址,並將進程的堆棧切換到異常棧,然後開始運行頁面錯誤處理函數。

頁面錯誤處理函數是在lib/pfentry.S中定義的,它首先要執行用戶程序中定義的pgfault_handler函數,然後再回到程序出錯位置繼續運行。

需要注意的是,如果用戶進程已經運行在異常棧了,此時又發生嵌套頁面錯誤,則需要在tf->tf_esp而不是從UXSTACKTOP壓入異常數據,而且這種情況下,你要保留一個空的4字節,再壓入UTrapframe。要檢查用戶進程是否運行在異常棧,可以檢查 tf->tf_esp 是否在區間 [UXSTACKTOP-PGSIZE, UXSTACKTOP-1]。

                    <-- UXSTACKTOP
trap-time esp   // 頁面錯誤時用戶棧的地址
trap-time eflags
trap-time eip
trap-time eax       start of struct PushRegs
trap-time ecx
trap-time edx
trap-time ebx
trap-time esp
trap-time ebp
trap-time esi
trap-time edi       end of struct PushRegs
tf_err (error code)
fault_va            <-- %esp when handler is run

回到頁面錯誤處繼續執行

在執行完頁面錯誤處理函數後,需要回到用戶進程之前出錯的位置繼續執行,這裏需要完成 lib/pfentry.S中的_pgfault_upcall函數。

這個函數做的工作就是將異常棧切換到常規棧,重新設置 EIP,注意之前頁面出錯地址存儲在 fault_va 中。這裏要添加代碼如下,此時esp指向的是前一節的UTrapframe的地址,這裏做的工作主要是:

  • 將用戶進程的常規棧當前位置減去4字節,然後將用戶進程頁面錯誤時的EIP存儲到該位置。這樣恢復常規棧的時候,棧頂存儲的是出錯時的EIP。
  • 然後將異常棧中存儲的用戶進程頁面錯誤時的通用寄存器和eflags寄存器的值還原。
  • 然後將異常棧中存儲的esp的值還原到esp寄存器。
  • 最後通過ret指令返回到用戶進程出錯時的地址繼續執行。(ret指令執行的操作就是將彈出棧頂元素,並將EIP設置爲該值,此時正好棧頂是我們在之前設置的出錯時的EIP的值)
  • 現在可以看到如果發生嵌套頁錯誤爲什麼多保留4個字節了,這是因爲發生嵌套頁錯誤時,此時我們的trap-time esp存儲的是異常棧,此時會將trap-time的EIP的值會被設置到esp-4處,如果不空出4字節,則會覆蓋原來的esp值了。
movl 0x28(%esp), %ebx # trap-time時的eip,注意UTrapframe結構
subl $0x4, 0x30(%esp) 
movl 0x30(%esp), %eax 
movl %ebx, (%eax)    # 將trap-time的eip拷貝到trap-time esp-4處
addl $0x8, %esp

popal 

addl $0x4, %esp # 設置eflags
popfl

popl %esp     # 將棧頂地址彈出到esp,此時棧頂值是用戶進程出錯時的eip值
ret           

最後還要完成lib/pgfault.c中的set_pgfault_handler函數,用於爲用戶進程分配異常棧以及頁面錯誤處理函數 env_pgfault_upcall 的初始化設置。

2.2 實現寫時複製fork

完成上一節準備工作後,開始實現COW的fork,fork實現的流程如下:

  • 1)父進程設置pgfault()函數爲頁面錯誤處理函數,用到前面的 set_pgfault_handler 函數。
  • 2)父進程調用 sys_exofork() 創建一個空白子進程。
  • 3)對父進程在UTOP之下的可寫或者COW的物理頁,父進程調用duppage,duppage會將這些頁面設置爲COW映射到子進程的地址空間,同時,也要將父進程本身的頁面重新映射,將頁面權限設置爲COW(注:子進程的COW設置要在父進程之前)。duppage將父子進程相關頁面權限設置爲不可寫,且在avail字段設置爲COW,用於區分只讀頁面和COW頁面。異常棧不以這種方式重新映射,需要在子進程分配一個新的頁面給異常棧用。fork()還要處理那些不是可寫的且不是COW的頁面。
  • 4)父進程設置子進程的頁面錯誤處理函數。
  • 5)父進程標識子進程狀態爲可運行。

當父子進程中任意一個試圖修改一個還沒有寫過的COW頁面,會觸發頁面錯誤,開始下面流程:

  • 1)內核發現用戶程序頁面錯誤後,轉至_pgfault_upcall處理,而_pgfault_upcall會調用pgfault()。
  • 2)pgfault()檢查這是一個寫錯誤(錯誤碼中的FEC_WR)且頁面權限是COW的,如果不是則報錯。
  • 3)pgfault()分配一個新的物理頁,並映射到一個臨時位置,然後將出錯頁面的內容拷貝到新的物理頁中,然後將新的頁設置爲用戶可讀寫權限,並映射到對應位置。

fork(),pgfault(),duppage()三個函數的具體實現見作業12。完成後make run-forktree,正常應該輸出下面的內容(順序可能不同):

1000: I am ''
1001: I am '0'
2000: I am '00'
2001: I am '000'
1002: I am '1'
3000: I am '11'
3001: I am '10'
4000: I am '100'
1003: I am '01'
5000: I am '010'
4001: I am '011'
2002: I am '110'
1004: I am '001'
1005: I am '111'
1006: I am '101'

forktree這個程序比較有意思,它先創建兩個子進程打印第一層 0, 1,然後子進程再分別創建子進程打印一棵樹出來,比如兩層是這樣的,打印結果是 '', 0, 1, 00, 01, 10, 11

         ‘’
        / \
      0   1
     /\  /\
    0 1  0 1

3 搶佔式調度和進程間通信

3.1 時鐘中斷

最後一部分是通過時鐘中斷來完成搶佔式調度。運行make run-spin可以看到子進程死循環佔用了CPU,沒法切換到其他進程了,現在需要通過時鐘中斷來強制調度。時鐘中斷屬於可屏蔽中斷,可以通過 eflags 寄存器的IF位來控制,注意由int指令觸發的軟件中斷不受eflags寄存器的控制,它是不可屏蔽中斷,此外NMI也屬於不可屏蔽中斷。

外部中斷通常稱之爲 IRQ,IRQ到中斷描述符表的入口不是固定的。不過在 pic_init 中我們將IRQ的0-15映射到了IDT的[IRQ_OFFSET, IRQ_OFFSET+15]。其中IRQ_OFFSET爲32,所以IRQ在IDT中範圍爲[32, 47],共16個。JOS中對中斷做了簡化處理,在內核態時外部中斷是禁止的,在用戶態時纔會開啓。中斷開啓和禁止是通過eflags寄存器的 FL_IF 位來控制,爲1表示開啓中斷,爲0則禁止中斷。

接下來類似實驗3那樣,設置中斷號和中斷處理程序。注意在實驗3中我將istrap基本都設置爲1了,雖然那時候不影響實驗結果,在實驗4這裏必須要全部將istrap值設爲0。因爲JOS中的這個istrap設爲1就會在開始處理中斷時將FL_IF置爲1,而設爲0則保持FL_IF不變,設爲0才能通過trap()中對FL_IF的檢查。最後在 trap() 函數中處理 IRQ_TIMER中斷,調用lapic_eio()sched_yield()即可。

3.2 進程間通信(IPC)

最後要完成進程間通信,常見的一個IPC例子就是管道。實現IPC有很多方式,哪種方式最好至今仍有爭論,JOS中會實現一種簡單的IPC機制。需要完成 sys_ipc_try_send() 和 sys_ipc_recv() 兩個系統調用,以及封裝了這兩個系統調用的庫函數實現。

JOS IPC中的消息包括兩個部分:一個32位的值以及一個可選的頁面映射。消息中包含這個頁面映射是爲了傳輸更多的數據以及實現進程間共享內存。

進程調用 sys_ipc_recv() 接收消息,調用 sys_ipc_try_send() 發送消息。如果要發送頁面映射,則調用時設置srcva參數,表示要將srcva處的頁面映射共享給接收進程。而接收進程的 sys_ipc_try_recv() 如果希望接收頁面映射,則會提供一個 dstva 參數。如果發送進程和接收進程都沒有設置參數表示希望傳輸頁面映射,則不傳輸。內核會在接收進程的 env_ipc_perm字段設置接收的頁面映射的權限。

任何進程都可以發送消息給其他進程,不需要它們是父子進程。這裏的安全由IPC相關係統調用保障,一個進程不能通過發送消息導致另一個進程奔潰,除非接收消息的進程本身存在BUG。

4 一些注意點

  • 完成作業15後,可以發現stresssched通不過測試,這個有個坑,檢查了很久才發現,原來要在 kern/sched.csched_halt(void) 中去掉 //sti的註釋,因爲在AP啓動完成且獲得鎖且第一次調用 sched_yield()時,如果發現沒有可運行進程,會執行sched_halt()導致CPU處於HALT狀態。因爲我們在bootloader中通過cli關閉了中斷的,所以此時需要開啓中斷,不然AP就一直處於HALT狀態而不參與調度了。

  • 另外,spin測試不要多加參數如CPUS=2,否則會測試失敗,因爲當父子進程在不同的CPU運行時,此時父進程去銷燬子進程會先將子進程設置爲 ENV_DYING 狀態,而後等子進程調度的時候再自己銷燬自己,這會跟要求輸出不一樣導致通不過測試。

  • 一些調試語句要注意輸出位置,可能會干擾測試結果,因爲作業是根據輸出來判定的,最好去掉多餘的調試語句來測試。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章