源码编译构建-谈谈C/C++编译的这些事

一个有点令人震惊的事实:C 编译器是用 C 编写的。

- 编辑

广义上的编译/构建是对用源语言编译的一个或多个程序文件进行解析转换,并结合共享库等资源,生成可以在目标机器上运行的可执行文件的过程。 本书无意讨论编译器的详细工作原理,这是《编译器原理》课程的内容。 在本章中,我们从用户的角度简要讨论编译和构建的过程概述。

本文引用自作者整理的以下书籍; 本文允许出于个人学习、教学等目的引用、教学或转载,但必须注明原作者“海洋巧克力哥”; 本文不允许复制或以纸质和电子出版物改编为目的的复制。

1.《Python编程基础与应用》,陈波,刘慧君,高等教育出版社。免费讲座视频

2.《Python编程基础与应用实验教程》,陈波,熊新智,张全和,刘慧君,赵恒君,高等教育出版社

3.《简明C与C++语言教程》,陈波,手稿待出版。免费讲座视频

12.1 gcc编译示例

考虑到Linux操作系统和gcc编译器(GNU Compiler Collection)在业界的基本地位,本章的讨论基于Linux操作系统和gcc编译器。 事实上,读者也可以在Windows操作系统上使用mingw或其他编译器来完成类似的任务。 详细流程请扫描二维码了解。

在Kiwi Pi 4B卡电脑(gcc版本10.2.1,Linux内核版本5.15)上,作者在/home/pi/C12_Build目录下准备了三个源代码文件,如图12-1所示。

图12-1 示例编译源代码文件

其中,compute.h文件内容如下:

compute.c文件的内容如下,请注意,作者故意在第1行和第2行重复引入compute.h头文件。

文件area.c内容如下:

源码编译构建_源码编译安装的步骤_源码编译啥意思

程序结构非常简单:函数circleArea()根据圆的直径估计圆的面积,它的声明位于compute.h中,定义在compute.c中; 文件area.c中的main()函数调用circleArea()来估计直径为4.1中圆的面积,然后通过printf()复制到控制台。

图12-2 使用gcc编译示例程序并在终端中运行

我们在Linux终端(terminal)中执行了表12-1所列的多条命令,完成了上述三个源代码文件的编译,并成功执行了名为area的可执行目标文件(executableobjectfile),具体见图12-2。 图中,pi是操作系统用户名,MVE是计算机名。

图12-3显示了上述示例程序的编译/构建过程的详细步骤。 如图所示,建立过程至少包括预处理、编译、汇编链接四个阶段。 这里的编译是狭义的编译,对应整个广义编译过程的第二阶段:解析预处理后的源程序文件源码编译构建,生成汇编语言代码文件。

图12-3 示例程序的编译/创建过程

与大多数编译器一样,C/C++ 也使用单独编译技术。 每个.c或.cpp源代码文件被视为一个编译单元(compilation unit),编译器将程序中的所有编译单元一一进行预处理、编译和汇编,生成独立的可重定位目标文件(relocatableobjectfile),然后由链接器组合并链接成一个完整的可执行目标文件(executableobjectfile)。

12.2 预处理

预处理器主要完成以下任务:

(1)替换或扩展宏定义(#define);

(2) 处理所有条件预处理指令,如#ifdef、#ifndef、#if、#elif、#else、#endif等。

(3) 处理#include预处理指令,将包含的头文件内容展开,插入到对应的文件中。

(4) 删除所有注释,包括/**/和//。

(5) 添加行号和文件名标记,该信息用于在软件调试时定位与错误或警告相关的代码行。

在图12-2所示的编译过程中,我们没有观察到预处理结果文件compute.i和area.i。 默认情况下,gcc 编译器不会生成这些文件。

通过执行表7-2中描述的终端命令之一,可以使用预处理器对compute.c进行预处理,从而得到结果文件compute.i。

与源代码文件一样,预处理器的输出文件compute.i是一个文本文件,其内容如下(删除了一些空行):

第1~8行,第11行:标记了一些行号和文件名。

第 10 行:circleArea() 函数的声明。 该声明来自头文件compute.h的第6行。 由于条件预处理指令#ifndef和#endif的“保护”,虽然我们“刻意”两次#include了compute.c中的头文件compute.h,但并没有导致circleArea()函数的重复声明。 当预处理器第一次将compute.h“包含”在compute.c中时,名为_COMPUTE_H的宏仍然是未定义的,预处理器正常扩展并插入compute.h的内容; 在第二个预处理器第一次“包含”compute.h时,发现已经定义了_COMPUTE_H宏,并没有创建#ifndef_COMPUTE_H预处理条件语句源码编译构建,相关内容被忽略。

第 14 行:对应于compute.c 的第5 行。 提醒读者注意这行代码中的三个变化:

(1)原代码第5行注释被删除;

(2)根据compute.h中的宏定义,将原来的PI宏替换为3.1415926;

(3) 根据compute.h中的宏定义,将原来的SQUARE(r)宏扩展为r*r。

12.3 编译

编译器(compiler)对预处理器输出的扩展名为.i的文本文件进行解析转换,得到扩展名为.s的文本文件,其内容为与平台相关的汇编语言代码。

编译是整个程序创建过程中最复杂的阶段,可分为5个步骤:词法分析、语法分析、语义分析、中间代码生成和优化、目标代码生成和优化。

另外,编译器默认不会生成 .s 中间文件。 通过执行以下终端命令将compute.i编译为compute.s。 -S选项要求gcc才编译,-o选项用于指定输出文件名。

linux>gcc-Scompute.i-ocompute.s

本例中,汇编语言文件compute.s的内容如下。

与C语言等中间语言相比,汇编语言是一种所谓的低级语言,读者可以将其视为机器指令的助记符。 特定的汇编语言和特定的机器语言指令集之间存在一一对应的关系,因此,上述汇编代码是平台相关的。 在这种情况下,它们基于armv8架构而不是intel的x86架构。

读者可能没有学过汇编语言或计算机指令集,但这并不妨碍我们对上述代码的功能做出合理的“猜测”。

第1行:arch是platform Architecture(架构)的缩写,armv8是ARM发布的8版本指令集。 这些指令集广泛应用于联通终端和嵌入式设备中。

第5行:表示circleArea名称是全局的,即该名称可以被其他目标文件引用。

第 6 行:circleArea 名称代表一个函数。

第10行:添加sp寄存器,sub是substract的缩写,sp是堆栈指针(stackpointer)寄存器。 根据本书第8.1节,这行代码是为cirleArea()函数分配堆栈空间,用于存储局部变量和参数等信息。

第17行和第20行:两条加法指令,对应.i代码中的3.1415926*r*r。 浮点数(float)的加法(乘法)用fmul表示。

第24行:减去栈顶的指针寄存器sp。 根据本书第8.1节,这行代码在函数返回之前释放了堆栈空间。 当分配栈空间时,栈顶指针减小,当空间释放时,栈顶指针变大,这说明栈确实是从高低地址向低地址增长的地址。

第26行:函数执行完毕后,返回调用点,ret为子程序的返回指令。

12.4 装配

汇编器(assembler)负责将编译器输出的.s汇编语言程序翻译成可重定位目标文件(relocatableobjectfile),扩展名为.o,是一个包含机器语言指令的二补码文件。

汇编过程比较简单,因为特定平台的汇编指令与机器指令之间存在一一对应的关系。

在Linux终端中,执行以下命令之一将compute.s编译为compute.o。 其中,-c选项要求gcc只进行汇编。

linux>ascompute.s-ocompute.o

linux>gcc-ccompute.s-ocompute.o

由于compute.o是一个包含机器指令的二进制补码文件,因此我们很难在这里显示其内容。 可重定位目标文件在不同平台上具有不同的格式。 在Linux终端上,可以使用objdump命令查看其反汇编代码:

执行结果显示compute.o的文件格式为elf64-littleaarch64,其中elf是ExecutableandLinkableFormat(可执行和可链接格式)的缩写,在Linux和Unix系统中广泛使用。 elf文件由很多节(section)组成,其中.text节包含了目标文件的机器指令。

12.5 链接

链接是将各个目标代码和数据段收集并组合成单个可执行目标文件的过程。 在操作系统的帮助下,可执行目标文件可以加载(复制)到视频内存中并运行。 广义链接可以发生在编译时(compiletime)、加载时(loadtime)甚至运行时(runtime),这里我们首先讨论编译时链接。

在这个例子中,如图12-3所示,compute.c和area.c经过预处理、编译和汇编后,我们得到两个可重定位目标文件compute.o和area.o。 通过执行以下终端命令,我们可以将compute.o和area.o链接到单个可执行目标文件区域。

linux>gccarea.ocompute.o-oarea

链接允许单独编译。 在分布式编译的前提下,我们可以将一个小程序分散到多个源文件中,这些源文件可以独立更改和编译。 当我们重建整个应用程序时,集成开发环境或构建工具会检查每个源文件的时间戳,只编译这些自上次编译以来再次发生更改的源文件,然后重新编译所有可重现的文件。 找到要链接的目标文件,以防止不必要的重新编译。

链接器主要完成两个任务:

(1)符号解析(symbolresolution)。 在目标文件中,符号与函数或全局变量相关联。 文件A可能引用文件B中的某个符号。所谓符号分析的目的是在所有链接的目标文件中找到该符号的“唯一”定义。 就这个例子而言,compute.c包含了circleArea()函数的定义,circleArea作为符号存在于compute.o中; area.c 调用函数circleArea(),而area.o 包含对符号circleArea 的引用。 链接器需要在所有链接的目标文件中找到符号circleArea,并将其实体包含在最终的可执行目标文件中,以便main()函数可以调用circleArea()。

在下面的Linux终端命令中,我们只向链接器提供area.o而不是compute.o,因为链接器无法解析符号circleArea,即找不到circleArea()函数的机器码,链接器ld 报告错误。

(二)搬迁。 当一个编译单元被单独编译时,编译器和汇编器并不知道该编译单元中的函数和全局对象在最终的可执行目标文件中的相对位置。 因此,目标文件中的代码和数据可以重定位,从地址0开始寻址。链接器需要确定可执行目标文件中每个符号的相对地址,并根据确定的地址更改对该符号的所有引用。

为了观察符号解析和链接器重定位的影响,请读者回顾12.4中compute.o的反汇编结果。 可以看到,符号circleArea在compute.o中从地址0开始。 我们执行objdump来反汇编可执行目标文件区域:

从上面的执行结果可以看出,函数circleArea()的补码代码从compute.o复制到可执行目标文件区域,其新的起始地址改为0x7b8; 函数 main() 的二补代码是从区域复制的。 o复制到可执行目标文件区域,其起始地址为0x774。 在main()函数的机器码中,我们可以看到机器指令bl7b8,它是main()调用circleArea()时的跳转指令:0x790是这条指令的地址; 0x9400000a是指令的字节Code mode; bl7b8是该指令的汇编语言模式。

12.6 项目依赖

程序创建过程还需要一些共享头文件和库文件的支持,我们称之为项目依赖。 在此示例中,area.c 包含头文件 stdio.h,area.o 包含对符号 printf 的引用。 这种依赖性需要在创建过程中解决。

我们以“冗长”模式运行 gcc 来编译示例程序,并从复杂的输出中提取以下片段。 -v选项比较冗长(啰嗦),要求gcc详细报告整个编译和建立过程。

在上面gcc的输出中,我们看到一系列头文件路径和库文件路径,其中库文件路径(LIBRARY_PATH)是由逗号分隔的多个路径的组合字符串。 该路径是gcc外部的,是在gcc的设计和安装阶段确定的。 在编译/创建的过程中,gcc会在头文件路径中寻找头文件stdio.h,并会在库文件路径中寻找目标文件printf.o。

笔者在电脑的/usr/include目录下找到了头文件stdio.h。

笔者在电脑的/usr/lib/aarch64-linux-gnu目录下找到了库文件lic.a,其中包含printf.o。 请参阅以下 Linux 终端命令及其执行结果。

这里的libc.a属于静态链接库(静态库),它由包括printf.o在内的多个预编译的可重定位目标文件组成。 ar是Linux下的库管理工具,可以帮助我们创建、查看和更改库文件。 “|” 上面的命令中是操作系统管道,大意是ar命令的输出通过管道流到grep命令作为其输入。 grep 命令在输入文本中查找 printf.o 的出现。

用户可以通过gcc的-I选项为预处理器指定额外的头文件搜索路径; 通过-L选项,用户可以为gcc指定额外的库文件搜索路径; 通过-l选项,可以为链接器指定它们。 链接涉及的库文件的名称。

静态链接库中的机器码在编译时(compiletime)被链接器“复制”到可执行目标文件中。 链接完成后,可执行目标文件就可以运行,无需依赖静态链接库。

还有动态链接共享库(shared library)。 在Linux系统中,其文件扩展名为.so,在Windows系统中,其文件扩展名为.dll。 共享库可以在加载时链接。 简单的描述就是操作系统在将可执行目标文件复制到显存的同时,也将相关的共享库代码复制到显存并进行相关的重定位操作。 共享库的存在可以有效地减少可执行目标文件的规格。 作为“共享”库,它可以被多个应用程序引用。

事实上,程序也可以在运行时加载或卸载共享库。 这对于小型应用程序非常有用,因为小程序的整个机器码大小可能非常大,仅在需要时加载相应的机器码有助于节省显存和程序启动时间。

为了帮助更多的青少年学子学好编程,作者在哔哩哔哩开设了两门免费在线课程,一门是Python零基础,一门是C、C++零基础,感谢您带走!

简单的 C 和 C++

Python编程基础与应用

如果你觉得纸质书看起来更方便,目前有两本Python书籍,C和C++正在出版中。

《Python编程基础与应用》

《Python编程基础与应用实验课程》