簡體   English   中英

功能已優化為'gcc -O2'的無限循環

[英]Function optimized to infinite loop at 'gcc -O2'

語境
我的一位朋友問我以下難題:

void fn(void)
{
  /* write something after this comment so that the program output is 10 */
  /* write something before this comment */
}

int main()
{
  int i = 5;
  fn();
  printf("%d\n", i);
  return 0;
}

我知道可以有多種解決方案,有些涉及宏,有些假設有關實現並違反C。

我感興趣的一種特定解決方案是對堆棧進行某些假設並編寫以下代碼:(我知道這是未定義的行為,但在許多實現上都可以按預期工作)

void fn(void)
{
  /* write something after this comment so that the program output is 10 */
  int a[1] = {0};
  int j = 0;
  while(a[j] != 5) ++j;  /* Search stack until you find 5 */
  a[j] = 10;             /* Overwrite it with 10 */
  /* write something before this comment */
}

問題
該程序在MSVC和gcc中無需優化即可正常運行。 但是當我用gcc -O2標志編譯它或嘗試使用ideone時 ,它在函數fn無限循環。

我的觀察
當我用gcc -S vs gcc -S -O2編譯文件並進行比較時,它清楚地表明gcc在函數fn保持了無限循環。


我了解,因為代碼調用了未定義的行為,所以不能稱其為錯誤。 但是,為什么以及如何編譯器分析行為並在O2處留下無限循環?


如果將某些變量更改為volatile,許多人表示要了解行為。 預期結果是:

  • 如果ij更改為volatile ,則程序行為保持不變。
  • 如果將數組avolatile ,則程序不會遭受無限循環。
  • 此外,如果我應用以下補丁
-  int a[1] = {0};
+  int aa[1] = {0};
+  int *a = aa;

程序行為保持不變(無限循環)

如果使用gcc -O2 -fdump-tree-optimized編譯代碼, gcc -O2 -fdump-tree-optimized得到以下中間文件:

;; Function fn (fn) (executed once)

Removing basic block 3
fn ()
{
<bb 2>:

<bb 3>:
  goto <bb 3>;

}



;; Function main (main) (executed once)

main ()
{
<bb 2>:
  fn ();

}
Invalid sum of incoming frequencies 0, should be 10000

這將驗證在以下答案之后做出的斷言。

這是未定義的行為,因此編譯器實際上可以執行任何操作,我們可以在GCC 4.8之前的Breaks Broken SPEC 2006 Benchmarks中找到一個類似的示例,其中gcc采取具有未定義行為的循環並將其優化為:

L2:
    jmp .L2

文章說( 重點是我的 ):

當然,這是一個無限循環。 由於SATD()無條件執行未定義的行為(這是3型函數),因此對於正確的C編譯器任何轉換(或根本沒有轉換)都是完全可以接受的行為 未定義的行為是在退出循環之前訪問d [16]。 在C99中,創建指向數組末尾一個位置的元素的指針是合法的,但是不得取消引用該指針 同樣,不得訪問數組末尾的一個數組元素。

如果我們使用Godbolt檢查您的程序, 將會看到:

fn:
.L2:
    jmp .L2

優化器使用的邏輯可能類似於以下內容:

  • 所有的元素a初始化為零
  • a永遠不會在循環之前或循環內進行修改
  • 因此a[j] != 5始終為true->無限循環
  • 由於無窮大, a[j] = 10; 是不可訪問的,因此可以進行優化, aj也可以,因為不再需要它們來確定循環條件。

這與以下文章中的情況類似:

int d[16];

分析以下循環:

for (dd=d[k=0]; k<16; dd=d[++k]) 

像這樣:

一旦看到d [++ k],就可以假定k的增量值在數組范圍內,因為否則會發生未定義的行為。 對於此處的代碼,GCC可以推斷出k在0..15范圍內。 稍后,當GCC看到k <16時,它自言自語:“啊哈-這個表達式總是正確的,所以我們有一個無限循環。”

也許有趣的第二點是,是否將無限循環視為可觀察到的行為( 即按規則 ),這影響了是否也可以優化無限循環。 C編譯器反駁Fermat的最后定理可以看出,在C11之前至少存在一些解釋的空間:

許多有知識的人(包括我在內)都讀這句話,說不得更改程序的終止行為。 顯然,有些編譯器作者不同意,或者不認為這很重要。 理性的人不同意這種解釋的事實似乎表明C標准是有缺陷的。

C11在第6.8.5節“ 迭代語句”中添加了說明,並且在此答案中進行了更詳細的介紹。

在優化版本中,編譯器已經決定了一些事項:

  1. 在測試之前,數組a不會改變。
  2. 數組a不包含5

因此,我們可以將代碼重寫為:

void fn(void) {
  int a[1] = {0};
  int j = 0;
  while(true) ++j;
  a[j] = 10;
}

現在,我們可以做出進一步的決定:

  1. while循環之后的所有代碼均為無效代碼(無法訪問)。
  2. j已寫入但從未讀取。 這樣我們就可以擺脫它。
  3. a從未讀過。

此時,您的代碼已減少為:

void fn(void) {
  int a[1] = {0};
  while(true);
}

我們可以記下a現在不再被讀取,因此我們也將其刪除:

void fn(void) {
  while(true);
}

現在,未優化的代碼:

在未優化的生成代碼中,陣列將保留在內存中。 您實際上將在運行時遍歷它。 一旦您經過數組的末尾,它后面可能會有一個5可讀性。

這就是為什么未優化的版本有時不會崩潰和燃燒的原因。

如果循環確實被優化成無限循環,則可能是由於靜態代碼分析看到您的數組是

  1. 不易volatile

  2. 僅包含0

  3. 永遠不會被寫入

因此不可能包含數字5 這意味着無限循環。

即使沒有做到這一點,您的方法也很容易失敗。 例如,某些編譯器有可能在不使循環變為無限的情況下優化代碼,但會將i的內容填充到寄存器中,從而使其無法從堆棧中使用。

作為附帶說明,我敢打賭,您的朋友實際期望的是:

void fn(void)
{
  /* write something after this comment so that the program output is 10 */
  printf("10\n"); /* Output 10 */
  while(1); /* Endless loop, function won't return, i won't be output */
  /* write something before this comment */
}

或這個(如果包括stdlib.h ):

void fn(void)
{
  /* write something after this comment so that the program output is 10 */
  printf("10\n"); /* Output 10 */
  exit(0); /* Exit gracefully */
  /* write something before this comment */
}

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM