情景劇:C/C++中的未定義行為(undefined behavior)

同勉共進發表於2021-06-08

寫在前面

本文嘗試以情景劇的方式,輕鬆、直觀地解釋C/C++中未定義行為(undefined behavior)的概念、設計動機、優缺點等內容1,希望讀者能夠通過閱讀本文,對undefined behavior有一個清晰、深刻、全面的認識。

正文

人物

彪哥:可將其視為C/C++標準(standard)或標準的制定者。

小編們:可將其視為編譯器或編譯器的編寫者(生產商),分別記為“小編1”、“小編2”、…、“小編N”。注意,這裡的編譯器是“廣義”的,即指將原始碼轉換為可執行檔案的“處理器”。

小猿們:程式設計師,即C/C++的使用者,分別記為“小猿1”、“小猿2”、…、“小猿N”。

佈景

這是一間寬敞明亮的會議室,牆上的橫幅上寫著“關於C語言標準制定與實現的三方洽談會第一次會議”。橫幅的正下方、長方形會議桌的一條短邊旁,是一位年約四十、微微發福的光頭中年男人,他手握茶杯,正襟危坐。在他面前的名牌上,赫然寫著“彪哥”。同時,在他的左右兩旁,即會議桌的兩個長邊上,各坐著幾個不同年齡、神情各異的人。他們依髮量多少依次就坐,髮量越少的離彪哥越近,其中,彪哥左手邊的人面前的名牌上依次寫著“小猿1”、“小猿2”、…、“小猿N”,而彪哥右手邊的人面前的名牌上依次寫著“小編1”、“小編2”、…、“小編N”。令人驚訝的是,整個會議室中,竟沒有一個頭發濃密之人,就連唯一一位女同志,髮量也只能勉強算是正常。

第一幕 彪哥開場

彪哥(輕咳一聲):咳咳,那個,我們們就開始吧。我先說兩句啊,我們的語言,C,主打的是什麼?啊?是快!是高效!正所謂魚與熊掌不可兼得,所以啊,安全性這一塊,必要的時候,可以適當地放寬一些嘛!你比如說,陣列越界訪問,越不越界,(看著小猿們)你們心裡沒點數嗎?程式碼是你們自己寫的啊!再說了,從邏輯上講,你訪問你已經定義的範圍,這是有意義的,但是,如果你訪問的範圍你之前都沒有定義,這本身就沒有任何意義嘛!就好比一個老師做家訪,班上有10個學生,他偏要訪問第11家,這不是扯呢嗎?再比如,解引用空指標,指標空不空,(再次看向小猿們)你們心裡沒點數嗎?程式碼是你們自己寫的啊!再說了,從邏輯上講,空指標沒有指向任何物件,你去解引用它,這本身就沒有任何意義嘛!還有啊,像 char *p = "hello"; 這樣的程式碼,你既然直接寫出了 "hello" 這一字串字面值(sring literal),潛臺詞就是將其作為一個整體、一個常量來用,後面不打算再改了。我的想法是,直接將字串字面值放入全域性的只讀資料區2,在記憶體中僅儲存一份拷貝,這樣,當下次再使用的時候,比如 char *q = "hello"; ,就可以直接共享記憶體了3。可你們,(再次看向小猿們)偏偏有人要寫 p[0] = 'y'; 這樣的程式碼,你說你,如果打算將 "hello" 作為字元陣列的話,一開始就直接寫 char arr[] = {'h', 'e', 'l', 'l', 'o'}; 不好嗎?所以啊,對以上這些邏輯上錯誤的、不合常理的、毫無意義的行為,我不打算花費任何額外的功夫來提供任何保障,因為我沒有必要因為個別人的愚蠢而犧牲語言的效率。換言之,我打算將以上行為稱為“未定義行為”,undefined behavior,並且,對這些行為,我不做任何的規定,(看著小編們,比了個V的手勢)具體你們怎麼搞,我的意見就兩個字——隨便!(看著大家一時愣在那裡,彪哥頓了頓,又故作鎮定地道)當然了,我這麼做並不是想偷懶,而且,這對你們雙方都是有好處的。(看著小編們)一方面,這給了你們很大的自由發揮空間,(又看向小猿們)另一方面,這麼一來,我們語言的學習路線一下子就陡峭了,學習門檻一下子就拔高了,那你們的工資,不也隨之提高了嗎?你們天天嚷嚷著“要提高程式設計師的薪資待遇”、“要提高程式設計師的薪資待遇”,這種事情,光從制度和政策上解決是治標不治本滴,要解決,還是要從技術上徹底解決嘛!你們說,是不是啊?

