C 编译过程

C 编译过程

我的博客
原文链接

本文介绍 C 编译过程,即,如何从源码生成可执行文件,目标程序。

编译

第一个阶段是编译 compilation

编译器为定义内容分配内存,从程序语句生成操作码,生成一个可重分配的目标文件 .o,汇编器也会将汇编语言源文件生成 .o 文件。

编译器一次完成一个转换单元的工作,一个转换单元是一个通过预处理的 .c 文件。

编译器以及汇编器创建可重分配的目标文件 .o

一个库设施可能会用来将目标文件组合到库文件。

编译阶段

编译是一个多阶段过程,每一个过程都使用上一个阶段的输出,编译器自身可以分为三个部分:

  • 第一阶段,解析源文件代码

  • 中间阶段,优化

  • 最后阶段,生成代码

第一阶段

预处理

预处理解析源码文件,评估预处理指令 (以 # 开始),比如 #define

移除空额

C 忽略空格,所以第一阶段处理会将所有的空格移除。

标志

C 程序由标志组成,这个标志可能是

  • 一个关键字 (比如 while)

  • 一个操作符 (比如 *)

  • 一个变量名

  • 一个字串 (比如 "my string")

  • 一个注释 (这个阶段会将它抛弃)

语法分析

语法分析确保标志按照语法规则以正确的方式组合。如果没有,那么编译器将会在这个阶段生成语法错误提示。语法分析的输出是被称作 parse tree 的结构体。

中间代码

这个阶段生成的代码是不依赖于设备的等效程序,称为 Intermediate Representation: IR,这个程序从 parse tree 生成。

IR 允许编译器支持多个不同的语言,支持多个不同的目标。

中间阶段

语义分析

语义分析添加更多的予以信息到 IR AST 中,并检查程序的逻辑结构。现在的编译器通常具备类似如下的功能,比如检测未使用的变量,使用了未初始化的变量等。在这个阶段发现任何问题,都会生成警告信息,而不是错误信息。

在这个阶段构建程序符号表,并插入 debug 信息。

优化

常用的优化包括函数的内联展开,移除无效代码,寄存器分配等。

最后阶段

代码生成

将优化后的代码生成目标平台的操作码。

内存分配

C 编译器在段内为代码与数据分配内存。每一个段包含一个不同的信息类型。段可能通过名称或属性确定。这个属性信息被链接器用来在内存内分配段信息。

代码

由编译器生成的操作码存储到它们自己的内存段内,通常为 .code.text

静态数据

静态数据区通常可以划分为两个子段:

  • 一个为未初始化的定义 (int iVar1)

  • 一个为已初始化的定义 (int iVar2 = 10)

未初始化的段通常为 .bssZI,初始化定义的段位 .dataRW

常量

常量肯恩够以两种形式给出:

  • 用户定义的常量对象 (const int c)

  • 字面值 ('magic numbers',宏定义或字串)

传统的 C 模型将用户定义的常量目标放到 .data 段。

字面值通常放到 .text/.code 段。

现在大部分 C 工具链支持 .const/.rodata 段来指定常量值。这个段可以被放到 ROM 中。

自动化变量

大部分的变量定义在函数、类内,作为自动化变量。这个变量也包括任何从非 void 函数中返回的临时返回值,以及函数参数。

对于这些编程对象,分配到栈中。对于函数参数以及返回值内存通常由调用函数分配 (通过将值入栈),对于局部对象,在函数被调用时,分配使用的内存。这个关键特性确保函数能够递归调用。

值得注意的是编译器不会创建一个 .stack 段。相反,会生成访问相关寄存器 (栈指针) 的操作码,栈指针在程序启动后会指向栈部分的顶部。

在现在大部分的微控制器中,尤其是 32 位的 RISC 架构中,自动化变量被放到分离的寄存器中,而不是栈中。比如 RAM 架构的流程调用标准 AAPCS 定义 CPU 寄存器用来存放函数调用的参数,以及返回的结果,还有函数的局部变量。

动态数据

动态对象的内存是从堆中分配的,与栈类似,堆不是由编译器在编译时分配的,儿实在链接阶段分配的。

目标文件

编译器生成目标文件 .o 文件。

目标文件包含编译后的源码,操作码以及数据段。注意到,目标文件只包含静态变量的段。在则会个阶段,段位置不固定。

.o 文件还不能被执行。虽然已经有了指令操作码,pc 指针相关地址,立即数等;但是静态及全局地址只是以它们相关段的起始地址的偏置。再其他模块中定义的地址完全不知道。目标文件包含两个表: Imports 以及 Exports

  • Exports

  • Imports

链接

链接器将目标文件组合到单个的可执行程序,为了完成这个工作,它必须完成多个任务。

符号解析

链接器最初的功能是在目标文件中解析引用,确保程序定义的每一个程序具有一个唯一地址。

如果任何引用没有被解析,那么会查找所有指定的 库/文档(.a) 文件,以及适当的模块,来解析这些引用。这是一个迭代的过程。在这之后,链接器如果还是不能解析一个符号,它会报错 unresolved reference

C 标准指定所有的外部目标必须在所有的目标文件中至少具有一个定义。

段串联

链接器之后将输入的目标文件中相似名称的段连接在一起,连接到一起的段 (输出段) 通常会给它们输入时的名称。程序地址也会做出调整。

段定位

为了能够执行代码,数据段必须放到内存的绝对位置。每一个段在内存中都具有一个绝对地址。通常,在非易失存储设备上具有一个基地址,来存放恒久存在的段 (比如代码);在易失存储设备上具有一个地址,来存放临时段 (比如栈)。

数据初始化

在一个嵌入式系统上,任何经过初始化了的数据必须存储在非易失存储设备上 (Flash/ROM)。在启动时任何非常量数据必须被复制到 RAM 中。非常常见的是将只读段 (比如代码) 复制到 RAM 中,来加速执行。

为了实现这个功能,连接器必须创建额外的段来实现从 ROMRAM 的复制。每一个需要通过复制初始化的段被划分为两个,一个为 ROM 部分 (可用于初始化部分),一个为 RAM 部分 (运行时位置)。链接器生成的初始化段通常称为 shadow data 段比如 .sdata

.bss 段也位于 RAM 中,但是不在 ROM 中有一个 shadow 复制。这个段可以通过算法初始化。

链接器控制

连接器操作的细节可以通过在命令行下调用选项控制 (通过链接器控制文件 LCF)。

你可能以另一个文件名认识了这个文件,比如链接脚本,链接配置文件等。LCF 文件定义物理内存布局 (Flash/SRAM) 以及不通程序区的放置。LCF 的语法是高度依赖编译器的,因此具有它自己的格式,虽然扮演的角色通常是一样的。

当使用一个 IDE 时,这些选项通常可以非常用户友好的的指定。IDE 生成需要的脚本以及调用选项。

要控制的最重要的事是最后内存段的位置。必须尊重硬件设备的内存布局,对于处理器而言,它希望自己想要的内容在指定的位置。

之后 LCF 指定栈与堆的大小。通常会将堆放到 RAM 的低地址位置,栈放到 RAM 的高地址位置,来尽量避免这两个区域冲突 (堆会向上生长,栈会向上生长)。

例子略

链接器会进行检查来确保你的代码段与数据段会进入到设计好的内存位置。

定位过程的输出是一个 load 载入文件,以独立于平台的格式给出,通常是 .ELF.DWARF 等。

ELF 文件也会被调试器在源码调试时使用。

载入

ELF 为与目标无关的格式的文件。为了载入到目标设备上,ELF 文件必须转化到 flash/PROM 的格式 (.bini.hex)。

关键点

  • 编译器将源码生成操作码与数据分布,产生一个目标文件

  • 编译器一次进行一个单独的工作

  • 链接器串联目标文件与库文件创建程序

  • 链接器分配栈与自由存储段

  • 链接器通过给定配置文件进行操作

  • 最终文件需要生成一个与目标设备绑定的文件才能被载入到目标设备上

posted @ 2022-01-22 22:16  ArvinDu  阅读(204)  评论(0编辑  收藏  举报