函數,從編輯到編譯

2021-02-20 C語言與C++編程

0、序

我從一生下來就呆在這個昏暗的地方。


我不明白為什麼程式設計師這麼喜歡 Dark Mode,Brighten Mode 才是我的最愛。聽說最近連 iphone 都開始支持 Dark Mode 了,沒話講。。。說好的絕不妥協呢?


我周圍是熙熙攘攘的函數群,穿插著變量聲明和宏定義。


在我們這裡,函數是一等公民。


當然,不光在 C++,在面向過程的 C 語言、面向對象的 Java ,尤其是在那些函數式編程的語言裡,我們都扮演著舉足輕重的角色。


能力越大,責任越大。我和一群函數夥伴們就負責維護著程序的功能。每個函數的一小步,合起來就是功能模塊的一大步。


作為一門靜態編譯型語言,我們不像那些解釋語言一樣,寫完就能直接運行,而是要先經過編譯這一道坎,成為機器語言,才能夠運行在我們賴以生存的機器上。


這道坎不是那麼好過的,再頂尖的程式設計師,也會在這上面栽跟頭。


放在往常,雖然程序偶爾會出 bug ,但大家齊心協力,可謂蟲(bug)擋殺蟲,過五關斬六將,整個程序也稱得上是井井有條。


但這次,我們遇到了大問題。

1、預編譯

今天的一切看起來都很平凡,至少我是這麼認為的。


屏幕外的程式設計師像平常一樣敲著代碼,我們像平常迎接著新函數的到來,像平常一樣嬉笑怒罵,像平常一樣期待著預編譯進程的到來。


預編譯進程是整個編譯進程的先鋒。


像往常一樣,我們從磁碟出發,沿著總線來到了內存。這裡就是進程的工作車間。


預編譯進程第一步會 刪除所有 #define,展開宏定義。處理條件預編譯指令


#define WINDOWS
#define BUFSIZE 1024
#define DEPTH 4
#define DECODE "utf-8"
...


上面的就是宏定義,每次我們都要在預編譯進程的指揮下,把語句裡出現的宏替換成對應的值。


這一步其實本來不需要我們幹的,程式設計師怕麻煩,想要做到「一處修改,處處更改」,就發明了宏定義,讓編譯器來幹這些「髒活累活」。


處理條件預編譯指令就有點不一樣了:



#ifdef WINDOWS
<experssion1>
#else
<experssion2>
#endif


如果宏定義有 WINDOWS,就留下 <experssion1>,沒有的話就只留 <experssion2> 。說白了,這就是個預編譯階段能執行的 if... else ... 語句。上面的語句一處理,就變成了:


<experssion1>


對,注釋也會被刪除。


可憐那些注釋,這一輩子都不曾領略 CPU 裡的風景。


第二步是處理 #include預編譯指令。


這一步就比上面的複雜多了。用專業的話來說,處理 「#include 」預編譯指令,就是將被包含的文件插入到該預編譯指令的位置。這個過程是遞歸進行的,也就是說被包含的文件可能還包含其他文件。


#include "config.h"
<expressions>


別看他們現在就只有短短兩句,等把 config.h文件內容複製過來,信息量一下子就大了。


#ifndef _CONFIG_H_
#define _CONFIG_H_

#define VERSION "1.0.0"
#define MODE 1
...
...
#endif

<expressions>


補充一句,這個 .h 後綴的傢伙,叫頭文件。他是我們與其他文件的函數公民的溝通渠道。


頭文件這個傢伙和源文件不太一樣,他是包含功能函數、數據接口聲明的載體文件,主要用於保存程序的聲明。也就是說,頭文件裡是沒有函數的——我們曾多次試圖佔領頭文件的領地,但都沒有成功——都是因為程式設計師的約束。


每個頭文件都會帶有一組條件預編譯語句,用來防止自己被多次編譯。


至於怎麼做到的,這太簡單了,我不說你也能想出來。


聽說有的編譯器還支持 #pragma once ,添在頭文件第一行就能做到相同的事情。可惜我們的編譯器有點舊,不兼容他們。


最後這步就比較快了,添加行號和文件名標識。


走到這裡,我們已經得到了編譯器調試的需要的行號信息,如果編譯到哪一步出錯,或者出現 warning 這樣的警告,就能把行號顯示出來,方便程式設計師及時發現問題源頭。


今天的預編譯比我想像中要快一點,可能這次沒什麼進程跟我們搶 CPU 資源吧。


