GCC 的整体架构

147 篇文章 15 订阅

(本文写于 2021 年 5 月 15 日, 在 2023 年 1 月 23 日修改.)

我 21 年 5 月想尝试了解一下 GCC 的 c++ 编译器. 在 Contributing to GCC 中, 有介绍一个方案, 就是可以从给编译器 debug 入手. 通过解决一个个已知的问题, 逐步了解整个庞然大物. 本文试着介绍一下 GCC 的整体架构 (以 C++ 前端为例).

源码地址在

gcc.gnu.org Git - gcc.git/summary​gcc.gnu.org/git/gcc.git

在 github 上也有一个镜像.

GitHub - gcc-mirror/gcc​github.com/gcc-mirror/gcc正在上传…重新上传取消

我一般都用后者, 因为后者比较方便搜索/查找. 另一大主流的编译器 LLVM 已经把代码库和 bug tracker 全部搬到了 github 上面.

构建 GCC

考虑到大部分读者并不会亲自编译一个 gcc, 具体过程我写在了下面这篇. 每次我自己想编译了都会看看

孙孟越:编译一个测试版的 GCC8 赞同 · 4 评论文章

2023 年的我对 build system 有了更加深刻的认知, 在这里稍微介绍一下.

首先, 需要有个东西, 他去管理该编译哪些东西, 很多时候不需要编译每一个翻译单元. 这个系统的典型例子就是 GNU Make. 当你提供了一个 build script, 告诉他应该怎么正确的编译以后. 他就可以根据这个 build script 实现正确的增量编译方案.

但是这个 build script 从哪里来呢? 于是有了更进一步的抽象. 现代 C++ 中, 最广泛采用的就是 CMake. 典型应用就是 CMake 分析你告诉他的一些关系, 然后计算出 build script, 再交给像 GNU Make 这种工具去 build.

说回 GCC 这个项目, 它用的就是 GNU Make, 但由于它的诞生时代, 他用的是 Autoconf 输出 Makefile, 也就是 Autoconf + GNU Make. 像 LLVM 这种更现代的项目, 它用的就是 CMake 输出 Ninja build script. 默认用的是 CMake+Ninja 的组合.

编译原理

在介绍编译器之前, 我们必须介绍一下编译原理.

编译器的输入, 是一个比较长的字符串.

第一步, 是预处理和词法分析. 就是把一个个字符, 变成一个个 token. 比如把 return 这六个字符放在一起, 理解成为一个单词. 通常的设计下, 宏定义的展开也在这一步进行.

第二步, 是语法和语义分析, 比如 a * b 可以理解成 a 乘上 b , 但是 int * a 就得理解成 int 指针. 这一步的输入是一长串 token, 或者叫 token 流, 输出则是编译器的中间表示. 这一步对于 C++ 来说非常复杂, GCC 在这部分花上了十几万行的代码.

一般来说, 第一步和第二步就称为编译器前端了. 对于普通的教科书来说, 下一步直接就是生成汇编代码了. 但对于工业级别的编译器来说, 还有很多优化需要做, 这些优化, 比如像常量折叠, 对于不同的语言 (C/C++/ObjC/Fortran) 等等都能用, 所以后来大家抽象出来了一个编译器中端.

架构

第三步, 编译器中端使劲做优化, 这段和语言/目标架构是无关的. 大家会操作编译器的中间表示, 在 GCC 里叫做 GIMPLE, 在 LLVM 里则是 LLVM IR.

第四步, 优化完的中间表示, 被送到编译器后端. 后端对生成的代码, 也会做一些优化. 同时在这个地方可以分配寄存器了, 可以做一些平台相关的优化了, 比如读取相邻位置的 8 Bytes, 可能优化成一次读取 16 Bytes 的指令等等. 这里的操作和 target 有关了, 所以用的另外一种编译器的中间表示, 在 GCC 里叫 RTL (寄存器转移语言), 在 LLVM 里面则是 LLVM MIR (机器中间表示).

第五步, 将上一步的结果变成汇编代码.

给大家看看这些中间结果长啥样. GIMPLE 和更低级的 RTL

GIMPLE

RTL

再给大家看看对应的 LLVM IR 和 LLVM MIR 的样子.

LLVM IR

LLVM MIR

目录结构

主要介绍最核心的东西. 根目录下有很多文件夹. gcc/ 目录下 是编译器的核心文件. 根目录下还有很多 lib 开头的文件夹, 是编译器的部分运行时库, 还有各个语言的标准库. 其他文件夹有兴趣可以自行了解.

然后在 gcc/ 目录下 , 有各个语言的前端代码. 比如 gcc/c 就是 C 语言前端的代码. gcc/cp 就是 C++ 前端代码. 还有 go , fortran 等语言前端. 然后编译器中端和后端就放在 gcc/ 这个目录下面.

GCC 代码的耦合性非常高, 经常会出现一个模块代码超过万行的情况. 幸好每个开发者只需要对他自己需要开发的那部分熟悉就行了. 最开始的时候 GCC 是使用的 C 完成的. 那时候他们设计出了一套比较复杂的垃圾回收系统(见这里 ), 十多年前他们才用 C++ 进行重构, 但是垃圾回收还是被保留了. 毕竟对上百万行的东西, 手工去修改内存的管理方式, 而且老的方式还能跑的话, 咱就是说没必要哈.

相比之下, 现代化的 LLVM 在设计的时候, 就广泛使用 C++ 的模板和 RAII, 使用的是 C++ 的内存管理范式.

2023 年的 GCC12 需要一个 C++11 标准的编译器才能进行编译了, 而在 2022 年 1 月份的 commit 5c69acb, 也终于把文件名从 .c 统统改为 .cc 了, 处理了历史遗留的文件名问题 (不过内容一直是按照 C++ 进行编译的).

编译工序

整个程序的主入口在 gcc/gcc.cc 之中的 driver::main . 我们仅拿 C++ 的编译过程进行介绍.

首先必须要理解一个事情, 像 gcc 这样的应用程序, 一边会成为 compiler driver (编译器的驱动), 举一个典型例子, 他做的事情是:

  1. 调用 cc1plus 把 C++ 代码变成汇编 (.S 文件)
  2. 调用 as, 把汇编代码变成 ELF 格式的二进制 (.o 文件)
  3. 调用 ld, 把 ELF 格式的二进制 link 在一起, 变成 library 或者是 executable.

而 GCC 的 compiler driver 具体做的事情, 默认则是根据文件后缀名来确定的.

在 gcc/gcc.cc 之中 储存了默认的 compiler, 它会根据后缀名, 调用这个文件应该完成的工序.

所以作为一个 C 语言的 compiler driver, 当 gcc 看到一个 .cpp 文件的时候, 会按照 C++ 来编译, 按照 C 来链接, 这就出错了!

不过 g++ 则会暗中指定 .c 文件也是 C++, 所以把 C++ 写在 .c 文件但是用 g++ 编译不会错, 但是用 gcc 编译 (会认为是 C 文件) 可能会报语法错.

我为这个事情单独写了一篇文章, 可以参考

gcc和g++是什么关系?609 赞同 · 9 评论回答正在上传…重新上传取消

对于确定 "driver 应该调用什么" 这个事情, 市面上别家的编译器设计, 有的也是看后缀名, 有的会看一些配置文件. 具体可以参考这个编译器的文档.

第一步: 预处理及词法分析

这一步 C 系列语言有一部分是共用的, 具体涉及到 C/C++/ObjC 这些语言. 涉及到预处理的部分是通过调用 CPP (C PreProcesser) 处理的. 而词法分析的主要内容在 gcc/c-family/c-lex.cc 和 gcc/cp/lex.cc 之中.

一般的编译器原理的书上, 词法分析是很重要的部分. 但是实际上, 由于 C++ 语法规则很复杂, 而且手写的 lexer 可以更个性化的定制, 所以这里用的是手写的递归下降 lexer.

第二步: 语法和语义分析

在 GCC 内部, 表示一个 token 的结构是 tree . tree 和 C++ 有关的部分放在了 gcc/cp/tree.cc 之中. parser 的目标就是把 token stream 变成 AST (抽象语法树).

在一般的编译原理书上, 一般会介绍很多种文法. 但是对于像 C++ 这么复杂的语言, 大家的选择还是一致的, 就是递归下降. 像 vector<vector<int>> 这种东西, 这个 >> 是很难做到上下文无关的, 所以 C++ 98 不允许大家这样写, 需要把两个尖括号分开来, 直到 C++11 才允许. 除此之外, 递归下降还可以更个性化地定制, 代码更加 human friendly, 于是最后大家都选择递归下降了.

具体的话, parser 的代码在 gcc/cp/parser.cc , 目前已经有 5 万行了. 我觉得大家也没必要去阅读这些代码 (除非遇到编译器 bug), 其实都是些体力活.

