1. 程式人生 > >CSAPP大作業 hello的一生

CSAPP大作業 hello的一生

摘 要

本文在linux作業系統下對C語言程式hello.c的執行全過程進行了分析。分析了從c檔案轉化為可執行檔案過程中的預處理、編譯、彙編和連結階段,和可執行檔案執行過程中的程序管理、儲存空間管理和I/O管理的原理。

第1章 概述

1.1 Hello簡介

Hello的P2P,020的整個過程:
程式設計師通過編輯器建立hello.c。前處理器根據以字元#開始的命令修改hello.c得到另一個C程式hello.i。編譯器將hello.i翻譯成文字檔案hello.s,它包含一個組合語言程式。彙編器將hello.s翻譯成機器語言指令,把這些指令打包成一種叫做可重定位目標程式的格式,並將結果儲存在目標檔案hello.o中。再經過連結器的處理,就得到了可執行目標檔案hello。
使用者鍵入命令,shell會fork一個子程序,並在這個子程序中呼叫execve載入hello。然後程式會跳轉到_start地址,最終呼叫hello的main函式。hello通過呼叫sleep getchar exit等系統函式執行程式,程式結束後會被shell回收。

1.2 環境與工具

硬體環境:Intel® Core™ i7-7700HQ CPU; 8.00GB RAM
軟體環境:Windows 10 64位;Vmware Workstation 14 Pro;Ubuntu 16.04 LTS 64位
開發工具:CodeBlocks 64位;Visual Studio Code;GCC 5.4.0;objdump;EDB;readelf;

1.3 中間結果

檔案 作用
hello.i hello.c預處理之後的文字檔案
hello.s hello.i編譯之後的文字檔案
hello.o hello.s彙編之後的二進位制檔案
hello hello.o連結之後的二進位制檔案
hello.asm hello的反彙編檔案
helloo.asm hello.o的反彙編檔案
hello.elf hello的elf檔案資訊
helloo.elf hello.o的elf檔案資訊

1.4 本章小結

本章簡要介紹了Hello的P2P,020的整個過程以及實驗的環境、工具和中間產物。

第2章 預處理

2.1 預處理的概念與作用

預處理一般是指由前處理器對程式原始碼文字進行處理的過程。前處理器(cpp)根據以字元#開頭的命令,修改原始的C程式。結果是得到另一個C程式,通常是以.i作為副檔名。
作用:
C語言的預處理主要有三個方面的內容:巨集定義、檔案包含和條件編譯。
1.巨集定義:將巨集名替換為文字(字串或程式碼)。
2.檔案包含:預處理程式將查詢指定的被包含檔案,並將其複製插入到#include命令出現的位置上。比如hello.c中第1行的#include命令告訴前處理器讀取系統標頭檔案stdio.h的內容,並把它直接插人程式文字中。
3.條件編譯:有些語句希望在條件滿足時才編譯,預處理過程中根據條件決定需要編譯的程式碼。

2.2在Ubuntu下預處理的命令

命令:cpp hello.c > hello.i
圖2-1 預處理命令

2.3 Hello的預處理結果解析

圖2-2 預處理結果
可以看出,預處理後的檔案是一個3118行的c語言檔案,其中3098行後的內容對應hello.c中第10行之後的內容。之前的內容是標頭檔案stdio.h unistd.h stdlib.h被複制並插入到#include命令出現的位置上產生的。被包含檔案還可能包含其他檔案,因此該過程可能是巢狀進行的。

2.4 本章小結

本章闡述了預處理的概念,作用和命令,並對hello.c的預處理結果進行了解析。

第3章 編譯

3.1 編譯的概念與作用

編譯是編譯器(ccl)將文字檔案hello.i翻譯成文字檔案hello.s的過程, hello.s包含一個組合語言程式。
過程:1.詞法分析 2.語法分析 3.語義分析 4.原始碼優化 5.程式碼生成,目的碼優化。
作用:把程式碼翻譯成組合語言。
注意:這兒的編譯是指從 .i 到 .s 即預處理後的檔案到生成組合語言程式

3.2 在Ubuntu下編譯的命令

命令:gcc -S hello.i -o hello.s
圖3-1 編譯命令

3.3 Hello的編譯結果解析

3.3.1資料

1.int sleepsecs:
sleepsecs是一個全域性變數,存在.data段中。由於sleepsecs為int型別,賦值時發生了型別的強制轉換。
圖3-2 sleepsecs的宣告和賦值
2.int i:
i是一個區域性變數。區域性變數一般存在暫存器或堆疊中。由i的賦值語句可以看出i存放的位置是-4(%rbp)
圖3-3 i的賦值
3.字串:
程式中有兩個字串:“Usage: Hello 學號 姓名!\n"和"Hello %s %s\n”
都存在只讀資料段中。
圖3-4 字串的儲存
4.陣列:
程式中有一個數組argv[],argv[[1]]和argv[[2]]作為for迴圈中printf的引數。
由取argv[[1]]和argv[[2]]值的彙編語句可知argv的首地址是-32(%rbp)。
取argv[[2]]的彙編語句:
圖3-5 取argv[2]的彙編語句
5.其他資料以立即數的形式出現。

3.3.2 賦值

1.int sleepsecs=2.5:
sleepsecs是全域性變數,在.data節中被賦值。
2.i=0:
圖3-6 i=0
MOV類的資料傳送指令:圖3-7 MOV類的資料傳送指令

3.3.3 型別轉換

將float型別的2.5賦值給int型別的sleepsecs時發生了強制型別轉換。2.5被向下取整為2.

3.3.4 算術操作

常用的整數算術操作:
圖3-8 常用的整數算術操作
程式中出現的算術操作是for迴圈中的i++,被編譯為:
圖3-9 i++

3.3.5 關係操作

比較和測試指令:圖3-10 比較和測試指令
程式中出現的比較指令:
argc!=3被編譯為:
圖3-11 argc!=3
for迴圈中的i<10被編譯為:
圖3-12 i<10

3.3.6 陣列/指標/結構操作

迴圈體中取陣列元素值的操作被編譯成:
圖3-13 迴圈體中取陣列元素值
程式中對argv[[1]],argv[[2]]的定址被編譯為基址+偏移的定址方式。
-32(%rbp)存放的是argv的首地址,在首地址上+8,+16得到argv[[1]],argv[[2]]的地址。

3.3.7 控制轉移

跳轉指令:
圖3-14 跳轉指令
程式中的控制轉移通過比較+跳轉實現:
1.if(argc!=3):
圖3-15 if(argc!=3)
如果argc == 3,執行接下來的語句,否則跳轉到L2處
2.for(i=0;i<10;i++):
圖3-16 for(i=0;i<10;i++)
如果i <= 9,跳轉到L4處執行迴圈體內的語句。

3.3.8 函式操作

函式呼叫大概包括以下幾個步驟(32位):
(1)引數入棧:將引數從右向左依次壓入系統棧中。
(2)返回地址入棧:將當前程式碼區呼叫指令的下一條指令地址壓入棧中,供函式返回時繼續執行。
(3)程式碼區跳轉:處理器從當前程式碼區跳轉到被呼叫函式的入口處。
(4)棧幀調整:具體包括:
  <1>儲存當前棧幀狀態值,已備後面恢復本棧幀時使用(EBP入棧)。
   <2>將當前棧幀切換到新棧幀(將ESP值裝入EBP,更新棧幀底部)。
  <3>給新棧幀分配空間(把ESP減去所需空間的大小,擡高棧頂)。
函式返回的步驟如下:
(1)儲存返回值,通常將函式的返回值儲存在暫存器EAX中。
(2)彈出當前幀,恢復上一個棧幀。具體包括:
  <1>在堆疊平衡的基礎上,給ESP加上棧幀的大小,降低棧頂,回收當前棧幀的空間。
  <2>將當前棧幀底部儲存的前棧幀EBP值彈入EBP暫存器,恢復出上一個棧幀。
  <3>將函式返回地址彈給EIP暫存器。
(3)跳轉:按照函式返回地址跳回母函式中繼續執行。

程式中的函式呼叫主要有如下幾個:
1.main函式
被系統啟動函式__libc_start_main呼叫
2.printf函式
printf(“Usage: Hello 學號 姓名!\n”):
圖3-17 printf("Usage: Hello 學號 姓名!\n")
printf(“Hello %s %s\n”,argv[1],argv[2]):
圖3-18 printf("Hello %s %s\n",argv[1],argv[2])
3.exit函式
exit(1):
圖3-19 exit(1)
4.sleep函式
Sleep(sleepsecs):
圖3-20 Sleep(sleepsecs)
5.getchar函式
getchar():
圖3-21 getchar()

3.4 本章小結

本章闡述了編譯的概念,作用和命令。並結合hello.i的編譯結果,就C語言中的資料與操作如何被翻譯成組合語言進行了總結和分析。

第4章 彙編

4.1 彙編的概念與作用

彙編器(as)將hello.s翻譯成機器語言指令,把這些指令打包成一種叫做可重定位目標程式(relocatable object program)的格式,並將結果儲存在目標檔案hello.o中。
作用:將彙編程式碼轉變為機器指令,生成目標檔案。
注意:這兒的彙編是指從 .s 到 .o 即編譯後的檔案到生成機器語言二進位制程式的過程。

4.2 在Ubuntu下彙編的命令

命令:gcc -c hello.s -o hello.o
圖4-1 彙編命令

4.3 可重定位目標elf格式

1.ELF頭:
ELF頭以一個16位元組的序列開始,這個序列描述了生成該檔案的系統的字的大小和位元組順序。ELF頭剩下的部分包含幫助連結器語法分析和解釋目標檔案的資訊,其中包括ELF頭的大小、目標檔案的型別、機器型別、節頭部表的檔案偏移,節頭部表中條目的大小和數量等。
圖4-2 ELF頭
2.節頭表
記錄了每個節的名稱、型別、屬性(讀寫許可權)、在ELF檔案中所佔的長度、對齊方式和偏移量
圖4-3 節頭表
3.重定位節
重定位條目告訴連結器在將目標檔案合併成可執行檔案時如何修改這個引用。如圖,偏移量是需要被修改的引用的節偏移,符號標識被修改引用應該指向的符號。型別告知連結器如何修改新的引用,加數是一個有符號常數,一些型別的重定位要用它對被修改引用的值做偏移調整。
圖4-4 重定位節
ELF重定位條目:圖4-5 ELF重定位條目
r_offset:
此成員指定應用重定位操作的位置。不同的目標檔案對於此成員的解釋會稍有不同。
r_info:
此成員指定必須對其進行重定位的符號表索引以及要應用的重定位型別。
重定位型別特定於處理器。重定位項的重定位型別或符號表索引是將 ELF32_R_TYPE 或 ELF32_R_SYM 分別應用於項的r_info成員所得的結果。
圖4-6 重定位項
r_addend:
此成員指定常量加數,用於計算將儲存在可重定位欄位中的值。
重定位型別:
R_X86_64_PC3:重定位一個使用32位PC相對地址的引用。在指令中編碼的32位值加上PC的當前執行時值,得到有效地址。
R_X86_64_32:重定位一個使用32位PC絕對地址的引用。直接使用在指令中編碼的32位值作為有效地址。
4.符號表
它存放在程式中定義和引用的函式和全域性變數的資訊,.symtab符號表不包含區域性變數的條目。
圖4-7 符號表

4.4 Hello.o的結果解析

1.反彙編程式碼與hello.s差別不大。
2. hello.s使用十進位制,反彙編程式碼中使用的是16進位制。
3.分支轉移:hello.s中使用段的標號(如:.L3)作為分支後跳轉的地址,反彙編程式碼中用相對main函式起始地址的偏移表示跳轉的地址。
4.函式呼叫:hello.s中函式呼叫後直接跟著函式的名字,反彙編程式碼中函式呼叫的目標地址是當前的下一條指令。在機器語言中call後的地址為全0.在重定位節中有對應的重定位條目,連結之後確定地址。
5.全域性變數:hello.s中使用段名稱+%rip訪問,反彙編程式碼中使用0+%rip訪問。機器語言中待訪問的全域性變數地址為全0.在重定位節中有對應的重定位條目,連結之後確定地址。
圖4-8 hello.o與hello.s對比

4.5 本章小結

這章介紹了彙編的概念、作用和命令。分析了可重定位目標檔案的格式,比較了反彙編程式碼與hello.s的相同點與不同點。

第5章 連結

5.1 連結的概念與作用

連結是將各種程式碼和資料片段收集並組合成為一個單一檔案的過程。
作用:將函式庫中相應的程式碼組合到目標檔案中。
注意:這兒的連結是指從 hello.o 到hello生成過程。

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/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
圖5-1 連結命令

5.3 可執行目標檔案hello的格式

1.節頭表:
在這裡插入圖片描述
圖5-2 節頭表
.text節是儲存了程式程式碼指令的程式碼節。一段可執行程式,存在Phdr,.text就會存在於text段中。由於.text節儲存了程式程式碼,因此節的型別為SHT_PROGBITS。
.rodata 儲存只讀資料。型別SHT_PROGBITS。
.plt 過程連結表(Procedure Linkage Table),包含動態連結器呼叫從共享庫匯入的函式所必須的相關程式碼。存在於text段中,型別SHT_PROGBITS。
.bss節儲存未初始化全域性資料,是data的一部分。程式載入時資料被初始化成0,在程式執行期間可以賦值,未儲存實際資料,型別SHT_NOBITS。
.got節儲存全域性偏移表。它和.plt節一起提供了對匯入的共享庫函式訪問的入口。由動態連結器在執行時進行修改。如果攻擊者獲得堆或者.bss漏洞的一個指標大小寫原語,就可以對該節任意修改。型別SHT_PROGBITS。
.dynsym節儲存共享庫匯入的動態符號資訊,該節在text段中,型別SHT_DYNSYM。
.dynstr儲存動態符號字串表,存放一系列字串,代表了符號的名稱,以空字元作為終止符。
.rel節儲存重定位資訊,型別SHT_REL。
.hash節,也稱為.gnu.hash,儲存一個查詢符號散列表。
.symtab節,儲存了ElfN_Sym型別的符號資訊,型別SHT_SYMTAB。
strtab節,儲存符號字串表,表中內容被.symtab的ElfN_Sym結構中的st_name條目引用。型別SHT_SYMTAB。
.shstrtab節,儲存節頭字串表,以空字元終止的字串集合,儲存了每個節節名,如.text,.data等。有個e_shsrndx的ELF檔案頭條目會指向.shstrtab節,e_shstrndx中儲存了.shstrtab的偏移量。這節的型別是SHT_SYMTAB。
.ctors和.dtors節,前者構造器,後者析構器,指向建構函式和解構函式的函式指標,建構函式是在main函式執行前需要執行的程式碼,析構是main函式之後需要執行的程式碼。
2.程式頭表:
圖5-3 程式頭表
可以看出,程式包含八個段。
1.PTDR: 指定程式頭表在檔案及程式記憶體映像中的位置和大小。
2.INTERP: 指定要作為解釋程式呼叫的以空字元結尾的路徑名的位置和大小。對於動態可執行檔案,必須設定此型別。
3.LOAD: 指定可裝入段,通過p_filesz和p_memsz進行描述。檔案中的位元組會對映到記憶體段的起始位置。
4.DYNAMIC: 指定動態連結資訊。
5.NOTE: 指定輔助資訊的位置和大小。
6.GNU_STACK: 許可權標誌,標誌棧是否是可執行的。
7.GNU_RELRO: 指定在重定位結束之後那些記憶體區域是需要設定只讀。

5.4 hello的虛擬地址空間

1.連線後的hello屬於elf可執行目標檔案,所包含的各類資訊如下:
圖5-4 可執行目標檔案包含的各類資訊
2.虛擬地址空間各段資訊:
(1).PDHR:起始位置為0x400040,大小為0x1c0
圖5-5 .PDHR
(2).INTERP:起始位置為0x400200,大小為0x1c圖5-6 .INTERP
(3).LOAD:起始位置為0x400000,大小為0x81c圖5-7 .LOAD
(4).LOAD:起始位置為0x600e00,大小為0x258(4).LOAD:起始位置為0x600e00,大小為0x258
同理可以找到其他段的位置和內容。

5.5 連結的重定位過程分析

1.hello中增加了許多節和被呼叫的函式。
2.對rodata的引用:在hello.o的反彙編檔案中對printf引數字串的引用使用全0替代。在hello中則使用確定地址,這是因為連結後全域性變數的地址能夠確定。
3.hello.o中main 的地址從0開始,hello中main的地址不再是0.庫函式的程式碼都連結到了程式中。
圖5-9 hello與hello.o的反彙編對比
圖5-10 hello的反彙編檔案

5.6 hello的執行流程

使用edb執行hello,說明從載入hello到_start,到call main,以及程式終止的所有過程。請列出其呼叫與跳轉的各個子程式名或程式地址。

