1. 程式人生 > >c語言程式-hello world-執行原理簡介

c語言程式-hello world-執行原理簡介

本文從一個最簡單C程式(hello)是如何被編譯和連結講起,描述了一個程式執行的原理。

一、程式執行之前

使用IDE(整合開發環境)的朋友們經常會有這樣的疑問:程式碼是怎麼從一個文字檔案變成可執行程式的呢?程式碼畢竟不是咒語,一個c程式在被執行之前其實經過了四個步驟,兩次編譯,彙編和連結。

1.預編譯

這裡我們只需要知道有一個叫前處理器(preprocessor也稱預編譯器)的程式會對c程式進行一些預處理工作。比如對一些巨集定義的變數進行替換。

2.編譯

編譯的過程中,編譯器(compiler)把C語言程式翻譯成組合語言程式,一條c語句通常需要好幾條彙編程式碼來取代,C編譯器為了提高程式執行的效率有時候會對程式進行優化,這就是為什麼即使在c程式中聲明瞭register變數,程式也不一定會用。瞭解編譯器這個特性對程式設計師來說也很重要,比如程式設計師可以通過指令告訴編譯器是生成“易於除錯”(debug)還是“程式碼儘可能小”(release)的版本。

3.彙編

編譯得到的組合語言通過彙編器(assembler)再彙編成可重定位目標程式hello.o,與之相反的一個過程被稱為反彙編(disassemble)。

4.連結(Link)

hello.0不能被系統直接執行,而且通常情況下.o有很多個,程式中還要包含一些共享目標檔案,比如hello程式中的printf函式,它位於靜態庫中,需要用連結器(linker)進行連結,Unix的聯結器就是大名鼎鼎的ld。

printf的宣告在標頭檔案stdio.h中,如果在安裝vc6.0時選擇了“安裝CRT原始碼”就可以在VC98\CRT\SRC目錄找到printf.c,裡面函式完整的定義。事實上很多編譯器套裝(比如gcc)為了提高編譯的效率,已經把這個標頭檔案中牽涉到的所有函式分別編譯成單獨模組並最後打包成了一個檔案(放在系統固定的資料夾中),這個檔案就是所謂的靜態庫,windows中字尾名是.lib

,unix是.a,當我們link的時候,只需要在指定庫中找到printf對應的那部分二進位制程式碼新增到程式中就行了。從理論上講hello.c中有幾個printf,就會在可執行檔案中嵌入幾次printf的二進位制模組,而且當系統內有多個hello同時執行時每個hello都會維護一段屬於自己的printf,這樣做顯然是一種浪費。

使用共享庫(shared library)可以解決這個問題,共享庫也是一個目標模組(字尾名.so),它在程式執行之前會被載入到儲存器中某一個特定的區域(linux中,是從地址0×40000000開始的一段區域),並和用到它的程式連結起來,這個過程被稱為動態連結,因此共享庫在windows中又被稱為動態連結庫(DLL)。比如hello在連結時其實並沒有把printf模組加到可執行程式中,而只是告訴我們的hello一聲,待會要用到printf的時候去共享庫裡找xx就行了。連結是程式再被真正執行前一個極其重要的步驟,但由於IDE給別人造成的錯覺,很多程式設計師居然不知道有這麼一步。

經過以上幾個步驟,hello.c已經變成了可執行程式hello,我們在shell中輸入./hello,螢幕上打印出“hello,world”。gcc提供了以上這些工具的一個集合,我們通常把gcc叫做一個編譯器,其實是不完整的,編譯器只是gcc的一個部分,gcc的全稱應該是gnu編譯器套裝(GNU Compiler Collection)。

二、儲存器中的hello

我們知道可執行程式在被CPU執行以前存在於記憶體中,於是我們很快就有了新的疑問,二進位制程式碼在記憶體中長什麼樣?記憶體其實是個模稜兩可的叫法,如果說世界上只有兩種儲存裝置,那麼說其中一個是記憶體另一個是外存就不會有爭議,但是站在CPU角度看,cache明顯要比我們的記憶體條要內多了,而站在U盤的角度,硬碟也頓時變成了記憶體。內和外永遠是相對的,比較科學的稱呼應該是dram(讀音為/draem/,即動態隨機儲存器)。既然有動態隨機儲存器(dynamic ram),也就有靜態隨機儲存器(static ram),CPU內部的快取記憶體用的就是用sram。

在瞭解儲存器之前我們先來區分一下程序和程式這兩個概念,維基百科上找到定義是:程序是程式執行的一個例項(instance)。這種說法解釋了為什麼同一個程式在記憶體中能有很多個程序。有些書上寫,程序是程式執行的一個過程,也沒有錯,但問題是程序本來和過程就是同一個東西(process),我們怎麼能用饅頭去解釋饃饃呢?

因此hello程式和hello程序是兩個東西,前者是留在磁碟中的一些磁訊號,而後者是系統各種資源(cpu、儲存器、IO裝置……)共同作用的結果。如果我們要徹底理解hello是怎麼執行的,首先就必須hello在記憶體中的佈局有一個比較理性的認識。下面來看一個程式在儲存器中的影象。

可能有人要問了,圖中儲存器的地址空間為什麼有4G?(0到0xffffffff),如果計算機的只有1G主存,那豈不是溢位了?事實上現代作業系統採取了一種叫虛擬儲存器(virtual memory)的機制來有效地管理儲存器,即把系統的儲存裝置全部隱藏在背後,無論實際的物理儲存器(dram)有多大都提供給我們一個固定虛擬的線性空間(32位作業系統就有4G空間),系統在幕後對實際的地址進行對映(可能在dram中,也可能在磁碟上),而我們就感覺自己在使用一臺儲存器很大的計算機,儘管當實際的dram很小時我們還是感覺很慢,於此同時硬碟燈在不停閃爍。

