《程式設計師的自我修養》(三)——庫與執行庫

吳尼瑪發表於2018-07-12

庫與執行庫

記憶體

  • 應用程式使用的記憶體空間一般都會包括以下“預設”區域:

    • 棧:棧用於維護函式呼叫的上下文。通常棧在使用者空間的最高地址處分配,可能會有數兆位元組的大小。
    • 堆:堆是用於容納應用程式動態分配的記憶體區域,當程式使用malloc或new分配記憶體時,得到的記憶體來自堆裡。堆通常存在於棧的下方(低地址方向),在某些時候,堆也可能沒有固定統一的儲存區域。堆一般比棧大得多,可以有幾十到數百兆位元組的容量。
    • 可執行檔案映像:由裝載器在裝載時將可執行檔案的記憶體讀取或對映到這裡。
    • 保留區:保留區並不是一個單一的記憶體區域,而是對記憶體中受到保護而禁止訪問的記憶體區域的總稱。
    • 動態連結庫對映區:用於對映動態連結庫。
  • Linux下一個程式裡典型的記憶體佈局(核心版本2.4.x):

    《程式設計師的自我修養》(三)——庫與執行庫

  • 棧儲存了一個函式呼叫所需要的維護資訊,這常常被稱為堆疊幀(Stack Frame)活動記錄(Activate Record)。堆疊幀一般包括如下幾個方面內容:

    • 函式的返回地址和引數。
    • 臨時變數:包括函式的非靜態區域性變數以及編譯器自動生成的其他臨時變數。
    • 儲存的上下文:包括在函式呼叫前後需要保持不變的暫存器。
  • int foo () { return 123;}這個函式的反彙編(VC9,i386,Debug模式)程式碼:

    《程式設計師的自我修養》(三)——庫與執行庫

  • 其中第4步的程式碼用於除錯,大致等價於如下虛擬碼:

edi = ebp - 0xC0;
ecx = 0x30;
eax = 0xCCCCCCCC;
for (; ecx != 0; --ecx, edi+=4)
  *((int*)edi) = eax;
複製程式碼
  • 可以看出實際上這段程式碼的是將記憶體地址從ebp-x0c0到ebp這一段全部初始化為0xCC(0xCCCC的漢字編碼就是燙,所以我們在除錯時會看到未初始化的變數或者記憶體區域的值是“燙”)。恰好就是第2步在棧上分配出來的空間。

  • 函式的呼叫方和被呼叫方對函式如何呼叫需要有統一的約定,這種統一的約定稱為呼叫慣例(Calling Convention)。通常呼叫慣例包含如下幾方面的內容。

    • 函式引數的傳遞順序和方式。
    • 棧的維護方式。
    • 名字修飾的策略。

常見的呼叫慣例.png

  • 函式將返回值儲存在eax中,返回後的函式的呼叫方在讀取eax。對於返回5~8位元組物件的情況,幾乎所有的呼叫慣例都是採用eax和edx聯合返回的方式進行的。如果返回值型別的尺寸太大,如下圖所示,C語言的函式返回時會使用一個臨時的棧上記憶體區域作為中轉,結果返回值物件會被拷貝兩次。因而不到萬不得已,不要輕易返回大尺寸的物件。

《程式設計師的自我修養》(三)——庫與執行庫

  • 一個普通的Windows程式的地址空間分佈可以如圖所示。

《程式設計師的自我修養》(三)——庫與執行庫

  • Windows系統提供了一個API叫做VirtualAlloc(),用來向系統申請空間,它與Linux下的mmap非常相似。實際上VirtualAlloc()申請的空間不一定只用於堆,它僅僅是向系統預留了一塊虛擬地址,應用程式可以按照需要隨意使用。但是,使用VirtualAlloc()函式申請空間時,系統要求空間大小必須為頁的整數倍,即對於x86系統來說,必須是4096位元組的整數倍。這就是作業系統的“批發”記憶體的介面函式了,4096位元組起批。

  • 在Windows中,堆管理器提供了一套與堆相關的API可以用來建立(HeapGreate)、分配(HeapAlloc)、釋放(HeapFree)和銷燬(HeapDestroy)堆空間。其中,HeapGreate就是通過VirtualAlloc()來實現向作業系統批發一塊記憶體空間。堆管理器通過這些API實現了堆分配演算法。

  • 我們經常使用的malloc函式實際上是執行庫提供的函式。它實際上是堆Heapxxxx系列函式的封裝,當一個堆空間不夠時,它會在程式中建立額外的堆。

  • 堆分配演算法實際上就是解決如何管理一大塊連續的記憶體空間,能夠按照需求分配、釋放其中的空間的題。堆分配演算法有很多種,例如簡單的空閒列表演算法、點陣圖演算法、物件池演算法等,也有很複雜、適用於某些高效能或者其他特殊要求的場合。實際上很多現實應用中,堆的分配演算法往往是採用多種演算法複合而成的。

