csapp大作業--hello的一生
摘 要
本文以hello程式切入點,研究一個程式從一個高階C語言程式開始,經過預處理、編譯、彙編、連結等到最後變成一個可執行檔案的生命週期,以此來了解系統如何通過硬體和系統軟體的交織、共同協作以達到執行應用程式的最終目的。將全書內容融會貫通,幫助深入理解計算機系統。
關鍵詞:計算機系統;程序;彙編;抽象
目 錄
第1章 概述 - 4 -
1.1 HELLO簡介 - 4 -
1.2 環境與工具 - 4 -
1.3 中間結果 - 4 -
1.4 本章小結 - 4 -
第2章 預處理 - 5 -
2.1 預處理的概念與作用 - 5 -
2.2在UBUNTU下預處理的命令 - 5 -
2.3 HELLO的預處理結果解析 - 5 -
2.4 本章小結 - 6 -
第3章 編譯 - 7 -
3.1 編譯的概念與作用 - 7 -
3.2 在UBUNTU下編譯的命令 - 7 -
3.3 HELLO的編譯結果解析 - 7 -
3.4 本章小結 - 13 -
第4章 彙編 - 14 -
4.1 彙編的概念與作用 - 14 -
4.2 在UBUNTU下彙編的命令 - 14 -
4.3 可重定位目標ELF格式 - 14 -
4.4 HELLO.O的結果解析 - 16 -
4.5 本章小結 - 18 -
第5章 連結 - 19 -
5.1 連結的概念與作用 - 19 -
5.2 在UBUNTU下連結的命令 - 19 -
5.3 可執行目標檔案HELLO的格式 - 19 -
5.4 HELLO的虛擬地址空間 - 21 -
5.5 連結的重定位過程分析 - 22 -
5.6 HELLO的執行流程 - 23 -
5.7 HELLO的動態連結分析 - 24 -
5.8 本章小結 -24 -
第6章 HELLO程序管理 - 25 -
6.1 程序的概念與作用 - 25 -
6.2 簡述殼SHELL-BASH的作用與處理流程 - 25 -
6.3 HELLO的FORK程序建立過程 - 25 -
6.4 HELLO的EXECVE過程 - 25 -
6.5 HELLO的程序執行 - 26 -
6.6 HELLO的異常與訊號處理 - 27 -
6.7本章小結 - 29 -
第7章 HELLO的儲存管理 - 30 -
7.1 HELLO的儲存器地址空間 - 30 -
7.2 INTEL邏輯地址到線性地址的變換-段式管理 - 30 -
7.3 HELLO的線性地址到實體地址的變換-頁式管理 - 30 -
7.4 TLB與四級頁表支援下的VA到PA的變換 - 31 -
7.5 三級CACHE支援下的實體記憶體訪問 - 32 -
7.6 HELLO程序FORK時的記憶體對映 - 32 -
7.7 HELLO程序EXECVE時的記憶體對映 - 33 -
7.8 缺頁故障與缺頁中斷處理 - 33 -
7.9動態儲存分配管理 - 34 -
7.10本章小結 - 36 -
第8章 HELLO的IO管理 - 37 -
8.1 LINUX的IO裝置管理方法 - 37 -
8.2 簡述UNIX IO介面及其函式 - 37 -
8.3 PRINTF的實現分析 - 38 -
8.4 GETCHAR的實現分析 - 40 -
8.5本章小結 - 40 -
結論 - 41 -
附件 - 42 -
參考文獻 - 43 -
第1章 概述
1.1 Hello簡介
程式設計師通過編輯器鍵入程式碼並儲存得到hello.c。hello.c經cpp預處理得到hello.i,然後經ccl編譯得到hello.s,再經as彙編得到hello.o,最後通過ld連結得到可執行檔案hello。於是hello由program變成process,此為P2P過程。
使用者輸出命令,bash為hello檔案fork出一個子程序,並用execve來載入程式,對映虛擬記憶體,然後在開始執行程序的時候分配並載入實體記憶體,再進入CPU執行程式。程式執行完畢後,程序被bash回收,記憶體中資料也被清除,此為020過程。
1.2 環境與工具
硬體環境:X64 CPU;2GHz;2G RAM;256GHD Disk
軟體環境:Windows 10;Vmware 16;Ubuntu 16.04
開發工具:Codeblocks;vim;gcc;readelf;HexEdit
1.3 中間結果
hello.c 源程式
hello.i 預處理之後的文字檔案
hello.s 編譯之後的彙編檔案
hello.o 彙編之後的可重定位目標執行檔案
hello 連結之後的可執行目標檔案
hello_o.objdump hello.o的反彙編檔案
hello_o.elf hello.o的ELF格式檔案
hello.objdump hello的反彙編檔案
hello.elf hello的ELF檔案
1.4 本章小結
簡述了P2P和020過程,並列出了環境和工具、中間結果,是對實驗的總括。
第2章 預處理
2.1 預處理的概念與作用
概念:前處理器(cpp)根據以字元#開頭的命令,修改原始的C程式,讀取系統標頭檔案的內容,並把它們直接插入程式文字中,得到.i副檔名檔案。
作用:
呼叫系統標頭檔案
用實際值替換用#define 定義的字串
簡化了程式,使編譯器翻譯程式時更加方便
2.2在Ubuntu下預處理的命令
在終端輸入命令cpp hello.c > hello.i
圖2.1 使用 cpp 命令生成 hello.i 檔案
2.3 Hello的預處理結果解析
開啟hello.i發現,程式已被拓展為3000多行,main函式從3110行開始,如下圖:
圖 2.2 hello.i中main函式的位置
main函式在檔案最後的位置,之前被插入了各種標頭檔案和會用到的函式的宣告,如下圖:
圖2.3 hello.i中標頭檔案
圖2.4 hello.i中呼叫函式的宣告
2.4 本章小結
主要介紹了預處理的過程、定義與作用,並結合實驗對預處理結果進行了解析。
第3章 編譯
3.1 編譯的概念與作用
概念:編譯器(ccl)將文字檔案hello.i翻譯成文字檔案hello.s, 它包含一個組合語言程式。
作用:生成語法樹並轉化為目的碼,使下一步轉成二進位制程式碼更加方便。
3.2 在Ubuntu下編譯的命令
在終端輸入命令gcc -S hello.i -o hello.s
圖3.1 使用gcc命令生成64位的hello.s檔案
3.3 Hello的編譯結果解析
3.3.1 hello.s中的標識
圖3.2 hello.s檔案中的標識
.file 原始檔
.data 資料段
.globl 全域性識別符號
.string 字串型別
.long long型別
.text 程式碼段
.align 對齊方式
3.3.2 資料
一、字串
圖3.3 hello.s中宣告字串
在.rodata只讀資料節聲明瞭兩個字串“Usage: Hello 學號 姓名!\n”和“Hello %s %s\n”作為兩個printf的格式化引數。
二、整數
程式中包含的整數型別資料有全域性變數sleepsecs,區域性變數i和main的引數argc。
1.sleepsecs
圖3.4 hello.s中全域性變數sleepsecs的宣告
可見將sleepsecs定義為全域性變數,對齊方式為4、大小為4位元組、型別為long、初始值賦為2。
2.i
區域性變數沒有特殊宣告,只有在呼叫的時候才會儲存在暫存器或棧中。比對彙編程式碼與C語言程式碼,我們可以發現在hello.s中,i儲存在棧上空間-4(%rbp)中,佔有4個位元組。如下圖劃線部分所示:
圖3.5 hello.s中的區域性變數i
3.argc
作為main的引數傳入,出現在main的棧中,不需要宣告,直接呼叫即可。如下圖劃線部分:
圖3.6 hello.s中main的引數argc
三、陣列
陣列char *argv[]作為main的引數,沒有單獨宣告,出現main的棧中,直接使用即可,如下圖劃線部分:
圖3.7 hello.s中main的引數陣列argv
3.3.3 數值操作
一、賦值操作
1.對全域性變數sleepsecs賦值
在開頭對於全域性變數的宣告中賦初值,上文已有說明和截圖,不再贅述。
2.對區域性變數i賦值
由於i佔4個位元組,使用movl語句實現賦值,如下圖:
圖3.8 hello.s中對區域性變數的賦值
二、運算操作
1.減法:SUB S,D D=D-S
圖3.9 hello.s中的減法
2.加法:ADD S,D D=D+S
圖3.10 hello.s中的加法
3.乘法:
IMULQ S R [%rdx]:R[%rax]=SR[%rax](有符號)
MULQ S R [%rdx]:R[%rax]=SR[%rax](無符號)
4.除法:
IDIVQ S R [%rdx]=R[%rdx]:R[%rax] mod S(有符號)R[%rax]=R[%rdx]:R[%rax] div S
DIVQ S R [%rdx]=R[%rdx]:R[%rax] mod S(無符號) R[%rax]=R[%rdx]:R[%rax] div S
3.3.4 關係操作與控制語句
一、關係操作
主要有cmp和test,如下圖:
圖3.11 比較指令
二、控制(跳轉)語句
組合語言中的跳轉語句如下圖:
圖3.12 跳轉指令
hello.s中用到了多種控制指令,通過這些指令的組合可以實現語句的跳轉、for、while迴圈等結構功能,如下圖劃線部分:
圖3.13 hello.s中控制語句
3.3.5 對陣列的操作
取陣列的第i位一般是去陣列頭指標再加上第i位的偏移量得到。首先從-32(%rbp)讀取argv地址存入rax,然後rax分別增加8個位元組、16個位元組,得到argv[1]、argv[2]的地址存入rax中,讀取此地址指向的值即是讀取argv[1]、argv[2]的值。
圖3.14 hello.s中對陣列的操作
3.3.6 函式操作
一、呼叫printf
以“printf(“Hello %s %s\n”,argv[1],argv[2]);”為例,將之前定義好的格式封裝如一個暫存器%edi中傳遞,再將所需要的數值分別放在暫存器%rsi、%rdx中,準備好引數後,使用命令call呼叫printf。Printf的返回值會被存放在%eax中並返回。
圖3.15 hello.s中呼叫printf函式
二、呼叫getchar和exit
無需準備引數,直接用call呼叫即可。
圖3.16 hello.s中呼叫exit和getchar函式
三、main函式返回值
若有返回值,先將返回值傳入暫存器%eax,然後leave使呼叫的棧空間恢復為之前的狀態,再執行ret。
圖3.17 hello.s中設定main返回值
3.4 本章小結
本章簡述了編譯的概念和作用,具體分析了一個c程式是如何被編譯器編譯成一個彙編程式的過程,還詳細分析了不同的c語句和翻譯成彙編語句之後的表示方法。
第4章 彙編
4.1 彙編的概念與作用
概念:編器(as)將hello.s翻譯成機器語言指令,把這些指令打包成 一種叫做可重定位目標程式(relocatable object program)的格式,並將結果儲存在目標 檔案hello.o中o hello.o檔案是一個二進位制檔案,它包含的17個位元組是函式main 的指令編碼。
作用:彙編,將程式碼轉成二進位制。
4.2 在Ubuntu下彙編的命令
在終端輸入命令as hello.s -o hello.o
圖4.1 使用 as 指令生成 hello.o 檔案
4.3 可重定位目標elf格式
ELF檔案的內容及各節基本資訊如下圖:
圖4.2 ELF檔案格式
輸入命令readelf –a hello.o > hello_o.elf可得到hello.o檔案的ELF格式。其組成如下:
1.ELF頭
以16位元組的序列Magic開始,描述了生成該檔案的系統 的字的大小和位元組順序。然後是含幫助連結器語法分析和解釋目標檔案的資訊,其中包括節頭大小、目標檔案的型別、機器型別、位元組頭部表的檔案偏移,以及節頭部表中條目的大小和數量等資訊,具體如下圖:
圖4.3 ELF頭
2.節頭
描述了.o檔案中各節的資訊,包括型別、位置、偏移量等。
圖4.4 節頭
3.重定位節
重定位節是.test節中位置的列表包含.text節中需要進行重定位的資訊,當連結器把這個目標檔案和其他檔案組合時,需要修改這些位置。可見本程式中需要重新定位的資訊有printf、puts、exit、sleep、getchar函式,還有全域性變數sleepsecs,以及.rodata中的兩個元素.L0和.L1,具體如下圖所示:
圖4.5 重定位節
4.4 Hello.o的結果解析
輸入命令objdump -d -r hello.o > hello_o.objdump可得到反彙編檔案的文字檔案形式,下面對照hello.s進行分析比較。我們顯然可以發現,二者的程式碼沒有太大差別,只在一些語句的使用上有微小的不同。
1.跳轉分支語句
hello.s中的跳轉是跳轉到分支的段名稱處如.L1、.L3,而反彙編檔案中的跳轉則是直接跳到具體的地址。
2.函式呼叫
hello.s中呼叫函式,直接call+函式名,而在反彙編檔案中則是呼叫標準庫中的函式,通過動態連結器確定函式的地址,在呼叫時需要call+函式地址。
3.全域性變數呼叫
hello.s檔案中未對main的地址進行賦值,而反彙編檔案則在開頭將main的首地址設為全0。在呼叫全域性變數時,.s檔案採用變數名(%rip)形式,而反彙編檔案則直接使用main的首地址,運用0x0(%rip)的方式。
4.十六進位制機器程式碼
反彙編檔案相較於.s檔案,每一行在相對地址後很明顯地多了一塊十六進位制程式碼。這些程式碼是每一行的彙編語句對應的機器指令。機器指令完全由0/1構成,這裡將其轉換成十六進位制顯示。
圖4.6 反彙編檔案與.s檔案的對比
4.5 本章小結
本章簡述了彙編的概念和作用,並分析了hello.s彙編指令被轉換成hello.o機器指令的過程,通過readelf檢視hello.o的ELF、反彙編的方式查看了hello.o反彙編的內容,比較其與hello.s之間的差別。學習了彙編指令對映到機器指令的具體方式。
第5章 連結
5.1 連結的概念與作用
概念:連結是將各種程式碼和資料片段收集並組合成一個單一檔案的過程,這個檔案可被載入到記憶體並執行。連結可以執行於程式編譯、載入、執行時。
作用:可以將一個大型程式分解為更小、更好管理的模組,並獨立地修改、編譯這些模組,降低了程式設計的難度。
5.2 在Ubuntu下連結的命令
輸入命令
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 /usr/lib/x86_64-linux-gnu/crt1.o /usr/lib/x86_64-linux-gnu/crti.o /usr/lib/gcc/x86_64-linux-gnu/5/crtbegin.o hello.o -lc /usr/lib/gcc/x86_64-linux-gnu/5/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o -z relro -o hello
圖 5.1 使用ld命令連結生成可執行程式hello
5.3 可執行目標檔案hello的格式
輸入命令reaadelf –a hello > hello.elf可以得到hello的ELF檔案,具體內容如下,關於ELF檔案的結構和具體內容上一章已經介紹過,此處不再贅述。
1.ELF頭
圖5.2 hello的ELF檔案的ELF頭
2.節頭部表
存放了每個節的大小,每個節在程式中的偏移量和程式被載入後各段的虛擬地址。
圖5.3 hello的ELF檔案的節頭
3.程式頭表
圖5.4 hello的ELF檔案的程式頭
5.4 hello的虛擬地址空間
將hello拖入edb中執行,在Data Dump中可以看到程式在0x4000000x401000段中載入,從虛擬地址0x400000開始,到0x401000結束。其餘節.dynamic.shstrtab存放在虛擬地址0x40fff之後。
圖5.5 hello在edb中執行程式虛擬地址
我們再回到圖5.4所示的程式頭表。程式頭表在執行的時候被使用,它告訴連結器執行時載入的內容並提供動態連結的資訊。每一個表項提供了各段在虛擬地址空間和實體地址空間的大小、位置、標誌、訪問許可權和對齊等資訊。各表項功能如下:
PHDR:程式頭表
INTERP:程式執行前需要呼叫的直譯器
LOAD:儲存常量資料、程式目的碼等
DYNAMIC:儲存動態連結器使用資訊
NOTE:儲存輔助資訊
GNU_STACK:異常標記
GNU_RELRO:儲存重定位後只讀區域的位置
5.5 連結的重定位過程分析
輸入命令objdump -d -r hello > hello.objdump反彙編hello,我們可以很明顯地看到,這個反彙編檔案比之前的hello_o.objdump長了很多,因為多了很多節,我們選取一部分列出:
.interp:儲存ld.so的路徑
.gnu.hash:GNU拓展的符號的雜湊表
.dynsym:執行時/動態符號表
.rela.dyn:執行時/動態重定位表
.plt:動態連結-過程連結表
.rela.plt:.plt節的重定位條目
.init:程式初始化需要執行的程式碼
.fini:當程式正常終止時需要執行的程式碼
.dynamic:存放被ld.so使用的動態連結資訊
.got:動態連結-全域性偏移量表-存放變數
.got.plt:動態連結-全域性偏移量表-存放函式
通過比較 hello.objdump 和 hello_o.objdump 瞭解連結器。
在進行連結後,程式將呼叫標準庫中的函式加入到了程式的.plt節中,呼叫函式時,hello_o.objdump採用call+<相對main的偏移量>的方法,而在hello.objdump則可以使用call+<函式名>的方法。具體可看下圖中劃線部分:
圖5.6 比較hello.objdump和hello_o.objdump
5.6 hello的執行流程
使用 edb 執行 hello,觀察函式執行流程,將過程中執行的主要函式列在下面:
ld-2.23.so!_dl_start 0x00007f5ffaf54a50
ld-2.23.so!_dl_setup_hash 0x00007f5ffaf54a50
ld-2.23.so!_dl_sysdep_start 0x00007fcfbfcee210
ld-2.23.so!_dl_init 0x00007fcfbfce5740
[email protected] 0x00000000004004c0
ld-2.23.so!_dl_fixup 0x00007f9cb4e9c9f0
ld-2.23.so!_dl_lookup_symbol_x 0x00007f18464559d0
libc-2.23.so!__cxa_atexit 0x00007f18460bb280
libc-2.23.so!__new_exitfn 0x00007f18460bb0a0
hello!_init
libc-2.23.so!__sigjmp_save 0x00007f18460b6210
[email protected] 0x00000000004004a0
[email protected] 0x00000000004004e0
5.7 Hello的動態連結分析
對於動態共享連結庫中PIC函式,編譯器沒有辦法預測函式的執行時地址,所以需要新增重定位記錄,等待動態連結器處理,為避免執行時修改呼叫模組的程式碼段,連結器採用延遲繫結的策略。動態連結器使用過程連結表PLT+全域性偏移量表 GOT實現函式的動態連結,GOT中存放函式目標地址,PLT使用GOT中地址跳轉到目標函式。
圖5.7 呼叫dl_init前的.got.plt節
圖5.8 呼叫dl_init後的.got.plt節
5.8 本章小結
在本章中主要介紹了連結的概念與作用、hello 的ELF格式,使用edb分析了hello的虛擬地址空間、重定位過程、執行流程、動態連結過程。
第6章 hello程序管理
6.1 程序的概念與作用
概念:程序是一個執行中的程式的例項,系統中的每個程式都執行在某個程序的上下文中。上下文是由程式正確執行所需的狀態組成的。包括存放在記憶體中的程式的程式碼和資料等。
作用:為程式提供邏輯控制流、私有地址空間的抽象。使我們的程式看起來好像是系統中當前執行的唯一程式一樣。
6.2 簡述殼Shell-bash的作用與處理流程
作用:是使用者使用Linux的橋樑。為使用者提供了一個介面以訪問作業系統核心的服務。
處理流程:
1.讀取使用者輸入的命令
2.對輸入內容進行分割,獲取引數
3.判斷命令是否是內建命令,如果是則直接執行,否則呼叫相應的程式為其分配子程序並執行
4.時刻監控鍵盤的輸入訊號,並對其進行相應的處理
6.3 Hello的fork程序建立過程
輸入命令後終端程式對其進行分析,發現不是內建命令,然後終端會呼叫fork 函式建立一個新的子程序,新建立的子程序幾乎但不完全與父程序相同,子程序得到與父程序使用者級虛擬地址空間相同的(但是獨立的)一份副本,但與父程序擁有不同的PID。在執行期間父、子程序可以任意交替,父程序會預設等待子程序結束並回收。
6.4 Hello的execve過程
在fork子程序之後,execve函式呼叫記憶體中的啟動載入器來執行hello程式,載入器刪除子程序現有的虛擬記憶體段, 並建立一組新的程式碼、資料、堆和棧段,具體內容如下圖。最後,載入器設定PC指向_start地址,_start最終呼叫hello中的main函式。
圖6.1 啟動載入器內部結構
6.5 Hello的程序執行
輸入命令後,hello開始執行。在hello執行時,還有一些程式在併發地執行,稱為併發流,這些程式在執行過程中與hello來回切換,如下圖所示:
圖6.2 程序切換示意圖
hello初始執行在使用者模式,不久後呼叫sleep函式,定時器開始計時。Sleep函式使程序後陷入核心模式,核心處理休眠請求主動釋放當前程序,並排程其他程序。此時需要先進行上下文切換:
1)儲存以前程序的上下文
2)恢復新恢復程序被儲存的上下文
3)將控制傳遞給這 個新恢復的程序
整個過程在核心模式下完成。完成上下文切換後,新的程序以使用者模式進行。
當定時器到時時(2.5secs)傳送一箇中斷訊號,再次進入核心模式執行中斷處理,完成上下文切換後,hello程序進入使用者模式繼續向下執行,直到再一次呼叫其他函式,執行流程與上述類似。程式的具體切換過程如下圖:
圖6.3 程序模式切換
6.6 hello的異常與訊號處理
1.沒有亂按鍵盤
程式正常結束並被回收。
圖6.4 正常執行hello程式
2.輸入Ctrl+Z
該操作向程序傳送了一個SIGSTP訊號,將程序掛起,但輸入ps命令我們可以看到程序未被回收。我們可以用fg 1命令將其調至前臺繼續執行直至結束並被回收。
圖6.5 在中途按下Ctrl+Z
3.輸出Ctrl+C
向程序傳送了一個SIGINT訊號,程序處理後結束hello並將其回收。
圖6.6 在中途按下Ctrl+C
4.輸入Ctrl+Z後再輸入pstree命令
可以看到程序的程序樹。這裡擷取一部分如下圖:
圖6.7 程序樹
5.亂按
我們可以發現在程序中途亂按對程序的執行沒有什麼影響,程式還是會照常輸出。當然,你亂按輸入的字元也會被顯示在螢幕上。
圖6.8 在程序執行中途亂按
6.7本章小結
本章主要介紹了程序的定義與作用、shell 的一般處理流程,呼叫fork建立新程序的過程、execve函式的過程、hello的異常與訊號處理等內容。
第7章 hello的儲存管理
7.1 hello的儲存器地址空間
邏輯地址:又稱相對地址,址由選擇符和偏移量組成。
線性地址&虛擬地址:二者是一個概念,是經過段機制轉化之後用於描述程式分頁資訊的地址,是對程式執行區塊的一個抽象對映。分頁機制中線性地址作為輸入。
實體地址:CPU 通過地址匯流排的定址,找到真實的實體記憶體對應地址。
7.2 Intel邏輯地址到線性地址的變換-段式管理
真實模式下:邏輯地址=線性地址=實體地址
保護模式下:線性地址=段選擇符+段內偏移地址,段選擇符的結構如下圖:
圖7.1 段選擇符內部結構
我們先看T1字元是0/1確定要轉移的是GDT中的段還是LDT中的段,再根據指定的暫存器的地址和大小得到陣列。再拿出段選擇符的前13位,在陣列中查詢到對應的段描述符,可得基地址,再結合段內偏移量獲得線性地址。
7.3 Hello的線性地址到實體地址的變換-頁式管理
hello的線性地址到實體地址的轉換需要查詢頁表。線性地址分為兩個部分,虛擬頁號VPN和虛擬頁偏移量VPO。VPN用於在頁表查詢物理頁號PPN,再與VPO結合獲得實體地址。該過程如下圖所示:
圖7.2 線性地址轉換至實體地址
7.4 TLB與四級頁表支援下的VA到PA的變換
前提如下:虛擬地址空間48位,實體地址空間52位,頁表大小4KB,4級頁表。TLB4路16組相聯。由一個頁表大小4KB,一個PTE條目8B,共512個條目,使 用 9 位二進位制索引,一共 4 個頁表共使用 36 位二進位制索引,所以VPN共36位,因為VA共 48位,所以VPO佔12位;因為TLB共16組,所以 TLBI 需 4 位,因為 VPN共36 位,所以TLBT佔32位。具體結構如下圖:
圖7.3 虛擬地址中用以訪問TLB的組成部分
變換地址時,CPU產生一個虛擬地址,MMU從TLB中取出相應的PTE。如果命中,則得到對應的實體地址。如果不命中,VPN會被分成4個部分。MMU向頁表中查詢,CR3確定第一級頁表的起始地址,VPN1確定在第一級頁表中的偏移量,查詢出 PTE,如果在實體記憶體中且許可權符合,確定第二級頁表的起始地址,以此類推,最終在第四級頁表中查詢到PPN,與VPO組合成 PA,並且向TLB中新增條目。
圖7.4 四級頁表支援下VA到PA的轉換
7.5 三級Cache支援下的實體記憶體訪問
前提:L1 Cache 是8路64組相聯,塊大小為64B。由於有64組,所以組索引CI需要6 bit,塊大小為64B故組內偏移CO需要6bit。因為PA共52 bit所以剩餘部分CT共40 bit。
實體記憶體訪問時,MMU傳送PA給L1快取,快取記憶體根據CI找到組、CT找到地址,並根據標記位判斷該地址是否已快取資料。若命中,則根據偏移量CO找到值取出資料後返回。若不命中,則再一次查詢快取L2、L3。如果仍不命中,則要去主存中讀取資料。過程如下圖所示:
圖7.5 三級Cache下訪問物理快取的過程
7.6 hello程序fork時的記憶體對映
mm_struct(記憶體描述符):描述了一個程序的整個虛擬記憶體空間。
vm_area_struct(區域結構描述符):描述了程序的虛擬記憶體空間的一個區間。
當用fork建立一個程序時,核心為新程序建立各種資料結構,並分配給它一個唯一的PID,為了給這個新程序建立虛擬記憶體,它建立了當前程序的mm_struct、vm_area_struct和頁表的原樣副本。然後將這兩個兩個程序的每個頁面都標記為只讀,並將兩個程序中的每個區域結構都標記為私有的寫時複製。
當fork在新程序中返回時,新程序現在的虛擬記憶體剛好和呼叫fork時存在的虛擬記憶體相同。當這兩個程序中的任一個後來進行寫操作時,寫時複製機制就會建立新頁面,因此,也就為每個程序保持了私有地址空間的抽象概念。
7.7 hello程序execve時的記憶體對映
execve函式載入並執行hello的步驟如下:
1.刪除當前的使用者區域
2.對映私有區域:為新程式的程式碼、資料、bss和棧區域建立新的區域結構。
3.對映共享區域:hello程式與標準C庫連結,這些物件動態連結到這個程式,然後再對映到使用者虛擬地址空間中的共享區域內。
4.設定程式計數器。execve做得最後一件事情是設定當前程序上下文中的程式計數器,使之指向程式碼區域的入口點。
圖7.6 載入器對映使用者地址空間區域
7.8 缺頁故障與缺頁中斷處理
當指令引用一個虛擬地址,在MMU中查詢頁表 時發現與該地址相對應的實體地址不在記憶體中,此時即為缺頁故障。
當出發缺頁故障時,處理程式執行如下的步驟:
1.判斷虛擬地址是否合法
把該虛擬地址與每個區域結構中的vm_start和vm_end作比較。如果不合法,則傳送段錯誤訊號,終止這個程序。
2.判斷試圖訪問的記憶體是否合法
如果不合法則出發一個保護程序終止程序。
3.判斷地址和訪問記憶體都合法後
選擇犧牲一個頁面,如果這個頁面被修改過,那麼就將它交換出去,換入新的頁面並更新頁表。當缺頁處理程式返回時,CPU重新啟動引起缺頁的指令,這條指令再次傳送VA到MMU,這次MMU就能正常翻譯VA了。
圖7.7 Linux的缺頁處理
7.9動態儲存分配管理
動態記憶體分配器維護著一個程序的虛擬記憶體區域,稱為堆。分配器將堆視為 一組不同大小的塊的集合來維護。每個塊就是一個連續的虛擬記憶體片,要麼是已 分配的,要麼是空閒的。已分配的塊顯式地保留為供應用程式使用。空閒塊可用來分配。空閒塊保持空閒,直到它顯式地被應用所分配。一個已分配的塊保持已分配狀態,直到它被釋放,這種釋放要麼是應用程式顯式執行的,要麼是記憶體分配器自身隱式執行的。
圖7.8 堆
分配器分為兩種基本風格:顯式分配器、隱式分配器。
隱式分配器:要求分配器檢測一個已分配塊何時不再使用,那麼就釋放這個塊,自動釋放未使用的已經分配的塊的過程叫做垃圾收集。
顯式分配器:要求應用顯式地釋放任何已分配的塊。
一、 帶邊界標籤的隱式空閒連結串列
1.堆及堆中記憶體塊的組織結構:
圖7.9 使用邊界標記的堆塊的格式
2.隱式連結串列
所謂隱式空閒連結串列,對比於顯式空閒連結串列,代表並不直接對空閒塊進行連結,而是將對記憶體空間中的所有塊組織成一個大連結串列,其中header和footer中的block 大小間接起到了前驅、後繼指標的作用。
3.空閒塊合併
因為有了footer,所以我們可以方便的對前面的空閒塊進行合併。合併的情況一共分為四種:前空後不空,前不空後空,前後都空,前後都不空。對於四種情況分別進行空閒塊合併,我們只需要通過改變header和footer中的值就可以完成這一操作。
圖7.10 不同情況下的空閒塊合併
二、顯示空間連結串列基本原理
將空閒塊組織成連結串列形式的資料結構。堆可以組織成一個雙向空閒連結串列,在每個空閒塊中,都包含一個pred(前驅)和succ(後繼)指標,如下圖:
圖7.11 使用雙向空閒連結串列的堆塊的格式
使用雙向連結串列而不是隱式空閒連結串列,使首次適配的分配時間從塊總數的線性時間減少到了空閒塊數量的線性時間。
維護連結串列的順序有:後進先出(LIFO),將新釋放的塊放置在連結串列的開始處,使用 LIFO 的順序和首次適配的放置策略,分配器會最先檢查最近使用過的塊,在這種情況下,釋放一個塊可以線上性的時間內完成,如果使用了邊界標記,那麼合併也可以在常數時間內完成。按照地址順序來維護連結串列,其中連結串列中的每個塊的地址都小於它的後繼的地址,在這種情況下,釋放一個塊需要線性時間的搜尋來定位合適的前驅。平衡點在於,按照地址排序首次適配比LIFO排序的首次適配有著更高的記憶體利用率,接近最佳適配的利用率。
7.10本章小結
本章主要介紹了hello的儲存器地址空間、段式管理和頁式管理,以intel Core7 為例介紹了VA到PA的變換、實體記憶體訪問,以及程序fork、execve時的記憶體對映,缺頁故障與缺頁中斷處理,動態儲存分配管理等。
第8章 hello的IO管理
8.1 Linux的IO裝置管理方法
裝置的模型化:所有的 IO 裝置都被模型化為檔案,而所有的輸入和輸出都被 當做對相應檔案的讀和寫來執行,這種將裝置優雅地對映為檔案的方式,允許 Linux 核心引出一個簡單低階的應用介面,稱為 Unix I/O
檔案的型別:
1.普通檔案(regular file):包含任意資料的檔案。
2.目錄(directory):包含一組連結的檔案,每個連結都將一個檔名對映到一個檔案(他還有另一個名字叫做“資料夾”)。
3.套接字(socket):用來與另一個程序進行跨網路通訊的檔案
4.命名通道
5.符號連結
6.字元和塊裝置
裝置管理:unix io介面
1.開啟和關閉檔案
2.讀取和寫入檔案
3.改變當前檔案的位置
8.2 簡述Unix IO介面及其函式
1.int open(char *filename, int flags, mode_t mode);
open函式將filename轉換為一個檔案描述符,並且返回描述符數字,返回的描述符總是在程序中當前沒有開啟的最小描述符。
2.int close(fd);
fd是需要關閉的檔案的描述符,close返回操作結果,關閉一個已關閉的描述符會出錯。
3.ssize_t read(int fd,void *buf,size_t n);
read函式從描述符為fd的當前檔案位置複製最多n個位元組到記憶體位置buf。返回值-1表示一個錯誤,而返回值0表示EOF。否則,返回值表示的是實際傳送的位元組數量。
4.ssize_t wirte(int fd,const void *buf,size_t n)
write函式從記憶體位置buf複製至多n個位元組到描述符為fd的當前檔案位置。
5.int dup2(int oldfd, int newfd);
dup2函式複製描述符表表項oldfd到描述符表項newfd,覆蓋描述符表表項newfd以前的內容。如果newfd已經打開了,dup2會在複製oldfd之前關閉newfd。
8.3 printf的實現分析
檢視printf程式碼如下:
圖8.1 printf程式碼
我們可以看到在引數列表中,有…的寫法,當傳遞引數的個數不確定時,我們可以用這種寫法。很顯然,我們需要一種方法來確定具體呼叫時引數的個數。首先,由va_list的定義,arg獲得…中的第一個引數的地址。然後是vsprintf語句,我們檢視vsprintf程式碼如圖8.2。
由i=vsprintf(buf, fmt, arg);我們可以推測vsprintf函式返回要列印的字串的長度。所以vsprintf的作用就是格式化。它接受確定輸出格式的格式字串fmt。用格式字串對個數變化的引數進行格式化,產生格式化輸出。vsprintf 程式按照格式 fmt 結合引數 args 生成格式化之後的字串,並返回字串的長度。
圖8.2 vsprintf程式碼
我們再回到printf程式碼,接著程式呼叫了write,檢視write函式如下:
圖8.3 write函式
在 write 函式中,將棧中引數放入暫存器,ecx 是字元個數,ebx 存放第一個 字元地址,int INT_VECTOR_SYS_CALLA 代表通過系統呼叫sys_call,檢視 sys_call 的實現:
圖8.4 sys_call函式
syscall將字串中的位元組從暫存器中通過匯流排複製到顯示卡的視訊記憶體中,視訊記憶體中儲存的是字元的ASCII碼。
字元顯示驅動子程式:從ASCII到字模庫到顯示vram(儲存每一個點的RGB顏色資訊)。
顯示晶片按照重新整理頻率逐行讀取vram,並通過訊號線向液晶顯示器傳輸每一個點(RGB分量)。最終使要顯示的字串出現在螢幕上。
8.4 getchar的實現分析
非同步異常-鍵盤中斷的處理:當用戶按鍵時,鍵盤介面會得到一個代表該按鍵 的鍵盤掃描碼,同時產生一箇中斷請求,中斷請求搶佔當前程序執行鍵盤中斷子 程式,鍵盤中斷子程式先從鍵盤介面取得該按鍵的掃描碼,然後將該按鍵掃描碼 轉換成ASCII碼,儲存到系統的鍵盤緩衝區之中。getchar等呼叫read系統函式,通過系統呼叫讀取按鍵ASCII碼,直到接受到回車鍵才返回。
getchr函式落實到底層呼叫了系統函式read,通過系統呼叫read讀取儲存在鍵盤緩衝區中的ASCII碼直到讀到回車符然後返回整個字串,getchar進行封裝, 大體邏輯是讀取字串的第一個字元然後返回。
8.5本章小結
本章主要介紹了linux的I/O裝置管理機制,瞭解了開、關、讀、寫、轉移檔案的介面及相關函式,簡單分析了printf和getchar函式的實現方法以及操作過程。
結論
hello的一生主要經歷瞭如下階段:
- 鍵盤輸入程式,得到hello.c原始檔,hello誕生了
- 預處理:將hello.c呼叫的所有外部的庫展開合併到一個hello.i檔案中
- 編譯:將hello.i編譯成彙編檔案hello.s
- 彙編:將hello.s會變成為可重定位目標檔案hello.o
- 連結:連結器對hello.o進行連結得到可執行檔案hello,此時hello已經被作業系統載入和執行
- 執行hello:在終端輸入命令,shell程序呼叫fork為hello建立一個子程序,隨後呼叫execve啟動載入器,加對映虛擬記憶體,進入程式入口後程序開始載入實體記憶體,然後進入main函式
- 執行指令:CPU為其分配時間片,在一個時間片中hello享有CPU資源,順序執行自己的控制邏輯流
- 記憶體使用:MMU 將程式中使用的虛擬記憶體地址通過頁表對映成實體地址。printf 會呼叫 malloc 向動態記憶體分配器申請堆中的記憶體
- hello執行過程中可能會收到來自鍵盤等的訊號,收到訊號後呼叫訊號處理程式進行處理,可能會有停止、掛起等
- 程序結束:shell父程序回收子程序,核心刪除為這個程序建立的所有資料結構。
通過對hello的一生的學習、梳理,深入理解了現代計算機作業系統各部分之間的協作、呼叫,對系統各部分的設計思想、處理方式有了基本的認識瞭解。並通過這些對計算機有了新的看法,為一些問題的解決提供了新的思路。
附件
hello.c 源程式
hello.i 預處理之後的文字檔案
hello.s 編譯之後的彙編檔案
hello.o 彙編之後的可重定位目標執行檔案
hello 連結之後的可執行目標檔案
hello_o.objdump hello.o的反彙編檔案
hello_o.elf hello.o的ELF格式檔案
hello.objdump hello的反彙編檔案
hello.elf hello的ELF檔案
參考文獻
為完成本次大作業你翻閱的書籍與網站等
[1] 龔奕利. 深入理解計算機系統.北京:機械工業出版社,2016.
[2] CSDN https://www.csdn.net/
[3] Baidu https://www.baidu.com/
[4] printf函式實現的深入剖析:
https://www.cnblogs.com/pianist/p/3315801.html
[5] 邏輯地址、線性地址和實體地址之間的轉換:
https://blog.csdn.net/gdj0001/article/details/80135196
[6] Github https://github.com/