程序的一生

一、程序的誕生

1. 概覽

程序,是我們天天接觸的東西。而且在很大意義上,我們是它們的締造者,不過,由於被現代化社會勞動的特性所左右,在創造過程中我們大量地使用了各種工具,甚至使得我們對於自己的作品有些什麼特質都沒有能夠充分了解,這不能不說是一件遺憾的事情。

下面是一個 Symbian 程序從源代碼以及相關的資源或者數據,生成最終的可執行程序的過程:

這張圖有點老,aif 現在已經過時,不過整個過程還基本保持着。

對於 Windows 類型的程序來說,較大的區別在於資源的處理。Windows 平臺允許把資源和可執行代碼以及數據等組合(也是鏈接)在同一個文件中,而沒有強制分離(當然也支持分離),而且資源的 ID 完全由用戶控制,不存在像 Symbian 那樣硬性的必須由資源編譯程序(rcomp.exe)生成。

2. 一些細節

2.1. 代碼依賴(庫)

事實上,以上的圖表在 link 步驟處的信息有所省略。link 所需要的輸入,除去由源代碼直接生成的 .obj 文件之外,還需要有所依賴的 .lib,只不過這些 lib 通常/大多數都不是你親手寫就的。

lib 分爲兩種,一種是代碼/實現本身就在 lib 當中的,庫中的代碼/實現會被複制到最終的可執行體中,當可執行運行時,將不再有其他相關依賴,這種庫被稱之爲靜態鏈接庫,有時簡稱爲靜態庫,即 static linked library,簡稱爲 static library 或者 static lib

另外一種 lib,本身體內不包含所需功能的代碼/實現,僅包含有一些描述,例如真正的實現是在哪一個文件中(通常是一個動態鏈接庫),函數名字與序號的對應關係,等等。這種庫鏈接之後,最終的可執行體在運行時必須依賴其他的函數實現所在的庫文件,否則系統加載就會失敗。這種庫通常稱之爲導入庫,即 import library,或者 import lib。事實上,可以把這種 lib 看作 static lib 的一個特例。

相對於 static linked library 的概念的,是 dynamic linked library,縮寫爲 DLL,即我們日常所稱呼的動態鏈接庫,或者動態庫。這類文件的擴展名在 Windows 類操作系統上通常爲 DLL,有一些有專用用途的可能會變,比如 OCX 等,在 Unix 類平臺上的擴展名則通常叫做 SO 或者 DSO(含義爲 shared object/dynamic shared object)。

動態庫有兩種使用方式,一種是通過與動態庫對應導入庫,在編譯時就建立依賴關係,另一種是通過操作系統的動態庫加載 API,例如 Windows 下的 LoadLibraryUnix 下的 dlopen,以及 Symbian 平臺下的 RLibrary 類。

由於動態鏈接庫的出現,使得原本簡單的事情出現了一些複雜化的地方。對於靜態鏈接庫中的函數來講,在鏈接階段調用者已經可以準確地知道其函數地址(至少是相對偏移),這樣就可以直接生成最終代碼;但是如果被調用的函數位於動態庫中的話,由於動態庫是在運行期才加載的,根本無法在執行體生成期中就知道目標函數的準確地址。這也是“動態”二字的中心思想所在。

導致這些問題的本質原因有若干。首先是,動態鏈接庫在執行期加載到進程中的基地址可能會變化,從而無法得到準確地址或者相對偏移,其次是,由於不可預料的其他因素(例如動態鏈接庫的版本變化),目標函數在動態鏈接庫自身中的位置也不能保證是固定的,更何況,有的函數可能會不存在(例如,執行時遇到了一個老版本的動態鏈接庫,或者要調用到的函數在更新的版本中被移除掉了)。

爲了解決上面的問題,操作系統的可執行體中通常都設立了兩個表格。其中的一個表格,幫助最終執行體表達出它自己在運行時需要依賴哪些別的動態庫(即使執行體本身就是一個動態庫,它自己也很可能會依賴其他動態庫);另一個表格則對外說明最終可執行體自己包含了些什麼可讓其他執行體引用的東西,在什麼位置。這兩個表格,前一個通常被稱作導入表(import table),後一個通常被稱作導出表(export table)。

