程式是怎麼執行的

dockerone發表於2015-04-03

  Docker是一個建立在作業系統+編譯器基礎之上的系統,所以瞭解作業系統,編譯器以及程式執行機制對我們理解Docker來說非常重要。本文是一個自己的體會,有很多不精確的地方,目的是希望大家多關注低層,多修煉內功,多讀好書。

  一直想寫篇文章來說明在程式執行過程中作業系統都幹了些什麼事。下面我試著說明:

  首先,任何程式都是有格式的,所謂無規矩不成方圓,任何美的,精巧的事物都是精密組織的,程式也一樣。我之前用的最多的是c#與java,有趣的是,當時很多人嘲笑java與c#們一直在用指令碼寫程式,大概在他們眼裡c與c++才是真正的程式。但是,現實就是現實,其實我們都是在一個叫做虛擬機器的程式下寫託管程式碼,它掌握著程式的編譯,連結,載入,對映與最終執行與終止。它就是作業系統,準確的講是作業系統+編譯器。他們是真正的元虛擬機器。

  然後我來解釋下如何執行一個程式:

  程式是精巧與複雜的,熟悉它以後你也會覺得它是脆弱的,因為只要有一個bit發生錯誤,整個系統就會崩潰。這個系統就是執行檔案格式,在linux下叫elf(executable linkable format)而windows下叫pe(portable execute)。我想寫作業系統第一步就是制定這個規則,不然一切都沒有規律。所以我想linus牛,但是ken tomason有過之而無不及,畢竟你是在人家基礎之上發展而來的,計算機世界就是如此沒辦法,誰讓你在人家下面呢?

  我以linux系統為例,簡單講講程式由編譯 連結裝載與執行。elf檔案格式分為很多段—section,總體分為只讀可執行的程式碼段與可讀可寫的資料段。.txt就是典型的程式碼段,.data .rodata .symbl .rel .got .plt都是資料段。那麼,編譯器負責將程式設計師寫的程式,編譯成elf檔案,程式碼,注視,程式碼行對應機器碼資訊,就是除錯資訊啦會進去.txt .code .comment .debug段,常量與靜態變數進入.data .rodata .bss。接下來,編譯器將引用的標頭檔案中的程式碼(特指靜態編譯)與引用的glibc中的庫函式打包(連結)到整個可執行檔案中,然後在elf檔案中設定檔案頭資訊,如段表位置,程式入口位置等資訊。當然,這裡不得不提的是符號表,與重定位表,他們是整個程式最終能跑起來的關鍵。gcc是靠符號,或者說程式是靠符號來連結的,不管是函式還是變數,都是符號而已,所以從側面講,寫程式跟寫文章沒啥區別。程式就像個圖書館,每個函式與變數都是書,連結程式好比在圖書館看書,當你看到一個點時,就會叫你去某某位置拿另一本書,翻到特定位置開始繼續讀,如果沒找到就會爆出連結錯誤。而重定位表就是一次性講所有對需要跳轉的位置進行更改,以確保程式中不存在沒有拿到手的書。

  好,現在程式已經連結好了,接下來就是作業系統進行裝載與執行了。當然這是靜態的連結,動態連結會稍微複雜,會寫很多,這裡不討論。作業系統會開啟elf檔案的裝載檢視,它能根據裝載檢視的段表—segment這跟section在中文都是段,沒辦法!這個檢視是將資料與程式碼分開的,相似section連結在一起,所以數量也比section少很多,目的是在裝載時節約記憶體。因為,段對映到記憶體是要地址對齊的,如按照地址4096(一般簇大小為4k)整除來對齊,這樣做是有好處的,能減少記憶體碎片,加快磁碟讀寫速度,磁碟最小扇區512byte,所以整數倍讀取能少一次定址,當然效率更高。這在遊戲引擎,資料庫設計領域比較多見,畢竟io是最大瓶頸,所以再這程式時也要考慮物件佔用記憶體大小是否是作業系統最小簇的整數倍來判斷一個程式是否是高人所做。

  回來,作業系統會最先讀取可執行的檔案頭,因為裡面有執行程式的資訊,如段表位置,程式入口,程式型別等。對於作業系統最重要的是段表與程式入口。其中段表就是elf中有多少段,每個段在檔案中的偏移,入口則是常說得main函式的虛擬地址。這裡就出現一個問題,程式非得以main函式開始嗎?其實看出來了,不用!只是gcc認定符號main為c語言的入口,其他程式照抄罷了,當然你可以加入編譯條件更改入口即可。gcc是stallman寫的,他是個黑客,全世界只要執行c的地方,他都能黑,呵呵。

  好了,作業系統在讀取可執行程式頭時做了三件事:1.建立虛擬記憶體空間來容納一個程式,2.根據檔案頭內容建立程式虛擬記憶體地址與elf檔案的對映關係表,vma(virtual memory area)結構,3.初始化程式的棧空間與堆空間。下面解釋下這三個過程。

  1,虛擬記憶體。虛擬記憶體是編譯器與作業系統的一個約定。任何程式在編譯無連結時得地址都是虛擬地址。為什麼要用虛擬地址這個問題說來話長。話說在很久以前,大家都很窮,都沒記憶體,但是要執行的程式很多,系統不可能為每個程式分配單獨的記憶體,同時領導還要求同時所有程式都要執行,咋辦呢?辦法總比問題多,我們可以分時嘛,你上完cpu我再上,但是大家各自在用cpu時,其他只能看著,直到一個人說"下一個",這個人不管在幹嘛都得放棄,讓其他人用cpu。這樣對所有人都公平,而且每個人在用cpu是能感覺到cpu只被它獨有,使用者體驗還挺好。所以一次解決可所有問題。而,這個組織人,就是那個喊“下一個”的傢伙就是作業系統。那,說這麼多,跟虛擬地址有啥關係呢?其實仔細想想如果大家都是用實體地址,而彼此在執行時都獨佔系統資源,那前一個程式修改了我的資料咋辦,得了,都由作業系統說了算吧,它做記憶體對映的維護,大家都用統一的地址空間,但是執行時對映到不同的實體記憶體互不干擾來。所以你可以看到所有linux程式都從相同的虛擬地址開始執行。

  2.建立記憶體到檔案得對映。我們知道,程式都不是一次性載入到記憶體的,而是一段段的,這是由著名的copy on write規則約束而來的。而這一段也是規定好大小的一般是作業系統簇的大小,也叫一頁。當程式執行過程中發現某個資料在記憶體中沒有則會報一個頁讀取錯誤,並觸發作業系統的缺頁中斷。這時就要靠作業系統通過讀取elf檔案頭建立的從檔案系統到虛擬記憶體的對映來獲取了。它等於是程式執行時到程式得一個索引結構,儲存了執行時程式虛擬記憶體地址到檔案地址的對應表。

  3.好了,第三步最簡單,就是作業系統載人main函式後面跟的那個char argc與char*argv了。他們是程式啟動引數。還要載入程式執行的環境變數,棧空間,堆空間,也就是靜態資料與全域性變數部分。然後把程式執行暫存器指向程式開始的地方。開始執行!看似簡單,但是很複雜的過程開始了!

  好了,這就是簡單的程式如何被作業系統執行的簡單描述,當然這只是靜態連結程式的載入,動態連結稍微複雜點。原理差不多,呵呵。

相關文章