編譯入門:傳說中的編譯是在做什么


一、 編譯的定義

編譯程序(Compiler)是一種程序。它把用高級語言寫的源程序作為數據接收,經過翻譯轉換,產生面向機器的代碼作為輸出。
這當中代碼還可能要由匯編程序或裝配程序作進一步加工,得出目標程序,交給計算機執行。

二、 Linux下的編譯過程概述

根據編譯的定義,編譯就是把高級語言的源文件進行一系列處理,最終得到二進制代碼的可執行文件(傳說中的binary)。
整個編譯過程在Linux系統下有4個抽象階段(其他操作系統還沒有研究過)。我們舉一個例子來說明,如何把hello.c這個C源文件編譯成名為hello的二進制文件。

#include <stdio.h>

int main()
{
    printf("happy new year!\n");
    return 0;
}

1預處理階段 (.c -> .i)

此階段主要完成#符號后面的各項內容到源文件的替換,例如頭文件#include和宏定義#define, #ifdef等。
可以用gcc的參數-E來指示編譯器只做預處理而不進行下面的3個步驟。例如:

gcc –E hello.c -o hello.i 

2. 編譯階段 (.i -> .s)

這個階段編譯器主要做詞法分析、語法分析、語義分析等,在檢查無錯誤后后,把代碼翻譯成匯編語言。 編譯器將文本文件hello.i 翻譯成文本文件hello.s, 它包含一個匯編語言程序,即一條低級機器語言指令。
可用gcc的參數-S來指示編譯器只編譯到匯編語言而不進行匯編和鏈接。 例如,

gcc -S hello.i -o hello.s

3. 匯編階段 (.s -> .o)

匯編器as 將hello.s 翻譯成機器語言,打包形成可重定位的目標文件hello.o 中(二進制文本形式)。

gcc -c hello.s -o hello.o

4. 鏈接階段 (.o -> binary)

此階段完成文件中調用的各種函數跟靜態庫和動態庫的連接,並將它們一起打包合並形成目標文件,即可執行文件。本例中,printf函數存在於一個名為printf.o的單獨預編譯目標文件中。必須得將其並入到hello.o的程序中,鏈接器就是負責處理這兩個的並入,結果得到hello文件,它就是一個可執行的目標文件。

gcc hello.o -o hello

其中,第1階段和第2階段由編譯器完成,第3階段由匯編器完成,第4階段由鏈接器完成。
此外,以上四個過程,可以用一條命令一次執行:

gcc hello.c -o hello

三、 編譯階原理詳述

編譯階段的原理細節講起來特別復雜,而且在實際編程中基本用不到這么細,所以借用其他博客的內容一筆帶過。
如果對編譯原理感興趣的話,可以搜一下編譯原理的三大經典教材:龍書,虎書和鯨書

編譯程序的工作過程一般可以分為5個階段:

詞法分析

詞法分析的任務是:輸入源程序,對構成源程序的字符串進行掃描和分解,識別出一個個單詞(定義符、標識符、運算符、界符、常數)。

在詞法分析階段的工作中所依循的是語言的語法規則(或稱構詞規則)。
描述語法規則的有效工具是正規式和有限自動機。

語法分析

語法分析的任務是:在詞法分析的基礎上,根據語言的語法規則,把單詞符號串分解成各類語法單元(語法范疇)(短語、子句、句子、程序段、程序),並確定整個輸入串是否構成語法上正確的程序。

語法分析所依循的是語言的語法規則。
語法規則通常用上下文無關文法描述。
詞法分析是一種線性分析,而語法分析是一種層次結構分析。

語義分析和中間代碼的產生

這一階段的任務是:對語法分析所識別出的各類語法范疇,分析其含義,並進行初步翻譯(產生中間代碼)。這一階段通常包含兩個方面的工作。

  1. 對每種語法范疇進行靜態語義的檢查,例如,變量是否定義、類型是否正確等等。
  2. 如果語義正確則進行中間代碼的翻譯。

這一階段所依循的是語言的語義規則,通常使用屬性文法描述語義規則。

優化

對於代碼(主要是中間代碼)進行加工變換,以期能夠產生更為高效(省時間和空間)的目標代碼 。
優化的主要方面有:公共子表達式的提取、循環優化、刪除無用代碼等等。

優化所依循的是程序的等價變換規則。

目標代碼生成

這一階段的任務是:把中間代碼(經過優化處理之后的)變換成特定機器上的低級語言代碼(絕對指令、可重定位指令、匯編指令)。

四、 Linux下的編譯工具概述

原版的cc是unix的系統下的C編譯器,商業軟件。
Linux系統中的cc通常是符號鏈接,指向gcc。可以通過$ls –l /usr/bin/cc來簡單察看,該變量是make程序的內建變量,默認指向gcc。cc符號鏈接和變量存在的意義在於源碼的移植性,可以方便的用gcc來編譯老的用cc編譯的Unix軟件,甚至連makefile都不用改在,而且也便於Linux程序在Unix下編譯。
上古時期的gcc全稱應該是GNU C Compiler,只能編譯C。后來gcc擴展成了編譯器套裝(包含C、C++、Objective-C、Ada、Fortran、Java編譯器),全稱則是GNU Compiler Collection。
g++: C++編譯器。
CC: makefile里面的宏定義,makefile里面的一個名字。

一些注意事項:

1. 后綴名為.c的源文件,gcc把它當做C程序,而g++把它當做C++程序。 后綴名為.cpp的源文件,兩者都把它當做C++程序。