2.2. 數據安排

任何一個程序,都必然會有數據打交道,其中一些數據的值在編譯時已經確定,另外一些則可能是運行時才能確定。我粗略地將數據分爲了幾個類別,編譯器和鏈接器在工作的時候,通常也會按照類別把星羅棋佈於程序代碼中的數據按類別彙總到一起,然後置入可執行體中。之所以要這樣做,是因爲操作系統(或者中央處理器)往往會有底層建築可以保障各種數據的特性要求(例如不許篡改),有的則可以節省內存的開銷。

通常鏈接器把每一類都放到可執行文件的一個節(Section,也有的翻譯爲段或者塊)中。節,是大多數現代操作系統所採用的可執行文件結構的基本單元。

2.2.1. 全局常量

有一部分數據,是在寫程序的時候就已經指定了其確切的值的,而且在整個程序的運行期間都不允許對它的值進行修改。這類數據主要爲使用 const 修飾的量(其實無所謂是全局的還是局部的),此類數據一般會被彙總到一個單獨的節中。這樣做的目的有兩個,一是對於那些支持頁保護的體系上,可以爲這些數據所加載到的頁增加只讀屬性,這樣就降低了數據被無意或者惡意篡改的風險,一旦有這樣的企圖,則可能引發嚴重異常,程序不再繼續執行;二是,如果此類數據所在的可執行體被操作系統加載了多次(例如,一個程序的多份實例,或者一個動態庫被多個程序同時加載),此類數據僅需要在一份物理內存頁上加載,而不必分配多份,節省了內存使用。

Symbian 上之所以長期不允許全局可寫數據存在於 DLL 中,就是因爲可寫全局數據勢必是在每個進程中都需要有單獨的一份內存佔用,而 Symbian 的設計人員認爲在 DLL 中全局的可寫量數量很少,通常也就幾十到幾百字節,而解釋是操作系統的內存分配最小單元(通常爲 4KB 的頁)也比這個數量大很多,一個進程浪費 3K,那麼這個 DLL 被加載的次數越多,浪費也就越嚴重,這對於本來資源就緊張的手機系統是不可接受的。

然而事實證明,這種設計思路是狹隘的,高版本的 Symbian 系統已經去除了這一限制就是證明。這一系統的設計缺陷嚴重地影響了 Symbian 開發人員的工作習慣,也降低了工組效率,對於現有的很多成熟的開源代碼也不能很好地支持。最糟糕的是,許多競爭系統在配置相近的硬件平臺上沒有此類限制也運行得不錯,使得這一設計更是弊端凸顯。

最後說一下這個節的大小。大小原則上爲所有數據的大小之和,當然,最終會根據可執行體的鏈接設置中的節對齊屬性取整。

最後再順便說一句,相同用途的節,在同一個可執行文件的映像中,可能會存在多個。

2.2.2. 全局已初始化變量

另外的一部分數據,儘管在寫程序的時候已經指定了值,但卻可能在運行的時候會被更改,這部分數據也會被彙總到單獨的一個節中。其大小,原則上等同於上文對全局常量的描述,只不過在運行期加載之後,所在頁不能被增加只讀的屬性。

2.2.3. 全局未初始化變量

還有一部分全局數據,我們在一開始並不知道/並不在意其初始值。同樣,這部分數據也會被單獨置入一個數據節中,爲了節省可執行體的佔用空間,此部分數據通常僅在文件中記錄一個大小,而不像前面的兩種數據那樣會佔用實際的文件字節。

3. 定局

如果一切順利(源代碼能夠正常編譯,而且鏈接時該找的都能找到而又沒什麼衝突),最終的可執行體就會理所當然地出現在眼前。

無論如何,歷盡諸多坎坷崎嶇,總算誕生了。

二、程序的活動

程序誕生之後,執行就註定是它的天職了。它總是安安靜靜地躺在那裏,等待別人對它發號司令,這個別人,有時候是最終用戶,有時候是別的程序,有時候甚至是它自己。

1. 程序和其他概念的關係

程序、可執行文件/可執行體、映像、模塊、進程、任務。

程序(Program)是個靜態的概念,體現到表現方式上,那就通常是一個文件。由於程序是可以執行的,所以其載體也被稱之爲可執行文件(Executable File),或者叫可執行體(Executive)。在現在的操作系統上,可執行文件通常既可以是一個可以直接運行的主程序文件,也可以是一個庫文件,但大多數情況下指代前者。

因爲可執行文件被加載之後,事實上存在着與進程在內存中的呈現的一種對應關係,因此有時也把可執行文件稱爲映像(Image)。被加載到內存中的可執行體,無論是主程序還是庫,通稱之爲模塊(Module)。

主程序被加載到內存開始執行後,即是產生了一個進程(Process)。除非程序的編制者有意爲之,或者系統本身有某種制約,一般一個程序都可以創建多分進程實例。如果拿 C++ 裏的屬於打個比方的話,程序就是 class,進程就是 object。前者是某種靜態描述,而後者可以被看做是有機的活體。

任務(Task)是個邏輯概念,在不同的系統上含義不同。在絕大多數時候,一個進程就是一個任務,不過在 Symbian 上,一個 App 有時也被稱作一個任務,並不管它在 EKA1 架構下其實只是一個線程而在 EKA2 架構下成爲了一個進程的區別。

2. 程序的加載

程序最開始從存儲介質上加載,直到系統把執行的控制權交給它,這段時間我們可以稱之爲初始化階段。在這段時間裏,程序本身就像一個被催眠了的殭屍,要無條件聽從大法師的擺佈和安排,這個大法師,就是通常隱身在幕後、爲絕大多數人所忽略的 —— 加載器。

系統內存在不止一個加載器,這是迫不得已的事情。要知道整個系統總是從一條指令開始啓動的,要經歷一個從簡單到複雜的環境變遷。在開始的簡陋時期,加載器必然只完成一些特定的或者特殊的工作,而不是一個全能的形象。直到整個系統初始化完畢,全能的加載器纔會有足夠的舞臺來施展。而我們上文所提到的,正是這個全能加載器,在 Symbian 系統中,這個傢伙和文件服務器廝混在一起。

加載器的主要工作在於把程序從外存上加載到內存中。觸發加載器開始工作的,通常是已經運行起來的程序對系統相關 API 的調用,例如,創建進程(Windows 系統上的 CreateProcess 函數族,Symbian 上的 RProcess 類的對應功能),或者動態加載 DLLWindows 系統上的 LoadLibrary 函數族,Symbian 上的 RLibrary 類的對應功能)。對於加載器來說,創建進程時的工作顯然要比加載一個 DLL要來的複雜許多。

3. 創建進程

拋開各個平臺的差異不說,創建進程起碼要有以下工作:

1、檢查目標映像是否存在,是否是合法的可執行文件;

2、分配虛擬地址空間;

3、分配足夠的物理空間,將必須的內容從映像中讀入內存;

4、創建必要的內核對象(進程對象和線程對象);

5、處理依賴(可能是遞歸的);

以上的步驟在各系統平臺上的執行順序可能不一致,而且由於內存模型的不同,有可能會有步驟的合併或者更細化的分離。

在第 5 步中,針對於所依賴的每個 DLL,又分別會重複第 1 和第 3 以及第 5 步,而且在某些平臺上,會在適當的線程上下文內調用 DLL 的入口點函數(如Windows 上的 DllMain 函數)。第 1 步很簡單,再次不必贅述。第 3 步和第 5 步,對於我們認知程序的內幕則相當重要。

4. 單個模塊的處理