預編譯階段結束,# 的數量大大減少,僅剩下幾個 #pragma 指令留在這裡。


和其他宏定義指令不一樣的是,#pragma 是能夠跟編譯器平起平坐的存在,預編譯進程見了都得避讓三分。


#pragma warning( disable: 4507 34; once: 4385; error: 164 )


像這條指令,就是專門給編譯器看的,意思是 『不顯示4507和34號警告信息 ,4385號警告信息僅報告一次,把164號警告信息作為一個錯誤』 。可以說,她是程式設計師和編譯器之間的信鴿。


<!--要注意的是,這裡面的第幾步第幾步是不嚴謹的,預編譯沒有劃分這樣的界限-->


對於我來說,預編譯階段是比較輕鬆的,最複雜也只是處理條件預編譯指令——刪除幾行代碼罷了。

2、編譯

所謂編譯過程,就是把預處理完的文件進行一系列詞法分析,語法分析,語義分析及優化後生產相應的彙編代碼文件。這一步是整個程序構建的核心部分,也是最容易出錯的一部分。

從現在開始,步驟就變得十分複雜了。

對函數來說,這一階段是最繁瑣也是最為危險的:稍有不慎,輕則 warning 重則 error 。

我見過許多出錯的函數,他們連著行號被編譯器帶到窗口,當街示眾。

也有些函數和 #pragma 關係比較好,小錯誤被遮掩過去,免去了示眾的命運。

2.1 掃描

我們要先經過一臺掃描器 (Scanner),這機器如此龐大,以至於我根本看不出內部的細節。

我對大型機器充滿好奇,編譯器給了我一本手冊——《編譯寶典》,他說裡面有講掃描器的實現。

可我看不懂。

編譯器告訴我,想要參透這本寶典,需要付出代價。

「代價?像嶽不群那樣?」

「你想哪兒去了!你說的那是《葵花寶典》,我說的代價是時間和精力!編譯器這種龐大的工程,需要一個團隊來合作完成,除非你是打算寫寫玩具編譯器。」

所以我放棄了造出這些機器的想法,因為函數的一生太短了,希望你能實現我的願望。

在掃描器裡的體驗不太舒服,它像一臺 X 光機,把我的身體裡裡外外看了個遍,給我的感覺很不妙。

出了機器,會收到一個檢查報告,像這樣(篇幅有限,只拿一個表達式舉例子):

拿著這份報告,就該去進行語法分析了。


2.2 語法分析

語法分析器(Grammar Parser)就不需要我整個躺進去,只用把掃描器生成的檢查報告交給他。

分析好之後,我拿到了一個盆栽 新的報告 —— 一棵樹,或者,準確一點,一棵語法樹(Syntax Tree)。

樹的枝葉一切正常,表示我的表達式是合法的。毫無疑問,我再次通過了檢查。

但有的函數就不這麼幸運了,他們會在這一步檢查出問題,比如括號不匹配,表達式中缺少操作符等等,這些錯誤會上報編譯器,最後報告給程式設計師。——他們面臨著整改的命運。


2.3 語義分析

剛剛的語法分析器,顧名思義,只完成了語法層面的分析,但他不了解表達式是不是真的有意義。

比如讓兩個指針做乘法,在語法上是合法的,但這是沒有意義的。語義分析器(Semantic Analyzer)就能夠檢查出這個錯誤。

但語義分析也不是萬能的,它也有局限性——語義分析僅僅能分析靜態語句。

你問我什麼是靜態語義?

我不知道,因為我只是一個函數。

所謂靜態語義,是能在編譯期間可以確定的語義,與之相對的動態語義,就是只有在運行期才能確定的語義。

從靜態語義上看,這句話是合法的,編譯期間不會報錯,但等到程序運行到這句時,就會報出 devided by 0 的錯誤,造成程序異常退出。

2.4 代碼生成與優化

走到這,編譯部分也算快結束了。

剩下的兩臺機器,一臺叫代碼生成器(Code Generator),一臺叫目標代碼優化器(Target Code Optimizer)。

目標代碼優化器總是嫌棄代碼生成器,因為代碼生成器生成的代碼效率低,還需要他花大功夫來優化。

用優化器的話講:「生成器那傢伙,每次生成一堆低效率的代碼,我還得從頭讀到尾,進行基於數據流分析(data-flow analyse)技術的全局優化,太累了。」

其實代碼生成器有做優化,叫做局部代碼優化,只是優化程度遠遠不及優化器,所以他不好意思反駁優化器。