小猿1(笑嘻嘻):對對對!老大您說的太對了!

小猿2(小聲嘟囔):完犢子,這行算是混不下去了!

彪哥(義正辭嚴):再說了,我們的語言是高大上的語言,不是什麼人上幾個月的培訓班就能學會的。我要做到的是,當其它語言的培訓廣告爛大街的時候,關於C語言的培訓一個都沒有!憑什麼我發明語言,卻讓這幫人拿來掙錢啊!(像是忽然想起什麼)哎哎哎,你看我,一不留神又跑題了!那個,我就先說這麼多,接下來,你們雙方有什麼意見,都說一說,說一說嘛!

第二幕 各抒己見

小編3(滿臉深沉):老大,您這個“隨便”資訊量有點大啊!按照您的意思,對於未定義行為,我可以給出警告或者直接報錯;也可以睜隻眼閉隻眼,置之不理;還可以關機、格盤、刪庫、下病毒嘍?隨便嘛!

彪哥(篤定地):對!理論上確實如此!(用下巴指指小猿們)如果你不怕被他們打死的話!

小編3(看了眼對面殺氣騰騰的小猿們,滿臉賠笑):我就說說,說說而已!

小編4(鄙夷地看了小編3一眼,又滿臉諂媚地看著彪哥):作為一個有擔當的編譯器,理應為彪哥和程式設計師朋友們分憂!對於某些未定義行為,我們也可以給出自己的定義嘛!比如,我們可以指定一個編譯選項 -fwrapv ,使得編譯器在任何情況下(無論是否啟用優化或啟用何種級別的優化),對有符號整數的溢位做wrap around處理4。對吧,彪哥?

彪哥(讚許地看著小編4):對!都說了,對於未定義行為,你們要怎麼搞,我不管。你們完全可以提供自己的實現,將某些未定義行為變成已定義行為嘛!5

小編1(鼓掌):undefined behavior,wonderful!老大,你這麼一搞哇,可給我們編譯器減輕了不少的工作。就拿陣列越界訪問這件事來說吧,如果在編譯期間檢測陣列越界,那肯定得做額外的工作,這樣一來,效率必然是會受到影響的嘛!現在好了,老大你把陣列越界訪問定義成undefined behavior,那我們就可以對越界訪問這件事置之不理了,換句話說,我們預設所有訪問都是有效的、安全的,直接生成彙編程式碼並最終生成可執行檔案,至於執行時發生什麼,那就不關我們的事了!再說了,安全訪問陣列元素,本就應該由程式設計師自己來保障嘛!

小猿4(陰陽怪氣):么~,真是甩得一手好鍋呀!

小編2(急切地):我……我說,美……美女,話不……不能這麼說呀!你……你比如,下……下面這段程式碼:

 1 #include <stdio.h>
 2 
 3 int main()
 4 {
 5     int arr[] = {1, 2, 3, 4, 5};
 6     int idx = 0;
 7     scanf("%d", &idx);
 8     printf("%d\n", arr[idx]);
 9     return 0;
10 }

索引是……是執行時由使用者輸入的,你讓我們怎……麼在編譯期檢測,(兩手一攤)臣妾做不到啊!相……相反地,你們程式設計師加一個對索引的條……條件判斷,卻是易……易如反掌的事!你說我說得對嗎,美……美女?

小編4(得意地):未定義行為,要得!這給我們進行編譯優化提供了更大的空間和更多的可能性。比如,下面這段程式碼:

 1 #include <stdio.h>
 2 #include <limits.h>
 3 
 4 int foo(int x)
 5 {
 6     return x + 1 > x;
 7 }
 8 
 9 int main()
