1. 程式人生 > 其它 >【Mit6.s081】 課程筆記

【Mit6.s081】 課程筆記

一、Introduction

作業系統應該提供的特性

硬體抽象

複用

隔離性

共享

許可權控制

高效能, 高硬體利用率

shell 是一個普通的程式, 而不是核心的一部分, 這充分說明系統呼叫介面的強大。 shell 是容易被替代的, 所以現代 Unix 系統有各種各樣的 shell

1.1 系統呼叫

一些常用的 syscall

Q: 這些呼叫是 xv6 的系統呼叫還是 linux 通用的?

這些系統呼叫指的都是 xv6 中的系統呼叫, 當然 linux 當中也有這些呼叫, 細節上會有一些不同

//fork
int fork();
/*
其作用是讓一個程序生成另外一個和這個程序的記憶體內容相同的子程序。在父程序中,
fork的返回值是這個子程序的PID,在子程序中,返回值是0
*/

//exit
int exit(int status);
/*
讓呼叫它的程序停止執行並且將記憶體等佔用的資源全部釋放。需要一個整數形式的狀態引數,
0代表以正常狀態退出,1代表以非正常狀態退出
*/

//wait
int wait(int* status);
/*
等待子程序退出,返回子程序PID,子程序的退出狀態儲存到int *status這個地址中。
如果呼叫者沒有子程序,wait將返回-1
*/

//exec
int exec(char* file, char* argv[]);
/*
載入一個檔案,獲取執行它的引數,執行。如果執行錯誤返回-1,執行成功則不會返回,
而是開始從檔案入口位置開始執行命令。檔案必須是ELF格式。
大部分都忽略掉第一個引數, 一般都是程式的名字
*/

//read
int read(int fd, char* buf, int n);
/*
向 fd 所指向的檔案中讀出 n 個位元組的內容放到 buf 當中, 返回成功讀出的位元組數目
*/


//write
int write(int fd, char* buf, int n);
/*
向 fd 所指向的檔案中寫入 buf 內容中的 n 個位元組, 返回成功寫入的位元組數目
*/


//close
int close(int fd);
/*
將開啟的檔案fd釋放,使該檔案描述符可以被後面的open、pipe等其他system call使用。
*/


//dup
int dup(int fd);
/*
dup()  複製一個已有的檔案描述符,返回一個指向同一個輸入/輸出物件的新描述符。
這兩個描述符共享一個檔案偏移
*/


//pipe
int pipe(int p[]);
/*
p[0]為讀取的檔案描述符,p[1]為寫入的檔案描述符
*/

/

1.2 I/O and File descriptors

  • file descriptor:檔案描述符,用來表示一個被核心管理的、可以被程序讀/寫的物件的一個整數,表現形式類似於位元組流,通過開啟檔案、目錄、裝置等方式獲得。一個檔案被開啟得越早,檔案描述符就越小。

通常來說, 0 是標準輸入,1 是標準輸出,2 是標準錯誤。shell將保證總是有3個檔案描述符是可用的

簡單起見,我們常常把檔案描述符指向的物件稱為“檔案”。檔案描述符的介面是對檔案、管道、裝置等的抽象,這種抽象使得它們看上去就是位元組流。

每一個指向檔案的檔案描述符都和一個偏移關聯

一個新分配的檔案描述符永遠都是當前程序的最小的未被使用的檔案描述符。

雖然 fork 複製了檔案描述符,但每一個檔案當前的偏移仍然是在父子程序之間共享的

1.3 Pipes

  • pipe:管道,暴露給程序的一對檔案描述符,一個檔案描述符用來讀,另一個檔案描述符用來寫,將資料從管道的一端寫入,將使其能夠被從管道的另一端讀出

管道是一個小的核心緩衝區,它以檔案描述符對的形式提供給程序,一個用於寫操作,一個用於讀操作。從管道的一端寫的資料可以從管道的另一端讀取。管道提供了一種程序間互動的方式。

pipe 可能看上去和臨時檔案沒有什麼兩樣:命令

echo hello world | wc

可以用無管道的方式實現:

echo hello world > /tmp/xyz; wc < /tmp/xyz

但管道和臨時檔案起碼有三個關鍵的不同點。首先,管道會進行自我清掃,如果是 shell 重定向的話,我們必須要在任務完成後刪除 /tmp/xyz。第二,管道可以傳輸任意長度的資料。第三,管道允許同步:兩個程序可以使用一對管道來進行二者之間的資訊傳遞,每一個讀操作都阻塞呼叫程序,直到另一個程序用 write 完成資料的傳送。

管道是使用迴圈佇列實現的, 所以管道是單向的

pipe讀寫端關閉的問題【部落格園】

為什麼要 close ? , 防止引用計數不為 0 產生孤兒程序, 詳見上述部落格

pipe會發生阻塞

pipe當寫滿快取區的時候會寫阻塞, 快取區為空的時候會發生讀阻塞

如果所有指向管道寫端的檔案描述符都關閉了(管道寫端的引用計數等於0),
而仍然有程序從管道的讀端讀資料,那麼管道中剩餘的資料都被讀取後,
再次 read 會返回0,就像讀到檔案末尾一樣。

如果有指向管道寫端的檔案描述符沒關閉(管道寫端的引用計數大於0),
而持有管道寫端的程序也沒有向管道中寫資料,這時有程序從管道讀端讀資料,
那麼管道中剩餘的資料都被讀取後,再次 read 會阻塞,
直到管道中有資料可讀了才讀取資料並返回。

1.4 File System

xv6檔案系統包含了檔案(byte arrays)和目錄(對其他檔案和目錄的引用)。目錄生成了一個樹,樹從根目錄/開始。對於不以/開頭的路徑,認為是是相對路徑

一個檔案的名稱和檔案本身是不一樣的, 檔案本身, 也叫 inode , 可以有多個名字(也叫 link ), 每個 link 包含了檔名和對一個 inode 的引用。 inode 儲存了檔案的元資料 , 包括該檔案的型別(file, directory or device)、大小、檔案在硬碟的儲存位置以及指向這個 inode 的 link 的個數

檔案儲存在硬碟上,硬碟的最小儲存單位叫做“扇區”(Sector)。每個扇區儲存512位元組(相當於0.5KB)。

作業系統讀取硬碟的時候,不會一個個扇區的讀取,這樣效率太低,而是一次性連續讀取多個扇區,即一次性讀取一個“塊”(block)。這種由多個扇區組成的“塊”,是檔案存取的最小單位。“塊”的大小,最常見的是4KB,即連續八個sector組成一個block。

檔案資料都儲存在“塊”中,那麼很顯然,我們還必須找到一個地方儲存檔案的“元資訊”,比如檔案的建立者、檔案的建立日期、檔案的大小等等。這種儲存檔案元資訊的區域就叫做 inode ,中文譯名為"索引節點"。

每一個檔案都有對應的inode,裡面包含了與該檔案有關的一些資訊。

可以使用 stat 命令檢視 inode 的資訊

root@VM-16-4-ubuntu:~/mit6.s081/xv6-labs-2020# stat user
  File: user
  Size: 4096      	Blocks: 8          IO Block: 4096   directory
Device: fc02h/64514d	Inode: 797278      Links: 2
Access: (0755/drwxr-xr-x)  Uid: (    0/    root)   Gid: (    0/    root)
Access: 2021-09-16 10:13:37.057875014 +0800
Modify: 2021-09-16 10:09:34.358559061 +0800
Change: 2021-09-16 10:09:34.358559061 +0800
 Birth: -

注意 inode 中沒有儲存檔名

目錄檔案

在 Linux 中, 目錄也是一種檔案, 開啟目錄實際上就是開啟目錄檔案

目錄檔案的結構是一系列的 目錄項(dirent) 的列表

xv-6 中, dirent 的定義如下

struct dirent {
		ushort inum;
    char name[DIRSIZ];
};

硬連結和軟連結

可以用不同的檔名訪問同樣的內容;對檔案內容進行修改,會影響到所有檔名;但是,刪除一個檔名,不影響另一個檔名的訪問。這種情況就被稱為"硬連結"(hard link)。

ln src dst

執行上面這條命令以後,原始檔與目標檔案的inode號碼相同,都指向同一個inode。inode資訊中有一項叫做"連結數",記錄指向該 inode 的檔名總數,這時就會增加1。

反過來,刪除一個檔名,就會使得inode節點中的"連結數"減 1 。當這個值減到0,表明沒有檔名指向這個inode ,系統就會回收這個 inode 號碼,以及其所對應 block 區域。

相當於一種引用計數

軟連結指的是,檔案 A 和檔案 B 的inode號碼雖然不一樣,但是檔案A的內容是檔案B的路徑。讀取檔案 A 時,系統會自動將訪問者導向檔案 B 。因此,無論開啟哪一個檔案,最終讀取的都是檔案 B 。這時,檔案 A 就稱為檔案 B 的"軟連結"(soft link)或者"符號連結(symbolic link)。

這意味著,檔案 A 依賴於檔案 B 而存在,如果刪除了檔案 B ,開啟檔案A就會報錯:“No such file or directory”。這是軟連結與硬連結最大的不同:檔案 A 指向檔案 B 的檔名,而不是檔案 B 的 inode 號碼,檔案 B 的 inode "連結數"不會因此發生變化。

例如, 在 macos 中, gcc 使用的名字是 gcc-xxx, 可以建立 link 來使用別名

zhl•~» which g++-11																			[11:26:50]
/opt/homebrew/bin/g++-11
zhl•~» cd /opt/homebrew/bin               		 			 		[11:27:04]
zhl•/opt/homebrew/bin(stable)» ln g++-11 uu             [11:27:08]
zhl•/opt/homebrew/bin(stable)» uu -v                    [11:27:12]
Using built-in specs.
COLLECT_GCC=uu
...
Thread model: posix
Supported LTO compression algorithms: zlib
gcc version 11.1.0 (Homebrew GCC 11.1.0)
zhl•/opt/homebrew/bin(stable)»

當然也可以在 bash 的配置檔案中新增 alias

三、 PageTables

頁表提供了虛擬記憶體的機制, 虛擬記憶體是一種重要的抽象, 虛擬記憶體把 I/O,磁碟, 記憶體進行統一的抽象

對每個程序來說,主存好像都在為自己服務

3.1 地址空間

通過設定頁表, 可以獲得強隔離。 如果我們不做任何工作,預設情況下,我們是沒有記憶體隔離性的

如果假設 shell 的記憶體地址位於 1000 - 2000 內,如果其他程式出錯誤,篡改了 1000 地址的資料, 則 shell 的存映象遭到了破壞。

所以現在我們的問題是如何在一個實體記憶體上,建立不同的地址空間,因為歸根到底,我們使用的還是一堆存放了記憶體資訊的DRAM晶片。

3.2 頁表

那麼如何實現地址空間呢?

最常見也是最靈活的方法就是使用頁表(page table), 頁表是在硬體中通過處理器和記憶體管理單元(MMU) 實現的

PageTable 提供由虛擬地址向實體地址對映的機制

MMU 不會儲存頁表, 它只會從記憶體中讀取page table,然後完成翻譯

PTE flag可以告訴硬體這些相應的虛擬地址怎樣被使用,比如PTE_V表明這個PTE是否存在,PTE_RPTE_WPTE_X控制這個頁是否允許被讀取、寫入和執行,PTE_U控制user mode是否有權訪問這個頁,如果PTE_U=0,則只有supervisor mode有權訪問這個頁。

採用三級頁表的形式, 每一個 pagetable 都是一個頁大小, PPN(Physical Page Number) 儲存著下一級 pagetable 的地址, L2, L1, L0 儲存著頁表內的偏移

SATP 暫存器指向第一級頁表, 更換 SATP 暫存器, 相當於更換了頁表。 每個程序都有自己的 pagetable , 切換程序的時候將頁表地址載入到 SATP 暫存器, 重新整理 TLB 快取

3.2 Kernel Page Table

地址0x80000000對應DDR記憶體

地址0x1000是boot ROM的實體地址,當你對主機板上電,主機板做的第一件事情就是執行儲存在boot ROM中的程式碼,當boot完成之後,會跳轉到地址 0x80000000 ,作業系統需要確保那個地址有一些資料能夠接著啟動作業系統。

KERNBASE 的地址是 0x80000000, 更低的位置屬於 I/O 裝置

因為我們想讓 XV6 儘可能的簡單易懂,所以這裡的虛擬地址到實體地址的對映,大部分是相等的關係。比如說核心會按照這種方式設定 page table ,虛擬地址 0x02000000 對應實體地址 0x02000000 。這意味著左側低於 PHYSTOP 的虛擬地址,與右側使用的實體地址是一樣的。

同時,kernel stack 被映射了兩次,在靠後的虛擬地址映射了一次,在 PHYSTOP 下的 Kernel data 中又映射了一次,但是實際使用的時候用的是上面的部分,因為有 Guard page 會更加安全。

3.3 User Space Memory

3.4 XV6 中的一些程式碼

kvminit 函式

kvm 應該是 kernel virtual memory 的意思

kvminit 程式碼如下

之後,通過kvmmap函式,將每一個I/O裝置對映到核心。例如,下圖中高亮的行將UART0對映到核心的地址空間。

可以檢視 memlayout.h 檔案, 檢視記憶體佈局

kvminithart 函式

這個函式首先設定了SATP暫存器,kernel_pagetable變數來自於kvminit第一行。所以這裡實際上是核心告訴MMU來使用剛剛設定好的 page table。當這裡這條指令執行之後,下一個指令的地址會發生什麼?

在這條指令之前,還不存在可用的page table,所以也就不存在地址翻譯。執行完這條指令之後,程式計數器(Program Counter)增加了4。而之後的下一條指令被執行時,程式計數器會被記憶體中的page table翻譯。

所以這條指令的執行時刻是一個非常重要的時刻。因為整個地址翻譯從這條指令之後開始生效,之後的每一個使用的記憶體地址都可能對應到與之不同的實體記憶體地址。因為在這條指令之前,我們使用的都是實體記憶體地址,這條指令之後page table開始生效,所有的記憶體地址都變成了另一個含義,也就是虛擬記憶體地址。

這裡能正常工作的原因是值得注意的。因為前一條指令還是在實體記憶體中,而後一條指令已經在虛擬記憶體中了。比如,下一條指令地址是0x80001110就是一個虛擬記憶體地址。

為什麼這裡能正常工作呢?因為kernel page的對映關係中,虛擬地址到實體地址是完全相等的。所以,在我們開啟虛擬地址翻譯硬體之後,地址翻譯硬體會將一個虛擬地址翻譯到相同的實體地址。所以實際上,我們最終還是能通過記憶體地址執行到正確的指令,因為經過地址翻譯0x80001110還是對應0x80001110。

所以一旦執行了 SATP 這樣的命令, 你的世界就會完全顛覆, 如果 page table 設定錯誤, 則也許會發生各種我們不期望看到的情況, 例如覆蓋 kernel data

walk 函式

// Return the address of the PTE in page table pagetable
// that corresponds to virtual address va.  If alloc!=0,
// create any required page-table pages.
//
// The risc-v Sv39 scheme has three levels of page-table
// pages. A page-table page contains 512 64-bit PTEs.
// A 64-bit virtual address is split into five fields:
//   39..63 -- must be zero.
//   30..38 -- 9 bits of level-2 index.
//   21..29 -- 9 bits of level-1 index.
//   12..20 -- 9 bits of level-0 index.
//    0..11 -- 12 bits of byte offset within the page.
pte_t *
walk(pagetable_t pagetable, uint64 va, int alloc)
{
  if(va >= MAXVA)
    panic("walk");

  for(int level = 2; level > 0; level--) {
    pte_t *pte = &pagetable[PX(level, va)];
    if(*pte & PTE_V) {
      pagetable = (pagetable_t)PTE2PA(*pte);
    } else {
      if(!alloc || (pagetable = (pde_t*)kalloc()) == 0)
        return 0;
      memset(pagetable, 0, PGSIZE);
      *pte = PA2PTE(pagetable) | PTE_V;
    }
  }
  return &pagetable[PX(0, va)];
}

四、 RISC-V calling convention

4.1 Assembly

ISA: Instruction Set

C -> Assembly(.S/.asm) -> binary (object.o)

組合語言沒有明確的workflow,只是一行行指令

組合語言是基於暫存器進行操作的,而不是基於記憶體操作

RISC-V vs x86:

  • RISC-V:精簡指令集,指令更少,更加簡單,唯一開源的ISA。ARM也是RISC(Reduced Instruction Set Chip)
  • x86:複雜指令集,指令很多並且可以實現複雜功能