2. 編譯階段,g++會調用gcc。對於C++代碼,兩者是等價的。

3. gcc不能自動和C++程序使用的庫鏈接,所以要用g++來完成鏈接。為了統一,常常用g++直接做編譯和鏈接,所以會讓人誤以為只能用g++來編譯C++代碼。實際上gcc也可以編譯C++代碼。


五、 C和C++混合編譯

時常在cpp的代碼之中看到這樣的代碼:

#ifdef __cplusplus
extern "C" {
#endif
//一段代碼
#ifdef __cplusplus
}
#endif

這樣的代碼到底是什么意思呢?首先,__cplusplus是cpp中的自定義宏,那么定義了這個宏的話表示這是一段cpp的代碼,也就是說,上面的代碼的含義是:如果這是一段cpp的代碼,那么加入extern “C”{}來處理其中的代碼。
要明白為何使用extern “C”,還得從cpp中對函數的重載處理開始說起。在c++中,為了支持重載機制,在編譯生成的匯編碼中,要對函數的名字進行一些處理,加入比如函數的返回類型等等.而在C中,只是簡單的函數名字而已,不會加入其他的信息.也就是說:C++和C對產生的函數名字的處理是不一樣的。
比如下面的一段簡單的函數,我們看看加入和不加入extern “C”產生的匯編代碼都有哪些變化:

int f(void)
{
    return 1;
}

在加入extern “C”之前和之后產生的匯編代碼是:

.file "test.cxx"
.text
.align 2
.globl _f
.def _f; .scl 2; .type 32; .endef
_f:
pushl %ebp
movl %esp, %ebp
movl $1, %eax
popl %ebp
ret
但是不加入了extern "C"之后
.file "test.cxx"
.text
.align 2
.globl __Z1fv
.def __Z1fv; .scl 2; .type 32; .endef
__Z1fv:
pushl %ebp
movl %esp, %ebp
movl $1, %eax
popl %ebp
ret

兩段匯編代碼同樣都是使用gcc -S命令產生的,所有的地方都是一樣的,唯獨是產生的函數名,一個是_f,一個是__Z1fv。  

明白了加入與不加入extern “C”之后對函數名稱產生的影響,我們繼續我們的討論:為什么需要使用extern “C”呢?C++之父在設計C++之時,考慮到當時已經存在了大量的C代碼,為了支持原來的C代碼和已經寫好C庫,需要在C++中盡可能的支持C,而extern “C”就是其中的一個策略
試想這樣的情況:一個庫文件已經用C寫好了而且運行得很良好,這個時候我們需要使用這個庫文件,但是我們需要使用C++來寫自己的新的代碼。如果我們自己的新代碼使用的是C++的方式鏈接這個C庫文件的話,那么就會出現鏈接錯誤。
我們來看一段代碼:首先,我們使用C的處理方式來寫一個函數,也就是說假設這個函數當時是用C寫成的:

//f1.c
extern "C"
{
    void f1()
    {
        return;
    }
}

編譯命令是:

gcc -c f1.c -o f1.o

產生了一個叫f1.o的庫文件。再寫一段代碼調用這個f1函數:

// test.cxx
//這個extern表示f1函數在別的地方定義,這樣可以通過
//編譯,但是鏈接的時候還是需要
//鏈接上原來的庫文件.
extern void f1();
int main()
{
    f1();
    return 0;
}

通過

gcc -c test.cxx -o test.o 

產生一個叫test.o的文件。然后,我們使用

gcc test.o f1.o

來鏈接兩個文件,可是出錯了,錯誤的提示是:

test.o(.text + 0x1f):test.cxx: undefine reference to 'f1()'

也就是說,在編譯test.cxx的時候編譯器是使用C++的方式來處理f1()函數的,但是實際上鏈接的庫文件卻是用C的方式來處理函數的,所以就會出現鏈接過不去的錯誤:因為鏈接器找不到函數。

因此,為了在C++代碼中調用用C寫成的庫文件,就需要用extern “C”來告訴編譯器:這是一個用C寫成的庫文件,請用C的方式來鏈接它們。
比如,現在我們有了一個C庫文件,它的頭文件是f.h,產生的lib文件是f.lib,那么我們如果要在C++中使用這個庫文件,我們需要這樣寫:

extern "C"
{
    #include "f.h"
}

回到上面的問題,如果要改正鏈接錯誤,我們需要這樣子改寫test.cxx:

extern "C"
{
    extern void f1();
}
int main()
{
    f1();
    return 0;
}

重新編譯並且鏈接就可以過去了.

總結下,C和C++對函數的處理方式是不同的。extern “C”是使C++能夠調用C寫的庫文件的一個手段,如果要對編譯器提示使用C的方式來處理函數的話,那么就要使用extern “C”來說明。


參考文獻: [編譯原理簡單介紹](https://blog.csdn.net/cflys/article/details/71274116) [程序編譯的4個階段](https://blog.csdn.net/dylandong/article/details/60465718) [gcc編譯的4個階段](https://blog.csdn.net/xiaohouye/article/details/52084770) [cc gcc g++的區別](https://www.cnblogs.com/xj626852095/p/3648246.html) [Gcc的編譯流程分為了四個步驟](https://blog.csdn.net/xiaohouye/article/details/52084770) [C和C++混合編程(__cplusplus使用)](https://blog.csdn.net/a125930123/article/details/53558041)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM