• 文章
  • 空指针解引用导致未定义行为
作者:
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 部分。