《程序員的自我修養》(三)——庫與運行庫

庫與運行庫

內存

  • 應用程序使用的內存空間通常都會包括如下「默認」區域:程序員

    • 棧:棧用於維護函數調用的上下文。一般棧在用戶空間的最高地址處分配,可能會有數兆字節的大小。
    • 堆:堆是用於容納應用程序動態分配的內存區域,當程序使用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內核的系統調用都包裝了起來,而且其導出函數對於應用程序開發者是不公開的,原則上應用程序不該該直接使用其中的任何導出函數。
相關文章
相關標籤/搜索