程式名稱 程式地址
ld-2.27.so!_dl_start 0x7f5d6118fea0
ld-2.27.so!_dl_init 0x7f5d6119e630
hello!_start 0x400500
[email protected] 0x4004b0
[email protected] 0x4004e0

5.7 Hello的動態連結分析

程式呼叫一個由共享庫定義的函式時,編譯器沒有辦法預測這個函式的執行時地址,因為定義它的共享模組在執行時可以載入到任意位置。GNU編譯系統使用延遲繫結的技術解決這個問題,將過程地址的延遲繫結推遲到第一次呼叫該過程時。
延遲繫結要用到全域性偏移量表(GOT)和過程連結表(PLT)兩個資料結構。如果一個目標模組呼叫定義在共享庫中的任何函式,那麼它就有自己的GOT和PLT。
PLT:PLT是一個數組,其中每個條目是16位元組程式碼。PLT[0]是一個特殊條目,跳轉到動態連結器中。每個條目都負責呼叫一個具體的函式。PLT[[1]]呼叫系統啟動函式 (__libc_start_main)。從PLT[[2]]開始的條目呼叫使用者程式碼呼叫的函式。
GOT:GOT是一個數組,其中每個條目是8位元組地址。和PLT聯合使用時,GOT[0]和GOT[[1]]包含動態連結器在解析函式地址時會使用的資訊。GOT[[2]]是動態連結器在ld-linux.so模組中的入口點。其餘的每個條目對應於一個被呼叫的函式,其地址需要在執行時被解析。
分析:圖5-11 節頭表中.got.plt節的資訊
在節頭表中找到GOT的起始位置為601000
呼叫_dl_start之前可以看出有16個為0的位元組:
圖5-12 呼叫_dl_start之前
呼叫_dl_start之後發現這些值發生了變化圖5-13 呼叫_dl_start之後
GOT[[2]]是動態連結器在ld-linux.so模組中的入口點,共享庫模組:
圖5-14 共享庫模組
注意第一次呼叫puts之前的跳轉地址:
圖5-15 第一次呼叫puts之前的跳轉地址
呼叫一次puts之後的跳轉地址:圖5-16 呼叫一次puts之後的跳轉地址
可以說明呼叫後printf連結到了動態庫。

5.8 本章小結

本章介紹了連結的概念和作用,分析了hello的格式、虛擬地址空間、重定位過程、執行流程和動態連結分析。

第6章 hello程序管理

6.1 程序的概念與作用

程序是一個執行中程式的例項。是系統進行資源分配和排程的基本單位,是作業系統結構的基礎。
作用:程序的概念為我們提供這樣一種假象,就好像我們的程式是系統中當前執行的唯一程式一樣,我們的程式好像是獨佔地使用處理器和記憶體,處理器好像是無間斷地一條接一條地執行我們程式中的指令,我們程式中的程式碼和資料好像是系統記憶體中唯一的物件。

6.2 簡述殼Shell-bash的作用與處理流程

Shell俗稱殼,是指“為使用者提供操作介面”的軟體(命令解析器)。它接收使用者命令,然後呼叫相應的應用程式。
1.功能:命令解釋。Linux系統中的所有可執行檔案都可以作為Shell命令來執行。
2.處理流程:
1)當用戶提交了一個命令後,Shell首先判斷它是否為內建命令,如果是就通過Shell內部的直譯器將其解釋為系統功能呼叫並轉交給核心執行。
2)若是外部命令或應用程式就試圖在硬碟中查詢該命令並將其調入記憶體,再將其解釋為系統功能呼叫並轉交給核心執行。

6.3 Hello的fork程序建立過程

在終端中輸入./hello 學號 姓名,shell判斷它不是內建命令,於是會載入並運行當前目錄下的可執行檔案hello.此時shell通過fork建立一個新的子程序。新建立的子程序幾乎但不完全與父程序相同。子程序得到與父程序使用者級虛擬地址空間相同的(但是獨立的)一份副本,包括程式碼和資料段、堆、共享庫和使用者棧。子程序還獲得與父程序任何開啟檔案描述符相同的副本,這就意味著當父程序呼叫fork時,子程序可以讀寫父程序中開啟的任何檔案。子程序與父程序有不同的pid。fork被呼叫一次,返回兩次。在父程序中fork返回子程序的pid,在子程序中fork返回0.父程序與子程序是併發執行的獨立程序。

6.4 Hello的execve過程

execve函式在新建立的子程序的上下文中載入並執行hello程式。execve函式載入並執行可執行目標檔案filename,且帶引數列表argv和環境變數列表envp。只有發生錯誤時execve才會返回到呼叫程式。所以,execve呼叫一次且從不返回。
載入並執行hello需要以下幾個步驟:
1.刪除已存在的使用者區域。刪除當前程序虛擬地址的使用者部分中已存在的區域結構。
2.對映私有區域。為新程式的程式碼、資料、bss和棧區域建立新的區域結構。所有這些新的區域都是私有的、寫時複製的。程式碼和資料區被對映為hello檔案中的.text和.data區。bss區域是請求二進位制零的,對映到匿名檔案,其大小包含在hello中。棧和堆區域也是請求二進位制零的,初始長度為零。
3.對映共享區域。如果hello程式與共享物件連結,那麼這些物件都是動態連結到這個程式的,然後再對映到使用者虛擬地址空間中的共享區域內。
4.設定程式計數器。設定當前程序上下文中的程式計數器,使之指向程式碼區域的入口點。下一次排程這個程序時,它將從這個入口點開始執行。

6.5 Hello的程序執行

