使用C语言这么多年,这两行代码居然看不懂,难道是编译器Bug吗
对编译器、OS内核、虚拟化、性能优化等技术感兴趣的童鞋,不妨右上角关注一下吧,近期会持续更新相关专题文章!
引言
使用C语言也有超过十年的时间了,本以为对它已经非常熟悉了,谁料前几天帮同事调查一个bug,定位出来之后,居然又被C语言小小地“惊喜”地一把。代码如下图所示:
尝试了GCC和Clang,居然没有任何编译告警,并且能够正常运行。后来,也有童鞋帮忙测试了Visual Studio,也是一样的结果。
我发了个微头条《用了这么多年C语言,居然又被这几行代码毁了三观》,引来很多童鞋围观。有位童鞋看到这篇微头条后,也发了一篇文章讲这个问题。但我看了下,几乎没有什么增量信息,没有解释清楚。
在好奇心的驱动下,我仔细研究了最新的C语言标准C18,以及编译器生成的语法树、中间代码、以及最终的汇编代码,终于彻底搞清楚了这个问题。
你以前见过2[array]这种数组索引方式吗?会是编译器的bug吗?这个锅该不该编译器来背?
希望通过这篇文章,能把这个问题彻底解释清楚!
内容提要
本文中,我将从C语言最新的标准C18入手,首先讲解数组索引表达式在语法和语义上的定义,然后以clang为例,实例讲解编译器生成的AST(抽象语法树)和IR(中间表示)对a[2]和2[a]处理上的异同,并分析最终的汇编代码有什么差异。
同时,我还会以我自己开发的一个编译器为例,介绍在语法和语义上如何处理a[2]和2[a]。
我们先回顾一下C语言数组的基础知识。
C语言数组基础
我们知道,在C语言中,很多时候数组名是可以作为指针来使用的。比如下面这段代码:
int array[3] = {0, 10, 20};
在绝大多数情况下,我们可以把数组名array当做int型的指针来用。因此array[2]和*(array + 2)的值是完全一样的,都是20。
需要注意的是,虽然大多数情况下,数组名可以当做指针来使用,但并不意味着两者是完全等价的。有些场景下,函数名和指针还是要区别对待。如下面的代码:
指针p指向数组array的起始地址,下面这段代码是合法的:
但是下面这段代码就是非法的:
因为,数组名array必须指向该数组的起始地址,它的值不能改变。
回顾了数组的基础知识后,现在开始讲解关于数组索引a[2]和2[a]的问题。
要真正搞清楚这个问题,我们就不得不去看看C语言标准究竟如何定义的。
鉴于有些童鞋对编译原理不熟悉,为了避免懵圈,在看C语言标准之前,先简单介绍一些关于语言文法描述的基础知识。
文法描述基础
有关编译原理的东西都比较枯燥,为了避免有的童鞋没有耐心看下去,我就只简单介绍一些和我们所讨论的问题相关的几个概念。
如果有童鞋确实对编译原理很感兴趣,可以右上角关注我一下,我近期会持续更新相关专题文章。
Primary Expression
C语言中,如变量名、字符串、整数、浮点数等可以作为基本操作数的表达式,被称为Primary Expression,这些Primary Expression组合起来,再构成更复杂的表达式。
Postfix Expression
Postfix Expression,即后缀表达式,是指一些运算符总是跟在操作数后面的表达式。C语言中典型的后缀表达式有:
- 数组索引:a[b]
- 函数调用:a(b)
- 结构体字段访问:a->b、a.b
- 自增自减操作:a++、a--
Binary Expression
Binary Expression,即二元表达式,是指一些由两个操组数和一个操作符构成的表达式。
在C语言中,像“+”,“-”、“*”、“/”等这些需要两个操作数构成的表达式,称为二元表达式。
当然,既然有二元表达式,就一定有一元表达式和三元表达式,但跟我们所讨论的问题无关,不再赘述。
有了这些基本概念之后,接下来我们来看看C语言标准对数组究竟是如何定义的。
C18 标准
我特意找来了C语言最新的C18标准,我们来看看对数组索引的语法和语义是怎么定义的。
语法定义
我们前面讲了,数组索引属于后缀表达式。C18对后缀表达式的定义如下图所示:
C18标准对数组索引的语法定义是:posfix-expression[ expression ]
而posfix-expression又可以推导出primary-expression,也就是说,primary-expression本身也是属于一种特殊的posfix-expression。
C11对primary-expression的的定义如下图所示:
和我们刚才讲的一样,identifier(标识符,可以简单理解为一个变量名)和constant(常量,如整数)都属于primary-expression。
为了便于理解,稍微理一下:
- 所有的post-expression、binary-expression、primary-expression等都属于expression
- 数组索引属于postfix-expression的一种。
- posfix-expression对数组索引的语法定义是:posfix-expression[ expression ]
- primary-expression是一种特殊的posfix-expression
- identifier(标识符)和constant(常量)都属于primary-expression
也就是说,这几种表达式的范围关系是:
expression > posfix-expression > primary-expression > identifier/constant
如下图所示:
此外,C18标准对数组索引还有一条限制规则:
含义很明确,就是说,数组索引中涉及的两个表达式中,其中一个必须是指向某种类型的指针,而另一个必须是整数类型,其索引得到的值由指针所指向的类型来决定。
也就是说,对于a[b]这个表达式,C18标准要求a和b之中,必须一个是指针类型,另外一个是整数类型。但是,C18标准并没有强制规定a必须是指针,而b必须是整数。
到此,我们可以确定了,在a是某种类型的数组名的前提下,形如a[2]、2[a]这样的表达式,在语法上,是完全符合C18对数组索引规范的。
那么语义上呢?我们再来看一下C18是如何定义的。
语义规范
C18对数组索引的语义也做了定义:
主要是规定了通过数组索引所得到的值是数组中的哪个元素。简单来说就是,对于a[b]表达式,假设a指向数组起始地址,b是一个整数,那么a[b]和(*(a + b))是完全等价的,a[b]取得的是数组a的第b个元素(数组下标从0开始)。
可见,在语义上,C18标准只是规定了数组索引操作时,应该取得数组中的哪个元素,并未对a[2]和2[a]做区分处理。
因此,我们就可以理解为a[2]和2[a]在语义上也是完全等价的。
那么,编译器又是怎么处理a[2]和2[a]这两种表达式呢?有什么区别吗?下面我们以Clang为例来看一下。
Clang对a[2]和2[a]的处理
为了尽可能减少干扰,方便分析,我把程序再精简一下,如下图:
下面开始从clang生成的抽象语法树、中间表示以及最终的汇编代码来分析一下。
clang语法分析
用下面这个命令打印出AST(Abstract Syntax Tree,抽象语法树):
整个语法树比较大,我只截取了部分与我们所讨论的问题相关的几个节点,如下图所示:
上图中,红色框对应x = array[2],绿色框对应y = 2[array]
为了方便理解,我对这两个二元表达式的语法树简化一下,去掉无用信息,并且换一种表现方式。
x = array[2]对应下图:
y = 2[array]对应下图:
对比一下,可以看出,两棵树唯一的区别就在ArraySubscriptExpr(数组下标表达式)节点左右两个叶子的顺序不同。
我们再仔细观察一下,尽管clang对array[2]和2[array]生成ArraySubscriptExpr(数组下标表达式)时左右叶子节点顺序不同,但是clang其实已经识别出了array是数组,2是索引值。并且,clang已经在语法树中把array和2的类型标记出来了,具体见下图:
到这里,我们就可以确定了,不管是a[2]还是2[a],clang在进行语法分析时,已经识别出array是数组起始地址,2是数组索引值。
因此,在语法分析阶段,两者是完全等价的。唯一的区别就是生产的数组下标表达式的左右叶子节点顺序不同而已。
接下来,我们再看看clang的生成的中间代码。
clang中间表示
用下面的命令生成中间代码,或者叫中间表示:
clang的中间表示(IR)是一种SSA(Static Single Assignment,静态单一赋值)形式,SSA的主要特点是每个变量只被赋值一次。关于SSA就不展开讨论了,感兴趣的童鞋可以自行查阅资料。
下面我们来看一下clang生成的中间代码:
简单解释一下:
- 在clang IR中以%开头的表示临时变量
- 第13行 %6表示数组声明:int array[5]
- 第21行 对应数组索引:array[2]
- 第24行 对应数组索引:2[array]
从第21和24行可以看出,clang对array[2]和2[array]生成的中间代码是一模一样的,最终得到的值都是:
*((void *)array + 2*sizeof(int))
汇编代码
再看一下最终的生成的汇编代码:
图中关键代码都进行了标注,其中0x400560是数组array的起始地址,0x400568是数组array的第二个元素的地址,即&array[2]。
图中最下面红框标注的两条指令,分别对应于array[2]和2[array],可见,最终生成的代码也是一模一样的。
结论
到此为止,我们可以确定了,对于a[2]和2[a]这两种数组索引方式:
- C语言标准在语法和语义上完全支持。
- 编译器在语法和语义上对它们进行了完全的等价处理。
由此可见,这真不是编译器的锅!
到此,本该结束了,但出于好奇,我又用我自己开发的一个编译器试了一下。
我自己的编译器是如何处理的呢?
先稍微说明一下,虽说是编译器,但目前只是一个解释器,这是我自己的一个正在开发中的编译器项目。初衷是为了提高大型分布式环境下的开发效率,降低系统的开发难度。
目前已经支持了并行处理、状态机、消息通信、内置定时器等特性,并且支持大部分C语言语法。以后陆续会加入面向对象、异常处理、JIT、机器代码生成等更多的特性。
在实现C语言语法的支持时,我没有参考C语言的标准,完全是根据自己对C语言的理解,自定义的一套文法,尽量兼容常用的C语言语法。
由于当初我还没见过2[array]这样的数组索引方式,所以目前的实现当然是不支持这种方式的。不过,还是试一下,看看它到底能不能识别出来,就当是一个测试用例吧。
语法分析
先看下它生成的抽象语法树:
其实跟clang生成的抽象语法树有些相似,但不如clang记录的信息那么丰富。
稍微解释一下,x = array[2]和y = 2[array]分别被解析成了一颗二叉树。
x = array[2]对应的二叉树如下图所示:
y = 2[array]对应的语法树如下图所示:
两棵二叉树的唯一区别是右节点。可见,在语法上,确实是识别出了array[2]和2[array]这两种索引方式,否则也不可能生成抽象语法树。
但是语义上呢?
语义分析
我们直接来看下程序解释执行的结果:
果然,符合我的预期,在语义分析阶段发现了错误。因为在处理2[array]时,错误地把2当做数组地址,array当做索引值来处理,结果发现array不是整数类型,因此报错退出。
结语
使用C语言已经有十年时间了,但C语言还是能够时不时地带给我一点“小惊喜”。虽说,很多时候这些“小惊喜”不见得真的有什么实际作用,但是在研究这些小问题的过程中,确实也是比较有趣的,而且也有不少收获。
如果你能有耐心看到这里,说明你对这个问题还是有一定兴趣的。那么,也许我的其它几篇文章你也会感兴趣的:
你真的理解"Hello world"吗? 从编译链接到OS内核系列专题
精通C语言?短短20行经典C语言代码很多人看不明白,你来试一下吧
C语言改一行代码,性能提升数倍:实例演示cache对性能影响有多大
如果觉得有用的话,记得点赞!
对编译器、OS内核、虚拟化、性能优化等技术感兴趣的童鞋,欢迎右上角关注!