1. 程式人生 > >HIT CS:APP Hello’s P2P

HIT CS:APP Hello’s P2P

第1章 概述

1.1 Hello簡介

hello的P2P(From Program to Process):
首先我們通過鍵盤輸入程式碼得到hello.c程式。然後hello.c經過預處理形成hello.i文字檔案,接著編譯生成hello.s組合語言文字檔案,其次經過彙編器,它成為hello.o可重定位目標程式二進位制檔案,最終連結器處理合並,結果得到可執行目標程式hello。執行該目標檔案,作業系統會使用fork函式形成一個子程序,使用execve函式載入此程序。至此,hello由一個‘程式’變成了‘程序’。
hello的020(From Zero-0 to Zero-0):
在hello執行的過程中。 程式對資料進行處理時,其空間在記憶體上申請。shell 為其對映虛擬記憶體,CPU訪問相關資料需要MMU的虛擬地址到實體地址的轉化,其中TLB和四級頁表提高了地址翻譯的速度。計算機的三級快取記憶體結構以下一級作為上一級的快取,讓hello的資料能夠從磁碟傳輸到暫存器。CPU為執行的hello分配時間片,執行邏輯控制流。作業系統將I/O裝置都抽象為檔案,讓hello程式能夠呼叫硬體進行從鍵盤讀入字元,向螢幕輸出內容的輸入輸出。最後shell負責回收hello程序,核心刪除相關資料,釋放其執行過程中佔用的記憶體空間。

1.2 環境與工具

1.硬體環境:Intel Core i5-7300HQ x64CPU @2.50GHz;8.00GB;
2.軟體環境:Windows 10 64 位作業系統;VMware 14;Ubuntu18.04.1 LTS
3.開發與除錯工具:GCC;EDB;READELF;objdump;gedit;

1.3 中間結果

名稱 作用 生成指令
hello.i 預處理後的文字檔案 gcc -E hello.c -o hello.i
hello.s 編譯後的彙編檔案 gcc -S hello.i -o hello.s
hello.o 彙編後的可重定位目標檔案 gcc -no-pie -fno-PIC -c hello.c -o hello.o
hello 連結後的可執行目標檔案 ld -o hello -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 hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
helloo.elf hello.o的ELF格式 readelf -a hello > helloo.elf
hello.elf hello的ELF格式 readelf -a hello > hello.elf
helloo.txt hello.o的反彙編程式碼 objdump -d -r hello.o > helloo.txt
hello.txt hello的反彙編程式碼 objdump -d -r hello > hello.txt

1.4 本章小結

本章介紹了hello從p2p到020全過程,大致介紹了hello的完整而又波折的一生,並且列出了做本次作業的軟硬體環境以及開發和除錯工具,最後列出了本次作業過程中產生的中間檔案。

第2章 預處理

2.1 預處理的概念與作用

 圖2.1 hello.c預處理在全過程中的位置
概念:前處理器(cpp)根據以字元#開頭的命令,修改原始的C程式。
作用:預處理在原始碼編譯之前對其進行的一些文字性質的操作,對源程式編譯之前做一些處理,生成擴充套件的C源程式。

  • 1.處理#include預編譯指令,將標頭檔案中的內容(原始檔之外的檔案)插入到原始檔中。
  • 2.進行了巨集替換的過程,定義和替換了由#define指令定義的符號
  • 3.刪除掉註釋的過程,註釋不會帶入到編譯階段
  • 4.處理所有條件編譯指令,如#if,#ifdef等;

2.2在Ubuntu下預處理的命令

命令:gcc -E hello.c -o hello.i
圖2.2   使用gcc命令生成hello.i檔案
圖2.3 Hello的預處理結果解析
開啟hello.i發現程式已經拓展為3118行,只有最後數十行是原hello.c程式內容。觀察可發現,程式開頭註釋已被刪除,並且標頭檔案以被插入到原始檔中。
圖2.3   hello.i檔案
開啟hello.i後發現預處理具體包括對標頭檔案的包含,巨集定義的擴充套件,條件編譯的選擇等。預處理主要是根據#符號進行處理,在hello.c中,就是將相應的.h檔案的內容插入到源程式檔案中,再對巨集定義進行拓展,再進行條件編譯的選擇,即根據條件值來決定是否執行包含其中的邏輯。

2.4 本章小結

本章介紹了在C語言編譯的時候,首先經歷的預處理階段。有關於預處理的定義及作用、Linux下預處理的命令及結果解析。
預處理功能是編譯程式的第一環,也不能忽視——合理地使用預處理功能編寫的程式便於閱讀、修改、移植和除錯,也有利於模組化程式設計。

第3章 編譯

3.1 編譯的概念與作用

概念:編譯器(ccl)將文字檔案hello.i翻譯成文字檔案hello.s,它包含一個組合語言程式。
作用:把預處理完的用高階程式設計語言書寫的源程式,經過一系列詞法分析,語法分析,語義分析及優化翻譯成等價的機器語言格式目標程式的翻譯程式。