系統中的每個程式都執行在某個程序的上下文中。上下文是由程式正確執行所需的狀態組成的。這個狀態包括存放在記憶體中的程式的程式碼和資料,它的棧、通用目的暫存器的內容、程式計數器、環境變數以及開啟檔案描述符的集合。
一個程序執行它的控制流的一部分的每一時間段叫做時間片。
處理器通常用某個控制暫存器的一個模式位來提供使用者模式和核心模式的功能。設定了模式位時,程序就執行在核心模式中,該程序可以執行指令集中的任何指令,可以訪問系統中的任何記憶體位置。沒有設定模式位時,程序就執行在使用者模式中,使用者模式中的程序不允許執行特權指令。
在程序執行的某些時刻,核心可以決定搶佔當前程序,並重新開始一個先前被搶佔了的程序的決定叫做排程。
程式在執行sleep函式時,sleep系統呼叫顯式地請求讓呼叫程序休眠,排程器搶佔當前程序,並使用一種稱為上下文切換的機制來將控制轉移到新的程序。sleep的倒計時結束後,控制會回到hello程序中。程式呼叫getchar()時,核心可以執行上下文切換,將控制轉移到其他程序。getchar()的資料傳輸結束之後,引發一箇中斷訊號,控制回到hello程序中。
呼叫sleep:
圖6-1 呼叫sleep
呼叫getchar:
圖6-2 呼叫getchar

6.6 hello的異常與訊號處理

異常的類別:
圖6-3 異常的類別
訊號:
圖6-4 訊號
訊號的處理:當核心把程序p從核心模式切換到使用者模式時,它會檢查程序p的未被阻塞的待處理訊號的集合。如果集合非空,核心強制p接收訊號k。收到這個訊號會觸發程序採取某種行為,一旦完成行為,控制就傳遞迴p的邏輯控制流中的下一條指令,每個訊號型別都有一種預設行為,也可以通過設定signal函式改變和訊號signum相關聯的行為。
1.正常退出:
圖6-5 正常退出
程式結束後,程序被回收。
2.隨便亂按:
圖6-6隨便亂按
亂按會將輸入的內容存到緩衝區,作為接下來的命令列輸入。
3.Ctrl+c:
圖6-7 Ctrl+c
Ctrl+c會使核心傳送一個SIGINT訊號。訊號處理程式會回收子程序。
4.Ctrl+z:
圖6-8 Ctrl+z

6.7本章小結

本章闡述了程序的定義和作用,shell的作用和處理流程,執行hello時的fork和execve過程。分析了hello的程序執行和異常與訊號處理過程。

第7章 hello的儲存管理

7.1 hello的儲存器地址空間

邏輯地址:在有地址變換功能的計算機中,訪內指令給出的地址 (運算元) 叫邏輯地址,也叫相對地址。要經過定址方式的計算或變換才得到記憶體儲器中的實際有效地址,即實體地址。是hello.o中的相對偏移地址。
線性地址:線性地址(Linear Address)是邏輯地址到實體地址變換之間的中間層。在分段部件中邏輯地址是段中的偏移地址,然後加上基地址就是線性地址。
虛擬地址:程式訪問儲存器所使用的邏輯地址稱為虛擬地址。是hello裡的虛擬記憶體地址。
實體地址:在儲存器裡以位元組為單位儲存資訊,為正確地存放或取得資訊,每一個位元組單元給以一個唯一的儲存器地址,稱為實體地址。是hello裡虛擬記憶體地址對應的實體地址。

7.2 Intel邏輯地址到線性地址的變換-段式管理

1.基本原理:
在段式儲存管理中,將程式的地址空間劃分為若干個段(segment),這樣每個程序有一個二維的地址空間。在段式儲存管理系統中,為每個段分配一個連續的分割槽,而程序中的各個段可以不連續地存放在記憶體的不同分割槽中。程式載入時,作業系統為所有段分配其所需記憶體,這些段不必連續,實體記憶體的管理採用動態分割槽的管理方法。
在為某個段分配實體記憶體時,可以採用首先適配法、下次適配法、最佳適配法等方法。
在回收某個段所佔用的空間時,要注意將收回的空間與其相鄰的空間合併。
段式儲存管理也需要硬體支援,實現邏輯地址到實體地址的對映。
程式通過分段劃分為多個模組,如程式碼段、資料段、共享段:
–可以分別編寫和編譯
–可以針對不同型別的段採取不同的保護
–可以按段為單位來進行共享,包括通過動態連結進行程式碼共享
這樣做的優點是:可以分別編寫和編譯源程式的一個檔案,並且可以針對不同型別的段採取不同的保護,也可以按段為單位來進行共享。
總的來說,段式儲存管理的優點是:沒有內碎片,外碎片可以通過記憶體緊縮來消除;便於實現記憶體共享。缺點與頁式儲存管理的缺點相同,程序必須全部裝入記憶體。

2.段式管理的資料結構:
為了實現段式管理,作業系統需要如下的資料結構來實現程序的地址空間到實體記憶體空間的對映,並跟蹤實體記憶體的使用情況,以便在裝入新的段的時候,合理地分配記憶體空間。
·程序段表:描述組成程序地址空間的各段,可以是指向系統段表中表項的索引。每段有段基址(baseaddress),即段內地址。
在系統中為每個程序建立一張段對映表,如圖:
圖7-1 段對映表
·系統段表:系統所有佔用段(已經分配的段)。
·空閒段表:記憶體中所有空閒段,可以結合到系統段表中。
3.段式管理的地址變換
圖7-2 段式管理的地址變換
在段式 管理系統中,整個程序的地址空間是二維的,即其邏輯地址由段號和段內地址兩部分組成。為了完成程序邏輯地址到實體地址的對映,處理器會查詢記憶體中的段表,由段號得到段的首地址,加上段內地址,得到實際的實體地址。這個過程也是由處理器的硬體直接完成的,作業系統只需在程序切換時,將程序段表的首地址裝入處理器的特定暫存器當中。這個暫存器一般被稱作段表地址暫存器。

7.3 Hello的線性地址到實體地址的變換-頁式管理

1.基本原理
將程式的邏輯地址空間劃分為固定大小的頁(page),而實體記憶體劃分為同樣大小的頁框(page frame)。程式載入時,可將任意一頁放入記憶體中任意一個頁框,這些頁框不必連續,從而實現了離散分配。該方法需要CPU的硬體支援,來實現邏輯地址和實體地址之間的對映。在頁式儲存管理方式中地址結構由兩部構成,前一部分是虛擬頁號(VPN),後一部分為虛擬頁偏移量(VPO):
圖7-3 地址結構
頁式管理方式的優點是:
1)沒有外碎片
2)一個程式不必連續存放。
3)便於改變程式佔用空間的大小(主要指隨著程式執行,動態生成的資料增多,所要求的地址空間相應增長)。
缺點是:要求程式全部裝入記憶體,沒有足夠的記憶體,程式就不能執行。
2.頁式管理的資料結構
在頁式系統中程序建立時,作業系統為程序中所有的頁分配頁框。當程序撤銷時收回所有分配給它的頁框。在程式的執行期間,如果允許程序動態地申請空間,作業系統還要為程序申請的空間分配物理頁框。作業系統為了完成這些功能,必須記錄系統記憶體中實際的頁框使用情況。作業系統還要在程序切換時,正確地切換兩個不同的程序地址空間到實體記憶體空間的對映。這就要求作業系統要記錄每個程序頁表的相關資訊。為了完成上述的功能,—個頁式系統中,一般要採用如下的資料結構。
頁表:頁表將虛擬記憶體對映到物理頁。每次地址翻譯硬體將一個虛擬地址轉換為實體地址時,都會讀取頁表。頁表是一個頁表條目(PTE)的陣列。虛擬地址空間的每個頁在頁表中一個固定偏移量處都有一個PTE。假設每個PTE是由一個有效位和一個n位地址欄位組成的。有效位表明了該虛擬頁當前是否被快取在DRAM中。如果設定了有效位,那麼地址欄位就表示DRAM中相應的物理頁的起始位置,這個物理頁中快取了該虛擬頁。如果沒有設定有效位,那麼一個空地址表示這個虛擬頁還未被分配。否則,這個地址就指向該虛擬頁在磁碟上的起始位置。
圖7-4 頁表
3.頁式管理地址變換
MMU利用VPN來選擇適當的PTE,將列表條目中PPN和虛擬地址中的VPO串聯起來,就得到相應的實體地址。
圖7-5 頁式管理地址變換

7.4 TLB與四級頁表支援下的VA到PA的變換

虛擬地址被劃分成4個VPN和1個VPO。每個VPNi都是一個到第i級頁表的索引,其中1<=i<=4.第j級頁表中的每個PTE,1<=j<=3,都指向第j+1級的某個頁表的基址。第四級頁表中的每個PTE包含某個物理頁面的PPN,或者一個磁碟塊的地址。為了構造實體地址,在能夠確定PPN之前,MMU必須訪問4個PTE。將得到的PPN和虛擬地址中的VPO串聯起來,就得到相應的實體地址。
圖7-6 頁表翻譯

7.5 三級Cache支援下的實體記憶體訪問

L1 d-cache的結構如圖所示:通過6-11位的組索引找到對應的組,將組中每一行的tag與CT比較,若標記位匹配且有效位為1,說明命中,根據0-5位的塊偏移取出資料,如果沒有匹配成功,則向下一級快取中查詢資料。取回資料後,如果有空閒塊則放置在空閒塊中,否則根據替換策略選擇犧牲塊。
圖7-7 地址翻譯

7.6 hello程序fork時的記憶體對映

當fork函式被當前程序呼叫時,核心為新程序建立各種資料結構,並分配給它一個唯一的pid。為了給這個新程序建立虛擬記憶體。它建立了當前程序的mm_struct、區域結構和頁表的原樣副本。它將兩個程序中的每個頁面都標記位只讀,並將兩個程序中的每個區域結構都標記為私有的寫時複製。
當fork在新程序中返回時,新程序現在的虛擬記憶體剛好和呼叫fork時存在的虛擬記憶體相同。當這兩個程序中的任一個後來進行寫操作時,寫時複製機制就會建立新頁面。

7.7 hello程序execve時的記憶體對映

載入並執行hello需要以下幾個步驟:
1.刪除已存在的使用者區域。刪除當前程序虛擬地址的使用者部分中已存在的區域結構。
2.對映私有區域。為新程式的程式碼、資料、bss和棧區域建立新的區域結構。所有這些新的區域都是私有的、寫時複製的。程式碼和資料區被對映為hello檔案中的.text和.data區。bss區域是請求二進位制零的,對映到匿名檔案,其大小包含在hello中。棧和堆區域也是請求二進位制零的,初始長度為零。
3.對映共享區域。如果hello程式與共享物件連結,那麼這些物件都是動態連結到這個程式的,然後再對映到使用者虛擬地址空間中的共享區域內。
4.設定程式計數器。設定當前程序上下文中的程式計數器,使之指向程式碼區域的入口點。下一次排程這個程序時,它將從這個入口點開始執行。
載入器如何對映使用者地址空間的區域:
圖7-8 載入器是如何對映使用者地址空間的區域的

7.8 缺頁故障與缺頁中斷處理

