MIT6.828 Preemptive Multitasking(上)

周小倫發表於2021-07-11

Lab4 Preemptive Multitasking(上)

PartA : 多處理器支援和協作多工

在實驗的這部分中,我們首先擴充jos使其執行在多處理器系統上,然後實現jos核心一些系統功能呼叫以支援使用者級環境去建立新環境。我們還需要實現協同式輪詢排程(cooperative round-robin scheduling)演算法,允許核心在舊的使用者環境資源放棄CPU或者退出的時候切換到一個新的使用者環境。

1. Multiprocessor Support

下面是一段對實驗指導書的翻譯。

我們將使JOS支援“對稱多處理”(SMP)的多處理器模型,其中所有CPU都有對系統資源(如記憶體和I / O匯流排)的等同許可權的訪問。 雖然所有CPU在SMP中在功能上相同,但在引導過程中,它們可以分為兩種型別:

  • 引導處理器BSP(bootstrap processor)負責初始化系統並且引導作業系統。
  • 應用處理器AP(application processor)在作業系統啟動之後被BSP啟用。

由哪個(些)處理器來擔任BSP的功能是由BIOS和硬體決定的,之前的所有程式碼都是在BSP上實現的。

在一個SMP系統中,每一個CPU都有一個伴隨的本地APIC(LAPIC)單元。LAPIC單元負責整個系統的中斷傳遞。LAPIC為與其相關聯的CPU提供了一個唯一的識別符號。在這個實驗中我們會使用LAPIC的一些基本功能(在kern/lapic.c中):

  • 讀取LAPIC識別符號(APIC ID),告知我們的程式碼正執行在哪個CPU上(參考cpunum()

    int
    cpunum(void)
    {
    	if (lapic)
    		return lapic[ID] >> 24;
    	return 0;
    }
    
  • 從BSP向AP傳送STARTUP處理器間中斷訊號IPI(interprocessor interrupt),喚醒其他CPU(參考lapic_startup()

  • 在Part C中,我們將通過LAPIC單元內建的定時器來觸發時鐘中斷,實現搶佔式多工(參考apic_init()

處理器通過記憶體對映I/O也稱為MMIO(memory-mapped I/O)來訪問LAPIC單元。在MMIO中,一部分的實體記憶體被硬連結到某些I/O裝置的暫存器。因此load/store訪存指令也可以特別用於訪問裝置暫存器。

在之前的實驗中我們已經知道了在實體地址0xA0000處有一個I/O hole(這個hole用於寫VGA顯示快取)。LAPIC單元存在於第二個I/O hole上,實體地址0xFE000000(4064MB)。這個高地址無法用之前設定對KERNBASE的直接對映去訪問。jos的虛擬記憶體對映在MMMIOBASE處留了4MB的空隙,所以我們可以在這裡對映硬體去訪問。之後的實驗中還會引入更多的MMIO區域,我們需要實現一個函式來分配這部分割槽域並對映到I/O裝置對應的記憶體

Exercise 1

實現kern/pmap.c中的mmio_map_region()函式。我們可以看到它在kern/lapic.c中在lapic_init()的開頭被呼叫。

(為了讓這個函式的測試案例能夠正常執行,我還需要把下一個練習也做完。

本來這裡想通過幾個斷點看一下執行流程的。。但是它的一堆assert測試會卡這個函式的實現。

image-20210705203358180

好分析一下mmio_map_region()讓我們乾的事

整體來講的功能就是把給定的[pa,pa+size]的實體地址和[MMIOBAZE, MMIOBASE + size]對應起來。

void *
mmio_map_region(physaddr_t pa, size_t size)
{
	// Where to start the next region.  Initially, this is the
	// beginning of the MMIO region.  Because this is static, its
	// value will be preserved between calls to mmio_map_region
	// (just like nextfree in boot_alloc).
	static uintptr_t base = MMIOBASE;

根據程式碼提示其實這裡可以很容易的寫完

  1. size要和pagesize做上取整
  2. 如果size + base超過了MMIOLIM則發出panic
  3. 對應map則用我們之前實現過的boot_map_region即可
  4. 這裡的base是一個靜態變數,所以我們要記得更新它,因為下一次分配的時候base就會變了
size = ROUNDUP(size,PGSIZE);
	if (size + base > MMIOLIM) {
		panic("wow overflow happen");
	}
	boot_map_region(kern_pgdir,base,size,pa,PTE_PCD|PTE_PWT|PTE_W);
	//panic("mmio_map_region not implemented");
	uintptr_t reserved_base = base;
	base += size;
	return (void*)reserved_base;

2. Application Processor Bootstrap

在引導AP之前,BSP首先需要收集有關多處理器系統的資訊,比如總CPU數,每個CPU對應的APIC ID以及LAPIC的記憶體對映地址等。kern/mpconfig.c中的mp_init()函式通過讀取BIOS記憶體中的MP配置表來獲取相關資訊。

kern/init.c中的boot_aps()函式驅動了AP的引導過程。AP從真實模式開始啟動(類似於bootloader),因此boot_aps()kern/mpentry.S中拷貝了一份AP入口程式碼(entry code)到一個真實模式下可以訪問的記憶體位置。與bootloader不同的是,我們對於AP入口程式碼存放的位置可以有一定控制權:在jos中使用MPENTRY_PADDR(0x7000)作為入口地址的存放位置,但是實際上640KB下任何未使用的地址都是可以使用的。

接下來,boot_aps()向對應AP的LAPIC單元傳送STARTUP的IPI訊號(處理器間中斷),使用AP的entry code初始化其CS:IP地址(在這裡我們就使用MPENTRY_PADDR)依次啟用APs。

在一些簡單的設定之後,AP將啟動分頁並進入保護模式,然後呼叫在kern/init.c中的啟動例程mp_mainboot_aps()在喚醒下一個AP之前會等待當前AP發出一個CPU_STARTED啟動標記,這個標記位在struct CpuInfo中的cpu_status域。

Exercise 2

首先閱讀kern/init.c中的boot_aps()以及mp_main()kern/mpentry.S彙編程式碼。我們需要確保理解APs的bootstrap過程中的控制流切換。

控制流切換

  1. 首先在系統載入的過程中,boot_aps()被呼叫。這個時候是由BSP呼叫

  2. 然後使用memmove()函式從mpentry.S中拷貝檔案中.global mpentry_start標籤處開始的入口程式碼直到.global mpentry_end結束,程式碼被拷貝到MPENTRY_PADDR(實體地址0x7000)對應的核心虛擬地址(別忘了必須拷貝到核心虛擬地址才可以被核心所操作)

    	code = KADDR(MPENTRY_PADDR);
    	memmove(code, mpentry_start, mpentry_end - mpentry_start);
    
  3. 然後boot_aps()根據每一個CPU的棧配置percpu_kstacks[]來為每一個AP設定棧地址mpentry_stack

  4. 再之後呼叫lapic_startup()函式來啟動AP,並等待AP的狀態變為CPU_STARTED以切換到下一個AP的配置。

    Lapis_startup後面看看在lab最後理解一下

  5. AP啟動後,執行從mpentry.S中複製的入口程式碼:

然後修改我們之前在kern/pmap.cpage_init()的實現,避免將MPENTRY_PADDR的區域也新增到page_free_list中,這樣我們才可以安全地在該實體地址處拷貝以及執行AP的引導程式碼。

其實只需要求出MPENTRY_PADDR對應在pages陣列中的索引。當我們對page初始化的時候跳過MPENTRY_PADDR所在的頁就好。

因為只是幾行彙編程式碼一頁足夠了。

image-20210706205204263

Question

比較kern/mpentry.Sboot/boot.S,記住兩個程式碼都是編譯連線後載入到KERNBASE之上執行的,為什麼mpentry.S需要一個多餘的巨集定義MPBOOTPHYS?換句話說,如果在kern / mpentry省略它會出現問題

首先我們來看一下這個巨集是在幹嘛

這是用來計算對應彙編程式碼的絕對地址。

因為我們會把mpentry_start移動到MPENTRY_PADDR上。因此下面的巨集定義就相當於在算當前的程式碼和起點的差值 + 真正的起始地址就會得到真正的地址

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

kern/mpentry.S 是執行在 KERNBASE 之上的,與其他的核心程式碼一樣。也就是說,類似於 mpentry_start, mpentry_end, start32 這類地址,都位於 0xf0000000 之上,顯然,真實模式是無法定址的。因此,真實模式下就可以通過 MPBOOTPHYS 巨集的轉換,執行這部分程式碼

3. Per-CPU State and Initialization

長長的翻譯。說實話每次看完這些翻譯,我還是一頭霧水,都還是通過慢慢看程式碼看懂的。

在編寫多處理器OS時,重要的是區分每個CPU的私有狀態,以及整個系統共享的全域性狀態。kern/cpu.h中定義了絕大部分CPU的狀態,包括用於儲存cpu變數的struct CpuInfo

cpunum()總是返回撥用它的CPU ID,能用來索引例如cpus的陣列。巨集定義thiscpu是當前CPU的struct CpuInfo的簡寫。

下面是應該瞭解的每個CPU狀態:

CPU核心堆疊

由於多個CPU可以同時陷入核心,我們需要為每個CPU設定獨立的核心棧來避免相干擾。 percpu_kstacks [ncpu] [kstksize]保留NCPU的核堆疊的空間。

在Lab 2中,我們將BootStack指向的實體記憶體,對映到虛擬地址Kstacktop處作為BSP的核心堆疊。相似地我們在本次實驗中需要為每個CPU的核心棧對映到陣列的對應區域。 CPU 0的堆疊仍將從Kstacktop開始向下增長;之後第n個CPU的核心棧從KSTACKTOP - n*KSTKGAP處開始向下增長。如inc/memlayout.h中所示。

CPU的TSS和TSS描述符

每CPU都需要任務狀態段(TSS),以便指定每個CPU的核心堆疊生命的位置。 CPU i的TSS儲存在CPU [i] .cpu_ts中,並且在GDT條目GDT [(GD_TSS0 >> 3)+ i]中定義相應的TSS描述符。 kern / trap.c中定義的全域性TS變數將不再有用。

CPU指向當前環境的指標

由於每個CPU可以同時執行不同的使用者程式,將curenv定義為指向當前CPU(當前程式碼正在執行的CPU)正在執行的環境的cpus[cpunum()].cpu_env(或是thiscpu->cpu_env)

CPU的系統暫存器。

包括系統暫存器在內的所有暫存器都屬於CPU私有。因此初始化這些暫存器的指令如lcr3, ltr, lgdt等,必須在每個CPU上都被執行。函式env_init_percpu()trap_init_percpu的功能就在於此。

Exercise 3

修改kern/pmap.c中的mem_init_mp(),使CPU核心棧對映到相應的虛擬記憶體。

這個函式根據程式碼提示可以很快做完,基本上只有兩點需要注意的

  1. 就是許可權記得寫成PTE_W
  2. 記得percpu_kstacks對應的是核心棧的虛擬地址要利用PADDR巨集定義把它轉換成實體地址
// LAB 4: Your code here:
	int i = 0;
	for (; i < NCPU; i++) {
		boot_map_region(kern_pgdir,KSTACKTOP - i * (KSTKSIZE + KSTKGAP) - KSTKSIZE,KSTKSIZE, PADDR(percpu_kstacks[i]),PTE_W);
	}

Exercise 4

kern/trap.c中的trap_init_percpu()初始化了BSP的TSS和TSS描述符(它可以在lab3中給正常工作,但是本實驗執行在其他CPU時不能正常工作),我們需要修改程式碼以使其支援所有CPU。

基本上也是根據程式碼提示來

  1. 不要使用ts暫存器。轉而利用thiscpu->cpu_ts來為每個cpu初始化tss暫存器的值
  2. 對於tss描述符要用這樣的方式gdt[(GD_TSS0 >> 3) + i]來儲存
thiscpu->cpu_ts.ts_esp0 = (uintptr_t)percpu_kstacks[cpunum()];
	thiscpu->cpu_ts.ts_ss0 = GD_KD;
	thiscpu->cpu_ts.ts_iomb = sizeof(struct Taskstate);

	// Initialize the TSS slot of the gdt.
	gdt[(GD_TSS0 >> 3) + cpunum()] = SEG16(STS_T32A, (uint32_t) (&(thiscpu->cpu_ts)),
					sizeof(struct Taskstate) - 1, 0);
	gdt[(GD_TSS0 >> 3) + cpunum()].sd_s = 0;

	// Load the TSS selector (like other segment selectors, the
	// bottom three bits are special; we leave them 0)
	ltr(GD_TSS0 + (cpunum() << 3));

可以得到下面的結果,發現確實可以產生合理的結果

image-20210706204335752

4. Locking

當前我們的程式碼會在mp_main()初始化完成所有AP之後陷入自旋(spin)。在讓這些AP做出下一步操作之前,我們需要解決多個CPU同時執行核心程式碼的競爭條件。

最簡單的方式就是使用一個大核心鎖(big kernel lock)。這個大核心鎖是一個單一的全域性鎖,當一個環境進入核心模式的時候就可以被獲取,然後返回到使用者態的時候被釋放。在這種模型下,使用者模式的環境可以在任意多個CPU下併發執行(concurrently),但是隻有一個環境能處於核心態,其餘環境進入核心態需要強制等待。

kern/spinlock.h中宣告瞭這個大核心鎖的實現函式kernel_lock()。同時它提供了lock_kernel()unlock_kernel()兩個函式用於上鎖和解鎖,我們需要在以下四個場景使用大核心鎖:

  • i386_init():在BSP喚醒其它CPU之前進行上鎖
  • mp_main():初始化AP之後進行上鎖,然後呼叫sched_yield()在當前AP上執行環境
  • trap():從使用者模式陷入核心之前獲得大鎖進行上鎖。通過TF_CS暫存器的低位來判斷陷阱是否發生在使用者模式或核心模式下
  • env_run():在切換回使用者態之前進行解鎖。時機不對會導致競爭或死鎖

這個整體按照程式碼提示,加一行減一行的非常容易

// In i386_init():
// Acquire the big kernel lock before waking up APs
// Your code here:
lock_kernel();

// In mp_main():
// Now that we have finished some basic setup, call sched_yield()
// to start running processes on this CPU.  But make sure that
// only one CPU can enter the scheduler at a time!
//
// Your code here:
// lock the kernel and start running enviroments
lock_kernel();
sched_yield();

// In trap():
// Trapped from user mode.
// Acquire the big kernel lock before doing any
// serious kernel work.
// LAB 4: Your code here.
lock_kernel();

// In env_run():
// address space switch
// reference from inc/x86.h
lcr3(PADDR(e->env_pgdir));
// release kernel lock here
unlock_kernel(); // newly added code
// drop into user mode
env_pop_tf(&(e->env_tf));

exercise 5

通過呼叫lock_kernel()unlock_kernel()函式來實現上面所描述的大核心鎖

首先在i386_init()中實現在bsp其他cpu之前進行上鎖

Question

似乎使用Big Kernel Lock保證了只能有一個CPU在核心態執行。 那為什麼每個CPU還需要單獨核心堆疊? 描述一個場景,其中使用共享核心堆疊將出錯,即使是對大核心鎖定的保護。

當cpu0在核心態執行的時候,這個時候如果cpu1發生中斷想要陷入核心態,那麼如果這兩個cpu是共享核心態的話就會發生錯誤。當發生中斷的時候,會進行棧的切換,cpu1再陷入之前要把一些引數儲存到核心棧中。如果核心棧共享的話,則就出現問題

5. Round-Robin Scheduling

我們的下一個任務是改變jos核心,實現對使用者環境的輪詢排程:

  • kern/sched.c中的sched_yield()負責從使用者環境中選擇一個新環境執行。其按照順序遍歷envs[]陣列,從上一次執行的環境開始,找到第一個ENV_RUNNABLE的環境然後呼叫env_run()
  • sched_yield()一定不能在兩個CPU上同時執行相同的環境。它可以通過環境的狀態是否為ENV_RUNNING來判斷這個環境是否正執行在某個CPU上。
  • 我們提供了一個新的系統呼叫sys_yield(),使得在使用者環境中可以通過該系統呼叫喚醒sched_yield(),主動放棄CPU。

exercise 6

sched_yield()中實現上述機制,注意我們要修改syscall()來支援對sys_yield()的排程。

// LAB 4: Your code here.
	size_t start = 0;
	if (curenv) {
		start = ENVX(curenv->env_id) + 1;
	}

	for (size_t i = 0; i < NENV; i++) {
		size_t index = (start + i) % NENV;
		if (envs[index].env_status == ENV_RUNNABLE) {
			env_run(&envs[index]);
		}
	}
	//
	// If no envs are runnable, but the environment previously
	// running on this CPU is still ENV_RUNNING, it's okay to
	// choose that environment.
	if(curenv && curenv->env_status == ENV_RUNNING) {
		env_run(curenv);
	}
	// sched_halt never returns
	sched_halt();

確保在mp_main()中呼叫sched_yield()

修改kern/init.c來建立三個或者更多的使用者環境,使其同時執行user/yield.c程式。

#else
	// Touch all you want.
	// ENV_CREATE(user_primes, ENV_TYPE_USER);
	ENV_CREATE(user_yield, ENV_TYPE_USER);
	ENV_CREATE(user_yield, ENV_TYPE_USER);
	ENV_CREATE(user_yield, ENV_TYPE_USER);

關於lab3的一個小bug

Lab3部落格中已經修復

trap.c中的trap_init(void)函式中

(-) SETGATE(idt[T_SYSCALL],1,GD_KT,syscall_handler,3);
(+) SETGATE(idt[T_SYSCALL],0,GD_KT,syscall_handler,3);

關於系統呼叫是要關中斷的也就是說它不是一個trap型別。不然這裡會過不了

image-20210708225242625

Question

  1. In your implementation of env_run() you should have called lcr3(). Before and after the call to lcr3(), your code makes references (at least it should) to the variable e, the argument to env_run. Upon loading the %cr3 register, the addressing context used by the MMU is instantly changed. But a virtual address (namely e) has meaning relative to a given address context--the address context specifies the physical address to which the virtual address maps. Why can the pointer e be dereferenced both before and after the addressing switch?

    這個是因為e位於UTOP以上,而在這上面的地址給予env_pgdir和kern_pgdir是一樣的

  2. 當核心進行使用者環境切換的時候,必須要保證舊的環境的暫存器值被儲存起來以便之後恢復。這個過程是在哪裡發生的?

    是在trapentry.S

    */
    .global _alltraps
    _alltraps:
    // make the stack look like a struct Trapframe
    	pushl %ds;
    	pushl %es;
    	pushal;
    // load GD_KD into %ds and %es
    	movl $GD_KD, %edx
    	movl %edx, %ds
    	movl %edx, %es
    // push %esp as an argument to trap()
    	pushl %esp;
    	call trap;
    
    

6. System Calls for Environment Creation

Unix系統提供了fork()系統呼叫作為程式建立原語(process creation primitive)。Unix的fork()拷貝呼叫程式(父程式)的整個程式空間以建立子程式,這種情況下父子程式之間唯一可觀察的區別就是他們的程式ID分別為pidppid(可以通過getpid()getppid()檢視)。在父程式中,fork()函式返回子程式的ID,而在子程式中返回0.

預設情況下,每一個程式都有其私有的地址空間,而且任意一個程式對於核心的修改對於其他程式而言都是不可見的。

現在我們將實現一個jos系統呼叫原語以使使用者建立新的使用者模式環境。完成這些這些系統呼叫。我們將實現以下的系統呼叫函式:

  • sys_exofork():建立一個幾乎為空白狀態的新環境:這個地址空間沒有任何使用者部分對映,也無法執行。新環境將會有和父親環境完全一致的暫存器狀態,而在父親環境執行該系統呼叫後會返回新建立環境的envid_t(如果建立失敗則返回錯誤碼),子環境返回0。由於子環境最初被標記為不可執行,故在子環境中sys_exofork()會一直wait,直到父環境顯式標記子環境為可執行,其才會在子環境中返回。
  • sys_env_set_status():設定指定的環境的狀態為ENV_RUNNABLE或者RUN_NOT_RUNNABLE。這個系統呼叫通常在一個新環境的地址空間和暫存器狀態完全初始化完成之後將其標記為可執行。
  • sys_page_alloc():分配一頁的實體記憶體然後將其對映到特定環境的地址空間的給定虛擬地址。
  • sys_page_map():將一個頁對映關係(不是頁的具體內容)從一個環境的地址空間拷貝到另一個環境的地址空間。實現共享記憶體。
  • sys_page_unmap():將給定環境的虛擬地址頁面解除對映。

上述所有系統呼叫函式都需要接受一個環境ID,jos的核心支援了環境號0代表當前環境。在kern/env.c中的envid2env()實現了這種對映。

我們在user/dumpfork.c中提供了和原始Unix 系統中fork()函式類似的函式實現。測試程式用上述系統呼叫建立並執行一個當前地址空間拷貝的子程式,然後兩個環境使用sys_yield()來回切換。父程式在10次迭代後退出;子程式在20次迭代後退出。

exercise 7

實現上述在kern/syscall.c中的系統呼叫函式,確保syscall()可以呼叫它們。你可能需要用到kern/pmap.ckern/env.c中的一些函式,尤其是envid2env()

現在你使用envid2env()的時候,將checkperm引數設定為1,確保當你的一些系統呼叫引數無效的時候會返回-E_INVAL。使用user/dumpfork.c測試你實現的這些系統呼叫。

實現sys_exofork()

首先從dumpfork開始,可以找到sys_exofork的原始定義

  1. 通過int2中斷進入trapentry.s
  2. 根據syscall進入trap_dispatch()
// This must be inlined.  Exercise for reader: why?
static inline envid_t __attribute__((always_inline))
sys_exofork(void)
{
	envid_t ret;
	asm volatile("int %2"
		     : "=a" (ret)
		     : "a" (SYS_exofork), "i" (T_SYSCALL));
	return ret;
}
  1. 在trap_dispatch()中會儲存當前暫存器資訊,然後執行syscall

隨後我們根據程式碼提示實現sys_exofork

static envid_t
sys_exofork(void)
{
	struct Env *child_env;
	int eno;
	// if alloc env error 
	// directly return
	if ((eno = env_alloc(&child_env,curenv->env_id) < 0)) {
		return eno;
	}
	// same register state as parent
	child_env->env_tf = thiscpu->cpu_env->env_tf;
	// status is not run
	child_env->env_status = ENV_NOT_RUNNABLE;
	// child_env return 0
	child_env->env_tf.tf_regs.reg_eax = 0;
	// father env return child env_id
	return child_env->env_id;
}

實現sys_env_set_status函式

首先找到這個函式的定義.

需要兩個引數分別為env_id和對應的狀態

int	sys_env_set_status(envid_t env, int status);

env_set就是把指定的env的狀態設定成傳入的status引數,只不過要注意一些條件判斷

static int
sys_env_set_status(envid_t envid, int status) {
	if((status != ENV_RUNNABLE) && (status != ENV_NOT_RUNNABLE)){
		return -E_INVAL;
	}
	struct Env *env;
	int eno = envid2env(envid,&env,1);
	if (eno < 0) {
		return -E_BAD_ENV;;
	}
	env->env_status = status;
	return 0;
}

實現sys_page_alloc()函式

基本按照提示來就可以了。但是有兩個要注意的點

  1. 就是如何判斷是否是頁對齊點

    PGOFF(va) != 0 // 來判斷是否是頁對奇的
    
  2. PTE_SYSCALL

// Flags in PTE_SYSCALL may be used in system calls.  (Others may not.)
#define PTE_SYSCALL	(PTE_AVAIL | PTE_P | PTE_W | PTE_U)

也就是說如果下面的式子成立的話,則出現了PTE_SYSCALL之外的位為1.

if (perm & (~PTE_SYSCALL))
// Allocate a page of memory and map it at 'va' with permission
// 'perm' in the address space of 'envid'.
// The page's contents are set to 0.
// If a page is already mapped at 'va', that page is unmapped as a
// side effect.
//
// perm -- PTE_U | PTE_P must be set, PTE_AVAIL | PTE_W may or may not be set,
//         but no other bits may be set.  See PTE_SYSCALL in inc/mmu.h.
//
// Return 0 on success, < 0 on error.  Errors are:
//	(1) -E_BAD_ENV if environment envid doesn't currently exist,
//		 or the caller doesn't have permission to change envid.
//	(2) -E_INVAL if va >= UTOP, or va is not page-aligned.
//	(3) -E_INVAL if perm is inappropriate (see above).
//	(4) -E_NO_MEM if there's no memory to allocate the new page,
//		 or to allocate any necessary page tables.
static int
sys_page_alloc(envid_t envid, void *va, int perm)
{
	// Hint: This function is a wrapper around page_alloc() and
	//   page_insert() from kern/pmap.c.
	//   Most of the new code you write should be to check the
	//   parameters for correctness.
	//   If page_insert() fails, remember to free the page you
	//   allocated!

	// LAB 4: Your code here.
	//(1)
	struct Env *env;
	int eno;
	if ((eno = envid2env(envid,&env,1) < 0)) {
		return -E_BAD_ENV;
	}
	// (2)
	if((uintptr_t)va >= UTOP || PGOFF(va) != 0){
		return -E_INVAL;
	}
	// (3)
	if(!(perm & PTE_U) || !(perm & PTE_P) || (perm & (~PTE_SYSCALL))){
		return -E_INVAL;
	}
	// (4)
	struct PageInfo *page;
	page = page_alloc(ALLOC_ZERO);
	if(page == NULL){
		return -E_NO_MEM;
	}
	eno = page_insert(env->env_pgdir,page,va,perm);
	if (eno < 0) {
		page_free(page);
		return -E_NO_MEM;
	}
	return 0;
	
}

實現sys_page_map函式

基本上按照提示也是比較好實現的

  1. 搞清楚page_map的功能就是把對應環境的虛擬地址和指定環境的虛擬地址相對應
static int
sys_page_map(envid_t srcenvid, void *srcva,
	     envid_t dstenvid, void *dstva, int perm)
{
	// Hint: This function is a wrapper around page_lookup() and
	//   page_insert() from kern/pmap.c.
	//   Again, most of the new code you write should be to check the
	//   parameters for correctness.
	//   Use the third argument to page_lookup() to
	//   check the current permissions on the page.

	// LAB 4: Your code here.
	// case 1 -E_BAD_ENV
	struct Env *srcv, *dstv;
	if (envid2env(srcenvid,&srcv,1) < 0 || envid2env(dstenvid,&dstv,1) < 0) {
		return -E_BAD_ENV;
	}
	// case 2 -E_INVAL
	if (((uintptr_t)srcva >= UTOP) || ((uintptr_t)dstva >= UTOP) ||
        (PGOFF(srcva) != 0) || (PGOFF(dstva) != 0)) {
        return -E_INVAL;
    }
	// case 3 -E_INVAL
	struct PageInfo *srcpage;
    pte_t *          scrpte_ptr;
    // use page look up to get source page and corresponding pte_t *
    if ((srcpage = page_lookup(srcv->env_pgdir, srcva, &scrpte_ptr)) ==
        NULL) {
        // srcva not mapped in srcenvid's address space
        return -E_INVAL;
    }

    if ((perm & (~PTE_SYSCALL)) || !(perm & PTE_U) || !(perm & PTE_P)) {
        return -E_INVAL;
    }
    if ((perm & PTE_W) && (!((*scrpte_ptr) & PTE_W))) {
        return -E_INVAL;
    }
	if (page_insert(dstv->env_pgdir, srcpage, dstva, perm) < 0) {
		return -E_NO_MEM;
	}
    return 0;
	
}

實現sys_page_unmap函式

static int sys_page_unmap(envid_t envid, void *va) {
	struct Env *curE;
	int eno;
	if ((eno = envid2env(envid,&curE,1) < 0)) {
		return -E_BAD_ENV;
	}
	if ((uintptr_t)va >= UTOP || PGOFF(va) != 0){
		return -E_INVAL;
	}
	page_remove(curE->env_pgdir,va);
	return 0;
}

PartA+: 回顧parA

emmmpartA寫了這麼多程式碼,居然才5分。但是在寫了幾個關於建立新環境的函式之後,相信大家都好奇之間的呼叫關係是怎麼樣的。是在哪裡執行了這些函式。以及之前的多cpu切換流程的梳理

1. 多cpu的初始化和啟動

關於BSP和AP的說明可以參考x86-64的多核初始化

關於Jos多cpu切換的流程分析多參考於Xv6學習小記(二)——多核啟動

感謝各位大佬們的無私分享。

1. 首先我們要從系統如何檢測CPU的個數開始說起

系統首先進行查詢MP浮點結構:
1.如果BIOS的EBDA已經定義,則在其中的第一K位元組中進行查詢,否則到2;

2.若EBDA未被定義,則在系統基本記憶體的最後一K位元組中尋找;

3.在BIOS ROM裡的0xF0000到0xFFFFF的地址空間中尋找。

關於記憶體低1MB的詳細資訊見下圖

image-20210710200757437

對應於mpsearch函式

如果EBDA(Extended BIOS Data Area,擴充套件BIOS資料區)不存在,BDA[0x0E]和BDA[0x0F]的值為0;如果EBDA存在,其段地址被儲存在BDA[0x0E]和BDA[0x0F]中,其中BDA[0x0E]儲存EBDA段地址的低8位,BDA[0x0F]儲存EDBA段地址的高8位,所以(BDA[0x0F]<<8) | BDA[0x0E]就表示了EDBA的段地址,將段地址左移4位即為EBDA的實體地址。如下面的程式碼所示。

p <<= 4
static struct mp *
mpsearch(void)
{
	uint8_t *bda;
	uint32_t p;
	struct mp *mp;

	static_assert(sizeof(*mp) == 16);

	// The BIOS data area lives in 16-bit segment 0x40.
	bda = (uint8_t *) KADDR(0x40 << 4); 

	// [MP 4] The 16-bit segment of the EBDA is in the two bytes
	// starting at byte 0x0E of the BDA.  0 if not present.
	if ((p = *(uint16_t *) (bda + 0x0E))) {
		p <<= 4;	// Translate from segment to PA
		if ((mp = mpsearch1(p, 1024))) // 在EBDA的前1kb個位元組中查詢
			return mp;
	} else {
		// The size of base memory, in KB is in the two bytes
		// starting at 0x13 of the BDA.
		p = *(uint16_t *) (bda + 0x13) * 1024; // 得到系統記憶體的末尾邊界地址
		if ((mp = mpsearch1(p - 1024, 1024)))
			return mp;
	}
	return mpsearch1(0xF0000, 0x10000); // 在rom area中尋找
}

關於mpsearch1函式

該函式將_MP_這個長度為4的字串作為了MP浮點結構的標識,匹配到此字串即找到了MP浮點結構,然後返回指向該MP浮點結構的指標。

// Look for an MP structure in the len bytes at physical address addr.
static struct mp *
mpsearch1(physaddr_t a, int len)
{
	struct mp *mp = KADDR(a), *end = KADDR(a + len);

	for (; mp < end; mp++)
		if (memcmp(mp->signature, "_MP_", 4) == 0 &&
		    sum(mp, sizeof(*mp)) == 0)
			return mp;
	return NULL;
}

mp_init函式先執行了mpconfig方法返回了MP配置表頭的虛擬地址

mpconfig函式

  1. 通過mpsearch獲得指向mp浮點結構的指標m
  2. 隨後通過m指標訪問到mp配置表頭,並將其轉換成虛擬地址
static struct mpconf *
mpconfig(struct mp **pmp)
{
	struct mpconf *conf;
	struct mp *mp;

	if ((mp = mpsearch()) == 0)
		return NULL;
	if (mp->physaddr == 0 || mp->type != 0) {
		cprintf("SMP: Default configurations not implemented\n");
		return NULL;
	}
	conf = (struct mpconf *) KADDR(mp->physaddr); 
	if (memcmp(conf, "PCMP", 4) != 0) {
		cprintf("SMP: Incorrect MP configuration table signature\n");
		return NULL;
	}
	if (sum(conf, conf->length) != 0) {
		cprintf("SMP: Bad MP configuration checksum\n");
		return NULL;
	}
	if (conf->version != 1 && conf->version != 4) {
		cprintf("SMP: Unsupported MP version %d\n", conf->version);
		return NULL;
	}
	if ((sum((uint8_t *)conf + conf->length, conf->xlength) + conf->xchecksum) & 0xff) {
		cprintf("SMP: Bad MP configuration extended checksum\n");
		return NULL;
	}
	*pmp = mp;
	return conf;
}

MP配置表頭的結構體如下:

struct mpconf {         				// configuration table header
  uchar signature[4];           // 標誌為"PCMP"
  ushort length;                // MP配置表的長度
  uchar version;                // [14]
  uchar checksum;               // all bytes must add up to 0
  uchar product[20];            // product id
  uint *oemtable;               // OEM table pointer
  ushort oemlength;             // OEM table length
  ushort entry;                 // 入口數
  uint *lapicaddr;              // local APIC的地址
  ushort xlength;               // extended table length
  uchar xchecksum;              // extended table checksum
  uchar reserved;
};

接下來來看mpinit方法

程式在mpinit()方法中遍歷MP擴充套件部分通過判斷入口型別來進行相應操作,如判斷入口型別為MPPROC時則將ncpu加1,部分程式碼如下

	bootcpu = &cpus[0];
	if ((conf = mpconfig(&mp)) == 0) //獲得mp表頭的指標
		return;
	ismp = 1;
	lapicaddr = conf->lapicaddr;
	// 遍歷mp表的條目
	for (p = conf->entries, i = 0; i < conf->entry; i++) {
		switch (*p) {
    // 如果是處理器
		case MPPROC:
			proc = (struct mpproc *)p;
			if (proc->flags & MPPROC_BOOT)  //判斷此CPU是否為主引導CPU(BSP)
				bootcpu = &cpus[ncpu];    //若是BSP,將此CPU設為第0個CPU
			if (ncpu < NCPU) {
				cpus[ncpu].cpu_id = ncpu;  //給每個CPU設定ID並存入cpus陣列中
				ncpu++; 		 //CPU個數+1
			} else {
				cprintf("SMP: too many CPUs, CPU %d disabled\n",
					proc->apicid);
			}
			p += sizeof(struct mpproc);
			continue;
		case MPBUS:
		case MPIOAPIC:
		case MPIOINTR:
		case MPLINTR:
			p += 8;
			continue;
		default:
			cprintf("mpinit: unknown config type %x\n", *p);
			ismp = 0;
			i = conf->entry;
		}
	}

	bootcpu->cpu_status = CPU_STARTED;
	if (!ismp) {
		// Didn't like what we found; fall back to no MP.
		ncpu = 1;
		lapicaddr = 0;
		cprintf("SMP: configuration not found, SMP disabled\n");
		return;
	}
	cprintf("SMP: CPU %d found %d CPU(s)\n", bootcpu->cpu_id,  ncpu);
	}

2. 隨後執行lapic_init函式

引用於

80486DX在1990年上市,其引入了SMP的概念,即多CPU(注意不是多核)。Intel為了適應SMP提出APIC(Advanced Programmable Interrupt Controller,高階中斷控制器)的新技術。APIC 由兩部分組成,一個稱為LAPIC(Local APIC,本地高階中斷控制器),一個稱為IOAPIC(I/O APIC,I/O 高階中斷控制器)。前者位於CPU中,在SMP 平臺,每個CPU 都有一個自己的LAPIC(後期多核後,每個邏輯核都有個LAPIC)。後者通常位於外部裝置晶片上,例如南橋上。像PIC 一樣,連線各個產生中斷的裝置。而IOAPIC和LAPIC通過APIC Bus連線在一起。如圖:

img

因此這裡我們要做的就是初始每個cpu的Local APIC。同時多cpu的中斷流程如下

  • 一個 CPU 給其他 CPU 傳送中斷的時候, 就在自己的 ICR 中, 放中斷向量和目標LAPIC ID, 然後通過匯流排傳送到對應 LAPIC,
  • 目標 LAPIC 根據自己的 LVT(Local Vector Table) 來對不同的中斷進行處理.
  1. 通過lab中實現的mmio_map_region函式將lapicaddr對映到虛擬地址.大小為4kb

    void
    llapic_init(void)
    {
    	if (!lapicaddr)
    		return;
    	
    	// lapicaddr is the physical address of the LAPIC's 4K MMIO region.  Map it in to virtual memory so we can access it.
    	
    	lapic = mmio_map_region(lapicaddr, 4096);
    
  2. 下面的函式大量用到lapicw這裡先看一下

    其實就是設定lvt表。具體關於apic的討論可以看這裡 XV6 的中斷和系統呼叫

    static void
    lapicw(int index, int value)
    {
    	lapic[index] = value;
    	lapic[ID];  // wait for write to finish, by reading
    }
    
  3. 下面的程式碼就是對於LVT表的初始化操作。深究可能仔細看看那這個xv6中文文件


	// Enable local APIC; set spurious interrupt vector.
	lapicw(SVR, ENABLE | (IRQ_OFFSET + IRQ_SPURIOUS));

	// The timer repeatedly counts down at bus frequency
	// from lapic[TICR] and then issues an interrupt.  
	// If we cared more about precise timekeeping,
	// TICR would be calibrated using an external time source.
	lapicw(TDCR, X1);
	lapicw(TIMER, PERIODIC | (IRQ_OFFSET + IRQ_TIMER)); // 這會讓lapic週期性地在iRQ_TIMER產生中斷
	lapicw(TICR, 10000000); 

	// Leave LINT0 of the BSP enabled so that it can get
	// interrupts from the 8259A chip.
	//
	// According to Intel MP Specification, the BIOS should initialize
	// BSP's local APIC in Virtual Wire Mode, in which 8259A's
	// INTR is virtually connected to BSP's LINTIN0. In this mode,
	// we do not need to program the IOAPIC.
	if (thiscpu != bootcpu)
		lapicw(LINT0, MASKED);

	// Disable NMI (LINT1) on all CPUs
	lapicw(LINT1, MASKED);

	// Disable performance counter overflow interrupts
	// on machines that provide that interrupt entry.
	if (((lapic[VER]>>16) & 0xFF) >= 4)
		lapicw(PCINT, MASKED);

	// Map error interrupt to IRQ_ERROR.
	lapicw(ERROR, IRQ_OFFSET + IRQ_ERROR);

	// Clear error status register (requires back-to-back writes).
	lapicw(ESR, 0);
	lapicw(ESR, 0);

	// Ack any outstanding interrupts.
	lapicw(EOI, 0);

	// Send an Init Level De-Assert to synchronize arbitration ID's.
	lapicw(ICRHI, 0);
	lapicw(ICRLO, BCAST | INIT | LEVEL);
	while(lapic[ICRLO] & DELIVS)
		;

	// Enable interrupts on the APIC (but not on the processor).
	lapicw(TPR, 0);
}

3. boot_aps函式

隨後進入boot_aps函式

  1. 先把entryother.S的程式碼拷貝到以0x7000起始的這塊記憶體。
  2. 然後逐步啟動所有的ap cpu
  3. 為每一個ap分配自己的核心棧
  4. 通過lapic_startap函式向這個CPU發中斷,讓此CPU執行boot程式
static void
boot_aps(void)
{
	extern unsigned char mpentry_start[], mpentry_end[];
	void *code;
	struct CpuInfo *c;

	// Write entry code to unused memory at MPENTRY_PADDR
	code = KADDR(MPENTRY_PADDR);
	memmove(code, mpentry_start, mpentry_end - mpentry_start);

	// Boot each AP one at a time
	for (c = cpus; c < cpus + ncpu; c++) {
		if (c == cpus + cpunum())  {// We've started already.
			cprintf("cpu has already startd(id): %08x\n", c->cpu_id);
			continue;
		}
		// Tell mpentry.S what stack to use 
		mpentry_kstack = percpu_kstacks[c - cpus] + KSTKSIZE;
		// Start the CPU at mpentry_start
		cprintf("cpu start(id): %08x\n", c->cpu_id);
		lapic_startap(c->cpu_id, PADDR(code));
		// Wait for the CPU to finish some basic setup in mp_main()
		while(c->cpu_status != CPU_STARTED)
			;
	}
}

lapic_startap函式

  1. outb指令

    用於向指定埠寫入1位元組的資料

    static inline void
    outb(int port, uint8_t data)
    {
    	asm volatile("outb %0,%w1" : : "a" (data), "d" (port));
    }
    
  2. 看不懂下面這部分。。

void
lapic_startap(uint8_t apicid, uint32_t addr)
{
	int i;
	uint16_t *wrv;

	// "The BSP must initialize CMOS shutdown code to 0AH
	// and the warm reset vector (DWORD based at 40:67) to point at
	// the AP startup code prior to the [universal startup algorithm]."
	outb(IO_RTC, 0xF);  // offset 0xF is shutdown code
	outb(IO_RTC+1, 0x0A);
  wrv = (uint16_t *)KADDR((0x40 << 4 | 0x67));  // Warm reset vector
	wrv[0] = 0;
	wrv[1] = addr >> 4;
  1. BSP通過向AP逐個傳送中斷來啟動AP,首先傳送INIT中斷來初始化AP,然後傳送SIPI中斷來啟動AP,傳送中斷使用的是寫ICR暫存器的方式
// 傳送INIT中斷以重置APlapicw(ICRHI, apicid<<24);             //將目標CPU的ID寫入ICR暫存器的目的地址域中lapicw(ICRLO, INIT | LEVEL | ASSERT);  //在ASSERT的情況下將INIT中斷寫入ICR暫存器microdelay(200);                       //等待200mslapicw(ICRLO, INIT | LEVEL);           //在非ASSERT的情況下將INIT中斷寫入ICR暫存器microdelay(100); // 等待100ms (INTEL官方手冊規定的是10ms,但是由於Bochs執行較慢,此處改為100ms)//INTEL官方規定傳送兩次startup IPI中斷for(i = 0; i < 2; i++){    lapicw(ICRHI, apicid<<24);          //將目標CPU的ID寫入ICR暫存器的目的地址域中    lapicw(ICRLO, STARTUP | (addr>>12));//將SIPI中斷寫入ICR暫存器的傳送模式域中,將啟動程式碼寫入向量域中    microdelay(200);                    //等待200ms}

ICR暫存器說明

中斷命令暫存器(ICR)是一個 64 位本地 APIC暫存器,允許執行在處理器上的軟體指定和傳送處理器間中斷(IPI)給系統中的其它處理器。傳送IPI時,必須設定ICR 以指明將要傳送的 IPI訊息的型別和目的處理器或處理器組。一般情況下,ICR暫存器的實體地址為0xFEE00300

SIPI是一個特殊的IPI。典型情況下,在傳送SIPI時,ICR的向量域中指向一個啟動例程,本例中即將entryother的程式碼地址寫入了ICR的向量域,以啟動AP。
4. 執行boot函式

通過上面的分析我們可以知道是在lapicw(ICRLO, STARTUP | (addr>>12))之後執行了啟動程式碼

在啟動彙編程式碼的最後我們發現了對於mp_main函式的呼叫

  1. mp_main函式中我們初始化了每一個ap的lapicenv以及trap
  2. 然後通知bsp可以進行下一個ap的喚醒了
void
mp_main(void)
{
	// We are in high EIP now, safe to switch to kern_pgdir 
	lcr3(PADDR(kern_pgdir));
	cprintf("SMP: CPU %d starting\n", cpunum());

	lapic_init();
	env_init_percpu();
	trap_init_percpu();
	xchg(&thiscpu->cpu_status, CPU_STARTED); // tell boot_aps() we're up // 這裡的BSP就可以被重新喚醒了

	// Now that we have finished some basic setup, call sched_yield()
	// to start running processes on this CPU.  But make sure that
	// only one CPU can enter the scheduler at a time!
	//
	// Your code here:
	lock_kernel();
	sched_yield();
}

2. 多cpu的切換

我們以CPUS = 4為引數,執行qemu

在我們完成了對一個bsp和3個ap的設定之後。我們在實驗中建立了三個user environment。來測試cpu切換

其中在user environment做了這樣的事情

是非常簡單的程式碼,輸出當前環境之後切換環境。這裡的sys_yied系統呼叫會執行我們上面實現的sched_yield()函式

#include <inc/lib.h>

void
umain(int argc, char **argv)
{
	int i;

	cprintf("Hello, I am environment %08x.\n", thisenv->env_id);
	for (i = 0; i < 5; i++) {
		sys_yield();
		cprintf("Back in environment %08x, iteration %d.\n",
			thisenv->env_id, i);
	}
	cprintf("All done in environment %08x.\n", thisenv->env_id);
}

1. Sched_yield函式

在進入這個函式之前我們先在shced_yield設一個斷點。看一下在run 第一個使用者環境之前的狀態

image-20210711154220679

可以發現我們已經建立了三個env並且啟動了4個cpu。這裡是在envs中找一個來在當前cpu執行

void
sched_yield(void)
{
	size_t start = 0;
	if (curenv) {
		start = ENVX(curenv->env_id) + 1;
	}

	for (size_t i = 0; i < NENV; i++) {
		size_t index = (start + i) % NENV;
		if (envs[index].env_status == ENV_RUNNABLE) {
			env_run(&envs[index]);
		}
	}

	if(curenv && curenv->env_status == ENV_RUNNING) {
		env_run(curenv);
	}
	// sched_halt never returns
	sched_halt();
}

2. 加鎖機制下的切換過程

  1. 在bsp中我們啟動aps的過程中會在執行boot_aps之前把核心鎖住
  2. 這樣當ap想要進入核心的時候就會pause住
  3. 當bsp內執行完第一個使用者環境後就會把它的鎖釋放
  4. 這樣pause在mp_main的ap就會獲得鎖。然後執行sched_yiedld去看一下是否有可以run的env
  5. 而在使用者環境我們執行sys_yied系統呼叫可以主動呼叫sched_yiedld

image-20210711165237480

相關文章