3.2 在Ubuntu下編譯的命令

gcc -S hello.i -o hello.s
在這裡插入圖片描述

3.3 Hello的編譯結果解析

3.3.1 資料

1.字串:
“Usage: Hello 學號 姓名!\n”,“Hello %s %s\n”
在這裡插入圖片描述
在這裡插入圖片描述
可以看出兩個字串都宣告在了.rodata 只讀資料節。
2.整數:
int sleepsecs在hello.c程式中為已經被賦值全域性變數,因為.data節存放已經初始化的全域性和靜態C變數,可知sleepsecs因存放在.data節中。如下圖3.4。
在這裡插入圖片描述
int i區域性變數儲存在暫存器或者棧中,這裡儲存在棧-4(%rbp)中。
在這裡插入圖片描述

3.3.2 賦值

int sleepsecs = 2.5該變數是全域性變數,直接在.data節中宣告為值2的long型別資料。(因為int型別,所以不是2.5)
在這裡插入圖片描述
i=0使用mov指令完成。
在這裡插入圖片描述

3.3.3 型別轉換

隱式型別轉換:
全域性變數int sleepsecs=2.5,將浮點數型別的2.5轉換為int型。浮點數轉換為整型,遵循向偶數舍入的原則,將2.5舍入為2。

3.3.4 算數操作

hello.c中涉及的算數操作有:i++
在這裡插入圖片描述

3.3.5 關係操作

hello.c中涉及的關係操作有:
argc!=3 計算argc-3然後設定條件碼,如果等於0就進行跳轉。
在這裡插入圖片描述
i<10計算i-9然後設定條件碼,如果小於等於0就進行跳轉,重複迴圈。
在這裡插入圖片描述

3.3.6 陣列/指標/結構操作

char *argv[] ,主函式傳入的第二個引數,指標型別陣列。
在這裡插入圖片描述
在64位系統裡。無論什麼型別的指標大小都是8位元組,main函式中訪問陣列元素argv[1],argv[2]時,從起始地址-16(%rbp)開始每8位元組取一次指標資料。

3.3.7 控制轉移

hello.c中涉及的控制轉移有:
if (argv!=3):
在這裡插入圖片描述
首先使用cmpl指令,計算argc-3然後設定條件碼,使用je判斷ZF標誌位,如果為0,說明 argv==3,不執行if中的程式碼跳轉到.L2,否則執行if中的程式碼。
for(i=0;i<10;i++):
在這裡插入圖片描述
首先無條件跳轉到位於.L3的比較程式碼如果i<=9,則跳入.L4執行迴圈體,否則說明迴圈結束。

3.3.7 函式操作

C語言函式操作包含引數傳遞、函式呼叫和函式返回功能。
hello.c中涉及的函式操作的有:
printf函式:
A:將%rdi設定為“Usage: Hello 學號 姓名!\n”字串的首地址,作為傳入的引數。
B:設定%rdi為“Hello %s %s\n”的首地址,設定%rsi為argv[1],%rdx為argv[2]。
(兩次區別:第一次printf因為只有一個字串引數,所以call [email protected];第二次printf傳入三個引數使用call [email protected])

exit函式:將%edi設定為1,作為傳入引數。

sleep函式: 將%edi設定為sleepsecs,作為傳入引數。

getchar函式: 略

3.4 本章小結

本章主要介紹了編譯的概念和作用以及編譯指令和C語言的各類操作的彙編程式碼。
編譯這個階段編譯器主要做詞法分析、語法分析、語義分析等,在檢查無錯誤後後,把程式碼翻譯成組合語言。 編譯器將文字檔案hello.i 翻譯成文字檔案hello.s, 它包含一個組合語言程式,即一條低階機器語言指令。

第4章 彙編

4.1 彙編的概念與作用

概念:彙編器(as)將hello.s翻譯成機器語言指令,把這些指令打包成一種叫做可重定位目標程式的格式,並將結果儲存在目標檔案hello.o 中。
作用:彙編器是將彙編程式碼轉變成機器可以執行的命令,每一個彙編語句幾乎都對應一條機器指令。

4.2 在Ubuntu下彙編的命令

gcc -no-pie -fno-PIC -c hello.c -o hello.o
在這裡插入圖片描述

4.3 可重定位目標elf格式

分析hello.o的ELF格式,用readelf等列出其各節的基本資訊,特別是重定位專案分析。使用readelf -a hello.o > hello.elf指令——
1.ELF頭:以一個16位元組的序列開始,這個描述了生成該檔案的系統的字的大小和位元組順序,ELF頭剩下的部分包含幫助連結器語法分析和解釋目標檔案的資訊,其中包括ELF頭的大小、目標檔案的型別、機器型別、 位元組頭部表的檔案偏移,以及節頭部表中條目的大小和數量。
在這裡插入圖片描述
2.節頭部表:不同節的位置和大小。
在這裡插入圖片描述
3.rela.text:一個.text 節中位置的列表,包含.text節中需要進行重定位的資訊。
在這裡插入圖片描述
4.rela.eh_frame:eh_frame節的重定位資訊
在這裡插入圖片描述
5.symtab:一個符號表,它存放程式中定義和引用的函式和全域性變數的資訊。
在這裡插入圖片描述