不過這不代表代碼生成器結構就簡單了,它生成代碼的過程十分依賴於目標機器——這意味著它要適配許許多多的機器,不同的機器有著不同的字長、寄存器、整數數據類型和浮點數數據類型等,它要考慮的事情太多了。

經過生成器,表達式的樣子發生了巨大的變化(這裡以 x86 的彙編語言來表示):

movl index, %ecx ; value of index to ecx
addl $4, %ecx ; ecx = ecx + 4
mull $8, %ecx ; ecx = ecx * 8
movl index, %eax ; value of index to eax
movl %ecx, array(,eax,4) ; array[index] = ecx

優化器對上面的代碼又做了一番深層次的優化,包括選擇尋址方式,刪除多餘指令等。(代碼比較短,所以優化效果並不明顯。)

movl index, %edx
leal 32(,%edx,8), %eax
movl %eax, array(,%edx,4)

每次走過這些流程,我都不得不感嘆於編譯器複雜的結構,也只有優秀的程式設計師們,才能夠完成這麼偉大的工程吧。

函數的編譯,就是這麼繁瑣,且枯燥。

今天令我驚訝的是,所有函數都完美的通過了編譯階段。

「Nice~ 這次可以早點休息了!」不止是我,其他函數也是這麼想的吧。

我們有說有笑,悠然等待著連結程序來做最後的收尾工作。

但萬萬沒想到,危機竟出現在連結階段。

3、連結

我聽長輩們說,連結器,擁有比編譯器更為悠久的歷史。

每當我把這個事實告訴新來的函數時,他們總是一臉不可思議:

「我們都是先編譯,再連結的,怎麼會先有連結器,再有編譯器?這又不是先有雞還是先有蛋的哲學問題。」

我第一次聽說的時候,也有這樣的疑惑。

「連結是在彙編語言時代就出現了的概念。在那之前,是機器語言的時代。但是想要對機器語言進行修改,那就太困難了,因為機器指令的修改經常造成具體指令地址的改變,牽一髮而動全身。所以彙編語言產生了,用符號來標記位置,而符號與實際地址的映射工作,就是連結器來做的。」我向他解釋道。

「我明白了,因為高級語言出現在後面,所以從高級語言到彙編語言的步驟——編譯,要比連結來的晚一些。」

是啊,程式語言的發展,從機器語言,到彙編語言,再到現在的高級語言,經過了幾十年的時間。但儘管是現代,我們編譯型高級語言,想要運行,還是得回到彙編語言,再被翻譯成機器語言,看起來是繞了一個大圈,但人類程式設計師的生產力,卻得到了質的飛躍。

人類總是能想出各種辦法來減輕他們的工作量。

...

連結過程主要包括了地址和空間分配(Address and Storage Allocation)、符號決議(Symbol Resolution)和重定位(Relocation)等這些步驟。

看起來挺高大上,其實連結器做的和早期程式設計師人工調整地址沒什麼兩樣,只是更加複雜而已——你不要指望現在的語言特性比早期簡單。

但從本質上說,就是把指令對其他符號地址的引用加以修正。連結的重點就是兩個不同的目標文件。

這一階段本來是很容易通過的,但今天,居然出現了大錯誤。

問題出在 main.c 中。

出乎所有函數的意料,包括 main。

4、尾聲

回到編輯器,我們檢查遍了 main 函數內部的所有函數,從他們的聲明,再到他們的實現,全都沒有問題。

「會不會是 #include 的時候出了什麼問題?」有函數提出了自己的看法。

我們決定分頭行動,一部分和其他文件協作檢查函數聲明,剩下一部分負責排查有沒有出現循環 #include 問題。

不知過了多少 CPU 周期,大家回來了,一無所獲,兩種問題都沒有出現。

我們一籌莫展。

「 main.c ,連結出錯...」我滿腦子都在想可能原因,「不會是 main 函數本身出了問題吧!」

「快,去看看宏定義有沒有異常!」

宏定義?雖然大家有些疑惑,但還是照做了。

果然,發現了異常:

...
...
#define main mian
...
...

我心裡怒罵「誰這麼缺德,幹這種事情?!」

好在刪掉這條「間諜」指令後,一切恢復正常,完美通過編譯連結。

我們終於可以休息了。

PS:危險指令,請勿模仿。除非,,,你想挨一頓毒打。

PPS:函數的運行以後也會寫到。

PPPS:如果大家對文章有什麼看法和意見,歡迎提出來~ 如果覺得文章有意思,點個讚再走吧

文中插圖來自《程式設計師的自我修養》。

