(一)编译那些事儿:编译过程与原理

(一)编译那些事儿:编译过程与原理

1. 语言与编译

为使计算机按照预设执行指令,需借助一种能与计算机交互的语言,即“机器语言(第一代计算机语言)”。它由一系列由“0”和“1”组成的指令构成。在计算机硬件设计时,需预先规划并设定指令集,例如明确哪些特定的01编码对应哪些特定的指令。

然而,机器语言存在显著局限性,不同类型的计算机(如DSP和ARM架构)的指令集各不相同。以加法运算为例,两者在寄存器调用方法上可能存在差异。为解决这一问题,“汇编语言(第二代计算机语言)”应运而生。

汇编语言通过为机器码指令赋予统一的助记符(如“ADD”表示加法指令),实现了对机器指令的抽象。它添加了一个接口层,将不同机器的具体指令调用差异进行屏蔽,只要确保所有机器的指令集都能与汇编语言相互转换,就能在一定程度上缓解指令集不兼容的问题。尽管汇编语言在一定程度上简化了编程,但仍面临诸多问题,如过于靠近底层,可抽象性差,代码可读性差;且未能彻底解决程序移植问题,不同厂家支持的汇编语言并不完全一样。

因此,“高级语言(第三代计算机语言)”出现了。高级语言在汇编语言的基础上进一步抽象,通过添加更丰富的接口层,屏蔽了繁琐的寄存器操作细节,提高了编程效率和代码的可理解性。例如,高级语言使用更接近自然语言的语法和语义,程序员无需关心底层硬件细节,像变量声明、函数调用等操作都更加直观。

每一次语言的发展,大多不是单纯改进原有实现,而是通过添加接口层、屏蔽差异的方式进行。第一代机器语言实现了与机器的直接沟通;第二代汇编语言通过添加接口层,以助记符抽象机器指令,屏蔽不同机器的调用差异;第三代高级语言进一步添加接口层,屏蔽寄存器操作细节,提升编程效率与代码可读性。

如果说第一代机器语言是直接给机器下达指令,那么第二代、第三代语言实际上都需要转换。这种将高级语言转为机器指令的过程,就叫做编译。

2. 编译过程

那么,高级语言编写的源代码是如何让计算机理解并执行的呢?其具体步骤如下:

源代码会先被编译生成中间代码。中间代码与汇编代码不同,它具有一定的可读性,便于人工理解,常见的如字节码。中间代码随后由虚拟机(解释器)进行处理,不过并非所有虚拟机都是逐行解释执行中间代码,有些虚拟机会采用即时编译(JIT)等优化执行方式,虚拟机负责将中间代码转换为计算机能够执行的机器指令。

在Linux系统中,可使用以下指令完成从源程序到目标程序的转换:

gcc -o hello hello.c main.c

此指令中,gcc编译器驱动程序会读取源文件hello.c和main.c,经过预处理、编译、汇编、链接四个阶段(分别由预处理器、编译器、汇编器、链接器完成,这四个程序共同构成编译系统),最终将其翻译成可执行目标程序hello。

1.1 预处理

预处理阶段主要负责处理各类预处理指令,包括宏定义的展开、条件编译指令的执行以及头文件的包含等操作。在此阶段,编译器依据源程序中以“#”字符开头的命令,对源程序进行修改,生成另一个源程序,通常该程序以“.i”作为文件扩展名。预处理器(CPP)执行的修改主要涵盖#include、#define 和条件编译这三个方面。例如,通过以下命令可对“main.c”文件进行预处理:

gcc -o main.i -E main.c

需注意的是,预处理仅仅是对源文件进行了扩展,所得到的依然是符合C语言语法的源程序。

1.2 编译

编译过程中,编译程序的主要工作是通过词法分析和语法分析,在确保所有指令均符合语法规则后,将源程序翻译成等价的中间代码表示或汇编代码。编译器(CCL)会把经预处理器处理后得到的文本文件“hello.i”和“main.i”,转换为“hello.s”与“main.s”。这些文件中包含的汇编语言程序,以标准的文本格式精确描述了低级机器语言指令。执行以下命令可实现此阶段的编译操作:

gcc -S main.i hello.i

1.3 汇编

汇编器(AS)负责将“hello.s”和“main.s”中的汇编语言指令翻译成机器语言指令,并将其打包成可重定位目标程序,这类程序一般以“.o”作为文件扩展名。可重定位目标程序属于二进制文件,其字节编码为机器语言指令,而非字符形式。通过运行以下指令,可得到重定位目标程序“main.o”和“hello.o”:

gcc -c main.s hello.s

由于“main.o”和“hello.o”已转换为二进制文件,若尝试使用文本编辑器打开,将看到呈现为乱码的内容。

1.4 链接

链接程序(LD)负责将“main.o”“hello.o”以及其他必要的目标文件组合起来,创建可执行目标文件。例如,使用以下命令:

gcc -o hello main.o hello.o

就能得到可执行程序“hello”。在终端输入“./hello”,程序便会加载并运行。

根据开发人员指定的库函数链接方式不同,链接处理分为两种:

1.4.1 静态链接

在此链接方式下,函数代码会从其所在的静态链接库拷贝到最终的可执行程序中。程序执行时,这些代码会被装入进程的虚拟地址空间。静态链接库实际上是目标文件的集合,每个文件包含库中一个或一组相关函数的代码。由于静态链接是将库函数代码直接拷贝到可执行文件中,所以若需更新库,就需重新编译和链接库文件。而且,对于标准函数,如果每个程序都将其代码复制到自身运行的文本段,会造成存储器资源的浪费。

1.4.2 动态链接

这种方式下,函数代码存放在动态链接库或共享对象的目标文件中。链接程序在最终的可执行程序中仅记录共享对象的名字及少量登记信息。当可执行文件执行时,动态链接库的全部内容会被映射到运行时相应进程的虚拟地址空间,动态链接程序依据可执行程序记录的信息找到对应函数代码。动态链接能使最终的可执行文件体积较小,原因是可执行文件中不包含库函数的实际代码,仅记录了相关引用信息。并且当共享对象被多个进程使用时可节省内存,因为内存中只需保存一份共享对象的代码,多个进程可以共享这份代码,不同进程通过映射将共享库代码映射到各自虚拟地址空间中使用。

在整个程序链接过程中,链接器仅拷贝重定位和符号信息,程序加载(execve)时才解析共享对象(在Unix系统下,共享库通常以“.so”为后缀,Windows系统下则为“dll”)中代码和数据的引用。共享库有两种共享方式:

  • 文件共享:所有引用该库的执行程序共享同一个.so文件,即物理上共享同一个文件,这使得不同程序对共享库的更新能保持一致。
  • 内存共享:共享库的.text节副本可被不同进程共享,由于代码段是只读的,不同进程可以安全地共享这部分内存,进一步节省了内存资源。
1.4.3 编译链接和载入
  • 编译:对预处理生成的文件,经过语法分析、词法分析、语义分析及优化,编译成若干目标模块。这一过程可理解为将高级语言转换为计算机能理解的二进制机器语言。
  • 链接:链接程序把编译生成的一组目标模块与所需的库函数链接,形成完整的载入模型。链接主要解决模块间相互引用问题,包括地址和空间分配、符号解析和重定位。编译生成目标文件时,会暂时搁置外部引用,链接时,链接器依据符号名称在相应模块中寻找对应符号,确定符号后,重写那些未确定符号的地址,此即重定位过程。
  • 载入:载入程序将载入模块加载到内存。
1.4.4 动态链接和静态链接的区别总结

静态链接以一组可重定位目标文件为输入,这些文件由不同代码和数据节组成。通过符号解析和重定位,生成可加载运行的完全链接可执行文件。但它存在更新库需重新编译链接、浪费存储器资源等缺点。

而共享库与动态链接旨在解决静态链接的问题。共享库作为目标模块,运行时可加载到任意内存地址,并与内存中的程序链接。动态链接在可执行文件体积和内存使用上具有优势,但并非在所有情况下都优于静态链接,某些场景下,动态链接可能导致性能受损。

3. 程序的运行

3.1 程序的准备

  1. 编写源代码:开发者运用编程语言编写源代码,并将其保存于硬盘。
  2. 编译:编译程序将用户源代码编译为若干机器码形式的目标模块,这些模块通常存储于硬盘。编译过程涉及词法分析、语法分析、语义分析、中间代码生成、代码优化以及目标代码生成。
  3. 链接:链接程序把编译后形成的目标模块与所需库函数链接,生成完整的装入模块,同样以机器码文件形式保存于硬盘。链接方式包括:
    • 静态链接:将库函数代码直接整合到可执行文件中。
    • 装入时动态链接:在程序装入内存时进行链接。
    • 运行时动态链接:在程序运行过程中按需进行链接。

3.2 程序的运行

  1. 装入:装入程序将装入模块从硬盘载入内存,把装入模块内容复制到内存特定位置,并搭建好程序运行环境。装入方式有:
    • 绝对装入:按照固定的内存地址装入程序。
    • 可重定位装入(相对装入):根据内存的实际情况对装入地址进行调整。
    • 动态运行时装入:在程序运行过程中根据需要进行地址重定位。
  2. 取指:PC(程序计数器)保存下一条待执行指令的内存地址。正常顺序执行时,PC 值自动递增,指向下一条指令地址;在执行分支和跳转指令时,PC 值被修改为目标指令地址,实现程序流程控制,如条件分支、循环、函数调用与返回等操作。
  3. 译码:CPU 的控制单元依据指令的操作码部分,识别指令类型(如数据传输、算术运算、逻辑运算、控制流等)以及操作数的寻址方式。针对不同指令类型,控制单元设置相应控制信号,将操作数传送至对应的运算单元(如算术逻辑单元 ALU)或存储单元(如寄存器、内存)。对于复杂指令集(CISC)处理器,一条指令可能包含多个微操作,译码过程会将其拆分为多个微操作以提升处理器性能;而精简指令集(RISC)处理器的指令相对简单,译码过程也更为简洁。
  4. 执行
    • 算术运算:CPU 的 ALU 针对加法、减法、乘法、除法等算术运算,依据操作数和操作码进行计算。操作数可来自寄存器或内存,计算结果可存储于寄存器或写回内存。现代处理器通常具备多个执行单元,能同时执行多个算术运算,提升并行处理能力。
    • 逻辑运算:涵盖与、或、非、异或等逻辑运算,用于逻辑判断和位操作,在控制流、数据处理及错误检测等方面应用广泛。
    • 内存访问:对于内存访问指令,CPU 需将逻辑地址转换为物理地址,此过程通常由内存管理单元(MMU)完成。
      • 逻辑地址和物理地址:逻辑地址是程序使用的地址,不考虑实际物理内存布局;物理地址是内存中的实际存储位置。
      • 分页系统中的地址转换:在分页系统里,逻辑地址分为页号和页内偏移量。MMU 通过查找页表将页号转换为页框号(物理页号),再结合页内偏移量得出物理地址。页表存于内存,一般由操作系统管理,为提高性能,常使用快表(TLB)作为页表的高速缓存,加快地址转换速度。
      • 分段系统中的地址转换:在分段系统中,逻辑地址分为段号和段内偏移量。MMU 通过查找段表将段号转换为段的基地址,再加上段内偏移量得到物理地址。段表包含段的基地址、段限长等信息,用于段的保护和越界检查。
      • 段页式系统中的地址转换:结合分段和分页的优点,逻辑地址先通过段表找到段的页表,再通过页表找到页框号,最终确定物理地址。
  5. 写回
    • 结果的存储位置:若指令执行结果需保存,依据指令语义,结果可存储在寄存器或内存中。
    • 内存存储:对于存储在内存中的结果,CPU 通过数据总线将结果发送到内存,由内存控制器将数据写入相应物理地址。
    • 寄存器存储:存储在寄存器中的结果将用于后续指令操作,提升数据访问速度。

4. appendix

4.1 c++的符号导入导出

  1. C++ 中符号的含义:在 C++ 编程里,“符号”一般指代程序中的函数名、变量名等标识符。在编译和链接的过程中,这些符号起着标识和引用代码特定部分的关键作用。
  2. 导入与导出的概念:“导入”和“导出”主要是动态链接库(DLL,Windows 系统)或共享对象(SO,Linux 系统)中的重要概念。当创建一个动态链接库时,如果希望其中某些函数或变量能被其他程序或库使用,就需要将这些函数或变量“导出”。“导出”操作实际上是把这些符号(函数或变量的名称)添加到库的公开符号表中,如此一来,其他程序在运行时便能链接到这些函数或变量。
    • 反之,当程序或库要使用动态链接库中的函数或变量时,就需要“导入”这些符号。“导入”即在自身程序中引用这些公开的符号,在运行阶段,动态链接器会负责找到这些符号所对应的实际函数或变量。简而言之,“导入”和“导出”构成了动态链接的基础,它们使得程序在运行时能够共享和使用代码。
  3. 符号表:在 C++ 编译过程中,符号表是编译器创建的一种数据结构,用于存储程序中定义的各类符号(如函数名、变量名等)及其相关信息(如类型、作用域、内存位置等)。
    • 可以使用 nm 命令查看编译后的二进制文件(如可执行文件或库文件)的符号表。例如,执行 nm my_program 命令,将会列出 my_program 中的所有符号以及它们在程序中的地址。不过需要注意的是,只有在编译时开启了调试信息(如使用 -g 选项),才能在编译后的二进制文件中看到完整的符号表,否则可能只能看到部分或没有符号信息。
  4. Linux 和 Windows 符号导入导出行为差异
    • Windows 系统:在 Windows 环境下,符号的导入和导出通常需要明确指定。例如,创建 DLL 时,要使用 __declspec(dllexport) 关键字来明确标记哪些函数或变量需要导出。同样,在使用 DLL 的程序中,需使用 __declspec(dllimport) 标记哪些函数或变量需要导入。示例代码如下:
// 在 DLL 中
__declspec(dllexport) void MyFunction();

// 在使用 DLL 的程序中
__declspec(dllimport) void MyFunction();
- **Linux 系统**:Linux 系统下的情况有所不同。在 Linux 的共享库中,默认情况下,函数和全局变量在链接时是可见的,但并非传统意义上的导出(它们是弱符号,链接行为与导出符号有别)。可以使用 `__attribute__((visibility("default")))` 和 `__attribute__((visibility("hidden")))` 来精准控制符号的可见性。示例代码如下:
// 这个函数会被导出
void MyFunction1();

// 这个函数不会被导出
__attribute__((visibility("hidden"))) void MyFunction2();

4.2 c/c++的编译器内建函数

内建函数与机器指令紧密相关,执行时不会产生常规的函数调用开销。严格来说,C++ 标准本身并未定义如后文所述的这类特定内建函数。不过,一些编译器,如 GCC 和 Clang,提供了一些内建函数,用于实现特定功能或优化代码性能。以下是 GCC 和 Clang 中一些常用的内建函数:

  • __builtin_expect(long exp, long c):此函数用于向编译器提供分支预测信息,以优化生成的代码。其中,exp 是预计的值,c 是期望的值。当 exp == c 时,该表达式结果为真的可能性更高。
  • __builtin_popcount(unsigned int x):该函数返回 x 的二进制表示中 1 的数量。
  • __builtin_clz(unsigned int x)__builtin_ctz(unsigned int x):这两个函数分别返回 x 的二进制表示中从最高位开始和最低位开始连续 0 的数量。
  • __builtin_ffs(int x):此函数返回 x 的二进制表示中第一个 1 的位置,如果 x0,则返回 0
  • __builtin_prefetch(const void *addr, ...):该函数用于预取 addr 指向的内存区域,从而减少后续访问该内存区域时的延迟。

需要注意的是,以上这些函数仅在 GCC 和 Clang 等部分编译器中可用,在其他编译器(如 MSVC)中可能无法使用。在使用这些函数时,务必确保代码能在目标编译器上正确编译和运行。此外,这些函数属于编译器扩展内容,并非 C++ 标准的一部分,因此在使用时要充分考虑代码的可移植性。若条件允许,应优先使用标准库提供的功能,仅在必要时才选用这些内建函数。

4.3 运行时库

运行时库(Runtime Library)是指程序在运行阶段需要依赖的代码库。运行时库既可以是动态库,也可以是静态库。它包含了程序运行时所需的基础功能模块,例如:

  • 内存管理(如malloc、free)
  • 输入输出操作(如printf、scanf)
  • 字符串处理(如strcpy、strlen)
  • 数学运算(如sin、cos)
  • 线程支持(如线程创建、同步原语)

核心作用:运行时库为程序提供了操作系统未直接暴露的底层功能封装,避免开发者重复实现基础功能,同时确保程序在不同环境下的兼容性。例如,C语言程序编译后需要依赖C运行时库(CRT)才能执行,因为程序中调用的标准库函数(如printf)实际由运行时库实现。

一般运行时库,都是这些相关的:

  1. 功能作用于程序运行阶段: 提供程序运行时必需的基础功能,而非编译时使用的工具(如编译器插件、语法检查库)。

  2. 与编程语言或平台深度绑定: 通常由编程语言标准或操作系统定义,是程序运行的“基础设施”。

4.4 dll库编译的时候选择不同的运行时库可能会导致内存泄露

  • 如果 DLL 使用 /MD (多线程 DLL) 而主程序使用 /MT (多线程静态)
  • 或者 DLL 使用 /MDd (调试版本) 而主程序使用 /MD (发布版本)

这种不匹配会导致多个独立的 C 运行时实例,每个实例维护自己的内存堆。当内存从一个堆分配但从另一个堆释放时,就会发生内存泄露。




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • (三)内核那些事儿:CPU中断和信号
  • (二)内核那些事儿:程序启动到运行的完整过程
  • (一)内核那些事儿:从硬件抽象到系统服务的完整框架
  • (七)内核那些事儿:操作系统对网络包的处理
  • (五)内核那些事儿:系统和程序的交互