4.4 Hello.o的結果解析

使用objdump -d -r hello.o > helloo.txt分析hello.o的反彙編,並與第3章的hello.s進行對照分析。
發現有以下幾點不同——
1.函式呼叫:原來的函式名字被替換成了函式的相對偏移地址
2.跳轉語句:由原來的段地址變成了相對偏移地址。
3.全域性變數:由使用段名稱+%rip,變成了在反彙編程式碼中0+%rip。這是因為 rodata中資料地址也需要重定位。
4.運算元:由hello.s裡面的十進位制,變成了在反彙編程式碼中的十六進位制。

組合語言以及機器碼的對應:
一條指令就是機器語言的一個語句,是一組有意義的二進位制程式碼。指令的基本格式:操作碼欄位和地址碼欄位,其中操作碼指明瞭指令的操作性質及功能,地址碼則給出了運算元或運算元的地址。

4.5 本章小結

本章介紹了從hello.s到hello.o的彙編過程,首先理清了彙編的概念和作用,知道了Linux下如何彙編以及反彙編。檢視hello.o的可重定位目標elf格式,並通過使用objdump得到反彙編程式碼與hello.s進行比較。
彙編器將組合語言轉化成機器語言,機器語言是用二進位制程式碼表示的計算機能直接識別和執行的一種機器指令的集合。組合語言作為最基礎也是最古老的語言我們也需要加以瞭解,這將有助於我們編寫出質量更高的程式碼。

第5章 連結

5.1 連結的概念與作用

概念:以一組可重定位目標檔案和命令列引數作為輸入,生成一個完全連結的、可以載入和執行的可執行目標檔案作為輸出。輸入的可重定位檔案由各種不同的程式碼和資料節組成,每一節都是一個連續的位元組序列。
作用:連結過程是因為彙編程式生成的目標檔案並不能立即就被執行,其中可能還有許多沒有解決的問題。 例如,某個原始檔中的函式可能引用了另一個原始檔中定義的某個符號(如變數或者函式呼叫等);在程式中可能呼叫了某個庫檔案中的函式等等。都需要經連結程式的處理方能得以解決。連結程式的主要工作就是將有關的目標檔案彼此相連線,也即將在一個檔案中引用的符號同該符號在另外一個檔案中的定義連線起來,使得所有的這些目標檔案成為一個能夠按作業系統裝入執行的統一整體。

5.2 在Ubuntu下連結的命令

ld -o hello -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 hello.o /usr/lib/x86_64-linux-gnu/libc.so /usr/lib/x86_64-linux-gnu/crtn.o
在這裡插入圖片描述

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

分析hello的ELF格式,使用readelf -a hello > helloo.elf命令列出其各段的基本資訊,包括各段的起始地址,大小等資訊。節頭對給出了所有的節的偏移位置和大小。
在這裡插入圖片描述

5.4 hello的虛擬地址空間

使用edb載入hello,檢視本程序的虛擬地址空間各段資訊,並與5.3對照分析說明。
在這裡插入圖片描述
在0x400000~0x401000段中,程式被載入。之間每個節的排列即開始結束同5.3圖中各段宣告。

5.5 連結的重定位過程分析

使用 objdump -d -r hello.o > helloo.txt指令獲得hello.o的反彙編程式碼。使用objdump -d -r hello > hello.txt指令獲得hello的反彙編程式碼。
在這裡插入圖片描述
先使用文字編輯器對二者進行對比——總覽如下圖,下面依次進行細節分析
在這裡插入圖片描述
hello相對於hello.o反彙編後有如下不同:
1.增加了節

hello.o反彙編 .text - - -
hello反彙編 .text .init .plt .fini

2.連結器加入外部函式。如初始化函式_init,程式中用到的printf、sleep、getchar、exit函式等等。
3.重定位符號引用。如對跳轉以及函式呼叫的地址,對於它們連結器修改程式碼節和資料節中對每個符號的引用,使其指向正確的虛擬記憶體地址。
4.重定位符號定義,連結器計算相對距離,將執行時記憶體地址賦給新的節,以及賦給輸入模組定義的每一個符號(函式)。

連結的過程:
連結需要完成兩個主要任務,首先是符號解析,將每一個符號引用正好和一個符號定義關聯起來。其次進行重定位,重定位又由兩部組成:
1.重定位節和符號定義:連結器將所有輸入相同型別的節合併為同一型別的新的聚合節。然後,連結器將執行時記憶體地址賦給新的聚合節,賦給輸入模組定義的每個節,以及賦給輸入模組定義的每一個符號。當這一步完成時,程式中的每條指令和全域性變數都有唯一的執行時記憶體地址了。
2.重定位節中的符號引用,連結器會修改程式碼節和資料節中對每一個符號的引用,使得他們指向正確的執行地址。

