天天看點

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)

.o有很多個,程式中還要包含一些共享目标檔案,比如hello程式中的printf函數,它位于靜态庫中,需要用連結器(linker)進行連結,Unix的連接配接器就是大名鼎鼎的ld。

VC98\CRT\SRC目錄找到printf.c,裡面函數完整的定義。事實上很多編譯器套裝(比如gcc)為了提高編譯的效率,已經把這個頭檔案中牽涉到的所有函數分别編譯成單獨子產品并最後打包成了一個檔案(放在系統固定的檔案夾中),這個檔案就是所謂的靜态庫,windows中字尾名是.lib,unix是.a,當我們link的時候,隻需要在指定庫中找到printf對應的那部分二進制代碼添加到程式中就行了。從理論上講hello.c中有幾個printf,就會在可執行檔案中嵌入幾次printf的二進制子產品,而且當系統内有多個hello同時運作時每個hello都會維護一段屬于自己的printf,這樣做顯然是一種浪費。

.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來輸出格式串,所謂系統調用可以看成是操作提供給程式員的一個程式設計接口,我們可以調用它來擷取作業系統提供的一些服務,完成一些和輸入輸出有關的操作。

繼續閱讀