Linux將虛擬儲存器高階的1/4留給核心,剩下3/4全留給使用者程序。虛擬儲存器上中的程式主要由以下幾個重要組成部分:

1.程序控制塊(process control block,簡稱PCB)

PCB中儲存了程序hello的執行時的儲存器影象和暫存器資訊,它幫助作業系統在記憶體中找到我們的hello程式,如果沒有它,hello只是和其它程式雜亂無章地分佈在記憶體中就亂套了。

2.棧(stack)和堆(heap)

程式中的自動變數都位於棧上,而堆則用來讓程式設計師自己手動分配(malloc)和釋放(free)的記憶體空間,如果程式設計師忘了釋放,則有垃圾收集器gc代勞。除此以外,棧還是程式轉移中一個很重要的概念,程式的返回地址通常也儲存在棧上。

3.文字段(text segment)和資料段(data segment)

所謂的文字段和資料段對應的就是程式的程式碼部分和全域性變數,把程式的程式碼和資料分開處理是有好處的,比如我們在windows開啟好幾個word,這些程序只是資料段不同罷了(它們都擁有相同的程式碼),因此記憶體中永遠只要有一份word的程式碼就行了。

4.共享庫的對映區域

作業系統通過將共享的物件對映到虛擬儲存器的“共享區域”來使得程式碼能夠共享,一方面提高儲存器的利用率,一方面可以使得程序能夠共享一些資料。

如果某一時刻系統中有20個程式正在執行,而這些程式都需要在螢幕上列印東西,系統就沒有必要為每個程式都維護一段printf的程式碼,只要分別從各自的.bss中取出字串然後用同一個printf完成輸出就行了。同樣的道理,當有多個hello在系統中執行時,它們也完全可以共享同一個文字段。這也就是為什麼會把程序定義為程式的一個例項的原因。不妨回想一下面向物件中物件的概念,我們在寫class的時候定義成員欄位不就是在分配資料?而定義方法欄位不就是在操作這些資料?在物件被例項化以前,這些定義只不過是一些“白紙黑字”,而只有經過例項化,例項們才在儲存器中有了自己的映像。而多個例項之間可以共享“方法”(文字)但是獨有“成員”(資料)的特點,也和程序如出一轍。

現在我們可以描述hello在儲存器中影象了。hello的程式碼位於文字段中,字串“hello,world”在只讀段中,printf位於共享庫的對映區域,程式在執行時用到了使用者棧,使用者棧從0xbfffffff開始,向下生長。以上的圖景只發生在一瞬間,我們難以追蹤,要想看清hello的本來模樣,還是得在目標檔案上做文章。

三、目標檔案的格式

1.可重定位目標檔案hello.o

這是書上典型的一個elf格式的可重定位目標檔案:

ELF Header
.text
.rodata
.data
.bss
.symtab
.rel.text
.rel.data
.debug
.line
.strtab
Section Headers

有興趣的朋友可以在Unix/Linux下使用readelf這個工具來檢視hello.o的具體格式。

2.可執行目標檔案a.out

可重定位目標檔案(hello.o)離最終的可執行目標檔案(a.out)只有一步之遙,這關鍵的一步就是前面說的連結。

連結通常有兩步,第一步是解析符號,符號解析主要用來解決多個模組之間全域性欄位的協調問題,比如我們在兩個.c的檔案中都定義了全域性變數x,或者引用了不曾定義過的函式foo(),連結器都會報錯(link error)。第二步就是重定位,重定位將每個目標模組的節最終合併成一個大的節(section),並且根據rel.text來修改呼叫外部函式(printf)或者引用任何全域性變數(“hello,world”)的指令。hello.o和a.out最大的區別在於,a.out的節頭目表為每個節都分配了真實地址,而hello.o中的節頭目表只在重定位時為連結器提供了一個快速定位節的方式。

下面是一個典型可執行目標檔案(但實際上要複雜得多):

ELF Header
.init
.text
.rodata
.data
.bss
.symtab
.debug
.line
.strtab
Section Headers

筆者在學習c的時候就聽到過這麼一句話——“main是程式的入口”,真的是這樣嗎?嘗試一下這條命令:

ld hello.o -lc
ld: warning: cannot find entry symbol _start; defaulting to 080481a4

這說明編譯器在_main之前會先去找一個_start符號。事實上程式在執行的初期還需要做一些初始化和清理的工作,這些程式碼位於crt1.o模組中,即c執行時(runtime)庫,它包含了程式的入口函式_start,由它負責呼叫__libc_start_main初始化libc,並且呼叫main函式進入真正的程式主體,這部分程式碼必須在連結時加進來(對我們來說是透明的),否則程式根本執行不到的main。

3.printf

printf的機器碼位於/lib/libc.so.6的共享庫中,它將在程式執行時被載入到儲存器的共享庫對映區域。printf中又用到了系統呼叫write來輸出格式串,所謂系統呼叫可以看成是操作提供給程式設計師的一個程式設計介面,我們可以呼叫它來獲取作業系統提供的一些服務,完成一些和輸入輸出有關的操作。

原地址為;http://www.chengyichao.info/principles-of-the-program-running/