執行庫

  • 一個典型的程式執行步驟大致如下:

    • 作業系統在建立程式後,把控制權交到了程式的入口,這個入口往往是執行庫中的某個入口函式。
    • 入口函式對執行庫和程式執行環境進行初始化,包括堆、I/O、執行緒、全域性變數構造,等等。
    • 入口函式在完成初始化之後,呼叫main函式,正式開始執行程式主體部分。
    • main函式執行完畢之後,返回到入口函式,入口函式進行清理工作,包括全域性變數析構、堆銷燬、關閉I/O等,然後進行系統呼叫結束程式。
  • C語言檔案操作是通過一個FILE結構的指標來進行的。在作業系統層面上,檔案操作也有類似於FILE的一個概念,在Linux裡,這叫做檔案描述符(File descriptor),而在Windows裡,叫做控制程式碼(Handle)。對於Windows中的控制程式碼,於Linux中的fd大同小異,不過Windows的控制程式碼並不是開啟檔案表的下標,而是其下標經過某種線性變換之後的結果。

  • IO初始化函式需要在使用者空間中建立stdin、stdout、stderr及其對應的FILE結構,使得程式進入main之後可以直接使用printf、scanf等函式。

  • MSVC的I/O初始化主要進行了如下幾個工作:

    • 建立開啟檔案表。
    • 如果能夠繼承自父程式,那麼從父程式獲取繼承的控制程式碼。
    • 初始化標準輸入輸出。
  • 入口函式只是冰山一角,它隸屬於一個龐大的程式碼集合,這個程式碼集合叫做執行庫。

  • 一個C語言執行庫大致包含了如下功能:

    • 啟動與退出:包括入口函式及入口函式所依賴的其他函式等。
    • 標準函式:由C語言標準跪地的C語言標準庫所擁有的函式實現。
    • I/O:I/O功能的封裝和實現。
    • 堆:堆的封裝和實現。
    • 語言實現:語言中的一些特殊功能的實現。
    • 除錯:實現除錯功能的程式碼。
  • C語言的執行庫從某種程度上來講是C語言的程式和不同作業系統平臺之間的抽象層,它將不同的作業系統API抽象成相同的庫函式。Linux和Windows平臺下的兩個主要C語言執行庫分別為glibc(GNU C Library)和MSVCRT(Microsoft Visual C Run-time)。值得注意的是,像執行緒操作這樣的功能並不是標準的C語言執行庫的一部分,但是glibc和MSVCRT都包含了執行緒操作的庫函式。所以glibc和MSVCRT事實上是標準C語言執行庫的超集,它們各自對C標準庫進行了一些擴充套件。

《程式設計師的自我修養》(三)——庫與執行庫

《程式設計師的自我修養》(三)——庫與執行庫

  • 當你的程式裡包含了某個C++標準庫的標頭檔案時,MSVC編譯器就認為該原始碼檔案是一個C++原始碼程式,它會在編譯時根據編譯選項,在目標檔案的“.drevtve”段增加相應的C++標準庫連結資訊。

  • 執行緒的訪問非常自由,它可以訪問程式記憶體裡的所有資料,甚至包括其他程式的堆疊,但實際運用中執行緒也擁有自己的私有儲存空間。其中包括棧、**執行緒區域性儲存(Thread Local Storage,TLS)**和暫存器。

  • C/C++執行庫在多執行緒環境下有很多坑,最典型的就是errno,還有像strtok()、printf(),一些與訊號相關的函式等等都是執行緒不安全的。CRT採用TLS、加鎖和改進函式呼叫方式的辦法來改進執行緒安全問題。

  • 一旦一個全域性變數被定義成TLS型別的,那麼每個執行緒都會擁有這個變數的一個副本,任何執行緒對該變數的修改都不會影響其他執行緒中該變數的副本。

  • TLS用法很簡單,如果要定義一個全域性變數為TLS型別的,只需要在它定義前加上相應的關鍵字即可。

    • 對於GCC來說,這個關鍵字就是__thread,定義方式:__thread int number;
    • 對於MSVC來說,想要的關鍵字為__declspec(thread),定義方式:__declspec(thread) int number;(注意:在Windows Vista和2008之前的作業系統這種方式不可用。)
  • 對於Windows系統來說,正常情況下一個全域性變數或靜態變數會被放到“.data”或“.bss”段中,但當我們使用__declspec(thread) 定義一個執行緒私有變數的時候,編譯器會把這些變數放到PE檔案的“.tls”段中。當系統啟動一個新的執行緒時,它會從程式的堆中分配一塊足夠大小的空間,然後把“.tls”段的內容複製到這塊空間中,於是每個執行緒都有自己獨立的一個“.tls”副本。

  • 當使用CRT時(基本所有程式都使用CRT),請儘量使用_beginthread()/_beginthreadex()/_endthread()/_endthreadex這組函式來建立執行緒。在MFC中,還有一組類似的函式是AfxBeginThread()和AfxEndThread(),它是MFC層面的執行緒包裝函式,它們會維護執行緒與MFC相關的結構,當我們使用MFC類庫時,儘量使用它提供的執行緒包裝函式以保證程式執行正確。