●編號601,輸入編號直達本文

●輸入m獲取文章目錄

分享C/C++技術文章

相關焦點

  • 查看Vue3 模板編譯後的 AST 和渲染函數
    Vue 在開始工作之前,需要對 HTML 模板進行一次編譯,轉換成 AST。一說到 AST 有人可能就害怕了,其實不必,你把它看做是一個 JS 對象,使用了樹這種數據結構,它在這裡的作用就是把 HTML 中每個節點轉換成一個 JS 對象,後面會通過這個 JS 對象來做更多的工作。
  • 啥是函數的隱式推斷?這跟文件的編譯順序又有啥關係?
    老張:這個問題就涉及到編譯器的編譯順序問題了。我們用的編譯器在編譯代碼生成可執行程序的時候,它的編譯順序是自上而下逐行編譯的。所以在剛才的代碼中,會先編譯main函數中的內容,後編譯你自己實現的add函數。小豆丁:哦,這個我能理解,可以這跟報錯有什麼關係呢?
  • 快速上手系列-C語言之預編譯命令、宏定義及條件編譯
    宏定義1、宏定義在進行文本編輯時,「替換」是一個很有用的功能。C語言編譯預處理程序也提供類似的功能:在源程序中,允許一個標識符(稱為宏名)來表示一個語言符號字符串。在C語言中,「宏」分為無參數的宏和有參數的宏。
  • qt5.12下繼承於Qdialog的類調用slot函數編譯錯誤匯總
    1 其他機器上編譯通過,換個機器編譯失敗很多時候,我們發現代碼的語法是正確的,而在這個機器上編譯也是通過的,但是換一臺機器後,我們發現,編譯竟然沒有通過,這個原因是未知的。或者ctrl+alt+delete進行後臺任務器的查看,進行相關的程序的關閉即可進行第二次調試和編譯。這個也是我在加密的機器上調試qt中比較尷尬多次遇到的問題。3 奇怪錯誤3:slot函數有定義但是無法響應slot函數定義了,但是每次調用都提示無法響應。
  • 如何編譯LabVIEW代碼並燒錄到Arduino上?
    既然串口通信太慢,那要是直接把LabVIEW程序編譯並下載到Arduino開發板上呢?這樣就可以避免通信的延時,還可以脫離PC!這裡就要用到LabVIEW另外一個工具包——Arduino Compatible Compiler For LabVIEW(後面簡稱為編譯器),通過它我們可以編譯LabVIEW代碼並下載到Arduino開發板中,使VI脫離PC機,嵌入到Arduino硬體中獨立運行!
  • keil編譯中所有編譯的錯誤信息
    ifdef有語法錯  Bad undef directive syntax :編譯預處理undef有語法錯  Bit field too large :位欄位太長  Call of non-function :調用未定義的函數  Call to function with no prototype :調用函數時沒有函數的說明  Cannot modify a
  • Linux下編譯時出現的錯誤及解決方法
    Linux下編譯時出現的錯誤及解決方法 (1)由於是Linux新手,所以現在才開始接觸線程編程,照著GUN/Linux編程指南中的一個例子輸入編譯,結果出現如下錯誤......
  • Keil編譯常見問題
    因為我一開始想的是每一個宏定義對應一個函數名,這樣做起來就比較清晰,但是我卻很傻逼地將函數名每次直接複製到宏名,導致了這種蛋碎的結果。}這樣如果點編譯,就會產生error: #159的錯誤,因為當函數a調用函數b時,發現在這之前都沒有函數b的任何聲明.
  • 前置聲明和C++編譯的問題
    使用場景: 1、用於兩個頭文件相互包含無法編譯通過的問題; 2、用於在其他的.h文件中只包含所需要的類,函數的聲明,不用多次#include 3、節省編譯時間[在編譯期間多次重複包含頭文件] 上面說的有點複雜了,我對前置聲明的理解是:在頭文件裡面我們需要使用對應的類型
  • Linux下C語言編譯的問題
    連結時缺失了相關目標文件(.o)  測試代碼如下: test.h test.c(調用func.c的函數) func.c main.c(調用test.c的函數)  然後編譯。,本例中test.o文件中包含了test()函數的實現,所以如果按下面這種方式連結就沒事了。
  • C語言陷阱與技巧20節,自定義「編譯時」assert方法,在代碼編譯階段...
    例如,在實現 open() 函數時,先完成它的功能固然是重要的,但是程式設計師還需要考慮各種「意外」,比如下面這種情況。假設不存在 /dev/sth 這個文件,仍然調用 open() 函數打開它:int fd = open("/dev/sth", O_RDONLY);此時 open() 函數不應該感到迷惑,而是具備處理這種「意外」的能力。標準庫的 open() 函數在遇到這種情況時,會返回一個錯誤碼,對應著「文件不存在」的錯誤信息。
  • Keil編譯警告:function "assert_param" declared implicitly的...
    出錯的原因:函數「assert_param」未聲明。assert_param2 問題分析函數assert_param是STM32官方庫文件中用到的。查看函數說明可知,assert_param是一個條件表達式宏定義,主要作用是對函數的輸入參數進行檢查。
  • 深入淺出iOS編譯
    :from libSystem,表示這個符號來自於libSystem,會在運行時動態綁定。-v to see invocation)Objective C的方法要到運行時才會報錯,因為Objective C是一門動態語言,編譯器無法確定對應的方法名(SEL)在運行時到底有沒有實現(IMP)。
  • SAST Weekly|靜態反編譯軟體IDA使用簡介
    說到反編譯,很多人想到的可能是修改,破解。但是實際上軟體的逆向工程在我們生活中有很多*正當*的應用。這期weekly將給大家簡要介紹下利用IDA的反編譯和在漢化上的應用。但是我最喜歡用的還是IDA,首先,IDA支持目前絕大多數常用的指令集(如x86,amd64,arm等),其次,IDA可以為反彙編後的代碼加上自己的注釋,方便理解,比如對於跳轉/調用指令,在IDA中就是這樣的形式(假如函數名稱已知)有人可能會說,我gdb也有這個功能,但是IDA還可以對常量進行處理,比如這個報錯函數這裡從程序中讀取到「System「字符串的內存,但是IDA作為靜態反編譯軟體
  • 內聯函數和外聯函數有什麼區別
    在類內定義的函數被默認成內聯函數。內聯函數從原始碼層看,有函數的結構,而在編譯後,卻不具備函數的性質。   內聯函數不是在調用時發生控制轉移,而是在編譯時將函數體嵌入在每一個調用處。編譯時,類似宏替換,使用函數體替換調用處的函數名。一般在代碼中用inline修飾,但是能否形成內聯函數,需要看編譯器對該函數定義的具體處理。   內聯擴展是用來消除函數調用時的時間開銷。
  • Excel中如何添加自定義函數到函數庫
    那麼該如何進行自定義函數,自定義的函數如何添加到函數庫,並對其添加必要的使用說明,使其更像內置函數呢?下面以自定義WLOOKUP函數為例,為讀者詳細講解。WLOOKUP自定義函數其實是INDEX和MATCH函數嵌套函數,實現的是查找匹配值功能,與微軟新出的XLOOKUP函數功能一致,但XLOOKUP函數只有Office 365和Excel2019版本中有,所以自定義WLOOKUP函數主要是為低版本Excel提供XLOOKUP函數功能。
  • 函數重載
    文章中關於報錯「無法解析的外部符號」這一問題得到的答案是:C和C++代碼在編譯成目標文件時對函數的命名不同。今天討論下為何會導致函數名不同。       還是上次的工程,工程結構如下       我們已經知道了兩種類型文件的同一函數生成的目標文件的不同,如下兩圖分別為」Main.cpp」和「Main.c」生成的兩個目標文件
  • 記一次有趣的APP反編譯的過程
    發送過一次消息之後輸入框會處於鎖定狀態,需要對方回復才可以繼續發送這樣很影響我的學習 所以我們查看一下他的app先用apktool反編譯康康使用命令 java -jar apktool.jar d filename 進行反編譯
  • CSS 預編譯語言 Sass 快速入門教程
    1、CSS 預編譯語言概述CSS 作為一門樣式語言,語法簡單,易於上手,但是由於不具備常規程式語言提供的變量、函數、繼承等機制,因此很容易寫出大量沒有邏輯、難以復用和擴展的代碼,在日常開發使用中,如果沒有完善的編碼規範,編寫的 CSS 代碼會非常冗餘且難以維護。
  • AVRGCC/WinAVR編譯環境中斷函數的使用方法
    新版(如2007 版WINAVR)中,INTERRUPT 宏不再可用,而建議用ISR 宏替代SIGNAL宏,ISR 和SIGNAL 是一回事,但以後的版本中SIGNAL 宏將會逐漸被丟棄,所以新的程序建議使用ISR,也就是使用ISR作為中斷服務函數名,下面將舉例說明一些具體的中斷使用。