摘要
本文中介绍了 Go 编译器的整体编译流程脉络和一个编译优化错误导致数据越界访问的 bug,并分析了对这个 bug 的排查和修复过程,希望能够借此让大家对 Go 编译器有更多的了解,在遇到类似问题时有排查思路。
缘起
某日,一位友人在群里招呼我,“看到有人给 Go 提了个编译器的 bug,挺有意思,感觉还挺严重的,要不要来看看?”于是我打开了 issue 40367[1] 。彼时,最新一条评论是 这条[2] :
提到将循环体中的一个常数从 1 改成 2 就无法复现问题,这顿时勾起了我的兴趣,于是我准备研究一番。
bug 代码跟现象如下图,正常来看,代码应该在输出 "5 6" 后停止,然而实际上却无限执行了下去,只能强行终止或等待程序触碰到无权限内存地址之后崩溃。
首先,我们要定位到这个问题具体的直接原因。简单来说,这个 bug 是 for-range loop 越界,原本循环应该在循环次数到达数组长度后终止,但是这个复现程序中的循环无限执行了下去。乍一看,问题像是有 bound check 被优化掉了,那么我们来实锤一下。有一个方便的网站,可以在线观察给定程序编译产出的汇编结果,我用 这个网站[3] 分别生成了原复现程序和将第六行的 +1 改为 +2 后不复现程序的汇编,供大家对比。抛开无关细节不提,可以很容易地看到前者的汇编相较于后者的确少了一次判断,导致循环无法终止,具体的位置是第二段代码的 105 行:
既然直接原因已经定位到了,那接下来我们就要想办法追进编译器来查看为什么汇编结果有问题了。对很多同学来说,追进编译器查问题的过程可能比较陌生,听起来就令人望而却步,那么我们如何来排查这个问题呢?
背景知识
在追踪这个具体问题之前,我们需要先了解一些相关知识背景。
Go 编译器的大体运行流程
想要追查 Go 编译器的问题,首先就需要了解 Go 编译器的大致运行流程。其实 Go 的编译器的实现中规中矩,相比于 GCC/Clang 等老牌编译器甚至有些简陋,许多优化并未实现。一个 Go 程序在生成汇编前的工作大概分为这几步:
- 语法解析。由于 Go 语言语法相当简单,所以 Go 编译器使用的是一个手写的 LALR (1) 解析器,这部分跟今天的 bug 无关,细节略过不提。
- 类型检查。Go 是强类型静态类型语言,在编译期会对赋值、函数调用等过程做类型检查,判断程序是否合法。另外,这个步骤会将一些 Go 自带的泛型函数变换成具体类型的函数调用,比方说 make 函数,在类型检查阶段会根据类型检查的结果变换成具体的 makeslice/makemap 等。这部分也跟今天的 bug 无关。
- 中间代码 (IR)生成。为方便做跨平台代码生成,也为方便做编译优化,现代编译器通常会将语法树变成一个中间代码表示