在第 3 步中,加載器的主要工作是打開目標映像文件,逐節將之加載到內存中去。根據各個節的鏈接時指定/自動生成的屬性,做相應的處理。到目前爲止,我們至少已經知道了幾類節,除了它們之外,代碼節也是我們耳熟能詳的。

4.1. 節的處理

對於代碼節,加載器首先需要按照節的大小在內存中分配空間(分配的單位通常是頁),然後把映像中的代碼複製到內存中。根據代碼本身所具有的特徵,一般會把這些內存頁面的屬性置爲“可執行、只讀”。

對於全局常量所在的節,分配並初始化之後的頁面屬性通常被置爲“只讀”。

對於全局已初始化變量所在的節,分配並初始化之後的頁面屬性通常被置爲“讀寫”。

以上兩個節的“初始化”操作,無非也就是把相應內容從文件中讀取到內存中。略有不同的是全局未初始化變量的節的處理,內存分配是必不可少的,初始化則變爲了簡單而又粗暴的內存清零操作(大家都很熟悉的 ZeroMemory 或者 Mem::FillZ 操作)。注意,這一點,正是 C/C++ 語言中“所有未指定初值的全局變量均初始化爲零”的保證。

另外還有一些和數據有關的節,例如共享節或者資源節,這些節和可執行文件所運行的平臺息息相關,暫時不在此敘述。

接下來要說到的是導出表和導入表的處理。這兩個表的處理是有關聯的。

我們知道,導出表中存放了本可執行體對外開放的函數(其實也有可能是變量)的名字,以及函數在執行體中的入口偏移,以及其他一些對我們目前的討論關聯不大的數據。當加載一個映像時,此映像在本進程中的起始地址就固定了下來,根據此起始地址,再加上導出表中的偏移信息,則其他的模塊對某一函數的引用信息就已經完整了。

在繼續之前,此處插一段別的內容。在某些系統(如 Windows)上,一個可執行體在鏈接時是可以強行指定將來被加載到內存中的起始地址的,如果在加載的時候該地址已經被佔用(例如在它之前被加載的其他可執行映像),則加載會失敗。如果是一個動態庫,而且又是被動態加載的,則僅僅是本模塊加載失敗而已,如果此庫是由於靜態依賴而在進程啓動初期被加載的,則進程的創建工作也會隨之失敗。動態庫的靜態依賴和動態依賴,後文會有描述。

接前文。當依賴的映像全部加載完畢後,加載器就會對本模塊中的導入表做填充動作了。逐個遍歷導入表中的導入函數,根據所依賴模塊的起始地址以及其導出表中的偏移信息,把正確的目標函數地址填入。

導入表和導出表的處理事宜完成之後,它們所佔據的內存頁屬性則與全局常量幾無不同。事實上,很多時候鏈接器會把導入表和導出表與全局常量放到同一個節中。(如是,很顯然,全局常量節的只讀屬性需要是在此填充操作之後才能設置的)。

另外還有一件事情,就是重定位。當模塊被加載到了一個並非等同於模塊本身所指定的首地址時,函數調用代碼中的目標地址就相應地發生了變化,因此加載器還需要根據重定位信息對其進行修正。(很顯然,代碼節的只讀屬性需要是在此修正操作之後才能設置的)。

5. 動態加載

前面有所提及的還有,動態庫有一種動態加載模式。動態加載一個 DLL,其初衷往往是爲了能夠實現代碼的跨系統版本兼容,而在各自版本的系統上,又能最大限度地利用最新最好的功能。由於是動態的依賴,因而此依賴關係就不會體現在導入表中,也因此,調用到得位於被依賴的模塊中的函數就不會由系統自動定位並取其地址,而只能由實現者自行去解決(Windows 平臺下的函數是 GetProcAddressSymbian 下的是 RLibrary::Lookup 方法,Unix 類系統下則是 dlsym 函數)。

動態依賴的好處是,除非執行流程已經流轉到了必要的分支,否則不會加載某些依賴庫,可以在一定程度上降低內存消耗,而且,如果所依賴到的函數如果不存在,很可能會由程序編制者選擇一個更優雅的容錯機制而不是導致程序無法運行,這就使得軟件的適應性得到了提高。當然也有缺點,代碼編寫更爲繁瑣,往往需要自己定義函數指針的原型,自己去處理函數不存在的情況等等。由於這些個原因,如果存在引用了目標依賴模塊中相當多數量的函數的情況,則通常不適於使用動態加載的方式。在 Symbian 平臺上,系統 API 的提供大量使用了類的形式,導致函數之間的關聯性有了增強,獲取單個函數地址即可進行有效使用的可能性被削弱,在一定程度上遏制了動態加載方式的應用。至於被動態起來的模塊的靜態依賴的處理,則與進程創建時的處理類似。

6. 內存消耗

從上文對可執行文件的加載過程的敘述即可以看出,對於整個系統來說,加載一個模塊大概會有以下的內存開銷。

代碼節和只讀的數據節,在理想狀態下,僅當此映像在第一次加載時需要分配內存,此後再有其他的加載請求的話,如果是本進程的只需增加引用計數即可,如果是其他進程的則調整虛擬內存的映射關係即可。糟糕的情況發生在,如果在其他進程中加載,其起始地址不能與之前進程中已經加載起來的實例保持一致的話,由於涉及到代碼重定位的工作,則不得不重新申請內存。

以上說的是本質上具有隻讀特性的節。如果不是隻讀的,必然需要在各個進程中都有獨立的副本,佔用另外的內存。

除了這些加載映像所必需的內存消耗之外,在進程真正要開始運行之前,還有一些額外的內存需要準備出來。一個是進程的默認堆,一個是主線程所要使用的棧。這兩種不同的內存,其區別主要是針對在進程中的用途而言的,對於加載器則沒有任何不同,都需要向操作系統申請。在可執行文件中,一般會有指示本程序要求的堆和棧的大小的域,加載器會優先使用這些指定的值去分配,如果沒有指定,則加載器將使用相應的默認值。

所指定的進程堆的大小,僅在進程創建時使用一次,而指定的線程的棧的大小則可能會被用到多次。主線程是由加載器創建出來的,我們沒有辦法動態指定其所使用的棧的大小,因此它必然使用可執行文件中的指定值或者操作系統的默認值。對於新創建的線程,創建代碼通常都會指定棧的大小(作爲線程創建函數的一個參數傳入),如果創建時沒有指定,則將採用與主線程相同的值。在現代操作系統中,線程的棧是各自私有的,這也解釋了爲什麼多個線程同時調用相同的函數時,局部變量的值不會互相影響(而全局變量需要做額外的互斥處理)。

還有一點可以說一下。通常進程的默認堆(之所以稱作默認堆是因爲堆也可以使用代碼另外創建)可以被進程中的所有線程訪問。在多線程的進程中,如果各個線程均有較爲頻繁的堆操作(分配或者釋放),則勢必帶來線程間同步引入的時間上的開銷,降低執行效率。在這種情況下,建議線程在執行之初就創建屬於自己的私有堆,可以提升一定的運行時速度。不過在實踐中發現,Symbian 系統上的線程創建時也會創建私有的堆,其驗證方法爲此線程中分配的內存無法在另外的線程中釋放。在 Windows 平臺下,當一個模塊被加載時也會爲此模塊生成私有的堆。

7. 和編程語言特性的關聯

TODO: 全局對象的構造和析構等)

三、程序的消亡

TODO: 退出、卸載等)

四、相關資料和推薦書目

  • PE 可執行文件格式 / ELF 可執行文件格式 / E32 可執行文件格式
  • 《深入解析 Windows 操作系統》
  • Symbian OS Internals - Real-timeKernel Programming
  • 《編程卓越之道》,第一卷,第二卷

五、後記

本文已經寫就經年,文中標記 TODO原計劃另行補齊,後來發現有名爲《程序員的自我修養——鏈接、加載與庫》的新書上市,其中的闡述比本文更加詳盡,遂決定不再更新。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章