在虛擬記憶體的習慣說法中,DRAM快取不命中稱為缺頁。例如:CPU引用了VP3中的一個字,VP3並未快取在DRAM中。地址翻譯硬體從記憶體中讀取PTE3,從有效位推斷出VP3未被快取,並且觸發一個缺頁異常。缺頁異常呼叫核心中的缺頁異常處理程式,該程式會選擇一個犧牲頁,在此例中就是存放在PP3中的VP4。如果VP4已經被修改了,那麼核心就會將它複製回磁碟。無論哪種情況,核心都會修改VP4的頁表條目,反映出VP4不再快取在主存中這一事實。缺頁之前:
圖7-9 缺頁前
接下來,核心從磁碟複製VP3到記憶體中的PP3,更新PTE3,隨後返回。當異常處理程式返回時,它會重新啟動導致缺頁的指令,該指令會把導致缺頁的虛擬地址重發送到地址翻譯硬體。但是現在VP3已經快取在主存中了,那麼也命中也能由地址翻譯硬體正常處理了。缺頁之後:
圖7-10 缺頁後

7.9動態儲存分配管理

動態記憶體分配器維護著一個程序的虛擬記憶體區域,稱為堆。分配器將堆視為一組不同大小的塊的集合來維護。每個塊就是一個連續的虛擬記憶體片,要麼是已分配的,要麼是空閒的。已分配的塊顯式地保留為供應用程式使用。空閒塊可用來分配。空閒塊保持空閒,直到它顯式地被應用所分配。一個已分配的塊保持已分配狀態,直到它被釋放,這種釋放要麼是應用程式顯式執行的,要麼是記憶體分配器自身隱式執行的。
1.隱式空閒連結串列:
圖7-11 隱式空閒連結串列
空閒塊通過頭部中的大小欄位隱含地連線著。分配器可以通過遍歷堆中所有的塊,從而間接地遍歷整個空閒塊的集合。
(1)放置策略:首次適配、下一次適配、最佳適配。
首次適配從頭開始搜尋空閒連結串列,選擇第一個合適的空閒塊。下一次適配從上一次查詢結束的地方開始。最佳適配檢查每個空閒塊,選擇適合所需請求大小的最小空閒塊。
(2)合併策略:立即合併、推遲合併。
立即合併就是在每次一個塊被釋放時,就合併所有的相鄰塊;推遲合併就是等到某個稍晚的時候再合併空閒塊。
帶邊界標記的合併:
圖7-12 使用邊界標記的堆塊
在每個塊的結尾新增一個腳部,分配器就可以通過檢查它的腳部,判斷前面一個塊的起始位置和狀態,從而使得對前面塊的合併能夠在常數時間之內進行。
2.顯式空閒連結串列
圖7-13 使用雙向空閒連結串列的堆塊
每個空閒塊中,都包含一個pred(前驅)和succ(後繼)指標。使用雙向連結串列使首次適配的時間減少到空閒塊數量的線性時間。
空閒連結串列中塊的排序策略:一種是用後進先出的順序維護連結串列,將新釋放的塊放置在連結串列的開始處,另一種方法是按照地址順序來維護連結串列,連結串列中每個塊的地址都小於它後繼的地址。
分離儲存:維護多個空閒連結串列,每個連結串列中的塊有大致相等的大小。將所有可能的塊大小分成一些等價類,也叫做大小類。
分離儲存的方法:簡單分離儲存和分離適配。

7.10本章小結

本章討論了儲存器地址空間,段式管理、頁式管理,TLB與四級頁表支援下的VA到PA的變換,三級Cache支援下的實體記憶體訪問,hello程序fork時和execve時的記憶體對映,缺頁故障與缺頁中斷處理和動態儲存分配管理。

第8章 hello的IO管理

8.1 Linux的IO裝置管理方法

所有的I/O裝置(例如網路、磁碟和終端)都被模型化為檔案,而所有的輸入和輸出都被當做對相應檔案的讀和寫來執行。這種將裝置優雅地對映為檔案的方式,允許Linux核心引出一個簡單、低階的應用介面,稱為Unix I/O,這使得所有的輸入和輸出都能以一種統一且一致的方式來執行。

8.2 簡述Unix IO介面及其函式

I/O介面操作
1.開啟檔案:一個應用程式通過要求核心開啟相應的檔案,來宣告它想要訪問一個I/O裝置。核心返回一個小的非負整數,叫做描述符,它在後續對此檔案的所有操作中標識這個檔案。核心記錄有關這個開啟檔案的所有資訊。應用程式只需記住這個描述符。
2.Linux shell建立的每個程序開始時都有三個開啟的檔案:標準輸入、標準輸出和標準錯誤。
3.改變當前的檔案位置:對於每個開啟的檔案,核心保持著一個檔案位置k,初始為0.這個檔案位置是從檔案開頭起始的位元組偏移量。應用程式能夠通過執行seek操作,顯式地設定檔案的當前位置為k。
4.讀寫檔案:一個讀操作就是從檔案複製n>0個位元組到記憶體,從當前檔案位置k開始,然後將k增加到k+n。給定一個大小為m位元組的檔案,當k>=m時執行讀操作會觸發一個稱為end-of-file(EOF)的條件,應用程式能檢測到這個條件。在檔案結尾處並沒有明確的“EOF符號”。
類似地,寫操作就是從記憶體複製n>0個位元組到一個檔案,從當前檔案位置k開始,然後更新k。
5.關閉檔案:當應用完成了對檔案的訪問之後,它就通知核心關閉這個檔案。作為響應,核心釋放檔案開啟時建立的資料結構,並將這個描述符恢復到可用的描述符池中。無論一個程序因為何種原因終止時,核心都會關閉所有開啟的檔案並釋放它們的記憶體資源。
函式:
1.int open(char *filename, int flags, mode_t mode)
程序通過呼叫open函式來開啟一個已存在的檔案或者建立一個新檔案。open函式將filename轉換為一個檔案描述符,而且返回描述符數字。flags引數指明瞭程序打算如何訪問這個檔案。mode引數指定了新檔案的訪問許可權位。
2.int close(int fd)
程序通過呼叫close函式關閉一個開啟的檔案。
3.ssize_t read(int fd, void *buf, size_t n)
應用程式通過呼叫read函式來執行輸入。read函式從描述符為fd的當前檔案位置複製最多n個位元組到記憶體位置buf。返回值-1表示一個錯誤,返回值0表示EOF。否則返回值表示的是實際傳送的位元組數量。
4.ssize_t write(int fd, const void *buf, size_t n)
應用程式通過呼叫write函式來執行輸出。write函式從記憶體位置buf複製至多n個位元組到描述符fd的當前檔案位置。

8.3 printf的實現分析

https://www.cnblogs.com/pianist/p/3315801.html
printf的程式碼:

1.	int printf(const char *fmt, ...)   
2.	{   
3.	     int i;   
4.	     char buf[256];   
5.	      
6.	     va_list arg = (va_list)((char*)(&fmt) + 4);   
7.	     i = vsprintf(buf, fmt, arg);   
8.	     write(buf, i);   
9.	      
10.	     return i;   
11.	}   

其中,va_list是一個字元指標,arg表示函式的第二個引數。
vsprintf的程式碼:

1.	int vsprintf(char *buf, const char *fmt, va_list args)   
2.	{   
3.	    char* p;   
4.	    char tmp[256];   
5.	    va_list p_next_arg = args;   
6.	     
7.	    for (p=buf;*fmt;fmt++) {   
8.	    if (*fmt != '%') {   
9.	    *p++ = *fmt;   
10.	    continue;   
11.	    }   
12.	     
13.	    fmt++;   
14.	     
15.	    switch (*fmt) {   
16.	    case 'x':   
17.	    itoa(tmp, *((int*)p_next_arg));   
18.	    strcpy(p, tmp);   
19.	    p_next_arg += 4;   
20.	    p += strlen(tmp);   
21.	    break;   
22.	    case 's':   
23.	    break;   
24.	    default:   
25.	    break;   
26.	    }   
27.	    }   
28.	     
29.	    return (p - buf);   
30.	}   

vsprintf的作用是格式化。它接受確定輸出格式的格式字串fnt。用格式字串對個數變化的引數進行格式化,產生格式化輸出,並返回要列印的字串的長度。
write的程式碼:

mov eax, _NR_write 
     mov ebx, [esp + 4] 
     mov ecx, [esp + 8] 
     int INT_VECTOR_SYS_CALL 

先給暫存器傳了幾個引數,然後通過系統呼叫sys_call

sys_call:
call save 
    
     push dword [p_proc_ready] 
    
     sti 
    
     push ecx 
     push ebx 
     call [sys_call_table + eax * 4] 
     add esp, 4 * 3 
    
     mov [esi + EAXREG - P_STACKBASE], eax 
    
     cli 
    
     ret 

syscall將字串中的位元組從暫存器中通過匯流排複製到顯示卡的視訊記憶體中,視訊記憶體中儲存的是字元的ASCII碼。
字元顯示驅動子程式:從ASCII到字模庫到顯示vram(儲存每一個點的RGB顏色資訊)。
顯示晶片按照重新整理頻率逐行讀取vram,並通過訊號線向液晶顯示器傳輸每一個點(RGB分量)。

8.4 getchar的實現分析

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.	}   

getchar函式呼叫read函式,將整個緩衝區都讀到buf裡,並將緩衝區的長度賦值給n。返回時返回buf的第一個元素,除非n<0。
非同步異常-鍵盤中斷的處理:鍵盤中斷處理子程式。接受按鍵掃描碼轉成ascii碼,儲存到系統的鍵盤緩衝區。
getchar等呼叫read系統函式,通過系統呼叫讀取按鍵ascii碼,直到接受到回車鍵才返回。

8.5本章小結

本章簡述了Linux的I/O裝置管理機制,Unix I/O介面及函式,並簡要分析了printf函式和getchar函式的實現。

結論

1.程式設計師通過編輯器建立hello.c。
2.前處理器根據以字元#開始的命令修改hello.c得到另一個C程式hello.i。
3.編譯器將hello.i翻譯成文字檔案hello.s,它包含一個組合語言程式。
4.彙編器將hello.s翻譯成機器語言指令,把這些指令打包成一種叫做可重定位目標程式的格式,並將結果儲存在目標檔案hello.o中。
5.再經過連結器的處理,就得到了可執行目標檔案hello。
6.使用者鍵入命令,shell會fork一個子程序。
7.在這個子程序中呼叫execve載入hello。
8.然後程式會跳轉到_start地址,最終呼叫hello的main函式。
9.hello通過呼叫sleep getchar exit等系統函式執行程式。
10.程序結束後會被shell回收。
做大作業的過程相當於複習了一下這個學期的知識,尤其是通過分析各種檔案,我對編譯彙編等過程的理解更加深刻了。這個大作業要求我們分析了hello程式執行各個階段的底層實現,使我具體地認識到計算機系統的各個部分是怎樣協調工作的。

參考文獻

為完成本次大作業你翻閱的書籍與網站等
1.深入理解計算機系統第三版
2.https://baike.baidu.com/item/預處理命令/10204389
3.https://blog.csdn.net/shiyongraow/article/details/81454995
4.https://blog.csdn.net/u011555996/article/details/70211315
5.https://docs.oracle.com/cd/E26926_01/html/E25910/chapter6-54839.html
6.https://baike.baidu.com/item/邏輯地址/3283849?fr=aladdin
7.https://baike.baidu.com/item/線性地址
8.https://baike.baidu.com/item/虛擬地址
9.https://www.cnblogs.com/huangwentian/p/7487670.html
10.https://blog.csdn.net/youyou519/article/details/82659007
11.https://docs.oracle.com/cd/E26926_01/html/E25910/chapter6-83432.html
12.https://www.cnblogs.com/pianist/p/3315801.html