10 {
11     int res = foo(INT_MAX);
12     printf("%d\n", res);
13     return 0;
14 }

如果不啟用優化(即使用-O0選項), foo 函式對應的彙編程式碼是這樣的:

 

                    圖1  -O0下foo函式的彙編程式碼 

並且輸出結果是06

然而,如果使用O2級別優化, foo 函式對應的彙編程式碼是這樣的:

              圖2  -O2下foo函式的彙編程式碼 

顯然,輸出結果是17

 小編4(繼續口沫橫飛):從數學,或者自然科學的角度來講,一個數加上1,一定比之前大。然而,在現代計算機中,有符號整數通常用2進位制補碼(2's complement)表示,同時,儲存一個整數的bit位也是有限的(如32bit),於是,就出現了 INT_MAX + 1 = INT_MIN 這種奇葩結果(前提是採用wrap around規則)。對於 return x + 1 > x; ,如果為了照顧 INT_MAX + 1 = INT_MIN 這唯一的特例,就不得不生成圖1所示的一大堆彙編程式碼。反之,如果我們不考慮這一特例,即不考慮有符號整數的溢位,那 x + 1 > x 就永遠是成立的,於是,就可以直接生成圖2所示的彙編程式碼,從而最終提升程式的執行效率。但是,能夠這樣優化的大前提是,標準允許我們對溢位的情況置之不理。幸運的是,彪哥將有符號整數的溢位定義為undefined behavior,這相當於給了我們全權處置權,這才使以上優化成為可能,這充分體現出了未定義行為的好處!

小猿4(厲聲道):你這是狡辯!現實中誰會寫 x + 1 > x 這樣的不等式!

小編4(不慌不忙):好,那我就來個不狡辯的。(笑嘻嘻地衝小猿4)美女,你猜下面的程式碼段在-O2選項下會生成怎樣的彙編程式碼?

1 int fun(int i) 
2 { 
3     int j, k = 0; 
4     for (j = i; j < i + 10; ++j) 
5     {
6         ++k;
7     } 
8     return k; 
9 }

 小編4(得意洋洋):怎麼樣?猜不出來吧?美女,請上眼!

            圖3  -O2下fun函式的彙編程式碼

小編4(搖頭晃腦,手舞足蹈):怎麼樣?驚不驚喜?意不意外?

小猿3(左手手指張開放在嘴上做驚訝狀):我的天吶!我們寫的程式碼,讓你們霍霍成啥樣了!

小編3(表情鄙夷,全身嘚瑟):什麼叫霍霍呀,是優化,優化懂不懂!

小猿4(疾言厲色):你這還是狡辯,迴圈次數跟 i 毫無關係,我直接寫 int k = 10; 不好嗎?什麼爛程式碼!

小編4(故做嚴肅狀):你也知道這是爛程式碼啊!即使你們程式設計師寫出了屎一樣的程式碼,我們編譯器也能把它優化得如春風般簡潔清新,而這,正是undefined behavior存在的意義!

小猿4(氣急敗壞地指向小編4):你……你!

小猿2(衝小猿4和小編4做了個稍安勿躁的手勢):那個,兩位,不要著急,消消氣,請讓我來說兩句。那個,要我說呀,這undefined behavior確實能帶來一定的好處,但不可否認,它也造成了一定的負面影響。首先,它大大增加了程式的除錯難度,比如說,最開始老大提到的 p[0] = 'y'; ,這句試圖修改只讀資料區,但編譯器沒有任何警告,直到執行時,程式崩潰。對於沒有經驗的程式設計師來說,他可能根本意識不到究竟是哪裡出問題了!其次,未定義行為可能會帶來安全隱患,比如說,黑客可能會利用陣列越界訪問執行惡意程式碼。所以啊,要我說,這undefined behavior,我們還是不要搞了。我建議,對所有行為,我們都應該給出明確的定義,至於怎麼定義,我們大家可以群策群力呀。大家說,是不是啊?

小編2(指手畫腳):老……弟,你前邊說的,都……都對!但……是,無論是修改字串字面值還……還是陣列越界訪問,都是不應該的,是邏輯錯誤的,這……這些,都應該由……你……你們程式設計師努力避免,你不……不能為了自己編碼和除錯方便,就……增加我們的工作量、降低語言的效率吧!這……事不該我……我們負責啊!

小猿4(咬牙切齒):我說你們除了甩鍋還會什麼!我們不是不盡力,可我們再怎麼盡力保障程式碼的正確性和有效性,也不可能做到萬無一失啊!從編譯層面進行規範和檢測,才是根本上的正解啊!所以,我建議,對於undefined behavior,直接廢除!

小編4(針鋒相對):不行,要堅決落實!

小猿2、3、4(大聲):直接廢除!

小編1、2、3、4(亦大聲):堅決落實!

(雙方炒作一團。)

彪哥(大喝一聲):停!別吵吵了!(語氣緩和下來)這樣吧,我們舉手表決,支援堅決落實的舉左手,支援直接廢除的舉右手!開始表決!

(小編們齊刷刷地舉起了左手,小猿2、3、4齊刷刷地舉起了右手,小猿1在同伴的怒視下,心不甘情不願地將剛舉起的左手放下,緩緩舉起了右手。)

彪哥(看看左右):還真是涇渭分明啊!下面,輪到我表態了。(說著,舉起了左手)好了,5:4,堅決落實派以微弱優勢勝出!堅決落實undefined behavior!少數服從多數,任何人不得反對!

第三幕 會議紀要

彪哥(又將語氣放緩和了些):那個,既然都已經做出決定了,我們的會議差不多也要結束了。下面,讓我們一起總結一下這次會議的要點。那個,我先說。

彪哥(正色道):

  1. 第一,我們的C語言,是有國際標準的,標準,是語言的藍圖,也是靈魂和基石;編譯器,是語言的實現,理論上,對於標準中任何明確規定的條款,都應該落實,否則,就不是C編譯器;C程式,也就是.c檔案和.h檔案,是C語言的應用。這一點,是正確理解undefined behavior的大前提。
  2. 第二,undefined behavior是從標準的角度而言的。所謂“未定義”,是指對那些“錯誤”的行為(編碼),標準沒有說明如何處理,也沒有做任何的規定。

彪哥(看看左右兩邊):那個,我就說這麼多。下面,你們雙方也各派一名代表,發表一下會議總結吧!

小編4(站起來,看著手中的筆記本,嚴肅地道):

  1. 第一,所謂未定義行為,是指對於程式設計師寫的某些在常理上或邏輯上存在錯誤,並且很容易在執行時出錯(如崩潰、結果不符合預期、或是引發其它更加嚴重、出乎意料的結果)的程式碼,標準並未做任何規定說該怎樣處理,因為這些程式碼本來就是沒有任何意義的。
  2. 第二,既然標準未做任何規定,也就是說沒有對我們編譯器做任何約束,那我們編譯器就可以自(wei)由(suo)發(yu)揮(wei)了。對於未定義行為,我們可以給出自己的定義,如報錯、警告、或是其它特定的處理方式;也可以置之不理,完全無視;當然,理論上也可以做其它任何事情,如關機、格盤、刪庫等等。
  3. 第三,對於未定義行為引發的任何後果,編譯器概不負責。因為未定義行為本身就是不受法律(即標準)保護的。編譯器的職責,只是為符合標準的“已定義行為”生成高效的程式碼(彙編及可執行檔案)8
  4. 第四,編譯器之所以對某些未定義行為不做檢測,主要有以下原因。首先,檢測需要做額外的工作,某些情況下可能會明顯降低編譯乃至執行效率,因此,出於效率考慮,沒有做檢測。其次,確實無法檢測,如陣列元素索引來自執行時的使用者輸入或是感測器的實時輸入等。最後,在某些情況下, 編譯器不檢測和處理未定義行為(即預設所有行為都是合理合法、有明確定義的),可以極大地優化程式,生成高效的可執行程式碼。
  5. 第五,未定義行為可以帶來好處,主要是未定義行為允許編譯器可以不檢測和處理某些“錯誤”,減輕了編譯負擔,提升了編譯效率,進一步地,為編譯器優化程式碼提供了更大的自由和更多可能性。
  6. 第六,“未定義”並不完全等於“非確定性”,更不等於“隨機”。事實上,許多編譯器對許多特定的未定義行為都有自己固定的處理方式(注意,不作任何處理也是一種處理),只不過,由於標準未做任何規定和約束,因此,對於同一未定義行為,不同的編譯器可能作出不同的處理,或者同一編譯器在不同的系統(環境)下可能作出不同的處理。即,對於同一未定義行為,其結果可能隨使用的編譯器、執行的系統(環境)的不同而不同,但如果在同一條件下,使用同一編譯器,其結果很有可能是確定的(如鐵定編譯時報錯或鐵定執行時崩潰)。當然,如果某未定義行為被編譯器置之不理,那麼,即使是在相同的條件下,也可能產生完全不可預測的結果。這,可能是對“未定義行為”最切合實際的描述。

小猿1(同樣一臉嚴肅):

  1. 第一,未定義行為在某些情況下確實會帶來速度的提升,但這也是以在一定程度上犧牲易用性和安全性為代價的,不可否認,未定義行為使得程式碼的除錯和排錯變得更加困難,也更容易產生安全漏洞。
  2. 第二,我們不得不承認,相比於編譯器檢測未定義行為,程式設計師通過在原始碼中新增特定的語句(如條件判斷、斷言等)來避免未定義行為往往更加容易,但前提是程式設計師有足夠的素養能夠意識到哪些程式碼可能會出現未定義行為以及出現怎樣的未定義行為。
  3. 第三,在程式設計師層面防範未定義行為的發生終究是治標不治本,從編譯器層面檢測和處理未定義行為,才是根本解決之道。
  4. 第四,有時候,特別是啟用了優化選項的時候,編譯器的工作可能與我們預想的大相徑庭,對這一點,我們程式設計師應當時刻保持警醒。
  5. 第五,想要好好愛一個人,瞭解他/她的缺點比了解他/她的優點更重要。同樣,想用好一種程式語言,瞭解它的缺點比了解它的優點更重要。

(聽完小猿1發言中的最後一條,會議室裡爆發出雷鳴般的掌聲。)

彪哥(待大家安靜下來,朗聲道):好了,我宣佈,關於C語言標準制定與實現的三方洽談會第一次會議,圓滿成功!(會議室裡再次響起掌聲)

彪哥(等會議室再度歸於沉寂)同時,我宣佈一下我們下一階段的議題,那就是關於未指定行為(unspecified behavior)的討論,會議時間另行通知。好了,散會!

(全劇終)

注:

1.本文的討論主要是基於C的,對於某些未定義行為,C++的表現可能與C不同,但本文不詳細討論這些細節。

2.在Windows下,為.rdata section,在Linux下,為.rodata section,參考這裡

3.讀者可以自行列印p和q的值,可以看到兩者是相同的。同時,對於較高版本的gcc編譯器(如gcc 5.1.0),如果 char *p = "hello"; 位於.cpp檔案中,是會給出warning的,提示從字串常量到char*的轉換已經廢棄。

4.事實上,GCC編譯器就是這麼做的。關於wrap around,參考這裡

5.出自《Rationale for International Standard Programming Languages C》,原文是“the implementor may augment the language by providing a definition of the officially undefined behavior.”,下載連結

6.彙編程式碼通過線上編譯器Compiler Explorer生成。

7.注意,對於更高版本的編譯器,如gcc 11.1,即使指定 -O0 選項,也會進行優化,除非如前文所述,指定 -fwrapv 選項。

8.原文是“Remember, their main goal is to give you fast code that obeys the letter of the law”,參考這裡

結束語

在下才疏學淺,能力有限,文中難免有錯誤紕漏之處,如果您在閱讀的過程中發現了本文的錯誤和不足,請您務必指出。您的批評指正就是在下前進的不竭動力!

相關文章