11.1 C语言的编译系统

 

 

11.1 C语言的编译系统

在深入研究C语言或任何编程语言时,理解其编译系统是至关重要的。编译系统不仅仅包括编译器本身,还包括一系列工具,它们共同协作,将人类可读的源代码转换为计算机可以执行的机器代码。本节将以广泛使用的GNU C编译系统(简称GCC系统)为例,揭示C语言编译过程的一般工作流程。

预处理器

C语言的编译过程首先从预处理开始。预处理器(如GCC中的cpp)处理源代码文件,执行如包含头文件、宏展开和条件编译等操作。这一步骤的结果是一个纯净的、预处理过的源代码版本,它将作为编译过程的输入。预处理过程确保了代码在进入编译阶段之前已经被适当地“清洁”和调整。

功能
  1. 文件包含:预处理器处理源代码中的#include指令,将指定的头文件内容插入到源文件中。这使得重用标准库和其他库的函数变得简单。
  2. 宏展开:通过#define指令定义的宏会被预处理器展开,替换掉源代码中的宏引用。
  3. 条件编译:预处理器根据指定的条件编译指令(如#if#ifdef)决定哪些代码段被编译,哪些被排除。

编译器、汇编器和连接器

经过预处理的代码接下来会被编译器处理,转换为汇编语言。汇编器将汇编语言进一步转换为机器码,形成可重定位的目标文件。最后,连接器将这些目标文件与必要的库文件链接在一起,生成最终的可执行文件。

驱动程序

GCC系统提供了一个驱动程序(通常是gcccc命令),方便用户执行从源代码到可执行文件的整个编译链。这个驱动程序背后自动调用预处理器、编译器、汇编器和连接器,简化了编译过程。

实际案例

以一个简单的由main.cswap.c组成的程序为例,我们可以通过运行以下命令来编译和链接这个程序,生成可执行文件:

gcc -v -o swap main.c swap.c

在这个命令中,-v选项使GCC输出每一步的详细信息,而-o选项指定了输出的可执行文件名。

结论

理解C语言的编译系统是深入学习和有效使用这门语言的关键。GCC作为一个功能强大的编译系统,提供了一套完整的工具链,支持从预处理到生成可执行文件的整个过程。通过掌握这些工具和它们的工作方式,开发者可以更有效地编写、编译和调试C程序。

 

 

11.1.2 汇编器

在C语言的编译过程中,汇编器扮演着将汇编代码转换为机器代码的关键角色。GCC编译系统中,编译器(例如gcc命令)首先将C程序文件编译成汇编代码。这一步骤可以通过执行类似gcc -S main.c的命令来完成,生成对应的汇编文件main.s

汇编过程

汇编器的工作可以分为两个主要阶段:

  1. 第一遍扫描:在这一遍中,汇编器遍历整个汇编代码,识别并记录所有的标识符(如变量和函数名),并将这些标识符存入符号表中。同时,为每个标识符分配相应的地址。

  2. 第二遍扫描:在第二遍扫描中,汇编器再次遍历代码,这次它将每个操作码转换成相应的机器语言指令。同时,它将对存储单元的引用(即之前记录在符号表中的标识符)转换为实际的地址。

处理外部符号

当汇编代码中包含对外部符号的引用时,汇编器生成的工作略显复杂。这些外部符号可能在其他模块或库中定义。如果生成的目标文件是可重定位的(即不是最终的绝对地址),汇编器还需处理这些外部引用,确保在最终链接过程中能正确解析。

简化的汇编过程

虽然传统的汇编过程需要两遍扫描,但现代的技术也允许通过一遍扫描完成从汇编代码到可重定位目标代码的转换。这种方法通过在第一遍扫描时同时进行地址分配和代码转换,以及利用一些优化技术来实现。

使用GCC汇编器

GCC提供了强大的工具来简化汇编过程。通过简单的命令,开发者可以轻松将C语言源代码编译成汇编语言,并将其进一步汇编成可重定位的目标文件:

  • 生成汇编代码:gcc -S main.c 生成main.s
  • 汇编成目标文件:as -o main.o main.s

这些步骤展示了从C源代码到可执行文件的编译链中汇编器的重要作用。理解汇编器的工作原理有助于开发者更深入地理解软件构建的底层机制,从而编写更高效、更可靠的代码。

 

 

11.1.3 连接器

编译和汇编过程产生的目标文件是程序构建过程中的一个中间产物。这些目标文件,无论是可重定位的还是可执行的,都需要通过一个过程被整合在一起,以形成最终的可执行程序或库。这个过程称为链接,由连接器(Linker)完成。

目标文件的类型

  1. 可重定位的目标文件:这种类型的文件包含了程序的二进制代码和数据,但还没有被分配最终的地址。它们需要与其他目标文件一起链接,以生成可执行文件或另一个可重定位目标文件。

  2. 可执行的目标文件:包含了准备加载到内存中并执行的完整程序的二进制代码和数据。

  3. 共享目标文件:一种特殊类型的可重定位目标文件,它可以在程序加载或运行时动态地被链接到程序中。

链接的过程

链接是将不同的代码和数据片段集合在一起,组织成一个单一的可执行文件的过程。这个过程可以是静态的,也可以是动态的:

  • 静态链接:在编译阶段或之前完成,将所有必需的库和模块合并到单个可执行文件中。
  • 动态链接:在程序加载到内存时或运行时进行,允许共享库在多个程序间共享。

连接器的主要任务

  1. 符号解析:连接器遍历所有的目标文件,识别出每个文件中定义和引用的符号。对于被多个模块引用的全局符号,连接器确定唯一的定义,以便所有的引用都指向同一个地址。

  2. 重定位:由于编译器和汇编器生成的目标文件中的地址是相对于文件开始的,连接器需要调整这些地址,使得每个符号引用指向正确的运行时内存地址。

静态与动态链接

  • 静态链接生成的可执行文件包含了所有必需的代码和数据,可以独立运行,但这可能导致文件体积较大。
  • 动态链接允许程序在运行时加载所需的共享库,可以减少冗余,节省内存和磁盘空间,但需要确保运行环境中有正确版本的库。

实现连接器

虽然连接器的概念可能看起来复杂,但一旦理解了目标文件的格式和连接过程中的基本任务,实现一个连接器就变得相对直接。连接器的实现涉及到对目标文件格式的深入了解,以及对符号解析和重定位算法的精确应用。

 

11.1.4 目标文件的格式

目标文件在不同操作系统中有不同的格式,这些格式定义了目标文件的结构,包括代码和数据如何存储以及如何被操作系统、编译器和连接器处理。UNIX系统的历史见证了多种目标文件格式的演化,从最早的a.out到System V UNIX采用的COFF,再到现代UNIX系统(如Linux、BSD UNIX变体和Sun Solaris)普遍采用的ELF格式。

ELF(Executable and Linkable Format)

ELF格式是目前UNIX系统中最为广泛使用的目标文件格式,它支持静态和动态链接,并提供了丰富的结构来描述程序的不同部分。

ELF文件的组成
  1. ELF头:位于文件的开始,包含了生成文件的系统信息、目标文件的类型(如可重定位、可执行或共享)、机器类型、节头表的位置和大小等信息。

  2. 节头表:描述了目标文件中各个节的位置和大小。

  3. 节(Sections):ELF文件包含多个节,每个节承载特定类型的数据:

    • .text:包含编译后的程序指令。
    • .rodata:存储只读数据,例如字符串常量。
    • .data:包含已初始化的全局和静态变量。
    • .bss:用于未初始化的全局和静态变量,此节不占用文件空间,但在加载时会分配内存。
    • .symtab:符号表,包含程序中定义和引用的符号信息,不包括局部变量。
    • .rel.text和**.rel.data**:包含重定位信息,用于连接过程中调整代码和数据引用。
    • .debug:存储调试信息,只在编译时使用-g选项时生成。
    • .line:源代码行号和程序指令之间的映射,辅助调试。
    • .strtab:字符串表,存储.symtab节和.debug节中的名称。
符号表(Symbol Table)

符号表是ELF文件中一个关键的部分,它记录了模块内定义和引用的全局变量和函数信息。符号表中的每个条目提供了符号的名称、地址(相对于其节的偏移或绝对地址)、大小和类型等信息。

重定位信息

重定位节(如.rel.text和.rel.data)包含必要的信息来修改代码和数据中的地址引用,以便在链接时或运行时将各个部分正确地拼接在一起。

结论

理解目标文件的格式对于深入理解编译和链接过程至关重要。ELF格式通过其灵活且丰富的结构,提供了一种有效的方式来描述不同类型的数据和代码如何被组织、链接和执行,使其成为现代UNIX系统中编译系统的基石。

 

11.1.5 符号解析

符号解析是连接器完成的一项关键任务,它确保程序中的每个符号引用都正确关联到定义该符号的目标文件中。这一过程涉及识别和处理在编译阶段留给连接器的符号表条目,以及在所有输入模块中定位这些符号的定义。

强符号与弱符号

为了处理可能出现的符号多重定义问题,UNIX系统的连接器区分两种类型的全局符号:强符号和弱符号。

  • 强符号:包括所有函数和已初始化的全局变量。
  • 弱符号:主要指未初始化的全局变量。

处理多重定义的规则

连接器使用以下规则来解决多重定义的全局符号问题:

  1. 规则1:不允许有多个强符号的定义。如果发现,连接器将报告错误并停止链接过程。
  2. 规则2:如果一个强符号和多个弱符号定义了同一个名称,连接器将选择强符号的定义,并忽略所有弱符号的定义。
  3. 规则3:如果有多个弱符号定义了同一个名称,连接器将选择任意一个弱符号的定义。

示例与潜在问题

通过上述规则,连接器能够在大多数情况下正确处理符号的多重定义。例如,根据规则2,即使多个模块定义了相同名称的符号,只要其中一个是强符号,连接过程就可以成功完成。

然而,这种机制可能会导致一些难以发现的错误,尤其是当多重定义的符号具有不同类型时。在这种情况下,即使连接器根据规则选择了一个定义,程序运行时的行为也可能与预期不符,因为不同类型的数据占用的字节大小和布局可能不同。

避免符号冲突的建议

为了降低由于符号冲突引发的问题,开发者可以:

  • 使用编译器和连接器提供的警告选项,如--warn-common,以便在编译和链接时获得有关多重定义符号的警告。
  • 尽量避免全局变量的多重定义,特别是在大型项目或多模块程序中。
  • 在定义全局符号时明确指定初始化状态,以控制符号的强弱属性。

结论

符号解析是连接过程中的一个复杂但至关重要的步骤。通过理解强符号和弱符号的概念,以及连接器如何处理符号的多重定义,开发者可以更有效地管理和避免潜在的链接错误,确保程序的正确链接和运行。

 

 

11.1.6 静态库

静态库是一种将多个可重定位目标文件打包在一起的文件集合,允许开发者在链接程序时重用编译好的代码。这种机制提供了一种高效的方式来管理和分发常用的函数库,如C语言的标准库(libc.a)和数学库(libm.a)。

静态库的作用

静态库使得开发者能够将频繁使用的函数和代码模块集中管理,而不必每次编译时都从源代码开始。当构建一个程序并链接到一个静态库时,连接器仅将程序实际引用的库模块复制到最终的可执行文件中。这种选择性链接减少了可执行文件的大小,并提高了构建过程的效率。

创建和使用静态库

静态库通常通过编译源文件生成目标文件,然后使用归档工具(如ar命令)将它们归档成一个库文件(通常以.a后缀命名)。例如,使用以下命令创建一个名为mylib.a的静态库:

gcc -c swap.c
ar rcs mylib.a swap.o

链接静态库时,可以在编译命令中指定库文件,如下所示:

gcc -o myprogram myprogram.c mylib.a

静态库和链接器的互动

链接器在处理静态库时遵循特定的规则,特别是在符号解析和模块选择方面。它按照命令行中指定的顺序扫描目标文件和库文件,解析外部引用。正确的命令行顺序对于成功的链接过程至关重要。一般建议将静态库放在命令行的最后,并根据依赖关系正确排序不同的库文件。

静态库的优点和局限

静态库提供了代码重用和分发的便利性,使得常用的函数集合可以轻松集成到多个程序中。然而,静态库也有其局限性,包括可能导致的可执行文件大小增加以及更新库需要重新链接程序的问题。

结论

静态库是C语言编译系统中重要的组成部分,它们为代码的模块化、重用和分发提供了强大的支持。通过有效地使用静态库,开发者可以提高开发效率,优化程序结构,并简化构建和维护过程。

 

 

11.1.7 可执行目标文件及装入

可执行目标文件是程序编译链接过程的最终产物,它包含了程序运行所需的所有代码和数据。在UNIX系统中,这些文件通常采用ELF(Executable and Linkable Format)格式,这种格式设计得易于将程序装入内存并执行。

ELF可执行文件的结构

ELF可执行文件的结构与可重定位目标文件相似,但有一些关键区别:

  • ELF头:包含了整个文件的概述,指明了程序入口点的地址,即程序开始执行的第一条指令的地址。
  • 代码和数据节:与可重定位目标文件中的对应节类似,不过这些节已经被重定位到最终的运行时地址。
  • 初始化代码(init节):定义了程序初始化时调用的函数。
  • 段头表:描述了文件中各个段(segment)如何映射到运行时的内存中。

程序的装入过程

当在UNIX shell中执行一个程序时,shell通过调用操作系统的装载器(loader)来装入并运行该程序。装载器负责将可执行目标文件从存储设备读入内存,并跳转到程序的入口点开始执行。

运行时内存映像

UNIX系统中的程序在运行时具有以下内存映像:

  • 代码段(text):存放程序的机器指令,从固定地址(如Linux中的0x08048000)开始。
  • 数据段:包含已初始化的全局变量和静态变量,紧随代码段之后。
  • BSS段:用于未初始化的全局变量和静态变量,位于数据段之后。
  • (heap):动态分配的内存区域,位于BSS段之后,通过malloc等函数管理。
  • :用于函数调用时存储局部变量和返回地址,从高地址向低地址增长。
  • 共享库区域内核空间:分别用于加载共享库和操作系统内核使用。

装载器的角色

装载器在将程序装入内存时,遵循段头表中的指示,将可执行文件的各个部分映射到相应的内存区域。一旦完成装入,装载器则通过跳转到程序的入口点(由ELF头指定)来启动程序执行。

程序启动流程

在程序的启动代码(通常位于crt1.o文件中)的帮助下,系统在执行用户代码之前完成必要的初始化工作,包括调用初始化代码、设置全局变量等。最终,启动代码会调用main函数,标志着用户程序的开始。

结论

可执行目标文件及其装入过程是理解程序如何从代码转变为运行过程的关键。通过ELF格式和UNIX系统的装载机制,程序被有效地装入内存并执行,这一过程虽复杂但对于程序的运行至关重要。

 

 

11.1.8 动态连接

动态连接是软件开发中用于解决静态库维护更新和内存资源优化的技术。它允许程序在运行时加载和链接共享库,即所谓的动态链接库(DLLs),而非在编译时静态链接库代码。

共享库与动态链接库(DLLs)

共享库(在UNIX系统中通常以.so后缀命名,在Windows系统中称为DLL,以.dll后缀命名)提供了一种机制,通过这种机制库的单一物理副本可以被多个程序共享。

共享库的双重共享机制
  1. 文件系统共享:对于给定的库,在文件系统中只存在一个共享对象文件(.so.dll文件),该文件的代码和数据可以被多个引用该库的程序共享。
  2. 内存共享:运行时,共享库的.text节(包含代码的部分)可以在内存中被不同的进程共享,显著减少了内存消耗。

动态连接过程

动态连接分为两个阶段:

  1. 静态准备阶段:在可执行文件创建时,连接器进行部分链接,准备好必要的重定位信息和符号表信息,这些信息将用于运行时解析对共享库中定义的代码和数据的引用。

  2. 运行时链接阶段:当程序运行时,动态连接器(如Linux下的ld-linux.so)负责完成对共享库的加载和链接。这一过程是由操作系统的装载器触发的,装载器首先加载程序的可执行文件,然后根据程序中的.interp节指定的动态连接器路径,加载和运行动态连接器。

创建和使用共享库

创建共享库涉及编译源代码为位置无关的代码(使用-fPIC选项),然后链接为共享对象文件:

gcc -shared -fPIC -o mylib.so swap.c

将共享库与程序链接:

gcc -o swap2 main.c ./mylib.so

动态连接的优势

  • 内存效率:减少了运行多个程序实例时的内存占用。
  • 维护更新:库的更新不需要重新编译链接所有依赖该库的程序,只需替换共享库文件即可。

结论

动态连接为程序提供了一种灵活且内存高效的方式来使用共享代码库。通过动态链接库,程序可以在运行时加载最新版本的库,从而简化了库的更新和维护过程,同时优化了内存资源的使用。

 

 

 

 

 

 

 

 

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

夏驰和徐策

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值