• 文章
  • 空指標解引用導致未定義行為
作者:
2015年2月16日

空指標解引用導致未定義行為

評分:3.8/5(60 票)
*****

最近,我無意中引發了一場大辯論,議題是當 P 是一個空指標時,在 C/C++ 中使用 &P->m_foo 表示式是否合法。程式設計師社群分成了兩大陣營。一方自信地宣稱這不合法,而另一方則同樣肯定地說這合法。雙方都給出了各種論據和連結,到某個時刻,我意識到我必須把事情說清楚。為此,我透過一個封閉的郵件列表聯絡了微軟 MVP 專家和 Visual C++ 微軟開發團隊。他們幫助我準備了這篇文章,現在歡迎所有感興趣的人閱讀。對於那些等不及想知道答案的人:那段程式碼是“不”正確的。

辯論歷史

這一切都始於一篇關於 PVS-Studio 分析器對 Linux 核心進行檢查的文章。但問題與檢查本身無關。關鍵在於,在那篇文章中,我引用了 Linux 程式碼中的以下片段:


1
2
3
4
5
6
7
8
9
10
static int podhd_try_init(struct usb_interface *interface,
        struct usb_line6_podhd *podhd)
{
  int err;
  struct usb_line6 *line6 = &podhd->line6;

  if ((interface == NULL) || (podhd == NULL))
    return -ENODEV;
  ....
}

我稱這段程式碼是危險的,因為我認為它會導致未定義行為

之後,我收到了大量的電子郵件和評論,讀者們反對我的這個觀點,我甚至差點就被他們有說服力的論點說服了。例如,為了證明那段程式碼是正確的,他們指出了 offsetof 宏的實現,通常是這樣的:


#define offsetof(st, m) ((size_t)(&((st *)0)->m))

這裡我們處理了空指標解引用,但程式碼仍然工作得很好。還有一些其他的郵件論證說,既然沒有透過空指標進行訪問,那就沒有問題。

儘管我容易輕信,但我還是會努力複核任何我可能懷疑的資訊。我開始研究這個主題,最終寫了一篇小文章:“關於空指標解引用問題的反思”。

一切都表明我是對的:不能那樣寫程式碼。但我沒能為我的結論提供有說服力的證據,也沒能引用標準中的相關摘錄。

發表那篇文章後,我再次收到了大量抗議郵件的轟炸,所以我認為我應該一勞永逸地把這一切搞清楚。我向語言專家提出了一個問題,以瞭解他們的意見。這篇文章是他們回答的總結。

關於 C 語言

當 'podhd' 是一個空指標時,'&podhd->line6' 表示式在 C 語言中是未定義行為。

C99 標準關於 '&' 取地址運算子的規定如下 (6.5.3.2 "Address and indirection operators"):

一元 & 運算子的運算元應該是一個函式指示符,一個 [] 或一元 * 運算子的結果,或者是一個指定了非位域且未使用 register 儲存類說明符宣告的物件的左值。

表示式 'podhd->line6' 顯然不是函式指示符,也不是 [] 或 * 運算子的結果。它“是”一個左值表示式。然而,當 'podhd' 指標為 NULL 時,該表示式並不指定一個物件,因為 6.3.2.3 "Pointers" 中說:

如果一個空指標常量被轉換成指標型別,得到的指標,稱為空指標,保證與任何指向物件或函式的指標比較結果為不相等。

當“一個左值在求值時未指定一個物件,其行為是未定義的” (C99 6.3.2.1 "Lvalues, arrays, and function designators")

左值是具有物件型別或除 void 之外的不完整型別的表示式;如果一個左值在求值時未指定一個物件,其行為是未定義的。

所以,簡而言之,是同樣的想法:

當 -> 運算子作用於該指標時,它求值為一個不存在物件的左值,結果是未定義行為。

關於 C++

在 C++ 語言中,情況完全相同。當 'podhd' 是一個空指標時,'&podhd->line6' 表示式是未定義行為。

我在前一篇文章中提到的 WG21 的討論(232. Is indirection through a null pointer undefined behavior?)帶來了一些困惑。參與討論的程式設計師堅持認為這個表示式不是未定義行為。然而,沒有人能在 C++ 標準中找到任何條款允許在 "poldh" 為空指標時使用 "poldh->line6"。