系統呼叫與API

  • 為了讓應用程式有能力訪問系統資源,也為了讓程式藉助作業系統做一些必須由作業系統支援的行為,每個作業系統都會提供一套介面,以供應用程式使用。這些介面往往通過中斷來實現,比如Linux使用0x80號中斷作為系統呼叫的入口,Windows採用0x2E號中斷作為系統呼叫入口。

  • 中斷一般具有兩種屬性,一個稱為中斷號(從0開始),一個稱為中斷處理程式(Interrupt Service Routine,ISR)。不同的中斷具有不同的中斷號,而同時一箇中斷處理程式一一對應一箇中斷號。在核心中,有一個陣列稱為中斷向量表(Interrupt Vector Table),這個陣列的第n項包含了指向第n號中斷的中斷處理程式的指標。當中斷到來時,CPU會暫停當前執行的程式碼,根據中斷的中斷號,在中斷向量表中找到對應的中斷處理程式,並呼叫它。中斷處理程式執行完成之後,CPU會繼續執行之前的程式碼。一個簡單的示意圖如下:

《程式設計師的自我修養》(三)——庫與執行庫

  • 由於中斷號是很有限的,作業系統不會捨得用一箇中斷號來對應一個系統呼叫,而更傾向於用一個或少數幾個中斷號來對應所有的系統呼叫。那麼,對於同一個中斷號,作業系統如何知道是哪一個系統呼叫要被呼叫呢?和中斷一樣,系統呼叫都有一個系統呼叫號,這個系統呼叫號通常就是系統呼叫在系統呼叫表中的位置。以Linux的0x80中斷為例,系統呼叫號是由eax傳入的。使用者將系統呼叫號放入eax,然後使用0x80呼叫中斷,中斷服務程式就可以從eax中取得系統呼叫號,進而呼叫對應的函式。下面是以fork為例的Linux系統呼叫的執行流程。

《程式設計師的自我修養》(三)——庫與執行庫

  • 很多作業系統是以系統呼叫作為應用程式最底層的,而Windows的最底層介面是Windows API。Windows API是Windows程式設計的基礎,儘管Windows的核心提供了數百個系統呼叫(Windows又把系統呼叫稱作系統服務),但是出於種種原因,微軟並沒有將這些系統呼叫公開,而在這些系統呼叫之上,建立了這樣一個API層,讓程式設計師只能呼叫API層的函式,而不是如Linux一般直接使用系統呼叫。Windows在加入API層以後,一個普通的fwrite()的呼叫路徑如圖:

《程式設計師的自我修養》(三)——庫與執行庫

  • Windows API是以DLL匯出函式的形式暴露給應用程式開發者的。微軟把這些Windows API DLL匯出函式的宣告的標頭檔案、匯出庫、相關檔案和工具一起提供給開發者,並讓它們稱為Software Development Kit(SDK)。當我們安裝了Visual Studio後,可以在SDK的安裝目錄下找到所有的Windows API函式宣告。其中有一個標頭檔案“Windows.h”包含了Windows API的核心部分,只要我們在程式裡面包含了它,就可以使用Windows API的核心部分了。

  • 在Windows NT系列的平臺上,系統的DLL在實現上都會依賴一個更為底層的DLL,叫做NTDLL.DLL,由它來進行系統呼叫,NTDLL.DLL把Windows NT核心的系統呼叫都包裝了起來,並且其匯出函式對於應用程式開發者是不公開的,原則上應用程式不應該直接使用其中的任何匯出函式。

相關文章