一些 RISC-V 彙編指令

  • ld/lb/lw rd, 8(rs):將*(rs+8)的值寫入到rd暫存器,lb=load byte, ld=load double word, lw=load word
  • sd/sb/sw rd, 8(rs):將*(rd)`的值寫入到rs+8地址上
  • add rd, rs1, rs2:*(rd) = *(rs1) + *(rs2)
  • addi rd, rs1, int: *(rd) = *(rs1) + int

4.2 Registers

RISC-V暫存器通過暫存器而非棧來傳遞函式引數,a0-a7是int引數,fa0-fa7是float引數

Caller 暫存器在進行函式呼叫後有可能會被改掉

4.3 Stack

fp 是當前的棧頂指標, sp 是棧底, 棧地址是由上往下是由大到小的

棧從高地址向低地址增長,每個大的box叫一個stack frame(棧幀),棧幀由函式呼叫來分配,每個棧幀大小不一定一樣,但是棧幀的最高處一定是return address

sp是stack pointer,用於指向棧頂(低地址),儲存在暫存器中

fp是frame pointer,用於指向當前幀底部(高地址),儲存在暫存器中,同時每個函式棧幀中儲存了呼叫當前函式的函式(父函式)的fp(儲存在to prev frame那一欄中)

這些棧幀都是由編譯器編譯生成的彙編檔案生成的

每一次我們呼叫一個函式,函式都會為自己建立一個Stack Frame,並且只給自己用。函式通過移動Stack Pointer來完成Stack Frame的空間分配。

addi sp, sp, -16
sd ra, 0(sp)

這兩行做的其實就是開闢新的 stack frame 並且備份返回地址 ra

在最後的時候

ld ra, 0(sp)
addi sp, sp, 16

進行復原

TA提問: 如果我們刪除掉Prologue和Epllogue,然後只剩下函式主體會發生什麼?有人可以猜一下嗎?

學生回答:sum_then_double 將不知道它應該返回的 Return address。所以呼叫 sum_to 的時候,Return address 被覆蓋了,最終 sum_to 函式不能返回到它原本的呼叫位置。

TA: 是的,完全正確。

七、Interrupts

中斷(interrupt) 和 syscall, page fault 的處理機制都比較類似, 但是有一些細小的差別

  • 非同步(asynchronous) ,中斷處理程式可能與 CPU 上執行的程序無關
  • 併發(concurrency)
  • 裝置需要被程式設計(program device)

本次課程討論了 console 中的 $ 符號是如何出現的, 以及如果我們在鍵盤中輸入 ls ,這些字元是如何在 console 中顯示出來的

PLIC : Platform-Level Interrupt Controller, 管理外部中斷

CLINT : Core-Local Interrupter, 負責定時器相關的中斷

7.1 裝置驅動概述

通常來說,管理裝置的程式碼稱為驅動,所有的驅動都在核心中。我們今天要看的是UART裝置的驅動,程式碼在uart.c檔案中。如果我們檢視程式碼的結構,我們可以發現大部分驅動都分為兩個部分,bottom/top。

bottom部分通常是Interrupt handler。當一箇中斷送到了CPU,並且CPU設定接收這個中斷,CPU會呼叫相應的Interrupt handler。Interrupt handler並不執行在任何特定程序的context中,它只是處理中斷。

top部分,是使用者程序,或者核心的其他部分呼叫的介面。對於UART來說,這裡有read/write介面,這些介面可以被更高層級的程式碼呼叫。

通常情況下,驅動中會有一些佇列(或者說buffer),top部分的程式碼會從佇列中讀寫資料,而Interrupt handler(bottom部分)同時也會向佇列中讀寫資料。這裡的佇列可以將並行執行的裝置和CPU解耦開來。

7.2 XV6 中斷硬體

XV6 中有一些與中斷相關的暫存器, 如下

  • SIE(Supervisor Interrupt Enable)暫存器, 通過一些 bit 來控制外部中斷(E), 軟體中斷(S), 定時器中斷(T)的開關
  • SSTATUS(Supervisor Status)暫存器, 有一個 bit 用來開啟或者關閉中斷, 每一個 CPU 都有獨立的 SIE 和 SSTATUS 暫存器,除了通過SIE暫存器來單獨控制特定的中斷,還可以通過SSTATUS暫存器中的一個bit來控制所有的中斷。
  • SIP(Supervisor Interrupt Pending)暫存器, 當發生中斷時,處理器可以通過檢視這個暫存器知道當前是什麼型別的中斷。
  • SCAUSE暫存器, 表明中斷的原因
  • STVEC暫存器, 它會儲存當trap,page fault或者中斷髮生時,CPU執行的使用者程式的程式計數器,這樣才能在稍後恢復程式的執行。

console driver(kernel/console.c) 是一個裝置驅動,通過UART串列埠接受輸入的符號。使用者程序通過readsystem call來從console中一行行讀取輸入

xv6中使用的UART是QEMU模擬的16550晶片。UART硬體對於程序來說是一組memory-mapped暫存器,即RISC-V上有一些實體地址是直接和UART裝置相連的。UART的地址從0x10000000或UART0開始,每個UART控制暫存器的大小為1位元組,其位置定義在kernel/uart.c中。

以下為一些 UART 中的控制暫存器

  • LSR (line status register) 暫存器, 用來指示輸入的位元組是否準備好被使用者程序讀取
  • RHR (receive holding register) 暫存器, 用來放置可以被使用者程序讀取的位元組。當 RHR 中的一個位元組被讀取時,UART 硬體將其從內部的 FIFO 硬碟中刪除,當 FIFO 中為空時,LSR 暫存器被置 0
  • THR(transmit holding register) 暫存器, 當用戶程序向 THR 寫入一個位元組時,UART將傳輸這個位元組

xv6的 main 函式將呼叫 consoleinit 來初始化 UART 硬體,使得 UART 硬體在接收到位元組或傳輸完成一個位元組時發出中斷

7.3 XV6 中對中斷的設定

start.c 中開始, entry.S 會進入 start 函式, 這裡有個 M mode

//start.c
// entry.S jumps here in machine mode on stack0.
void
start()
{
  // set M Previous Privilege mode to Supervisor, for mret.
  unsigned long x = r_mstatus();
  x &= ~MSTATUS_MPP_MASK;
  x |= MSTATUS_MPP_S;
  w_mstatus(x);

  // set M Exception Program Counter to main, for mret.
  // requires gcc -mcmodel=medany
  w_mepc((uint64)main);

  // disable paging for now.
  // 關閉頁表功能, 直接使用實體地址
  w_satp(0);

  //這裡將所有的中斷都設定在Supervisor mode,然後設定SIE暫存器來接收External,軟體和定時器中斷,之後初始化定時器。
  w_medeleg(0xffff);
  w_mideleg(0xffff);
  w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE);

  // ask for clock interrupts.
  timerinit();

  // keep each CPU's hartid in its tp register, for cpuid().
  int id = r_mhartid();
  w_tp(id);

  // switch to supervisor mode and jump to main().
  asm volatile("mret");
}

接著我們看 main 函式中是如何處理External中斷。

//main.c
// start() jumps here in supervisor mode on all CPUs.
void
main()
{
  if(cpuid() == 0){
    consoleinit();
    printfinit();
    printf("\n");
    printf("xv6 kernel is booting\n");
    printf("\n");
    kinit();         // physical page allocator
    kvminit();       // create kernel page table
    kvminithart();   // turn on paging
    procinit();      // process table
    trapinit();      // trap vectors
    trapinithart();  // install kernel trap vector
    plicinit();      // set up interrupt controller
    plicinithart();  // ask PLIC for device interrupts
    binit();         // buffer cache
    iinit();         // inode cache
    fileinit();      // file table
    virtio_disk_init(); // emulated hard disk
    userinit();      // first user process
    __sync_synchronize();
    started = 1;
  } else {
    while(started == 0)
      ;
    __sync_synchronize();
    printf("hart %d starting\n", cpuid());
    kvminithart();    // turn on paging
    trapinithart();   // install kernel trap vector
    plicinithart();   // ask PLIC for device interrupts
  }

  scheduler();        
}

這裡第一個外設是 console , 這是我們 print 輸出的位置, 檢視 console.c 中的 consoleinit 函式

//console.c
void
consoleinit(void)
{
  initlock(&cons.lock, "cons");

  uartinit();

  // connect read and write system calls
  // to consoleread and consolewrite.
  devsw[CONSOLE].read = consoleread;
  devsw[CONSOLE].write = consolewrite;
}

呼叫了uartinit,uartinit函式位於uart.c檔案。這個函式實際上就是配置好UART晶片使其可以被使用。

//uart.c
void
uartinit(void)
{
  // disable interrupts.
  WriteReg(IER, 0x00);

  // special mode to set baud rate.
  WriteReg(LCR, LCR_BAUD_LATCH);

  // LSB for baud rate of 38.4K.
  WriteReg(0, 0x03);

  // MSB for baud rate of 38.4K.
  WriteReg(1, 0x00);

  // leave set-baud mode,
  // and set word length to 8 bits, no parity.
  WriteReg(LCR, LCR_EIGHT_BITS);

  // reset and enable FIFOs.
  WriteReg(FCR, FCR_FIFO_ENABLE | FCR_FIFO_CLEAR);

  // enable transmit and receive interrupts.
  WriteReg(IER, IER_TX_ENABLE | IER_RX_ENABLE);

  initlock(&uart_tx_lock, "uart");
}

這裡的流程是先關閉中斷,之後設定波特率,設定字元長度為8bit,重置FIFO,最後再重新開啟中斷。

以上就是uartinit函式,執行完這個函式之後,原則上UART就可以生成中斷了。但是因為我們還沒有對PLIC程式設計,所以中斷不能被CPU感知

//plic.c
void
plicinit(void)
{
  // set desired IRQ priorities non-zero (otherwise disabled).
  *(uint32*)(PLIC + UART0_IRQ*4) = 1;
  *(uint32*)(PLIC + VIRTIO0_IRQ*4) = 1;
}

PLIC 與外設一樣,也佔用了一個 I/O 地址(0xC000_0000)。程式碼的第一行使能了 UART 的中斷,這裡實際上就是設定 PLIC 會接收哪些中斷,進而將中斷路由到 CPU。類似的,程式碼的第二行設定 PLIC 接收來自 IO 磁碟的中斷,我們這節課不會介紹這部分內容。

plicinit是由 0 號 CPU 執行,之後,每個 CPU 的核都需要呼叫 plicinithart 函式表明對於哪些外設中斷感興趣。

//plic.c
void
plicinithart(void)
{
  int hart = cpuid();
  
  // set uart's enable bit for this hart's S-mode. 
  *(uint32*)PLIC_SENABLE(hart)= (1 << UART0_IRQ) | (1 << VIRTIO0_IRQ);

  // set this hart's S-mode priority threshold to 0.
  *(uint32*)PLIC_SPRIORITY(hart) = 0;
}

所以在 plicinithart 函式中,每個 CPU 的核都表明自己對來自於 UART 和 VIRTIO 的中斷感興趣。因為我們忽略中斷的優先順序,所以我們將優先順序設定為 0。

到目前為止,我們有了生成中斷的外部裝置,我們有了PLIC可以傳遞中斷到單個的CPU。但是CPU自己還沒有設定好接收中斷,因為我們還沒有設定好SSTATUS暫存器。在main函式的最後,程式呼叫了scheduler函式,

//proc.c
void
scheduler(void)
{
  struct proc *p;
  struct cpu *c = mycpu();
  
  c->proc = 0;
  for(;;){
    // Avoid deadlock by ensuring that devices can interrupt.
    intr_on();

scheduler函式主要是執行程序。但是在實際執行程序之前,會執行intr_on函式來使得CPU能接收中斷。

// enable device interrupts
static inline void
intr_on()
{
  w_sstatus(r_sstatus() | SSTATUS_SIE);
}

intr_on函式只完成一件事情,就是設定SSTATUS暫存器,開啟中斷標誌位。

在這個時間點,中斷被完全打開了。如果PLIC正好有pending的中斷,那麼這個CPU核會收到中斷。

7.4 UART 驅動 top 部分

Init.c 是系統啟動後執行的第一個使用者級別程序

//user/init.c
int
main(void)
{
  int pid, wpid;

  if(open("console", O_RDWR) < 0){
    mknod("console", CONSOLE, 0);
    open("console", O_RDWR);
  }
  dup(0);  // stdout
  dup(0);  // stderr

  for(;;){
    printf("init: starting sh\n");
    pid = fork();

首先這個程序的 main 函式建立了一個代表 Console 的裝置。這裡通過 mknod 操作建立了 console 裝置。因為這是第一個開啟的檔案,所以這裡的檔案描述符 0 。之後通過 dup 建立 stdout 和 stderr 。這裡實際上通過複製檔案描述符 0 ,得到了另外兩個檔案描述符 1 , 2 。最終檔案描述符 0 ,1 ,2 都用來代表 Console 。

//sh.c
int
getcmd(char *buf, int nbuf)
{
  fprintf(2, "$ ");
  memset(buf, 0, nbuf);
  gets(buf, nbuf);
  if(buf[0] == 0) // EOF
    return -1;
  return 0;
}

儘管 Console 背後是 UART 裝置,但是從應用程式來看,它就像是一個普通的檔案。Shell 程式只是向檔案描述符2 寫了資料,它並不知道檔案描述符 2 對應的是什麼。在 Unix 系統中,裝置是由檔案表示。我們來看一下這裡的fprintf 是如何工作的。

在printf.c檔案中,程式碼只是呼叫了write系統呼叫,在我們的例子中,fd對應的就是檔案描述符2,c是字元“$”。

static void
putc(int fd, char c)
{
  write(fd, &c, 1);
}

檢視 sysfile.c 中的 sys_write 函式

//sysfile.c
uint64
sys_write(void)
{
  struct file *f;
  int n;
  uint64 p;

  if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argaddr(1, &p) < 0)
    return -1;

  return filewrite(f, p, n);
}

這個函式中首先對引數做了檢查,然後又呼叫了 filewrite 函式。filewrite 函式位於 file.c 檔案中。

//file.c
// Write to file f.
// addr is a user virtual address.
int
filewrite(struct file *f, uint64 addr, int n)
{
  int r, ret = 0;

  if(f->writable == 0)
    return -1;

  if(f->type == FD_PIPE){
    ret = pipewrite(f->pipe, addr, n);
  } else if(f->type == FD_DEVICE){
    if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
      return -1;
    ret = devsw[f->major].write(1, addr, n);
  } else if(f->type == FD_INODE){
    // write a few blocks at a time to avoid exceeding
    // the maximum log transaction size, including
    // i-node, indirect block, allocation blocks,
    // and 2 blocks of slop for non-aligned writes.
    // this really belongs lower down, since writei()
    // might be writing a device like the console.
......

在 filewrite 函式中首先會判斷檔案描述符的型別。mknod 生成的檔案描述符屬於裝置(FD_DEVICE),而對於裝置型別的檔案描述符,我們會為這個特定的裝置執行裝置相應的 write 函式。因為我們現在的裝置是 Console,所以我們知道這裡會呼叫 console.c 中的 consolewrite 函式。

// console.c
//
// user write()s to the console go here.
//
int
consolewrite(int user_src, uint64 src, int n)
{
  int i;

  acquire(&cons.lock);
  for(i = 0; i < n; i++){
    char c;
    if(either_copyin(&c, user_src, src+i, 1) == -1)
      break;
    uartputc(c);
  }
  release(&cons.lock);

  return i;
}

這裡先通過 either_copyin 將字元拷入,之後呼叫 uartputc 函式。uartputc 函式將字元寫入給 UART 裝置,所以你可以認為 consolewrite 是一個 UART 驅動的 top 部分。uart.c 檔案中的 uartputc 函式會實際的列印字元。

//uart.c
void
uartputc(int c)
{
  acquire(&uart_tx_lock);

  if(panicked){
    for(;;)
      ;
  }

  while(1){
    if(((uart_tx_w + 1) % UART_TX_BUF_SIZE) == uart_tx_r){
      // buffer is full.
      // wait for uartstart() to open up space in the buffer.
      sleep(&uart_tx_r, &uart_tx_lock);
    } else {
      uart_tx_buf[uart_tx_w] = c;
      uart_tx_w = (uart_tx_w + 1) % UART_TX_BUF_SIZE;
      uartstart();
      release(&uart_tx_lock);
      return;
    }
  }
}

uartputc函式會稍微有趣一些。在 UART 的內部會有一個 buffer 用來發送資料,buffer 的大小是 32 個字元。同時還有一個為 consumer 提供的讀指標和為 producer 提供的寫指標,來構建一個環形的 buffer(注,或者可以認為是環形佇列)。

當 buffer 是滿的時候,向其寫入資料是沒有意義的,所以這裡會 sleep 一段時間,將 CPU 出讓給其他程序。當然,對於我們來說,buffer 必然不是滿的,因為提示符 “$” 是我們送出的第一個字元。所以程式碼會走到 else ,字元會被送到 buffer 中,更新寫指標,之後再呼叫 uartstart 函式。

// if the UART is idle, and a character is waiting
// in the transmit buffer, send it.
// caller must hold uart_tx_lock.
// called from both the top- and bottom-half.
void
uartstart()
{
  while(1){
    if(uart_tx_w == uart_tx_r){
      // transmit buffer is empty.
      return;
    }
    
    if((ReadReg(LSR) & LSR_TX_IDLE) == 0){
      // the UART transmit holding register is full,
      // so we cannot give it another byte.
      // it will interrupt when it's ready for a new byte.
      return;
    }
    
    int c = uart_tx_buf[uart_tx_r];
    uart_tx_r = (uart_tx_r + 1) % UART_TX_BUF_SIZE;
    
    // maybe uartputc() is waiting for space in the buffer.
    wakeup(&uart_tx_r);
    
    WriteReg(THR, c);
  }
}

7.5 UART 驅動的 buttom 部分

buttom 部分也就是 interrupt handler 部分。

當發生中斷的時候, 會送去 PLIC , PLIC 將中斷路由給一個特定的 CPU core, 如果這個 CPU 設定了 SIE 暫存器的 E bit(外部中斷),那麼會發生如下事情

  • 清除 SIE 暫存器相應的 bit, 防止被其他中斷打擾, 處理完成後, 再恢復相應的 bit
  • 設定 SEPC 暫存器為當前的 PC
  • 儲存當前的 mode
  • 將 mode 設定為 Supervisor mode
  • 將程式計數器的值設定為 STVEC 中的值 (STVEC 儲存的值要麼是 uservec 或者 kernelvec 函式的地址,取決於中斷髮生時是在使用者空間還是在核心空間)

檢視 usertrap 函式,

//trap.c
    syscall();
  } else if((which_dev = devintr()) != 0){
    // ok
  } else if((r_scause() == 13 || r_scause() == 15)){
    //page fault

在 trap.c 的 devintr 函式中,首先會通過 SCAUSE 暫存器判斷當前中斷是否是來自於外設的中斷。如果是的話,再呼叫 plic_claim 函式來獲取中斷。

//trap.c
// check if it's an external interrupt or software interrupt,
// and handle it.
// returns 2 if timer interrupt,
// 1 if other device,
// 0 if not recognized.
int
devintr()
{
  uint64 scause = r_scause();

  if((scause & 0x8000000000000000L) &&
     (scause & 0xff) == 9){
    // this is a supervisor external interrupt, via PLIC.

    // irq indicates which device interrupted.
    int irq = plic_claim();

    if(irq == UART0_IRQ){
      uartintr();
    } else if(irq == VIRTIO0_IRQ){
      virtio_disk_intr();
    } else if(irq){
      printf("unexpected interrupt irq=%d\n", irq);
    }
    ...
}

在這個函式中,當前 CPU 核會告知 PLIC ,自己要處理中斷,PLIC_SCLAIM 會將中斷號返回,對於 UART 來說,返回的中斷號是10。

//plic.c
// ask the PLIC what interrupt we should serve.
int
plic_claim(void)
{
  int hart = cpuid();
  int irq = *(uint32*)PLIC_SCLAIM(hart);
  return irq;
}

可以看出, 如果是 UART 中斷, 就會呼叫 uartintr 函式

int
uartgetc(void)
{
  if(ReadReg(LSR) & 0x01){
    // input data is ready.
    return ReadReg(RHR);
  } else {
    return -1;
  }
}

// handle a uart interrupt, raised because input has
// arrived, or the uart is ready for more output, or
// both. called from trap.c.
void
uartintr(void)
{
  // read and process incoming characters.
  while(1){
    int c = uartgetc();
    if(c == -1)
      break;
    consoleintr(c);
  }

  // send buffered characters.
  acquire(&uart_tx_lock);
  uartstart();
  release(&uart_tx_lock);
}

八、 多程序和鎖

這節課主要講述了鎖相關的知識和自旋鎖(spin lock)的實現

8.1 為什麼要使用鎖

首先引入了一張圖片, 從圖片中我們可以看出,單核效能近幾年緩步不前,所以核心的數量在近幾年不斷的增加以提高效能。

而多核帶來效能提升的一方面,也帶來了對共享資料的條件競爭(race condition),由於條件競爭的存在, 可能會使得執行的結果出現錯誤, 鎖便是一種用來解決該問題的方案。

race condition 會有不同的表現形式, 並且它有可能發生也有可能不會發生。

8.2 鎖是如何避免 race condition

以 kree 函式為例, 假設 freelist 被兩個 CPU 共享, 如下圖所示。 可能會發生頁面的丟失。

// Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc().  (The exception is when
// initializing the allocator; see kinit above.)
void
kfree(void *pa)
{
  struct run *r;

  if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
    panic("kfree");

  // Fill with junk to catch dangling refs.
  memset(pa, 1, PGSIZE);

  r = (struct run*)pa;

  acquire(&kmem.lock);
  r->next = kmem.freelist;
  kmem.freelist = r;
  release(&kmem.lock);
}

acquirerelease 中間的程式碼片稱為 臨界區(critical section)

之所以被稱為 critical section ,是因為通常會在這裡以原子的方式執行共享資料的更新。所以基本上來說,對於臨界區的程式碼,它們要麼會一起執行,要麼一條也不會執行。

所以永遠也不可能看到臨界區的程式碼,如同在 race condition 中一樣在多個 CPU 上交織的執行,所以這樣就能避免 race condition。

我們並非一定要使用鎖, 鎖的使用完全是由程式設計者決定的, 程式碼不會自動加鎖, 程式設計師需要自己決定加鎖的位置

8.3 什麼時候使用鎖

鎖限制了併發,也限制了效能, 由此引出一個問題, 我們應該什麼時候使用鎖

這裡給出一個十分保守的規則:

  • 如果兩個程序訪問了一個共享的資料結構,並且其中一個程序會更新共享的資料結構,那麼就需要對於這個共享的資料結構加鎖。

某種程度來說, 這個規則過於嚴格, 如果有兩個程序共享一個數據結構,並且其中一個程序會更新這個資料結構,在某些場合不加鎖也可以正常工作。不加鎖的程式通常稱為 lock free program, 這樣可以獲得更好的效能和併發性, 但是會有一定的程式設計難度。

矛盾的是,有時候這個規則太過嚴格,而有時候這個規則又太過寬鬆了。除了共享的資料,在一些其他場合也需要鎖,例如對於 printf ,如果我們將一個字串傳遞給它, XV6 會嘗試原子性的將整個字串輸出,而不是與其他程序的 printf 交織輸出。儘管這裡沒有共享的資料結構,但在這裡鎖仍然很有用處,因為我們想要 printf 的輸出也是序列化的。

對於系統呼叫 rename("d1/x", "d2/y") ,假設流程如下

lock d1 ; erase x ; release d1;
lock d2 ; add y ; 	release d2;

這樣做會有問題, 當執行完第一行後, 以其他程序的視角來看, 檔案 x 便完全消失了, 這是一個錯誤的結果,因為檔案只是被重新命名了而已, 在任何一個時間點都應該是存在的。

所以正確的做法是, 先獲取 d1, d2 的鎖, 再進行操作, 最後釋放

lock d1, d2; erase x, add y ;release d1, d2

8.4 鎖的特性和死鎖

鎖通常有三種特性, 理解它們可以幫助你更好的理解鎖。

  • 鎖可以避免丟失更新。如果你回想我們之前在 kalloc.c 中的例子,丟失更新是指我們丟失了對於某個記憶體 page 在 kfree 函式中的更新。如果沒有鎖,在出現 race condition 的時候,記憶體 page 不會被加到 freelist 中。但是加上鎖之後,我們就不會丟失這裡的更新。

  • 鎖可以打包多個操作,使它們具有原子性。我們之前介紹了加鎖解鎖之間的區域是 critical section ,在 critical section 的所有操作會都會作為一個原子操作執行。

  • 鎖可以維護共享資料結構的不變性。 共享資料結構如果不被任何程序修改的話是會保持不變的。如果某個程序 acquire 了鎖並且做了一些更新操作,共享資料的不變性暫時會被破壞,但是在 release 鎖之後,資料的不變性又恢復了。你們可以回想一下之前在 kfree 函式中的 freelist 資料,所有的 free page 都在一個單鏈表上。但是在 kfree 函式中,這個單鏈表的 head 節點會更新。 freelist並不太複雜,對於一些更復雜的資料結構可能會更好的幫助你理解鎖的作用。

當我們不恰當的使用鎖的時候, 可能會帶來一些問題, 最典型的例子就是死鎖 (deadlock) 。

給出一個最簡單的場景, 對一個鎖進行 acquire 之後再次對同一個鎖 acquire , 此時就會產生死鎖。

在 XV6 中, 系統會偵測到這樣的死鎖, XV6 看到一個程序對同一個鎖多次 acquire 操作後會產生一個 panic

下面給出了一個稍微複雜一些的例子

CPU0:  rename("d1/x", "d2/y") 
CPU1:  rename("d2/a", "d1/b")

CPU執行的操作序列如下

CPU0: lock d1, lock d2
CPU1: lcok d2, lock d1

很顯然,當 CPU0 獲取 d1 的鎖並且 CPU1 獲取 d2 的鎖之後, 發生了死鎖。

這裡的解決方案是,如果你有多個鎖,你需要對鎖進行排序,所有的操作都必須以相同的順序獲取鎖。

不過在設計一個作業系統的時候,定義一個全域性的鎖的順序會有些問題。如果一個模組 m1 中方法 g 呼叫了另一個模組 m2 中的方法 f,那麼 m1 中的方法 g 需要知道 m2 的方法 f 使用了哪些鎖。因為如果 m2 使用了一些鎖,那麼 m1 的方法 g 必須集合 f 和 g 中的鎖,並形成一個全域性的鎖的排序。這意味著在 m2 中的鎖必須對 m1 可見,這樣 m1 才能以恰當的方法呼叫 m2。

但是這樣又違背了程式碼抽象的原則。在完美的情況下,程式碼抽象要求 m1 完全不知道 m2 是如何實現的。所以當你設計一些更大的系統時,鎖使得程式碼的模組化更加的複雜了。

通常來說,開發的流程是:

  • 先以coarse-grained lock(注,也就是大鎖)開始。
  • 再對程式進行測試,來看一下程式是否能使用多核。
  • 如果可以的話,那麼工作就結束了,你對於鎖的設計足夠好了;如果不可以的話,那意味著鎖存在競爭,多個程序會嘗試獲取同一個鎖,因此它們將會序列化的執行,效能也上不去,之後你就需要重構程式。

在這個流程中,測試的過程比較重要。有可能模組使用了coarse-grained lock,但是它並沒有經常被並行的呼叫,那麼其實就沒有必要重構程式,因為重構程式設計到大量的工作,並且也會使得程式碼變得複雜。所以如果不是必要的話,還是不要進行重構。

8.5 自旋鎖的實現

假定我們使用這種實現

void acquire(struct lock& l){
    while(1){
        if(l->locked == 0){
            l->locked = 1;
            return;
        }
    }
}

會出現什麼問題呢, 兩個程序可能同時讀到鎖的 locked 欄位為 0。

所以這其中會有 race condition, 這樣違背了鎖的特性

為了解決這裡的問題並且得到一個正確的鎖的實現方式, 最常見的方式是依賴於特殊的硬體指令, 保證執行一次原子的 test-and-set 操作, 在 XV6 中, 這條指令就是 amoswap (atomic memory swap)

該指令接收三個引數, 分別是 address , 暫存器 r1, 暫存器 r2, 做的事情如下

amoswap addr, r1, r2:
  lock addr
    tmp <- *addr
    *addr <- r1
    r2 <- tmp
  unlock 

簡單來說就是對 addr 設定一個值 r1, 並且把 addr 原來對值放入 r2 中

我們對 XV6 中的程式碼進行探究

// Mutual exclusion lock.
struct spinlock {
  uint locked;       // Is the lock held?

  // For debugging:
  char *name;        // Name of lock.
  struct cpu *cpu;   // The cpu holding the lock.
};

// Acquire the lock.
// Loops (spins) until the lock is acquired.
void
acquire(struct spinlock *lk)
{
  push_off(); // disable interrupts to avoid deadlock.
  if(holding(lk))
    panic("acquire");

  // On RISC-V, sync_lock_test_and_set turns into an atomic swap:
  //   a5 = 1
  //   s1 = &lk->locked
  //   amoswap.w.aq a5, a5, (s1)
  while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
    ;

  // Tell the C compiler and the processor to not move loads or stores
  // past this point, to ensure that the critical section's memory
  // references happen strictly after the lock is acquired.
  // On RISC-V, this emits a fence instruction.
  __sync_synchronize();

  // Record info about lock acquisition for holding() and debugging.
  lk->cpu = mycpu();
}

acquire 函式中的迴圈就是 set-and-test 迴圈, 實際上 C 標準已經定義了這些原子操作, 所以 C 標準庫中已經有一個函式 __sync_lock_test_and_set ,它裡面的具體行為與我剛剛描述的是一樣的。

在程式碼中, 該函式嘗試將 lk->locked 設定為 1 , 該函式返回 lk->locked 的舊值, 並且該函式是原子的

當 locked 為 0 當時候, 將其置 1 並且跳出迴圈。 當為 1 的時候, 寫入一個新的 1, 繼續迴圈

kernel.asm 中檢視程式碼

  while(__sync_lock_test_and_set(&lk->locked, 1) != 0)
    80000c32:	87ba                	mv	a5,a4
    80000c34:	0cf4a7af          	amoswap.w.aq	a5,a5,(s1)
    80000c38:	2781                	sext.w	a5,a5
    80000c3a:	ffe5                	bnez	a5,80000c32 <acquire+0x22>

(這裡還沒有太看明白,到時候再看看 L4 彙編再回來看看)

release 的彙編中也使用了 amoswap

  __sync_lock_release(&lk->locked);
    80000ce2:	0f50000f          	fence	iorw,ow
    80000ce6:	0804a02f          	amoswap.w	zero,zero,(s1)

C 程式碼中

// Release the lock.
void
release(struct spinlock *lk)
{
  if(!holding(lk))
    panic("release");

  lk->cpu = 0;

  // Tell the C compiler and the CPU to not move loads or stores
  // past this point, to ensure that all the stores in the critical
  // section are visible to other CPUs before the lock is released,
  // and that loads in the critical section occur strictly before
  // the lock is released.
  // On RISC-V, this emits a fence instruction.
  __sync_synchronize();

  // Release the lock, equivalent to lk->locked = 0.
  // This code doesn't use a C assignment, since the C standard
  // implies that an assignment might be implemented with
  // multiple store instructions.
  // On RISC-V, sync_lock_release turns into an atomic swap:
  //   s1 = &lk->locked
  //   amoswap.w zero, zero, (s1)
  __sync_lock_release(&lk->locked);

  pop_off();
}

Q:為什麼不直接使用 store 指令 ?

很多人經常會認為一個 store 指令是一個原子操作,但實際並不總是這樣,這取決於具體的實現。

例如,對於CPU內的快取,每一個 cache line 的大小可能大於一個整數,那麼 store 指令實際的過程將會是:首先會載入 cache line ,之後再更新 cache line 。所以對於 store 指令來說,裡面包含了兩個微指令。這樣的話就有可能得到錯誤的結果。所以為了避免理解硬體實現的所有細節,例如整數操作不是原子的,或者向一個 64bit 的記憶體值寫資料是不是原子的,我們直接使用一個 RISC-V 提供的確保原子性的指令來將 locked 欄位寫為 0。

Q:為什麼在 acquire 函式中,最開始需要關中斷 ?

uartputc 函式會 acquire 鎖,UART 本質上就是傳輸字元,當 UART 完成了字元傳輸它會做什麼?是的,它會產生一箇中斷之後會執行 uartintr 函式,在 uartintr 函式中,會獲取同一把鎖,但是這把鎖正在被 uartputc 持有。

所以 spinlock 需要處理兩類併發,一類是不同 CPU 之間的併發,一類是相同 CPU 上中斷和普通程式之間的併發。針對後一種情況,我們需要在 acquire 中關閉中斷。中斷會在 release 的結束位置再次開啟,因為在這個位置才能再次安全的接收中斷。

Q: 指令亂序如何應對 ?

1 lock
2 x = x + 1
3 unlock

在序列執行環境中, 如果我們將語句 2 放到 3 後面執行, 並不會改變指令的正確性。

因為語句 2 和其他的語句完全獨立, 沒有關聯

但是對於併發執行來說, 這將會是一個災難。

為了禁止,或者說為了告訴編譯器和硬體不要這樣做,我們需要使用 memory fence 或者叫做 synchronize 指令,來確定指令的移動範圍。

對於 synchronize 指令,任何在它之前的 load/store 指令,都不能移動到它之後。鎖的 acquire 和 release 函式都包含了 synchronize 指令。