而 parser 进一步还会调用不同的子模块进行语义处理, 比如下面这些. 这些函数按照具体功能分在了不同的翻译单元中. 举一些例子:

  1. 处理函数/变量的声明. 在文件 gcc/cp/decl.ccgcc/cp/decl2.cc 之中.
  2. 处理模板 (parameterized types). 在文件 gcc/cp/pt.cc 之中.
  3. 函数调用以及重载决议. 在文件 gcc/cp/call.cc 之中. 我也写过一篇关于函数重载的文章, 其机制非常复杂.
  4. 名字查找. 在文件 gcc/cp/name-lookup.cc 之中.
  5. 类的处理. 在文件 gcc/cp/class.cc 之中.
  6. 类型检验, 比如检查是不是完整类型. 在文件 gcc/cp/typeck.cc 之中.
  7. 语义检查, 比如不可使用 private 函数等. 在文件 gcc/cp/semantics.cc 之中.
  8. coroutine 功能. 在文件 gcc/cp/coroutine.cc 之中.
  9. module 功能. 在文件 gcc/cp/module.cc 之中.

上面只列举了一小部分, 完整的 C++ 前端大约在十几万行的代码量级. 基本上 C++ 的标准迭代都在这里反映出来. 除此之外, 这些模块之间也可能会互相调用, 代码的耦合性比较高.

整个编译器前端部分到此就结束了.

现在的编译器行业, 前端技术发展已经非常成熟了. 工作岗位一般就是给新的语言维护前端, 或者在原来的语言上魔改, 然后魔改前端. 因为这些语言仍然在进化, 所以编译器前端仍然是需要人手的, 但注定会是一个比较小众的行业.

第三步: 转换成中间表示 GIMPLE

下一步是把 AST 变成 GIMPLE, 这个过程叫 gimplify, 涉及到的源码在 gcc/cp/cp-gimplify.cc , gcc/c-family/c-gimplify.cc 和 gcc/gimplify.cc 中. 可以想象, 第一个文件是 C++ 前端独享, 第二个文件是 C/C++/ObjC 前端共享, 第三个文件是所有语言前端共享.

在这里会做那些耳熟能详的优化, 这里就举几个例子, 具体可以参考这里.

  • Dead code elimination 删掉不可访问的代码
  • Static profile estimation 静态分支预测
  • Dead store elimination 如果有连续两次 store 中间没有 read, 那就删掉第一次 store.
  • Loop optimization 循环优化
  • Vectorization 向量化
  • Return value optimization 局部变量返回值优化 (类似于 C++ 中的 NVRO)
  • Array prefetching 数组预取

GIMPLE 的结果可以拿出来看, 可以给编译器传递一个 -fdump-tree-all 的选项, 它就会 dump 出 GIMPLE(SSA) 结果. 而不同的优化选项, 比如 O1 O2 O3, 实际上不同的点就在于要做哪些优化. (SSA Passes 之中还会插入一些 IPA Passes, 用来优化控制流图.)

编译器的中端, 对于 CPU 架构, 目前可以认为比较成熟了. 换句话说, CPU 上能优化掉的东西, 已经发掘地差不多了. 但是, 对于异构计算, 则是还有很多研究和开发的空间. 比如 GPU 上的代码优化, 比如计算图的优化. 像现在比较活跃的就是 LLVM MLIR 项目.

第四/五步: 编译器后端

下一步, 就是从 GIMPLE 到 RTL 了. 这些部分我了解的比较少了.

这里涉及到一些, 像是寄存器分配、合并指令、去除死代码、去掉无意义的 jump、指令重排之类的 pass. 具体可以参考这里.

给编译器传递 -fdump-rtl-all 的选项可以打印出全体 RTL pass 的输出.

编译器后端岗位倒是很多人在做, 主要是很多新的芯片和架构, 这个很多厂商和科研机构很关心, 工作岗位不少.

行业展望

我们介绍了编译器的三段式架构, 介绍了一些行业状态.

编译器前端技术发展已经非常成熟了. 工作岗位一般就是给新的语言维护前端, 或者在原来的语言上魔改, 然后魔改前端. 因为这些语言仍然在进化, 所以编译器前端仍然是需要人手的, 但注定会是一个比较小众的行业.

编译器的中端, 对于 CPU 架构, 目前可以认为比较成熟了. 换句话说, CPU 上能优化掉的东西, 已经发掘地差不多了. 但是, 对于异构计算, 则是还有很多研究和开发的空间. 比如 GPU 上的代码优化, 比如计算图的优化. 像现在比较活跃的就是 MLIR 项目.

编译器后端岗位倒是很多人在做, 主要是很多新的芯片和架构, 这个很多厂商和科研机构很关心, 工作岗位不少.

拓展阅读

下面这本书挺老的了, 很多内容已经过时了. 不过市面上也没啥更好的选项了.

深入分析GCC​book.douban.com/subject/26984172/正在上传…重新上传取消

GCC 的整体架构 - 知乎 (zhihu.com)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值