5.6 hello的執行流程

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

假裝有表
----

5.7 Hello的動態連結分析

觀察dl_init前後動態連結專案的變化:首先知道.got.plt節的起始地址是 0x601000,在DataDump中找到該位置。
在這裡插入圖片描述
圖 5.6 dl_init 前的.got.plt 節
在這裡插入圖片描述
圖 5.7 dl_init 後的.got.plt 節
使用edb執行至dl_init,發現在地址0x601000 發生了變化:(如上圖5.6和5.7) 可以看到 dl_init 後出現了兩個地址,0x7f74faad8170 和 0x7f74fa8c6750。這其實就是GOT[1]和 GOT[2]。
由此可以印證動態連結的基本思想是把程式按照模組拆分成各個相對獨立部分,在程式執行時才將它們連結在一起形成一個完整的程式,而不是像靜態連結一樣把所有程式模組都連結成一個單獨的可執行檔案。而且需要用到動態連結庫。比如在形成可執行程式時,發現引用了一個外部的函式,此時會檢查動態連結庫,發現這個函式名是一個動態連結符號,此時可執行程式就不對這個符號進行重定位,而把這個過程留到裝載時再進行。

5.8 本章小結

本章中主要介紹了連結的概念與作用、hello的ELF格式,分析了hello的虛擬地址空間、重定位過程、執行流程、動態連結過程。
本章我們對連結的步驟和過程進行了詳細的分解和解析,得知連結器在軟體開發中扮演著一個關鍵的角色,因為它們使得分離編譯成為可能。我們不用將一個大型的應用程式組織為一個巨大的原始檔,而是可以把它分解為更小、更好管理的模組。

第6章 hello程序管理

6.1 程序的概念與作用

概念:程序是一個執行中程式的例項。是計算機中的程式關於某資料集合上的一次執行活動,是系統進行資源分配和排程的基本單位,是作業系統結構的基礎。
作用:程序為應用程式提供了關鍵抽象——一個獨立的邏輯控制流,它提供一個假象,好像是我們的程式獨佔的使用處理器;一個私有的地址空間,它提供一個假象,好像我們的程式獨佔的使用記憶體系統。

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

作用:
Shell是一個命令直譯器,它解釋由使用者輸入的命令並且把它們送到核心。主要功能如下:
1.可互動,和非互動的使用shell。在互動式模式,shell從鍵盤接收輸入;在非互動式模式,shell從檔案中獲取輸入。
2. shell中可以同步和非同步的執行命令。在同步模式,shell要等命令執行完,才能接收下面的輸入。在非同步模式,命令執行的同時,shell就可接收其它的輸入。重定向功能,可以更細緻的控制命令的輸入輸出。另外,shell允許設定命令的執行環境。
3. shell提供了少量的內建命令,以便自身功能更加完備和高效。
4. shell除了執行命令,還提供了變數,流程控制,引用和函式等,類似高階語言一樣,能編寫功能豐富的程式。

處理流程:
1.讀取輸入的命令列.
2.解析引用並分割命令列為各個單詞,各單詞稱為token。其中重定向所在的token會被儲存下來,直到擴充套件步驟(5)結束後才進行相關處理,如進行擴充套件、截斷檔案等。
3.檢查命令列結構。主要檢查是否有命令列表、是否有shell程式設計結構的命令,如if判斷命令、迴圈結構的for/while/select/until,這些命令屬於保留關鍵字,需要特殊處理。
4.對第一個token進行別名擴充套件。如果檢查出它是別名,則擴充套件後回到(2)再次進行token分解過程。如果檢查出它是函式,則執行函式體中的複合命令。如果它既是別名,又是函式(即命令別名和函式同名稱的情況),則優先執行別名。在概念上,別名的臨時性最強,優先順序最高。
5.進行各種擴充套件。擴充套件順序為:大括號擴充套件;波浪號擴充套件;引數、變數和命令替換、算術擴充套件(如果系統支援,此步還進行程序替換);單詞拆分;檔名擴充套件。
6.引號去除。
7.搜尋和執行命令。
8.返回退出狀態碼。

6.3 Hello的fork程序建立過程

鍵入‘./hello 1170301023 王琦’後,Shell解析命令列,發現不是內建命令,判斷為執行當前目錄的可執行目標檔案,通過呼叫fork函式建立一個新的執行的子程序int fork(void)。其中子程序返回0,父程序返回子程序的PID,新建立的子程序幾乎但不完全與父程序相同:子程序得到與父程序虛擬地址空間相同的(但是獨立的)一份副本(程式碼、資料段、堆、共享庫以及使用者棧),但有不同於父程序的PID。
在這裡插入圖片描述

6.4 Hello的execve過程

execve函式載入並執行可執行目標檔案filename,且帶引數列表argv和環境變數列表envp。只有當出現錯誤時,例如找不到filename,execve 才會返回到呼叫程式。與fork一次呼叫返回兩次不同,execve呼叫一次並從不返回。在execve載入了filename之後,呼叫啟動程式碼,啟動程式碼設定棧,並將控制傳遞給新程式的主函式。

6.5 Hello的程序執行

核心為每個程序維持一個上下文,上下文是核心重新啟動一個被搶佔的程序所需要的狀態。核心可以搶佔當前程序,並重新開始一個被搶佔的程序所需的狀態 ,叫做排程。如果hello程式不被搶佔則順序執行,假如發生被搶佔的情況,則使用上下文切換機制將控制轉移到新的程序,需要:1.儲存以前程序的上下文;2.恢復新恢復程序被儲存的上下文;3.將控制傳遞給這個新恢復的程序。
當用戶態與核心態相互轉換時可能發生上下文切換。例如hello初始執行在使用者態,在hello程序系統呼叫sleep函式後,顯式地請求讓呼叫程序休眠。將hello程序從執行佇列中移出,定時器開始計時。此時發生上下文切換將當前程序的控制權交給其他程序。過程如下圖——
在這裡插入圖片描述
這其中一個程序執行它的控制流的一部分的每一時間段叫做時間片。例如上圖每兩條橫線中間為一個時間片。

6.6 hello的異常與訊號處理

1.正常執行:
在這裡插入圖片描述
2.Ctrl-z:產生SIGSTP訊號,將hello程序掛起。
在這裡插入圖片描述
3.指令ps:
在這裡插入圖片描述
4.指令jobs:
在這裡插入圖片描述
5.指令pstree:
在這裡插入圖片描述
6.指令fg:傳送SIGCONT訊號繼續執行停止程序,將hello調到前臺
在這裡插入圖片描述
7.Ctrl-c:產生SIGINT訊號,結束並回收hello程序。
在這裡插入圖片描述
8.kill -9 pid:傳送SIGKILL訊號給指定的pid殺死程序。
在這裡插入圖片描述

6.7本章小結

本章介紹了程序的概念與作用,Shell-bash的作用與處理流程,如何呼叫fork建立新程序,以及呼叫execve執行hello,hello的程序執行,hello的異常與訊號處理。瞭解了本章Linux下異常處理的機制,可以使得我們在以後編寫程式的時候儘量減少異常的發生。

第7章 hello的儲存管理

7.1 hello的儲存器地址空間

結合hello說明邏輯地址、線性地址、虛擬地址、實體地址的概念。
邏輯地址:程式程式碼經過編譯後出現在彙編程式中地址。是hello.o的相對偏移地址。
線性地址:是邏輯地址到實體地址變換之間的中間層。在分段部件中邏輯地址是段中的偏移地址,然後加上基地址就是線性地址。是hello裡面的虛擬記憶體地址。
虛擬地址:CPU可以生成一個虛擬地址。虛擬地址是Windows程式時執行在保護模式下,訪問儲存器所使用的邏輯地址,可以寫為“段:偏移量”的形式。是hello裡面的虛擬記憶體地址。
實體地址:計算機系統的主存被組織成一個由M個連續的位元組大小的單元組成的陣列。每位元組都有一個唯一的實體地址。其出現在CPU地址總線上的定址實體記憶體的地址訊號,是地址變換的最終結果。是hello在執行時虛擬記憶體地址對應的地址。

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

在段式儲存管理中,將程式的地址空間劃分為若干個段,這樣每個程序有一個二維的地址空間。系統為每個段分配一個連續的分割槽,而程序中的各個段可以不連續地存放在記憶體的不同分割槽中。程式載入時,作業系統為所有段分配其所需記憶體,這些段不必連續,實體記憶體的管理採用動態分割槽的管理方法。
在為某個段分配實體記憶體時,可以採用首先適配法、下次適配法、最佳適配法等方法。在回收某個段所佔用的空間時,要注意將收回的空間與其相鄰的空間合併。
為了實現段式管理,作業系統需要如下的資料結構來實現程序的地址空間到實體記憶體空間的對映,並跟蹤實體記憶體的使用情況,以便在裝入新的段的時候,合理地分配記憶體空間。在這裡插入圖片描述
在段式管理系統中,整個程序的地址空間是二維的,即其邏輯地址由段號和段內地址兩部分組成。為了完成程序邏輯地址到實體地址的對映,處理器會查詢記憶體中的段表,由段號得到段的首地址,加上段內地址,得到實際的實體地址。作業系統需在程序切換時,將程序段表的首地址裝入處理器的段表地址暫存器。
在這裡插入圖片描述

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

線性地址到實體地址的變換是通過分頁機制完成的。
將程式的邏輯地址空間劃分為固定大小的頁,而實體記憶體劃分為同樣大小的頁框。程式載入時,可將任意一頁放人記憶體中任意一個頁框,這些頁框不必連續,從而實現了離散分配。該方法需要CPU的硬體支援,來實現邏輯地址和實體地址之間的對映。在頁式儲存管理方式中地址結構由兩部構成,前一部分是頁號,後一部分為頁內地址w。
在頁式系統中程序建立時,作業系統為程序中所有的頁分配頁框。當程序撤銷時收回所有分配給它的頁框。在程式的執行期間,如果允許程序動態地申請空間,作業系統還要為程序申請的空間分配物理頁框。作業系統必須記錄系統記憶體中實際的頁框使用情況。為了完成上述的功能,一般要採用如下的資料結構。
在這裡插入圖片描述
在頁式系統中,指令所給出的地址分為兩部分:虛擬頁號和虛擬頁偏移量。CPU中的記憶體管理單元按邏輯頁號通過查程序頁表得到物理頁框號,將物理頁框號與頁內地址相加形成實體地址。
虛擬頁號和虛擬頁偏移量->查程序頁表,得物理頁號->實體地址:
在這裡插入圖片描述
上述過程通常由處理器的硬體直接完成,不需要軟體參與。通常,作業系統只需在程序切換時,把程序頁表的首地址裝入處理器特定的暫存器中即可。一般來說,頁表儲存在主存之中。這樣處理器每訪問一個在記憶體中的運算元,就要訪問兩次記憶體:第一次用來查詢頁表將運算元的 邏輯地址變換為實體地址;第二次完成真正的讀寫操作。

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

在這裡插入圖片描述
TLB通過虛擬地址VPN部分進行索引,分為索引與標記兩個部分。若TLB命中,則從TLB中可以直接找到各級頁表,然後得到PPN,與PPO結合即可得到實體地址。若TLB不命中,則需要從快取記憶體中到PPN。

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

得到實體地址後,對實體地址進行分析,將其拆分成CT(快取標記位)、CI(快取組索引)、CO(快取偏移)。首先在一級cache中尋找:如果匹配成功且塊的標誌位為1,則命中(hit),根據資料偏移量CO取出資料返回。如果沒有匹配成功或者匹配成功但是標誌位是1,則不命中(miss),向下一級快取中查詢資料,找到之後返回結果。具體處理如下——
Cache讀策略:
1.命中,則從cache中讀相應資料到CPU或上一級cache中。
2.失敗,則從主存或下一級cache中讀取資料,並替換出一行資料,通常採用LRU演算法。
Cache寫策略:
1.命中
(1)寫回:只寫本級cache,暫時不寫資料到主存或下一級cache,等到該行被替換出去時,才將資料寫回到主存或下一級cache。
(2)直寫:寫本級cache,同時寫資料到主存或下一級cache,等到該行被替換出去時,就不用寫回資料了。
2.失敗
(1)按寫分配,分兩種:
[1]先寫資料到主存或下一級cache,並從主存或下一級cache讀取剛才修 改過的資料,即:先寫資料,再為所寫資料分配cache line;
[2]先分配cache line給所寫資料,即:從主存中讀取一行資料到cache, 然後直接對cache進行修改,並不把資料到寫到主存或下一級cache,一直等 到該行被替換出去,才寫資料到主存或下一級cache。
(2)寫不分配:
直接寫資料到主存或下一級cache,並且不從主存或下一級cache中讀取 被改寫的資料,即:不分配cache line給被修改的資料。

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

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

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

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

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

缺頁故障:DRAM快取不命中稱為缺頁。如CPU引用了VP 3中的一個字, VP 3並未快取在DRAM中。地址翻譯硬體從記憶體中讀取PTE3, 從有效位推斷出VP3 未被快取,並且觸發一個缺頁異常。
在這裡插入圖片描述
缺頁中斷處理:缺頁異常呼叫核心中的缺頁異常處理程式,該程式會選擇一個犧牲頁,然後將控制返回給引起故障的指令。當指令再次執行,相應的物理頁面已經駐留在記憶體中了,指令可以沒有故障地執行完成。

7.9動態儲存分配管理

動態記憶體分配器的基本原理:
動態記憶體分配器維護著一個程序的虛擬記憶體區域,稱為堆。分配器將堆視為一組不同大小的塊的集合來維護。每個塊就是一個連續的虛擬記憶體片,要麼是已分配的,要麼是空閒的。已分配的塊顯式地保留為供應用程式使用。空閒塊可用來分配。空閒塊保持空閒,直到它顯式地被應用所分配。一個已分配的塊保持已分配狀態,直到它被釋放,這種釋放要麼是應用程式顯式執行的,要麼是記憶體分配器自身隱式執行的。
分配器主要有:顯式分配器,要求應用顯式地釋放任何已分配的塊;
隱式分配器:要求分配器檢測一個已分配塊何時不再使用,那麼就釋放這個塊, 自動釋放未使用的已經分配的塊的過程叫做垃圾收集。

帶邊界標籤的隱式空閒連結串列分配器原理:
隱式空閒連結串列並不直接對空閒塊進行連結,而是通過頭部中的大小欄位隱含地連線著的。分配器可以通過遍歷堆中所有的塊,從而間接地遍歷整個空閒塊的集合,將所有塊組織成一個大連結串列。其中Header和Footer中的block大小間接起到了前驅、後繼指標的作用。
帶邊界標籤的空閒塊合併:邊界標記在每個塊的結尾處新增腳部(是頭部的一個副本)。分配器可以通過檢查腳部,判斷前面一個塊的起始位置和狀態。腳部在據當前塊開始位置一個字的距離。
在這裡插入圖片描述

情況1:兩個鄰接的塊都是已分配的,不可能合併,當前塊的狀態從已分配變成空閒。
情況2:當前塊與後面的塊合併,用當前塊與後面塊的大小的和來更新當前塊的頭部和後面塊的腳部。
情況3:前面塊與當前塊合併,用兩個塊的大小的和來更新前面塊的頭部和當前塊的腳部。
情況4:合併三個塊形成一個單獨的空閒塊,用三個塊的大小的和來更新前面塊的頭部和當前塊的腳部。
顯式空閒連結串列的基本原理:
顯式空閒連結串列是將空閒塊組織為某種形式的顯式資料結構。因為根據定義,程式不需要一個空閒塊的主體,所以實現這個資料結構的指標可以存放在這些空閒塊的主體裡面。堆可以組織成一個雙向空閒連結串列,在每個空閒塊中,都包含一個前驅和後繼指標。
使用雙向連結串列,使首次適配的分配時間從塊總數的線性時間減少到了空閒塊數量的線性時間。釋放一個塊的時間可以是線性的,也可能是常數,取決於我們所選擇的空閒連結串列中塊的排序策略。
維護連結串列:
1.後進先出(LIFO)的順序,將新釋放的塊位置放在連結串列的開始處。使用LIFO的順序和首次適配的放置策略,分配器會最先檢查最近使用過的塊。在這種情況下,釋放一個塊可以在常數時間內完成。如果使用了邊界標記,那麼合併也可以在常數時間內完成。
2.按照地址順序,其中連結串列中每個塊的地址都小於它後繼的地址。在這種情況下,釋放一個塊需要線性時間的搜尋來定位合適的前驅。平衡點在於,按照地址排序的首次適配比LIFO排序的首次適配有更高的儲存器利用率,接近最佳適配的利用率。

7.10本章小結

本章介紹了hello的儲存器地址空間、intel的段式管理、hello的頁式管理,以 介紹了TLB和四級頁表支援下的VA到PA的變換還有三級cache支援下的實體記憶體訪問,還介紹了hello程序fork時的記憶體對映、execve 時的記憶體對映、缺頁故障與缺頁中斷處理、動態儲存分配管理。
雖然hello是一個最基礎的程式,但是它卻具有相當的代表性。許許多多的程序就相當於但無數個hello放在一起管理,計算機需要有邏輯清晰的儲存和訪問機制才能保證訪存的速度。

第8章 hello的IO管理

8.1 Linux的IO裝置管理方法

裝置的模型化:檔案
裝置管理:所有的I/O裝置(例如網路、磁碟和終端)都被模型化為檔案,而所有的輸入和輸出都被當作對相應檔案的讀和寫來執行。這使得所有的輸入和輸出都能以一種統一且一致的方式來執行。
1.開啟檔案。
2.改變當前的檔案位置。
3.讀寫檔案。
4.關閉檔案。

8.2 簡述Unix IO介面及其函式

Unix IO介面:
將裝置優雅地對映為檔案的方式,允許Linux 核心引出一個簡單、低階的應用介面,以檔案的方式對I/O裝置進行讀寫,將裝置均對映為檔案。對檔案的操作,即Unix I/O介面。

函式:

1.開啟檔案。一個應用程式通過要求核心開啟相應的檔案,來宣告它想要訪間一個I/O裝置。核心返回一個小的非負整數叫做描述符。它在後續對此檔案的所有操作中標識這個檔案。核心記錄有關這個開啟檔案的所有資訊。應用程式只需記住這個描述符。Linux shell建立的每個程序開始時都有三個開啟的檔案:標準輸入、標準輸出和標準錯誤。
程序是通過呼叫open 函式來開啟一個已存在的檔案或者建立一個新檔案的:
int open(char *filename, int flags, mode_t mode);
open函式將filename轉換為一個檔案描述符,並且返回描述符數字。返回的描述符總是在程序中當前沒有開啟的最小描述符。flags引數指明瞭程序打算如何訪問這個檔案,mode引數指定了新檔案的訪問許可權位。返回,若成功則為新檔案描述符,若出錯為-1。

