You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

275 lines
20 KiB
Markdown

2 years ago
# 不定期加餐5 | 借助实例探究C++编译器的内部机制
你好我是宫文学。欢迎来到编译原理实战课的加餐环节今天我们来探讨一下C++的编译器。
在前面的课程中我们已经一起解析了很多语言的编译器了但一直没有讨论C和C++的编译器。并不是因为它们不重要而是因为C语言家族的编译器实现起来要更复杂一些阅读代码的难度也更高一些会对初学者造成比较大的挑战。
不过没有解析C和C++语言的特性及其编译器的实现其实在我心里也多多少少有点遗憾因为C和C++是很经典的语言。至今为止,我们仍然有一些编程任务是很难用其他语言来代替的,比如,针对响应时间和内存访问量,需要做精确控制的高性能的服务端程序,以及一些系统级的编程任务,等等。
**C和C++有很多个编译器今天我们要研究的是Clang编译器**。其实它只是前端编译器而后端用的是LLVM。之所以选择Clang是因为它的模块划分更清晰更便于理解并且还可以跟课程里介绍过的LLVM后端工具串联起来学习。
另外因为C++语言的特性比较多编译器实现起来也比较复杂一些下手阅读编译器的源代码会让人觉得有点挑战。所以今天这一讲我的主要目的就是给你展示如何借助调试工具深入到Clang的内部去理解它的运行机制。
**我们会具体探究哪个特性呢我选择了C++的模板技术**。这个技术是很多人学习C++时感觉有困难的一个技术点。通过探究它在编译器中的实现过程你不仅会加深了解编译器是如何支持元编程的也能够加深对C++模板技术本身的了解。
那么下面我们就先来认识一下Clang这个前端。
## 认识Clang
Clang是LLVM的一个子项目它是C、C++和Objective-C的前端。在llvm.org的官方网站上你可以下载Clang+LLVM的源代码这次我用的是10.0.1版本。为了省事你可以下载带有全部子项目的代码这样就同时包含了LLVM和Clang。然后你可以参考官网的文档用Cmake编译一下。
我使用的命令如下,你可以参考:
```
cd llvm-project-10.0.1
#创建用于编译的目录
mkdir build
cd build
#生成用于编译的文件
cmake -DCMAKE_BUILD_TYPE=Debug -DLLVM_TARGETS_TO_BUILD="X86" -DLLVM_BUILD_EXAMPLES=ON ../llvm
#调用底层的build工具去执行具体的build
cmake --build .
```
**这里你要注意的地方是我为Cmake提供的一些变量的值**。我让Cmake只为x86架构生成代码这样可以大大降低编译工作量也减少了对磁盘空间的占用并且我是编译成了**debug的版本**这样的话我就可以用LLDB或其他调试工具来跟踪Clang编译C++代码的过程。
编译完毕以后你要把llvm-project-10.0.1 /build/bin目录加到PATH中以便在命令行使用Clang和LLVM的各种工具。你可以写一个简单的C++程序比如说foo.cpp然后就可以用“clang++ foo.cpp”来编译这个程序。
> 补充如果你像我一样是在macOS上编译C++程序并且使用了像iostream这样的标准库那么可能编译器会报找不到头文件的错误。这是我们经常会遇到的一个问题。
>  
> 这个时候你需要安装Xcode的命令行工具。甚至还要像我一样在.zshrc文件中设置两个环境变量
```
export CPLUS_INCLUDE_PATH="/Library/Developer/CommandLineTools/usr/include/c++/v1:$CPLUS_INCLUDE_PATH"
export SDKROOT="/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk"
```
到目前为止你就把Clang的环境配置好了。那回过头来你可以先去看看Clang的源代码结构。
你会看到,**Clang的源代码主要分为两个部分**:头文件(.h文件全部放在include目录下而.cpp文件则都放在了lib目录下。这两个目录下的子目录结构是一致的每个子目录代表了一个模块模块的划分还是很清晰的。比如
* AST目录包含了AST的数据结构以及对AST进行遍历处理的功能。
* Lex目录词法分析功能。
* Parse目录语法分析功能。
* Sema目录语义分析功能Sema是Sematic Analysis的缩写
接下来你可以进入这些目录去寻找一下词法分析、语法分析、语义分析等功能的实现。由于Clang的代码组织很清晰你可以很轻松地根据源代码的名称猜到它的功能从而找到语法分析等功能的具体实现。
现在,你可以先猜测一下,**Clang的词法分析和语法分析都是如何实现的呢**
如果你已经学过了第二个模块中几个编译器的实现可能就会猜测得非常准确因为你已经在Java编译器、Go的编译器、V8的编译器中多次见到了这种实现思路
* **词法分析:**手写的词法分析器,也就是用手工的方法构造有限自动机。
* **语法分析:**总体上,采用了手写的递归下降解析器;在表达式解析部分,采用的是运算符优先级解析器。
所以,针对词法分析和语法分析的内容,我们就不多展开了。
那么,**Clang的语义分析有什么特点呢**
通过前面课程的学习现在你已经知道语义分析首先要做的是建立符号表并做引用消解。C和C++在这方面的实现比较简单。简单在哪里呢?因为它**要求必须声明在前,使用在后**,这就让引用消解变得很简单。
而更现代一些的语言在声明和使用的顺序上可以更加自由比如Java类中方法中可以引用类成员变量和其他方法而被引用的成员变量和方法可以在该方法之后声明。这种情况对引用消解算法的要求就要更高一些。
然后现在你也知道在语义分析阶段编译器还要做类型检查、类型推导和其他很多的语义检查。这些方面Clang实现得也很清晰你可以去看它的StaticAnalysis模块。
最后在语义分析阶段Clang还会做一些更加复杂的工作比如C++的模板元编程机制。
我在[探究元编程](https://time.geekbang.org/column/article/282919)的那一讲中介绍过C++的模板机制它能有效地提高代码的复用能力。比如你可以实现一个树的容器类用来保存整型、浮点型等各种不同类型的数据并且它不会像Java的泛型那样浪费额外的存储空间。因为C++的模板机制,会根据不同的模板类型生成不同的代码。
**那么C++具体是如何实现这一套机制的呢**?接下来我就带你一起去深入了解一下,从而让你对模板元编程技术的理解也更加深入。
## 揭秘模板的实现机制
首先我们通过一个示例程序来观察一下Clang是如何编译模板程序的。假设你写了一个简单的函数min用来比较两个参数的大小并返回比较小的那个参数。
```
int min(float a, float b){
return a<b ? a : b;
}
```
你可以用clang++命令带上“-ast-dump”参数来编译这个示例程序并显示编译后产生的AST。
```
clang++ -Xclang -ast-dump min.cpp
```
下图中展示的是min函数对应的AST。你能发现AST节点的命名都很直观一下子就能看明白每个节点的含义。其中函数声明的节点是FunctionDecl也就是Function Declaration的缩写。
![](https://static001.geekbang.org/resource/image/50/a8/50e994b96ef6fbc57f8afd0969yy6ba8.jpg)
min函数是一个普通的函数只适用于参数为浮点型的情况。那么我们再增加一个使用模板的版本并且函数名称一样这样就可以支持用多种数据类型来比较大小比如整型、双精度型等。
```
template <typename T> T min(T a, T b){
return a<b ? a : b;
}
```
这时顶层的AST节点是FunctionTemplateDecl也就是函数模板声明。它有两个子节点一个是模板类型参数声明TemplateTypeParmDecl也就是尖括号里面的部分第二个子节点其实是一个普通的函数声明节点其AST的结构几乎跟普通的min函数版本是一样的。
![](https://static001.geekbang.org/resource/image/a6/51/a65d293573d52eyy6b69894dbda6f851.jpg)
这样通过查看AST你就能了解函数模板和普通函数的联系和区别了。接下来就要进入重点了**函数模板是如何变成一个具体的函数的?**
为此我们在main函数里调用一下min函数并传入两个整型的参数min(2,3)
```
int main(){
min(2,3);
}
```
这个时候我们再看一下它生成的AST就会发现函数模板声明之下增加了一个新的函数声明。这个函数的名称仍然是min但是参数类型具体化了是整型。
![](https://static001.geekbang.org/resource/image/f9/39/f9b26cbab65beca0eab565cdfcf93c39.jpg)
这说明当编译器发现有一个min(2,3)这样的函数调用的时候,就会根据参数的类型,在函数模板的基础上生成一个参数类型确定的函数,然后编译成目标代码。**这个过程叫做特化Specialization也就是从一般到具体的过程。**函数模板可以支持各种类型,而特化后的版本只针对某个具体的数据类型。
那么特化过程是怎样发生的呢我们目前只看到了ASTAST反映了编译的结果但它并没有揭示编译的过程。而只有搞清楚这个过程我们才能真正理解模板函数的编译机制。
**要揭示编译过程,最快的方法是用调试器来跟踪程序的执行过程。**最常用的调试器就是LLDB和GDB。这里我使用的是LLDB你可以参考我给出的命令来设置断点、调试程序。
![](https://static001.geekbang.org/resource/image/11/71/117d0c937bd8c0dacdf1f98f2b54d671.jpg)
> 小提示如果你像我一样是在macOS中运行LLDB可能会遇到报错信息即操作系统不让LLDB附加到被调试的程序上。这是出于安全上的考虑。你需要重启macOS并在启动时按住command-R键进入系统恢复界面然后在命令行窗口里输入“csrutil disable”来关闭这个安全选项。
不过在跟踪clang++执行的时候你会发现clang++只是一个壳真正的编译工作不是在这个可执行文件里完成的。实际上clang++启动了一个子进程来完成编译工作这个子进程执行的是clang-10。所以你需要另外启动一个LLDB来调试新启动的进程。
![](https://static001.geekbang.org/resource/image/03/27/03b9199c070ea23955493f331bcc8927.jpg)
在使用LLDB的时候你会发现确定好在什么位置上设置断点是特别重要的这能大大节省单步跟踪所花费的时间。
**那么现在,我们想要探究函数模板是什么时候被特化的,应该在哪里设置断点呢?**
在研究前面示例程序的AST的时候我们发现编译器会在函数特化的时候创建一棵新的函数声明的子树这就需要建立一个新的FunctionDecl节点。因此我们可以监控FunctionDecl的构建函数都是什么时候被调用的就可以快速得到整个调用过程。
那怎么查看调用过程呢当clang-10在FunctionDecl断点停下以后你可以用“bt”命令打印出调用栈。我把这个调用栈整理了一下并加了注释你可以很容易看清楚编译器的运行过程
![](https://static001.geekbang.org/resource/image/bb/1b/bb0afc7e0e9ab5d4b2021d3cc0b6301b.jpg "图1模板函数特化时的调用栈")
接着,分析这个调用栈,你会发现其主要的处理过程是这样的:
* 第一语法分析器在解析表达式“min(2,3)”的时候会去做引用消解弄清楚这个min()函数是在哪里定义的。在这里,你又一次看到语法分析和语义分析交错起来的情况。在这个点上,编译器并没有做完所有的语法分析工作,但是语义分析的功能会被按需调用。
* 第二,由于函数允许重载,所以编译器会在所有可能的重载函数中,去匹配参数类型正确的那个。
* 第三编译器没有找到与参数类型相匹配的普通函数于是就去函数模板中找结果找到了以T作为类型参数的函数模板。
* 第四根据min(2,3)中参数的类型对函数模板的类型参数进行推导结果推导出T应该是整型。这里你要注意min(2,3)的第一个参数和第二个参数的类型需要是一样的,这样才能推导出正确的模板参数。如果一个是整型,一个是浮点型,那么类型推导就会失败。
* 最后,把推导出来的类型,也就是整型,去替换函数模板中的类型参数,就得到了一个新的函数定义。不过在这里,编译器只生成了函数声明的节点,缺少函数体,是个空壳子。
注意,这里最后一句的说法只是目前我自己的判断,所以我们要来验证一下。
Clang在重要的数据结构中都有dump()函数AST节点也有这个函数。因此你可以在LLDB中调用dump()函数来显示一棵AST子树的信息。
```
(lldb) expr Function->dump()
```
这个时候在父进程的LLDB窗口中会显示出被dump出的信息输出格式跟我们在编译的时候使用-ast-dump参数显示的AST是一样的。从输出的信息中你会看到当前的函数声明是缺少函数体的。
![](https://static001.geekbang.org/resource/image/12/72/12b70df96e84a6f55a0fdbb0cdd6d372.jpg)
**那么,函数体是什么时候被添加进来的呢?**这个也不难,你仍然可以用调试器来找到答案。
从前面函数模板的AST中你已经知道函数体中包含了一个ConditionalOperator节点。所以我们可以故技重施在ConditionalOperator()上设置断点来等着。因为编译器要实例化函数体就一定会新创建一个ConditionalOperator节点。
事实证明,这个策略是成功的。程序会按照你的预期在这个断点停下,然后你会得到下面的调用栈:
![](https://static001.geekbang.org/resource/image/4c/a5/4c86365e55460021fc40b5d005c41aa5.jpg "图2创建函数体的过程")
研究这个调用栈,你会得到两个信息:
1. 从函数模板实例化出具体的函数是被延后执行的程序是在即将解析完毕AST之后才去执行这项任务的。
2. Clang使用了TreeTransform这样的工具类自顶向下地遍历一棵子树来完成对AST的变换。
这样经过上述处理以后函数的特化才算最终完成。这个时候你再dump一下这个函数声明节点的信息就会发现它已经是一个完整的函数声明了。
好了到此为止你就知道了Clang对函数模板的处理过程。我再给你强调一下其中的关键步骤你需要好好掌握
* 在处理函数调用时,要去消解函数的引用,找到这个函数的定义;
* 如果有多个重载的函数,需要找到参数类型匹配的那个;
* 如果找不到符合条件的普通函数,那就去找函数模板;
* 找到函数模板后,推导出模板参数,也就是正确的数据类型;
* 之后,根据推导出的模板参数来生成一个具体的函数声明。
**其中的关键点,是特化的过程。编译器总是要把模板做特化处理,然后才能被程序的其他部分使用。**
抓住了这个关键点你还可以进一步在大脑中推演一下编译器是如何处理类模板的。然后你可以通过打印AST和跟踪执行这两个技术手段来验证你的想法。
不过,模板技术可不仅仅能够支持函数模板和类模板,它还有很多其他的能力。比如,在[第36讲](https://time.geekbang.org/column/article/282919)我介绍元编程的时候,曾经举过一个计算阶乘的例子。在那个例子中,模板参数不是类型,而是一个整数,这样程序就可以在编译期实现对阶乘值的计算。
好了,现在你已经知道,**对于类型参数,编译器的主要工作是进行类型推导和特化**。
**那么针对非类型参数,编译器是如何处理的呢?**如何完成编译期的计算功能的呢?接下来,我们就一起来分析一下。
## 使用非类型模板参数
首先,你可以看看我新提供的这个示例程序,这个程序同样使用了模板技术,来计算阶乘值。
```
template<int n>
struct Fact {
static const int value = n*Fact<n-1>::value; //递归计算
};
template<>
struct Fact<1> {
static const int value =1; //当参数为1时阶乘值是1
};
int main(){
int a = Fact<3>::value; //在编译期就计算出阶乘值
}
```
在Fact这个结构体中value是一个静态的常量。在运行时你可以用Fact<3>::value这样的表达式直接使用一个阶乘值不需要进行计算。而这个值其实是在编译期计算出来的。
**那编译期具体的计算过程是怎样的呢?**你可以像我们在前面研究函数模板那样如法炮制,马上就能探究清楚。
比如你可以先看一下示例程序在编译过程中形成的AST我在其中做了一些标注方便你理解
![](https://static001.geekbang.org/resource/image/7a/d6/7a1884bd2b01ccf65c42e80acc55ecd6.jpg)![](https://static001.geekbang.org/resource/image/66/dd/66376b3d937aeba74e7bf33b6ba94fdd.jpg)
可以看到在AST中首先声明了一个结构体的模板其AST节点的类型是ClassTemplateDecl。
接着是针对这个模板做的特化。由于在main函数中引用了Fact<3>::value所以编译器必须把Fact<3>特化。特化的结果是生成了一棵ClassTemplateSpecializationDecl子树此时模板参数为3。而这个特化版本又引用了Fact<2>::value。
那么编译器需要再把Fact<2>特化。进一步这个特化版本又引用了Fact<1>::value。
而Fact<1>这个特化版本在程序中就已经提供了它的value字段的值是常数1。
那么经过这个分析过程Fact<3>的值就可以递归地计算出来了。如果`Fact<n>`中n的值更大那计算过程也是一样的。
```
Fact<3>::value = 3 * Fact<2>::value
= 3 * 2 * Fact<1>::value
= 3 * 2 * 1
```
另外你还可以用这节课中学到的debug方法跟踪一下上述过程验证一下你的想法。在这个过程中你仍然要注意设置最合适的断点。
## 课程小结
今天我们一起探讨了C++的模板机制的部分功能并借此了解了Clang编译C++程序的机制。通过这节课你会发现编译器是通过特化的机制来生成新的AST子树也就是生成新的程序从而支持模板机制的。另外你还要明确特化的过程是递归的直到不再有特化任务为止。
模板功能是一个比较复杂的功能。而你发现,当你有能力进到编译器的内部时,你会更快、更深刻地掌握模板功能的实质。这也是编译原理知识对于学习编程的帮助。
探究C++的编译器是一项有点挑战的工作。所以在这节课里我更关注的是如何带你突破障碍掌握探究Clang编译器的方法。这节课我只带你涉及了Clang编译器一个方面的功能你可以用这节课教给你的方法继续去探究你关心的其他特性是如何实现的可能会有很多惊喜的发现呢
## 一课一思
在计算阶乘的示例程序中当n是正整数时都是能够正常编译的。而当n是0或者负数时是不能正常编译的。你能否探究一下编译器是如何发现和处理这种类型的编译错误的呢
欢迎在留言区分享你的发现。如果你使用这节课的方法探究了C++编译器的其他特性,也欢迎你分享出来。