你真的懂"Hello world"嗎?從編譯器到OS內核系列:編譯器基本概念

本文是《你真的理解"Hello world"嗎?從編譯鏈接到OS內核系列專題》的第一章的第一小節,主要介紹編譯器的基本概念以及C語言程序的構建過程。全系列大綱如下:


前言

第1章 編譯器的工作原理

1.1 編譯器的基本概念及C語言程序的構建過程(本篇)

第2章 鏈接器的工作原理

第3章 深入分析ELF文件格式

第4章 動態鏈接庫與靜態鏈接庫

第5章 程序的數據存儲

第6章 函數參數、返回值的傳遞過程

第7章 虛擬內存

第8章 程序的加載和重定位

第9章 程序在內存中的佈局

第10章 帶你重新認識main()函數

第11章 從應用程序到系統調用

第12章 程序的退出和資源回收


本系列專題將帶你瞭解"Hello World"背後隱藏的那些極為重要卻又鮮為人知的底層技術,讓你對計算機系統的基本原理有一個全面深刻的理解。

有興趣的童鞋,不妨關注一下吧!

注:本篇的重點是介紹基本概念,讓童鞋們對C語言程序構建過程有一個整體的瞭解,後續會有專門的章節詳細介紹編譯、鏈接的實現原理等技術細節,敬請期待!

引子

人與人之間可以通過文字語言或者肢體語言進行交流,海豚之間通過發出不同頻率的“脈衝聲”進行交流,計算機之間則通過由0和1組成的電信號進行交流。

那麼,人與計算機之間如何進行交流呢?這就不得不用到編譯器了。

你真的懂

編譯器是什麼

簡單來講,編譯器就是把一種語言(通常是某種高度抽象的高級語言)轉化為另一種語言(通常是某種低級語言)的計算機程序。

編譯器要解決的根本問題

人類無法直觀地理解電信號所攜帶的信息,同樣,計算機也無法理解人類使用的自然語言所表達的含義。

因此,要想讓計算機能夠“聽懂”人話,幫人辦事兒,就必須要能夠把人類的語言,翻譯成計算機能夠識別的二進制機器指令。而這,就是編譯器所要解決的最根本的問題。

你真的懂

C語言程序的構建過程

"Hello world"我們再熟悉不過了,它是怎麼從我們編輯器裡的文本文件轉化成可執行的二進制文件呢?

你真的懂

C語言的構建過程,有下面幾個典型的過程:

預編譯

所謂預編譯,是指在正式編譯階段之前,對源碼文件先進行一系列的預處理,以便於後續編譯階段的處理。預編譯階段的主要工作有:

  • 把"#include"指令中引用的頭文件展開在當前源文件中。
  • 把"#define"指令定義的宏標記在源文件中引用該標記的地方進行展開。
  • 對"#if"、"#ifdef"、"#elif"、"#else"、"#endif"等條件編譯指令進行處理,把條件不滿足的代碼刪除。
  • 刪除所有"//"和"/* */"標記的註釋信息。
  • 其他處理,如添加行號和文件位置標識等信息,以及處理"#pragma"等預編譯指令。

如下圖中,左側是test.h文件,右側是main.c文件

你真的懂

test.h 和 main.c

請注意,我們在頭文件test.h中定義了兩個宏,並且聲明瞭area()函數,然後在main.c中包含test.h,並且在main()函數中引用兩個宏定義。

我們用下面的命令對它進行預處理:

<code>gcc -E -I. main.c -o main.i/<code>

得到的輸出文件是main.i,如下圖右側所示:

你真的懂

main.c 和 main.i

從main.i中,我們看到test.h被完全的展開了,並且對宏定義LENGTH和WIDTH的引用也被展開,所有的註釋被刪除,此外還添加了文件位置信息和行號信息。

預編譯器所做的工作比較簡單,原理和實現也相對容易,主要就是普通的文本處理。

編譯

編譯階段的主要是把經過預處理的C語言源代碼經過一詞法分析、語法分析、語義分析和代碼優化之後,產生彙編代碼,也就是在編譯階段的目標代碼。

典型的編譯過程,如下圖所示:

你真的懂

典型的編譯過程

我們對main.i文件進行編譯:

<code>gcc -S main.i -o main.s/<code>

得到的彙編文件main.s的如下圖所示:

你真的懂

main.s

這是程序構建過程中的最核心也是最複雜的環節之一,我將會在下篇文章中對其實現原理進行詳細講解。

彙編

編譯階段產生的目標代碼是彙編代碼,相對原始的C語言文件來說,它的語法更加簡潔,數據類型更加簡單,程序結構更易處理。

儘管彙編代碼跟最終的二進制機器指令已經非常接近,但它仍然屬於文本語言,無法直接被計算機識別。

因此,彙編階段所做的主要工作,就是根據CPU廠商提供的彙編指令和機器指令的對照表,把彙編指令翻譯成機器指令,這個階段的最終輸出結果被稱為目標文件

對main.s進行彙編,將生成目標文件main.o:

<code>gcc -c main.s -o main/<code>

我用file命令看一下main.o的信息:

你真的懂

可以看出,此時main.o的類型是可重定向(relocatable)的目標文件,還不是最終的可執行文件。

我會在稍後更新的本系列專題的第二章《鏈接器的基本原理》中詳細講解兩者的區別。

彙編器原理比較簡單,但是由於CPU所支持的指令多而雜,實現起來會比較繁瑣。

你真的懂

擴展知識 - 把文本形式的彙編代碼翻譯成二進制機器指令的程序叫做彙編器,而與之相反的,把二進制機器指令翻譯成文本形式的彙編代碼的程序,叫做反彙編器。

在程序調試過程中,反彙編器經常會用到,尤其在調試一些系統底層軟件的時候,反彙編器是必不可少的工具,常用的調試工具一般都會集成反彙編器,比如gdb,Virtual Studio等。此外,在軟件逆向工程中,反彙編器也是最核心的工具之一。

鏈接

鏈接是C語言程序構建過程的最後一個環節。簡單來說,鏈接階段的主要工作,就是把多個目標文件之間建立起來一種聯繫,然後根據這種聯繫,把這些相互關聯的目標文件組合起來,最終生成一個可執行文件。

這裡的目標文件,包括彙編階段產生的目標文件,以及這些目標文件中引用的外部函數所在的庫文件,包括動態鏈接庫和靜態鏈接庫。

我們仍以計算長方形面積的程序為例,但是稍稍做些修改,我們把area()函數單獨放在一個test.c中,源碼如下圖所示:

你真的懂

然後重新執行上面的預編譯、編譯和彙編的步驟:

你真的懂

此時我們得到兩個目標文件:test.o和main.o,然後

<code>gcc test.o main.o -o main/<code>

這樣,我們就把test.o和main.o鏈接成了一個可執行文件main.

注:為了便於概念的理解,我在這裡故意忽略了對外部庫的鏈接處理,我將在本系列專題的第二章《鏈接器的工作原理》中,重點講解動態鏈接和靜態鏈接過程。

小結

編譯器要解決的根本任務是要將人類可識別的文本語言轉換為計算機可識別的二進制指令。

對於C語言來說,編譯器在程序構建過程中,經過預編譯、編譯、彙編和鏈接,把C語言編寫的源文件最終生成可執行的二進制文件。

你真的懂

後續更新內容預告

本篇作為本系列專題第一章的第一節,主要側重讀概念的介紹和C語言程序構建的基本過程。後續章節將會更注重技術實現的細節。

你真的懂

下篇是本系列專題第一章的第二小節,將重點講解編譯器的實現原理,通過實例講解詞法分析、語法分析、語義分析、中間表示生成、代碼優化以及代碼生成的技術細節。

然後就是本系列專題的第二章《鏈接器的工作原理》,實例講解鏈接過程中的各種技術細節。

思考題

經過本節的介紹,我們知道了C語言程序構建過程要經過預編譯、編譯、彙編、鏈接等過程,不妨思考一下:

  1. 所有的編程語言編寫的程序都要經過這樣的構建過程嗎?不同編程語言編寫的程序,構建過程有什麼差異嗎?
  2. C語言程序是否一定要經過這些構建過程呢?有沒有什麼辦法可以直接運行C語言源碼文件呢?
你真的懂

對於這些問題,你有自己的答案嗎?歡迎留言討論!

如果覺得有用,就動動手指點個讚唄:-)

對編譯器、OS內核、虛擬化、性能優化等底層技術感興趣的童鞋,不妨關注下,後續會持續更新相關內容。


分享到:


相關文章: