关于 C 语言编译流程 PCAL 的总结

GCC 编译器

参考:GCC 编译器介绍(转)

GCC 是 GNU 项目的编译器组件之一,也是 GNU 最具有代表性的作品。在 GCC 设计之初仅仅作为一个 C 语言的编译器,可是经过十多年的发展,GCC 已经不仅仅能支持 C 语言;它现在还支持 Ada、C++、Java、Objective-C、Pascal、COBOL,以及支持函数式编程和逻辑编程的 Mercury,等等。而 GCC 也不再单是 GNU C Compiler 的意思,而是 GNU Compiler Collection 也即是 GNU 编译器家族的意思了,目前已经成为 Linux 下最重要的编译工具之一。

用 GCC 编译程序生成可执行文件看起来似乎只通过编译一步就完成了,但事实上,使用 GCC 编译工具由 C 语言源程序生成可执行文件的过程并不单单是一个编译的过程,而要经过下面的四个过程,可总结为 PCAL

  • 预处理(Pre-Processing)cpp.c/.cpp.i 预处理后
  • 编译(Compiling)cc:→ .s 汇编代码
  • 汇编(Assembling)as:→ .o 机器代码
  • 链接(Linking)ld:→ a.out 可执行文件
1
2
3
4
5
# 例子
gcc -E hello.c -o hello.i
gcc -S hello.i -o hello.s
as hello.s -o hello.o 或 gcc -c hello.s -o hello.o
ld hello.o world.o -e main -o helloworld

在实际编译的时候,GCC 首先调用 cpp 命令进行预处理,主要实现对源代码编译前的预处理,比如将源代码中指定的头文件包含进来。接着调用 cc 命令进行编译,作为整个编译过程的一个中间步骤,该过程会将源代码翻译生成汇编代码。汇编过程是针对汇编语言的步骤,调用 as 命令进行工作,生成扩展名为 .o 的目标文件。当所有的目标文件都生成之后,GCC 就调用连接器 ld 来完成最后的关键性工作 —— 链接。

GCC 编译流程图

常用的编译选项

  • -c:这是 gcc 命令的常用选项。-c 选项告诉 GCC 仅把源程序编译为目标代码而不做链接工作,所以采用该选项的编译指令不会生成最终的可执行程序,而是生成一个与源程序文件名相同的以 .o 为后缀的目标文件。例如一个 Test.c 的源程序经过下面的编译之后会生成一个 Test.o 文件。
  • -S:使用该选项会生成一个后缀名为 .s 的汇编语言文件,但是同样不会生成可执行程序。
  • -e:该选项只对文件进行预处理,预处理的输出结果被送到标准输出(比如显示器)。
  • -v:在 Shell 的提示符号下键入 gcc -v,屏幕上就会显示出目前正在使用的 gcc 版本的信息。
  • -x language:强制编译器指定的语言编译器来编译某个源程序。
  • -O-O2:编译优化。
  • -o:生成的名字。
  • -l<DIR>:库依赖选项,指定库及头文件路径。在 Linux 下开发程序的时候,通常来讲都需要借助一个或多个函数库的支持才能够完成相应的功能。一般情况下,Linux 下的大多数函数都将头文件放到系统 /usr/include 目录下,而库文件则放到 /usr/lib 目录下。

常见的文件后缀

  • .cc:C++ 源程序
  • .cxx:C++ 源程序
  • .m:Objective-C 源程序
  • .i:预处理后的 C 文件
  • .ii:预处理后的 C++ 文件
  • .s:汇编语言源程序
  • .S:汇编语言源程序
  • .h:头文件,通常不出现在命令行上

详解 PCAL

参考:编译学习笔记系列

预处理 Pre-Processing

编译 Compiling

整个编译过程分成 编译前端编译后端,前端负责生产与机器无关的中间代码,后端负责生成与机器有关的目标代码

编译前端和编译后端

  • 词法分析(Lexical)

    源代码是由一个一个的字符组成,编译的第一步是将其中的字符序列使用扫描器(Scanner)分割成一系列单词或符号,在编译器中称为记号(Token)。

    词法分析产生的记号一般可以分成「关键字」,「标识符」,「字面量(数字,字符串等)」,「特殊符号(+,-,=)」。

    在计算机语言中,我们说的语法的不同,在编译系统中最直接的便是词法分析的方法不同导致的。

  • 语法分析(Grammar,如 LR、LL 分析)

    从词法分析过程中得到的 Token 序列,仅仅是简单的单词序列,并不能表达意义。语法分析这一过程,通过语法分析器(Grammar Parser)采用上下文无关语法分析手段,产生语法树。这个树是以表达式为节点的树。

  • 语义分析(Semantic)

    从语法分析过程中得到的语法树,仅仅是完成了语法层面的分析,无法了解这个语句是否真正有意义,是否合法。语义分析过程便是对表达式中的变量与类型进行判断,分析其是否语义不匹配(编译完成后变量名不存在,只有相应的地址信息)。

  • 中间代码(语言)生成(Intermediate Representation)

    中间语言介于源语言和目标语言之间。常用的中间语言有逆波兰表示、三元式、四元式和树表示等。

    将语义分析的步骤中得到的标识后的语法树(Commended Syntax Tree)通过源码级优化器(Source Code Optimizer)做优化,生成中间代码。

  • 目标代码生成与优化

    从上步骤中拿到的中间代码是与机器无关的,通过此步骤中代码生成器(Code Generator)生成与机器相关的目标代码(即汇编代码)。

注意:这里所涉及的是上面 PCAL 中的 Compiling 一步而已,后面还需要对目标代码(汇编代码)进行汇编(机器代码)并链接(可执行文件)。

汇编 Assembling

待整理!

汇编由下面 3 类指令组成:

  1. 汇编指令:如 mov、add 等,有着对应的机器码。
  2. 伪指令:没有对应的机器码,由编译器执行,计算机不执行。
  3. 其他符号:如 +、-、*、/ 等由编译器识别,没有对应的机器码。

机器码中,指令和数据,存在存储器中没有区别。CPU 可以把它看作指令 mov AX, BX,也可以看作数据 89D8H

寄存器

  • 运算器:进行信息的处理
  • 存储器(寄存器):进行信息存储
  • 控制器:控制各个器件进行各种操作

对于汇编程序来说,CPU 中主要的部件是寄存器。如 8086 CPU 有 14 个寄存器:

AX、BX、CX、DX、SI、DI、SP、BP、IP、CS、SS、DS、ES、PSW

其中:

  • AX、BX、CX、DX:用来存放一般数据,大小是 16 位即 2 个字节

    寄存器大小示意图

    8086 CPU 为了兼容上一代的 8 位 CPU,AX、BX、CX、DX 都可以分成两个独立的寄存器。如,AX 可分为 AH (AX 的高 8 位)和 AL(AX 的低 8 位)。

  • CS:代码段地址寄存器

  • DS:数据段寄存器(除了 CS、DS,还有 2 个段寄存器 SS、ES)

  • IP:指令指针寄存器,存放偏移地址

80386 的指针寄存器有基址寄存器 EBP,堆栈指针寄存器 ESP 和指令指针寄存器 EIP。只需要了解基址寄存器 EBP 和堆栈指针寄存器 ESP 即可。指令指针寄存器 EIP 总是指向下一条要执行的指令的地址,一般情况下无需修改 EIP。

EBP 称为基址寄存器,可作为通用寄存器用于存放操作数,常用来代替堆栈指针访问堆栈中的数据。

ESP 称为堆栈指针寄存器,不可作为通用寄存器使用,ESP 存放当前堆栈栈顶的地址,一般情况下,ESP 和 EBP 联合使用来访问函数中的参数和局部变量。

堆、栈及栈帧的组成

参考:程序的堆和栈及栈帧的组成

栈帧 表示程序的函数调用记录,而栈帧又是记录在栈上面,很明显栈上保持了 N 个栈帧的实体,那就可以说栈帧将栈分割成了 N 个记录块,但是这些记录块大小不固定,因为栈帧不仅保存诸如:函数入参、出参、返回地址和上一个栈帧的栈底指针等信息,还保存了函数内部的自动变量(甚至可以是动态分配内存)。因此,不是所有的栈帧的大小都相同。

栈帧相对于某个函数而言,就是该函数在栈中所占去的空间。

要深入理解函数及其和指针的结合应用,需要理解 程序栈。大部分的现代的块结构语言,比如 C 语言都用到了程序栈来支持函数执行。调用函数时,会创建函数的栈帧并将其推到程序栈上。函数返回时,其栈帧从程序栈上弹出。

程序栈存放栈帧(stack frame)。栈帧有时候也称为 活跃记录(activation record)或 活跃帧(activation frame)。

链接 Linking

符号表

1
2
3
4
5
6
7
8
9
10
11
12
//hello.c
#include <stdio.h>

int global_uninit_val;
int global_init_val = 123;
static int static_val 456;

int main() {
int a = 1;;
printf("hello world\n");
return 0;
}

编译器最后一步是将不同的目标文件结合到一起。在链接中,目标文件之间的相互拼合实际上是目标文件对地址的引用,具体到 C 语言,是 函数和变量 的引用。比如上面 hello.c 例子中,hello.c 中的 main 函数引用到了 stdio.h 中的 printf 函数。在 hello.c 生成目标文件时,调用 printf 的跳转暂时是无法知道具体的地址。在编译器将所有目标文件链接成执行文件时,将跳转 printf 的地址替换成真正 printf 实际地址。

在链接中,我们将函数和变量统一称为 符号(Symbol),函数名或者变量名就是符号。

正因为链接时符号作为各个目标文件的的链接的主要的依据,因此管理好目标文件的符号非常重要。在可执行文件中将符号统一交由 符号表(Symbol Table)进行管理。

在编译后,C 会将符号保存至符号表中,且符号是用于链接同一个函数或变量的唯一标志,也就是说相同的一个程序中不可以拥有两个相同函数的实现。

但这种方式导致了另外一个问题,一旦 C 程序变得庞大,函数或者全局变量的命名重名变得难以避免。当引用到其他的库时,需要时刻小心函数命名以防出现函数重名便需要非常的小心。这是C函数的一个历史包袱,为避免这种情况,一般的 C 函数库都加上特定的前缀进行区分。

但这种原始简单的区分方式只能暂时避免符号重名的情况,并不能根本的解决这问题。为解决这个问题,目前大部分新出的语言都提出了称为 命名空间 的方式用以解决这个问题,同样作为 C 语言的升级版 C++ 也通过支持命名空间(namespace)的方式解决符号冲突的问题。

我们知道 C++ 语言支持函数重载,也支持两个不同类中可以声明相同函数名的函数。这其实是通过符号修饰(name decoration)或称符号改编(name mangling)来实现的。

符号修饰

这种通过添加符号将函数、变量的符号进行修饰的过程称为 函数签名。函数签名包含:函数名、函数命名空间、类名、参数类型。

符号重名请看:C 语言全局变量初始化和符号重名的问题

函数的签名是指函数原型中除去返回值的部分,包括函数名、形参表和关键字 const(如果使用了的话)的信息。

静态链接

参考:编译、链接学习笔记(三)静态链接

将多个目标文件链接成一个可执行文件的过程称为 静态链接

静态链接的两个步骤
  1. 文件混合
    合并各个目标文件中相同的段,并且将所有目标文件的符号表中的符号统一放置到 全局的符号表 中。
    符号表合并

  2. 符号解析与重定位
    读取段中的数据,重定位信息,调整代码中变量和函数的地址,将外部符号的引用地址使用伪地址进行填充。

进程装载

参考:编译、链接学习笔记(四)进程装载

程序与进程的区别

程序是指计算机可执行文件在磁盘中的保存方式,他是一对预编译好的指令和数据的集合文件。而进程是计算机将程序按照一定规则运行的过程。两者相比程序是一个静态的概念,进程是一个动态的概念

虚拟地址空间

每个进程运行起来后,操作系统为其分配了进程独立拥有的 虚拟地址空间(Virtual Address Space)。这空间的大小是由计算机操作系统以及 CPU 的位数共同决定。CPU 的物理属性决定了空间的最大值,除此之外,操作系统也会决定其访问空间的权限等。

在操作系统来看,我们知道一个计算机的物理内存其实是有限的,而且操作系统层面上看还要支持多个进程的并发运行。操作系统并不会真的分配实际大小的内存空间,只是给进程一个假象,让进程看起来拥有非常大空间,实际情况是操作系统通过分配时间片运行进程并不断切换,让进程「看起来」拥有一段连续且非常大的内存空间。

如果从 C 程序的角度而言,最简单的可从指针的位数来看地址的空间大小。在 32 位机器下,指针的长度也为 4 个字节;在 64 位机器下,指针长度为 8 个字节。

进程虚拟地址空间都是在操作系统的操作之下,并非所有的地址空间都可以访问,只能使用由操作系统分配的地址。如访问非操作系统分配的内存空间,操作系统将会强制结束进程。Windows 下会遇到「进行因非法操作需要关闭」,Linux 下出现「Segmentation Fault」,Mac OS 下会发生「EXE_BAD_ACCESS」。

进程的建立

一个程序的执行一般伴随这个进程的建立。进程的建立包含如下三步:

  1. 创建独立的虚拟内存空间。

    操作系统为进程创建独立的虚拟内存空间,实际的操作是为进程分配一个页目录(Page Directory),以及为创建映射函数所需要的数据结构。

  2. 读取可执行文件头,建立虚拟空间与可执行文件的映射关系。

    当程序运行时,一旦发生缺页,则需要依赖一套映射规则,将可执行文件的页映射到虚拟内存。此步骤则是建立虚拟内存和可执行文件的映射规则。
    发生缺页

  3. 指令寄存器指向可执行文件的入口,进程开始执行。

    操作系统通过将指令寄存器指向可执行文件的入口,将控制器交由进程。

动态链接

参考:编译、链接学习笔记(五)动态链接动态链接

动态链接是指编译系统在链接阶段并不把目标文件和函数库文件链接在一起,而是等到程序在运行过程中需要使用时才链接函数库。

动态链接的优点
  • 共享:多个进程可以共用一个 DLL(动态链接库,Dynamic Linking Library),比较节省内存,从而可以减少文件的交换。

  • 部分装入:一个进程可以将多种操作分散在不同的 DLL 中实现,而只将当前操作的 DLL 装入内存。

  • 便于局部代码修改:即便于代码升级和代码重用;只要函数的接口参数(输入和输出)不变,则修改函数及其 DLL 时,无需对可执行文件重新编译或链接。

  • 便于适应运行环境:调用不同的 DLL,就可以适应多种使用环境并提供不同的功能。例如:不同的显示卡只需厂商为其提供特定的DLL,而操作系统和应用程序则不必修改。

动态链接的缺点
  • 增加了程序执行时的链接开销。

  • 程序由多个文件组成,因此增加了管理复杂度。

动态链接与静态链接

链接分为两种,一种是静态链接,另一种是动态链接。动态链接发生在程序运行时,其函数代码不出现在程序的可执行文件中;而静态链接发生在编译时,静态链接的函数代码实际被插入到程序的可执行文件中。

用于静态链接的函数通常保存在 OBJ 和 IJB 文件中。例如,编写一个由一些独立的编译单元(文件)组成的大型程序时,链接器在创建可执行文件时将每个模块 OBJ 文件结合起来,此时可执行文件将包含OBJ文件中的所有代码。

使用动态链接时,用于动态链接的函数代码保存在与程序的其他部分相独立的 DEF 文件中。用户的程序要包含链接 DLL 函数加载指令的少量代码,但并不将函数本身包括进来。
  
函数的可执行代码位于一个 DLL 中,该 DLL 包含一个或多个已被编译、链接并与使用它们的进程分开存储的函数。
  
目前主流的操作系统中,对于动态链接文件有不同的命名与扩展名:

  1. Linux:动态链接文件称为 动态共享对象(DSO,Dynamic Shared Object),以 .so 为扩展名。
  2. Windows:动态链接文件称为 动态链接库(DLL,Dynamic Linking Library),以 .dll 为扩展名。
  3. Mac OS:动态链接文件也称为 动态链接库(DLL,Dynamic Linking Library),以 .dylib 为扩展名。

参考

已在文中各标题下。