"polhd" 指標未能滿足基本約束 (5.2.5/4, 第二點),即它必須指定一個物件。沒有任何 C++ 物件的地址是 nullptr。

總結一下


struct usb_line6 *line6 = &podhd->line6;

當 podhd 指標等於 0 時,這段程式碼在 C 和 C++ 中都是不正確的。如果指標等於 0,就會發生未定義行為。

程式能正常執行純屬運氣。未定義行為可能以不同形式出現,包括程式完全按照程式設計師預期的方式執行。這只是未定義行為的一種特例,僅此而已。

你不能這樣寫程式碼。指標在解引用之前必須進行檢查。

補充想法和連結

  • 在考慮 'offsetof()' 運算子的慣用法實現時,必須考慮到編譯器實現被允許使用非可移植的技術來實現其功能。編譯器的庫實現使用空指標常量來實現 'offsetof()' 這一事實,並不意味著使用者程式碼在 'podhd' 是空指標時使用 '&podhd->line6' 就是可以的。
  • GCC 可以/確實會假設永遠不會發生未定義行為來進行最佳化,並且會移除這裡的空檢查——核心編譯時帶有一堆開關來告訴編譯器不要這樣做。作為一個例子,專家們引用了文章“每個C程式設計師都應瞭解的未定義行為 #2/3”。
  • 你可能還會發現有趣的是,一個類似的空指標使用涉及到了 TUN/TAP 驅動程式的一個核心漏洞。請參閱“空指標的樂趣”。一個可能讓一些人認為相似性不適用的主要區別是,在 TUN/TAP 驅動程式的 bug 中,空指標訪問的結構欄位被明確地作為值來初始化一個變數,而不僅僅是獲取該欄位的地址。然而,就標準 C 而言,透過空指標獲取欄位的地址仍然是未定義行為。
  • 有沒有在 P == nullptr 的情況下寫 &P->m_foo 是可以的?有,例如當它是 sizeof 運算子的引數時:sizeof(&P->m_foo)。

致謝

這篇文章的問世,得益於那些我毫無理由懷疑其能力的專家們。我要感謝以下人士幫助我撰寫本文:

  • Michael Burr 是一位 C/C++ 愛好者,專注於系統級和嵌入式軟體,包括 Windows 服務、網路和裝置驅動程式。他經常在 StackOverflow 社群回答關於 C 和 C++ 的問題(偶爾也回答一些比較簡單的 C# 問題)。他擁有 6 個 Visual C++ 領域的微軟 MVP 獎項。
  • Billy O'Neal 是一名(主要是)C++ 開發者,也是 StackOverflow 的貢獻者。他是微軟可信賴計算團隊的一名軟體開發工程師。他之前曾在多個安全相關的公司工作,包括 Malware Bytes 和 PreEmptive Solutions。
  • Giovanni Dicanio 是一名計算機程式設計師,專注於 Windows 作業系統開發。Giovanni 曾在義大利計算機雜誌上發表關於 C++、OpenGL 和其他程式設計主題的計算機程式設計文章。他也為一些開源專案貢獻了程式碼。Giovanni 喜歡在 Microsoft MSDN 論壇以及最近在 StackOverflow 上幫助人們解決 C 和 C++ 程式設計問題。他擁有 8 個 Visual C++ 領域的微軟 MVP 獎項。
  • Gabriel Dos Reis 是微軟的首席軟體開發工程師。他也是一名研究員和 C++ 社群的長期成員。他的研究興趣包括用於可靠軟體的程式設計工具。在加入微軟之前,他是德州農工大學的助理教授。Dos Reis 博士因其在可靠計算數學編譯器和教育活動方面的研究而獲得 2012 年美國國家科學基金會 CAREER 獎。他是 C++ 標準化委員會的成員。

參考文獻

  1. 維基百科。Undefined Behavior (未定義行為)。
  2. C 和 C++ 中的未定義行為指南。第 1 部分,第 2 部分,第 3 部分。
  3. 維基百科。offsetof
  4. LLVM 部落格。每個 C 程式設計師都應瞭解的未定義行為 #2/3
  5. LWN。空指標的樂趣。第 1 部分,第 2 部分。