2.讀寫檔案。一個讀操作就是從檔案複製n>0個位元組到記憶體,從當前檔案位置k開始,然後將k增加到k+n。給定一個大小為m位元組的檔案,當k≥m時執行讀操作會觸發一個稱為end-of-file的條件,應用程式能檢測到這個條件。類似地,寫操作就是從記憶體複製n>0個位元組到一個檔案,從當前檔案位置k開始,然後更新k。
read函式從描述符為fd的當前檔案位置複製最多n個位元組到記憶體位置buf。返回值-1表示一個錯誤,而返回值0表示EOF。否則,返回值表示的是實際傳送的位元組數量。返回,若成功則為讀的位元組數,若EOF則為0, 若出錯為-1。
write函式從記憶體位置buf複製至多n個位元組到描述符fd的當前檔案位置。返回,若成功則為寫的位元組數,若出錯則為-1。

3.關閉檔案。當應用完成了對檔案的訪問之後,它就通知核心關閉這個檔案,作為相應,核心釋放檔案開啟時建立的資料結構,並將這個描述符恢復到可用的描述符池中。無論一個程序以何種原因終止時,核心都會關閉所有開啟的檔案並釋放它們的記憶體資源。關閉檔案是通知核心你要結束訪問一個檔案。返回,若成功則為0, 若出錯則為-1。

8.3 printf的實現分析

printf函式程式碼如圖8.5所示:
在這裡插入圖片描述

我們可以發現,它呼叫了兩個外部函式,一個是vsprintf,還有一個是write。vsprintf函式的作用是將所有的引數內容格式化之後存入buf,然後返回格式化陣列的長度write函式是將buf中的i個元素寫到終端的函式。
在這裡插入圖片描述

從vsprintf生成顯示資訊,傳送到write系統函式,到陷阱-系統呼叫 int 0x80或syscall.字元顯示驅動子程式。從ASCII到字模庫到顯示vram(儲存每一個點的RGB顏色資訊)。顯示晶片按照重新整理頻率逐行讀取vram,並通過訊號線向液晶顯示器傳輸每一個點(RGB分量)。

8.4 getchar的實現分析

#define getchar() getc(stdin)。getchar有一個int型的返回值。當程式呼叫getchar時,程式就等著使用者按鍵,使用者輸入的字元被存放在鍵盤緩衝區中,直到使用者按回車為止(回車字元也放在緩衝區中)。當用戶鍵入回車之後,getchar才開始從stdin流中每次讀入一個字元。getchar函式的返回值是使用者輸入的第一個字元的ASCII碼,如出錯返回-1,且將使用者輸入的字元回顯到螢幕。如使用者在按回車之前輸入了不止一個字元,其他字元會保留在鍵盤快取區中,等待後續getchar呼叫讀取。也就是說,後續的getchar呼叫不會等待使用者按鍵,而直接讀取緩衝區中的字元,直到緩衝區中的字元讀完為後,才等待使用者按鍵。
在這裡插入圖片描述
非同步異常-鍵盤中斷的處理:鍵盤中斷處理子程式。接受按鍵掃描碼轉成ascii碼,儲存到系統的鍵盤緩衝區。
getchar等呼叫read系統函式,通過系統呼叫讀取按鍵ascii碼,直到接受到回車鍵才返回。

8.5本章小結

本章講述了linux的I/O裝置管理機制,瞭解了開啟、關閉、讀寫、轉移檔案的介面及相關函式,簡單分析了printf和getchar函式的實現方法以及操作過程。

結論

至此,我們已經遍歷了程式hello的一生,雖然它是極為簡單和基礎的,但是它的一生卻絲毫不注水,不拖沓,顯得井井有條,充滿了程式設計師的‘邏輯美’。下面我們來簡單地回顧一下hello的精彩的一生——

  1. hello.c經過前處理器 cpp 的預處理得到 hello.i。
  2. 編譯器ccl將得到的 hello.i 編譯成彙編檔案 hello.s。
  3. 彙編器as將hello.s翻譯成機器語言指令得到可重定位目標檔案hello.o
  4. 連結器ld將hello.o與動態連結庫連結生成可執行目標檔案hello
  5. 在shell中輸入./hello 1170301023 王琦,核心為hello fork一個子程序。
    6.execve 通過載入器將hello中的程式碼和資料從磁碟複製到記憶體,為其建立虛擬記憶體映像,載入器在程式頭部表的引導下將hello的片複製到程式碼段和資料段,執行_start函式。
  6. 當CPU訪問 hello 時,請求一個虛擬地址,MMU通過頁表將虛擬地址對映到對應的實體地址通過三級快取結構完成訪存。
  7. hello執行過程中可能遇到各種訊號,shell 為其提供了各種訊號處理程式。
    9.核心通過排程完成hello和其他所有程序的上下文切換,成功執行hello。
  8. Unix I/O 幫助 hello 實現了輸出到螢幕和從鍵盤輸入的功能。
  9. 最後 hello 執行 return 0;shell父程序回收hello,核心刪除hello程序的所有痕跡。
    hello的一生圓滿結束,它死而無憾,因為有我,以及許許多多的準備役程式設計師們的見證!