系統級編程漫游
系統級編程提供學生從用戶級、程序員的視角認識處理器、網絡和操作系統,通過對匯編器和匯編代碼、程序性能評測和優化、內存組織層次、網絡協議和操作以及並行編程的學習,理解底層計算機系統對應用程序的影響,能夠在編寫高級語言代碼的同時,思考低層次的影響與優化,即能夠在系統層級進行編程及程序的優化。
編譯系統的組成
一個程序的生命周期從高級語言的編寫開始,然后被轉化為一系列的低級機器語言指令,這些指令按照一種稱為可執行目標程序的格式打包,並以二進制磁盤文件的形式存儲起來。
Unix系統中,這個轉化工作由GCC編譯器驅動程序完成。GCC讀取源文件hello.c,然后把它翻譯成一個可執行目標程序hello,一共由四個階段完成。執行這四個階段的程序(預處理器、編譯器、匯編器、鏈接器)一起構成了編譯系統。
四個階段的核心功能如下:
- 預處理階段:預處理器根據字符#開頭的命令,修改原始的C程序,讀取進頭文件的內容,直接插入到程序文本中,得到了另一個C程序- hello.i
- 編譯階段:編譯器把文本文件 hello.i 翻譯成文本文件 hello.s,包含一個匯編語言程序。
- 匯編階段:匯編器把 hello.s 翻譯成機器語言指令,把這些指令打包成一種叫做可重定位目標程序的格式,保存在目標二進制文件 hello.o 中。
- 鏈接階段:鏈接器負責合並各個的預編譯好的目標文件,輸出 hello 可執行目標文件,可以被加載到內存中,由系統執行。
操作系統概覽
操作系統是介於硬件和應用程序之間的一層軟件系統,所有應用程序對硬件的操作都必須經過操作系統。
操作系統的兩個基本功能是:
- 防止硬件被失控的應用程序濫用。
- 向應用程序提供簡單一致的機制來控制復雜而又通常大不相同的低級硬件設備。
操作系統提供了三個抽象概念來實現這兩個基本功能:
- 進程
操作系統提供了一種假象:系統上只有這個進程在運行,使程序看上去獨占處理器、主存和I/O設備。實際上在一個系統上可以同時運行多個進程,進程數是可以多於CPU個數的。CPU通過在進程間快速切換來給人以所有進程都在並發執行的假象。
為了達到CPU在進程間切換的效果,操作系統負責管理進程運行的上下文,上下文包括PC、寄存器的當前值和主存的內容等。單處理器在任一時刻只能運行一個進程的代碼。當操作系統決定要進行進程切換時,會先保存當前進程的上下文信息,然后將新進程的上下文恢復,並將控制權傳遞到新進程。新進程就會從它上次暫停的地方繼續往下運行。
注:進程是操作系統進行資源分配的最小單位
在操作系統中,一個進程可以又多個稱為線程的執行單元構成,每個線程都運行在進程的上下文中。同一進程中的多個線程共享代碼和全局數據。
注:線程是操作系統進行任務調度和執行的最小單位
- 虛擬內存
虛擬內存提供了一種假象:每個進程都在獨占地使用內存。每個進程看到的內存都是一致的,稱為虛擬地址空間。
- 文件
文件就是字節序列。每個I/O設備,包括鍵盤、磁盤、顯示器、打印機和網絡都可以看成文件。系統中的所有輸入輸出都是通過調用一組稱為Unix I/O的系統調用讀寫文件來實現的。
文件的概念簡單而強大,它屏蔽了所有底層硬件的實現細節,通過一致的視圖來操作這些硬件。這使得不同廠商提供的設備都能運行在同一台計算機上
硬件系統的組成
- 總線
貫穿整個系統的一組電子管道,攜帶信息字節並負責在各個部件間傳遞。
- IO設備
每個I/O設備都通過一個控制器或適配器與I/O總線相連。
- 主存
臨時存儲設備,在處理器執行程序時,用來存放程序和程序處理的數據。
主存在物理上由一組動態隨機存取存儲器(DRAM)芯片組成,邏輯上它是一個線性的字節數組,每個字節都有唯一的地址(數組索引),從0開始。
- 處理器
處理器是解釋和執行存儲在主存中指令的引擎,它的核心是一個大小為一個字的存儲設備(寄存器),稱作程序計數器(PC),在任何時刻,PC都指向主存中的某條機器語言指令,即PC保存的是主存中的某個地址。
處理器一直在不斷地執行PC指向指令,接着更新PC,將其指向下一條指令,下一條指令的地址和剛被執行的上一條指令的地址不一定是相鄰的。
處理器中包含一些擁有固定名字的寄存器,這些寄存器的大小是單個字長。
Amdahl’s Law (阿姆達爾定律)
阿姆達爾定律的主要思想是,當我們對系統的某個部分加速時,其對系統整體性能的影響取決於該部分的重要性和加速程度。其加速比公式如下:(a=該部分所占的比例 k=該部分提升的比例)
程序生命周期
- 編寫 edit
在編輯器中編寫出高級語言代碼
- 編譯 compile
高級語言源代碼通過編譯系統的編譯,被翻譯成可執行文件 - 執行 execute
可執行文件首先存儲在硬盤中,當IO設備(如:鍵盤)讀入運行命令之后,總線負責把程序從硬盤中加載到主存中,處理器進一步執行程序,然后進行輸出。
數據的表示
信息存儲
整數的表示雖然只能編碼一個相對較小的數值范圍,但這種表示是精確的;浮點數雖然能編碼一個較大的數值范圍,但這種表示是近似的。
- 計算機使用字節(byte, 1byte=8bits)而不是單獨的位來作為最小尋址單位。
- 機器級程序將內存視為一個非常大的字節數組,稱為虛擬內存。內存的每個字節都由一個唯一的數字來標識,稱為它的地址,所有可能的地址的集合就稱為虛擬地址空間。
- 十六進制表示法,以0x開頭表示十六進制值。
- 字長(word size)指明了指針數據的標稱大小,字長決定了虛擬地址空間的最大大小。對於一個字長為w的機器而言,其虛擬地址空間范圍為0-2w-1,程序最多訪問2w個字節。字:固定大小的字節塊
- 有兩種有兩種字節順序:小端法(little endian)是最低有效字節在最前面。大端法(big endian)是最高有效字節在最前面。對於選擇哪種字節順序並沒有任何技術上的理由,但是一旦選擇了特定的操作系統,字節順序就固定下來。
- C語言中的數據類型
位操作
- 與或非
- Bit Shifts ( << and >> )
- 左移:x向左移動 k 位,丟棄最高的 k 位,在右端補 k 個0
- 邏輯右移:在左端補 k 個 0
- 算術右移:在左端補 k 個 最高有效位的值。
- 實際上,幾乎所有的編譯器和機器組合都對有符號數使用算數右移,另外對於無符號數,右移必須是邏輯
- ^: 異或: 不同為1,相同為0
- 德摩根定理:與的非等於非的與
整數的表示
- 2s補碼: 二進制 取反+1
- Overflow: 16位無符號的整數最大值是63535,如果超出這個范圍,就會整數溢出,整數溢出在c語言中不會被檢查到,因此程序員要進行檢查
- Conversion:數的不同大小表示之間發生轉換
浮點數的表示
- Fixed Point Notation:定點數形式
- BCD (Binary-Coded Decimal)
用二進制替換十進制數
- IEEE Floating Point
- 表現形式
- 計算方式:
E (真值) = Exp(機器表示(移碼)) – Bias(偏移量) 32位的時候 Bias-127
M = 1 + frac = 1.xxx…x - 逆運算:
- 非規格化的 E全為0: E=1-Bias M=f
- 特殊值: E全為1: M 全為 0 的時候表示無窮大 ,否則表示 NaN
程序的表示
寄存器
寄存器分成兩種類型:用戶寄存器和控制寄存器。用戶寄存器如數據寄存器、地址寄存器,是ALU的一部分;控制寄存器如PC,IR,Status Flags,Stack Pointer,是CU的一部分。
前六個寄存器稱為通用寄存器,有其『特定』的用途:
- ax 累加器:加法和乘法指令的缺省寄存器,存儲函數返回值
- bx 基址寄存器:在內存尋址時存放基地址
- cx 計數器: REP & LOOP 指令的內定計數器
- dx 除法寄存器:存放整數除法產生的余數
- si 源索引寄存器:用於保存源索引值
- di 目標索引寄存器:用於保存目標索引值
%rsp(%esp) 和 %rbp(%ebp) 則是作為棧指針和基指針來使用的。
操作數
三種基本類型:立即數(Imm)、寄存器值(Reg)和內存值(Mem)
對於 movq
指令來說,需要源操作數和目標操作數,源操作數可以是立即數、寄存器值或內存值的任意一種,但目標操作數只能是寄存器值或內存值。指令的具體格式可以這樣寫 movq [Imm|Reg|Mem], [Reg|Mem]
,第一個是源操作數,第二個是目標操作數:
movq Imm, Reg -> mov $0x5, %rax -> temp = 0x5; movq Imm, Mem -> mov $0x5, (%rax) -> *p = 0x5; movq Reg, Reg -> mov %rax, %rdx -> temp2 = temp1; movq Reg, Mem -> mov %rax, (%rdx) -> *p = temp; movq Mem, Reg -> mov (%rax), %rdx -> temp = *p;
這里有一種情況是不存在的,沒有 movq Mem, Mem
這個方式,也就是說,我們沒有辦法用一條指令完成內存間的數據交換。
上面的例子中有些操作數是帶括號的,括號的意思就是尋址,這也分兩種情況:
- 普通模式,(R),相當於
Mem[Reg[R]]
,也就是說寄存器 R 指定內存地址,類似於 C 語言中的指針,語法為:movq (%rcx), %rax
也就是說以 %rcx 寄存器中存儲的地址去內存里找對應的數據,存到寄存器 %rax 中
- 移位模式,D(R),相當於
Mem[Reg[R]+D]
,寄存器 R 給出起始的內存地址,然后 D 是偏移量,語法為:movq 8(%rbp),%rdx
也就是說以 %rbp 寄存器中存儲的地址再加上 8 個偏移量去內存里找對應的數據,存到寄存器 %rdx 中
對於尋址來說,比較通用的格式是 D(Rb, Ri, S)
-> Mem[Reg[Rb]+S*Reg[Ri]+D]
,其中:
D
- 常數偏移量
Rb
- 基寄存器
Ri
- 索引寄存器,不能是 %rsp
S
- 系數
除此之外,還有如下三種特殊情況
(Rb, Ri)
->Mem[Reg[Rb]+Reg[Ri]]
D(Rb, Ri)
->Mem[Reg[Rb]+Reg[Ri]+D]
(Rb, Ri, S)
->Mem[Reg[Rb]+S*Reg[Ri]]
指令
- Fetch-Execute Cycle
- 指令集
- 數據傳送類
MOV PUSH POP LEA IN
- 算術運算類
ADD CMP
- 位與邏輯運算類
TEST
- 字符串處理類
STOSB REPE/REPZ REPNE/REPNZ
- 控制轉移類
JMP CALL RET
- 處理器控制類
匯編語言
- 語言風格
- GAS Style :GAS(GNU Assembly)/AT&T - The one on the CSAPP book
- MASM Style:Intel/MASM- The one that we use on VC ++ IDE
- 區別
- 兩者的源操作數和目的操作數的位置相反
- 前者的匯編指令中帶有后綴(如b/w/l),指示操作數的長度(8/16/32 bits)
- s前者在寄存器前加“%”,在常數和符號地址前加“$”
- 前者間接尋址用( )表示,而后者用[ ]表示
- –movl %edx,%eax mov eax,edx
–movl (%edx),%eax mov eax,[edx]
- 匯編語言的元素
–Constants Statements Instructions Identifiers
內存分配和布局
結構化數據
- 虛擬內存的大致分布
- 全局變量 vs 局部變量
- 數據存儲在內存中,機器沒有類型和變量的概念,只有位和字節的概念。所以所有的變量都可以通過字節來表示,所以可以通過一個讀入一個 character 當作 int。
- 指針:我們可以通過引用來共享參數,而不需要進行賦值。指針賦予了動態分配內存的能力。但是使用好指針需要我們對所有的內存進行管理。
- 數組跟指針是可以轉化的:指針指向數組的第一個元素
(區別char 和 int 型的數組,在增長的時候,intArray需要*4)
myarray + N = &(myarray[N])
myarray[N] = *(myarray + N)
–a[n] [m] == (a[n])[m] == ((a[n]) + m) == ((a + n*3) + m)
- 指針的運算
- 加、減:移動指針到下一個/上一個 元素,偏移量與數據類型,即類型的字節數相關。例如,當前地址為1000,如果是一個int型的指針,ptr++ 后 指向1004, 如果是一個char* 指針 ptr++ 后 指向1001
**&a 的類型是 int[5] a的類型是 int
- 相等、大於、小於
- 字符串:C語言中沒有字符串類型,是一個char數組以 ‘\0’ (0x00)結尾。
- 結構體:長度計算 1. alignment 取最大 2. size 取 alignment 和 元素個數的 乘積
- 聯合體:size 取 最大元素的 size
- 對齊(Alignment):對齊數據邊界甚至允許到雙字邊界,使用padding來對齊。能夠提升計算機查找的效率
函數調用和棧幀
- 變量和參數
- 變量具有生存域:同名的變量也會被映射到不同地址存儲來實現域,如局部變量和全局變量
- 形參和實參也是存在不同的地址中,防止其操作帶來的影響。
- 按值傳遞和按引用傳遞(傳遞地址)
- 參數的實現機制
全局變量會被靜態的分配,在程序執行之前;局部變量則是動態分配(編譯器也會實現留出一個大空間給局部變量),同樣的,實參也是需要被動態分配的。
- Activation Record and Stack Frame
- 為了最小化動態分配的成本,編譯器計算每一個 function 需要的總空間,並把這些空間放在一個 chunk 中,這就是激活記錄/棧幀。顯然,他們是被存儲在棧中的。
- 硬件的支持:stack pointer register esp;frame pointer register ebp
- 規則:只有棧頂的激活記錄能夠被訪問;分配規律:從高地址向低地址(棧頂)拓展
- 調用/返回的過程
當函數發生調用的時候,調用方需要進行保存現場:
- push parameters & return address into the stack
被調用方需要構造自己的棧幀:
- push frame pointer (ebp) into the stack
- set the ebp equal to esp
- allocate a chunk of memory by decrement the stack pointer(esp) by as many memory addresses as are required to store the local state of the callee.
- 函數調用規則
_cdecl(C語言默認的規則)
- 參數順序: 從右到左入棧
- 參數存儲位置
- 寄存器使用
- 調用方還是被調用方進行unwinding?調用方負責清理棧上面的函數
靜態內存分配
- 靜態意味着發生在編譯和鏈接時期,編譯完成之后,不能夠修改
- 所有的全局變量、聲明為靜態的局部變量、explicit constants(strings sets) 都進行靜態分配
- 靜態分配會在main函數之前開始,之后結束。局部靜態變量只有在作用域中起作用,每次調用不會重新初始化。
- 靜態分配的問題
- 命名困難
- 程序在運行前不能很准確的知道需要多大的存儲
- 靜態分配預留了內存空間,但是有時候某個數據結構只是暫時性的被需要
- 遞歸調用無法實現
動態內存分配
- 動態是指在運行期間分配內存
- 棧分配:Allocated & deallocated in last-in, first-out order, with functions calls and returns
- Register EBP indicates highest stack address
- Register ESP indicates lowest stack address (address of top element)
- Pushing : Decrement esp by 4 & Write operand at address given by ESP
- Poping: Increment esp by 4 & Write to Dest
- 堆分配:Allocated and deallocated memory at arbitrary times & programs use to store data(eg. malloc).
- 棧是自動分配的,當在作用域的時候創建,不在域的時候摧毀;堆是手工分配的,創建和摧毀都基於請求
int main() { int myInt; // declare an int on the stack myInt = 5; // set the memory to five return 0; } int main() { int* myInt = (int*) malloc(sizeof(int)); if ( myInt != NULL ) { *myInt = 5; // free is uesd to release memory but // do not: free memory in stack or free same memory twice free(myInt); myInt = NULL; //Should set pointer to NULL when done } return 0; }
- 棧和堆的區別
- 棧:快速訪問 ,不需要釋放內存塊, 空間由CPU管理,內存不會被fragment,僅限局部變量,限制於棧的大小,大小不能改變
- 堆:相對慢,需要自己來管理內存,效率無法保證,內存會被fragment,變量可以重新調整大小(realloc)
內存分配算法
- 內存分配的困難: malloc會分配之前沒有使用過的內存,算法要能夠決定哪些塊能夠使用
- 常見算法
- First Fit: 第一塊滿足條件的
- Best Fit: 與要求最貼切的
- Worst Fit: 最大的內存塊
內存缺陷
- 野指針:指向“垃圾”內存的指針。人們一般不會錯用NULL指針,因為用if語句很容易判斷。
- 指針變量沒有被初始化。任何指針變量剛被創建時不會自動成為NULL指針,它的缺省值是隨機的,它會亂指一氣。所以,指針變量在創建的同時應當被初始化,要么將指針設置為NULL,要么讓它指向合法的內存。
- 指針p被free或者delete之后,沒有置為NULL,讓人誤以為p是個合法的指針。
- 指針操作超越了變量的作用范圍。這種情況讓人防不勝防,示例程序如下:
class A { public: void Func(void){ cout << “Func of class A” << endl; } }; void Test(void) { A *p; { A a; p = &a; // 注意 a 的生命期 } p->Func(); // p是“野指針” }
其他各種的錯誤程序 (bad references)void GetMemory (char* p, int num) { p = (char *) malloc(sizeof(char) * num); } void main (void) { char *str = NULL; GetMemory(str, 100); strcpy(str, "hello"); }
char *GetString(void){ char p[ ] = "hello world"; return p; // 編譯器將提出警告 } void main (void) { char *str = NULL; str = GetString(); // str 的內容是垃圾 cout<< str << endl; } //correct code char *GetString(void){ char *p = "hello world"; return p; } void main (void){ char *str = NULL; str = GetString(); cout<< str << endl; }
int i; double d; // wrong!!! scanf("%d %lf", i, d); // here is the correct call: scanf("%d %lf", &i, &d);
int* ptr_to_zero() { int i = 0; return &i; } //不要用return語句返回指向“棧內存”的指針
- Overwrite Problem
- 數組訪問越界
- 內存分配,大小指定錯誤
- 輸入超出內存空間
char s[8]; int i; gets(s); /* reads “123456789” from stdin */
- String要以 \0 結尾、
char *heapify_string(char *s) { int len = strlen(s); char *new_s = (char *) malloc(len); strcpy(new_s, s); return new_s; } //correct char *new_s = (char *) malloc(len + 1);
- 二次釋放 (twice free)
- 內存泄漏(memory leak)
- 不再使用的內存沒有回到內存池中
- 最終系統會用光所有的內存
- 是一個慢性、長期的內存殺手,多數的異常、錯誤都是由內存泄漏引起的
- 特別注意一種情形,當釋放結構體的時候,只釋放了結構體本身,沒有釋放結構體內部屬性所指向的內存空間
typedef struct { char *name; int age; char *address; int phone; } Person; void my_function() { Person *p = (Person *) malloc(sizeof(Person)); p->name = (char *) malloc(M); ... p->address = (char *) malloc(N); ... free(p); // what about name and address? }
- Exterminating Memory Bugs
內存管理
- keep track of memory allocation
- bitmap: an array of bits, one per allocation chunk
- linked list : stores contiguous regions of free or allocated memory
- Implicit free list using lengths : 隱式空閑鏈表
- Explicit list among the free blocks using pointers within the free blocks :
- Segregated free lists 分離的空閑鏈表
Different free lists for different size classes
- Blocks sorted by size :
Can use a balanced tree (e.g. Red-Black tree) with pointers within each free block, and the length used as a key
- Placement Policy
- First Fit
- Best Fit
- Worst Fit
- Segregated Fit (分離適配)
- 確定請求類的大小,並且對適當的空閑鏈表做首次適配,查找合適的塊
- 如果找到一個,我們(可選地)分割它,並將剩余的部分插入到適當的空閑鏈表中
- 如果找不到,我們就搜索下一個更大的大小類的空閑鏈表。如此重復,直到找到一個合適的塊
- 如果沒有空閑鏈aaaaa'a'a'a'a'a'a'a'a'a表中有合適的塊,那么就向操作系統請求額外的對存儲器,從這個新的對存儲器中分配出一個塊,將剩余的部分放置在最大的大小類中。
- Splitting Free Blocks
- Getting Additional Heap Memory
- Coalescing free blocks:Immediate coalescing (立即合並) & Deferred coalescing (推遲合並)
- Asks the kernel for additional heap memory
- Deallocation
- Coalesce: To combine two or more nodes into one. (with Boundary Tags(邊界標記))
- Dynamic Memory Management
- Explicit Memory Management (EMM)
- Automatic Memory Management (AMM)
- Lazy processing: blocks are reorganized only if needed
- Implicit Memory Management -- application never has to free
- automatic reclamation of heap-allocated storage
- Common in functional languages, scripting languages, and modern object oriented languages: Lisp, Java, Perl, …
垃圾回收
- Mark and Sweep Collecting 標記清除法
- Use extra mark bit in the head of each block
- Mark: Start at roots and sets mark bit on all reachable memory
- Sweep: Scan all blocks and free blocks that are not marked
void mark ( ptr p ) { if (( b = isPtr(p)) == NULL) return; if ( blockMarked(b)) return; markBlock(b); len = length(b); for (i=0; i < len; i++) mark(b[i]); //mark all child return; } void sweep ( ptr b, ptr end) { while (b < end) { if (blockMarked(b)) unmarkBlock(b); else if (blockAllocated(b)) free(b); b = nextBlock(b); } return; }
- Copying Collection 復制法
- use 2 heaps
- One used by program & The other unused until GC time
- Process:
- Start at the roots & traverse the reachable data
- Copy reachable data from the active heap (from-space) to the other heap (to-space)
- Dead objects are left behind in from space
- Heaps switch roles
- Reference Counting
- Keep track of the number of pointers to each object (the reference count).
- When the reference count goes to 0, the object is unreachable garbage
- Generational GC 分代式垃圾回收法
- If an object has been reachable for a long time, it is likely to remain so
- In many languages, most objects died young
- we save work by scanning the young objects frequently and the old objects infrequently
- process:
- Assign objects to different generations G0, G1,…
- •G0 contains young objects, most likely to be garbage
•G0 scanned more often than G1
- 總結
- 引用計數是解決顯式內存分配問題的常用解決方案。實現賦值時遞增和遞減操作的代碼通常是程序緩慢的原因之一。無論如何,引用計數也不是全面的解決方案,因為循環引用從不會被刪除。
- 垃圾回收只會在內存變得緊張時才會運行。當內存尚且寬裕時,程序將全速運行,不會在釋放內存上花費任何時間。
- 分代, 復制回收程序在很大程度上克服了早期的標記&清除算法的低效。
- 現代垃圾回收程序進行堆緊縮。堆緊縮將減少程序引用的頁的數量,這意味着內存訪問命中率將更高,交換將更少。
- 采用垃圾回收的程序不會因為內存泄漏的累積而崩潰。采用 GC 的程序擁有更長期的穩定性。
- 采用垃圾回收的程序更容易發現指針錯漏。 這是因為沒有指向已經釋放的內存的懸掛指針。因為沒有顯式的內存管理代碼,也就不可能有相應的錯漏。
- 垃圾回收並非什么仙丹妙葯。它有着以下不足:
•內存回收何時運行是不可預測的,所以程序可能意外暫停。
•運行內存回收的時間是沒有上界的。盡管在實踐中它的運行通常很快,但無法保證這一點。
•除了回收程序以外的所有線程在回收進行時都會停止運行。
- 垃圾回收程序也許會留下一些本該回收的內存
- 垃圾回收應該被實現為一個基本的操作系統內核服務。但是現實並非如此,造成了采用垃圾回收的程序被迫帶着它們的垃圾回收實現到處跑。顯式內存回收程序通常會把內存放回自己的內部內存池中而不是把內存交還給操作系統。
鏈接和加載
鏈接器
- 功能:將可重定位的目標文件(包括庫)轉化為可執行目標文件。靜態鏈接在編譯之后進行,動態鏈接在加載和運行期間進行。
- 主要任務:
- 符號解析:將每個符號引用正好和一個符號定義關聯起來,每個符號對應一個函數、一個全局變量或一個靜態變量。
- 重定位:把每個符號定義與一個內存位置關聯起來,重定位這些節,然后修改所有對這些符號的引用。
- 靜態鏈接:在我們的實際開發中,不可能將所有代碼放在一個源文件中,所以會出現多個源文件,而且多個源文件之間不是獨立的,而會存在多種依賴關系,如一個源文件可能要調用另一個源文件中定義的函數,但是每個源文件都是獨立編譯的,即每個.c文件會形成一個.o文件,為了滿足前面說的依賴關系,則需要將這些源文件產生的目標文件進行鏈接,從而形成一個可以執行的程序。這個鏈接的過程就是靜態鏈接。
優缺點:浪費空間,更新困難(如果庫函數的代碼修改,需要重新編譯鏈接);在可執行程序中已經具備了所有執行程序所需要的任何東西,在執行的時候運行速度快。
- 動態鏈接:動態鏈接的基本思想是把程序按照模塊拆分成各個相對獨立部分,在程序運行時才將它們鏈接在一起形成一個完整的程序,而不是像靜態鏈接一樣把所有程序模塊都鏈接成一個單獨的可執行文件。
- 鏈接器的重要性:
- 幫助構造大型程序
- 避免一些危險的編程錯誤,如解析符號引用出現的錯誤
- 理解語言的作用域規則的實現
- 理解其他重要的系統概念:加載和運行程序、虛擬內存、分頁、內存映射
- 利用共享庫
- 鏈接過程
- 符號解析
鏈接器只知道非靜態的全局變量/函數,而對於局部變量一無所知;局部非靜態變量會保存在棧中;局部靜態變量會保存在.bss
或.data
中
- 聚合
- 重定位
把不同可重定位對象文件拼成可執行對象文件
當鏈接器進行鏈接的時候,首先決定各個目標文件在最終可執行文件里的位置。然后訪問所有目標文件的地址重定義表,對其中記錄的地址進行重定向(加上一個偏移量,即該編譯單元在可執行文件上的起始地址)。然后遍歷所有目標文件的未解決符號表,並且在所有的導出符號表里查找匹配的符號,並在未解決符號表中所記錄的位置上填寫實現地址。最后把所有的目標文件的內容寫在各自的位置上,再作一些另的工作,就生成一個可執行文件。
目標文件格式
- 目標文件格式:
- File header
包含文件屬性:是否可執行、動態還是靜態編譯、啟動地址、操作系統 - Sections
包含 text data bss 三個節,包含已編譯的機器代碼、已初始化的全局變量、未初始化的全局變量
把數據和代碼放在不同的section中,能夠 1. 保護代碼 2. 提升命中率 3.共享內存
此外還有如 .rodate .symtab .rel .txt .rel .data .debug - Section header table
描述每一個區的 名字、長度、偏移量、是否可讀可寫
- 目標文件類型:
- 可重定位:包含二進制代碼和數據,如 .o .a .lib .obj
- 可執行: 包含二進制代碼和數據,可以復制到內存中執行,如 /bin/bash .out .exe
- 共享:一種特殊類型的可重定位目標文件,可以在加載或者運行時被動態地加載到內存中並鏈接,如 .so .dll
符號解析
- 在鏈接器的上下文中,有三種不同的符號
- 由模塊m定義並被其他模塊引用的 全局符號。對應於非靜態的C函數和全局變量
- 由其他模塊定義並被模塊m引用的 全局符號,也稱外部符號。 對應於在其他模塊中定義的非靜態的C函數和全局變量
- 只被模塊m定義和引用的 局部符號。對應於帶 static 屬性的 C函數和全局變量,這些符號在模塊m中隨處可見,但是不能被其他模塊引用。
- 多重定義的全局符號
- 強符號:函數和初始化的全局變量
- 弱符號:未初始化的全局變量
鏈接器在處理強弱符號的時候遵守以下規則:
- 不能出現多個同名的強符號,不然就會出現鏈接錯誤
- 如果有同名的強符號和弱符號,選擇強符號,也就意味着弱符號是『無效』d而
- 如果有多個弱符號,隨便選擇一個
- 靜態庫:將所有相關的目標模塊打包成為一個單獨的文件,成為靜態庫,可以用作鏈接器的輸入。
重定位
- 重定位的功能
完成了符號解析之后,代碼中的符號引用和符號定義就關聯起來了,並且鏈接器也知道目標模塊中代碼節和數據節的確切大小。此時進行重定位, 合並輸入模塊,為每個符號分配運行時地址:
- 重定位節和符號定義:合並相同類型的節為聚合節;
- 重定位節中的符號引用:修改代碼節和數據節中對每個符號的引用,使得他們指向正確的運行時地址。
- 重定位條目:匯編器遇到對最終位置未知的目標引用,就會生成一個重定位條目,告訴鏈接器如何修改引用。
- 重定位算法:
事先知道 text 和 m.symobl 的重定位地址
- 相對引用:
refaddr=ARRD(text)+ m.offset
*refptr= (unsigned) (ADDR(m.symbol)+m.append -refaddr)
- 絕對引用
*refptr= (unsigned) (ADDR(m.symbol)+m.append )
加載
Liux系統中的每個程序都運行在一個進程上下文中,有自己的虛擬地址空間。
當shell運行一個程序時,父shell進程生成一個子進程,是父進程的復制。子進程通過execve系統調用啟動加載器
加載器刪除子進程現有的虛擬內存段,並創建一組新的代碼、數據、堆和棧段。新的棧和堆段被初始化為零。通過將虛擬地址空間中的頁映射到可執行文件的頁的大小的片,新的代碼和數據段被初始化為可執行文件的內容。
加載器跳轉到-_start地址,調用應用程序的main函數。
除了一些頭部信息,沒有任何從磁盤到內存的數據復制。直到CPU引用一個被映射的虛擬頁時才會進行復制。此時,操作系統利用他的頁面調度機制自動將頁面從磁盤傳送到內存。
優化程序性能
性能優化的邏輯問題
- 性能測量:這個程序是不是 run fast
bottlenecks/ Hot spots
- 優化程序:怎么讓程序變得更快
創建基准線 找到程序中的瓶頸 找到歸因 優化代碼 重新測試
性能測量原則
- 二八定理
80%的CPU運行時間和資源被20%的程序占用
鎖定20%的代碼就能獲得更好的性能
- Amdahl’s Law
p=該部分所占的比例 s=該部分提升的比例
性能測量方式
- 測量對象:時間(牆上時鍾時間、CPU時間)
CPU time = user CPU time + system CPU time
Wall Clock Time > CPU time
- 測量工具:Timer (硬件、操作系統、編程語言 c/c++)
- In Hardware
- Real Time CMOS Clock:
使用CMOS RAM 來存儲時間
原理是使用晶體振盪器產生原始時鍾頻率
Clock Cycle (時鍾周期/振盪周期 ) = seconds per cycle
Clock Frequency (時鍾頻率) = cycles per second (1 Hz.=1 cycle/sec), etc.14.318MHz
- PIT: 可編程間隔定時器
- TSC: 時間戳記數器
- In Os
以Windows為例,系統時間計時始於:啟動的時候讀取RTC后,進行轉換。Windows time是自上次啟動系統以來經過的毫秒數。Windows時間周期為49.7天。返回值是一個DWORD類型:DWORD GetTickCount(void);
- In C/C++
•數據類型:clock_t, time_t
•結構:struct tm
•函數: Clock() Time() Difftime() Mktime() .....
總結:
在硬件中,是通過時鍾和外部晶體實時時鍾RTC以及時間戳TSC
在操作系統中,是通過讀取RTC后,通過系統調用時間函數,和時間片來確定時間的
在程序中是通過CLOCK(函數),通過其返回的值來確定當前的時間
- 測量技術:抽樣分析
- 原理:計時器周期性地中斷程序並記錄程序計數器,然后估計在程序中花費的時間,進一步檢查程序是否將大部分時間花在幾個地方
- 原因:節省時間和成本,高效
- 工具:GNU Gprof ; Vtune ; VC profiler
分析器 (profiler/profiling) 的主要功能在於 1. 確認引起程序瓶頸的主要代碼塊 2. 哪個部分的代碼被最為頻繁的調用
優化程序性能
- 用好編譯器的不同參數設定
- 寫對編譯器友好的代碼,尤其是過程調用和內存引用,時刻注意內層循環
- 根據機器來優化代碼,包括利用指令級並行、避免不可以預測的分支以及有效利用緩存
最根源的優化是對編譯器的優化,比方說在寄存器分配、代碼排序和選擇、死代碼消除、效率提升等方面,都可以由編譯器做一定的輔助工作。
但是因為這畢竟是一個自動的過程,而代碼本身可以非常多樣,在不能改變程序行為的前提下,很多時候編譯器的優化策略是趨於保守的。並且大部分用來優化的信息來自於過程和靜態信息,很難充分進行動態優化。
接下來會介紹一些我們自己需要注意的地方,而不是依賴處理器或者編譯器來解決。
代碼移動
如果一個表達式總是得到同樣的結果,最好把它移動到循環外面,這樣只需要計算一次。編譯器有時候可以自動完成,比如說使用 -O1
優化。一個例子:
void set_row(double *a, double *b, long i, long n){ long j; for (j = 0; j < n; j++){ a[n*i + j] = b[j]; } }
這里 n*i
是重復被計算的,可以放到循環外面
long j; int ni = n * i; for (j = 0; j < n; j++){ a[ni + j] = b[j]; }
減少計算強度
用更簡單的表達式來完成用時較久的操作,例如 16*x
就可以用 x << 4
代替,一個比較明顯的例子是,可以把乘積轉化位一系列的加法,如下:
for (i = 0; i < n; i++){ int ni = n * i; for (j = 0; j < n; j++) a[ni + j] = b[j]; }
可以把 n*i
用加法代替,比如:
int ni = 0; for (i = 0; i < n; i++){ for (j = 0; j < n; j++) a[ni + j] = b[j]; ni += n; }
公共子表達式
可以重用部分表達式的計算結果,例如:
/* Sum neighbors of i, j */ up = val[(i-1)*n + j ]; down = val[(i+1)*n + j ]; left = val[i*n + j-1]; right = val[i*n + j+1]; sum = up + down + left + right;
可以優化為
long inj = i*n + j; up = val[inj - n]; down = val[inj + n]; left = val[inj - 1]; right = val[inj + 1]; sum = up + down + left + right;
雖然說,現代處理器對乘法也有很好的優化,但是既然可以從 3 次乘法運算減少到只需要 1 次,為什么不這樣做呢?螞蟻再小也是肉嘛。
小心過程調用
void lower1(char *s){ size_t i; for (i = 0; i < strlen(s); i++) if (s[i] >= 'A' && s[i] <= 'Z') s[i] -= ('A' - 'a'); }
在字符串長度增加的時候,時間復雜度是二次方的!每次循環中都會調用一次 strlen(s)
,而這個函數本身需要通過遍歷字符串來取得長度,因此時間復雜度就成了二次方。
那么只計算一次就好了:
void lower2(char *s){ size_t i; size_t len = strlen(s); for (i = 0; i < len; i++) if (s[i] >= 'A' && s[i] <= 'Z') s[i] -= ('A' - 'a'); }
為什么編譯器不能自動把這個過程調用給移到外面去呢?
編譯器的策略必須是保守的,因為過程調用之后所發生的事情是不可控的,所以不能直接改變代碼邏輯,比方說,假如 strlen
這個函數改變了字符串 s
的長度,那么每次都需要重新計算。如果移出去的話,就會導致問題。
所以很多時候只能靠程序員自己進行代碼優化。
注意內存問題
接下來我們看另一段代碼及其匯編代碼
// 把 nxn 的矩陣 a 的每一行加起來,存到向量 b 中 void sum_rows1(double *a, double *b, long n) { long i, j; for (i = 0; i < n; i++) { b[i] = 0; for (j = 0; j < n; j++) b[i] += a[i*n + j]; } }
對應的匯編代碼為
# sum_rows1 的內循環 .L4: movsd (%rsi, %rax, 8), %xmm0 # 浮點數載入 addsd (%rdi), %xmm0 # 浮點數加 movsd %xmm0, (%rsi, %rax, 8) # 浮點數保存 addq $8, %rdi cmpq %rcx, %rdi jne .L4
可以看到在匯編中,每次都會把 b[i]
存進去再讀出來,為什么編譯器會有這么奇怪的做法呢?因為有可能這里的 a
和 b
指向的是同一塊內存地址,那么每次更新,都會使得值發生變化。但是中間過程是什么,實際上是沒有必要存儲起來的,所以我們引入一個臨時變量,這樣就可以消除內存引用的問題。
// 把 nxn 的矩陣 a 的每一行加起來,存到向量 b 中 void sum_rows2(double *a, double *b, long n) { long i, j; for (i = 0; i < n; i++) { double val = 0; for (j = 0; j < n; j++) val += a[i*n + j]; b[i] = val; } }
對應的匯編代碼為
可以看到,加入了臨時變量后,解決了奇怪的內存問題,生成的匯編代碼干凈了許多。
處理條件分支
這個問題,如果不是對處理器執行指令的機制有一定了解的話,可能會難以理解。
現代處理器普遍采用超標量設計,也就是基於流水線來進行指令的處理,也就是說,當執行當前指令時,接下來要執行的幾條指令已經進入流水線的處理流程了。
這個很重要,對於順序執行來說,不會有任何問題,但是對於條件分支來說,在跳轉指令時可能會改變程序的走向,也就是說,之前載入的指令可能是無效的。這個時候就只能清空流水線,然后重新進行載入。為了減少清空流水線所帶來的性能損失,處理器內部會采用稱為『分支預測』的技術。
比方說在一個循環中,根據預測,可能除了最后一次跳出循環的時候會判斷錯誤之外,其他都是沒有問題的。這就可以接受,但是如果處理器不停判斷錯誤的話(比方說代碼邏輯寫得很奇怪),性能就會得到極大的拖累。
分支問題有些時候會成為最主要的影響性能的因素,但有的時候其實很難避免。
存儲器層次結構
存儲技術
存儲技術分為三大板塊:隨機訪問存儲器、磁盤存儲、固態硬盤
隨機訪問存儲器
- 靜態RAM
靜態RAM比動態RAM更快也更貴,常用來做高速緩存存儲器。將每個位存儲在一個雙穩態存儲器單元里,只要有電,就能保持穩定值。 【每位晶體管-6 相對訪問時間-1x 花費-1000x 持續 不敏感 】
- 動態RAM
DRAM用來作為主存以及圖形系統的幀緩沖區。將每個為存儲為對一個電容的充電。每個單元由一個電容和一個訪問晶體管組成,對干擾比較敏感。【每位晶體管-1 相對訪問時間-10x 花費-1x 持續 不敏感 】
- 傳統的DRAM: DRAM芯片中的單元被分成d個超單元,每個超單元被分成w個 DRAM 單元。 一個d*w 的 DRAM單元存儲了 dw 位信息。DRAM被組織成二維陣列能夠降低芯片上地址引腳的數量,但是也因此必須分兩次發送地址,增加了訪問時間。
- 內存模塊:DRAM芯片封裝在內存模塊中,插到主板的擴展槽上。分為:SIMM (Single Inline Memory Module) 單列直插內存模塊 && DIMM (Dual Inline Memory Module) 雙列直插內存模塊。多個模塊連接到內存控制器,能夠聚合成主存。
- 增強的DRAM:•快頁模式 •擴展數據輸出 •同步DRAM •Rambus DRAM •雙倍速率SDRAM
- 非易失性存儲器
無論是 DRAM 還是 SRAM,一旦不通電,所有的信息都會丟失。由於歷史原因,整體都被成為 ROM (read-only Memory),雖然現在有的類型是可寫的。 ROM以他們能夠被重編程(重寫)的次數和對它們進行重編程的機制來進行區分:
- PROM: 可編程,只能被編程一次,每個存儲器單元有一種熔絲,只能用高電流熔斷一次。
- EPROM:可擦除可編程,利用光進行擦除,可以達到1000次
- EEPROM:電可擦除可編程,可達到100000次
- Flash Memory:基於EEPROM,為大量的電子設備提供快速持久的存儲,如數碼相機、手機、音樂播放器等
固件程序會存儲在 ROM 中(比如 BIOS,磁盤控制器,網卡,圖形加速器,安全子系統等等)
硬盤存儲(傳統機械硬盤)
機械硬盤有許多片磁盤(platter)組成,每一片磁盤有兩面;每一面由一圈圈的磁道(track)組成,而每個磁道會被分隔成不同的扇區(sector)。
//
硬盤的容量指的是最大能存儲的比特數,與硬盤的結構分層類似,容量取決於下面三個方面:
- 記錄密度(bits/in):track 中 1 英寸能保存的字節數
- 磁道密度(tracks/in):1 英寸直徑能保存多少條 track
- Areal 密度(bits/in 的平方):上面兩個數值的乘積
現在硬盤會把相鄰的若干個磁道切分成小塊,每一塊叫做記錄區(recording zone)。記錄區中的每條磁道都包含同樣數量的扇區(sector);但是每個記錄區中包含的扇區和磁道的數目是不一樣的,外層的更多,內層的更少;正因為如此,我們計算容量用的是平均的數值。
容量 Capacity = 每個扇區的字節數(bytes/sector) x 磁道上的平均扇區數(avg sectors/track) x 磁盤一面的磁道數(tracks/surface) x 磁盤的面數(surfaces/platter) x 硬盤包含的磁盤數(platters/disk)
假設我們現在已經從藍色區域讀取完了數據,接下來需要從紅色區域讀,首先需要尋址,把讀取的指針放到紅色區域所在的磁道,然后等待磁盤旋轉,旋轉到紅色區域之后,才可以開始真正的數據傳輸過程。
總的訪問時間 Taccess = 尋址時間 Tavg seek + 旋轉時間 Tavg rotation + 傳輸時間 Tavg transfer
- 尋址時間 Tavg seek 因為物理規律的限制,一般是 3-9 ms
- 旋轉延遲 Tavg rotation 取決於硬盤具體的轉速,一般來說是 7200 RPM,
T= (60s/min)/(2×PRM)
- 傳輸時間 Tavg tranfer 就是需要讀取的扇區數目
T= (60s/min)/RPM×1/(avg #sectors/track)
舉個例子,假設轉速是 7200 RPM,平均尋址時間 9ms,平均每個磁道的 sector 數目是 400,那么我們有:
- Tavg rotation = 1/2 x (60 secs / 7200 RPM) x 1000 ms/sec = 4 ms
- Tavg transfer = 60 / 7200 RPM x 1/400 secs/track x 1000 ms/sec = 0.02 ms
- Taccess = 9 ms + 4 ms + 0.02 ms
從這里可以看出,主要決定訪問時間的是尋址時間和旋轉延遲;讀取一個扇區的第一個比特是非常耗時的,之后的都幾乎可以忽略不計;硬盤比 SRAM 慢 40,000 倍,比 DRAM 慢 2500 倍。
最后需要知道的就是邏輯分區和實際的物理分區的區別,為了使用方便,會用連續的數字來標志所有可用的扇區,具體的映射工作由磁盤控制器完成。
磁盤控制器的主要功能包括: 邏輯分區到物理分區的映射、 磁盤的操作控制、緩存、中斷
固態硬盤
一個SSD由一個或多個閃存芯片和一個閃存翻譯層組成,閃存芯片替代傳統磁盤中的機械驅動器,閃存翻譯層是一個硬件/固件設備,扮演與磁盤控制器相同的角色,將對邏輯塊的請求翻譯成對底層物理設備的訪問。
固態硬盤中分成很多的塊(Block),每個塊又有很多頁(Page),大約 32-128 個,每個頁可以存放一定數據(大概 4-512KB),頁是進行數據讀寫的最小單位。但是有一點需要注意,對一個頁進行寫入操作的時候,需要先把整個塊清空(設計限制),而一個塊大概在 100,000 次寫入之后就會報廢。
與傳統的機械硬盤相比,固態硬盤在讀寫速度上有很大的優勢。但是因為設計本身的約束,連續訪問會比隨機訪問快,但是如果需要寫入 Page,那么需要移動其他 Page,擦除整個 Block,然后才能寫入,則相對慢。現在固態硬盤的讀寫速度差距已經沒有以前那么大了,但是仍然有一些差距。
不過與機械硬盤相比,固態硬盤存在一個具體的壽命限制,價格也比較貴,但是因為速度上的優勢,越來越多設備開始使用固態硬盤。
存儲技術趨勢
現代計算機頻繁地使用基於SRAM的高速緩存,試圖彌補 處理器-內存 之間的差距。這種方法行之有效是因為應用程序的一個稱為局部性原理的基本屬性。
局部性原理
- 時間局部性(Temporal Locality): 如果一個信息項正在被訪問,那么在近期它很可能還會被再次訪問。程序循環、堆棧等是產生時間局部性的原因。
- 空間局部性(Spatial Locality): 在最近的將來將用到的信息很可能與現在正在使用的信息在空間地址上是臨近的
- 順序局部性(Order Locality): 在典型程序中,除轉移類指令外,大部分指令是順序進行的。順序執行和非順序執行的比例大致是5:1。此外,對大型數組訪問也是順序的。指令的順序執行、數組的連續存放等是產生順序局部性的原因。
sum = 0; for (i = 0; i < n; i++){ sum += a[i]; } return sum;
這里每次循環都會訪問 sum
是滿足時間局部性的;數組的訪問是連續的,屬於空間局部性。
根據這個特性,在寫遍歷數組的時候(尤其是高維),尤其要注意按照內存排列順序來訪問,不然性能會慘不忍睹。程序員應該理解局部性原理:有良好局部性的程序比局部性差的程序運行得更快。在硬件層,高速緩存存儲器的小而快速的存儲器來保存最近被引用的指令和數據項,從而提高對主存的訪問速度。在操作系統級,局部性原理允許系統使用主存作為虛擬地址空間最近被引用塊的高速緩存,用主存來緩存磁盤文件系統中最近被使用的緩存塊。
存儲器層次結構
存儲器層次結構呈金字塔狀。從上至下,設備的訪問速度越來越慢,容量越來越大,單位字節的造價越來越低。
存儲器層次結構的主要思想是上一層存儲器作為下一層存儲器的高速緩存。
利用局部性原理,程序會更傾向於訪問第 k 層的數據,而非第 k+1 層,這樣就減少了訪問時間。
緩存管理
- 緩存命中:當程序需要 k+1 層的某個數據對象 d 時,它首先在當前存儲在第 k 層的一個塊中查找 d 。如果 d 剛好緩存在第 k 層中,那么就稱為緩存命中。
- 緩存不命中:如果第k層中沒有緩存數據對象d,那么就是緩存不命中。
- 強制性失效(Cold/compulsory Miss): CPU 第一次訪問相應緩存塊,緩存中肯定沒有對應數據,這是不可避免的
- 沖突失效(Confilict Miss): 在直接相聯或組相聯的緩存中,不同的緩存塊由於索引相同相互替換,引起的失效叫做沖突失效。
- 容量失效(Capacity Miss): 有限的緩存容量導致緩存放不下而被替換,被替換出去的緩存塊再被訪問,引起的失效叫做容量失效
- 覆蓋:當發生緩存不命中的時候,第k層的緩存會從第k+1層的緩存中取出包含 d 的那個塊。如果第k層的緩存已經滿了,可能就會覆蓋現存的一個塊。
- 緩存管理的各種政策
高速緩沖存儲器
高速緩存存儲器是由硬件自動管理的 SRAM 內存,CPU 會首先從這里找數據,其所處的位置如下(藍色部分):
高速緩沖存儲器的組成有三個部分:
- S 表示集合(set)數量
- E 表示數據行(line)的數量
- B 表示每個緩存塊(block)保存的字節數目
緩存中存放數據的空間大小為:C=S×E×B
實際上可以理解為三種層級關系,對應不同的索引,這樣分層的好處在於,通過層級關系簡化搜索需要的時間,並且和字節的排布也是一一對應的。當處理器需要訪問一個地址時,會先在高速緩沖存儲器中進行查找,查找過程中我們首先在概念上把這個地址划分成三個部分:
讀取過程:
具體在從緩存中讀取一個地址時,首先我們通過 set index 確定要在哪個 set 中尋找,確定后利用 tag 和同一個 set 中的每個 line 進行比對,找到 tag 相同的那個 line,最后再根據 block offset 確定要從 line 的哪個位置讀起(這里的 line 和 block 是一個意思)。這個過程可以記為:組選擇+行匹配+字提取
寫入過程:
在整個存儲層級中,不同的層級可能會存放同一個數據的不同拷貝(如 L1, L2, L3, 主內存, 硬盤)。如果發生寫入命中的時候(也就是要寫入的地址在緩存中有),有兩種策略:
- Write-through: 命中后更新緩存,同時寫入到內存中
- Write-back: 直到這個緩存需要被置換出去,才寫入到內存中(需要額外的 dirty bit 來表示緩存中的數據是否和內存中相同,因為可能在其他的時候內存中對應地址的數據已經更新,那么重復寫入就會導致原有數據丟失)
在寫入 miss 的時候,同樣有兩種方式:
- Write-allocate: 載入到緩存中,並更新緩存(如果之后還需要對其操作,這個方式就比較好)
- No-write-allocate: 直接寫入到內存中,不載入到緩存
這四種策略通常的搭配是:
- Write-through + No-write-allocate
- Write-back + Write-allocate
其中第一種可以保證絕對的數據一致性,第二種效率會比較高(通常情況下)。
高速緩存類型
根據每個組的高速緩存行數E,高速緩存被分為不同的類。
直接映射(Direct Mapped Cache)
每個組只有一行的高速緩存。映射方式:Y=X mod N
- 組選擇:從w的地址空間中間抽取 s 個組索引位。
- 行匹配:當且僅當設置了有效位,並且tag與w地址中的tag匹配的時候,緩存中存在w的副本。
- 字選擇:塊偏移量提供了所需要的第一個字節的偏移。
- 不命中時的行替換
每個組只包含有一行,因此直接用新取出的行替換當前的行即可(根據組索引來定位)。
全相聯高速緩存(fully assocative cache)
一個組包含所有的行。E=C/B (C: cache size B: block size )
映射方式:允許主存中每一個字塊映射到Cache中的任何一塊位置上。(當Cache查找時,所有標記都需要進行比對)
- 組選擇:只有一個組,所以地址中沒有組索引。
- 行匹配和字選擇
標記和數據塊一起存放在高速緩存中,當進行搜索的時候,CPU會將主存的標記域與Cache中的所有合法標記域進行比對(按內容尋址),如果比對成功,則找到,否則發生miss。如果Cache已經裝滿了,需要使用一種置換算法來決定從cache中丟棄的數據塊(victim block),最簡單的置換算法是FIFO,但較少使用,還有LRU等其他算法。
全相聯映射降低了塊的沖突率,提高了cache的命中率,但是增加了tag的位數,同時需要特殊的硬件支持,成本高,通常使用在小容量的Cache中。
(tag field唯一確定和標識一個數據塊,增加了位數就需要更多的存儲容量支持)
組關聯高速緩存(set associtative cache)
每一組保存多於一個小於C/B的高速緩存行。優化沖突不命中的問題。
映射方式:將數據塊映射到由幾個高速緩存塊組成的某個塊組中,同一個高速緩存中的所有組的大小必須相同。
- 組選擇:與直接映射相同,組索引表示組
- 行匹配:需要檢查多個行的標記位和有效位,確定所有請求的字是否在集合中。
- 字選擇:跟之前一樣
- 行替換: 如果沒有空行,需要進行抉擇:隨機選擇 —> 利用局部性原理來替換將來引用可能性最小的行 如 最不常使用、最近最少使用
編寫高速緩存友好的代碼
- 讓最常見的情況運行的快。把注意力集中在核心函數里的循環上。
- 盡量減少每個循環內部的緩存不命中數量: 對局部變量的反復引用,步長為1的引用模式;行優先訪問
置換策略
最佳替換算法的基本思想是:替換掉在未來最長時間段內不再使用的高速緩存塊
LRU 最近最少被使用:保留訪問記錄,需要存儲空間,減慢緩存速度
LRU是最近最少使用頁面置換算法(Least Recently Used),也就是首先淘汰最長時間未被使用的頁面!
LFU是最近最不常用頁面置換算法(Least Frequently Used),也就是淘汰一定時期內被訪問次數最少的頁!
LRU關鍵是看頁面最后一次被使用到發生調度的時間長短;
而LFU關鍵是看一定時間段內頁面被使用的頻率!
FIFO 先進先出
隨機選擇:
有效存取時間和命中幾率
EAT(effective access time):每次訪問所需要的平均時間
H為命中率,Access_c是高速緩存的訪問時間,Access_M是主存儲器的訪問時間
異常控制流和進程管理
異常控制流
從開機到關機,處理器做的工作其實很簡單,就是不斷讀取並執行指令,每次執行一條,整個指令執行的序列,稱為處理器的控制流。到目前為止,我們已經學過了兩種改變控制流的方式:
- 跳轉和分支
- 調用和返回
這兩個操作對應於程序的改變。但是這實際上僅僅局限於程序本身的控制,沒有辦法去應對更加復雜的情況。系統狀態發生變化的時候,無論是跳轉/分支還是調用/返回都是無能為力的,比如:
- 數據從磁盤或者網絡適配器到達
- 指令除以了零
- 用戶按下 ctrl+c
- 系統的計時器到時間
這時候就要輪到另一種更加復雜的機制登場了,稱之為異常控制流(exceptional control flow)。首先需要注意的是,雖然名稱里包含異常(實際上也用到了異常),但是跟代碼中 try catch 所涉及的異常是不一樣的。
異常控制流存在於系統的每個層級,最底層的機制稱為異常(Exception),用以改變控制流以響應系統事件,通常是由硬件的操作系統共同實現的。更高層次的異常控制流包括進程切換(Process Context Switch)、信號(Signal)和非本地跳轉(Nonlocal Jumps),也可以看做是一個從硬件過渡到操作系統,再從操作系統過渡到語言庫的過程。進程切換是由硬件計時器和操作系統共同實現的,而信號則只是操作系統層面的概念了,到了非本地跳轉就已經是在 C 運行時庫(應用層)中實現的了。
異常 Exception
這里的異常指的是把控制交給系統內核來響應某些事件(例如處理器狀態的變化),其中內核是操作系統常駐內存的一部分,而這類事件包括除以零、數學運算溢出、頁錯誤、I/O 請求完成或用戶按下了 ctrl+c 等等系統級別的事件。
系統會通過異常表(Exception Table)來確定跳轉的位置,每種事件都有對應的唯一的異常編號,發生對應異常時就會調用對應的異常處理代碼
異常的類型
異步異常(中斷)
異步異常(Asynchronous Exception)稱之為中斷(Interrupt),是由處理器外面發生的事情引起的。對於執行程序來說,這種“中斷”的發生完全是異步的,因為不知道什么時候會發生。CPU對其的響應也完全是被動的,但是可以屏蔽掉[1]。這種情況下:
- 需要設置處理器的中斷指針(interrupt pin)
- 處理完成后會返回之前控制流中的『下一條』指令
比較常見的中斷有兩種:計時器中斷和 I/O 中斷。計時器中斷是由計時器芯片每隔幾毫秒觸發的,內核用計時器終端來從用戶程序手上拿回控制權。I/O 中斷類型比較多樣,比方說鍵盤輸入了 ctrl-c,網絡中一個包接收完畢,都會觸發這樣的中斷。
同步異常
同步異常(Synchronous Exception)是因為執行某條指令所導致的事件,分為陷阱(Trap)、故障(Fault)和終止(Abort)三種情況。
類型 | 原因 | 行為 | 示例 |
陷阱 | 有意的異常 | 返回到下一條指令 | 系統調用,斷點 |
故障 | 潛在可恢復的錯誤 | 返回到當前指令 | 頁故障(page faults) |
終止 | 不可恢復的錯誤 | 終止當前程序 | 非法指令 |
這里需要注意三種不同類型的處理方式,比方說陷阱和中斷一樣,會返回執行『下一條』指令;而故障會重新執行之前觸發事件的指令;終止則是直接退出當前的程序。
總結
- 中斷是來自處理器外部的I/O設備的信號的結果。中斷是I/O設備與處理器異步工作的重要機制。
- 陷阱是有意的異常,是執行一條指令的結果。陷阱最重要的用途是在用戶程序和內核之間提供系統調用的接口。
- 故障由錯誤情況引起,它可能被故障處理程序所修正。如果修正了這個錯誤,就將控制返回到故障指令重新執行,否則返回到內核的終止例程。
- 終止是不可恢復的致命錯誤造成的結果。
系統調用示例
系統調用看起來像是函數調用,但其實是走異常控制流的,在 x86-64 系統中,每個系統調用都有一個唯一的 ID,如
編號 | 名稱 | 描述 |
0 | read |
讀取文件 |
1 | write |
寫入文件 |
2 | open |
打開文件 |
3 | close |
關閉文件 |
4 | stat |
獲取文件信息 |
57 | fork |
創建進程 |
59 | execve |
執行一個程序 |
60 | _exit |
關閉進程 |
62 | kill |
向進程發送信號 |
舉個例子,假設用戶調用了 open(filename, options)
,系統實際上會執行 __open
函數,也就是進行系統調用 syscall
,如果返回值是負數,則是出錯。
故障示例
這里我們以 Page Fault 為例,來說明 Fault 的機制。Page Fault 發生的條件是:
- 用戶寫入內存位置
- 但該位置目前還不在內存中
那么系統會通過 Page Fault 把對應的部分載入到內存中,然后重新執行賦值語句:
進程
進程是程序(指令和數據)的真正運行實例。
進程給每個應用提供了兩個非常關鍵的抽象:一是邏輯控制流,二是私有地址空間。邏輯控制流通過稱為上下文切換(context switching)的內核機制讓每個程序都感覺自己在獨占處理器。私有地址空間則是通過稱為虛擬內存(virtual memory)的機制讓每個程序都感覺自己在獨占內存。這樣的抽象使得具體的進程不需要操心處理器和內存的相關適宜,也保證了在不同情況下運行同樣的程序能得到相同的結果。
進程切換 Process Context Switch
左邊是單進程的模型,內存中保存着進程所需的各種信息,因為該進程獨占 CPU,所以並不需要保存寄存器值。而在右邊的單核多進程模型中,虛線部分可以認為是當前正在執行的進程,因為我們可能會切換到其他進程,所以內存中需要另一塊區域來保存當前的寄存器值,以便下次執行的時候進行恢復(也就是所謂的上下文切換)。整個過程中,CPU 交替執行不同的進程,虛擬內存系統會負責管理地址空間,而沒有執行的進程的寄存器值會被保存在內存中。切換到另一個進程的時候,會載入已保存的對應於將要執行的進程的寄存器值。
而現代處理器一般有多個核心,所以可以真正同時執行多個進程。這些進程會共享主存以及一部分緩存,具體的調度是由內核控制的,示意圖如下:
多任務分為搶占式多任務和協作式多任務。切換進程時,內核會負責具體的調度,來決定運行哪一個進程:
線程
- 為什么要引入線程?
引入進程的目的是為了更好地使多道程序並發執行,以提高資源利用量和系統吞吐量,增加並發程度,(滿足功能需求)。引入線程,則是為了減小程序在並發執行時所付出的時空開銷,提高操作系統的並發性能。引入線程后,進程的內涵發生改變,只作為除CPU以外系統資源的分配單元,線程則作為處理器的分配單元。有了線程之后,線程切換時,有可能候會發生進程切換,也有可能不發生進程切換,平均下來,每次切換所需要的開銷就小了,讓更多的線程參與並發,也不會影響到響應時間的問題,提高系統並發性。
- 什么是多線程?
操作系統的一項能力來支持多個線程在一個進程內執行。
- 用戶態和內核態?
用戶級線程相對內核級的優點:(1):所有線程管理數據結構都在一個進程的用戶地址空間中,線程切換不需要內核態特權,進程不需要為了線程管理而切換到內核態,節省了兩次狀態轉換的開銷;(2) 調度可以是一個用程序相關的,為應用程序量身定做調度算法而不擾亂底層的調度程序;(3) 用戶級線程可以在任何操作系統中運行,不需要對底層內核進行修改。線程庫是一組供所有應用程序共享的應用程序級別的函數。
用戶級線程相對於內核級的缺點:(1) 在典型的操作系統中,許多系統調用都會引起阻塞。當一個線程阻塞,整個進程阻塞(2) 一個多線程應用程序不能利用多處理技術。
Windows 進程API