有關本文的PDF和相關附件,請移步GitHub:https://github.com/szm981120/CSAPP_lastwork
目錄
摘要
本文以Linux環境下,簡單的C程序hello.c從program到process的過程爲線索,介紹了GCC編譯系統的四個工作環節。又以運行hello程序爲核心,展開介紹了程序的進程管理,相關數據的存儲管理,和I/O管理。以《深入理解計算機系統》(第三版)爲主要參考文獻,結合實際操作,圖文並茂地介紹了一些具體的概念、原理和實踐。對讀者理解計算機中程序的一生有一定的幫助作用。
關鍵詞:編譯系統;進程;信號與異常;內存管理;Linux I/O管理
第1章 概述
1.1 Hello簡介
Hello的P2P(from program to process)過程,就是從編程到處理執行的過程。我們在編譯器中編寫高級語言代碼,比如常用的Visual Studio,codeblocks等,用編譯器構建(Build)成功後,就生成了一個.exe可執行程序文件。這只是P2P的表面過程。
P2P的深層過程,可以在Linux下編譯代碼中得到體現。假設我們在Linux環境下,用GCC編譯器驅動程序來編譯hello.c,有四個階段:預處理階段、編譯階段、彙編階段、鏈接階段,這四個階段的主角分別是:預處理器(cpp)、編譯器(ccl)、彙編器(as)和鏈接器(ld)。
經歷了這四個階段,hello.c就成爲了hello.out(Linux環境),P2P的過程就結束了。
Hello的O2O(from zero-0 to zero-0)過程,就是“赤條條地來,赤條條地走”。從在編輯工具裏編輯好hello程序之後,經過曲折的P2P之路,由hello.c經歷hello.i, hello.s, hello.o和其他.o一起鏈接成爲hello.out。接着,我們在進程中調用hello,實現了hello的邏輯控制流的意義所在。Execve函數運行起hello,在虛擬內存空間中給hello分配空間,又有地址翻譯把hello的虛擬地址翻譯成物理地址,硬件根據物理地址在主存中取址,形成軟硬件結合的運行體系。Hello運行結束後,進程終止,內存回收,內核把關於hello的一切數據全部抹去,這樣hello“揮一揮手,不帶走一片雲彩”。
不僅僅是針對hello,正是有了P2P和O2O,計算機中的程序才得以正常運行,有條不紊,不會出錯。
1.2 環境與工具
硬件環境:Intel Core i5 7200U,2.50GHz,4GB RAM,256GB SSD
軟件環境:Windows 10 家庭中文版,Ubuntu 18.04.1 LTS(VMware)
工具:codeblocks,gedit,gcc,objdump,readelf 等
1.3 中間結果
hello.c |
源程序C語言代碼 |
hello.i |
hello.c預處理後的文本文件 |
hello.s |
編譯後的彙編語言文本文件 |
hello.o |
彙編後的可重定位目標文件 |
hello |
鏈接後的可執行目標文件 |
helloo_obj.s |
hello.o利用objdump工具的反彙編代碼 |
hello_obj.s |
hello利用objdump工具的反彙編代碼 |
其他中間結果 |
readelf和objdump的其他調試代碼,反映在了標準輸出中,沒有保存到本地文件 |
1.4 本章小結
本章介紹了hello的P2P(from program to process)和O2O(from zero-0 to zero-0)過程,是全文的一個簡單概括。本章還列舉了環境工具和中間結果,以供讀者總覽。
第2章 預處理
2.1 預處理的概念與作用
預處理是源文件到目標文件轉化的第一環節。GCC編譯器驅動程序把源程序文件翻譯成可執行目標文件,首先要經歷預處理階段。
在預處理階段,預處理器(cpp)根據以字符#開頭的命令,修改原始的C程序。這些命令通常是.c文件開頭的一些以#開頭的命令。
這些命令告訴預處理器讀取相應的文件(如頭文件),並把這些文件直接插入程序文本中。結果就得到了另一個C程序,通常是以.i作爲文件擴展名。
2.2 在Ubuntu下預處理的命令
GCC編譯器驅動程序的預處理器的預處理命令格式如下:
gcc -E xx.c -o yy.i
其中,-E選項指只預處理,不編譯。xx.c是源文件。-o選項指定了預處理輸出文件的文件名,這個文件名就是yy.i,通常我們建議輸出文件名與源文件同名,而不同後綴。
Gcc選項還可以加-C選項,表示預處理時不刪除註釋信息,配合-E選項使用。
2.3 Hello的預處理結果解析
預處理過程其實是把源程序.c文件,擴展成爲一個新的文本模式的.i文件,擴展內容就是預處理的插入內容。
由圖2.4可以看出,hello.c經過預處理後,源代碼前面插入了大量的其它代碼,這是由預處理器完成的。並且源代碼的預處理命令已經被移除掉了,事實上,這些預處理命令被翻譯成了插入的代碼。
2.4 本章小結
預處理階段是hello的“P2P”之路的第一步,從此,hello的代碼被逐步解析成爲機器能夠理解的代碼。
在Linux中,預處理階段由GCC編譯器驅動程序的預處理器(cpp)執行,轉換過程由.c文件變成.i文件。
第3章 編譯
3.1 編譯的概念與作用
編譯是源文件到目標文件轉化的第二環節。預處理器(cpp)對.c文件預處理爲.i文件後,由編譯器(ccl)繼續對.i文件在編譯階段中加工。
在編譯階段,編譯器(ccl)將.i文件翻譯成另一種文本格式的.s文件,它包含一個彙編語言程序。彙編語言是一種低級機器語言指令,爲不同高級語言的不同編譯器提供了通用的輸出語言,是高級程序語言和機器語言之間的橋樑。
3.2 在Ubuntu下編譯的命令
GCC編譯器驅動程序的編譯器的編譯命令格式如下:
gcc -S xx.i -o yy.s
或者
gcc -S xx.c -o yy.
其中,-S選項指只編譯,不彙編和鏈接。-o選項指定了輸出的文件。至於操作對象,既可以是源文件.c,也可以是預處理後的.i文件,如果是對.c文件操作,則gcc默認對.c文件先進行預處理,再進行編譯。
另外,-O選項給編譯過程提出了指定的優化級別。Gcc編譯器有幾種優化模式:-O0, -O, -O1, -O2, -Os, -O3,優化級別越高優化效果越好,但編譯時間會變長。
3.3 Hello的編譯結果解析
hello.c中包含了很多數據類型和指令操作,下面逐一解析。
3.3.1 數據
hello.c中的常量都是在語句中出現的。這種常量在彙編語言中都是用立即數直接表示的。
- 常量
- 變量
hello.c中的變量主要有全局變量sleepsecs和局部變量i。全局變量在.s文件中的頭部聲明。
這部分表示,sleepsecs在.text聲明爲全局變量,存放在.data節中,對齊方式爲4字節對齊,並把sleepsecs聲明爲@object類型,大小爲4字節。接着,又給sleepsecs賦值爲long類型的值2(源文件中,int sleepsecs = 2.5,long和int類型大小相同,編譯器將int轉成了long存儲,並且把賦值語句右側的2.5直接舍成2).
至於局部變量i,是在main函數中聲明的。對於編譯器來說,局部變量要麼保存在寄存器中,要麼保存在棧空間中。從編譯結果看出,此處的變量i被保存到了棧空間中,還可以找到循環的大致位置。
3.3.2 操作
1. 賦值
hello.c中一共有兩處賦值,一個是全局變量sleepsecs的初始化賦值,一個是局部變量i的初始化賦值。全局變量的賦值是在.s的頭部就已經做好了的,值在.data節中。至於局部變量的賦值,用的是MOV指令。
movl $0, -4(%rbp)
局部變量i保存在棧中,具體位置在-4(%rbp)。這句操作指令的含義是把立即數0,傳送到棧上的-4(%rbp)中,即對i賦值。需要注意的是64位系統有幾種不同的MOV指令:
指令 |
效果 |
描述 |
MOV S, D |
D←S |
傳送 |
movb |
傳送字節 |
|
movw |
傳送字(2字節) |
|
movl |
傳送雙字(4字節) |
|
movq |
傳送四字(8字節) |
|
movabsq I, R |
傳送絕對的四字 |
拿hello.c中的i賦值爲例,這裏用的是movl,也就是傳送4字節的數據,而i是int類型的。
2. 類型轉換
hello.c中只有一個隱式的類型轉換,就是在全局變量的初始化賦值中。這裏的類型轉換是把浮點數轉換爲整型,這種情況是基本類型轉換中比較複雜的情形。因爲對於浮點數來說,向整數舍入,通常是找到最接近浮點數的整數來作爲舍入結果。但是,如果有兩個整型距離這個浮點數相同,就需要決策了。一種比較好的方式是向偶數舍入,這種方式決定把2.5舍入爲2,而把3.5舍入爲4.在統計學中,這種舍入方法是簡單方法中,較優的一個。因此我們也看到了,在.data節,sleepsecs的值被聲明爲2.
簡單數據類型的轉換還有很多種。通常來說,低精度向高精度的舍入不太可能造成數據丟失,但高精度向低精度的轉換可能會損失精度,甚至出現意想不到的效果。比如int類型的-1其實是unsigned int類型的最大值,負數轉換爲unsigned類型時,會有這些麻煩。
3. 算術操作和邏輯操作
hello.c中只有一處算術操作,就是循環語句中的循環變量i++, 彙編語言中的算術操作使用一系列指令集來完成的。
指令 |
效果 |
描述 |
leaq S, D |
D←&S |
加載有效地址 |
INC D |
D←D+1 |
加1 |
DEC D |
D←D-1 |
減1 |
NEG D |
D←-D |
取負 |
NOT D |
D←~D |
取補 |
ADD S, D |
D←D+S |
加 |
SUB S, D |
D←D-S |
減 |
IMUL S, D |
D←D*S |
乘 |
XOR S, D |
D←D^S |
異或 |
OR S, D |
D←D|S |
或 |
AND S, D |
D←D&S |
與 |
SAL k, D |
D←D<<k |
左移 |
SHL k, D |
D←D<<k |
左移(等同於SAL) |
SAR k, D |
D←D>>Ak |
算術右移 |
SHR k, D |
D←D>>Lk |
邏輯右移 |
hello.c中無邏輯操作。
4. 關係操作
hello.c中的關係操作在判斷語句中,包括循環終止條件的判斷。而這些關係操作也僅限於大小比較。彙編語言中的比較不僅限於大小的比較。
指令 |
基於 |
描述 |
cmpb S1, S2 |
S2-S1 |
比較字節 |
cmpw S1, S2 |
比較字 |
|
cmpl S1, S2 |
比較雙字 |
|
cmpq S1, S2 |
比較四字 |
|
testb S1, S2 |
S1&S2 |
測試字節 |
testw S1, S2 |
測試字 |
|
testl S1, S2 |
測試雙字 |
|
testq S1, S2 |
測試四字 |
CMP指令根據兩個操作數之差來設置條件碼。除了只設置條件碼而不更新目的寄存器之外,CMP指令和SUB指令行爲是一樣的。
TEST指令行爲與AND指令一樣,除了它們只設置條件碼而不改變目的寄存器的值。
以hello.c中的第一個條件判斷爲例:
argc!=3
它的彙編代碼是:
cmpl $3, -20(%rbp)
je .L2
這是指,用argc和3作差,根據結果設置條件碼,再根據條件碼,判斷是否跳轉到L2處。
5. 控制轉移
hello.c中的控制轉移也是伴隨着條件判斷出現的。比如說,如果argc!=3,那麼就執行if塊的語句。如果i<10的話,那麼就繼續i++,並轉而執行新的迭代。彙編語言中的控制轉移有兩種,一種是通過訪問條件碼,用jump指令配合條件控制來執行控制轉移。另一種是用條件傳送來實現條件分支。不加優化編譯的hello.s中無條件傳送指令。
指令 |
跳轉條件 |
描述 |
jmp Label |
1 |
直接跳轉 |
jmp *Operand |
1 |
間接跳轉 |
je Label |
ZF |
相等/零 |
jne Label |
~ZF |
不相等/非零 |
js Label |
SF |
負數 |
jns Label |
~SF |
非負數 |
jg Label |
~(SF^OF)&~ZF |
大於(有符號) |
jge Label |
~(SF^OF) |
大於或等於(有符號) |
jl Label |
SF^OF |
小於(有符號) |
jle Label |
(SF^OF)|ZF |
小於或等於(有符號) |
ja Label |
~CF&~ZF |
超過(無符號) |
jae Label |
~CF |
超過或相等(無符號) |
jbe Label |
CF |
低於(無符號) |
jbe Label |
CF|ZF |
低於或相等(無符號) |
對hello.c中的循環終止條件判斷解析:
6. 函數操作以及參數
hello.c中共有五個函數調用,除了main函數外,還有printf、exit、sleep和getchar函數。其中,只有getchar沒有參數。
函數作爲一種過程,假設過程P調用過程Q,有這樣的機制:
傳遞控制。在進入過程Q的時候,程序計數器必須被設置爲Q的代碼的起始地址,然後在返回時,要把程序計數器設置爲P中調用Q後面那條指令的地址。
傳遞數據。P必須能夠向Q提供一個或者多個參數,Q必須能夠向P返回一個值。
分配和釋放內存。在開始時,Q可能需要爲局部變量分配空間,而在返回前,又必須釋放掉這些空間。這些空間往往是運行時棧。
以main函數爲例,作爲分析。
7. 字符串和數組
hello.c中也是有字符串和數組的。事實上這裏面的字符串作爲printf的參數,在主函數中是以如下形式聲明的:
而數組char *argv[]是main的參數,在棧空間中,argv的首地址被存放在-32(%rbp)的位置,調用數組元素時,根據棧指針加上偏移量來尋址。
3.4 本章小結
編譯是“hello”P2P之路的第二步,結果是把源程序代碼轉化成了彙編語言代碼。彙編語言是高級程序語言和機器語言之間的橋樑。編譯系統的工作就是把源程序代碼,不斷地朝着機器能夠識別的代碼的方向轉化。
第4章 彙編
4.1 彙編的概念與作用
彙編是源文件到目標文件轉化的第三環節。經過了預處理和編譯,源文件已經變成了由彙編語言編寫的.s文件。接下來,由彙編器(as)將.s文件翻譯成爲機器語言指令,把這些指令打包成一種叫做可重定位目標程序的格式,並把結果保存在目標文件.o中。如果我們在文本編輯器中打開.o文件,只能看到一堆亂碼。
4.2 在Ubuntu下彙編的命令
GCC的彙編指令格式如下:
gcc -c xx.c -o yy.o
應作業要求,我們加上如下選項:
gcc -m64 -no-pie -fno-PIC -c hello.c -o hello.o
其中,-c選項是隻進行預處理、編譯和彙編,而不進行鏈接。xx.c是操作對象,也可以是.i或.s文件。-o指定了輸出文件,輸出文件是.o格式文件。建議輸出文件名與操作對象同名不同後綴。
4.3 可重定位目標elf格式
ELF頭 |
.text |
.rodata |
.data |
.bss |
.symtab |
.rel.text |
.rel.data |
.debug |
.line |
.strtab |
節頭部表 |
要分析hello.o的ELF格式,參考表4.1,用
readelf -a hello.o
命令可以將hello.o的ELF信息打印到標準輸出上。
4.3.1 ELF頭
ELF頭以一個16字節的序列開始,這個序列描述了生成該文件的系統的字的大小和字節順序。ELF頭剩下的部分包含幫助鏈接器語法分析和解釋目標文件的信息。可以得知ELF頭的大小爲64字節,目標文件類型爲ELF64,系統架構爲X86-64,節頭部表的文件偏移1152字節等。
4.3.2 節頭部表
節頭部表描述了不同節的位置和大小,目標文件中每個節都有一個固定大小的條目。
4.3.3 重定位節
hello.o的重定位節中一共有8個條目,給出了偏移量、信息、類型、符號值、符號名稱+加數等信息。
R_X86_64_PC32是和R_X86_64_32相對應的,比較常見的重定位類型。前者是重定位一個使用32位PC相對地址的引用,而後者是重定位一個使用32位絕對地址的引用。我在會彙編時,最開始是從之前生成的.s文件彙編過來的,這樣的話圖4.5會不一樣,偏移量和.rodata的加數會有些許不同,另外還會遇到R_X86_64_PLT32類型,Linux Kernels網站上給出如下解釋:
This Linux kernel change "x86: Treat R_X86_64_PLT32 as R_X86_64_PC32" is included in the Linux 3.18.100 release. This change is authored by H.J. Lu <hjl.tools [at] gmail.com> on Wed Feb 7 14:20:09 2018 -0800.
重定位PC相對引用的重定位算法爲:
refaddr = ADDR(s) + r.offset;
*refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr);
假設算法運行時,鏈接器爲每個節(用ADDR(s)表示)和每個符號都選擇了運行時地址(用ADDR(r.symbol))表示。拿.rodata的重定位爲例,它的重定位地址爲refptr. 則應先計算引用的運行時地址refaddr = ADDR(s) + r.offset, .rodata的offset爲0x16,ADDR(s)是由鏈接器確定的。然後,更新引用,refptr = (unsigned) (ADDR(r.symbol) + r.addend – refaddr), .rodata的ADDR(r.symbol)也是由鏈接器確定的,addend查表可知爲+0,refaddr已經算出來了,所以,.rodata的重定位地址我們就可以算出來了。
4.3.4 符號表
符號表是由彙編器構造的,使用編譯器輸出到彙編語言.s文件中的符號。.symtab節中包含ELF符號表。這張符號表包含一個條目的數組,每個條目的格式有如下數據結構:
typedef struct{
int name;
char type:4,
binding:4;
char reserved;
short section;
long value;
long size;
}Elf64_Symbol;
其中,name是字符串表中的字節偏移,指向符號的以null結尾的字符串的名字。value是符號的地址。對於可重定位的模塊來說,value是距定義目標的節的起始位置的偏移。size是目標的大小(以字節爲單位)。type通常要麼是數據,要麼是函數。binding字段表示符號是本地的還是全局的。
4.3.5 其它節
有些其它節在我們的hello.o是不存在的。
4.4 Hello.o的結果解析
說明機器語言的構成,與彙編語言的映射關係。特別是機器語言中的操作數與彙編語言不一致,特別是分支轉移函數調用等。
hello.o是由hello.s彙編得到的,而hello.o的反彙編卻和hello.s不一樣了,兩者不同之處,主要是彙編器對hello.s做的手腳。彙編之前,hello.s只是把擴展後的源代碼翻譯成了彙編代碼,像是一張使用說明書。而彙編之後,hello.o獲得了重定位信息,符號表等ELF格式信息,像是實戰視頻教程。
彙編之後,hello.o比hello.s更加具體,比如說函數調用的地址,從函數名稱變成了主函數首地址加上偏移量,而條件跳轉也從跳轉到段名稱變成了跳轉到指定偏移地址。還有對全局變量的引用,之前是.LC0(%rip), 而現在是$0x0, 至於這個全局變量的地址到底在哪,這些信息都保存在重定位信息裏了。還有一些細節,立即數的表示由十進制變成了十六進制。
4.5 本章小結
可以說,彙編之後的hello變得更加豐滿,更加難懂,也更貼近機器了。hello.o作爲可重定位的目標文件,即將和其它目標文件一起鏈接成最後的可執行目標文件。
到此,我們的hello已經擁有了彙編語言解釋,ELF格式的諸多信息,即將達成最後的可執行文件了。
第5章 鏈接
5.1 鏈接的概念與作用
鏈接過程是hello成爲可執行目標文件的最後一步。在經歷了預處理、編譯和彙編之後生成的.o文件,只需要再經過鏈接器(ld)和其它可重定位目標文件鏈接,就可以生成最終的可執行目標程序了。
5.2 在Ubuntu下鏈接的命令
按照要求,我們將如下文件與hello.o鏈接生成可執行目標文件hello。參考命令如下:
ld -dynamic-linker /lin64/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/7/crtbegin.o /usr/lib/gcc/x86_64-linux-gnu/7/crtend.o /usr/lib/x86_64-linux-gnu/crtn.o hello.o -lc -z relro -o hello
鏈接的文件都是64位庫中的一些可重定位目標文件。
5.3 可執行目標文件hello的格式
分析hello的ELF格式,用readelf等列出其各段的基本信息,包括各段的起始地址,大小等信息。
readelf -a hello
命令可以將hello文件的ELF格式信息輸出到標準輸出上。
這裏有用的信息包括各段的大小,起始地址等信息。
5.4 hello的虛擬地址空間
正如圖5.3所示,hello的各段的虛擬地址空間從0x400000到0x401000,可以在edb的data dump中查看。
5.5 鏈接的重定位過程分析
乍一看,hello和hello.o的區別有,在鏈接之前,各段的地址僅僅是一個偏移量,而非“地址”,鏈接之後,各段有了實質的虛擬地址,每一條指令也有有對應的虛擬地址。之前提到過了,從hello.s到hello.o有一個區別,是有些段尋址時,從.L0(%rip)變成了$0x0,而後者的具體地址放在了重定位信息裏。現在,hello.o經過鏈接中的重定位後,基本上所有未確定的信息都有了確定的地址,比如說,在加載全局變量字符串時(printf的參數字串),hello中給出的指令是:
mov $0x4006f4, %edi
可以說,每一個條目都找到了虛擬內存地址。調用的函數,也都是call函數的虛擬地址。
這個重定位的過程,需要PC相對引用重定位算法好好解析一下。下面就以hello.o中的調用exit函數爲例,其指令是這樣的:
24: e8 00 00 00 00 callq 29 <main+0x29>
首先,對任意一條指令的重定位,我們需要知道一些信息。
按照重定位PC相對引用算法,先計算引用的運行時地址:
refaddr = 0x4005e7 + 0x27 = 0x40060e
再更新該引用:
*refptr = 0x4004e0 – 0x4 – 0x40060c = 0xfffffed0(-0x130)
其它的指令重定位,如果也是R_X86_64_PC32(R_X86_64_PLT32)的,重定位結果也是按這個算法算。如果是絕對引用的話,有如下算法:
*refptr = (unsigned) (ADDR(r.symbol) + r.addend)
具體示例不再列舉了,計算過程和相對引用相似,更簡單。
5.6 hello的執行流程
子程序名 |
程序地址 |
簡述 |
ld-2.27.so!_dl_start |
0x7fefaff21ea0 |
開始加載 |
ld-2.27.so!_dl_init |
0x7fefaff30630 |
|
libc-2.27.so!__libc_start_main |
0x7fefafb50ab0 |
|
libc-2.27.so!__cxa_atexit |
0x7fefafb72430 |
|
hello!__libc_csu_init |
0x400670 |
|
libc-2.27.so!_setjmp |
0x7fefafb6dc10 |
|
hello!main |
0x4005e7 |
hello.c的主函數 |
hello!puts@plt |
0x4004b0 |
hello.c中調用 |
hello!exit@plt |
0x4004e0 |
hello.c中調用 |
hello!printf@plt |
0x4004c0 |
hello.c中調用 |
hello!sleep@plt |
0x4004f0 |
hello.c中調用 |
hello!getchar@plt |
0x4004d0 |
hello.c中調用 |
libc-2.27.so!exit |
|
|
因爲hello程序有一個分支調用了exit函數,故程序在那裏就終止了,如果在hello中進入了另一條分支,事實上是會運行到libc-2.27.so!exit終止的。而且,每次加載hello的虛擬地址也是不同的。但hello中的main和調用的一些函數的地址是經過重定位之後,固定下來的虛擬地址。
5.7 Hello的動態鏈接分析
分析hello程序的動態鏈接項目,通過edb調試,分析在dl_init前後,這些項目的內容變化。要截圖標識說明。
hello在調用.so共享庫函數時,會涉及到動態鏈接。現代系統在處理共享庫在地址空間中的分配的時候,採用了位置無關代碼(PIC)方式。位置無關代碼指,編譯共享模塊的代碼段,是把它們加載到內存的任何位置而無需鏈接器修改。用戶對GCC使用-fpic選項指示GNU編譯系統生成PIC代碼。共享庫的編譯必須總是使用該選項。
PIC代碼引用包括數據引用和函數調用。對數據引用有一個事實,就是代碼段中任何指令和數據段中任何變量之間的距離是一個運行時常量,與代碼段和數據段的絕對內存位置是無關的。在編譯器想要生成對PIC全局變量引用時,在數據段開始的地方創建了全局偏移量表(GOT)。
以圖5.14爲例說明。在執行例程addvec時,mov語句實現了addcnt的引用。%rip存放的是下一條指令地址,即addl指令地址,運行時代碼段中的addl距離數據段中的GOT[3]的距離是常量0x2008b9,故用基址偏移尋址找到GOT[3],而GOT[3]中存放的是addcnt的真實地址,這樣mov就實現了把&addcnt存入%rax的操作。
對PIC函數調用,有一個現象叫延遲綁定,即將過程地址的綁定推遲到第一次調用該過程時。把函數地址的解析推遲到它實際被調用的地方,能避免動態鏈接器在加載時進行成百上千個其實並不需要的重定位。第一次調用過程的運行時開銷很大,但是其後的每次調用都只會花費一條指令和一個間接的內存引用。
延遲綁定是通過GOT和過程鏈接表(PLT)實現的。GOT是數據段的一部分,PLT是代碼段的一部分。
對一個函數的調用,首先程序進入函數的PLT條目,PLT條目下的第一條PLT指令通過函數的GOT條目跳轉,因爲首次調用GOT條目指向的是PLT條目下的第二條指令,所以此時已經運行到了PLT的第二條指令。
PLT的第二條指令通常是把函數的ID壓棧,隨後跳轉到PLT[0]。PLT[0]是一個特殊條目,它跳轉到動態鏈接器中。PLT[0]又藉助GOT[1]間接地把動態鏈接器的一個參數壓入棧中,這可能是動態鏈接器在解析函數地址時會使用的信息。這時,動態鏈接器會通過兩個棧中的條目來確定函數的運行時位置,用這個地址重寫它的GOT條目,再把控制傳給函數例程。
此時就是第二次調用函數了,控制會轉入到它的PLT條目,然後再借它的GOT條目跳轉,經過動態鏈接器的更新,GOT條目中存放的已經是函數的地址,所以這次調用就進入到函數例程了。
下面對hello的dl_start函數的GOT更新環節做解析。
先找到hello的GOT表,之後在edb中對GOT表做跟蹤分析。
對GOT[2]的地址0x7f358f0c0750跟蹤。
根據圖5.19中的函數的GOT條目,配合圖5.15的函數的PLT條目來看,實踐證明GOT和PLT配合調用函數的機制是正確的。
5.8 本章小結
本章介紹了hello在真正成爲process之前的最後一步——鏈接。簡單跟蹤了hello的鏈接過程,包括靜態鏈接和動態鏈接。重點分析了重定位和動態鏈接中的PIC調用和引用。終於,心心念的hello的P2P之路,走完了。
第6章 hello進程管理
6.1 進程的概念與作用
進程的經典定義就是一個執行中程序的實例。系統中的每個程序都運行在某個進程的上下文中。上下文是由程序正確運行所需的狀態組成的。這個狀態包括存放在內存中的程序的代碼和數據,它的棧、通用目的寄存器的內容、程序計數器、環境變量以及打開文件描述符的集合。
進程給應用程序提供了關鍵的抽象,一個獨立地邏輯控制流,提供一個假象,好像我們的程序獨佔地使用處理器,還提供了一個私有的地址空間,提供一個假象,好像我們的程序獨佔地使用內存系統。
6.2 簡述殼Shell-bash的作用與處理流程
shell是一個交互型的應用級程序,它代表用戶運行其他程序。Shell執行一系列的讀/求值步驟,然後終止。讀步驟讀取來自用戶的一個命令行。求值步驟解析命令行,並代表用戶運行程序。
shell的首要任務是調用parseline函數,這個函數解析了以空格分割的命令行參數,並構造最終會傳遞給execve的argv向量。第一個參數被假設爲要麼是一個內置的shell命令名,馬上就會解釋這個命令,要麼是一個可執行目標文件,會在一個新的子進程的上下文中加載並運行這個文件。
如果最後一個參數是一個“&”字符,那麼parseline返回1,表示應該在後臺執行該程序(shell不會等待它完成)。否則,它返回0,表示應該在前臺執行這個程序(shell回等待它完成)。
在解析了命令行之後,eval函數調用builtin_command函數,該函數檢查第一個命令行參數是否是一個內置的shell命令。如果是,它就立即解釋這個命令,並返回值1。否則返回0.簡單的shell只有一個內置命令——quit命令,該命令會終止shell。實際使用的shell會有大量的內置命令。
如果builtin_command返回0,那麼shell創建一個子進程,並在子進程中執行所請求的程序。如果用戶要在後臺運行該程序,那麼shell返回到循環的頂部,等待下一個命令行。否則,shell使用waitpid函數等待作業終止。當作業終止時,shell就開始下一輪迭代。
6.3 Hello的fork進程創建過程
父函數可以通過fork函數創建一個新的運行的子進程。函數聲明如下:
pid_t fork(void);
新創建的子進程幾乎但不完全與父進程相同。子進程得到與父進程用戶級虛擬地址空間相同的但是獨立地一份副本,包括代碼和數據段、堆、共享庫以及用戶棧。子進程還獲得與父進程任何打開文件描述符相同的副本,這就意味着當父進程調用fork時,子進程可以讀寫父進程中打開的任何文件。父進程和新創建的子進程之間最大的差別在於它們有不同的PID。
有幾個需要注意的地方。fork函數調用一次,返回兩次。父進程調用一次fork,但卻有一次是返回到父進程,而另一次是返回到子進程的。父進程和子進程是併發運行的獨立進程,內核可以以任意方式交替執行它們的邏輯控制流中的指令。父進程和子進程還具有相同但是獨立的地址空間,從虛擬內存的角度看fork函數,子進程使用父進程的地址空間,但有寫時複製的特性。父子進程還有共享的文件。
通常父進程用waitpid函數來等待子進程終止或停止。
pid_t waitpid(pid_t pid, int *statusp, int options);
在父進程調用fork後,到waitpid子進程終止或停止這段時間裏,父進程執行的操作,和子進程的操作(如果沒有什麼其它複雜的操作的話),在時間順序上是拓撲排序執行的。有可能,這段時間裏父子進程的邏輯控制流指令交替執行。而父進程的waitpid後的指令,只能在子進程終止或停止後,waitpid返回後才能執行。
6.4 Hello的execve過程
execve函數在當前進程的上下文中加載並運行一個新程序。函數聲明如下:
int execve(const char *filename, const char *argv[], const char *envp[]);
execve函數加載並運行可執行目標文件filename,且帶參數列表argv和環境變量列表envp。只有當出現錯誤時,例如找不到filename,execve纔會返回到調用程序。正常情況下,execve調用一次,但從不返回。
在execve加載filename之後,調用啓動代碼。啓動代碼設置棧,並將控制傳遞給新程序的主函數,該主函數有如下形式的原型:
int main(int argc, char **argv, char *envp);
main開始執行時,用戶棧的組織結構由圖6.1所示。
從棧底(高地址)到棧頂(低地址),首先是參數和環境字符串。棧往上緊隨其後的是以null結尾的指針數組,其中每個指針都指向棧中的一個環境變量字符串。全局變量environ指向這些之陣中的第一個envp[0]。緊隨環境變量數組之後的是以null結尾的argv[]數組,其中每個元素都指向棧中的一個參數字符串。在棧的頂部是系統啓動函數libc_start_main的棧幀。
6.5 Hello的進程執行
結合進程上下文信息、進程時間片,闡述進程調度的過程,用戶態與核心態轉換等等。
hello在執行時,有自己的邏輯控制流。多個進程的邏輯控制流在時間上可以交錯,表現爲交替運行。每個進程執行它的流的一部分,然後被搶佔(暫時掛起),然後輪到其它進程。
一個邏輯流的執行在時間上和另一個流重疊,成爲併發流,這兩個流併發地運行。一個進程執行它的控制流的一部分的每一時間段叫時間片。
如圖6.2,每一個進程在時間段上的黑線段,都是一個時間片。同一時間上不會有兩個進程同時佔用處理器。在總體上看,併發控制流是在一段時間裏是共用處理器的。
當hello執行到sleep函數時,會被掛起一段時間。掛起就是指進程被搶佔,也就是hello會在邏輯控制流上出現一段斷片。sleep函數的聲明如下:
unsigned int sleep(unsigned int secs);
這會使當前進程掛起secs秒,如果請求的時間量到了,sleep返回0,否則返回還剩下的要休眠的秒數。
正常情況下,hello正常運行,直到調用函數sleep,hello進程會臨時交出控制權。
進程控制權的交換涉及到上下文切換。操作系統內核使用一種成爲上下文切換的較高層形式的異常控制流來實現多任務。內核爲每個進程維持一個上下文。上下文就是內核重新啓動一個被搶佔的進程所需的狀態。
在進程執行的某些時刻,內核可以決定搶佔當前進程,並重新開始一個先前被搶佔了的進程。這種決策就叫調度,是由內核中成爲調度器的代碼處理的。在內核調度了一個新的進程運行後,它就搶佔當前進程,並使用一種稱爲上下文切換的機制來將控制轉移到新的進程,上下文切換要做到:
- 保存當前進程的上下文
- 恢復某個先前被搶佔的進程被保存的上下文
- 將控制傳遞給這個新恢復的進程
上下文切換有點像電影片場中的拍攝過程。一個場景是一個進程,場景的切換,要保存當前現場,把控制權交給另一個場景的導演,如果那個場景暫時結束了,就把控制再交回來,這時現場也是恢復了的。
再回到hello進程來,調用sleep後,內核中的調度器將hello進程掛起,然後進入到內核模式,由於hello調用sleep的這個過程沒有顯式地創建新的進程,所以,在hello被搶佔了secs秒後,內核又會選擇hello進程,恢復它被搶佔時的上下文,並把控制交給它,這時,又回到了用戶模式。
6.6 hello的異常與信號處理
Ctrl-z後可以運行ps jobs pstree fg kill 等命令,請分別給出各命令及運行結截屏,說明異常與信號的處理。
按照要求,對hello執行過程中,可能遭遇的操作進行分析。先分析一下hello程序的內容。
argc是執行hello時的參數個數,*argv[]是執行hello時,輸入的參數數組,並且這時已經被解析過的參數字串。
首先,如果參數不爲3,那麼會打印一條默認語句,並異常退出。如果參數是3個,那麼會執行一個循環,每次循環會使hello進程休眠2.5秒,休眠後又會恢復hello。而且循環裏會輸出一條格式字串,其中有輸入的兩個參數字串。循環結束後,有一個getchar()等待一個標準輸入,然後就結束了。
6.6.1 正常運行
6.6.2 Ctrl+C信號
6.6.3 Ctrl+Z信號
6.6.4 標準輸入
這是一個有趣的現象,如果輸入3個參數,在hello循環時亂按鍵盤,向進程給出一些標準輸入,那麼在循環結束之後,getchar()會把緩衝區裏的這些輸入發送給shell,等於shell接收到了一些不明所以的輸入。
以上四種情況,可以用ps和kill命令來測試,如圖6.8中的ctrl+z信號,看似效果是和ctrl+c終止進程一樣的,但實際上,如果用bg或fg命令是可以讓hello繼續運行的。kill命令可以直接殺死進程。
6.7本章小結
正如書中所說,進程是計算機科學中最深刻、最成功的概念之一。進程給應用程序提供的關鍵抽象,使得進程可以併發地執行。信號和異常的處理,使得併發執行的過程變得井然有序,如信號處理程序,一切都按照規矩運行。shell-bash的建立,給用戶和進程之間提供了一個操作平臺。總之,這部分內容既底層,又貼近用戶。
第7章 hello的存儲管理
7.1 hello的存儲器地址空間
計算機系統的主存被組織成一個由M個連續的字節大小的單元組成的數組。每字節都有一個唯一的物理地址。物理地址是在地址總線上,以電子形式存在的,使得數據總線可以訪問主存的某個特定存儲單元的內存地址。
在一個帶虛擬內存的系統中,CPU從一個有N=2n個地址的地址空間中生成虛擬地址,這個地址空間成爲虛擬地址空間。
地址空間是一個非負整數的有序集合,如果地址空間中的整數是連續的,那麼我們說它是一個線性地址空間。線性地址就是線性地址空間中的地址。
在有地址變換功能的計算機中,訪問指令給出的地址(操作數)叫邏輯地址,也叫相對地址。要經過尋址方式的計算或變換纔得到內存儲器中的物理地址。
edb調試中看到的hello的指令地址都是16位的虛擬地址,有些訪問指令的地址也是邏輯地址,在程序中虛擬地址和邏輯地址沒有明顯的界限。通常來說我們是看不到程序的物理地址的。至於線性地址,只是一個地址的概念。
邏輯地址轉換成線性地址,虛擬地址,是由段式管理執行的。
線性地址轉換成物理地址,是由頁式管理執行的。
7.2 Intel邏輯地址到線性地址的變換-段式管理
在段式存儲管理中,將程序的地址空間劃分爲若干個段,這樣每個進程有一個二維的地址空間。在段式存儲管理系統中,則爲每個段分配一個連續的分區,而進程中的各個段可以不連續地存放在內存的不同分區中。
用戶棧是棧段寄存器,共享庫的內存映射區域和運行時堆都是輔助段寄存器,讀/寫段是數據段寄存器,只讀代碼段是代碼段寄存器。
代碼段寄存器中的RPL字段表示CPU的當前特權級。RPL=00,爲第0級,位於最高級的內核態,RPL=11,爲第3級,位於最低級的用戶態,第0級高於第3級。出於環保護機制,內核工作在第0環,用戶工作在第3環,中間環留給中間軟件用。Linux僅用第0環和第3環。TI=0,選擇全局描述符表(GDT),TI=1,選擇局部描述符表(LDT)。
段描述符是一種數據結構,實際上就是段表項,分爲用戶的代碼段和數據段描述符,還有系統控制端描述符。
全局描述符表GDT:只有一個,用來存放系統內每個任務都可能訪問的描述符,例如,內核代碼段、內核數據段、用戶代碼段、用戶數據段以及TSS(任務狀態段)等都屬於GDT中描述的段
局部描述符表LDT:存放某任務(即用戶進程)專用的描述符
中斷描述符表IDT:包含256箇中斷門、陷阱門和任務門描述符
7.3 Hello的線性地址到物理地址的變換-頁式管理
虛擬內存被組織爲一個由存放在磁盤上的N個連續的字節大小的單元組成的數組。每字節都有一個唯一的虛擬地址,作爲到數組的索引。磁盤上數組的內容被緩存在主存中。虛擬頁是帶虛擬內存系統將虛擬內存分割爲大小固定的塊,作爲磁盤和主存(較高層)之間的傳輸單元。任何時刻,虛擬頁面只有三種情況,要麼是未分配的,要麼是緩存的,要麼是未緩存的。
頁表是一個存放在物理內存中的數據結構,將虛擬頁映射到物理頁。每次地址翻譯硬件將一個虛擬地址轉換爲物理地址時,都會讀取頁表。操作系統負責維護頁表的內容,以及在磁盤與DRAM之間來回傳送頁。
頁表就是一個頁表條目(PTE)的數組。虛擬地址空間中的每個頁在頁表中一個固定偏移量處都有一個PTE。可以假設每個PTE是由一個有效位和一個n位地址字段組成的。有效位表明了該虛擬頁當前是否被緩存在DRAM中。如果設置了有效位,那麼地址字段就表示物理內存中相應的物理頁的起始位置,這個物理頁中緩存了該虛擬頁。如果沒有設置有效位,那麼一個空地址表示這個虛擬頁還未被分配。否則,一個非空地址指向的是該虛擬頁在磁盤上的起始位置。
形式上來說,地址翻譯是一個N元素的虛擬地址空間中的元素到一個M元素的物理地址空間中元素的映射。
圖7.2展示了內存管理單元(MMU)如何利用頁表來實現虛擬地址到物理地址的映射。CPU中的一個控制寄存器,頁表基址寄存器指向當前頁表。n位的虛擬地址包含兩個部分,一個p位的虛擬頁面偏移(VPO)和一個n-p位的虛擬頁號(VPN)。MMU利用VPN來選擇適當的PTE。將頁表條目中的物理頁號(PPN)和虛擬地址中的VPO串聯起來,就得到相應的物理地址。注意,因爲物理和虛擬頁面都是P字節的,所以物理頁面偏移(PPO)和VPO是相同的。
7.4 TLB與四級頁表支持下的VA到PA的變換
Intel Core i7 實現支持48位(256TB)虛擬地址空間和52位(4PB)物理地址空間。Linux使用的是4KB的頁。X64 CPU上的PTE爲64位(8bytes),所以每個頁表一共有512個條目。512個PTE需要有9位VPN來定位。在四級頁表的條件下,一共需要36位VPN,因爲虛擬地址空間是48位的,所以低12位是VPO。TLB是四路組聯的,共有16組,需要有4位TLBI來定位,所以VPN的低4位是TLBI,高32位是TLBT。
Core i7 MMU用四級的頁表來將虛擬地址翻譯成物理地址。36位VPN被劃分成四個9位的片,每個片被用作到一個頁表的偏移量。CR3寄存器包含L1頁表的物理地址。VPN1提供到一個L1 PTE的偏移量,這個PTE包含L2頁表的基地址。VPN2提供到一個PTE的偏移量,以此類推。
7.5 三級Cache支持下的物理內存訪問
物理內存訪問,是基於MMU將虛擬地址翻譯成物理地址之後,向cache中訪問的。
圖7.8的右半部分,是L1 cache中的物理地址尋址,L2和L3的尋址原理和L1相似。
在cache中物理地址尋址,按照三個步驟:組選擇、行匹配和字選擇。在衝突不命中時還會發生行替換。
高速緩存(S, E, B, m)是一個高速緩存組的數組。一共有S個組,每個組包含E行,每行包含1個有效位,t個標記位和一個log2B位的數據塊。
高速緩存的結構將m個地址位劃分爲t個標記位,s個組索引位,和b個塊偏移位。
在組選擇中,cache按照物理地址的s個組索引位(S=2s)來定位該地址映射的組。
選擇好組後,遍歷組中的每一行,比較行的標記和地址的標記,當且僅當這兩者相同,並且行的有效位設爲1時,纔可以說這一行中包含着地址的一個副本。也就是緩存命中了。
最後是字選擇。定位好了要尋址的地址在哪一行之後,根據地址的塊偏移量,在行的數據塊中偏移尋址,最後得到的字,就是我們尋址得到的字。
如果緩存不命中,那麼它需要從存儲器層次結構中的下一層取出被請求的塊,然後將新的塊存儲在組索引位指示的組中的一個高速緩存行中。這個過程,如果有衝突不命中,就會觸發行的替換。
L2和L3 cache的物理地址尋址,和上述過程類似。
7.6 hello進程fork時的內存映射
進程這一抽象能夠爲每個進程提供自己私有的虛擬地址空間,可以免受其他進程的錯誤讀寫。不過,許多進程有同樣的只讀代碼區域。而且,許多程序需要訪問只讀運行時庫的相同副本。那麼,如果每個進程都在物理內存中保持這些常用代碼的副本,那就是極端的浪費了。爲了避免這種浪費,內存映射中有了共享對象的概念。
如果一個進程將一個共享對象映射到它的虛擬地址空間的一個區域內,那麼這個進程對這個區域的任何寫操作,對於那些也把這個共享對象映射到它們虛擬內存的其他進程而言,也是可見的。而且這些變化也會反映在磁盤上的原始對象中。
還有一種更節省資源的機制,就是寫時複製。
圖7.8的情況,是兩個進程將一個私有對象映射到它們虛擬內存的不同區域,但是共享這個對象同一個物理副本。對於每個映射私有對象的進程,相應私有區域的頁表條目都被標記爲只讀,並且區域結構被標記爲寫時複製。只要沒有進程試圖寫它自己的私有區域,它們就可以繼續共享物理內存中對象的一個單獨副本。然而,只要有一個進程試圖寫私有區域的某個頁面,那麼這個寫操作會觸發一個保護故障。當故障處理程序注意到保護異常是由於進程試圖寫私有的寫時複製區域中的一個頁面而引起的,它就會在物理內存中創建這個頁面的一個新副本,更新頁表條目指向這個新的副本,然後恢復這個頁面的可寫權限,當故障程序返回時,CPU重新執行這個寫操作,現在在新創建的頁面上這個寫操作就可以正常執行了。通過延遲私有對象中的副本直到最後可能的時刻,寫時複製最充分地利用了稀有的物理內存。
在父進程用fork調用子進程時,內核爲新進程創建各種數據結構,並分配給它一個唯一的PID。爲了給這個新進程創建虛擬內存,它創建了當前進程的mm_struct、區域結構和頁表的原樣副本。它將兩個進程中的每個頁面都標記位只讀,並將兩個進程中的每個區域結構都標記爲私有的寫時複製。
當fork在新進程中返回時,新進程現在的虛擬內存剛好和調用fork時存在的虛擬內存相同。當這兩個進程中的任一個後來進行寫操作時,寫時複製機制就會創建新頁面,因此,也就爲每個進程保持了私有地址空間的抽象概念。
7.7 hello進程execve時的內存映射
hello調用execve後,execve在當前進程中加載並運行包含在可執行目標文件hello.out中的程序,用hello.out程序有效地替代了當前程序。加載並運行hello.out需要以下幾個步驟:
- 刪除已存在的用戶區域。刪除當前進程虛擬地址的用戶部分中的已存在的區域結構。
- 映射私有區域。爲新程序的代碼、數據、bss和棧區域創建新的區域結構,所有這些新的區域都是私有的、寫時複製的。代碼和數據區域被映射爲hello.out文件中的.text和.data區。bss區域是請求二進制零的,映射到匿名文件,其大小包含在hello.out中,棧和堆地址也是請求二進制零的,初始長度爲零。圖7.9概括了私有區域的不同映射。
- 映射共享區域, 如果hello.out程序與共享對象(或目標)鏈接,比如標準C庫libc.so,那麼這些對象都是動態鏈接到這個程序的,然後再映射到用戶虛擬地址空間中的共享區域內。
- 設置程序計數器(PC)。execve做的最後一件事情就是設置當前進程上下文的程序計數器,使之指向代碼區域的入口點。
下一次調度hello進程時,它將從這個入口點開始執行。Linux將根據需要換入代碼和頁面。
7.8 缺頁故障與缺頁中斷處理
物理內存(DRAM)緩存不命中成爲缺頁。假設CPU引用了磁盤上的一個字,而這個字所屬的虛擬頁並未緩存在DRAM中。地址翻譯硬件會從內存中讀取虛擬頁對應的頁表,推斷出這個虛擬頁未被緩存,然後觸發一個缺頁異常。缺頁異常調用內核中的缺頁異常處理程序,該程序會選擇一個犧牲頁。如果被犧牲的頁面被修改了,那麼內核會把它複製回磁盤。總之,內核會修改被犧牲頁的頁表條目,表示它不再緩存在DRAM中了。
之後,內核從磁盤把本來要讀取的那個虛擬頁,複製到內存中犧牲頁的那個位置,更新它的頁表條目,隨後返回。當異常處理程序返回時,會重新啓動導致缺頁的指令,該指令會把導致缺頁的虛擬地址重發送到地址翻譯硬件。於是,地址翻譯硬件可以正常處理現在的頁命中了。
7.9動態存儲分配管理
動態內存分配器維護着一個進程的虛擬內存區域,稱爲堆。對於每個進程,內核維護着一個變量brk,它指向堆的頂部。
分配器將堆視爲一組不同大小的塊的集合來維護。每個塊就是一個連續的虛擬內存片,要麼是已分配的,要麼是空閒的。已分配的塊顯式地保留爲供應用程序使用。空閒塊可用來分配。空閒塊保持空閒,直到它顯式地被應用所分配。一個已分配的塊保持已分配狀態,直到它被釋放,這種釋放要麼是應用程序顯式執行的,要麼是內存分配器自身隱式執行的。
分配器有兩種基本風格,顯式分配器和隱式分配器。兩種風格都要求應用顯式地分配塊。不同之處在於由哪個實體來負責釋放已分配的塊。
顯式分配器:要求應用顯式地釋放任何已分配的塊。C程序通過調用malloc函數來分配一個塊,並通過調用free函數來釋放一個塊。
隱式分配器:要求分配器檢測一個已分配塊何時不再使用,那麼就釋放這個塊。
7.9.1 隱式空閒鏈表
隱式空閒鏈表是隱式分配器組織空閒塊的一種形式。隱式空閒鏈表有如圖7.11的堆塊數據結構。
在這種情況中,一個塊是由一個字的頭部、有效載荷,以及可能的一些額外的填充組成的,頭部編碼了這個塊的大小(包括頭部和所有的填充),以及這個塊是已分配的還是空閒的。如果有雙字對齊的約束條件,那麼塊大小就總是8的倍數,且塊大小的最低3位總是0.
頭部後面就是應用調用malloc時請求的有效載荷。有效載荷後面是一片不使用的填充塊,其大小可以是任意的。
當一個應用請求一個k字節的塊時,分配器搜索空閒鏈表,查找一個足夠大可以放置請求塊的空閒塊。分配器執行這種搜索方式是由放置策略決定的。一些常見的策略有首次適配、下一次適配和最佳適配。
首次適配從頭開始搜索空閒鏈表,選擇第一個合適的空閒塊。下一次適配和首次適配相似,只不過是從上一次查詢結束的地方開始。最佳適配檢查每個空閒塊,選擇適合所需請求大小的最小空閒塊。
如果適配到的空閒塊比我們的請求塊大小要大很多,那麼就要把空閒塊分割成兩部分,一部分變成分配塊,剩下的變成一個新空閒塊。
如果分配器找不到一個合適的空閒塊(合併後也找不到),那麼分配器會調用sbrk函數,向內核申請額外的堆內存。分配器將額外的內存轉化成一個大空閒塊,把被請求塊放置在這個新的大空閒塊中。
每一次有新空閒塊生成時,都涉及到空閒塊的合併。爲了解決假碎片問題,任何實際的分配器都必須合併相鄰的空閒塊,這個過程叫合併。
通常堆塊的結構還有一個腳部,這是在每個塊的結尾處,腳部就是頭部的一個副本。在合併中,有頭部和腳部的塊,有利於從被合併塊去定位相鄰塊,獲知相鄰塊的信息。
假設我們有塊m1, n, m2,已分配標記爲a,空閒標記爲f,合併過程有圖7.12所示的四種情況。
7.9.2 顯式空閒鏈表
顯式空閒鏈表和隱式空閒鏈表在很多地方都相似,它將空閒塊組織成了一個實際的顯式數據結構,就是雙向鏈表。其堆塊的數據結構如圖7.13所示。
空閒塊中的pred祖先指針,指向空閒鏈表中,該塊的前驅,succ後繼指針,指向空閒鏈表中,該塊的後繼。
使用雙向鏈表而不是隱式空閒鏈表,使首次適配的分配時間從塊總數的線性時間減少到了空閒塊數量的線性時間。不過釋放一個塊的時間可以是線性的,也可能是個常數,這取決於我們選擇的空閒鏈表中塊的排序策略。
一種方法是用後進先出(LIFO),將新釋放的塊放置在鏈表的開始處,使用LIFO的順序和首次適配的放置策略,分配器會最先檢查最近使用過的塊,在這種情況下,釋放一個塊可以在線性的時間內完成,如果使用了邊界標記,那麼合併也可以在常數時間內完成。
另一種方法是按照地址順序來維護鏈表,其中鏈表中的每個塊的地址都小於它的後繼的地址,在這種情況下,釋放一個塊需要線性時間的搜索來定位合適的前驅。平衡點在於,按照地址排序首次適配比LIFO排序的首次適配有着更高的內存利用率,接近最佳適配的利用率。
7.10本章小結
本章講述了64位系統中的內存管理,虛擬內存和物理內存之間的關係,動態內存分配(主要表現爲malloc和free)等內容,對程序的內存有一個相對全面的介紹,也討論了內存的組織形式,和程序與內存的互動。
關於本章內容,具體還請查閱《深入理解計算機系統》第三版的第九章。
第8章 hello的IO管理
8.1 Linux的IO設備管理方法
一個Linux文件就是一個m個字節的序列:
B0, B1, …, Bk, …, Bm-1
所有的I/O設備(例如網絡、磁盤和終端)都被模型化爲文件,而所有的輸入和輸出都被當做對相應文件的讀和寫來執行,這種將設備優雅地映射爲文件的方式,允許Linux內核引出一個簡單低級的應用接口,稱爲Unix I/O。
8.2 簡述Unix IO接口及其函數
設備可以通過Unix I/O接口被映射爲文件,這使得所有的輸入和輸出都能以一種統一且一致的方式來執行:
- 打開文件。一個應用程序通過要求內核打開相應的文件,來宣告它想要訪問一個I/O設備,內核返回一個小的非負整數,叫做描述符,它在後續對此文件的所有操作中標識這個文件,內核記錄有關這個打開文件的所有信息。應用程序只需記住這個描述符。
- Linux shell創建的每個進程開始時都有三個打開的文件:標準輸入(描述符爲0)、標準輸出(描述符爲1)和標準錯誤(描述符爲2)。頭文件<unistd.h>定義了常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO,它們可以用來代替顯式的描述符值。
- 改變當前的文件位置。對於每個打開的文件,內核保持着一個文件位置k,初始爲0,這個文件位置是從文件開頭起始的字節偏移量,應用程序能夠通過執行seek,顯式地將改變當前文件位置k。
- 讀寫文件。一個讀操作就是從文件複製n>0個字節到內存,從當前文件位置k開始,然後將k增加到k+n。給定一個大小爲m字節的而文件,當k>=m時執行讀操作會觸發一個成爲end-of-file(EOF)的條件,應用程序能檢測到這個條件。在文件結尾處並沒有明確的“EOF符號”。類似一個寫操作就是從內存中複製n>0個字節到一個文件,從當前文件位置k開始,然後更新k。
- 關閉文件。當應用完成了對文件的訪問之後,它就通知內核關閉這個文件。作爲響應,內核釋放文件打開時創建的數據結構,並將這個描述符恢復到可用的描述符池中。無論一個進程因爲何種原因終止時,內核都會關閉所有打開的文件並釋放它們的內存資源。
在Unix I/O接口中,進程是通過調用open函數來打開一個存在的文件或者創建一個新文件的,函數聲明如下:
int open(char *filename, int flags, mode_t mode);
open函數將filename轉換爲一個文件描述符,並且返回描述符數字。返回的描述符總是在進程中當前沒有打開的最小描述符。flags參數指明瞭進程打算如何訪問這個文件。mode參數指定了新文件的訪問權限位。作爲上下文的一部分,每個進程都有一個umask,它是通過調用umask函數來設置的。當進程通過帶某個mode參數的open函數調用來創建一個新文件時,文件的訪問權限位被設置成mode&~umask。
進程通過調用close函數關閉一個打開的文件。函數聲明如下:
int close(int fd);
關閉一個已關閉的描述符會出錯。關閉成功返回0,若出錯則返回-1.
應用程序是通過分別調用read和write函數來執行輸入和輸出的。函數聲明如下:
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf, size_t n);
read函數從描述符爲fd的當前文件位置賦值最多n個字節到內存位置buf。返回值-1表示一個錯誤,0表示EOF,否則返回值表示的是實際傳送的字節數量。
write函數從內存位置buf複製至多n個字節到描述符爲fd的當前文件位置。
通過調用lseek函數,應用程序能夠顯式第修改當前文件的位置。
8.3 printf的實現分析
printf函數是在stdio.h頭文件中聲明的,具體代碼實現如下:
int printf(const char *fmt, ...)
{
int i;
char buf[256];
va_list arg = (va_list)((char*)(&fmt) + 4);
i = vsprintf(buf, fmt, arg);
write(buf, i);
return i;
}
它的參數包括一個字串fmt,和…。…表示參數個數不能確定,也就是格式化標準輸出,我們也不能確定到底有幾個格式串。
在函數的第6行,arg變量定位到了第二個參數,也就是第一個格式串。和這句有關的具體問題,還是請看參考文獻[6],本節只是簡述printf的實現過程。
va_list是一個數據類型,其聲明如下:
typedef char *va_list
至於賦值語句右側的,是一個地址偏移定位,定位到了從fmt開始之後的第一個char*變量,也就是第二個參數了。
接下來是調用vsprintf函數,並把返回值賦給整型變量i。後來又調用write函數從內存位置buf處複製i個字節到標準輸出。想必這個i就是printf需要的輸出字符總數,那麼vsprintf就是對參數解析了。vsprintf函數代碼如下:
int vsprintf(char *buf, const char *fmt, va_list args)
{
char* p;
char tmp[256];
va_list p_next_arg = args;
for (p=buf;*fmt;fmt++) { //p初始化爲buf,下面即將把fmt解析並把結果存入buf中
/* 尋找格式化字串 */
if (*fmt != '%') {
*p++ = *fmt;
continue;
}
fmt++; //此時,fmt指向的是格式化字串的內容了
switch (*fmt) {
/*這是格式化字串爲%x的情況*/
case 'x':
itoa(tmp, *((int*)p_next_arg)); //把fmt對應的那個參數字串轉換格式,放到tmp串中
strcpy(p, tmp); //tmp串存到p中,也就是buf中
p_next_arg += 4; //定位到下一個參數
p += strlen(tmp); //buf中的指針也要往下走
break;
/* Case %s */
case 's':
break;
default:
break;
}
}
return (p - buf);
}
這個vsprintf只處理了%x這一種格式化字串的情況。已經給出比較詳細的註釋了。
write函數的彙編代碼是這樣給出的:
1. write:
2. mov eax, _NR_write
3. mov ebx, [esp + 4]
4. mov ecx, [esp + 8]
5. int INT_VECTOR_SYS_CALL
第5行,表示要通過系統來調用sys_call這個函數,函數實現爲:
1. sys_call:
2. call save
3.
4. push dword [p_proc_ready]
5.
6. sti
7.
8. push ecx
9. push ebx
10. call [sys_call_table + eax * 4]
11. add esp, 4 * 3
12.
13. mov [esi + EAXREG - P_STACKBASE], eax
14.
15. cli
16.
17. ret
在write和sys_call中,ecx寄存器中存放的是要打印元素的個數,ebx寄存器中存放的是要打印的buf字符數組中的第一個元素。這個函數的功能就是不斷地打印出字符,直到遇到’\0’。
字符顯示驅動子程序:從ASCII到字模庫到顯示vram(存儲每一個點的RGB顏色信息)。
顯示芯片按照刷新頻率逐行讀取vram,並通過信號線向液晶顯示器傳輸每一個點(RGB分量)。
到此,printf要打印的東西,就呈現在標準輸出上了。
8.4 getchar的實現分析
getchar的函數聲明在stdio.h頭文件中,具體代碼實現如下:
1. int getchar(void)
2. {
3. static char buf[BUFSIZ];
4. static char* bb=buf;
5. static int n=0;
6. if(n==0)
7. {
8. n=read(0,buf,BUFSIZ);
9. bb=buf;
10. }
11. return(--n>=0)?(unsigned char)*bb++:EOF;
12. }
bb是緩衝區的開始,int變量n初始化爲0,只有在n爲0的情況下,從緩衝區中讀BUFSIZ個字節,就是緩衝區中的內容全部讀入。這時候,n的值被修改爲,成功讀入的字節數,正常情況下n就是一個正數值。返回時,如果n大於0,那麼就返回緩衝區的第一個字符。否則,就是沒有從緩衝區讀到字節,返回EOF。
異步異常-鍵盤中斷的處理:鍵盤中斷處理子程序。接受按鍵掃描碼轉成ascii碼,保存到系統的鍵盤緩衝區。
getchar等調用read系統函數,通過系統調用讀取按鍵ascii碼,直到接受到回車鍵才返回。
8.5本章小結
所有的I/O設備都被模型化爲文件,通過文件的讀寫來實現I/O操作。Unix I/O接口函數可以實現一些I/O操作。printf的實現和vsprintf以及write有關,要先解析格式化字串,再調用I/O函數write寫到標準輸出上。getchar函數與鍵盤迴車相關,也就是需要異步異常-鍵盤中斷的處理,之後調用I/O函數read,讀取標準輸入。
結論
用計算機系統的語言,逐條總結hello所經歷的過程。
你對計算機系統的設計與實現的深切感悟,你的創新理念,如新的設計與實現方法。
本文圍繞着hello在計算機系統中的一生展開,到此已落下帷幕。hello的一生,也是其它程序的一生,計算機系統的學習,就是程序在計算機中的日記,我們和程序一起成長。
hello在計算機系統中的一生概括如下:
- 編輯(誕生)。用codeblocks之類的編譯器將正確的程序寫用高級語言出來。
- 預處理(編譯系統第一環節)。GCC中的預處理器將源代碼中的預處理指令拓展,成爲新的文本文件。
- 編譯(編譯系統第二環節)。GCC中的編譯器將拓展代碼文件按照規則,編譯爲彙編代碼文本文件。
- 彙編(編譯系統第三環節)。GCC中的彙編器將彙編代碼翻譯成機器指令格式,打包成可重定位目標文件。
- 鏈接(編譯系統第四環節)。GCC中的鏈接器將可重定位目標文件和其它必要的可重定位目標文件一切鏈接,生成可執行目標文件。
- 運行(進程產生)。在shell中運行hello,shell爲hello創建一個子進程,並調用execve執行hello。
- 內存管理(運行同時)。在shell中運行hello的同時,爲hello分配了虛擬地址空間。
- 內存訪問(運行過程中)。在hello的運行過程中,任何指令語句的執行,都調動着系統和硬件的配合,將虛擬地址翻譯成物理地址,並在主存中取址。是CPU和主存之間的交互。
- 信號與異常(插曲)。信號與異常是hello運行過程中的協奏曲,很多信號與異常是必要的。但如果有不必要的信號與異常發生,會對hello造成一些影響。
- 終止(死亡)。hello由於某種原因(正常或非正常)而終止成爲僵死進程,shell回收hello,同時內核刪除hello的數據相關,爲hello善後。
在一個學期裏學完計算機系統這麼多知識,是非常有挑戰的,的確,學期結束後,感覺自己仍有很多迷惑之處。寫下這麼一篇長文,既是完成作業,也是期末複習,也是對一學期知識的簡單總結。
計算機系統不僅僅是硬件人士的聖經,也是軟件人員的必讀書目。它真正意義在於,瞭解程序的一生,這樣我們才能真正地認識計算機中的程序。
作者才疏學淺,對計算機系統理解鄙陋,缺點和疏漏在所難免,望讀者多加指正。
附件
hello.c |
源程序C語言代碼 |
hello.i |
hello.c預處理後的文本文件 |
hello.s |
編譯後的彙編語言文本文件 |
hello.o |
彙編後的可重定位目標文件 |
hello |
鏈接後的可執行目標文件 |
helloo_obj.s |
hello.o利用objdump工具的反彙編代碼 |
hello_obj.s |
hello利用objdump工具的反彙編代碼 |
其他中間結果 |
readelf和objdump的其他調試代碼,反映在了標準輸出中,沒有保存到本地文件 |
參考文獻
[1] 《深入理解計算機系統》第三版,蘭德爾 E.布萊恩特,大衛 R.奧哈拉倫
[2] GCC編譯命令常用選項,https://www.cnblogs.com/clover-toeic/p/3737129.html
[4] 物理地址,https://baike.baidu.com/item/%E7%89%A9%E7%90%86%E5%9C%B0%E5%9D%80/2901583?fr=aladdin
[5] 邏輯地址,https://baike.baidu.com/item/%E9%80%BB%E8%BE%91%E5%9C%B0%E5%9D%80/3283849?fr=aladdin
[6] https://www.cnblogs.com/pianist/p/3315801.html
[7] getchar,https://baike.baidu.com/item/getchar/919709?fr=aladdin